devrites 6.1.2 → 6.1.4

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 (128) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/docs/command-map.md +1 -1
  4. package/engine/commands.go +1 -1
  5. package/engine/help.go +5 -1
  6. package/engine/internal/acceptance/cli.go +14 -5
  7. package/engine/internal/acceptance/run.go +20 -5
  8. package/engine/internal/lib/apply_bundle.go +474 -0
  9. package/engine/internal/lib/claims.go +4 -0
  10. package/engine/internal/lib/claims_holder.go +35 -5
  11. package/engine/internal/lib/diffscope.go +2 -1
  12. package/engine/internal/lib/dispatch.go +3 -0
  13. package/engine/main.go +4 -0
  14. package/pack/.claude/agents/devrites-plan-drafter.md +11 -2
  15. package/pack/.claude/agents/devrites-slice-wright.md +4 -1
  16. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  17. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  18. package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -0
  19. package/pack/.claude/skills/devrites-lib/reference/standards/gates.md +3 -0
  20. package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +7 -0
  21. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  22. package/pack/.claude/skills/rite-build/SKILL.md +3 -0
  23. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +2 -1
  24. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -0
  25. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  26. package/pack/.claude/skills/rite-plan/SKILL.md +20 -9
  27. package/pack/.claude/skills/rite-polish/SKILL.md +6 -4
  28. package/pack/.claude/skills/rite-prove/SKILL.md +3 -2
  29. package/pack/.claude/skills/rite-review/SKILL.md +6 -1
  30. package/pack/.claude/skills/rite-seal/SKILL.md +2 -2
  31. package/pack/.claude/skills/rite-seal/reference/phase-contract.md +7 -1
  32. package/pack/.claude/skills/rite-vet/SKILL.md +9 -5
  33. package/pack/generated/claude/agents/devrites-plan-drafter.md +11 -2
  34. package/pack/generated/claude/agents/devrites-slice-wright.md +4 -1
  35. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  36. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  37. package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -0
  38. package/pack/generated/claude/skills/devrites-lib/reference/standards/gates.md +3 -0
  39. package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +7 -0
  40. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  41. package/pack/generated/claude/skills/rite-build/SKILL.md +3 -0
  42. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +2 -1
  43. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -0
  44. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  45. package/pack/generated/claude/skills/rite-plan/SKILL.md +20 -9
  46. package/pack/generated/claude/skills/rite-polish/SKILL.md +6 -4
  47. package/pack/generated/claude/skills/rite-prove/SKILL.md +3 -2
  48. package/pack/generated/claude/skills/rite-review/SKILL.md +6 -1
  49. package/pack/generated/claude/skills/rite-seal/SKILL.md +2 -2
  50. package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +7 -1
  51. package/pack/generated/claude/skills/rite-vet/SKILL.md +9 -5
  52. package/pack/generated/codex/agents/devrites-plan-drafter.toml +11 -2
  53. package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -1
  54. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  55. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  56. package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -0
  57. package/pack/generated/codex/skills/devrites-lib/reference/standards/gates.md +3 -0
  58. package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +7 -0
  59. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  60. package/pack/generated/codex/skills/rite-build/SKILL.md +3 -0
  61. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +2 -1
  62. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -0
  63. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  64. package/pack/generated/codex/skills/rite-plan/SKILL.md +20 -9
  65. package/pack/generated/codex/skills/rite-polish/SKILL.md +6 -4
  66. package/pack/generated/codex/skills/rite-prove/SKILL.md +3 -2
  67. package/pack/generated/codex/skills/rite-review/SKILL.md +6 -1
  68. package/pack/generated/codex/skills/rite-seal/SKILL.md +2 -2
  69. package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +7 -1
  70. package/pack/generated/codex/skills/rite-vet/SKILL.md +9 -5
  71. package/pack/generated/devin/agents/devrites-plan-drafter.md +11 -2
  72. package/pack/generated/devin/agents/devrites-slice-wright.md +4 -1
  73. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  74. package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  75. package/pack/generated/devin/skills/devrites-lib/reference/standards/core.md +3 -0
  76. package/pack/generated/devin/skills/devrites-lib/reference/standards/gates.md +3 -0
  77. package/pack/generated/devin/skills/devrites-lib/reference/standards/testing.md +7 -0
  78. package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  79. package/pack/generated/devin/skills/rite-build/SKILL.md +3 -0
  80. package/pack/generated/devin/skills/rite-build/reference/one-slice-cycle.md +2 -1
  81. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +7 -0
  82. package/pack/generated/devin/skills/rite-define/SKILL.md +10 -1
  83. package/pack/generated/devin/skills/rite-plan/SKILL.md +20 -9
  84. package/pack/generated/devin/skills/rite-polish/SKILL.md +6 -4
  85. package/pack/generated/devin/skills/rite-prove/SKILL.md +3 -2
  86. package/pack/generated/devin/skills/rite-review/SKILL.md +6 -1
  87. package/pack/generated/devin/skills/rite-seal/SKILL.md +2 -2
  88. package/pack/generated/devin/skills/rite-seal/reference/phase-contract.md +7 -1
  89. package/pack/generated/devin/skills/rite-vet/SKILL.md +9 -5
  90. package/pack/generated/omp/agents/devrites-plan-drafter.md +11 -2
  91. package/pack/generated/omp/agents/devrites-slice-wright.md +4 -1
  92. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  93. package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  94. package/pack/generated/omp/skills/devrites-lib/reference/standards/core.md +3 -0
  95. package/pack/generated/omp/skills/devrites-lib/reference/standards/gates.md +3 -0
  96. package/pack/generated/omp/skills/devrites-lib/reference/standards/testing.md +7 -0
  97. package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  98. package/pack/generated/omp/skills/rite-build/SKILL.md +3 -0
  99. package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +2 -1
  100. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +7 -0
  101. package/pack/generated/omp/skills/rite-define/SKILL.md +10 -1
  102. package/pack/generated/omp/skills/rite-plan/SKILL.md +20 -9
  103. package/pack/generated/omp/skills/rite-polish/SKILL.md +6 -4
  104. package/pack/generated/omp/skills/rite-prove/SKILL.md +3 -2
  105. package/pack/generated/omp/skills/rite-review/SKILL.md +6 -1
  106. package/pack/generated/omp/skills/rite-seal/SKILL.md +2 -2
  107. package/pack/generated/omp/skills/rite-seal/reference/phase-contract.md +7 -1
  108. package/pack/generated/omp/skills/rite-vet/SKILL.md +9 -5
  109. package/pack/generated/pi/agents/devrites-plan-drafter.md +11 -2
  110. package/pack/generated/pi/agents/devrites-slice-wright.md +4 -1
  111. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +7 -3
  112. package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +21 -7
  113. package/pack/generated/pi/skills/devrites-lib/reference/standards/core.md +3 -0
  114. package/pack/generated/pi/skills/devrites-lib/reference/standards/gates.md +3 -0
  115. package/pack/generated/pi/skills/devrites-lib/reference/standards/testing.md +7 -0
  116. package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +1 -1
  117. package/pack/generated/pi/skills/rite-build/SKILL.md +3 -0
  118. package/pack/generated/pi/skills/rite-build/reference/one-slice-cycle.md +2 -1
  119. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +7 -0
  120. package/pack/generated/pi/skills/rite-define/SKILL.md +10 -1
  121. package/pack/generated/pi/skills/rite-plan/SKILL.md +20 -9
  122. package/pack/generated/pi/skills/rite-polish/SKILL.md +6 -4
  123. package/pack/generated/pi/skills/rite-prove/SKILL.md +3 -2
  124. package/pack/generated/pi/skills/rite-review/SKILL.md +6 -1
  125. package/pack/generated/pi/skills/rite-seal/SKILL.md +2 -2
  126. package/pack/generated/pi/skills/rite-seal/reference/phase-contract.md +7 -1
  127. package/pack/generated/pi/skills/rite-vet/SKILL.md +9 -5
  128. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [6.1.4](https://github.com/ViktorsBaikers/DevRites/compare/v6.1.3...v6.1.4) (2026-10-16)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** free dead-session claims and surface writer-safe rule ([#110](https://github.com/ViktorsBaikers/DevRites/issues/110)) ([3d431b1](https://github.com/ViktorsBaikers/DevRites/commit/3d431b12cab83d4aaff7aaa4ced2c75210416a90))
10
+
11
+ ## [6.1.3](https://github.com/ViktorsBaikers/DevRites/compare/v6.1.2...v6.1.3) (2026-10-16)
12
+
13
+ ### Changed
14
+
15
+ * **devrites:** apply plan bundles in the engine and overlap phase work ([#109](https://github.com/ViktorsBaikers/DevRites/issues/109)) ([4394b0a](https://github.com/ViktorsBaikers/DevRites/commit/4394b0af21677da279be0ca5c359af728e482fd9))
16
+
5
17
  ## [6.1.2](https://github.com/ViktorsBaikers/DevRites/compare/v6.1.1...v6.1.2) (2026-10-16)
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -31,7 +31,7 @@ history can't lose it. After you clear the conversation, switch hosts, or hand
31
31
  the feature to another engineer, the next session reads the same plan,
32
32
  decisions, and evidence before it continues.
33
33
 
34
- **Status:** [`v6.1.2`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v6.1.2): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
34
+ **Status:** [`v6.1.4`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v6.1.4): 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
 
@@ -44,7 +44,7 @@ The Go surface is intentionally closed and deterministic:
44
44
  | Candidate and deterministic checks | `check candidate`, `check readiness [--emit-binding]`, `check seal`, `check slice <slug> <SLICE-ID>`, `check task-graph`, `check regression <slug> [--update]`, `check drift <slug> [--record]`, `check windows <slug>`, `check dup`, `check path-disjoint`, `parallel select`, `check skill-trust`, `check indexes`, `detect commands [--root <dir>] [--json]` | Candidate validates/hashes the strict project manifest; readiness checks phase files, the required `tasks.md` slice graph, or emits the vetted Build-input binding; seal checks final files/open gates, that graph and binding, then exact candidate bindings. `check slice` preflights one slice before wright dispatch — contract present, exact Writer allowlist paths, `Satisfies` AC ids resolvable, bounded scope — so a malformed slice fails before a writer context is spent. `check indexes` reports install manifest and optional code-index directory presence as JSON. `parallel select` returns the greedy path-disjoint subset ≤ cap from the supplied ready slices. `check regression` compares current workspace progress against the durable `regression-baseline.json` high-water mark and `--update` ratchets it at a checkpoint, so a lost slice, unchecked criterion, or reopened question fails closed instead of passing silently. `check windows` scans the feature diff for newly introduced deferral markers (TODO/FIXME/skipped test/stub idioms) and fails when any lacks a `windows.md` waiver row — silent deferrals block the seal. `check dup` reports advisory near-duplicate clusters that survive renaming (normalized token shingles, transitive clusters, distance-boosted rank, `*`-marked diff overlap, stable content hashes, `.devrites/dup-ignore` dismissal ledger) — leads for reviewer triage, never a gate. `check drift` attributes readiness-input drift to the exact artifact: `--record` snapshots per-input SHA-256 digests into `readiness-inputs.json` under the feature lock, and a later compare names each `changed`/`missing`/`added` input (falling back to the aggregate `eng-review.md` binding plus mtime candidates when no baseline exists) so a stale-binding repair starts at the right file — advisory, never a gate. `detect commands` resolves the repository's own test/lint/vet/build commands (Makefile targets, then `package.json` scripts, then language-manifest fallbacks, plus the lockfile-derived package manager) so `gates.md` `CHECK` lines and proof steps cite real wiring instead of invented commands — read-only, no installs, no execution. |
45
45
  | Workspace observation | `observe summary`, `orient`, `observe slice <slug> <SLICE-ID>`, `next`, `handoff` | JSON workspace snapshot for agents (cursor, task graph, `artifact_budgets`, `bulk_files`); `orient` is an alias for `observe summary`. `observe slice` prints one `SLICE-###` section of `tasks.md` so Build reads a slice, not the file. `next` prints the minimal remaining lifecycle path plus advisory skips (e.g. clarify at zero open questions). `handoff` emits the deterministic resume record (cursor, `awaiting_human`, blocking question gates, ledger reduction with unmet/stale ids, dead ends, read-next order) — the `/rite-handoff` spine and compaction-fallback entry. |
46
46
  | Context packets, dispatch waves, metrics, claims, notes | `context [slug] (--phase <p> \| --skill <name>) [--role <r>] [--trigger a,b]`, `dispatch <slug> <open\|start\|seal\|return\|status\|abandon>`, `metrics record`, `metrics summary`, `claim <add\|release\|list\|check>`, `note <add\|list\|check\|rm>` | `context` emits one deduplicated read-set bundle per phase/role/skill from each skill's `loads:` manifest — a dispatch target reads it instead of selecting files; `--skill` works without a workspace when `--out` is given. `dispatch` owns the launch-wave barrier (seal needs a handle per role; return needs seal) and auto-records dispatch/return metrics. `metrics` owns the append-only `metrics.jsonl` ledger. `claim` owns the append-only `.devrites/claims.jsonl` ledger — advisory, session-scoped file claims with TTL so concurrent same-tree sessions see each other's write intent before edits collide (conflict exits 3; never a substitute for one-writer-per-worktree). `note` owns the anchored `notes.md` ledger — notes bound to verbatim code quotes, regraded `exact`/`moved`/`stale`/`ambiguous`/`lost` as the code drifts; `check seal` refuses non-`exact` anchors. `check diff-scope` is the mechanical changed-paths ⊆ allowlist gate run before reviewer dispatch. |
47
- | Atomic workspace state | `state resolve`, `state merge-manifest`, `state close`, `migrate [--dry-run] [--answer id=choice]` | Go owns answer/drop/batch resolution, the recorded predecessor-chain manifest union for a release milestone, transactional close, and fail-closed v5 schema normalization. |
47
+ | Atomic workspace state | `state resolve`, `state merge-manifest`, `state apply-bundle <slug> <bundle.json>... [--dry-run]`, `state close`, `migrate [--dry-run] [--answer id=choice]` | Go owns answer/drop/batch resolution, the recorded predecessor-chain manifest union for a release milestone, the all-or-nothing apply of plan-drafter hunk bundles (base-hash pins, unique non-overlapping anchors, no lost IDs, no new task-graph or budget defect, no appended dated delta section), transactional close, and fail-closed v5 schema normalization. |
48
48
  | Acceptance ledger | `gates scaffold`, `gates status`, `gates run [--timeout N]`, `gates reverify [--timeout N]`, `gates lint [--strict]`, `gates attest <id> <note>`, `gates abandon <id> <why>` | Go owns the `gates.md` ledger: per-outcome gates whose runnable `CHECK`/`CWD` must exactly match a `test-plan.md` Build-entry preflight row, evidence bound to the gate definition digest (stale = unmet), `ABANDON:` handoffs, and oracle lint. Readiness/seal require a parse-clean, all-met ledger whenever `gates.md` is a required artifact. |
49
49
  | Security | `secret-scan` | `/rite-ship`, safe hooks, or an operator scans staged blobs, stdin, or touched files. |
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. |
@@ -34,7 +34,7 @@ func rootModeFor(command string, args []string) rootMode {
34
34
  return rootLenient
35
35
  case "state":
36
36
  switch subcommand {
37
- case "resolve", "merge-manifest", "close":
37
+ case "resolve", "merge-manifest", "apply-bundle", "close":
38
38
  return rootStrict
39
39
  }
40
40
  return rootUnused
package/engine/help.go CHANGED
@@ -30,7 +30,7 @@ const (
30
30
  check dup [slug] [--all|--worktree|--staged|--base <ref>]
31
31
  `
32
32
 
33
- stateUsage = `usage: devrites-engine state <resolve|merge-manifest|close> ...
33
+ stateUsage = `usage: devrites-engine state <resolve|merge-manifest|apply-bundle|close> ...
34
34
 
35
35
  state resolve <qid> "<answer>"
36
36
  state resolve --drop <qid> ["<reason>"]
@@ -39,6 +39,7 @@ const (
39
39
  state resolve --human --drop <qid> ["<reason>"]
40
40
  state resolve --human --batch <file>
41
41
  state merge-manifest <slug> [<predecessor>...]
42
+ state apply-bundle <slug> <bundle.json>... [--dry-run]
42
43
  state close <slug>
43
44
  `
44
45
 
@@ -86,6 +87,7 @@ const (
86
87
  dupUsageLine = "usage: devrites-engine check dup [slug] [--all|--worktree|--staged|--base <ref>] [--min-lines n] [--threshold f] [--ignore-file <path>] [--limit n] [--exclude <csv>] [--phase p] [--cwd <dir>]"
87
88
  resolveUsage = `usage: devrites-engine state resolve <qid> "<answer>" | state resolve --drop <qid> ["<reason>"] | state resolve --batch <file> | state resolve --human <qid> "<answer>" | state resolve --human --drop <qid> ["<reason>"] | state resolve --human --batch <file>`
88
89
  mergeManifestUsage = "usage: devrites-engine state merge-manifest <slug> [<predecessor>...]"
90
+ applyBundleUsage = "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]"
89
91
  closeUsage = "usage: devrites-engine state close <slug>"
90
92
  observeSliceUsage = "usage: devrites-engine observe slice <slug> <SLICE-ID>"
91
93
  observeSummaryUsage = "usage: devrites-engine observe summary [slug]"
@@ -286,6 +288,8 @@ func helpState(rest []string) string {
286
288
  return resolveUsage
287
289
  case "merge-manifest":
288
290
  return mergeManifestUsage
291
+ case "apply-bundle":
292
+ return applyBundleUsage
289
293
  case "close":
290
294
  return closeUsage
291
295
  default:
@@ -248,9 +248,19 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
248
248
  return ExitBlocked
249
249
  }
250
250
 
251
- lockErr := state.WithFeatureLock(root, slug, func() error {
252
- text, err := ReadLedgerFile(ledgerPath)
253
- if err != nil {
251
+ // The gates lock serializes check execution; the feature lock is held only
252
+ // to read inputs and per writeback, so dispatch and other workspace writers
253
+ // are not blocked for the whole (often minutes-long) run.
254
+ featureLock := func(fn func() error) error { return state.WithFeatureLock(root, slug, fn) }
255
+ lockErr := state.WithLock(ledgerPath+".lock", func() error {
256
+ var text string
257
+ var approved []ApprovedCommand
258
+ if err := featureLock(func() error {
259
+ var err error
260
+ text, err = ReadLedgerFile(ledgerPath)
261
+ approved = loadTestPlan(testPlanPath)
262
+ return err
263
+ }); err != nil {
254
264
  return err
255
265
  }
256
266
  doc := ParseLedger(text)
@@ -260,8 +270,7 @@ func cmdRun(root string, args []string, reverify bool, stdout, stderr io.Writer)
260
270
  }
261
271
  return nil
262
272
  }
263
- approved := loadTestPlan(testPlanPath)
264
- for _, result := range RunGates(context.Background(), project, ledgerPath, doc, approved, reverify, timeout) {
273
+ for _, result := range RunGates(context.Background(), project, ledgerPath, doc, approved, reverify, timeout, featureLock) {
265
274
  switch {
266
275
  case !result.Ran:
267
276
  fmt.Fprintf(stdout, "gate %s: skipped (%s)\n", result.ID, result.Detail)
@@ -65,9 +65,19 @@ func ReadLedgerFile(ledgerPath string) (string, error) {
65
65
  // Each pass writes definition-bound evidence; each failure clears the box and
66
66
  // restores EVIDENCE: pending.
67
67
  //
68
- // The caller holds the feature lock across this call.
69
- func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, approved []ApprovedCommand, reverify bool, timeout time.Duration) []GateResult {
68
+ // Gates sharing one CHECK and CWD execute once per call; each matches its own
69
+ // EXPECT against that shared output. writeLock, when non-nil, wraps every
70
+ // ledger writeback so the caller need not hold the feature lock while checks
71
+ // execute; writeResult re-verifies the gate's oracle before it writes.
72
+ func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, approved []ApprovedCommand, reverify bool, timeout time.Duration, writeLock func(func() error) error) []GateResult {
70
73
  var results []GateResult
74
+ outcomes := map[string]RunOutcome{}
75
+ write := func(gate *Gate, evidence string, checked bool) error {
76
+ if writeLock == nil {
77
+ return writeResult(ledgerPath, gate, evidence, checked)
78
+ }
79
+ return writeLock(func() error { return writeResult(ledgerPath, gate, evidence, checked) })
80
+ }
71
81
  for _, gate := range doc.Gates {
72
82
  if _, abandoned := doc.Abandoned[gate.ID]; abandoned {
73
83
  results = append(results, GateResult{ID: gate.ID, Detail: "abandoned; handoff required"})
@@ -94,17 +104,22 @@ func RunGates(ctx context.Context, root, ledgerPath string, doc *Document, appro
94
104
  if gate.Cwd != "" {
95
105
  cwd = filepath.Join(root, filepath.FromSlash(gate.Cwd))
96
106
  }
97
- outcome := Execute(ctx, gate.Check, cwd, timeout)
107
+ key := gate.Check + "\x00" + cwd
108
+ outcome, ran := outcomes[key]
109
+ if !ran {
110
+ outcome = Execute(ctx, gate.Check, cwd, timeout)
111
+ outcomes[key] = outcome
112
+ }
98
113
  if outcome.Err == nil && outcome.ExitCode == 0 && expectation.Matches(outcome.Output) {
99
114
  evidence := FormatEvidence(gate, OutputDigest(outcome.Output), outcome.OutputBytes, time.Now().UTC().Format(time.RFC3339))
100
- writeErr := writeResult(ledgerPath, gate, evidence, true)
115
+ writeErr := write(gate, evidence, true)
101
116
  results = append(results, GateResult{
102
117
  ID: gate.ID, Ran: true, Passed: true,
103
118
  Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
104
119
  Detail: resultDetail(writeErr, "pass"),
105
120
  })
106
121
  } else {
107
- writeErr := writeResult(ledgerPath, gate, "pending", false)
122
+ writeErr := write(gate, "pending", false)
108
123
  results = append(results, GateResult{
109
124
  ID: gate.ID, Ran: true,
110
125
  Discarded: errors.Is(writeErr, ErrOracleMoved), WriteErr: writeErr,
@@ -0,0 +1,474 @@
1
+ package lib
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "io/fs"
11
+ "os"
12
+ "path/filepath"
13
+ "regexp"
14
+ "slices"
15
+ "sort"
16
+ "strings"
17
+
18
+ "github.com/devrites/devrites/internal/devritespaths"
19
+ "github.com/devrites/devrites/internal/fsutil"
20
+ "github.com/devrites/devrites/internal/state"
21
+ )
22
+
23
+ // maxBundleBytes bounds one drafter bundle read. A bundle carries hunks for a
24
+ // handful of planning artifacts, each capped at state.MaxArtifactBytes.
25
+ const maxBundleBytes = 8 << 20
26
+
27
+ // trackedIDRE matches the workspace ID families whose silent disappearance is
28
+ // a planning defect: an AC, slice, or horizon item never vanishes unrecorded.
29
+ var trackedIDRE = regexp.MustCompile(`\b(?:AC|REQ|HZN|SLICE|PF|INV|DEC|PROH|EDGE|DRIFT|ASM|Q|EVID)-\d+(?:-[A-Z][A-Z0-9]*)*\b`)
30
+
31
+ // datedHeadingRE finds a dated section heading. In the current-state planning
32
+ // artifacts it marks an appended repair delta that leaves the superseded clause
33
+ // beside its replacement, growing the file and the next review's surface.
34
+ var datedHeadingRE = regexp.MustCompile(`(?m)^#{2,4}\s.*\b\d{4}-\d{2}-\d{2}\b`)
35
+
36
+ var currentStatePlanning = map[string]bool{
37
+ "architecture.md": true, "plan.md": true, "tasks.md": true, "test-plan.md": true, "traceability.md": true,
38
+ }
39
+
40
+ // candidateBundle is the plan-drafter's candidate_files result. Unknown fields
41
+ // (acceptance_map, technical_decisions, ...) are the root's to reconcile and
42
+ // are ignored here.
43
+ type candidateBundle struct {
44
+ BaseHashes map[string]string `json:"base_hashes"`
45
+ CandidateFiles []candidateFile `json:"candidate_files"`
46
+ RetiredIDs []string `json:"retired_ids"`
47
+ }
48
+
49
+ type candidateFile struct {
50
+ Path string `json:"path"`
51
+ Operation string `json:"operation"`
52
+ Content *string `json:"content"`
53
+ Hunks []candidateHunk `json:"hunks"`
54
+ }
55
+
56
+ type candidateHunk struct {
57
+ ID string `json:"id"`
58
+ OldText string `json:"old_text"`
59
+ NewText string `json:"new_text"`
60
+ }
61
+
62
+ // bundleTarget is every requested change to one workspace file, merged across
63
+ // bundles so anchor-disjoint drafter clusters apply against one shared base.
64
+ type bundleTarget struct {
65
+ rel string
66
+ op string // create | replace | patch
67
+ content string
68
+ hunks []candidateHunk
69
+ pins []string // base hashes named by each bundle that touches rel
70
+ before []byte
71
+ existed bool
72
+ after []byte
73
+ hunkSeen int
74
+ }
75
+
76
+ // RunApplyBundle implements `devrites-engine state apply-bundle <slug>
77
+ // <bundle.json>... [--dry-run]`. It replaces the root's hand fold of drafter
78
+ // hunks: every bundle is validated against the current bytes (base hash pin,
79
+ // unique non-overlapping old_text, no lost tracked ID, no new task-graph
80
+ // problem, no new material budget overshoot) and then all files are written,
81
+ // or none are. Several bundles combine when their hunks are disjoint.
82
+ //
83
+ // Exit codes:
84
+ //
85
+ // 0 applied (or dry-run clean)
86
+ // 2 usage error
87
+ // 3 blocked: a bundle failed validation; nothing was written
88
+ // 1 internal/IO error
89
+ func RunApplyBundle(root string, args []string, stdout, stderr io.Writer) int {
90
+ dryRun := false
91
+ var positional []string
92
+ for _, arg := range args {
93
+ if arg == "--dry-run" {
94
+ dryRun = true
95
+ continue
96
+ }
97
+ if strings.HasPrefix(arg, "-") {
98
+ fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
99
+ return 2
100
+ }
101
+ positional = append(positional, arg)
102
+ }
103
+ if len(positional) < 2 {
104
+ fmt.Fprintln(stderr, "usage: devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]")
105
+ return 2
106
+ }
107
+ slug := positional[0]
108
+ workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
109
+ if err != nil {
110
+ fmt.Fprintf(stderr, "apply-bundle: BLOCKED: workspace for %s: %v\n", slug, err)
111
+ return 3
112
+ }
113
+ if err := state.RequireWorkspaceSchema(root, slug); err != nil {
114
+ fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %v\n", err)
115
+ return 3
116
+ }
117
+ bundles := make([]candidateBundle, 0, len(positional)-1)
118
+ for _, name := range positional[1:] {
119
+ bundle, err := readCandidateBundle(name)
120
+ if err != nil {
121
+ fmt.Fprintf(stderr, "apply-bundle: BLOCKED: bundle %s: %v\n", name, err)
122
+ return 3
123
+ }
124
+ bundles = append(bundles, bundle)
125
+ }
126
+
127
+ code := 0
128
+ if err := state.WithFeatureLock(root, slug, func() error {
129
+ code = applyBundlesLocked(slug, workspace, bundles, dryRun, stdout, stderr)
130
+ return nil
131
+ }); err != nil {
132
+ fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
133
+ return 1
134
+ }
135
+ return code
136
+ }
137
+
138
+ func readCandidateBundle(name string) (candidateBundle, error) {
139
+ var bundle candidateBundle
140
+ raw, err := readBoundedRegularFile(name, maxBundleBytes)
141
+ if err != nil {
142
+ return bundle, err
143
+ }
144
+ if err := json.Unmarshal(raw, &bundle); err != nil {
145
+ return bundle, fmt.Errorf("not a JSON candidate bundle: %w", err)
146
+ }
147
+ if len(bundle.CandidateFiles) == 0 {
148
+ return bundle, errors.New("candidate_files is empty")
149
+ }
150
+ return bundle, nil
151
+ }
152
+
153
+ func applyBundlesLocked(slug, workspace string, bundles []candidateBundle, dryRun bool, stdout, stderr io.Writer) int {
154
+ ws, err := os.OpenRoot(workspace)
155
+ if err != nil {
156
+ fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
157
+ return 1
158
+ }
159
+ defer func() { _ = ws.Close() }()
160
+
161
+ targets, retired, problems := collectBundleTargets(slug, bundles)
162
+ if len(problems) == 0 {
163
+ problems = materializeTargets(ws, targets)
164
+ }
165
+ if len(problems) == 0 {
166
+ problems = checkBundleResult(targets, retired)
167
+ }
168
+ if len(problems) > 0 {
169
+ for _, problem := range problems {
170
+ fmt.Fprintf(stderr, "apply-bundle: BLOCKED: %s\n", problem)
171
+ }
172
+ fmt.Fprintln(stderr, "apply-bundle: nothing written")
173
+ return 3
174
+ }
175
+
176
+ hunks := 0
177
+ for _, target := range targets {
178
+ hunks += len(target.hunks)
179
+ lines := state.CountLines(target.after)
180
+ budget := ""
181
+ if limit, ok := state.ArtifactLineBudget(filepath.Base(target.rel)); ok {
182
+ budget = fmt.Sprintf("/%d", limit)
183
+ }
184
+ fmt.Fprintf(stdout, "apply-bundle: %s %s %d hunk(s) sha256 %s -> %s lines %d%s\n",
185
+ target.rel, target.op, len(target.hunks), shortHash(target.before, target.existed), shortHash(target.after, true), lines, budget)
186
+ }
187
+ if dryRun {
188
+ fmt.Fprintf(stdout, "apply-bundle: dry-run clean: %d file(s), %d hunk(s); nothing written\n", len(targets), hunks)
189
+ return 0
190
+ }
191
+ if err := writeBundleTargets(ws, targets); err != nil {
192
+ fmt.Fprintf(stderr, "apply-bundle: %v\n", err)
193
+ return 1
194
+ }
195
+ fmt.Fprintf(stdout, "apply-bundle: applied %d file(s), %d hunk(s)\n", len(targets), hunks)
196
+ return 0
197
+ }
198
+
199
+ // collectBundleTargets merges every bundle's candidate files by workspace path.
200
+ // One path takes either one create/replace or any number of patch entries.
201
+ func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarget, map[string]bool, []string) {
202
+ var problems []string
203
+ byRel := map[string]*bundleTarget{}
204
+ retired := map[string]bool{}
205
+ prefix := ".devrites/work/" + slug + "/"
206
+ for bi, bundle := range bundles {
207
+ for _, id := range bundle.RetiredIDs {
208
+ retired[strings.TrimSpace(id)] = true
209
+ }
210
+ pins := map[string]string{}
211
+ for path, hash := range bundle.BaseHashes {
212
+ pins[strings.TrimPrefix(filepath.ToSlash(path), prefix)] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
213
+ }
214
+ for _, file := range bundle.CandidateFiles {
215
+ rel := strings.TrimPrefix(filepath.ToSlash(file.Path), prefix)
216
+ where := fmt.Sprintf("bundle %d %s", bi+1, file.Path)
217
+ if !filepath.IsLocal(filepath.FromSlash(rel)) || !strings.HasSuffix(rel, ".md") {
218
+ problems = append(problems, where+": path must be a Markdown file inside the "+slug+" workspace")
219
+ continue
220
+ }
221
+ op := strings.ToLower(strings.TrimSpace(file.Operation))
222
+ switch op {
223
+ case "create", "replace":
224
+ if file.Content == nil {
225
+ problems = append(problems, where+": "+op+" needs content")
226
+ continue
227
+ }
228
+ case "patch":
229
+ if len(file.Hunks) == 0 {
230
+ problems = append(problems, where+": patch needs hunks")
231
+ continue
232
+ }
233
+ default:
234
+ problems = append(problems, fmt.Sprintf("%s: unknown operation %q", where, file.Operation))
235
+ continue
236
+ }
237
+ target, seen := byRel[rel]
238
+ if !seen {
239
+ target = &bundleTarget{rel: rel, op: op}
240
+ byRel[rel] = target
241
+ } else if op != "patch" || target.op != "patch" {
242
+ problems = append(problems, where+": conflicts with another candidate for the same file (only patch hunks combine)")
243
+ continue
244
+ }
245
+ if op != "create" {
246
+ pin, ok := pins[rel]
247
+ if !ok {
248
+ problems = append(problems, where+": base_hashes has no pin for this file")
249
+ continue
250
+ }
251
+ target.pins = append(target.pins, pin)
252
+ }
253
+ if file.Content != nil {
254
+ target.content = *file.Content
255
+ }
256
+ target.hunks = append(target.hunks, file.Hunks...)
257
+ }
258
+ }
259
+ targets := make([]*bundleTarget, 0, len(byRel))
260
+ for _, target := range byRel {
261
+ targets = append(targets, target)
262
+ }
263
+ sort.Slice(targets, func(i, j int) bool { return targets[i].rel < targets[j].rel })
264
+ return targets, retired, problems
265
+ }
266
+
267
+ // materializeTargets reads each file's current bytes, verifies its pins, and
268
+ // computes the after bytes.
269
+ func materializeTargets(ws *os.Root, targets []*bundleTarget) []string {
270
+ var problems []string
271
+ for _, target := range targets {
272
+ before, existed, err := readBundleFile(ws, target.rel)
273
+ if err != nil {
274
+ problems = append(problems, fmt.Sprintf("%s: %v", target.rel, err))
275
+ continue
276
+ }
277
+ target.before, target.existed = before, existed
278
+ if target.op == "create" {
279
+ if existed {
280
+ problems = append(problems, target.rel+": create target already exists")
281
+ continue
282
+ }
283
+ target.after = []byte(target.content)
284
+ continue
285
+ }
286
+ if !existed {
287
+ problems = append(problems, target.rel+": "+target.op+" target does not exist")
288
+ continue
289
+ }
290
+ current := sha256Hex(before)
291
+ for _, pin := range target.pins {
292
+ if pin != current {
293
+ problems = append(problems, fmt.Sprintf("%s: stale base: pinned %s, current %s", target.rel, abbrev(pin), abbrev(current)))
294
+ break
295
+ }
296
+ }
297
+ if target.op == "replace" {
298
+ target.after = []byte(target.content)
299
+ continue
300
+ }
301
+ after, hunkProblems := applyHunks(string(before), target.hunks)
302
+ for _, problem := range hunkProblems {
303
+ problems = append(problems, target.rel+": "+problem)
304
+ }
305
+ target.after = []byte(after)
306
+ }
307
+ return problems
308
+ }
309
+
310
+ // applyHunks replaces each hunk's old_text, which must occur exactly once in
311
+ // base and must not overlap another hunk's span.
312
+ func applyHunks(base string, hunks []candidateHunk) (string, []string) {
313
+ type span struct {
314
+ start, end int
315
+ hunk candidateHunk
316
+ }
317
+ var problems []string
318
+ spans := make([]span, 0, len(hunks))
319
+ for i, hunk := range hunks {
320
+ name := hunk.ID
321
+ if name == "" {
322
+ name = fmt.Sprintf("#%d", i+1)
323
+ }
324
+ hunk.ID = name
325
+ if hunk.OldText == "" {
326
+ problems = append(problems, "hunk "+name+": empty old_text")
327
+ continue
328
+ }
329
+ start := strings.Index(base, hunk.OldText)
330
+ if start < 0 {
331
+ problems = append(problems, "hunk "+name+": old_text not found (stale anchor)")
332
+ continue
333
+ }
334
+ // Search from start+1 so an overlapping second occurrence also counts.
335
+ if strings.Contains(base[start+1:], hunk.OldText) {
336
+ problems = append(problems, "hunk "+name+": old_text occurs more than once (non-unique anchor)")
337
+ continue
338
+ }
339
+ spans = append(spans, span{start, start + len(hunk.OldText), hunk})
340
+ }
341
+ sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
342
+ for i := 1; i < len(spans); i++ {
343
+ if spans[i].start < spans[i-1].end {
344
+ problems = append(problems, fmt.Sprintf("hunks %s and %s overlap", spans[i-1].hunk.ID, spans[i].hunk.ID))
345
+ }
346
+ }
347
+ if len(problems) > 0 {
348
+ return base, problems
349
+ }
350
+ var out strings.Builder
351
+ last := 0
352
+ for _, s := range spans {
353
+ out.WriteString(base[last:s.start])
354
+ out.WriteString(s.hunk.NewText)
355
+ last = s.end
356
+ }
357
+ out.WriteString(base[last:])
358
+ return out.String(), nil
359
+ }
360
+
361
+ // checkBundleResult rejects an outcome the root would otherwise discover only
362
+ // at Vet: a tracked ID that vanished from every touched file without being
363
+ // listed in retired_ids, a new tasks.md graph problem, or a budgeted artifact
364
+ // that grew into material overshoot without a `Budget override:` line.
365
+ func checkBundleResult(targets []*bundleTarget, retired map[string]bool) []string {
366
+ var problems []string
367
+ beforeIDs, afterIDs := map[string]bool{}, map[string]bool{}
368
+ for _, target := range targets {
369
+ for _, id := range trackedIDRE.FindAllString(string(target.before), -1) {
370
+ beforeIDs[id] = true
371
+ }
372
+ for _, id := range trackedIDRE.FindAllString(string(target.after), -1) {
373
+ afterIDs[id] = true
374
+ }
375
+ }
376
+ var lost []string
377
+ for id := range beforeIDs {
378
+ if !afterIDs[id] && !retired[id] {
379
+ lost = append(lost, id)
380
+ }
381
+ }
382
+ if len(lost) > 0 {
383
+ slices.Sort(lost)
384
+ problems = append(problems, "IDs vanished from every touched file (list them in retired_ids if intended): "+strings.Join(lost, ", "))
385
+ }
386
+
387
+ for _, target := range targets {
388
+ name := filepath.Base(target.rel)
389
+ if name == "tasks.md" && filepath.Dir(target.rel) == "." {
390
+ old := map[string]bool{}
391
+ if target.existed {
392
+ for _, p := range state.ParseTaskGraph(target.before).Problems {
393
+ old[p] = true
394
+ }
395
+ }
396
+ for _, p := range state.ParseTaskGraph(target.after).Problems {
397
+ if !old[p] {
398
+ problems = append(problems, "tasks.md: "+p)
399
+ }
400
+ }
401
+ }
402
+ if filepath.Dir(target.rel) == "." && currentStatePlanning[name] &&
403
+ len(datedHeadingRE.FindAllIndex(target.after, -1)) > len(datedHeadingRE.FindAllIndex(target.before, -1)) {
404
+ problems = append(problems, target.rel+": adds a dated delta section; rewrite the owning clause in place (rationale to decisions.md, closed narrative to history/)")
405
+ }
406
+ if filepath.Dir(target.rel) != "." || !state.ArtifactBudgetGateApplies(name) {
407
+ continue
408
+ }
409
+ after, _ := state.ArtifactBudget(name, target.after)
410
+ before, _ := state.ArtifactBudget(name, target.before)
411
+ grew := !target.existed || after.Lines > before.Lines || after.Bytes > before.Bytes
412
+ if after.Material && !after.Override && grew {
413
+ problems = append(problems, fmt.Sprintf("%s: %d lines / %d bytes is a material overshoot of its %d-line budget; split or relocate instead of growing it",
414
+ target.rel, after.Lines, after.Bytes, after.LineBudget))
415
+ }
416
+ }
417
+ return problems
418
+ }
419
+
420
+ // writeBundleTargets writes every target atomically and, if one write fails,
421
+ // restores the files already written so the workspace never holds half a
422
+ // bundle.
423
+ func writeBundleTargets(ws *os.Root, targets []*bundleTarget) error {
424
+ for i, target := range targets {
425
+ if err := fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(target.rel), target.after, 0o644); err != nil {
426
+ for _, done := range targets[:i] {
427
+ if done.existed {
428
+ _ = fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(done.rel), done.before, 0o644)
429
+ } else {
430
+ _ = ws.Remove(filepath.FromSlash(done.rel))
431
+ }
432
+ }
433
+ return fmt.Errorf("write %s: %w (earlier files restored)", target.rel, err)
434
+ }
435
+ }
436
+ return nil
437
+ }
438
+
439
+ func readBundleFile(ws *os.Root, rel string) ([]byte, bool, error) {
440
+ info, err := ws.Lstat(filepath.FromSlash(rel))
441
+ if errors.Is(err, fs.ErrNotExist) {
442
+ return nil, false, nil
443
+ }
444
+ if err != nil {
445
+ return nil, false, err
446
+ }
447
+ if !info.Mode().IsRegular() {
448
+ return nil, false, errors.New("not a regular file")
449
+ }
450
+ if info.Size() > state.MaxArtifactBytes {
451
+ return nil, false, fmt.Errorf("exceeds the %d-byte artifact cap", state.MaxArtifactBytes)
452
+ }
453
+ raw, err := ws.ReadFile(filepath.FromSlash(rel))
454
+ return raw, true, err
455
+ }
456
+
457
+ func sha256Hex(raw []byte) string {
458
+ sum := sha256.Sum256(raw)
459
+ return hex.EncodeToString(sum[:])
460
+ }
461
+
462
+ func shortHash(raw []byte, exists bool) string {
463
+ if !exists {
464
+ return "(new)"
465
+ }
466
+ return abbrev(sha256Hex(raw))
467
+ }
468
+
469
+ func abbrev(hash string) string {
470
+ if len(hash) > 12 {
471
+ return hash[:12]
472
+ }
473
+ return hash
474
+ }
@@ -55,6 +55,9 @@ type claimRecord struct {
55
55
  // so a later session can prove that holder exited (see holderProbe).
56
56
  Host string `json:"host,omitempty"`
57
57
  Anchors []claimProc `json:"anchors,omitempty"`
58
+ // Holder is the anchor that is the claiming agent session itself; its
59
+ // exit releases the claim even while the shell that launched it lives.
60
+ Holder *claimProc `json:"holder,omitempty"`
58
61
  }
59
62
 
60
63
  // RunClaim manages the advisory file-claim ledger.
@@ -318,6 +321,7 @@ func claimAddLocked(path, session, reason string, ttl int, paths []string, stdou
318
321
  ExpiresAt: now.Add(time.Duration(ttl) * time.Minute).Format(time.RFC3339),
319
322
  Host: probe.host,
320
323
  Anchors: probe.anchors(now),
324
+ Holder: probe.holder(now),
321
325
  }
322
326
  if err := appendClaim(path, rec); err != nil {
323
327
  fmt.Fprintf(stderr, "claim: %v\n", err)