From e65900623a404eb58dcf060ae3dcaaef27786ecf Mon Sep 17 00:00:00 2001 From: splashthree <232408301+splashthree@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:56:13 -0700 Subject: [PATCH 1/2] feat: artifact versioning, draft+confirm auto-refresh, retro roll-up & command contract lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four additive, advisory layers — protected core byte-for-byte unchanged, exit 0 on every path, existing record/impact/report output byte-identical: - Content versioning: version {list,show,diff,rollback,gc} folded into audit_artifacts.py, keyed to the change-ledger's existing 16-hex hashes via one pure module (version_model.py) — the gitignored object store is the ledger rehydrated to bytes, no second index. Rollback is preview -> named-human confirm, append-only, torn-write-safe. - Draft+confirm auto-refresh: refresh {detect,scan,draft,apply,reject,status} back-propagates a merged spec's shipped reality into pre-Build artifacts. Review-first/divergence-aware; agent edits only a .proposed; a named human echoes the reviewed diffhash to apply (One Rule); lands as a rollback-able refreshed version attributed via a source_spec rider key. - Cross-ledger retro roll-up: retro_report.py + /sdlc-retro — recurring findings, repeat-stale artifacts, the refresh funnel (divergence-heuristic tuning signal), disposition-debt rollup. Patterns, not people; no data over fabricated zeros. - Command contract lint: test_command_contracts.py validates every command doc's script invocations against live --help, plus agent/file cross-refs, with self-tests proving each detection class fires. Plus multi-machine honesty on every "content not captured" path, docs for endpoint-only capture semantics, and 1.3.0 integration: the three new commands registered in docs/commands.md (count-word vocabulary extended in its guard test) and all new output routed through the _glyph() Windows- console fallback. 807 tests passing on the rebased tree. Co-Authored-By: Claude Fable 5 --- .gitignore | 10 + CLAUDE.md | 13 +- commands/sdlc-audit-artifacts.md | 13 +- commands/sdlc-next.md | 17 +- commands/sdlc-refresh.md | 147 ++ commands/sdlc-retro.md | 93 ++ commands/sdlc-revise.md | 16 +- commands/sdlc-status.md | 12 + commands/sdlc-version.md | 120 ++ docs/commands.md | 5 +- references/artifact-versioning.md | 193 +++ scripts/audit_artifacts.py | 1390 ++++++++++++++++- scripts/retro_report.py | 416 +++++ scripts/tests/test_command_contracts.py | 460 ++++++ .../tests/test_registry_docs_consistency.py | 4 +- scripts/tests/test_retro_report.py | 400 +++++ scripts/tests/test_version_model.py | 136 ++ scripts/tests/test_version_refresh.py | 932 +++++++++++ scripts/version_model.py | 144 ++ 19 files changed, 4510 insertions(+), 11 deletions(-) create mode 100644 commands/sdlc-refresh.md create mode 100644 commands/sdlc-retro.md create mode 100644 commands/sdlc-version.md create mode 100644 references/artifact-versioning.md create mode 100644 scripts/retro_report.py create mode 100644 scripts/tests/test_command_contracts.py create mode 100644 scripts/tests/test_retro_report.py create mode 100644 scripts/tests/test_version_model.py create mode 100644 scripts/tests/test_version_refresh.py create mode 100644 scripts/version_model.py 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..f30f57c --- /dev/null +++ b/scripts/tests/test_retro_report.py @@ -0,0 +1,400 @@ +"""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: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + + +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) + (tmp_path / REQ).write_text( + "# Requirements\n\n## FR-001 Duplicate claim\n" + "Reject a duplicate within 12 hours; return HTTP 409.\n", encoding="utf-8") + 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) + (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nwithin 12 hours; HTTP 409.\n", + encoding="utf-8") + 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..97419ac --- /dev/null +++ b/scripts/tests/test_version_refresh.py @@ -0,0 +1,932 @@ +"""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: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + + +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) + req.write_text("line one EDITED\nline two\nline three\n", encoding="utf-8") + 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.""" + _proposed(tmp_path, spec_rel, stem).write_text(text, encoding="utf-8") + + +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 + (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nNow within 4 hours; HTTP 409.\n", + encoding="utf-8") + 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) + (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nmoved on disk after draft\n", + encoding="utf-8") + _, 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)" From d67db30710ead9bdc71113aa0798c0a7422f2c3f Mon Sep 17 00:00:00 2001 From: MCKRUZ Date: Fri, 7 Aug 2026 10:32:24 -0400 Subject: [PATCH 2/2] fix: two new tests only passed where the line endings were Unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The versioning suite asserts on exact file bytes — `version show` returns what the store captured, byte for byte. But the fixture helpers wrote their corpus in text mode, which expands newlines to CRLF on Windows. So the assertions compared LF literals against CRLF content and failed on Windows while passing on Linux CI. The product code is not at fault: every content path in audit_artifacts.py already reads and writes bytes. Only the fixtures were platform-dependent. Routing every artifact-content write through the _write helper, which now pins LF, makes the corpus byte-identical on both platforms. Also adds a windows-latest CI job. The plugin is authored on Windows and ships PowerShell hooks, but CI ran only on Linux — this is the second Windows-only defect to reach master unseen, after the cp1252 console fault fixed in 1.3.0. It is a separate job rather than a matrix on `test` so the existing required check name is unchanged, and it keeps the runner's default CRLF checkout because that mirrors the machine it exists to protect. Verified on Windows: 801 passed, 7 skipped (was 799 passed, 2 failed). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 16 ++++++++++++++++ scripts/tests/test_retro_report.py | 18 ++++++++++++------ scripts/tests/test_version_refresh.py | 19 ++++++++++++------- 3 files changed, 40 insertions(+), 13 deletions(-) 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/scripts/tests/test_retro_report.py b/scripts/tests/test_retro_report.py index f30f57c..bd08783 100644 --- a/scripts/tests/test_retro_report.py +++ b/scripts/tests/test_retro_report.py @@ -31,8 +31,15 @@ # --- 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") + p.write_text(text, encoding="utf-8", newline="\n") def _append_jsonl(p: Path, rows: list[dict]) -> None: @@ -192,9 +199,9 @@ def test_repeat_stale_excludes_refresh_reject_edges(tmp_path, capsys): 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) - (tmp_path / REQ).write_text( - "# Requirements\n\n## FR-001 Duplicate claim\n" - "Reject a duplicate within 12 hours; return HTTP 409.\n", encoding="utf-8") + _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) @@ -272,8 +279,7 @@ def test_debt_rollup_names_each_ledger(tmp_path, capsys): _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) - (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nwithin 12 hours; HTTP 409.\n", - encoding="utf-8") + _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) diff --git a/scripts/tests/test_version_refresh.py b/scripts/tests/test_version_refresh.py index 97419ac..05fa33a 100644 --- a/scripts/tests/test_version_refresh.py +++ b/scripts/tests/test_version_refresh.py @@ -24,8 +24,15 @@ 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") + p.write_text(text, encoding="utf-8", newline="\n") def run_cli(argv, capsys) -> tuple[int, str]: @@ -56,7 +63,7 @@ def _two_versions(tmp_path: Path, capsys) -> str: req = tmp_path / REQ _write(req, "line one\nline two\n") run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) - req.write_text("line one EDITED\nline two\nline three\n", encoding="utf-8") + _write(req, "line one EDITED\nline two\nline three\n") run_cli(["record", "--scan", "--repo", str(tmp_path)], capsys) return REQ @@ -114,7 +121,7 @@ def _proposed(tmp_path: Path, spec_rel: str, stem: str) -> Path: def _agent_edits(tmp_path: Path, spec_rel: str, stem: str, text: str) -> None: """Simulate the discipline agent editing ONLY the .proposed draft.""" - _proposed(tmp_path, spec_rel, stem).write_text(text, encoding="utf-8") + _write(_proposed(tmp_path, spec_rel, stem), text) def _apply(tmp_path, spec_rel, stem, capsys, *, actor="kai", reviewed=None, ack=True, extra=None): @@ -531,8 +538,7 @@ def test_already_fresher_is_suppressed(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) # baseline: spec + req at ts0 - (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nNow within 4 hours; HTTP 409.\n", - encoding="utf-8") + _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 @@ -707,8 +713,7 @@ def test_cleans_up_draft_on_apply(self, tmp_path, capsys): def test_staleness_guard_when_upstream_moves(self, tmp_path, capsys): spec = self._drift_and_draft(tmp_path, capsys) - (tmp_path / REQ).write_text("# Requirements\n\n## FR-001\nmoved on disk after draft\n", - encoding="utf-8") + _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