@azure-id/orc 1.9.2 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README-id.md +21 -36
  3. package/README.md +25 -36
  4. package/bin/build-agents.js +117 -20
  5. package/bin/cli.js +725 -29
  6. package/bin/gotcha-import.js +1081 -0
  7. package/bin/gotcha.js +1286 -0
  8. package/bin/graph-query.js +1 -1
  9. package/bin/graph.js +717 -717
  10. package/bin/habit.js +1453 -0
  11. package/bin/mockrun-catalog.js +281 -276
  12. package/bin/run-undo.js +398 -0
  13. package/bin/trace-write.js +657 -0
  14. package/bin/verify-contracts.js +525 -79
  15. package/bin/verify-package.js +51 -4
  16. package/bin/webui/api.js +26 -0
  17. package/bin/webui/app.html +239 -232
  18. package/bin/webui/css/00-tokens.css +110 -92
  19. package/bin/webui/css/04-motion.css +87 -0
  20. package/bin/webui/css/06-responsive.css +203 -178
  21. package/bin/webui/css/panels/behaviour.css +205 -0
  22. package/bin/webui/fixtures/behaviour.js +532 -0
  23. package/bin/webui/fixtures/index.js +34 -0
  24. package/bin/webui/fixtures/knowledge.js +7 -1
  25. package/bin/webui/fixtures/stats.js +16 -0
  26. package/bin/webui/i18n/en/behaviour.json +143 -0
  27. package/bin/webui/i18n/en/nav.json +25 -24
  28. package/bin/webui/i18n/en/tour.json +37 -35
  29. package/bin/webui/i18n/id/behaviour.json +143 -0
  30. package/bin/webui/i18n/id/nav.json +25 -24
  31. package/bin/webui/i18n/id/tour.json +37 -35
  32. package/bin/webui/js/01-i18n.js +155 -154
  33. package/bin/webui/js/90-tour.js +498 -494
  34. package/bin/webui/js/91-shortcuts.js +126 -126
  35. package/bin/webui/js/99-boot.js +121 -118
  36. package/bin/webui/js/panels/behaviour.js +1022 -0
  37. package/mock-run/INDEX.md +109 -107
  38. package/mock-run/gotcha-import.md +118 -0
  39. package/mock-run/habits.md +129 -0
  40. package/mock-run/orc-quick.md +6 -1
  41. package/package.json +1 -1
  42. package/templates/agents/MODEL-MAPPING.md +7 -7
  43. package/templates/agents/orc-advisor-opus-5-xhigh.md +1 -7
  44. package/templates/agents/orc-analyze-mini-opus-5-med.md +1 -6
  45. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +1 -4
  46. package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
  47. package/templates/agents/orc-claude-writer-opus-5-med.md +1 -8
  48. package/templates/agents/orc-context-combiner-opus-5-high.md +1 -11
  49. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  50. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  51. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  52. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  53. package/templates/agents/orc-executor-opus-5-high.md +14 -6
  54. package/templates/agents/orc-executor-opus-5-low.md +14 -6
  55. package/templates/agents/orc-executor-opus-5-med.md +14 -6
  56. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  57. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  58. package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
  59. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  60. package/templates/agents/orc-judge-opus-5-xhigh.md +5 -9
  61. package/templates/agents/orc-learn-writer-opus-5-low.md +1 -7
  62. package/templates/agents/orc-pattern-codifier-opus-5-med.md +1 -8
  63. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  64. package/templates/agents/orc-planner-mini-opus-5-med.md +1 -4
  65. package/templates/agents/orc-planner-mini-sonnet-5-high.md +1 -2
  66. package/templates/agents/orc-planner-opus-5-med.md +1 -4
  67. package/templates/agents/orc-recon-opus-5-low.md +1 -8
  68. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  69. package/templates/agents/orc-retro-opus-5-med.md +6 -8
  70. package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
  71. package/templates/agents/orc-reviewer-opus-5-med.md +52 -16
  72. package/templates/agents/orc-scout-opus-5-low.md +1 -6
  73. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  74. package/templates/agents/orc-system-analyst-opus-5-high.md +1 -6
  75. package/templates/agents/orc-test-author-opus-5-med.md +4 -5
  76. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  77. package/templates/agents/orc-verifier-opus-5-med.md +16 -8
  78. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
  79. package/templates/agents/orc-wiki-scanner-opus-5-med.md +1 -8
  80. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  81. package/templates/commands/orc-analyze.md +13 -21
  82. package/templates/commands/orc-fast.md +10 -15
  83. package/templates/commands/orc-poly.md +12 -21
  84. package/templates/commands/orc-pr-driver.md +11 -30
  85. package/templates/commands/orc-pr-setup.md +10 -31
  86. package/templates/commands/orc-route.md +11 -41
  87. package/templates/commands/orc-test.md +5 -60
  88. package/templates/hooks/README.md +34 -0
  89. package/templates/hooks/orc-session-hook.js +264 -0
  90. package/templates/hooks/orc-statusline.js +3 -1
  91. package/templates/skills/_shared/README.md +9 -0
  92. package/templates/skills/_shared/code-graph.md +47 -55
  93. package/templates/skills/_shared/config-precedence.md +3 -1
  94. package/templates/skills/_shared/extra-dispatch.md +73 -88
  95. package/templates/skills/_shared/gotchas.md +228 -177
  96. package/templates/skills/_shared/habits.md +101 -0
  97. package/templates/skills/_shared/lane-contract.md +84 -0
  98. package/templates/skills/_shared/phases/README.md +142 -83
  99. package/templates/skills/_shared/phases/analyst-gates.md +10 -21
  100. package/templates/skills/_shared/phases/execution.md +8 -14
  101. package/templates/skills/_shared/phases/house-rules.md +27 -32
  102. package/templates/skills/_shared/phases/intake.md +127 -133
  103. package/templates/skills/_shared/phases/mock-example.md +46 -56
  104. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  105. package/templates/skills/_shared/phases/planning.md +7 -17
  106. package/templates/skills/_shared/phases/preflight.md +19 -42
  107. package/templates/skills/_shared/phases/review.md +23 -27
  108. package/templates/skills/_shared/phases/rules.md +18 -42
  109. package/templates/skills/_shared/phases/scoring.md +55 -65
  110. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  111. package/templates/skills/_shared/phases/security.md +45 -55
  112. package/templates/skills/_shared/phases/ship.md +6 -15
  113. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  114. package/templates/skills/_shared/phases/summary.md +73 -48
  115. package/templates/skills/_shared/phases/testgen.md +41 -51
  116. package/templates/skills/_shared/phases/trace-verbs.md +433 -0
  117. package/templates/skills/_shared/phases/trace.md +136 -367
  118. package/templates/skills/_shared/phases/verify.md +62 -70
  119. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  120. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  121. package/templates/skills/_shared/read-ladder.md +2 -55
  122. package/templates/skills/_shared/return-validation.md +17 -70
  123. package/templates/skills/_shared/review-slice.md +79 -0
  124. package/templates/skills/_shared/smoke-gate.md +46 -28
  125. package/templates/skills/context-combiner/SKILL.md +15 -44
  126. package/templates/skills/orc/SKILL.md +34 -62
  127. package/templates/skills/orc/references/pattern-gate.md +89 -89
  128. package/templates/skills/orc/references/phases/intake.md +41 -47
  129. package/templates/skills/orc/references/phases/integration.md +13 -19
  130. package/templates/skills/orc/references/preflight-report.md +8 -9
  131. package/templates/skills/orc/references/ultra-mode.md +8 -6
  132. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  133. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  134. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  135. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +11 -52
  136. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  137. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  138. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +11 -22
  139. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  140. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  141. package/templates/skills/orc-advisor/SKILL.md +56 -60
  142. package/templates/skills/orc-analyze/SKILL.md +30 -66
  143. package/templates/skills/orc-analyze/schemas/report-audit.md +2 -1
  144. package/templates/skills/orc-analyze/schemas/report-prose.md +2 -1
  145. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  146. package/templates/skills/orc-diy/README.md +31 -0
  147. package/templates/skills/orc-diy/SKILL.md +23 -74
  148. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  149. package/templates/skills/orc-fast/SKILL.md +44 -72
  150. package/templates/skills/orc-judge/SKILL.md +77 -82
  151. package/templates/skills/orc-mini/SKILL.md +61 -109
  152. package/templates/skills/orc-pattern/SKILL.md +27 -48
  153. package/templates/skills/orc-poly/SKILL.md +32 -61
  154. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  155. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  156. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  157. package/templates/skills/orc-quick/README.md +43 -2
  158. package/templates/skills/orc-quick/SKILL.md +76 -107
  159. package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
  160. package/templates/skills/orc-quick/references/gh-mode.md +48 -1
  161. package/templates/skills/orc-quick/references/look.md +3 -1
  162. package/templates/skills/orc-retro/SKILL.md +19 -18
  163. package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
  164. package/templates/skills/orc-route/SKILL.md +25 -45
  165. package/templates/skills/orc-test/SKILL.md +16 -37
  166. package/templates/skills/orc-verify/SKILL.md +14 -32
  167. package/templates/skills/orc-wait/SKILL.md +156 -163
  168. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
  169. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  170. package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
  171. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  172. package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
package/bin/cli.js CHANGED
@@ -179,10 +179,19 @@ function writeStdoutSync(str) {
179
179
  }
180
180
  }
181
181
 
182
+ let PROBE_CAPTURE = null;
183
+
182
184
  // Print the object and (optionally) exit with the human path's code.
183
185
  // `compact` (v1.9.1 B1) writes the answer with no indentation: a `--brief`
184
186
  // answer is read by a model, and the card inside it carries its own newlines.
185
187
  function emitJson(obj, exitCode, compact) {
188
+ // v2.0.0 T20 — an in-process probe (`orc lane config` `probes{}`) takes the
189
+ // object instead of stdout. Outside a probe this line does nothing.
190
+ if (PROBE_CAPTURE) {
191
+ PROBE_CAPTURE.obj = obj;
192
+ if (exitCode !== undefined) process.exit(exitCode);
193
+ return;
194
+ }
186
195
  writeStdoutSync((compact ? JSON.stringify(obj) : JSON.stringify(obj, null, 2)) + "\n");
187
196
  if (exitCode !== undefined) process.exit(exitCode);
188
197
  }
@@ -427,6 +436,35 @@ function installGuards(claudeDir) {
427
436
  wireGraph("PreToolUse", "Bash|Read");
428
437
  wireGraph("PostToolUse", "Read");
429
438
 
439
+ // 7) The session hook (v2.0.0 Q8/Q9) — TWO entries, one file. `Stop` rings
440
+ // the terminal bell when a turn of an ORC run ends, and only under
441
+ // `notify: bell` (default off: the hook reads the key and returns, so an
442
+ // unarmed bell is byte-identical to not having it — the read-gate rule).
443
+ // `SessionStart` on `compact` prints ONE line naming the run's
444
+ // state-of-play.md while a run is in flight. Same (event, matcher) match as
445
+ // the graph hook, so re-running `orc update` changes nothing.
446
+ const sessionHookCmd = nodeCmd(path.join(hooksDest, "orc-session-hook.js"));
447
+ const wireSession = (arrName, matcher) => {
448
+ settings.hooks[arrName] = settings.hooks[arrName] || [];
449
+ for (const entry of settings.hooks[arrName]) {
450
+ if ((entry.matcher || null) !== (matcher || null)) continue;
451
+ for (const h of entry.hooks || []) {
452
+ if (typeof h.command === "string" && h.command.includes("orc-session-hook")) {
453
+ h.command = sessionHookCmd;
454
+ return;
455
+ }
456
+ }
457
+ }
458
+ const entry = { hooks: [{ type: "command", command: sessionHookCmd }] };
459
+ if (matcher) entry.matcher = matcher;
460
+ settings.hooks[arrName].push(entry);
461
+ console.log(` add settings.json → ${arrName} session hook${matcher ? ` (${matcher})` : ""}`);
462
+ };
463
+ wireSession("Stop", null);
464
+ wireSession("SessionStart", "compact");
465
+ // v2.0.2 (eval D13): the reviewer / verifier / judge get the gotcha card here.
466
+ wireSession("SubagentStart", null);
467
+
430
468
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
431
469
  }
432
470
 
@@ -1130,6 +1168,15 @@ const vFallbackAgent = tag((raw) => {
1130
1168
  "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.",
1131
1169
  };
1132
1170
  }, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"] });
1171
+ // v2.0.0 W2 (DE-16) — `auto` is refused BY NAME. It is the one value a reader
1172
+ // of the research expects, and ORC does not have it: no habit is ever applied
1173
+ // without a yes, so a generic "must be one of" would read as a missing feature.
1174
+ const vHabits = tag((raw) => {
1175
+ const v = String(raw || "").trim();
1176
+ if (v === "auto")
1177
+ return { err: "`auto` is not a habits level. ORC never applies a habit without your yes. Use off, observe or propose." };
1178
+ return ["off", "observe", "propose"].includes(v) ? { value: v } : { err: "must be one of: off, observe, propose" };
1179
+ }, { kind: "enum", choices: ["off", "observe", "propose"] });
1133
1180
  const vPath = tag(
1134
1181
  (raw) => (raw && raw.trim() ? { value: raw } : { err: "must be a non-empty path" }),
1135
1182
  { kind: "path" }
@@ -1161,6 +1208,26 @@ const vRepo = tag(
1161
1208
  : { err: "must be a GitHub owner/repo (e.g. azure-id/orc)" },
1162
1209
  { kind: "repo" }
1163
1210
  );
1211
+ // v2.0.0 W4 — the reviewer card budget. Refused BY NAME below the floor: the
1212
+ // budget is a size, and review learning has no off switch (DE-10).
1213
+ const vCardBudget = tag((raw) => {
1214
+ const n = Number(raw);
1215
+ if (!Number.isInteger(n)) return { err: "gotcha_card_budget must be an integer >= 200" };
1216
+ if (n < 200)
1217
+ return { err: `gotcha_card_budget must be 200 or more (got ${n}). It is the SIZE of the reviewer card, not an off switch — review learning is always on` };
1218
+ return { value: n };
1219
+ }, { kind: "int", min: 200 });
1220
+ // An optional one-word value: empty clears it.
1221
+ const vOptText = tag((raw) => {
1222
+ const s = String(raw == null ? "" : raw).trim();
1223
+ if (/\s/.test(s)) return { err: "must be one word (no spaces)" };
1224
+ return { value: s };
1225
+ }, { kind: "text" });
1226
+ const vSonarUrl = tag((raw) => {
1227
+ const s = String(raw == null ? "" : raw).trim().replace(/\/+$/, "");
1228
+ if (s && !/^https?:\/\/[^\s/]+(\/\S*)?$/.test(s)) return { err: "must be an http(s) URL (e.g. https://sonarcloud.io)" };
1229
+ return { value: s };
1230
+ }, { kind: "text" });
1164
1231
 
1165
1232
  // Roles `orc extra` may hand to a non-Claude worker. Declared HERE, above
1166
1233
  // CONFIG_META, because CONFIG_META is evaluated at module load and a `const`
@@ -1319,6 +1386,14 @@ const CONFIG_FAMILIES = {
1319
1386
  // structure costs no model tokens, so nothing about a dispatched model can
1320
1387
  // shadow whether it is built.
1321
1388
  graph: { contested: false, question: "whether code lanes build and read a local map of how the repo is connected" },
1389
+ // v2.0.0 W2 — habits. UNCONTESTED: an accepted habit is not a key, it is the
1390
+ // `learned` RANK under your config file (bin/habit.js), so nothing here
1391
+ // competes with anything. `offers` holds the end-of-run offers a habit can
1392
+ // answer; before 2.0.0 no key existed, so nothing could remember them.
1393
+ habits: { contested: false, question: "whether ORC learns your usual answers and proposes them — never applied without your yes" },
1394
+ offers: { contested: false, question: "which offers a lane still asks after the work, and which your setting answers" },
1395
+ notify: { contested: false, question: "whether a turn of an ORC run ends with the terminal bell" },
1396
+ rules: { contested: false, question: "how large the rules card is in an executor slice" },
1322
1397
  };
1323
1398
 
1324
1399
  // Ordered, tiered metadata. Common first, then advanced.
@@ -1348,6 +1423,15 @@ const CONFIG_META = [
1348
1423
  { key: "pattern_findings", def: "ask", tier: "common", answers: [{ family: "patterns", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-pattern", "orc-wiki"], validate: vEnum("ask", "on", "off"), options: ["ask", "on", "off"], desc: "Code-pattern gate on an FE/BE cache miss: ask = prompt, on = auto-codify, off = always agnostic." },
1349
1424
  { key: "gotchas", def: "on", tier: "common", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-brainstorm", "orc-claude", "orc-diy", "orc-doc", "orc-fast", "orc-learn", "orc-mini", "orc-retro", "orc-wiki"], validate: vEnum("on", "off"), options: ["on", "off"], desc: "Repair memory: record a gotcha when a repair loop goes red → green, and inject the scope-matching ones into executor slices. Never injected unfiltered; see .claude/orc/gotchas.md." },
1350
1425
  { key: "gotchas_max", def: 40, tier: "common", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: ["orc"], validate: vInt(5), options: [20, 40, 60, 100], desc: "Live gotcha entries kept before the lowest-value tail is archived to gotchas-archive.md (never deleted)." },
1426
+ // --- v2.0.0 W4 — the gotchas engine (bin/gotcha.js) ---------------------------
1427
+ // Read by the CLI (`orc gotcha card`, `import`, `sync`), never by a spine,
1428
+ // so each `lanes[]` is empty and each key is on the seed-empty allowlist.
1429
+ // Review learning is ALWAYS ON (DE-10): the budget is a SIZE, never a switch.
1430
+ { key: "gotcha_card_budget", def: 600, tier: "common", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: [], validate: vCardBudget, options: [200, 400, 600, 900], desc: "The size of the reviewer card from `orc gotcha card`, in tokens. It is a SIZE, never an off switch: review learning is always on. The minimum is 200; a lower value is refused. Entries that do not fit are COUNTED in the card header, never dropped in silence." },
1431
+ { key: "gotcha_sync_hours", def: 6, tier: "advanced", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [1, 6, 24], desc: "How old the last `orc gotcha sync` may be before the next review step runs it again." },
1432
+ { key: "sonar_url", def: "", tier: "advanced", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: [], validate: vSonarUrl, desc: "The SonarQube / SonarCloud base URL for `orc gotcha import sonar` (e.g. https://sonarcloud.io). The token is NEVER a key: set SONAR_TOKEN in the environment." },
1433
+ { key: "sonar_project", def: "", tier: "advanced", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: [], validate: vOptText, desc: "The Sonar project key for `orc gotcha import sonar`, so the import needs no flags." },
1434
+ { key: "sonar_org", def: "", tier: "advanced", answers: [{ family: "gotchas", prio: "P2", mode: "replace" }], lanes: [], validate: vOptText, desc: "The SonarCloud organization for `orc gotcha import sonar` (empty for a self-hosted SonarQube)." },
1351
1435
  { key: "security_review", def: "off", tier: "common", answers: [{ family: "security", prio: "P2", mode: "replace" }], lanes: ["orc"], validate: vEnum("off", "ask", "on"), options: ["off", "ask", "on"], desc: "Opt-in Phase 5.5 security pass on runs with a task scored >= 70 (risk floor). OFF by default." },
1352
1436
  { key: "run_budget_dispatches", def: 0, tier: "common", answers: [{ family: "waves", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-budget"], validate: vInt(0), options: [0, 8, 12, 20, 30], desc: "Subagent budget for one run. The Phase-1 forecast estimates how many subagents the run will dispatch; if that exceeds this number the run STOPS before wave 1 (a hard gate like the batch pause, not a hint) and offers proceed / a cheaper lane / re-plan smaller. 0 = off, nothing changes." },
1353
1437
  { key: "mock_example", def: "ask", tier: "common", answers: [{ family: "mock", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-brainstorm", "orc-diy", "orc-fast", "orc-grill", "orc-mini"], validate: vEnum("ask", "on", "off"), options: ["ask", "on", "off"], desc: "Post-verify mocked runnable example (mock-examples/<slug>/, never committed): ask = offer after a green verify, on = always, off = never." },
@@ -1477,7 +1561,23 @@ const CONFIG_META = [
1477
1561
  { key: "test_max_rps", def: 4, tier: "common", answers: [{ family: "test", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [1, 2, 4, 8], desc: "Requests per second the runner is allowed to send, with a fixed small concurrency beside it. The CLI PACES — this is not a suggestion, because ORC's own run must never be the incident. A 429 is a RESULT and not an error: it means rate limiting works, so the runner records it and backs off rather than pushing through." },
1478
1562
  { key: "test_case_budget", def: 200, tier: "common", answers: [{ family: "test", prio: "P2", mode: "replace" }], lanes: ["orc-test"], validate: vInt(1), options: [50, 100, 200, 500], desc: "Cases one run may expand to before it STOPS expanding. A PLANNED stop, not an interrupt (the wiki_refresh_budget shape): the matrix is combinatorial, and a run that silently grows to 4,000 cases against a staging box is a denial of service you wrote yourself. On reaching it the CLI names the targets it did not finish and offers `--budget`." },
1479
1563
  { key: "test_ui_driver", def: "playwright", tier: "common", answers: [{ family: "test", prio: "P2", mode: "replace" }], lanes: ["orc-test"], validate: vEnum("playwright", "none"), options: ["playwright", "none"], desc: "What drives the front-end half. `playwright` authors a journey SCRIPT and runs it — a step an LLM took is not a step you can re-run, and the artifacts (trace.zip, video, HAR) are the evidence. `none` skips the FE half entirely. ORC ships zero dependencies and NEVER installs Playwright into your project: an absent driver is a STATE with a named install command, not a failure." },
1564
+ // --- v2.0.0 W2 — habits (bin/habit.js) -------------------------------------
1565
+ // `habits` is read by the CLI, never by a lane: `orc lane config` resolves it
1566
+ // and hands the lane a `habits{}` block (the `code_graph` answer). The other
1567
+ // three are the offers a habit can answer; W3 wires the lanes that read them,
1568
+ // so their `lanes[]` is empty until then. OFF by default, and off is
1569
+ // specified as ZERO BYTES in `orc lane config` — that is a test.
1570
+ { key: "habits", def: "off", tier: "common", answers: [{ family: "habits", prio: "P2", mode: "replace" }], lanes: [], validate: vHabits, options: ["off", "observe", "propose"], desc: "Whether ORC learns the answers you give to its questions, from the ASK lines in your run traces. off = nothing is read and nothing changes (the default). observe = ORC computes your usual answers and shows them in `orc habit show`, and never proposes one. propose = at the END of a run, at most once, ORC asks whether an answer you keep giving should become your usual. NOTHING is applied without your yes, and there is no automatic level. An accepted habit is the `learned` rank, below your own config file: `orc config set` always wins, and `orc habit forget <id>` undoes it." },
1571
+ { key: "review_before_push", def: "ask", tier: "common", answers: [{ family: "offers", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("ask", "on", "off"), options: ["ask", "on", "off"], desc: "Whether a lane offers a code review before it pushes. ask = offer it (the default), on = always review first, off = never offer it. A habit can learn only `on`." },
1572
+ { key: "mini_tdd", def: "ask", tier: "common", answers: [{ family: "testing", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("ask", "on", "off"), options: ["ask", "on", "off"], desc: "Whether /orc-mini asks at intake for a test-driven loop. ask = ask (the default), on = always use it, off = never. A habit can learn only `on`." },
1573
+ { key: "quick_update_tests", def: "ask", tier: "common", answers: [{ family: "offers", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("ask", "on"), options: ["ask", "on"], desc: "Whether /orc-quick offers to update the tests after a code change. ask = offer it (the default), on = always update them. There is no `off`: tests are the careful side. It affects only the offer after the work, never the dispatch gate." },
1574
+ // v2.0.0 W6c — Q8. The terminal bell when a turn of an ORC run ends. The hook is
1575
+ // wired always and reads this key first, so off is byte-identical to no hook.
1576
+ { key: "notify", def: "off", tier: "common", answers: [{ family: "notify", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "bell"), options: ["off", "bell"], desc: "Whether the installed session hook rings the terminal bell when a turn of an ORC run ends. off = silent (the default). bell = one bell through the Stop hook, only while an ORC run is open in this session and only when the run moved since the last bell, so a normal chat never rings. No OS notification and no network." },
1480
1577
  { key: "doc_dir", def: DOC_DIR_DEFAULT, tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["orc-doc"], validate: vPath, desc: "Where /orc-doc folders live. Project root, not .claude/ — a document is a deliverable a human opens, and the same call /orc-quick, /orc-brainstorm and poly-repo-implementation/ already made." },
1578
+ // v2.0.0 W6c — DE-17. The compact rules card for orc-mini / orc-fast executors.
1579
+ // OFF until eval E4 passes (eval/results/2.0.0/E4.md); off = today's card, byte for byte.
1580
+ { key: "rules_card_compact", def: "on", tier: "advanced", answers: [{ family: "rules", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether `orc rules slice` builds the COMPACT rules card for the orc-mini and orc-fast lanes too (orc-quick always gets it). The compact card keeps every rule id and the first line of each HARD rule, and drops the worked examples. on (the default since v2.0.2 — eval E4 passed: no new unmet[] items, no new rules_conflicts[], the smoke gate green on the first try at least as often). off = the full card, byte-identical to 2.0.0." },
1481
1581
  { key: "wiki_scan_tier", def: "ladder", tier: "advanced", answers: [{ family: "wiki", prio: "P2", mode: "replace" }], lanes: ["orc-wiki"], validate: vEnum("ladder", "always_deep"), desc: "Wiki scan tier: ladder picks light/deep per delta (first scan, STRUCTURAL, wide delta or a new exported symbol → deep; otherwise light), always_deep restores pre-v0.46.0 behaviour. The resolved tier is always printed — a cheaper model is never a quiet substitution." },
1482
1582
  { key: "wiki_tier_deep_files", def: 3, tier: "advanced", answers: [{ family: "wiki", prio: "P2", mode: "replace" }], lanes: ["orc-wiki"], validate: vInt(1), desc: "Covered files touched at or above this count send the refresh to the DEEP scanner." },
1483
1583
  { key: "wiki_refresh_budget", def: 0, tier: "advanced", answers: [{ family: "wiki", prio: "P2", mode: "replace" }], lanes: ["orc-wiki"], validate: vInt(0), desc: "Max scan-tasks per refresh run; 0 = no cap. A capped refresh is a PLANNED stop, not an interrupt: sync has already run, so the wiki is registered and consistent, and the remaining docs are AGING, not broken. Separate from the fixed pause-every-5 rule — do not merge them." },
@@ -1827,7 +1927,7 @@ function groupRule(title) {
1827
1927
  // `not-read`: a higher rank in this key's family resolved, so nobody looked;
1828
1928
  // `inert`: this key's master gate is off, which removes it from the
1829
1929
  // conversation rather than losing it a precedence contest (`design-03` §5).
1830
- function configKeyState(m, map, claudeDir) {
1930
+ function configKeyState(m, map, claudeDir, learned) {
1831
1931
  const has = Object.prototype.hasOwnProperty.call(map, m.key);
1832
1932
  if (m.gated_by) {
1833
1933
  const g = metaFor(m.gated_by);
@@ -1841,14 +1941,35 @@ function configKeyState(m, map, claudeDir) {
1841
1941
  }
1842
1942
  const why = shadowReason(m.key, map, claudeDir);
1843
1943
  if (why) return { state: "not-read", reason: why, source: has ? "overridden" : "default" };
1944
+ // v2.0.0 W2 — the `learned` rank: below the file, above the shipped default.
1945
+ const l = !has && learned && learned[m.key];
1946
+ if (l)
1947
+ return {
1948
+ state: "learned",
1949
+ reason: `learned from habit ${l.id} (${l.count} of ${l.total}) — undo: orc habit forget ${l.id}`,
1950
+ source: `learned:${l.id}`,
1951
+ value: l.value,
1952
+ };
1844
1953
  return { state: has ? "overridden" : "default", reason: null, source: has ? "overridden" : "default" };
1845
1954
  }
1846
1955
 
1956
+ // The learned values in force project-wide, or {} under `habits: off`.
1957
+ function configLearned(map, claudeDir) {
1958
+ const H = require("./habit.js");
1959
+ if (H.habitsMode(map) === "off") return {};
1960
+ try {
1961
+ return H.learnedFor(claudeDir, habitDeps(), map, null);
1962
+ } catch (_) {
1963
+ return {};
1964
+ }
1965
+ }
1966
+
1847
1967
  const STATE_PAINT = {
1848
1968
  overridden: (s) => ui.color.green(s),
1849
1969
  default: (s) => ui.color.gray(s),
1850
1970
  "not-read": (s) => ui.color.yellow(s),
1851
1971
  inert: (s) => ui.color.yellow(s),
1972
+ learned: (s) => ui.color.cyan(s),
1852
1973
  };
1853
1974
 
1854
1975
  function configList(claudeDir) {
@@ -1863,6 +1984,7 @@ function configList(claudeDir) {
1863
1984
  // UI axis and it still drives the interactive menu, so it rides along as a row
1864
1985
  // marker instead of being the table's spine.
1865
1986
  const families = laneFamilies(null, map, claudeDir);
1987
+ const learned = configLearned(map, claudeDir);
1866
1988
  const byFamily = new Map();
1867
1989
  for (const m of CONFIG_META) {
1868
1990
  const f = m.answers[0].family;
@@ -1891,9 +2013,9 @@ function configList(claudeDir) {
1891
2013
  return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb);
1892
2014
  });
1893
2015
  for (const m of metas) {
1894
- const st = configKeyState(m, map, claudeDir);
2016
+ const st = configKeyState(m, map, claudeDir, learned);
1895
2017
  const has = Object.prototype.hasOwnProperty.call(map, m.key);
1896
- const val = has ? map[m.key] : m.def;
2018
+ const val = has ? map[m.key] : st.value !== undefined ? st.value : m.def;
1897
2019
  const src = (STATE_PAINT[st.state] || ((s) => s))(st.state.padEnd(10));
1898
2020
  const opts = m.options ? ` ${ui.color.gray("[options: " + m.options.join(" | ") + "]")}` : "";
1899
2021
  // v0.55.0 — a DEPRECATED member of a live key is marked on the row that
@@ -2195,10 +2317,11 @@ function scoreTableJson(map, claudeDir) {
2195
2317
  function configListJson(claudeDir) {
2196
2318
  const { path: p, map } = readOverride(claudeDir);
2197
2319
  const has = (k) => Object.prototype.hasOwnProperty.call(map, k);
2320
+ const learned = configLearned(map, claudeDir);
2198
2321
  const keys = CONFIG_META.map((m) => {
2199
2322
  const v = m.validate || {};
2200
2323
  const why = shadowReason(m.key, map, claudeDir);
2201
- const st = configKeyState(m, map, claudeDir);
2324
+ const st = configKeyState(m, map, claudeDir, learned);
2202
2325
  return {
2203
2326
  key: m.key,
2204
2327
  tier: m.tier,
@@ -2217,7 +2340,7 @@ function configListJson(claudeDir) {
2217
2340
  // The master gate that makes this key inert. A gate does not win a
2218
2341
  // precedence contest; it removes its dependants from the conversation.
2219
2342
  gated_by: m.gated_by || null,
2220
- value: has(m.key) ? map[m.key] : m.def,
2343
+ value: has(m.key) ? map[m.key] : st.value !== undefined ? st.value : m.def,
2221
2344
  default: m.def,
2222
2345
  is_overridden: has(m.key),
2223
2346
  is_shadowed: !!why,
@@ -2228,7 +2351,7 @@ function configListJson(claudeDir) {
2228
2351
  // answers "does anything read it", from `LANE_RANK_STATES`' own words.
2229
2352
  state: st.state,
2230
2353
  state_reason: st.reason,
2231
- source: has(m.key) ? "overridden" : "default",
2354
+ source: st.source,
2232
2355
  desc: m.desc,
2233
2356
  options: m.options || null,
2234
2357
  control: {
@@ -2292,7 +2415,7 @@ function configListJson(claudeDir) {
2292
2415
  // lane-scoped answer asks `orc lane config <lane> --json`, which is the
2293
2416
  // command that owns it.
2294
2417
  families_resolved: laneFamilies(null, map, claudeDir),
2295
- rank_states: LANE_RANK_STATES,
2418
+ rank_states: require("./habit.js").habitsMode(map) !== "off" ? LANE_RANK_STATES.concat("learned") : LANE_RANK_STATES,
2296
2419
  // Permanently on and deliberately not a key — say so, or a reader hunts for
2297
2420
  // the switch (only the folder is configurable).
2298
2421
  behavior_trace: { always_on: true, configurable_key: "log_dir" },
@@ -2358,6 +2481,14 @@ function configSet(claudeDir, key, rawValue) {
2358
2481
  // precedent: a renamed mechanism must never be a silent revert.
2359
2482
  if (key === "extra_roles") extraLegacyRoleWarn(res.value);
2360
2483
  if (key === "opus5_only") opus5Notice(String(res.value) === "true", claudeDir);
2484
+ // DE-27 — the v0.40.0 key stays so an existing file is never silently
2485
+ // overridden, but it is not a feature switch any more.
2486
+ if (key === "gotchas" && String(res.value) === "off")
2487
+ console.error(
2488
+ " ⚠ review learning is designed to stay on. `gotchas: off` is kept for compatibility: it stops the\n" +
2489
+ " gotcha block in EXECUTOR slices only. The reviewer card (`orc gotcha card`) is always built.\n" +
2490
+ " To make the card smaller, set gotcha_card_budget (min 200)."
2491
+ );
2361
2492
  if (key === "opus5_only" || key === "extra_enabled") extraShadowNotice(claudeDir);
2362
2493
  }
2363
2494
 
@@ -3234,9 +3365,26 @@ function laneConfig(lane, claudeDir) {
3234
3365
  inert_reason: hit ? hit.reason : gate ? `${m.gated_by} is off, so nothing in its block is consulted` : null,
3235
3366
  };
3236
3367
  });
3368
+ // v2.0.0 W2 — the `learned` rank (bin/habit.js, DE-6). `habits: off` (the
3369
+ // default) adds ZERO BYTES: the habit keys leave `not_read`, no key gains a
3370
+ // field, and there is no `habits` block — the answer is the one 1.9.2 gave.
3371
+ const H = require("./habit.js");
3372
+ const hmode = H.habitsMode(map);
3373
+ let hab = null;
3374
+ if (hmode !== "off") {
3375
+ hab = H.laneBlock(claudeDir, habitDeps(), map, lane);
3376
+ for (const k of keys) {
3377
+ const l = !has(k.key) && hab.learned[k.key];
3378
+ if (l) k.value = l.value;
3379
+ k.source = has(k.key) ? "overridden" : l ? `learned:${l.id}` : "default";
3380
+ k.state = k.is_inert ? "inert" : k.is_shadowed ? "not-read" : l ? "learned" : has(k.key) ? "overridden" : "default";
3381
+ }
3382
+ }
3237
3383
  const effective = {};
3238
3384
  for (const k of keys) effective[k.key] = k.value;
3239
- return {
3385
+ const announce = laneAnnounce(lane, map, claudeDir, families);
3386
+ if (hab) for (const k of keys) if (k.state === "learned") announce.push(H.announceLine(k.key, hab.learned[k.key]));
3387
+ const out = {
3240
3388
  lane,
3241
3389
  command: (LANES.find((l) => l.lane === lane) || {}).command || null,
3242
3390
  command_note: (LANES.find((l) => l.lane === lane) || {}).note || null,
@@ -3247,16 +3395,31 @@ function laneConfig(lane, claudeDir) {
3247
3395
  effective,
3248
3396
  families,
3249
3397
  roles: laneRoles(lane, map, claudeDir),
3250
- announce: laneAnnounce(lane, map, claudeDir, families),
3398
+ announce,
3251
3399
  stops: LANE_STOPS.filter((s) => rows.some((m) => m.key === s.key))
3252
3400
  .filter((s) => s.arms(has(s.key) ? map[s.key] : metaFor(s.key).def))
3253
3401
  .map((s) => ({ key: s.key, value: has(s.key) ? map[s.key] : metaFor(s.key).def, when: s.when, action: s.action })),
3254
3402
  // The keys this lane deliberately ignores. An empty answer is an ANSWER
3255
3403
  // (v0.43.0): "this lane does not read doc_language" is information, and it
3256
3404
  // is what makes the two-way lint possible at all.
3257
- not_read: CONFIG_META.filter((m) => !(m.lanes || []).includes(lane)).map((m) => m.key),
3258
- rank_states: LANE_RANK_STATES,
3405
+ not_read: CONFIG_META.filter((m) => !(m.lanes || []).includes(lane))
3406
+ .filter((m) => hab || !H.HABIT_KEYS.includes(m.key))
3407
+ .map((m) => m.key),
3408
+ rank_states: hab ? LANE_RANK_STATES.concat("learned") : LANE_RANK_STATES,
3259
3409
  };
3410
+ if (hab) out.habits = hab.block;
3411
+ else for (const f of H.HABIT_FAMILIES) delete out.families[f];
3412
+ return out;
3413
+ }
3414
+
3415
+ // The helpers `bin/habit.js` borrows, so it REUSES the trace listing and the
3416
+ // timestamp parser `orc stats` / `orc run list` already use.
3417
+ function runUndoDeps() {
3418
+ return { args, wantsJson, emitJson, resolveClaudeDir, resolveRunDir, ensureRunDir, repoRootOf };
3419
+ }
3420
+
3421
+ function habitDeps() {
3422
+ return { flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, listTraces, traceTs };
3260
3423
  }
3261
3424
 
3262
3425
  function laneList(claudeDir) {
@@ -3330,6 +3493,19 @@ const LANE_CALLS = {
3330
3493
  never: "never read `clear` as proof that an AD-HOC dispatch finished — the hook writes no SPAWN for one, so no record exists",
3331
3494
  lanes: ["orc", "orc-doc", "orc-fast", "orc-mini", "orc-quick", "orc-test", "orc-wiki"],
3332
3495
  },
3496
+ // v2.0.0 T21 — the CLI holds the trace pen: one packet → the .txt AND the .jsonl.
3497
+ "trace-write": {
3498
+ cmd: "orc trace write --packet - [--json]",
3499
+ what: "write ONE phase packet (stdin, plain YAML or JSON) into the run's trace pair",
3500
+ 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" },
3501
+ states: null,
3502
+ cost: "free",
3503
+ when: "at every phase close, in the same tool block as the next phase's first dispatch",
3504
+ on_absent: "exit ≠ 0 → dispatch `orc-trace-writer-haiku-4-5` with the SAME packet (the fallback)",
3505
+ canonical: "_shared/phases/trace.md",
3506
+ never: "never append trace lines by hand, and never retry a refused packet with a \"now\" timestamp",
3507
+ lanes: ["orc", "orc-fast", "orc-mini", "orc-quick", "orc-test"],
3508
+ },
3333
3509
  "lane-phases": {
3334
3510
  cmd: "orc lane phases <lane> [--json]",
3335
3511
  what: "which SHARED phases this lane runs, in order — the file, the layers to read, and when",
@@ -3938,7 +4114,7 @@ function laneCallsCmd(lane, claudeDir) {
3938
4114
  //
3939
4115
  // Per phase: \`file\` + \`layers\` (never a line number — /orc-doc rule 2, a
3940
4116
  // stored line number is a wrong line number one edit later), \`when\` and
3941
- // \`read\` (the partial-read declarations registered in \`_shared/read-ladder.md\`
4117
+ // \`read\` (the partial-read declarations registered in \`_shared/phases/README.md\`
3942
4118
  // at W10), \`optional_when\` (a config KEY, resolved through the priority ladder
3943
4119
  // by \`orc lane config\`, never read raw), and \`calls\` — catalogue ids from
3944
4120
  // \`LANE_CALLS\`, DERIVED rather than restated, so a lane can ask for one
@@ -3966,9 +4142,25 @@ const PHASE_FILES = {
3966
4142
  why_single_layer: null,
3967
4143
  why_two_layers:
3968
4144
  "every other lane READS the protocol; orc-diy STITCHES a flow-shaped restatement of it into FLOW-COMPILED.md, and a composed flow owes one packet per ENABLED phase group rather than per phase",
4145
+ // v2.0.0 T1 — the verbs are NOT here: every lane owes TRACE_ALWAYS_VERBS,
4146
+ // and the rest arrive with the phase that emits them.
4147
+ trace_verbs: [],
4148
+ },
4149
+ // v2.0.0 T1 — the full human verb table, split out of trace.md so a run
4150
+ // stops reading ~30 KB of grammar it never emits. `when: on-demand`: a lane
4151
+ // opens it only for a verb its `trace_grammar` does not carry. `via` names
4152
+ // the phase file that points at it — the lint accepts that pointer instead
4153
+ // of one per lane spine.
4154
+ "trace-verbs": {
4155
+ file: "_shared/phases/trace-verbs.md",
4156
+ layers: ["core"],
4157
+ via: "trace",
4158
+ why_single_layer: "it is a reference table, and TRACE_VERBS in bin/cli.js is the data behind it; a lane reads its own rows from `trace_grammar`",
4159
+ trace_verbs: [],
3969
4160
  },
3970
4161
  preflight: {
3971
4162
  file: "_shared/phases/preflight.md",
4163
+ trace_verbs: ["GATE", "GRAPH-CONSULT"],
3972
4164
  layers: ["core", "full"],
3973
4165
  // W13 moved this off a hardcoded `lane !== "orc"` branch and onto the same
3974
4166
  // lane->layer map the build phases use — one mechanism, not two.
@@ -3983,6 +4175,7 @@ const PHASE_FILES = {
3983
4175
  },
3984
4176
  "plan-handoff": {
3985
4177
  file: "_shared/phases/plan-handoff.md",
4178
+ trace_verbs: ["PHASE", "GATE"],
3986
4179
  layers: ["core"],
3987
4180
  why_single_layer:
3988
4181
  "executing a plan another session wrote is one procedure, and /orc-route READS it to define what a plan is — a second definition is drift the lint cannot see (v0.42.0)",
@@ -3994,12 +4187,14 @@ const PHASE_FILES = {
3994
4187
  },
3995
4188
  "analyst-gates": {
3996
4189
  file: "_shared/phases/analyst-gates.md",
4190
+ trace_verbs: ["GATE"],
3997
4191
  layers: ["core"],
3998
4192
  why_single_layer:
3999
4193
  "these are the orchestrator-side gates on a returned analysis or plan, and a trimmed lane runs fewer of them rather than different ones",
4000
4194
  },
4001
4195
  "wiki-consult": {
4002
4196
  file: "_shared/phases/wiki-consult.md",
4197
+ trace_verbs: ["WIKI-CONSULT", "CROSSLINK"],
4003
4198
  layers: ["core"],
4004
4199
  why_single_layer: "the precedence ladder (code > fresh wiki > stale wiki > model priors) does not bend for a faster lane",
4005
4200
  },
@@ -4024,6 +4219,7 @@ const PHASE_FILES = {
4024
4219
  },
4025
4220
  "stop-resume": {
4026
4221
  file: "_shared/phases/stop-resume.md",
4222
+ trace_verbs: ["GATE"],
4027
4223
  layers: ["core"],
4028
4224
  why_single_layer:
4029
4225
  "the stop sequence is identical in every lane that stops; which moments are MANDATORY stops is already a per-lane rule in the spine",
@@ -4031,6 +4227,7 @@ const PHASE_FILES = {
4031
4227
 
4032
4228
  "planning": {
4033
4229
  file: "_shared/phases/planning.md",
4230
+ trace_verbs: ["PHASE", "CONFIG", "WIKI-CONSULT", "CROSSLINK", "GRAPH-MAP", "GRAPH-COCHANGE", "GATE", "PACT"],
4034
4231
  layers: ["full", "composed"],
4035
4232
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4036
4233
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4043,6 +4240,7 @@ const PHASE_FILES = {
4043
4240
  },
4044
4241
  "scoring": {
4045
4242
  file: "_shared/phases/scoring.md",
4243
+ trace_verbs: ["PHASE", "SCORE"],
4046
4244
  layers: ["full", "composed"],
4047
4245
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4048
4246
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4055,6 +4253,7 @@ const PHASE_FILES = {
4055
4253
  },
4056
4254
  "execution": {
4057
4255
  file: "_shared/phases/execution.md",
4256
+ trace_verbs: ["PHASE", "GATE", "OUTCOME", "TDD-RED", "TDD-GREEN", "REPRO", "REPLAN", "QUESTION", "CONTEXT-GAP", "BOUNDARY", "EXTRA", "GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-HINT"],
4058
4257
  layers: ["full", "composed"],
4059
4258
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4060
4259
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4067,6 +4266,7 @@ const PHASE_FILES = {
4067
4266
  },
4068
4267
  "review": {
4069
4268
  file: "_shared/phases/review.md",
4269
+ trace_verbs: ["PHASE", "FINDING", "GRAPH-CHANGES"],
4070
4270
  layers: ["full", "composed"],
4071
4271
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4072
4272
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4091,6 +4291,7 @@ const PHASE_FILES = {
4091
4291
  },
4092
4292
  "verify": {
4093
4293
  file: "_shared/phases/verify.md",
4294
+ trace_verbs: ["PHASE", "VERDICT", "PACT"],
4094
4295
  layers: ["full", "composed"],
4095
4296
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4096
4297
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4115,6 +4316,7 @@ const PHASE_FILES = {
4115
4316
  },
4116
4317
  "mock-example": {
4117
4318
  file: "_shared/phases/mock-example.md",
4319
+ trace_verbs: ["PHASE", "DRIFT"],
4118
4320
  layers: ["full", "composed"],
4119
4321
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4120
4322
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4127,6 +4329,7 @@ const PHASE_FILES = {
4127
4329
  },
4128
4330
  "ship": {
4129
4331
  file: "_shared/phases/ship.md",
4332
+ trace_verbs: ["PHASE", "GATE", "GRAPH-UPDATE", "GRAPH-GAIN"],
4130
4333
  layers: ["full", "composed"],
4131
4334
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4132
4335
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4139,6 +4342,7 @@ const PHASE_FILES = {
4139
4342
  },
4140
4343
  "summary": {
4141
4344
  file: "_shared/phases/summary.md",
4345
+ trace_verbs: ["PHASE", "FINDING-OUTCOME"],
4142
4346
  layers: ["full", "composed"],
4143
4347
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4144
4348
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4169,12 +4373,205 @@ const PREFLIGHT_CALL_IDS = new Set([
4169
4373
  "run-list",
4170
4374
  ]);
4171
4375
 
4376
+ // ── v2.0.0 T20 — `probes{}`: one preflight call instead of 4–6 ──────────────
4377
+ //
4378
+ // W0 counted 2–6 CLI calls at preflight before the first dispatch, each one a
4379
+ // round trip that re-sends the whole cached session. `orc lane config` is
4380
+ // already the one call every lane makes first, so it now carries the ANSWERS of
4381
+ // the probes that lane makes at start.
4382
+ //
4383
+ // Each probe runs the stand-alone command's OWN code, in this process, with its
4384
+ // own argv: the same function, the same exit code, the same fields — so the
4385
+ // entry cannot drift from the command. The entry is BRIEF (T17): `exit`, `line`
4386
+ // and the fields a lane branches on. `--probes-full` adds the whole stand-alone
4387
+ // answer as `full`; `--no-probes` leaves the block out.
4388
+ //
4389
+ // READ-ONLY, always. `graph status` runs WITHOUT `--heal` (a heal builds or
4390
+ // writes the index) — `heal_needed: true` says the lane still makes that one
4391
+ // call. `extra resolve` runs without the `orc extra` credential sweep (a
4392
+ // delete). A probe that throws or gives no answer is `{exit, error}` — never a
4393
+ // thrown error, and never a change to `lane config`'s own exit code.
4394
+ //
4395
+ // The set per lane is DERIVED: PREFLIGHT_CALL_IDS ∩ the lane's LANE_CALLS rows,
4396
+ // plus `graph-status` and `rules-slice` where the lane makes them, plus
4397
+ // /orc-quick's slot probe. `challenge-status` needs a slug, so it is not here.
4398
+ const PROBE_DEFS = {
4399
+ "wiki-status": {
4400
+ argv: () => ["wiki", "status"],
4401
+ run: () => wiki(),
4402
+ pick: ["state", "tier", "docs", "distance", "last_scan"],
4403
+ line: (j) =>
4404
+ j.state === "none"
4405
+ ? "wiki: none — no wiki yet"
4406
+ : j.state === "registered"
4407
+ ? `wiki: ${j.tier || "tier unknown"}${j.distance === null ? "" : ` (${j.distance}c)`} — ${plural(j.docs, "doc")} · last scan ${j.last_scan || "?"}`
4408
+ : `wiki: ${String(j.state).toUpperCase()} — ${plural(j.docs, "doc")} (run: orc wiki sync)`,
4409
+ },
4410
+ "pattern-status": {
4411
+ argv: () => ["pattern", "status"],
4412
+ run: () => pattern(),
4413
+ pick: ["cached"],
4414
+ derive: (j) => ({ langs: (j.patterns || []).map((p) => p.lang) }),
4415
+ line: (j) => (j.cached ? `pattern: cached — ${(j.patterns || []).map((p) => p.lang).join(", ")}` : "pattern: none cached"),
4416
+ },
4417
+ "gotcha-status": {
4418
+ argv: () => ["gotcha", "status"],
4419
+ run: () => gotcha(),
4420
+ pick: ["count", "gotchas_max"],
4421
+ line: (j) => (j.count ? `gotchas: ${j.count} live (cap ${j.gotchas_max})` : "gotchas: none recorded — nothing to inject"),
4422
+ },
4423
+ "pact-status": {
4424
+ argv: () => ["pact", "status"],
4425
+ run: () => pact(),
4426
+ pick: ["ok", "reason", "entries", "counts"],
4427
+ line: (j) => j.line || "pact: no ledger yet — a first run",
4428
+ },
4429
+ "boundary-status": {
4430
+ argv: () => ["boundary", "status"],
4431
+ run: () => boundary(),
4432
+ pick: ["ok", "reason", "counts", "stale"],
4433
+ line: (j) => j.line || "boundary: no cards yet — every area is UNKNOWN, never assumed safe",
4434
+ },
4435
+ "aftermath-status": {
4436
+ argv: () => ["aftermath", "status"],
4437
+ run: () => aftermath(),
4438
+ pick: ["ok", "reason", "window_days", "counts"],
4439
+ line: (j) =>
4440
+ j.ok
4441
+ ? `aftermath: ${plural((j.runs || []).length, "run")} in the last ${j.window_days} days · ${Object.entries(j.counts || {}).map(([k, v]) => `${v} ${k}`).join(" · ") || "no grade"}`
4442
+ : j.reason === "shallow"
4443
+ ? `aftermath: no runs in the last ${j.window_days} days — too shallow to grade`
4444
+ : `aftermath: ${j.reason}`,
4445
+ },
4446
+ "diy-status": {
4447
+ argv: () => ["diy", "status"],
4448
+ run: () => diy(),
4449
+ pick: ["state", "reason"],
4450
+ line: (j) => `diy: ${j.state}${j.reason ? " — " + j.reason : ""}`,
4451
+ },
4452
+ "run-list": {
4453
+ argv: () => ["run", "list"],
4454
+ run: () => runCmd(),
4455
+ pick: ["total"],
4456
+ line: (j) => `runs: ${j.total}`,
4457
+ },
4458
+ "graph-status": {
4459
+ argv: () => ["graph", "status", "--if-enabled", "--brief"],
4460
+ run: () => graphCmd(),
4461
+ pick: ["state", "enabled", "exists", "files", "symbols", "generation", "auto_update", "notes", "trace"],
4462
+ derive: (j) => ({ heal_needed: !!(j.enabled && j.auto_update && (j.state === "none" || j.state === "drifted")) }),
4463
+ line: (j) => j.line,
4464
+ },
4465
+ "rules-slice": {
4466
+ argv: (lane) => ["rules", "slice", "--lane", lane],
4467
+ run: () => rulesCmd(),
4468
+ pick: ["packs", "overrides"],
4469
+ line: (j) => j.line,
4470
+ },
4471
+ "extra-slot": {
4472
+ argv: () => ["extra", "resolve", "--slot", "quick-executor"],
4473
+ run: () => extraResolveCmd(resolveClaudeDir(), undefined),
4474
+ pick: ["resolved", "via", "slot", "needs_reping", "held_back"],
4475
+ line: (j) => (j.resolved === "extra" ? `extra: ${j.announce}` : `extra: ${j.slot} → claude ${(j.claude && j.claude.agent) || ""}`.trim()),
4476
+ },
4477
+ };
4478
+
4479
+ function laneProbeIds(lane) {
4480
+ const ids = Object.entries(LANE_CALLS)
4481
+ .filter(([id, c]) => id !== "lane-config" && PREFLIGHT_CALL_IDS.has(id) && c.lanes.includes(lane) && PROBE_DEFS[id])
4482
+ .map(([id]) => id);
4483
+ if (LANE_CALLS["graph-status"].lanes.includes(lane)) ids.push("graph-status");
4484
+ if (LANE_CALLS["rules-slice"].lanes.includes(lane)) ids.push("rules-slice");
4485
+ if (lane === "orc-quick") ids.push("extra-slot");
4486
+ return ids;
4487
+ }
4488
+
4489
+ // Run ONE stand-alone command in this process and take its answer. The argv is
4490
+ // swapped in for the call and put back after it; stdout, stderr and exit are
4491
+ // caught, so nothing reaches the terminal and nothing ends this process.
4492
+ function runProbe(argv, fn) {
4493
+ const saved = args.slice();
4494
+ const pass = [];
4495
+ const di = saved.indexOf("--dir");
4496
+ if (di !== -1 && saved[di + 1] !== undefined) pass.push("--dir", saved[di + 1]);
4497
+ if (saved.includes("--global")) pass.push("--global");
4498
+ args.splice(0, args.length, ...argv, "--json", ...pass);
4499
+ const cap = { obj: null, text: [], err: [] };
4500
+ const SENT = { probe: true };
4501
+ let code = null;
4502
+ let error = null;
4503
+ const keep = { exit: process.exit, log: console.log, error: console.error, warn: console.warn };
4504
+ process.exit = (c) => {
4505
+ code = c === undefined ? 0 : c;
4506
+ throw SENT;
4507
+ };
4508
+ console.log = (...a) => cap.text.push(a.map(String).join(" "));
4509
+ console.error = console.warn = (...a) => cap.err.push(a.map(String).join(" "));
4510
+ PROBE_CAPTURE = cap;
4511
+ try {
4512
+ fn();
4513
+ if (code === null) code = 0;
4514
+ } catch (e) {
4515
+ if (e !== SENT) {
4516
+ error = String((e && e.message) || e).split("\n")[0];
4517
+ if (code === null) code = 1;
4518
+ }
4519
+ } finally {
4520
+ PROBE_CAPTURE = null;
4521
+ process.exit = keep.exit;
4522
+ console.log = keep.log;
4523
+ console.error = keep.error;
4524
+ console.warn = keep.warn;
4525
+ args.splice(0, args.length, ...saved);
4526
+ }
4527
+ let obj = cap.obj;
4528
+ if (!obj && cap.text.length) {
4529
+ try {
4530
+ obj = JSON.parse(cap.text.join("\n"));
4531
+ } catch (_) {}
4532
+ }
4533
+ if (!obj && !error) error = String(cap.err.find((l) => l.trim()) || "no answer").trim().split("\n")[0];
4534
+ return { exit: code, obj, error };
4535
+ }
4536
+
4537
+ function laneProbes(lane, full) {
4538
+ const out = {};
4539
+ for (const id of laneProbeIds(lane)) {
4540
+ const def = PROBE_DEFS[id];
4541
+ const r = runProbe(def.argv(lane), def.run);
4542
+ if (r.error || !r.obj) {
4543
+ out[id] = { exit: r.exit, error: r.error };
4544
+ continue;
4545
+ }
4546
+ const j = r.obj;
4547
+ let line;
4548
+ try {
4549
+ line = def.line(j);
4550
+ } catch (e) {
4551
+ line = null;
4552
+ }
4553
+ const e = { exit: r.exit, line };
4554
+ for (const k of def.pick) if (Object.prototype.hasOwnProperty.call(j, k)) e[k] = j[k];
4555
+ if (def.derive) Object.assign(e, def.derive(j));
4556
+ if (full) e.full = j;
4557
+ out[id] = e;
4558
+ }
4559
+ return out;
4560
+ }
4561
+
4172
4562
  // tier + packet cadence per trace-owning lane. This is the same data the tier
4173
4563
  // table in \`_shared/phases/trace.md\` renders for a human; the lint asserts the
4174
4564
  // two agree, so a lane cannot be in one and absent from the other.
4175
4565
  const LANE_TRACE = {
4176
- orc: { tier: "Build lanes", token: "orc" },
4177
- "orc-mini": { tier: "Build lanes", token: "mini" },
4566
+ // `spine_verbs` (v2.0.0 T1): the verbs a lane's own SPINE emits outside
4567
+ // any declared phase row — orc's ultra gates, and orc-mini, whose pipeline is
4568
+ // still in-spine. They join `trace_grammar` like a phase's `trace_verbs`.
4569
+ orc: { tier: "Build lanes", token: "orc", spine_verbs: ["ADVISE", "JUDGE"] },
4570
+ "orc-mini": {
4571
+ tier: "Build lanes",
4572
+ token: "mini",
4573
+ spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "GRAPH-CONSULT", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-NOTES"],
4574
+ },
4178
4575
  "orc-fast": { tier: "Build lanes", token: "fast" },
4179
4576
  "orc-wiki": { tier: "Multi-dispatch", token: "wiki" },
4180
4577
  "orc-pr-driver": { tier: "Multi-dispatch", token: "prdriver" },
@@ -4202,12 +4599,96 @@ const LANE_TRACE = {
4202
4599
  "orc-export": { tier: "Single-dispatch", token: "export" },
4203
4600
  };
4204
4601
 
4602
+ // v2.0.0 T1 — the CLOSED trace verb set, as DATA (the `LANE_CALLS` shape).
4603
+ // `_shared/phases/trace-verbs.md` renders this table for a human, and
4604
+ // `bin/verify-contracts.js` holds the two together BOTH ways, plus every
4605
+ // `trace_verbs` / `spine_verbs` entry in the phase manifest: a verb a phase
4606
+ // lists that this registry lacks FAILS. `orc lane phases <lane> --json` hands a
4607
+ // lane `trace_grammar` = the grammar lines of exactly the verbs its phases emit
4608
+ // plus TRACE_ALWAYS_VERBS, so a run never reads the whole table.
4609
+ //
4610
+ // grammar — the line shape; ` · ` separates the sub-forms of one verb.
4611
+ // emitter — who produces it: `orc → writer` is a packet event, `hook` needs
4612
+ // no cooperation, `X→orc → writer` is a subagent-returned marker.
4613
+ // meaning — ONE line. The detail lives at `owner`.
4614
+ const TRACE_VERBS = {
4615
+ PHASE: { grammar: "PHASE <name> start|end", emitter: "orc → writer", meaning: "phase transition", owner: "_shared/phases/trace.md" },
4616
+ "PHASE-EDGE": { grammar: "PHASE-EDGE <role-family> :: first=<agent>", emitter: "hook", meaning: "deterministic phase inference: a SPAWN whose role family differs from the previous one opens an edge", owner: "hooks/orc-trace.js" },
4617
+ 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" },
4618
+ "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" },
4619
+ 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" },
4620
+ "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" },
4621
+ "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" },
4622
+ "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" },
4623
+ "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" },
4624
+ "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" },
4625
+ "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" },
4626
+ "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" },
4627
+ "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" },
4628
+ "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" },
4629
+ SPAWN: { grammar: "SPAWN <agent>", emitter: "hook", meaning: "an agent dispatch was observed (skeleton)", owner: "hooks/orc-trace.js" },
4630
+ 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" },
4631
+ DISPATCH: { grammar: "DISPATCH <agent> :: <task> expect=<model>/<effort>[ via=extra:<profile>]", emitter: "orc → writer", meaning: "orchestrator dispatched a named agent (the claim)", owner: "_shared/phases/trace-verbs.md" },
4632
+ SCORE: { grammar: "SCORE task=<id> score=<n> band=<range> model=<m> facets=<compact-vector>[ via=extra:<profile>] :: <reason>", emitter: "orc → writer", meaning: "scoring decision (tunes the rubric); a fix cycle emits `task=fix-<n>`", owner: "_shared/phases/scoring.md" },
4633
+ VERIFY: { grammar: "VERIFY <task> actual=<model>/<effort> ✅ MATCH · VERIFY <task> actual=<model>/<effort> ⛔ DOWNGRADE expected=<m>/<e>", emitter: "orc → writer", meaning: "claimed-vs-actual model check; surface a downgrade to the user, not just the trace", owner: "_shared/return-validation.md" },
4634
+ ASK: { grammar: "ASK <qid> :: offered=<o1|o2|…> rec=<o|none> chose=<o|other> by=<user|ledger|learned|config|default> [pre=<o>] [ctx=<k=v,…>]", emitter: "orc → writer", meaning: "one answered decision point — what `orc habit` learns from; the free text of an `other` answer is never stored", owner: "_shared/phases/trace-verbs.md" },
4635
+ QUESTION: { grammar: "QUESTION count=<n> :: <topic>", emitter: "subagent→orc → writer", meaning: "stopped to ask the user", owner: "_shared/phases/trace-verbs.md" },
4636
+ "CONTEXT-GAP": { grammar: "CONTEXT-GAP :: <what was already known>", emitter: "subagent→orc → writer", meaning: "asked/re-derived something already in context", owner: "_shared/phases/trace-verbs.md" },
4637
+ REPLAN: { grammar: "REPLAN wave=<n> :: <reason>", emitter: "orc → writer", meaning: "re-planned after a conflict/failure", owner: "_shared/phases/trace-verbs.md" },
4638
+ GATE: { grammar: "GATE <grounding|coverage|graph|evidence|derivation|facet|schema|judgment|wave-boundary|budget|stack-gate|stack-certainty|layer-green> pass|bounce|escalate :: <detail>", emitter: "orc → writer", meaning: "exit-gate result; `escalate` is judgment-only; bounce detail lists the misses", owner: "_shared/phases/trace-verbs.md" },
4639
+ ADVISE: { grammar: "ADVISE :: brief=<path> questions=<n>", emitter: "orc → writer", meaning: "ultra Phase U0 — advisor brief received, clarification round relayed", owner: "orc/references/ultra-mode.md" },
4640
+ JUDGE: { grammar: "JUDGE <analysis|plan|implementation> <verdict> round=<n> blocking=<n> advisory=<n> downgraded=<n>", emitter: "orc → writer", meaning: "ultra judgment verdict", owner: "orc/references/ultra-mode.md" },
4641
+ 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" },
4642
+ 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" },
4643
+ "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 — what became of each finding; `orc gotcha quality` reads it", owner: "_shared/phases/trace-verbs.md" },
4644
+ VERDICT: { grammar: "VERDICT pass|fail :: <detail>", emitter: "verifier→orc → writer", meaning: "verification outcome", owner: "_shared/phases/verify.md" },
4645
+ 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" },
4646
+ "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" },
4647
+ "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" },
4648
+ 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" },
4649
+ 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" },
4650
+ 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" },
4651
+ 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" },
4652
+ 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" },
4653
+ 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" },
4654
+ 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" },
4655
+ FINISH: { grammar: "FINISH :: <detail>", emitter: "orc → writer", meaning: "run ended — mandatory, even on an abort", owner: "_shared/phases/trace.md" },
4656
+ };
4657
+
4658
+ // The verbs EVERY trace-owning lane owes, whatever its phases list. Every lane
4659
+ // sends at least the ONE end-of-run packet, and trace.md makes each of these
4660
+ // part of it: DISPATCH + VERIFY (the main return is dispatched and checked),
4661
+ // NOTE (the packet's `decisions`), STATS (every trace-owning lane emits it, not
4662
+ // just `orc`) and FINISH (mandatory, even on an abort). PHASE and CONFIG are
4663
+ // NOT here: a single-dispatch lane closes no named phase, and CONFIG is the
4664
+ // build lanes' Phase-1 line — both come in through the phase rows that emit them.
4665
+ const TRACE_ALWAYS_VERBS = ["DISPATCH", "VERIFY", "NOTE", "STATS", "FINISH"];
4666
+
4667
+ // `trace_grammar` for one lane: TRACE_VERBS order, hook verbs left out (a lane
4668
+ // never emits them), `null` for a lane that owns no trace.
4669
+ function laneTraceGrammar(lane) {
4670
+ const t = LANE_TRACE[lane];
4671
+ if (!t) return null;
4672
+ const want = new Set(TRACE_ALWAYS_VERBS);
4673
+ for (const id of LANE_PHASE_ORDER[lane] || PHASE_ORDER)
4674
+ if ((LANE_PHASES[id] || []).includes(lane)) for (const v of PHASE_FILES[id].trace_verbs || []) want.add(v);
4675
+ for (const p of LANE_OWN_PHASES[lane] || []) for (const v of p.trace_verbs || []) want.add(v);
4676
+ for (const v of t.spine_verbs || []) want.add(v);
4677
+ const out = {};
4678
+ for (const [v, d] of Object.entries(TRACE_VERBS)) if (want.has(v) && d.emitter !== "hook") out[v] = d.grammar;
4679
+ return out;
4680
+ }
4681
+
4205
4682
  // Which lanes run which SHARED phase. A lane absent from a row does not run
4206
4683
  // that phase — \`/orc-retro\` is in no trace row because it mines traces and
4207
4684
  // writes none (its hard rule 4), and \`context-combiner\` is in none because it
4208
4685
  // is a PHASE of the analyze run, not a lane (v0.42.0).
4209
4686
  const LANE_PHASES = {
4210
4687
  trace: Object.keys(LANE_TRACE),
4688
+ // v2.0.0 T1 — the verb table, ON DEMAND. Every lane that traces may need a
4689
+ // verb its `trace_grammar` does not carry, and it reaches the table through
4690
+ // trace.md (`via` in PHASE_FILES), never through its own spine.
4691
+ "trace-verbs": Object.keys(LANE_TRACE),
4211
4692
  preflight: [
4212
4693
  "orc",
4213
4694
  "orc-boundary",
@@ -4262,6 +4743,7 @@ const LANE_PHASES = {
4262
4743
  const PHASE_ORDER = [
4263
4744
  "preflight",
4264
4745
  "trace",
4746
+ "trace-verbs",
4265
4747
  "intake",
4266
4748
  "plan-handoff",
4267
4749
  "wiki-consult",
@@ -4296,6 +4778,7 @@ const PHASE_ORDER = [
4296
4778
  const LANE_PHASE_ORDER = {
4297
4779
  "orc-diy": [
4298
4780
  "trace",
4781
+ "trace-verbs",
4299
4782
  "intake",
4300
4783
  "planning",
4301
4784
  "wave-grouping",
@@ -4320,7 +4803,7 @@ const LANE_PHASE_ORDER = {
4320
4803
  // A row names a FILE and, when the phase still lives in that lane's spine, the
4321
4804
  // HEADING inside it (plan rule: a manifest names a file, a layer and at most a
4322
4805
  // heading — never a line number, /orc-doc rule 2). `read: "section"` is the
4323
- // partial-read declaration registered in `_shared/read-ladder.md`.
4806
+ // partial-read declaration registered in `_shared/phases/README.md`.
4324
4807
  //
4325
4808
  // WHY MOST ROWS NAME A HEADING RATHER THAN A FILE (v1.0.0 W14, measured):
4326
4809
  // moving a phase into its own file pays only when a run does NOT reach it —
@@ -4463,7 +4946,7 @@ const LANE_OWN_PHASES = {
4463
4946
  { 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"] },
4464
4947
  { 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"] },
4465
4948
  { ord: 2, id: "q2", file: "orc-quick/SKILL.md", heading: "## Q2 — ASK (ONE user turn: questions + the gate together)", read: "section", trace_verbs: [] },
4466
- { 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", "FINISH"] },
4949
+ { 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", "FINISH"] },
4467
4950
  ],
4468
4951
  "orc-wiki": [
4469
4952
  { ord: 0, id: "phase-0", file: "orc-wiki/references/phases/phase-0.md", layers: ["full"], trace_verbs: [] },
@@ -4493,20 +4976,22 @@ function lanePhaseRows(lane) {
4493
4976
  file: def.file,
4494
4977
  layers,
4495
4978
  read: "whole",
4496
- // `always` must be justified (read-ladder.md, W10): a lane reads the
4979
+ // `always` must be justified (phases/README.md, W10): a lane reads the
4497
4980
  // config resolver and opens its trace pointer before it can do anything
4498
4981
  // at all. Every other phase is `on-phase` — most runs skip most phases.
4499
4982
  // `compile-time` is orc-diy and only orc-diy: it opens none of these
4500
4983
  // during a run. `orc diy compile` reads the `composed` layer once and
4501
4984
  // stitches it into FLOW-COMPILED.md, which is the only spine that run
4502
4985
  // follows. Calling that `on-phase` would describe a read that never
4503
- // happens (read-ladder.md rule 5).
4986
+ // happens (phases/README.md rule 5).
4504
4987
  when:
4505
4988
  lane === "orc-diy" && (PHASE_FILES[id].lane_layers || {})["orc-diy"]
4506
4989
  ? "compile-time"
4507
4990
  : id === "preflight" || id === "trace"
4508
4991
  ? "always"
4509
- : "on-phase",
4992
+ : PHASE_FILES[id].via
4993
+ ? "on-demand"
4994
+ : "on-phase",
4510
4995
  optional_when: null,
4511
4996
  // v1.7.0 — `rules` names its own call for the same reason `preflight`
4512
4997
  // does: the phase IS that command, and a phase row reading "none
@@ -4654,8 +5139,10 @@ function laneRailsManifest() {
4654
5139
  // is an answer, not a gap.
4655
5140
  if (!t || !t.token) continue;
4656
5141
  const rows = [];
5142
+ // An `on-demand` row is a reference a lane MAY open, never a phase a run
5143
+ // is IN — a rail step for it would be a phase the statusline can never show.
4657
5144
  for (const p of lanePhaseRows(lane))
4658
- rows.push({ id: p.id, label: railLabel(p), kind: railKind(p), verbs: [] });
5145
+ if (p.when !== "on-demand") rows.push({ id: p.id, label: railLabel(p), kind: railKind(p), verbs: [] });
4659
5146
  for (const p of LANE_OWN_PHASES[lane] || [])
4660
5147
  rows.push({
4661
5148
  id: p.id,
@@ -4700,6 +5187,9 @@ function lanePhasesCmd(lane, claudeDir) {
4700
5187
  lane: l,
4701
5188
  trace_tier: t ? t.tier : null,
4702
5189
  trace_token: t ? t.token : null,
5190
+ // v2.0.0 T1 — the grammar line of every verb this lane's phases emit,
5191
+ // plus TRACE_ALWAYS_VERBS. A verb not here → `_shared/phases/trace-verbs.md`.
5192
+ trace_grammar: laneTraceGrammar(l),
4703
5193
  phases,
4704
5194
  shared_phase_count: phases.length,
4705
5195
  // `null` is NOT `[]`: a lane whose own pipeline is still declared in its
@@ -4730,6 +5220,11 @@ function lanePhasesCmd(lane, claudeDir) {
4730
5220
  ? ui.color.gray(`tier ${r.trace_tier} · lane token \`${r.trace_token}\``)
4731
5221
  : ui.color.gray("this lane owns no trace (it is not a run entry point)"))
4732
5222
  );
5223
+ if (r.trace_grammar)
5224
+ console.log(
5225
+ " trace grammar " +
5226
+ ui.color.gray(`${Object.keys(r.trace_grammar).length} verbs: ${Object.keys(r.trace_grammar).join(" · ")} (--json → trace_grammar)`)
5227
+ );
4733
5228
  if (!r.phases.length) {
4734
5229
  // An empty answer is an ANSWER: this lane runs no SHARED phase yet.
4735
5230
  console.log(
@@ -4800,6 +5295,9 @@ function laneConfigCmd(lane, claudeDir) {
4800
5295
  return 2;
4801
5296
  }
4802
5297
  const d = laneConfig(lane, claudeDir);
5298
+ // v2.0.0 T20 — the preflight probes' answers ride on this one call. LAST, so
5299
+ // every 1.9.2 field keeps its position.
5300
+ if (!args.includes("--no-probes")) d.probes = laneProbes(lane, args.includes("--probes-full"));
4803
5301
  if (wantsJson()) {
4804
5302
  emitJson({ ok: true, ...d });
4805
5303
  return 0;
@@ -4816,6 +5314,31 @@ function laneConfigCmd(lane, claudeDir) {
4816
5314
  console.log("");
4817
5315
  }
4818
5316
 
5317
+ // v2.0.0 T20 — the probes this lane makes at start, one line each.
5318
+ if (d.probes && Object.keys(d.probes).length) {
5319
+ console.log(ui.header("Probes (the preflight answers — exit code is data)"));
5320
+ const pw = Math.max(...Object.keys(d.probes).map((k) => k.length));
5321
+ for (const [id, p] of Object.entries(d.probes))
5322
+ console.log(` ${ui.color.cyan(id.padEnd(pw))} exit ${p.exit} ${p.error ? ui.color.yellow("error: " + p.error) : p.line}`);
5323
+ console.log("");
5324
+ }
5325
+
5326
+ // v2.0.0 W2 — only when `habits` is not off; off prints nothing new.
5327
+ if (d.habits) {
5328
+ const h = d.habits;
5329
+ console.log(ui.header("Habits"));
5330
+ console.log(" " + h.line);
5331
+ for (const l of h.learned) console.log(` learned ${l.key}: ${l.value} (${l.id}, ${l.count} of ${l.total}) undo: ${l.undo}`);
5332
+ for (const x of h.suggestions) console.log(` usual ${x.qid}: ${x.option} ${x.mark} (${x.id})`);
5333
+ if (h.proposal) {
5334
+ console.log(` proposal ${h.proposal.id} (${h.proposal.kind}) — show at the END of the run only:`);
5335
+ console.log(" " + h.proposal.line);
5336
+ console.log(ui.color.gray(` yes: ${h.proposal.yes}${h.proposal.keep ? " keep: " + h.proposal.keep : ""}${h.proposal.later ? " not now: " + h.proposal.later : ""} never: ${h.proposal.never}`));
5337
+ console.log(ui.color.gray(` trace: ${h.proposal.trace}`));
5338
+ }
5339
+ console.log("");
5340
+ }
5341
+
4819
5342
  console.log(ui.header(`Keys this lane reads (${d.keys.length})`));
4820
5343
  if (!d.keys.length) console.log(ui.color.gray(" none — this lane reads no config key."));
4821
5344
  const pad = d.keys.length ? Math.max(...d.keys.map((k) => k.key.length)) : 0;
@@ -4826,7 +5349,9 @@ function laneConfigCmd(lane, claudeDir) {
4826
5349
  ? ui.color.yellow(" shadowed")
4827
5350
  : k.is_overridden
4828
5351
  ? ui.color.green(" overridden")
4829
- : ui.color.gray(" default");
5352
+ : k.state === "learned"
5353
+ ? ui.color.cyan(" learned") + ui.color.gray(` (${k.source})`)
5354
+ : ui.color.gray(" default");
4830
5355
  console.log(` ${ui.color.cyan(k.key.padEnd(pad))} ${String(k.value).padEnd(24)} ${k.prio} ${k.family}${mark}`);
4831
5356
  if (k.inert_reason) console.log(ui.color.gray(` ↳ ${k.inert_reason}`));
4832
5357
  else if (k.shadow_reason) console.log(ui.color.gray(` ↳ ${k.shadow_reason}`));
@@ -8404,6 +8929,11 @@ function gotchaStatus(claudeDir, verbose) {
8404
8929
  trigger: e.fields.trigger || null,
8405
8930
  fields: e.fields,
8406
8931
  })),
8932
+ // v2.0.0 W7 — the Behaviour panel's Gotchas tab: source, computed
8933
+ // status and a 7-week hits line per entry, plus the last sync state.
8934
+ // It READS the sync file; it never runs a sync.
8935
+ // `status` (a lane preflight) skips it: only `list` pays for the git call.
8936
+ ...(verbose ? { panel: require("./gotcha.js").panelView(claudeDir, gotchaDeps(), entries) } : {}),
8407
8937
  },
8408
8938
  entries.length ? 0 : 1
8409
8939
  );
@@ -9752,6 +10282,18 @@ ${line || ""}`;
9752
10282
  process.exit(1);
9753
10283
  }
9754
10284
 
10285
+ // The helpers `bin/gotcha.js` borrows: the v1 parser and paths stay HERE, so
10286
+ // `GOTCHA_HEAD` has one home and a 1.9.2 CLI still reads a 2.0 file.
10287
+ function gotchaDeps() {
10288
+ return { args, flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, repoRootOf, parseGotchas, gotchasPath, gotchasArchivePath, GOTCHA_HEAD };
10289
+ }
10290
+
10291
+ // An async importer that throws: one line, exit 1, never a stack in a lane.
10292
+ function asyncFail(e) {
10293
+ console.error("❌ " + ((e && e.message) || String(e)));
10294
+ process.exit(1);
10295
+ }
10296
+
9755
10297
  function gotcha() {
9756
10298
  if (flag("--global")) {
9757
10299
  console.error("❌ orc gotcha is project-scoped — the memory is this repo's. Run it from the project (or with --dir <path>).");
@@ -9765,7 +10307,8 @@ function gotcha() {
9765
10307
  gotchaStatus(claudeDir, false);
9766
10308
  break;
9767
10309
  case "list":
9768
- if (args.includes("--archived")) gotchaArchived(claudeDir);
10310
+ if (args.includes("--candidates")) require("./gotcha.js").gotchaCmd(gotchaDeps(), "list");
10311
+ else if (args.includes("--archived")) gotchaArchived(claudeDir);
9769
10312
  else gotchaStatus(claudeDir, true);
9770
10313
  break;
9771
10314
  case "show":
@@ -9777,13 +10320,35 @@ function gotcha() {
9777
10320
  if (args.includes("--dry-run")) gotchaPruneDryRun(claudeDir);
9778
10321
  else gotchaPrune(claudeDir);
9779
10322
  break;
10323
+ // v2.0.0 W4 — the gotchas engine (bin/gotcha.js): the CLI assigns ids,
10324
+ // dedupes, matches, builds the reviewer card and computes promotion.
10325
+ case "add":
10326
+ case "match":
10327
+ case "card":
10328
+ case "filter":
10329
+ case "observe":
10330
+ case "accept":
10331
+ case "quality":
10332
+ case "why":
10333
+ case "export":
10334
+ require("./gotcha.js").gotchaCmd(gotchaDeps(), pos[1]);
10335
+ break;
10336
+ // v2.0.0 W6a — the importers and the review-step sync (bin/gotcha-import.js).
10337
+ // They FEED `observe`; they never write a gotchas.md entry themselves.
10338
+ case "import":
10339
+ require("./gotcha-import.js").importCmd(gotchaDeps()).catch(asyncFail);
10340
+ break;
10341
+ case "sync":
10342
+ require("./gotcha-import.js").syncCmd(gotchaDeps()).catch(asyncFail);
10343
+ break;
9780
10344
  default:
9781
10345
  console.error(
9782
10346
  `Unknown: orc gotcha ${pos[1]}\n` +
9783
10347
  "Usage: orc gotcha status | list [--archived] repair memory (exit 0 = entries, 1 = none)\n" +
9784
10348
  " orc gotcha show <id> ONE entry, every field (exit 0 / 3 unknown id)\n" +
9785
10349
  " orc gotcha prune [--dry-run] archive the low-value tail down to gotchas_max.\n" +
9786
- " --dry-run NAMES every entry and writes nothing"
10350
+ " --dry-run NAMES every entry and writes nothing\n" +
10351
+ require("./gotcha.js").USAGE.replace(/^Usage: /, " ")
9787
10352
  );
9788
10353
  process.exit(1);
9789
10354
  }
@@ -10227,9 +10792,12 @@ function pr(alias) {
10227
10792
  // alias `orc pr-stack-template [<slug>]`.
10228
10793
  if (alias === "pr-stack-template") return stackTemplate(claudeDir, pos[1]);
10229
10794
  if (alias === "pr-stack-status") return stackStatus(claudeDir, pos[1]);
10795
+ // v2.0.0 W6a (Q1) — the unresolved review threads, read-only, via `gh`.
10796
+ if (pos[1] === "threads") return require("./gotcha-import.js").prThreadsCmd(gotchaDeps());
10230
10797
  const usage =
10231
10798
  "Usage: orc pr stack template [<slug>] write a fill-in stack-plan skeleton\n" +
10232
10799
  " orc pr stack status [<slug>] is a plan READY? (exit 0 ready / 1 absent-or-unfilled)\n" +
10800
+ " orc pr threads <n> [--all] the unresolved review threads (read-only; exit 0 · 1 none open · 5 gh missing/not authed)\n" +
10233
10801
  " (aliases: orc pr-stack-template, orc pr-stack-status)";
10234
10802
  if (pos[1] !== "stack") {
10235
10803
  console.error(`Unknown: orc ${pos.slice(1).join(" ") || ""}\n${usage}`);
@@ -11386,6 +11954,9 @@ function runCmd() {
11386
11954
  // Read-only, and the ONE reader of the pending sidecar. 0 clear / 1 in-flight / 2 unknown.
11387
11955
  if (sub === "inflight") return runInflightCmd(claudeDir);
11388
11956
 
11957
+ // v2.0.0 W6b (Q2)  the pre-run tree, so `orc undo` can never eat a user edit.
11958
+ if (sub === "snapshot") return require("./run-undo.js").snapshotCmd(runUndoDeps());
11959
+
11389
11960
  if (sub === "show") {
11390
11961
  const arg = pos[2];
11391
11962
  const pick = /^\d+$/.test(String(arg)) ? runs[Number(arg) - 1] : runs.find((r) => r.slug === arg);
@@ -11473,7 +12044,7 @@ function runCmd() {
11473
12044
  if (sub !== "list") {
11474
12045
  console.error(
11475
12046
  `Unknown subcommand: orc run ${sub}\n` +
11476
- `Try: orc run list | orc run show <slug|n> | orc run close <slug> --reason "<why>" | orc run reopen <slug>`
12047
+ `Try: orc run list | orc run show <slug|n> | orc run close <slug> --reason "<why>" | orc run reopen <slug> | orc run snapshot --run <slug>`
11477
12048
  );
11478
12049
  process.exit(1);
11479
12050
  }
@@ -11653,7 +12224,7 @@ function stats() {
11653
12224
  const empty = (msg) => {
11654
12225
  if (asJson)
11655
12226
  emitJson(
11656
- { log_dir: dir, runs: 0, from: null, to: null, lanes: {}, agents: {}, dispatches: 0, downgrades: 0, unfinished: 0, unknown_lane: 0, graph: statsGraph(claudeDir) },
12227
+ { log_dir: dir, runs: 0, from: null, to: null, lanes: {}, agents: {}, dispatches: 0, downgrades: 0, unfinished: 0, unknown_lane: 0, graph: statsGraph(claudeDir), questions: null },
11657
12228
  1
11658
12229
  );
11659
12230
  console.log(msg);
@@ -11715,6 +12286,13 @@ function stats() {
11715
12286
 
11716
12287
  const laneRows = [...lanes.entries()].sort((a, b) => b[1] - a[1]);
11717
12288
  const agentRows = [...agents.entries()].sort((a, b) => b[1] - a[1]);
12289
+ // v2.0.0 W6b (Q7) — questions per run, from the habit engine's ONE pass over
12290
+ // the ASK / QUESTION lines. null = it could not be computed, never a guess.
12291
+ let questions = null;
12292
+ try {
12293
+ const H = require("./habit.js");
12294
+ questions = H.questionStats(H.collect(claudeDir, habitDeps()), { since: typeof since === "string" && since ? since : null });
12295
+ } catch (_) {}
11718
12296
 
11719
12297
  if (asJson) {
11720
12298
  console.log(
@@ -11731,6 +12309,7 @@ function stats() {
11731
12309
  unfinished,
11732
12310
  unknown_lane: unknownLane,
11733
12311
  graph: statsGraph(claudeDir),
12312
+ questions,
11734
12313
  },
11735
12314
  null,
11736
12315
  2
@@ -11768,6 +12347,20 @@ function stats() {
11768
12347
  ["retrieval probably kept out", `~${kTok(gr.avoided_low)} – ${kTok(gr.avoided_high)} (an ESTIMATE — see \`orc graph gain\`)`],
11769
12348
  ]));
11770
12349
  }
12350
+ // Q7 — "fewer questions", measured. Every field --json carries is printed.
12351
+ if (questions && questions.runs) {
12352
+ console.log("\n" + ui.color.bold("Questions") + ` ${questions.asked} asked · ${questions.answered_for_you} answered for you`);
12353
+ console.log(ui.kv([
12354
+ ["per run (median)", String(questions.per_run_p50)],
12355
+ ["asked by a subagent", String(questions.subagent_questions)],
12356
+ ["how each was answered", Object.entries(questions.by).map(([k, v]) => `${k} ${v}`).join(" · ")],
12357
+ ["last runs, oldest first", questions.per_run_trend.map((r) => r.asked).join(" ")],
12358
+ ]));
12359
+ for (const [l, r] of Object.entries(questions.by_lane).sort((a, b) => b[1].asked - a[1].asked))
12360
+ console.log(` /${l.padEnd(18)} ${String(r.asked).padStart(3)} asked in ${plural(r.runs, "run")} · median ${r.per_run_p50} · ${r.answered_for_you} answered for you`);
12361
+ const pts = Object.entries(questions.by_point).sort((a, b) => b[1].asked - a[1].asked);
12362
+ for (const [q, r] of pts) console.log(ui.color.gray(` ${q.padEnd(34)} ${r.asked} asked · ${r.answered_for_you} answered for you`));
12363
+ }
11771
12364
  console.log("\n" + ui.color.bold("Health"));
11772
12365
  console.log(ui.kv([
11773
12366
  ["model downgrades", String(downgrades)],
@@ -34471,6 +35064,37 @@ function doctor() {
34471
35064
  }
34472
35065
  } catch (_) {}
34473
35066
 
35067
+ // 5a-bis2) the session hook (v2.0.0 Q8/Q9). TWO entries: `Stop` (the bell,
35068
+ // armed only by `notify: bell`) and `SessionStart` on `compact` (the run
35069
+ // pointer after a compaction, always armed). One finding when either is missing.
35070
+ try {
35071
+ const cfg = resolvedConfig(claudeDir);
35072
+ let hk = {};
35073
+ try {
35074
+ hk = (JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8")) || {}).hooks || {};
35075
+ } catch (_) {}
35076
+ const wiredOn = (event, matcher) =>
35077
+ Array.isArray(hk[event]) &&
35078
+ hk[event].some((e) => (e.matcher || null) === (matcher || null) && (e.hooks || []).some((h) => String(h.command || "").includes("orc-session-hook")));
35079
+ const missing = [["Stop", null], ["SessionStart", "compact"], ["SubagentStart", null]].filter(([e, m]) => !wiredOn(e, m)).map(([e, m]) => (m ? `${e} ${m}` : e));
35080
+ const bell = String(cfg.notify || "off") === "bell";
35081
+ if (missing.length)
35082
+ warn(
35083
+ "session-hook-unwired",
35084
+ `session hook not wired on ${missing.join(", ")} — ` +
35085
+ [
35086
+ missing.includes("SessionStart compact") ? "a compacted session is not told which run is in flight" : null,
35087
+ missing.includes("Stop") ? (bell ? "notify is bell, but the bell never rings" : "the bell cannot ring if notify is set to bell") : null,
35088
+ missing.includes("SubagentStart") ? "a reviewer gets no gotcha card" : null,
35089
+ ]
35090
+ .filter(Boolean)
35091
+ .join("; ") +
35092
+ "; run `orc update`",
35093
+ { fixable: true, fix: "orc update", fix_command: "orc update" }
35094
+ );
35095
+ else ok(`session hook wired (Stop + SessionStart compact + SubagentStart) · notify ${bell ? "bell" : "off"}`);
35096
+ } catch (_) {}
35097
+
34474
35098
  // 5a-ter) the code graph (v1.8.0). ONE finding, and only while `code_graph` is
34475
35099
  // on — the read-gate rule. DRIFTED is the only state worth a line: NONE is
34476
35100
  // built by the next code lane's preflight, and a doctor that warns about a
@@ -40906,8 +41530,23 @@ Usage:
40906
41530
  --reason "<why>" moves RESUME.md aside (never deletes it) and records why,
40907
41531
  so it stops counting as waiting [--json]
40908
41532
  orc run reopen <slug|n> put it back — it is waiting again [--json]
41533
+ orc run snapshot --run <slug> at run start: what the tree looked like BEFORE the run
41534
+ (a git ref + the untracked files), so undo can tell your
41535
+ edits from the run's own [--json]
41536
+ orc undo --run <slug> [--files a,b] PRINTS the commands that revert ONLY the files this run
41537
+ [--apply] changed, back to the snapshot; --apply runs them. Never
41538
+ touches a file outside the run, keeps a run file you
41539
+ edited later (exit 4) [--json]
40909
41540
  orc stats [--since YYYY-MM-DD] [--json] how much you actually use each lane and agent, counted
40910
41541
  from the trace filenames — no model, instant, free
41542
+ orc habit show|log|points|why|accept|decline|forget|reset|doctor|export|purge your usual answers,
41543
+ learned from the ASK lines in your traces (config key
41544
+ \`habits\`, off by default). Never applied without your yes [--json]
41545
+ orc habit repo [accept|decline|forget <id>] soft preferences read from git history (commit,
41546
+ branch, test-file naming). An accepted one rides in
41547
+ \`orc rules slice\` as the LEARNED tier [--json]
41548
+ orc trace write --packet -|<file> write ONE phase packet into the run's trace pair (.txt +
41549
+ .jsonl). Exit 2 = a bad packet, nothing written [--json]
40911
41550
  orc wait the deterministic half of /orc-wait — a wait costs zero
40912
41551
  tokens, because a detached command does it, not a model
40913
41552
  orc wait lanes [--json] which lanes support a wait, what each one checkpoints,
@@ -44454,6 +45093,22 @@ const RULES_PRECEDENCE = [
44454
45093
  { rank: 3, layer: "ORC rules", scope: "everything", where: "<claude>/skills/_shared/rules/", beats: "nothing — it is the floor, and yours replaces it" },
44455
45094
  ];
44456
45095
 
45096
+ // v2.0.0 DE-8 — the LEARNED tier sits between your rules and the ORC rules.
45097
+ // It is named ONLY while `habits` is on: off = the ladder of the release before.
45098
+ function rulesPrecedence(claudeDir) {
45099
+ let on = false;
45100
+ try {
45101
+ on = require("./habit.js").habitsMode(readOverride(claudeDir).map) !== "off";
45102
+ } catch (_) {}
45103
+ if (!on) return RULES_PRECEDENCE;
45104
+ return [
45105
+ RULES_PRECEDENCE[0],
45106
+ RULES_PRECEDENCE[1],
45107
+ { rank: 3, layer: "learned", scope: "commit, branch and test-file naming", where: "<claude>/orc/habits-state.json (`orc habit repo`)", beats: "the ORC rules — and your rules beat it" },
45108
+ Object.assign({}, RULES_PRECEDENCE[2], { rank: 4 }),
45109
+ ];
45110
+ }
45111
+
44457
45112
  // Which packs ride in which lane. `ui` is absent from every row ON PURPOSE: it
44458
45113
  // is added PER TASK by the orchestrator when a task's declared files are
44459
45114
  // front-end. A UI rule in a backend slice is tokens paid on every spawn for a
@@ -44722,6 +45377,10 @@ function rulesLine(packs, blocks, overrides) {
44722
45377
  // which are what a rule's body is mostly made of. Nothing is hidden: the pack
44723
45378
  // file is named beside the rules, exactly as the light tiers already are.
44724
45379
  const RULES_COMPACT_LANES = new Set(["orc-quick"]);
45380
+ // v2.0.0 DE-17 — the SAME compact card for orc-mini / orc-fast, behind
45381
+ // `rules_card_compact` (default off). It flips only when eval E4 passes
45382
+ // (eval/results/2.0.0/E4.md). Off = the full card, byte for byte.
45383
+ const RULES_COMPACT_OPT_IN = new Set(["orc-mini", "orc-fast"]);
44725
45384
 
44726
45385
  // One line out of a rule body: the first paragraph, whitespace collapsed, cut
44727
45386
  // at a word boundary. A rule whose instruction does not survive that is a rule
@@ -44742,7 +45401,16 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44742
45401
  const overridden = new Set(overrides.filter((o) => o.known).map((o) => o.id));
44743
45402
  const want = new Set([...(RULE_LANE_PACKS[lane] || []), ...(extraPacks || [])]);
44744
45403
  const chosen = packs.packs.filter((p) => want.has(p.id));
44745
- const compact = RULES_COMPACT_LANES.has(lane);
45404
+ let cfgMap = {};
45405
+ try {
45406
+ cfgMap = readOverride(claudeDir).map || {};
45407
+ } catch (_) {}
45408
+ const compact =
45409
+ RULES_COMPACT_LANES.has(lane) || (RULES_COMPACT_OPT_IN.has(lane) && /^(on|true)$/.test(String(cfgMap.rules_card_compact === undefined || cfgMap.rules_card_compact === null ? "on" : cfgMap.rules_card_compact)));
45410
+ // DE-8 — the LEARNED tier. `null` under `habits: off`: then the slice is
45411
+ // byte-identical to the release before, and the field is not even present.
45412
+ const learned = require("./habit.js").learnedRules(claudeDir, repoRootOf(claudeDir), cfgMap);
45413
+ const inForce = learned ? learned.filter((r) => r.state === "applied") : [];
44746
45414
 
44747
45415
  const L = [];
44748
45416
  if (!led.empty) {
@@ -44752,6 +45420,13 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44752
45420
  if (body) L.push(pr, body, "");
44753
45421
  }
44754
45422
  }
45423
+ if (inForce.length) {
45424
+ L.push(
45425
+ "LEARNED PREFERENCES — computed from this repo's own history, and accepted by you. Your rules above win over these. These win over the ORC rules below.",
45426
+ ...inForce.map((r) => `${r.id} · ${r.rule}`),
45427
+ ""
45428
+ );
45429
+ }
44755
45430
  if (chosen.length) {
44756
45431
  L.push(
44757
45432
  `ORC RULES — the anti-slop baseline. Shipped with ORC, read-only. ${packs.count} rules, ${plural(chosen.length, "pack")} in this slice.`,
@@ -44795,6 +45470,9 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44795
45470
  // `compact` is REPORTED, never asked for. A reader that cannot tell a short
44796
45471
  // card from a stripped one cannot trust either.
44797
45472
  compact,
45473
+ // Present ONLY when habits is on (DE-16: off = zero bytes). Stale rows are
45474
+ // listed with their reason and are NOT in `text`.
45475
+ ...(learned ? { learned } : {}),
44798
45476
  text: L.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""),
44799
45477
  overrides,
44800
45478
  line: rulesLine(packs, led.blocks, overrides),
@@ -45071,7 +45749,7 @@ function rulesCmd() {
45071
45749
  },
45072
45750
  priorities: RULE_PRIORITIES,
45073
45751
  tiers: RULE_TIERS,
45074
- precedence: RULES_PRECEDENCE,
45752
+ precedence: rulesPrecedence(claudeDir),
45075
45753
  overrides,
45076
45754
  overrides_note:
45077
45755
  "counted from ORC rule ids you NAMED in your own rules. A conflict you did not name is found by the agent at dispatch and returned as rules_conflicts[] — the CLI cannot parse intent, so it does not pretend to.",
@@ -45084,7 +45762,7 @@ function rulesCmd() {
45084
45762
 
45085
45763
  console.log(`\norc rules — ${line}\n`);
45086
45764
  console.log(ui.color.bold(" Precedence") + ui.color.gray(" the order never changes"));
45087
- for (const r of RULES_PRECEDENCE)
45765
+ for (const r of rulesPrecedence(claudeDir))
45088
45766
  console.log(
45089
45767
  ` ${r.rank}. ${ui.color.cyan(r.layer.padEnd(12))} ${ui.color.gray(r.scope)}\n` +
45090
45768
  ` ${ui.color.gray(r.where)}\n` +
@@ -45247,7 +45925,7 @@ function lintScanFile(abs, rel, opts) {
45247
45925
  } catch (_) {
45248
45926
  return out;
45249
45927
  }
45250
- if (text.includes("")) return out; // binary
45928
+ if (text.includes("\u0000")) return out; // binary
45251
45929
  const lines = text.replace(/\r\n/g, "\n").split("\n");
45252
45930
 
45253
45931
  // THE CARVE-OUT, and it is not a convenience. A rule that bans a word has to
@@ -45590,6 +46268,16 @@ function jsonCrash(err) {
45590
46268
  case "graph":
45591
46269
  graphCmd();
45592
46270
  break;
46271
+ // v2.0.0 W2 — the habit engine. Every subcommand is a READ except accept,
46272
+ // decline, forget, reset and purge, which are habits-state.json's only writers.
46273
+ case "habit":
46274
+ require("./habit.js").habitCmd(habitDeps());
46275
+ break;
46276
+ // v2.0.0 T21 — the CLI holds the trace pen: one packet → the .txt + .jsonl
46277
+ // pair. The Haiku writer is the fallback when this exits ≠ 0.
46278
+ case "trace":
46279
+ require("./trace-write.js").traceCmd({ flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride });
46280
+ break;
45593
46281
  case "mock":
45594
46282
  mock();
45595
46283
  break;
@@ -45679,12 +46367,20 @@ function jsonCrash(err) {
45679
46367
  case "pr-stack-status":
45680
46368
  pr(cmd);
45681
46369
  break;
46370
+ // v2.0.0 W6a (Q3) — the failing CI steps, read-only, flaky re-attempts named.
46371
+ case "ci":
46372
+ require("./gotcha-import.js").ciFailedCmd(gotchaDeps());
46373
+ break;
45682
46374
  case "resume":
45683
46375
  resume();
45684
46376
  break;
45685
46377
  case "run":
45686
46378
  runCmd();
45687
46379
  break;
46380
+ // v2.0.0 W6b (Q2)  revert ONLY what a run changed, back to its snapshot.
46381
+ case "undo":
46382
+ require("./run-undo.js").undoCmd(runUndoDeps());
46383
+ break;
45688
46384
  case "stats":
45689
46385
  stats();
45690
46386
  break;