devrites 7.2.6 → 7.2.8

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 (95) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -1
  3. package/SECURITY.md +11 -7
  4. package/docs/command-map.md +4 -0
  5. package/docs/engine/commands.md +25 -3
  6. package/docs/orchestration.md +9 -0
  7. package/engine/internal/acceptance/cli.go +9 -5
  8. package/engine/internal/acceptance/run.go +97 -38
  9. package/engine/internal/devritespaths/paths.go +7 -2
  10. package/engine/internal/install/apply.go +5 -0
  11. package/engine/internal/install/merge.go +48 -6
  12. package/engine/internal/install/preflight.go +21 -3
  13. package/engine/internal/install/uninstall.go +9 -10
  14. package/engine/internal/lib/apply_bundle.go +7 -1
  15. package/engine/internal/lib/closeout.go +7 -7
  16. package/engine/internal/lib/context.go +10 -6
  17. package/engine/internal/lib/dispatch.go +81 -21
  18. package/engine/internal/lib/resolve.go +10 -5
  19. package/engine/internal/lib/state_edit.go +9 -2
  20. package/engine/internal/lib/taskgraph.go +94 -21
  21. package/engine/internal/lib/triggers.go +2 -2
  22. package/engine/internal/state/cursor.go +5 -5
  23. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  24. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +9 -8
  25. package/pack/.claude/skills/devrites-lib/reference/standards/principles.md +5 -6
  26. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  27. package/pack/.claude/skills/rite-autocomplete/reference/lookahead.md +18 -35
  28. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  29. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +26 -16
  30. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +5 -5
  31. package/pack/.claude/skills/rite-spec/SKILL.md +2 -2
  32. package/pack/.claude/skills/rite-temper/SKILL.md +4 -5
  33. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  34. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +9 -8
  35. package/pack/generated/claude/skills/devrites-lib/reference/standards/principles.md +5 -6
  36. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  37. package/pack/generated/claude/skills/rite-autocomplete/reference/lookahead.md +18 -35
  38. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  39. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +26 -16
  40. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +5 -5
  41. package/pack/generated/claude/skills/rite-spec/SKILL.md +2 -2
  42. package/pack/generated/claude/skills/rite-temper/SKILL.md +4 -5
  43. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  44. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +9 -8
  45. package/pack/generated/codex/skills/devrites-lib/reference/standards/principles.md +5 -6
  46. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  47. package/pack/generated/codex/skills/rite-autocomplete/reference/lookahead.md +18 -35
  48. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  49. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +26 -16
  50. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +5 -5
  51. package/pack/generated/codex/skills/rite-spec/SKILL.md +2 -2
  52. package/pack/generated/codex/skills/rite-temper/SKILL.md +4 -5
  53. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  54. package/pack/generated/devin/skills/devrites-lib/reference/standards/agents.md +9 -8
  55. package/pack/generated/devin/skills/devrites-lib/reference/standards/principles.md +5 -6
  56. package/pack/generated/devin/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  57. package/pack/generated/devin/skills/rite-autocomplete/reference/lookahead.md +18 -35
  58. package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  59. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +26 -16
  60. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +5 -5
  61. package/pack/generated/devin/skills/rite-spec/SKILL.md +2 -2
  62. package/pack/generated/devin/skills/rite-temper/SKILL.md +4 -5
  63. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  64. package/pack/generated/dsh/skills/devrites-lib/reference/standards/agents.md +9 -8
  65. package/pack/generated/dsh/skills/devrites-lib/reference/standards/principles.md +5 -6
  66. package/pack/generated/dsh/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  67. package/pack/generated/dsh/skills/rite-autocomplete/reference/lookahead.md +18 -35
  68. package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  69. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +26 -16
  70. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +5 -5
  71. package/pack/generated/dsh/skills/rite-spec/SKILL.md +2 -2
  72. package/pack/generated/dsh/skills/rite-temper/SKILL.md +4 -5
  73. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  74. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +13 -8
  75. package/pack/generated/omp/skills/devrites-lib/reference/standards/principles.md +5 -6
  76. package/pack/generated/omp/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  77. package/pack/generated/omp/skills/rite-autocomplete/reference/lookahead.md +18 -35
  78. package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  79. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +26 -16
  80. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +5 -5
  81. package/pack/generated/omp/skills/rite-spec/SKILL.md +2 -2
  82. package/pack/generated/omp/skills/rite-temper/SKILL.md +4 -5
  83. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  84. package/pack/generated/pi/skills/devrites-lib/reference/standards/agents.md +9 -8
  85. package/pack/generated/pi/skills/devrites-lib/reference/standards/principles.md +5 -6
  86. package/pack/generated/pi/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  87. package/pack/generated/pi/skills/rite-autocomplete/reference/lookahead.md +18 -35
  88. package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  89. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +26 -16
  90. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +5 -5
  91. package/pack/generated/pi/skills/rite-spec/SKILL.md +2 -2
  92. package/pack/generated/pi/skills/rite-temper/SKILL.md +4 -5
  93. package/package.json +3 -3
  94. package/scripts/build-host-artifacts.sh +5 -6
  95. package/scripts/omp-generate.sh +24 -14
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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.2.8](https://github.com/ViktorsBaikers/DevRites/compare/v7.2.7...v7.2.8) (2026-10-31)
6
+
7
+ ### Fixed
8
+
9
+ * **no-release:** close installer evidence and recovery integrity gaps ([#200](https://github.com/ViktorsBaikers/DevRites/issues/200)) ([edb8754](https://github.com/ViktorsBaikers/DevRites/commit/edb8754ecf80d4a0dd83590c5b346787c3647244))
10
+ * **no-release:** select exact OMP specialists and preserve reviewer roles ([#199](https://github.com/ViktorsBaikers/DevRites/issues/199)) ([b31814a](https://github.com/ViktorsBaikers/DevRites/commit/b31814a37d80c45a27d5470ab5464cd93c91f76a))
11
+ * **release:** publish pending installer fixes ([dfd2192](https://github.com/ViktorsBaikers/DevRites/commit/dfd2192d1b7d9da1b0111603760c5eb8f246b9a4))
12
+
13
+ ## [7.2.7](https://github.com/ViktorsBaikers/DevRites/compare/v7.2.6...v7.2.7) (2026-10-31)
14
+
15
+ ### Fixed
16
+
17
+ * **skills:** keep lookahead planning through false stops ([#192](https://github.com/ViktorsBaikers/DevRites/issues/192)) ([1185459](https://github.com/ViktorsBaikers/DevRites/commit/1185459e0477fe7fc9ee6b8e32e3ef315100ae15))
18
+
5
19
  ## [7.2.6](https://github.com/ViktorsBaikers/DevRites/compare/v7.2.5...v7.2.6) (2026-10-31)
6
20
 
7
21
  ### 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.2.6`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.2.6): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
34
+ **Status:** [`v7.2.8`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.2.8): 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
 
@@ -382,6 +382,11 @@ with `--force`; legacy manifests without hashes also require `--force`.
382
382
  `--force --dry-run` lists the exact destructive actions. Every settings and
383
383
  marker merge is checked before the first write, so a rejected merge (such as a
384
384
  Codex config that already sets top-level `default_permissions`) leaves the project untouched.
385
+ Unmerges receive the same preflight: malformed settings or legacy hooks JSON
386
+ blocks uninstall and dropped-host pruning, including dry runs, before writes.
387
+ Existing files retain their permission bits. Claude permission ownership is
388
+ recorded before settings replacement, so interrupted merges retain the records
389
+ needed to remove installer-added grants on retry and uninstall.
385
390
  Marker-merged files keep user content outside the DevRites block, and
386
391
  `.devrites/` runtime state stays in place. `update` reuses the install flags
387
392
  recorded in the manifest; flags you pass explicitly win and are recorded. The installer refuses symlinks, junctions, and paths that
package/SECURITY.md CHANGED
@@ -218,13 +218,17 @@ hosts or their makers. codebase-memory-mcp, ego-lite, browser-harness, and
218
218
  Playwright MCP are optional user-selected tools that DevRites calls through
219
219
  their documented interfaces rather than bundling them.
220
220
 
221
- Repository npm-audit exceptions are temporary trust records, not claims that an
222
- upstream issue is fixed. Each entry in `scripts/npm-audit-exceptions.json` is
223
- restricted to an exact advisory, package range, installed node path, owner,
224
- reason, source, and near-term expiry; validation re-audits the live dependency
225
- graph and fails stale, broadened, unmatched, expired, or inside-the-7-day
226
- refresh-horizon entries. Prefer pinning a patched ancestor (see
227
- `package.json` `overrides`) over extending an expiry.
221
+ Advisories whose only installed copy is inside the npm CLI tarball
222
+ (`node_modules/npm/node_modules/`) are not blocking. Overrides cannot replace
223
+ those copies, and the publish plugin cannot take a newer npm that still bundles
224
+ them. The same advisory installed anywhere else still blocks. Any other
225
+ npm-audit exception is a temporary trust record, not a claim that an upstream
226
+ issue is fixed. Each entry in `scripts/npm-audit-exceptions.json` is restricted
227
+ to an exact advisory, package range, installed node path, owner, reason, source,
228
+ and near-term expiry; validation re-audits the live dependency graph and fails
229
+ stale, broadened, unmatched, expired, or inside-the-7-day-refresh-horizon
230
+ entries. Prefer pinning a patched ancestor (see `package.json` `overrides`)
231
+ over extending an expiry.
228
232
 
229
233
  ### Agentic trust boundaries
230
234
 
@@ -50,6 +50,10 @@ The Go surface is intentionally closed and deterministic:
50
50
  | Install/operator | `install`, `update`, `uninstall`, `version` | Direct update acquires latest; npm/bootstrap may supply local candidates; the engine performs manifest-owned local changes. |
51
51
  | Native policy | no engine command | Skills/root own spec grammar re-read, qid allocation, Clarify cursor edits, AFK/recovery accounting, and read-only `/rite-doctor`. |
52
52
 
53
+ `state apply-bundle` merges workspace-relative path aliases into one target:
54
+ `plan.md`, `./plan.md`, and `refs/../plan.md` share base pins and patch hunks.
55
+ Conflicting replacements or base hashes block the entire bundle without writes.
56
+
53
57
  `devrites-engine help` exhaustively lists the operational engine commands.
54
58
  Standard `help`, `-h`, `--help`, `version`, and `--version` forms remain
55
59
  available; each operational command and subcommand also accepts `-h` / `--help`.
@@ -17,7 +17,7 @@ workflow strategy.
17
17
  | `check preconditions <slug> [--slice SLICE-###] [--timeout N]` | Run the `test-plan.md` Build-entry preflight rows whose optional `Run at` cell is `vet` (with `--slice`, `pre-dispatch <SLICE>`); `action` rows and rows without the cell never run. Writes one candidate-bound transcript per row under `references/gates/preflight-*/`; a row passes on exit 0. A `pre-dispatch` row whose newest transcript passed is kept, not re-run: it is a pre-edit baseline, so a re-dispatch never runs it against the slice's own edits; changing its command or cwd retires the pass. Exit `3` on any failure or an invalid `Run at` value. |
18
18
  | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the `tasks.md` slice graph, canonical `AC-###` presence when `tasks.md`/`test-plan.md` are required, the stable Build-input binding, and exact candidate bindings. `check readiness` and `check seal` also block a `scripts/` test driver that a tasks.md Writer allowlist plans or touched-files.md lists when no gates.md `CHECK` runs it and no `Driver waiver: <path> — DEC-### <reason>` line cites a recorded decision; waived drivers print as `note: unexecuted driver (waived by DEC-###): <path>` lines, on pass or block. |
19
19
  | `check path-disjoint [--root <dir>] [<json-file> | -]` | Verify slice path sets are pairwise disjoint. |
20
- | `parallel select --cap <1-10> [--root <dir>] [--slug <slug>] [--from-plan \| <json-file> \| -]` | Choose the greedy path-disjoint subset ≤ cap from supplied ready slices, or with `--from-plan` (needs `--slug`) from the plan: tasks.md slices whose dependencies state.md checks `built`, with Writer allowlist (else Files likely touched) paths plus the nearest manifest/lockfile when `External dependencies:` names a package (`none*`, existing, or no-new prose without a coordinate). With `--slug` and a running lease, the lease's not-yet-landed slices hold cap slots and paths, slices it names are never picked, and only new picks print. |
20
+ | `parallel select --cap <1-10> [--root <dir>] [--slug <slug>] [--from-plan \| <json-file> \| -]` | Choose the greedy path-disjoint subset ≤ cap from supplied ready slices, or with `--from-plan` (needs `--slug`) from the plan: tasks.md slices whose dependencies are built (state.md checkbox, or an evidence.md heading that says that slice was checkpointed plus a `Checkpoint: <sha>` line), with Writer allowlist (else Files likely touched) paths plus the nearest manifest/lockfile when `External dependencies:` names a package (module path, npm scope, backticked name, or a non-toolchain version). With `--slug` and a running lease, the lease's not-yet-landed slices hold cap slots and paths, slices it names are never picked, and only new picks print. |
21
21
  | `parallel <create\|add\|integrate\|cleanup\|status\|abort\|lease-read\|lease-write\|lease-clear\|record-green>` | Deterministic parallel worktree leases, integration, and cleanup for parallel writer batches. A lease is a pipeline: `integrate --slice <id> --apply-to-control` lands one green sibling onto control (refusing if anything but this lease moved control), and `add` starts new path-disjoint siblings from the current control tip while others still run. |
22
22
  | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles, unknown deps, malformed tokens, duplicate IDs, missing `Dependencies`, and `depends_on` mismatch. Prints a non-blocking `ADVISORY size:` line (and records a `size-advisory` metric) for each slice with 12+ Writer allowlist files and no `Size rationale:`. A `Dependencies` ID may carry a `(via <interface>)` suffix (empty or unclosed is a malformed token); prints a non-blocking `ADVISORY edge:` line (and records an `edge-advisory` metric) for each edge with no via, or whose via text is not a case-insensitive substring of the dependency's `Consumes / Produces:`. Prints `schedule: <n> slices in <r> build rounds at cap 10` (the `/rite-build` batch selection replayed over each slice's Writer allowlist, else Files likely touched, plus the manifest parallel select reserves; omitted when a slice has no path) and a non-blocking `ADVISORY hub:` line (top five; records a `hub-advisory` metric) for each path, compared case-folded, that three or more slices without `Serial rationale:` write. Advisories print only when the graph is valid. |
23
23
  | `check regression <slug> [--update]` | Compare the workspace's structural progress facts (phase ordinal, checked ACs, met/abandoned gates, done slices, resolved questions, present artifacts) against the recorded `regression-baseline.json` high-water mark; `--update` ratchets the baseline forward under the feature lock and prints each blessed regression. |
@@ -37,8 +37,8 @@ workflow strategy.
37
37
  | `next [slug]` | Print the minimal remaining lifecycle path plus advisory skips; in Build also `eligible_now:`, `batch:` and the `select-command:` (`parallel select --root <project> --slug <slug> --cap N --from-plan`, N = AFK max_parallel else 10) that reproduces the batch. Prints `blocked: unattributed root edit: ...` after `open-questions:` and exits `3` while the ADR-0038 tripwire flags a product path; the rest of the output still prints. |
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
- | `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. |
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 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. |
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. |
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. |
@@ -58,6 +58,13 @@ workflow strategy.
58
58
  | `overhaul <records\|admit\|snapshot\|score\|bench\|render> ...` | Deterministic tools behind the standalone `/overhaul` skill: run-record generations and cross-record validation, worker-receipt admission and quote anchoring, index-safe baseline snapshots and per-attempt deltas, exact-rational readiness scoring, benchmark ratio intervals, and escaped offline review/report views. Operates only on explicit run-area and repository paths. See [Overhaul tools](#overhaul-tools). |
59
59
  | `version` | Print the engine version. |
60
60
 
61
+ For `context --role`, optional `devrites-` prefixes and `.lane` suffixes resolve
62
+ the base specialist's contract, workspace read-set, and role-specific rules.
63
+ The full label remains in bundle filenames and attribution. Reviewer
64
+ `dispatch return` requires `--result <file>` or an explicit
65
+ `--outcome findings|no-findings|gap`; omitting both refuses the return without
66
+ changing the wave or releasing its claim. Non-reviewer returns are unchanged.
67
+
61
68
  `help`, `-h`, and `--help` print this operational inventory. Each operational
62
69
  command and subcommand also accepts `-h` / `--help` and prints that command's
63
70
  usage to stdout without requiring a workspace. `version` and `--version` print
@@ -66,6 +73,21 @@ engine has no compatibility aliases or tombstones. The `add`/`upgrade`/`remove`
66
73
  conveniences belong only to the `npx devrites` adapter, not to the engine
67
74
  command namespace.
68
75
 
76
+ ## State and evidence safety
77
+
78
+ - `state set` resolves known cursor aliases before checking protected fields
79
+ and duplicate keys. Underscore variants cannot bypass phase, schema, or
80
+ saved-return-cursor protections; extension keys retain their spelling.
81
+ - `state resolve` ignores fenced examples when matching and removing an
82
+ Awaiting-human section. Resolving another question leaves the live wait intact.
83
+ - `state close` reads the physical `ACTIVE` pointer under the feature lock;
84
+ `DEVRITES_WORKSPACE` cannot make it clear another feature's pointer.
85
+ - `gates run` and `reverify` snapshot every declared `INPUTS` dependency around
86
+ the actual shared CHECK execution, recheck during locked writeback, and
87
+ invalidate earlier passes if later checks move their inputs.
88
+ Missing or changed inputs cannot certify a pass. Execution, discarded-result,
89
+ or evidence-write failures exit `3`, even if an older ledger still reads met.
90
+
69
91
  ## Check boundary
70
92
 
71
93
  The candidate gate validates and hashes path/state/type/mode/content identity;
@@ -37,6 +37,15 @@ Skills name the exact role and bounded task in normal language. The host owns
37
37
  internal spawn fields, scheduling, waiting, follow-ups, and result delivery.
38
38
  DevRites does not parse rollout logs or maintain dispatch receipts.
39
39
 
40
+ On OMP, each `task` item selects its specialist through `agent`, including every
41
+ item in a `tasks[]` batch: `devrites-code-reviewer`, `devrites-test-analyst`, or
42
+ `devrites-slice-wright` for those roles. Writing “you are…” in a task or naming it
43
+ after a specialist does not select that profile. Lane suffixes stay on engine
44
+ role labels, not native profile names. Check the actual host selector before
45
+ admitting results: the engine records declared roles and host handles, not
46
+ authenticated native profile identity. A missing profile remains a gap, not
47
+ permission to select a generic/default child.
48
+
40
49
  Quick, Standard, and Full select evidence/review depth only; they are not agent
41
50
  API versions or execution tiers. The shared contract lives in
42
51
  [`orchestration-profiles.md`](../pack/.claude/skills/devrites-lib/reference/orchestration-profiles.md).
@@ -160,12 +160,12 @@ func cmdStatus(root string, args []string, stdout, stderr io.Writer) int {
160
160
  fmt.Fprintf(stderr, "gates status: %v\n", err)
161
161
  return ExitBlocked
162
162
  }
163
- return reportStatus(slug, ledgerPath, testPlanPath, stdout, stderr)
163
+ return reportStatus(slug, ledgerPath, testPlanPath, false, stdout, stderr)
164
164
  }
165
165
 
166
166
  // reportStatus prints the stable greppable reduction. It returns ExitOK only
167
- // for a parse-clean ledger with every gate met and none abandoned.
168
- func reportStatus(slug, ledgerPath, testPlanPath string, stdout, stderr io.Writer) int {
167
+ // for a parse-clean ledger with every gate met, none abandoned and no failed attempt.
168
+ func reportStatus(slug, ledgerPath, testPlanPath string, attemptFailed bool, stdout, stderr io.Writer) int {
169
169
  text, err := ReadLedgerFile(ledgerPath)
170
170
  if err != nil {
171
171
  fmt.Fprintf(stderr, "gates status: %v\n", err)
@@ -191,7 +191,7 @@ func reportStatus(slug, ledgerPath, testPlanPath string, stdout, stderr io.Write
191
191
  case reduction.HandoffRequired():
192
192
  fmt.Fprintln(stdout, "result: handoff")
193
193
  return ExitBlocked
194
- case !reduction.AllMet():
194
+ case attemptFailed || !reduction.AllMet():
195
195
  fmt.Fprintln(stdout, "result: not-met")
196
196
  return ExitBlocked
197
197
  default:
@@ -269,6 +269,7 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
269
269
  }
270
270
  var ran []GateResult
271
271
  var ranDoc *Document
272
+ attemptFailed := false
272
273
 
273
274
  // The gates lock serializes check execution; the feature lock is held only
274
275
  // to read inputs and per writeback, so dispatch and other workspace writers
@@ -294,6 +295,9 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
294
295
  }
295
296
  ran, ranDoc = RunGates(context.Background(), project, ledgerPath, doc, approved, reverify, timeout, featureLock), doc
296
297
  for _, result := range ran {
298
+ if result.Ran && !result.Passed || result.Discarded || result.WriteErr != nil {
299
+ attemptFailed = true
300
+ }
297
301
  switch {
298
302
  case !result.Ran:
299
303
  fmt.Fprintf(stdout, "gate %s: skipped (%s)\n", result.ID, result.Detail)
@@ -320,7 +324,7 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
320
324
  }
321
325
  }
322
326
  fmt.Fprintln(stdout, "---")
323
- return reportStatus(slug, ledgerPath, testPlanPath, stdout, stderr)
327
+ return reportStatus(slug, ledgerPath, testPlanPath, attemptFailed, stdout, stderr)
324
328
  }
325
329
 
326
330
  func cmdLint(root string, args []string, stdout, stderr io.Writer) int {
@@ -28,7 +28,7 @@ type GateResult struct {
28
28
  Ran bool
29
29
  Passed bool
30
30
  Detail string // exit/match/timeout/approval diagnostic
31
- Discarded bool // result dropped: ledger changed under the run
31
+ Discarded bool // result dropped: bound oracle or inputs changed during the run
32
32
  WriteErr error // evidence writeback failed for a reason other than drift
33
33
  Reused bool // met gate kept because its INPUTS are unchanged
34
34
  Inputs string // INPUTS content digest at run time, when declared
@@ -77,13 +77,24 @@ func ReadLedgerFile(ledgerPath string) (string, error) {
77
77
  // execute; writeResult re-verifies the gate's oracle before it writes.
78
78
  func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, approved []ApprovedCommand, reverify bool, timeout time.Duration, writeLock func(func() error) error) []GateResult {
79
79
  var results []GateResult
80
- outcomes := map[string]RunOutcome{}
81
- spans := map[string][2]time.Time{}
82
- write := func(gate *Gate, evidence string, checked bool) error {
83
- if writeLock == nil {
84
- return writeResult(ledgerPath, gate, evidence, checked)
80
+ executions := map[string]struct {
81
+ ran bool
82
+ outcome RunOutcome
83
+ span [2]time.Time
84
+ inputs map[string]string
85
+ }{}
86
+ // Collect every dependent INPUTS spec before any shared command can run.
87
+ for _, gate := range doc.Gates {
88
+ if gate.Check == "" || gate.Inputs == "" {
89
+ continue
90
+ }
91
+ key := gate.Check + "\x00" + filepath.Join(root, filepath.FromSlash(gate.Cwd))
92
+ execution := executions[key]
93
+ if execution.inputs == nil {
94
+ execution.inputs = map[string]string{}
85
95
  }
86
- return writeLock(func() error { return writeResult(ledgerPath, gate, evidence, checked) })
96
+ execution.inputs[gate.Inputs] = ""
97
+ executions[key] = execution
87
98
  }
88
99
  for _, gate := range doc.Gates {
89
100
  if _, abandoned := doc.Abandoned[gate.ID]; abandoned {
@@ -95,15 +106,15 @@ func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, appro
95
106
  continue
96
107
  }
97
108
  inputs := ""
98
- if gate.Inputs != "" {
99
- // An unreadable or unmatched spec yields "", which never matches, so the gate re-executes.
100
- inputs, _ = InputsDigest(root, gate.Inputs)
101
- }
102
109
  if GateState(gate, doc) == StateMet {
103
110
  if gate.Inputs == "" && !reverify {
104
111
  results = append(results, GateResult{ID: gate.ID, Passed: true, Detail: "already met"})
105
112
  continue
106
113
  }
114
+ if gate.Inputs != "" {
115
+ // An unreadable or unmatched spec never matches recorded evidence.
116
+ inputs, _ = InputsDigest(root, gate.Inputs)
117
+ }
107
118
  // A gate that declares INPUTS is reused, under run and reverify alike,
108
119
  // exactly while the files it depends on are byte-identical; any change re-executes it.
109
120
  if inputs != "" && inputs == recordedInputs(gate) {
@@ -120,36 +131,84 @@ func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, appro
120
131
  results = append(results, GateResult{ID: gate.ID, Detail: err.Error()})
121
132
  continue
122
133
  }
123
- cwd := root
124
- if gate.Cwd != "" {
125
- cwd = filepath.Join(root, filepath.FromSlash(gate.Cwd))
126
- }
134
+ cwd := filepath.Join(root, filepath.FromSlash(gate.Cwd))
127
135
  key := gate.Check + "\x00" + cwd
128
- outcome, ran := outcomes[key]
129
- if !ran {
136
+ execution := executions[key]
137
+ if !execution.ran {
138
+ for spec := range execution.inputs {
139
+ execution.inputs[spec], _ = InputsDigest(root, spec)
140
+ }
130
141
  started := time.Now().UTC()
131
- outcome = Execute(ctx, gate.Check, cwd, timeout)
132
- outcomes[key] = outcome
133
- spans[key] = [2]time.Time{started, time.Now().UTC()}
134
- }
135
- span := spans[key]
136
- if outcome.Err == nil && outcome.ExitCode == 0 && expectation.Matches(outcome.Output) {
137
- evidence := withInputs(FormatEvidence(gate, OutputDigest(outcome.Output), outcome.OutputBytes, time.Now().UTC().Format(time.RFC3339)), inputs)
138
- writeErr := write(gate, evidence, true)
139
- results = append(results, GateResult{
140
- ID: gate.ID, Ran: true, Passed: true,
141
- Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
142
- Detail: resultDetail(writeErr, "pass"),
143
- Inputs: inputs, Outcome: outcome, Started: span[0], Ended: span[1],
144
- })
142
+ execution.outcome = Execute(ctx, gate.Check, cwd, timeout)
143
+ execution.span = [2]time.Time{started, time.Now().UTC()}
144
+ for spec, before := range execution.inputs {
145
+ after, _ := InputsDigest(root, spec)
146
+ if after != before {
147
+ execution.inputs[spec] = ""
148
+ }
149
+ }
150
+ execution.ran = true
151
+ executions[key] = execution
152
+ }
153
+ outcome := execution.outcome
154
+ inputs = execution.inputs[gate.Inputs]
155
+ passed := outcome.Err == nil && outcome.ExitCode == 0 && expectation.Matches(outcome.Output)
156
+ detail := "pass"
157
+ if !passed {
158
+ detail = failDetail(outcome, expectation)
159
+ }
160
+ write := func() error {
161
+ if passed && gate.Inputs != "" {
162
+ // Earlier writebacks may have moved files since execution.
163
+ current, _ := InputsDigest(root, gate.Inputs)
164
+ if inputs == "" || current != inputs {
165
+ passed = false
166
+ detail = "INPUTS changed or could not be read during the run"
167
+ }
168
+ }
169
+ evidence := "pending"
170
+ if passed {
171
+ evidence = withInputs(FormatEvidence(gate, OutputDigest(outcome.Output), outcome.OutputBytes, time.Now().UTC().Format(time.RFC3339)), inputs)
172
+ }
173
+ return writeResult(ledgerPath, gate, evidence, passed)
174
+ }
175
+ var writeErr error
176
+ if writeLock == nil {
177
+ writeErr = write()
178
+ } else {
179
+ writeErr = writeLock(write)
180
+ }
181
+ results = append(results, GateResult{
182
+ ID: gate.ID, Ran: true, Passed: passed,
183
+ Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
184
+ Detail: resultDetail(writeErr, detail),
185
+ Inputs: inputs, Outcome: outcome, Started: execution.span[0], Ended: execution.span[1],
186
+ })
187
+ }
188
+ // Later checks may invalidate an earlier execution or reused pass.
189
+ for i, gate := range doc.Gates {
190
+ result := &results[i]
191
+ if !result.Passed || result.Discarded || result.WriteErr != nil || gate.Inputs == "" {
192
+ continue
193
+ }
194
+ validate := func() error {
195
+ current, _ := InputsDigest(root, gate.Inputs)
196
+ if current != "" && current == result.Inputs {
197
+ return nil
198
+ }
199
+ result.Passed, result.Reused, result.Discarded = false, false, true
200
+ result.Detail = "INPUTS changed or could not be read after a later check"
201
+ return writeResult(ledgerPath, gate, "pending", false)
202
+ }
203
+ var err error
204
+ if writeLock == nil {
205
+ err = validate()
145
206
  } else {
146
- writeErr := write(gate, "pending", false)
147
- results = append(results, GateResult{
148
- ID: gate.ID, Ran: true,
149
- Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
150
- Detail: resultDetail(writeErr, failDetail(outcome, expectation)),
151
- Inputs: inputs, Outcome: outcome, Started: span[0], Ended: span[1],
152
- })
207
+ err = writeLock(validate)
208
+ }
209
+ if err != nil {
210
+ result.WriteErr = err
211
+ result.Detail = resultDetail(err, result.Detail)
153
212
  }
154
213
  }
155
214
  return results
@@ -194,14 +194,19 @@ func checkedCanonicalDir(root, candidate, parent, slug string) (string, error) {
194
194
  return filepath.Clean(candidate), nil
195
195
  }
196
196
 
197
- // ActiveSlug reads and trims the active-feature pointer. A missing pointer file
198
- // is not an error; it yields the empty slug.
197
+ // ActiveSlug resolves the workspace override, or reads the active-feature pointer.
198
+ // A missing pointer file is not an error; it yields the empty slug.
199
199
  func ActiveSlug(root string) (string, error) {
200
200
  if ws, err := WorkspaceOverrideChecked(root, ""); err != nil {
201
201
  return "", err
202
202
  } else if ws != "" {
203
203
  return filepath.Base(ws), nil
204
204
  }
205
+ return PhysicalActiveSlug(root)
206
+ }
207
+
208
+ // PhysicalActiveSlug reads the validated ACTIVE pointer without workspace overrides.
209
+ func PhysicalActiveSlug(root string) (string, error) {
205
210
  activePath := filepath.Join(root, "ACTIVE")
206
211
  if info, err := os.Lstat(activePath); err == nil && info.Mode()&os.ModeSymlink != 0 {
207
212
  return "", fmt.Errorf("DRV-ACTIVE-SYMLINK: ACTIVE is a symlink; run `rm -f %q`", activePath)
@@ -231,6 +231,11 @@ func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error
231
231
  return err
232
232
  }
233
233
  defer func() { _ = root.Close() }()
234
+ if info, err := root.Stat(rel); err == nil {
235
+ perm = info.Mode().Perm()
236
+ } else if !os.IsNotExist(err) {
237
+ return err
238
+ }
234
239
  return fsutil.WriteFileAtomicIn(root, rel, data, perm)
235
240
  }
236
241
 
@@ -17,11 +17,18 @@ import (
17
17
  )
18
18
 
19
19
  func (r *runner) installClaudeSettingsMarker(state claudeSettingsMerge) error {
20
+ return r.installMarker(hostpack.ClaudeSettingsMerge.MarkerRel, claudeSettingsMarkerText(state))
21
+ }
22
+
23
+ func claudeSettingsMarkerText(state claudeSettingsMerge) string {
20
24
  var b strings.Builder
21
25
  b.WriteString(hostpack.ClaudeSettingsMerge.MarkerText + "\n" + claudeRulesAddedLine)
22
26
  if state.created {
23
27
  b.WriteString("\n" + claudeSettingsCreatedLine)
24
28
  }
29
+ if state.defaultModeOwned {
30
+ b.WriteString("\ndefault-mode=added")
31
+ }
25
32
  rules := make([]string, 0, len(state.added))
26
33
  for rule := range state.added {
27
34
  rules = append(rules, rule)
@@ -30,7 +37,7 @@ func (r *runner) installClaudeSettingsMarker(state claudeSettingsMerge) error {
30
37
  for _, rule := range rules {
31
38
  b.WriteString("\nrule:" + rule)
32
39
  }
33
- return r.installMarker(hostpack.ClaudeSettingsMerge.MarkerRel, b.String())
40
+ return b.String()
34
41
  }
35
42
 
36
43
  const (
@@ -364,16 +371,45 @@ func (r *runner) mergeClaudeSettings(merge hostpack.JSONMerge) error {
364
371
  return fmt.Errorf("encode merged Claude settings: %w", err)
365
372
  }
366
373
  data = append(data, '\n')
374
+ // Publish ownership before grants; keep retired ownership until settings land.
375
+ pending := r.pendingClaudeSettingsOwnership(state)
376
+ if err := r.installClaudeSettingsMarker(pending); err != nil {
377
+ return err
378
+ }
379
+ if r.records[merge.MarkerRel] == "" {
380
+ return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", merge.TargetRel, merge.MarkerRel)
381
+ }
382
+ if err := r.recheckPath(merge.TargetRel); err != nil {
383
+ return err
384
+ }
367
385
  if err := r.writeConfined(dest, data, 0o644); err != nil {
368
386
  return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
369
387
  }
370
- return r.installClaudeSettingsMarker(state)
388
+ if pending.defaultModeOwned || len(pending.added) != len(state.added) {
389
+ if err := r.refreshPreflight(merge.MarkerRel); err != nil {
390
+ return err
391
+ }
392
+ return r.installClaudeSettingsMarker(state)
393
+ }
394
+ return nil
371
395
  }
372
396
 
373
397
  // claudeSettingsMerge is what the settings marker records about a merge.
374
398
  type claudeSettingsMerge struct {
375
- created bool
376
- added map[string]bool
399
+ created bool
400
+ added map[string]bool
401
+ defaultModeOwned bool
402
+ }
403
+
404
+ func (r *runner) pendingClaudeSettingsOwnership(next claudeSettingsMerge) claudeSettingsMerge {
405
+ pending := claudeSettingsMerge{
406
+ created: next.created, added: r.managedClaudePermissionRules(),
407
+ defaultModeOwned: r.claudeDefaultModeOwned(),
408
+ }
409
+ for rule := range next.added {
410
+ pending.added[rule] = true
411
+ }
412
+ return pending
377
413
  }
378
414
 
379
415
  // claudeSettingsNext returns the merged .claude/settings.json without
@@ -433,6 +469,12 @@ func stripHooksWith(path string, write func([]byte) error, remove func() error)
433
469
  }
434
470
 
435
471
  func (r *runner) stripClaudeSettings(path string) error {
472
+ return r.stripClaudeSettingsWith(path, func(data []byte) error {
473
+ return r.writeConfined(path, data, 0o644)
474
+ }, func() error { return r.removeConfined(path) })
475
+ }
476
+
477
+ func (r *runner) stripClaudeSettingsWith(path string, write func([]byte) error, remove func() error) error {
436
478
  current, err := readJSON(path)
437
479
  if err != nil {
438
480
  if errors.Is(err, fs.ErrNotExist) {
@@ -442,14 +484,14 @@ func (r *runner) stripClaudeSettings(path string) error {
442
484
  }
443
485
  next := stripDevritesSettings(current, r.claudeDefaultModeOwned(), r.managedClaudePermissionRules())
444
486
  if len(next) == 0 && r.claudeSettingsCreated() {
445
- return r.removeConfined(path)
487
+ return remove()
446
488
  }
447
489
  data, err := json.MarshalIndent(next, "", " ")
448
490
  if err != nil {
449
491
  return fmt.Errorf("encode Claude settings: %w", err)
450
492
  }
451
493
  data = append(data, '\n')
452
- return r.writeConfined(path, data, 0o644)
494
+ return write(data)
453
495
  }
454
496
 
455
497
  func (r *runner) stripMarkerConfined(path, begin, end string) error {
@@ -6,6 +6,7 @@ import (
6
6
  "fmt"
7
7
  "io"
8
8
  "io/fs"
9
+ "maps"
9
10
  "os"
10
11
  "path/filepath"
11
12
  "slices"
@@ -22,6 +23,7 @@ func (r *runner) preflightInstall() error {
22
23
  return fmt.Errorf("preflight install paths: %w", err)
23
24
  }
24
25
  var conflicts []string
26
+ dryTree := map[string][]byte{}
25
27
  for rel := range desired {
26
28
  snapshot, err := r.rememberPath(rel)
27
29
  if err != nil {
@@ -34,7 +36,8 @@ func (r *runner) preflightInstall() error {
34
36
  }
35
37
  }
36
38
  }
37
- for rel, record := range r.prev {
39
+ for _, rel := range slices.Sorted(maps.Keys(r.prev)) {
40
+ record := r.prev[rel]
38
41
  if desired[rel] || hostpack.PreserveOnPrune(rel) {
39
42
  continue
40
43
  }
@@ -58,7 +61,7 @@ func (r *runner) preflightInstall() error {
58
61
  if _, err := r.rememberPath(merge.TargetRel); err != nil {
59
62
  return err
60
63
  }
61
- if err := dryRunStripMarker(map[string][]byte{}, r.target, merge); err != nil {
64
+ if err := r.dryRunStripMarker(dryTree, merge); err != nil {
62
65
  return err
63
66
  }
64
67
  }
@@ -104,19 +107,34 @@ func (r *runner) preflightMerges() error {
104
107
  return fmt.Errorf("merge %s: %w", hostpack.CodexConfigMerge.TargetRel, err)
105
108
  }
106
109
  }
107
- if _, _, err := r.claudeSettingsNext(); err != nil {
110
+ _, settings, err := r.claudeSettingsNext()
111
+ if err != nil {
108
112
  return fmt.Errorf("seed claude settings: %w", err)
109
113
  }
114
+ marker := hostpack.ClaudeSettingsMerge.MarkerRel
115
+ if snapshot := r.preflight[marker]; !snapshot.missing && !r.opts.Force {
116
+ if _, managed := r.prev[marker]; !managed {
117
+ text := claudeSettingsMarkerText(r.pendingClaudeSettingsOwnership(settings)) + "\n"
118
+ sum := sha256.Sum256([]byte(text))
119
+ if snapshot.hash != fmt.Sprintf("sha256:%x", sum[:]) {
120
+ return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", hostpack.ClaudeSettingsMerge.TargetRel, marker)
121
+ }
122
+ }
123
+ }
110
124
  return nil
111
125
  }
112
126
 
113
127
  func (r *runner) preflightUninstall(entries []string) error {
114
128
  var conflicts []string
129
+ dryTree := map[string][]byte{}
115
130
  for _, rel := range entries {
116
131
  if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
117
132
  if _, err := r.rememberPath(merge.TargetRel); err != nil {
118
133
  return err
119
134
  }
135
+ if err := r.dryRunStripMarker(dryTree, merge); err != nil {
136
+ return err
137
+ }
120
138
  }
121
139
  if !hostpack.ShouldRemoveOnUninstall(rel, entries) {
122
140
  continue