@azure-id/orc 2.1.0 → 2.2.0

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 (153) hide show
  1. package/CHANGELOG.md +374 -0
  2. package/README.md +49 -36
  3. package/bin/build-agents.js +7 -5
  4. package/bin/cli.js +1006 -235
  5. package/bin/fix.js +21 -18
  6. package/bin/graph-extract.js +314 -6
  7. package/bin/graph-gain.js +83 -2
  8. package/bin/graph-map.js +23 -1
  9. package/bin/graph-notes.js +1 -1
  10. package/bin/graph-query.js +1362 -37
  11. package/bin/graph-resolve.js +1 -1
  12. package/bin/graph-shard.js +29 -6
  13. package/bin/graph-signals.js +5 -3
  14. package/bin/graph.js +7 -3
  15. package/bin/habit.js +1 -1
  16. package/bin/pricing.json +8 -1
  17. package/bin/trace-write.js +40 -11
  18. package/bin/verify-contracts.js +169 -60
  19. package/bin/verify-package.js +3 -3
  20. package/bin/webui/api.js +8 -0
  21. package/bin/webui/fixtures/extra.js +9 -9
  22. package/bin/webui/fixtures/flow.js +1 -1
  23. package/bin/webui/fixtures/index.js +2 -2
  24. package/bin/webui/fixtures/knowledge.js +8 -1
  25. package/bin/webui/fixtures/pact.js +112 -111
  26. package/bin/webui/fixtures/stats.js +2 -2
  27. package/bin/webui/i18n/en/knowledge.json +2 -0
  28. package/bin/webui/i18n/id/knowledge.json +2 -0
  29. package/bin/webui/js/panels/knowledge.js +17 -0
  30. package/guides/code-graph-benefit.md +116 -0
  31. package/guides/configuration.md +150 -0
  32. package/guides/documents.md +262 -0
  33. package/guides/extra-models.md +448 -0
  34. package/guides/habits-and-gotchas.md +152 -0
  35. package/guides/knowledge-reads.md +190 -0
  36. package/guides/model-selection.md +186 -0
  37. package/guides/rules.md +305 -0
  38. package/guides/status-line.md +344 -0
  39. package/mock-run/a-normal-day.md +2 -2
  40. package/mock-run/extra-slots.md +177 -177
  41. package/mock-run/orc-cli.md +3 -2
  42. package/mock-run/orc-doc.md +452 -448
  43. package/mock-run/orc-fast.md +2 -2
  44. package/mock-run/orc-quick.md +146 -146
  45. package/mock-run/orc.md +4 -4
  46. package/package.json +2 -1
  47. package/templates/agents/MODEL-MAPPING.md +12 -12
  48. package/templates/agents/{orc-executor-haiku-4-5.md → orc-executor-haiku-5-high.md} +21 -8
  49. package/templates/agents/orc-executor-opus-4-7-high.md +17 -5
  50. package/templates/agents/orc-executor-opus-4-7-med.md +17 -5
  51. package/templates/agents/orc-executor-opus-4-8-high.md +17 -5
  52. package/templates/agents/orc-executor-opus-5-high.md +17 -5
  53. package/templates/agents/orc-executor-opus-5-low.md +17 -5
  54. package/templates/agents/orc-executor-opus-5-med.md +17 -5
  55. package/templates/agents/orc-executor-sonnet-4-6-high.md +17 -5
  56. package/templates/agents/orc-executor-sonnet-5-high.md +17 -5
  57. package/templates/agents/orc-executor-sonnet-5-low.md +17 -5
  58. package/templates/agents/orc-executor-sonnet-5-med.md +17 -5
  59. package/templates/agents/{orc-graph-noter-sonnet-4-6-med.md → orc-graph-noter-haiku-5-high.md} +4 -4
  60. package/templates/agents/orc-planner-mini-opus-5-med.md +3 -1
  61. package/templates/agents/orc-planner-mini-sonnet-5-high.md +3 -1
  62. package/templates/agents/orc-planner-opus-5-med.md +3 -1
  63. package/templates/agents/{orc-recon-sonnet-4-6-med.md → orc-recon-sonnet-5-med.md} +4 -4
  64. package/templates/agents/{orc-trace-writer-haiku-4-5.md → orc-trace-writer-haiku-5-high.md} +4 -3
  65. package/templates/commands/orc-doc.md +128 -128
  66. package/templates/commands/orc-export.md +51 -46
  67. package/templates/commands/orc-fast.md +10 -10
  68. package/templates/hooks/README.md +8 -3
  69. package/templates/hooks/orc-effort-guard.js +105 -27
  70. package/templates/hooks/orc-graph-hook.js +252 -4
  71. package/templates/hooks/orc-statusline.js +13 -6
  72. package/templates/hooks/orc-trace.js +1 -1
  73. package/templates/skills/_shared/README.md +2 -2
  74. package/templates/skills/_shared/code-graph.md +185 -251
  75. package/templates/skills/_shared/extra-dispatch.md +4 -4
  76. package/templates/skills/_shared/lane-contract.md +7 -3
  77. package/templates/skills/_shared/opus5-only.md +3 -3
  78. package/templates/skills/_shared/phases/execution.md +13 -11
  79. package/templates/skills/_shared/phases/planning.md +27 -9
  80. package/templates/skills/_shared/phases/preflight.md +3 -3
  81. package/templates/skills/_shared/phases/review.md +4 -3
  82. package/templates/skills/_shared/phases/ship.md +7 -10
  83. package/templates/skills/_shared/phases/summary.md +2 -3
  84. package/templates/skills/_shared/phases/trace-verbs.md +21 -16
  85. package/templates/skills/_shared/phases/trace.md +3 -3
  86. package/templates/skills/_shared/phases/wiki-consult.md +2 -2
  87. package/templates/skills/_shared/pr-templates.md +108 -106
  88. package/templates/skills/_shared/read-ladder.md +8 -2
  89. package/templates/skills/_shared/return-validation.md +10 -6
  90. package/templates/skills/_shared/review-slice.md +1 -1
  91. package/templates/skills/_shared/stack-plan.md +135 -135
  92. package/templates/skills/_shared/wait.md +14 -2
  93. package/templates/skills/context-combiner/SKILL.md +2 -2
  94. package/templates/skills/orc/SKILL.md +7 -7
  95. package/templates/skills/orc/config.md +2 -2
  96. package/templates/skills/orc/examples/full-run-mock.md +7 -7
  97. package/templates/skills/orc/references/effort-and-mode.md +222 -222
  98. package/templates/skills/orc/schemas/planning-output.md +3 -0
  99. package/templates/skills/orc-aftermath/SKILL.md +15 -15
  100. package/templates/skills/orc-analyze/SKILL.md +2 -2
  101. package/templates/skills/orc-analyze-mini/SKILL.md +2 -2
  102. package/templates/skills/orc-boundary/SKILL.md +15 -15
  103. package/templates/skills/orc-boundary/references/card.md +81 -78
  104. package/templates/skills/orc-boundary/references/gate.md +113 -113
  105. package/templates/skills/orc-brainstorm/SKILL.md +15 -15
  106. package/templates/skills/orc-budget/SKILL.md +15 -15
  107. package/templates/skills/orc-challenge/README.md +1 -1
  108. package/templates/skills/orc-challenge/SKILL.md +16 -16
  109. package/templates/skills/orc-challenge/examples/council-full-roster.md +1 -1
  110. package/templates/skills/orc-claude/SKILL.md +4 -4
  111. package/templates/skills/orc-claude/examples/claude-run-mock.md +1 -1
  112. package/templates/skills/orc-diy/SKILL.md +2 -2
  113. package/templates/skills/orc-diy/references/blocks/wiki.md +26 -26
  114. package/templates/skills/orc-diy/references/flow-schema.md +2 -2
  115. package/templates/skills/orc-doc/README.md +2 -2
  116. package/templates/skills/orc-doc/SKILL.md +501 -490
  117. package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +12 -1
  118. package/templates/skills/orc-doc/references/chunking.md +6 -4
  119. package/templates/skills/orc-doc/references/gates.md +311 -311
  120. package/templates/skills/orc-doc/references/resume-protocol.md +229 -228
  121. package/templates/skills/orc-explain/SKILL.md +88 -88
  122. package/templates/skills/orc-export/SKILL.md +190 -187
  123. package/templates/skills/orc-fast/SKILL.md +17 -14
  124. package/templates/skills/orc-grill/SKILL.md +16 -16
  125. package/templates/skills/orc-grill/references/grill-doc.md +1 -1
  126. package/templates/skills/orc-handoff/SKILL.md +11 -11
  127. package/templates/skills/orc-learn/SKILL.md +13 -13
  128. package/templates/skills/orc-learn/examples/learn-run-mock.md +2 -2
  129. package/templates/skills/orc-mini/SKILL.md +12 -12
  130. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -5
  131. package/templates/skills/orc-pact/SKILL.md +15 -15
  132. package/templates/skills/orc-pact/references/gate.md +70 -70
  133. package/templates/skills/orc-pattern/SKILL.md +3 -3
  134. package/templates/skills/orc-poly/SKILL.md +4 -4
  135. package/templates/skills/orc-poly/examples/poly-run-mock.md +51 -51
  136. package/templates/skills/orc-pr-driver/SKILL.md +4 -3
  137. package/templates/skills/orc-pr-driver/references/orc-run-split.md +108 -99
  138. package/templates/skills/orc-pr-setup/SKILL.md +2 -2
  139. package/templates/skills/orc-quick/README.md +11 -12
  140. package/templates/skills/orc-quick/SKILL.md +29 -32
  141. package/templates/skills/orc-quick/references/context-doc.md +5 -5
  142. package/templates/skills/orc-quick/references/dispatch-gate.md +10 -10
  143. package/templates/skills/orc-quick/references/gh-mode.md +2 -1
  144. package/templates/skills/orc-quick/references/look.md +34 -12
  145. package/templates/skills/orc-retro/SKILL.md +2 -2
  146. package/templates/skills/orc-retro/examples/retro-mock.md +2 -2
  147. package/templates/skills/orc-route/SKILL.md +2 -2
  148. package/templates/skills/orc-test/SKILL.md +6 -3
  149. package/templates/skills/orc-verify/SKILL.md +2 -2
  150. package/templates/skills/orc-wait/SKILL.md +10 -2
  151. package/templates/skills/orc-wiki/SKILL.md +4 -4
  152. package/templates/skills/orc-wiki/references/pattern-prewarm.md +19 -19
  153. package/templates/agents/orc-executor-sonnet-4-6-med.md +0 -155
package/bin/cli.js CHANGED
@@ -244,6 +244,7 @@ function listSkillNames() {
244
244
  function nodeCmd(absPath) {
245
245
  return `node "${absPath.replace(/\\/g, "/")}"`;
246
246
  }
247
+ const TYPED_GATE_MATCHER = "orc|orc-diy"; // the skills the effort guard gates, typed (F13)
247
248
 
248
249
  // Install the ORC guard scripts and MERGE their wiring into settings.json.
249
250
  // Non-destructive: never clobbers an existing statusLine, never duplicates the
@@ -292,6 +293,7 @@ function installGuards(claudeDir) {
292
293
  );
293
294
  console.log(" Add these manually so ORC is guarded:");
294
295
  console.log(` PreToolUse (matcher \"Skill\"): ${guardCmd}`);
296
+ console.log(` UserPromptExpansion (matcher "${TYPED_GATE_MATCHER}"): ${guardCmd}`);
295
297
  console.log(` statusLine: ${statusCmd}`);
296
298
  return;
297
299
  }
@@ -319,6 +321,28 @@ function installGuards(claudeDir) {
319
321
  console.log(" upd settings.json → PreToolUse effort guard path");
320
322
  }
321
323
 
324
+ // 1b) The typed-command gate (v2.1.2, F13) — the SAME file on a second
325
+ // event. A typed /orc or /orc-diy loads the skill with no Skill tool
326
+ // call, so the entry above never sees it. The matcher is an exact list,
327
+ // so the hook starts for these two commands and for nothing else.
328
+ settings.hooks.UserPromptExpansion = settings.hooks.UserPromptExpansion || [];
329
+ let typedGated = false;
330
+ for (const entry of settings.hooks.UserPromptExpansion) {
331
+ for (const h of entry.hooks || []) {
332
+ if (typeof h.command === "string" && h.command.includes("orc-effort-guard")) {
333
+ h.command = guardCmd; // keep the path current
334
+ entry.matcher = TYPED_GATE_MATCHER; // repair a hand-edited matcher
335
+ typedGated = true;
336
+ }
337
+ }
338
+ }
339
+ if (!typedGated) {
340
+ settings.hooks.UserPromptExpansion.push({ matcher: TYPED_GATE_MATCHER, hooks: [{ type: "command", command: guardCmd }] });
341
+ console.log(" add settings.json → UserPromptExpansion effort gate (typed /orc, /orc-diy)");
342
+ } else {
343
+ console.log(" upd settings.json → UserPromptExpansion effort gate path");
344
+ }
345
+
322
346
  // 2) statusLine model warning — set ONLY if the user has none (never clobber).
323
347
  if (!settings.statusLine) {
324
348
  settings.statusLine = { type: "command", command: statusCmd };
@@ -451,6 +475,9 @@ function installGuards(claudeDir) {
451
475
  wireGraph("PreToolUse", "Grep|Glob");
452
476
  wireGraph("PreToolUse", "Bash|Read");
453
477
  wireGraph("PostToolUse", "Read");
478
+ // R13 (v2.2.0): an executor returning to the main session gets the
479
+ // touchpoint-4 `changes` card. A new entry of its own; no other hook is touched.
480
+ wireGraph("PostToolUse", "Task|Agent");
454
481
 
455
482
  // 7) The session hook (v2.0.0 Q8/Q9) — TWO entries, one file. `Stop` rings
456
483
  // the terminal bell when a turn of an ORC run ends, and only under
@@ -1131,6 +1158,8 @@ const KNOWN_MODELS = [
1131
1158
  // Kept so a config written before v2.1.0 still validates.
1132
1159
  "claude-sonnet-5",
1133
1160
  "claude-sonnet-4-6",
1161
+ "claude-haiku-5-5",
1162
+ // Kept so a config written before v2.2.0 still validates.
1134
1163
  "claude-haiku-4-5",
1135
1164
  "claude-fable-5",
1136
1165
  ];
@@ -1177,7 +1206,7 @@ const vModel = tag((raw) => {
1177
1206
  // would go stale the next time a band moves. `orc-` is required so a typo lands
1178
1207
  // as a refusal rather than as a dispatch to a name nothing answers to.
1179
1208
  const vFallbackAgent = tag((raw) => {
1180
- const v = String(raw || "").trim();
1209
+ const v = resolveLegacyAgents(String(raw || "").trim());
1181
1210
  if (v === "band" || v === "ask") return { value: v };
1182
1211
  if (!/^orc-[a-z0-9-]+$/.test(v))
1183
1212
  return { err: 'must be "band", "ask", or an ORC agent name (e.g. orc-executor-opus-5-med)' };
@@ -1186,7 +1215,7 @@ const vFallbackAgent = tag((raw) => {
1186
1215
  warn:
1187
1216
  "pinned to one agent — this overrides the score table AND a slot's own pinned agent for every fallback, whatever the task. `band` is what keeps a fallback a change of WHO and not a change of tier.",
1188
1217
  };
1189
- }, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"] });
1218
+ }, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-5-high"] });
1190
1219
  // v2.0.0 W2 (DE-16) — `auto` is refused BY NAME. It is the one value a reader
1191
1220
  // of the research expects, and ORC does not have it: no habit is ever applied
1192
1221
  // without a yes, so a generic "must be one of" would read as a missing feature.
@@ -1486,11 +1515,11 @@ const CONFIG_META = [
1486
1515
  // reason: no lane reads this key. Every lane calls `orc graph … --if-enabled`
1487
1516
  // and the CLI resolves it, which is how /orc-quick takes part while its Q0
1488
1517
  // still reads `log_dir` and nothing else.
1489
- { key: "code_graph", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether code-changing lanes (orc, ultra, diy, mini, fast, quick) build and read a local code graph under .claude/orc/graph/. The structure is extracted by the CLI from git blob SHAs and costs no model tokens; a teammate's change is found and healed at the next preflight. Off means no lane reads or writes it. See `orc graph status`." },
1518
+ { key: "code_graph", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "semi", "on"), options: ["off", "semi", "on"], desc: "Whether code-changing lanes (orc, ultra, diy, mini, fast, quick) build and read a local code graph under .claude/orc/graph/. The structure is extracted by the CLI from git blob SHAs and costs no model tokens; a teammate's change is found and healed at the next preflight. Off means no lane reads or writes it. Semi keeps it built and current (preflight heal and the SubagentStop hook) but no lane reads it and nothing goes into any context. See `orc graph status`." },
1490
1519
  // v1.8.0 W7 — the graph's operating keys. All `lanes: []` for the same reason
1491
1520
  // as `code_graph`: the CLI resolves them behind `--if-enabled`. All gated by
1492
1521
  // `code_graph`, so turning the graph off makes every one of them INERT, out loud.
1493
- { key: "code_graph_notes", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vEnum("off", "wave", "end"), options: ["off", "wave", "end"], desc: "Whether a code lane pays for one-sentence notes on the functions it changed (orc-graph-noter-sonnet-4-6-med). `wave` = one batch per wave (per code-writing request in /orc-quick); `end` = one batch at the end of the run. The structure layer is free either way — notes are the only part of the graph that costs tokens. Inert while code_graph is off." },
1522
+ { key: "code_graph_notes", def: "off", tier: "common", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vEnum("off", "wave", "end"), options: ["off", "wave", "end"], desc: "Whether a code lane pays for one-sentence notes on the functions it changed (orc-graph-noter-haiku-5-high). `wave` = one batch per wave (per code-writing request in /orc-quick); `end` = one batch at the end of the run. The structure layer is free either way — notes are the only part of the graph that costs tokens. Inert while code_graph is off." },
1494
1523
  { key: "code_graph_notes_min", def: 5, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vInt(1), options: [3, 5, 10], desc: "Fewest changed functions worth a notes dispatch. Below it nothing is dispatched and the functions wait for a later batch: a subagent's fixed start-up cost (~5.7K tokens) makes a one-function batch the most expensive note there is." },
1495
1524
  { key: "code_graph_notes_cap", def: 40, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vInt(1), options: [20, 40, 80], desc: "Most functions one notes dispatch covers. The rest wait for the next batch." },
1496
1525
  { key: "code_graph_card_budget", def: 1200, tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vRange(300, 4000), options: [600, 1200, 2400], desc: "Token budget for one `orc graph ctx` card. A card never exceeds it and always says what it hid — and every card is sent again on each later turn of the agent that received it." },
@@ -1539,7 +1568,7 @@ const CONFIG_META = [
1539
1568
  // is a fake fix), a per-profile stall budget (the number describes ORC's
1540
1569
  // patience, not a provider), and a key to disable the stall report.
1541
1570
  { key: "extra_stall_s", def: 180, tier: "common", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: [], validate: vInt(0), options: [0, 60, 120, 180, 300, 600], desc: "Seconds a foreign worker may produce NOTHING before the dispatch is stopped as `stalled`. The clock is reset by observable progress — new bytes on the worker's stream, new bytes on stderr, or a declared file that changed on disk — so it never fires on a worker that is merely slow. This is what a wall clock cannot see: `extra_timeout_s` measures the whole dispatch, and an opencode that stops mid-task and waits for someone to type `continue` burns all 900 seconds looking like a timeout. A `stalled` dispatch is RETRYABLE, so `extra_resume` continues it from what is already on disk rather than starting over. 0 turns it off and the wall clock is the only stop again. Clamped below the wall clock, because a budget that can never fire is worse than none. Engine `cli` only — engine `api` already has a per-request inactivity timeout on its own socket." },
1542
- { key: "extra_fallback_agent", def: "band", tier: "common", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick", "orc-wiki"], validate: vFallbackAgent, options: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"], desc: "WHICH Claude agent picks up a task the foreign worker could not finish. `band` is the pre-v0.56.1 behaviour and stays the default: the exact agent the score table or the slot would have used, so a fallback changes WHO runs it and nothing else. `ask` STOPS and puts the choice to you with the failure and the position already on the table — right when a stall has just cost you fifteen minutes and you would rather pick than accept a default. Any installed agent name is accepted verbatim, for the case where you already know a stalled slice wants more (or less) than its band. It never changes the score, never widens `declared_files` and never moves `acceptance[]` — a fallback is not a re-plan." },
1571
+ { key: "extra_fallback_agent", def: "band", tier: "common", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick", "orc-wiki"], validate: vFallbackAgent, options: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-5-high"], desc: "WHICH Claude agent picks up a task the foreign worker could not finish. `band` is the pre-v0.56.1 behaviour and stays the default: the exact agent the score table or the slot would have used, so a fallback changes WHO runs it and nothing else. `ask` STOPS and puts the choice to you with the failure and the position already on the table — right when a stall has just cost you fifteen minutes and you would rather pick than accept a default. Any installed agent name is accepted verbatim, for the case where you already know a stalled slice wants more (or less) than its band. It never changes the score, never widens `declared_files` and never moves `acceptance[]` — a fallback is not a re-plan." },
1543
1572
  { key: "extra_resume_max", def: 2, tier: "advanced", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: [], validate: vInt(0), desc: "Resume attempts per task before the fallback procedure takes over. Bounded like `tdd_loop_max`: hitting the cap STOPS with an honest report naming the Claude agent, never a silent third loop. A resume never widens `declared_files`, never moves `acceptance[]` and never moves the score — it is a continuation, not a discount." },
1544
1573
  // --- v1.0.0 W5 — the stall DEMOTION. THIRTEEN keys became FIFTEEN, and both
1545
1574
  // additions are numbers with a documented off value rather than a switch.
@@ -1642,6 +1671,36 @@ const LEGACY_KEYS = {
1642
1671
  opus5_executor_only: "opus5_only", // v0.36.0 — widened from executors to every role
1643
1672
  };
1644
1673
 
1674
+ // v2.2.0 — the agent RENAMES. An agent's model change is always a rename, so a
1675
+ // config or flow written before 2.2.0 can still name an agent that no longer
1676
+ // ships (`rubric_bands_override`, `orc diy` `fixed_executor`,
1677
+ // `extra_fallback_agent`). Every place that READS an agent name from a user
1678
+ // file resolves it through this map: the old name becomes the new agent, with
1679
+ // ONE notice line on stderr, never a crash.
1680
+ const LEGACY_AGENTS = {
1681
+ "orc-executor-haiku-4-5": "orc-executor-haiku-5-high",
1682
+ "orc-executor-sonnet-4-6-med": "orc-executor-haiku-5-high",
1683
+ "orc-trace-writer-haiku-4-5": "orc-trace-writer-haiku-5-high",
1684
+ "orc-graph-noter-sonnet-4-6-med": "orc-graph-noter-haiku-5-high",
1685
+ };
1686
+ const LEGACY_AGENT_RE = new RegExp(`(?<![\\w-])(${Object.keys(LEGACY_AGENTS).join("|")})(?![\\w-])`, "g");
1687
+ const legacyAgentNoticed = new Set();
1688
+ function legacyAgentNotice(old) {
1689
+ if (legacyAgentNoticed.has(old)) return;
1690
+ legacyAgentNoticed.add(old);
1691
+ process.stderr.write(`agent ${old} is now ${LEGACY_AGENTS[old]} (2.2.0)\n`);
1692
+ }
1693
+ // One name, or a text that holds names (a hand-written block such as
1694
+ // rubric_bands_override). Anything that is not a string comes back unchanged.
1695
+ function resolveLegacyAgents(v) {
1696
+ if (typeof v !== "string" || !v) return v;
1697
+ return v.replace(LEGACY_AGENT_RE, (old) => {
1698
+ legacyAgentNotice(old);
1699
+ return LEGACY_AGENTS[old];
1700
+ });
1701
+ }
1702
+ const LEGACY_AGENT_KEYS = new Set(["rubric_bands_override", "extra_fallback_agent", "fixed_executor"]);
1703
+
1645
1704
  // A key ORC used to have and REMOVED. Distinct from LEGACY_KEYS, which is a
1646
1705
  // RENAME and resolves to a live key: a retired key resolves to nothing, so
1647
1706
  // there is no value to carry forward and no new name to point at.
@@ -1806,7 +1865,7 @@ function readOverride(claudeDir) {
1806
1865
  for (const e of doc.entries) {
1807
1866
  if (!e.key) continue;
1808
1867
  if (e.key !== e.wrote && Object.prototype.hasOwnProperty.call(map, e.key)) continue;
1809
- map[e.key] = e.value;
1868
+ map[e.key] = LEGACY_AGENT_KEYS.has(e.key) ? resolveLegacyAgents(e.value) : e.value;
1810
1869
  }
1811
1870
  return { path: doc.path, map };
1812
1871
  }
@@ -2586,7 +2645,7 @@ function extraShadowNotice(claudeDir) {
2586
2645
  // a golden test asserts every agent named here exists in templates/agents/).
2587
2646
  const OPUS5_ONLY_ROLES = [
2588
2647
  ["mini executor", "orc-executor-sonnet-5-high", "orc-executor-opus-5-low"],
2589
- ["fast executor", "orc-executor-sonnet-4-6-high", "orc-executor-opus-5-low"],
2648
+ ["fast executor", "orc-executor-sonnet-5-med", "orc-executor-opus-5-low"],
2590
2649
  ["mini analyze", "orc-analyze-mini-sonnet-5-high", "orc-analyze-mini-opus-5-med"],
2591
2650
  ["mini plan", "orc-planner-mini-sonnet-5-high", "orc-planner-mini-opus-5-med"],
2592
2651
  ["scout", "orc-scout-sonnet-4-6-high", "orc-scout-opus-5-low"],
@@ -2624,7 +2683,7 @@ function opus5Notice(on, claudeDir) {
2624
2683
  "\n Fixed roles now dispatched instead of their defaults:\n\n" + roleRows + "\n" +
2625
2684
  "\n Already Opus 5.5, unchanged: analyst · planner · reviewer · verifier · test-author\n" +
2626
2685
  " · combiner · learn-writer · advisor · judge.\n" +
2627
- " NEVER forced: orc-trace-writer-haiku-4-5 (it transcribes a packet, no reasoning)\n" +
2686
+ " NEVER forced: orc-trace-writer-haiku-5-high (it transcribes a packet, no reasoning)\n" +
2628
2687
  " and orc-diy (its table is compile-owned — re-run `orc diy compile` to change it).\n" +
2629
2688
  "\n " + ui.mark.warn("Tier requirement — read this one:") + "\n" +
2630
2689
  " Today only the [90,100] executor band needs an Opus 5.5 main session. With this ON,\n" +
@@ -3367,13 +3426,17 @@ function laneAnnounce(lane, map, claudeDir, families) {
3367
3426
  // sat in the spine, and the spine was skipped. This line comes from the
3368
3427
  // resolver every lane already obeys and prints VERBATIM at preflight, so the
3369
3428
  // three steps are in front of the lane whatever the spine said.
3370
- const graphOn = String(Object.prototype.hasOwnProperty.call(map, "code_graph") ? map.code_graph : metaFor("code_graph").def) === "on";
3429
+ const graphModeL = String(Object.prototype.hasOwnProperty.call(map, "code_graph") ? map.code_graph : metaFor("code_graph").def).toLowerCase();
3430
+ const graphOn = graphModeL === "on";
3431
+ // v2.2.0 semi: the graph is kept current, never read — ONE line, no steps.
3432
+ if (graphModeL === "semi" && LANE_CALLS["graph-status"].lanes.includes(lane)) out.push("graph: semi — kept current, not read");
3433
+ // v2.2.0: the four touchpoints, one line each (`_shared/code-graph.md` §0).
3371
3434
  if (graphOn && LANE_CALLS["graph-status"].lanes.includes(lane))
3372
3435
  out.push(
3373
- "graph: on — use the code-graph cache: (1) now: `orc graph status --if-enabled --heal --json --brief` (builds or updates it); " +
3374
- "(2) every code-writing slice: `orc graph ctx <declared files> --if-enabled --json --brief` cards; " +
3375
- "(3) after every code change: `orc graph update --if-enabled --json --brief` (keeps it for the next run). " +
3376
- "Copy each `line` and `trace` verbatim; `--brief` drops the rows you never print"
3436
+ "graph: on (1/4) consult — print probes[\"graph-status\"].line; run `orc graph status --if-enabled --heal --json --brief` only when heal_needed; exit 3 → no other graph call",
3437
+ "graph: on (2/4) orient — once, before you choose files: the request names ONE symbol → `orc graph ctx <symbol> --anchors --focus=<words> --if-enabled --json --brief` (callers, tests, coverage AND the slice anchors in ONE call: no Grep of that name, no `coverage`, no 3/4 call); else `orc graph map --focus=<3–6 words> --budget 800 --if-enabled --json --brief`, or `orc graph ctx <names, max 5> --if-enabled --json --brief` when it names files",
3438
+ "graph: on (3/4) anchors — every dispatch that writes code: the 2/4 `anchors_card` when you ran `ctx <symbol> --anchors`; else ONE `orc graph ctx --for-slice <declared files> --focus=<words> [--refs=<files>] --anchors --if-enabled --json --brief`; the slice carries the card inline as graph_anchors",
3439
+ "graph: on (4/4) affected tests — after the code: the hook prints a `graph changes` card when the executor returns; run its tests[] first. No card → `orc graph changes --files=<actual files> --if-enabled --json --brief`. Update only when update_step is lane; no lane calls `orc graph gain`. Copy each `line` and `trace` verbatim"
3377
3440
  );
3378
3441
  // A shadowed setting must never be silent, and neither must an inert one.
3379
3442
  const shadowed = [];
@@ -3556,11 +3619,11 @@ const LANE_CALLS = {
3556
3619
  "trace-write": {
3557
3620
  cmd: "orc trace write --packet - [--json]",
3558
3621
  what: "write ONE phase packet (stdin, plain YAML or JSON) into the run's trace pair",
3559
- exits: { 0: "written", 1: "usage or unreadable input", 2: "invalid packet — unknown verb, bad ts, parse error; nothing written", 3: "trace-file state — no `.current` on a later packet, or a rename target in the way; nothing written" },
3622
+ exits: { 0: "written (`lines_written` 0 when every ASK was refused — read `ask_rejected`)", 1: "usage or unreadable input", 2: "invalid packet — unknown verb, bad ts, parse error; nothing written", 3: "trace-file state or any other write error; nothing written" },
3560
3623
  states: null,
3561
3624
  cost: "free",
3562
3625
  when: "at every phase close, in the same tool block as the next phase's first dispatch",
3563
- on_absent: "exit ≠ 0 → dispatch `orc-trace-writer-haiku-4-5` with the SAME packet (the fallback)",
3626
+ on_absent: "exit ≠ 0 → dispatch `orc-trace-writer-haiku-5-high` with the SAME packet (the fallback)",
3564
3627
  canonical: "_shared/phases/trace.md",
3565
3628
  never: "never append trace lines by hand, and never retry a refused packet with a \"now\" timestamp",
3566
3629
  lanes: ["orc", "orc-fast", "orc-mini", "orc-quick", "orc-test"],
@@ -3580,8 +3643,9 @@ const LANE_CALLS = {
3580
3643
  "wiki-status": {
3581
3644
  cmd: "orc wiki status [--json]",
3582
3645
  what: "does a wiki EXIST, and how fresh is it",
3583
- exits: { 0: "answered in every state — read `tier`" },
3584
- states: ["none", "FRESH", "AGING", "STALE"],
3646
+ // v2.1.2 F03 — `state` and `tier` are TWO fields; the old list mixed them.
3647
+ exits: { 0: "answered in every state — read `state`; when it is `registered`, read `tier` (FRESH · AGING · STALE · unknown)" },
3648
+ states: ["none", "unregistered", "corrupt", "drifted", "registered"],
3585
3649
  cost: "free",
3586
3650
  when: "once, at preflight, before any decision that needs project knowledge",
3587
3651
  on_absent: "`none` means absent; say so in ONE user line and continue — a missing wiki is never a blocker",
@@ -3590,7 +3654,9 @@ const LANE_CALLS = {
3590
3654
  // v1.9.0: /orc-mini joins. It always consulted the wiki; it now selects PAGE
3591
3655
  // PATHS for its slices instead of reading page bodies into its own context,
3592
3656
  // and the tier this command computes is what that selection rests on.
3593
- lanes: ["orc", "orc-boundary", "orc-brainstorm", "orc-fast", "orc-grill", "orc-mini", "orc-poly", "orc-quick", "orc-route", "orc-wiki"],
3657
+ // v2.1.2 F03: /orc-learn and the /orc-diy wiki gate join — both computed the
3658
+ // tier by hand, which this row's own `never` forbids.
3659
+ lanes: ["orc", "orc-boundary", "orc-brainstorm", "orc-diy", "orc-fast", "orc-grill", "orc-learn", "orc-mini", "orc-poly", "orc-quick", "orc-route", "orc-wiki"],
3594
3660
  },
3595
3661
  "pattern-status": {
3596
3662
  cmd: "orc pattern status <lang>",
@@ -3642,7 +3708,7 @@ const LANE_CALLS = {
3642
3708
  "review-policy": {
3643
3709
  cmd: "orc review policy [--json]",
3644
3710
  what: "does the project's CLAUDE.md / AGENTS.md name its own review — the file, the line, the quote and the name",
3645
- exits: { 0: "answered in every state — read `policy` (orc | project)" },
3711
+ exits: { 0: "answered in every state — read `policy` (orc | project)", 1: "usage" },
3646
3712
  states: ["orc", "project"],
3647
3713
  cost: "free",
3648
3714
  when: "at preflight, through the `review-policy` probe of `orc lane config`",
@@ -3659,7 +3725,7 @@ const LANE_CALLS = {
3659
3725
  exits: { 0: "FRESH (after a heal, too)", 1: "NONE — no index and it could not be built, or unavailable (not a git repository)", 2: "DRIFTED — and it could not be healed (locked, or `code_graph_auto_update: false`)", 3: "OFF — `code_graph` is off" },
3660
3726
  states: ["fresh", "none", "drifted", "off", "unavailable"],
3661
3727
  cost: "free",
3662
- when: "once, at preflight, in a code-changing lane — BEFORE the first dispatch",
3728
+ when: "touchpoint 1 (consult), at preflight BEFORE the first dispatch: print `probes[\"graph-status\"].line` from `orc lane config`; run this call ONLY when that probe says `heal_needed`. Exit 3 (off or semi) → no other graph call this run",
3663
3729
  on_absent: "exit 3 is an ANSWER — print `graph: off` once and make no other graph call; any other exit never blocks: print its `line`, copy its `trace`, continue",
3664
3730
  canonical: "_shared/code-graph.md",
3665
3731
  never: "never read `code_graph` from the config yourself — `--if-enabled` is how a lane takes part without reading a key",
@@ -3671,7 +3737,7 @@ const LANE_CALLS = {
3671
3737
  exits: { 0: "built, updated or unchanged", 1: "unavailable — not a git repository, or another update holds the lock", 3: "off — `code_graph` is off" },
3672
3738
  states: ["built", "updated", "unchanged", "unavailable", "off"],
3673
3739
  cost: "free",
3674
- when: "at preflight when status was 1 or 2; after every wave's worktree audit; after a green smoke gate; after a code-writing /orc-quick request; at ship. v1.8.0 EW3 adds two paths that need no lane step: a READ heals what it is about to answer for, and the installed graph hook updates once when an ORC executor finishes",
3740
+ when: "only when `update_step` is `lane` (`probes[\"graph-status\"].update_step`; hooks off, or notes on): after every wave's worktree audit; after a green smoke gate; after a code-writing /orc-quick request; at ship. `hook`, `preflight` or `none` → no update call: the graph hook updates when an ORC executor finishes, and a READ heals what it is about to answer for",
3675
3741
  on_absent: "exit 1 never blocks a phase — say it once and continue; the next preflight heals it",
3676
3742
  canonical: "_shared/code-graph.md",
3677
3743
  never: "never run it from a WATCHER or a background process — a continuous rebuild is how graph tools freeze a machine. A one-shot update at a discrete event (an executor finishing, a read that found its own target stale) is not that: it takes the same lock, it is bounded by `code_graph_heal_ms`, and a second one that finds the lock held SKIPS rather than queues",
@@ -3683,7 +3749,7 @@ const LANE_CALLS = {
3683
3749
  exits: { 0: "found (at least one target)", 1: "no graph index", 3: "off — `code_graph` is off", 4: "not found or ambiguous — nearest names or every candidate listed" },
3684
3750
  states: null,
3685
3751
  cost: "free",
3686
- when: "at slice build (the declared files, one call), in /orc-quick's Q1 look, and by the executor itself — the read ladder's step 0",
3752
+ when: "touchpoint 3 (anchors): ONE `ctx --for-slice <declared files> --focus=<words> [--refs=<files>] --anchors` per dispatch that writes code — the slice carries its `card` inline as `graph_anchors`; a request that names ONE symbol: `ctx <symbol> --anchors --focus=<words>` is orient + anchors in one call (its `anchors_card` is the `graph_anchors`); touchpoint 2 (orient) when the request names files or symbols (max 5); and by the executor itself — the read ladder's step 0",
3687
3753
  on_absent: "exit 4 → back to read ladder step 1; an unknown symbol is never a reason to guess",
3688
3754
  canonical: "_shared/code-graph.md",
3689
3755
  never: "never act on behaviour from a card alone — read the RANGE it names; a card whose header says CHANGED is hints only",
@@ -3717,29 +3783,19 @@ const LANE_CALLS = {
3717
3783
  },
3718
3784
  // EW5 — the cheap signals. All three read what is already on disk (the index,
3719
3785
  // and git's own history), so all three cost zero model tokens.
3720
- "graph-coverage": {
3721
- cmd: "orc graph coverage <file…> --if-enabled [--json]",
3722
- what: "how much of each file the extractor actually saw — `full` · `partial` with the line ranges it did not finish · `skipped:<reason>` · `excluded`",
3723
- exits: { 0: "answered — ALWAYS, when a graph exists; a gap is an answer, never an error", 1: "no graph index", 3: "off — `code_graph` is off" },
3724
- states: null,
3725
- cost: "free",
3726
- when: "before you trust a card's SILENCE about a file — one batch call for every file in the slice",
3727
- on_absent: "exit 1 or 3 → read the source, exactly as before the graph existed",
3728
- canonical: "_shared/code-graph.md",
3729
- never: "never read `no recorded gap` as proof of completeness — a file marked `full` was fully PARSED, not fully understood",
3730
- lanes: ["orc", "orc-diy", "orc-quick"],
3731
- },
3786
+ // v2.2.0: `graph-coverage` left the catalogue — only /orc-quick still names
3787
+ // it, and a call one lane makes belongs to that lane.
3732
3788
  "graph-changes": {
3733
3789
  cmd: "orc graph changes [--base <ref>] --if-enabled [--json]",
3734
3790
  what: "the symbols THIS diff's hunks overlap, each with its callers, its tests, and a risk word that carries its own reason",
3735
3791
  exits: { 0: "answered (an empty answer is an answer)", 1: "no graph index, or not a git repository", 3: "off — `code_graph` is off" },
3736
3792
  states: null,
3737
3793
  cost: "free",
3738
- when: "at review, instead of `impact` on the declared files — and in a verify or ship summary",
3794
+ when: "touchpoint 4 (affected tests): after the code, before the suite — `--files=<actual files>`; print `tests_line`, run `tests[]` first, then the suite, then print `blast_line`. At review, instead of `impact` on the declared files",
3739
3795
  on_absent: "exit 1 or 3 → review from the diff alone, exactly as before",
3740
3796
  canonical: "_shared/code-graph.md",
3741
3797
  never: "never report `risk` without the `why` beside it — a rating nobody can check is a rating nobody should act on",
3742
- lanes: ["orc", "orc-diy", "orc-mini", "orc-quick"],
3798
+ lanes: ["orc", "orc-diy", "orc-fast", "orc-mini", "orc-quick"],
3743
3799
  },
3744
3800
  "graph-cochange": {
3745
3801
  cmd: "orc graph cochange <file> --if-enabled [--json]",
@@ -3776,17 +3832,17 @@ const LANE_CALLS = {
3776
3832
  exits: { 0: "a line", 1: "no ledger, or no rows yet", 3: "off" },
3777
3833
  states: null,
3778
3834
  cost: "free",
3779
- when: "ONCE, at the close of a code-writing request (quick) or after a green smoke gate (mini)",
3835
+ when: "by hand — a USER command (the Code graph tab reads the same ledger). No lane calls it",
3780
3836
  on_absent: "exit 1 is an ANSWER — there is nothing to report yet, so report nothing",
3781
3837
  canonical: "_shared/code-graph.md",
3782
3838
  never: "never restate the line as a saving, never drop the word estimate or the range, and never let this call block or fail a run",
3783
- lanes: ["orc-mini", "orc-quick"],
3839
+ lanes: [],
3784
3840
  },
3785
3841
  "graph-notes-pending": {
3786
3842
  cmd: "orc graph notes pending --files <a,b> [--at wave|end] --if-enabled [--json]",
3787
3843
  what: "which changed functions have no note for their current body — the input to one notes batch",
3788
3844
  exits: { 0: "rows to note — dispatch the noter", 1: "no graph index, or no --files", 3: "off — `code_graph` or `code_graph_notes` is off", 5: "none pending, fewer than `code_graph_notes_min`, or deferred to the other `--at` site — dispatch nothing" },
3789
- states: ["pending", "below-min", "none", "deferred", "off"],
3845
+ states: ["pending", "below-min", "none", "deferred", "off", "usage"],
3790
3846
  cost: "free",
3791
3847
  when: "after the update that follows a wave, a green smoke gate or a code-writing request — only while notes are on",
3792
3848
  on_absent: "exit 5 is an ANSWER — the symbols wait for a later batch and nothing is lost",
@@ -3796,24 +3852,26 @@ const LANE_CALLS = {
3796
3852
  // both stopped making a SECOND call for them: each now runs
3797
3853
  // `update --notes-pending`, which answers the update and the pending batch
3798
3854
  // in one, and still dispatches the noter when the answer has rows.
3799
- lanes: ["orc", "orc-diy", "orc-fast"],
3855
+ // v2.2.0: no lane names it any more — every lane asks for the batch
3856
+ // INSIDE `update --notes-pending`; the noter agent makes this call itself.
3857
+ lanes: [],
3800
3858
  },
3801
3859
  "gotcha-list": {
3802
3860
  cmd: "orc gotcha list [--archived] [--json]",
3803
3861
  what: "the live repair-memory entries, or the archived tail",
3804
- exits: { 0: "answered" },
3862
+ exits: { 0: "entries listed", 1: "the ledger is empty — an ANSWER" },
3805
3863
  states: null,
3806
3864
  cost: "free",
3807
3865
  when: "on demand, when the user asks what this project has already gotten wrong",
3808
- on_absent: "an empty list is an ANSWER",
3866
+ on_absent: "exit 1 is an ANSWER — inject nothing",
3809
3867
  canonical: "_shared/gotchas.md",
3810
3868
  never: "never write the ledger from a lane — a gotcha is recorded only on a red → green repair",
3811
3869
  lanes: ["orc-boundary", "orc-brainstorm", "orc-grill"],
3812
3870
  },
3813
3871
  "extra-resolve": {
3814
- cmd: "orc extra resolve <score> [--json]",
3872
+ cmd: "orc extra resolve <score> | --slot <slot> [--json]",
3815
3873
  what: "does this SCORE route to a non-Claude worker, and what is the Claude answer it displaced",
3816
- exits: { 0: "extra", 1: "claude" },
3874
+ exits: { 0: "extra", 1: "claude — or a bad score (read `reason`)", 2: "unknown slot, or both a score and a slot" },
3817
3875
  states: null,
3818
3876
  cost: "free",
3819
3877
  when: "per scored task, before dispatch",
@@ -3823,9 +3881,9 @@ const LANE_CALLS = {
3823
3881
  lanes: ["orc", "orc-diy", "orc-doc", "orc-fast", "orc-quick", "orc-test", "orc-wiki"],
3824
3882
  },
3825
3883
  "extra-role": {
3826
- cmd: "orc extra role list|set|clear [--json]",
3884
+ cmd: "orc extra role list|show|set|rm [--json]",
3827
3885
  what: "which fixed POSITION is held by a non-Claude worker",
3828
- exits: { 0: "answered" },
3886
+ exits: { 0: "answered — list: at least one slot routes", 1: "list: no slot routes (an ANSWER) · show/set/rm: refused, read `reason`", 2: "unknown slot" },
3829
3887
  states: null,
3830
3888
  cost: "free",
3831
3889
  when: "before dispatching a role that has no score",
@@ -3835,9 +3893,10 @@ const LANE_CALLS = {
3835
3893
  lanes: ["orc-diy", "orc-doc", "orc-fast", "orc-quick", "orc-wiki"],
3836
3894
  },
3837
3895
  "extra-dispatch": {
3838
- cmd: "orc extra dispatch --slice <f> (--score N | --slot S) [--json]",
3896
+ // The score or the slot is a FIELD of the slice, never a flag.
3897
+ cmd: "orc extra dispatch --task <slice.json> [--json]",
3839
3898
  what: "send ONE task to a non-Claude worker and get a validated return",
3840
- exits: { 0: "dispatched (read `outcome`)", 1: "refused — read `reason`" },
3899
+ exits: { 0: "done", 1: "failed — read `reason`; run `orc extra reconcile <task>` first", 2: "bad slice or usage — nothing was sent", 3: "not routed, or held by the concurrency cap — dispatch the pinned Claude agent BY NAME", 4: "partial — reconcile before anything else" },
3841
3900
  states: null,
3842
3901
  cost: "paid-per-task",
3843
3902
  when: "per task, only after `extra resolve` or `extra role` said extra",
@@ -3861,7 +3920,7 @@ const LANE_CALLS = {
3861
3920
  "extra-stats": {
3862
3921
  cmd: "orc extra stats [--since <d>] [--json]",
3863
3922
  what: "what the foreign dispatches actually cost, per profile",
3864
- exits: { 0: "answered" },
3923
+ exits: { 0: "answered", 1: "zero dispatches in the window — an ANSWER" },
3865
3924
  states: null,
3866
3925
  cost: "free",
3867
3926
  when: "on demand, and at the end of a run that dispatched foreign",
@@ -3873,7 +3932,7 @@ const LANE_CALLS = {
3873
3932
  "extra-rates": {
3874
3933
  cmd: "orc extra rates [--json]",
3875
3934
  what: "the dated price table for foreign models, and what is missing from it",
3876
- exits: { 0: "answered" },
3935
+ exits: { 0: "every model is priced", 1: "a model has no rate — it reads as an EM DASH, never zero" },
3877
3936
  states: null,
3878
3937
  cost: "free",
3879
3938
  when: "before quoting any dollar figure for a foreign dispatch",
@@ -3900,7 +3959,7 @@ const LANE_CALLS = {
3900
3959
  exits: { 0: "compiled", 1: "refused" },
3901
3960
  states: null,
3902
3961
  cost: "free",
3903
- when: "only when the user asks, or after `diy status` reports STALE",
3962
+ when: "only on `/orc-diy compile` — the user's explicit ask. A STALE status is REPORTED with this as the fix; the lane never runs it",
3904
3963
  on_absent: "a refusal names its reason; never stitch a flow in-session",
3905
3964
  canonical: null,
3906
3965
  never: "never edit FLOW-COMPILED.md or flow.lock.json by hand, and never patch a flow conversationally",
@@ -3909,7 +3968,7 @@ const LANE_CALLS = {
3909
3968
  "wiki-sync": {
3910
3969
  cmd: "orc wiki sync [--check]",
3911
3970
  what: "re-derive wiki/INDEX.md and wiki-meta.json from the doc headers",
3912
- exits: { 0: "in sync", 1: "--check found drift" },
3971
+ exits: { 0: "in sync", 1: "no wiki docs, or (--check) drift or a crosslink alarm" },
3913
3972
  states: null,
3914
3973
  cost: "free",
3915
3974
  when: "after every scan-task, at every pause, and at Phase 3",
@@ -3921,8 +3980,8 @@ const LANE_CALLS = {
3921
3980
  "wiki-impact": {
3922
3981
  cmd: "orc wiki impact [--json]",
3923
3982
  what: "does the wiki need a DELTA refresh or a FULL one",
3924
- exits: { 0: "fresh", 1: "delta", 2: "full recommended", 3: "structural" },
3925
- states: null,
3983
+ exits: { 0: "CLEAN — the wiki still covers HEAD", 1: "cannot compute — no wiki, not registered, no scan_commit, or git failed (read `reason`)", 2: "DELTA — refresh only the touched docs", 3: "FULL recommended — read `reasons` (affected %, STRUCTURAL, aging); advisory, the user decides" },
3984
+ states: ["CLEAN", "DELTA", "FULL", "unavailable"],
3926
3985
  cost: "free",
3927
3986
  when: "before any refresh, to choose its shape",
3928
3987
  on_absent: "delta is the DEFAULT; a full refresh is recommended, never silent",
@@ -3933,7 +3992,7 @@ const LANE_CALLS = {
3933
3992
  "wiki-debt": {
3934
3993
  cmd: "orc wiki debt [--json]",
3935
3994
  what: "what the wiki is missing, as a report",
3936
- exits: { 0: "answered" },
3995
+ exits: { 0: "no debt", 1: "debt pending — read `rows`", 3: "no wiki, or not registered" },
3937
3996
  states: null,
3938
3997
  cost: "free",
3939
3998
  when: "on demand",
@@ -3945,7 +4004,7 @@ const LANE_CALLS = {
3945
4004
  "wiki-usage": {
3946
4005
  cmd: "orc wiki usage [--json]",
3947
4006
  what: "which wiki docs are actually being read",
3948
- exits: { 0: "answered" },
4007
+ exits: { 0: "answered", 1: "no trace recorded a consult yet — `used: null`, never zero" },
3949
4008
  states: null,
3950
4009
  cost: "free",
3951
4010
  when: "when planning a refresh or a retirement",
@@ -3969,7 +4028,7 @@ const LANE_CALLS = {
3969
4028
  "challenge-status": {
3970
4029
  cmd: "orc challenge status <slug> [--json]",
3971
4030
  what: "where a challenge cycle stands right now",
3972
- exits: { 0: "answered" },
4031
+ exits: { 0: "passed, or nothing blocking", 1: "blocking findings, or STALE-PASS", 2: "a P0, or the ledger was TAMPERED", 3: "no such cycle, or unreadable" },
3973
4032
  states: null,
3974
4033
  cost: "free",
3975
4034
  when: "at entry, and after any record",
@@ -3979,9 +4038,9 @@ const LANE_CALLS = {
3979
4038
  lanes: ["orc-analyze", "orc-challenge"],
3980
4039
  },
3981
4040
  "challenge-init": {
3982
- cmd: "orc challenge init <slug> --goal … --audience … --done-means … --council …",
4041
+ cmd: "orc challenge init <slug> --artifact <path> (--template <path> | --no-template) --goal … --audience … --done-means … --council …",
3983
4042
  what: "freeze the goal, the audience, the finish line and the council roster",
3984
- exits: { 0: "created", 1: "refused — a required answer is missing, BY NAME" },
4043
+ exits: { 0: "created", 2: "refused — a required answer is missing, BY NAME" },
3985
4044
  states: null,
3986
4045
  cost: "free",
3987
4046
  when: "once per artifact, before any judging",
@@ -3993,7 +4052,7 @@ const LANE_CALLS = {
3993
4052
  "challenge-report": {
3994
4053
  cmd: "orc challenge report <slug> [--json]",
3995
4054
  what: "the findings and the fix brief for a cycle",
3996
- exits: { 0: "answered" },
4055
+ exits: { 0: "answered", 3: "no such cycle" },
3997
4056
  states: null,
3998
4057
  cost: "free",
3999
4058
  when: "after a verdict is recorded",
@@ -4005,7 +4064,7 @@ const LANE_CALLS = {
4005
4064
  "pact-status": {
4006
4065
  cmd: "orc pact status [--json]",
4007
4066
  what: "which promises this project makes, and which are in doubt",
4008
- exits: { 0: "answered" },
4067
+ exits: { 0: "every promise HOLDING (UNCHECKABLE never raises the code)", 1: "a promise DRIFTED", 2: "a promise is BROKEN", 3: "no ledger yet — a first run (an ANSWER)" },
4009
4068
  states: ["HOLDING", "DRIFTED", "UNCHECKABLE", "BROKEN"],
4010
4069
  cost: "free",
4011
4070
  when: "at preflight when the run will touch an anchored area",
@@ -4015,9 +4074,9 @@ const LANE_CALLS = {
4015
4074
  lanes: ["orc", "orc-aftermath", "orc-pact"],
4016
4075
  },
4017
4076
  "boundary-status": {
4018
- cmd: "orc boundary status [--area <a>] [--json]",
4077
+ cmd: "orc boundary status [<area path>] [--json]",
4019
4078
  what: "may ORC attempt this area itself",
4020
- exits: { 0: "answered" },
4079
+ exits: { 0: "every carded area EXECUTE", 1: "an area ESCALATES", 2: "an area REFUSES", 3: "no card, a malformed card, or every card stale — UNKNOWN, never safe" },
4021
4080
  states: ["EXECUTE", "ESCALATE", "REFUSE"],
4022
4081
  cost: "free",
4023
4082
  when: "at preflight, per area the run will touch",
@@ -4029,8 +4088,8 @@ const LANE_CALLS = {
4029
4088
  "aftermath-status": {
4030
4089
  cmd: "orc aftermath status [--json]",
4031
4090
  what: "did what we shipped hold up",
4032
- exits: { 0: "answered" },
4033
- states: null,
4091
+ exits: { 0: "nothing came back", 1: "CHURN on a graded run", 2: "a run was REVERTED", 3: "not a git work tree, or too shallow to grade (an ANSWER)" },
4092
+ states: ["HELD", "CHURN", "REVERTED", "TOO_RECENT", "SHALLOW"],
4034
4093
  cost: "free",
4035
4094
  when: "at preflight, ONLY when there is a real churn signal in the area about to be touched",
4036
4095
  on_absent: "a run under 7 days old is TOO_RECENT and KEEPS ITS SLOT — an answer, not a gap",
@@ -4039,21 +4098,27 @@ const LANE_CALLS = {
4039
4098
  lanes: ["orc", "orc-aftermath"],
4040
4099
  },
4041
4100
  "budget-forecast": {
4042
- cmd: "orc budget forecast --plan <f> [--json]",
4101
+ // v2.1.2 F16 — the plan is a POSITIONAL, and exit 1 is an ANSWER here.
4102
+ cmd: "orc budget forecast <plan-file> [--json] [--naive] [--as tokens|usd|quota|context|all]",
4043
4103
  what: "what will this PLAN cost, as a range with a sample count",
4044
- exits: { 0: "answered", 1: "refused — no history to forecast from" },
4104
+ exits: {
4105
+ 0: "answered — every band has budget_min_samples or more",
4106
+ 1: "answered — a LOW-CONFIDENCE band (fewer samples than budget_min_samples): the top of the range is soft",
4107
+ 2: "answered — a task forecasts above 90% of its model's context window",
4108
+ 3: "refused — no plan file, no `- id:` task blocks, or no history to forecast from",
4109
+ },
4045
4110
  states: null,
4046
4111
  cost: "free",
4047
4112
  when: "after planning, before the first wave",
4048
- on_absent: "no history → it REFUSES; `--naive` is the price-table floor, and a refusal is still shown once",
4113
+ on_absent: "no history → it REFUSES with exit 3; `--naive` is the price-table floor, and a refusal is still shown once",
4049
4114
  canonical: null,
4050
4115
  never: "never quote dollars without a dated price table, a quota without a known plan, or a figure without its sample count",
4051
4116
  lanes: ["orc-budget", "orc-route"],
4052
4117
  },
4053
4118
  "budget-actual": {
4054
- cmd: "orc budget actual [--since <d>] [--json]",
4119
+ cmd: "orc budget actual <run-slug> [--json]",
4055
4120
  what: "what a run actually cost",
4056
- exits: { 0: "answered" },
4121
+ exits: { 0: "answered", 3: "no run matches the slug — read `known`" },
4057
4122
  states: null,
4058
4123
  cost: "free",
4059
4124
  when: "after a run, or on demand",
@@ -4066,7 +4131,7 @@ const LANE_CALLS = {
4066
4131
  cmd: "orc run list [--json]",
4067
4132
  what: "which runs are unfinished, and where each one stands",
4068
4133
  exits: { 0: "answered" },
4069
- states: ["waiting", "closed"],
4134
+ states: ["waiting", "closed", "done", "empty"],
4070
4135
  cost: "free",
4071
4136
  when: "at entry, when resuming",
4072
4137
  on_absent: "a listing may only claim what the disk proves — a missing `state-of-play.md` never means incomplete",
@@ -4089,11 +4154,11 @@ const LANE_CALLS = {
4089
4154
  "export-import": {
4090
4155
  cmd: "orc export import [--json]",
4091
4156
  what: "read an existing AGENTS.md or .cursorrules and say what is already wrong",
4092
- exits: { 0: "answered" },
4157
+ exits: { 0: "answered", 3: "nothing to import — an ANSWER" },
4093
4158
  states: null,
4094
4159
  cost: "free",
4095
4160
  when: "on demand, in a repo that never ran ORC",
4096
- on_absent: "nothing to import is an ANSWER",
4161
+ on_absent: "exit 3 is an ANSWER — say so in one line",
4097
4162
  canonical: null,
4098
4163
  never: "never apply an import without the user's yes",
4099
4164
  lanes: ["orc-export", "orc-pact"],
@@ -4419,7 +4484,7 @@ const PHASE_FILES = {
4419
4484
  },
4420
4485
  "ship": {
4421
4486
  file: "_shared/phases/ship.md",
4422
- trace_verbs: ["PHASE", "GATE", "GRAPH-UPDATE", "GRAPH-GAIN"],
4487
+ trace_verbs: ["PHASE", "GATE", "GRAPH-UPDATE"],
4423
4488
  layers: ["full", "composed"],
4424
4489
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4425
4490
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4549,7 +4614,21 @@ const PROBE_DEFS = {
4549
4614
  argv: () => ["graph", "status", "--if-enabled", "--brief"],
4550
4615
  run: () => graphCmd(),
4551
4616
  pick: ["state", "enabled", "exists", "files", "symbols", "generation", "auto_update", "notes", "trace"],
4552
- derive: (j) => ({ heal_needed: !!(j.enabled && j.auto_update && (j.state === "none" || j.state === "drifted")) }),
4617
+ // v2.2.0: `update_step` names WHO keeps the graph current after a code
4618
+ // change — none (off), the SubagentStop hook, the lane's own update step,
4619
+ // or only the preflight heal (semi with the hooks off). A semi answer says
4620
+ // state "semi" AFTER its own heal, so heal_needed reads the config too.
4621
+ derive: (j) => {
4622
+ const { map } = readOverride(resolveClaudeDir());
4623
+ const get = (k) => String(Object.prototype.hasOwnProperty.call(map, k) ? map[k] : metaFor(k).def).toLowerCase();
4624
+ const mode = get("code_graph");
4625
+ const hooksOn = get("code_graph_hooks").split(",")[0].trim() === "on";
4626
+ const notesOff = get("code_graph_notes") === "off";
4627
+ const update_step =
4628
+ mode === "on" ? (hooksOn && notesOff ? "hook" : "lane") : mode === "semi" ? (hooksOn ? "hook" : "preflight") : "none";
4629
+ const maintained = mode === "on" || mode === "semi";
4630
+ return { heal_needed: !!(maintained && j.auto_update && (j.state === "none" || j.state === "drifted" || (j.state === "semi" && (j.index_state === "none" || j.index_state === "drifted")))), update_step };
4631
+ },
4553
4632
  line: (j) => j.line,
4554
4633
  },
4555
4634
  "rules-slice": {
@@ -4671,19 +4750,21 @@ const LANE_TRACE = {
4671
4750
  // `spine_verbs` (v2.0.0 T1): the verbs a lane's own SPINE emits outside
4672
4751
  // any declared phase row — orc's ultra gates, and orc-mini, whose pipeline is
4673
4752
  // still in-spine. They join `trace_grammar` like a phase's `trace_verbs`.
4674
- orc: { tier: "Build lanes", token: "orc", spine_verbs: ["ADVISE", "JUDGE"] },
4753
+ // v2.1.2 F04/F68: a graph read a lane may make hands it that read's verb —
4754
+ // mini and fast run their graph steps in-spine, and `coverage` had no verb.
4755
+ orc: { tier: "Build lanes", token: "orc", spine_verbs: ["ADVISE", "JUDGE", "GRAPH-COVERAGE"] },
4675
4756
  "orc-mini": {
4676
4757
  tier: "Build lanes",
4677
4758
  token: "mini",
4678
- spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "GRAPH-CONSULT", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-NOTES"],
4759
+ spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "GRAPH-CONSULT", "GRAPH-MAP", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-CHANGES", "GRAPH-NOTES"],
4679
4760
  },
4680
- "orc-fast": { tier: "Build lanes", token: "fast" },
4761
+ "orc-fast": { tier: "Build lanes", token: "fast", spine_verbs: ["GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-CHANGES"] },
4681
4762
  "orc-wiki": { tier: "Multi-dispatch", token: "wiki" },
4682
4763
  "orc-pr-driver": { tier: "Multi-dispatch", token: "prdriver" },
4683
4764
  "orc-diy": { tier: "Composed", token: "diy" },
4684
4765
  "orc-quick": { tier: "Iterative", token: "quick" },
4685
4766
  "orc-challenge": { tier: "Iterative", token: "challenge" },
4686
- "orc-doc": { tier: "Iterative", token: "doc" },
4767
+ "orc-doc": { tier: "Iterative", token: "doc", spine_verbs: ["DOC"] },
4687
4768
  "orc-test": { tier: "Iterative", token: "test" },
4688
4769
  "orc-claude": { tier: "Single-dispatch", token: "claude" },
4689
4770
  "orc-analyze": { tier: "Single-dispatch", token: "analyze" },
@@ -4722,14 +4803,15 @@ const TRACE_VERBS = {
4722
4803
  CONFIG: { grammar: "CONFIG <key=value …>", emitter: "orc → writer", meaning: "Phase 1 — the resolved config values this run will consume, ALWAYS with opus5_only", owner: "_shared/phases/trace-verbs.md" },
4723
4804
  "WIKI-CONSULT": { grammar: "WIKI-CONSULT <fresh|aging|stale|absent|empty> :: docs=<list|none>", emitter: "orc → writer", meaning: "project wiki consulted for grounding, with its staleness tier", owner: "_shared/phases/wiki-consult.md" },
4724
4805
  CROSSLINK: { grammar: "CROSSLINK <cached|configured-no-cache|none> :: boundaries=<n> peers=<names> · CROSSLINK inject task=<id> :: <boundary>", emitter: "orc → writer", meaning: "cross-repo peer-knowledge state at the consult point; `inject` when a slice receives a linked contract", owner: "_shared/phases/wiki-consult.md" },
4725
- "GRAPH-CONSULT": { grammar: "GRAPH-CONSULT <fresh|updated|built|drifted|none|off> :: files=<n> symbols=<n>[ density=<n>[ thin=1]] · GRAPH-CONSULT card task=<id> :: targets=<…>", emitter: "orc → writer", meaning: "the code graph's preflight state, copied VERBATIM from the CLI `trace` field", owner: "_shared/code-graph.md" },
4806
+ "GRAPH-CONSULT": { grammar: "GRAPH-CONSULT <fresh|updated|built|drifted|none|semi|off|pack|anchors> :: files=<n> symbols=<n>[ density=<n>[ thin=1]] · GRAPH-CONSULT card task=<id> :: targets=<…> · GRAPH-CONSULT pack :: files=<n> refs=<n> excerpts=<n> tokens=<n> gen=<n> · GRAPH-CONSULT anchors :: ranges=<n> files=<n> gen=<n>", emitter: "orc → writer", meaning: "the code graph's preflight state, copied VERBATIM from the CLI `trace` field", owner: "_shared/code-graph.md" },
4726
4807
  "GRAPH-UPDATE": { grammar: "GRAPH-UPDATE <state> :: parsed=<n> reused=<n> deleted=<n> gen=<n> route=<n> ms=<n>", emitter: "orc → writer", meaning: "one graph update, the `trace` field of `orc graph update --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4727
4808
  "GRAPH-MAP": { grammar: "GRAPH-MAP <repo|focused> :: files=<shown>/<total> [focus=<a,b>] gen=<n>", emitter: "orc → writer", meaning: "ONE line at the start of planning, the `trace` field of `orc graph map --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4728
4809
  "GRAPH-CHANGES": { grammar: "GRAPH-CHANGES <found|none> :: symbols=<n> high=<n> medium=<n> low=<n> gen=<n>", emitter: "orc → writer", meaning: "one line at review, the `trace` field of `orc graph changes --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4729
4810
  "GRAPH-COMPLEXITY": { grammar: "GRAPH-COMPLEXITY <mini-ok|recommend-orc> :: files=<n> callers=<n> caller_files=<n> maybe=<n> tests=<n> risk=<n> cochange=<n> gen=<n>", emitter: "orc → writer", meaning: "one line per /orc-mini run, the `trace` field of the `--complexity` impact call, verbatim", owner: "_shared/phases/trace-verbs.md" },
4730
- "GRAPH-COCHANGE": { grammar: "GRAPH-COCHANGE <found|none> :: rows=<n> commits=<n>", emitter: "orc → writer", meaning: "one line per planning batch, for the file that produced the widest answer", owner: "_shared/phases/trace-verbs.md" },
4811
+ "GRAPH-COCHANGE": { grammar: "GRAPH-COCHANGE <found|none> :: rows=<n> commits=<n> · GRAPH-COCHANGE none :: history=<n>", emitter: "orc → writer", meaning: "one line per planning batch, for the file that produced the widest answer", owner: "_shared/phases/trace-verbs.md" },
4812
+ "GRAPH-COVERAGE": { grammar: "GRAPH-COVERAGE <clean|gaps> :: paths=<n> gaps=<n> gen=<n>", emitter: "orc → writer", meaning: "the `trace` field of `orc graph coverage --json` — which files a card's silence can be trusted on", owner: "_shared/phases/trace-verbs.md" },
4731
4813
  "GRAPH-HINT": { grammar: "GRAPH-HINT injected=<n> subagent_start=<n> read_notes=<n> updates=<n>", emitter: "orc → writer", meaning: "ONE line per phase close, the graph hook's counters; omitted when the counter file does not exist", owner: "_shared/phases/trace-verbs.md" },
4732
- "GRAPH-GAIN": { grammar: "GRAPH-GAIN paid=<n> low=<n> high=<n> calls=<n>", emitter: "orc → writer", meaning: "ONE line per run at ship; `paid` is exact, `low`/`high` are an estimate never collapsed into one number", owner: "_shared/phases/trace-verbs.md" },
4814
+ "GRAPH-GAIN": { grammar: "GRAPH-GAIN paid=<n> low=<n> high=<n> calls=<n>", emitter: "orc → writer", meaning: "OLD traces only — no lane emits it now (`orc graph gain` is a user command); `paid` is exact, `low`/`high` are an estimate never collapsed into one number", owner: "_shared/phases/trace-verbs.md" },
4733
4815
  "GRAPH-NOTES": { grammar: "GRAPH-NOTES <applied|below-min|none|deferred|off|skipped> :: <detail>", emitter: "orc → writer", meaning: "one notes batch; `applied` copies the noter's one-line return verbatim", owner: "_shared/phases/trace-verbs.md" },
4734
4816
  SPAWN: { grammar: "SPAWN <agent>", emitter: "hook", meaning: "an agent dispatch was observed (skeleton)", owner: "hooks/orc-trace.js" },
4735
4817
  RETURN: { grammar: "RETURN <agent> :: <desc> dur=<m>m<s>s [model=<id>]", emitter: "hook", meaning: "a subagent finished (skeleton) — not an orchestrator obligation; the model check is VERIFY", owner: "hooks/orc-trace.js" },
@@ -4746,18 +4828,20 @@ const TRACE_VERBS = {
4746
4828
  OUTCOME: { grammar: "OUTCOME task=<id> score=<n> band=<range> model=<m> retries=<n> requeues=<n> needs_context=<n> unmet=<n>", emitter: "orc → writer", meaning: "task closed — links the scoring band to what it actually took", owner: "_shared/phases/execution.md" },
4747
4829
  FINDING: { grammar: "FINDING p0=<n> p1=<n> p2=<n> p3=<n>[ pre=<n> suppressed=<n> folded=<n>]", emitter: "reviewer→orc → writer", meaning: "review outcome (P0–P3 severity ladder); the tail counts the after-filter's buckets", owner: "_shared/phases/review.md" },
4748
4830
  "REVIEW-WHICH": { grammar: "REVIEW-WHICH chose=<orc|project|skip> name=<the review the rule names> by=<user|ledger|learned> :: <file:line of the rule>", emitter: "orc → writer", meaning: "the answer to §0 \"which review\" when the project names its own review — written ALWAYS, also under habits off; the FINISH nudge reads it", owner: "_shared/phases/trace-verbs.md" },
4749
- FIX: { grammar: "FIX source=<sonar|ci|defect|pr|review|other> introduced_by=<orc|ai|human|unknown> by=<user|evidence> obs=<id8>[ missed_by=<run>] :: <path:lines> <rule or finding>", emitter: "cli", meaning: "one fix recorded by `orc fix record` (/orc-fix) into the HOST run's trace — the CLI writes it, never a packet", owner: "orc-fix/SKILL.md" },
4831
+ FIX: { grammar: "FIX source=<sonar|ci|defect|pr|review> introduced_by=<orc|ai|human|unknown> by=<user|evidence> obs=<id8>[ missed_by=<run>] :: <path:lines> <rule or finding>", emitter: "cli", meaning: "one fix recorded by `orc fix record` (/orc-fix) into the HOST run's trace — the CLI writes it, never a packet — `source` is the FILED kind: a class `other` is filed as `defect`", owner: "orc-fix/SKILL.md" },
4832
+ WAIT: { grammar: "WAIT mode=<safe|soft|hard> requested=<n>m start=<HH:MM> end=<HH:MM> hops=<planned>/<max> trigger=<user|gate> · WAIT end :: done=<k>/<planned> reason=<elapsed|recovered|cancelled|max-hops> · WAIT block :: reason=\"<why>\" by=user · WAIT unblock", emitter: "cli", meaning: "a wait started, ended, blocked or unblocked — `orc wait` writes it into the open trace, never a packet", owner: "_shared/wait.md" },
4750
4833
  "FINDING-OUTCOME": { grammar: "FINDING-OUTCOME addressed=<n> disputed=<n> wontfix=<n> open=<n> pre=<n> suppressed=<n> :: <cat>:<addressed>/<total>,…", emitter: "orc → writer", meaning: "ONE line at review close (a clean review too) — what became of each finding; `orc gotcha quality` counts it as a review", owner: "_shared/phases/trace-verbs.md" },
4751
4834
  VERDICT: { grammar: "VERDICT pass|fail :: <detail>", emitter: "verifier→orc → writer", meaning: "verification outcome", owner: "_shared/phases/verify.md" },
4752
4835
  DRIFT: { grammar: "DRIFT loop=<n> :: <user description, compressed>", emitter: "orc → writer", meaning: "mock-example drift-recovery loop opened (hard cap 2 loops)", owner: "_shared/drift-recovery.md" },
4753
4836
  "TDD-RED": { grammar: "TDD-RED task=<id> iter=<n> :: <failing tests>", emitter: "executor→orc → writer", meaning: "TDD repair-loop iteration — the plan's acceptance tests still red", owner: "_shared/phases/trace-verbs.md" },
4754
4837
  "TDD-GREEN": { grammar: "TDD-GREEN task=<id> iter=<n>", emitter: "executor→orc → writer", meaning: "the task's TDD acceptance tests pass", owner: "_shared/phases/trace-verbs.md" },
4755
4838
  REPRO: { grammar: "REPRO red|green :: <cmd> exit=<n> · REPRO none :: <reason>", emitter: "executor→orc → writer", meaning: "a DEFECT task's reproduction: RED before the fix, GREEN after", owner: "_shared/return-validation.md" },
4756
- NOTE: { grammar: "NOTE :: <decisions>", emitter: "writer", meaning: "the packet's `decisions` field — the WHY layer; one line, only when non-empty", owner: "agents/orc-trace-writer-haiku-4-5.md" },
4839
+ NOTE: { grammar: "NOTE :: <decisions>", emitter: "writer", meaning: "the packet's `decisions` field — the WHY layer; one line, only when non-empty", owner: "agents/orc-trace-writer-haiku-5-high.md" },
4757
4840
  STATS: { grammar: "STATS lane=<l> slug=<s> dispatches=<n> waves=<n> tasks=<n> bands=<h:n,m:n,l:n> downgrades=<n> duration_ms=<n>", emitter: "orc → writer", meaning: "ONE summary line per run, in the FINISH packet, immediately BEFORE the FINISH line", owner: "_shared/phases/trace-verbs.md" },
4758
4841
  PACT: { grammar: "PACT <state> :: <ids> · PACT inject task=<id> :: <PACT-id> · PACT recheck pass|fail :: <ids>", emitter: "orc → writer", meaning: "invariant-ledger state at the Phase-1 probe, a promise injected into a task, the Phase-6 recheck", owner: "orc-pact/references/gate.md" },
4759
4842
  BOUNDARY: { grammar: "BOUNDARY <EXECUTE|ESCALATE|REFUSE|unknown> task=<id> :: <area> · BOUNDARY lift task=<id> :: <area>", emitter: "orc → writer", meaning: "per-task boundary verdict; an uncarded area is `unknown`, never REFUSE", owner: "orc-boundary/references/gate.md" },
4760
4843
  CHALLENGE: { grammar: "CHALLENGE iter=<n> findings=P0:<n>/P1:<n>/P2:<n> coverage=<n>% verdict=PASS|FAIL · CHALLENGE accept|rebut :: <id> · CHALLENGE regoal|retemplate :: v<n>", emitter: "orc → writer", meaning: "one line per completed /orc-challenge iteration — `orc challenge record`'s `trace_line`, verbatim", owner: "orc-challenge/SKILL.md" },
4844
+ DOC: { grammar: "DOC cycle=<n> sections=<k>/<m>[ wave=<k>/<n>]", emitter: "orc → writer", meaning: "one line per completed /orc-doc cycle — a completed WAVE is a completed cycle", owner: "orc-doc/SKILL.md" },
4761
4845
  EXTRA: { grammar: "EXTRA <profile>/<model> engine=<api|claude-shim|cli> task=<id> band=[lo,hi) tok=in/cw/cr/out outcome=<done|partial|failed|fallback> dur=<m>m<s>s · EXTRA fallback task=<id> :: <reason> → <agent> · EXTRA substitution task=<id> :: requested=<m> reported=<m> · EXTRA reroute task=<id> :: <providers> · EXTRA resume task=<id> attempt=<n> :: from=<reason> attribution=<verdict> target=<extra:profile|agent> files_preexisting=<n> · EXTRA orphan task=<id> :: attempt=<n> lease-expired files_changed=<n> state=<state> · EXTRA demote run=<slug> :: profile=<p> reason=<consecutive-stall|stale-live-attempt|manual> n=<k> → <ladder>", emitter: "orc → writer", meaning: "one line per FOREIGN dispatch — `orc extra dispatch`'s `trace_line`, verbatim", owner: "_shared/extra-dispatch.md" },
4762
4846
  FINISH: { grammar: "FINISH :: <detail>", emitter: "orc → writer", meaning: "run ended — mandatory, even on an abort", owner: "_shared/phases/trace.md" },
4763
4847
  };
@@ -4786,6 +4870,29 @@ function laneTraceGrammar(lane) {
4786
4870
  return out;
4787
4871
  }
4788
4872
 
4873
+ // v2.2.0 T13 — the verb heads of a lane, by lane name or trace token (`quick`,
4874
+ // `orc-quick`); null when it names no traced lane. `orc trace write` lists these
4875
+ // when it refuses an unknown verb.
4876
+ function laneVerbsFor(x) {
4877
+ const k = String(x || "");
4878
+ const lane = LANE_TRACE[k] ? k : Object.keys(LANE_TRACE).find((l) => LANE_TRACE[l].token === k);
4879
+ const g = lane ? laneTraceGrammar(lane) : null;
4880
+ return g ? Object.keys(g) : null;
4881
+ }
4882
+
4883
+ // v2.2.0 T13 — `{ qid: "offered=<o1|o2>" }` for every ASK question registered for a lane.
4884
+ function laneAskIds(lane) {
4885
+ let H = null;
4886
+ try { H = require("./habit.js"); } catch (_) { return {}; }
4887
+ const out = {};
4888
+ for (const [q, p] of Object.entries(H.ASK_POINTS || {})) {
4889
+ if (!(p.lanes || []).includes(lane)) continue;
4890
+ const o = Object.keys(p.options || {});
4891
+ out[q] = `offered=${o.length ? o.join("|") : "<the agent names you offered>"}`;
4892
+ }
4893
+ return out;
4894
+ }
4895
+
4789
4896
  // Which lanes run which SHARED phase. A lane absent from a row does not run
4790
4897
  // that phase — \`/orc-retro\` is in no trace row because it mines traces and
4791
4898
  // writes none (its hard rule 4), and \`context-combiner\` is in none because it
@@ -4811,7 +4918,7 @@ const LANE_PHASES = {
4811
4918
  "orc-test",
4812
4919
  ],
4813
4920
  "stop-resume": ["orc", "orc-wiki", "orc-diy", "orc-test"],
4814
- intake: ["orc", "orc-mini", "orc-diy", "orc-challenge"],
4921
+ intake: ["orc", "orc-mini", "orc-diy"],
4815
4922
  "plan-handoff": ["orc", "orc-route"],
4816
4923
  "wave-grouping": ["orc", "orc-diy"],
4817
4924
  "analyst-gates": ["orc", "orc-analyze", "orc-mini"],
@@ -5051,9 +5158,9 @@ const LANE_OWN_PHASES = {
5051
5158
  // pointer names a heading byte-for-byte, and a payload test asserts each
5052
5159
  // one still exists in the spine.
5053
5160
  { ord: 0, id: "q0", file: "orc-quick/SKILL.md", heading: "## Q0 — Preflight (ONE time per session, silent, nothing can stop the run)", read: "section", trace_verbs: ["GATE", "GRAPH-CONSULT"] },
5054
- { ord: 1, id: "q1", file: "orc-quick/SKILL.md", heading: "## Q1 — LOOK (silent — no questions here)", read: "section", trace_verbs: ["GATE", "WIKI-CONSULT", "GRAPH-CONSULT", "GRAPH-MAP"] },
5161
+ { ord: 1, id: "q1", file: "orc-quick/SKILL.md", heading: "## Q1 — LOOK (silent — no questions here)", read: "section", trace_verbs: ["GATE", "WIKI-CONSULT", "GRAPH-CONSULT", "GRAPH-MAP", "GRAPH-COVERAGE"] },
5055
5162
  { ord: 2, id: "q2", file: "orc-quick/SKILL.md", heading: "## Q2 — ASK (ONE user turn: questions + the gate together)", read: "section", trace_verbs: [] },
5056
- { ord: 3, id: "q3", file: "orc-quick/SKILL.md", heading: "## Q3 — DO (dispatch → build/test → write the doc → offer)", read: "section", trace_verbs: ["DISPATCH", "VERIFY", "REPRO", "GRAPH-CHANGES", "GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-GAIN", "OUTCOME", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "FINISH"] },
5163
+ { ord: 3, id: "q3", file: "orc-quick/SKILL.md", heading: "## Q3 — DO (dispatch → build/test → write the doc → offer)", read: "section", trace_verbs: ["DISPATCH", "VERIFY", "REPRO", "GRAPH-CHANGES", "GRAPH-UPDATE", "GRAPH-NOTES", "OUTCOME", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "FINISH"] },
5057
5164
  ],
5058
5165
  "orc-wiki": [
5059
5166
  { ord: 0, id: "phase-0", file: "orc-wiki/references/phases/phase-0.md", layers: ["full"], trace_verbs: [] },
@@ -5316,6 +5423,8 @@ function lanePhasesCmd(lane, claudeDir) {
5316
5423
  layer_set: PHASE_LAYERS,
5317
5424
  phase_files: PHASE_FILES,
5318
5425
  lanes: out,
5426
+ // v2.2.0 T13 — one lane answered: its grammar and ASK ids at the top level too.
5427
+ ...(out.length === 1 ? { trace_grammar: out[0].trace_grammar, ask_ids: laneAskIds(out[0].lane) } : {}),
5319
5428
  });
5320
5429
  return 0;
5321
5430
  }
@@ -5332,6 +5441,10 @@ function lanePhasesCmd(lane, claudeDir) {
5332
5441
  " trace grammar " +
5333
5442
  ui.color.gray(`${Object.keys(r.trace_grammar).length} verbs: ${Object.keys(r.trace_grammar).join(" · ")} (--json → trace_grammar)`)
5334
5443
  );
5444
+ if (!all) {
5445
+ const ids = Object.keys(laneAskIds(r.lane));
5446
+ if (ids.length) console.log(" ask ids " + ui.color.gray(`${ids.join(" · ")} (--json → ask_ids)`));
5447
+ }
5335
5448
  if (!r.phases.length) {
5336
5449
  // An empty answer is an ANSWER: this lane runs no SHARED phase yet.
5337
5450
  console.log(
@@ -5620,14 +5733,13 @@ const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
5620
5733
  // (`sonnet-5-low`, `sonnet-5-med`).
5621
5734
  //
5622
5735
  // It also feeds `fixed_executor`'s option list, so every entry here is offerable
5623
- // — which is what keeps the seven agents no band names reachable (D14).
5736
+ // — which is what keeps the six agents no band names reachable (D14).
5624
5737
  //
5625
5738
  // Effort ranks `low(0) < medium(1) < high(2) < xhigh(3) < max(4)`. There is no
5626
5739
  // session tier at 0: an agent at low effort fits every tier of its own model,
5627
5740
  // which is correct — a low-effort child never exceeds its parent.
5628
5741
  const DIY_EXECUTORS = {
5629
- "orc-executor-haiku-4-5": { model: 1, effort: 1 },
5630
- "orc-executor-sonnet-4-6-med": { model: 2, effort: 1 },
5742
+ "orc-executor-haiku-5-high": { model: 1, effort: 2 },
5631
5743
  "orc-executor-sonnet-4-6-high": { model: 2, effort: 2 },
5632
5744
  "orc-executor-sonnet-5-low": { model: 3, effort: 0 },
5633
5745
  "orc-executor-sonnet-5-med": { model: 3, effort: 1 },
@@ -5802,7 +5914,9 @@ function readDiyConfig(claudeDir) {
5802
5914
  if (!t || t.startsWith("#")) continue;
5803
5915
  const i = t.indexOf(":");
5804
5916
  if (i === -1) continue;
5805
- map[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
5917
+ const k = t.slice(0, i).trim();
5918
+ const v = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
5919
+ map[k] = LEGACY_AGENT_KEYS.has(k) ? resolveLegacyAgents(v) : v;
5806
5920
  }
5807
5921
  return Object.keys(map).length ? map : null;
5808
5922
  }
@@ -5812,7 +5926,7 @@ function readDiyConfig(claudeDir) {
5812
5926
  // where the ledger is in hand — a validator that read the ledger would make
5813
5927
  // `orc diy set` fail on a machine that has not connected yet.
5814
5928
  function vDiyExecutor(v) {
5815
- const raw = String(v || "").trim();
5929
+ const raw = resolveLegacyAgents(String(v || "").trim());
5816
5930
  if (!raw) return { value: "" };
5817
5931
  if (Object.prototype.hasOwnProperty.call(DIY_EXECUTORS, raw)) return { value: raw };
5818
5932
  if (/^extra:[^/]+\/[^/]+\/.+$/.test(raw)) return { value: raw };
@@ -5924,7 +6038,7 @@ function diyValidate(cfg) {
5924
6038
  // v2.1.0 — FIVE BANDS. The score is always a whole number, so the user's closed
5925
6039
  // ranges 0–20 · 21–30 · 31–40 · 41–89 · 90–100 map exactly to these half-open
5926
6040
  // rows. The bottom three are Sonnet 5 with effort as the dial; from 41 every
5927
- // task runs on Opus 5.5. `haiku-4-5`, `sonnet-4-6-med` and `sonnet-4-6-high` are
6041
+ // task runs on Opus 5.5. `haiku-5-high` (v2.2.0: was `haiku-4-5` and `sonnet-4-6-med`) and `sonnet-4-6-high` are
5928
6042
  // named by no band now and STAY ON DISK (the same D14 rule as above).
5929
6043
  const DIY_SCORE_TABLE = [
5930
6044
  [0, 21, "orc-executor-sonnet-5-low"],
@@ -7213,30 +7327,60 @@ const unquote = (s) => String(s == null ? "" : s).trim().replace(/^["']|["']$/g,
7213
7327
  const plural = (n, word, many) => `${n} ${n === 1 ? word : many || word + "s"}`;
7214
7328
 
7215
7329
  // Minimal frontmatter reader — the exact subset schemas/wiki-doc.md uses:
7216
- // scalars, inline arrays (`covers: [a, b]`), and one nested map level
7217
- // (`covered_files:` + indented `path: hash`). No YAML dep, by house rule.
7330
+ // scalars, inline arrays (`covers: [a, b]`), one nested map level
7331
+ // (`covered_files:` + indented `path: hash`), and — v2.1.2 (F02) — YAML block
7332
+ // lists (`checklist:` then `- item` lines, indented or not), the shape the
7333
+ // boundary card reference shows. No YAML dep, by house rule.
7334
+ function stripComment(val) {
7335
+ // ` # …` ends a value only OUTSIDE quotes.
7336
+ let q = null;
7337
+ for (let i = 0; i < val.length; i++) {
7338
+ const ch = val[i];
7339
+ if (q) { if (ch === q) q = null; continue; }
7340
+ if (ch === '"' || ch === "'") { q = ch; continue; }
7341
+ if (ch === "#" && i > 0 && /\s/.test(val[i - 1])) return val.slice(0, i).trim();
7342
+ }
7343
+ return val.trim();
7344
+ }
7345
+ function splitInline(body) {
7346
+ // `a, "b, c", 'd'` → ["a", "b, c", "d"] — a comma inside quotes is text.
7347
+ const out = [];
7348
+ let cur = "";
7349
+ let q = null;
7350
+ for (const ch of body) {
7351
+ if (q) { if (ch === q) q = null; cur += ch; continue; }
7352
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
7353
+ if (ch === ",") { out.push(cur); cur = ""; continue; }
7354
+ cur += ch;
7355
+ }
7356
+ out.push(cur);
7357
+ return out.map((s) => unquote(s)).filter(Boolean);
7358
+ }
7218
7359
  function parseDocHeader(text) {
7219
- const m = text.replace(/^/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
7360
+ const m = text.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
7220
7361
  if (!m) return null;
7221
7362
  const h = {};
7222
- let mapKey = null;
7363
+ let openKey = null; // a key with an empty value: a map or a list follows
7223
7364
  for (const raw of m[1].split(/\r?\n/)) {
7224
7365
  if (!raw.trim() || raw.trim().startsWith("#")) continue;
7225
- if (/^\s+\S/.test(raw) && mapKey) {
7226
- const kv = raw.trim().replace(/\s+#.*$/, "").match(/^(.+?):\s*(.*)$/);
7227
- if (kv) h[mapKey][unquote(kv[1])] = unquote(kv[2]);
7366
+ if (openKey && /^\s*-(\s|$)/.test(raw)) {
7367
+ if (!Array.isArray(h[openKey])) h[openKey] = []; // the first `- ` makes it a list
7368
+ const v = unquote(stripComment(raw.replace(/^\s*-\s*/, "")));
7369
+ if (v) h[openKey].push(v);
7370
+ continue;
7371
+ }
7372
+ if (/^\s+\S/.test(raw) && openKey && !Array.isArray(h[openKey])) {
7373
+ const kv = stripComment(raw.trim()).match(/^(.+?):\s*(.*)$/);
7374
+ if (kv) h[openKey][unquote(kv[1])] = unquote(kv[2]);
7228
7375
  continue;
7229
7376
  }
7230
7377
  const kv = raw.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
7231
7378
  if (!kv) continue;
7232
- mapKey = null;
7379
+ openKey = null;
7233
7380
  const key = kv[1];
7234
- const val = kv[2].replace(/\s+#.*$/, "").trim();
7235
- if (val === "") { h[key] = {}; mapKey = key; continue; }
7236
- if (val.startsWith("[")) {
7237
- h[key] = val.replace(/^\[|\]$/g, "").split(",").map((s) => unquote(s)).filter(Boolean);
7238
- continue;
7239
- }
7381
+ const val = stripComment(kv[2]);
7382
+ if (val === "") { h[key] = {}; openKey = key; continue; }
7383
+ if (val.startsWith("[")) { h[key] = splitInline(val.replace(/^\[|\]$/g, "")); continue; }
7240
7384
  h[key] = unquote(val);
7241
7385
  }
7242
7386
  return h;
@@ -9360,7 +9504,7 @@ function graphMeasured(claudeDir, root, opts) {
9360
9504
  } catch (_) {}
9361
9505
  const used = (text.match(/graph_used/g) || []).length;
9362
9506
  const none = (text.match(/graph_used[^\n]*none/g) || []).length;
9363
- groups[m[1] === "off" ? "off" : "on"].push({ name: t.name, lane: t.lane, start: meta.start, end: meta.end, hints, graph_used: used, graph_used_none: none });
9507
+ groups[m[1] === "off" || m[1] === "semi" ? "off" : "on"].push({ name: t.name, lane: t.lane, start: meta.start, end: meta.end, hints, graph_used: used, graph_used_none: none });
9364
9508
  }
9365
9509
 
9366
9510
  const corpus = readToolCorpus(root);
@@ -9427,12 +9571,25 @@ function gainRowFor(sub, Q, model, operands, r, ms, forSlice) {
9427
9571
  }
9428
9572
  const base = { cmd: sub, targets: operands.slice(0, 5), gen, ms, paid };
9429
9573
 
9574
+ // v2.2.0 (T6): the PACK — paid is the pack file the executor reads.
9575
+ if (sub === "ctx" && forSlice && r.pack) {
9576
+ const p = r.pack;
9577
+ return { ...base, cmd: "pack", paid: { card: p.tokens, source: 0, hints: 0 }, ...K.packCost(model, p.refs || [], (r.files || []).filter((rel) => (Number((model.byFile[rel] || {}).lines) || 0) > 150), p.tokens) };
9578
+ }
9579
+ // v2.2.0 (R6): the ANCHORS — paid is the anchor block; avoided low is 0.
9580
+ if (sub === "ctx" && r.anchors) {
9581
+ const c = K.impactCost(model, r.anchors.refs || [], 1, 0, []);
9582
+ return { ...base, cmd: "anchors", ...c, paid: { card: r.anchors.tokens, source: 0, hints: 0 }, avoided: { ...c.avoided, low: 0 } };
9583
+ }
9430
9584
  if (sub === "impact" || (sub === "ctx" && forSlice)) {
9431
9585
  const files = sub === "impact" ? r.files || [] : r.files || [];
9432
9586
  const callerFiles = sub === "impact" ? (r.callers || []).map((c) => c.file) : (r.blocks || []).flatMap((b) => (b.callers || []).map((c) => c.file));
9433
9587
  const symbols = sub === "impact" ? r.symbols || 1 : (r.blocks || []).reduce((a, b) => a + (b.total_callers || 0), 0) || 1;
9434
9588
  const hits = files.reduce((a, rel) => a + ((model.byFile[rel] || {}).symbols || []).reduce((n, x) => n + (x.kind === "module" ? 0 : Q.nameHits(model, x.name)), 0), 0);
9435
9589
  const c = K.impactCost(model, files, symbols, hits, callerFiles);
9590
+ // v2.2.0 (G-08): an executor reads its declared files whole either way, so
9591
+ // an outside view done well saved nothing — `low` is 0.
9592
+ if (forSlice) c.avoided = { ...c.avoided, low: 0 };
9436
9593
  return { ...base, cmd: forSlice ? "for-slice" : "impact", ...c };
9437
9594
  }
9438
9595
  // D4 (v1.8.2 W7): the map's targets are its FOCUS, and what it replaced is
@@ -9513,20 +9670,44 @@ function graphGainAppend(claudeDir, row) {
9513
9670
  // `ctx`, whose rows are three scalars each).
9514
9671
  // Every drop leaves its COUNT behind, at the level it was dropped from, except
9515
9672
  // where the answer already carries that count under another name.
9673
+ // v2.2.0 (G-19): the reads whose every answer — ok, empty, declined or failed —
9674
+ // leaves one ledger row with its outcome.
9675
+ const GRAPH_READ_SUBS = new Set(["ctx", "impact", "path", "map", "changes", "cochange", "coverage"]);
9516
9676
  const GRAPH_BRIEF_DROP = new Set(["callers", "calls", "effects", "symbols", "imports", "importers", "blocks", "names", "rows", "chain", "waiting"]);
9517
9677
  const GRAPH_BRIEF_KEEP = new Set(["nearest", "candidates", "cards"]);
9518
9678
  // V2/V3 answers that ARE the answer: `complexity` and `facts` are ten rows at
9519
9679
  // most, and they are the whole reason the call was made. They pass through
9520
9680
  // whole, never walked.
9521
9681
  const GRAPH_BRIEF_WHOLE = new Set(["complexity", "facts"]);
9522
- const GRAPH_BRIEF_NO_COUNT = { impact: ["callers"], map: ["files"], changes: ["symbols"] };
9682
+ const GRAPH_BRIEF_NO_COUNT = { impact: ["callers"], map: ["files"], changes: ["symbols"], ctx: ["tests_via"] };
9683
+
9684
+ // v2.2.0 (R7): a brief map answer keeps only these keys; every list is counted.
9685
+ const GRAPH_BRIEF_MAP_KEEP = new Set(["ok", "state", "line", "trace", "generation", "card", "counts"]);
9523
9686
 
9524
9687
  function briefOf(sub, obj) {
9688
+ if (sub === "map" && obj && typeof obj === "object") {
9689
+ const out = {};
9690
+ const counts = { ...((obj.counts && typeof obj.counts === "object") ? obj.counts : {}) };
9691
+ for (const [k, v] of Object.entries(obj)) {
9692
+ if (GRAPH_BRIEF_MAP_KEEP.has(k)) { if (k !== "counts") out[k] = v; continue; }
9693
+ if (Array.isArray(v)) counts[k] = v.length;
9694
+ }
9695
+ if (Object.keys(counts).length) out.counts = counts;
9696
+ return out;
9697
+ }
9525
9698
  const noCount = new Set(GRAPH_BRIEF_NO_COUNT[sub] || []);
9526
9699
  const walk = (o) => {
9527
9700
  const out = {};
9528
9701
  const counts = {};
9529
9702
  for (const [k, v] of Object.entries(o)) {
9703
+ // v2.2.0 (T6-b): a map's `focus` list (every focused file) was the bulk
9704
+ // of a 10 KB brief answer. It is counted, and any other string list in a
9705
+ // map answer is cut to 12 with its count.
9706
+ if (sub === "map" && o === obj && Array.isArray(v) && (k === "focus" || (v.length > 12 && typeof v[0] === "string"))) {
9707
+ counts[k] = v.length;
9708
+ if (k !== "focus") out[k] = v.slice(0, 12);
9709
+ continue;
9710
+ }
9530
9711
  if (Array.isArray(v)) {
9531
9712
  const rows = GRAPH_BRIEF_DROP.has(k) || (!GRAPH_BRIEF_KEEP.has(k) && v.length > 0 && v[0] !== null && typeof v[0] === "object");
9532
9713
  if (!rows) out[k] = v;
@@ -9544,15 +9725,29 @@ function briefOf(sub, obj) {
9544
9725
  if (Object.keys(counts).length) out.counts = { ...(out.counts || {}), ...counts };
9545
9726
  return out;
9546
9727
  };
9547
- return walk(obj);
9728
+ const out = walk(obj);
9729
+ // R11: a FULL coverage verdict is one word in a brief answer — the card row
9730
+ // already says how many files. A partial one keeps its gap list.
9731
+ if (sub === "ctx" && out.coverage && out.coverage.verdict === "full") out.coverage = "full";
9732
+ return out;
9733
+ }
9734
+
9735
+ // v2.2.0 (T6-a): write the PACK atomically, the folder created. Relative to
9736
+ // the repository root; overwritten every call.
9737
+ function writeGraphPack(root, rel, text) {
9738
+ const G = require("./graph.js");
9739
+ const abs = path.resolve(root, rel);
9740
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
9741
+ G.atomicWrite(abs, text);
9742
+ return abs;
9548
9743
  }
9549
9744
 
9550
9745
  function graphCmd() {
9551
9746
  const usage =
9552
9747
  "Usage: orc graph status [--heal] | update [--notes-pending --files <a,b>] | gc | audit [--top=N]\n" +
9553
- " orc graph ctx <symbol|file[:line]>… [--source [N]] [--callers-source] | ctx --for-slice <file…> | impact <file…> | path <from> <to>\n" +
9748
+ " orc graph ctx <symbol|file[:line]>… [--source [N]] [--callers-source] | ctx --for-slice <file…> [--focus=<w,…>] [--refs=<file,…>] [--anchors] [--pack=<path>] [--pack-budget=N] | impact <file…> | path <from> <to>\n" +
9554
9749
  " orc graph coverage <file…> | changes [--base <ref>] [--files=<a,b>] | cochange <file>\n" +
9555
- " orc graph map [--focus <file|name,…>] [--budget N]\n" +
9750
+ " orc graph map [--focus=<file|name|word,…>] [--budget N]\n" +
9556
9751
  " orc graph gain [--run <name>] [--since 7d] [--history [--limit 40]] [--measured] [--reset --yes]\n" +
9557
9752
  " orc graph notes pending --files <a,b> [--cap 40] [--min 5] [--with-source] | notes apply <file|->\n" +
9558
9753
  " [--if-enabled] [--depth N] [--budget TOKENS] [--format prose|tree] [--offset N] [--json] [--brief] [--dir <path>]\n" +
@@ -9604,7 +9799,14 @@ function graphCmd() {
9604
9799
  };
9605
9800
  const ovr = readOverride(claudeDir).map;
9606
9801
  const cfg = (k) => (Object.prototype.hasOwnProperty.call(ovr, k) ? ovr[k] : metaFor(k).def);
9607
- const enabled = String(cfg("code_graph")) === "on";
9802
+ // v2.2.0: `code_graph` is off | semi | on. `semi` keeps the graph BUILT and
9803
+ // CURRENT (status --heal, update, the SubagentStop hook) but no lane READS it:
9804
+ // every gated read answers exit 3 exactly as `off` does. A person who types a
9805
+ // read WITHOUT --if-enabled still gets an answer from the index.
9806
+ const graphMode = String(cfg("code_graph")).toLowerCase();
9807
+ const enabled = graphMode === "on";
9808
+ const maintained = enabled || graphMode === "semi";
9809
+ const isSemi = graphMode === "semi";
9608
9810
  // v1.8.2 W2 (G8): the extra globs the map must never index. One string,
9609
9811
  // split here; the engine takes the list.
9610
9812
  const ignore = String(cfg("code_graph_ignore") || "")
@@ -9618,6 +9820,8 @@ function graphCmd() {
9618
9820
  const notesPendingAnswer = (gated) => {
9619
9821
  const N = require("./graph-notes.js");
9620
9822
  const notesMode = String(cfg("code_graph_notes"));
9823
+ if (gated && isSemi)
9824
+ return { answer: { ok: true, state: "semi", mode: "semi", enabled: false, notes: notesMode, exit: 3 }, line: "graph: semi (kept current; lanes do not read it)", trace: "GRAPH-NOTES off :: mode=semi" };
9621
9825
  if (gated && (!enabled || notesMode === "off"))
9622
9826
  return { answer: { ok: true, state: "off", enabled, notes: notesMode, exit: 3 }, line: enabled ? "graph notes: off" : "graph: off", trace: "GRAPH-NOTES off" };
9623
9827
  // `--at wave|end` names the CALL SITE. A batch the configured mode places at
@@ -9696,6 +9900,25 @@ function graphCmd() {
9696
9900
  }
9697
9901
  const full = { ...obj, ...(line && jsonLine ? { line } : {}), ...(trace ? { trace } : {}) };
9698
9902
  const payload = asJson && briefOn ? briefOf(sub, full) : full;
9903
+ // v2.2.0 (G-19): every READ gets a row with its OUTCOME — a declined or
9904
+ // failed read paid for its line too, and an empty answer is not a saving.
9905
+ if (GRAPH_READ_SUBS.has(sub) && (exit === 0 || exit === 1 || exit === 4)) {
9906
+ const empty =
9907
+ exit === 0 &&
9908
+ ((Array.isArray(obj.rows) && !obj.rows.length) || (sub === "changes" && Array.isArray(obj.symbols) && !obj.symbols.length) || (!obj.card && !line));
9909
+ const outcome = exit === 4 ? "declined" : exit === 1 ? "error" : empty ? "empty" : "ok";
9910
+ if (!gainRow) {
9911
+ try {
9912
+ const K = require("./graph-gain.js");
9913
+ const cmd = sub === "ctx" && args.includes("--for-slice") ? (eqFlag("--pack") ? "pack" : args.includes("--anchors") ? "anchors" : "for-slice") : sub;
9914
+ gainRow = { cmd, targets: positionals().slice(2, 7), gen: obj.generation || null, ms: 0, paid: { card: K.tok(obj.card || line || ""), source: 0, hints: 0 }, avoided: { low: 0, high: 0, calls_low: 0, calls_high: 0 }, basis: { files: [], grep_hits: 0 } };
9915
+ } catch (_) {}
9916
+ }
9917
+ if (gainRow) {
9918
+ gainRow.outcome = outcome;
9919
+ gainRow.exit = exit;
9920
+ }
9921
+ }
9699
9922
  if (gainRow) {
9700
9923
  try {
9701
9924
  const K = require("./graph-gain.js");
@@ -9711,19 +9934,27 @@ function graphCmd() {
9711
9934
  process.exit(exit);
9712
9935
  };
9713
9936
 
9937
+ // The --if-enabled exit-3 answer of every gated READ: off, or semi.
9938
+ const offGate = (verb) => {
9939
+ const V = String(verb || "").toUpperCase();
9940
+ return isSemi
9941
+ ? finish({ ok: true, enabled: false, state: "semi", mode: "semi", exit: 3 }, "graph: semi (kept current; lanes do not read it)", `GRAPH-${V} off :: mode=semi`)
9942
+ : finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
9943
+ };
9944
+
9714
9945
  if (sub === "status") {
9715
- let r = G.graphStatus(claudeDir, root, { enabled, ignore });
9946
+ let r = G.graphStatus(claudeDir, root, { enabled: maintained, ignore });
9716
9947
  const autoUpdate = String(cfg("code_graph_auto_update")) !== "false";
9717
9948
  // `--heal` (W9): consult AND build in ONE call. A lane that had to run
9718
9949
  // status, read the exit code and then run update skipped the second step.
9719
9950
  let healed = null;
9720
- if (flag("--heal") === true && enabled && autoUpdate && (r.state === "none" || r.state === "drifted")) {
9721
- const u = G.graphUpdate(claudeDir, root, { enabled, ifEnabled: true, ignore });
9951
+ if (flag("--heal") === true && maintained && autoUpdate && (r.state === "none" || r.state === "drifted")) {
9952
+ const u = G.graphUpdate(claudeDir, root, { enabled: maintained, ifEnabled: true, ignore });
9722
9953
  healed =
9723
9954
  u.exit === 0
9724
9955
  ? { state: u.state, added: u.added, changed: u.changed, deleted: u.deleted, parsed: u.parsed, reused: u.reused, engine_upgrade: u.engine_upgrade, ms: u.ms }
9725
9956
  : { state: u.state, reason: u.reason };
9726
- if (u.exit === 0) r = G.graphStatus(claudeDir, root, { enabled, ignore });
9957
+ if (u.exit === 0) r = G.graphStatus(claudeDir, root, { enabled: maintained, ignore });
9727
9958
  }
9728
9959
  // What the lane does NEXT depends on these two, and the lane reads no key.
9729
9960
  r.auto_update = autoUpdate;
@@ -9736,7 +9967,7 @@ function graphCmd() {
9736
9967
  // obeys — the index did not change, so `generation` and `gen_id` do not.
9737
9968
  const SIGD = require("./graph-signals.js");
9738
9969
  let meta = metaNow();
9739
- if (flag("--heal") === true && enabled && meta && !meta.density && r.exists) {
9970
+ if (flag("--heal") === true && maintained && meta && !meta.density && r.exists) {
9740
9971
  const capMs = Number(cfg("code_graph_heal_ms")) || 0;
9741
9972
  if (!capMs || !meta.update_ms || meta.update_ms <= capMs) {
9742
9973
  try {
@@ -9786,6 +10017,19 @@ function graphCmd() {
9786
10017
  : `graph: ${plural(moved, "file")} changed outside ORC → updated (${healed.ms} ms) — ${size}`;
9787
10018
  } else if (healed) line += ` · could not heal: ${healed.reason}`;
9788
10019
  if (densityText && ["fresh", "drifted", "built", "updated"].includes(consult)) line += densityText;
10020
+ // semi: built and healed above exactly as `on`, then the lane is told OFF
10021
+ // (exit 3) so it reads nothing. Without --if-enabled the person sees the state.
10022
+ if (isSemi && flag("--if-enabled") === true) {
10023
+ r.index_state = consult;
10024
+ r.state = "semi";
10025
+ r.mode = "semi";
10026
+ r.exit = 3;
10027
+ return finish(
10028
+ r,
10029
+ `graph: semi — kept current (${plural(r.files || 0, "file")} · ${plural(r.symbols || 0, "symbol")}); lanes do not read it`,
10030
+ `GRAPH-CONSULT semi :: files=${r.files || 0} symbols=${r.symbols || 0} gen=${r.generation || 0}`
10031
+ );
10032
+ }
9789
10033
  return finish(
9790
10034
  r,
9791
10035
  line,
@@ -9798,7 +10042,7 @@ function graphCmd() {
9798
10042
 
9799
10043
  if (sub === "update") {
9800
10044
  const r = G.graphUpdate(claudeDir, root, {
9801
- enabled,
10045
+ enabled: maintained,
9802
10046
  ifEnabled: flag("--if-enabled") === true,
9803
10047
  ignore,
9804
10048
  say: asJson ? null : (s) => console.log(s),
@@ -9817,7 +10061,7 @@ function graphCmd() {
9817
10061
  `graph: ${verb} — ${plural(r.files, "file")} (${r.parsed} parsed, ${r.reused} reused, ${r.deleted} deleted` +
9818
10062
  `${r.skipped ? `, ${r.skipped} skipped` : ""}) · ${r.ms} ms`;
9819
10063
  }
9820
- if (r.exit === 0 && !enabled) {
10064
+ if (r.exit === 0 && !maintained) {
9821
10065
  line += "\n note: code_graph is off — lanes will not read this graph (orc config set code_graph on)";
9822
10066
  }
9823
10067
  const trace =
@@ -9870,15 +10114,17 @@ function graphCmd() {
9870
10114
  // the lock, the read answers from the old generation — and every touched card
9871
10115
  // already says `CHANGED since index` for itself.
9872
10116
  const healOnRead = (paths) => {
9873
- if (!enabled) return null;
10117
+ if (!maintained) return null;
9874
10118
  const autoUpdate = String(cfg("code_graph_auto_update")) !== "false";
9875
10119
  const capRaw = Number(cfg("code_graph_heal_ms"));
9876
10120
  const cap = Number.isFinite(capRaw) ? capRaw : 1500;
9877
10121
  if (!autoUpdate || cap <= 0) return null;
9878
10122
  const meta = metaNow();
9879
10123
  if (!meta) return null; // no graph at all — `update` is the answer, not a heal
10124
+ // v2.2.0 (G-13): decide `moved` FIRST. Nothing moved is no line at all —
10125
+ // an over-cap store that is current had nothing to heal. A missing
10126
+ // `update_ms` (a cold build writes `build_ms`) counts as under the cap.
9880
10127
  const last = Number(meta.update_ms) || 0;
9881
- if (last > cap) return { state: "skipped", reason: "over-cap", last_ms: last, cap_ms: cap };
9882
10128
 
9883
10129
  let moved = null;
9884
10130
  try {
@@ -9903,8 +10149,9 @@ function graphCmd() {
9903
10149
  } catch (_) {}
9904
10150
  }
9905
10151
  if (!moved) return null;
10152
+ if (last > cap) return { state: "skipped", reason: "over-cap", trigger: moved, last_ms: last, cap_ms: cap };
9906
10153
 
9907
- const u = G.graphUpdate(claudeDir, root, { enabled, ifEnabled: true, ignore });
10154
+ const u = G.graphUpdate(claudeDir, root, { enabled: maintained, ifEnabled: true, ignore });
9908
10155
  if (u.exit !== 0) return { state: "skipped", reason: u.reason || u.state, trigger: moved };
9909
10156
  return { state: u.state, trigger: moved, added: u.added, changed: u.changed, deleted: u.deleted, generation: u.generation, ms: u.ms };
9910
10157
  };
@@ -9913,7 +10160,7 @@ function graphCmd() {
9913
10160
  const asPaths = (list) => list.map((x) => String(x).split("\\").join("/").replace(/^\.\//, "").replace(/:\d+$/, ""));
9914
10161
 
9915
10162
  if (sub === "coverage") {
9916
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10163
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
9917
10164
  const VALUE_FLAGS = new Set(["--dir"]);
9918
10165
  const plain = [];
9919
10166
  for (let i = 0; i < args.length; i++) {
@@ -9936,7 +10183,7 @@ function graphCmd() {
9936
10183
  ? `partial ${(x.ranges || []).map((g) => `${g[0]}-${g[1]}`).join(",")}`
9937
10184
  : x.coverage;
9938
10185
  const line =
9939
- `graph coverage (gen ${r.generation}) — ${plural(r.gaps, "gap")} in ${plural(r.rows.length, "path")}\n` +
10186
+ `graph parse coverage (gen ${r.generation}) — ${plural(r.gaps, "gap")} in ${plural(r.rows.length, "path")}\n` +
9940
10187
  r.rows.map((x) => ` ${x.path} ${LABEL(x)}${x.changed_since_index && x.changed_since_index !== "current" ? ` [${x.changed_since_index} since index]` : ""}`).join("\n") +
9941
10188
  "\n no recorded gap is not proof of completeness — the source is ground truth";
9942
10189
  return finish(r, line, `GRAPH-COVERAGE ${r.gaps ? "gaps" : "clean"} :: paths=${r.rows.length} gaps=${r.gaps} gen=${r.generation}`);
@@ -9944,7 +10191,7 @@ function graphCmd() {
9944
10191
 
9945
10192
  // ── E7: the cheap signals ──────────────────────────────────────────────────
9946
10193
  if (sub === "changes" || sub === "cochange") {
9947
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10194
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
9948
10195
  const VALUE_FLAGS = new Set(["--dir", "--base"]);
9949
10196
  const plain = [];
9950
10197
  for (let i = 0; i < args.length; i++) {
@@ -9980,9 +10227,18 @@ function graphCmd() {
9980
10227
  const Q = require("./graph-query.js");
9981
10228
  const m = Q.loadModel(claudeDir, root);
9982
10229
  if (m) {
9983
- const hits = r.symbols.reduce((a, x) => a + Q.nameHits(m, x.qname), 0);
9984
- const callerFiles = r.symbols.flatMap((x) => (x.callers || []).map((cc) => cc.file || cc));
9985
- gainRow = { cmd: "changes", targets: [r.base], gen: r.generation, ms: r.ms || 0, paid: { card: K.tok(line), source: 0, hints: 0 }, ...K.changesCost(m, r.symbols.length, hits, callerFiles) };
10230
+ // v2.2.0 (G-08): only the symbols a file OUTSIDE the diff calls are
10231
+ // priced, and a second read of the same diff in the same run adds 0.
10232
+ const priced = K.changesPriced(m, r.symbols, r.files, (x) => Q.nameHits(m, x.qname));
10233
+ const key = require("crypto").createHash("sha1").update(K.changesKey(r.symbols.map((x) => ({ id: x.id, body_hash: (m.symbols.get(x.id) || {}).body_hash })))).digest("hex").slice(0, 16);
10234
+ let run = null;
10235
+ try {
10236
+ run = fs.readFileSync(path.join(resolveLogDir(claudeDir), ".current"), "utf8").trim() || null;
10237
+ } catch (_) {}
10238
+ const repeat = K.changesSeen(claudeDir, run, key);
10239
+ const { priced: _n, ...cost } = priced;
10240
+ if (repeat) cost.avoided = { low: 0, high: 0, calls_low: 0, calls_high: 0 };
10241
+ gainRow = { cmd: "changes", targets: [r.base], gen: r.generation, ms: r.ms || 0, key, ...(repeat ? { repeat: true } : {}), paid: { card: K.tok(line), source: 0, hints: 0 }, ...cost };
9986
10242
  }
9987
10243
  } catch (_) {}
9988
10244
  }
@@ -9990,6 +10246,17 @@ function graphCmd() {
9990
10246
  }
9991
10247
  const file = plain[2];
9992
10248
  if (!file) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
10249
+ // v2.2.0 (G-15): under 20 commits there is no history to count — an
10250
+ // ANSWER (exit 4), given at once, before any `git log` is paid for.
10251
+ try {
10252
+ const rc = spawnSync("git", ["rev-list", "--count", "HEAD"], { cwd: root, encoding: "utf8" });
10253
+ const n = rc.status === 0 ? Number(String(rc.stdout).trim()) : NaN;
10254
+ if (Number.isFinite(n) && n < 20)
10255
+ return finish(
10256
+ { ok: true, state: "none", reason: "short-history", file: asPaths([file])[0], history: n, rows: [], exit: 4 },
10257
+ `cochange: history too short (${plural(n, "commit")}) — no answer`,
10258
+ `GRAPH-COCHANGE none :: history=${n}`);
10259
+ } catch (_) {}
9993
10260
  const r = SIG.graphCochange(claudeDir, root, { file });
9994
10261
  if (r.exit === 1) return finish(r, r.reason === "missing-operand" ? usage : `graph: unavailable — ${r.reason}`);
9995
10262
  const line = r.rows.length
@@ -10006,7 +10273,7 @@ function graphCmd() {
10006
10273
  // `lanes: []`). It reads the index, the file table and the first lines of
10007
10274
  // each empty file. It never parses, never writes, and costs no model tokens.
10008
10275
  if (sub === "audit") {
10009
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10276
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
10010
10277
  const A = require("./graph-audit.js");
10011
10278
  const r = A.audit(claudeDir, root, { top: eqFlag("--top") });
10012
10279
  if (r.exit === 1) return finish(r, "graph: none — no index yet (run: orc graph update)");
@@ -10072,7 +10339,7 @@ function graphCmd() {
10072
10339
  // has the runs to say so — what actually happened. Three different kinds of
10073
10340
  // knowing, never mixed into one number.
10074
10341
  if (sub === "gain") {
10075
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10342
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
10076
10343
  const K = require("./graph-gain.js");
10077
10344
  const U = require("./ui.js");
10078
10345
  const rng = (a, b) => (a === b ? kTok(a) : `${kTok(a)} – ${kTok(b)}`);
@@ -10150,6 +10417,7 @@ function graphCmd() {
10150
10417
  `\n avoided ~${rng(r.avoided.low, r.avoided.high)} tokens (estimate — the read ladder done well … done badly)` +
10151
10418
  `\n net ~${rng(r.net.low, r.net.high)} tokens · ~${r.calls.low} – ${r.calls.high} tool calls` +
10152
10419
  (byCmd ? `\n by command ${byCmd}` : "") +
10420
+ `\n outcomes ok ${(r.outcomes || {}).ok || 0} · empty ${(r.outcomes || {}).empty || 0} · declined ${(r.outcomes || {}).declined || 0} · error ${(r.outcomes || {}).error || 0}` +
10153
10421
  `\n hints ${r.hints.injected} injected · ${r.hints.read_notes} read notes · ${r.hints.updates} hook updates` +
10154
10422
  // R2 (v1.9.1): the reads the `on,read` hint would have named ranges for.
10155
10423
  // A COUNT, printed only when there is one — it is the data DE-8 waits
@@ -10174,7 +10442,7 @@ function graphCmd() {
10174
10442
  // files OR names, comma-separated or as bare words, so a planner can hand it
10175
10443
  // whatever the request happened to mention.
10176
10444
  if (sub === "map") {
10177
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10445
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
10178
10446
  const VALUE_FLAGS = new Set(["--dir", "--budget", "--focus"]);
10179
10447
  const plain = [];
10180
10448
  for (let i = 0; i < args.length; i++) {
@@ -10184,8 +10452,10 @@ function graphCmd() {
10184
10452
  }
10185
10453
  if (!String(args[i]).startsWith("--")) plain.push(args[i]);
10186
10454
  }
10455
+ // B1: `--focus=a,b` is the lane form; the old space form still works.
10456
+ const focusRaw = eqFlag("--focus") !== undefined ? eqFlag("--focus") : flag("--focus") === true ? "" : flag("--focus");
10187
10457
  const focus = [
10188
- ...String(flag("--focus") || "")
10458
+ ...String(focusRaw || "")
10189
10459
  .split(",")
10190
10460
  .map((x) => x.trim())
10191
10461
  .filter(Boolean),
@@ -10202,13 +10472,14 @@ function graphCmd() {
10202
10472
  // The caveat is INSIDE the card (`graph-query.js`), so the `--json` reader
10203
10473
  // gets it too. Appending it here would have shown it only to a human.
10204
10474
  const line = r.card;
10205
- const trace = `GRAPH-MAP ${focus.length ? "focused" : "repo"} :: files=${r.shown}/${r.total_files}${focus.length ? ` focus=${r.focus.join(",")}` : ""} gen=${r.generation}`;
10475
+ // v2.1.2 (F05): `focused` means the focus RESOLVED to files, never that a flag was passed.
10476
+ const trace = `GRAPH-MAP ${r.focus.length ? "focused" : "repo"} :: files=${r.shown}/${r.total_files}${r.focus.length ? ` focus=${r.focus.slice(0, 8).join(",")}${r.focus.length > 8 ? `,+${r.focus.length - 8}` : ""}` : ""} gen=${r.generation}`;
10206
10477
  const gainRow = r.exit === 0 ? gainRowFor("map", Q, model, focus, r, Date.now() - t0, false) : null;
10207
10478
  return finish(r, line, trace, false, gainRow);
10208
10479
  }
10209
10480
 
10210
10481
  if (sub === "ctx" || sub === "impact" || sub === "path") {
10211
- if (!enabled && flag("--if-enabled") === true) return finish({ ok: true, enabled: false, state: "off", exit: 3 }, "graph: off");
10482
+ if (!enabled && flag("--if-enabled") === true) return offGate(sub);
10212
10483
  // Positionals after the subcommand, with every value-flag's value removed.
10213
10484
  // D1: `--source` takes an OPTIONAL count, so it only swallows the next word
10214
10485
  // when that word is a number — otherwise `ctx --source handler` would lose
@@ -10224,7 +10495,8 @@ function graphCmd() {
10224
10495
  }
10225
10496
  if (!String(args[i]).startsWith("--")) plain.push(args[i]);
10226
10497
  }
10227
- const operands = plain.slice(2);
10498
+ // v2.2.0 (R10): `--for-slice a.js,b.vue` is split like `--refs=` — a path never holds a comma.
10499
+ const operands = plain.slice(2).flatMap((x) => String(x).split(",")).map((s) => s.trim()).filter(Boolean);
10228
10500
  const sourceLines = srcAt === -1 ? undefined : srcTakesValue ? Number(args[srcAt + 1]) : require("./graph-query.js").SOURCE_DEFAULT;
10229
10501
  // D2 is a SWITCH, so it is read from `args` — `flag()` would read the first
10230
10502
  // declared file as its value.
@@ -10249,7 +10521,9 @@ function graphCmd() {
10249
10521
  // heal that was skipped changed nothing. Either way the generation it pins
10250
10522
  // itself to is the one on disk now.
10251
10523
  let fast = null;
10252
- if (sub === "ctx" && !forSlice && operands.length === 1) {
10524
+ // R11: `ctx <symbol> --anchors` builds the slice anchors from the WHOLE
10525
+ // model, so it never takes the one-shard path.
10526
+ if (sub === "ctx" && !forSlice && operands.length === 1 && !args.includes("--anchors")) {
10253
10527
  try {
10254
10528
  fast = require("./graph-shard.js").fastModel(claudeDir, root, operands[0], { callersSource });
10255
10529
  } catch (_) {
@@ -10297,6 +10571,50 @@ function graphCmd() {
10297
10571
  // hint never pays for the same name twice (D3).
10298
10572
  r = Q.forSlice(model, operands, opts);
10299
10573
  if (r.ok) noteGraphNamesSeen(claudeDir, r.names);
10574
+ // v2.2.0 (T6-a): `--pack=<path>` writes the executor PACK — the outside
10575
+ // view, the reference outlines, the quoted ranges and the tests, in ONE
10576
+ // file — and the answer becomes the ONE line that points at it.
10577
+ const packPath = eqFlag("--pack");
10578
+ if (packPath) {
10579
+ const list = (x) => String(x || "").split(",").map((s) => s.trim()).filter(Boolean);
10580
+ if (!r.ok && r.state === "not-found")
10581
+ r = { ok: true, state: "found", view: "for-slice", format: "tree", files: [], missing: r.missing || operands, skipped: [], blocks: [], names: [], budget: { used: 0, max: Number(opts.budget) || 0 }, card: "", exit: 0 };
10582
+ const p = Q.buildPack(claudeDir, model, {
10583
+ declared: operands,
10584
+ refs: list(eqFlag("--refs")),
10585
+ focus: list(eqFlag("--focus")),
10586
+ budget: eqFlag("--pack-budget"),
10587
+ outlineBudget: eqFlag("--pack-outline"),
10588
+ sliceCard: r.files && r.files.length ? r.card : null,
10589
+ });
10590
+ if (!p.ok) r = p;
10591
+ else {
10592
+ const shown = String(packPath).split("\\").join("/");
10593
+ writeGraphPack(root, packPath, p.text);
10594
+ const gen = (model.meta && model.meta.generation) || 0;
10595
+ r.pack = { path: shown, files: p.files, refs: p.refs, excerpts: p.excerpts, tokens: p.tokens, cut: p.cut };
10596
+ r.card = `graph pack → ${shown} · ${p.files} files · ${p.refs.length} refs · ${p.excerpts} quoted ranges · ~${p.tokens} tokens · gen ${gen} — the executor reads it first`;
10597
+ r.pack_trace = `GRAPH-CONSULT pack :: files=${p.files} refs=${p.refs.length} excerpts=${p.excerpts} tokens=${p.tokens} gen=${gen}`;
10598
+ }
10599
+ }
10600
+ // v2.2.0 (R6): `--anchors` — no file. Up to 12 `path:a-b` ranges the
10601
+ // dig found and ONE shell command that prints them all; the card is the
10602
+ // anchor block the slice carries INLINE as `graph_anchors`.
10603
+ if (!packPath && args.includes("--anchors")) {
10604
+ const list = (x) => String(x || "").split(",").map((s) => s.trim()).filter(Boolean);
10605
+ if (!r.ok && r.state === "not-found")
10606
+ r = { ok: true, state: "found", view: "for-slice", format: "tree", files: [], missing: r.missing || operands, skipped: [], blocks: [], names: [], budget: { used: 0, max: Number(opts.budget) || 0 }, card: "", exit: 0 };
10607
+ const a = Q.buildAnchors(claudeDir, model, { declared: operands, refs: list(eqFlag("--refs")), focus: list(eqFlag("--focus")) });
10608
+ if (!a.ok) r = a;
10609
+ else {
10610
+ r.anchors = { lines: a.lines, read_cmd: a.read_cmd, code: a.code, tokens: a.tokens, read_tokens: a.read_tokens, ranges: a.ranges, files: a.files, refs: a.refs, refs_missing: a.refs_missing, missing: r.missing || [] };
10611
+ r.card = a.card;
10612
+ const miss = r.missing || [];
10613
+ const rmiss = a.refs_missing || [];
10614
+ r.anchors_line = `graph anchors: ${a.ranges} ranges in ${a.files} files · ~${a.read_tokens} tokens to read · gen ${a.gen}${a.code ? " · code inline" : ""}${miss.length ? ` · not found: ${miss[0]}${miss.length > 1 ? ` (+${miss.length - 1} more)` : ""}` : ""}${rmiss.length ? ` · ref not found: ${rmiss[0]}` : ""}`;
10615
+ r.pack_trace = `GRAPH-CONSULT anchors :: ranges=${a.ranges} files=${a.files} gen=${a.gen}`;
10616
+ }
10617
+ }
10300
10618
  } else if (operands.length === 1) {
10301
10619
  if (fast && fast.model) {
10302
10620
  try {
@@ -10317,6 +10635,21 @@ function graphCmd() {
10317
10635
  // is the only way to find out why.
10318
10636
  r.read = fast && fast.model ? "sharded" : "full";
10319
10637
  if (fast && !fast.model) r.read_fallback = fast.reason;
10638
+ // R11 (v2.2.0): `ctx <symbol> --anchors` — ONE look-time call gives the
10639
+ // card AND the slice anchors. Declared = the target file + the non-test
10640
+ // caller files the card names (dedup, max 10); focus defaults to the
10641
+ // target's name. Same shape as `--for-slice --anchors`.
10642
+ if (r.ok && r.target && args.includes("--anchors")) {
10643
+ const list = (x) => String(x || "").split(",").map((s) => s.trim()).filter(Boolean);
10644
+ const declared = [...new Set([r.target.file, ...(r.callers || []).map((c) => c.file).filter((f) => !Q.TEST_FILE.test(f))])].slice(0, 10);
10645
+ const focus = list(eqFlag("--focus"));
10646
+ const a = Q.buildAnchors(claudeDir, model, { declared, refs: list(eqFlag("--refs")), focus: focus.length ? focus : [r.target.name || String(r.target.qname || "").split(".").pop()] });
10647
+ if (a.ok) {
10648
+ r.anchors = { lines: a.lines, read_cmd: a.read_cmd, code: a.code, tokens: a.tokens, read_tokens: a.read_tokens, ranges: a.ranges, files: a.files, refs: a.refs, refs_missing: a.refs_missing, missing: [] };
10649
+ r.anchors_card = a.card;
10650
+ r.anchors_count = a.ranges;
10651
+ }
10652
+ }
10320
10653
  }
10321
10654
  else {
10322
10655
  // Several targets, ONE call, ONE budget (W9): a slice builder that had to
@@ -10353,7 +10686,14 @@ function graphCmd() {
10353
10686
  if (operands.length < 2) return finish({ ok: false, reason: "missing-operand", usage, exit: 1 }, usage);
10354
10687
  r = Q.pathBetween(model, operands[0], operands[1]);
10355
10688
  }
10356
- let line = r.card;
10689
+ let line = r.anchors_line || r.card;
10690
+ if (r.anchors_line) delete r.anchors_line;
10691
+ if (r.anchors_count !== undefined) {
10692
+ const lines = String(line || "").split("\n");
10693
+ lines[0] = `${lines[0]} · anchors ${r.anchors_count} ranges`;
10694
+ line = lines.join("\n");
10695
+ delete r.anchors_count;
10696
+ }
10357
10697
  if (line && r.complexity) line = `${line}\n ${r.complexity.line}`;
10358
10698
  if (!line && r.state === "not-found") {
10359
10699
  const near = (r.nearest || []).map((s) => `${s.qname} (${s.file}:${s.lines[0]})`);
@@ -10363,7 +10703,7 @@ function graphCmd() {
10363
10703
  if (sub === "path") line = "graph: path needs two known symbols — " + ((r.candidates || []).length ? `ambiguous: ${r.candidates.map((s) => `${s.qname} (${s.file})`).join(", ")}` : "one end was not found");
10364
10704
  }
10365
10705
  if (!line && r.state === "ambiguous-target")
10366
- line = `graph: "${r.query}" names ${r.candidates.length} symbols — pick one:\n` + r.candidates.map((s) => ` ${s.id}`).join("\n");
10706
+ line = `graph: "${r.query}" names ${r.candidates.length} ${r.candidates[0] && !r.candidates[0].id ? "files" : "symbols"} — pick one:\n` + r.candidates.map((s) => ` ${s.id || s.file}`).join("\n");
10367
10707
  if (healed) r.healed = healed;
10368
10708
  // A heal is a real event and it gets ONE line above the card — a card that
10369
10709
  // silently answers from a different generation than the reader expected is
@@ -10383,6 +10723,8 @@ ${line || ""}`;
10383
10723
  cx
10384
10724
  ? `GRAPH-COMPLEXITY ${cx.verdict} :: files=${cx.numbers.files} callers=${cx.numbers.callers_outside} caller_files=${cx.numbers.caller_files_outside}` +
10385
10725
  ` maybe=${cx.numbers.maybe} tests=${cx.numbers.tests_reaching} risk=${cx.numbers.risk.length} cochange=${cx.numbers.cochange_missing.length} gen=${r.generation || (r.facts && r.facts.generation) || 0}`
10726
+ : sub === "ctx" && r.exit === 0 && r.pack_trace
10727
+ ? r.pack_trace
10386
10728
  : sub === "ctx" && r.exit === 0
10387
10729
  ? `GRAPH-CONSULT ${r.view === "for-slice" ? "slice" : "card"} :: targets=${(r.view === "for-slice" ? r.files : r.cards ? r.cards.filter((c) => c.state === "found").map((c) => c.query) : [operands[0]]).join(",")}` +
10388
10730
  (healed && healed.state && healed.state !== "skipped" ? ` healed=${healed.trigger}` : "")
@@ -10397,7 +10739,8 @@ ${line || ""}`;
10397
10739
  // argument to it.
10398
10740
  }
10399
10741
  }
10400
- return finish(r, line, trace, !r.card, gainRow);
10742
+ if (r.pack_trace) delete r.pack_trace;
10743
+ return finish(r, line, trace, !r.card || !!r.pack || !!r.anchors, gainRow);
10401
10744
  }
10402
10745
 
10403
10746
  if (asJson) emitJson({ ok: false, reason: "unknown-subcommand", usage }, 1);
@@ -10810,6 +11153,34 @@ function stackTemplate(claudeDir, slugArg) {
10810
11153
  );
10811
11154
  }
10812
11155
 
11156
+ // v2.1.2 F19 — what counts as a placeholder an author still has to fill.
11157
+ // NOT one: anything inside an HTML comment (the skeleton's own instructions say
11158
+ // `fill every <...> in`) or a fenced code block (a picked PR template lives
11159
+ // there, and the driver fills it per layer); a generic type (`Promise<Order>` —
11160
+ // the `<` follows a word character); a closing or self-closing tag; the inline
11161
+ // HTML a Markdown author writes on purpose. A one-character hole (`<n>`) IS
11162
+ // one, and so is a paragraph that is one placeholder from its first character
11163
+ // to its last (the skeleton's `## Decisions` body).
11164
+ const STACK_INLINE_TAGS = new Set(["a", "b", "br", "code", "del", "details", "div", "em", "hr", "i", "img", "kbd", "li", "ol", "p", "pre", "s", "span", "strong", "sub", "summary", "sup", "table", "td", "th", "tr", "u", "ul"]);
11165
+ function stackHoles(text) {
11166
+ const src = String(text)
11167
+ .replace(/\r\n/g, "\n")
11168
+ .replace(/<!--[\s\S]*?-->/g, " ")
11169
+ .replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, " ");
11170
+ const holes = [];
11171
+ for (const m of src.matchAll(/<([^<>\n]{1,120})>/g)) {
11172
+ const inner = m[1];
11173
+ if (m.index > 0 && /\w/.test(src[m.index - 1])) continue;
11174
+ if (inner.startsWith("/") || inner.endsWith("/")) continue;
11175
+ const tag = /^([a-z][a-z0-9]*)(?:\s[^<>]*)?$/.exec(inner);
11176
+ if (tag && STACK_INLINE_TAGS.has(tag[1])) continue;
11177
+ holes.push(m[0]);
11178
+ }
11179
+ for (const m of src.matchAll(/^<[^<>]*\n[^<>]*>[ \t]*$/gm))
11180
+ holes.push(m[0].replace(/\s+/g, " ").slice(0, 60) + (m[0].length > 60 ? "…>" : ""));
11181
+ return holes;
11182
+ }
11183
+
10813
11184
  // Exit code IS the contract (same convention as `orc pattern status` /
10814
11185
  // `orc diy status`): 0 = READY, 1 = absent | unfilled. A driver branches on it
10815
11186
  // without parsing prose.
@@ -10821,7 +11192,7 @@ function stackProbe(claudeDir, slug) {
10821
11192
  if (!fs.existsSync(p.plan)) return { slug, ready: false, plan_path: p.plan, exists: false, problems: ["absent"] };
10822
11193
  const text = fs.readFileSync(p.plan, "utf8");
10823
11194
  const problems = [];
10824
- const holes = [...text.matchAll(/<[^<>\n]{2,60}>/g)].map((m) => m[0]);
11195
+ const holes = stackHoles(text);
10825
11196
  if (holes.length)
10826
11197
  problems.push(`${plural(holes.length, "unfilled placeholder")} (e.g. ${[...new Set(holes)].slice(0, 5).join(" ")})`);
10827
11198
  const ticket = /^-\s*ticket:\s*(.+)$/m.exec(text);
@@ -10876,30 +11247,18 @@ function stackStatus(claudeDir, slugArg) {
10876
11247
  );
10877
11248
  process.exit(1);
10878
11249
  }
10879
- const text = fs.readFileSync(p.plan, "utf8");
10880
- const problems = [];
10881
- // Unfilled placeholders — the whole reason a hand-filled plan needs a probe.
10882
- const holes = [...text.matchAll(/<[^<>\n]{2,60}>/g)].map((m) => m[0]);
10883
- if (holes.length) {
10884
- const uniq = [...new Set(holes)].slice(0, 5);
10885
- problems.push(`${plural(holes.length, "unfilled placeholder")} (e.g. ${uniq.join(" ")})`);
10886
- }
10887
- const ticket = /^-\s*ticket:\s*(.+)$/m.exec(text);
10888
- if (!ticket || !ticket[1].trim()) problems.push("no ticket");
10889
- // Layer sections: `## Layer <n> — <title>` (the schema's own heading shape).
10890
- const layers = [...text.matchAll(/^##\s+Layer\s+\d+\b/gm)].length;
10891
- if (layers < 2) problems.push(`${layers} layer section(s) — a stack needs 2+`);
10892
- if (!/^##\s+Decisions\s*$/m.test(text)) problems.push("no `## Decisions` section");
10893
- if (problems.length) {
11250
+ // v2.1.2 F19 — ONE engine: the human path renders the probe the JSON path emits.
11251
+ const probe = stackProbe(claudeDir, slug);
11252
+ if (probe.problems.length) {
10894
11253
  console.log(
10895
11254
  `✗ NOT READY — ${STACK_DIR}/${slug}/${STACK_PLAN_FILE}\n` +
10896
- problems.map((x) => " - " + x).join("\n") +
11255
+ probe.problems.map((x) => " - " + x).join("\n") +
10897
11256
  "\n Fill it in, or run `/orc-pr-setup` to have the layers planned for you."
10898
11257
  );
10899
11258
  process.exit(1);
10900
11259
  }
10901
11260
  console.log(
10902
- `✓ READY — ${STACK_DIR}/${slug}/${STACK_PLAN_FILE} (${plural(layers, "layer")}, ticket ${ticket[1].trim()})\n` +
11261
+ `✓ READY — ${STACK_DIR}/${slug}/${STACK_PLAN_FILE} (${plural(probe.layers, "layer")}, ticket ${probe.ticket})\n` +
10903
11262
  " Run `/orc-pr-driver` to build + submit the stack."
10904
11263
  );
10905
11264
  }
@@ -11950,6 +12309,33 @@ function resume() {
11950
12309
  // exit 2 unknown — cannot prove either way
11951
12310
  const INFLIGHT_STALE_MS = 6 * 60 * 60 * 1000;
11952
12311
 
12312
+ // v2.1.2 (F14, F20) — THE OPEN RUN, the one idea orc-trace.js, orc-read-gate.js
12313
+ // and orc-session-hook.js already share: `.current` names a trace, and the newer
12314
+ // of the trace's and the pointer's own mtime is younger than 6 hours. A pointer
12315
+ // older than that belongs to a run that ended without deleting it. `lane` is null
12316
+ // for the trace hook's own bootstrap name `run-<DDMMYY>-<HHMMSS>.txt`: no lane
12317
+ // registered that run. → { name, run, path, exists, lane, slug } | null
12318
+ function openRunPointer(claudeDir) {
12319
+ const dir = resolveLogDir(claudeDir);
12320
+ let name = null;
12321
+ try {
12322
+ name = fs.readFileSync(path.join(dir, ".current"), "utf8").trim() || null;
12323
+ } catch (_) {}
12324
+ if (!name) return null;
12325
+ const file = path.join(dir, name);
12326
+ const mt = (p) => {
12327
+ try {
12328
+ return fs.statSync(p).mtimeMs;
12329
+ } catch (_) {
12330
+ return 0;
12331
+ }
12332
+ };
12333
+ const last = Math.max(mt(file), mt(path.join(dir, ".current")));
12334
+ if (!last || Date.now() - last >= INFLIGHT_STALE_MS) return null;
12335
+ const m = TRACE_NAME.exec(name);
12336
+ return { name, run: name.replace(/\.txt$/, ""), path: file, exists: fs.existsSync(file), lane: m ? m[1] : null, slug: m ? m[2] : null };
12337
+ }
12338
+
11953
12339
  function runInflightCmd(claudeDir) {
11954
12340
  const dir = resolveLogDir(claudeDir);
11955
12341
  const out = {
@@ -12624,11 +13010,31 @@ const MODEL_FAMILIES = ["opus", "sonnet", "haiku", "fable"];
12624
13010
  const EFFORT_WORDS = new Set(["low", "med", "medium", "high", "xhigh", "max"]);
12625
13011
 
12626
13012
  // `orc-executor-opus-4-8-high` → `claude-opus-4-8`. An agent's model lives in its
12627
- // NAME by house rule (a model change is always a rename), so this needs no table.
13013
+ // NAME by house rule (a model change is always a rename) — EXCEPT a point
13014
+ // release inside one family, which kept the name on purpose (§4z.33 Opus 5.5,
13015
+ // §4z.39.1 Sonnet 5.5). v2.1.2 (F25): a SHIPPED agent answers with the `model:`
13016
+ // its own file pins, so `orc-executor-opus-5-low` is `claude-opus-5-5`. Any
13017
+ // other name falls back to the name rule.
13018
+ let PINNED_MODELS = null;
13019
+ function pinnedModels() {
13020
+ if (PINNED_MODELS) return PINNED_MODELS;
13021
+ PINNED_MODELS = {};
13022
+ try {
13023
+ const dir = path.join(TEMPLATES, "agents");
13024
+ for (const f of fs.readdirSync(dir)) {
13025
+ if (!f.endsWith(".md")) continue;
13026
+ const m = /^model:\s*(\S+)\s*$/m.exec(fs.readFileSync(path.join(dir, f), "utf8"));
13027
+ if (m) PINNED_MODELS[f.slice(0, -3)] = m[1];
13028
+ }
13029
+ } catch (_) {}
13030
+ return PINNED_MODELS;
13031
+ }
12628
13032
  function modelOf(name) {
12629
13033
  if (!name) return null;
12630
13034
  const s = String(name);
12631
13035
  if (s.startsWith("claude-")) return s;
13036
+ const pinned = pinnedModels()[s];
13037
+ if (pinned) return pinned;
12632
13038
  const parts = s.split("-");
12633
13039
  const i = parts.findIndex((p) => MODEL_FAMILIES.includes(p));
12634
13040
  if (i === -1) return null;
@@ -12851,6 +13257,11 @@ function listTraces(claudeDir) {
12851
13257
  // with the same MODEL whose timestamp is nearest and not after it by more than
12852
13258
  // the group's own duration. A group nothing can claim counts into `unattributed`
12853
13259
  // — which is ALWAYS printed, never silently dropped.
13260
+ //
13261
+ // v2.1.2 F62 — a point release inside one family joins its family's DISPATCH
13262
+ // line: `expect=opus-5/low` (the agent's NAME token) claims a `claude-opus-5-5`
13263
+ // block. The names kept the family token on purpose (§4z.33, §4z.39.1).
13264
+ const sameModel = (got, want) => got === want || String(got).startsWith(want + "-");
12854
13265
  function joinRun(trace, groups) {
12855
13266
  const out = { rows: [], unattributed: [] };
12856
13267
  if (!trace || trace.start === null) return out;
@@ -12861,7 +13272,7 @@ function joinRun(trace, groups) {
12861
13272
  let best = null;
12862
13273
  for (const g of inWindow) {
12863
13274
  if (g.claimed) continue;
12864
- if (want && g.model && modelOf(g.model) !== want) continue;
13275
+ if (want && g.model && !sameModel(modelOf(g.model), want)) continue;
12865
13276
  const dist = d.at === null ? 0 : Math.abs(g.start - d.at);
12866
13277
  if (!best || dist < best.dist) best = { g, dist };
12867
13278
  }
@@ -13135,17 +13546,17 @@ function parsePlanTasks(text) {
13135
13546
  // Fixed roles a run dispatches besides executors. Named, not guessed: the
13136
13547
  // forecast has to say WHICH roles it priced or the total is unfalsifiable.
13137
13548
  const LANE_FIXED_ROLES = {
13138
- orc: ["orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
13139
- ultra: ["orc-advisor-opus-5-xhigh", "orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-judge-opus-5-xhigh", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
13140
- mini: ["orc-analyze-mini-sonnet-5-high", "orc-planner-mini-sonnet-5-high", "orc-trace-writer-haiku-4-5"],
13141
- fast: ["orc-trace-writer-haiku-4-5"],
13549
+ orc: ["orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-5-high"],
13550
+ ultra: ["orc-advisor-opus-5-xhigh", "orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-judge-opus-5-xhigh", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-5-high"],
13551
+ mini: ["orc-analyze-mini-sonnet-5-high", "orc-planner-mini-sonnet-5-high", "orc-trace-writer-haiku-5-high"],
13552
+ fast: ["orc-trace-writer-haiku-5-high"],
13142
13553
  };
13143
13554
  const FORECAST_LANES = ["ultra", "orc", "mini", "fast"];
13144
13555
  const LANE_CMD = { orc: "/orc", ultra: "/orc-ultra", mini: "/orc-mini", fast: "/orc-fast" };
13145
13556
  // orc-mini and orc-fast dispatch ONE executor for the whole request — they do
13146
13557
  // not score per task. Rendering a per-band table for either would be a table the
13147
13558
  // lane never runs.
13148
- const LANE_ONE_EXECUTOR = { mini: "orc-executor-sonnet-5-high", fast: "orc-executor-sonnet-4-6-high" };
13559
+ const LANE_ONE_EXECUTOR = { mini: "orc-executor-sonnet-5-high", fast: "orc-executor-sonnet-5-med" };
13149
13560
 
13150
13561
  function laneForecast(lane, tasks, rates, cfg, claudeDir) {
13151
13562
  const rows = [];
@@ -13176,16 +13587,37 @@ function laneForecast(lane, tasks, rates, cfg, claudeDir) {
13176
13587
  }
13177
13588
  }
13178
13589
 
13590
+ // v2.1.2 F25 — every row is priced at the model THAT ROW runs on: a band at
13591
+ // its agent's pinned model, a foreign band from its provider's own dated rate,
13592
+ // a fixed role at its own model. One row with tokens and no rate makes the
13593
+ // lane's dollars null — never an Opus figure for work Opus will not do.
13594
+ const table = readPricingCache;
13595
+ const usd = { p50: 0, p90: 0 };
13596
+ let priceable = !!table;
13597
+ const addUsd = (v50, v90, model, extra) => {
13598
+ if (!v50) return;
13599
+ const rate = extra ? foreignRate(table, extra.provider, extra.model) : rateFor(table, model);
13600
+ if (!rate) {
13601
+ priceable = false;
13602
+ return;
13603
+ }
13604
+ const at = (v) => (v.input * rate.input + v.cache_write * rate.cache_write + v.cache_read * rate.cache_read + v.output * rate.output) / 1e6;
13605
+ usd.p50 += at(v50);
13606
+ usd.p90 += at(v90 || v50);
13607
+ };
13608
+
13179
13609
  for (const [band, g] of grouped) {
13180
13610
  const src = (rates && rates.bands && rates.bands[band]) || (g.agent && rates && rates.roles && rates.roles[g.agent]) || null;
13181
13611
  const samples = src ? src.samples : 0;
13182
13612
  if (!src || samples < minSamples) low++;
13183
13613
  const per50 = src ? src.p50 : null;
13184
13614
  const per90 = src ? src.p90 : null;
13185
- rows.push({ band, agent: g.agent, via: g.via || "claude", extra: g.extra || null, count: g.n, tasks: g.tasks, samples, p50: per50 ? mulVec(per50, g.n) : null, p90: per90 ? mulVec(per90, g.n) : null });
13615
+ const model = g.extra ? null : modelOf(g.agent);
13616
+ rows.push({ band, agent: g.agent, model, via: g.via || "claude", extra: g.extra || null, count: g.n, tasks: g.tasks, samples, p50: per50 ? mulVec(per50, g.n) : null, p90: per90 ? mulVec(per90, g.n) : null });
13186
13617
  if (per50) {
13187
13618
  Object.assign(p50, sumVec(p50, mulVec(per50, g.n)));
13188
13619
  Object.assign(p90, sumVec(p90, mulVec(per90 || per50, g.n)));
13620
+ addUsd(mulVec(per50, g.n), mulVec(per90 || per50, g.n), model, g.extra);
13189
13621
  }
13190
13622
  // Context risk: the p90 PEAK prompt for this band against the model's
13191
13623
  // window. Forecast before the wave, not after compaction.
@@ -13202,15 +13634,16 @@ function laneForecast(lane, tasks, rates, cfg, claudeDir) {
13202
13634
  const src = rates && rates.roles && rates.roles[role];
13203
13635
  if (!src) {
13204
13636
  lowRoles++;
13205
- fixed.push({ role, samples: 0, p50: null });
13637
+ fixed.push({ role, model: modelOf(role), samples: 0, p50: null });
13206
13638
  continue;
13207
13639
  }
13208
- fixed.push({ role, samples: src.samples, p50: src.p50, p90: src.p90 });
13640
+ fixed.push({ role, model: modelOf(role), samples: src.samples, p50: src.p50, p90: src.p90 });
13209
13641
  Object.assign(p50, sumVec(p50, src.p50));
13210
13642
  Object.assign(p90, sumVec(p90, src.p90));
13643
+ addUsd(src.p50, src.p90, modelOf(role), null);
13211
13644
  }
13212
13645
 
13213
- return { lane, cmd: LANE_CMD[lane], rows, fixed, p50, p90, low_confidence_bands: low, low_confidence_roles: lowRoles, context_risk: contextRisk };
13646
+ return { lane, cmd: LANE_CMD[lane], rows, fixed, p50, p90, usd: priceable ? usd : { p50: null, p90: null }, low_confidence_bands: low, low_confidence_roles: lowRoles, context_risk: contextRisk };
13214
13647
  }
13215
13648
 
13216
13649
  const mulVec = (v, n) => {
@@ -13252,8 +13685,12 @@ function budgetForecast(claudeDir, planPath) {
13252
13685
  const asView = typeof flag("--as") === "string" ? String(flag("--as")) : String(cfg.budget_units || "auto");
13253
13686
 
13254
13687
  if (!planPath) {
13255
- console.error("usage: orc budget forecast <plan-file> [--json] [--as tokens|usd|quota|context|all]");
13256
- process.exit(1);
13688
+ // v2.1.2 F16 — exit 1 is a low-confidence ANSWER on this route, so a missing
13689
+ // plan is the "no plan" refusal (3), and --json still gets its one object.
13690
+ const usage = "usage: orc budget forecast <plan-file> [--json] [--naive] [--as tokens|usd|quota|context|all]";
13691
+ if (asJson) emitJson({ ok: false, reason: "no-plan", path: null, hint: usage }, 3);
13692
+ console.error(usage);
13693
+ process.exit(3);
13257
13694
  }
13258
13695
  const root = repoRootOf(claudeDir);
13259
13696
  const abs = path.isAbsolute(planPath)
@@ -13287,8 +13724,9 @@ function budgetForecast(claudeDir, planPath) {
13287
13724
  const waves = Math.max(1, new Set(tasks.map((t) => t.depends_on.length)).size);
13288
13725
  const lanes = FORECAST_LANES.map((l) => laneForecast(l, tasks, rates, cfg, claudeDir));
13289
13726
  const primary = lanes.find((l) => l.lane === "orc");
13290
- const money50 = priceVector(claudeDir, primary.p50, "claude-opus-4-8");
13291
- const money90 = priceVector(claudeDir, primary.p90, "claude-opus-4-8");
13727
+ // v2.1.2 F25 — the dollars are the SUM of the rows, each at its own model.
13728
+ const money50 = { usd: primary.usd.p50, weighted: weightedTokens(primary.p50), raw: rawTokens(primary.p50) };
13729
+ const money90 = { usd: primary.usd.p90, weighted: weightedTokens(primary.p90), raw: rawTokens(primary.p90) };
13292
13730
  const quota = quotaView(table, cfg, money50.weighted);
13293
13731
  const risk = primary.context_risk;
13294
13732
  const code = risk.length ? 2 : primary.low_confidence_bands ? 1 : 0;
@@ -13320,7 +13758,7 @@ function budgetForecast(claudeDir, planPath) {
13320
13758
  cmd: l.cmd,
13321
13759
  raw: rawTokens(l.p50),
13322
13760
  weighted: weightedTokens(l.p50),
13323
- usd: priceVector(claudeDir, l.p50, "claude-opus-4-8").usd,
13761
+ usd: l.usd.p50,
13324
13762
  low_confidence_bands: l.low_confidence_bands,
13325
13763
  low_confidence_roles: l.low_confidence_roles,
13326
13764
  })),
@@ -13355,7 +13793,7 @@ function budgetForecast(claudeDir, planPath) {
13355
13793
  const stale = table && table._stale ? ` ⚠ price table ${table._age_days === null ? "undated" : table._age_days + " days old"} (> ${PRICE_STALE_DAYS})` : table ? ` price table ${table.as_of} (${table._age_days} days old ✓)` : "";
13356
13794
  console.log(
13357
13795
  `\nUSD ` +
13358
- (money50.usd === null ? "unavailable: no rate for this model in the price table" : `$${money50.usd.toFixed(2)} → $${money90.usd.toFixed(2)}${stale}`)
13796
+ (money50.usd === null ? "unavailable: a band or role has no rate in the price table" : `$${money50.usd.toFixed(2)} → $${money90.usd.toFixed(2)}${stale}`)
13359
13797
  );
13360
13798
  }
13361
13799
  if (show("quota"))
@@ -13390,7 +13828,7 @@ function budgetForecast(claudeDir, planPath) {
13390
13828
  console.log("\n lane raw p50 weighted usd p50");
13391
13829
  console.log(" " + "─".repeat(46));
13392
13830
  for (const l of lanes) {
13393
- const u = priceVector(claudeDir, l.p50, "claude-opus-4-8").usd;
13831
+ const u = l.usd.p50;
13394
13832
  console.log(
13395
13833
  ` ${(l.cmd || l.lane).padEnd(13)} ${(rawTokens(l.p50) ? kTok(rawTokens(l.p50)) : "—").padStart(8)} ${(rawTokens(l.p50) ? kTok(weightedTokens(l.p50)) : "—").padStart(11)} ${(u === null || !rawTokens(l.p50) ? "—" : "$" + u.toFixed(2)).padStart(10)}` +
13396
13834
  (l.low_confidence_bands + l.low_confidence_roles ? " floor" : "") +
@@ -13437,7 +13875,18 @@ function budgetActual(claudeDir, slugArg) {
13437
13875
  };
13438
13876
  });
13439
13877
  const unatt = j.unattributed.reduce((a, g) => sumVec(a, g.vec), { ...ZERO_VEC });
13440
- const money = priceVector(claudeDir, actual, "claude-opus-4-8");
13878
+ // v2.1.2 F25 — each joined dispatch at the model its transcript block names.
13879
+ let usdActual = 0;
13880
+ for (const row of j.rows) {
13881
+ if (!row.group) continue;
13882
+ const u = priceVector(claudeDir, row.group.vec, row.group.model).usd;
13883
+ if (u === null) {
13884
+ usdActual = null;
13885
+ break;
13886
+ }
13887
+ usdActual += u;
13888
+ }
13889
+ const money = { usd: usdActual, weighted: weightedTokens(actual), raw: rawTokens(actual) };
13441
13890
  const cacheShare = rawTokens(actual) ? actual.cache_read / rawTokens(actual) : 0;
13442
13891
 
13443
13892
  if (asJson)
@@ -13613,19 +14062,29 @@ function pactStateOf(root, e) {
13613
14062
  if (lc && lc.status === "fail") return { state: "BROKEN", why: `check failed at ${String(lc.commit || "").slice(0, 8)}${lc.at ? " (" + lc.at + ")" : ""}` };
13614
14063
  if (kind === "manual" || !e.check || !e.check.ref)
13615
14064
  return { state: "UNCHECKABLE", why: "no cheap check exists — this promise is held by review, not by a runner" };
13616
- const anchorFiles = (e.anchors || []).map((a) => String(a).split(":")[0]).filter(Boolean);
14065
+ const anchorFiles = [...new Set((e.anchors || []).map((a) => String(a).split(":")[0]).filter(Boolean))];
13617
14066
  if (!e.verified_commit) return { state: "DRIFTED", why: "never verified at a commit" };
13618
14067
  const argv = ["rev-list", "--count", `${e.verified_commit}..HEAD`];
13619
14068
  if (anchorFiles.length) argv.push("--", ...anchorFiles.slice(0, 100));
13620
14069
  const n = gitIn(root, argv);
13621
14070
  if (n === null || !/^\d+$/.test(n)) return { state: "UNCHECKABLE", why: `verified_commit ${String(e.verified_commit).slice(0, 8)} is not resolvable here` };
13622
14071
  const d = Number(n);
13623
- if (d > 0)
14072
+ if (d > 0) {
14073
+ // v2.1.2 F18 — name the files the commits TOUCHED, not every anchor. Two
14074
+ // anchors in one file are one file.
14075
+ const sha = String(e.verified_commit).slice(0, 8);
14076
+ if (!anchorFiles.length)
14077
+ return { state: "DRIFTED", why: `${plural(d, "commit")} since ${sha} — no anchors, so every commit counts`, distance: d, touched_files: [] };
14078
+ const log = gitIn(root, ["log", "--format=", "--name-only", `${e.verified_commit}..HEAD`, "--", ...anchorFiles.slice(0, 100)]) || "";
14079
+ const touched = [...new Set(log.split(/\r?\n/).map((s) => s.trim()).filter(Boolean))];
14080
+ const list = touched.slice(0, 3).join(", ") + (touched.length > 3 ? ", …" : "");
13624
14081
  return {
13625
14082
  state: "DRIFTED",
13626
- why: `${plural(d, "commit")} since ${String(e.verified_commit).slice(0, 8)} touched ${plural(anchorFiles.length, "anchored file")}`,
14083
+ why: `${plural(d, "commit")} since ${sha} touched ${touched.length} of ${plural(anchorFiles.length, "anchored file")}${list ? ` (${list})` : ""}`,
13627
14084
  distance: d,
14085
+ touched_files: touched,
13628
14086
  };
14087
+ }
13629
14088
  return { state: "HOLDING", why: `verified at ${String(e.verified_commit).slice(0, 8)}; no commit since has touched its anchors`, distance: 0 };
13630
14089
  }
13631
14090
 
@@ -13651,6 +14110,7 @@ function pactRows(claudeDir) {
13651
14110
  state: st.state,
13652
14111
  why: st.why,
13653
14112
  distance: st.distance === undefined ? null : st.distance,
14113
+ touched_files: st.touched_files || null,
13654
14114
  };
13655
14115
  });
13656
14116
  const live = rows.filter((r) => !r.retired);
@@ -13914,6 +14374,10 @@ function readBoundaryCards(claudeDir) {
13914
14374
  if (out !== null && /^\d+$/.test(out)) distance = Number(out);
13915
14375
  }
13916
14376
  const malformed = [];
14377
+ // v2.1.2 (F02): a list field that came back as a map is unreadable, never empty.
14378
+ for (const k of ["anchored_files", "checklist", "reasons"])
14379
+ if (h[k] && typeof h[k] === "object" && !Array.isArray(h[k]) && Object.keys(h[k]).length)
14380
+ malformed.push(`${k} is not a list — write \`- item\` lines or [a, b]`);
13917
14381
  if (!BOUNDARY_VERDICTS.includes(verdict)) malformed.push(`verdict must be one of ${BOUNDARY_VERDICTS.join(" | ")}`);
13918
14382
  if (verdict === "REFUSE" && !checklist.length)
13919
14383
  malformed.push("a REFUSE with no checklist is malformed — a boundary must name what would make it a yes");
@@ -14350,7 +14814,12 @@ function aftermathStatus(claudeDir) {
14350
14814
  process.exit(3);
14351
14815
  }
14352
14816
  const cut = Date.now() - windowDays * 86400000;
14353
- const inWindow = runs.filter((r) => r.mtime >= cut && r.slug);
14817
+ // v2.1.2 F20 — never grade the run that is still OPEN. /orc-aftermath writes
14818
+ // `.current` and touches its own trace at A0, so every report carried a
14819
+ // TOO_RECENT row about itself, and `history too shallow` (exit 3) could
14820
+ // never be reached from inside the lane.
14821
+ const open = openRunPointer(claudeDir);
14822
+ const inWindow = runs.filter((r) => r.mtime >= cut && r.slug && !(open && r.name === open.name));
14354
14823
  if (!inWindow.length) {
14355
14824
  if (asJson) emitJson({ ok: false, reason: "shallow", window_days: windowDays, log_dir: dir, runs: [] }, 3);
14356
14825
  console.log(`no runs in the last ${windowDays} days under ${dir} — history too shallow to grade.`);
@@ -17316,6 +17785,8 @@ const DOC_VALUE_FLAGS = [
17316
17785
  "--type", "--template", "--title", "--language", "--target", "--length",
17317
17786
  "--role", "--section", "--set", "--dir", "--budget", "--limit",
17318
17787
  "--only", "--confirm",
17788
+ // v2.1.2 — the checker's recorder
17789
+ "--checked", "--findings",
17319
17790
  // v0.49.2
17320
17791
  "--priority", "--text", "--set-file", "--reason", "--as", "--where", "--note", "--kind",
17321
17792
  ];
@@ -17802,6 +18273,9 @@ function docPartsView(claudeDir, slug) {
17802
18273
  hash,
17803
18274
  state: docPartState(rec, exists, hash),
17804
18275
  findings: rec.findings || 0,
18276
+ // v2.1.2 — how many edit rounds a FLAGGED section has already had. The
18277
+ // D8 cap of 2 is read from here, never from a model's memory.
18278
+ edit_rounds: rec.edit_rounds || 0,
17805
18279
  cycle: rec.cycle || null,
17806
18280
  subsections: (o.subsections || []).map((s) => {
17807
18281
  const part = src.parts.find((x) => x.sub === s.id);
@@ -19491,7 +19965,7 @@ function docPlanShape(claudeDir, slugArg, roleOverride) {
19491
19965
  } else if (role === "check") {
19492
19966
  // The hash is what turns a re-check from a full pass into a diff: a section
19493
19967
  // whose hash has not moved since it was checked does not need re-reading.
19494
- // In v2 a checker gets one bounded PART FILE, so there is no line arithmetic
19968
+ // In v2 a checker gets only the bounded PART FILES of one slice, so there is no line arithmetic
19495
19969
  // anywhere in the loop.
19496
19970
  items = v2
19497
19971
  ? pv.rows
@@ -20174,7 +20648,20 @@ function docPartsCmd(claudeDir, slugArg) {
20174
20648
  const prev = d.sections[row.id] || {};
20175
20649
  const parts = {};
20176
20650
  for (const pt of src.parts) if (pt.sub) parts[pt.sub] = pt.hash;
20177
- d.sections[row.id] = { ...prev, source_hash: row.hash, state: "written", cycle: d.cycle || 0, findings: prev.findings || 0, parts };
20651
+ // v2.1.2 — a hash that MOVED is a new body: the findings recorded against
20652
+ // the old one no longer describe it, so they are cleared and the section
20653
+ // goes back to the check. If it carried findings, that was an edit round.
20654
+ const moved = prev.source_hash !== row.hash;
20655
+ const rounds = (prev.edit_rounds || 0) + (moved && prev.findings > 0 ? 1 : 0);
20656
+ d.sections[row.id] = {
20657
+ ...prev,
20658
+ source_hash: row.hash,
20659
+ state: "written",
20660
+ cycle: d.cycle || 0,
20661
+ findings: moved ? 0 : prev.findings || 0,
20662
+ ...(rounds ? { edit_rounds: rounds } : {}),
20663
+ parts,
20664
+ };
20178
20665
  confirmed.push(row.id);
20179
20666
  }
20180
20667
  if (unknown.length || empty.length || drifted.length) {
@@ -20194,6 +20681,65 @@ function docPartsCmd(claudeDir, slugArg) {
20194
20681
  docWrite(claudeDir, slug, d);
20195
20682
  }
20196
20683
 
20684
+ // v2.1.2 — the CHECKER's recorder. `--confirm` was built for writer returns
20685
+ // only, so `checked` and `findings` were read in five places and written in
20686
+ // none: `orc doc next` named the paid `plan-check` forever.
20687
+ // --checked <ids> clean, or advisory (P2/P3) findings only
20688
+ // --findings <id>=<n>,… n = the P0+P1 count, n >= 1
20689
+ // Same refusal shape as `--confirm`: one bad id and NOTHING is written. Only a
20690
+ // part whose recorded hash still matches the file can carry a check result —
20691
+ // a verdict on a body nobody validated is a verdict on nothing.
20692
+ const idList = (raw) =>
20693
+ String(raw || "")
20694
+ .split(",")
20695
+ .map((s) => s.trim())
20696
+ .filter(Boolean);
20697
+ const wantChecked = idList(docOpt("--checked"));
20698
+ const wantFlagged = idList(docOpt("--findings"));
20699
+ const checked = [];
20700
+ const flagged = [];
20701
+ if (wantChecked.length || wantFlagged.length) {
20702
+ const view = docPartsView(claudeDir, slug);
20703
+ const d = view.d;
20704
+ const unknown = [];
20705
+ const bad = [];
20706
+ const refused = [];
20707
+ const take = (want, n) => {
20708
+ const row = view.rows.find((r) => r.id === want || r.id.startsWith(want));
20709
+ if (!row) return void unknown.push(want);
20710
+ if (row.state !== "written" && row.state !== "checked") return void refused.push({ id: row.id, state: row.state });
20711
+ if (checked.includes(row.id) || flagged.some((f) => f.id === row.id)) return void bad.push(want);
20712
+ if (n) flagged.push({ id: row.id, findings: n });
20713
+ else checked.push(row.id);
20714
+ };
20715
+ for (const want of wantChecked) take(want, 0);
20716
+ for (const pair of wantFlagged) {
20717
+ const m = /^(.+)=(\d+)$/.exec(pair);
20718
+ if (!m || Number(m[2]) < 1) bad.push(pair);
20719
+ else take(m[1].trim(), Number(m[2]));
20720
+ }
20721
+ if (unknown.length || bad.length || refused.length) {
20722
+ const hint =
20723
+ (unknown.length ? `no such section: ${unknown.join(", ")}. ` : "") +
20724
+ (bad.length
20725
+ ? `not a check result: ${bad.join(", ")} — --findings takes <id>=<n> with n >= 1, and a section is named once. `
20726
+ : "") +
20727
+ (refused.length
20728
+ ? `no validated return on record for: ` +
20729
+ refused.map((x) => `${x.id} (${x.state})`).join(", ") +
20730
+ ` — a check result is recorded only against a confirmed hash. Run \`orc doc parts ${slug} --confirm <ids>\` first. `
20731
+ : "") +
20732
+ "Nothing was written.";
20733
+ const reason = unknown.length ? "no-such-section" : bad.length ? "bad-findings" : "not-confirmed";
20734
+ if (asJson) emitJson({ ok: false, reason, slug, unknown, bad, refused, checked: [], flagged: [], hint }, 1);
20735
+ console.error("❌ " + hint);
20736
+ process.exit(1);
20737
+ }
20738
+ for (const id of checked) d.sections[id] = { ...(d.sections[id] || {}), state: "checked", findings: 0, checked_cycle: d.cycle || 0 };
20739
+ for (const f of flagged) d.sections[f.id] = { ...(d.sections[f.id] || {}), state: "written", findings: f.findings };
20740
+ docWrite(claudeDir, slug, d);
20741
+ }
20742
+
20197
20743
  const view = docPartsView(claudeDir, slug);
20198
20744
  const d = view.d;
20199
20745
  const wave = docWaveState(d, view.rows);
@@ -20207,8 +20753,10 @@ function docPartsCmd(claudeDir, slugArg) {
20207
20753
  dir: path.relative(view.paths.root, view.paths.sections).split(path.sep).join("/"),
20208
20754
  front: view.front,
20209
20755
  confirmed: confirm.length ? confirm : [],
20210
- parts: view.rows.map(({ id, heading, required, files, nested, exists, lines, hash, state, subsections, ordinal_ok, findings }) => ({
20211
- id, heading, required, files, nested, exists, lines, hash, state, subsections, ordinal_ok, findings,
20756
+ checked,
20757
+ flagged,
20758
+ parts: view.rows.map(({ id, heading, required, files, nested, exists, lines, hash, state, subsections, ordinal_ok, findings, edit_rounds }) => ({
20759
+ id, heading, required, files, nested, exists, lines, hash, state, subsections, ordinal_ok, findings, edit_rounds,
20212
20760
  })),
20213
20761
  total: view.rows.length,
20214
20762
  written: view.rows.filter((r) => r.state === "written" || r.state === "checked").length,
@@ -21878,7 +22426,26 @@ function docNextAction(claudeDir, slug) {
21878
22426
  // THE FREE CHECK ALWAYS RUNS BEFORE THE PAID ONE. `orc doc lint` costs zero
21879
22427
  // tokens and its findings ride in the checker's slice, so no model is ever
21880
22428
  // paid to count sentences.
21881
- const writtenSince = (v2 ? pv.rows : view.sections).filter((s) => s.state === "written").length;
22429
+ //
22430
+ // v2.1.2 — a section the checker FLAGGED goes to the edit, not back to the
22431
+ // check. And the cap of 2 rounds is a human decision, not a third round.
22432
+ const flagged = pv.rows.filter((r) => r.state === "written" && r.findings > 0);
22433
+ const capped = flagged.filter((r) => r.edit_rounds >= 2);
22434
+ if (capped.length)
22435
+ return BLOCK(
22436
+ "D8",
22437
+ `${capped[0].heading}: 2 edit rounds, and the checker still reports ${capped[0].findings} blocking finding(s). Accept it, or say what should change.`,
22438
+ [`orc doc parts ${slug} --checked ${capped[0].id}`, `orc doc log ${slug} --kind gap --text "<what stays open>"`]
22439
+ );
22440
+ if (flagged.length)
22441
+ return A(
22442
+ "D8",
22443
+ "plan-edit",
22444
+ `orc doc plan ${slug} --role edit --only ${flagged.map((r) => r.id).join(",")} --json`,
22445
+ `${plural(flagged.length, "section")} ${flagged.length === 1 ? "carries" : "carry"} blocking checker findings`,
22446
+ true
22447
+ );
22448
+ const writtenSince = (v2 ? pv.rows : view.sections).filter((s) => s.state === "written" && !s.findings).length;
21882
22449
  if (lint && lint.errors)
21883
22450
  return A("D7", "lint", `orc doc lint ${slug} --json`, `${plural(lint.errors, "lint error")} — the free check runs before the paid one`, false, [
21884
22451
  `orc doc map ${slug} --json`,
@@ -22550,7 +23117,8 @@ function doc() {
22550
23117
  " orc doc status <slug> [--json] 0 nothing to do / 1 something to do / 2 unknown slug\n" +
22551
23118
  " orc doc show <slug> [--json] full state: sections, cycles, extracts\n" +
22552
23119
  " orc doc map <slug> [--json] the DERIVED section map (fresh line numbers)\n" +
22553
- " orc doc parts <slug> [--confirm <ids>] the SECTION FILES (works before any compile)\n" +
23120
+ " orc doc parts <slug> [--confirm <ids>] [--checked <ids>] [--findings <id>=<n>,…]\n" +
23121
+ " the SECTION FILES (works before any compile)\n" +
22554
23122
  " orc doc compile <slug> [--partial] sections/ -> document.md. FREE, on demand\n" +
22555
23123
  " orc doc split <slug> [--section <id> --by-heading] document -> sections/, or a section -> parts\n" +
22556
23124
  " orc doc migrate <slug> [--clean] v1 -> v2, lazy and non-destructive\n" +
@@ -26682,7 +27250,7 @@ const EXTRA_SLOTS = [
26682
27250
  {
26683
27251
  slot: "quick-executor",
26684
27252
  lane: "/orc-quick",
26685
- claude: ["orc-executor-sonnet-4-6-med", "orc-executor-opus-5-low"],
27253
+ claude: ["orc-executor-sonnet-5-med", "orc-executor-opus-5-low"],
26686
27254
  claude_opus5: ["orc-executor-opus-5-low"],
26687
27255
  asks: true,
26688
27256
  announce: "the dispatch gate — offered as a third option, never a default",
@@ -26691,8 +27259,8 @@ const EXTRA_SLOTS = [
26691
27259
  {
26692
27260
  slot: "fast-executor",
26693
27261
  lane: "/orc-fast",
26694
- claude: ["orc-executor-sonnet-4-6-high"],
26695
- claude_opus5: ["orc-executor-opus-5-med"],
27262
+ claude: ["orc-executor-sonnet-5-med"],
27263
+ claude_opus5: ["orc-executor-opus-5-low"],
26696
27264
  asks: false,
26697
27265
  announce: "the F0 preflight `extra:` line, before wave 1",
26698
27266
  why: "one executor, one slice, a build+test smoke gate behind it — the checks that catch a bad implementation here are engine-blind.",
@@ -26713,7 +27281,7 @@ const EXTRA_SLOTS = [
26713
27281
  claude_opus5: null,
26714
27282
  asks: false,
26715
27283
  announce: "before the wave",
26716
- why: "the checker reads one bounded part and reports; it rewrites nothing, so a finding it makes is a finding you read.",
27284
+ why: "the checker reads only the bounded part files of one slice and reports; it rewrites nothing, so a finding it makes is a finding you read.",
26717
27285
  },
26718
27286
  // v1.5.0 - `/orc-test`'s DESIGNER, and only the designer. The interpreter
26719
27287
  // deliberately gets NO slot: its slice is CAPTURED RESPONSE BODIES FROM THE
@@ -30165,7 +30733,7 @@ function extraResumeTarget(v, result, cfg) {
30165
30733
  const EXTRA_FALLBACK_ALTERNATES = [
30166
30734
  { agent: "orc-executor-opus-5-med", why: "more thinking than most bands buy, without the top-tier cost." },
30167
30735
  { agent: "orc-executor-opus-5-low", why: "the same model on the cheapest rung — right when the slice turned out to be mechanical." },
30168
- { agent: "orc-executor-sonnet-4-6-high", why: "a different model entirely, for a slice where Opus is not the constraint." },
30736
+ { agent: "orc-executor-sonnet-5-high", why: "a different model entirely, for a slice where Opus is not the constraint." },
30169
30737
  ];
30170
30738
 
30171
30739
  function extraFallbackChoice(cfg, claude) {
@@ -35178,6 +35746,14 @@ function doctor() {
35178
35746
  (arr || []).some((e) => (e.hooks || []).some((h) => typeof h.command === "string" && h.command.includes(needle)));
35179
35747
  if (hasCmd(hooks.PreToolUse, "orc-effort-guard")) ok("effort guard wired (PreToolUse)");
35180
35748
  else warn("effort-guard-unwired", "effort guard NOT wired — /orc won't be effort-gated; run `orc update`", { fixable: true });
35749
+ const typedEntry = (hooks.UserPromptExpansion || []).find((e) =>
35750
+ (e.hooks || []).some((h) => typeof h.command === "string" && h.command.includes("orc-effort-guard"))
35751
+ );
35752
+ if (!typedEntry)
35753
+ warn("typed-gate-unwired", "a typed /orc or /orc-diy is NOT effort-gated (no UserPromptExpansion entry) — run `orc update`", { fixable: true, fix_command: "orc update" });
35754
+ else if (typedEntry.matcher !== TYPED_GATE_MATCHER)
35755
+ warn("typed-gate-matcher", `typed-command gate matcher is "${typedEntry.matcher || ""}" — needs "${TYPED_GATE_MATCHER}"; run \`orc update\``, { fixable: true, fix_command: "orc update" });
35756
+ else ok(`typed /orc + /orc-diy gate wired (UserPromptExpansion ${TYPED_GATE_MATCHER})`);
35181
35757
  const traceEntry = (hooks.PreToolUse || []).find((e) =>
35182
35758
  (e.hooks || []).some((h) => typeof h.command === "string" && h.command.includes("orc-trace"))
35183
35759
  );
@@ -35194,7 +35770,7 @@ function doctor() {
35194
35770
  else warn("trace-return-unwired", "trace RETURN hook NOT wired (SubagentStop) — run `orc update`", { fixable: true });
35195
35771
  if (settings.statusLine && typeof settings.statusLine.command === "string") {
35196
35772
  if (settings.statusLine.command.includes("orc-statusline")) ok("statusline is ORC's model warning");
35197
- else ok("statusline present (yours — ORC left it untouched)");
35773
+ else ok("statusline present (yours — ORC left it untouched) · a typed /orc reads its effort from ORC's line, so it is not effort-gated here");
35198
35774
  } else
35199
35775
  warn("statusline-missing", "no statusLine — the non-Opus/high model warning won't show; run `orc update`", {
35200
35776
  fixable: true,
@@ -35384,10 +35960,12 @@ function doctor() {
35384
35960
  // graph nobody has asked for yet is a doctor people learn to scroll past.
35385
35961
  try {
35386
35962
  const cfg = resolvedConfig(claudeDir);
35387
- if (String(cfg.code_graph || "off") !== "on") ok("code graph off (fine — the default)");
35963
+ const dMode = String(cfg.code_graph || "off").toLowerCase();
35964
+ if (dMode !== "on" && dMode !== "semi") ok("code graph off (fine — the default)");
35388
35965
  else {
35389
35966
  const gs = require("./graph.js").graphStatus(claudeDir, repoRootOf(claudeDir), { enabled: true });
35390
- if (gs.state === "drifted") {
35967
+ if (dMode === "semi") ok("code graph semi — kept current, lanes do not read it");
35968
+ else if (gs.state === "drifted") {
35391
35969
  const b = gs.behind || {};
35392
35970
  const n = (b.added || 0) + (b.changed || 0) + (b.deleted || 0);
35393
35971
  warn(
@@ -35416,13 +35994,14 @@ function doctor() {
35416
35994
  ["PreToolUse", "Grep|Glob"],
35417
35995
  ["PreToolUse", "Bash|Read"],
35418
35996
  ["PostToolUse", "Read"],
35997
+ ["PostToolUse", "Task|Agent"],
35419
35998
  ];
35420
35999
  const wired = ENTRIES.filter(([e, m]) => wiredOn(e, m)).map(([e, m]) => (m ? `${e} ${m}` : e));
35421
- if (wired.length === ENTRIES.length) ok("graph hook wired on all five entries");
36000
+ if (wired.length === ENTRIES.length) ok("graph hook wired on all six entries");
35422
36001
  else
35423
36002
  warn(
35424
36003
  "graph-hook-unwired",
35425
- `graph hook wired on ${wired.length} of 5 entries (${wired.join(", ") || "none"}) — the graph will not update itself when an executor finishes, and subagents get no anchors; run \`orc update\``,
36004
+ `graph hook wired on ${wired.length} of ${ENTRIES.length} entries (${wired.join(", ") || "none"}) — the graph will not update itself when an executor finishes, and subagents get no anchors; run \`orc update\``,
35426
36005
  { fixable: true, fix: "orc update", fix_command: "orc update" }
35427
36006
  );
35428
36007
  }
@@ -41685,6 +42264,10 @@ Usage:
41685
42264
  … --confirm <id,id> record a VALIDATED RETURN's hash. Until then a file on
41686
42265
  disk is \`unconfirmed\`: a wave killed by a usage limit
41687
42266
  leaves exactly that, and it is re-written, never shipped
42267
+ … --checked <id,id> record a CLEAN CHECK (advisory findings only). The part
42268
+ stays \`checked\` only while its hash holds
42269
+ … --findings <id>=<n>,… record the BLOCKING (P0+P1) count per part. \`orc doc next\`
42270
+ then names the edit; after 2 rounds it asks you instead
41688
42271
  orc doc compile <slug> [--partial] sections/ → document.md. ZERO model tokens, on demand.
41689
42272
  --partial writes what exists and NAMES what is missing;
41690
42273
  nothing is ever stubbed into the deliverable
@@ -41846,6 +42429,9 @@ Usage:
41846
42429
  and where its safe point is
41847
42430
  orc wait plan <spec> [--json] turn 30 · 90m · 2h · until 18:41 · reset into hops
41848
42431
  [--hop <min>] [--max-hops <n>] (exit 0 planned / 1 unparsable / 2 no usage reading)
42432
+ orc wait start <spec> --mode <m> record the wait that starts now and write its WAIT line
42433
+ (no run in flight = recorded: false, exit 0) [--json]
42434
+ orc wait end --reason <why> close it: elapsed | recovered | cancelled | max-hops [--json]
41849
42435
  orc onboarding [<topic>] guided walkthrough (menu on a TTY; prints all when piped)
41850
42436
  topics: overview, install, first-run, lanes,
41851
42437
  config, knowledge, upgrade, troubleshooting
@@ -42028,22 +42614,17 @@ function waitHops(totalMinutes, hopMinutes, maxHops) {
42028
42614
  return { hops, uncovered_minutes: left, truncated: left > 0 };
42029
42615
  }
42030
42616
 
42031
- function waitPlanCmd(claudeDir) {
42032
- const asJson = wantsJson();
42033
- const pos = positionals().slice(2);
42034
- const spec = pos.join(" ");
42035
- const now = Date.now();
42617
+ // v2.1.2 F10 — the parse and the hop arithmetic, ONCE. `wait plan` renders this
42618
+ // and `wait start` records it; two copies of the arithmetic would be two ideas
42619
+ // of when a wait ends. A refusal comes back as { ok:false, exit, reason, hint }
42620
+ // and the caller prints it.
42621
+ function waitPlanCompute(claudeDir, spec, now) {
42036
42622
  const cfg = resolvedConfig(claudeDir);
42037
42623
  const hopMin = Number(flag("--hop")) || Number(cfg.wait_hop_minutes) || WAIT_HOP_DEFAULT_MIN;
42038
42624
  const maxHops = Number(flag("--max-hops")) || Number(cfg.wait_max_hops) || WAIT_MAX_HOPS_DEFAULT;
42039
42625
 
42040
42626
  const parsed = waitParseSpec(spec, now);
42041
- if (!parsed.ok) {
42042
- if (asJson) emitJson({ ok: false, reason: parsed.reason, spec, hint: parsed.hint }, 1);
42043
- console.error(ui.mark.err(`I cannot read "${spec}" as a wait.`));
42044
- console.error(" " + parsed.hint);
42045
- process.exit(1);
42046
- }
42627
+ if (!parsed.ok) return { ok: false, exit: 1, reason: parsed.reason, spec, hint: parsed.hint };
42047
42628
 
42048
42629
  let minutes = parsed.minutes;
42049
42630
  let source = parsed.kind;
@@ -42056,15 +42637,14 @@ function waitPlanCmd(claudeDir) {
42056
42637
  const why = reading
42057
42638
  ? "the reading has no usable reset time"
42058
42639
  : "there is no reading in the last 30 minutes";
42059
- if (asJson)
42060
- emitJson(
42061
- { ok: false, reason: "no-reading", spec, hint: `${why}. Type a time instead. Example: orc wait plan 45` },
42062
- 2
42063
- );
42064
- console.error(ui.mark.err("I cannot read the reset time."));
42065
- console.error(" " + why + ".");
42066
- console.error(" Type a time instead. Example: " + ui.color.cyan("orc wait plan 45"));
42067
- process.exit(2);
42640
+ return {
42641
+ ok: false,
42642
+ exit: 2,
42643
+ reason: "no-reading",
42644
+ spec,
42645
+ why,
42646
+ hint: `${why}. Type a time instead. Example: orc wait plan 45`,
42647
+ };
42068
42648
  }
42069
42649
  minutes = Math.max(1, Math.round((at - now) / 60000));
42070
42650
  source = "reset";
@@ -42072,7 +42652,7 @@ function waitPlanCmd(claudeDir) {
42072
42652
 
42073
42653
  const h = waitHops(minutes, hopMin, maxHops);
42074
42654
  const endsAt = new Date(now + minutes * 60000);
42075
- const out = {
42655
+ return {
42076
42656
  ok: true,
42077
42657
  spec,
42078
42658
  source,
@@ -42090,16 +42670,38 @@ function waitPlanCmd(claudeDir) {
42090
42670
  modes: WAIT_MODES,
42091
42671
  note: "A detached command does the waiting. No model runs, and no tokens are spent.",
42092
42672
  };
42673
+ }
42674
+
42675
+ // The two refusals of the plan, in the human voice. Shared by `plan` and `start`
42676
+ // so the same mistake reads the same way in both.
42677
+ function waitPlanRefuse(plan, asJson) {
42678
+ if (asJson) emitJson({ ok: false, reason: plan.reason, spec: plan.spec, hint: plan.hint }, plan.exit);
42679
+ if (plan.exit === 2) {
42680
+ console.error(ui.mark.bad("I cannot read the reset time."));
42681
+ console.error(" " + plan.why + ".");
42682
+ console.error(" Type a time instead. Example: " + ui.color.cyan("orc wait plan 45"));
42683
+ } else {
42684
+ console.error(ui.mark.bad(`I cannot read "${plan.spec}" as a wait.`));
42685
+ console.error(" " + plan.hint);
42686
+ }
42687
+ process.exit(plan.exit);
42688
+ }
42689
+
42690
+ function waitPlanCmd(claudeDir) {
42691
+ const asJson = wantsJson();
42692
+ const spec = positionals().slice(2).join(" ");
42693
+ const out = waitPlanCompute(claudeDir, spec, Date.now());
42694
+ if (!out.ok) waitPlanRefuse(out, asJson);
42093
42695
  if (asJson) emitJson(out, 0);
42094
42696
 
42095
42697
  console.log(ui.header("ORC · wait — the plan"));
42096
42698
  console.log("");
42097
- console.log(` ${ui.color.cyan("length")} ${minutes} min (from ${source})`);
42098
- console.log(` ${ui.color.cyan("ends at")} ${endsAt.toTimeString().slice(0, 5)}`);
42099
- console.log(` ${ui.color.cyan("hops")} ${h.hops.join(" + ")} (${h.hops.length} of ${maxHops} max)`);
42100
- if (h.truncated)
42699
+ console.log(` ${ui.color.cyan("length")} ${out.minutes} min (from ${out.source})`);
42700
+ console.log(` ${ui.color.cyan("ends at")} ${new Date(out.ends_at).toTimeString().slice(0, 5)}`);
42701
+ console.log(` ${ui.color.cyan("hops")} ${out.hops.join(" + ")} (${out.hop_count} of ${out.max_hops} max)`);
42702
+ if (out.truncated)
42101
42703
  console.log(
42102
- ui.mark.warn(` ${h.uncovered_minutes} min are NOT covered — wait_max_hops is ${maxHops}.`)
42704
+ ui.mark.warn(` ${out.uncovered_minutes} min are NOT covered — wait_max_hops is ${out.max_hops}.`)
42103
42705
  );
42104
42706
  if (out.crosses_cache_ttl)
42105
42707
  console.log(
@@ -42691,7 +43293,7 @@ function waitBlockCmd(claudeDir, unblock) {
42691
43293
  ? `no run named "${slug}" under ${resolveRunDir(claudeDir)}`
42692
43294
  : "no run is in flight, and no slug was given";
42693
43295
  if (asJson) emitJson({ ok: false, reason: "no-run", slug: slug || null, hint }, 2);
42694
- console.error(ui.mark.err("I cannot find the run this is about."));
43296
+ console.error(ui.mark.bad("I cannot find the run this is about."));
42695
43297
  console.error(" " + hint + ".");
42696
43298
  process.exit(2);
42697
43299
  }
@@ -42708,7 +43310,9 @@ function waitBlockCmd(claudeDir, unblock) {
42708
43310
  block_reason: null,
42709
43311
  unblocked_at: new Date().toISOString(),
42710
43312
  });
42711
- const trace = waitTraceLine(claudeDir, "WAIT", "unblock");
43313
+ // v2.1.2 F11 — the sub-form is part of the VERB (`WAIT block :: …`), the
43314
+ // shape TRACE_VERBS declares.
43315
+ const trace = waitTraceLine(claudeDir, "WAIT unblock", null);
42712
43316
  if (asJson) emitJson({ ok: true, slug: run.slug, blocked: false, trace_line: trace }, 0);
42713
43317
  console.log(ui.mark.ok(`${run.slug}: the gate is live again.`));
42714
43318
  return;
@@ -42728,7 +43332,7 @@ function waitBlockCmd(claudeDir, unblock) {
42728
43332
  },
42729
43333
  1
42730
43334
  );
42731
- console.error(ui.mark.err("A block needs a reason."));
43335
+ console.error(ui.mark.bad("A block needs a reason."));
42732
43336
  console.error(' orc wait block ' + run.slug + ' --reason "window resets in 5m, task needs 10"');
42733
43337
  process.exit(1);
42734
43338
  }
@@ -42738,7 +43342,7 @@ function waitBlockCmd(claudeDir, unblock) {
42738
43342
  block_reason: reason,
42739
43343
  unblocked_at: null,
42740
43344
  });
42741
- const trace = waitTraceLine(claudeDir, "WAIT", `block reason="${reason}" by=user`);
43345
+ const trace = waitTraceLine(claudeDir, "WAIT block", `reason="${reason}" by=user`);
42742
43346
  if (asJson)
42743
43347
  emitJson(
42744
43348
  { ok: true, slug: run.slug, blocked: true, blocked_at: st.blocked_at, reason, trace_line: trace },
@@ -42754,6 +43358,163 @@ function waitBlockCmd(claudeDir, unblock) {
42754
43358
  );
42755
43359
  }
42756
43360
 
43361
+ // v2.1.2 F10 — a wait's activity is COMPUTED from its start and the clock, never
43362
+ // stored. A session that died mid-wait writes no `wait_ended_at`, so a stored
43363
+ // flag would read `waiting` for ever; one hop of grace after `ends_at` is the
43364
+ // last moment a live wait can still be hopping.
43365
+ function waitActive(st, now) {
43366
+ const s = st || {};
43367
+ const t = typeof now === "number" ? now : Date.now();
43368
+ const started = !!s.wait_started_at;
43369
+ const ended = !!s.wait_ended_at;
43370
+ const grace = (Number(s.hop_minutes) || WAIT_HOP_DEFAULT_MIN) * 60000;
43371
+ const endsAt = Date.parse(s.ends_at);
43372
+ const active = started && !ended && Number.isFinite(endsAt) && t <= endsAt + grace;
43373
+ const hops = Array.isArray(s.hops) ? s.hops : [];
43374
+ const planned = Number(s.hops_planned) || hops.length;
43375
+ let done = 0;
43376
+ if (ended) done = Number(s.hops_done) || 0;
43377
+ else if (started) {
43378
+ // A hop is done when the clock has passed its cumulative boundary.
43379
+ const elapsed = (t - Date.parse(s.wait_started_at)) / 60000;
43380
+ let sum = 0;
43381
+ for (const h of hops) {
43382
+ sum += Number(h) || 0;
43383
+ if (sum <= elapsed) done++;
43384
+ }
43385
+ done = Math.min(done, planned);
43386
+ }
43387
+ return { started, ended, active, stale: started && !ended && !active, hops_done: done, hops_planned: planned };
43388
+ }
43389
+
43390
+ const WAIT_TRIGGERS = ["user", "gate"];
43391
+ const WAIT_END_REASONS = ["elapsed", "recovered", "cancelled", "max-hops"];
43392
+
43393
+ // `orc wait start <spec> --mode <m>` — the STARTED half of wait.json, and the
43394
+ // `WAIT mode=…` line. With no run in flight a wait is simply a wait: the answer
43395
+ // is `recorded: false`, exit 0, and NOTHING is written.
43396
+ function waitStartCmd(claudeDir) {
43397
+ const asJson = wantsJson();
43398
+ const mode = flag("--mode");
43399
+ const trigger = flag("--trigger") === undefined ? "user" : flag("--trigger");
43400
+ const runFlag = typeof flag("--run") === "string" ? flag("--run") : null;
43401
+ // positionals() does not know these three flags, so their VALUES arrive as
43402
+ // positionals — and "30 hard" is not a spec. Take each one out once.
43403
+ const pos = positionals().slice(2);
43404
+ for (const v of [mode, trigger, runFlag]) {
43405
+ const i = typeof v === "string" ? pos.indexOf(v) : -1;
43406
+ if (i !== -1 && args.includes(v)) pos.splice(i, 1);
43407
+ }
43408
+ const now = Date.now();
43409
+ const plan = waitPlanCompute(claudeDir, pos.join(" "), now);
43410
+ if (!plan.ok) waitPlanRefuse(plan, asJson);
43411
+
43412
+ if (!WAIT_MODES.includes(mode)) {
43413
+ const hint = `a wait is recorded with its mode. Pass --mode <${WAIT_MODES.join("|")}>.`;
43414
+ if (asJson) emitJson({ ok: false, reason: "no-mode", spec: plan.spec, hint }, 1);
43415
+ console.error(ui.mark.bad("A wait needs a mode."));
43416
+ console.error(" " + hint);
43417
+ process.exit(1);
43418
+ }
43419
+ if (!WAIT_TRIGGERS.includes(trigger)) {
43420
+ const hint = `--trigger is ${WAIT_TRIGGERS.join(" or ")}.`;
43421
+ if (asJson) emitJson({ ok: false, reason: "bad-trigger", spec: plan.spec, hint }, 1);
43422
+ console.error(ui.mark.bad("I cannot read that trigger."));
43423
+ console.error(" " + hint);
43424
+ process.exit(1);
43425
+ }
43426
+
43427
+ const run = waitResolveRun(claudeDir, runFlag);
43428
+ if (!run) {
43429
+ if (asJson) emitJson({ ...plan, ok: true, recorded: false, reason: "no-run", mode, trigger }, 0);
43430
+ console.log(ui.color.gray("No run is in flight, so nothing is recorded. The wait is still a wait."));
43431
+ return;
43432
+ }
43433
+ if (waitActive(readWaitState(claudeDir, run.slug), now).active) {
43434
+ const hint = "this run already has a wait. `orc wait end` or `orc wait cancel` closes it.";
43435
+ if (asJson) emitJson({ ok: false, reason: "already-waiting", slug: run.slug, hint }, 1);
43436
+ console.error(ui.mark.warn(`${run.slug} already has a wait running.`));
43437
+ console.error(" " + hint);
43438
+ process.exit(1);
43439
+ }
43440
+
43441
+ writeWaitState(claudeDir, run.slug, {
43442
+ wait_started_at: new Date(now).toISOString(),
43443
+ mode,
43444
+ trigger,
43445
+ minutes: plan.minutes,
43446
+ hop_minutes: plan.hop_minutes,
43447
+ hops: plan.hops,
43448
+ hops_planned: plan.hop_count,
43449
+ max_hops: plan.max_hops,
43450
+ ends_at: plan.ends_at,
43451
+ hops_done: 0,
43452
+ wait_ended_at: null,
43453
+ end_reason: null,
43454
+ cancel_requested_at: null,
43455
+ });
43456
+ const hhmm = (iso) => new Date(iso).toTimeString().slice(0, 5);
43457
+ const trace = waitTraceLine(
43458
+ claudeDir,
43459
+ `WAIT mode=${mode} requested=${plan.minutes}m start=${hhmm(now)} end=${hhmm(plan.ends_at)} ` +
43460
+ `hops=${plan.hop_count}/${plan.max_hops} trigger=${trigger}`,
43461
+ null
43462
+ );
43463
+ if (asJson)
43464
+ emitJson({ ...plan, ok: true, recorded: true, slug: run.slug, mode, trigger, trace_line: trace }, 0);
43465
+ console.log(
43466
+ ui.mark.ok(`${run.slug}: the wait is recorded — ${mode}, ${plan.minutes} min, ends ${hhmm(plan.ends_at)}.`)
43467
+ );
43468
+ }
43469
+
43470
+ // `orc wait end [<slug>] --reason <r>` — closes the wait `start` recorded. The
43471
+ // hop count is taken from the clock at this moment, then FROZEN in the file.
43472
+ function waitEndCmd(claudeDir) {
43473
+ const asJson = wantsJson();
43474
+ const reason = typeof flag("--reason") === "string" ? String(flag("--reason")).trim() : "";
43475
+ const run = waitResolveRun(claudeDir, positionals()[2]);
43476
+ if (!run) {
43477
+ if (asJson) emitJson({ ok: false, reason: "no-run", hint: "no run is in flight" }, 1);
43478
+ console.error(ui.mark.warn("No run is in flight, so there is no wait to end."));
43479
+ process.exit(1);
43480
+ }
43481
+ const now = Date.now();
43482
+ const act = waitActive(readWaitState(claudeDir, run.slug), now);
43483
+ if (!act.active) {
43484
+ if (asJson) emitJson({ ok: false, reason: "no-wait", slug: run.slug }, 1);
43485
+ console.error(ui.mark.warn(`${run.slug} has no wait running.`));
43486
+ process.exit(1);
43487
+ }
43488
+ if (!WAIT_END_REASONS.includes(reason)) {
43489
+ const hint = `say why the wait ended. Pass --reason <${WAIT_END_REASONS.join("|")}>.`;
43490
+ if (asJson) emitJson({ ok: false, reason: "no-reason", slug: run.slug, hint }, 1);
43491
+ console.error(ui.mark.bad("The end of a wait needs a reason."));
43492
+ console.error(" " + hint);
43493
+ process.exit(1);
43494
+ }
43495
+ const st = writeWaitState(claudeDir, run.slug, {
43496
+ wait_ended_at: new Date(now).toISOString(),
43497
+ end_reason: reason,
43498
+ hops_done: act.hops_done,
43499
+ });
43500
+ const trace = waitTraceLine(claudeDir, "WAIT end", `done=${act.hops_done}/${act.hops_planned} reason=${reason}`);
43501
+ if (asJson)
43502
+ emitJson(
43503
+ {
43504
+ ok: true,
43505
+ slug: run.slug,
43506
+ ended: true,
43507
+ end_reason: reason,
43508
+ hops_done: act.hops_done,
43509
+ hops_planned: act.hops_planned,
43510
+ wait_ended_at: st.wait_ended_at,
43511
+ trace_line: trace,
43512
+ },
43513
+ 0
43514
+ );
43515
+ console.log(ui.mark.ok(`${run.slug}: the wait ended — ${reason}, hop ${act.hops_done} of ${act.hops_planned}.`));
43516
+ }
43517
+
42757
43518
  function waitCancelCmd(claudeDir) {
42758
43519
  const asJson = wantsJson();
42759
43520
  const run = waitResolveRun(claudeDir, positionals()[2]);
@@ -42763,7 +43524,7 @@ function waitCancelCmd(claudeDir) {
42763
43524
  process.exit(1);
42764
43525
  }
42765
43526
  const cur = readWaitState(claudeDir, run.slug);
42766
- if (!cur || !cur.wait_started_at || cur.wait_ended_at) {
43527
+ if (!waitActive(cur).active) {
42767
43528
  if (asJson) emitJson({ ok: false, reason: "no-wait", slug: run.slug }, 1);
42768
43529
  console.error(ui.mark.warn(`${run.slug} has no wait running.`));
42769
43530
  process.exit(1);
@@ -42786,15 +43547,19 @@ function waitStatusCmd(claudeDir) {
42786
43547
  }
42787
43548
  const st = readWaitState(claudeDir, run.slug) || {};
42788
43549
  const blocked = !!st.blocked_at;
42789
- const waiting = !!st.wait_started_at && !st.wait_ended_at;
43550
+ // v2.1.2 F10 — computed from the start and the clock. A wait whose session
43551
+ // died reads `stale`, never `waiting`.
43552
+ const act = waitActive(st);
43553
+ const waiting = act.active;
42790
43554
  const ageMin = blocked ? Math.round((Date.now() - Date.parse(st.blocked_at)) / 60000) : null;
42791
43555
  const out = {
42792
43556
  ok: true,
42793
43557
  run: run.slug,
42794
43558
  waiting,
43559
+ stale: act.stale,
42795
43560
  mode: st.mode || null,
42796
43561
  hop: st.hop || null,
42797
- hops_done: st.hops_done || 0,
43562
+ hops_done: act.hops_done,
42798
43563
  hops_planned: st.hops_planned || null,
42799
43564
  ends_at: st.ends_at || null,
42800
43565
  cancel_requested: !!st.cancel_requested_at,
@@ -42813,7 +43578,7 @@ function waitStatusCmd(claudeDir) {
42813
43578
  ` ${ui.color.cyan("wait")} ` +
42814
43579
  (waiting
42815
43580
  ? `${st.mode || "?"} · hop ${out.hops_done} of ${out.hops_planned || "?"} · ends ${out.ends_at || "?"}`
42816
- : ui.color.gray("none"))
43581
+ : ui.color.gray(act.stale ? "none — a wait was started and never closed (stale)" : "none"))
42817
43582
  );
42818
43583
  console.log(
42819
43584
  ` ${ui.color.cyan("block")} ` +
@@ -42839,9 +43604,15 @@ function waitCmd() {
42839
43604
  return waitBlockCmd(claudeDir, true);
42840
43605
  case "cancel":
42841
43606
  return waitCancelCmd(claudeDir);
43607
+ case "start":
43608
+ return waitStartCmd(claudeDir);
43609
+ case "end":
43610
+ return waitEndCmd(claudeDir);
42842
43611
  default:
42843
43612
  console.error(`usage: orc wait lanes [--json]
42844
43613
  orc wait plan <30|90m|2h|until 18:41|reset> [--hop <min>] [--max-hops <n>] [--json]
43614
+ orc wait start <spec> --mode <safe|soft|hard> [--trigger user|gate] [--run <slug>] [--json]
43615
+ orc wait end [<slug>] --reason <elapsed|recovered|cancelled|max-hops> [--json]
42845
43616
  orc wait status [<slug>] [--json]
42846
43617
  orc wait block <slug> --reason "<why>" [--json]
42847
43618
  orc wait unblock [<slug>] [--json]
@@ -44939,7 +45710,7 @@ function slEngine() {
44939
45710
  // concluded could not be measured.
44940
45711
  const SL_TASK_FIXTURES = {
44941
45712
  healthy: { id: "t1", name: "orc-executor-opus-5-low", type: "orc-executor-opus-5-low", description: "wire the retry ladder", status: "running", model: "claude-opus-5-5", effort: "low", tokenCount: 84000, contextWindowSize: 200000, startTime: 1767225600000 - 17 * 60000 },
44942
- degraded: { id: "t2", name: "orc-executor-sonnet-4-6-med", type: "orc-executor-sonnet-4-6-med", description: "rename two files", status: "failed", model: "claude-sonnet-4-6", effort: "medium", tokenCount: 191000, contextWindowSize: 200000, startTime: 1767225600000 - 96 * 60000 },
45713
+ degraded: { id: "t2", name: "orc-executor-haiku-5-high", type: "orc-executor-haiku-5-high", description: "rename two files", status: "failed", model: "claude-haiku-5-5", effort: "high", tokenCount: 191000, contextWindowSize: 200000, startTime: 1767225600000 - 96 * 60000 },
44943
45714
  empty: { id: "t3" },
44944
45715
  };
44945
45716
 
@@ -46947,7 +47718,7 @@ function lintScanFile(abs, rel, opts) {
46947
47718
  const low = prose.toLowerCase();
46948
47719
  if (on("OSW-10"))
46949
47720
  for (const w of LINT_LEXICON)
46950
- if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\// ── the `--json` crash envelope (v0.49.2) ───────────────────────────────────")}\\b`, "i").test(low)) {
47721
+ if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(low)) {
46951
47722
  add("OSW-10", n, w, "use the plain word");
46952
47723
  break; // one finding per line — a list of six is a list nobody reads
46953
47724
  }
@@ -47249,7 +48020,7 @@ function jsonCrash(err) {
47249
48020
  // v2.1.0 W6 — the deterministic half of `/orc-fix`. It READS `.current`
47250
48021
  // (the host run) and never writes it: the lane opens no run of its own.
47251
48022
  case "fix":
47252
- require("./fix.js").fixCmd(Object.assign(gotchaDeps(), { resolveLogDir, TRACE_NAME, inflightStaleMs: INFLIGHT_STALE_MS }));
48023
+ require("./fix.js").fixCmd(Object.assign(gotchaDeps(), { resolveLogDir, TRACE_NAME, inflightStaleMs: INFLIGHT_STALE_MS, openRunPointer }));
47253
48024
  break;
47254
48025
  case "graph":
47255
48026
  graphCmd();
@@ -47263,7 +48034,7 @@ function jsonCrash(err) {
47263
48034
  // pair. The Haiku writer is the fallback when this exits ≠ 0.
47264
48035
  case "trace":
47265
48036
  require("./trace-write.js").traceCmd({
47266
- flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride, reviewPolicyCompute,
48037
+ flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride, reviewPolicyCompute, laneVerbsFor,
47267
48038
  logSweep: (claudeDir) => require("./clear-logs.js").sweep(claudeDir, clearLogsDeps()),
47268
48039
  });
47269
48040
  break;