devrites 7.0.2 → 7.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [7.0.3](https://github.com/ViktorsBaikers/DevRites/compare/v7.0.2...v7.0.3) (2026-10-21)
6
+
7
+ ### Changed
8
+
9
+ * **no-release:** move the Go toolchain to go1.27.3 ([#155](https://github.com/ViktorsBaikers/DevRites/issues/155)) ([293da70](https://github.com/ViktorsBaikers/DevRites/commit/293da70c8c4b4de8ed22caababa7914594c1ff9b))
10
+
11
+ ### Fixed
12
+
13
+ * **devrites:** freeze and probe a parallel sibling in its worktree ([#156](https://github.com/ViktorsBaikers/DevRites/issues/156)) ([e959804](https://github.com/ViktorsBaikers/DevRites/commit/e959804721f291db3b489eb30988792c5ab3c3fb))
14
+ * **no-release:** check anchors blocks prose-only blocker returns ([#154](https://github.com/ViktorsBaikers/DevRites/issues/154)) ([0807fe2](https://github.com/ViktorsBaikers/DevRites/commit/0807fe2f92100188616110beb037d81e752ee1b8))
15
+ * **no-release:** traceability progress cells no longer stale readiness ([#153](https://github.com/ViktorsBaikers/DevRites/issues/153)) ([7465b3e](https://github.com/ViktorsBaikers/DevRites/commit/7465b3eaaf180718015dfdab3d0af96579ab5b85))
16
+
5
17
  ## [7.0.2](https://github.com/ViktorsBaikers/DevRites/compare/v7.0.1...v7.0.2) (2026-10-20)
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -31,7 +31,7 @@ history can't lose it. After you clear the conversation, switch hosts, or hand
31
31
  the feature to another engineer, the next session reads the same plan,
32
32
  decisions, and evidence before it continues.
33
33
 
34
- **Status:** [`v7.0.2`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.0.2): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
34
+ **Status:** [`v7.0.3`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.0.3): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
35
35
 
36
36
  This is the latest published release; `main` may contain unreleased work.
37
37
 
@@ -83,3 +83,13 @@ every canonical `AC-###` ID in `spec.md`'s Acceptance criteria section to appear
83
83
  as a literal substring in `tasks.md`. When `test-plan.md` is required, the same
84
84
  IDs must appear there. This is identity presence, not semantic coverage. Guard:
85
85
  `engine/internal/state/acceptance_test.go`.
86
+
87
+ ## Amendment (2026-10-21)
88
+
89
+ Build and Prove fill `traceability.md`'s Evidence and Status cells as evidence
90
+ lands, and those cells are Build-mutable evidence projections that Vet does not
91
+ approve. The binding therefore hashes `traceability.md` with every cell under an
92
+ `Evidence` or `Status` table header blanked; every other byte stays bound. A
93
+ binding line recorded over the raw bytes still verifies in `check readiness`
94
+ while the inputs are unchanged, and `check drift` reports a change confined to those cells as
95
+ `progress-only`. Guard: `engine/internal/gate/readiness_binding_test.go`.
package/docs/cli.md CHANGED
@@ -26,7 +26,7 @@ devrites-engine check preconditions <slug> [--slice SLICE-###] [--timeout N]
26
26
  devrites-engine check seal <slug>
27
27
  devrites-engine check slice <slug> <SLICE-ID>
28
28
  devrites-engine check diff-scope <slug> --allow <csv>|--allow-file <path> [--worktree|--staged|--base <ref>] [--snapshot|--baseline] [--verbose]
29
- devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N]
29
+ devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N] [--worktree <path> --slice <SLICE-ID>]
30
30
  devrites-engine check task-graph <slug>
31
31
  devrites-engine check regression <slug> [--update]
32
32
  devrites-engine check drift <slug> [--record]
@@ -48,7 +48,7 @@ devrites-engine context [slug] (--phase <p>|--skill <name>) [--role <r>] [--whol
48
48
  devrites-engine metrics record <slug> --phase <p> --event <e>
49
49
  devrites-engine metrics summary [slug]
50
50
  devrites-engine dispatch <slug> <open|start|seal|return|status|abandon|attribute>
51
- devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID>|--paths <path>...) [--base <ref>]
51
+ devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID>|--paths <path>...) [--base <ref>] [--worktree <path>]
52
52
  devrites-engine packet <slug> --wave <w> --phase <p> --role <r> [--slice <SLICE-ID>] [--recheck] [--checks <G-ID,...>] [--focus <file>]
53
53
  devrites-engine parallel <subcommand>
54
54
  devrites-engine gates <subcommand> <slug>
@@ -160,7 +160,8 @@ self-contained updater.
160
160
  ones; unresolved slots print `unresolved` rather than guessing.
161
161
  - `check anchors <slug> <return-file>` anchors each Critical/Important row's
162
162
  quote at its cited `file:line` and its `breaks` quote; Unverified or Deferred
163
- rows exit `3` and never drive a repair round.
163
+ rows exit `3` and never drive a repair round. A `Counts:` head declaring more
164
+ Critical/Important findings than there are `Finding:` rows also exits `3`.
164
165
  - `check dup [slug] [--all|--worktree|--staged|--base <ref>]` reports advisory
165
166
  near-duplicate code clusters that survive comment stripping, literal
166
167
  collapsing, and identifier renaming. Diff modes mark units overlapping
@@ -24,10 +24,10 @@ workflow strategy.
24
24
  | `check drift <slug> [--record]` | Attribute readiness-input changes to the exact artifact. `--record` writes the per-input `readiness-inputs.json` digest baseline under the feature lock; without it the workspace diffs current inputs against that baseline (or falls back to the aggregate binding recorded in `eng-review.md`). Advisory: exit `0` on drift; a corrupt baseline exits `3`. A `test-plan.md` change limited to exact `Workflow Artifact reviewed identity: <64hex>` lines is reported as `reviewed-identity-only` (ADR-0034). `--record` also stores each slice's proof bytes (its `tasks.md` block plus the `test-plan.md` lines naming it); both modes print `ADVISORY proportion:` for a slice whose proof grew over 2x since that baseline or carries over 3x the median proof per AC. |
25
25
  | `check windows <slug> [--worktree\|--staged\|--base <ref>\|--since-dispatch <wave>]` | Fail on deferral and bar-lowering markers the change introduces without a waiver row in the workspace's `windows.md`: added TODO/FIXME/XXX/HACK, skipped/focused-test and stub idioms, checker suppressions, one-line empty catch; removed `deleted-test` files and net `removed-assertion` lines in remaining test files. `--since-dispatch <wave>` reports only `deleted-test`/`removed-assertion` against the work-tree snapshot (`tree` in `dispatch.json`) recorded when that build wright wave opened, untracked files included; it takes no `--cwd` and records the `windows-since` metric. |
26
26
  | `check dup [slug] [--all\|--worktree\|--staged\|--base <ref>] [--min-lines n] [--threshold f] [--ignore-file <path>] [--limit n] [--exclude <csv>]` | Report near-duplicate code clusters that survive comment stripping, literal collapsing, and identifier renaming — token-shingle matches chained into segments, clustered transitively, ranked with bounded path/line-distance boosts. Diff modes mark units overlapping changed hunks with `*`; stable content-derived cluster hashes let `.devrites/dup-ignore` dismissals survive line moves but resurface on structural edits. Staged mode reads index blobs and excludes untracked files. Bounded heuristic: successful scans/leads exit `0`; usage, unavailable input, and partial scans exit `2`. |
27
- | `check anchors <slug> <return-file> [--skills-root <dir>]` | Deterministic quote anchoring for one reviewer return (agents.md § Result admission). For each Critical/Important `Finding:` row (list markers and bold tolerated), it anchors the backticked observed quote at the cited `path:line[-line]`, looking in the workspace first, then the project, never outside either. Exact match → `anchored`. Exactly one other line → `relocated <cited> -> path:N`. Otherwise → `Unverified: <row> — anchor lost\|ambiguous`; this also covers a missing file:line or quote, or a quote under 8 characters. The `breaks "<quote>"` must appear in spec.md/decisions.md (spec ID) or in the named shipped standard under the skills root (`--skills-root` for host installs). No breaks quote → `Deferred: <row> — no spec anchor`. Prints the candidate digest and records an `anchors` metrics event. Exit `0` all anchored, `3` any Unverified/Deferred row (never drives a repair round), `2` usage or unreadable input. |
27
+ | `check anchors <slug> <return-file> [--skills-root <dir>]` | Deterministic quote anchoring for one reviewer return (agents.md § Result admission). For each Critical/Important `Finding:` row (list markers and bold tolerated), it anchors the backticked observed quote at the cited `path:line[-line]`, looking in the workspace first, then the project, never outside either. Exact match → `anchored`. Exactly one other line → `relocated <cited> -> path:N`. Otherwise → `Unverified: <row> — anchor lost\|ambiguous`; this also covers a missing file:line or quote, or a quote under 8 characters. The `breaks "<quote>"` must appear in spec.md/decisions.md (spec ID) or in the named shipped standard under the skills root (`--skills-root` for host installs). No breaks quote → `Deferred: <row> — no spec anchor`. Prints the candidate digest and records an `anchors` metrics event. Exit `0` all anchored, `3` any Unverified/Deferred row (never drives a repair round) or a `Counts:` head declaring more Critical/Important findings than `Finding:` rows, `2` usage or unreadable input. |
28
28
  | `check slice <slug> <SLICE-ID>` | Pre-dispatch lint of one slice's wright contract: contract present, exact Writer allowlist paths, resolvable `Satisfies` AC ids, bounded scope. Same non-blocking size advisory as `check task-graph`, plus the `check drift` proportion advisory for that slice. |
29
29
  | `check diff-scope <slug> --allow <csv>\|--allow-file <path> [--worktree\|--staged\|--base <ref>] [--baseline] [--verbose]`, `check diff-scope <slug> --snapshot` | Verify the changed-path set stays inside the declared allowlist; the mechanical gate run before reviewer dispatch. `--snapshot` records the mode and SHA-256 of every currently changed path in `<feature>/diff-scope-baseline.json` (an engine machine file, never reported); run it before writer dispatch. `--baseline` drops a changed path whose fingerprint still matches, printing `preexisting (unchanged)`; a baselined path whose bytes or mode changed, or a new path, still blocks; a run with only preexisting changes is an empty diff; a missing baseline exits `2`. More than 20 violations print grouped by the first two path segments (count plus 3 examples, at most 20 groups); `--verbose` lists every path. Exit codes are unchanged. |
30
- | `check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N]` | Safe-perturbation probe (testing.md § Safe perturbation). Copies the candidate (HEAD plus every touched-files.md manifest row, at the project's prefix below the git toplevel) into a detached temporary worktree, requires it to hash to the `check candidate` digest, applies the wright-supplied mutant patch (toplevel-relative paths), and runs the gate's CHECK only when it is an approved test-plan.md preflight row. RED counts only when a runner FAIL line (go test, pytest, cargo test, jest/vitest) names `--expect`; build/setup failure, timeout, exit 0 (survivor), or a RED naming only other tests is `cannot_verify`. Reverts the patch, requires the copy back at the candidate digest and the gate GREEN (exit 0 plus EXPECT), and requires the checkout candidate unchanged. Writes `<feature>/evidence/perturb/<gate>-<digest12>-aN.log`. Never mutates the checkout. Exit `0` verified, `3` cannot_verify or refused, `2` usage. |
30
+ | `check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N] [--worktree <path> --slice <SLICE-ID>]` | Safe-perturbation probe (testing.md § Safe perturbation). Copies the candidate (HEAD plus every touched-files.md manifest row, at the project's prefix below the git toplevel) into a detached temporary worktree, requires it to hash to the `check candidate` digest, applies the wright-supplied mutant patch (toplevel-relative paths), and runs the gate's CHECK only when it is an approved test-plan.md preflight row. RED counts only when a runner FAIL line (go test, pytest, cargo test, jest/vitest) names `--expect`; build/setup failure, timeout, exit 0 (survivor), or a RED naming only other tests is `cannot_verify`. Reverts the patch, requires the copy back at the candidate digest and the gate GREEN (exit 0 plus EXPECT), and requires the checkout candidate unchanged. Writes `<feature>/evidence/perturb/<gate>-<digest12>-aN.log`. Never mutates the checkout. `--worktree <path> --slice <SLICE-ID>` (both or neither) probes a linked worktree of the same repository instead (a parallel sibling, checked as for `candidate freeze --worktree`): its HEAD plus the slice's tasks.md write set, hashed as `candidate freeze` does, with `--patch` resolved against the worktree; the transcript stays under the control workspace. Exit `0` verified, `3` cannot_verify or refused, `2` usage. |
31
31
  | `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
32
32
  | `check indexes [--root <dir>]` | Report install manifest and in-repo code-index artifact presence (`.codebase-memory/`) as JSON. codebase-memory-mcp normally indexes into its central store, so its `index_status` is authoritative. |
33
33
  | `observe summary <slug>` | Emit a sanitized workspace summary, as one compact JSON line, from one retained observation. `task_graph.ok` is true iff `task_graph.problems` is empty; `problems` lists cycle, unknown-dep, malformed-token, duplicate-id, and missing-`Dependencies` blockers. `inflight_waves` lists dispatch waves still `open` or `sealed`, with their pending roles. |
@@ -38,7 +38,7 @@ workflow strategy.
38
38
  | `recall --paths <p1,p2,...> [--limit n]` | Read-time query over `.devrites/archive/`: list archived features whose `touched-files.md` candidate manifest covers a queried project-relative path (exact or directory prefix, case-folded), ranked by overlap count, then newest archive-directory mtime, then slug (default 10 features). Each feature prints up to 8 `archive/<slug>/<file>:<line>` pointers, then `+N more`: `decisions.md` `DEC-###` entries and `## Carry-forward constraints` bullets whose `Files:` overlap (or a constraint naming no files), plus `review.md`/`seal.md`/`drift.md` lines that mention a queried path; fenced examples are ignored. Never writes, caches, or indexes (no metric either); symlinked archive entries are refused. An unreadable archive file prints `unreadable:` and the scan continues. Exit `0` (including `recall: no archive`), `1` invalid archive directory, `2` usage. |
39
39
  | `context [slug] (--phase <p>\|--skill <name>) [--role <r>] [--slice <SLICE-ID>] [--recheck <inventory>] [--trigger a,b] [--inline-contract] [--whole]` | Emit one deduplicated read-set bundle per phase/role/skill from each skill's `loads:` manifest; `--skill` works without a workspace when `--out` is given. A root `--phase build\|autocomplete` bundle (no `--role`, no `--slice`) defaults to `--slice <active_slice>` while the state.md cursor phase is `build` and active_slice names a slice present in tasks.md (prints `scope: slice SLICE-### (auto from state.md) ...`); `--whole` opts out. Every bundle opens, after its header comments, with a table of contents: one `L<start>-<end> <section label>` line per `===== label =====` section, giving its exact line range in the bundle file. A bundle without `--role` (the root's own read) gives over-budget artifacts as by-reference indexes; a `--role` bundle leads with the role's agent contract and names the manifest's `rootOnly` paths instead of inlining them. When the contract resolves under `.claude/agents`, `.omp/agents`, or `.codex/agents` (the host loads it as the agent's system prompt), the bundle writes one header line naming its path, size, and SHA-256 instead of the body; other layouts (pi, devin, unknown) and `--inline-contract` (only with `--role`) inline the body, and a missing contract still exits `3`. Inside inlined files, a `--role` bundle replaces each `<!-- root-only -->`…`<!-- /root-only -->` block with a one-line stub naming the omitted heading, and does the same for each `<!-- model-surface -->` block unless the role is `security-auditor`, the feature has an `ai-spec.md`, or `spec.md`/`plan.md`/`tasks.md`/`design-brief.md` are absent or (outside the spec template's `AI-SPEC annex`) name a model, agent, RAG, or tool surface; an unbalanced or headingless marker exits `2`. `--slice` inlines that slice's `tasks.md` block and turns each over-budget artifact, and each artifact over 16 KiB except `spec.md`, into a by-reference index (path, SHA-256, headings, line hits for every ID the block cites); repeat `--slice` for several slices; an unknown slice exits `3`. In phase `build`, a `--role` bundle other than `slice-wright` without `--slice` exits `3` (`unscoped build reviewer bundle`). `--recheck <inventory>` (phase `build`, with `--role` and `--slice`; path relative to the workspace) writes `<phase>-<role>.<slice>.recheck.bundle.md`: the inventory file and `core.md` inline whole, every other non-`rootOnly` pack file becomes a by-reference index (path, SHA-256, headings with line numbers) followed by each section the inventory cites as `<file>.md § <heading>`; workspace handling matches `--slice`. A missing or empty inventory exits `2`. First reviews, full-inventory rechecks, Vet, `/rite-review`, and Seal do not use it. |
40
40
  | `dispatch <slug> <open\|start\|seal\|return\|status\|abandon\|attribute>` | Launch-wave barrier for parallel dispatch: seal needs a handle per role, return needs seal; the start that gives the last role its handle seals the wave, and an explicit seal on a sealed wave is a no-op; `open --claim-session <id> --claim <path>... [--claim-ttl <minutes>]` records one file claim for the wave in claims.jsonl and exits `3`, creating no wave and no claim, while a live foreign session holds an overlapping path; the return that completes the wave releases that claim; `abandon --release` releases it on any other host-confirmed terminal path (a stop or a refused launch); re-opening an abandoned wave inherits its kept claim or, with new claim flags, replaces and releases it; claim flags apply to open only and `--release` to abandon only; start/return auto-record metrics, `return --outcome findings|no-findings|gap` records the role's Outcome line, and `abandon` records its reason, so `metrics summary` shows per-role yield and lost waves. Bare `status` prints every open, sealed, or abandoned wave and folds complete waves into one `N complete wave(s) (use --all)` line; `--all` lists every wave; it still exits nonzero while any wave is open or sealed. For a reviewer, auditor, or test-analyst role, `return --result <file>` stores the return verbatim as `packets/<wave>-<role>.return.md` (exit `3` if that file exists), derives the outcome from its `Outcome:`/`Counts:` head (a malformed or inconsistent head records `gap` with a warning, never `no-findings`), exits `3` when an explicit `--outcome` disagrees, and records each `Finding:` row's `role|slice|file|section|severity` key, so `metrics summary` adds per-role `resolution: resolved re-raised open late` counts. A `--result` file that already is that `packets/` path is kept as the stored return. A build wave with a wright role needs `--slice <SLICE-ID>` per slice, and a slice-bound vet wave takes it too; a reviewer-only wave (for example a Build slice review) may take it, uncapped, so its `Finding:` keys name that slice; a reviewer, auditor, or test-analyst wave needs `--packet <file>` and `open` exits `3` on a packet that seeds a conclusion (gate `*-PASS` token, `no-findings`, an `Outcome:`/`Counts:` line, an "already passed" claim, or zero Critical/Important counts) or tells the agent to read an installed `agents/devrites-*.md`/`.toml` role contract (on the line or under a "Read …" heading or label; canonical `pack/` paths excepted), and a wright wave's `--contract` gets the same role-contract check; a Review or Seal reviewer wave also exits `3` unless the packet names an existing non-empty `.diff`/`.patch` file and the manifest covers every checkpointed path; every 5 rounds of either kind on one slice, `open` exits `3` until `--reslice-review <DRIFT-ID>` names an unused `drift.md` `## <DRIFT-ID>` heading for that slice. `open` also warns when an earlier wave for the same slice never returned. Under `.devrites/AFK` max_parallel >= 2, a first-round build wright wave narrower than the `parallel select --from-plan` batch exits `3` (printing the batch, the command and the parallel-batch lifecycle) until evidence.md records `serial: <skipped ids> — <reason>`; repair rounds, lease siblings and HITL runs are never refused. A build wright `--contract` must name `gates precheck` under `Writer-safe checks:` when gates.md has a `RUN: writer` gate. `open` exits `3` when a `--role` names no pack agent in a project-level install (a `.<lane>` suffix is allowed), and lints a reviewer packet against the wave's roles, so a recheck may name its own role's fingerprint. Root-edit tripwire (ADR-0038): the first `open` records `<feature>/tree-baseline.json`, which maps dirty product paths outside `.devrites/` and `.scratch/` to their mode plus SHA-256, accepted as user work. The return that completes a wright wave (any phase) adds its slice write sets and claim paths. `open` exits `3` with `unattributed root edit: <paths>` while a dirty product path differs from that baseline and no live claim or in-flight wright wave covers it, unless the new wright wave covers the path through `--claim` or a slice write set. `attribute --reason <evidence> --path <path>...` records owner work, gate output, a standalone wright, or a sanctioned root write for flagged paths only. Each finding writes an `unattributed-edit` metrics row. The engine never reverts a file, and a workspace without the baseline is never blocked. |
41
- | `candidate freeze <slug> --wave <w> (--slice <SLICE-ID> \| --paths <path>...) [--base <ref>]` | Writes packets/<w>.diff (tracked changes vs base, default HEAD, plus untracked files as /dev/null diffs) and packets/<w>.sha256 (base, diff digest, per-file sha256, aggregate check-candidate digest); never replaces a wave's frozen candidate with a different one. |
41
+ | `candidate freeze <slug> --wave <w> (--slice <SLICE-ID> \| --paths <path>...) [--base <ref>] [--worktree <path>]` | Writes packets/<w>.diff (tracked changes vs base, default HEAD, plus untracked files as /dev/null diffs) and packets/<w>.sha256 (base, diff digest, per-file sha256, aggregate check-candidate digest); never replaces a wave's frozen candidate with a different one. `--worktree <path>` (requires `--base`) reads the same path set from a parallel sibling's linked worktree before it lands: the path must be a symlink-free worktree top level that `git worktree list` names, shares the control repository, and is neither a submodule nor the control checkout. It exits `3` on uncommitted path-set changes there and adds a `head <sha>` row naming the worktree HEAD (the transfer commit); packets stay in the control workspace. |
42
42
  | `packet <slug> --wave <w> --phase <p> --role <r> [--slice <SLICE-ID>] [--recheck] [--checks <G-ID,...>] [--focus <file>]` | Renders one reviewer/auditor/test-analyst or same-worktree slice-wright by-reference packet (packets/<w>.<role>.md, or packets/<w>.contract.md for a wright) naming the context bundle (the .recheck bundle with `--recheck`) and frozen diff. It copies packets/<w>.sha256 (header rows only when large), resolves `--checks` through gates.md and approved preflight rows (adding `gates precheck` for `RUN: writer` gates), and accepts only anchored `--focus` rows (a doubt-reviewer's 1-3 row claim is required). It runs the dispatch open lints on a draft and never replaces a prior packet with a refused one (exit `3`). |
43
43
  | `metrics record <slug> --phase <p> --event <e> [--role r] [--bytes n] [--note s]` | Append an event to the workspace's `metrics.jsonl` ledger. |
44
44
  | `metrics summary [slug] [--by-slice]` | Reduce `metrics.jsonl` into a per-phase roll-up. `--by-slice` reads `dispatch.json` instead: per slice, build wright rounds, slice-bound Vet rechecks, and agent-minutes (role start to `returned_at`; abandoned waves excluded). When the ledger has `resume` events it adds `resumes count=N minutes_to_dispatch=...` (each resume timed to the next `dispatch` event; `none` when another resume came first, `open` when none followed). |
@@ -69,7 +69,9 @@ identity binds raw bytes for `spec.md`, `decision-coverage.md`,
69
69
  `architecture.md`, `plan.md`, `tasks.md`, `traceability.md`, and
70
70
  `test-plan.md`; it also binds presence or absence and bytes for optional
71
71
  `strategy.md`, `design-brief.md`, `ai-spec.md`, and project-root
72
- `.devrites/principles.md`. It excludes Build-mutable ledgers and evidence.
72
+ `.devrites/principles.md`. It excludes Build-mutable ledgers and evidence. Cells under a `traceability.md` table's `Evidence` or `Status`
73
+ header are blanked before hashing, so recording progress there does not stale
74
+ the binding.
73
75
 
74
76
  Each input is limited to 1 MiB and the aggregate to 8 MiB; symlinks and
75
77
  non-regular files fail closed. Ordinary readiness verifies the binding whenever
package/engine/go.mod CHANGED
@@ -2,4 +2,4 @@ module github.com/devrites/devrites
2
2
 
3
3
  go 1.26
4
4
 
5
- toolchain go1.27.2
5
+ toolchain go1.27.3
package/engine/help.go CHANGED
@@ -24,7 +24,7 @@ const (
24
24
  check slice <slug> <SLICE-ID>
25
25
  check diff-scope <slug> --allow <csv> | --allow-file <path> [--worktree|--staged|--base <ref>] [--allow-empty] [--baseline] [--verbose] [--cwd <dir>]
26
26
  check diff-scope <slug> --snapshot [--worktree|--staged|--base <ref>] [--cwd <dir>]
27
- check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N]
27
+ check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N] [--worktree <path> --slice <SLICE-ID>]
28
28
  check skill-trust <path>
29
29
  check indexes [--root <dir>]
30
30
  check regression <slug> [--update]
@@ -49,6 +49,10 @@ func ReadinessBinding(root, slug string) (string, error) {
49
49
  }
50
50
 
51
51
  func readinessBindingFromObservation(observation *state.WorkspaceObservation) (string, error) {
52
+ return bindReadinessInputs(observation, true)
53
+ }
54
+
55
+ func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress bool) (string, error) {
52
56
  digest := sha256.New()
53
57
  writeReadinessField(digest, readinessBindingDomain)
54
58
  writeReadinessField(digest, readinessBindingVersion)
@@ -58,6 +62,9 @@ func readinessBindingFromObservation(observation *state.WorkspaceObservation) (s
58
62
  if err != nil {
59
63
  return "", err
60
64
  }
65
+ if blankProgress && input.logical == "traceability.md" {
66
+ content = withoutProgressCells(content)
67
+ }
61
68
  writeReadinessField(digest, string(input.logical))
62
69
  if present {
63
70
  writeReadinessField(digest, "present")
@@ -110,6 +117,12 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
110
117
  if err != nil {
111
118
  return "", err
112
119
  }
120
+ // A line recorded over the raw bytes, before progress cells were blanked,
121
+ // still verifies while those bytes are unchanged.
122
+ legacy, err := bindReadinessInputs(observation, false)
123
+ if err != nil {
124
+ return "", err
125
+ }
113
126
  fact, ok := observation.Fact("eng-review.md")
114
127
  if !ok || fact.State() == state.ArtifactAbsent {
115
128
  return expected, errors.New("readiness input eng-review.md is missing")
@@ -130,7 +143,7 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
130
143
  continue
131
144
  }
132
145
  attempts++
133
- if line == expected {
146
+ if line == expected || line == legacy {
134
147
  matches++
135
148
  }
136
149
  }
@@ -150,7 +163,7 @@ type ReadinessInputDigest struct {
150
163
  SHA256 string `json:"sha256,omitempty"`
151
164
  // Normalized is test-plan.md's digest without its reviewed-identity lines,
152
165
  // so drift can prove a Workflow Artifact byte correction changed nothing
153
- // else Vet approved.
166
+ // else Vet approved, or traceability.md's digest without progress cells.
154
167
  Normalized string `json:"normalized,omitempty"`
155
168
  }
156
169
 
@@ -187,9 +200,13 @@ func ReadinessInputDigests(root, slug string) ([]ReadinessInputDigest, string, e
187
200
  if present {
188
201
  sum := sha256.Sum256(content)
189
202
  entry.SHA256 = hex.EncodeToString(sum[:])
190
- if input.logical == "test-plan.md" {
203
+ switch input.logical {
204
+ case "test-plan.md":
191
205
  normalized := sha256.Sum256(withoutReviewedIdentity(content))
192
206
  entry.Normalized = hex.EncodeToString(normalized[:])
207
+ case "traceability.md":
208
+ normalized := sha256.Sum256(withoutProgressCells(content))
209
+ entry.Normalized = hex.EncodeToString(normalized[:])
193
210
  }
194
211
  }
195
212
  digests = append(digests, entry)
@@ -197,6 +214,83 @@ func ReadinessInputDigests(root, slug string) ([]ReadinessInputDigest, string, e
197
214
  return digests, binding, nil
198
215
  }
199
216
 
217
+ // progressColumns name the traceability.md columns Build and Prove fill in as
218
+ // evidence lands. Vet approves the rows, not their progress, so the binding
219
+ // blanks these cells and keeps every other cell byte-exact.
220
+ var progressColumns = map[string]bool{"evidence": true, "status": true}
221
+
222
+ var delimiterCellRE = regexp.MustCompile(`^\s*:?-+:?\s*$`)
223
+
224
+ // withoutProgressCells blanks every Evidence and Status cell in the Markdown
225
+ // tables of content. A table is the header row, its delimiter row, and the
226
+ // table rows that follow.
227
+ func withoutProgressCells(content []byte) []byte {
228
+ lines := strings.Split(string(content), "\n")
229
+ var blank []int
230
+ for i, line := range lines {
231
+ prefix, cells, ok := tableCells(line)
232
+ switch {
233
+ case !ok:
234
+ blank = nil
235
+ case i+1 < len(lines) && delimiterRow(lines[i+1]):
236
+ blank = nil
237
+ for c, cell := range cells {
238
+ if progressColumns[strings.ToLower(strings.TrimSpace(cell))] {
239
+ blank = append(blank, c)
240
+ }
241
+ }
242
+ case len(blank) > 0 && !delimiterRow(line):
243
+ for _, c := range blank {
244
+ if c < len(cells) {
245
+ cells[c] = ""
246
+ }
247
+ }
248
+ lines[i] = prefix + "|" + strings.Join(cells, "|") + "|"
249
+ }
250
+ }
251
+ return []byte(strings.Join(lines, "\n"))
252
+ }
253
+
254
+ // tableCells splits a Markdown table row on its unescaped pipes. prefix is the
255
+ // indentation before the first pipe.
256
+ func tableCells(line string) (prefix string, cells []string, ok bool) {
257
+ trimmed := strings.TrimRight(strings.TrimSuffix(line, "\r"), " \t")
258
+ start := strings.Index(trimmed, "|")
259
+ if start < 0 || strings.TrimSpace(trimmed[:start]) != "" {
260
+ return "", nil, false
261
+ }
262
+ var cell strings.Builder
263
+ for i := start + 1; i < len(trimmed); i++ {
264
+ switch {
265
+ case trimmed[i] == '\\' && i+1 < len(trimmed) && trimmed[i+1] == '|':
266
+ cell.WriteString(`\|`)
267
+ i++
268
+ case trimmed[i] == '|':
269
+ cells = append(cells, cell.String())
270
+ cell.Reset()
271
+ default:
272
+ cell.WriteByte(trimmed[i])
273
+ }
274
+ }
275
+ if cell.Len() > 0 {
276
+ cells = append(cells, cell.String())
277
+ }
278
+ return trimmed[:start], cells, true
279
+ }
280
+
281
+ func delimiterRow(line string) bool {
282
+ _, cells, ok := tableCells(line)
283
+ if !ok || len(cells) == 0 {
284
+ return false
285
+ }
286
+ for _, cell := range cells {
287
+ if !delimiterCellRE.MatchString(cell) {
288
+ return false
289
+ }
290
+ }
291
+ return true
292
+ }
293
+
200
294
  func readinessDiagnosticError(diagnostic state.ArtifactDiagnostic) error {
201
295
  prefix := fmt.Sprintf("readiness input %s is %s (%s); ", diagnostic.Path, diagnostic.State, diagnostic.Code)
202
296
  repair := diagnosticRepair(diagnostic.Code)
@@ -108,6 +108,12 @@ func RunCheckAnchors(root string, args []string, stdout, stderr io.Writer) int {
108
108
  summary := fmt.Sprintf("anchored=%d relocated=%d unverified=%d deferred=%d",
109
109
  counts["anchored"], counts["relocated"], counts["Unverified"], counts["Deferred"])
110
110
  recordMetricNote(root, slug, cursorPhase(root, slug), "anchors", "", summary)
111
+ // A head that declares more blockers than there are Finding: rows means
112
+ // rows were written as prose; zero parsed rows must not read as clean.
113
+ if declared := declaredBlockers(string(raw)); declared > rows {
114
+ fmt.Fprintf(stdout, "check anchors: BLOCKED: Counts declares %d Critical/Important but %d are Finding: rows; rewrite each as a Finding: row and rerun\n", declared, rows)
115
+ return 3
116
+ }
111
117
  if counts["Unverified"]+counts["Deferred"] > 0 {
112
118
  fmt.Fprintf(stdout, "check anchors: BLOCKED: %d Critical/Important rows, %s; Unverified and Deferred rows never drive a repair round\n", rows, summary)
113
119
  return 3
@@ -116,6 +122,26 @@ func RunCheckAnchors(root string, args []string, stdout, stderr io.Writer) int {
116
122
  return 0
117
123
  }
118
124
 
125
+ // declaredBlockers sums the critical and important entries of the return's
126
+ // first Counts: line, or 0 when there is none.
127
+ func declaredBlockers(text string) int {
128
+ for _, line := range strings.Split(text, "\n") {
129
+ rest, ok := strings.CutPrefix(strings.TrimSpace(line), "Counts:")
130
+ if !ok {
131
+ continue
132
+ }
133
+ total := 0
134
+ for _, field := range strings.Fields(rest) {
135
+ if m := countRE.FindStringSubmatch(field); m != nil && (m[1] == "critical" || m[1] == "important") {
136
+ n, _ := strconv.Atoi(m[2])
137
+ total += n
138
+ }
139
+ }
140
+ return total
141
+ }
142
+ return 0
143
+ }
144
+
119
145
  // row classifies one blocker row: anchored, relocated, Unverified or Deferred.
120
146
  func (a anchorer) row(cells []string) (status, detail string) {
121
147
  if len(cells) < 3 {
@@ -132,7 +132,14 @@ func compareDrift(workspace, baselinePath string, digests []gate.ReadinessInputD
132
132
  if !hasBaseline {
133
133
  return compareDriftFallback(workspace, binding, stdout)
134
134
  }
135
- diffs := diffInputs(baseline.Inputs, digests)
135
+ var diffs []driftDiff
136
+ for _, d := range diffInputs(baseline.Inputs, digests) {
137
+ if d.Kind == "progress-only" {
138
+ fmt.Fprintf(stdout, "check drift: %s progress-only — only Evidence/Status cells moved; no re-vet needed\n", d.Path)
139
+ continue
140
+ }
141
+ diffs = append(diffs, d)
142
+ }
136
143
  if len(diffs) == 0 && baseline.Binding == binding {
137
144
  fmt.Fprintf(stdout, "check drift: none (baseline %s)\n", baseline.RecordedAt)
138
145
  return 0
@@ -215,6 +222,9 @@ func diffInputs(base, current []gate.ReadinessInputDigest) []driftDiff {
215
222
  kind := "changed"
216
223
  if b.Normalized != "" && b.Normalized == c.Normalized {
217
224
  kind = "reviewed-identity-only"
225
+ if b.Path == "traceability.md" {
226
+ kind = "progress-only"
227
+ }
218
228
  }
219
229
  diffs = append(diffs, driftDiff{Path: b.Path, Kind: kind})
220
230
  }
@@ -12,11 +12,12 @@ import (
12
12
  "strings"
13
13
 
14
14
  "github.com/devrites/devrites/internal/devritespaths"
15
+ "github.com/devrites/devrites/internal/parallel"
15
16
  "github.com/devrites/devrites/internal/state"
16
17
  )
17
18
 
18
19
  // CandidateFreezeUsage is the help and usage-error text for `candidate freeze`.
19
- const CandidateFreezeUsage = `usage: devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID> | --paths <path>...) [--base <ref>]
20
+ const CandidateFreezeUsage = `usage: devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID> | --paths <path>...) [--base <ref>] [--worktree <path>]
20
21
 
21
22
  Freezes one review candidate. It writes packets/<w>.diff (the path set's
22
23
  tracked changes against --base, default HEAD, plus each untracked file as a
@@ -24,7 +25,10 @@ tracked changes against --base, default HEAD, plus each untracked file as a
24
25
  sha256, and the aggregate candidate digest under the check candidate domain
25
26
  and bounds). --slice takes that slice's Writer allowlist (else Files likely
26
27
  touched) from tasks.md. A wave's frozen candidate is never replaced by a
27
- different one; freeze a changed candidate under a new wave.
28
+ different one; freeze a changed candidate under a new wave. --worktree reads
29
+ the path set from a linked worktree of this repository (a parallel sibling)
30
+ instead of the checkout, requires --base, refuses uncommitted path-set
31
+ changes, and records that worktree's HEAD (the transfer commit) as head.
28
32
  `
29
33
 
30
34
  // packetNameRE bounds a wave, role, phase, or slice used in a packets/ or
@@ -34,7 +38,7 @@ var packetNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
34
38
  // RunCandidateFreeze implements `candidate freeze`: the producer of the frozen
35
39
  // .diff a Review or Seal reviewer packet must name.
36
40
  func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) int {
37
- var wave, slice, base string
41
+ var wave, slice, base, worktree string
38
42
  var paths, positional []string
39
43
  for i := 0; i < len(args); i++ {
40
44
  value := argAt(args, i+1)
@@ -45,6 +49,8 @@ func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) in
45
49
  slice = value
46
50
  case "--base":
47
51
  base = value
52
+ case "--worktree":
53
+ worktree = value
48
54
  case "--paths":
49
55
  for i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
50
56
  i++
@@ -61,7 +67,7 @@ func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) in
61
67
  }
62
68
  i++
63
69
  }
64
- if len(positional) != 1 || !packetNameRE.MatchString(wave) || (slice == "") == (len(paths) == 0) {
70
+ if len(positional) != 1 || !packetNameRE.MatchString(wave) || (slice == "") == (len(paths) == 0) || (worktree != "" && base == "") {
65
71
  fmt.Fprint(stderr, CandidateFreezeUsage)
66
72
  return 2
67
73
  }
@@ -75,16 +81,16 @@ func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) in
75
81
  return fail(err)
76
82
  }
77
83
  if slice != "" {
78
- raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
79
- if err != nil {
84
+ if paths, err = slicePaths(root, slug, slice); err != nil {
80
85
  return fail(err)
81
86
  }
82
- writes, _ := sliceWritePaths(raw, ParseTaskGraph(raw))
83
- if paths = writes[slice]; len(paths) == 0 {
84
- return fail(fmt.Errorf("slice %s names no Writer allowlist or Files likely touched in tasks.md", slice))
85
- }
86
87
  }
87
88
  project := projectDir(root)
89
+ if worktree != "" {
90
+ if project, err = checkedWorktree(root, worktree); err != nil {
91
+ return fail(err)
92
+ }
93
+ }
88
94
  rows, err := freezeRows(project, paths)
89
95
  if err != nil {
90
96
  return fail(err)
@@ -99,12 +105,24 @@ func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) in
99
105
  if len(diff) == 0 {
100
106
  return fail(errors.New("the path set has no change against the base; there is no candidate to freeze"))
101
107
  }
108
+ head := ""
109
+ if worktree != "" {
110
+ // Review binds to the transfer commit, so the frozen tree must be exactly it.
111
+ dirty, commit, err := freezeDiff(project, "HEAD", rows)
112
+ if err != nil {
113
+ return fail(err)
114
+ }
115
+ if len(dirty) != 0 {
116
+ return fail(errors.New("the worktree has uncommitted changes on the path set; the frozen candidate must be its HEAD transfer commit"))
117
+ }
118
+ head = "head " + commit + "\n"
119
+ }
102
120
  digest, err := hashCandidate(project, rows)
103
121
  if err != nil {
104
122
  return fail(err)
105
123
  }
106
124
  var sums strings.Builder
107
- fmt.Fprintf(&sums, "%s %s\nbase %s\ndiff %x packets/%s.diff\n", candidateDigestDomain, candidateDigestVersion, baseCommit, sha256.Sum256(diff), wave)
125
+ fmt.Fprintf(&sums, "%s %s\nbase %s\n%sdiff %x packets/%s.diff\n", candidateDigestDomain, candidateDigestVersion, baseCommit, head, sha256.Sum256(diff), wave)
108
126
  for _, row := range rows {
109
127
  if row.state != "present" {
110
128
  fmt.Fprintf(&sums, "file absent %s\n", row.path)
@@ -139,6 +157,84 @@ func RunCandidateFreeze(root string, args []string, stdout, stderr io.Writer) in
139
157
  return 0
140
158
  }
141
159
 
160
+ // slicePaths is the slice's tasks.md write set: Writer allowlist, else Files
161
+ // likely touched.
162
+ func slicePaths(root, slug, slice string) ([]string, error) {
163
+ raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
164
+ if err != nil {
165
+ return nil, err
166
+ }
167
+ writes, _ := sliceWritePaths(raw, ParseTaskGraph(raw))
168
+ if len(writes[slice]) == 0 {
169
+ return nil, fmt.Errorf("slice %s names no Writer allowlist or Files likely touched in tasks.md", slice)
170
+ }
171
+ return writes[slice], nil
172
+ }
173
+
174
+ // checkedWorktree returns the project's directory inside path, a linked
175
+ // worktree of the control checkout's repository: path itself, plus the
176
+ // project's prefix below the git toplevel. It refuses a symlinked component, a
177
+ // directory that is not a worktree's top level, a submodule, another
178
+ // repository, a path git does not list as a worktree, and the control checkout.
179
+ func checkedWorktree(root, path string) (string, error) {
180
+ abs, err := filepath.Abs(path)
181
+ if err != nil {
182
+ return "", err
183
+ }
184
+ if err := refuseSymlinkedPath(abs); err != nil {
185
+ return "", err
186
+ }
187
+ out, err := runGitCommand(projectDir(root), nil, "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel", "--show-prefix")
188
+ if err != nil {
189
+ return "", err
190
+ }
191
+ control := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
192
+ if len(control) != 3 {
193
+ return "", fmt.Errorf("git rev-parse printed %q for the control checkout", out)
194
+ }
195
+ out, err = runGitCommand(abs, nil, "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel", "--show-superproject-working-tree")
196
+ got := strings.Split(strings.TrimSpace(string(out)), "\n")
197
+ switch {
198
+ case err != nil:
199
+ return "", fmt.Errorf("worktree %s is not a git worktree: %w", abs, err)
200
+ case len(got) != 2:
201
+ return "", fmt.Errorf("worktree %s is inside a submodule", abs)
202
+ case !sameDir(got[0], control[0]):
203
+ return "", fmt.Errorf("worktree %s belongs to another repository", abs)
204
+ case !sameDir(got[1], abs):
205
+ return "", fmt.Errorf("worktree %s is not a worktree's top level", abs)
206
+ case sameDir(abs, control[1]):
207
+ return "", fmt.Errorf("worktree %s is the control checkout, not a linked worktree", abs)
208
+ }
209
+ registered, err := parallel.RegisteredWorktrees(control[1])
210
+ if err != nil {
211
+ return "", err
212
+ }
213
+ if !registered[abs] {
214
+ return "", fmt.Errorf("worktree %s is not listed by git worktree list", abs)
215
+ }
216
+ project := filepath.Join(abs, filepath.FromSlash(control[2]))
217
+ return project, refuseSymlinkedPath(project)
218
+ }
219
+
220
+ // sameDir reports whether a and b name one existing directory, whatever their
221
+ // spelling.
222
+ func sameDir(a, b string) bool {
223
+ ai, aerr := os.Stat(a)
224
+ bi, berr := os.Stat(b)
225
+ return aerr == nil && berr == nil && ai.IsDir() && os.SameFile(ai, bi)
226
+ }
227
+
228
+ // refuseSymlinkedPath refuses an absolute path any component of which is a
229
+ // symlink (or that does not exist).
230
+ func refuseSymlinkedPath(abs string) error {
231
+ resolved, err := filepath.EvalSymlinks(abs)
232
+ if err == nil && resolved != abs {
233
+ err = fmt.Errorf("worktree %s has a symlinked component (it resolves to %s)", abs, resolved)
234
+ }
235
+ return err
236
+ }
237
+
142
238
  // freezeRows normalizes, deduplicates, and sorts the path set, marking each
143
239
  // row present or deleted (absent) as hashCandidate expects.
144
240
  func freezeRows(project string, paths []string) ([]candidateRow, error) {
@@ -22,7 +22,7 @@ import (
22
22
  )
23
23
 
24
24
  // PerturbUsage is the help and usage-error text for `check perturb`.
25
- const PerturbUsage = `usage: devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N]
25
+ const PerturbUsage = `usage: devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N] [--worktree <path> --slice <SLICE-ID>]
26
26
 
27
27
  Runs one safe-perturbation probe without touching the checkout. It copies the
28
28
  current candidate (HEAD plus every touched-files.md manifest row) into a
@@ -34,8 +34,10 @@ test, jest/vitest) names --expect; a build or setup failure, a timeout, exit 0
34
34
  (the mutant survived), or a RED naming only other tests is cannot_verify. It
35
35
  then reverts the patch, requires the copy back at the candidate digest and the
36
36
  gate GREEN (exit 0 plus EXPECT), and requires the checkout candidate unchanged.
37
- The transcript is written under evidence/perturb/. Exit 0 verified; exit 3
38
- cannot_verify or refused.
37
+ The transcript is written under evidence/perturb/. --worktree probes a linked
38
+ worktree of this repository (a parallel sibling) instead: its HEAD plus the
39
+ --slice write set from tasks.md, with --patch resolved against it. Exit 0
40
+ verified; exit 3 cannot_verify or refused.
39
41
  `
40
42
 
41
43
  const perturbOutputTail = 64 << 10
@@ -65,7 +67,7 @@ type perturbProbe struct {
65
67
  // RunCheckPerturb implements `check perturb`: an engine-run, candidate-bound
66
68
  // safe-perturbation probe (testing.md § Safe perturbation).
67
69
  func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
68
- var patchArg, expect, gateID string
70
+ var patchArg, expect, gateID, worktree, slice string
69
71
  var positional []string
70
72
  timeout := acceptance.DefaultTimeout
71
73
  for i := 0; i < len(args); i++ {
@@ -77,6 +79,10 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
77
79
  expect = strings.TrimSpace(value)
78
80
  case "--gate":
79
81
  gateID = value
82
+ case "--worktree":
83
+ worktree = value
84
+ case "--slice":
85
+ slice = value
80
86
  case "--timeout":
81
87
  n, err := strconv.Atoi(value)
82
88
  if err != nil || n <= 0 {
@@ -94,7 +100,7 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
94
100
  }
95
101
  i++
96
102
  }
97
- if len(positional) != 1 || patchArg == "" || expect == "" || gateID == "" {
103
+ if len(positional) != 1 || patchArg == "" || expect == "" || gateID == "" || (worktree == "") != (slice == "") {
98
104
  fmt.Fprint(stderr, PerturbUsage)
99
105
  return 2
100
106
  }
@@ -107,7 +113,19 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
107
113
  if err != nil {
108
114
  return refuse(err)
109
115
  }
110
- identity, err := loadCandidateIdentity(root, slug)
116
+ project := projectDir(root)
117
+ load := func() (candidateIdentity, error) { return loadCandidateIdentity(root, slug) }
118
+ if worktree != "" {
119
+ if project, err = checkedWorktree(root, worktree); err != nil {
120
+ return refuse(err)
121
+ }
122
+ paths, err := slicePaths(root, slug, slice)
123
+ if err != nil {
124
+ return refuse(err)
125
+ }
126
+ load = func() (candidateIdentity, error) { return pathSetIdentity(project, paths) }
127
+ }
128
+ identity, err := load()
111
129
  if err != nil {
112
130
  return refuse(err)
113
131
  }
@@ -115,7 +133,6 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
115
133
  if err != nil {
116
134
  return refuse(err)
117
135
  }
118
- project := projectDir(root)
119
136
  patchPath := patchArg
120
137
  if !filepath.IsAbs(patchPath) {
121
138
  patchPath = filepath.Join(project, filepath.FromSlash(patchPath))
@@ -130,7 +147,7 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
130
147
 
131
148
  probe := runPerturbProbe(project, identity, gate, patch, expect, timeout)
132
149
  after := "unavailable"
133
- if current, err := loadCandidateIdentity(root, slug); err != nil {
150
+ if current, err := load(); err != nil {
134
151
  after += ": " + oneLine(err.Error())
135
152
  } else {
136
153
  after = current.digest
@@ -152,6 +169,20 @@ func RunCheckPerturb(root string, args []string, stdout, stderr io.Writer) int {
152
169
  return 0
153
170
  }
154
171
 
172
+ // pathSetIdentity validates and hashes the path set in project, as candidate
173
+ // freeze does, for a candidate that has no manifest.
174
+ func pathSetIdentity(project string, paths []string) (candidateIdentity, error) {
175
+ rows, err := freezeRows(project, paths)
176
+ if err == nil {
177
+ err = validateCandidateRows(project, rows)
178
+ }
179
+ if err != nil {
180
+ return candidateIdentity{}, err
181
+ }
182
+ digest, err := hashCandidate(project, rows)
183
+ return candidateIdentity{digest: digest, rows: rows}, err
184
+ }
185
+
155
186
  // perturbGate returns the live runnable gate id, refusing a CHECK/CWD that is
156
187
  // not an approved test-plan.md preflight row: a probe never runs unvetted code.
157
188
  func perturbGate(root, slug, id string) (*acceptance.Gate, error) {
@@ -236,6 +236,9 @@ func registeredWorktrees(repo string) (map[string]bool, error) {
236
236
  return set, nil
237
237
  }
238
238
 
239
+ // RegisteredWorktrees is registeredWorktrees for callers outside the package.
240
+ func RegisteredWorktrees(repo string) (map[string]bool, error) { return registeredWorktrees(repo) }
241
+
239
242
  func resolvedPath(p string) string {
240
243
  if r, err := filepath.EvalSymlinks(p); err == nil {
241
244
  return r
package/engine/main.go CHANGED
@@ -48,7 +48,7 @@ Usage:
48
48
  Report near-duplicate code clusters that survive renaming (advisory)
49
49
  devrites-engine check anchors <slug> <return-file> [--skills-root <dir>]
50
50
  Anchor each Critical/Important row's quotes; Unverified or Deferred rows exit 3
51
- devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N]
51
+ devrites-engine check perturb <slug> --patch <file> --expect <test-name> --gate <G-ID> [--timeout N] [--worktree <path> --slice <SLICE-ID>]
52
52
  Run one mutant patch against a candidate copy; RED must name --expect
53
53
  devrites-engine observe summary <slug> Emit sanitized JSON workspace summary
54
54
  devrites-engine orient <slug> Alias for observe summary
@@ -60,7 +60,7 @@ Usage:
60
60
  Emit one deduplicated read-set bundle for a phase, skill, or dispatch role
61
61
  devrites-engine metrics record <slug> --phase <p> --event <e> [--role r] [--bytes n] [--note s]
62
62
  devrites-engine metrics summary [slug] [--by-slice] Summarize the metrics ledger, or per-slice rounds and agent-minutes
63
- devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID> | --paths <path>...) [--base <ref>]
63
+ devrites-engine candidate freeze <slug> --wave <w> (--slice <SLICE-ID> | --paths <path>...) [--base <ref>] [--worktree <path>]
64
64
  Freeze a review candidate to packets/<w>.diff + packets/<w>.sha256
65
65
  devrites-engine packet <slug> --wave <w> --phase <p> --role <r> [--slice <SLICE-ID>] [--recheck] [--checks <G-ID,...>] [--focus <file>]
66
66
  Render and lint one role's by-reference dispatch packet
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
@@ -105,10 +105,13 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
105
105
  precondition is dropped from the batch and recorded as a gap, not dispatched.
106
106
  2. Write lease; freeze `B=HEAD`; `parallel create` worktrees.
107
107
  3. Dispatch ≤10 wrights in parallel (cwd=worktree; allowlist; prove `HEAD==B`;
108
- one `--slice` per sibling on the wave).
108
+ one `--slice` per sibling on the wave). A linked worktree has no `.devrites`:
109
+ tell each wright to run `gates precheck` with `DEVRITES_ROOT=<control>/.devrites`.
109
110
  4. Inspect each returned path list, cumulative diff from `B`, transfer identity,
110
111
  and required result fields against the immutable contract. Reject malformed,
111
- stale, or out-of-scope work. Apply
112
+ stale, or out-of-scope work. Freeze and probe the sibling in its worktree:
113
+ `candidate freeze <slug> --wave <w> --slice <id> --base <B> --worktree <worktree_path>`,
114
+ `check perturb ... --worktree <worktree_path> --slice <id>`. Apply
112
115
  [independent Build review](phase-contract.md#independent-build-review): first
113
116
  full inventory, then bounded owning-role rechecks after repairs. Run approved
114
117
  proof in the same window as that review. Gap or Critical/Important → `gap`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "7.0.2",
3
+ "version": "7.0.3",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code, Codex, omp, pi, and Devin CLI",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",