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:
parent
329af60753
commit
35e86d7b85
24 changed files with 1618 additions and 51 deletions
78
.githooks/prepare-commit-msg
Executable file
78
.githooks/prepare-commit-msg
Executable 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
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
| workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md |
|
| 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-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-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-T01 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
| task | RMGR-WP-0001-T02 | 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 |
|
| 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-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-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-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-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-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-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-T06 | done | — | 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-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-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-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-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-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-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 |
|
| 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-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-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-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-T02 | done | — | 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-T03 | done | — | 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-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-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-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-T01 | done | — | 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-T02 | done | — | 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-T03 | done | — | 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-T04 | done | — | 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-T05 | done | — | 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-T06 | done | — | 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-T07 | progress | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
||||||
|
|
|
||||||
6
config/assistant-provenance.yaml
Normal file
6
config/assistant-provenance.yaml
Normal 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
|
||||||
34
docs/repository-classification-contract_v1.0.md
Normal file
34
docs/repository-classification-contract_v1.0.md
Normal 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.
|
||||||
|
|
||||||
53
docs/repository-classification-contract_v1.0.yaml
Normal file
53
docs/repository-classification-contract_v1.0.yaml
Normal 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
|
||||||
|
|
@ -70,3 +70,43 @@ one deduplicated request to `repo-manager`; repeated `fix-consistency` runs cann
|
||||||
resolve the gate and waste execution time.
|
resolve the gate and waste execution time.
|
||||||
|
|
||||||
Work: `RMGR-WP-0005-T01`.
|
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`.
|
||||||
|
|
|
||||||
46
docs/work-record-uuid-derivation_v1.md
Normal file
46
docs/work-record-uuid-derivation_v1.md
Normal 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.
|
||||||
|
|
||||||
114
src/repo_manager/classification.py
Normal file
114
src/repo_manager/classification.py
Normal 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
|
||||||
|
|
@ -183,6 +183,65 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
register_parser.add_argument("--push", action="store_true")
|
register_parser.add_argument("--push", action="store_true")
|
||||||
register_parser.add_argument("--no-commit", 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)
|
add_rapp_parser(sub)
|
||||||
|
|
||||||
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
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("--force", action="store_true")
|
||||||
p_scaf.add_argument("--no-commit", 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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.version or args.command in (None, "version"):
|
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))
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
return 0 if result.status == "applied" else 1
|
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.command == "rapp":
|
||||||
if args.rapp_command == "init":
|
if args.rapp_command == "init":
|
||||||
result = rapp_init(
|
result = rapp_init(
|
||||||
|
|
@ -438,6 +552,42 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(json.dumps(result.to_dict(), indent=2))
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
return 0 if result.status == "applied" else 1
|
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()
|
parser.print_help()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
|
||||||
313
src/repo_manager/commands/record.py
Normal file
313
src/repo_manager/commands/record.py
Normal 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
|
||||||
|
|
@ -187,4 +187,6 @@ def scaffold_repository(
|
||||||
except GitError as exc:
|
except GitError as exc:
|
||||||
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
|
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)
|
||||||
|
|
|
||||||
68
src/repo_manager/identifiers.py
Normal file
68
src/repo_manager/identifiers.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,8 +7,10 @@ from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from repo_manager.classification import require_valid_classification
|
||||||
from repo_manager.gitops import head_sha, is_git_repo
|
from repo_manager.gitops import head_sha, is_git_repo
|
||||||
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
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.register import iter_register_files, parse_register_file
|
||||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_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
|
return None
|
||||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
if isinstance(data, dict) and "repo_classification" in data:
|
if isinstance(data, dict) and "repo_classification" in data:
|
||||||
return data["repo_classification"]
|
classification = data["repo_classification"]
|
||||||
return data if isinstance(data, dict) else None
|
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]:
|
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,
|
title=wp.title,
|
||||||
source_path=wp.path,
|
source_path=wp.path,
|
||||||
uuid=wp.state_hub_workstream_id,
|
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:
|
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,
|
source_path=wp.path,
|
||||||
uuid=task.state_hub_task_id,
|
uuid=task.state_hub_task_id,
|
||||||
parent_id=wp.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"),
|
"workplan_count": sum(1 for r in records if r.kind == "workplan"),
|
||||||
"task_count": sum(1 for r in records if r.kind == "task"),
|
"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:")),
|
"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),
|
"record_count": len(records),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
src/repo_manager/parse/record.py
Normal file
78
src/repo_manager/parse/record.py
Normal 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)
|
||||||
135
src/repo_manager/provenance.py
Normal file
135
src/repo_manager/provenance.py
Normal 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,
|
||||||
|
}
|
||||||
38
tests/test_classification.py
Normal file
38
tests/test_classification.py
Normal 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
50
tests/test_identifiers.py
Normal 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
105
tests/test_provenance.py
Normal 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())
|
||||||
130
tests/test_record_command.py
Normal file
130
tests/test_record_command.py
Normal 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.",
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,18 @@ def test_durable_scaffold_writes_intent(tmp_path: Path):
|
||||||
assert report.ok, report.to_dict()
|
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):
|
def test_cli_scaffold(tmp_path: Path):
|
||||||
dest = tmp_path / "cli-tool"
|
dest = tmp_path / "cli-tool"
|
||||||
assert (
|
assert (
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-16"
|
created: "2026-08-16"
|
||||||
updated: "2026-08-18"
|
updated: "2026-08-21"
|
||||||
parent_project: prj-state-hub-retirement
|
parent_project: prj-state-hub-retirement
|
||||||
parent_workplan: SHR-WP-0001
|
parent_workplan: SHR-WP-0001
|
||||||
related:
|
related:
|
||||||
|
|
@ -86,7 +86,7 @@ anti-patterns (`INTENT.md`+`GOAL.md`, `PRJ-WP-`). Index:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T02
|
id: RMGR-WP-0004-T02
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2c9b0cb1-f0f0-40f7-83ef-1828298338d3"
|
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
|
- every `prj-` repo would collide on workplan prefix `PRJ-WP-`, because the
|
||||||
prefix is derived from the flavor marker rather than from the project.
|
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
|
`contradictory` and flags both known findings. Remaining: call from the
|
||||||
consistency lane / STATE-WP-0080 guard. (Do not set `progress` until
|
consistency lane / STATE-WP-0080 guard. The earlier C-15 gate is gone:
|
||||||
`RMGR-WP-0005-T06` stops C-15 forcing this task back to `wait`.)
|
`RMGR-WP-0005-T06` is complete, so this task is no longer `todo` in substance.
|
||||||
|
|
||||||
## Own governed scaffolding
|
## Own governed scaffolding
|
||||||
|
|
||||||
|
|
@ -202,7 +202,7 @@ deletes it. Inventory `caller:custodian-cli` is already `move` →
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T06
|
id: RMGR-WP-0004-T06
|
||||||
status: wait
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "faaeb016-3ba8-493a-bbdf-9227de24f720"
|
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:
|
with compatibility tests. Cover:
|
||||||
|
|
||||||
- a `prj-` repo scaffolds `GOAL.md`, no `INTENT.md`, no flavor-derived prefix;
|
- 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
|
- a durable repo preserves the agreed durable-file contract from the State Hub
|
||||||
(regression guard against changing ordinary-repo behaviour);
|
handoff (byte identity is intentionally retired with the old template owner);
|
||||||
- conformance checking flags both known live findings from T02;
|
- conformance checking flags both known live findings from T02;
|
||||||
- flavor signals that disagree warn rather than silently resolving;
|
- flavor signals that disagree warn rather than silently resolving;
|
||||||
- re-running scaffolding on a conformant repo is idempotent.
|
- 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
|
## Enforce workplan identifier uniqueness
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -287,7 +293,7 @@ ADR-007 § Migration.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T09
|
id: RMGR-WP-0004-T09
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3a6ba954-4e35-4b2d-8dff-d3db84ed832c"
|
state_hub_task_id: "3a6ba954-4e35-4b2d-8dff-d3db84ed832c"
|
||||||
```
|
```
|
||||||
|
|
@ -457,7 +463,7 @@ allocates its next number, or they become live collisions.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0004-T07
|
id: RMGR-WP-0004-T07
|
||||||
status: wait
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "7fdd54fe-fba7-4713-9814-d4534ff19706"
|
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
|
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
|
repository-standards enforcement is a Repo Manager responsibility — that is a
|
||||||
Custodian decision, raised from here, not made here.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,7 @@ before then.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0005-T03
|
id: RMGR-WP-0005-T03
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "28067729-498d-4f47-89bd-5b9718e999c7"
|
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
|
Remaining prerequisite is the live-collision remediation below, tracked on
|
||||||
`RMGR-WP-0004-T09`. 11 files, 5 identifiers.
|
`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
|
## Migrate the fleet
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ adapter's focused compatibility tests pass.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T02
|
id: RMGR-WP-0008-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "017f3b29-7100-4771-98c0-59fe91895a5f"
|
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`).
|
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
|
## Decision records
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T03
|
id: RMGR-WP-0008-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "392ac64d-15b6-4f4e-9e18-96dd7d299208"
|
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`).
|
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
|
## Dependencies and human-flag records
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T04
|
id: RMGR-WP-0008-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "216a03e6-a0ee-4e30-ae96-fa11e77a22ac"
|
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.
|
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
|
## Repository registers
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -178,7 +192,7 @@ the live register rows.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T06
|
id: RMGR-WP-0008-T06
|
||||||
status: todo
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ef7934de-a1e2-4541-8068-e75876446b60"
|
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
|
Coordinate with HUB-WP-0004: the topic spine sits on the boundary between
|
||||||
repository classification (here) and cross-domain projection (`hub-core`).
|
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
|
## Owner-side UI
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -210,10 +231,10 @@ here just because the inventory named repo-manager.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Workplan write path live, A2b routable through the adapter
|
- [x] Workplan write path live, A2b routable through the adapter
|
||||||
- [ ] Intake and decision parsers + write paths live; `resolve_decision`
|
- [x] Intake and decision parsers + write paths live; `resolve_decision`
|
||||||
guarantee preserved
|
guarantee preserved
|
||||||
- [ ] Register spine live, A3's six registers routable
|
- [ ] 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
|
- [ ] A4 ownership decided and recorded
|
||||||
- [ ] Compatibility tests per slice, matching the RMGR-WP-0002 evidence pattern
|
- [ ] Compatibility tests per slice, matching the RMGR-WP-0002 evidence pattern
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Coding-assistant commit provenance trailers"
|
title: "Coding-assistant commit provenance trailers"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: repo-manager
|
repo: repo-manager
|
||||||
status: ready
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-21"
|
created: "2026-08-21"
|
||||||
|
|
@ -73,7 +73,7 @@ different mechanism and a different decision.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T01
|
id: RMGR-WP-0009-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "39ac4f86-a936-4726-8ad2-d6219f24b1df"
|
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
|
Decide also whether `Co-Authored-By: Claude Opus 5` is kept, folded into
|
||||||
`Assistant-Model`, or dropped. Keeping both duplicates the model.
|
`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
|
## Implement the hook
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T02
|
id: RMGR-WP-0009-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7aaccdde-b79f-43ca-b1cd-81c7c3153229"
|
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
|
Prefer `core.hooksPath` over per-repo `.git/hooks` (not committable, lost on
|
||||||
clone) and over committed hook files (needs opt-in per repo).
|
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
|
## Wire Claude Code
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T03
|
id: RMGR-WP-0009-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f7a29cca-d76c-4205-a33b-3a2276459fce"
|
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
|
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.
|
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
|
## Wire Codex
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T04
|
id: RMGR-WP-0009-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "180e50ff-6d43-46f3-84d2-b5337ff73900"
|
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
|
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.
|
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
|
## Wire Grok
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T05
|
id: RMGR-WP-0009-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "b8451fe8-233f-4862-a238-5ee84901e7fb"
|
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
|
Same discovery for Grok. Lowest priority of the three only because it appears
|
||||||
least in current history — confirm that is still true before deciding.
|
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
|
## Report on assistant activity and collisions
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T06
|
id: RMGR-WP-0009-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "a28d9287-b488-4946-bf46-6cad1a392778"
|
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
|
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`.
|
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
|
## Handle existing history
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0009-T07
|
id: RMGR-WP-0009-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "e4ff1ab5-3507-4dff-bb0d-3fed3e0be80f"
|
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
|
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.
|
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
|
## Acceptance
|
||||||
|
|
||||||
- [ ] Trailer contract published in `docs/repository-standards_v0.1.md`
|
- [x] Trailer contract published in `docs/repository-standards_v0.1.md`
|
||||||
- [ ] Hook live via `core.hooksPath`; never blocks, idempotent, silent for humans
|
- [x] 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
|
- [x] 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] `git log` alone answers "which assistant, which model, which session"
|
||||||
- [ ] Overlapping-session report runs against at least one real collision window
|
- [ ] 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue