feat: advance repository records and provenance

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-21 22:07:48 +02:00
parent 329af60753
commit 35e86d7b85
24 changed files with 1618 additions and 51 deletions

78
.githooks/prepare-commit-msg Executable file
View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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`.

View file

@ -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(<fleet-namespace> + "\n" + <canonical-identifier>)
```
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 <fleet-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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,
}

View file

@ -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),
},
}

View file

@ -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)

View file

@ -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,
}

View file

@ -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

50
tests/test_identifiers.py Normal file
View file

@ -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

105
tests/test_provenance.py Normal file
View file

@ -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())

View file

@ -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.",
}

View file

@ -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 (

View file

@ -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.

View file

@ -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
`<fleet-namespace>\n<canonical-id>`. `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

View file

@ -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

View file

@ -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