devrites 7.3.1 → 7.3.2

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +35 -3
  5. package/docs/release.md +5 -2
  6. package/engine/Makefile +2 -2
  7. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  8. package/engine/internal/acceptance/preconditions.go +17 -11
  9. package/engine/internal/acceptance/transcript.go +23 -4
  10. package/engine/internal/gate/gate.go +8 -6
  11. package/engine/internal/gate/readiness_binding.go +50 -21
  12. package/engine/internal/install/apply.go +36 -21
  13. package/engine/internal/install/binary.go +67 -9
  14. package/engine/internal/install/install.go +63 -21
  15. package/engine/internal/install/manifest.go +4 -5
  16. package/engine/internal/install/merge.go +89 -33
  17. package/engine/internal/install/preflight.go +60 -19
  18. package/engine/internal/install/uninstall.go +19 -9
  19. package/engine/internal/install/update.go +0 -4
  20. package/engine/internal/lib/claims.go +170 -4
  21. package/engine/internal/lib/claims_holder.go +13 -16
  22. package/engine/internal/lib/closeout.go +52 -28
  23. package/engine/internal/lib/context_io.go +88 -4
  24. package/engine/internal/lib/continuation.go +111 -24
  25. package/engine/internal/lib/dispatch.go +157 -24
  26. package/engine/internal/lib/final_prerequisites.go +137 -0
  27. package/engine/internal/lib/manifest_union.go +62 -25
  28. package/engine/internal/lib/next.go +10 -10
  29. package/engine/internal/lib/packet.go +32 -8
  30. package/engine/internal/lib/packet_integrity.go +194 -2
  31. package/engine/internal/lib/resolve.go +128 -142
  32. package/engine/internal/lib/treebaseline.go +0 -1
  33. package/engine/internal/markdowntext/markdowntext.go +110 -31
  34. package/engine/internal/overhaul/records/check.go +45 -5
  35. package/engine/internal/overhaul/records/consistency.go +34 -8
  36. package/engine/internal/overhaul/records/run.go +55 -11
  37. package/engine/internal/parallel/landing.go +17 -0
  38. package/engine/internal/state/acceptance.go +35 -5
  39. package/engine/internal/state/feature.go +3 -2
  40. package/engine/internal/state/observation.go +1 -1
  41. package/engine/internal/state/write.go +20 -44
  42. package/engine/internal/workflow/commands.go +25 -3
  43. package/engine/main.go +19 -0
  44. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  45. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  46. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  47. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  48. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  49. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  50. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  51. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  52. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  53. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  54. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  55. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  56. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  57. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  58. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  59. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  60. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  61. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  62. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  69. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  72. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  73. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  74. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  75. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  76. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  77. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  78. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  79. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  80. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  81. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  82. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  83. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  84. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  85. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  86. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  87. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  88. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  89. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  90. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  91. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  98. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  99. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  100. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  101. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  102. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  103. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  104. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  105. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  106. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  107. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  108. package/package.json +1 -1
  109. package/scripts/dsh-generate.py +1 -1
  110. package/scripts/install-lib.sh +29 -0
  111. package/uninstall.sh +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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.3.2](https://github.com/ViktorsBaikers/DevRites/compare/v7.3.1...v7.3.2) (2026-11-02)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** harden lifecycle, installer and host safety ([#205](https://github.com/ViktorsBaikers/DevRites/issues/205)) ([cac2f8a](https://github.com/ViktorsBaikers/DevRites/commit/cac2f8ab657bbe0d44198ed587413779cf5ac44d))
10
+
5
11
  ## [7.3.1](https://github.com/ViktorsBaikers/DevRites/compare/v7.3.0...v7.3.1) (2026-11-01)
6
12
 
7
13
  ### Fixed
@@ -43,6 +49,22 @@ All notable changes to DevRites are documented here. The format follows [Keep a
43
49
  workspaces or reviving their controller leases.
44
50
  - Execute Autocomplete's global frontier at cold Prove/Vet/Review and native wait
45
51
  boundaries; preserve frozen candidates, standalone scope, and non-green gates.
52
+ - Preserve selected workspace identity across native command forms, resolver
53
+ resumes, and handoffs; reject empty answers and invalid wait transitions before
54
+ publishing question changes.
55
+ - Keep candidate and next-step observations read-only, and retain unfinished Build
56
+ and Polish work even when their required documents already exist.
57
+ - Confine installer reads and mutations to the validated project root; acquire
58
+ shared-marker ownership before changing configuration, replay disabled-host
59
+ update flags, and reject linked pin destinations outside the project.
60
+ - Preserve uninstall ownership after binary-removal failures so ordinary retries
61
+ work without touching another installation. Stage Windows launcher uninstalls
62
+ outside the installed image; refuse direct self-removal before project changes.
63
+ - Bind Overhaul scorecards to current calculator inputs and admitted receipts to
64
+ immutable generation hashes; require complete writer reservations and
65
+ lane-matched coverage proof.
66
+ - Serialize Overhaul's shared-tree writer observation windows without filtering
67
+ away unauthorized changes; retain parallel read-only review on immutable inputs.
46
68
 
47
69
  ## [7.2.8](https://github.com/ViktorsBaikers/DevRites/compare/v7.2.7...v7.2.8) (2026-10-31)
48
70
 
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.3.1`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.3.1): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
34
+ **Status:** [`v7.3.2`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.3.2): 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
 
@@ -288,6 +288,12 @@ npx devrites@latest uninstall
288
288
  npx devrites@latest uninstall --keep-binary
289
289
  ```
290
290
 
291
+ On Windows, use `npx devrites@latest uninstall` or the Bash `uninstall.sh`
292
+ launcher to remove the shared engine binary. These launchers run a private
293
+ temporary copy so Windows can delete the original installed executable.
294
+ Direct `devrites-engine uninstall` cannot delete its own running image and
295
+ refuses before changing project files; use the launcher or `--keep-binary`.
296
+
291
297
  Run `npx devrites@latest --help` for common flags and
292
298
  `npx devrites@latest <command> --help` for command-specific flags.
293
299
 
@@ -387,6 +393,10 @@ blocks uninstall and dropped-host pruning, including dry runs, before writes.
387
393
  Existing files retain their permission bits. Claude permission ownership is
388
394
  recorded before settings replacement, so interrupted merges retain the records
389
395
  needed to remove installer-added grants on retry and uninstall.
396
+ User-pinned aliases keep their recorded ownership hashes; refreshing does not
397
+ silently adopt local edits. Shared-config marker collisions fail before writes.
398
+ Legacy hook cleanup matches known shipped commands, not user commands that merely
399
+ mention DevRites.
390
400
  Marker-merged files keep user content outside the DevRites block, and
391
401
  `.devrites/` runtime state stays in place. `update` reuses the install flags
392
402
  recorded in the manifest; flags you pass explicitly win and are recorded. The installer refuses symlinks, junctions, and paths that
@@ -395,8 +405,11 @@ escape the target.
395
405
  The optional shared `devrites-engine` binary is the only artifact installed
396
406
  outside the project. Before replacement, the staged release binary must report
397
407
  the requested version; after installation, the binary at its final path must
398
- report it again in a new process. The installer keeps a backup until the
399
- second check passes and restores it on failure.
408
+ report it again in a new process. The selected binary is installed after merge
409
+ preflight but before project payload and version publication, so binary failure
410
+ leaves an ordinary update retry possible. The installer keeps a backup until the
411
+ second check passes and restores it on failure. If restoration also fails, it
412
+ retains the backup and reports its recovery path.
400
413
 
401
414
  `devrites-engine update` resolves the latest stable release, acquires its
402
415
  bundle and platform binary, and hands local paths to the downloaded engine, so
@@ -549,6 +562,9 @@ npm test
549
562
  (cd engine && go test ./... -count=1)
550
563
  ```
551
564
 
565
+ Native Windows launcher integration tests also require Node and Git for Windows
566
+ (including Git Bash). They exercise the real installed engine through both launchers.
567
+
552
568
  Canonical pack changes belong in `pack/.claude/`. Rebuild host payloads with
553
569
  `bash scripts/build-host-artifacts.sh` instead of editing `pack/generated/`.
554
570
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow and
package/bin/devrites.mjs CHANGED
@@ -2,10 +2,10 @@
2
2
  // devrites - npx entry point for the engine-owned DevRites installer.
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
- import { chmodSync, createReadStream, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
5
+ import { chmodSync, copyFileSync, createReadStream, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
6
6
  import { open } from 'node:fs/promises';
7
7
  import { constants as osConstants, tmpdir } from 'node:os';
8
- import { dirname, join } from 'node:path';
8
+ import { dirname, join, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const here = dirname(fileURLToPath(import.meta.url));
@@ -108,6 +108,31 @@ function runEngine(command, args, env) {
108
108
  });
109
109
  }
110
110
 
111
+ function windowsInstalledEngine() {
112
+ const dirs = (process.env.PATH || '').split(';');
113
+ if (process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift(process.cwd());
114
+ for (const dir of dirs) {
115
+ if (!dir) continue;
116
+ const candidate = resolve(dir.replace(/^"|"$/g, ''), 'devrites-engine.exe');
117
+ try {
118
+ if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
119
+ } catch {}
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function stageWindowsUninstall(engine) {
125
+ if (!engine || command !== 'uninstall' || process.platform !== 'win32' || temporaryDirs.has(dirname(engine))) return engine;
126
+ try {
127
+ // Windows cannot unlink the running installed image; launch a private copy.
128
+ const staged = join(privateTempDir(), 'devrites-engine.exe');
129
+ copyFileSync(engine, staged);
130
+ return staged;
131
+ } catch (error) {
132
+ throw new Error(`could not stage devrites-engine for Windows uninstall: ${error.message}`);
133
+ }
134
+ }
135
+
111
136
  async function acquireEngine() {
112
137
  const repo = process.env.DEVRITES_REPO || 'ViktorsBaikers/DevRites';
113
138
  const tag = normalizeReleaseTag(process.env.DEVRITES_REF || pkgVersion());
@@ -123,7 +148,8 @@ async function acquireEngine() {
123
148
  acquisitionFailure = `${envEngine} reports version '${got}', want ${tag}`;
124
149
  return null;
125
150
  }
126
- if (command === 'uninstall' && spawnSync('devrites-engine', ['uninstall', '-h'], { stdio: 'ignore' }).status === 0) return 'devrites-engine';
151
+ const installedEngine = command === 'uninstall' && process.platform === 'win32' ? windowsInstalledEngine() : 'devrites-engine';
152
+ if (command === 'uninstall' && installedEngine && spawnSync(installedEngine, ['uninstall', '-h'], { stdio: 'ignore' }).status === 0) return installedEngine;
127
153
 
128
154
  // A git checkout's payload can be ahead of the published engine of the same
129
155
  // version, so a checkout builds its own engine before downloading one.
@@ -135,7 +161,7 @@ async function acquireEngine() {
135
161
  const built = checkout ? null : buildEngine(tag);
136
162
  if (built) return built;
137
163
 
138
- if (command === 'uninstall') return 'devrites-engine';
164
+ if (command === 'uninstall') return installedEngine;
139
165
  const probe = spawnSync('devrites-engine', ['version'], { encoding: 'utf8' });
140
166
  if (!probe.error && probe.status === 0 && String(probe.stdout).trim().replace(/^v/, '') === tag.slice(1)) return 'devrites-engine';
141
167
  return null;
@@ -316,7 +342,7 @@ if (first && SUBCOMMAND_ALIASES.has(first)) {
316
342
 
317
343
  let engine;
318
344
  try {
319
- engine = await acquireEngine();
345
+ engine = stageWindowsUninstall(await acquireEngine());
320
346
  } catch (error) {
321
347
  console.error(`devrites: ${error.message}`);
322
348
  process.exit(1);
@@ -38,7 +38,7 @@ workflow strategy.
38
38
  | `handoff [slug] [--resume]` | Emit the deterministic resume record: cursor fields, `awaiting_human`, open `questions.md` gate kinds, the `gates.md` reduction with unmet/stale/abandoned ids, `decisions.md` dead ends, in-flight dispatch waves (`inflight_wave:` lines: phase, state, pending roles, slices), missing required files, the canonical read-next order, and the same `continuation:` lines as `check continuation`. Read-only unless `--resume`, which appends one `resume` event (role root) to `metrics.jsonl`; `metrics summary` then reports minutes from each resume to the next dispatch. The `/rite-handoff` spine and the compaction-fallback entry point. |
39
39
  | `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. |
40
40
  | `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) ...`), unless `.devrites/AFK` `max_parallel` is 2 or more or a parallel lease is `running` (the bundle stays whole); `--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. |
41
- | `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 build wright wave that leaves `parallel select --from-plan` picks unstarted exits `3` (printing the batch, the command and the parallel-batch lifecycle) until evidence.md records `serial: <skipped ids> — <reason>`; a repair round or a running lease slice does not waive those picks, and a reason that only says the skipped slice is repairing, already started, or in a wave does not count. A wave naming two or more slices needs one `--role slice-wright.<SLICE-ID>` per slice; one shared role exits `3`. HITL runs are never width-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
+ | `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); wave IDs are single-use, including after abandonment; a new attempt needs a new ID, and retained overlapping writer claims require explicit release or host-grounded termination rather than TTL expiry or session reuse; 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 build wright wave that leaves `parallel select --from-plan` picks unstarted exits `3` (printing the batch, the command and the parallel-batch lifecycle) until evidence.md records `serial: <skipped ids> — <reason>`; a repair round or a running lease slice does not waive those picks, and a reason that only says the skipped slice is repairing, already started, or in a wave does not count. A wave naming two or more slices needs one `--role slice-wright.<SLICE-ID>` per slice; one shared role exits `3`. HITL runs are never width-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. |
42
42
  | `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. |
43
43
  | `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), lists a wright slice's tasks.md `Tests/proof` as its first writer-safe check, 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`). |
44
44
  | `metrics record <slug> --phase <p> --event <e> [--role r] [--bytes n] [--note s]` | Append an event to the workspace's `metrics.jsonl` ledger. |
@@ -47,10 +47,10 @@ workflow strategy.
47
47
  | `note <add\|list\|check\|rm> <slug> ...` | Anchored workspace notes in `notes.md`: `add <slug> <subject> <quote> <title> [body]` binds a note to a verbatim quote in one repository file and assigns `NOTE-###`; `list` and `check` regrade each anchor `exact`/`moved`/`stale`/`ambiguous`/`lost` by searching the repo for the quote (dependency and workspace trees excluded); `check --repair` rewrites a `moved` subject to its new file; `rm` deletes by ID. `check seal` refuses a malformed `notes.md` and any non-`exact` anchor. |
48
48
  | `gates <scaffold\|status\|run\|reverify\|lint\|precheck\|attest\|abandon> <slug>` | Operate the `gates.md` acceptance ledger: seed per-AC gates, execute runnable gates bound to `test-plan.md` preflight rows, attest manual gates, and reduce to `all-met`/`not-met`/`handoff`/`malformed`. A gate with `INPUTS:` records its files' content digest and is reused by `run` and `reverify` while they are unchanged. `run`/`reverify --transcripts` atomically publishes a private redacted v2 executed transcript, or returns its verified originating record unchanged, under `references/gates/<gate-id>/`; legacy v1/reuse-only logs cannot authorize reuse. `precheck <slug> --worktree <path> [--slice <SLICE-ID>]` runs every `RUN: writer` gate and `check windows` against the wright's worktree (with `--slice`, it prints `SKIP <gate-id>` for a gate whose id only other unbuilt slices name in `tasks.md`, since gate ids reuse `AC-###` ids; a gate no slice names still runs), prints PASS/FAIL per gate id, exits `3` on any FAIL or on a malformed ledger, and writes no ledger, EVIDENCE line or transcript (`check windows` still appends its metric event); its output is never evidence. |
49
49
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
50
- | `state merge-manifest <slug> [pred...]` | Fold the recorded predecessor chain's manifests into the release candidate manifest. |
50
+ | `state merge-manifest <slug> [pred...]` | Fold the complete recorded predecessor chain's manifests into the release candidate manifest. Numbered sequences must reach position 1 through consecutive, unique positive positions; explicit predecessor lists cannot omit recorded members. Fully unnumbered legacy chains remain supported. Invalid or missing members leave the manifest unchanged. |
51
51
  | `state set <slug> <key>=<value>... [--unset <key>]...` | Write `state.md` cursor fields all-or-nothing under the feature lock. Refuses an unknown `phase`, the engine-owned `schema` row, a pipe or line break in a value, and an overwrite of a saved `return_phase`/`return_next_action`. Setting `status=awaiting_human` under `.devrites/AFK` prints each open question the sentinel's `allow_gates` auto-picks. |
52
52
  | `state append <slug> <ledger.md> --heading "<text>" --body-file <path\|->` | Append one `## <heading>` section to `assumptions.md`, `decisions.md`, `drift.md`, `evidence.md`, or `questions.md` atomically. Refuses a heading that already exists outside fenced code and a body line that opens another `#`/`##` section; for a new `questions.md` `q-` entry, also refuses a non-open status, a missing or unknown gate, a raised_at that is not a full RFC 3339 timestamp, options outside 2-4 concrete choices plus the closing "Something else" escape hatch, `(Recommended)` on any option but 1, and `proposed:` without a `(Recommended)` option 1 (or option 1 without `proposed:`); question or entry length is advisory only; reports an advisory when the ledger passes its line budget, and blocks (exit 3, nothing written) when a ledger other than evidence.md would land at or above 1.5x its line or byte budget without a covering `Budget override: <=N lines; <reason>` (N at most 2x the line budget). |
53
- | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
53
+ | `state close <slug>` | Archive a workspace and clear matching `ACTIVE`. Final phases/statuses enforce Seal structure, candidate freshness, release-union integrity, retained reviewer settlement, and runnable gate origins before mutation. Explicit nonfinal exits remain physical archival, not proof of delivery. |
54
54
  | `migrate <slug> [--dry-run] [--answer id=choice]` | Normalize a pre-v5 workspace to the current schema; fail-closed, one-shot. |
55
55
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
56
56
  | `open-visual <path-or-name> [--slug <slug>] [--no-open]` | Resolve a local visual HTML file, optionally open it in the OS browser, warn if the sibling outline is missing or inventory ids are absent from HTML, and print agent path tips. No network. |
@@ -65,6 +65,38 @@ The full label remains in bundle filenames and attribution. Reviewer
65
65
  `--outcome findings|no-findings|gap`; omitting both refuses the return without
66
66
  changing the wave or releasing its claim. Non-reviewer returns are unchanged.
67
67
 
68
+ Generated packets bind an immutable wave/role context snapshot and its actual
69
+ SHA-256, not a mutable generic or recheck bundle. Workspace overrides retain their
70
+ validated metadata namespace; foreign paths, escapes, and linked context parents
71
+ cannot replace that authority.
72
+
73
+ Seal and final closeout require settled native accounts for every launched role;
74
+ open/sealed proof, custom, and writer roles block too. At least one actual reviewer
75
+ account remains required. Abandoned proof/reviewer accounts need a genuinely newer
76
+ settled account with the same canonical phase, role, and exact unordered slice set,
77
+ a distinct native handle, and valid start/return times. An abandoned writer or
78
+ claim-holder instead needs its actual native returns or host-confirmed termination
79
+ recorded by `dispatch abandon --release`; replacement work alone cannot prove death.
80
+ That command records `terminal_at` even for a claimless or refused launch, without
81
+ fabricating returned work or review approval. A failed claim release cannot stamp it.
82
+ An empty, missing, or malformed dispatch ledger cannot establish settlement.
83
+ Every met runnable gate must retain its approved, bound execution origin, including
84
+ complete v2 metadata/footer and matching body bytes when unredacted and untruncated.
85
+ These final checks do not rerun proof commands.
86
+
87
+ Feature mutation locks live at `.devrites/.locks/features/<slug>/.lock`, outside
88
+ movable workspace directories, and remain held through archival and `ACTIVE`
89
+ updates. A closed slug cannot recreate a work directory through a stale writer.
90
+ Stop existing controllers before upgrading, then restart them together; old and
91
+ new lock protocols must not run concurrently.
92
+
93
+ Vet readiness identities bind normalized `gates.md` definitions: gate membership,
94
+ IDs, titles, `CHECK`, `EXPECT`, `CWD`, `INPUTS`, and `RUN`. Checkbox, evidence, and
95
+ progress changes do not invalidate Vet; changing an oracle or converting a
96
+ runnable gate to manual requires Plan repair and re-Vet. Older identities that
97
+ omitted these definitions require normal re-Vet after upgrade, never a manual
98
+ hash refresh or inferred approval.
99
+
68
100
  `help`, `-h`, and `--help` print this operational inventory. Each operational
69
101
  command and subcommand also accepts `-h` / `--help` and prints that command's
70
102
  usage to stdout without requiring a workspace. `version` and `--version` print
package/docs/release.md CHANGED
@@ -24,8 +24,11 @@ version and run these steps:
24
24
 
25
25
  The release archive
26
26
  consumes the validated generated files from the same Git index as the rest of
27
- its payload. Package and release installs validate and copy
28
- `pack/generated/{claude,codex,omp,pi,devin}`. When a shell install or update shim runs from a
27
+ its payload. Prior build outputs under `engine/dist/` remain excluded even when
28
+ indexed.
29
+
30
+ Package and release installs validate and copy
31
+ `pack/generated/{claude,codex,omp,pi,devin,dsh}`. When a shell install or update shim runs from a
29
32
  source checkout whose generated payload is incomplete, the shim may regenerate
30
33
  the missing host payload before handing that local candidate to the engine. The
31
34
  engine itself only validates and copies host payloads; it never generates them.
package/engine/Makefile CHANGED
@@ -1,7 +1,7 @@
1
1
  BINARY := devrites
2
2
  DIST := dist
3
- # darwin/amd64 kept for Intel Macs; the five targets the release matrix ships.
4
- PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64
3
+ # darwin/amd64 kept for Intel Macs; the six targets the release matrix ships.
4
+ PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
5
5
 
6
6
  .PHONY: build test test-race vet quality govulncheck lint cover crosscompile clean
7
7
 
@@ -0,0 +1,39 @@
1
+ package acceptance
2
+
3
+ import "fmt"
4
+
5
+ // CheckRetainedOrigins checks current runnable met gates without executing commands.
6
+ // Historical candidate labels remain valid when the definition and INPUTS match.
7
+ func CheckRetainedOrigins(root, slug string) error {
8
+ work, ledgerPath, testPlanPath, project, err := featurePaths(root, slug)
9
+ if err != nil {
10
+ return err
11
+ }
12
+ text, err := ReadLedgerFile(ledgerPath)
13
+ if err != nil {
14
+ return err
15
+ }
16
+ doc := ParseLedger(text)
17
+ if len(doc.Errors) != 0 {
18
+ return fmt.Errorf("retained gate origins: invalid %s: %v", GatesFile, doc.Errors)
19
+ }
20
+ approved := loadTestPlan(testPlanPath)
21
+ for _, gate := range doc.Gates {
22
+ if gate.Check == "" || GateState(gate, doc) != StateMet {
23
+ continue
24
+ }
25
+ if !Approved(gate.Check, gate.Cwd, approved) {
26
+ return fmt.Errorf("gate %s: CHECK/CWD is not approved by current test-plan.md; restore the approved definition and record its passing origin", gate.ID)
27
+ }
28
+ if gate.Inputs != "" {
29
+ inputs, err := InputsDigest(project, gate.Inputs)
30
+ if err != nil || inputs == "" || inputs != recordedInputs(gate) {
31
+ return fmt.Errorf("gate %s: retained origin does not match current INPUTS; record a passing approved check against current inputs", gate.ID)
32
+ }
33
+ }
34
+ if passingOrigin(work, gate) == "" {
35
+ return fmt.Errorf("gate %s: no usable retained passing origin; record the approved check with gates run --transcripts", gate.ID)
36
+ }
37
+ }
38
+ return nil
39
+ }
@@ -44,13 +44,20 @@ func PreflightRowsPassed(root, slug string, rows []PreflightRow) (bool, error) {
44
44
  return true, nil
45
45
  }
46
46
 
47
- // PreflightPassed reports whether the row's newest transcript under
48
- // <work>/references/gates/ records RESULT: pass. The transcript records its
49
- // candidate but the lookup does not compare it: Vet writes workspace artifacts
50
- // after the run, and only a change to the row's command or cwd retires a pass.
47
+ // PreflightPassed requires the newest transcript to be a complete passing record
48
+ // for this row. Historical candidate labels do not retire an unchanged row.
51
49
  func PreflightPassed(work string, row PreflightRow) bool {
52
- dir := filepath.Join(work, "references", "gates", transcriptGateName(PreflightGateID(row)))
53
- entries, err := os.ReadDir(dir)
50
+ dir, err := transcriptDirectory(work, PreflightGateID(row), false)
51
+ if err != nil {
52
+ return false
53
+ }
54
+ defer func() { _ = dir.Close() }()
55
+ listing, err := dir.Open(".")
56
+ if err != nil {
57
+ return false
58
+ }
59
+ entries, err := listing.ReadDir(-1)
60
+ _ = listing.Close()
54
61
  if err != nil {
55
62
  return false
56
63
  }
@@ -67,11 +74,10 @@ func PreflightPassed(work string, row PreflightRow) bool {
67
74
  if newest == "" {
68
75
  return false
69
76
  }
70
- // #nosec G304 -- transcript path built from the workspace and a sanitized row id
71
- body, err := os.ReadFile(filepath.Join(dir, newest))
72
- // Only the header counts: command output could print the verdict line.
73
- header, _, _ := strings.Cut(string(body), "--- OUTPUT ---")
74
- return err == nil && strings.Contains(header, "\nRESULT: pass\n")
77
+ body, err := readTranscript(dir, newest)
78
+ gate := &Gate{ID: PreflightGateID(row), Title: row.Gate, Check: row.Command, Cwd: row.Cwd,
79
+ Expect: "exit 0 (Expected: " + valueOr(row.Expected, "unstated") + ")"}
80
+ return err == nil && validTranscript(string(body), gate, nil)
75
81
  }
76
82
 
77
83
  // RunPreconditions is `check preconditions <slug> [--slice S]`: it executes
@@ -236,6 +236,11 @@ func readTranscript(dir *os.Root, name string) ([]byte, error) {
236
236
  }
237
237
 
238
238
  func validOrigin(raw string, gate *Gate, evidence []string) bool {
239
+ return validTranscript(raw, gate, evidence)
240
+ }
241
+
242
+ // validTranscript validates complete executed records; evidence additionally binds ledger proof.
243
+ func validTranscript(raw string, gate *Gate, evidence []string) bool {
239
244
  const footer = "TRANSCRIPT_SHA256: "
240
245
  end := strings.LastIndex(raw, footer)
241
246
  if end < 0 || raw[end:] != footer+OutputDigest(raw[:end])+"\n" {
@@ -261,14 +266,25 @@ func validOrigin(raw string, gate *Gate, evidence []string) bool {
261
266
  for key, want := range map[string]string{
262
267
  "GATE": transcriptMetadata(gate.ID), "TITLE": transcriptMetadata(gate.Title),
263
268
  "COMMAND": transcriptMetadata(gate.Check), "CWD": transcriptMetadata(valueOr(gate.Cwd, ".")),
264
- "EXPECT": transcriptMetadata(gate.Expect), "DEFINITION_SHA256": evidence[1],
265
- "INPUTS": transcriptMetadata(valueOr(gate.Inputs, "none")), "INPUTS_SHA256": valueOr(evidence[4], "none"),
266
- "RESULT": "pass", "EXIT": "0", "OUTPUT_SHA256": evidence[2], "OUTPUT_BYTES": evidence[3],
269
+ "EXPECT": transcriptMetadata(gate.Expect), "DEFINITION_SHA256": DefinitionDigest(gate),
270
+ "INPUTS": transcriptMetadata(valueOr(gate.Inputs, "none")),
271
+ "RESULT": "pass", "EXIT": "0",
267
272
  } {
268
273
  if got, exists := fields[key]; !exists || got != want {
269
274
  return false
270
275
  }
271
276
  }
277
+ if evidence != nil && (fields["DEFINITION_SHA256"] != evidence[1] || fields["OUTPUT_SHA256"] != evidence[2] ||
278
+ fields["OUTPUT_BYTES"] != evidence[3] || fields["INPUTS_SHA256"] != valueOr(evidence[4], "none")) {
279
+ return false
280
+ }
281
+ if fields["INPUTS_SHA256"] != "none" && !transcriptDigestRE.MatchString(fields["INPUTS_SHA256"]) {
282
+ return false
283
+ }
284
+ bytes, err := strconv.ParseInt(fields["OUTPUT_BYTES"], 10, 64)
285
+ if err != nil || bytes < 0 || !transcriptDigestRE.MatchString(fields["OUTPUT_SHA256"]) {
286
+ return false
287
+ }
272
288
  started, err := time.Parse(time.RFC3339Nano, fields["STARTED"])
273
289
  if err != nil || started.IsZero() {
274
290
  return false
@@ -286,7 +302,10 @@ func validOrigin(raw string, gate *Gate, evidence []string) bool {
286
302
  return false
287
303
  }
288
304
  truncated, err := strconv.ParseBool(fields["TRUNCATED"])
289
- return err == nil && len(output) <= maxTranscriptOutput && (!truncated || len(output) == maxTranscriptOutput)
305
+ if err != nil || len(output) > maxTranscriptOutput || (truncated && len(output) != maxTranscriptOutput) {
306
+ return false
307
+ }
308
+ return redactions != 0 || truncated || (bytes == int64(len(output)) && fields["OUTPUT_SHA256"] == OutputDigest(output))
290
309
  }
291
310
 
292
311
  func exitLabel(outcome RunOutcome) string {
@@ -316,23 +316,25 @@ var (
316
316
  specAcceptanceClaimRE = regexp.MustCompile(`(?im)^\s{0,3}#{1,6}\s+.*acceptance criteria|\bAC-\d+\b`)
317
317
  )
318
318
 
319
- // specClaimsAcceptance reports whether structural (unfenced) spec text looks
319
+ // specClaimsAcceptance reports whether spec prose looks
320
320
  // like it carries acceptance criteria, so a parse that finds none is a defect
321
321
  // in the spec rather than a spec with nothing to map.
322
322
  func specClaimsAcceptance(spec []byte) bool {
323
- structural, err := markdowntext.Structural(spec)
323
+ structural, err := markdowntext.Prose(spec)
324
324
  return err == nil && specAcceptanceClaimRE.Match(structural)
325
325
  }
326
326
 
327
- // specUnreadAcceptanceProblems names acceptance text the parser skips: ids that
328
- // are not exactly three digits and acceptance headings after the first
329
- // canonical one, which would otherwise escape the coverage check.
327
+ // specUnreadAcceptanceProblems names acceptance text the parser skips:
328
+ // noncanonical ids/headings and declarations outside the canonical section.
330
329
  func specUnreadAcceptanceProblems(spec []byte) []string {
331
- structural, err := markdowntext.Structural(spec)
330
+ structural, err := markdowntext.Prose(spec)
332
331
  if err != nil {
333
332
  return nil
334
333
  }
335
334
  var problems []string
335
+ for _, id := range state.AcceptanceDeclarationsOutsideSection(spec) {
336
+ problems = append(problems, fmt.Sprintf("spec.md declares %s outside the first `## Acceptance criteria` section, so the gate does not check it", id))
337
+ }
336
338
  seen := map[string]bool{}
337
339
  readHeading := false
338
340
  for _, line := range strings.Split(string(structural), "\n") {
@@ -1,15 +1,18 @@
1
1
  package gate
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "crypto/sha256"
5
6
  "encoding/binary"
6
7
  "encoding/hex"
7
8
  "errors"
8
9
  "fmt"
9
- "hash"
10
+ "io"
10
11
  "regexp"
12
+ "slices"
11
13
  "strings"
12
14
 
15
+ "github.com/devrites/devrites/internal/acceptance"
13
16
  "github.com/devrites/devrites/internal/markdowntext"
14
17
  "github.com/devrites/devrites/internal/state"
15
18
  )
@@ -17,7 +20,7 @@ import (
17
20
  const (
18
21
  readinessBindingLabel = "Readiness inputs SHA-256: "
19
22
  readinessBindingDomain = "devrites-readiness-inputs"
20
- readinessBindingVersion = "1"
23
+ readinessBindingVersion = "2"
21
24
  )
22
25
 
23
26
  type readinessInput struct {
@@ -37,10 +40,13 @@ var readinessInputs = [...]readinessInput{
37
40
  {logical: "design-brief.md"},
38
41
  {logical: "ai-spec.md"},
39
42
  {logical: ".devrites/principles.md"},
43
+ {logical: acceptance.GatesFile},
40
44
  }
41
45
 
42
46
  // ReadinessBinding returns the exact line Vet records in eng-review.md.
43
47
  // Explicit sources resolve live first, then safely archived planning bytes.
48
+ // Gate definitions are bound independently of proof progress. Older bindings
49
+ // omitted those definitions and require normal re-Vet, never automatic refresh.
44
50
  func ReadinessBinding(root, slug string) (string, error) {
45
51
  observation, err := state.ObserveReferencedWorkspace(root, slug)
46
52
  if err != nil {
@@ -50,10 +56,6 @@ func ReadinessBinding(root, slug string) (string, error) {
50
56
  }
51
57
 
52
58
  func readinessBindingFromObservation(observation *state.WorkspaceObservation) (string, error) {
53
- return bindReadinessInputs(observation, true)
54
- }
55
-
56
- func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress bool) (string, error) {
57
59
  digest := sha256.New()
58
60
  writeReadinessField(digest, readinessBindingDomain)
59
61
  writeReadinessField(digest, readinessBindingVersion)
@@ -63,7 +65,7 @@ func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress
63
65
  if err != nil {
64
66
  return "", err
65
67
  }
66
- if blankProgress && input.logical == "traceability.md" {
68
+ if input.logical == "traceability.md" {
67
69
  content = withoutProgressCells(content)
68
70
  }
69
71
  writeReadinessField(digest, string(input.logical))
@@ -85,12 +87,26 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
85
87
  }
86
88
  switch fact.State() {
87
89
  case state.ArtifactAbsent:
88
- if input.required {
90
+ required := input.required
91
+ if input.logical == acceptance.GatesFile {
92
+ phase, err := observation.DeclaredPhase()
93
+ if err != nil {
94
+ return nil, false, err
95
+ }
96
+ policy, _ := state.PolicyFor(phase)
97
+ required = policyRequires(policy, input.logical)
98
+ }
99
+ if required {
89
100
  return nil, false, fmt.Errorf("readiness input %s is missing", input.logical)
90
101
  }
91
102
  return nil, false, nil
92
103
  case state.ArtifactEmpty, state.ArtifactPresent:
93
- return fact.Bytes(), true, nil
104
+ content := fact.Bytes()
105
+ if input.logical == acceptance.GatesFile {
106
+ content, err := normalizedReadinessGates(content)
107
+ return content, true, err
108
+ }
109
+ return content, true, nil
94
110
  case state.ArtifactMalformed, state.ArtifactUnsafe, state.ArtifactUnreadable:
95
111
  diagnostic, _ := fact.Diagnostic()
96
112
  return nil, false, readinessDiagnosticError(diagnostic)
@@ -99,6 +115,25 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
99
115
  }
100
116
  }
101
117
 
118
+ // Vet approves obligations, not their checkbox, evidence, or progress prose.
119
+ func normalizedReadinessGates(content []byte) ([]byte, error) {
120
+ doc := acceptance.ParseLedger(string(content))
121
+ if len(doc.Errors) > 0 {
122
+ return nil, fmt.Errorf("readiness input gates.md is malformed: %s", strings.Join(doc.Errors, "; "))
123
+ }
124
+ slices.SortFunc(doc.Gates, func(a, b *acceptance.Gate) int {
125
+ return strings.Compare(a.ID, b.ID)
126
+ })
127
+ var normalized bytes.Buffer
128
+ writeReadinessLength(&normalized, uint64(len(doc.Gates)))
129
+ for _, gate := range doc.Gates {
130
+ for _, field := range [...]string{gate.ID, gate.Title, gate.Check, gate.Expect, gate.Cwd, gate.Inputs, gate.Run} {
131
+ writeReadinessField(&normalized, field)
132
+ }
133
+ }
134
+ return normalized.Bytes(), nil
135
+ }
136
+
102
137
  func readinessDiagnostics(observation *state.WorkspaceObservation) []state.ArtifactDiagnostic {
103
138
  var diagnostics []state.ArtifactDiagnostic
104
139
  for _, input := range readinessInputs {
@@ -118,12 +153,6 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
118
153
  if err != nil {
119
154
  return "", err
120
155
  }
121
- // A line recorded over the raw bytes, before progress cells were blanked,
122
- // still verifies while those bytes are unchanged.
123
- legacy, err := bindReadinessInputs(observation, false)
124
- if err != nil {
125
- return "", err
126
- }
127
156
  fact, ok := observation.Fact("eng-review.md")
128
157
  if !ok || fact.State() == state.ArtifactAbsent {
129
158
  return expected, errors.New("readiness input eng-review.md is missing")
@@ -144,7 +173,7 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
144
173
  continue
145
174
  }
146
175
  attempts++
147
- if line == expected || line == legacy {
176
+ if line == expected {
148
177
  matches++
149
178
  }
150
179
  }
@@ -155,9 +184,9 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
155
184
  }
156
185
 
157
186
  // ReadinessInputDigest is one readiness input's content identity at a moment
158
- // in time. The aggregate binding answers "did anything change"; the per-input
159
- // digest answers "which artifact changed", which is what a drift diagnosis and
160
- // a re-vet route need.
187
+ // in time (canonical definitions only for gates.md). The aggregate binding
188
+ // answers "did anything change"; the per-input digest answers "which artifact
189
+ // changed", which is what a drift diagnosis and a re-vet route need.
161
190
  type ReadinessInputDigest struct {
162
191
  Path string `json:"path"`
163
192
  Present bool `json:"present"`
@@ -322,12 +351,12 @@ func policyRequires(policy state.PhasePolicy, name state.ArtifactPath) bool {
322
351
  return false
323
352
  }
324
353
 
325
- func writeReadinessField(digest hash.Hash, value string) {
354
+ func writeReadinessField(digest io.Writer, value string) {
326
355
  writeReadinessLength(digest, uint64(len(value)))
327
356
  _, _ = digest.Write([]byte(value))
328
357
  }
329
358
 
330
- func writeReadinessLength(digest hash.Hash, value uint64) {
359
+ func writeReadinessLength(digest io.Writer, value uint64) {
331
360
  var encoded [8]byte
332
361
  binary.BigEndian.PutUint64(encoded[:], value)
333
362
  _, _ = digest.Write(encoded[:])