diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc8f9b..40cb7d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ name: CI # test — full pytest suite, incl. the golden-repo compose test (real payload + # flagship profile, snapshot-asserted tree) and the payload<->install-map # completeness tripwire. BLOCKS. +# test-windows — the same suite on windows-latest. The plugin is authored on Windows and +# ships PowerShell hooks, so Linux-only CI cannot see console-encoding or +# line-ending faults. BLOCKS. # sync-check — harness/ is GENERATED from delivery-standard/kit and must never drift. # BLOCKS. A red here means the kit's main moved without a plugin sync. # actionlint — lints the workflows the harness EMITS, composed and token-stubbed the @@ -31,6 +34,19 @@ jobs: - uses: astral-sh/setup-uv@v8.3.2 - run: uv run --project scripts pytest scripts/tests -q + # The plugin is developed on Windows but CI ran only on Linux, so two Windows-only defects + # reached master unseen: a cp1252 console fault (fixed in 1.3.0) and a CRLF test fixture. + # A separate job rather than a matrix on `test`, so the existing check name is unchanged. + # The runner's default CRLF checkout is deliberate — it mirrors the dev machine this is + # meant to protect; the suite must pass with either line-ending style. + test-windows: + name: pytest (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v8.3.2 + - run: uv run --project scripts pytest scripts/tests -q + sync-check: name: harness == kit (generated-copy invariant) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0ef5a4a..ac6d994 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,15 @@ desktop.ini /tmp/ *.log +# Artifact versioning & refresh — transient / non-authoritative local state. +# The committed source of truth stays the metadata ledger (.sdlc/metrics/artifact-log.jsonl); +# the object store and refresh drafts are a local safety net a fresh clone/CI degrades past cleanly. +.sdlc/refresh/**/*.proposed +.sdlc/refresh/**/candidates.json +.sdlc/refresh/_rollback/ +.sdlc/versions/objects/ +# Override (opt in per repo): to make content snapshots portable across clones/CI, comment out the +# `.sdlc/versions/objects/` line above and commit the store. History stays lean by default. + # Do not ignore .sdlc/ — it's part of the plugin's test data # Target projects will have their own .gitignore diff --git a/CLAUDE.md b/CLAUDE.md index 27fc9fd..7cd7d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ A Claude Code plugin that orchestrates the full SDLC lifecycle using company-con ## Architecture - `plugin.json` — Plugin manifest (entry point for Claude Code) - `SKILL.md` — Main skill definition (loaded when plugin activates) -- `commands/` — 23 slash commands (`/sdlc`, `/sdlc-setup`, `/sdlc-status`, `/sdlc-next`, `/sdlc-gate`, `/sdlc-enhance`, `/sdlc-coach`, `/sdlc-review`, `/sdlc-intake`, `/sdlc-brief`, `/sdlc-spec`, `/sdlc-phase-report`, `/sdlc-audit`, `/sdlc-feature`, `/sdlc-experience`, `/sdlc-data`, `/sdlc-rules`, `/sdlc-channel`, `/sdlc-evals`, `/sdlc-harness`, `/sdlc-upgrade`, `/sdlc-revise`, `/sdlc-audit-artifacts`) +- `commands/` — 26 slash commands (`/sdlc`, `/sdlc-setup`, `/sdlc-status`, `/sdlc-next`, `/sdlc-gate`, `/sdlc-enhance`, `/sdlc-coach`, `/sdlc-review`, `/sdlc-intake`, `/sdlc-brief`, `/sdlc-spec`, `/sdlc-phase-report`, `/sdlc-audit`, `/sdlc-feature`, `/sdlc-experience`, `/sdlc-data`, `/sdlc-rules`, `/sdlc-channel`, `/sdlc-evals`, `/sdlc-harness`, `/sdlc-upgrade`, `/sdlc-revise`, `/sdlc-audit-artifacts`, `/sdlc-version`, `/sdlc-refresh`, `/sdlc-retro`) - `agents/` — 13 agents (orchestrator, requirements-analyst, compliance-checker, section-evaluator, narrative-enhancer, gate-repair, multi-reviewer, discovery-analyst, feature-architect, visual-designer, conversation-designer, data-analyst, bizreq-analyst) - `channels/` — Cross-profile channel descriptor library (`_schema.yaml`, `ag-ui.yaml`, `voice.yaml`, `chat.yaml`) — the delivery-surface vocabulary the channel layer reads - `profiles/` — Company/stack YAML configs with compliance gates @@ -60,6 +60,9 @@ When adding a new agent or command, document both modes in its file. `discovery- - **Multi-discipline channel layer** — an additive layer giving Data, Design, and Bizreq first-class seats and organizing a feature around its **customer channel of use**. `channels/` holds schema-guarded YAML descriptors (`ag-ui`, `voice`, `chat`) whose acceptance dimensions ride a spec's *existing* `## Acceptance Checks`; `validate_channel.py` guards the library and `check_channel.py` is an advisory (exit 0) DoR lint that never changes a ready/not-ready verdict. Six commands (`/sdlc-feature`, `/sdlc-experience`, `/sdlc-data`, `/sdlc-rules`, `/sdlc-channel`, `/sdlc-evals`, `/sdlc-harness`, `/sdlc-upgrade`) drive five interview-driven discipline agents (`feature-architect`, `visual-designer`, `conversation-designer`, `data-analyst`, `bizreq-analyst`) that also serve as `/sdlc-review` council lenses (7 viewpoints). A spec's optional `channel:` frontmatter field binds the surface; a phase-spanning `.sdlc/decision-log.md` (owner + 2-business-day clock, surfaced in `/sdlc-status` via `track_decisions.py`) and optional discipline sign-offs on the state sign-off record round it out. The protected core (`check_spec.py`, `check_gates.py`, `section-evaluator`, `harness/**`, `phase_model.py`, `phase-registry.yaml`, `/sdlc-coach`, `/sdlc-spec`) is byte-for-byte unchanged. See `references/channel-model.md` and `references/team-model.md` - **Close handoff-report generation** — Phase C Step 4 ("Hand over the record") is a two-pass draft: `generate_handoff_report.py` does the deterministic assembly first — phase report index, per-phase gate/sign-off table (from `state.yaml`), metrics history (reusing `scorecard.py`), spec backlog (reusing `track_specs.py`) — filling the existing `final-handoff-report.md` template and marking the judgment sections (outcomes vs the Phase 0 statement, debt log, open items, dashboard handover) with `[Fill: ...]` slots for the Explore agent to enrich. Honest by design (missing data reads "no data", never a fabricated zero) and refuses to clobber a human-edited report without `--force`. Runs standalone (`--repo`) or in-workflow (`--state`) - **Artifact update & audit** — an additive, advisory layer for *changing* a pre-Build artifact after the fact and auditing the trail. `artifact_model.py` owns the change-ledger entry shape and the staleness disposition state machine (`OPEN → REFRESHED | ACKNOWLEDGED | NOT_AFFECTED`) with honest counting (ACKNOWLEDGED needs an owner, NOT_AFFECTED a reason, or it still counts as debt); `artifact_lineage.py` harvests declared `upstream → downstream` edges (frozen-layer `source_artifacts`, id references, explicit markdown paths) with a labeled **coarse** phase-order fallback and cycle-safe traversal. `audit_artifacts.py` (`record` / `impact` / `report`, **exit 0 always**) records changes to an append-only `.sdlc/metrics/artifact-log.jsonl` — its **own** JSONL, never inside `gate_results`, so `/sdlc-audit` output is byte-identical with or without it — and flags a downstream as a *stale candidate* when an upstream changed after it last did. `/sdlc-revise ` is the PM-facing write path (discipline agent proposes, named human decides; records the why to the ledger **and** a linked `DL-NN` decision-log item; re-gates; shows impact to disposition). `/sdlc-audit-artifacts` is the read-only sibling to `/sdlc-audit` (freshness dashboard, `--impact`, `--history`). A `record --scan` step in `/sdlc-next` captures direct edits at advance time; `/sdlc-status` surfaces a stale rollup. The protected core (incl. `advance_phase.py`) is byte-for-byte unchanged. See `references/artifact-lifecycle.md` +- **Artifact versioning & draft+confirm auto-refresh** — an additive, advisory package built on the artifact-update-audit layer that (1) gives every pre-Build artifact a **content history** (diff/rollback) and (2) back-propagates a merged spec's shipped reality **up** into `requirements.md`/`epics.md`/`feature-brief.md`/`business-rules.md`. One new **pure** module `version_model.py` derives the ordinal-keyed version list `v1..vN` straight from the change-ledger's existing 16-hex SHA-256 hashes — the content-addressed object store (`.sdlc/versions/objects//<16hex>`) is just those hashes rehydrated to bytes, so there is **no second index** to drift (a dup-hash rollback is its own ordinal, rendered "restored from vX"; a pre-existing file with no ledger entry synthesizes a `v1` baseline, never a crash). The `version` + `refresh` verbs **fold into `audit_artifacts.py`** (the sanctioned extend target; its existing `record`/`impact`/`report` output stays byte-identical) so one best-effort `capture()` seam keeps content↔hash lockstep. `/sdlc-version {list,show,diff,rollback,gc}` is the read/undo path (rollback is preview→named-human-confirm, append-only, `--ack-signoff` for signed-off artifacts, refuses on an uncaptured version; `gc` is cross-ledger refcounted and protects unknown sign-offs). `/sdlc-refresh {detect,scan,draft,apply,reject,status}` is the reverse-propagation path: **review-first / divergence-aware** (a faithful spec drafts nothing without `--draft`), the discipline agent edits only a `.proposed`, a named human echoes the reviewed diffhash to `apply` (One Rule), and the refresh lands as a rollback-able `refreshed` version attributed to the spec via a `source_spec` **rider key** (`artifact_model.py` unchanged). Both write **nothing** to `state.yaml` (re-gating stays the command layer's `/sdlc-gate` job) and exit 0 on every path; the object store and `.proposed` drafts are **gitignored** local safety nets (a fresh clone/CI degrades to "content not captured", documented override to commit). The protected core is byte-for-byte unchanged. See `references/artifact-versioning.md` +- **Cross-ledger retro roll-up** — `retro_report.py` (surfaced as `/sdlc-retro`) is the read-only report that turns the accumulated ledgers into retro input: recurring findings grouped by category+target across review rounds (candidates for a permanent check — the "findings become new checks" feed), repeat-stale artifacts, the **refresh funnel** per merged spec and per upstream stem (detected → drifted → refreshed → rejected → open — the tuning signal for the divergence heuristic), and a combined disposition-debt rollup naming each source ledger. Same discipline as the scorecard: "no data" over fabricated zeros, patterns keyed by category/artifact/stem and **never by actor** (no ranking flag exists), refuses activity metrics, writes nothing, exit 0 on every path, dual-mode `--repo`/`--state` +- **Command contract lint** — `scripts/tests/test_command_contracts.py` is the static half of the command-prose safety harness: it extracts every `uv run … scripts/.py` invocation from fenced blocks in `commands/*.md` and validates script existence, subcommand chains, and every `--flag` against live `--help` output (cached subprocesses), plus agent-name and `references/`/`templates/` path cross-references. Conservative by design (ambiguous → skipped, never a false violation; intentional exceptions live in an explained allowlist) with self-tests proving each detection class fires. Live "does-the-model-follow-the-doc" evals remain out of scope ## Testing ```bash @@ -81,5 +84,13 @@ uv run scripts/record_findings.py report --repo /tmp/test --strict uv run scripts/audit_artifacts.py record --scan --repo /tmp/test uv run scripts/audit_artifacts.py impact FR-012 --repo /tmp/test uv run scripts/audit_artifacts.py report --repo /tmp/test --json +uv run scripts/audit_artifacts.py version list requirements.md --repo /tmp/test +uv run scripts/audit_artifacts.py version diff requirements.md prev latest --repo /tmp/test +uv run scripts/audit_artifacts.py version rollback requirements.md prev --repo /tmp/test +uv run scripts/audit_artifacts.py refresh detect --spec specs/0001-duplicate-claim-409.md --repo /tmp/test +uv run scripts/audit_artifacts.py refresh scan --repo /tmp/test --json +uv run scripts/audit_artifacts.py refresh status --spec specs/0001-duplicate-claim-409.md --repo /tmp/test +uv run scripts/retro_report.py --state /tmp/test/.sdlc/state.yaml --json +uv run scripts/retro_report.py --repo /tmp/test --window-days 30 uv run --project scripts python -m pytest scripts/tests/ -q ``` diff --git a/commands/sdlc-audit-artifacts.md b/commands/sdlc-audit-artifacts.md index a1737cd..28faf99 100644 --- a/commands/sdlc-audit-artifacts.md +++ b/commands/sdlc-audit-artifacts.md @@ -47,6 +47,10 @@ gate, changes a verdict, or edits your artifacts, specs, or `state.yaml`. ```bash uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py report --history FR-012 --state .sdlc/state.yaml ``` + `--history` shows the change **metadata** (when/who/why). To see what the artifact actually + **said** at each version — or to diff two versions — point the user at the read-only + `/sdlc-version list|show|diff ` command (the content complement). Rolling one back lives + there too, behind its own named-human confirm; this command never mutates. 4. **Display results** with the honest confidence labels intact: - Every downstream edge is tagged **declared** (a written-down link: a frozen layer's @@ -80,8 +84,11 @@ gate, changes a verdict, or edits your artifacts, specs, or `state.yaml`. - The user runs `/sdlc-audit-artifacts` — never `audit_artifacts.py` by hand. The command owns the scan, the lens selection, and the disposition recording. -- **Read-only with respect to your project.** The only thing this command ever writes is the - change-ledger (`.sdlc/metrics/artifact-log.jsonl`) — the audit trail itself. It never edits an - artifact, a spec, or `state.yaml`. To *change* an artifact, use `/sdlc-revise`. +- **Read-only with respect to your project.** The only authoritative thing this command writes is the + change-ledger (`.sdlc/metrics/artifact-log.jsonl`) — the audit trail itself; the `record --scan` in + step 2 also best-effort captures each changed artifact's content into the gitignored, non- + authoritative version store (`.sdlc/versions/`), which a store fault silently skips without changing + this command's output. It never edits an artifact, a spec, or `state.yaml`. To *change* an artifact, + use `/sdlc-revise`; to *roll one back*, `/sdlc-version`. - **Advisory by construction** — `audit_artifacts.py` exits 0 always. Staleness is never a gate; a candidate is a prompt for a human's judgement, not a verdict. diff --git a/commands/sdlc-next.md b/commands/sdlc-next.md index bc4cf74..d5a7e3e 100644 --- a/commands/sdlc-next.md +++ b/commands/sdlc-next.md @@ -70,8 +70,21 @@ Run exit gate checks for the current phase and advance to the next phase if all ```bash uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py record --scan --state .sdlc/state.yaml ``` - This appends only to `.sdlc/metrics/artifact-log.jsonl` (the audit trail) — it never modifies - artifacts or `state.yaml`, and never blocks the advance. If the script is absent, skip it. + This appends only to `.sdlc/metrics/artifact-log.jsonl` (the audit trail) and captures each + changed artifact's **content** lockstep into the local version store (`.sdlc/versions/`), so the + edit is diffable and roll-back-able via `/sdlc-version`. Content capture is best-effort — a store + fault leaves the ledger append and this command byte-identical. It never modifies artifacts or + `state.yaml`, and never blocks the advance. If the script is absent, skip it. + + - **Then, optionally surface merged-spec drift (advisory, exit 0):** if any specs reached + `status: merged` during the loop just completed, offer to back-propagate what they shipped into + the pre-Build artifacts. Run the read-only scan and, for any spec with drifted upstreams, suggest + the human run `/sdlc-refresh detect --spec `: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh scan --state .sdlc/state.yaml + ``` + This writes nothing and never blocks — it only points at candidates a named human may choose to + refresh. Skip silently if there are no merged specs or the script is absent. Then perform the advance via `advance_phase.py` (it applies the state updates below and records any discipline sign-offs captured in step 4 on the phase's existing sign-off record): diff --git a/commands/sdlc-refresh.md b/commands/sdlc-refresh.md new file mode 100644 index 0000000..bfb0993 --- /dev/null +++ b/commands/sdlc-refresh.md @@ -0,0 +1,147 @@ +# /sdlc-refresh — Back-Propagate a Built Spec into Its Upstream Artifacts + +Traceability normally flows forward (requirement → spec → code). This command runs it **backward**: +when a spec merges, it surfaces the pre-Build artifacts that spec implies an edit to — +`requirements.md`, `epics.md`, `feature-brief.md`, `business-rules.md` — so they stop silently +drifting from what actually shipped. It reads as `/sdlc-revise` **inverted**: instead of you changing +one artifact and rippling forward, a merged spec proposes the upstream edits and a **named human** +confirms them. + +``` +/sdlc-refresh detect --spec specs/0001-duplicate-claim-409.md +/sdlc-refresh draft --spec specs/0001-duplicate-claim-409.md --draft +/sdlc-refresh apply --spec specs/0001-duplicate-claim-409.md requirements --actor me +/sdlc-refresh status --spec specs/0001-duplicate-claim-409.md +``` + +The **One Rule** holds throughout: the discipline agent **drafts** the change into a `.proposed` +copy; a **named human decides** whether to apply it. No agent ever edits the real artifact. + +## Instructions + +1. **Resolve mode and repo root:** + - **Workflow mode** (default): look for `.sdlc/state.yaml`; pass `--state .sdlc/state.yaml`. If not + found, tell the user to run `/sdlc-setup` first. + - **Standalone mode** (`--repo `, or no `.sdlc/`): operate on the given repo. + +2. **Detect — review first, draft only on a signal (the divergence-aware default).** List the + pre-Build upstreams the merged spec traces to (from its `source:` frontmatter and any in-vocabulary + id in its body). A faithful spec that correctly realizes its upstream lists the upstreams as + *trace-only (no drift detected)* and drafts **nothing** — the layer never becomes a rubber-stamp: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh detect --spec --state .sdlc/state.yaml + ``` + - Each row is tagged **declared** (a written-down link) or **coarse** (a phase-order guess). + Coarse guesses are listed for context and **never** auto-drafted. + - **Zero-candidate honesty:** a `source: —`, or a non-vocabulary / nonexistent id (`REQ-042`, + `FR-999`), yields zero candidates and the nudge *"no traceable upstream — add an FR/EP/US/BR id + to `source:`"* — never a fabricated guess, never a crash. + - Upstreams that already changed **after** the spec merged are suppressed as already-fresher. + - Pass `--draft` to mark all declared upstreams draft-eligible even without a drift signal; + `--transitive` / `--include-coarse` widen the set (coarse still never drafts). + + To see this across **all** merged specs at once (this is what backs the `/sdlc-status` drift + nudge), use `refresh scan` instead of `detect --spec`. + +3. **Draft — spawn the owning discipline agent to edit the `.proposed` (never the real file).** + `draft` copies each eligible upstream to a `.proposed` beside a `candidates.json` that pins the + upstream's hash at draft time: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh draft --spec [] [--draft] --state .sdlc/state.yaml + ``` + Then, for each drafted stem, spawn the mapped discipline agent with an explicit contract: + **"Edit ONLY this `.proposed` file to reflect what spec NNNN shipped; the real artifact stays + untouched. Return the edited `.proposed`."** + + | Drafted stem | Owning discipline agent | + |--------------|-------------------------| + | `requirements` | `requirements-analyst` | + | `epics` | `feature-architect` | + | `feature-brief` | `feature-architect` | + | `business-rules` | `bizreq-analyst` | + + (This is the same `DISCIPLINE_BY_STEM` map the script emits per candidate — keep this table in + sync with the script, which is the single source.) A re-`draft` overwrites an existing `.proposed` + and warns. + +4. **Apply — the named human confirms one stem at a time (preview → confirm).** `apply` previews the + diff until the human echoes the diffhash they saw: + + a. **Preview** (writes nothing to the artifact) — prints the diff of `.proposed` vs the real + upstream and a `diffhash`: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh apply --spec --state .sdlc/state.yaml + ``` + + b. **Confirm** — the human reviews the diff, then re-runs with `--actor --reviewed + ` (presence of `--reviewed` is the confirm signal). `--actor` must be a real person, + never a discipline agent: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py \ + refresh apply --spec --actor "" --reviewed \ + --reason "" --decision-ref DL-NN --state .sdlc/state.yaml + ``` + - **Staleness guard:** if the upstream moved since the draft (its current hash ≠ the pinned + `candidates.json` hash), apply refuses — *"upstream moved since draft; re-run detect/draft"* — + no write, exit 0. + - **Sign-off gate:** a signed-off / completed-phase upstream refuses without `--ack-signoff` + (exit 0, no write); `--repo` with no sign-off data stays conservative and still asks for it. + - On confirm it records a `refreshed` change (attributed to this spec) and captures a snapshot, + so the refresh is **rollback-able** via `/sdlc-version rollback`. That snapshot is a local, + gitignored safety net — on another machine it may read *"content not captured"* (honest, not a + bug); see *"Working across machines"* in `references/artifact-versioning.md`. + +5. **Reject — record that a listed upstream is NOT affected.** If the spec doesn't ripple to an + upstream, take it off the books honestly: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py \ + refresh reject --spec --reason "" --owner "" --state .sdlc/state.yaml + ``` + - `--reason` is **required** — a rejection without a reason still counts as debt (honest counting). + +6. **Open a linked decision-log item and re-gate** after an apply (a refresh *is* a change): + - Allocate the next `DL-NN` in `.sdlc/decision-log.md` (create from + `${CLAUDE_PLUGIN_ROOT}/templates/phases/01-requirements/decision-log.md` if missing), owner = + the actor, 2-business-day clock, id matching `--decision-ref`. + - Run `/sdlc-gate` for the refreshed artifact's phase and report PASS/FAIL. Re-gating is a real, + human-visible gate run — this command writes **nothing** to `state.yaml`. + +7. **Status & report.** Show the per-spec disposition of each upstream (honest counting; reads "no + data" when empty): + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh status --spec --state .sdlc/state.yaml + ``` + ``` + Refresh : upstreams — refreshed, not-affected, open + Applied: in (actor: , DL-NN) → rollback via /sdlc-version + Re-gate: PASS | FAIL () + ``` + +## Arguments + +- `detect|scan|draft|apply|reject|status` — the verb. +- `--spec `: the merged spec (required for every verb except `scan`; optional on `status` + where it defaults to a rollup across all merged specs). +- ``: `requirements | epics | feature-brief | business-rules` — which upstream (positional on + `draft`/`apply`/`reject`). +- detect/draft: `--draft` (draft without a drift signal), `--transitive`, `--include-coarse`. +- apply: `--actor --reviewed ` to confirm; `--ack-signoff`; `--reason`; + `--decision-ref DL-NN`. +- reject: `--reason` (required), `--owner`, `--actor`. +- `--repo `: standalone mode. `--json` on detect/scan/draft/status for machine output. + +## Important + +- The user runs `/sdlc-refresh` — never `audit_artifacts.py` by hand. The command owns mode + resolution, agent routing, the preview→confirm handshake, and the decision-log + re-gate + follow-through. +- **Agent drafts, human decides.** The discipline agent edits only the `.proposed`; a named human + echoes the diffhash and owns the decision-log item. Same One Rule as `/sdlc-revise` and + `/sdlc-spec`'s risk tier. +- **Review-first, never a rubber stamp.** Detection lists trace-only candidates and drafts only on a + drift signal or explicit `--draft`. A faithful spec produces a review, not an edit. +- **Additive and advisory.** `refresh` writes only to the change-ledger, the object store, and the + transient `.sdlc/refresh/` drafts. It never touches `state.yaml`, and `audit_artifacts.py` exits 0 + on every path — including every refuse/abort. See `references/artifact-versioning.md`. +- When a refresh lands, the artifact now matches what shipped — and the next `/sdlc-status` scan will + flag any frozen layer downstream of it, closing the loop forward again. diff --git a/commands/sdlc-retro.md b/commands/sdlc-retro.md new file mode 100644 index 0000000..9d1dcb3 --- /dev/null +++ b/commands/sdlc-retro.md @@ -0,0 +1,93 @@ +# /sdlc-retro — Cross-Ledger Retro Roll-up + +Read across the three advisory ledgers at once and surface **what keeps happening** — the retro +question the per-round commands can't answer on their own. It reports four patterns: + +1. **Recurring findings** — a `(category, target)` group seen in **>= 2 distinct review rounds** is a + candidate for a permanent check (this is how "findings become new checks"). +2. **Repeat-stale artifacts** — per downstream artifact, how many times it was dispositioned for + staleness, and whether it is stale **right now**. +3. **Refresh funnel** — per merged spec and by upstream stem: candidates → drifted → refreshed → + rejected → still open. This doubles as the **tuning signal for the divergence heuristic**. +4. **Disposition debt rollup** — combined honest-counting debt across all three ledgers, each line + naming its source. + +This is a **sibling** to `/sdlc-audit-artifacts` (freshness *now*) and `/sdlc-audit` (gate +effectiveness). This command reads the accumulated *history* and reports the recurring shape of it. +It reads three ledgers, computes nothing about people, and never blocks. + +## Instructions + +1. **Resolve mode and repo root:** + - **Workflow mode** (default): look for `.sdlc/state.yaml`. Pass `--state .sdlc/state.yaml`. If + not found, tell the user to run `/sdlc-setup` first. + - **Standalone mode** (`--repo `, or no `.sdlc/` found): run against any repo with a + `.sdlc/` directory. The roll-up is only as deep as the ledgers in that repo — with a thin or + missing ledger, each section degrades to **"no data"** (never a fabricated zero), and the + missing context is noted by that "no data" line rather than guessed at. + +2. **Run the roll-up** (read-only — writes nothing, ever): + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/retro_report.py --state .sdlc/state.yaml + ``` + In standalone mode point it at any repo instead: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/retro_report.py --repo /path/to/repo + ``` + +3. **Scope by window** (optional). Limit the time-stamped ledger events (finding rounds, refresh / + reject events, staleness dispositions) to the last N days. Current-state facts — whether an + artifact is stale *now*, and the debt rollup — always reflect the present regardless of the window: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/retro_report.py --state .sdlc/state.yaml --window-days 30 + ``` + +4. **Machine-readable output** for dashboards or a client artifact: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/retro_report.py --state .sdlc/state.yaml --json + ``` + The JSON has a stable top-level shape: `has_data` (a bool per section), `recurring_findings`, + `repeat_stale`, `refresh_funnel` (`by_spec` + `by_stem`), `debt`, and `window_days`. + +5. **Act on the patterns** (a human's call — this command only surfaces them): + - A **recurring finding** across rounds is the promotion signal: consider turning it into a + permanent check (a spec DoR rule, a gate, a lint) so it stops recurring. + - A **repeat-stale artifact** flagged again and again is a lineage or ownership smell — the + upstream keeps moving under it. Disposition it in `/sdlc-audit-artifacts`, or fix the source. + - The **refresh funnel** tunes the divergence heuristic (see below). + +## How to read the refresh funnel + +The funnel is the **tuning signal for the divergence heuristic** in the refresh layer — the +conservative, deliberately-imperfect drift detector. Read each upstream **stem** line: + +- **High rejected / low applied** ("4 rejected / 0 applied") — the heuristic is flagging drift that + humans keep judging *not affected*. The signal is **noisy** for that stem; the heuristic is too + eager there. +- **Candidates that never drift** ("0 of N candidates ever drifted") — the heuristic almost never + fires. The signal may be **too tight (quiet)**; real drift could be slipping through unflagged. +- A healthy stem lands somewhere between: some drift detected, some applied, few rejected. + +It is a signal to *tune the heuristic*, not a verdict on anyone's work. + +## Arguments + +- No arguments: full roll-up over all history (workflow mode, `--repo .`). +- `--state `: workflow mode — the `.sdlc/` beside the state file. +- `--repo `: standalone mode — any repo with a `.sdlc/` present. +- `--window-days `: only count time-stamped ledger events from the last N days. +- `--json`: emit the stable JSON shape with per-section `has_data` flags. + +## Important + +- The user runs `/sdlc-retro` — never `retro_report.py` by hand. The command owns mode resolution and + the windowing. +- **Read-only. Writes nothing** — no ledger appends, no `state.yaml`, no artifacts, no files. It is a + pure read across `findings-log.jsonl` and `artifact-log.jsonl`. Re-gating and dispositioning stay + where they live (`/sdlc-gate`, `/sdlc-revise`, `/sdlc-audit-artifacts`). +- **Patterns, not people.** Every pattern is keyed by category, artifact, or upstream stem — **never + by actor**. There is deliberately **no flag to rank by person**, in the same spirit as the steering + scorecard's forbidden metrics: this command never reports velocity, story points, PR count, or + lines of code, and never attributes a pattern to an individual. +- **Advisory by construction** — `retro_report.py` exits 0 on every path (no stack traces). A + recurring pattern is a prompt for a human's judgement, never a gate. diff --git a/commands/sdlc-revise.md b/commands/sdlc-revise.md index 8d5d6d0..f3a7944 100644 --- a/commands/sdlc-revise.md +++ b/commands/sdlc-revise.md @@ -35,7 +35,17 @@ decides** it. No agent silently rewrites an artifact. Show the human what depends on this target (declared vs. coarse) so they revise with eyes open. 4. **Interview to change it — route to the owning discipline agent.** The agent proposes concrete - wording; the human confirms or edits. Then apply the confirmed edit to the artifact file. + wording; the human confirms or edits. + + **Before applying the confirmed edit, snapshot the pre-image (advisory, exit 0)** so the change is + reversible — this captures the artifact's *current* content into the local version store lockstep + with the ledger: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py record --scan --state .sdlc/state.yaml + ``` + Then apply the confirmed edit to the artifact file. (Step 5a's `record --event revised` then + captures the post-image, so `/sdlc-version diff`/`rollback` sees both sides of the change.) Content + capture is best-effort; if the store can't be written the ledger append is byte-identical. | Target | Owning discipline agent | |--------|-------------------------| @@ -119,3 +129,7 @@ decides** it. No agent silently rewrites an artifact. account for the change. Disposition it; the tool never blocks on it. - When behavior changes, the artifact changes here — and if a spec or code already realizes it, that change belongs in the **same PR** as the code. A stale artifact lies to the next agent and human. +- **Undo path.** A revise that went wrong is reversible: `/sdlc-version diff prev latest` + shows what changed and `/sdlc-version rollback prev` restores it (preview → named-human + confirm), provided the pre-image was captured in step 4. The reverse direction — pulling a built + spec's shipped reality *up* into these artifacts — is `/sdlc-refresh`. diff --git a/commands/sdlc-status.md b/commands/sdlc-status.md index ad00b76..f00d70a 100644 --- a/commands/sdlc-status.md +++ b/commands/sdlc-status.md @@ -48,6 +48,18 @@ Display the current SDLC progress for this project. yet) or the script is absent, skip silently — no baseline has been recorded. Advisory only (exit 0) — never blocks. See `/sdlc-audit-artifacts` for the full dashboard. + - **Merged-spec drift nudge** — surface pre-Build artifacts a merged spec implies an edit to + (reverse propagation), so the drift is visible without anyone remembering to look: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py refresh scan --state .sdlc/state.yaml --json + ``` + If `drifted_total` is 0 (every merged spec is faithful) or there are no merged specs, skip + silently. Otherwise, for each `results[]` entry with `drifted > 0`, render one nudge line — + `↩ {drifted} upstream artifact(s) may have drifted from merged spec {spec} — /sdlc-refresh detect --spec {spec_node}`. + Entries whose upstreams are all trace-only (`drifted == 0`) produce no line. If the script is + absent, skip silently. Advisory only (exit 0) — never blocks. See `/sdlc-refresh` for the + draft+confirm path. + 6. **Suggest next action:** Based on current phase status: - If phase is `active`: suggest running `/sdlc` for phase guidance - If all gates would pass: suggest running `/sdlc-next` to advance diff --git a/commands/sdlc-version.md b/commands/sdlc-version.md new file mode 100644 index 0000000..ae59f18 --- /dev/null +++ b/commands/sdlc-version.md @@ -0,0 +1,120 @@ +# /sdlc-version — Content History of One Artifact (diff & roll back) + +See what a pre-Build artifact **used to say**, diff any two of its versions, and — when a change went +wrong — roll it back. This is the content complement to `/sdlc-audit-artifacts`: that command tracks +*that* an artifact changed (the metadata trail); this one recovers *what it said* at each version and +restores it if needed. + +``` +/sdlc-version list requirements.md +/sdlc-version diff FR-012 prev latest +/sdlc-version rollback requirements.md prev +``` + +Versions are derived from the change-ledger's SHA-256 hashes — the object store is just those hashes +rehydrated to bytes, so there is no second index to drift. Snapshots are a **local safety net** +(`.sdlc/versions/objects/` is gitignored by default); a fresh clone or CI degrades to *"content not +captured"* at exit 0, never a crash. + +## Instructions + +1. **Resolve mode and repo root:** + - **Workflow mode** (default): look for `.sdlc/state.yaml`; pass `--state .sdlc/state.yaml`. If not + found, tell the user to run `/sdlc-setup` first. + - **Standalone mode** (`--repo `, or no `.sdlc/`): operate on the given repo. History is only + as deep as that repo's ledger and object store. + +2. **Pick the verb** from the user's request. Every one accepts an **id** (`FR-012`) or an + **artifact path** and joins on the repo-relative path, so `FR-012` and its file resolve to the + same history. + + - **`list `** — the version list `v1..vN`: ordinal, event, when, who. A version whose + bytes were never captured is marked `[content not captured]` (honest — not a lie that it's gone): + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py version list --state .sdlc/state.yaml + ``` + + - **`show [ref]`** — print one version's content. `ref` is `vN | latest | prev | + ` (default `latest`). Missing blob → *"content not captured for this version"*: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py version show v2 --state .sdlc/state.yaml + ``` + + - **`diff [a] [b]`** — unified diff between two versions (default `prev` → `latest`): + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py version diff prev latest --state .sdlc/state.yaml + ``` + +3. **Roll back — preview first, then a named human confirms** (the One Rule; identical handshake to + `/sdlc-revise` and `refresh apply`): + + a. **Preview** (default — writes nothing to the artifact). It prints the diff you'd apply and a + `diffhash` to echo back: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py version rollback prev --state .sdlc/state.yaml + ``` + - Rolling back to an **uncaptured** version refuses here (*"content not captured — cannot + restore"*) — it never restores from a missing object. + + b. **Confirm** — the human reviews the diff, then re-runs with `--confirm --actor + --reviewed `. Echoing the exact `diffhash` from the preview is how they attest they + saw *this* change; `--actor` must be a real person, never a discipline agent: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py \ + version rollback prev --confirm --actor "" --reviewed \ + --decision-ref DL-NN --state .sdlc/state.yaml + ``` + - **Sign-off gate:** if the artifact is signed off or in a completed phase, the rollback refuses + unless the human adds `--ack-signoff` (exit 0, no write). In `--repo` mode with no sign-off + data it stays conservative and still asks for the flag. + - The revert is recorded as an **append-only** `revised` version — the rollback is itself + undoable, and a restored earlier hash renders "restored from vX". Nothing is ever destroyed. + +4. **Open a linked decision-log item and re-gate** (a confirmed rollback *is* a change): + - Read `.sdlc/decision-log.md`, allocate the next `DL-NN`, append one row (id matching the + `--decision-ref` you passed), owner = the actor, with the 2-business-day clock — exactly as + `/sdlc-revise` does. If the file is missing, create it from + `${CLAUDE_PLUGIN_ROOT}/templates/phases/01-requirements/decision-log.md`. + - Run `/sdlc-gate` for the artifact's phase and report PASS/FAIL. Re-gating stays a real, + human-visible gate run — this command writes **nothing** to `state.yaml`. + +5. **Maintenance — `gc` (optional, preview by default).** Prune old snapshots, keeping the newest N + per artifact. An object is evicted only if **no** retained version across **all** artifacts + references its hash, it is not any artifact's latest, and it is not sign-off-protected + (unknown/unparseable sign-off ⇒ protected). Preview first; `--apply` to delete: + ```bash + uv run --project ${CLAUDE_PLUGIN_ROOT}/scripts ${CLAUDE_PLUGIN_ROOT}/scripts/audit_artifacts.py version gc --keep 10 --state .sdlc/state.yaml + ``` + +## Arguments + +- `list|show|diff|rollback|gc` — the verb. +- ``: an id (`FR-012`) or an artifact path — the two resolve to one history. +- `[ref]` / `[a] [b]`: `vN | latest | prev | `; ordinal is the primary key, a hash prefix + is a disambiguated hint (ambiguous prefix → highest ordinal + lists alternatives). +- `--repo `: standalone mode (no `.sdlc/` present). +- rollback: `--confirm --actor --reviewed ` to apply; `--ack-signoff` to change a + signed-off artifact; `--decision-ref DL-NN` to link the decision. +- gc: `--keep N`, `--apply`. +- `--json` on `list` for machine output. + +## Important + +- The user runs `/sdlc-version` — never `audit_artifacts.py` by hand. The command owns mode + resolution, the preview→confirm handshake, and the decision-log + re-gate follow-through. +- **Additive and advisory.** `version` writes only to the object store (`.sdlc/versions/`) and, on a + confirmed rollback, appends one `revised` entry to the change-ledger. It never touches + `state.yaml`'s gate results or sign-off records, and `audit_artifacts.py` exits 0 on every path. +- **Versions are scan-time snapshots, not keystroke history.** Content is captured only at the + layer's capture points (`/sdlc-revise`, `/sdlc-next`'s and `/sdlc-audit-artifacts`'s `record --scan`, + `refresh apply`, `rollback`) — there are no write-path hooks, so several edits between captures + collapse into one version. See *"What a version is (and isn't)"* in + `references/artifact-versioning.md`. +- **The store is a local safety net.** `.sdlc/versions/objects/` is gitignored by default, so a + version's bytes live only on the machine that captured them; a version present in one clone reads + *"content not captured"* in another (fresh clone/CI, after `gc`, or a store fault) — honest, not a + bug. Start capturing here with `record --scan`, or flip the documented one-line `.gitignore` + override to make snapshots portable. See *"Working across machines"* in + `references/artifact-versioning.md`. +- **Rollback is reversible.** Every confirmed rollback appends a new version rather than overwriting + history, so you can always roll forward again. diff --git a/docs/commands.md b/docs/commands.md index d7aaf9b..908233f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -717,7 +717,7 @@ The Build loop's Intent beat — before building any change. A spec that `check_ ## Additional Commands (summaries) -Twelve commands have their full flow documented in their command files rather than here. One +Fifteen commands have their full flow documented in their command files rather than here. One line each; see `commands/.md` for the complete instructions. | Command | What it does | @@ -734,6 +734,9 @@ line each; see `commands/.md` for the complete instructions. | `/sdlc-evals` | Author the versioned golden set for an LLM-powered spec | | `/sdlc-revise` | Change one specific artifact (id or section) — discipline agent proposes, human decides; records the why to the change-ledger + a linked `DL-NN`, re-gates, shows downstream staleness to disposition | | `/sdlc-audit-artifacts` | Read-only sibling to `/sdlc-audit`: artifact freshness dashboard, forward `--impact`, and `--history` change trail (advisory; never blocks) | +| `/sdlc-version` | Content history for any pre-Build artifact — list/show/diff versions derived from the change-ledger's hashes; rollback is preview → named-human confirm, append-only ("restored from vX"), `--ack-signoff` for signed-off artifacts; `gc` prunes the local store safely | +| `/sdlc-refresh` | Reverse propagation — back-propagate a merged spec's shipped reality into pre-Build artifacts: detect (review-first, divergence-aware) → draft a `.proposed` → named-human apply/reject → status. The One Rule throughout: agent proposes, human decides | +| `/sdlc-retro` | Read-only cross-ledger retro roll-up: recurring findings (permanent-check candidates), repeat-stale artifacts, the refresh funnel (divergence-heuristic tuning signal), and a disposition-debt rollup. Patterns, not people; never blocks | --- diff --git a/references/artifact-versioning.md b/references/artifact-versioning.md new file mode 100644 index 0000000..20e7cae --- /dev/null +++ b/references/artifact-versioning.md @@ -0,0 +1,193 @@ +# Artifact Versioning & Auto-Refresh — content history and back-propagation + +The [artifact-lifecycle layer](artifact-lifecycle.md) records change **metadata** (that an artifact +moved, when, by whom, why) and detects **forward** staleness. This layer closes the two gaps that +left behind: + +1. **No content history / no safety net.** You could see *that* `FR-012` changed, never *what it said + before* — you could not diff or roll back, and there was no reversibility to make an automated edit + safe. +2. **Traceability only flowed forward.** Requirement → spec was declared; nothing flowed a built + spec's **shipped reality back up** into `requirements.md` / `epics.md` / `feature-brief.md` / + `business-rules.md`, so those pre-Build artifacts silently drifted from what shipped. + +Two additions close them as **one package**: content-snapshot **versioning** (diff/rollback) and +draft+confirm **auto-refresh** (reverse propagation). Versioning is built first because it is the +**safety net** that makes an automated refresh reversible. + +The commands are `/sdlc-version` (content history: list/show/diff/rollback) and `/sdlc-refresh` +(detect → draft → apply/reject → status). The engine folds into `scripts/audit_artifacts.py`; the +derivation math lives in one **pure** module, `scripts/version_model.py`. Everything here is +**advisory — `audit_artifacts.py` exits 0 on every path.** + +--- + +## The store is the ledger rehydrated to bytes + +The trust anchor of versioning is that it introduces **no second index**. Every version's identity is +the SHA-256 hash the change-ledger already carries (`track_artifacts.compute_checksum` output: +`sha256:` + 16 hex). The content-addressed object store is just those hashes rehydrated to bytes: + +``` +.sdlc/versions/objects//<16hex> content blobs, sharded on the first 2 hex, global dedup +``` + +`version_model.object_relpath(hash)` computes that path; the filename **is** the ledger's 16-hex, so +the join between "what the ledger says changed" and "the bytes of that version" is the hash itself — +there is nothing to drift. `versions_for(ledger, artifact)` walks +`artifact_model.changes_for(ledger, artifact)` **in ledger order** and counts *occurrences*, so a +version's ordinal `vN` is the primary key. A rollback that re-introduces an earlier hash is its own +ordinal (never a skipped one) and is rendered **"restored from vX"** — the ordinal the content first +appeared as. + +`resolve_version(ref)` accepts `vN | latest | prev | `; the ordinal is the key and a hash +prefix is only a disambiguated hint (an ambiguous prefix resolves to the **highest** matching ordinal +and lists the alternatives — never a silent guess). + +## What a version is (and isn't) + +A version is a **scan-time snapshot**, not keystroke history. There are **no write-path hooks**: the +content store only gains a blob at the layer's capture points, so a version is captured when — + +- `/sdlc-revise` records a change (it captures the pre- **and** post-edit image); +- `/sdlc-next` runs its advance-time `record --scan`; +- `/sdlc-audit-artifacts` runs its step-2 `record --scan`; +- `refresh apply` lands a `refreshed` change (pre-image captured, post-image materialized); +- `version rollback` lands a `revised` change (same canonical mutate order). + +Between two capture points the file is invisible to the layer: **five edits collapse into the one +version** the next capture records. That is by design (the ledger, not a file-watcher, is the source +of truth) — but it means the history is as coarse as your scans, never a per-save timeline. Scan +often (or revise through `/sdlc-revise`) if you want finer granularity. + +## Reverse propagation, divergence-aware + +Detection surfaces the pre-Build upstreams a `status: merged` spec traces to — from its `source:` +frontmatter **and** any in-vocabulary id (`FR|EP|US|BR|SCEN|FE|NFR|ADR`) in its body — via +`artifact_lineage.upstream_of`. The default is **review-first**: a faithful spec that correctly +realizes its upstream lists that upstream as *trace-only (no drift detected)* and drafts **nothing**. +A `.proposed` is only auto-drafted when a drift signal is present (the spec's acceptance/scope text +references a delta absent upstream) **or** the human passes `--draft`. This keeps the layer from +becoming a rubber-stamp mill while still making the candidates *surface automatically* the moment a +spec merges — zero human memory required. + +The confirm path is `/sdlc-revise` inverted and enforces the same **One Rule**: the mapped discipline +agent edits **only** a `.proposed` copy (the real artifact stays untouched); a **named human** echoes +the diffhash they reviewed and owns the applied change. `DISCIPLINE_BY_STEM` is the single in-code +source for the stem→agent routing (`requirements→requirements-analyst`, +`epics`/`feature-brief`→`feature-architect`, `business-rules→bizreq-analyst`), emitted per candidate +so the prose routing table in the command docs can't silently drift. + +## The canonical mutate order (stated once, obeyed by rollback and refresh apply) + +The post-image content is **already known** before the real file is touched (it is the target object +for a rollback, or the reviewed `.proposed` for an apply), so both write paths obey one order: + +1. **Capture the pre-image** (current bytes → object). Reversibility is a *precondition* (**R6**): if + capture fails, surface *"capture failed — this change is not reversible"*, **refuse, exit 0, no + write.** +2. **Materialize the post-image object** from the known new content (idempotent write-if-absent). +3. **Append the ledger change** (`revised` for rollback, `refreshed` for apply; hash = post-image). +4. **`os.replace` the real file LAST** (temp-in-same-dir → atomic rename). + +**Recovery** keys off *"ledger ahead of disk"*: on the next run, if an artifact's latest ledger hash +≠ its on-disk hash and the post-image object exists, the `os.replace` is redone (idempotent). Because +every step's object is present before the ledger references it, recovery can always complete. + +## Requirements & resolutions (R1–R6) + +| # | Requirement | Resolution | +|---|-------------|------------| +| **R1** | Refresh targets only pre-Build phase artifacts. | `PRE_BUILD_STEMS = {requirements, epics, feature-brief, business-rules}`. Frozen layers are structurally *downstream* and never returned by `upstream_of(spec)`; refreshing the phase artifact lets the existing forward-staleness engine flag the dependent layer on the next `/sdlc-status`. | +| **R2** | A phantom or missing upstream never fabricates a candidate. | `source: —`, or a non-vocabulary / nonexistent id (`REQ-042`, `FR-999`), yields **zero** candidates + the nudge *"no traceable upstream — add an FR/EP/US/BR id to `source:`"*. Never a guess, never a crash. | +| **R3** | The lineage→candidate adapter can't `IndexError` on an edge with no declared basis. | `basis = bases[0] if bases else "coarse-phase-order"`; `upstream_hash` from `latest_change_per_artifact(ledger)`, falling back to `compute_checksum(current file)` when absent. | +| **R4** | A rejection must not render as forward staleness. | `reject` records `NOT_AFFECTED` as a **reverse** edge (downstream = the upstream artifact, upstream = the spec) — absent from the forward lineage graph, so `compute_staleness` never renders it. `/sdlc-audit-artifacts` forward counts are unaffected. | +| **R5** | Changing a signed-off / completed-phase artifact needs explicit acknowledgement. | A shared `--ack-signoff` gate on `rollback --confirm` and `refresh apply`; without it, WARN and refuse (exit 0, no write). `--repo` with no sign-off data stays conservative and still asks for the flag. | +| **R6** | An automated mutate must be reversible by construction. | Pre-image capture is step 1 of the canonical order and a hard precondition; `track_artifacts.compute_checksum` is the single hashing path shared by ledger identity and object filename, so content↔hash stays lockstep at one `capture()` call-site. | + +Two supporting blockers: **B2** — an artifact with no ledger entry (a pre-existing Phase-1 file) +yields a single **`v1` baseline** synthesized from the current file's hash (`present=False`), never +`[]` and never a `KeyError`. **B3** — the `version`/`refresh` subcommands write **nothing** to +`state.yaml`; re-gating stays the command layer's job (`/sdlc-version` and `/sdlc-refresh` instruct a +real `/sdlc-gate` run afterward, exactly as `/sdlc-revise` does), so "no `gate_results` phantom rows" +and "this layer writes no `state.yaml`" are both literally true. The apply/rollback impact preview is +a read-only `compute_staleness` render. + +## `gc` — cross-ledger refcounted, sign-off-protected + +`version gc --keep N` (preview by default; `--apply` to delete) prunes old snapshots. Because the +store is globally deduplicated, an object is evicted **only** if: no retained version across **all** +artifacts references its hash, it is not the latest for any artifact, and it is not +sign-off-protected. There is no direct sign-off→hash map, so for a signed-off artifact the substitute +protects `latest_change_per_artifact` plus every ledger-referenced hash; an **unknown or unparseable +sign-off ⇒ the object is PROTECTED** (a hard invariant — gc never deletes what it can't prove is +safe). Pruned versions render *"content not captured"*; the ledger is unchanged. + +## Working across machines (`.gitignore` policy — a local safety net) + +The committed source of truth is the metadata ledger (`.sdlc/metrics/artifact-log.jsonl`). The object +store and the transient refresh drafts are **local, non-authoritative** and gitignored: + +``` +.sdlc/refresh/**/*.proposed # auto-refresh drafts (real artifact untouched) +.sdlc/refresh/**/candidates.json # working candidate list (pinned upstream_hash) +.sdlc/refresh/_rollback/ # rollback previews awaiting confirm +.sdlc/versions/objects/ # content blobs — LOCKED default: gitignored +``` + +Because the blobs are gitignored, **a version's bytes live only on the machine that captured them.** +On a second machine (a fresh clone, CI, after a `gc` prune, or after a store fault) that version's +content is simply absent, and `list` / `show` / `diff` / `rollback` all read *"content not +captured"* at exit 0 — never a crash, and **not a bug**. The metadata (that the version exists, its +hash, when, by whom) is intact from the ledger; only the recoverable *bytes* are missing. The CLI +messages say this and point back here. + +Two honest ways forward: + +- **Start capturing on this machine.** Run the `record --scan` step (via `/sdlc-audit-artifacts` or + `/sdlc-next`) here; from that scan on, this machine has the bytes of whatever it snapshots. It + cannot recover a version captured only elsewhere. +- **Make the store portable.** Flip the one-line documented override in `.gitignore` — comment out + the `.sdlc/versions/objects/` line and commit the store — so snapshots travel with the repo. History + stays lean by default; this trades repo size for team-portable diff/rollback. + +## How it stays additive + +- **Advisory, never blocks.** Exit 0 on every path, including every refuse/abort (missing + `--ack-signoff`, a moved `upstream_hash`, an agent-name `--actor`, rollback-to-uncaptured, + capture-failed, R2 zero-candidate, empty / no-specs / first-ever-run). +- **Protected core byte-for-byte unchanged.** `check_spec.py`, `check_gates.py`, `section-evaluator`, + `harness/**`, `phase_model.py`, `phase-registry.yaml`, `advance_phase.py`, `/sdlc-coach`, + `/sdlc-spec`, `plugin.json` — and `artifact_model.py`: the `refreshed` entry reuses + `change_entry(...)` unchanged, and the `source_spec` attribution is a **rider key** the caller adds + to the dict, not a change to the model. +- **The ledger stays its own JSONL.** Nothing is written into `gate_results`; `/sdlc-audit` is + byte-identical with or without a version store or refresh run present. `audit_artifacts.py`'s + existing `record` / `impact` / `report` output is byte-identical too — `capture()` rides the ledger + append best-effort and prints nothing. + +## Deletability caveat (the revert boundary) + +Folding the verbs into `audit_artifacts.py` (rather than a standalone script) buys a single +`capture()` call-site that enforces content↔hash lockstep by construction — at the cost of +deletability. Reverting this feature is a **scoped code-revert** of the `version` and `refresh` +subcommand groups plus the `capture()` / `record_change()` wiring (and deleting `version_model.py`, +the two command docs, and this reference), **not** a single file delete. The forward-audit layer +underneath is untouched by such a revert. + +## Deferred / accepted for v1 + +- **`NOT_AFFECTED` rejections are sticky** — a spec that re-merges after a rejection produces no new + detection signal (reopen-on-spec-change needs a pinned spec-content hash; deferred). +- **The divergence heuristic needs tuning** — the review-only default + `--draft` bound the blast + radius; a too-tight signal means the feature proposes little until tuned (visible, not silent). +- **`objects/` is a local-only safety net** (gitignored) — a fresh clone/CI degrades to "content not + captured"; flip the documented override for portability. +- **R1's payoff is latent** — the dependent frozen layer is flagged on the *next* `/sdlc-status`; the + apply impact preview mitigates but doesn't fully resolve. +- **`gc` is a maintenance subcommand** (preview default, no dedicated slash command) — unbounded local + growth until `gc --apply` is run. + +*If this layer were reverted, the plugin would gate, freeze, revise, and audit exactly as it does +today. Versioning and auto-refresh only ever add reversibility and surface drift a human then +decides.* diff --git a/scripts/audit_artifacts.py b/scripts/audit_artifacts.py index b70003a..057d448 100644 --- a/scripts/audit_artifacts.py +++ b/scripts/audit_artifacts.py @@ -26,7 +26,11 @@ """ import argparse +import difflib +import hashlib import json +import os +import re import sys from datetime import datetime, timezone from pathlib import Path @@ -37,9 +41,34 @@ import artifact_lineage as al import artifact_model as am import phase_model as pm +import track_specs as ts +import version_model as vm from track_artifacts import compute_checksum LEDGER_NAME = "artifact-log.jsonl" +VERSIONS_DIR_NAME = "versions" +REFRESH_DIR_NAME = "refresh" + +# Pre-Build authored artifacts the refresh layer may back-propagate into (R1). Frozen layers are +# structurally downstream and are never returned by upstream_of(spec), so they are deliberately +# absent — refreshing the phase artifact lets the forward-staleness engine flag the layer next run. +PRE_BUILD_STEMS = ("requirements", "epics", "feature-brief", "business-rules") + +# The single source for the stem -> discipline-agent routing (mirrors /sdlc-revise's prose table so +# it cannot silently drift). Emitted per refresh candidate; also the actor-name denylist below. +DISCIPLINE_BY_STEM = { + "requirements": "requirements-analyst", + "epics": "feature-architect", + "feature-brief": "feature-architect", + "business-rules": "bizreq-analyst", +} + +# An --actor may not be an agent: the One Rule is that a *named human* decides. Covers the full +# council so a confirm can't be signed by any discipline agent, not just the routed one. +DISCIPLINE_AGENTS = frozenset({ + "requirements-analyst", "feature-architect", "bizreq-analyst", + "visual-designer", "conversation-designer", "data-analyst", +}) # --- Path / state plumbing -------------------------------------------------------------------- @@ -99,6 +128,108 @@ def load_state(sdlc_dir: Path) -> dict: return {} +# --- Content-addressed version store ---------------------------------------------------------- +# The object store is the ledger's hashes rehydrated to bytes: a blob's filename IS the 16-hex the +# ledger already records (via track_artifacts.compute_checksum), so there is no second index that can +# drift from freshness. It is a LOCAL safety net (gitignored) — every read degrades to "content not +# captured" and every write is best-effort, so a store fault never changes an advisory command's +# exit code or stdout. All content↔hash lockstep is enforced at the single `record_change` seam. + +def versions_dir_of(sdlc_dir: Path) -> Path: + return sdlc_dir / VERSIONS_DIR_NAME + + +def refresh_dir_of(sdlc_dir: Path) -> Path: + return sdlc_dir / REFRESH_DIR_NAME + + +def hash_bytes(data: bytes) -> str: + """The ledger-form hash of in-memory bytes — identical to compute_checksum of a file holding the + same bytes (the hash-join invariant): `sha256:` + first 16 hex of the SHA-256 digest.""" + return f"sha256:{hashlib.sha256(data).hexdigest()[:16]}" + + +def capture_bytes(versions_dir: Path, data: bytes) -> str | None: + """Best-effort: store `data` at objects//<16hex>; return its hash, or None on any I/O error. + Idempotent write-if-absent (atomic temp→rename). Swallows errors so a capture failure can never + change a caller's exit code or stdout — the ledger stays the source of truth, the blob a bonus.""" + try: + h = hash_bytes(data) + rel = vm.object_relpath(h) + if not rel: + return None + dest = versions_dir / rel + if not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.parent / (dest.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, dest) + return h + except OSError: + return None + + +def capture_file(versions_dir: Path, path: Path) -> str | None: + """Best-effort capture of a file's current bytes into the store.""" + try: + return capture_bytes(versions_dir, path.read_bytes()) + except OSError: + return None + + +def read_blob(versions_dir: Path, h: str) -> bytes | None: + """The captured bytes for a hash, or None if the blob is absent/unreadable (degrade, never crash).""" + rel = vm.object_relpath(h) + if not rel: + return None + p = versions_dir / rel + try: + return p.read_bytes() if p.is_file() else None + except OSError: + return None + + +def present_hashes_in_store(versions_dir: Path) -> set[str]: + """Every ledger-form hash whose blob currently exists in the object store.""" + out: set[str] = set() + objects = versions_dir / "objects" + if not objects.exists(): + return out + try: + for shard in objects.iterdir(): + if shard.is_dir(): + for blob in shard.iterdir(): + if blob.is_file() and not blob.name.endswith(".tmp"): + out.add(f"sha256:{blob.name}") + except OSError: + return out + return out + + +def record_change(base_dir: Path, metrics_dir: Path, versions_dir: Path, + entries: list[dict], *, contents: dict[str, bytes] | None = None) -> Path: + """THE single lockstep seam: capture each change entry's content, then append every entry. + + For scan/revise the on-disk file already IS the post-image, so the current file is captured. For + the canonical mutate flows (rollback / refresh apply) the file is still the pre-image at append + time, so the caller passes the known post-image bytes in `contents` (the object is materialized + up front — capture here is then an idempotent no-op). Capture is best-effort; the ledger append + is unconditional, so stdout / exit / the ledger are byte-identical whether or not the store is + writable. Disposition entries carry no content and pass straight through.""" + contents = contents or {} + for e in entries: + if not am.is_change_entry(e): + continue + art = e.get("artifact") + if not art: + continue + if art in contents: + capture_bytes(versions_dir, contents[art]) + else: + capture_file(versions_dir, base_dir / art) + return append_entries(metrics_dir, entries) + + # --- Artifact scanning & identity ------------------------------------------------------------- def scan_hashes(base_dir: Path, sdlc_dir: Path) -> dict[str, str]: @@ -241,7 +372,7 @@ def do_scan(base_dir: Path, sdlc_dir: Path, metrics_dir: Path, actor: str) -> in if not new_entries: print("Artifact scan: no changes since the last ledger entry (nothing to record).") return 0 - append_entries(metrics_dir, new_entries) + record_change(base_dir, metrics_dir, versions_dir_of(sdlc_dir), new_entries) if first_run: print(f"Baseline recorded: {created} artifact(s) hashed — history starts now.") else: @@ -263,7 +394,7 @@ def do_change(args, base_dir: Path, sdlc_dir: Path, metrics_dir: Path) -> int: phase=args.phase or phase_id_of(node), hash=sha, prev_hash=prev.get("hash") if prev else None, actor=args.actor or "", reason=args.reason or "", decision_ref=args.decision_ref or "") - append_entries(metrics_dir, [entry]) + record_change(base_dir, metrics_dir, versions_dir_of(sdlc_dir), [entry]) tgt = f" ({args.target})" if args.target else "" print(f"Recorded {entry['event']} of {node}{tgt} by {args.actor or 'unknown'}" + (f" — ref {args.decision_ref}" if args.decision_ref else "")) @@ -506,6 +637,1148 @@ def _signoff_note(node: str, phases: dict) -> str: return "unsigned" +# --- Torn-write-safe content mutate (shared by rollback and refresh apply) -------------------- +# Canonical order: capture pre-image -> materialize post-image -> append ledger -> os.replace LAST. +# The post-image bytes are known before the real file is touched (a rollback target blob, or a +# refresh .proposed), so a torn write is always recoverable: if the ledger's latest hash for a node +# differs from disk and the post-image blob exists, redo the replace. A one-line journal makes that +# recovery fire ONLY for an interrupted mutate — never for ordinary, not-yet-scanned drift. + +def _pending_path(versions_dir: Path) -> Path: + return versions_dir / "pending.json" + + +def _write_pending(versions_dir: Path, node: str, want_hash: str) -> None: + try: + versions_dir.mkdir(parents=True, exist_ok=True) + _pending_path(versions_dir).write_text( + json.dumps({"node": node, "hash": want_hash}), encoding="utf-8") + except OSError: + pass + + +def _clear_pending(versions_dir: Path) -> None: + try: + _pending_path(versions_dir).unlink() + except OSError: + pass + + +def _atomic_write(target: Path, data: bytes) -> None: + """Write-temp-in-same-dir -> atomic rename (the final durable step of the mutate order).""" + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.parent / (target.name + ".sdlc-tmp") + tmp.write_bytes(data) + os.replace(tmp, target) + + +def recover_pending(base_dir: Path, versions_dir: Path) -> None: + """Torn-write recovery: complete an interrupted mutate. Fires only when the journal marks a + mutate as in-flight (written just before os.replace, cleared right after), so ordinary + not-yet-scanned drift is never clobbered. Best-effort and silent — a recovery fault never + changes a command's output or exit code.""" + p = _pending_path(versions_dir) + if not p.exists(): + return + try: + rec = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + _clear_pending(versions_dir) + return + node, want = rec.get("node"), rec.get("hash") + if node and want: + target = base_dir / node + try: + cur = compute_checksum(target) if target.is_file() else None + except OSError: + cur = None + if want != cur: + data = read_blob(versions_dir, want) + if data is not None: + try: + _atomic_write(target, data) + except OSError: + pass + _clear_pending(versions_dir) + + +def mutate_artifact(base_dir: Path, metrics_dir: Path, versions_dir: Path, node: str, + new_bytes: bytes, *, event: str, target_id: str = "", actor: str = "", + reason: str = "", decision_ref: str = "", source_spec: str = "") -> tuple[bool, str]: + """The canonical torn-write-safe mutate — shared verbatim by rollback and refresh apply. + + Returns (ok, message): + ok=False -> nothing written (a structural precondition failed); message says why. + ok=True -> message is the new post-image hash, or a 'ledger ahead of disk' note if the final + os.replace faulted (recover_pending reconciles it on the next run).""" + target = base_dir / node + # 1. Capture the pre-image. Reversibility is a precondition: no capture, no mutate (R6). + if target.is_file(): + if capture_file(versions_dir, target) is None: + return False, "capture failed — this change is not reversible; nothing written" + # 2. Materialize the post-image object from the known bytes (idempotent write-if-absent). + post_hash = capture_bytes(versions_dir, new_bytes) + if post_hash is None: + return False, "could not materialize the new content in the store; nothing written" + # 3. Append the ledger change. record_change captures the post-image from `contents` (the file on + # disk is still the PRE-image until step 4), so it must be passed explicitly here. + prev = am.latest_change_per_artifact(load_ledger(metrics_dir / LEDGER_NAME)).get(node) + entry = am.change_entry( + ts=now_iso(), artifact=node, event=event, target=target_id, phase=phase_id_of(node), + hash=post_hash, prev_hash=prev.get("hash") if prev else None, + actor=actor, reason=reason, decision_ref=decision_ref) + if source_spec: + entry["source_spec"] = source_spec # additive rider; artifact_model.change_entry is unchanged + record_change(base_dir, metrics_dir, versions_dir, [entry], contents={node: new_bytes}) + # 4. os.replace LAST, journalled so an interruption here reconciles on the next run. + try: + _write_pending(versions_dir, node, post_hash) + _atomic_write(target, new_bytes) + _clear_pending(versions_dir) + except OSError: + return True, (f"{post_hash} recorded, but writing {node} failed — the ledger is ahead of " + f"disk and will reconcile on the next run") + return True, post_hash + + +def _signoff_guard(node: str, sdlc_dir: Path, ack: bool) -> tuple[bool, str]: + """R5: changing a signed-off / completed-phase artifact needs explicit --ack-signoff. When the + sign-off status can't be read (e.g. --repo with no state.yaml), stay conservative and still + require the flag with a generic warning — never silently mutate a possibly-signed artifact.""" + state = load_state(sdlc_dir) + if not state: + if not ack: + return False, ("cannot read sign-off status (no state.yaml) — pass --ack-signoff to " + "confirm you accept changing a possibly signed-off artifact; nothing written") + return True, "" + phases = state.get("phases", {}) if isinstance(state, dict) else {} + note = _signoff_note(node, phases) + if note in ("signed-off ✓", "phase completed") and not ack: + return False, (f"{node} is {note} — pass --ack-signoff to confirm changing a signed-off " + f"artifact (the override is recorded); nothing written") + return True, "" + + +def _confirm_guards(node: str, sdlc_dir: Path, args, diffhash: str) -> tuple[bool, str]: + """Human-confirm hardening for a --confirm mutate: a named human actor (never a discipline + agent), an echoed --reviewed proving they saw *this* diff (no blanket escape), then + the R5 sign-off gate. Any failure -> refuse, nothing written (exit 0 at the caller).""" + actor = (getattr(args, "actor", "") or "").strip() + if not actor: + return False, "--confirm needs --actor (a named human owns the change); nothing written" + if actor.lower() in DISCIPLINE_AGENTS: + return False, (f"--actor {actor!r} is a discipline agent — an agent proposes, a named human " + f"decides (the One Rule); pass a human name. Nothing written") + reviewed = vm.hash_hex(getattr(args, "reviewed", "") or "").strip().lower() + if not reviewed: + return False, ("re-run without --confirm to preview the diff, then pass --reviewed " + "to confirm you saw it; nothing written") + if not vm.hash_hex(diffhash).lower().startswith(reviewed): + return False, (f"--reviewed does not match the current diff ({diffhash}) — the content changed " + f"since you previewed it; re-preview. Nothing written") + return _signoff_guard(node, sdlc_dir, getattr(args, "ack_signoff", False)) + + +def _unified(a: bytes, b: bytes, a_label: str, b_label: str) -> str: + a_lines = a.decode("utf-8", errors="replace").splitlines(keepends=True) + b_lines = b.decode("utf-8", errors="replace").splitlines(keepends=True) + return "".join(difflib.unified_diff(a_lines, b_lines, fromfile=a_label, tofile=b_label)) + + +def _rollback_preview_path(sdlc_dir: Path, node: str) -> Path: + return refresh_dir_of(sdlc_dir) / "_rollback" / (node.replace("/", "__") + ".preview") + + +def _write_rollback_preview(sdlc_dir: Path, node: str, text: str) -> None: + try: + p = _rollback_preview_path(sdlc_dir, node) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + except OSError: + pass + + +def _clear_rollback_preview(sdlc_dir: Path, node: str) -> None: + try: + _rollback_preview_path(sdlc_dir, node).unlink() + except OSError: + pass + + +# --- version: content history / diff / rollback / gc ------------------------------------------ + +def _resolve_artifact(base_dir: Path, sdlc_dir: Path, arg: str) -> str: + """Resolve an id (FR-012) or a path to a repo-relative node — the join key shared with the + ledger and lineage graph. Falls back to the normalized path so an as-yet-unknown artifact still + gets a stable key (never split by a basename-vs-relpath mismatch).""" + nodes = al.discover_nodes(base_dir, sdlc_dir) + return resolve_target_node(base_dir, sdlc_dir, arg, nodes) or _normalize_artifact_arg(base_dir, arg) + + +def _versions_of(base_dir: Path, metrics_dir: Path, versions_dir: Path, node: str) -> list[dict]: + """The derived version list for one node: ledger changes, current file (baseline synthesis), + and the set of hashes actually present in the store (so `present` is honest).""" + ledger = load_ledger(metrics_dir / LEDGER_NAME) + p = base_dir / node + try: + cur = compute_checksum(p) if p.is_file() else None + except OSError: + cur = None + return vm.versions_for(ledger, node, current_hash=cur, + present_hashes=present_hashes_in_store(versions_dir)) + + +def _not_captured_note() -> str: + """The shared 'why + what to do' appended to every missing-blob message — stated ONCE here, not + per call-site. Honest multi-machine story: the object store is a local, gitignored safety net, so + a version's bytes exist only on the machine that captured them. Two short advisory lines: the + honest cause, then the remedy (start capturing here, or flip the documented .gitignore override).""" + return ( + "The version store is local and gitignored, so a version's bytes live only on the machine " + "that captured them — a fresh clone/CI, a `gc` prune, or a store fault all read this.\n" + "Run the `record --scan` step (via /sdlc-audit-artifacts or /sdlc-next) on this machine to " + "capture from now on, or see references/artifact-versioning.md for the documented .gitignore " + "override that commits the store for team-portable history." + ) + + +def do_version_list(args, base_dir, sdlc_dir, metrics_dir, versions_dir) -> int: + node = _resolve_artifact(base_dir, sdlc_dir, args.artifact) + versions = _versions_of(base_dir, metrics_dir, versions_dir, node) + if getattr(args, "json", False): + print(json.dumps({"artifact": node, "versions": versions}, indent=2)) + return 0 + print(f"Version history — {node}") + print("=" * 50) + if not versions: + print(" (no versions — run `record --scan` to seed a baseline, or edit via /sdlc-revise)") + return 0 + for v in versions: + when = (v.get("ts") or "")[:10] or "----------" + who = v.get("actor") or "?" + rf = f" (restored from v{v['restored_from']})" if v.get("restored_from") else "" + tag = "" if v["present"] else " [content not captured]" + print(f" v{v['n']:<3} {v['event']:9} {v['hash']} {when} {who}{rf}{tag}") + print("=" * 50) + print("ADVISORY — content snapshots are a local, gitignored safety net (exit 0).") + uncaptured = sum(1 for v in versions if not v["present"]) + if uncaptured == len(versions): + # Fresh-clone / post-gc case: nothing captured on this machine. Explain once as a footer so + # the per-row [content not captured] tags don't read as a bug. + print(_not_captured_note()) + elif uncaptured: + # Mixed history: one line, not the full note — show/diff on that version give the remedy. + print(f"{uncaptured} version(s) not captured on this machine — " + "see references/artifact-versioning.md.") + return 0 + + +def do_version_show(args, base_dir, sdlc_dir, metrics_dir, versions_dir) -> int: + node = _resolve_artifact(base_dir, sdlc_dir, args.artifact) + versions = _versions_of(base_dir, metrics_dir, versions_dir, node) + row, note = vm.resolve_version(versions, args.ref) + if row is None: + print(f"show — {note} ({node})") + return 0 + data = read_blob(versions_dir, row["hash"]) if row["present"] else None + if data is None: + print(f"show — v{row['n']} content not captured for this version (hash {row['hash']}).") + print(_not_captured_note()) + return 0 + sys.stdout.write(data.decode("utf-8", errors="replace")) + return 0 + + +def do_version_diff(args, base_dir, sdlc_dir, metrics_dir, versions_dir) -> int: + node = _resolve_artifact(base_dir, sdlc_dir, args.artifact) + versions = _versions_of(base_dir, metrics_dir, versions_dir, node) + if not versions: + print(f"diff — no version history for {node} (no data).") + return 0 + if len(versions) < 2 and not (args.a and args.b): + print(f"diff — only one version of {node}; nothing to compare.") + return 0 + row_a, na = vm.resolve_version(versions, args.a or "prev") + if row_a is None: + print(f"diff — {na} ({node})") + return 0 + row_b, nb = vm.resolve_version(versions, args.b or "latest") + if row_b is None: + print(f"diff — {nb} ({node})") + return 0 + da = read_blob(versions_dir, row_a["hash"]) if row_a["present"] else None + db = read_blob(versions_dir, row_b["hash"]) if row_b["present"] else None + if da is None: + print(f"diff — v{row_a['n']} content not captured for this version (hash {row_a['hash']}).") + print(_not_captured_note()) + return 0 + if db is None: + print(f"diff — v{row_b['n']} content not captured for this version (hash {row_b['hash']}).") + print(_not_captured_note()) + return 0 + text = _unified(da, db, f"{node}@v{row_a['n']}", f"{node}@v{row_b['n']}") + if not text.strip(): + print(f"diff — v{row_a['n']} and v{row_b['n']} are identical.") + return 0 + sys.stdout.write(text if text.endswith("\n") else text + "\n") + return 0 + + +def do_version_rollback(args, base_dir, sdlc_dir, metrics_dir, versions_dir) -> int: + node = _resolve_artifact(base_dir, sdlc_dir, args.artifact) + versions = _versions_of(base_dir, metrics_dir, versions_dir, node) + if not versions: + print(f"rollback — no version history for {node} (nothing to restore).") + return 0 + row, note = vm.resolve_version(versions, args.ref) + if row is None: + print(f"rollback — {note} ({node})") + return 0 + # Edge E2: refuse a rollback to an uncaptured version — never os.replace from a missing object. + target_bytes = read_blob(versions_dir, row["hash"]) if row["present"] else None + if target_bytes is None: + print(f"rollback — v{row['n']} content not captured — cannot restore (hash {row['hash']}); " + f"nothing written.") + print(_not_captured_note()) + return 0 + p = base_dir / node + try: + cur_bytes = p.read_bytes() if p.is_file() else b"" + except OSError: + cur_bytes = b"" + if hash_bytes(cur_bytes) == row["hash"]: + print(f"rollback — {node} already matches v{row['n']} ({row['hash']}); nothing to do.") + return 0 + diff_text = _unified(cur_bytes, target_bytes, f"{node}@current", f"{node}@v{row['n']}") + diffhash = hash_bytes(diff_text.encode("utf-8")) + + if not getattr(args, "confirm", False): + _write_rollback_preview(sdlc_dir, node, diff_text) + print(f"Rollback preview — restore {node} to v{row['n']} ({row['event']}, {row['hash']}):") + print("-" * 50) + sys.stdout.write(diff_text if diff_text.endswith("\n") else diff_text + "\n") + print("-" * 50) + print(f"To apply: version rollback {args.artifact} {args.ref} --confirm --actor " + f"--reviewed {diffhash}") + print("ADVISORY — preview only; nothing written (exit 0).") + return 0 + + ok, msg = _confirm_guards(node, sdlc_dir, args, diffhash) + if not ok: + print(f"rollback — {msg}") + return 0 + ok, msg = mutate_artifact( + base_dir, metrics_dir, versions_dir, node, target_bytes, event="revised", + actor=args.actor, reason=f"rollback to v{row['n']} ({row['hash']})", + decision_ref=getattr(args, "decision_ref", "") or "") + if not ok: + print(f"rollback — {msg}") + return 0 + _clear_rollback_preview(sdlc_dir, node) + print(f"Rolled back {node} to v{row['n']} content ({row['hash']}) by {args.actor}.") + print(f"Recorded as a new, append-only version — the rollback is itself reversible and renders " + f"'restored from v{row['n']}'.") + print("Re-gate the affected phase with /sdlc-gate (this command writes no state.yaml).") + return 0 + + +def do_version_gc(args, base_dir, sdlc_dir, metrics_dir, versions_dir) -> int: + keep = args.keep if args.keep is not None else 10 + ledger = load_ledger(metrics_dir / LEDGER_NAME) + present = present_hashes_in_store(versions_dir) + if not present: + print("gc — the object store is empty (no data); nothing to prune.") + return 0 + arts = sorted({e.get("artifact") for e in ledger + if am.is_change_entry(e) and e.get("artifact")}) + state = load_state(sdlc_dir) + signoff_known = bool(state) + phases = state.get("phases", {}) if isinstance(state, dict) else {} + + # Cross-ledger refcount: an object survives if ANY artifact retains it in its newest N (or is its + # latest), OR it belongs to a sign-off-protected artifact. Dedup means one blob can be a prunable + # old version of A yet the protected latest of B — the union below keeps it either way. + retained: set[str] = set() + protected: set[str] = set() + for art in arts: + versions = vm.versions_for(ledger, art, present_hashes=present) + if not versions: + continue + for r in (versions[-keep:] if keep > 0 else versions): + if r["hash"]: + retained.add(r["hash"]) + retained.add(versions[-1]["hash"]) # the latest is always retained + # Sign-off protection (W4): protect every hash of a signed-off artifact; when sign-off is + # unknowable (no state.yaml) treat every artifact as protected — gc no-ops rather than risk it. + note = _signoff_note(art, phases) + if (not signoff_known) or note in ("signed-off ✓", "phase completed"): + for r in versions: + if r["hash"]: + protected.add(r["hash"]) + + keepset = retained | protected + evictable = sorted(h for h in present if h not in keepset) + kept = len(present) - len(evictable) + + if not evictable: + why = " (sign-off status unknown — everything protected)" if not signoff_known else "" + print(f"gc — nothing to prune: all {len(present)} stored object(s) are retained " + f"(keep={keep}){why}.") + return 0 + + if not getattr(args, "apply", False): + print(f"gc preview — {len(evictable)} object(s) prunable, {kept} retained (keep={keep}):") + for h in evictable: + print(f" • {h} {vm.object_relpath(h)}") + print("Run with --apply to delete. ADVISORY — preview only; nothing deleted (exit 0).") + return 0 + + deleted = 0 + for h in evictable: + rel = vm.object_relpath(h) + if not rel: + continue + try: + (versions_dir / rel).unlink() + deleted += 1 + except OSError: + continue + print(f"gc — pruned {deleted} object(s); {kept} retained (keep={keep}). " + f"Pruned versions now render 'content not captured'; the ledger is unchanged.") + print(_not_captured_note()) + return 0 + + +def cmd_version(args) -> int: + base_dir, sdlc_dir, metrics_dir = resolve_paths(args) + versions_dir = versions_dir_of(sdlc_dir) + recover_pending(base_dir, versions_dir) # complete any mutate interrupted mid-os.replace + fn = _VERSION_DISPATCH.get(args.version_cmd) + if fn is None: + print("version: choose list | show | diff | rollback | gc") + return 0 + return fn(args, base_dir, sdlc_dir, metrics_dir, versions_dir) + + +# The dispatch is the single source of truth for the version verbs. +_VERSION_DISPATCH = { + "list": do_version_list, + "show": do_version_show, + "diff": do_version_diff, + "rollback": do_version_rollback, + "gc": do_version_gc, +} + + +# --- refresh: reverse-propagation (detect read-only; draft/apply/reject/status write) --------- +# Traceability is declared forward (requirement -> spec); this reads it BACKWARD to surface the +# pre-Build upstreams a merged spec's shipped reality implies an edit to. `detect`/`scan` are pure +# reads (side-effect-free): they list candidates and a conservative drift verdict, never write. +# `draft` seeds a `.proposed` copy of each eligible upstream (a discipline agent then edits ONLY +# that file — the real artifact stays untouched); a NAMED HUMAN `apply`s (the One Rule) through the +# shared canonical mutate order, or `reject`s to NOT_AFFECTED. `status` self-counts the spec's +# upstream dispositions from the ledger (REFRESHED via the additive `source_spec` rider, honest). + +def _resolve_spec_node(base_dir: Path, spec_arg: str) -> str | None: + """Resolve a --spec argument to a repo-relative `specs/*.md` node, or None. Accepts a repo-relative + path, an absolute path inside the repo, or a bare filename resolved under specs/.""" + if not spec_arg: + return None + node = _normalize_artifact_arg(base_dir, spec_arg) + if node.startswith("specs/") and (base_dir / node).is_file(): + return node + cand = f"specs/{Path(spec_arg).name}" + return cand if (base_dir / cand).is_file() else None + + +def _spec_id_of(base_dir: Path, spec_node: str) -> str: + try: + fm = al.read_yaml_frontmatter((base_dir / spec_node).read_text(encoding="utf-8", errors="replace")) + except OSError: + fm = {} + return str(fm.get("spec") or Path(spec_node).stem) + + +def _safe_checksum(p: Path) -> str: + try: + return compute_checksum(p) if p.is_file() else "" + except OSError: + return "" + + +def _pre_build_stem(node: str) -> str | None: + """The PRE_BUILD_STEMS entry a node matches by filename stem (R1 filter), else None. Mirrors the + `== or startswith` convention artifact_lineage uses for id ownership.""" + stem = Path(node).stem.lower() + for s in PRE_BUILD_STEMS: + if stem == s or stem.startswith(s): + return s + return None + + +def _spec_referenced_ids(spec_text: str) -> set[str]: + return {m.group(0) for m in al.ID_RE.finditer(spec_text)} + + +def _id_substantiated_in(base_dir: Path, target_node: str, spec_ids: set[str]) -> bool: + """True if some in-vocabulary id the spec cites, owned by the target's stem, ACTUALLY appears in + the target file. The lineage graph maps an id to its owner stem without checking the id exists + there, so a spec citing a phantom id (FR-999 no upstream declares) would otherwise surface a + fabricated candidate. This is the R2 honesty guard: a phantom id yields no real candidate.""" + try: + up_text = (base_dir / target_node).read_text(encoding="utf-8", errors="replace") + except OSError: + return False + stem = Path(target_node).stem.lower() + for sid in spec_ids: + m = al.ID_RE.fullmatch(sid) + if not m: + continue + owner_stem = al.ID_OWNER_STEM.get(m.group(1)) + if owner_stem and (stem == owner_stem or stem.startswith(owner_stem)) and sid in up_text: + return True + return False + + +# A "salient" token: a number with a time/size/percent unit, a 3+ digit code, or a decimal — the +# tokens most likely to encode a shipped-reality delta (an SLA, a status code, a limit). +_SALIENT_RE = re.compile( + r"\b\d+(?:\.\d+)?\s?(?:ms|s|sec|secs|second|seconds|min|mins|minute|minutes|" + r"hr|hrs|hour|hours|day|days|%|kb|mb|gb)\b" + r"|\b\d{3,}\b" + r"|\b\d+\.\d+\b", + re.IGNORECASE) + + +def _md_section(text: str, header_kw: str) -> str: + """Concatenated body of every `##`-style section whose header contains header_kw (case-insensitive).""" + out, capture = [], False + for ln in text.splitlines(): + if ln.lstrip().startswith("#"): + capture = header_kw.lower() in ln.lower() + continue + if capture: + out.append(ln) + return "\n".join(out) + + +def _divergence_signal(spec_text: str, upstream_text: str) -> tuple[bool, str]: + """CONSERVATIVE, advisory drift heuristic (documented as needs-tuning). A salient token present in + the spec's Acceptance/Scope sections but ABSENT from the upstream is treated as a drift signal. + No signal -> trace-only (no auto-draft without --draft). Whitespace-insensitive substring match + keeps false positives down (e.g. '4h' matches '4 hours'); it never raises.""" + if not spec_text or not upstream_text: + return False, "" + acc = "\n".join(_md_section(spec_text, kw) for kw in ("acceptance", "scope")) + up_norm = re.sub(r"\s+", "", upstream_text.lower()) + for m in _SALIENT_RE.finditer(acc): + tok = m.group(0) + norm = re.sub(r"\s+", "", tok.lower()) + if norm and norm not in up_norm: + return True, f"spec acceptance/scope mentions {tok.strip()!r}, absent upstream" + return False, "" + + +def _detect_candidates(base_dir: Path, sdlc_dir: Path, metrics_dir: Path, spec_node: str, *, + transitive: bool, include_coarse: bool) -> tuple[list[dict], dict, str]: + """Read-only: the pre-Build upstreams a spec traces to, each with a drift verdict + already-fresher + flag. Side-effect-free. Returns (candidates, spec_meta, spec_ts).""" + try: + spec_text = (base_dir / spec_node).read_text(encoding="utf-8", errors="replace") + except OSError: + spec_text = "" + fm = al.read_yaml_frontmatter(spec_text) + spec_meta = {"status": str(fm.get("status") or "").strip().lower(), "source": fm.get("source")} + spec_ids = _spec_referenced_ids(spec_text) + + graph = al.build_graph(base_dir, sdlc_dir) + rows = al.upstream_of(graph, spec_node) + ledger = load_ledger(metrics_dir / LEDGER_NAME) + latest = am.latest_change_per_artifact(ledger) + spec_ts = (latest.get(spec_node) or {}).get("ts", "") + + candidates: list[dict] = [] + seen: set[str] = set() + for r in rows: + node = r["node"] + stem = _pre_build_stem(node) + if not stem: + continue # R1: pre-Build authored artifacts only + conf = r.get("confidence", "declared") + if conf == "coarse" and not include_coarse: + continue # coarse guesses are opt-in + depth = len(r.get("path", [node])) - 1 + if depth > 1 and not transitive: + continue # depth-1 declared by default + bases = r.get("bases") or [] + basis = bases[0] if bases else "coarse-phase-order" # R3/E4 empty-bases guard + if basis == "id-reference" and not _id_substantiated_in(base_dir, node, spec_ids): + continue # R2: drop a candidate a phantom id conjured + if node in seen: + continue + seen.add(node) + up_change = latest.get(node) + up_hash = (up_change or {}).get("hash") or _safe_checksum(base_dir / node) + already_fresher = bool(spec_ts and up_change and _ts_lt(spec_ts, up_change.get("ts", ""))) + try: + up_text = (base_dir / node).read_text(encoding="utf-8", errors="replace") + except OSError: + up_text = "" + drift, detail = _divergence_signal(spec_text, up_text) + candidates.append({ + "stem": stem, "target": node, "basis": basis, "confidence": conf, "depth": depth, + "upstream_hash": up_hash or "", "discipline": DISCIPLINE_BY_STEM.get(stem, ""), + "already_fresher": already_fresher, "drift": drift, "drift_detail": detail, + }) + candidates.sort(key=lambda c: (c["confidence"] != "declared", c["target"])) + return candidates, spec_meta, spec_ts + + +def do_refresh_detect(args, base_dir, sdlc_dir, metrics_dir) -> int: + spec_node = _resolve_spec_node(base_dir, args.spec) + if not spec_node: + print(f"refresh detect — could not find spec '{args.spec}' (expected a specs/*.md path); " + f"nothing detected.") + return 0 + candidates, meta, _ = _detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=getattr(args, "transitive", False), + include_coarse=getattr(args, "include_coarse", False)) + spec_id = _spec_id_of(base_dir, spec_node) + + if getattr(args, "json", False): + print(json.dumps({"spec": spec_id, "spec_node": spec_node, "status": meta["status"], + "candidates": candidates}, indent=2)) + return 0 + + print(f"Refresh detection — spec {spec_id} ({spec_node})") + print("=" * 50) + if meta["status"] and meta["status"] != "merged": + print(f"NOTE: spec status is '{meta['status']}', not 'merged' — this reflects intended, not " + f"yet-shipped, reality.") + active = [c for c in candidates if not c["already_fresher"]] + fresher = [c for c in candidates if c["already_fresher"]] + if not active and not fresher: + src = meta.get("source") + src_note = "—" if src in (None, "", "—") else src + print("No traceable upstream — add an FR/EP/US/BR id to `source:` (or reference one in the " + f"spec body) so a refresh can trace back to it. (spec source: {src_note})") + print("=" * 50) + print("ADVISORY — nothing to refresh; nothing written (exit 0).") + return 0 + + force = getattr(args, "draft", False) + n_elig = 0 + print("Upstream artifacts this spec traces to:") + for c in active: + declared = c["confidence"] == "declared" + eligible = declared and (c["drift"] or force) + n_elig += 1 if eligible else 0 + disc = f"[{c['discipline']}]" if c["discipline"] else "" + conf = "" if declared else " COARSE (phase-order guess)" + verdict = f"DRIFT — {c['drift_detail']}" if c["drift"] else "trace-only (no drift detected)" + print(f" • {c['target']} {disc}{conf}") + print(f" basis: {c['basis']} {verdict}") + arrow = _glyph("→", "->") + if eligible: + extra = "" if c["drift"] else " --draft" + print(f" {arrow} would draft: refresh draft --spec {args.spec}{extra}") + elif declared: + print(f" {arrow} review only (pass --draft to propose anyway)") + else: + print(f" {arrow} review only (coarse guess — never auto-drafted)") + for c in fresher: + print(f" • {c['target']} [already fresher — changed after the spec; suppressed]") + print("=" * 50) + print(f"{len(active)} candidate(s), {n_elig} draft-eligible, {len(fresher)} already-fresher. " + f"Coarse guesses are never auto-drafted.") + print("ADVISORY — review-first; nothing drafted or written (exit 0).") + return 0 + + +def do_refresh_scan(args, base_dir, sdlc_dir, metrics_dir) -> int: + specs = ts.scan_specs(base_dir / "specs") + merged = [s for s in specs if s.get("status") == "merged"] + results: list[dict] = [] + for s in merged: + spec_node = _normalize_artifact_arg(base_dir, s["path"]) + cands, _, _ = _detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=getattr(args, "transitive", False), + include_coarse=getattr(args, "include_coarse", False)) + active = [c for c in cands if not c["already_fresher"]] + drifted = [c for c in active if c["drift"] and c["confidence"] == "declared"] + results.append({ + "spec": s["id"], "spec_node": spec_node, "name": s["name"], + "candidates": len(active), "drifted": len(drifted), + "targets": [c["target"] for c in active], + "drifted_targets": [c["target"] for c in drifted], + }) + + if getattr(args, "json", False): + total_drift = sum(r["drifted"] for r in results) + print(json.dumps({"merged_specs": len(merged), "drifted_total": total_drift, + "results": results}, indent=2)) + return 0 + + print("Refresh scan — merged specs vs their pre-Build upstreams") + print("=" * 50) + if not merged: + print(" (no merged specs — nothing to back-propagate)") + print("=" * 50) + print("ADVISORY — nothing to refresh; nothing written (exit 0).") + return 0 + total_drift = 0 + for r in results: + if r["candidates"] == 0: + continue + total_drift += r["drifted"] + flag = f" {_glyph('⚠', '(!)')} {r['drifted']} may have drifted" if r["drifted"] else "" + print(f" spec {r['spec']} {r['name']}: {r['candidates']} upstream candidate(s){flag}") + for t in r["drifted_targets"]: + print(f" drift {_glyph('→', '->')} {t}") + print("=" * 50) + print(f"{len(merged)} merged spec(s); {total_drift} upstream artifact(s) may have drifted from a " + f"merged spec.") + print("ADVISORY — review one with `refresh detect --spec ` (exit 0).") + return 0 + + +# --- refresh write path: draft -> apply/reject -> status -------------------------------------- +# A `.proposed` per eligible upstream + a per-spec candidates.json (pins the draft-time upstream hash +# and the routed discipline). All under .sdlc/refresh// — gitignored, transient, deleted on +# apply AND reject. The spec-stem keys the dir (always filesystem-safe); the frontmatter spec id keys +# the ledger `source_spec` rider so REFRESHED is attributable per spec. + +def _refresh_spec_dir(sdlc_dir: Path, spec_node: str) -> Path: + return refresh_dir_of(sdlc_dir) / Path(spec_node).stem + + +def _proposed_path(sdlc_dir: Path, spec_node: str, stem: str) -> Path: + return _refresh_spec_dir(sdlc_dir, spec_node) / f"{stem}.proposed" + + +def _candidates_path(sdlc_dir: Path, spec_node: str) -> Path: + return _refresh_spec_dir(sdlc_dir, spec_node) / "candidates.json" + + +def _load_candidates(sdlc_dir: Path, spec_node: str) -> dict: + p = _candidates_path(sdlc_dir, spec_node) + if not p.is_file(): + return {} + try: + obj = json.loads(p.read_text(encoding="utf-8")) + return obj if isinstance(obj, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _save_candidates(sdlc_dir: Path, spec_node: str, data: dict) -> None: + try: + p = _candidates_path(sdlc_dir, spec_node) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data, indent=2), encoding="utf-8") + except OSError: + pass + + +def _clear_draft(sdlc_dir: Path, spec_node: str, stem: str) -> None: + """Remove one stem's `.proposed` and drop it from candidates.json; delete the spec dir when the + last draft is resolved. Best-effort — a cleanup fault never changes exit code or stdout.""" + try: + _proposed_path(sdlc_dir, spec_node, stem).unlink() + except OSError: + pass + data = _load_candidates(sdlc_dir, spec_node) + if data: + remaining = [c for c in data.get("candidates", []) if c.get("stem") != stem] + if remaining: + data["candidates"] = remaining + _save_candidates(sdlc_dir, spec_node, data) + else: + try: + _candidates_path(sdlc_dir, spec_node).unlink() + except OSError: + pass + spec_dir = _refresh_spec_dir(sdlc_dir, spec_node) + try: + if spec_dir.is_dir() and not any(spec_dir.iterdir()): + spec_dir.rmdir() + except OSError: + pass + + +def _discipline_of(node: str) -> str: + return DISCIPLINE_BY_STEM.get(_pre_build_stem(node) or "", "") + + +def _eligible_candidates(candidates: list[dict], *, force: bool, stem: str | None) -> list[dict]: + """The draftable subset: declared, not already-fresher, and (drifted OR --draft). Coarse guesses + are never draftable (mirrors detect). Optionally narrowed to one stem.""" + out = [c for c in candidates + if c["confidence"] == "declared" and not c["already_fresher"] and (c["drift"] or force)] + if stem: + out = [c for c in out if c["stem"] == stem] + return out + + +def do_refresh_draft(args, base_dir, sdlc_dir, metrics_dir) -> int: + spec_node = _resolve_spec_node(base_dir, args.spec) + if not spec_node: + print(f"refresh draft — could not find spec '{args.spec}' (expected a specs/*.md path); " + f"nothing drafted.") + return 0 + spec_id = _spec_id_of(base_dir, spec_node) + candidates, meta, _ = _detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=getattr(args, "transitive", False), + include_coarse=getattr(args, "include_coarse", False)) + force = getattr(args, "draft", False) + eligible = _eligible_candidates(candidates, force=force, stem=getattr(args, "stem", None)) + + if not eligible: + drifted = [c for c in candidates + if c["confidence"] == "declared" and not c["already_fresher"] and c["drift"]] + if drifted and not force: # unreachable given the filter, kept for clarity of intent + print("refresh draft — drifted candidates exist; nothing selected.") + elif any(c["confidence"] == "declared" and not c["already_fresher"] for c in candidates): + print("refresh draft — no drift detected on any declared upstream; nothing drafted. " + "Pass --draft to propose a refresh anyway (review-first default).") + else: + print("refresh draft — no draftable upstream for this spec (no declared, in-vocabulary " + "candidate). Add an FR/EP/US/BR id to `source:`. Nothing drafted.") + return 0 + + existed = _candidates_path(sdlc_dir, spec_node).is_file() + records: list[dict] = [] + written: list[str] = [] + for c in eligible: + target = c["target"] + try: + cur = (base_dir / target).read_bytes() + except OSError: + continue # can't read the upstream — skip it (best-effort), never crash + pp = _proposed_path(sdlc_dir, spec_node, c["stem"]) + try: + pp.parent.mkdir(parents=True, exist_ok=True) + pp.write_bytes(cur) # seed with the CURRENT upstream; the discipline agent edits this copy + except OSError: + continue + records.append({ + "stem": c["stem"], "target": target, "basis": c["basis"], + "upstream_hash": hash_bytes(cur), # PINNED to the exact bytes copied (staleness guard) + "discipline": c["discipline"], + "proposed": _proposed_path(sdlc_dir, spec_node, c["stem"]).name, + }) + written.append(c["stem"]) + + if not records: + print("refresh draft — could not stage any draft (upstream unreadable or store not " + "writable); nothing drafted.") + return 0 + _save_candidates(sdlc_dir, spec_node, { + "spec_id": spec_id, "spec_node": spec_node, "candidates": records}) + + if getattr(args, "json", False): + print(json.dumps({"spec": spec_id, "spec_node": spec_node, + "drafted": written, "candidates": records, + "overwrote_existing": existed}, indent=2)) + return 0 + + if existed: + print(f"refresh draft — re-drafting spec {spec_id}: previous drafts overwritten with the " + f"current upstream content.") + print(f"Staged {len(records)} draft(s) for spec {spec_id} under .sdlc/refresh/{Path(spec_node).stem}/:") + for r in records: + disc = f" {_glyph('→', '->')} have {r['discipline']} edit it" if r["discipline"] else "" + print(f" • {r['stem']}.proposed (from {r['target']}){disc}") + print("-" * 50) + print("The agent edits ONLY the .proposed; the real artifact stays untouched (the One Rule).") + print(f"Then a named human runs: refresh apply --spec {args.spec} --actor ") + print("ADVISORY — only the .proposed drafts were written; no artifact changed (exit 0).") + return 0 + + +def _candidate_for_stem(args, base_dir, sdlc_dir, metrics_dir, spec_node, stem) -> dict | None: + """The candidate record for one stem — from candidates.json if present (carries the pinned + draft-time hash), else a fresh detect (so `reject` can target a never-drafted candidate).""" + for c in _load_candidates(sdlc_dir, spec_node).get("candidates", []): + if c.get("stem") == stem: + return c + cands, _, _ = _detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=getattr(args, "transitive", False), + include_coarse=getattr(args, "include_coarse", False)) + for c in cands: + if c["stem"] == stem: + return {"stem": stem, "target": c["target"], "basis": c["basis"], + "upstream_hash": c["upstream_hash"], "discipline": c["discipline"]} + return None + + +def do_refresh_apply(args, base_dir, sdlc_dir, metrics_dir) -> int: + spec_node = _resolve_spec_node(base_dir, args.spec) + if not spec_node: + print(f"refresh apply — could not find spec '{args.spec}' (expected a specs/*.md path); " + f"nothing written.") + return 0 + spec_id = _spec_id_of(base_dir, spec_node) + stem = args.stem + data = _load_candidates(sdlc_dir, spec_node) + rec = next((c for c in data.get("candidates", []) if c.get("stem") == stem), None) + if rec is None: + print(f"refresh apply — no draft for stem '{stem}' of spec {spec_id}. " + f"Run `refresh draft --spec {args.spec}` first; nothing written.") + return 0 + target = rec["target"] + proposed = _proposed_path(sdlc_dir, spec_node, stem) + if not proposed.is_file(): + print(f"refresh apply — the {stem}.proposed draft is missing; re-run `refresh draft`. " + f"Nothing written.") + return 0 + try: + new_bytes = proposed.read_bytes() + except OSError: + print(f"refresh apply — could not read the {stem}.proposed draft; nothing written.") + return 0 + + # Staleness guard: the upstream must not have moved on disk since the draft pinned it. + pinned = rec.get("upstream_hash", "") + cur_hash = _safe_checksum(base_dir / target) + if pinned and cur_hash and cur_hash != pinned: + print(f"refresh apply — {target} moved since the draft (pinned {pinned}, now {cur_hash}); " + f"re-run `refresh detect`/`draft` so the proposal reflects the current upstream. " + f"Nothing written.") + return 0 + if hash_bytes(new_bytes) == cur_hash: + print(f"refresh apply — the {stem}.proposed draft is identical to {target} (the agent made " + f"no edit); nothing to apply. Nothing written.") + return 0 + + try: + cur_bytes = (base_dir / target).read_bytes() if (base_dir / target).is_file() else b"" + except OSError: + cur_bytes = b"" + diff_text = _unified(cur_bytes, new_bytes, f"{target}@current", f"{target}@proposed({spec_id})") + diffhash = hash_bytes(diff_text.encode("utf-8")) + + # Preview until the human echoes the diffhash (mirrors rollback's preview->confirm handshake). + if not (getattr(args, "reviewed", None) or "").strip(): + print(f"Refresh preview — apply {stem}.proposed to {target} (from spec {spec_id}):") + print("-" * 50) + sys.stdout.write(diff_text if diff_text.endswith("\n") else diff_text + "\n") + print("-" * 50) + print(f"To apply: refresh apply --spec {args.spec} {stem} --actor --reviewed {diffhash}") + print("ADVISORY — preview only; nothing written (exit 0).") + return 0 + + ok, msg = _confirm_guards(target, sdlc_dir, args, diffhash) + if not ok: + print(f"refresh apply — {msg}") + return 0 + ok, msg = mutate_artifact( + base_dir, metrics_dir, versions_dir_of(sdlc_dir), target, new_bytes, + event="refreshed", target_id=spec_id, actor=args.actor, + reason=getattr(args, "reason", "") or f"auto-refresh from merged spec {spec_id}", + decision_ref=getattr(args, "decision_ref", "") or "", source_spec=spec_id) + if not ok: + print(f"refresh apply — {msg}") + return 0 + _clear_draft(sdlc_dir, spec_node, stem) + print(f"Refreshed {target} from spec {spec_id} by {args.actor} (recorded as a `refreshed` " + f"change, attributed to the spec; rollback via /sdlc-version).") + _print_apply_impact(base_dir, sdlc_dir, target) + print("Open a DL-NN decision-log item for this refresh, then re-gate the affected phase with " + "/sdlc-gate (this command writes no state.yaml).") + return 0 + + +def _print_apply_impact(base_dir: Path, sdlc_dir: Path, target: str) -> None: + """Read-only forward impact of the refresh: what may now go stale downstream (the forward + engine flags it on the next /sdlc-status). Never writes; a lineage fault is swallowed.""" + try: + graph = al.build_graph(base_dir, sdlc_dir) + rows = al.downstream_of(graph, target) + except Exception: + return + if not rows: + return + print(f" Impact — {len(rows)} downstream artifact(s) may now be stale relative to {target}:") + for r in sorted(rows, key=lambda x: (x["confidence"], x["node"]))[:8]: + label = "declared" if r["confidence"] == "declared" else "coarse" + print(f" • {r['node']} [{label}]") + + +def do_refresh_reject(args, base_dir, sdlc_dir, metrics_dir) -> int: + spec_node = _resolve_spec_node(base_dir, args.spec) + if not spec_node: + print(f"refresh reject — could not find spec '{args.spec}' (expected a specs/*.md path); " + f"nothing recorded.") + return 0 + spec_id = _spec_id_of(base_dir, spec_node) + stem = args.stem + rec = _candidate_for_stem(args, base_dir, sdlc_dir, metrics_dir, spec_node, stem) + if rec is None: + print(f"refresh reject — no candidate upstream for stem '{stem}' of spec {spec_id}; " + f"nothing recorded.") + return 0 + target = rec["target"] + # R4: record NOT_AFFECTED as (downstream = the upstream artifact, upstream = the spec). This + # reverse edge is absent from the forward lineage graph, so compute_staleness never renders it — + # the rejection lives only in this refresh view. Pin to the spec's hash (sticky-reject caveat). + entry = am.disposition_entry( + ts=now_iso(), downstream=target, upstream=spec_node, + upstream_hash=_safe_checksum(base_dir / spec_node), disposition="NOT_AFFECTED", + owner=getattr(args, "owner", "") or "", reason=getattr(args, "reason", "") or "", + actor=getattr(args, "actor", "") or "") + append_entries(metrics_dir, [entry]) + _clear_draft(sdlc_dir, spec_node, stem) + off, why = am.validate_disposition(entry) + tag = "off the books" if off else f"STILL COUNTS as debt ({why})" + print(f"Recorded NOT_AFFECTED — {target} judged unaffected by spec {spec_id}: {tag}.") + if not (getattr(args, "reason", "") or "").strip(): + print(" Pass --reason TEXT so the rejection is off the books (honest counting).") + return 0 + + +def _refresh_status_rows(base_dir, sdlc_dir, metrics_dir, spec_node, *, transitive, include_coarse): + """(spec_id, meta, rows) for one spec. Each row: {target, stem, discipline, disposition, + off_books, drift}. Universe = active candidates ∪ this-spec REFRESHED ∪ this-spec dispositions.""" + spec_id = _spec_id_of(base_dir, spec_node) + candidates, meta, _ = _detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=transitive, include_coarse=include_coarse) + ledger = load_ledger(metrics_dir / LEDGER_NAME) + refreshed = {e.get("artifact") for e in ledger + if am.is_change_entry(e) and am.normalize_event(e.get("event")) == "refreshed" + and e.get("source_spec") == spec_id and e.get("artifact")} + disp_by_target: dict[str, dict] = {} + for e in ledger: # ledger is time-ordered; last write wins + if am.is_disposition_entry(e) and e.get("upstream") == spec_node and e.get("downstream"): + disp_by_target[e["downstream"]] = e + + meta_by_target: dict[str, dict] = {} + for c in candidates: + if not c["already_fresher"]: # active OPEN universe (already-fresher isn't this spec's debt) + meta_by_target.setdefault(c["target"], {"stem": c["stem"], "drift": c["drift"]}) + for t in refreshed | set(disp_by_target): + meta_by_target.setdefault(t, {"stem": _pre_build_stem(t) or "", "drift": False}) + + rows = [] + for t, m in meta_by_target.items(): + if t in refreshed: + disp, off = "REFRESHED", True # derived from a real content change — trustworthy + elif t in disp_by_target: + e = disp_by_target[t] + disp = am.normalize_disposition(e.get("disposition")) or "OPEN" + off, _ = am.validate_disposition(e) + else: + disp, off = "OPEN", False + rows.append({"target": t, "stem": m["stem"], "discipline": _discipline_of(t), + "disposition": disp, "off_books": off, "drift": m.get("drift", False)}) + rows.sort(key=lambda r: r["target"]) + return spec_id, meta, rows + + +def _status_counts(rows: list[dict]) -> dict: + refreshed = sum(1 for r in rows if r["disposition"] == "REFRESHED") + open_debt = sum(1 for r in rows + if r["disposition"] == "OPEN" + or (r["disposition"] in ("ACKNOWLEDGED", "NOT_AFFECTED") and not r["off_books"])) + off_books = sum(1 for r in rows + if r["disposition"] in ("ACKNOWLEDGED", "NOT_AFFECTED") and r["off_books"]) + return {"total": len(rows), "open": open_debt, "refreshed": refreshed, "off_books": off_books} + + +def do_refresh_status(args, base_dir, sdlc_dir, metrics_dir) -> int: + transitive = getattr(args, "transitive", False) + include_coarse = getattr(args, "include_coarse", False) + + if getattr(args, "spec", None): + spec_node = _resolve_spec_node(base_dir, args.spec) + if not spec_node: + print(f"refresh status — could not find spec '{args.spec}'; no data.") + return 0 + spec_id, meta, rows = _refresh_status_rows( + base_dir, sdlc_dir, metrics_dir, spec_node, + transitive=transitive, include_coarse=include_coarse) + counts = _status_counts(rows) + if getattr(args, "json", False): + print(json.dumps({"spec": spec_id, "spec_node": spec_node, + "counts": counts, "rows": rows}, indent=2)) + return 0 + print(f"Refresh status — spec {spec_id} ({spec_node})") + print("=" * 50) + if not rows: + print(" (no upstream candidates and no recorded refresh activity — no data)") + print("=" * 50) + print("ADVISORY — nothing to report (exit 0).") + return 0 + for r in rows: + disc = f" [{r['discipline']}]" if r["discipline"] else "" + flag = "" if r["off_books"] or r["disposition"] == "OPEN" else f" {_glyph('⚠', '(!)')} still counts" + print(f" {r['target']}{disc}") + print(f" {r['disposition']}{flag}") + print("=" * 50) + print(f"{counts['total']} upstream(s): {counts['open']} open · {counts['refreshed']} refreshed " + f"· {counts['off_books']} off the books") + print("ADVISORY — honest counting; a mislabeled disposition still counts as debt (exit 0).") + return 0 + + # No --spec: a rollup across every merged spec. + merged = [s for s in ts.scan_specs(base_dir / "specs") if s.get("status") == "merged"] + rollup = [] + for s in merged: + sn = _normalize_artifact_arg(base_dir, s["path"]) + _, _, rows = _refresh_status_rows(base_dir, sdlc_dir, metrics_dir, sn, + transitive=transitive, include_coarse=include_coarse) + c = _status_counts(rows) + rollup.append({"spec": s["id"], "name": s["name"], **c}) + if getattr(args, "json", False): + print(json.dumps({"merged_specs": len(merged), + "open_total": sum(r["open"] for r in rollup), + "results": rollup}, indent=2)) + return 0 + print("Refresh status — all merged specs") + print("=" * 50) + if not merged: + print(" (no merged specs — no data)") + print("=" * 50) + print("ADVISORY — nothing to report (exit 0).") + return 0 + for r in rollup: + if r["total"] == 0: + continue + print(f" spec {r['spec']} {r['name']}: {r['open']} open · {r['refreshed']} refreshed " + f"· {r['off_books']} off the books") + print("=" * 50) + print(f"{len(merged)} merged spec(s); {sum(r['open'] for r in rollup)} open upstream refresh(es). " + f"Detail one with `refresh status --spec `.") + print("ADVISORY — honest counting (exit 0).") + return 0 + + +def cmd_refresh(args) -> int: + base_dir, sdlc_dir, metrics_dir = resolve_paths(args) + recover_pending(base_dir, versions_dir_of(sdlc_dir)) # complete any interrupted mutate first + fn = _REFRESH_DISPATCH.get(args.refresh_cmd) + if fn is None: + print("refresh: choose detect | scan | draft | apply | reject | status") + return 0 + return fn(args, base_dir, sdlc_dir, metrics_dir) + + +# The dispatch is the single source of truth for the refresh verbs. +_REFRESH_DISPATCH = { + "detect": do_refresh_detect, + "scan": do_refresh_scan, + "draft": do_refresh_draft, + "apply": do_refresh_apply, + "reject": do_refresh_reject, + "status": do_refresh_status, +} + + # --- CLI --------------------------------------------------------------------------------------- def main() -> None: @@ -544,16 +1817,129 @@ def main() -> None: p_rep.add_argument("--since", help="Filter to changes on/after this ISO date") p_rep.add_argument("--json", action="store_true", help="Emit JSON") + _add_version_cli(sub, common) + _add_refresh_cli(sub, common) + args = parser.parse_args() if args.command == "record": rc = cmd_record(args) elif args.command == "impact": rc = cmd_impact(args) + elif args.command == "version": + rc = cmd_version(args) + elif args.command == "refresh": + rc = cmd_refresh(args) else: rc = cmd_report(args) # Advisory by construction — never a non-zero exit. sys.exit(0 if rc is None else 0) +def _add_version_cli(sub, common) -> None: + """`version` subcommand group: content history / diff / rollback / gc. Leaf-level --state/--repo + so the flag follows the verb (audit_artifacts.py version list --repo ).""" + p_ver = sub.add_parser("version", help="Artifact content version history (list/show/diff/rollback/gc)") + vsub = p_ver.add_subparsers(dest="version_cmd", required=True) + + v_list = vsub.add_parser("list", parents=[common], help="List v1..vN for an artifact") + v_list.add_argument("artifact", help="An id (FR-012) or an artifact path") + v_list.add_argument("--json", action="store_true", help="Emit JSON") + + v_show = vsub.add_parser("show", parents=[common], help="Print the content of one version") + v_show.add_argument("artifact", help="An id (FR-012) or an artifact path") + v_show.add_argument("ref", nargs="?", default="latest", help="vN | latest | prev | ") + + v_diff = vsub.add_parser("diff", parents=[common], + help="Unified diff between two versions (default prev->latest)") + v_diff.add_argument("artifact", help="An id (FR-012) or an artifact path") + v_diff.add_argument("a", nargs="?", default=None, help="from ref (default: prev)") + v_diff.add_argument("b", nargs="?", default=None, help="to ref (default: latest)") + + v_rb = vsub.add_parser("rollback", parents=[common], + help="Restore an artifact to an earlier version (preview by default)") + v_rb.add_argument("artifact", help="An id (FR-012) or an artifact path") + v_rb.add_argument("ref", nargs="?", default="prev", help="vN | latest | prev | ") + v_rb.add_argument("--confirm", action="store_true", help="Apply the rollback (default: preview)") + v_rb.add_argument("--actor", help="Named human owning the change (required with --confirm)") + v_rb.add_argument("--reviewed", help="Echo the diffhash from the preview to confirm you saw it") + v_rb.add_argument("--ack-signoff", dest="ack_signoff", action="store_true", + help="Acknowledge changing a signed-off / completed-phase artifact") + v_rb.add_argument("--decision-ref", dest="decision_ref", help="Linked decision-log id (DL-NN)") + + v_gc = vsub.add_parser("gc", parents=[common], + help="Prune old snapshots, keeping the newest N per artifact (preview by default)") + v_gc.add_argument("--keep", type=int, default=10, help="Retain the newest N versions per artifact") + v_gc.add_argument("--apply", action="store_true", help="Actually delete (default: preview)") + + +def _add_refresh_cli(sub, common) -> None: + """`refresh` subcommand group: reverse-propagation. detect/scan read-only; draft/apply/reject/ + status are the draft+confirm write path (a named human `apply`s — the One Rule).""" + p_ref = sub.add_parser( + "refresh", help="Reverse-propagation: surface & confirm the pre-Build edits a merged spec implies") + rsub = p_ref.add_subparsers(dest="refresh_cmd", required=True) + + r_det = rsub.add_parser("detect", parents=[common], + help="List the pre-Build upstreams a spec traces to (review-only)") + r_det.add_argument("--spec", required=True, help="Path to the spec (specs/NNNN-*.md)") + r_det.add_argument("--draft", action="store_true", + help="Mark all declared candidates draft-eligible, not only drifted ones") + r_det.add_argument("--transitive", action="store_true", + help="Include transitive (depth>1) declared upstreams") + r_det.add_argument("--include-coarse", dest="include_coarse", action="store_true", + help="Include coarse phase-order guesses (listed, never auto-drafted)") + r_det.add_argument("--json", action="store_true", help="Emit JSON") + + r_scan = rsub.add_parser("scan", parents=[common], + help="Detect across all merged specs (backs the /sdlc-status drift nudge)") + r_scan.add_argument("--transitive", action="store_true", + help="Include transitive declared upstreams") + r_scan.add_argument("--include-coarse", dest="include_coarse", action="store_true", + help="Include coarse phase-order guesses") + r_scan.add_argument("--json", action="store_true", help="Emit JSON") + + r_draft = rsub.add_parser("draft", parents=[common], + help="Seed a .proposed copy per eligible upstream for a discipline agent to edit") + r_draft.add_argument("--spec", required=True, help="Path to the spec (specs/NNNN-*.md)") + r_draft.add_argument("stem", nargs="?", default=None, + help="Draft only this stem (default: every eligible upstream)") + r_draft.add_argument("--draft", action="store_true", + help="Draft declared candidates even without a drift signal (review-first default)") + r_draft.add_argument("--transitive", action="store_true", help="Include transitive declared upstreams") + r_draft.add_argument("--include-coarse", dest="include_coarse", action="store_true", + help="Include coarse guesses (still never draft-eligible)") + r_draft.add_argument("--json", action="store_true", help="Emit JSON") + + r_app = rsub.add_parser("apply", parents=[common], + help="Apply a reviewed .proposed to its upstream (named human; preview until --reviewed)") + r_app.add_argument("--spec", required=True, help="Path to the spec (specs/NNNN-*.md)") + r_app.add_argument("stem", help="Which drafted stem to apply (e.g. requirements)") + r_app.add_argument("--actor", help="Named human owning the change (never a discipline agent)") + r_app.add_argument("--reviewed", help="Echo the diffhash from the preview to confirm you saw it") + r_app.add_argument("--ack-signoff", dest="ack_signoff", action="store_true", + help="Acknowledge changing a signed-off / completed-phase artifact") + r_app.add_argument("--reason", help="Why the refresh was applied") + r_app.add_argument("--decision-ref", dest="decision_ref", help="Linked decision-log id (DL-NN)") + + r_rej = rsub.add_parser("reject", parents=[common], + help="Record NOT_AFFECTED for an upstream a spec does not ripple to") + r_rej.add_argument("--spec", required=True, help="Path to the spec (specs/NNNN-*.md)") + r_rej.add_argument("stem", help="Which candidate stem to reject (e.g. business-rules)") + r_rej.add_argument("--reason", help="Why it is unaffected (REQUIRED to be off the books)") + r_rej.add_argument("--owner", help="Who judged it unaffected") + r_rej.add_argument("--actor", help="Who recorded the disposition") + r_rej.add_argument("--transitive", action="store_true", help="Include transitive declared upstreams") + r_rej.add_argument("--include-coarse", dest="include_coarse", action="store_true", + help="Include coarse guesses when resolving the stem") + + r_stat = rsub.add_parser("status", parents=[common], + help="Per-spec upstream refresh dispositions (honest counting)") + r_stat.add_argument("--spec", help="Path to one spec (default: rollup across all merged specs)") + r_stat.add_argument("--transitive", action="store_true", help="Include transitive declared upstreams") + r_stat.add_argument("--include-coarse", dest="include_coarse", action="store_true", + help="Include coarse guesses") + r_stat.add_argument("--json", action="store_true", help="Emit JSON") + + if __name__ == "__main__": main() diff --git a/scripts/retro_report.py b/scripts/retro_report.py new file mode 100644 index 0000000..5c12fa6 --- /dev/null +++ b/scripts/retro_report.py @@ -0,0 +1,416 @@ +"""retro_report.py — the cross-ledger retro roll-up behind /sdlc-retro. + +Three ledgers already record, honestly and append-only, what the advisory layers surfaced round +after round: review findings (findings-log.jsonl), artifact staleness + change history +(artifact-log.jsonl), and — folded into the same artifact ledger — the reverse-propagation refresh +trail (`refreshed` changes + reject dispositions). Each on its own answers "what is open right now". +This tool reads all three at once and answers the *retro* question: what keeps happening. + +Four read-only sections, all exit 0 always (advisory — a retro never blocks and never mutates): + + 1. RECURRING FINDINGS — a (category, target) group seen in >= 2 distinct rounds is a candidate + for a permanent check ("findings become new checks"). + 2. REPEAT-STALE ARTIFACTS — per downstream artifact, how many times it was dispositioned for + staleness, and whether it is stale right now. + 3. REFRESH FUNNEL — per merged spec and by upstream stem: candidates → drifted → refreshed + → rejected → still open. Doubles as the divergence-heuristic tuning + signal (lots rejected / few applied = noisy; nothing ever drifts = too + tight). + 4. DISPOSITION DEBT ROLLUP — combined honest-counting debt across the three ledgers, each line + naming its source. + +Everything is keyed by category / artifact / stem — never by actor. There is deliberately no flag to +rank by person, and the forbidden activity metrics (velocity, story points, PR count, LOC) are never +computed. Patterns, not people. + +Standalone or Workflow (CLAUDE.md design rule): + --repo standalone (reads /.sdlc) | --state .sdlc/state.yaml in-workflow +""" + +import argparse +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import artifact_model as am +import audit_artifacts as aa +import findings_model as fm +import track_specs as ts + +FINDINGS_LEDGER_NAME = "findings-log.jsonl" + + +# --- Loading (never crashes; a non-dict / bad line is skipped, mirroring aa.load_ledger) ------- + +def load_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + out: list[dict] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(obj, dict): + out.append(obj) + return out + + +# --- Window plumbing --------------------------------------------------------------------------- + +def cutoff_from(window_days) -> datetime | None: + """The oldest timestamp still in the window, or None for all-history. A non-positive N is treated + as no window (never silently drops everything).""" + if not window_days or window_days <= 0: + return None + return datetime.now(timezone.utc) - timedelta(days=window_days) + + +def in_window(ts: str, cutoff: datetime | None) -> bool: + """True if `ts` is inside the window. An unparseable timestamp is kept (honest: we don't drop a + real event just because its clock stamp is malformed).""" + if cutoff is None: + return True + dt = aa._parse_ts(ts) + if dt is None: + return True + return dt >= cutoff + + +def _safe(fn, default): + """Run a section builder; on any fault return `default` so one bad ledger never sinks the rest + (and never a stack trace — the whole tool is advisory).""" + try: + return fn() + except Exception: + return default + + +# --- Section 1: recurring findings ------------------------------------------------------------- + +def recurring_findings(findings: list[dict], cutoff: datetime | None) -> list[dict]: + """Group findings-log entries by (category, target). A group seen in >= 2 distinct rounds (a round + = one distinct report timestamp — one `record_findings record` invocation shares one timestamp) is + a candidate for a permanent check. Keyed by category+target, never by actor.""" + groups: dict[tuple, dict] = {} + for e in findings: + if not in_window(e.get("timestamp", ""), cutoff): + continue + cat = (e.get("category") or "").strip() + tgt = (e.get("target") or "").strip() + if not (cat or tgt): + continue + g = groups.setdefault((cat, tgt), {"category": cat, "target": tgt, + "times": 0, "rounds": set(), "dispositions": {}}) + g["times"] += 1 + g["rounds"].add(e.get("timestamp", "")) + disp = fm.normalize_disposition(e.get("disposition")) or (e.get("disposition") or "?") + g["dispositions"][disp] = g["dispositions"].get(disp, 0) + 1 + + out: list[dict] = [] + for g in groups.values(): + rounds = len(g["rounds"]) + if rounds >= 2: + out.append({"category": g["category"], "target": g["target"], + "times": g["times"], "rounds": rounds, "dispositions": g["dispositions"]}) + out.sort(key=lambda x: (-x["times"], -x["rounds"], x["category"], x["target"])) + return out + + +# --- Section 2: repeat-stale artifacts --------------------------------------------------------- + +def repeat_stale(art_ledger: list[dict], base_dir: Path, sdlc_dir: Path, + cutoff: datetime | None) -> list[dict]: + """Per downstream artifact: how many staleness disposition entries it accrued in the window + (flagged), how many are legitimately off the books vs still open, and whether it is stale right + now (from compute_staleness on the *current* ledger — a now-fact, so it ignores the window). + + Refresh-reject reverse edges (upstream is a specs/* path) are excluded here — they belong to the + refresh funnel (section 3), so they are not double-counted as staleness flags.""" + by_down: dict[str, list[dict]] = {} + for e in art_ledger: + if not am.is_disposition_entry(e): + continue + if str(e.get("upstream") or "").startswith("specs/"): + continue # refresh-reject edge — counted in the refresh funnel, not here + if not in_window(e.get("ts", ""), cutoff): + continue + down = str(e.get("downstream") or "").strip() + if down: + by_down.setdefault(down, []).append(e) + + stale_items = aa.compute_staleness(base_dir, sdlc_dir, art_ledger) + currently = {i["downstream"] for i in stale_items} + + out: list[dict] = [] + for down in sorted(set(by_down) | currently): + entries = by_down.get(down, []) + flagged = len(entries) + open_ = sum(1 for e in entries if am.counts_as_debt(e)) + dispositioned = flagged - open_ + out.append({"artifact": down, "flagged": flagged, "dispositioned": dispositioned, + "open": open_, "currently_stale": down in currently}) + out.sort(key=lambda x: (not x["currently_stale"], -x["flagged"], x["artifact"])) + return out + + +# --- Section 3: refresh funnel (also the divergence-heuristic tuning signal) ------------------- + +def _empty_stem(stem: str) -> dict: + return {"stem": stem, "detected": 0, "drifted": 0, "refreshed": 0, "rejected": 0, "open": 0} + + +def _stem_note(agg: dict) -> dict: + """One honest tuning readout per stem. High rejected/applied ⇒ the divergence signal is noisy; + candidates that never drift ⇒ it is too tight (quiet).""" + a = dict(agg) + applied, rejected = a["refreshed"], a["rejected"] + detected, drifted = a["detected"], a["drifted"] + if applied == 0 and rejected > 0: + note = f"{rejected} rejected / 0 applied — drift signal may be noisy for this stem" + elif detected > 0 and drifted == 0 and applied == 0 and rejected == 0: + note = f"0 of {detected} candidate(s) ever drifted — divergence signal may be too tight (quiet)" + elif rejected > applied and applied > 0: + note = f"{rejected} rejected / {applied} applied — drift signal may be noisy for this stem" + elif applied > 0: + note = f"{applied} applied / {rejected} rejected — signal landing" + else: + note = "candidates detected; nothing applied or rejected yet" + a["note"] = note + return a + + +def refresh_funnel(base_dir: Path, sdlc_dir: Path, metrics_dir: Path, + art_ledger: list[dict], cutoff: datetime | None) -> dict: + """Reuse audit_artifacts' own detection/status internals rather than reimplementing the drift + call. Per merged spec: detected (active candidates) → drifted → refreshed (`refreshed` change + events attributed to the spec via the source_spec rider) → rejected (NOT_AFFECTED whose upstream + is this spec) → still open. Aggregated the same way by upstream stem.""" + merged = [s for s in ts.scan_specs(base_dir / "specs") if s.get("status") == "merged"] + by_spec: list[dict] = [] + stem_agg: dict[str, dict] = {} + + for s in merged: + spec_node = aa._normalize_artifact_arg(base_dir, s["path"]) + spec_id = aa._spec_id_of(base_dir, spec_node) + cands, _, _ = aa._detect_candidates( + base_dir, sdlc_dir, metrics_dir, spec_node, transitive=False, include_coarse=False) + active = [c for c in cands if not c["already_fresher"]] + drifted = [c for c in active if c["drift"] and c["confidence"] == "declared"] + + refreshed_targets = [ + e.get("artifact") for e in art_ledger + if am.is_change_entry(e) + and am.normalize_event(e.get("event")) == "refreshed" + and e.get("source_spec") == spec_id and e.get("artifact") + and in_window(e.get("ts", ""), cutoff)] + rejected_targets = [ + e.get("downstream") for e in art_ledger + if am.is_disposition_entry(e) and e.get("upstream") == spec_node + and am.normalize_disposition(e.get("disposition")) == "NOT_AFFECTED" + and e.get("downstream") and in_window(e.get("ts", ""), cutoff)] + + _, _, rows = aa._refresh_status_rows( + base_dir, sdlc_dir, metrics_dir, spec_node, transitive=False, include_coarse=False) + open_rows = [r for r in rows if _row_is_open(r)] + + by_spec.append({ + "spec": spec_id, "spec_node": spec_node, "name": s.get("name", ""), + "detected": len(active), "drifted": len(drifted), + "refreshed": len(refreshed_targets), "rejected": len(rejected_targets), + "open": len(open_rows), + }) + + for c in active: + agg = stem_agg.setdefault(c["stem"], _empty_stem(c["stem"])) + agg["detected"] += 1 + if c["drift"] and c["confidence"] == "declared": + agg["drifted"] += 1 + for t in refreshed_targets: + st = aa._pre_build_stem(t) or "?" + stem_agg.setdefault(st, _empty_stem(st))["refreshed"] += 1 + for t in rejected_targets: + st = aa._pre_build_stem(t) or "?" + stem_agg.setdefault(st, _empty_stem(st))["rejected"] += 1 + for r in open_rows: + st = r.get("stem") or "?" + stem_agg.setdefault(st, _empty_stem(st))["open"] += 1 + + by_spec.sort(key=lambda x: str(x["spec"])) + by_stem = sorted((_stem_note(a) for a in stem_agg.values()), key=lambda x: x["stem"]) + return {"by_spec": by_spec, "by_stem": by_stem} + + +def _row_is_open(row: dict) -> bool: + """A refresh-status row still counts as open debt: OPEN, or a mislabeled off-books disposition.""" + disp = row.get("disposition") + if disp == "OPEN": + return True + return disp in ("ACKNOWLEDGED", "NOT_AFFECTED") and not row.get("off_books") + + +# --- Section 4: disposition debt rollup -------------------------------------------------------- + +def _current_findings_state(findings: list[dict]) -> list[dict]: + """Latest entry per fingerprint — a finding's current disposition (mirrors record_findings).""" + latest: dict[str, dict] = {} + for e in findings: + fp = e.get("fingerprint") or fm.fingerprint(e) + if fp: + latest[fp] = e + return list(latest.values()) + + +def debt_rollup(findings: list[dict], art_ledger: list[dict], base_dir: Path, sdlc_dir: Path, + funnel: dict) -> dict: + """Combined honest-counting debt — a now-measurement, so the window never applies. Each source is + named on its own line by the caller.""" + findings_debt = len(fm.open_debt(_current_findings_state(findings))) + stale_debt = len(am.open_debt(aa.compute_staleness(base_dir, sdlc_dir, art_ledger))) + refresh_open = sum(sp["open"] for sp in funnel.get("by_spec", [])) + return { + "findings": findings_debt, + "artifact_staleness": stale_debt, + "refresh_open": refresh_open, + "total": findings_debt + stale_debt + refresh_open, + } + + +# --- Assembly ---------------------------------------------------------------------------------- + +def build_payload(args) -> dict: + base_dir, sdlc_dir, metrics_dir = aa.resolve_paths(args) + cutoff = cutoff_from(getattr(args, "window_days", None)) + art_ledger = load_jsonl(metrics_dir / aa.LEDGER_NAME) + findings = load_jsonl(metrics_dir / FINDINGS_LEDGER_NAME) + + recurring = _safe(lambda: recurring_findings(findings, cutoff), []) + stale = _safe(lambda: repeat_stale(art_ledger, base_dir, sdlc_dir, cutoff), []) + funnel = _safe(lambda: refresh_funnel(base_dir, sdlc_dir, metrics_dir, art_ledger, cutoff), + {"by_spec": [], "by_stem": []}) + debt = _safe(lambda: debt_rollup(findings, art_ledger, base_dir, sdlc_dir, funnel), + {"findings": 0, "artifact_staleness": 0, "refresh_open": 0, "total": 0}) + + has_data = { + "recurring_findings": bool(recurring), + "repeat_stale": bool(stale), + "refresh_funnel": any( + (sp["detected"] + sp["drifted"] + sp["refreshed"] + sp["rejected"] + sp["open"]) > 0 + for sp in funnel.get("by_spec", [])), + # Debt is a real measurement (possibly 0) whenever either ledger exists; only truly-absent + # ledgers read "no data" (never a fabricated zero). + "debt": bool(art_ledger) or bool(findings), + } + return { + "has_data": has_data, + "recurring_findings": recurring, + "repeat_stale": stale, + "refresh_funnel": funnel, + "debt": debt, + "window_days": getattr(args, "window_days", None), + } + + +def format_report(p: dict) -> str: + hd = p["has_data"] + wd = p["window_days"] + window = f" (last {wd} days)" if wd else " (all history)" + L = [f"Retro Roll-up{window}", "=" * 60, ""] + + L.append("Recurring findings (candidates for a permanent check):") + if not hd["recurring_findings"]: + L.append(" no data") + else: + for g in p["recurring_findings"]: + disp = ", ".join(f"{k}={v}" for k, v in sorted(g["dispositions"].items())) or "none" + L.append(f" • {g['category'] or '—'} @ {g['target'] or '—'}") + L.append(f" seen {g['times']} times across {g['rounds']} rounds — " + f"candidate for a permanent check") + L.append(f" dispositions: {disp}") + L.append("") + + L.append("Repeat-stale artifacts (artifact-log.jsonl):") + if not hd["repeat_stale"]: + L.append(" no data") + else: + for r in p["repeat_stale"]: + cur = " · currently STALE" if r["currently_stale"] else "" + L.append(f" • {r['artifact']}") + L.append(f" flagged {r['flagged']} times " + f"({r['dispositioned']} dispositioned, {r['open']} open){cur}") + L.append("") + + L.append("Refresh funnel — divergence-heuristic tuning signal (artifact-log.jsonl):") + if not hd["refresh_funnel"]: + L.append(" no data") + else: + L.append(" by spec:") + for sp in p["refresh_funnel"]["by_spec"]: + if (sp["detected"] + sp["refreshed"] + sp["rejected"] + sp["open"]) == 0: + continue + name = f" {sp['name']}" if sp.get("name") else "" + arrow = aa._glyph("→", "->") + L.append(f" spec {sp['spec']}{name}: {sp['detected']} detected {arrow} " + f"{sp['drifted']} drifted {arrow} {sp['refreshed']} refreshed {arrow} " + f"{sp['rejected']} rejected {arrow} {sp['open']} still open") + L.append(" by upstream stem:") + for st in p["refresh_funnel"]["by_stem"]: + L.append(f" {st['stem']}: {st['detected']} detected · {st['drifted']} drifted · " + f"{st['refreshed']} applied · {st['rejected']} rejected · {st['open']} open") + L.append(f" {st['note']}") + L.append("") + + L.append("Disposition debt rollup (honest counting):") + if not hd["debt"]: + L.append(" no data") + else: + d = p["debt"] + L.append(f" findings debt (findings-log.jsonl): {d['findings']}") + L.append(f" artifact staleness (artifact-log.jsonl): {d['artifact_staleness']}") + L.append(f" refresh open (artifact-log.jsonl): {d['refresh_open']}") + L.append(f" total open debt: {d['total']}") + L.append("") + + L.append("=" * 60) + L.append("ADVISORY — read-only; patterns, not people; never blocks (exit 0).") + L.append("Never tracked: velocity, story points, PR count, lines of code. No per-person ranking.") + return "\n".join(L) + + +# --- CLI --------------------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Cross-ledger retro roll-up: recurring findings, repeat-stale artifacts, the " + "refresh funnel, and combined disposition debt (read-only, advisory; exit 0)") + src = p.add_mutually_exclusive_group() + src.add_argument("--state", help="Path to .sdlc/state.yaml (workflow mode)") + src.add_argument("--repo", default=".", help="Repo root containing .sdlc/ (standalone; default cwd)") + p.add_argument("--window-days", dest="window_days", type=int, default=None, + help="Only count time-stamped ledger events from the last N days (default: all history)") + p.add_argument("--json", action="store_true", help="Emit JSON (per-section has_data flags)") + return p + + +def main() -> None: + args = build_parser().parse_args() + try: + payload = build_payload(args) + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(format_report(payload)) + except SystemExit: + raise # resolve_paths exits 0 on a missing --state; honour it + except Exception as exc: # advisory: never a stack trace, never a non-zero exit + print(f"retro — could not complete the roll-up ({type(exc).__name__}); no data.") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_command_contracts.py b/scripts/tests/test_command_contracts.py new file mode 100644 index 0000000..43f665d --- /dev/null +++ b/scripts/tests/test_command_contracts.py @@ -0,0 +1,460 @@ +"""Static command-prose contract lint (the STATIC half of the command-prose safety harness). + +The plugin's 25+ commands/*.md files are prose instructions that embed real CLI +invocations. The scripts are unit-tested, but nothing catches a *doc* that names a +script, subcommand, flag, agent, or repo file that no longer exists — that +doc-to-script drift is the most common silent breakage (a renamed script, a +removed flag, a deleted agent). This module parses every fenced code block in +commands/*.md, extracts the plugin's `uv run ... scripts/.py` invocations, +and checks each one against ground truth: + + 1. the referenced scripts/.py exists; + 2. every subcommand + `--flag` used appears in the script's own `--help` + (captured live via subprocess, cached per (script, subcommand-tuple)); + 3. backticked agent references resolve to an agents/.md file; + 4. `references/.md` / `templates/<...>` paths mentioned in the doc exist. + +OUT OF SCOPE for v1: live "does the model actually follow the doc" evals. This +lint only checks that the invocations a doc names are *real* — not that the +surrounding prose reasons about them correctly. + +Conservative by construction — the lint must not cry wolf. Anything it cannot +classify with confidence (an ambiguous positional-vs-subcommand token, a +placeholder like ``/`[]`, a short flag, a script whose `--help` +does not exit 0) is SKIPPED, never reported. Genuine-but-intentional references +(e.g. a harness-installed target-repo agent that is not a plugin agent) are +carried in the module-level ALLOWLIST with a comment. + +Runtime: `--help` output is cached at module scope per (script, subcommand-tuple), +so the whole harness issues ~30 `uv run ... --help` subprocesses on a cold cache +and finishes well under 30s (measured ~5-8s locally; a single cached help call is +~0.1s). +""" + +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent.parent +COMMANDS_DIR = PLUGIN_ROOT / "commands" +SCRIPTS_DIR = PLUGIN_ROOT / "scripts" +AGENTS_DIR = PLUGIN_ROOT / "agents" + + +# --------------------------------------------------------------------------- +# ALLOWLIST — doc filename -> list of (substring, reason). A violation whose +# message contains an allowlisted substring for its doc is suppressed. Keep this +# empty unless a finding is genuinely intentional; every entry needs a reason. +# --------------------------------------------------------------------------- +ALLOWLIST: dict[str, list[tuple[str, str]]] = { + # `ux-reviewer` is a code-review sub-agent the *harness installer* writes into + # the target repo (composed from stack packs), not a plugin agents/*.md file — + # so it correctly has no agents/ux-reviewer.md. Referenced only in setup prose. + "sdlc-setup.md": [ + ("references agent `ux-reviewer`", "harness-installed target-repo reviewer, not a plugin agent"), + ], +} + + +# --------------------------------------------------------------------------- +# help capture (cached at module scope) +# --------------------------------------------------------------------------- +_HELP_CACHE: dict[tuple[str, tuple[str, ...]], tuple[int, str]] = {} + + +def get_help(script: str, chain: tuple[str, ...]) -> tuple[int, str]: + """Return (returncode, combined stdout+stderr) of `script --help`, cached.""" + key = (script, tuple(chain)) + if key in _HELP_CACHE: + return _HELP_CACHE[key] + cmd = [ + "uv", "run", "--project", str(SCRIPTS_DIR), + str(SCRIPTS_DIR / f"{script}.py"), + *chain, "--help", + ] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + res = (r.returncode, (r.stdout or "") + "\n" + (r.stderr or "")) + except Exception: # noqa: BLE001 - a help subprocess failing must never crash the lint + res = (1, "") + _HELP_CACHE[key] = res + return res + + +_CHOICE_GROUP_RE = re.compile(r"\{([a-z0-9_,-]+)\}") +_WORD_RE = re.compile(r"^[a-z][a-z0-9_-]*$") + + +def help_subcommands(script: str, chain: tuple[str, ...]) -> set[str]: + """Lowercase subcommand choices argparse advertises at this help level (empty if leaf/failed).""" + code, text = get_help(script, chain) + if code != 0: + return set() + out: set[str] = set() + for grp in _CHOICE_GROUP_RE.findall(text): + for tok in grp.split(","): + if _WORD_RE.match(tok): + out.add(tok) + return out + + +_FLAG_RE = re.compile(r"--[A-Za-z][A-Za-z0-9-]*") + + +def valid_flags(script: str, chain: tuple[str, ...]) -> set[str]: + """Union of --flags from the top level down to `chain` (so parent/global flags count too).""" + flags: set[str] = set() + for i in range(len(chain) + 1): + code, text = get_help(script, chain[:i]) + if code == 0: + flags.update(_FLAG_RE.findall(text)) + return flags + + +# --------------------------------------------------------------------------- +# markdown / invocation extraction +# --------------------------------------------------------------------------- +_SCRIPT_RE = re.compile(r"scripts/([A-Za-z0-9_]+)\.py") + + +def iter_fenced_blocks(text: str): + """Yield the inner text of every ``` fenced code block in a markdown document.""" + lines = text.splitlines() + inside = False + buf: list[str] = [] + for line in lines: + if line.lstrip().startswith("```"): + if inside: + yield "\n".join(buf) + buf = [] + inside = not inside + continue + if inside: + buf.append(line) + # an unterminated fence is ignored (defensive; docs are well-formed) + + +def join_continuations(block: str) -> list[str]: + """Join backslash-newline continuations, return the block's logical lines.""" + joined = re.sub(r"\\\n", " ", block) + return joined.splitlines() + + +def extract_invocations(block: str) -> list[tuple[str, str]]: + """From a fenced block, return (script_name, argstring) for each plugin script invocation.""" + out: list[tuple[str, str]] = [] + for line in join_continuations(block): + if "uv run" not in line or "scripts/" not in line: + continue + matches = list(_SCRIPT_RE.finditer(line)) + if not matches: + continue + # the `--project .../scripts` prefix has no `.py`; the real target is the + # last scripts/.py on the line. everything after it is the argstring. + m = matches[-1] + out.append((m.group(1), line[m.end():])) + return out + + +# --------------------------------------------------------------------------- +# argstring tokenisation +# --------------------------------------------------------------------------- +def _unwrap(tok: str) -> str: + """Strip wrapping quotes and brackets so `[--state`, `""`, `[]` classify cleanly.""" + prev = None + t = tok.strip() + while t and t != prev: + prev = t + if t and t[0] in "\"'([{": + t = t[1:] + if t and t[-1] in "\"')]}": + t = t[:-1] + return t + + +def _is_placeholder(core: str) -> bool: + return core == "" or "<" in core or ">" in core or "{" in core or core == "..." + + +def analyse_invocation(script: str, argstring: str) -> tuple[tuple[str, ...], list[str], list[str]]: + """Return (subcommand_chain, used_long_flags, skips) for one invocation. + + Subcommands are the leading positional tokens (before the first flag) that + argparse actually advertises as choices, at most two deep. `skips` records + anything intentionally not classified, for transparency only. + """ + raw = argstring.split() + tokens = [(_unwrap(t), t) for t in raw] + + # --- subcommand chain: only descend on tokens argparse lists as choices --- + chain: list[str] = [] + skips: list[str] = [] + for core, _orig in tokens: + if core.startswith("-"): + break # flags always follow the subcommand chain in these docs + if _is_placeholder(core): + break + if not _WORD_RE.match(core): + break + if len(chain) >= 2: + break + choices = help_subcommands(script, tuple(chain)) + if core in choices and get_help(script, tuple(chain) + (core,))[0] == 0: + chain.append(core) + continue + break # a positional argument (e.g. FR-012), not a subcommand + + # --- used long flags --- + used: list[str] = [] + for core, _orig in tokens: + if not core.startswith("--"): + continue + name = core.split("=", 1)[0] + if _FLAG_RE.fullmatch(name): + if name not in used: + used.append(name) + else: + skips.append(f"unclassifiable flag token {core!r}") + return tuple(chain), used, skips + + +# --------------------------------------------------------------------------- +# cross-reference extraction (agents, repo files) +# --------------------------------------------------------------------------- +_AGENT_KEBAB = r"[a-z][a-z0-9]*(?:-[a-z0-9]+)+" +_AGENT_REF_RES = [ + re.compile(r"`(" + _AGENT_KEBAB + r")`\s+(?:sub-?)?agents?\b", re.IGNORECASE), + re.compile(r"(?:sub-?)?agents?\s+`(" + _AGENT_KEBAB + r")`", re.IGNORECASE), +] +_FILE_REF_RE = re.compile(r"(?:references|templates)/[A-Za-z0-9._/-]+") + + +def agent_refs(text: str) -> set[str]: + """Backticked kebab tokens sitting next to the word 'agent' — the conservative agent signal.""" + out: set[str] = set() + for rx in _AGENT_REF_RES: + for m in rx.finditer(text): + out.add(m.group(1)) + return out + + +def file_refs(text: str) -> set[str]: + """Concrete references/ and templates/ paths (placeholder-bearing paths are skipped).""" + out: set[str] = set() + for m in _FILE_REF_RE.finditer(text): + nxt = text[m.end():m.end() + 1] + if nxt in "<{*$": # a placeholder immediately follows -> not a concrete path + continue + path = m.group(0).rstrip(".") + if path in ("references", "templates"): + continue + out.add(path) + return out + + +# --------------------------------------------------------------------------- +# the lint +# --------------------------------------------------------------------------- +def command_docs() -> list[Path]: + return sorted(COMMANDS_DIR.glob("*.md")) + + +def _allowlisted(doc: str, message: str) -> bool: + for sub, _reason in ALLOWLIST.get(doc, []): + if sub in message: + return True + return False + + +def scan_doc(name: str, text: str, agent_set: set[str]) -> tuple[list[str], int]: + """Raw (pre-allowlist) contract violations for one doc's text, + its invocation count. + + Factored out of collect_violations so the self-tests can drive the exact same + detection path with planted defects: a silent no-op here fails those tests too. + """ + raw: list[str] = [] + n_invocations = 0 + + # 1-3: script existence + subcommand/flag contracts + for block in iter_fenced_blocks(text): + for script, argstring in extract_invocations(block): + n_invocations += 1 + script_path = SCRIPTS_DIR / f"{script}.py" + if not script_path.exists(): + raw.append(f"{name}: references scripts/{script}.py — no such script") + continue + chain, used, _skips = analyse_invocation(script, argstring) + top_code, _ = get_help(script, ()) + if top_code != 0: + continue # can't introspect flags; skip rather than cry wolf + allowed = valid_flags(script, chain) + sub = (" " + " ".join(chain)) if chain else "" + for flag in used: + if flag not in allowed: + raw.append( + f"{name}: `{script}.py{sub}` uses {flag} — not in its --help" + ) + + # 4a: agent cross-references + for ref in sorted(agent_refs(text)): + if ref not in agent_set: + raw.append(f"{name}: references agent `{ref}` — no agents/{ref}.md") + + # 4b: repo file cross-references + for ref in sorted(file_refs(text)): + if not (PLUGIN_ROOT / ref).exists(): + raw.append(f"{name}: references {ref} — path does not exist") + + return raw, n_invocations + + +def collect_violations() -> tuple[list[str], list[str], int]: + """Return (violations, suppressed, invocation_count) across every command doc.""" + agent_set = {p.stem for p in AGENTS_DIR.glob("*.md")} + violations: list[str] = [] + suppressed: list[str] = [] + n_invocations = 0 + + for doc in command_docs(): + name = doc.name + text = doc.read_text(encoding="utf-8") + raw, n = scan_doc(name, text, agent_set) + n_invocations += n + for msg in raw: + (suppressed if _allowlisted(name, msg) else violations).append(msg) + + return violations, suppressed, n_invocations + + +# module-scope single pass (help cache makes the tests below share the work) +_VIOLATIONS, _SUPPRESSED, _N_INVOCATIONS = collect_violations() + + +def test_extraction_found_invocations(): + """Guard against a silently-broken parser: the docs really do embed script calls.""" + assert _N_INVOCATIONS >= 25, ( + f"only extracted {_N_INVOCATIONS} invocations — the parser is likely broken" + ) + + +def test_command_docs_reference_only_real_contracts(): + """Every script/subcommand/flag/agent/file a command doc names must actually exist.""" + assert not _VIOLATIONS, ( + "command-prose drift detected (doc references something that no longer exists):\n " + + "\n ".join(_VIOLATIONS) + ) + + +# --------------------------------------------------------------------------- +# self-tests — prove the lint actually FIRES on planted defects. Without these, +# a silent degradation of collect_violations() to a no-op (a broken regex, a +# swallowed exception, an over-broad allowlist) would leave the two tests above +# passing vacuously and the drift they guard against undetected. Each case below +# feeds synthetic markdown / a fake agent name through the real detection path +# (scan_doc, the same one collect_violations uses) and asserts the defect surfaces. +# --------------------------------------------------------------------------- +_TEST_AGENTS = {"orchestrator", "requirements-analyst"} # a couple of real stems + + +def _fence(*lines: str) -> str: + """Wrap lines in a ``` code fence so iter_fenced_blocks picks them up.""" + return "```\n" + "\n".join(lines) + "\n```" + + +def test_selftest_unknown_flag_is_reported(): + """A real script called with a flag absent from its --help must be flagged.""" + text = _fence("uv run --project scripts scripts/audit_artifacts.py report --no-such-flag") + raw, n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert n == 1 + assert any("--no-such-flag" in m for m in raw), raw + + +def test_selftest_real_flag_is_not_reported(): + """The flag path must not over-report: a genuine --json/--repo stays clean. + + This also proves --help introspection is live — if get_help silently returned + empty, every real flag would (wrongly) be reported and this would fail. + """ + text = _fence("uv run --project scripts scripts/audit_artifacts.py report --json --repo /tmp/x") + raw, n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert n == 1 + assert raw == [], raw + + +def test_selftest_nonexistent_script_is_reported(): + """An invocation of scripts/.py with no such file must be flagged.""" + text = _fence("uv run --project scripts scripts/does_not_exist.py report --json") + raw, _n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert any("does_not_exist.py" in m and "no such script" in m for m in raw), raw + + +def test_selftest_unknown_agent_ref_is_reported(): + """A backticked kebab name next to 'agent' with no agents/.md must be flagged.""" + text = "run the `made-up-agent` agent to do the thing" + raw, _n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert any("made-up-agent" in m for m in raw), raw + + +def test_selftest_known_agent_ref_is_not_reported(): + """A real agent name must not be flagged (guards against over-reporting).""" + text = "run the `requirements-analyst` agent to do the thing" + raw, _n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert raw == [], raw + + +def test_selftest_nonexistent_file_ref_is_reported(): + """A concrete references/ path that does not exist must be flagged.""" + text = "see references/does-not-exist.md for details" + raw, _n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert any("references/does-not-exist.md" in m for m in raw), raw + + +def test_selftest_two_level_subcommand_vs_positional(): + """analyse_invocation must descend a 2-level subcommand but stop at a positional.""" + chain2, _used2, _ = analyse_invocation( + "audit_artifacts", " version list requirements.md --repo /tmp/x" + ) + assert chain2 == ("version", "list") # both are argparse choices + chain1, used1, _ = analyse_invocation("audit_artifacts", " impact FR-012 --repo /tmp/x") + assert chain1 == ("impact",) # FR-012 is a positional, not a subcommand + assert "--repo" in used1 + + +def test_selftest_allowlist_suppresses_only_matching_message(): + """_allowlisted must suppress exactly its substring, for its doc only — nothing broader.""" + doc = "sdlc-setup.md" + assert _allowlisted( + doc, "sdlc-setup.md: references agent `ux-reviewer` — no agents/ux-reviewer.md" + ) + # a different agent under the same doc is NOT suppressed + assert not _allowlisted( + doc, "sdlc-setup.md: references agent `other-agent` — no agents/other-agent.md" + ) + # the same substring under a different doc is NOT suppressed + assert not _allowlisted("other.md", "references agent `ux-reviewer`") + + +def test_selftest_clean_input_yields_no_violations(): + """A fully valid invocation produces zero violations (baseline for the above).""" + text = _fence("uv run --project scripts scripts/audit_artifacts.py report --json") + raw, n = scan_doc("synthetic.md", text, _TEST_AGENTS) + assert n == 1 + assert raw == [], raw + + +if __name__ == "__main__": # manual run: python test_command_contracts.py + print(f"invocations parsed: {_N_INVOCATIONS}") + print(f"help subprocesses: {len(_HELP_CACHE)}") + if _SUPPRESSED: + print("suppressed (allowlisted):") + for s in _SUPPRESSED: + print(" " + s) + if _VIOLATIONS: + print("VIOLATIONS:") + for v in _VIOLATIONS: + print(" " + v) + sys.exit(1) + print("clean") diff --git a/scripts/tests/test_registry_docs_consistency.py b/scripts/tests/test_registry_docs_consistency.py index d3d79b7..19f3492 100644 --- a/scripts/tests/test_registry_docs_consistency.py +++ b/scripts/tests/test_registry_docs_consistency.py @@ -169,7 +169,9 @@ def test_the_additional_commands_count_matches_its_table(): section = re.search(r"## Additional Commands \(summaries\)(.*?)(?=\n## |\Z)", text, re.S) assert section, "the Additional Commands section was renamed or removed" - words = {"Six": 6, "Seven": 7, "Eight": 8, "Nine": 9, "Ten": 10, "Eleven": 11, "Twelve": 12} + words = {"Six": 6, "Seven": 7, "Eight": 8, "Nine": 9, "Ten": 10, "Eleven": 11, "Twelve": 12, + "Thirteen": 13, "Fourteen": 14, "Fifteen": 15, "Sixteen": 16, "Seventeen": 17, + "Eighteen": 18} stated = re.search(r"^(\w+) commands have their full flow", section.group(1), re.M) assert stated, "the count sentence was reworded — update this check or the sentence" diff --git a/scripts/tests/test_retro_report.py b/scripts/tests/test_retro_report.py new file mode 100644 index 0000000..bd08783 --- /dev/null +++ b/scripts/tests/test_retro_report.py @@ -0,0 +1,406 @@ +"""Tests for retro_report.py — the cross-ledger retro roll-up. + +Covers: recurrence detection (2 rounds = candidate, 1 = not), repeat-stale disposition counting and +the refresh-reject exclusion, currently-stale detection via compute_staleness, the refresh funnel +math (per-spec + by-stem aggregation, refreshed-via-source_spec attribution), honest no-data on an +empty repo, exit 0 on every path (parametrized), dual-mode --repo / --state, the stable --json +shape, --window-days filtering, and that output/JSON never carries an actor-keyed ranking. + +House conventions (run_cli via sys.argv + SystemExit + capsys, _write, tmp_path) are adapted from +test_version_refresh.py. +""" + +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +import audit_artifacts as aa +import retro_report as rr + +REQ = ".sdlc/artifacts/01-requirements/requirements.md" +EPICS = ".sdlc/artifacts/02-design/epics.md" +FINDINGS = ".sdlc/metrics/findings-log.jsonl" +ARTIFACT_LEDGER = ".sdlc/metrics/artifact-log.jsonl" + +ACTOR_SENTINEL = "ZZ_ACTOR_SENTINEL_ZZ" + + +# --- helpers ----------------------------------------------------------------------------------- + +def _write(p: Path, text: str) -> None: + """Write fixture content with LF endings on every platform. + + The `newline` argument is load-bearing, not cosmetic: the store captures — and + `version show` returns — the exact bytes on disk, so a plain text-mode write (which + expands each newline to CRLF on Windows) makes a byte-exact assertion pass on Linux + and fail on Windows. Every fixture write of artifact content goes through this helper + so the corpus is byte-identical on both.""" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8", newline="\n") + + +def _append_jsonl(p: Path, rows: list[dict]) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "a", encoding="utf-8") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + + +def run_cli(argv, capsys) -> tuple[int, str]: + old = sys.argv + sys.argv = ["retro_report.py"] + argv + try: + with pytest.raises(SystemExit) as ei: + rr.main() + code = ei.value.code + finally: + sys.argv = old + return code, capsys.readouterr().out + + +def run_aa(argv, capsys) -> str: + """Drive audit_artifacts (for real scans) — exits 0, output captured.""" + old = sys.argv + sys.argv = ["audit_artifacts.py"] + argv + try: + with pytest.raises(SystemExit): + aa.main() + finally: + sys.argv = old + return capsys.readouterr().out + + +def _json_out(argv, capsys) -> dict: + if "--json" not in argv: + argv = argv + ["--json"] + code, out = run_cli(argv, capsys) + assert code == 0 + return json.loads(out) + + +def _iso(days_ago: float = 0.0) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat() + + +def _finding(cat, target, ts, *, disp="OPEN", sev="HIGH", actor=ACTOR_SENTINEL) -> dict: + """A findings-log entry in the record_findings shape.""" + from findings_model import fingerprint + f = {"category": cat, "target": target} + return { + "timestamp": ts, "report": "review-report.md", + "id": "F-1", "category": cat, "severity": sev, "target": target, + "disposition": disp, "fingerprint": fingerprint(f), + "target_sha": None, "detail": "detail", "actor": actor, + } + + +def _disp_entry(downstream, upstream, ts, *, disposition="ACKNOWLEDGED", + owner="", reason="", actor=ACTOR_SENTINEL) -> dict: + """An artifact-log disposition entry (am.disposition_entry shape).""" + import artifact_model as am + return am.disposition_entry(ts=ts, downstream=downstream, upstream=upstream, + disposition=disposition, owner=owner, reason=reason, actor=actor) + + +def _write_upstreams(tmp_path: Path, *, req_hours="8") -> None: + _write(tmp_path / REQ, + "# Requirements\n\n## FR-001 Duplicate claim\n" + f"Reject a duplicate within {req_hours} hours; return HTTP 409.\n") + _write(tmp_path / EPICS, "# Epics\n\n## EP-01 Claims\nCovers FR-001.\n") + + +def _write_spec(tmp_path: Path, *, sid="0001", status="merged", + source="FR-001", accept="within 12 hours returns HTTP 409") -> str: + rel = f"specs/{sid}-thing.md" + _write(tmp_path / rel, + "---\n" + f'spec: "{sid}"\n' + f"name: thing {sid}\n" + f"status: {status}\n" + "risk: HIGH\n" + f"source: {source}\n" + 'channel: "—"\n' + "---\n" + f"# Thing {sid}\n\n" + "## Scope — in\nDo the thing.\n\n" + "## Scope — out\nNothing.\n\n" + "## Acceptance Checks\n" + f"- {accept}\n") + return rel + + +# --- recurrence -------------------------------------------------------------------------------- + +def test_recurrence_two_rounds_is_candidate_one_round_is_not(tmp_path, capsys): + fp = tmp_path / FINDINGS + # Finding A: same (category, target) in two distinct rounds (two timestamps) => candidate. + _append_jsonl(fp, [_finding("null-check", "auth.py:10", _iso(2))]) + _append_jsonl(fp, [_finding("null-check", "auth.py:10", _iso(1))]) + # Finding B: one round only => NOT a candidate. + _append_jsonl(fp, [_finding("style", "ui.py:3", _iso(1))]) + + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"]["recurring_findings"] is True + recs = data["recurring_findings"] + keys = {(r["category"], r["target"]) for r in recs} + assert ("null-check", "auth.py:10") in keys + assert ("style", "ui.py:3") not in keys + a = next(r for r in recs if r["category"] == "null-check") + assert a["times"] == 2 and a["rounds"] == 2 + + _, text = run_cli(["--repo", str(tmp_path)], capsys) + assert "seen 2 times across 2 rounds" in text + assert "candidate for a permanent check" in text + + +def test_recurrence_two_findings_same_round_is_not_recurring(tmp_path, capsys): + fp = tmp_path / FINDINGS + ts = _iso(1) + # Two entries, same round (shared timestamp) => 1 round => not a candidate. + _append_jsonl(fp, [_finding("null-check", "auth.py:10", ts), + _finding("null-check", "auth.py:10", ts)]) + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"]["recurring_findings"] is False + assert data["recurring_findings"] == [] + + +# --- repeat-stale ------------------------------------------------------------------------------ + +def test_repeat_stale_disposition_counting(tmp_path, capsys): + lp = tmp_path / ARTIFACT_LEDGER + # Two staleness dispositions on the same downstream: one off-books (reason), one open (no reason). + _append_jsonl(lp, [ + _disp_entry(EPICS, REQ, _iso(2), disposition="NOT_AFFECTED", reason="handled"), + _disp_entry(EPICS, REQ, _iso(1), disposition="ACKNOWLEDGED", owner=""), # no owner -> counts + ]) + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"]["repeat_stale"] is True + row = next(r for r in data["repeat_stale"] if r["artifact"] == EPICS) + assert row["flagged"] == 2 + assert row["dispositioned"] == 1 # the NOT_AFFECTED-with-reason + assert row["open"] == 1 # the ACKNOWLEDGED-without-owner still counts + + +def test_repeat_stale_excludes_refresh_reject_edges(tmp_path, capsys): + lp = tmp_path / ARTIFACT_LEDGER + # A refresh-reject reverse edge: upstream is a specs/* path -> belongs to the funnel, not here. + _append_jsonl(lp, [ + _disp_entry(REQ, "specs/0001-thing.md", _iso(1), + disposition="NOT_AFFECTED", reason="no ripple"), + ]) + data = _json_out(["--repo", str(tmp_path)], capsys) + # REQ must not appear as a repeat-stale artifact from the reject edge. + assert all(r["artifact"] != REQ for r in data["repeat_stale"]) + + +def test_repeat_stale_currently_stale_from_scan(tmp_path, capsys): + _write_upstreams(tmp_path) + run_aa(["record", "--scan", "--repo", str(tmp_path)], capsys) # v1 baseline (both created) + _write(tmp_path / REQ, + "# Requirements\n\n## FR-001 Duplicate claim\n" + "Reject a duplicate within 12 hours; return HTTP 409.\n") + run_aa(["record", "--scan", "--repo", str(tmp_path)], capsys) # requirements drifts later + data = _json_out(["--repo", str(tmp_path)], capsys) + epics = next((r for r in data["repeat_stale"] if r["artifact"] == EPICS), None) + assert epics is not None, "epics should be currently stale (upstream changed after it)" + assert epics["currently_stale"] is True + + +# --- refresh funnel ---------------------------------------------------------------------------- + +def _seed_refresh_corpus(tmp_path, capsys, *, sid="0001"): + """A merged spec drifting from requirements, plus injected refreshed + rejected ledger events.""" + _write_upstreams(tmp_path) # requirements says '8 hours' + spec_rel = _write_spec(tmp_path, sid=sid) # spec acceptance says '12 hours' -> drift + spec_node = spec_rel # already repo-relative + lp = tmp_path / ARTIFACT_LEDGER + # A refreshed change attributed to the spec via source_spec; a reject on epics via the spec edge. + import artifact_model as am + _append_jsonl(lp, [ + am.change_entry(ts=_iso(0.5), artifact=REQ, event="refreshed", + hash="sha256:deadbeefdeadbeef", actor=ACTOR_SENTINEL, + reason="auto-refresh") | {"source_spec": sid}, + _disp_entry(EPICS, spec_node, _iso(0.5), disposition="NOT_AFFECTED", reason="no ripple"), + ]) + return spec_node + + +def test_refresh_funnel_math_and_by_stem(tmp_path, capsys): + _seed_refresh_corpus(tmp_path, capsys, sid="0001") + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"]["refresh_funnel"] is True + + by_spec = {r["spec"]: r for r in data["refresh_funnel"]["by_spec"]} + assert "0001" in by_spec + sp = by_spec["0001"] + assert sp["detected"] >= 1 # requirements is an active candidate + assert sp["drifted"] >= 1 # the '12 hours' salient token is absent upstream + assert sp["refreshed"] == 1 # one refreshed change attributed to the spec + assert sp["rejected"] == 1 # one NOT_AFFECTED reject on epics + + by_stem = {r["stem"]: r for r in data["refresh_funnel"]["by_stem"]} + assert by_stem["requirements"]["refreshed"] == 1 + assert by_stem["requirements"]["detected"] >= 1 + assert by_stem["epics"]["rejected"] == 1 + assert "note" in by_stem["requirements"] + + +def test_refreshed_attributed_by_source_spec(tmp_path, capsys): + """A refreshed event's source_spec attributes it to exactly one spec, not the other.""" + _write_upstreams(tmp_path) + _write_spec(tmp_path, sid="0001") + _write_spec(tmp_path, sid="0002") + import artifact_model as am + _append_jsonl(tmp_path / ARTIFACT_LEDGER, [ + am.change_entry(ts=_iso(0.5), artifact=REQ, event="refreshed", + hash="sha256:deadbeefdeadbeef", actor=ACTOR_SENTINEL, + reason="auto-refresh") | {"source_spec": "0002"}, + ]) + data = _json_out(["--repo", str(tmp_path)], capsys) + by_spec = {r["spec"]: r for r in data["refresh_funnel"]["by_spec"]} + assert by_spec["0001"]["refreshed"] == 0 + assert by_spec["0002"]["refreshed"] == 1 + + +def test_stem_note_noisy_and_quiet(): + noisy = rr._stem_note(rr._empty_stem("business-rules") | {"rejected": 4, "refreshed": 0}) + assert "noisy" in noisy["note"] and "4 rejected / 0 applied" in noisy["note"] + quiet = rr._stem_note(rr._empty_stem("requirements") | {"detected": 3, "drifted": 0}) + assert "too tight" in quiet["note"] or "quiet" in quiet["note"] + + +# --- debt rollup ------------------------------------------------------------------------------- + +def test_debt_rollup_names_each_ledger(tmp_path, capsys): + # One open HIGH finding (debt) + a currently-stale artifact (debt). + _append_jsonl(tmp_path / FINDINGS, [_finding("null-check", "auth.py:10", _iso(1), disp="OPEN")]) + _write_upstreams(tmp_path) + run_aa(["record", "--scan", "--repo", str(tmp_path)], capsys) + _write(tmp_path / REQ, "# Requirements\n\n## FR-001\nwithin 12 hours; HTTP 409.\n") + run_aa(["record", "--scan", "--repo", str(tmp_path)], capsys) + + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"]["debt"] is True + assert data["debt"]["findings"] >= 1 + assert data["debt"]["artifact_staleness"] >= 1 + assert data["debt"]["total"] == (data["debt"]["findings"] + + data["debt"]["artifact_staleness"] + + data["debt"]["refresh_open"]) + + _, text = run_cli(["--repo", str(tmp_path)], capsys) + assert "findings-log.jsonl" in text + assert "artifact-log.jsonl" in text + + +# --- no data / empty repo ---------------------------------------------------------------------- + +def test_no_data_on_empty_repo(tmp_path, capsys): + (tmp_path / ".sdlc").mkdir(parents=True, exist_ok=True) + code, text = run_cli(["--repo", str(tmp_path)], capsys) + assert code == 0 + assert text.count("no data") == 4 # all four sections + data = _json_out(["--repo", str(tmp_path)], capsys) + assert data["has_data"] == {"recurring_findings": False, "repeat_stale": False, + "refresh_funnel": False, "debt": False} + + +# --- window filtering -------------------------------------------------------------------------- + +def test_window_days_filters_out_old_round(tmp_path, capsys): + fp = tmp_path / FINDINGS + _append_jsonl(fp, [_finding("null-check", "auth.py:10", _iso(400))]) # old round + _append_jsonl(fp, [_finding("null-check", "auth.py:10", _iso(1))]) # recent round + + # All history: 2 rounds => candidate. + full = _json_out(["--repo", str(tmp_path)], capsys) + assert full["has_data"]["recurring_findings"] is True + + # Last 30 days: only 1 round in window => not a candidate. + win = _json_out(["--repo", str(tmp_path), "--window-days", "30"], capsys) + assert win["has_data"]["recurring_findings"] is False + assert win["window_days"] == 30 + + +# --- dual mode --------------------------------------------------------------------------------- + +def test_dual_mode_repo_and_state_agree(tmp_path, capsys): + import yaml + _append_jsonl(tmp_path / FINDINGS, [_finding("null-check", "a.py:1", _iso(2)), + _finding("null-check", "a.py:1", _iso(1))]) + _write(tmp_path / ".sdlc/state.yaml", yaml.safe_dump({"phases": {}})) + + via_repo = _json_out(["--repo", str(tmp_path)], capsys) + via_state = _json_out(["--state", str(tmp_path / ".sdlc/state.yaml")], capsys) + assert via_repo["recurring_findings"] == via_state["recurring_findings"] + assert via_repo["has_data"] == via_state["has_data"] + + +def test_missing_state_exits_zero(tmp_path, capsys): + code, _ = run_cli(["--state", str(tmp_path / "nope/.sdlc/state.yaml")], capsys) + assert code == 0 + + +# --- json shape -------------------------------------------------------------------------------- + +def test_json_shape_is_stable(tmp_path, capsys): + (tmp_path / ".sdlc").mkdir(parents=True, exist_ok=True) + data = _json_out(["--repo", str(tmp_path)], capsys) + assert set(data.keys()) == {"has_data", "recurring_findings", "repeat_stale", + "refresh_funnel", "debt", "window_days"} + assert set(data["has_data"].keys()) == {"recurring_findings", "repeat_stale", + "refresh_funnel", "debt"} + assert set(data["refresh_funnel"].keys()) == {"by_spec", "by_stem"} + assert set(data["debt"].keys()) == {"findings", "artifact_staleness", "refresh_open", "total"} + assert data["window_days"] is None + + +# --- exit 0 on every path ---------------------------------------------------------------------- + +@pytest.mark.parametrize("argv_factory", [ + lambda tp: ["--repo", str(tp)], + lambda tp: ["--repo", str(tp), "--json"], + lambda tp: ["--repo", str(tp), "--window-days", "7"], + lambda tp: ["--repo", str(tp), "--window-days", "0"], # non-positive window => all history + lambda tp: ["--repo", str(tp), "--window-days", "-5"], + lambda tp: ["--state", str(tp / "missing/.sdlc/state.yaml")], + lambda tp: ["--repo", str(tp / "does-not-exist")], +]) +def test_exit_zero_every_path(tmp_path, capsys, argv_factory): + (tmp_path / ".sdlc/metrics").mkdir(parents=True, exist_ok=True) + code, _ = run_cli(argv_factory(tmp_path), capsys) + assert code == 0 + + +def test_exit_zero_on_garbage_ledgers(tmp_path, capsys): + _write(tmp_path / FINDINGS, "not json\n42\n[\"array\"]\n{\"category\": \"x\"}\n") + _write(tmp_path / ARTIFACT_LEDGER, "garbage\nnull\n") + code, _ = run_cli(["--repo", str(tmp_path)], capsys) + assert code == 0 + code, _ = run_cli(["--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + + +# --- patterns, not people ---------------------------------------------------------------------- + +def test_no_actor_ranking_anywhere(tmp_path, capsys): + """Every ledger entry carries a distinctive actor; it must never surface in output or JSON, and + no key may be actor-keyed (patterns are keyed by category / artifact / stem).""" + _append_jsonl(tmp_path / FINDINGS, [_finding("null-check", "auth.py:10", _iso(2)), + _finding("null-check", "auth.py:10", _iso(1))]) + _seed_refresh_corpus(tmp_path, capsys, sid="0001") + _append_jsonl(tmp_path / ARTIFACT_LEDGER, [ + _disp_entry(EPICS, REQ, _iso(1), disposition="ACKNOWLEDGED", owner="")]) + + code, text = run_cli(["--repo", str(tmp_path)], capsys) + assert code == 0 + assert ACTOR_SENTINEL not in text + + _, jtext = run_cli(["--repo", str(tmp_path), "--json"], capsys) + assert ACTOR_SENTINEL not in jtext + blob = jtext.lower() + assert "actor" not in blob # no actor key anywhere in the JSON + assert "by_actor" not in blob + assert "ranking" not in blob diff --git a/scripts/tests/test_version_model.py b/scripts/tests/test_version_model.py new file mode 100644 index 0000000..3796f2f --- /dev/null +++ b/scripts/tests/test_version_model.py @@ -0,0 +1,136 @@ +"""Tests for version_model — the pure content-version derivation (ordinal, dup-hash, baseline).""" + +from artifact_model import change_entry +from version_model import ( + hash_hex, + object_relpath, + resolve_version, + versions_for, +) + +TS0 = "2026-07-01T00:00:00+00:00" +TS1 = "2026-07-10T00:00:00+00:00" +TS2 = "2026-07-20T00:00:00+00:00" + +ART = ".sdlc/artifacts/01-requirements/requirements.md" + + +def _ledger(*hashes_events): + """Build a change ledger for ART from (ts, event, hash) triples, plus unrelated noise.""" + out = [change_entry(ts="x", artifact="other.md", event="created", hash="sha256:other")] + for ts, event, h in hashes_events: + out.append(change_entry(ts=ts, artifact=ART, event=event, hash=h, actor="kai")) + return out + + +# --- hash identity ---------------------------------------------------------------------------- + +class TestHashIdentity: + def test_hash_hex_strips_prefix(self): + assert hash_hex("sha256:abcd1234abcd1234") == "abcd1234abcd1234" + assert hash_hex("abcd") == "abcd" + assert hash_hex("") == "" + assert hash_hex(None) == "" + + def test_object_relpath_shards_on_first_two_hex(self): + assert object_relpath("sha256:ab12cd34ef567890") == "objects/ab/ab12cd34ef567890" + assert object_relpath("") == "" + + def test_object_relpath_is_hash_join(self): + # The store filename IS the ledger's 16-hex — the join key that makes the store the ledger + # rehydrated to bytes, with no second index. + h = "sha256:0123456789abcdef" + assert object_relpath(h).endswith(hash_hex(h)) + + +# --- version enumeration ---------------------------------------------------------------------- + +class TestVersionsFor: + def test_ordinal_in_ledger_order(self): + ledger = _ledger((TS0, "created", "sha256:v1"), (TS1, "revised", "sha256:v2"), + (TS2, "revised", "sha256:v3")) + rows = versions_for(ledger, ART) + assert [r["n"] for r in rows] == [1, 2, 3] + assert [r["hash"] for r in rows] == ["sha256:v1", "sha256:v2", "sha256:v3"] + assert [r["event"] for r in rows] == ["created", "revised", "revised"] + + def test_dup_hash_is_its_own_ordinal_and_marked_restored(self): + # A rollback re-introduces an earlier hash: it must be v4 (not a skipped ordinal) and carry + # restored_from pointing at the ordinal it first appeared as. + ledger = _ledger((TS0, "created", "sha256:a"), (TS1, "revised", "sha256:b"), + (TS2, "revised", "sha256:c"), ("2026-07-25T00:00:00+00:00", "revised", "sha256:b")) + rows = versions_for(ledger, ART) + assert [r["n"] for r in rows] == [1, 2, 3, 4] + assert "restored_from" not in rows[1] # v2 is the first 'b' + assert rows[3]["restored_from"] == 2 # v4 restores v2's content + + def test_present_reflects_store_set_only(self): + ledger = _ledger((TS0, "created", "sha256:a"), (TS1, "revised", "sha256:b")) + rows = versions_for(ledger, ART, present_hashes={"sha256:a"}) + assert rows[0]["present"] is True + assert rows[1]["present"] is False + + def test_baseline_synthesis_when_no_ledger_entry(self): + # A pre-existing artifact with no change entry -> a single v1 baseline from the current hash, + # never [] and never a KeyError. + rows = versions_for([], ART, current_hash="sha256:cur") + assert len(rows) == 1 + assert rows[0]["n"] == 1 and rows[0]["event"] == "baseline" + assert rows[0]["hash"] == "sha256:cur" + assert rows[0]["present"] is False # not in the (empty) store set + + def test_baseline_present_when_content_happens_to_be_stored(self): + rows = versions_for([], ART, current_hash="sha256:cur", present_hashes={"sha256:cur"}) + assert rows[0]["present"] is True + + def test_no_ledger_no_file_is_no_data(self): + assert versions_for([], ART) == [] + + +# --- reference resolution --------------------------------------------------------------------- + +class TestResolveVersion: + def _rows(self): + ledger = _ledger((TS0, "created", "sha256:aa11"), (TS1, "revised", "sha256:bb22"), + (TS2, "revised", "sha256:cc33")) + return versions_for(ledger, ART) + + def test_latest_and_default(self): + rows = self._rows() + assert resolve_version(rows, "latest")[0]["n"] == 3 + assert resolve_version(rows, "")[0]["n"] == 3 + + def test_prev(self): + rows = self._rows() + assert resolve_version(rows, "prev")[0]["n"] == 2 + + def test_prev_needs_two(self): + one = versions_for([], ART, current_hash="sha256:x") + row, note = resolve_version(one, "prev") + assert row is None and "only one" in note + + def test_vn_ordinal(self): + rows = self._rows() + assert resolve_version(rows, "v2")[0]["hash"] == "sha256:bb22" + row, note = resolve_version(rows, "v9") + assert row is None and "no version v9" in note + + def test_hash_prefix_unique(self): + rows = self._rows() + assert resolve_version(rows, "bb")[0]["n"] == 2 + assert resolve_version(rows, "sha256:cc")[0]["n"] == 3 + + def test_hash_prefix_ambiguous_picks_highest_and_lists(self): + ledger = _ledger((TS0, "created", "sha256:ab11"), (TS1, "revised", "sha256:ab22")) + rows = versions_for(ledger, ART) + row, note = resolve_version(rows, "ab") + assert row["n"] == 2 and "v1" in note and "v2" in note + + def test_unresolvable(self): + rows = self._rows() + row, note = resolve_version(rows, "zzz") + assert row is None and "could not resolve" in note + + def test_empty_versions(self): + row, note = resolve_version([], "latest") + assert row is None and note == "no versions" diff --git a/scripts/tests/test_version_refresh.py b/scripts/tests/test_version_refresh.py new file mode 100644 index 0000000..05fa33a --- /dev/null +++ b/scripts/tests/test_version_refresh.py @@ -0,0 +1,937 @@ +"""Tests for the artifact versioning + refresh layer folded into audit_artifacts. + +Covers the invariants the plan calls out: the hash-join (object filename === ledger 16-hex), the +canonical torn-write-safe mutate (post-image on disk after os.replace; kill-between-append-and-replace +reconciles), rollback's confirm hardening + refuse-to-uncaptured, cross-ledger-refcounted gc with +sign-off protection, and capture-is-best-effort (a store fault leaves record --scan byte-identical). +Every path asserts exit 0 (advisory). P4/P5 (refresh) extend this file. +""" + +import json +import re +import shutil +import sys +from pathlib import Path + +import pytest + +import artifact_model as am +import audit_artifacts as aa +import version_model as vm +from track_artifacts import compute_checksum + +REQ = ".sdlc/artifacts/01-requirements/requirements.md" + + +def _write(p: Path, text: str) -> None: + """Write fixture content with LF endings on every platform. + + The `newline` argument is load-bearing, not cosmetic: the store captures — and + `version show` returns — the exact bytes on disk, so a plain text-mode write (which + expands each newline to CRLF on Windows) makes a byte-exact assertion pass on Linux + and fail on Windows. Every fixture write of artifact content goes through this helper + so the corpus is byte-identical on both.""" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8", newline="\n") + + +def run_cli(argv, capsys) -> tuple[int, str]: + old = sys.argv + sys.argv = ["audit_artifacts.py"] + argv + try: + with pytest.raises(SystemExit) as ei: + aa.main() + code = ei.value.code + finally: + sys.argv = old + return code, capsys.readouterr().out + + +def _write_state(tmp_path: Path, *, signed: bool) -> None: + """Minimal state.yaml so load_state is truthy; the requirements phase is signed or not.""" + import yaml + pid = aa.phase_id_of(REQ) + pdata = {"status": "completed", "sign_off": True} if signed else {"status": "in_progress"} + sdlc = tmp_path / ".sdlc" + sdlc.mkdir(parents=True, exist_ok=True) + (sdlc / "state.yaml").write_text(yaml.safe_dump({"phases": {pid: pdata}}), encoding="utf-8") + + +def _two_versions(tmp_path: Path, capsys) -> str: + """Author requirements.md, baseline-scan (v1), drift + rescan (v2). Blobs land via the capture + seam. Returns the node key. No state.yaml (standalone --repo).""" + req = tmp_path / REQ + _write(req, "line one\nline two\n") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) + _write(req, "line one EDITED\nline two\nline three\n") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) + return REQ + + +def _diffhash(out: str) -> str | None: + m = re.search(r"--reviewed (sha256:[0-9a-f]+)", out) + return m.group(1) if m else None + + +# --- refresh corpus helpers (P4) -------------------------------------------------------------- + +EPICS = ".sdlc/artifacts/02-design/epics.md" + + +def _write_upstreams(tmp_path: Path, *, req_text: str | None = None) -> None: + """A minimal pre-Build corpus: requirements.md (owns FR-001, an '8 hours' SLA) + epics.md.""" + _write(tmp_path / REQ, req_text or + "# Requirements\n\n## FR-001 Duplicate claim\n" + "Reject a duplicate within 8 hours; return HTTP 409.\n") + _write(tmp_path / EPICS, "# Epics\n\n## EP-01 Claims\nCovers FR-001.\n") + + +def _spec_rel(sid: str = "0001") -> str: + return f"specs/{sid}-thing.md" + + +def _write_spec(tmp_path: Path, *, sid: str = "0001", status: str = "merged", + source: str = "FR-001", accept: str = "within 8 hours returns HTTP 409") -> str: + """Author a spec tracing to `source`, with one acceptance check. Returns its repo-relative path.""" + text = ( + "---\n" + f'spec: "{sid}"\n' + f"name: thing {sid}\n" + f"status: {status}\n" + "risk: HIGH\n" + f"source: {source}\n" + 'channel: "—"\n' + "---\n" + f"# Thing {sid}\n\n" + "## Scope — in\nDo the thing.\n\n" + "## Scope — out\nNothing.\n\n" + "## Acceptance Checks\n" + f"- {accept}\n" + ) + _write(tmp_path / _spec_rel(sid), text) + return _spec_rel(sid) + + +# --- refresh write-path helpers (P5) ---------------------------------------------------------- + +def _proposed(tmp_path: Path, spec_rel: str, stem: str) -> Path: + """The .proposed path for a stem (via the module, so the dir-keying convention is never dup'd).""" + return aa._proposed_path(tmp_path / ".sdlc", spec_rel, stem) + + +def _agent_edits(tmp_path: Path, spec_rel: str, stem: str, text: str) -> None: + """Simulate the discipline agent editing ONLY the .proposed draft.""" + _write(_proposed(tmp_path, spec_rel, stem), text) + + +def _apply(tmp_path, spec_rel, stem, capsys, *, actor="kai", reviewed=None, ack=True, extra=None): + argv = ["refresh", "apply", "--spec", spec_rel, stem, "--repo", str(tmp_path)] + if actor: + argv += ["--actor", actor] + if reviewed: + argv += ["--reviewed", reviewed] + if ack: + argv += ["--ack-signoff"] + if extra: + argv += extra + return run_cli(argv, capsys) + + +def _confirm_apply(tmp_path, spec_rel, stem, capsys, *, actor="kai", ack=True): + """Preview to harvest the diffhash, then apply for real. Returns (code, confirm_out).""" + _, prev = _apply(tmp_path, spec_rel, stem, capsys, actor=None, reviewed=None, ack=False) + dh = _diffhash(prev) + return _apply(tmp_path, spec_rel, stem, capsys, actor=actor, reviewed=dh, ack=ack) + + +# --- hash-join invariant ---------------------------------------------------------------------- + +class TestHashJoin: + def test_object_filename_is_the_ledger_hash(self, tmp_path): + data = b"# Requirements\nhello\n" + vdir = tmp_path / ".sdlc" / "versions" + h = aa.capture_bytes(vdir, data) + assert h == aa.hash_bytes(data) + rel = vm.object_relpath(h) + blob = vdir / rel + assert blob.is_file() + assert blob.name == vm.hash_hex(h) # filename IS the 16-hex — no second index + + def test_hash_bytes_equals_compute_checksum_of_same_bytes(self, tmp_path): + data = b"identical bytes\nacross both hashing paths\n" + f = tmp_path / "f.md" + f.write_bytes(data) + assert aa.hash_bytes(data) == compute_checksum(f) + + def test_capture_is_idempotent(self, tmp_path): + vdir = tmp_path / ".sdlc" / "versions" + data = b"same\n" + h1 = aa.capture_bytes(vdir, data) + h2 = aa.capture_bytes(vdir, data) # write-if-absent — second call is a no-op + assert h1 == h2 + assert len(list((vdir / "objects").rglob("*"))) >= 1 + + +# --- version list / show (P2, regression) ----------------------------------------------------- + +class TestListShow: + def test_list_shows_ordinals_and_capture_state(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + code, out = run_cli(["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + rows = json.loads(out)["versions"] + assert [r["n"] for r in rows] == [1, 2] + assert all(r["present"] for r in rows) # both captured by the scan seam + + def test_show_retrieves_old_content_after_drift(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) # disk is now v2 + code, out = run_cli(["version", "show", "requirements.md", "v1", "--repo", str(tmp_path)], capsys) + assert code == 0 + assert out == "line one\nline two\n" # the ORIGINAL, not what's on disk + + def test_baseline_synthesis_before_any_scan(self, tmp_path, capsys): + _write(tmp_path / REQ, "never scanned\n") + code, out = run_cli(["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys) + rows = json.loads(out)["versions"] + assert len(rows) == 1 and rows[0]["event"] == "baseline" + assert rows[0]["present"] is False # nothing captured yet + + +# --- diff ------------------------------------------------------------------------------------- + +class TestDiff: + def test_default_prev_to_latest(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + code, out = run_cli(["version", "diff", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 + assert "-line one" in out and "+line one EDITED" in out and "+line three" in out + + def test_single_version_nothing_to_compare(self, tmp_path, capsys): + _write(tmp_path / REQ, "only one\n") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) + code, out = run_cli(["version", "diff", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 and "only one version" in out + + def test_missing_blob_degrades(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + # evict the v1 blob, then diff must degrade (not crash). + rows = json.loads(run_cli( + ["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys)[1])["versions"] + v1 = next(r for r in rows if r["n"] == 1) + (tmp_path / ".sdlc" / "versions" / vm.object_relpath(v1["hash"])).unlink() + code, out = run_cli(["version", "diff", "requirements.md", "v1", "v2", "--repo", str(tmp_path)], capsys) + assert code == 0 and "content not captured" in out + # multi-machine honesty: the message explains WHY (local & gitignored) and what to do. + assert "local and gitignored" in out + assert "record --scan" in out and "references/artifact-versioning.md" in out + + +# --- rollback: canonical mutate + confirm hardening ------------------------------------------- + +class TestRollback: + def test_preview_writes_no_change_and_offers_diffhash(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + before = (tmp_path / REQ).read_text(encoding="utf-8") + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys) + assert code == 0 and _diffhash(out) + assert (tmp_path / REQ).read_text(encoding="utf-8") == before # preview never writes + + def test_confirm_applies_and_is_append_only_restore(self, tmp_path, capsys): + # Standalone --repo has no state.yaml, so sign-off can't be verified -> --ack-signoff required + # (the plan's "degrade to a generic warning still requiring the flag"). + node = _two_versions(tmp_path, capsys) + dh = _diffhash(run_cli(["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys)[1]) + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--ack-signoff", "--repo", str(tmp_path)], capsys) + assert code == 0 and "Rolled back" in out + # disk is the original v1 content again + assert (tmp_path / node).read_text(encoding="utf-8") == "line one\nline two\n" + # append-only: a new v3 whose content is v1's -> rendered "restored from v1" + rows = json.loads(run_cli( + ["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys)[1])["versions"] + assert [r["n"] for r in rows] == [1, 2, 3] + assert rows[2]["event"] == "revised" and rows[2]["restored_from"] == 1 + + def test_mutate_leaves_post_image_hash_on_disk(self, tmp_path, capsys): + """B1 forward direction: after os.replace, the ledger's latest hash == the file's hash.""" + node = _two_versions(tmp_path, capsys) + dh = _diffhash(run_cli(["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys)[1]) + run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--ack-signoff", "--repo", str(tmp_path)], capsys) + ledger = aa.load_ledger(tmp_path / ".sdlc" / "metrics" / "artifact-log.jsonl") + latest = am.latest_change_per_artifact(ledger)[node] + assert latest["hash"] == compute_checksum(tmp_path / node) + + def test_agent_actor_is_refused(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + dh = _diffhash(run_cli(["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys)[1]) + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", + "--actor", "requirements-analyst", "--reviewed", dh, "--repo", str(tmp_path)], capsys) + assert code == 0 and "discipline agent" in out and "One Rule" in out + + def test_confirm_without_reviewed_is_refused(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", + "--actor", "kai", "--repo", str(tmp_path)], capsys) + assert code == 0 and "--reviewed" in out + + def test_stale_reviewed_is_refused(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", "sha256:deadbeefdeadbeef", "--repo", str(tmp_path)], capsys) + assert code == 0 and "does not match" in out + + def test_rollback_to_uncaptured_refuses(self, tmp_path, capsys): + """Edge E2: a rollback target with no blob refuses — never os.replace from a missing object.""" + _two_versions(tmp_path, capsys) + rows = json.loads(run_cli( + ["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys)[1])["versions"] + v1 = next(r for r in rows if r["n"] == 1) + (tmp_path / ".sdlc" / "versions" / vm.object_relpath(v1["hash"])).unlink() + before = (tmp_path / REQ).read_text(encoding="utf-8") + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", "sha256:whatever", "--repo", str(tmp_path)], capsys) + assert code == 0 and "content not captured" in out + # the refuse message carries the multi-machine hint (why + remedy), not just the bare refusal. + assert "local and gitignored" in out and "record --scan" in out + assert (tmp_path / REQ).read_text(encoding="utf-8") == before # nothing written + + def test_idempotent_rollback_to_current_is_noop(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + code, out = run_cli(["version", "rollback", "requirements.md", "latest", "--repo", str(tmp_path)], capsys) + assert code == 0 and "already matches" in out + + def test_signed_off_needs_ack(self, tmp_path, capsys): + node = _two_versions(tmp_path, capsys) + _write_state(tmp_path, signed=True) # requirements phase now signed off + dh = _diffhash(run_cli(["version", "rollback", "requirements.md", "v1", + "--state", str(tmp_path / ".sdlc" / "state.yaml")], capsys)[1]) + # without --ack-signoff -> refuse + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--state", str(tmp_path / ".sdlc" / "state.yaml")], capsys) + assert code == 0 and "ack-signoff" in out + assert (tmp_path / node).read_text(encoding="utf-8") != "line one\nline two\n" + # with the flag -> applies + code, out = run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--ack-signoff", + "--state", str(tmp_path / ".sdlc" / "state.yaml")], capsys) + assert code == 0 and "Rolled back" in out + + +# --- torn-write recovery ---------------------------------------------------------------------- + +class TestRecovery: + def test_journal_completes_interrupted_replace(self, tmp_path): + vdir = tmp_path / ".sdlc" / "versions" + target = tmp_path / REQ + _write(target, "OLD\n") + newbytes = b"NEW COMMITTED CONTENT\n" + h = aa.capture_bytes(vdir, newbytes) # post-image object exists + aa._write_pending(vdir, REQ, h) # ledger appended, killed before os.replace + aa.recover_pending(tmp_path, vdir) + assert target.read_bytes() == newbytes # replace redone + assert not aa._pending_path(vdir).exists() # journal cleared + + def test_no_journal_leaves_drift_untouched(self, tmp_path): + """Ordinary un-scanned drift must NEVER be clobbered — recovery fires only on the journal.""" + vdir = tmp_path / ".sdlc" / "versions" + target = tmp_path / REQ + _write(target, "user's unsaved edit\n") + aa.capture_bytes(vdir, b"some other content\n") # a blob exists, but no journal + aa.recover_pending(tmp_path, vdir) + assert target.read_text(encoding="utf-8") == "user's unsaved edit\n" + + def test_recovery_survives_corrupt_journal(self, tmp_path): + vdir = tmp_path / ".sdlc" / "versions" + vdir.mkdir(parents=True, exist_ok=True) + aa._pending_path(vdir).write_text("{not json", encoding="utf-8") + aa.recover_pending(tmp_path, vdir) # must not raise + assert not aa._pending_path(vdir).exists() + + +# --- gc --------------------------------------------------------------------------------------- + +class TestGc: + def test_unknown_signoff_protects_everything(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) # --repo, no state.yaml -> sign-off unknown + code, out = run_cli(["version", "gc", "--keep", "1", "--repo", str(tmp_path)], capsys) + assert code == 0 and "sign-off status unknown" in out and "nothing to prune" in out + + def test_evicts_non_retained_when_signoff_known(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + _write_state(tmp_path, signed=False) # known + unsigned -> not protected + state = str(tmp_path / ".sdlc" / "state.yaml") + code, out = run_cli(["version", "gc", "--keep", "1", "--state", state], capsys) + assert code == 0 and "1 object(s) prunable" in out + # preview only — the object is still on disk + assert list((tmp_path / ".sdlc" / "versions" / "objects").rglob("*sha256*")) or \ + any((tmp_path / ".sdlc" / "versions" / "objects").rglob("*")) + code, out = run_cli(["version", "gc", "--keep", "1", "--apply", "--state", state], capsys) + assert code == 0 and "pruned 1 object" in out + + def test_signed_off_artifact_is_protected(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + _write_state(tmp_path, signed=True) + state = str(tmp_path / ".sdlc" / "state.yaml") + code, out = run_cli(["version", "gc", "--keep", "1", "--apply", "--state", state], capsys) + assert code == 0 and "nothing to prune" in out + + def test_shared_hash_retained_across_artifacts(self, tmp_path, capsys): + """Dedup: a hash that is an old version of one artifact but the latest of another survives.""" + node = _two_versions(tmp_path, capsys) + # roll back to v1 -> v3 shares v1's hash; that hash is now the LATEST, must never be evicted. + dh = _diffhash(run_cli(["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys)[1]) + run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--ack-signoff", "--repo", str(tmp_path)], capsys) + _write_state(tmp_path, signed=False) + state = str(tmp_path / ".sdlc" / "state.yaml") + run_cli(["version", "gc", "--keep", "1", "--apply", "--state", state], capsys) + # v1's content is still retrievable because v3 (latest) shares its hash. + code, out = run_cli(["version", "show", "requirements.md", "v1", "--state", state], capsys) + assert code == 0 and out == "line one\nline two\n" + + +# --- multi-machine honesty: the enriched missing-blob hint ------------------------------------ + +class TestMultiMachineHint: + """Every degraded-content message explains the local/gitignored store honestly and says what to + do — so a user on a second machine (fresh clone, CI, post-gc, store fault) doesn't read a bug.""" + + def test_show_missing_blob_prints_hint(self, tmp_path, capsys): + _write(tmp_path / REQ, "never scanned\n") # baseline, nothing captured (present=False) + code, out = run_cli(["version", "show", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 and "content not captured" in out + assert "local and gitignored" in out + assert "record --scan" in out and "references/artifact-versioning.md" in out + + def test_list_fresh_clone_prints_footer_hint(self, tmp_path, capsys): + """Zero captured versions (the fresh-clone case) -> the hint is printed once as a footer.""" + _write(tmp_path / REQ, "never scanned\n") + code, out = run_cli(["version", "list", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 and "[content not captured]" in out # the per-row tag is still there + assert "local and gitignored" in out and "record --scan" in out + + def test_list_all_captured_does_not_spam_hint(self, tmp_path, capsys): + """No missing blobs -> no footer hint (don't cry wolf when every version is present).""" + _two_versions(tmp_path, capsys) # both captured by the scan seam + code, out = run_cli(["version", "list", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 + assert "content not captured" not in out # nothing degraded -> no hint + assert "record --scan" not in out + + def test_list_mixed_history_prints_one_line_pointer(self, tmp_path, capsys): + """Some-but-not-all captured (single-blob gc / store fault) -> a one-line pointer, not the + full two-line note — the tagged row still gets an explanation without footer spam.""" + _two_versions(tmp_path, capsys) + rows = json.loads(run_cli( + ["version", "list", "requirements.md", "--repo", str(tmp_path), "--json"], capsys)[1])["versions"] + v1 = next(r for r in rows if r["n"] == 1) + (tmp_path / ".sdlc" / "versions" / vm.object_relpath(v1["hash"])).unlink() # evict v1 only + code, out = run_cli(["version", "list", "requirements.md", "--repo", str(tmp_path)], capsys) + assert code == 0 and "[content not captured]" in out + assert "1 version(s) not captured on this machine" in out + assert "references/artifact-versioning.md" in out + assert "record --scan" not in out # the full note is NOT printed + + def test_gc_apply_points_at_recovery(self, tmp_path, capsys): + """After a prune, gc says the pruned versions now degrade AND how to recover / go portable.""" + _two_versions(tmp_path, capsys) + _write_state(tmp_path, signed=False) + state = str(tmp_path / ".sdlc" / "state.yaml") + code, out = run_cli(["version", "gc", "--keep", "1", "--apply", "--state", state], capsys) + assert code == 0 and "pruned 1 object" in out + assert "local and gitignored" in out and "references/artifact-versioning.md" in out + + +# --- capture best-effort ---------------------------------------------------------------------- + +class TestCaptureBestEffort: + def test_store_fault_leaves_scan_byte_identical(self, tmp_path, capsys, monkeypatch): + """An unwritable object store must not change record --scan's exit / stdout / ledger.""" + _write(tmp_path / REQ, "content\n") + monkeypatch.setattr(aa, "capture_bytes", lambda *a, **k: None) # store fully unwritable + code, out = run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) + assert code == 0 and "Baseline recorded" in out + ledger = aa.load_ledger(tmp_path / ".sdlc" / "metrics" / "artifact-log.jsonl") + assert any(e.get("artifact") == REQ and e.get("event") == "created" for e in ledger) + assert not (tmp_path / ".sdlc" / "versions" / "objects").exists() # no blobs, but ledger intact + + +# --- refresh detect: divergence-aware, review-first (P4) -------------------------------------- + +class TestRefreshDetect: + def test_surfaces_substantiated_upstream_with_discipline(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") # faithful + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + cands = json.loads(out)["candidates"] + assert len(cands) == 1 # only the substantiated FR-001 owner + c = cands[0] + assert c["target"] == REQ + assert c["discipline"] == "requirements-analyst" # DISCIPLINE_BY_STEM, emitted per candidate + assert c["basis"] == "id-reference" + assert c["confidence"] == "declared" + assert c["drift"] is False # faithful spec -> trace-only + + def test_faithful_spec_is_review_only_without_draft(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 + assert "trace-only" in out and "review only" in out + assert "would draft" not in out # review-first default: no draft + + def test_drift_makes_candidate_draft_eligible(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") # 4h != upstream 8h + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "DRIFT" in out and "would draft" in out + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert json.loads(out)["candidates"][0]["drift"] is True + + def test_draft_flag_makes_faithful_eligible(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") + code, out = run_cli(["refresh", "detect", "--spec", spec, "--draft", "--repo", str(tmp_path)], capsys) + assert code == 0 and "would draft" in out # --draft widens eligibility to declared + + def test_phantom_id_yields_no_candidate(self, tmp_path, capsys): + """R2: a spec citing an id no upstream declares surfaces nothing — never a fabricated target.""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, source="FR-999") # FR-999 absent from requirements.md + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 and json.loads(out)["candidates"] == [] + _, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert "No traceable upstream" in out + + def test_no_source_yields_nudge(self, tmp_path, capsys): + """R2: source: — (no id anywhere) -> zero candidates + the nudge, never a coarse fabrication.""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, source="—") + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "No traceable upstream" in out + + def test_non_pre_build_upstream_filtered(self, tmp_path, capsys): + """R1: an NFR reference maps to non-functional-requirements.md, NOT a PRE_BUILD stem -> filtered.""" + _write(tmp_path / ".sdlc/artifacts/01-requirements/non-functional-requirements.md", + "# NFR\n\n## NFR-01 Latency\nUnder 200 ms.\n") + spec = _write_spec(tmp_path, source="NFR-01") + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 and json.loads(out)["candidates"] == [] + + def test_coarse_listed_but_never_draftable(self, tmp_path, capsys): + """--include-coarse surfaces a phase-order guess, but it is never draft-eligible (even faithful).""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, source="—") # no declared upstream -> coarse edges exist + code, out = run_cli(["refresh", "detect", "--spec", spec, "--include-coarse", + "--draft", "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + cands = json.loads(out)["candidates"] + assert cands and all(c["confidence"] == "coarse" for c in cands) + _, out = run_cli(["refresh", "detect", "--spec", spec, "--include-coarse", "--draft", + "--repo", str(tmp_path)], capsys) + assert "never auto-drafted" in out and "would draft" not in out + + def test_already_fresher_is_suppressed(self, tmp_path, capsys): + """An upstream changed AFTER the spec last did is suppressed as already-fresher (not a candidate).""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) # baseline: spec + req at ts0 + _write(tmp_path / REQ, "# Requirements\n\n## FR-001\nNow within 4 hours; HTTP 409.\n") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) # req now at ts1 > spec ts0 + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + cands = json.loads(out)["candidates"] + assert len(cands) == 1 and cands[0]["already_fresher"] is True + _, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert "already fresher" in out and "would draft" not in out + + def test_non_merged_spec_notes_status(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, status="ready", accept="within 4 hours returns HTTP 409") + code, out = run_cli(["refresh", "detect", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "not 'merged'" in out # still runs (advisory), but flags the status + + def test_unresolvable_spec_exits_zero(self, tmp_path, capsys): + _write_upstreams(tmp_path) + code, out = run_cli(["refresh", "detect", "--spec", "specs/nope.md", "--repo", str(tmp_path)], capsys) + assert code == 0 and "could not find spec" in out + + def test_detect_writes_nothing(self, tmp_path, capsys): + """Detection is side-effect-free: no ledger, no object store, no refresh dir.""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["refresh", "detect", "--spec", spec, "--draft", "--repo", str(tmp_path)], capsys) + assert not (tmp_path / ".sdlc" / "metrics" / "artifact-log.jsonl").exists() + assert not (tmp_path / ".sdlc" / "versions").exists() + assert not (tmp_path / ".sdlc" / "refresh").exists() + + +# --- refresh scan: rollup across merged specs (P4) -------------------------------------------- + +class TestRefreshScan: + def test_counts_merged_and_drift(self, tmp_path, capsys): + _write_upstreams(tmp_path) + _write_spec(tmp_path, sid="0001", accept="within 4 hours returns HTTP 409") # merged + drift + _write_spec(tmp_path, sid="0002", status="ready", accept="within 4 hours") # not merged + code, out = run_cli(["refresh", "scan", "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + data = json.loads(out) + assert data["merged_specs"] == 1 # only the merged spec is scanned + assert data["drifted_total"] == 1 + + def test_no_merged_specs(self, tmp_path, capsys): + _write_upstreams(tmp_path) + _write_spec(tmp_path, status="ready") + code, out = run_cli(["refresh", "scan", "--repo", str(tmp_path)], capsys) + assert code == 0 and "no merged specs" in out + + def test_scan_writes_nothing(self, tmp_path, capsys): + _write_upstreams(tmp_path) + _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["refresh", "scan", "--repo", str(tmp_path)], capsys) + assert not (tmp_path / ".sdlc" / "metrics" / "artifact-log.jsonl").exists() + assert not (tmp_path / ".sdlc" / "versions").exists() + + +# --- refresh draft: seed .proposed + candidates.json (P5) ------------------------------------- + +class TestRefreshDraft: + def test_drafts_drifted_candidate(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") # drifts + code, out = run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "Staged 1 draft" in out + pp = _proposed(tmp_path, spec, "requirements") + assert pp.is_file() + assert pp.read_text() == (tmp_path / REQ).read_text() # seeded with the CURRENT upstream + + def test_candidates_json_pins_hash_and_discipline(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + data = json.loads(aa._candidates_path(tmp_path / ".sdlc", spec).read_text()) + rec = data["candidates"][0] + assert rec["stem"] == "requirements" + assert rec["discipline"] == "requirements-analyst" + assert rec["upstream_hash"] == aa.hash_bytes((tmp_path / REQ).read_bytes()) # pinned to bytes copied + + def test_review_first_no_draft_without_flag(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") # faithful, no drift + code, out = run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "no drift detected" in out and "Pass --draft" in out + assert not _proposed(tmp_path, spec, "requirements").exists() + + def test_draft_flag_drafts_faithful(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") + code, out = run_cli(["refresh", "draft", "--spec", spec, "--draft", "--repo", str(tmp_path)], capsys) + assert code == 0 and "Staged 1 draft" in out + assert _proposed(tmp_path, spec, "requirements").is_file() + + def test_redraft_overwrites_and_warns(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + _agent_edits(tmp_path, spec, "requirements", "stale edit\n") + code, out = run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "re-drafting" in out and "overwritten" in out + # the re-draft re-seeds from the current upstream, discarding the stale edit + assert _proposed(tmp_path, spec, "requirements").read_text() == (tmp_path / REQ).read_text() + + def test_no_candidate_nudges(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, source="—") + code, out = run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + assert code == 0 and "no draftable upstream" in out + + +# --- refresh apply: named-human confirm, guards, canonical mutate (P5) ------------------------ + +class TestRefreshApply: + def _drift_and_draft(self, tmp_path, capsys, *, sid="0001"): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, sid=sid, accept="within 4 hours returns HTTP 409") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) # baseline v1 + run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + _agent_edits(tmp_path, spec, "requirements", + "# Requirements\n\n## FR-001 Duplicate claim\n" + "Reject a duplicate within 4 hours; return HTTP 409.\n") + return spec + + def test_preview_does_not_touch_the_real_file(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + before = (tmp_path / REQ).read_text() + code, out = _apply(tmp_path, spec, "requirements", capsys, actor=None, reviewed=None, ack=False) + assert code == 0 and "preview only" in out and _diffhash(out) + assert (tmp_path / REQ).read_text() == before # untouched + + def test_agent_actor_rejected(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + _, prev = _apply(tmp_path, spec, "requirements", capsys, actor=None, ack=False) + code, out = _apply(tmp_path, spec, "requirements", capsys, + actor="requirements-analyst", reviewed=_diffhash(prev)) + assert code == 0 and "discipline agent" in out and "One Rule" in out + assert "4 hours" not in (tmp_path / REQ).read_text() # nothing written + + def test_wrong_reviewed_rejected(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + code, out = _apply(tmp_path, spec, "requirements", capsys, reviewed="sha256:deadbeef") + assert code == 0 and "does not match" in out + assert "4 hours" not in (tmp_path / REQ).read_text() + + def test_confirm_applies_and_records_refreshed_with_source_spec(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + code, out = _confirm_apply(tmp_path, spec, "requirements", capsys) + assert code == 0 and "Refreshed" in out + assert "within 4 hours" in (tmp_path / REQ).read_text() # the real artifact moved + ledger = aa.load_ledger(tmp_path / ".sdlc" / "metrics" / "artifact-log.jsonl") + refreshed = [e for e in ledger if e.get("event") == "refreshed"] + assert len(refreshed) == 1 + assert refreshed[0]["source_spec"] == "0001" # additive rider, per-spec attribution + assert refreshed[0]["artifact"] == REQ + + def test_apply_is_rollback_able(self, tmp_path, capsys): + """The safety net: a refresh lands as an append-only version, restorable to the pre-image.""" + spec = self._drift_and_draft(tmp_path, capsys) + _confirm_apply(tmp_path, spec, "requirements", capsys) + versions = aa._versions_of(tmp_path, tmp_path / ".sdlc" / "metrics", + tmp_path / ".sdlc" / "versions", REQ) + assert [v["event"] for v in versions] == ["created", "refreshed"] + _, prev = run_cli(["version", "rollback", REQ, "prev", "--repo", str(tmp_path)], capsys) + run_cli(["version", "rollback", REQ, "prev", "--confirm", "--actor", "kai", + "--reviewed", _diffhash(prev), "--ack-signoff", "--repo", str(tmp_path)], capsys) + assert "within 8 hours" in (tmp_path / REQ).read_text() # restored the pre-refresh content + + def test_cleans_up_draft_on_apply(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + _confirm_apply(tmp_path, spec, "requirements", capsys) + assert not _proposed(tmp_path, spec, "requirements").exists() + assert not aa._candidates_path(tmp_path / ".sdlc", spec).exists() + + def test_staleness_guard_when_upstream_moves(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + _write(tmp_path / REQ, "# Requirements\n\n## FR-001\nmoved on disk after draft\n") + _, prev = _apply(tmp_path, spec, "requirements", capsys, actor=None, ack=False) + code, out = _apply(tmp_path, spec, "requirements", capsys, reviewed="sha256:whatever") + assert code == 0 and "moved since the draft" in out + + def test_identical_draft_is_noop(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 8 hours returns HTTP 409") + run_cli(["refresh", "draft", "--spec", spec, "--draft", "--repo", str(tmp_path)], capsys) + # agent left the .proposed untouched (identical to the upstream) + code, out = _apply(tmp_path, spec, "requirements", capsys, actor=None, ack=False) + assert code == 0 and "identical" in out and "no edit" in out + + def test_missing_draft_errors(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + code, out = _apply(tmp_path, spec, "requirements", capsys) + assert code == 0 and "no draft for stem" in out + + def test_signoff_gate_blocks_without_ack(self, tmp_path, capsys): + spec = self._drift_and_draft(tmp_path, capsys) + _write_state(tmp_path, signed=True) # requirements phase signed off + code, out = _apply(tmp_path, spec, "requirements", capsys, ack=False, + reviewed=None, actor="kai") + # no --reviewed yet -> preview; harvest hash then confirm without --ack-signoff + _, prev = _apply(tmp_path, spec, "requirements", capsys, actor=None, ack=False) + code, out = _apply(tmp_path, spec, "requirements", capsys, reviewed=_diffhash(prev), ack=False) + assert code == 0 and "signed-off" in out and "--ack-signoff" in out + assert "within 4 hours" not in (tmp_path / REQ).read_text() + + +# --- refresh reject: NOT_AFFECTED on the reverse edge (P5) ------------------------------------ + +class TestRefreshReject: + def _spec_citing_br(self, tmp_path): + _write_upstreams(tmp_path) + _write(tmp_path / ".sdlc/artifacts/01-requirements/business-rules.md", + "# Business Rules\n\n## BR-01 Retention\nKeep logs 90 days.\n") + return _write_spec(tmp_path, accept="within 4 hours; relates to BR-01") + + def test_reject_with_reason_is_off_books(self, tmp_path, capsys): + spec = self._spec_citing_br(tmp_path) + code, out = run_cli(["refresh", "reject", "--spec", spec, "business-rules", + "--reason", "retention unaffected", "--owner", "jane", + "--repo", str(tmp_path)], capsys) + assert code == 0 and "NOT_AFFECTED" in out and "off the books" in out + + def test_reject_without_reason_still_counts(self, tmp_path, capsys): + spec = self._spec_citing_br(tmp_path) + code, out = run_cli(["refresh", "reject", "--spec", spec, "business-rules", + "--repo", str(tmp_path)], capsys) + assert code == 0 and "STILL COUNTS" in out and "--reason" in out + + def test_reject_non_candidate_refused(self, tmp_path, capsys): + _write_upstreams(tmp_path) # spec cites only FR-001 + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + code, out = run_cli(["refresh", "reject", "--spec", spec, "business-rules", + "--reason", "x", "--repo", str(tmp_path)], capsys) + assert code == 0 and "no candidate upstream" in out + + def test_reject_edge_invisible_to_forward_report(self, tmp_path, capsys): + """R4: the reject records (downstream=upstream-artifact, upstream=spec) — a reverse edge the + forward lineage graph has no counterpart for, so compute_staleness never renders it.""" + spec = self._spec_citing_br(tmp_path) + run_cli(["refresh", "reject", "--spec", spec, "business-rules", + "--reason", "x", "--repo", str(tmp_path)], capsys) + code, out = run_cli(["report", "--repo", str(tmp_path), "--json"], capsys) + data = json.loads(out) + assert code == 0 and data["stale"] == 0 and data["open"] == 0 + + +# --- refresh status: honest per-spec disposition counting (P5) -------------------------------- + +class TestRefreshStatus: + def test_open_when_nothing_done(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + code, out = run_cli(["refresh", "status", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + assert code == 0 + counts = json.loads(out)["counts"] + assert counts["open"] == 1 and counts["refreshed"] == 0 + + def test_refreshed_attributed_via_source_spec(self, tmp_path, capsys): + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) + run_cli(["refresh", "draft", "--spec", spec, "--repo", str(tmp_path)], capsys) + _agent_edits(tmp_path, spec, "requirements", + "# Requirements\n\n## FR-001\nwithin 4 hours; HTTP 409.\n") + _confirm_apply(tmp_path, spec, "requirements", capsys) + code, out = run_cli(["refresh", "status", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + counts = json.loads(out)["counts"] + assert code == 0 and counts["refreshed"] == 1 and counts["open"] == 0 + + def test_not_affected_without_reason_counts_in_status(self, tmp_path, capsys): + _write_upstreams(tmp_path) + _write(tmp_path / ".sdlc/artifacts/01-requirements/business-rules.md", + "# Business Rules\n\n## BR-01\nKeep logs 90 days.\n") + spec = _write_spec(tmp_path, accept="within 4 hours; relates to BR-01") + run_cli(["refresh", "reject", "--spec", spec, "business-rules", "--repo", str(tmp_path)], capsys) + code, out = run_cli(["refresh", "status", "--spec", spec, "--repo", str(tmp_path), "--json"], capsys) + rows = {r["target"].split("/")[-1]: r for r in json.loads(out)["rows"]} + assert rows["business-rules.md"]["disposition"] == "NOT_AFFECTED" + assert rows["business-rules.md"]["off_books"] is False # no reason -> still counts + + def test_rollup_across_merged_specs(self, tmp_path, capsys): + _write_upstreams(tmp_path) + _write_spec(tmp_path, sid="0001", accept="within 4 hours returns HTTP 409") + _write_spec(tmp_path, sid="0002", status="ready", accept="within 4 hours") + code, out = run_cli(["refresh", "status", "--repo", str(tmp_path), "--json"], capsys) + data = json.loads(out) + assert code == 0 and data["merged_specs"] == 1 + + def test_no_data(self, tmp_path, capsys): + _write_upstreams(tmp_path) + code, out = run_cli(["refresh", "status", "--repo", str(tmp_path)], capsys) + assert code == 0 and "no merged specs" in out + + +# --- exit-0 across every version path --------------------------------------------------------- + +class TestExitZero: + @pytest.mark.parametrize("argv", [ + ["version", "list", "requirements.md"], + ["version", "show", "requirements.md", "v9"], + ["version", "diff", "requirements.md"], + ["version", "rollback", "requirements.md", "v9"], + ["version", "rollback", "requirements.md", "prev", "--confirm", "--actor", "kai"], + ["version", "gc"], + ["version", "gc", "--apply"], + ["refresh", "detect", "--spec", "specs/missing.md"], + ["refresh", "scan"], + ["refresh", "draft", "--spec", "specs/missing.md"], + ["refresh", "apply", "--spec", "specs/missing.md", "requirements"], + ["refresh", "reject", "--spec", "specs/missing.md", "requirements"], + ["refresh", "status", "--spec", "specs/missing.md"], + ["refresh", "status"], + ]) + def test_never_nonzero(self, tmp_path, capsys, argv): + _write(tmp_path / REQ, "content\n") + code, _ = run_cli(argv + ["--repo", str(tmp_path)], capsys) + assert code == 0 + + +# --- P7: additive-contract / byte-identical invariants ---------------------------------------- + +class TestInvariants: + """The plan's non-negotiables, asserted mechanically: artifact_model unchanged (the source_spec + rider is caller-added), /sdlc-audit output byte-identical with/without a version store, the layer + writes no state.yaml, the ledger stays its own JSONL with no gate_results rows, and the store + + refresh drafts are gitignored.""" + + def test_change_entry_carries_no_source_spec_rider(self): + """artifact_model.py stays byte-for-byte unchanged: `source_spec` is a key the refresh caller + rides onto the returned dict, never a field of change_entry itself.""" + e = am.change_entry(ts="2026-01-01T00:00:00+00:00", artifact=REQ, event="refreshed") + assert "source_spec" not in e + + def test_report_json_byte_identical_without_version_store(self, tmp_path, capsys): + """`report` (the /sdlc-audit-artifacts engine) must never read the object store — deleting the + whole store leaves its JSON byte-for-byte identical (the store is derived, non-authoritative).""" + _two_versions(tmp_path, capsys) # ledger + a populated store + _, with_store = run_cli(["report", "--repo", str(tmp_path), "--json"], capsys) + store = tmp_path / ".sdlc" / "versions" + assert any(store.rglob("*")) # the scan captured blobs + shutil.rmtree(store) + _, without_store = run_cli(["report", "--repo", str(tmp_path), "--json"], capsys) + assert with_store == without_store and with_store.strip() + + def test_impact_json_byte_identical_without_version_store(self, tmp_path, capsys): + _two_versions(tmp_path, capsys) + _, with_store = run_cli(["impact", "requirements.md", "--repo", str(tmp_path), "--json"], capsys) + shutil.rmtree(tmp_path / ".sdlc" / "versions") + _, without_store = run_cli(["impact", "requirements.md", "--repo", str(tmp_path), "--json"], capsys) + assert with_store == without_store and with_store.strip() + + def test_version_and_refresh_never_write_state_yaml(self, tmp_path, capsys): + """B3: a refresh apply and a version rollback both mutate the artifact + ledger + store — + never state.yaml. (Refuse paths leave it untouched too, so this holds regardless of guards.)""" + _write_upstreams(tmp_path) + spec = _write_spec(tmp_path, accept="within 4 hours returns HTTP 409") + _write_state(tmp_path, signed=False) # known + unsigned -> no ack gate + state = str(tmp_path / ".sdlc" / "state.yaml") + run_cli(["record", "--scan", "--state", state], capsys) # v1 baseline + before = (tmp_path / ".sdlc" / "state.yaml").read_bytes() + + # (a) draft + named-human apply (mutates requirements.md up to the spec's reality) + run_cli(["refresh", "draft", "--spec", spec, "--state", state], capsys) + _agent_edits(tmp_path, spec, "requirements", + "# Requirements\n\n## FR-001 Duplicate claim\n" + "Reject a duplicate within 4 hours; return HTTP 409.\n") + _, prev = run_cli(["refresh", "apply", "--spec", spec, "requirements", "--state", state], capsys) + run_cli(["refresh", "apply", "--spec", spec, "requirements", "--actor", "kai", + "--reviewed", _diffhash(prev), "--state", state], capsys) + # (b) roll it back (append-only revert of the same file) + _, prev2 = run_cli(["version", "rollback", "requirements.md", "prev", "--state", state], capsys) + run_cli(["version", "rollback", "requirements.md", "prev", "--confirm", "--actor", "kai", + "--reviewed", _diffhash(prev2), "--state", state], capsys) + + assert (tmp_path / ".sdlc" / "state.yaml").read_bytes() == before + + def test_ledger_stays_its_own_jsonl_with_no_gate_results_rows(self, tmp_path, capsys): + """The change-ledger is the only *.jsonl the layer writes, and no entry carries a gate_results + key — so /sdlc-audit's gate history can never absorb a phantom row from this layer.""" + _two_versions(tmp_path, capsys) + dh = _diffhash(run_cli( + ["version", "rollback", "requirements.md", "v1", "--repo", str(tmp_path)], capsys)[1]) + run_cli(["version", "rollback", "requirements.md", "v1", "--confirm", "--actor", "kai", + "--reviewed", dh, "--ack-signoff", "--repo", str(tmp_path)], capsys) + metrics = tmp_path / ".sdlc" / "metrics" + assert sorted(p.name for p in metrics.glob("*.jsonl")) == ["artifact-log.jsonl"] + for line in (metrics / "artifact-log.jsonl").read_text(encoding="utf-8").splitlines(): + assert "gate_results" not in json.loads(line) + + def test_gitignore_ignores_the_store_and_refresh_drafts(self): + """The locked .gitignore default: object store + transient refresh drafts are local-only.""" + gi = (Path(__file__).resolve().parents[2] / ".gitignore").read_text(encoding="utf-8") + for needed in (".sdlc/versions/objects/", + ".sdlc/refresh/**/*.proposed", + ".sdlc/refresh/**/candidates.json", + ".sdlc/refresh/_rollback/"): + assert needed in gi diff --git a/scripts/version_model.py b/scripts/version_model.py new file mode 100644 index 0000000..e8ac5f3 --- /dev/null +++ b/scripts/version_model.py @@ -0,0 +1,144 @@ +"""version_model.py — pure derivation of an artifact's *content* version history from the ledger. + +The artifact-update-audit layer (`artifact_model.py`, `audit_artifacts.py`) already records change +**metadata** — every new/changed hash, when, by whom, why — to the append-only change-ledger. This +module is the content complement: given that same ledger, it derives an ordinal-keyed version list +(`v1..vN`) for one artifact so a human can diff or roll back. It is the **trust anchor** of the +versioning feature and does NO I/O — every version's identity is the SHA-256 hash the ledger already +carries (`track_artifacts.compute_checksum` output: `sha256:` + 16 hex), so the content-addressed +object store is just those hashes rehydrated to bytes. There is no second index to drift. + +Design rules (mirrors `artifact_model.py`): + - **Pure.** No filesystem, no clock, no randomness. The caller supplies the ledger, the current + file's hash (for baseline synthesis), and the set of hashes actually present in the object store + (so `present` can be computed without this module touching disk). Trivially unit-testable. + - **Ordinal is the primary key.** `versions_for` counts *occurrences* in ledger order, not distinct + hashes — so a rollback that restores an earlier hash is its own version (never a skipped ordinal), + and is rendered "restored from vX". + - **Baseline synthesis, never a crash.** A pre-existing artifact that predates the ledger has zero + change entries; rather than `[]` or a KeyError, we synthesize a single `v1` baseline from the + current file's hash, marked `present=False` unless its content happens to be in the store. +""" + +import re + +HASH_PREFIX = "sha256:" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import artifact_model as am + + +# --- Hash / object identity ------------------------------------------------------------------- + +def hash_hex(h: str) -> str: + """The bare hex of a ledger hash — strips a leading `sha256:` if present. '' for a falsy hash.""" + s = str(h or "").strip() + return s[len(HASH_PREFIX):] if s.startswith(HASH_PREFIX) else s + + +def object_relpath(h: str) -> str: + """Store-relative path for a hash's blob: `objects//`, sharded on the first 2 hex. + + Empty string for a falsy/hashless version so the caller can treat it as "no blob".""" + hx = hash_hex(h) + if not hx: + return "" + shard = hx[:2] if len(hx) >= 2 else hx + return f"objects/{shard}/{hx}" + + +# --- Version enumeration (pure) --------------------------------------------------------------- + +def _row(n: int, entry: dict | None, *, present_hashes: set[str], baseline: bool = False, + current_hash: str | None = None) -> dict: + h = (current_hash if baseline else (entry or {}).get("hash")) or "" + return { + "n": n, + "hash": h, + "event": "baseline" if baseline else ((entry or {}).get("event") or ""), + "ts": "" if baseline else ((entry or {}).get("ts") or ""), + "actor": "" if baseline else ((entry or {}).get("actor") or ""), + # A version's content is retrievable iff its blob is in the store. Computed from the caller's + # set so this module never touches disk. Baselines are usually uncaptured (present=False), + # but if the same content was later stored we honestly report it as present. + "present": bool(h) and h in (present_hashes or set()), + } + + +def versions_for(ledger: list[dict], artifact: str, *, current_hash: str | None = None, + present_hashes: set[str] | None = None) -> list[dict]: + """Ordinal version list `v1..vN` for `artifact`, derived from the ledger in time order. + + Each row: {n, hash, event, ts, actor, present, restored_from?}. `restored_from` is the ordinal a + dup-hash version first appeared as (a rollback), so the reader can render "restored from vX". + + Baseline synthesis: an artifact with no change entry yet (pre-existing, predates the ledger) + yields a single `v1` baseline from `current_hash` — never `[]`, never a KeyError. If it also has + no current file (nothing on disk, nothing in the ledger), the honest answer is `[]` ("no data").""" + present_hashes = present_hashes or set() + changes = am.changes_for(ledger, artifact) + + if not changes: + if current_hash: + return [_row(1, None, present_hashes=present_hashes, baseline=True, current_hash=current_hash)] + return [] + + rows = [_row(i, e, present_hashes=present_hashes) for i, e in enumerate(changes, start=1)] + + # Annotate rollbacks: a version whose hash first appeared earlier is a restore of that ordinal. + first_seen: dict[str, int] = {} + for row in rows: + h = row["hash"] + if not h: + continue + if h in first_seen: + row["restored_from"] = first_seen[h] + else: + first_seen[h] = row["n"] + return rows + + +# --- Reference resolution --------------------------------------------------------------------- + +_VN_RE = re.compile(r"v(\d+)$", re.IGNORECASE) + + +def resolve_version(versions: list[dict], ref: str) -> tuple[dict | None, str]: + """Resolve a user ref to one version row. Returns (row, note); row is None if unresolvable. + + Refs, checked in order: `latest`, `prev`, `vN` (ordinal — the primary key), else a hash prefix + (a disambiguated *hint*, not the key). An ambiguous prefix resolves to the **highest** matching + ordinal and `note` lists the alternatives, so the caller can surface the ambiguity without ever + guessing silently.""" + if not versions: + return None, "no versions" + ref = str(ref or "").strip() + if not ref or ref == "latest": + return versions[-1], "" + if ref.lower() == "prev": + if len(versions) < 2: + return None, "only one version — no previous" + return versions[-2], "" + + m = _VN_RE.fullmatch(ref) + if m: + n = int(m.group(1)) + for row in versions: + if row["n"] == n: + return row, "" + return None, f"no version v{n} (have v1..v{versions[-1]['n']})" + + # Hash-prefix hint (accept a bare hex or a full `sha256:` form). + needle = hash_hex(ref).lower() + if needle: + matches = [row for row in versions if hash_hex(row["hash"]).lower().startswith(needle)] + if len(matches) == 1: + return matches[0], "" + if len(matches) > 1: + chosen = max(matches, key=lambda r: r["n"]) + alts = ", ".join(f"v{r['n']}" for r in matches) + return chosen, f"prefix {ref!r} matched {alts} — using v{chosen['n']}" + return None, f"could not resolve {ref!r} to a version (try latest, prev, vN, or a hash prefix)"