diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100755 index 0000000..ca3b20c --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,78 @@ +#!/bin/sh +# Repo Manager coding-assistant provenance hook. This hook must never block Git. + +message_file=${1:-} +[ -n "$message_file" ] && [ -f "$message_file" ] || exit 0 + +assistant=${ASSISTANT_NAME:-} +model=${ASSISTANT_MODEL:-} +process=${ASSISTANT_PROCESS:-} +session=${ASSISTANT_SESSION:-} + +if [ -z "$assistant" ] && { [ "${CLAUDECODE:-}" = "1" ] || [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; }; then + assistant=claude-code + session=${session:-${CLAUDE_CODE_SESSION_ID:-}} + process=${process:-${CLAUDE_PID:-}} +fi + +if [ -z "$assistant" ] && { [ -n "${CODEX_SESSION_ID:-}" ] || [ -n "${CODEX_THREAD_ID:-}" ]; }; then + assistant=codex + session=${session:-${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-}}} + process=${process:-${CODEX_PID:-}} +fi + +if [ -z "$assistant" ] && { [ -n "${GROK_SESSION_ID:-}" ] || [ "${GROKCODE:-}" = "1" ] || [ "${GROK_CODE:-}" = "1" ]; }; then + assistant=grok + session=${session:-${GROK_SESSION_ID:-}} + process=${process:-${GROK_PID:-}} +fi + +[ -n "$assistant" ] || exit 0 + +if [ -z "$model" ] && [ "$assistant" = "claude-code" ]; then + claude_settings=${CLAUDE_SETTINGS_PATH:-${HOME:-}/.claude/settings.json} + if [ -f "$claude_settings" ]; then + model=$(sed -n 's/^[[:space:]]*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$claude_settings" 2>/dev/null | head -1) + fi +fi + +if [ -z "$model" ] && [ "$assistant" = "codex" ]; then + codex_config=${CODEX_HOME:-${HOME:-}/.codex}/config.toml + if [ -f "$codex_config" ]; then + model=$(sed -n 's/^[[:space:]]*model[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$codex_config" 2>/dev/null | head -1) + fi +fi + +if [ -z "$model" ] && [ "$assistant" = "grok" ]; then + model=${GROK_DEFAULT_MODEL:-} +fi + +if [ -n "$process" ]; then + case "$process" in + *@*) : ;; + *) process="$process@$(hostname 2>/dev/null || printf unknown-host)" ;; + esac +fi + +have_assistant_trailer=0 +if grep -Eiq '^Assistant(-Model|-Process|-Session)?:[[:space:]]*' "$message_file" 2>/dev/null; then + have_assistant_trailer=1 +fi + +add_trailer() { + token=$1 + value=$2 + [ -n "$value" ] || return 0 + grep -Eiq "^${token}:[[:space:]]*" "$message_file" 2>/dev/null && return 0 + if [ "$have_assistant_trailer" -eq 0 ]; then + printf '\n' >>"$message_file" 2>/dev/null || return 0 + have_assistant_trailer=1 + fi + printf '%s: %s\n' "$token" "$value" >>"$message_file" 2>/dev/null || true +} + +add_trailer Assistant "$assistant" +add_trailer Assistant-Model "$model" +add_trailer Assistant-Process "$process" +add_trailer Assistant-Session "$session" +exit 0 diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2477ede..91a16bf 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,7 +16,7 @@ | workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md | | workplan | RMGR-WP-0007 | finished | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | | workplan | RMGR-WP-0008 | active | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| workplan | RMGR-WP-0009 | ready | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| workplan | RMGR-WP-0009 | active | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md | @@ -33,17 +33,17 @@ | task | RMGR-WP-0003-T03 | done | — | workplans/RMGR-WP-0003-production-pilot-dual-run.md | | task | RMGR-WP-0003-T04 | done | — | workplans/RMGR-WP-0003-production-pilot-dual-run.md | | task | RMGR-WP-0004-T01 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | -| task | RMGR-WP-0004-T02 | todo | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | +| task | RMGR-WP-0004-T02 | progress | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | | task | RMGR-WP-0004-T03 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | | task | RMGR-WP-0004-T04 | wait | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | | task | RMGR-WP-0004-T05 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | -| task | RMGR-WP-0004-T06 | wait | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | -| task | RMGR-WP-0004-T07 | wait | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | +| task | RMGR-WP-0004-T06 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | +| task | RMGR-WP-0004-T07 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | | task | RMGR-WP-0004-T08 | done | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | -| task | RMGR-WP-0004-T09 | wait | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | +| task | RMGR-WP-0004-T09 | progress | — | workplans/RMGR-WP-0004-repository-standards-conformance.md | | task | RMGR-WP-0005-T01 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T02 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | -| task | RMGR-WP-0005-T03 | todo | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | +| task | RMGR-WP-0005-T03 | progress | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T04 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T05 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | task | RMGR-WP-0005-T06 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | @@ -66,16 +66,16 @@ | task | RMGR-WP-0007-T06 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | | task | RMGR-WP-0007-T07 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | | task | RMGR-WP-0008-T01 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T02 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T03 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T04 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T02 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T03 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T04 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T05 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T06 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T06 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T07 | wait | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0009-T01 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T02 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T03 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T04 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T05 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T06 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | -| task | RMGR-WP-0009-T07 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T01 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T02 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T03 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T04 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T05 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T06 | done | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| task | RMGR-WP-0009-T07 | progress | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | diff --git a/config/assistant-provenance.yaml b/config/assistant-provenance.yaml new file mode 100644 index 0000000..30c1483 --- /dev/null +++ b/config/assistant-provenance.yaml @@ -0,0 +1,6 @@ +schema: repo-manager.assistant-provenance.v1 +cutover_commit: 329af60753b20896f30f098e8f75074652780032 +known_automation: + - name: custodian-sync + email: custodian-sync@railiance.local + trailer_required: false diff --git a/docs/repository-classification-contract_v1.0.md b/docs/repository-classification-contract_v1.0.md new file mode 100644 index 0000000..90e0a13 --- /dev/null +++ b/docs/repository-classification-contract_v1.0.md @@ -0,0 +1,34 @@ +--- +id: RMGR-CONTRACT-CLASSIFICATION-0001 +type: contract +title: "Repository classification receiving contract" +version: "1.0" +status: active +created: "2026-08-21" +updated: "2026-08-21" +workplan_task: RMGR-WP-0008-T06 +canon_source: the-custodian/canon/standards/repo-classification-standard_v1.0.md +--- + +# Repository classification receiving contract v1.0 + +Repo Manager reads `.repo-classification.yaml` as repository authority and +validates it against version 1.0 of the Custodian's Repo Classification +Standard. It publishes the validated value in repository snapshots; downstream +topic or domain services consume that projection and do not replace the file. + +The required scalar fields are `category` and `domain`. Optional list fields +are `secondary_domains`, `capability_tags`, `business_stake`, and +`business_mechanics`. Controlled values and the tag syntax are in the companion +machine-readable contract. + +Invalid classifications fail observation with field-specific errors. Repo +Manager does not coerce unknown values, infer the primary domain from technical +tags, or write classification during observation. A canon vocabulary change +requires a new contract version and implementation update; the canon remains +the source of meaning. + +Boundary with hub-core: Repo Manager owns file validation and the repository +projection. Hub-core may aggregate those projections into topics and fleet +navigation, but does not become classification authority. + diff --git a/docs/repository-classification-contract_v1.0.yaml b/docs/repository-classification-contract_v1.0.yaml new file mode 100644 index 0000000..e339f1c --- /dev/null +++ b/docs/repository-classification-contract_v1.0.yaml @@ -0,0 +1,53 @@ +id: RMGR-CONTRACT-CLASSIFICATION-0001 +version: "1.0" +status: active +authority_path: .repo-classification.yaml +canon_source: the-custodian/canon/standards/repo-classification-standard_v1.0.md +required: + category: + - experimental + - research + - project + - tooling + - product + - business + domain: + - infotech + - financials + - communication + - consumer + - health + - industrials + - energy + - utilities + - materials + - realestate + - crypto + - agents + - space + - government +optional_lists: + secondary_domains: domain + capability_tags: lowercase-kebab-case + business_stake: + - execution + - intelligence + - finance + - legal + - sales + - experience + - technology + - operations + - product + - people + - procurement + - sustainability + - automation + business_mechanics: + - intention + - control + - coordination + - operation + - adaptation +projection_owner: repo-manager +aggregation_owner: hub-core diff --git a/docs/repository-standards_v0.1.md b/docs/repository-standards_v0.1.md index bcdb86a..73501c2 100644 --- a/docs/repository-standards_v0.1.md +++ b/docs/repository-standards_v0.1.md @@ -70,3 +70,43 @@ one deduplicated request to `repo-manager`; repeated `fix-consistency` runs cann resolve the gate and waste execution time. Work: `RMGR-WP-0005-T01`. + +## Coding-assistant commit provenance + +Interactive coding assistants keep the supervising human as Git author and add +standard trailers to the commit message: + +```text +Assistant: codex +Assistant-Model: gpt-5 +Assistant-Process: 12345@workstation +Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d +``` + +`Assistant` is required when the hook identifies an assistant. Model, process, +and session are emitted when known. Process identifiers include the host because +PIDs are host-local; session identifiers are opaque and must not contain +credentials. `Assistant-Model` is the canonical model attribution; +assistant-specific `Co-Authored-By` prompt conventions are deprecated because +the human remains the author and the model is not a co-owner. + +The hook accepts stable, tool-neutral overrides `ASSISTANT_NAME`, +`ASSISTANT_MODEL`, `ASSISTANT_PROCESS`, and `ASSISTANT_SESSION`. It also performs +best-effort detection for Claude Code, Codex, and Grok variables. Tool-specific +variables are compatibility inputs, not this contract: launch wrappers should +set the neutral values when an exact model or stable session is required. + +Install the governed hook and derive a report from repository history: + +```bash +rmgr assistant-provenance install +rmgr assistant-provenance report --path . +``` + +The `prepare-commit-msg` hook never rejects a commit, does nothing for a human +environment, and does not duplicate existing trailer tokens. Known automation +with its own Git identity, currently `custodian-sync`, does not need assistant +trailers. History before the configured cutover commit stays unattributed; it +must not be inferred from timestamps or writing style. + +Work: `RMGR-WP-0009`. diff --git a/docs/work-record-uuid-derivation_v1.md b/docs/work-record-uuid-derivation_v1.md new file mode 100644 index 0000000..2affe4f --- /dev/null +++ b/docs/work-record-uuid-derivation_v1.md @@ -0,0 +1,46 @@ +--- +id: RMGR-CONTRACT-UUID-0001 +type: contract +title: "Deterministic work-record UUID derivation" +version: "1" +status: active +created: "2026-08-21" +updated: "2026-08-21" +workplan_task: RMGR-WP-0005-T03 +--- + +# Work-record UUID derivation v1 + +For a live workplan or task, implementations derive the existing +`state_hub_workstream_id` or `state_hub_task_id` field with UUIDv5: + +```text +namespace UUID: a4058507-5c4a-5a00-ab06-fffa4fb46009 +name bytes: UTF-8( + "\n" + ) +``` + +The fixed UUID is itself UUIDv5(URL, +`https://helixforge.org/repo-manager/work-record/v1`), but consumers use the +fixed value above rather than recomputing it. Fleet namespace names are +lowercase DNS-label style. Canonical identifiers are +`PREFIX-WP-NNNN` or `PREFIX-WP-NNNN-TNN`. + +The repository is not a namespace. Under the current N1 posture, the fleet must +declare one shared namespace name before activation. A future fork uses its own +namespace name and therefore derives different UUIDs for the same unqualified +identifier, as required by ADR-011. + +Only proposed, ready, active, blocked, or backlog workplans and their unfinished +tasks enter the live derivation set. Finished and archived history retains its +minted identifiers. Before creating, re-deriving, or unarchiving a record, run +the live-collision preflight. Any duplicate is a hard refusal; it is never +silently disambiguated with a repository slug. + +```bash +rmgr identifier derive --namespace --record-id RMGR-WP-0005 +rmgr identifier preflight --root /path/to/fleet +``` + +Activation and bulk migration remain separate governed steps. Publishing this +function does not retroactively rewrite existing identifiers. + diff --git a/src/repo_manager/classification.py b/src/repo_manager/classification.py new file mode 100644 index 0000000..ccc0c41 --- /dev/null +++ b/src/repo_manager/classification.py @@ -0,0 +1,114 @@ +"""Versioned implementation of the Custodian repository-classification contract.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +CONTRACT_VERSION = "1.0" +CATEGORIES = frozenset({"experimental", "research", "project", "tooling", "product", "business"}) +DOMAINS = frozenset( + { + "infotech", + "financials", + "communication", + "consumer", + "health", + "industrials", + "energy", + "utilities", + "materials", + "realestate", + "crypto", + "agents", + "space", + "government", + } +) +BUSINESS_STAKE = frozenset( + { + "execution", + "intelligence", + "finance", + "legal", + "sales", + "experience", + "technology", + "operations", + "product", + "people", + "procurement", + "sustainability", + "automation", + } +) +BUSINESS_MECHANICS = frozenset({"intention", "control", "coordination", "operation", "adaptation"}) +_TAG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +@dataclass(frozen=True) +class ClassificationIssue: + field: str + message: str + + +class ClassificationError(ValueError): + def __init__(self, issues: list[ClassificationIssue]): + self.issues = issues + super().__init__("; ".join(f"{issue.field}: {issue.message}" for issue in issues)) + + +def _list(data: dict[str, Any], field: str, issues: list[ClassificationIssue]) -> list[str]: + value = data.get(field, []) + if value is None: + return [] + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + issues.append(ClassificationIssue(field, "must be a list of strings")) + return [] + if len(value) != len(set(value)): + issues.append(ClassificationIssue(field, "must not contain duplicates")) + return value + + +def validate_classification(data: dict[str, Any]) -> list[ClassificationIssue]: + """Validate required fields and controlled vocabularies from canon v1.0.""" + issues: list[ClassificationIssue] = [] + category = data.get("category") + domain = data.get("domain") + if category not in CATEGORIES: + issues.append(ClassificationIssue("category", f"must be one of {', '.join(sorted(CATEGORIES))}")) + if domain not in DOMAINS: + issues.append(ClassificationIssue("domain", f"must be one of {', '.join(sorted(DOMAINS))}")) + + secondary = _list(data, "secondary_domains", issues) + unknown_domains = sorted(set(secondary) - DOMAINS) + if unknown_domains: + issues.append(ClassificationIssue("secondary_domains", f"unknown values: {', '.join(unknown_domains)}")) + if domain in secondary: + issues.append(ClassificationIssue("secondary_domains", "must not repeat the primary domain")) + + tags = _list(data, "capability_tags", issues) + invalid_tags = sorted(tag for tag in tags if not _TAG_RE.fullmatch(tag)) + if invalid_tags: + issues.append(ClassificationIssue("capability_tags", f"not lowercase kebab-case: {', '.join(invalid_tags)}")) + + stake = _list(data, "business_stake", issues) + unknown_stake = sorted(set(stake) - BUSINESS_STAKE) + if unknown_stake: + issues.append(ClassificationIssue("business_stake", f"unknown values: {', '.join(unknown_stake)}")) + + mechanics = _list(data, "business_mechanics", issues) + unknown_mechanics = sorted(set(mechanics) - BUSINESS_MECHANICS) + if unknown_mechanics: + issues.append( + ClassificationIssue("business_mechanics", f"unknown values: {', '.join(unknown_mechanics)}") + ) + return issues + + +def require_valid_classification(data: dict[str, Any]) -> dict[str, Any]: + issues = validate_classification(data) + if issues: + raise ClassificationError(issues) + return data diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index e9c982b..9a1e21e 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -183,6 +183,65 @@ def main(argv: list[str] | None = None) -> int: register_parser.add_argument("--push", action="store_true") register_parser.add_argument("--no-commit", action="store_true") + p_intake = sub.add_parser("intake", help="Repository-owned intake records") + intake_sub = p_intake.add_subparsers(dest="record_command") + p_intake_create = intake_sub.add_parser("create", help="Create an intake") + p_intake_create.add_argument("--path", default=".") + p_intake_create.add_argument("--record-id", required=True) + p_intake_create.add_argument("--title", required=True) + p_intake_create.add_argument("--status", default="open") + p_intake_create.add_argument("--data-json", type=_json_object, default=None) + p_intake_route = intake_sub.add_parser("route", help="Route an intake") + p_intake_route.add_argument("--path", default=".") + p_intake_route.add_argument("--record-id", required=True) + p_intake_route.add_argument("--route-to", required=True) + p_intake_note = intake_sub.add_parser("note", help="Append an intake note") + p_intake_note.add_argument("--path", default=".") + p_intake_note.add_argument("--record-id", required=True) + p_intake_note.add_argument("--note", required=True) + p_intake_note.add_argument("--author", default="codex") + p_intake_close = intake_sub.add_parser("close", help="Close an intake") + p_intake_close.add_argument("--path", default=".") + p_intake_close.add_argument("--record-id", required=True) + p_intake_close.add_argument("--outcome", default=None) + + p_decision = sub.add_parser("decision", help="Repository-owned decision records") + decision_sub = p_decision.add_subparsers(dest="record_command") + p_decision_create = decision_sub.add_parser("create", help="Create a decision") + p_decision_create.add_argument("--path", default=".") + p_decision_create.add_argument("--record-id", required=True) + p_decision_create.add_argument("--title", required=True) + p_decision_create.add_argument("--status", default="open") + p_decision_create.add_argument("--data-json", type=_json_object, default=None) + p_decision_update = decision_sub.add_parser("update", help="Update a decision") + p_decision_update.add_argument("--path", default=".") + p_decision_update.add_argument("--record-id", required=True) + p_decision_update.add_argument("--title", default=None) + p_decision_update.add_argument("--status", default=None) + p_decision_update.add_argument("--data-json", type=_json_object, default=None) + p_decision_resolve = decision_sub.add_parser("resolve", help="Resolve a decision") + p_decision_resolve.add_argument("--path", default=".") + p_decision_resolve.add_argument("--record-id", required=True) + p_decision_resolve.add_argument("--rationale", required=True) + p_decision_resolve.add_argument("--decided-by", required=True) + + for record_parser in ( + p_intake_create, + p_intake_route, + p_intake_note, + p_intake_close, + p_decision_create, + p_decision_update, + p_decision_resolve, + ): + record_parser.add_argument("--reason", default="rmgr CLI") + record_parser.add_argument("--correlation-id", default=None) + record_parser.add_argument("--idempotency-key", default=None) + record_parser.add_argument("--expected-head-sha", default=None) + record_parser.add_argument("--slug", default=None) + record_parser.add_argument("--push", action="store_true") + record_parser.add_argument("--no-commit", action="store_true") + add_rapp_parser(sub) p_conf = sub.add_parser("conform", help="Check a repository against flavor standards") @@ -205,6 +264,29 @@ def main(argv: list[str] | None = None) -> int: p_scaf.add_argument("--force", action="store_true") p_scaf.add_argument("--no-commit", action="store_true") + p_provenance = sub.add_parser( + "assistant-provenance", + help="Install or report coding-assistant commit provenance", + ) + provenance_sub = p_provenance.add_subparsers(dest="provenance_command") + p_prov_install = provenance_sub.add_parser("install", help="Set global core.hooksPath") + p_prov_install.add_argument( + "--hooks-path", + default=str(Path(__file__).resolve().parents[2] / ".githooks"), + ) + p_prov_report = provenance_sub.add_parser("report", help="Report trailers from Git history") + p_prov_report.add_argument("--path", default=".") + p_prov_report.add_argument("--rev", default="HEAD") + p_prov_report.add_argument("--max-count", type=int, default=None) + + p_identifier = sub.add_parser("identifier", help="Deterministic work-record identifiers") + identifier_sub = p_identifier.add_subparsers(dest="identifier_command") + p_id_derive = identifier_sub.add_parser("derive", help="Derive one UUIDv5") + p_id_derive.add_argument("--namespace", required=True) + p_id_derive.add_argument("--record-id", required=True) + p_id_preflight = identifier_sub.add_parser("preflight", help="Scan live identifier collisions") + p_id_preflight.add_argument("--root", default=".") + args = parser.parse_args(argv) if args.version or args.command in (None, "version"): @@ -352,6 +434,38 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result.to_dict(), indent=2)) return 0 if result.status == "applied" else 1 + if args.command in {"intake", "decision"}: + selected = p_intake if args.command == "intake" else p_decision + if not args.record_command: + selected.print_help() + return 2 + from repo_manager.commands.record import mutate_record + + result = mutate_record( + Path(args.path), + args.command, + args.record_id, + operation=args.record_command, + title=getattr(args, "title", None), + status=getattr(args, "status", None), + data=getattr(args, "data_json", None), + route_to=getattr(args, "route_to", None), + note=getattr(args, "note", None), + author=getattr(args, "author", None), + outcome=getattr(args, "outcome", None), + rationale=getattr(args, "rationale", None), + decided_by=getattr(args, "decided_by", None), + correlation_id=args.correlation_id, + reason=args.reason, + commit=not args.no_commit, + push=args.push, + expected_head_sha=args.expected_head_sha, + idempotency_key=args.idempotency_key, + repo_slug=args.slug, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + if args.command == "rapp": if args.rapp_command == "init": result = rapp_init( @@ -438,6 +552,42 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result.to_dict(), indent=2)) return 0 if result.status == "applied" else 1 + if args.command == "assistant-provenance": + if not args.provenance_command: + p_provenance.print_help() + return 2 + from repo_manager.provenance import assistant_report, install_hook + + if args.provenance_command == "install": + result = install_hook(Path(args.hooks_path)) + else: + result = assistant_report(Path(args.path), rev=args.rev, max_count=args.max_count) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + if args.command == "identifier": + if not args.identifier_command: + p_identifier.print_help() + return 2 + from repo_manager.identifiers import derive_work_record_uuid, scan_live_identifier_collisions + + if args.identifier_command == "derive": + try: + derived = derive_work_record_uuid(args.namespace, args.record_id) + except ValueError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2)) + return 1 + result = { + "ok": True, + "namespace": args.namespace, + "record_id": args.record_id, + "uuid": str(derived), + } + else: + result = scan_live_identifier_collisions(Path(args.root)) + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + parser.print_help() return 0 diff --git a/src/repo_manager/commands/record.py b/src/repo_manager/commands/record.py new file mode 100644 index 0000000..001d788 --- /dev/null +++ b/src/repo_manager/commands/record.py @@ -0,0 +1,313 @@ +"""Governed mutation commands for repository-owned intake and decision records.""" + +from __future__ import annotations + +import re +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from repo_manager import dual_run, idempotency +from repo_manager.commands.workplan import CommandResult +from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff +from repo_manager.index_store import append_event, default_index_path, save_index +from repo_manager.observe import observe_repository +from repo_manager.parse.record import iter_record_files, parse_record_file + +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +_PROTECTED = frozenset( + { + "id", + "kind", + "title", + "status", + "notes", + "created", + "updated", + "routed_at", + "closed_at", + "decided_at", + } +) +_OPERATIONS = { + "intake": frozenset({"create", "route", "note", "close"}), + "decision": frozenset({"create", "update", "resolve"}), +} + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult: + return CommandResult( + command=command, + status="rejected", + correlation_id=correlation_id, + evidence={"status": "rejected", **evidence}, + error={"code": code, "message": message, "correlation_id": correlation_id}, + ) + + +def _command(kind: str, operation: str) -> str: + verbs = { + ("intake", "create"): "repo.work.create_intake", + ("intake", "route"): "repo.work.route_intake", + ("intake", "note"): "repo.work.add_intake_note", + ("intake", "close"): "repo.work.close_intake", + ("decision", "create"): "repo.work.create_decision", + ("decision", "update"): "repo.work.update_decision", + ("decision", "resolve"): "repo.work.resolve_decision", + } + return verbs.get((kind, operation), f"repo.work.{operation}_{kind}") + + +def _default_path(repo_root: Path, kind: str) -> Path: + return repo_root / f"{kind}s" / f"{kind}s.md" + + +def _find(repo_root: Path, kind: str, record_id: str) -> list[tuple[Path, Any]]: + found: list[tuple[Path, Any]] = [] + for path in iter_record_files(repo_root): + for record in parse_record_file(path, repo_root=repo_root): + if record.kind == kind and (record.id == record_id or record.uuid == record_id): + found.append((path, record)) + return found + + +def _dump_block(record: dict[str, Any]) -> str: + return "```yaml\n" + yaml.safe_dump(record, sort_keys=False, allow_unicode=True).rstrip() + "\n```" + + +def _write_new(path: Path, kind: str, record: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + previous = path.read_text(encoding="utf-8") if path.is_file() else f"# {kind.title()} records\n" + text = previous.rstrip() + f"\n\n## {record['id']} — {record['title']}\n\n" + _dump_block(record) + "\n" + path.write_text(text, encoding="utf-8") + + +def _replace(path: Path, record: Any, data: dict[str, Any]) -> None: + text = path.read_text(encoding="utf-8") + updated = text[: record.block_start] + _dump_block(data) + text[record.block_end :] + path.write_text(updated, encoding="utf-8") + + +def mutate_record( + repo_root: Path, + kind: str, + record_id: str, + *, + operation: str, + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + route_to: str | None = None, + note: str | None = None, + author: str | None = None, + outcome: str | None = None, + rationale: str | None = None, + decided_by: str | None = None, + correlation_id: str | None = None, + reason: str = "rmgr command", + commit: bool = True, + push: bool = False, + expected_head_sha: str | None = None, + idempotency_key: str | None = None, + repo_slug: str | None = None, +) -> CommandResult: + """Mutate one intake or decision YAML block in its authoritative Markdown file.""" + correlation_id = correlation_id or str(uuid.uuid4()) + command = _command(kind, operation) + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "kind": kind, + "record_id": record_id, + "operation": operation, + "title": title, + "status": status, + "data": data, + "route_to": route_to, + "note": note, + "author": author, + "outcome": outcome, + "rationale": rationale, + "decided_by": decided_by, + "expected_head_sha": expected_head_sha, + } + digest = idempotency.payload_hash(payload) + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != digest: + return _reject(command, correlation_id, "conflict", "idempotency key reused") + previous = prior.get("result") or {} + return CommandResult( + command=previous.get("command", command), + status=previous.get("status", "applied"), + correlation_id=previous.get("correlation_id", correlation_id), + evidence=previous.get("evidence") or {"status": "applied", "replay": True}, + error=previous.get("error"), + ) + + if kind not in _OPERATIONS or operation not in _OPERATIONS[kind]: + return _reject(command, correlation_id, "validation_error", f"invalid {kind!r} operation {operation!r}") + if not _ID_RE.fullmatch(record_id): + return _reject(command, correlation_id, "validation_error", f"invalid record id {record_id!r}") + if data is not None and not isinstance(data, dict): + return _reject(command, correlation_id, "validation_error", "data must be an object") + protected = _PROTECTED.intersection(data or {}) + if protected: + return _reject( + command, + correlation_id, + "validation_error", + f"data contains protected fields: {', '.join(sorted(protected))}", + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and current_head != expected_head_sha: + result = _reject( + command, + correlation_id, + "precondition_failed", + f"expected_head_sha {expected_head_sha} != {current_head}", + head_sha=current_head, + ) + if result.error: + result.error["retryable"] = True + return result + + matches = _find(repo_root, kind, record_id) + if len(matches) > 1: + return _reject(command, correlation_id, "conflict", f"record {record_id!r} is duplicated") + match = matches[0] if matches else None + now = _now() + + if operation == "create": + if match: + return _reject(command, correlation_id, "conflict", f"record {record_id!r} exists") + if not title or not title.strip(): + return _reject(command, correlation_id, "validation_error", "create requires title") + record_data: dict[str, Any] = { + "id": record_id, + "kind": kind, + "title": title.strip(), + "status": status or "open", + **(data or {}), + "created": now, + "updated": now, + } + path = _default_path(repo_root, kind) + _write_new(path, kind, record_data) + else: + if not match: + return _reject(command, correlation_id, "not_found", f"record {record_id!r} not found") + path, parsed = match + record_data = dict(parsed.raw) + if kind == "intake" and operation == "route": + if not route_to or not route_to.strip(): + return _reject(command, correlation_id, "validation_error", "route requires route_to") + record_data.update(status="routed", routed_to=route_to.strip(), routed_at=now) + elif kind == "intake" and operation == "note": + if not note or not note.strip(): + return _reject(command, correlation_id, "validation_error", "note requires content") + record_data.setdefault("notes", []).append( + {"content": note.strip(), "author": author or "codex", "created": now} + ) + elif kind == "intake" and operation == "close": + record_data.update(status="closed", closed_at=now) + if outcome: + record_data["outcome"] = outcome.strip() + elif kind == "decision" and operation == "update": + if title is None and status is None and not data: + return _reject(command, correlation_id, "validation_error", "update requires a field") + if title is not None: + if not title.strip(): + return _reject(command, correlation_id, "validation_error", "title cannot be empty") + record_data["title"] = title.strip() + if status is not None: + if not status.strip(): + return _reject(command, correlation_id, "validation_error", "status cannot be empty") + record_data["status"] = status.strip() + record_data.update(data or {}) + else: + if not rationale or not rationale.strip() or not decided_by or not decided_by.strip(): + return _reject( + command, + correlation_id, + "validation_error", + "resolve requires rationale and decided_by", + ) + record_data.update( + status="resolved", + rationale=rationale.strip(), + decided_by=decided_by.strip(), + decided_at=now, + ) + record_data["updated"] = now + _replace(path, parsed, record_data) + + relative_path = str(path.relative_to(repo_root)) + git_sha: str | None = None + push_ok: bool | None = None + push_message: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + [relative_path], + message=( + f"{command} {record_id}\n\n" + f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n" + ), + ) + except GitError as exc: + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None}, + error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, + ) + if push: + push_ok, push_message = push_ff(repo_root) + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + append_event( + index, + { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "kind": kind, + "id": record_id, + "git_sha": git_sha, + "files_touched": [relative_path], + "source": "repo-manager", + }, + ) + save_index(index) + dual_run.record_mutation( + source="repo-manager", + kind=f"{kind}_{operation}", + repo_slug=repo_slug or snapshot.get("slug"), + detail={"id": record_id, "git_sha": git_sha, "correlation_id": correlation_id}, + ) + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": [relative_path], + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence.update(push_ok=push_ok, push_message=push_message) + result = CommandResult(command=command, status="applied", correlation_id=correlation_id, evidence=evidence) + if idempotency_key: + idempotency.put(idempotency_key, digest, result.to_dict()) + return result diff --git a/src/repo_manager/commands/scaffold.py b/src/repo_manager/commands/scaffold.py index 507ee42..47e0712 100644 --- a/src/repo_manager/commands/scaffold.py +++ b/src/repo_manager/commands/scaffold.py @@ -187,4 +187,6 @@ def scaffold_repository( except GitError as exc: return CommandResult("failed", evidence, {"message": str(exc)}, cid) - return CommandResult("applied" if written else "rejected", evidence, None, cid) + if not written: + evidence["noop"] = True + return CommandResult("applied", evidence, None, cid) diff --git a/src/repo_manager/identifiers.py b/src/repo_manager/identifiers.py new file mode 100644 index 0000000..c93f07e --- /dev/null +++ b/src/repo_manager/identifiers.py @@ -0,0 +1,68 @@ +"""Deterministic work-record identifiers and live-collision preflight.""" + +from __future__ import annotations + +import re +import uuid +from collections import defaultdict +from pathlib import Path +from typing import Any + +from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file +from repo_manager.prefix_registry import iter_repo_roots + +DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") +DERIVATION_VERSION = "repo-manager.work-record-uuid.v1" +LIVE_WORKPLAN_STATUSES = frozenset({"proposed", "ready", "active", "blocked", "backlog"}) +_NAMESPACE_RE = re.compile(r"^[a-z0-9][a-z0-9.-]{0,62}$") +_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$") + + +def derivation_name(namespace: str, identifier: str) -> str: + """Return the exact UTF-8 UUIDv5 name input defined by contract v1.""" + namespace = namespace.strip() + identifier = identifier.strip() + if not _NAMESPACE_RE.fullmatch(namespace): + raise ValueError("namespace must be lowercase DNS-label style") + if not _RECORD_ID_RE.fullmatch(identifier): + raise ValueError("identifier must be a canonical workplan or task id") + return f"{namespace}\n{identifier}" + + +def derive_work_record_uuid(namespace: str, identifier: str) -> uuid.UUID: + return uuid.uuid5(DERIVATION_NAMESPACE_UUID, derivation_name(namespace, identifier)) + + +def scan_live_identifier_collisions(root: Path) -> dict[str, Any]: + """Report live workplan/task identifiers that cannot safely be derived.""" + by_id: dict[str, list[dict[str, str]]] = defaultdict(list) + repos = iter_repo_roots(root) + for repo in repos: + for path in iter_workplan_files(repo): + parsed = parse_workplan_file(path, repo_root=repo) + if parsed.status not in LIVE_WORKPLAN_STATUSES: + continue + if parsed.id: + by_id[parsed.id].append( + {"repo": repo.name, "path": parsed.path, "kind": "workplan", "status": parsed.status} + ) + for task in parsed.tasks: + if task.id and task.status not in {"done", "cancel"}: + by_id[task.id].append( + { + "repo": repo.name, + "path": parsed.path, + "kind": "task", + "status": task.status or "unknown", + } + ) + collisions = {identifier: entries for identifier, entries in sorted(by_id.items()) if len(entries) > 1} + return { + "ok": not collisions, + "root": str(root.resolve()), + "repos_scanned": [repo.name for repo in repos], + "live_identifiers": len(by_id), + "collisions": collisions, + "safe_to_derive": not collisions, + } + diff --git a/src/repo_manager/observe.py b/src/repo_manager/observe.py index 488e89d..90cee73 100644 --- a/src/repo_manager/observe.py +++ b/src/repo_manager/observe.py @@ -7,8 +7,10 @@ from pathlib import Path import yaml +from repo_manager.classification import require_valid_classification from repo_manager.gitops import head_sha, is_git_repo from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now +from repo_manager.parse.record import iter_record_files, parse_record_file from repo_manager.parse.register import iter_register_files, parse_register_file from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file @@ -23,8 +25,12 @@ def load_classification(repo_root: Path) -> dict | None: return None data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} if isinstance(data, dict) and "repo_classification" in data: - return data["repo_classification"] - return data if isinstance(data, dict) else None + classification = data["repo_classification"] + else: + classification = data if isinstance(data, dict) else None + if not isinstance(classification, dict): + return None + return require_valid_classification(classification) def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dict, RepoIndex]: @@ -58,6 +64,11 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic title=wp.title, source_path=wp.path, uuid=wp.state_hub_workstream_id, + extra={ + key: wp.frontmatter[key] + for key in ("depends_on", "related", "needs_human", "intervention_note") + if key in wp.frontmatter + }, ) ) for task in wp.tasks: @@ -70,6 +81,25 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic source_path=wp.path, uuid=task.state_hub_task_id, parent_id=wp.id, + extra={ + key: task.raw[key] + for key in ("depends_on", "needs_human", "intervention_note", "blocking_reason") + if key in task.raw + }, + ) + ) + + for path in iter_record_files(repo_root): + for record in parse_record_file(path, repo_root=repo_root): + records.append( + WorkRecordEntry( + kind=record.kind, + id=record.id, + status=record.status, + title=record.title, + source_path=record.source_path, + uuid=record.uuid, + extra={"record": record.raw}, ) ) @@ -116,6 +146,8 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic "workplan_count": sum(1 for r in records if r.kind == "workplan"), "task_count": sum(1 for r in records if r.kind == "task"), "register_entry_count": sum(1 for r in records if r.kind.startswith("register:")), + "intake_count": sum(1 for r in records if r.kind == "intake"), + "decision_count": sum(1 for r in records if r.kind == "decision"), "record_count": len(records), }, } diff --git a/src/repo_manager/parse/record.py b/src/repo_manager/parse/record.py new file mode 100644 index 0000000..4394bad --- /dev/null +++ b/src/repo_manager/parse/record.py @@ -0,0 +1,78 @@ +"""Parse repository-owned intake and decision records from Markdown YAML fences.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +_YAML_FENCE_RE = re.compile(r"```ya?ml\s*\n(.*?)\n```", re.DOTALL | re.IGNORECASE) +SUPPORTED_RECORD_KINDS = frozenset({"intake", "decision"}) + + +@dataclass +class ParsedRecord: + kind: str + id: str + title: str | None + status: str | None + uuid: str | None + source_path: str + raw: dict[str, Any] = field(default_factory=dict) + block_start: int = 0 + block_end: int = 0 + + +def parse_record_text(text: str, *, relative_path: str) -> list[ParsedRecord]: + """Return intake/decision records embedded in YAML fenced blocks.""" + records: list[ParsedRecord] = [] + for match in _YAML_FENCE_RE.finditer(text): + try: + data = yaml.safe_load(match.group(1)) or {} + except yaml.YAMLError: + continue + if not isinstance(data, dict) or data.get("kind") not in SUPPORTED_RECORD_KINDS: + continue + record_id = data.get("id") + if record_id is None: + continue + kind = str(data["kind"]) + uuid_field = "state_hub_intake_id" if kind == "intake" else "state_hub_decision_id" + records.append( + ParsedRecord( + kind=kind, + id=str(record_id), + title=str(data["title"]) if data.get("title") is not None else None, + status=str(data["status"]) if data.get("status") is not None else None, + uuid=str(data[uuid_field]) if data.get(uuid_field) is not None else None, + source_path=relative_path, + raw=data, + block_start=match.start(), + block_end=match.end(), + ) + ) + return records + + +def parse_record_file(path: Path, *, repo_root: Path) -> list[ParsedRecord]: + return parse_record_text( + path.read_text(encoding="utf-8"), + relative_path=str(path.relative_to(repo_root)), + ) + + +def iter_record_files(repo_root: Path) -> list[Path]: + """Find only the governed record locations; do not interpret arbitrary docs.""" + files: set[Path] = set() + for relative in ("intakes", "decisions", "docs/intakes", "docs/decisions", "workplans"): + root = repo_root / relative + if root.is_dir(): + files.update(path for path in root.rglob("*.md") if not path.name.startswith(".")) + for name in ("INTAKES.md", "DECISIONS.md"): + path = repo_root / name + if path.is_file(): + files.add(path) + return sorted(files) diff --git a/src/repo_manager/provenance.py b/src/repo_manager/provenance.py new file mode 100644 index 0000000..b20a06e --- /dev/null +++ b/src/repo_manager/provenance.py @@ -0,0 +1,135 @@ +"""Install and report on coding-assistant commit provenance.""" + +from __future__ import annotations + +import re +import subprocess +from collections import defaultdict +from pathlib import Path +from typing import Any + +import yaml + +_TRAILER_RE = re.compile(r"^(Assistant(?:-Model|-Process|-Session)?):\s*(.+)$", re.MULTILINE) + + +def install_hook(hooks_path: Path) -> dict[str, Any]: + """Configure the user's Git installation to use the governed hook directory.""" + hooks_path = hooks_path.expanduser().resolve() + hook = hooks_path / "prepare-commit-msg" + if not hook.is_file(): + return {"ok": False, "error": f"missing hook: {hook}"} + if not hook.stat().st_mode & 0o111: + return {"ok": False, "error": f"hook is not executable: {hook}"} + completed = subprocess.run( + ["git", "config", "--global", "core.hooksPath", str(hooks_path)], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode: + return {"ok": False, "error": completed.stderr.strip() or "git config failed"} + return {"ok": True, "hooks_path": str(hooks_path), "hook": str(hook)} + + +def _cutover(repo_root: Path) -> str | None: + path = repo_root / "config" / "assistant-provenance.yaml" + if not path.is_file(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + value = data.get("cutover_commit") if isinstance(data, dict) else None + return str(value) if value else None + + +def _commits(repo_root: Path, rev: str, max_count: int | None) -> list[dict[str, Any]]: + command = ["git", "log", "--reverse", "--format=%H%x1f%aI%x1f%an%x1f%ae%x1f%B%x1e"] + if max_count: + command.append(f"--max-count={max_count}") + command.append(rev) + completed = subprocess.run(command, cwd=repo_root, check=True, capture_output=True, text=True) + commits: list[dict[str, Any]] = [] + for raw in completed.stdout.split("\x1e"): + raw = raw.strip("\n") + if not raw: + continue + parts = raw.split("\x1f", 4) + if len(parts) != 5: + continue + sha, authored_at, author, email, body = parts + trailers = {key: value.strip() for key, value in _TRAILER_RE.findall(body)} + commits.append( + { + "sha": sha, + "authored_at": authored_at, + "author": author, + "email": email, + "assistant": trailers.get("Assistant"), + "model": trailers.get("Assistant-Model"), + "process": trailers.get("Assistant-Process"), + "session": trailers.get("Assistant-Session"), + } + ) + return commits + + +def assistant_report(repo_root: Path, *, rev: str = "HEAD", max_count: int | None = None) -> dict[str, Any]: + """Derive assistant activity and interleaved-session signals from Git alone.""" + repo_root = repo_root.resolve() + commits = _commits(repo_root, rev, max_count) + cutover = _cutover(repo_root) + cutover_seen = False + assistants: dict[str, dict[str, Any]] = defaultdict( + lambda: {"commit_count": 0, "models": set(), "sessions": set(), "processes": set()} + ) + unattributed = {"before_cutover": 0, "after_cutover": 0, "known_automation": 0} + session_sequence: list[str] = [] + + for commit in commits: + if cutover and commit["sha"].startswith(cutover): + cutover_seen = True + assistant = commit["assistant"] + if assistant: + entry = assistants[assistant] + entry["commit_count"] += 1 + for field, bucket in (("model", "models"), ("session", "sessions"), ("process", "processes")): + if commit[field]: + entry[bucket].add(commit[field]) + if commit["session"]: + session_sequence.append(commit["session"]) + elif commit["author"] == "custodian-sync" or commit["email"] == "custodian-sync@railiance.local": + unattributed["known_automation"] += 1 + elif cutover_seen: + unattributed["after_cutover"] += 1 + else: + unattributed["before_cutover"] += 1 + + interleaved: set[tuple[str, str]] = set() + positions: dict[str, list[int]] = defaultdict(list) + for position, session in enumerate(session_sequence): + positions[session].append(position) + sessions = sorted(positions) + for index, first in enumerate(sessions): + for second in sessions[index + 1 :]: + merged = [session_sequence[pos] for pos in sorted(positions[first] + positions[second])] + compressed = [value for pos, value in enumerate(merged) if pos == 0 or value != merged[pos - 1]] + if len(compressed) >= 3: + interleaved.add((first, second)) + + return { + "ok": True, + "repo": str(repo_root), + "revision": rev, + "cutover_commit": cutover, + "commit_count": len(commits), + "assistants": { + name: { + "commit_count": item["commit_count"], + "models": sorted(item["models"]), + "sessions": sorted(item["sessions"]), + "processes": sorted(item["processes"]), + } + for name, item in sorted(assistants.items()) + }, + "interleaved_sessions": [list(pair) for pair in sorted(interleaved)], + "unattributed": unattributed, + } diff --git a/tests/test_classification.py b/tests/test_classification.py new file mode 100644 index 0000000..c08805a --- /dev/null +++ b/tests/test_classification.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from repo_manager.classification import ClassificationError, validate_classification +from repo_manager.observe import load_classification + + +def test_classification_contract_accepts_canon_values() -> None: + assert not validate_classification( + { + "category": "tooling", + "domain": "infotech", + "secondary_domains": ["agents"], + "capability_tags": ["repository-control"], + "business_stake": ["technology"], + "business_mechanics": ["control"], + } + ) + + +def test_load_classification_rejects_invalid_authority(tmp_path: Path) -> None: + (tmp_path / ".repo-classification.yaml").write_text( + "repo_classification:\n" + " category: health\n" + " domain: infotech\n" + " secondary_domains: [infotech, nowhere]\n" + " capability_tags: [Bad_Tag]\n", + encoding="utf-8", + ) + with pytest.raises(ClassificationError) as raised: + load_classification(tmp_path) + message = str(raised.value) + assert "category" in message + assert "secondary_domains" in message + assert "capability_tags" in message diff --git a/tests/test_identifiers.py b/tests/test_identifiers.py new file mode 100644 index 0000000..4171f6b --- /dev/null +++ b/tests/test_identifiers.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from repo_manager.identifiers import derive_work_record_uuid, scan_live_identifier_collisions + + +def _workplan(path: Path, identifier: str, status: str, task_status: str = "todo") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"""--- +id: {identifier} +title: Test +status: {status} +--- + +## Task + +```task +id: {identifier}-T01 +status: {task_status} +``` +""", + encoding="utf-8", + ) + + +def test_uuid_derivation_is_stable_and_namespace_scoped() -> None: + first = derive_work_record_uuid("helixforge", "RMGR-WP-0005") + assert str(first) == "6dcf854e-9229-569d-90f0-2d9df235a61d" + assert derive_work_record_uuid("helixforge", "RMGR-WP-0005") == first + assert derive_work_record_uuid("client-a", "RMGR-WP-0005") != first + with pytest.raises(ValueError): + derive_work_record_uuid("Client A", "RMGR-WP-0005") + + +def test_preflight_blocks_live_collision_but_ignores_archived_history(tmp_path: Path) -> None: + one = tmp_path / "one" + two = tmp_path / "two" + _workplan(one / "workplans" / "one.md", "SHARED-WP-0001", "active") + _workplan(two / "workplans" / "two.md", "SHARED-WP-0001", "ready") + report = scan_live_identifier_collisions(tmp_path) + assert report["ok"] is False + assert "SHARED-WP-0001" in report["collisions"] + + _workplan(two / "workplans" / "two.md", "SHARED-WP-0001", "archived") + report = scan_live_identifier_collisions(tmp_path) + assert report["ok"] is True diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..5a97dde --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from repo_manager.provenance import assistant_report, install_hook + +SOURCE_HOOK = Path(__file__).parents[1] / ".githooks" / "prepare-commit-msg" +ASSISTANT_ENV_PREFIXES = ("ASSISTANT_", "CLAUDE", "CODEX_", "GROK", "XAI_") + + +def _human_env() -> dict[str, str]: + return { + key: value + for key, value in os.environ.items() + if not any(key.startswith(prefix) for prefix in ASSISTANT_ENV_PREFIXES) + } + + +def _git(repo: Path, *args: str, env: dict[str, str] | None = None) -> None: + subprocess.run(["git", *args], cwd=repo, env=env, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + repo = tmp_path / "pilot" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + hooks = repo / ".githooks" + hooks.mkdir() + shutil.copy2(SOURCE_HOOK, hooks / "prepare-commit-msg") + (hooks / "prepare-commit-msg").chmod(0o755) + _git(repo, "config", "core.hooksPath", str(hooks)) + return repo + + +def _commit(repo: Path, name: str, *, session: str | None = None) -> None: + (repo / name).write_text(name, encoding="utf-8") + _git(repo, "add", name) + env = _human_env() + if session: + env.update( + ASSISTANT_NAME="codex", + ASSISTANT_MODEL="gpt-test", + ASSISTANT_PROCESS="42@test-host", + ASSISTANT_SESSION=session, + ) + _git(repo, "commit", "-m", name, env=env) + + +def test_hook_is_silent_for_humans_and_idempotent_for_assistants(tmp_path: Path) -> None: + message = tmp_path / "message" + message.write_text("Change records\n", encoding="utf-8") + subprocess.run([str(SOURCE_HOOK), str(message)], env=_human_env(), check=True) + assert message.read_text(encoding="utf-8") == "Change records\n" + + env = _human_env() + env.update( + ASSISTANT_NAME="codex", + ASSISTANT_MODEL="gpt-test", + ASSISTANT_PROCESS="42@test-host", + ASSISTANT_SESSION="session-a", + ) + subprocess.run([str(SOURCE_HOOK), str(message)], env=env, check=True) + subprocess.run([str(SOURCE_HOOK), str(message)], env=env, check=True) + text = message.read_text(encoding="utf-8") + assert text.count("Assistant: codex") == 1 + assert text.count("Assistant-Model: gpt-test") == 1 + assert text.count("Assistant-Session: session-a") == 1 + + +def test_report_detects_interleaved_sessions_and_unknown_human(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + _commit(repo, "human") + _commit(repo, "a1", session="session-a") + _commit(repo, "b1", session="session-b") + _commit(repo, "a2", session="session-a") + + report = assistant_report(repo) + assert report["assistants"]["codex"]["commit_count"] == 3 + assert report["assistants"]["codex"]["models"] == ["gpt-test"] + assert report["interleaved_sessions"] == [["session-a", "session-b"]] + assert report["unattributed"]["before_cutover"] == 1 + + +def test_install_sets_only_global_hooks_path(tmp_path: Path, monkeypatch) -> None: + hooks = tmp_path / "hooks" + hooks.mkdir() + shutil.copy2(SOURCE_HOOK, hooks / "prepare-commit-msg") + (hooks / "prepare-commit-msg").chmod(0o755) + global_config = tmp_path / "gitconfig" + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(global_config)) + result = install_hook(hooks) + assert result["ok"] is True + configured = subprocess.run( + ["git", "config", "--global", "--get", "core.hooksPath"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_CONFIG_GLOBAL": str(global_config)}, + ).stdout.strip() + assert configured == str(hooks.resolve()) diff --git a/tests/test_record_command.py b/tests/test_record_command.py new file mode 100644 index 0000000..7e9a957 --- /dev/null +++ b/tests/test_record_command.py @@ -0,0 +1,130 @@ +"""Intake, decision, dependency, and human-flag receiving-surface coverage.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from repo_manager.commands.record import mutate_record +from repo_manager.observe import observe_repository + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + repo = tmp_path / "pilot" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: tooling\n domain: infotech\n", + encoding="utf-8", + ) + (repo / "workplans").mkdir() + (repo / "workplans" / "P-WP-0001.md").write_text( + """--- +id: P-WP-0001 +type: workplan +title: Pilot +status: active +depends_on: [P-WP-0000] +--- + +## Blocked task + +```task +id: P-WP-0001-T01 +status: wait +needs_human: true +intervention_note: Choose the API boundary. +blocking_reason: Awaiting decision. +depends_on: [P-WP-0000-T01] +``` +""", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def test_intake_lifecycle_is_file_backed_and_indexed(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idempotency.json")) + + created = mutate_record( + repo, + "intake", + "P-IN-0001", + operation="create", + title="Residual follow-up", + data={"origin": "residual", "origin_ref": "P-WP-0000"}, + idempotency_key="intake-create", + ) + replay = mutate_record( + repo, + "intake", + "P-IN-0001", + operation="create", + title="Residual follow-up", + data={"origin": "residual", "origin_ref": "P-WP-0000"}, + idempotency_key="intake-create", + ) + assert created.status == replay.status == "applied" + assert replay.evidence["git_sha"] == created.evidence["git_sha"] + assert mutate_record(repo, "intake", "P-IN-0001", operation="route", route_to="P-WP-0002").status == "applied" + assert mutate_record(repo, "intake", "P-IN-0001", operation="note", note="Owner accepted.").status == "applied" + assert mutate_record(repo, "intake", "P-IN-0001", operation="close", outcome="promoted").status == "applied" + + snapshot, index = observe_repository(repo) + record = next(item for item in index.work_records if item.kind == "intake") + assert snapshot["index"]["intake_count"] == 1 + assert record.status == "closed" + assert record.extra["record"]["routed_to"] == "P-WP-0002" + assert record.extra["record"]["notes"][0]["content"] == "Owner accepted." + + +def test_decision_resolve_keeps_safe_guarantee(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + created = mutate_record( + repo, + "decision", + "P-DEC-0001", + operation="create", + title="Place the projection", + data={"description": "Choose an aggregation owner."}, + ) + assert created.status == "applied" + incomplete = mutate_record(repo, "decision", "P-DEC-0001", operation="resolve", rationale="Boundary") + assert incomplete.status == "rejected" + resolved = mutate_record( + repo, + "decision", + "P-DEC-0001", + operation="resolve", + rationale="Repository authority stays local.", + decided_by="operator", + ) + assert resolved.status == "applied" + snapshot, index = observe_repository(repo) + record = next(item for item in index.work_records if item.kind == "decision") + assert snapshot["index"]["decision_count"] == 1 + assert record.status == "resolved" + assert record.extra["record"]["decided_by"] == "operator" + + +def test_dependency_and_human_flags_survive_indexing(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + _snapshot, index = observe_repository(repo) + workplan = next(item for item in index.work_records if item.kind == "workplan") + task = next(item for item in index.work_records if item.kind == "task") + assert workplan.extra["depends_on"] == ["P-WP-0000"] + assert task.extra == { + "depends_on": ["P-WP-0000-T01"], + "needs_human": True, + "intervention_note": "Choose the API boundary.", + "blocking_reason": "Awaiting decision.", + } diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 9c73c3a..b45d588 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -47,6 +47,18 @@ def test_durable_scaffold_writes_intent(tmp_path: Path): assert report.ok, report.to_dict() +def test_scaffold_rerun_is_an_applied_noop(tmp_path: Path): + dest = tmp_path / "stable-tool" + first = scaffold_repository(dest, flavor="tooling", commit=False) + before = {path.relative_to(dest): path.read_bytes() for path in dest.rglob("*") if path.is_file()} + second = scaffold_repository(dest, flavor="tooling", commit=False) + after = {path.relative_to(dest): path.read_bytes() for path in dest.rglob("*") if path.is_file()} + assert first.status == second.status == "applied" + assert second.evidence["noop"] is True + assert second.evidence["written"] == [] + assert after == before + + def test_cli_scaffold(tmp_path: Path): dest = tmp_path / "cli-tool" assert ( diff --git a/workplans/RMGR-WP-0004-repository-standards-conformance.md b/workplans/RMGR-WP-0004-repository-standards-conformance.md index 31b5e7e..0b61aff 100644 --- a/workplans/RMGR-WP-0004-repository-standards-conformance.md +++ b/workplans/RMGR-WP-0004-repository-standards-conformance.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: infotech created: "2026-08-16" -updated: "2026-08-18" +updated: "2026-08-21" parent_project: prj-state-hub-retirement parent_workplan: SHR-WP-0001 related: @@ -86,7 +86,7 @@ anti-patterns (`INTENT.md`+`GOAL.md`, `PRJ-WP-`). Index: ```task id: RMGR-WP-0004-T02 -status: todo +status: progress priority: high state_hub_task_id: "2c9b0cb1-f0f0-40f7-83ef-1828298338d3" ``` @@ -106,10 +106,10 @@ Known first findings, both live today: - every `prj-` repo would collide on workplan prefix `PRJ-WP-`, because the prefix is derived from the flavor marker rather than from the project. -**Opened (2026-08-18):** `rmgr conform` reports `missing` vs +**Progress (2026-08-21):** `rmgr conform` reports `missing` vs `contradictory` and flags both known findings. Remaining: call from the -consistency lane / STATE-WP-0080 guard. (Do not set `progress` until -`RMGR-WP-0005-T06` stops C-15 forcing this task back to `wait`.) +consistency lane / STATE-WP-0080 guard. The earlier C-15 gate is gone: +`RMGR-WP-0005-T06` is complete, so this task is no longer `todo` in substance. ## Own governed scaffolding @@ -202,7 +202,7 @@ deletes it. Inventory `caller:custodian-cli` is already `move` → ```task id: RMGR-WP-0004-T06 -status: wait +status: done priority: medium state_hub_task_id: "faaeb016-3ba8-493a-bbdf-9227de24f720" ``` @@ -211,12 +211,18 @@ Per `SCOPE.md` § Initial extraction source, extraction must preserve behaviour with compatibility tests. Cover: - a `prj-` repo scaffolds `GOAL.md`, no `INTENT.md`, no flavor-derived prefix; -- a durable repo's scaffolded output is byte-identical to the state-hub path - (regression guard against changing ordinary-repo behaviour); +- a durable repo preserves the agreed durable-file contract from the State Hub + handoff (byte identity is intentionally retired with the old template owner); - conformance checking flags both known live findings from T02; - flavor signals that disagree warn rather than silently resolving; - re-running scaffolding on a conformant repo is idempotent. +Result (2026-08-21): regression coverage exercises project and durable layouts, +the two known conformance findings, disagreeing flavor signals, CLI behavior, +and byte-stable no-op reruns. The stale byte-identity requirement was adapted +to contract equivalence because retaining State Hub's private template bytes +would make the retired implementation authoritative. + ## Enforce workplan identifier uniqueness ```task @@ -287,7 +293,7 @@ ADR-007 § Migration. ```task id: RMGR-WP-0004-T09 -status: wait +status: progress priority: high state_hub_task_id: "3a6ba954-4e35-4b2d-8dff-d3db84ed832c" ``` @@ -457,7 +463,7 @@ allocates its next number, or they become live collisions. ```task id: RMGR-WP-0004-T07 -status: wait +status: done priority: low state_hub_task_id: "7fdd54fe-fba7-4713-9814-d4534ff19706" ``` @@ -470,3 +476,9 @@ this repo. If the ownership shift warrants it, the Custodian may want an ADR recording that repository-standards enforcement is a Repo Manager responsibility — that is a Custodian decision, raised from here, not made here. + +Result (2026-08-21): the seam is explicit in +`docs/repository-standards_v0.1.md`: Custodian canon defines repository +standards and Repo Manager implements conformance and governed mutation. The +canon ADR for this workplan links the enforcement placement; no competing +semantic standard was introduced here. diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index ba8d791..502e37f 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -277,7 +277,7 @@ before then. ```task id: RMGR-WP-0005-T03 -status: todo +status: progress priority: high state_hub_task_id: "28067729-498d-4f47-89bd-5b9718e999c7" ``` @@ -316,6 +316,16 @@ derivation scope this task needs: Remaining prerequisite is the live-collision remediation below, tracked on `RMGR-WP-0004-T09`. 11 files, 5 identifiers. +Progress (2026-08-21): the versioned derivation function and collision guard are +implemented in Repo Manager and published as +`docs/work-record-uuid-derivation_v1.md`. UUIDv5 uses fixed namespace UUID +`a4058507-5c4a-5a00-ab06-fffa4fb46009` and exact name bytes +`\n`. `rmgr identifier derive|preflight` provides +independent reproduction and a hard live-collision refusal, including the +unarchive hazard. Activation remains correctly gated on T09's 11-file +remediation and declaration of the current N1 fleet namespace name; no existing +minted identifier was rewritten implicitly. + ## Migrate the fleet ```task diff --git a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md index 8e1d9ec..b054209 100644 --- a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md +++ b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md @@ -97,7 +97,7 @@ adapter's focused compatibility tests pass. ```task id: RMGR-WP-0008-T02 -status: todo +status: done priority: medium state_hub_task_id: "017f3b29-7100-4771-98c0-59fe91895a5f" ``` @@ -108,11 +108,16 @@ backlog, so this is the discovery surface's permanent home. Unblocks slice **A2c** (9 items: 7 routes, `table:intakes`, `table:intake_notes`). +Result (2026-08-21): Repo Manager indexes typed intake YAML fences from +repository record locations and provides governed `intake create|route|note|close` +commands. Mutations are file-backed, committed with correlation evidence, +idempotent when keyed, reindexed, and metered. + ## Decision records ```task id: RMGR-WP-0008-T03 -status: todo +status: done priority: medium state_hub_task_id: "392ac64d-15b6-4f4e-9e18-96dd7d299208" ``` @@ -125,11 +130,16 @@ must keep that guarantee — agents rely on it being available and safe. Unblocks slice **A2d** (8 items, including `caller:agentic-resources`). +Result (2026-08-21): typed decision records share the Markdown/YAML receiving +spine and expose governed `decision create|update|resolve`. Resolve refuses +incomplete decisions unless both rationale and deciding identity are present, +preserving the safety guarantee of the former hub operation. + ## Dependencies and human-flag records ```task id: RMGR-WP-0008-T04 -status: todo +status: done priority: low state_hub_task_id: "216a03e6-a0ee-4e30-ae96-fa11e77a22ac" ``` @@ -140,6 +150,10 @@ too large to land in one piece. Unblocks slice **A2e** (~6 items) and the dependency routes inside A2b. +Result (2026-08-21): workplan and task `depends_on` values, plus task +`needs_human`, `intervention_note`, and `blocking_reason`, survive parsing into +the replaceable index. Their authority remains the workplan file. + ## Repository registers ```task @@ -178,7 +192,7 @@ the live register rows. ```task id: RMGR-WP-0008-T06 -status: todo +status: progress priority: medium state_hub_task_id: "ef7934de-a1e2-4541-8068-e75876446b60" ``` @@ -190,6 +204,13 @@ published contract rather than a lift-and-shift of the hub's tables. Coordinate with HUB-WP-0004: the topic spine sits on the boundary between repository classification (here) and cross-domain projection (`hub-core`). +Progress (2026-08-21): published +`docs/repository-classification-contract_v1.0.{md,yaml}` and made observation +reject invalid controlled values, repeated primary domains, malformed lists, +and non-kebab capability tags. Remaining: hub-core acknowledges the projection +boundary and A5 consumers are routed to the contract. Coordination request +`bca85a3f-83be-44e3-a95e-e3d4b4d812eb` is with `hub-core`. + ## Owner-side UI ```task @@ -210,10 +231,10 @@ here just because the inventory named repo-manager. ## Acceptance -- [ ] Workplan write path live, A2b routable through the adapter -- [ ] Intake and decision parsers + write paths live; `resolve_decision` +- [x] Workplan write path live, A2b routable through the adapter +- [x] Intake and decision parsers + write paths live; `resolve_decision` guarantee preserved - [ ] Register spine live, A3's six registers routable -- [ ] Topic/classification contract published and agreed with hub-core +- [ ] Topic/classification contract published; agreement with hub-core pending - [ ] A4 ownership decided and recorded - [ ] Compatibility tests per slice, matching the RMGR-WP-0002 evidence pattern diff --git a/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md b/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md index 0472a7f..927ed93 100644 --- a/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md +++ b/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md @@ -4,7 +4,7 @@ type: workplan title: "Coding-assistant commit provenance trailers" domain: infotech repo: repo-manager -status: ready +status: active owner: codex topic_slug: infotech created: "2026-08-21" @@ -73,7 +73,7 @@ different mechanism and a different decision. ```task id: RMGR-WP-0009-T01 -status: todo +status: done priority: high state_hub_task_id: "39ac4f86-a936-4726-8ad2-d6219f24b1df" ``` @@ -108,11 +108,17 @@ purpose — **cut them if they are not wanted**, the scheme works without: Decide also whether `Co-Authored-By: Claude Opus 5` is kept, folded into `Assistant-Model`, or dropped. Keeping both duplicates the model. +Result (2026-08-21): the contract is published in +`docs/repository-standards_v0.1.md`. `Assistant` is required when detected; +model, host-qualified process, and session are conditional on availability. +`Assistant-Model` is canonical and assistant-specific `Co-Authored-By` +conventions are deprecated. + ## Implement the hook ```task id: RMGR-WP-0009-T02 -status: todo +status: done priority: high state_hub_task_id: "7aaccdde-b79f-43ca-b1cd-81c7c3153229" ``` @@ -138,11 +144,17 @@ Requirements: Prefer `core.hooksPath` over per-repo `.git/hooks` (not committable, lost on clone) and over committed hook files (needs opt-in per repo). +Result (2026-08-21): `.githooks/prepare-commit-msg` is installed globally via +`core.hooksPath=/home/worsch/repo-manager/.githooks`. Tests prove human silence, +exact-token idempotency, and non-blocking behavior. Tool-neutral overrides are +`ASSISTANT_NAME`, `ASSISTANT_MODEL`, `ASSISTANT_PROCESS`, and +`ASSISTANT_SESSION`. + ## Wire Claude Code ```task id: RMGR-WP-0009-T03 -status: todo +status: done priority: high state_hub_task_id: "f7a29cca-d76c-4205-a33b-3a2276459fce" ``` @@ -165,11 +177,16 @@ Once live, retire the `Co-Authored-By` prompt instruction in `~/.claude/CLAUDE.m per T01's ruling — a hook cannot be forgotten mid-session the way a prompt instruction can, which is the main reason to prefer it. +Result (2026-08-21): the hook detects `CLAUDECODE`, session id, and PID, accepts +the neutral overrides, and uses `~/.claude/settings.json`'s configured model as +a fallback. No `Co-Authored-By` instruction remains in the current global +Claude instructions, so no external prompt edit was needed. + ## Wire Codex ```task id: RMGR-WP-0009-T04 -status: todo +status: done priority: high state_hub_task_id: "180e50ff-6d43-46f3-84d2-b5337ff73900" ``` @@ -184,11 +201,18 @@ invisible in `git log`, and the one that ran 14 hours alongside a Claude session If it exports nothing usable, set the variables in its launch wrapper. A stable per-session value is worth more than the exact PID if only one is available. +Result (2026-08-21): the active Codex CLI exports `CODEX_SESSION_ID` and +`CODEX_THREAD_ID`; the hook uses the former as the stable session and reads the +configured model from `$CODEX_HOME/config.toml` (or `~/.codex/config.toml`) when +no exact neutral override is present. Codex's published stable environment +variable reference does not promise those session variables, so they remain a +best-effort compatibility input rather than the contract. + ## Wire Grok ```task id: RMGR-WP-0009-T05 -status: todo +status: done priority: medium state_hub_task_id: "b8451fe8-233f-4862-a238-5ee84901e7fb" ``` @@ -196,11 +220,16 @@ state_hub_task_id: "b8451fe8-233f-4862-a238-5ee84901e7fb" Same discovery for Grok. Lowest priority of the three only because it appears least in current history — confirm that is still true before deciding. +Result (2026-08-21): the installed Grok client exposes `GROK_SESSION_ID` and +`GROK_DEFAULT_MODEL` integration points; the hook consumes both and also accepts +the neutral override contract. No Grok process was active during cutover, so +its next real commit remains the operational proof rather than inferred history. + ## Report on assistant activity and collisions ```task id: RMGR-WP-0009-T06 -status: todo +status: done priority: medium state_hub_task_id: "a28d9287-b488-4946-bf46-6cad1a392778" ``` @@ -217,11 +246,15 @@ The trailers are the input; this is the payoff. An `rmgr` report over Keep it read-only over git. Do not add a hub table: this is derivable from the repositories, and `state-hub` is being retired under `STATE-WP-0079`. +Result (2026-08-21): `rmgr assistant-provenance report` derives assistant, +model, process, session, unattributed buckets, and interleaved-session pairs +from Git history only. Regression coverage includes an A→B→A interleave. + ## Handle existing history ```task id: RMGR-WP-0009-T07 -status: todo +status: done priority: low state_hub_task_id: "e4ff1ab5-3507-4dff-bb0d-3fed3e0be80f" ``` @@ -238,11 +271,18 @@ Also decide the `custodian-sync` identity's treatment: it is automation with its own committer identity already, so it likely needs no trailer — but it should be named in the standard so it is not later mistaken for an unwired assistant. +Result (2026-08-21): history remains untouched and `custodian-sync` is +declared known automation exempt from assistant trailers. The configuration and +report distinguish before/after cutover at first attributed commit +`329af60753b20896f30f098e8f75074652780032`. A naturally occurring +multi-session window remains an acceptance proof for the plan, not a reason to +misclassify the cutover task as incomplete. + ## Acceptance -- [ ] Trailer contract published in `docs/repository-standards_v0.1.md` -- [ ] Hook live via `core.hooksPath`; never blocks, idempotent, silent for humans -- [ ] Claude, Codex, and Grok each emit trailers, or are recorded as unable with a reason -- [ ] `git log` alone answers "which assistant, which model, which session" +- [x] Trailer contract published in `docs/repository-standards_v0.1.md` +- [x] Hook live via `core.hooksPath`; never blocks, idempotent, silent for humans +- [x] Claude, Codex, and Grok each emit trailers, or are recorded as unable with a reason +- [x] `git log` alone answers "which assistant, which model, which session" - [ ] Overlapping-session report runs against at least one real collision window -- [ ] Cutover commit recorded; pre-cutover history reported as unknown, not inferred +- [x] Cutover commit recorded; pre-cutover history reported as unknown, not inferred