devrites 7.2.7 → 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 (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -1
  3. package/docs/command-map.md +4 -0
  4. package/docs/engine/commands.md +22 -0
  5. package/docs/orchestration.md +9 -0
  6. package/engine/internal/acceptance/cli.go +9 -5
  7. package/engine/internal/acceptance/run.go +97 -38
  8. package/engine/internal/devritespaths/paths.go +7 -2
  9. package/engine/internal/install/apply.go +5 -0
  10. package/engine/internal/install/merge.go +48 -6
  11. package/engine/internal/install/preflight.go +21 -3
  12. package/engine/internal/install/uninstall.go +9 -10
  13. package/engine/internal/lib/apply_bundle.go +7 -1
  14. package/engine/internal/lib/closeout.go +7 -7
  15. package/engine/internal/lib/context.go +3 -2
  16. package/engine/internal/lib/dispatch.go +4 -0
  17. package/engine/internal/lib/resolve.go +10 -5
  18. package/engine/internal/lib/state_edit.go +9 -2
  19. package/engine/internal/lib/triggers.go +2 -2
  20. package/engine/internal/state/cursor.go +5 -5
  21. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +6 -2
  22. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +3 -3
  23. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +6 -2
  24. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +3 -3
  25. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +6 -2
  26. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +3 -3
  27. package/pack/generated/devin/skills/devrites-lib/reference/standards/agents.md +6 -2
  28. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +3 -3
  29. package/pack/generated/dsh/skills/devrites-lib/reference/standards/agents.md +6 -2
  30. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +3 -3
  31. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +10 -2
  32. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +3 -3
  33. package/pack/generated/pi/skills/devrites-lib/reference/standards/agents.md +6 -2
  34. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +3 -3
  35. package/package.json +1 -1
  36. package/scripts/build-host-artifacts.sh +5 -6
  37. package/scripts/omp-generate.sh +24 -14
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## [7.2.7](https://github.com/ViktorsBaikers/DevRites/compare/v7.2.6...v7.2.7) (2026-10-31)
6
14
 
7
15
  ### 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.7`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v7.2.7): 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
@@ -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`.
@@ -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
@@ -35,18 +35,12 @@ func (r *runner) uninstall() error {
35
35
  return err
36
36
  }
37
37
 
38
- // Per-path would-be content for a dry run; a nil value means the file
39
- // would be removed.
40
- dryTree := map[string][]byte{}
41
38
  for _, rel := range entries {
42
39
  merge, ok := hostpack.ManagedMergeForMarker(rel)
43
40
  if !ok {
44
41
  continue
45
42
  }
46
43
  if r.opts.DryRun {
47
- if err := dryRunStripMarker(dryTree, r.target, merge); err != nil {
48
- return err
49
- }
50
44
  fmt.Fprintf(r.opts.Stdout, " [merge-remove] %s\n", merge.DryRun)
51
45
  continue
52
46
  }
@@ -142,11 +136,16 @@ func (r *runner) uninstall() error {
142
136
  // dryRunStripMarker runs the real marker strip against an in-memory view of
143
137
  // the target (tree), so a dry run follows the same sequence as the real
144
138
  // uninstall and reports the same errors without writing anything.
145
- func dryRunStripMarker(tree map[string][]byte, target string, merge hostpack.ManagedMerge) error {
146
- if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel || merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
147
- return nil
139
+ func (r *runner) dryRunStripMarker(tree map[string][]byte, merge hostpack.ManagedMerge) error {
140
+ path := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
141
+ write := func(data []byte) error { tree[path] = data; return nil }
142
+ remove := func() error { tree[path] = nil; return nil }
143
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
144
+ return stripHooksWith(path, write, remove)
145
+ }
146
+ if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
147
+ return r.stripClaudeSettingsWith(path, write, remove)
148
148
  }
149
- path := filepath.Join(target, filepath.FromSlash(merge.TargetRel))
150
149
  data, seen := tree[path]
151
150
  if !seen {
152
151
  var err error
@@ -210,7 +210,12 @@ func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarg
210
210
  }
211
211
  pins := map[string]string{}
212
212
  for path, hash := range bundle.BaseHashes {
213
- pins[strings.TrimPrefix(filepath.ToSlash(path), prefix)] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
213
+ rel := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(filepath.ToSlash(path), prefix)))
214
+ pin := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
215
+ if previous, ok := pins[rel]; ok && previous != pin {
216
+ problems = append(problems, fmt.Sprintf("bundle %d %s: conflicting base hashes for the same file", bi+1, path))
217
+ }
218
+ pins[rel] = pin
214
219
  }
215
220
  for _, file := range bundle.CandidateFiles {
216
221
  rel := strings.TrimPrefix(filepath.ToSlash(file.Path), prefix)
@@ -219,6 +224,7 @@ func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarg
219
224
  problems = append(problems, where+": path must be a Markdown file inside the "+slug+" workspace")
220
225
  continue
221
226
  }
227
+ rel = filepath.ToSlash(filepath.Clean(rel))
222
228
  op := strings.ToLower(strings.TrimSpace(file.Operation))
223
229
  switch op {
224
230
  case "create", "replace":
@@ -62,11 +62,6 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
62
62
  archDisplay := filepath.ToSlash(filepath.Join("archive", slug))
63
63
  active := filepath.Join(dv, "ACTIVE")
64
64
 
65
- activeSlug, err := devritespaths.ActiveSlug(dv)
66
- if err != nil {
67
- fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
68
- return 1
69
- }
70
65
  // A broken symlink at the destination still counts as "already there", so use
71
66
  // Lstat rather than Stat to avoid silently overwriting it.
72
67
  if _, err := os.Lstat(arch); err == nil {
@@ -84,7 +79,7 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
84
79
  // open file, so there the lock is released just before the rename.
85
80
  code := 1
86
81
  if err := state.WithFeatureLockRelease(dv, slug, func(release func()) error {
87
- code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, activeSlug, slug, stdout, stderr)
82
+ code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
88
83
  return nil
89
84
  }); err != nil {
90
85
  fmt.Fprintf(stderr, "state close: cannot lock feature %s: %v\n", slug, err)
@@ -96,7 +91,12 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
96
91
  // closeOutLocked performs the rename and the conditional ACTIVE clear. It runs
97
92
  // under the feature lock held by CloseOut and reports an exit code rather than
98
93
  // an error, preserving CloseOut's documented exit codes.
99
- func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active, activeSlug, slug string, stdout, stderr io.Writer) int {
94
+ func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
95
+ activeSlug, err := devritespaths.PhysicalActiveSlug(filepath.Dir(active))
96
+ if err != nil {
97
+ fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
98
+ return 1
99
+ }
100
100
  // Fail closed: if the move does not happen, leave the cursor alone and do not
101
101
  // claim success: a lost audit trail must never look like a clean close-out.
102
102
  if err := os.MkdirAll(archiveDir, 0o755); err != nil {
@@ -80,7 +80,7 @@ func RunContext(root string, args []string, stdout, stderr io.Writer) int {
80
80
  }
81
81
  // A Build reviewer judges one slice; an unscoped bundle inlines whole
82
82
  // plan/tasks/architecture into every dispatch.
83
- if opts.phase == "build" && opts.role != "" && opts.role != "slice-wright" && opts.slice == "" {
83
+ if opts.phase == "build" && opts.role != "" && roleBase(opts.role) != "slice-wright" && opts.slice == "" {
84
84
  fmt.Fprintf(stderr, "context: BLOCKED: unscoped build reviewer bundle: --role %s in phase build needs --slice <SLICE-ID>\n", opts.role)
85
85
  return 3
86
86
  }
@@ -455,7 +455,7 @@ func workspaceReadSet(manifest loadsManifest, role string) ([]string, bool) {
455
455
  if role == "" {
456
456
  return manifest.Workspace, false
457
457
  }
458
- if list, ok := manifest.WorkspaceByRole[role]; ok {
458
+ if list, ok := manifest.WorkspaceByRole[roleBase(role)]; ok {
459
459
  return list, true
460
460
  }
461
461
  return manifest.Workspace, false
@@ -915,6 +915,7 @@ func parseLoadsManifest(text string) (loadsManifest, bool, error) {
915
915
  }
916
916
 
917
917
  func roleAgentPath(manifest loadsManifest, role string) string {
918
+ role = roleBase(role)
918
919
  if manifest.Agents != nil {
919
920
  if rel, ok := manifest.Agents[role]; ok {
920
921
  return rel
@@ -50,6 +50,7 @@ For a reviewer role, return --result <file> stores the return verbatim as
50
50
  packets/<wave>-<role>.return.md (never overwritten), derives the outcome
51
51
  from its Outcome/Counts head (malformed records gap), refuses an --outcome
52
52
  that disagrees, and records its Finding: keys for metrics summary.
53
+ Reviewer returns require --result or --outcome; omitting both leaves the role pending.
53
54
 
54
55
  A build wave with a wright role must name its slice(s) with --slice; a vet
55
56
  wave may (slice-bound recheck), and so may a reviewer-only wave (uncapped),
@@ -455,6 +456,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
455
456
  if r.Returned {
456
457
  return fail("role %q already returned", role)
457
458
  }
459
+ if isReviewerRole(role) && result == "" && outcome == "" {
460
+ return fail("reviewer %q needs --result <file> or --outcome <findings|no-findings|gap>", role)
461
+ }
458
462
  if outcome != "" && !slices.Contains([]string{"findings", "no-findings", "gap"}, outcome) {
459
463
  return fail("--outcome %q is not findings, no-findings, or gap", outcome)
460
464
  }
@@ -361,6 +361,10 @@ func clearAwaiting(sfile, qid string) error {
361
361
  return fmt.Errorf("read state %s: %w", sfile, err)
362
362
  }
363
363
  lines := splitLinesNoTrailing(data)
364
+ visible, err := structuralLines(data)
365
+ if err != nil {
366
+ return fmt.Errorf("read state %s: %w", sfile, err)
367
+ }
364
368
  resumePolicy, _ := state.PolicyFor(state.PhaseBuild)
365
369
  if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
366
370
  if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
@@ -373,7 +377,7 @@ func clearAwaiting(sfile, qid string) error {
373
377
  // First check whether the awaiting block references this question at all.
374
378
  inAw := false
375
379
  var awaitingLines []string
376
- for _, line := range lines {
380
+ for _, line := range visible {
377
381
  switch {
378
382
  case awaitingRe.MatchString(line):
379
383
  inAw = true
@@ -400,7 +404,8 @@ func clearAwaiting(sfile, qid string) error {
400
404
  ts := nowUTC()
401
405
  var out []string
402
406
  inAw, inLog, logAppended := false, false, false
403
- for _, line := range lines {
407
+ for i, original := range lines {
408
+ line := visible[i]
404
409
  if awaitingRe.MatchString(line) {
405
410
  inAw = true
406
411
  continue
@@ -413,7 +418,7 @@ func clearAwaiting(sfile, qid string) error {
413
418
  }
414
419
  switch {
415
420
  case logHdrRe.MatchString(line):
416
- out = append(out, line)
421
+ out = append(out, original)
417
422
  inLog = true
418
423
  continue
419
424
  case inLog && hdrSpaceRe.MatchString(line):
@@ -422,10 +427,10 @@ func clearAwaiting(sfile, qid string) error {
422
427
  logAppended = true
423
428
  }
424
429
  inLog = false
425
- out = append(out, line)
430
+ out = append(out, original)
426
431
  continue
427
432
  }
428
- out = append(out, line)
433
+ out = append(out, original)
429
434
  }
430
435
  if inLog && !logAppended {
431
436
  out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
@@ -58,10 +58,17 @@ func RunStateSet(root string, args []string, stdout, stderr io.Writer) int {
58
58
  return fail(stderr, stateSetUsage, 2)
59
59
  }
60
60
  }
61
- if !cursorKeyPattern.MatchString(key) || seen[key] {
61
+ if !cursorKeyPattern.MatchString(key) {
62
62
  return fail(stderr, fmt.Sprintf("state set: key %q is not a snake_case cursor key or repeats", key), 2)
63
63
  }
64
- seen[key] = true
64
+ if canonical, ok := state.CanonicalCursorKey(key); ok {
65
+ key = canonical
66
+ }
67
+ identity := strings.ReplaceAll(key, "_", "")
68
+ if seen[identity] {
69
+ return fail(stderr, fmt.Sprintf("state set: key %q is not a snake_case cursor key or repeats", key), 2)
70
+ }
71
+ seen[identity] = true
65
72
  changes = append(changes, change{key, value})
66
73
  }
67
74
  sfile, code := stateEditWorkspace(root, slug, "state.md", stderr)
@@ -52,7 +52,7 @@ var (
52
52
  // corpus (minus the template's AI-SPEC annex) names a model, agent, RAG, or
53
53
  // tool surface.
54
54
  func keepModelSurface(role string, facts triggerFacts) bool {
55
- return role == "security-auditor" || facts.aiSpec || strings.TrimSpace(facts.corpus) == "" ||
55
+ return roleBase(role) == "security-auditor" || facts.aiSpec || strings.TrimSpace(facts.corpus) == "" ||
56
56
  aiSignalRe.MatchString(aiAnnexRe.ReplaceAllString(facts.corpus, "$1"))
57
57
  }
58
58
 
@@ -102,7 +102,7 @@ func suggestTriggers(manifest loadsManifest, root, featureDir string, opts conte
102
102
  }
103
103
 
104
104
  func gatherTriggerFacts(root, featureDir, role string) triggerFacts {
105
- facts := triggerFacts{role: role}
105
+ facts := triggerFacts{role: roleBase(role)}
106
106
  if root != "" {
107
107
  afkPath := filepath.Join(root, "AFK")
108
108
  if info, err := os.Stat(afkPath); err == nil && info.Mode().IsRegular() {
@@ -265,7 +265,7 @@ func ConvertCursorToTable(lines []string) ([]string, bool) {
265
265
  if strings.Contains(value, "|") {
266
266
  continue
267
267
  }
268
- spelling, ok := canonicalCursorSpelling(key)
268
+ spelling, ok := CanonicalCursorKey(key)
269
269
  if !ok {
270
270
  continue
271
271
  }
@@ -278,14 +278,14 @@ func ConvertCursorToTable(lines []string) ([]string, bool) {
278
278
  }
279
279
 
280
280
  func isMigratableCursorKey(key string) bool {
281
- _, ok := canonicalCursorSpelling(key)
281
+ _, ok := CanonicalCursorKey(key)
282
282
  return ok
283
283
  }
284
284
 
285
- // canonicalCursorSpelling maps a cursor key (including aliases) to the
285
+ // CanonicalCursorKey maps a cursor key (including aliases) to the
286
286
  // canonical table-row spelling. Matching is normalization-based, so
287
- // "Next step" resolves to the "next_action" spelling.
288
- func canonicalCursorSpelling(key string) (string, bool) {
287
+ // "Next step" resolves to the "next_action" spelling. Unknown extensions return false.
288
+ func CanonicalCursorKey(key string) (string, bool) {
289
289
  normalized := normalizeCursorKey(key)
290
290
  canonicals := []string{CursorPhase, CursorStatus, CursorNextAction, CursorQuestionID,
291
291
  CursorActiveSlice, CursorAFKSlicesRemaining, CursorSequenceParent,
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. Task prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. Task prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. spawn_agent prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context --skills-root .agents/skills <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. Task prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context --skills-root .devin/skills <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. Task prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context --skills-root .dsh/skills <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,16 @@ Files own briefs; [`parallel-dispatch.md`](skill://devrites-lib/reference/parall
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ On omp, every `task` item (each `tasks[]` member) sets `agent` to the exact native profile;
49
+ use `agent: "devrites-code-reviewer"`, `agent: "devrites-test-analyst"`, or
50
+ `agent: "devrites-slice-wright"` for those roles, including rechecks and repairs.
51
+ Keep `.frontend`, `.backend`, and `.SLICE-001` on dispatch labels, not `agent`.
52
+ An omitted `agent` selects the default worker; `agent: "reviewer"` is generic. Hosts
53
+ spawn/wait/deliver. Task prose and display names do not select profiles.
54
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
55
+ `devrites-<base-role>`. Verify the host selector before recording a start or
56
+ admitting a result; a ledger role and handle alone do not prove profile identity.
57
+ Root MUST NOT advance/claim completion before admitting required results;
50
58
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
59
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
60
  role — wave or solo — builds its read-set with `devrites-engine context --skills-root .omp/skills <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](skill://devrites-lib/reference/parallel-dispatch.md#scale-limits)).
@@ -45,8 +45,12 @@ Files own briefs; [`parallel-dispatch.md`](../parallel-dispatch.md) owns rosters
45
45
  <!-- root-only -->
46
46
  ## Native invocation
47
47
 
48
- Skills name exact fresh roles, omit native fields;
49
- hosts spawn/wait/deliver. Root MUST NOT advance/claim completion before admitting required results;
48
+ Skills name exact fresh roles; hosts select matching native profiles and
49
+ spawn/wait/deliver. Task prose and display names do not select profiles.
50
+ Dispatch labels may add `devrites-` or `.lane`; the native profile stays
51
+ `devrites-<base-role>`. Verify the host selector before recording a start or
52
+ admitting a result; a ledger role and handle alone do not prove profile identity.
53
+ Root MUST NOT advance/claim completion before admitting required results;
50
54
  running/orphaned/unavailable = `gap` — no root/generic substitute.
51
55
  Where a feature workspace exists, every fresh dispatch to a named `devrites-*`
52
56
  role — wave or solo — builds its read-set with `devrites-engine context --skills-root .pi/skills <slug>
@@ -128,9 +128,9 @@ Lease: `batch_id`, `created_at`, `base_sha`, `n`,
128
128
  proof in the same window as that review. Gap or Critical/Important → `gap`;
129
129
  else proof → `green|red|gap`.
130
130
 
131
- A recheck round is one dispatch per changed surface, not one per checklist: the
132
- recheck agent carries every open checklist (code, test, doubt) for that diff and
133
- answers each by name. Merging cuts dispatches; dropping a checklist does not.
131
+ Group recheck findings by owning role and changed surface. Each exact
132
+ specialist rechecks its own fingerprints and the correction diff; code, test,
133
+ and doubt remain separate dispatches, even on the same diff.
134
134
  Reviewer fan-out does not shrink the writer batch. Reviewers queue at the
135
135
  same cap as writers (10; see
136
136
  [`parallel-dispatch.md`](../../devrites-lib/reference/parallel-dispatch.md#scale-limits)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "7.2.7",
3
+ "version": "7.2.8",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code, Codex, omp, pi, and Devin CLI",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
@@ -100,7 +100,6 @@ render_codex_skill_tree() {
100
100
  ;;
101
101
  esac
102
102
  done < <(find "$_sd" -type f)
103
- pin_context_skills_root "$_dest" .agents/skills
104
103
  }
105
104
 
106
105
  render_omp_skill_tree() {
@@ -114,18 +113,15 @@ render_omp_skill_tree() {
114
113
  case "$_r" in
115
114
  SKILL.md)
116
115
  gen_omp_skill_file "$f" "$_out"
117
- omp_rewrite_skill_refs "$_out" "$(basename "$_dest")/$_r"
118
116
  ;;
119
117
  *.md)
120
118
  gen_omp_markdown_file "$f" "$_out"
121
- omp_rewrite_skill_refs "$_out" "$(basename "$_dest")/$_r"
122
119
  ;;
123
120
  *)
124
121
  cp "$f" "$_out"
125
122
  ;;
126
123
  esac
127
124
  done < <(find "$_sd" -type f)
128
- pin_context_skills_root "$_dest" .omp/skills
129
125
  }
130
126
 
131
127
  render_pi_skill_tree() {
@@ -148,7 +144,6 @@ render_pi_skill_tree() {
148
144
  ;;
149
145
  esac
150
146
  done < <(find "$_sd" -type f)
151
- pin_context_skills_root "$_dest" .pi/skills
152
147
  }
153
148
 
154
149
  render_devin_skill_tree() {
@@ -171,7 +166,6 @@ render_devin_skill_tree() {
171
166
  ;;
172
167
  esac
173
168
  done < <(find "$_sd" -type f)
174
- pin_context_skills_root "$_dest" .devin/skills
175
169
  }
176
170
 
177
171
  mkdir -p "$OUT_ROOT/claude" "$OUT_ROOT/codex" "$OUT_ROOT/omp" "$OUT_ROOT/pi" "$OUT_ROOT/devin"
@@ -204,6 +198,7 @@ while IFS= read -r d; do
204
198
  [ "$name" = ".impeccable" ] && continue
205
199
  render_codex_skill_tree "$d" "$OUT_ROOT/codex/skills/$name"
206
200
  done < <(find "$PACK_SRC/skills" -mindepth 1 -maxdepth 1 -type d)
201
+ pin_context_skills_root "$OUT_ROOT/codex/skills" .agents/skills
207
202
 
208
203
  while IFS= read -r f; do
209
204
  name="$(basename "$f" .md)"
@@ -225,6 +220,8 @@ while IFS= read -r d; do
225
220
  ;;
226
221
  esac
227
222
  done < <(find "$PACK_SRC/skills" -mindepth 1 -maxdepth 1 -type d)
223
+ omp_rewrite_skill_refs "$OUT_ROOT/omp/skills"
224
+ pin_context_skills_root "$OUT_ROOT/omp/skills" .omp/skills
228
225
 
229
226
  while IFS= read -r f; do
230
227
  name="$(basename "$f" .md)"
@@ -248,6 +245,7 @@ while IFS= read -r d; do
248
245
  ;;
249
246
  esac
250
247
  done < <(find "$PACK_SRC/skills" -mindepth 1 -maxdepth 1 -type d)
248
+ pin_context_skills_root "$OUT_ROOT/pi/skills" .pi/skills
251
249
 
252
250
  while IFS= read -r f; do
253
251
  name="$(basename "$f" .md)"
@@ -265,6 +263,7 @@ while IFS= read -r d; do
265
263
  [ "$name" = ".impeccable" ] && continue
266
264
  render_devin_skill_tree "$d" "$OUT_ROOT/devin/skills/$name"
267
265
  done < <(find "$PACK_SRC/skills" -mindepth 1 -maxdepth 1 -type d)
266
+ pin_context_skills_root "$OUT_ROOT/devin/skills" .devin/skills
268
267
 
269
268
  while IFS= read -r f; do
270
269
  name="$(basename "$f" .md)"
@@ -44,6 +44,7 @@ gen_omp_markdown_file() {
44
44
  -e '/Rules in scope/{N;s#;\n[[:space:]]*`[^`]+` mirror on Codex##;}' \
45
45
  -e '/On Codex, use the/{N;s# On Codex, use the mirrors under\n`[^`]+`\.##;s# On Codex, use the\nmirrors under `[^`]+`\.##;s# On Codex, use the\nmirror under `[^`]+`\.##;}' \
46
46
  -e 's#Host mapping: Claude Code uses `Agent` \(`Task` is its legacy alias\); Codex uses#Host mapping: omp fresh-context dispatch uses the `task` tool (`tasks[]` batch); hosts use#g' \
47
+ -e 's#Skills name exact fresh roles; hosts select matching native profiles and#On omp, every `task` item (each `tasks[]` member) sets `agent` to the exact native profile;\nuse `agent: "devrites-code-reviewer"`, `agent: "devrites-test-analyst"`, or\n`agent: "devrites-slice-wright"` for those roles, including rechecks and repairs.\nKeep `.frontend`, `.backend`, and `.SLICE-001` on dispatch labels, not `agent`.\nAn omitted `agent` selects the default worker; `agent: "reviewer"` is generic. Hosts#' \
47
48
  -e 's#`Agent` call#`task` call#g' \
48
49
  -e 's#Claude: N concurrent Task wrights \(`acceptEdits`, cwd=worktree\)\.#omp: one `tasks[]` batch of N wrights; `task` has no `cwd` or `acceptEdits`, so each task names its absolute worktree path and the wright works only there.#g' \
49
50
  -e 's#^Claude grants only the exact wright `acceptEdits`;#omp has no permission modes: only the wright'"'"'s `tools` allowlist has `edit`/`write`, and the root stays source-read-only by instruction. Claude grants only the exact wright `acceptEdits`;#' \
@@ -62,24 +63,33 @@ gen_omp_markdown_file() {
62
63
  "$_src" > "$_out"
63
64
  }
64
65
 
65
- # Rewrite one installed omp skill file in place. $2 is its path under the skills
66
- # root (<skill>/<path>).
66
+ # Rewrite installed omp skill Markdown in place. $1 is the complete skills root.
67
67
  # - omp loads skill files as skill://<skill>/<path> and cannot climb above a
68
68
  # skill root, so each `../` link target or backtick path becomes an absolute
69
69
  # skill:// URL.
70
70
  omp_rewrite_skill_refs() {
71
- python3 - "$1" "$2" <<'PY'
72
- import posixpath, re, sys
73
- path, rel = sys.argv[1], sys.argv[2]
74
- here = posixpath.dirname(rel)
75
- def fix(m):
76
- target = posixpath.normpath(posixpath.join(here, m.group(2)))
77
- return m.group(0) if target.startswith("..") else m.group(1) + "skill://" + target
78
- with open(path, encoding="utf-8") as fh:
79
- text = fh.read()
80
- text = re.sub(r"(\]\(|`)((?:\.\./)+[A-Za-z0-9._/-]+)", fix, text)
81
- with open(path, "w", encoding="utf-8") as fh:
82
- fh.write(text)
71
+ python3 - "$1" <<'PY'
72
+ import os, posixpath, re, sys
73
+ root = sys.argv[1]
74
+ def fail(error):
75
+ raise error
76
+ for directory, _, names in os.walk(root, onerror=fail):
77
+ for name in names:
78
+ if not name.endswith(".md"):
79
+ continue
80
+ path = os.path.join(directory, name)
81
+ if os.path.islink(path):
82
+ continue
83
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
84
+ here = posixpath.dirname(rel)
85
+ def fix(m):
86
+ target = posixpath.normpath(posixpath.join(here, m.group(2)))
87
+ return m.group(0) if target.startswith("..") else m.group(1) + "skill://" + target
88
+ with open(path, encoding="utf-8") as fh:
89
+ text = fh.read()
90
+ text = re.sub(r"(\]\(|`)((?:\.\./)+[A-Za-z0-9._/-]+)", fix, text)
91
+ with open(path, "w", encoding="utf-8") as fh:
92
+ fh.write(text)
83
93
  PY
84
94
  }
85
95