@azure-id/orc 1.9.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/CHANGELOG.md +722 -0
  2. package/README-id.md +56 -42
  3. package/README.md +58 -41
  4. package/bin/build-agents.js +125 -29
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +1873 -180
  7. package/bin/fix.js +459 -0
  8. package/bin/gotcha-import.js +1081 -0
  9. package/bin/gotcha.js +1566 -0
  10. package/bin/graph-query.js +1 -1
  11. package/bin/graph.js +717 -717
  12. package/bin/habit.js +1538 -0
  13. package/bin/mockrun-catalog.js +284 -276
  14. package/bin/onboarding-content.js +2 -1
  15. package/bin/pricing.json +28 -21
  16. package/bin/run-undo.js +398 -0
  17. package/bin/trace-write.js +869 -0
  18. package/bin/verify-contracts.js +627 -100
  19. package/bin/verify-package.js +68 -10
  20. package/bin/webui/api.js +151 -6
  21. package/bin/webui/app.html +239 -232
  22. package/bin/webui/css/00-tokens.css +110 -92
  23. package/bin/webui/css/04-motion.css +87 -0
  24. package/bin/webui/css/06-responsive.css +203 -178
  25. package/bin/webui/css/panels/behaviour.css +205 -0
  26. package/bin/webui/css/panels/hookui.css +79 -0
  27. package/bin/webui/fixtures/behaviour.js +562 -0
  28. package/bin/webui/fixtures/extra.js +9 -9
  29. package/bin/webui/fixtures/flow.js +81 -81
  30. package/bin/webui/fixtures/hookui.js +506 -431
  31. package/bin/webui/fixtures/index.js +66 -3
  32. package/bin/webui/fixtures/knowledge.js +9 -3
  33. package/bin/webui/fixtures/maintenance.js +123 -42
  34. package/bin/webui/fixtures/rules.js +1007 -1002
  35. package/bin/webui/fixtures/settings.js +6 -7
  36. package/bin/webui/fixtures/stats.js +25 -6
  37. package/bin/webui/fixtures/wait.js +98 -97
  38. package/bin/webui/i18n/en/behaviour.json +156 -0
  39. package/bin/webui/i18n/en/hookui.json +290 -194
  40. package/bin/webui/i18n/en/maintenance.json +71 -52
  41. package/bin/webui/i18n/en/nav.json +25 -24
  42. package/bin/webui/i18n/en/settings.json +60 -60
  43. package/bin/webui/i18n/en/tour.json +37 -35
  44. package/bin/webui/i18n/id/behaviour.json +156 -0
  45. package/bin/webui/i18n/id/hookui.json +290 -194
  46. package/bin/webui/i18n/id/maintenance.json +71 -52
  47. package/bin/webui/i18n/id/nav.json +25 -24
  48. package/bin/webui/i18n/id/settings.json +60 -60
  49. package/bin/webui/i18n/id/tour.json +37 -35
  50. package/bin/webui/js/01-i18n.js +155 -154
  51. package/bin/webui/js/05-banners.js +181 -172
  52. package/bin/webui/js/06-edit.js +193 -183
  53. package/bin/webui/js/90-tour.js +498 -494
  54. package/bin/webui/js/91-shortcuts.js +126 -126
  55. package/bin/webui/js/99-boot.js +121 -118
  56. package/bin/webui/js/panels/behaviour.js +1087 -0
  57. package/bin/webui/js/panels/hookui.js +2196 -1691
  58. package/bin/webui/js/panels/maintenance.js +336 -235
  59. package/bin/webui/js/panels/settings.js +726 -719
  60. package/mock-run/INDEX.md +110 -107
  61. package/mock-run/extra-slots.md +1 -1
  62. package/mock-run/gotcha-import.md +118 -0
  63. package/mock-run/habits.md +129 -0
  64. package/mock-run/orc-aftermath.md +392 -392
  65. package/mock-run/orc-budget.md +29 -33
  66. package/mock-run/orc-cli.md +200 -200
  67. package/mock-run/orc-explain.md +86 -86
  68. package/mock-run/orc-extra.md +389 -392
  69. package/mock-run/orc-fix.md +87 -0
  70. package/mock-run/orc-pattern.md +112 -112
  71. package/mock-run/orc-quick.md +6 -1
  72. package/mock-run/orc.md +157 -157
  73. package/package.json +1 -1
  74. package/templates/agents/MODEL-MAPPING.md +37 -34
  75. package/templates/agents/orc-advisor-opus-5-xhigh.md +1 -7
  76. package/templates/agents/orc-analyze-mini-opus-5-med.md +1 -6
  77. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -5
  78. package/templates/agents/{orc-claude-writer-opus-4-8-high.md → orc-claude-writer-opus-5-low.md} +48 -53
  79. package/templates/agents/orc-context-combiner-opus-5-high.md +1 -11
  80. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  81. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  82. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  83. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  84. package/templates/agents/orc-executor-opus-5-high.md +14 -6
  85. package/templates/agents/orc-executor-opus-5-low.md +14 -6
  86. package/templates/agents/orc-executor-opus-5-med.md +14 -6
  87. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  88. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  89. package/templates/agents/orc-executor-sonnet-5-high.md +15 -7
  90. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  91. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  92. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  93. package/templates/agents/orc-judge-opus-5-xhigh.md +5 -9
  94. package/templates/agents/orc-learn-writer-opus-5-low.md +1 -7
  95. package/templates/agents/orc-pattern-codifier-opus-5-med.md +1 -8
  96. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  97. package/templates/agents/orc-planner-mini-opus-5-med.md +1 -4
  98. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -3
  99. package/templates/agents/orc-planner-opus-5-med.md +1 -4
  100. package/templates/agents/orc-recon-opus-5-low.md +1 -8
  101. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  102. package/templates/agents/orc-retro-opus-5-med.md +6 -8
  103. package/templates/agents/orc-retro-sonnet-5-high.md +7 -8
  104. package/templates/agents/orc-reviewer-opus-5-low.md +96 -0
  105. package/templates/agents/orc-scout-opus-5-low.md +1 -6
  106. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  107. package/templates/agents/orc-system-analyst-opus-5-high.md +1 -6
  108. package/templates/agents/orc-test-author-opus-5-med.md +4 -5
  109. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  110. package/templates/agents/orc-verifier-opus-5-med.md +16 -8
  111. package/templates/agents/{orc-wiki-scanner-opus-4-8-high.md → orc-wiki-scanner-opus-5-low.md} +74 -79
  112. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  113. package/templates/commands/orc-analyze.md +13 -21
  114. package/templates/commands/orc-fast.md +10 -15
  115. package/templates/commands/orc-fix.md +18 -0
  116. package/templates/commands/orc-poly.md +12 -21
  117. package/templates/commands/orc-pr-driver.md +11 -30
  118. package/templates/commands/orc-pr-setup.md +10 -31
  119. package/templates/commands/orc-route.md +11 -41
  120. package/templates/commands/orc-test.md +5 -60
  121. package/templates/commands/orc-wiki.md +64 -64
  122. package/templates/hooks/README.md +109 -7
  123. package/templates/hooks/orc-session-hook.js +339 -0
  124. package/templates/hooks/orc-statusline-render.js +317 -23
  125. package/templates/hooks/orc-statusline.js +165 -50
  126. package/templates/hooks/orc-subagent-line.js +23 -5
  127. package/templates/hooks/orc-weather-fetch.js +123 -0
  128. package/templates/skills/_shared/README.md +9 -0
  129. package/templates/skills/_shared/code-graph.md +47 -55
  130. package/templates/skills/_shared/config-precedence.md +3 -1
  131. package/templates/skills/_shared/extra-dispatch.md +78 -93
  132. package/templates/skills/_shared/gotchas.md +248 -177
  133. package/templates/skills/_shared/habits.md +101 -0
  134. package/templates/skills/_shared/lane-contract.md +84 -0
  135. package/templates/skills/_shared/opus5-only.md +14 -14
  136. package/templates/skills/_shared/phases/README.md +142 -83
  137. package/templates/skills/_shared/phases/analyst-gates.md +10 -21
  138. package/templates/skills/_shared/phases/execution.md +8 -14
  139. package/templates/skills/_shared/phases/house-rules.md +27 -32
  140. package/templates/skills/_shared/phases/intake.md +127 -133
  141. package/templates/skills/_shared/phases/mock-example.md +46 -56
  142. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  143. package/templates/skills/_shared/phases/planning.md +7 -17
  144. package/templates/skills/_shared/phases/preflight.md +19 -42
  145. package/templates/skills/_shared/phases/review.md +24 -28
  146. package/templates/skills/_shared/phases/rules.md +18 -42
  147. package/templates/skills/_shared/phases/scoring.md +55 -65
  148. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  149. package/templates/skills/_shared/phases/security.md +45 -55
  150. package/templates/skills/_shared/phases/ship.md +6 -15
  151. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  152. package/templates/skills/_shared/phases/summary.md +73 -48
  153. package/templates/skills/_shared/phases/testgen.md +41 -51
  154. package/templates/skills/_shared/phases/trace-verbs.md +447 -0
  155. package/templates/skills/_shared/phases/trace.md +66 -297
  156. package/templates/skills/_shared/phases/verify.md +62 -70
  157. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  158. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  159. package/templates/skills/_shared/read-ladder.md +2 -55
  160. package/templates/skills/_shared/return-validation.md +17 -70
  161. package/templates/skills/_shared/review-slice.md +106 -0
  162. package/templates/skills/_shared/smoke-gate.md +46 -28
  163. package/templates/skills/_shared/wait.md +1 -0
  164. package/templates/skills/context-combiner/SKILL.md +15 -44
  165. package/templates/skills/orc/README.md +2 -2
  166. package/templates/skills/orc/SKILL.md +35 -63
  167. package/templates/skills/orc/config.md +25 -27
  168. package/templates/skills/orc/examples/full-run-mock.md +2 -1
  169. package/templates/skills/orc/references/effort-and-mode.md +17 -17
  170. package/templates/skills/orc/references/pattern-gate.md +89 -89
  171. package/templates/skills/orc/references/phases/intake.md +41 -47
  172. package/templates/skills/orc/references/phases/integration.md +13 -19
  173. package/templates/skills/orc/references/preflight-report.md +11 -12
  174. package/templates/skills/orc/references/ultra-mode.md +9 -7
  175. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  176. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  177. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  178. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  179. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +12 -53
  180. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  181. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  182. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +11 -22
  183. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  184. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  185. package/templates/skills/orc-advisor/SKILL.md +56 -60
  186. package/templates/skills/orc-analyze/SKILL.md +33 -67
  187. package/templates/skills/orc-analyze/schemas/report-audit.md +2 -1
  188. package/templates/skills/orc-analyze/schemas/report-prose.md +2 -1
  189. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  190. package/templates/skills/orc-claude/SKILL.md +14 -14
  191. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  192. package/templates/skills/orc-diy/README.md +31 -0
  193. package/templates/skills/orc-diy/SKILL.md +27 -78
  194. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  195. package/templates/skills/orc-fast/SKILL.md +49 -75
  196. package/templates/skills/orc-fix/SKILL.md +110 -0
  197. package/templates/skills/orc-judge/SKILL.md +77 -82
  198. package/templates/skills/orc-mini/SKILL.md +64 -112
  199. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  200. package/templates/skills/orc-pattern/SKILL.md +27 -48
  201. package/templates/skills/orc-poly/SKILL.md +32 -61
  202. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  203. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  204. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  205. package/templates/skills/orc-quick/README.md +44 -3
  206. package/templates/skills/orc-quick/SKILL.md +77 -108
  207. package/templates/skills/orc-quick/references/dispatch-gate.md +18 -6
  208. package/templates/skills/orc-quick/references/gh-mode.md +50 -1
  209. package/templates/skills/orc-quick/references/look.md +3 -1
  210. package/templates/skills/orc-retro/SKILL.md +20 -19
  211. package/templates/skills/orc-retro/examples/retro-mock.md +17 -17
  212. package/templates/skills/orc-route/SKILL.md +25 -45
  213. package/templates/skills/orc-test/SKILL.md +16 -37
  214. package/templates/skills/orc-verify/SKILL.md +14 -32
  215. package/templates/skills/orc-wait/SKILL.md +156 -163
  216. package/templates/skills/orc-wiki/README.md +325 -325
  217. package/templates/skills/orc-wiki/SKILL.md +195 -195
  218. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  219. package/templates/skills/orc-wiki/references/extra.md +2 -2
  220. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  221. package/templates/skills/orc-wiki/references/phases/phase-0.md +2 -7
  222. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  223. package/templates/skills/orc-wiki/references/phases/phase-2.md +4 -9
  224. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  225. package/templates/skills/orc-wiki/references/phases/phase-3c.md +4 -8
  226. package/templates/agents/orc-claude-writer-opus-5-med.md +0 -55
  227. package/templates/agents/orc-reviewer-opus-5-med.md +0 -60
  228. package/templates/agents/orc-wiki-scanner-opus-5-med.md +0 -81
package/bin/cli.js CHANGED
@@ -95,8 +95,24 @@ const SL_VALUE_FLAGS = new Set([
95
95
  "--prefix", "--suffix", "--emphasis", "--hide-when", "--width", "--precision",
96
96
  "--min-width", "--min-cols", "--max-cols", "--priority", "--separator",
97
97
  "--max-width", "--theme", "--state", "--set",
98
+ // v2.0.4 — the design fields.
99
+ "--label-pos", "--value-pos", "--caption", "--caption-pos", "--caption-align",
100
+ "--caption-color", "--caption-case", "--fill-color", "--empty-color",
101
+ "--ramp-colors", "--ramp-stops", "--fill-char", "--empty-char", "--brackets",
102
+ "--threshold", "--pad-left", "--pad-right", "--align", "--sign", "--max-len",
103
+ "--unknown", "--state-color", "--state-glyph", "--param", "--sep-color",
98
104
  ]);
99
105
 
106
+ // `orc statusline` value flags: "" and "-x" are real values here ("" clears).
107
+ function slFlag(name) {
108
+ const i = args.indexOf(name);
109
+ if (i === -1) return undefined;
110
+ if (i + 1 >= args.length) return true;
111
+ const val = args[i + 1];
112
+ if (SL_VALUE_FLAGS.has(val) || ["--json", "--board", "--draw-empty", "--global", "--dir", "--ansi", "--align-columns"].includes(val)) return true;
113
+ return val;
114
+ }
115
+
100
116
  function flag(name) {
101
117
  const i = args.indexOf(name);
102
118
  if (i === -1) return undefined;
@@ -112,7 +128,7 @@ function positionals() {
112
128
  for (let i = 0; i < args.length; i++) {
113
129
  const a = args[i];
114
130
  if (a === "--global") continue;
115
- if (a === "--dir" || a === "--from" || a === "--preset" || a === "--reason") {
131
+ if (a === "--dir" || a === "--from" || a === "--preset" || a === "--reason" || a === "--board") {
116
132
  i++; // skip the flag's value
117
133
  continue;
118
134
  }
@@ -179,10 +195,19 @@ function writeStdoutSync(str) {
179
195
  }
180
196
  }
181
197
 
198
+ let PROBE_CAPTURE = null;
199
+
182
200
  // Print the object and (optionally) exit with the human path's code.
183
201
  // `compact` (v1.9.1 B1) writes the answer with no indentation: a `--brief`
184
202
  // answer is read by a model, and the card inside it carries its own newlines.
185
203
  function emitJson(obj, exitCode, compact) {
204
+ // v2.0.0 T20 — an in-process probe (`orc lane config` `probes{}`) takes the
205
+ // object instead of stdout. Outside a probe this line does nothing.
206
+ if (PROBE_CAPTURE) {
207
+ PROBE_CAPTURE.obj = obj;
208
+ if (exitCode !== undefined) process.exit(exitCode);
209
+ return;
210
+ }
186
211
  writeStdoutSync((compact ? JSON.stringify(obj) : JSON.stringify(obj, null, 2)) + "\n");
187
212
  if (exitCode !== undefined) process.exit(exitCode);
188
213
  }
@@ -239,7 +264,7 @@ function installGuards(claudeDir) {
239
264
  // EW3: the graph hook shells `orc graph update`, and a hook must never
240
265
  // guess at PATH — the installer is the one place that knows for certain
241
266
  // which cli.js this payload came from.
242
- JSON.stringify({ version: currentVersion(), cli: path.resolve(__dirname, "cli.js") }) + "\n"
267
+ JSON.stringify({ version: currentVersion(), cli: path.resolve(__dirname, "cli.js"), catalog_hash: slCatalogHash() }) + "\n"
243
268
  );
244
269
  } catch (_) {}
245
270
  // The statusline's phase rail (v1.2.1). Regenerated every install from the
@@ -427,6 +452,35 @@ function installGuards(claudeDir) {
427
452
  wireGraph("PreToolUse", "Bash|Read");
428
453
  wireGraph("PostToolUse", "Read");
429
454
 
455
+ // 7) The session hook (v2.0.0 Q8/Q9) — TWO entries, one file. `Stop` rings
456
+ // the terminal bell when a turn of an ORC run ends, and only under
457
+ // `notify: bell` (default off: the hook reads the key and returns, so an
458
+ // unarmed bell is byte-identical to not having it — the read-gate rule).
459
+ // `SessionStart` on `compact` prints ONE line naming the run's
460
+ // state-of-play.md while a run is in flight. Same (event, matcher) match as
461
+ // the graph hook, so re-running `orc update` changes nothing.
462
+ const sessionHookCmd = nodeCmd(path.join(hooksDest, "orc-session-hook.js"));
463
+ const wireSession = (arrName, matcher) => {
464
+ settings.hooks[arrName] = settings.hooks[arrName] || [];
465
+ for (const entry of settings.hooks[arrName]) {
466
+ if ((entry.matcher || null) !== (matcher || null)) continue;
467
+ for (const h of entry.hooks || []) {
468
+ if (typeof h.command === "string" && h.command.includes("orc-session-hook")) {
469
+ h.command = sessionHookCmd;
470
+ return;
471
+ }
472
+ }
473
+ }
474
+ const entry = { hooks: [{ type: "command", command: sessionHookCmd }] };
475
+ if (matcher) entry.matcher = matcher;
476
+ settings.hooks[arrName].push(entry);
477
+ console.log(` add settings.json → ${arrName} session hook${matcher ? ` (${matcher})` : ""}`);
478
+ };
479
+ wireSession("Stop", null);
480
+ wireSession("SessionStart", "compact");
481
+ // v2.0.2 (eval D13): the reviewer / verifier / judge get the gotcha card here.
482
+ wireSession("SubagentStart", null);
483
+
430
484
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
431
485
  }
432
486
 
@@ -721,6 +775,7 @@ function install({ overwrite, forcePrune }) {
721
775
  }
722
776
 
723
777
  installGuards(claudeDir);
778
+ slRecompileInstalled(claudeDir);
724
779
 
725
780
  // Record ORC's footprint, then (on update only) prune files that left the
726
781
  // payload. A fresh init has nothing to prune; update diffs against the
@@ -1072,6 +1127,8 @@ const KNOWN_MODELS = [
1072
1127
  "claude-opus-5",
1073
1128
  "claude-opus-4-8",
1074
1129
  "claude-opus-4-7",
1130
+ "claude-sonnet-5-5",
1131
+ // Kept so a config written before v2.1.0 still validates.
1075
1132
  "claude-sonnet-5",
1076
1133
  "claude-sonnet-4-6",
1077
1134
  "claude-haiku-4-5",
@@ -1130,6 +1187,15 @@ const vFallbackAgent = tag((raw) => {
1130
1187
  "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
1188
  };
1132
1189
  }, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"] });
1190
+ // v2.0.0 W2 (DE-16) — `auto` is refused BY NAME. It is the one value a reader
1191
+ // of the research expects, and ORC does not have it: no habit is ever applied
1192
+ // without a yes, so a generic "must be one of" would read as a missing feature.
1193
+ const vHabits = tag((raw) => {
1194
+ const v = String(raw || "").trim();
1195
+ if (v === "auto")
1196
+ return { err: "`auto` is not a habits level. ORC never applies a habit without your yes. Use off, observe or propose." };
1197
+ return ["off", "observe", "propose"].includes(v) ? { value: v } : { err: "must be one of: off, observe, propose" };
1198
+ }, { kind: "enum", choices: ["off", "observe", "propose"] });
1133
1199
  const vPath = tag(
1134
1200
  (raw) => (raw && raw.trim() ? { value: raw } : { err: "must be a non-empty path" }),
1135
1201
  { kind: "path" }
@@ -1161,6 +1227,26 @@ const vRepo = tag(
1161
1227
  : { err: "must be a GitHub owner/repo (e.g. azure-id/orc)" },
1162
1228
  { kind: "repo" }
1163
1229
  );
1230
+ // v2.0.0 W4 — the reviewer card budget. Refused BY NAME below the floor: the
1231
+ // budget is a size, and review learning has no off switch (DE-10).
1232
+ const vCardBudget = tag((raw) => {
1233
+ const n = Number(raw);
1234
+ if (!Number.isInteger(n)) return { err: "gotcha_card_budget must be an integer >= 200" };
1235
+ if (n < 200)
1236
+ 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` };
1237
+ return { value: n };
1238
+ }, { kind: "int", min: 200 });
1239
+ // An optional one-word value: empty clears it.
1240
+ const vOptText = tag((raw) => {
1241
+ const s = String(raw == null ? "" : raw).trim();
1242
+ if (/\s/.test(s)) return { err: "must be one word (no spaces)" };
1243
+ return { value: s };
1244
+ }, { kind: "text" });
1245
+ const vSonarUrl = tag((raw) => {
1246
+ const s = String(raw == null ? "" : raw).trim().replace(/\/+$/, "");
1247
+ if (s && !/^https?:\/\/[^\s/]+(\/\S*)?$/.test(s)) return { err: "must be an http(s) URL (e.g. https://sonarcloud.io)" };
1248
+ return { value: s };
1249
+ }, { kind: "text" });
1164
1250
 
1165
1251
  // Roles `orc extra` may hand to a non-Claude worker. Declared HERE, above
1166
1252
  // CONFIG_META, because CONFIG_META is evaluated at module load and a `const`
@@ -1169,6 +1255,8 @@ const vRepo = tag(
1169
1255
  // and no "forever": "forever" is the option that makes every other one
1170
1256
  // pointless, and a deadline that renews itself on use is not a deadline.
1171
1257
  const EXTRA_TTL_DAYS = [1, 3, 7, 14, 30, 90, 180, 360];
1258
+ // v2.1.0 — the closed set `orc clear logs --older-than` and `log_retention_days` take.
1259
+ const LOG_RETENTION_DAYS = [30, 60, 90, 120, 240, 360];
1172
1260
  const EXTRA_ROLES_ALL = [
1173
1261
  "executor",
1174
1262
  "reviewer",
@@ -1319,6 +1407,14 @@ const CONFIG_FAMILIES = {
1319
1407
  // structure costs no model tokens, so nothing about a dispatched model can
1320
1408
  // shadow whether it is built.
1321
1409
  graph: { contested: false, question: "whether code lanes build and read a local map of how the repo is connected" },
1410
+ // v2.0.0 W2 — habits. UNCONTESTED: an accepted habit is not a key, it is the
1411
+ // `learned` RANK under your config file (bin/habit.js), so nothing here
1412
+ // competes with anything. `offers` holds the end-of-run offers a habit can
1413
+ // answer; before 2.0.0 no key existed, so nothing could remember them.
1414
+ habits: { contested: false, question: "whether ORC learns your usual answers and proposes them — never applied without your yes" },
1415
+ offers: { contested: false, question: "which offers a lane still asks after the work, and which your setting answers" },
1416
+ notify: { contested: false, question: "whether a turn of an ORC run ends with the terminal bell" },
1417
+ rules: { contested: false, question: "how large the rules card is in an executor slice" },
1322
1418
  };
1323
1419
 
1324
1420
  // Ordered, tiered metadata. Common first, then advanced.
@@ -1348,6 +1444,15 @@ const CONFIG_META = [
1348
1444
  { 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
1445
  { 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
1446
  { 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)." },
1447
+ // --- v2.0.0 W4 — the gotchas engine (bin/gotcha.js) ---------------------------
1448
+ // Read by the CLI (`orc gotcha card`, `import`, `sync`), never by a spine,
1449
+ // so each `lanes[]` is empty and each key is on the seed-empty allowlist.
1450
+ // Review learning is ALWAYS ON (DE-10): the budget is a SIZE, never a switch.
1451
+ { 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." },
1452
+ { 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." },
1453
+ { 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." },
1454
+ { 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." },
1455
+ { 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
1456
  { 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
1457
  { 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
1458
  { 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." },
@@ -1395,6 +1500,11 @@ const CONFIG_META = [
1395
1500
  // v1.8.2 W2 (G8) — the key the engine already understood and no config
1396
1501
  // exposed. Same `lanes: []` reason as every graph key: the CLI reads it.
1397
1502
  { key: "code_graph_ignore", def: "", tier: "advanced", answers: [{ family: "graph", prio: "P2", mode: "replace" }], gated_by: "code_graph", lanes: [], validate: vGlobs, options: ["", "vendor/**", "generated/**,*.gen.ts"], desc: "Extra paths the graph never indexes, as a comma-separated list of globs relative to the repository root (`vendor/**,generated/**,*.gen.ts`). The engine always skips node_modules, vendor, dist/build at the root, caches, bundles, `.d.ts` and generated files; this adds to that list. A skipped file is reported `excluded` by `orc graph coverage`, never silently. Empty = nothing extra." },
1503
+ // v2.1.0 (DE-21) — WRITE-THROUGH. The value lives in settings.json
1504
+ // (`statusLine.refreshInterval`), never in orc.config.yaml: `config set` calls
1505
+ // the same core as `orc statusline refresh`, and `config list` READS it back,
1506
+ // so the two can never disagree. 0.2 / 0.5 / 0.7 are below Claude Code's floor.
1507
+ { key: "statusline_refresh", def: "off", tier: "advanced", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "1", "2", "3", "5", "10"), options: ["off", "1", "2", "3", "5", "10"], desc: "How often the status line redraws when nothing happens, in seconds (off = only on events). Written straight into the settings.json that holds ORC's hook (statusLine.refreshInterval) — the same as `orc statusline refresh <n>`. Claude Code runs the line at most once a second, so 0.2, 0.5 and 0.7 are refused. The timer ticks for every teammate who shares that settings file." },
1398
1508
  { key: "statusline_custom", def: "off", tier: "common", answers: [{ family: "statusline", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "Whether the status line renders YOUR composed layout instead of the shipped two lines. Off is byte-identical to what ships. Compose the layout in `orc ui` > CLI Hook Interface (the CLI half exists so the panel has something to shell). Turning this on with an invalid or missing layout is refused, naming the reason; a layout that later becomes unreadable falls back to the shipped lines silently and is reported by `orc doctor`." },
1399
1509
  { key: "wait_hop_minutes", def: 30, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [5, 10, 15, 30], desc: "How long ONE detached hop waits before ORC re-reads the window. Short on purpose: each wake-up is session activity, and session activity is the only thing that makes the statusline write a fresh reading. One long sleep wakes into a reading as stale as the sleep was long." },
1400
1510
  { key: "wait_max_hops", def: 5, tier: "advanced", answers: [{ family: "wait", prio: "P2", mode: "replace" }], lanes: [], validate: vInt(1), options: [1, 2, 3, 5, 8, 12], desc: "How many hops before ORC gives up and stops with the hand-back. A wrong reset time must cost you a bounded wait, never a session that never comes back." },
@@ -1442,7 +1552,7 @@ const CONFIG_META = [
1442
1552
  // decision with a recorded reason (the /orc-pact retirement rule).
1443
1553
  { key: "extra_demote_after", def: 2, tier: "common", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: [], validate: vInt(0), options: [0, 2, 3, 5], desc: "Consecutive `stalled` dispatches on ONE profile, inside one run, before that profile is DEMOTED to the bottom of the ladder for the rest of the run — so `opus5_only` (or the shipped score table) becomes the effective P0 and the work stays on Claude. Only `stalled` counts: a 401 or a rate limit has its own answer (`extra_on_failure`, the vault, `extra_resume`), and demoting on one would hide a credential problem behind a routing change. A resume of the same stalled attempt is the SAME stall, never a second one. A demotion is RUN state — it never writes your config — it is ANNOUNCED before the next dispatch, and it is never auto-promoted back: `orc extra promote <run> --reason \"<why>\"`. 0 turns this clock off and leaves only extra_demote_stale_min." },
1444
1554
  { key: "extra_demote_stale_min", def: 20, tier: "common", answers: [{ family: "extra", prio: "P2", mode: "replace" }], gated_by: "extra_enabled", lanes: [], validate: vInt(0), options: [0, 10, 20, 45], desc: "Minutes a LIVE foreign attempt may show no observable progress before its profile is demoted. This is a different question from `extra_stall_s`, which stops ONE dispatch after 180s of silence: this clock is about the RUN — two workers in flight, both quiet, and a wave that is going nowhere — so the two have their own budgets and their own off values and neither is a simplification of the other. It reads the journal's own progress file on disk, never a remembered fact. 0 turns this clock off and leaves only extra_demote_after." },
1445
- { key: "opus5_only", def: false, tier: "common", answers: [{ family: "executor-band", prio: "P1", mode: "replace" }, { family: "fixed-role-model", prio: "P1", mode: "replace" }], lanes: ["orc", "orc-analyze", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-fast", "orc-mini", "orc-pattern", "orc-quick", "orc-retro", "orc-wiki"], validate: vEnum("true", "false"), options: ["true", "false"], desc: "EVERY dispatched role uses ONE model — Opus 5.5 — with EFFORT as the cost dial (executors: [0,40) low · [40,80) medium · [80,100] high; each fixed role its own pinned effort). Deep SWE-benchmark work on cost vs efficiency across Claude models finds a single Opus 5.5 agent with the effort ladder the most efficient setup. It FORCES: while on it outranks a hand-written rubric_bands_override. Needs an Opus 5.5 main session or EVERY dispatch silently downgrades. Excludes the Haiku trace writer and orc-diy (compile-owned)." },
1555
+ { key: "opus5_only", def: false, tier: "common", answers: [{ family: "executor-band", prio: "P1", mode: "replace" }, { family: "fixed-role-model", prio: "P1", mode: "replace" }], lanes: ["orc", "orc-analyze", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-fast", "orc-mini", "orc-pattern", "orc-quick", "orc-retro", "orc-wiki"], validate: vEnum("true", "false"), options: ["true", "false"], desc: "EVERY dispatched role uses ONE model — Opus 5.5 — with EFFORT as the cost dial (executors: [0,90) low · [90,100] medium; each fixed role its own pinned effort). Deep SWE-benchmark work on cost vs efficiency across Claude models finds a single Opus 5.5 agent with the effort ladder the most efficient setup. It FORCES: while on it outranks a hand-written rubric_bands_override. Needs an Opus 5.5 main session or EVERY dispatch silently downgrades. Excludes the Haiku trace writer and orc-diy (compile-owned)." },
1446
1556
  // --- v0.46.0 — the six new lanes ------------------------------------------
1447
1557
  { key: "pact_gate", def: "warn", tier: "common", answers: [{ family: "pact", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-pact"], validate: vEnum("off", "warn"), options: ["off", "warn"], desc: "Invariant ledger at Phase 1 + planning: warn = print the one pact line and inject a DRIFTED/BROKEN promise whose anchors intersect the plan's declared files as a planner constraint; off = nothing. NEVER blocks — a promise is advice with a receipt, not a gate. See /orc-pact." },
1448
1558
  { key: "pact_recheck_on_verify", def: "true", tier: "common", answers: [{ family: "pact", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-pact"], validate: vEnum("true", "false"), options: ["true", "false"], desc: "Phase 6: re-run the cheap checks for ONLY the invariants the change touched (`orc pact check`), so a promise that just leaked is caught in the run that broke it." },
@@ -1477,7 +1587,23 @@ const CONFIG_META = [
1477
1587
  { 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
1588
  { 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
1589
  { 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." },
1590
+ // --- v2.0.0 W2 — habits (bin/habit.js) -------------------------------------
1591
+ // `habits` is read by the CLI, never by a lane: `orc lane config` resolves it
1592
+ // and hands the lane a `habits{}` block (the `code_graph` answer). The other
1593
+ // three are the offers a habit can answer; W3 wires the lanes that read them,
1594
+ // so their `lanes[]` is empty until then. OFF by default, and off is
1595
+ // specified as ZERO BYTES in `orc lane config` — that is a test.
1596
+ { 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." },
1597
+ { 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`." },
1598
+ { 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`." },
1599
+ { 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." },
1600
+ // v2.0.0 W6c — Q8. The terminal bell when a turn of an ORC run ends. The hook is
1601
+ // wired always and reads this key first, so off is byte-identical to no hook.
1602
+ { 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
1603
  { 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." },
1604
+ // v2.0.0 W6c — DE-17. The compact rules card for orc-mini / orc-fast executors.
1605
+ // OFF until eval E4 passes (eval/results/2.0.0/E4.md); off = today's card, byte for byte.
1606
+ { 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
1607
  { 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
1608
  { 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
1609
  { 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." },
@@ -1494,7 +1620,9 @@ const CONFIG_META = [
1494
1620
  { key: "wiki_refresh_ask_tasks", def: 3, tier: "advanced", answers: [{ family: "wiki", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-wiki"], validate: vInt(1), desc: "Post-ship wiki refresh ask fires when the run's task count >= this." },
1495
1621
  { key: "wiki_refresh_ask_files", def: 10, tier: "advanced", answers: [{ family: "wiki", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-wiki"], validate: vInt(1), desc: "…or when the run's touched-file count exceeds this (full/ultra lanes)." },
1496
1622
  { key: "retro_repo", def: "azure-id/orc", tier: "advanced", answers: [{ family: "retro", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-retro"], validate: vRepo, desc: "GitHub owner/repo that receives /orc-retro reports (PR preferred, issue fallback)." },
1497
- { key: "log_dir", def: ".claude/orc/logs", tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-pattern", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-verify", "orc-wiki"], validate: vPath, desc: "Persistent trace folder (never auto-deleted)." },
1623
+ { key: "log_dir", def: ".claude/orc/logs", tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-explain", "orc-export", "orc-fast", "orc-fix", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-pattern", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-verify", "orc-wiki"], validate: vPath, desc: "Persistent trace folder. Kept until you run `orc clear logs --apply`, or until the automatic sweep removes it (`log_retention_auto: on`, older than `log_retention_days`)." },
1624
+ { key: "log_retention_days", def: 90, tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum(...LOG_RETENTION_DAYS.map(String)), options: LOG_RETENTION_DAYS, desc: "The age `orc clear logs` deletes at: finished traces and finished run folders (with their `refs/orc/runs/<slug>/pre` ref) whose LAST activity is older than this many days. It never deletes user data, a waiting run, the active trace, or a file younger than 6 hours. A trace with an ASK line stays for 180 days (habit evidence), and the cutoff never goes below `aftermath_window_days`." },
1625
+ { key: "log_retention_auto", def: "off", tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: [], validate: vEnum("off", "on"), options: ["off", "on"], desc: "on = `orc trace write` runs `orc clear logs --apply` after a lane's FINISH packet, at most once per 24 hours, and writes `last_sweep` into logs-rollup.json. It never blocks a run and never changes the exit code. off (the default) = nothing is deleted unless you run the command." },
1498
1626
  { key: "run_dir", def: RUN_DIR_DEFAULT, tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-challenge", "orc-doc", "orc-pact"], validate: vPath, desc: "Run artifact root (checkpoints/state-of-play) — outside the installer's blast radius." },
1499
1627
  { key: "analyzer_dir", def: ".claude/skills/orc/analyzer", tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["orc", "orc-analyze"], validate: vPath, desc: "Internal analyst artifact dir." },
1500
1628
  { key: "planner_dir", def: ".claude/skills/orc/planner", tier: "advanced", answers: [{ family: "paths", prio: "P2", mode: "replace" }], lanes: ["orc"], validate: vPath, desc: "Internal planner artifact dir." },
@@ -1827,7 +1955,7 @@ function groupRule(title) {
1827
1955
  // `not-read`: a higher rank in this key's family resolved, so nobody looked;
1828
1956
  // `inert`: this key's master gate is off, which removes it from the
1829
1957
  // conversation rather than losing it a precedence contest (`design-03` §5).
1830
- function configKeyState(m, map, claudeDir) {
1958
+ function configKeyState(m, map, claudeDir, learned) {
1831
1959
  const has = Object.prototype.hasOwnProperty.call(map, m.key);
1832
1960
  if (m.gated_by) {
1833
1961
  const g = metaFor(m.gated_by);
@@ -1841,18 +1969,48 @@ function configKeyState(m, map, claudeDir) {
1841
1969
  }
1842
1970
  const why = shadowReason(m.key, map, claudeDir);
1843
1971
  if (why) return { state: "not-read", reason: why, source: has ? "overridden" : "default" };
1972
+ // v2.0.0 W2 — the `learned` rank: below the file, above the shipped default.
1973
+ const l = !has && learned && learned[m.key];
1974
+ if (l)
1975
+ return {
1976
+ state: "learned",
1977
+ reason: `learned from habit ${l.id} (${l.count} of ${l.total}) — undo: orc habit forget ${l.id}`,
1978
+ source: `learned:${l.id}`,
1979
+ value: l.value,
1980
+ };
1844
1981
  return { state: has ? "overridden" : "default", reason: null, source: has ? "overridden" : "default" };
1845
1982
  }
1846
1983
 
1984
+ // The learned values in force project-wide, or {} under `habits: off`.
1985
+ function configLearned(map, claudeDir) {
1986
+ const H = require("./habit.js");
1987
+ if (H.habitsMode(map) === "off") return {};
1988
+ try {
1989
+ return H.learnedFor(claudeDir, habitDeps(), map, null);
1990
+ } catch (_) {
1991
+ return {};
1992
+ }
1993
+ }
1994
+
1847
1995
  const STATE_PAINT = {
1848
1996
  overridden: (s) => ui.color.green(s),
1849
1997
  default: (s) => ui.color.gray(s),
1850
1998
  "not-read": (s) => ui.color.yellow(s),
1851
1999
  inert: (s) => ui.color.yellow(s),
2000
+ learned: (s) => ui.color.cyan(s),
1852
2001
  };
1853
2002
 
2003
+ // v2.1.0 (DE-21) — a write-through key is READ from where it lives.
2004
+ function withLiveKeys(map, claudeDir) {
2005
+ try {
2006
+ const v = slRefreshInterval(slHookInfo(claudeDir, SL_BOARDS.status), SL_BOARDS.status);
2007
+ map.statusline_refresh = v == null ? "off" : String(v);
2008
+ } catch (_) {}
2009
+ return map;
2010
+ }
1854
2011
  function configList(claudeDir) {
1855
- const { path: p, map } = readOverride(claudeDir);
2012
+ const { path: p, map: raw } = readOverride(claudeDir);
2013
+ const map = withLiveKeys(Object.assign({}, raw), claudeDir);
1856
2014
  console.log(
1857
2015
  `\nORC config (override: ${p}${fs.existsSync(p) ? "" : " — not created yet"})\n`
1858
2016
  );
@@ -1863,6 +2021,7 @@ function configList(claudeDir) {
1863
2021
  // UI axis and it still drives the interactive menu, so it rides along as a row
1864
2022
  // marker instead of being the table's spine.
1865
2023
  const families = laneFamilies(null, map, claudeDir);
2024
+ const learned = configLearned(map, claudeDir);
1866
2025
  const byFamily = new Map();
1867
2026
  for (const m of CONFIG_META) {
1868
2027
  const f = m.answers[0].family;
@@ -1891,9 +2050,9 @@ function configList(claudeDir) {
1891
2050
  return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb);
1892
2051
  });
1893
2052
  for (const m of metas) {
1894
- const st = configKeyState(m, map, claudeDir);
2053
+ const st = configKeyState(m, map, claudeDir, learned);
1895
2054
  const has = Object.prototype.hasOwnProperty.call(map, m.key);
1896
- const val = has ? map[m.key] : m.def;
2055
+ const val = has ? map[m.key] : st.value !== undefined ? st.value : m.def;
1897
2056
  const src = (STATE_PAINT[st.state] || ((s) => s))(st.state.padEnd(10));
1898
2057
  const opts = m.options ? ` ${ui.color.gray("[options: " + m.options.join(" | ") + "]")}` : "";
1899
2058
  // v0.55.0 — a DEPRECATED member of a live key is marked on the row that
@@ -1923,7 +2082,7 @@ function configList(claudeDir) {
1923
2082
  } catch (_) {}
1924
2083
  console.log(ui.header("Resolve order (highest wins)"));
1925
2084
  console.log(
1926
- "\n extra route row > opus5_only > rubric_bands_override > the default 6-band table\n"
2085
+ "\n extra route row > opus5_only > rubric_bands_override > the default 5-band table\n"
1927
2086
  );
1928
2087
  if (rows.length)
1929
2088
  console.log(
@@ -2085,7 +2244,7 @@ function shadowReason(key, map, claudeDir) {
2085
2244
  // v1.0.0 W4 — TWO BANDS, not three, and the same 90 edge as the default table's
2086
2245
  // top two rows (D13). That symmetry is the point: once the default table's high
2087
2246
  // end is already Opus 5.5 with effort as the dial, the forcing mode differs from
2088
- // it only BELOW 65, so a third band here would be a distinction the default
2247
+ // it only BELOW 41 (v2.1.0; it was 65), so a third band here would be a distinction the default
2089
2248
  // table stopped making.
2090
2249
  //
2091
2250
  // THIS IS THE ONLY COPY. Until W4 there were two arrays with these rows in this
@@ -2127,7 +2286,7 @@ function scoreTableJson(map, claudeDir) {
2127
2286
  // an extra route row covering this score (only for the scores it covers)
2128
2287
  // > opus5_only
2129
2288
  // > rubric_bands_override
2130
- // > the default 6-band table
2289
+ // > the default 5-band table
2131
2290
  //
2132
2291
  // Extra is an OVERLAY, not a replacement, which is what makes "cheap grunt
2133
2292
  // work goes to DeepSeek, hard work stays on Opus 5.5" a two-command setup
@@ -2193,12 +2352,14 @@ function scoreTableJson(map, claudeDir) {
2193
2352
  // things only a renderer needs: the control shape (derived from the validator),
2194
2353
  // whether another key shadows this one, and the resolved score ladder.
2195
2354
  function configListJson(claudeDir) {
2196
- const { path: p, map } = readOverride(claudeDir);
2355
+ const { path: p, map: raw } = readOverride(claudeDir);
2356
+ const map = withLiveKeys(Object.assign({}, raw), claudeDir);
2197
2357
  const has = (k) => Object.prototype.hasOwnProperty.call(map, k);
2358
+ const learned = configLearned(map, claudeDir);
2198
2359
  const keys = CONFIG_META.map((m) => {
2199
2360
  const v = m.validate || {};
2200
2361
  const why = shadowReason(m.key, map, claudeDir);
2201
- const st = configKeyState(m, map, claudeDir);
2362
+ const st = configKeyState(m, map, claudeDir, learned);
2202
2363
  return {
2203
2364
  key: m.key,
2204
2365
  tier: m.tier,
@@ -2217,7 +2378,7 @@ function configListJson(claudeDir) {
2217
2378
  // The master gate that makes this key inert. A gate does not win a
2218
2379
  // precedence contest; it removes its dependants from the conversation.
2219
2380
  gated_by: m.gated_by || null,
2220
- value: has(m.key) ? map[m.key] : m.def,
2381
+ value: has(m.key) ? map[m.key] : st.value !== undefined ? st.value : m.def,
2221
2382
  default: m.def,
2222
2383
  is_overridden: has(m.key),
2223
2384
  is_shadowed: !!why,
@@ -2228,7 +2389,7 @@ function configListJson(claudeDir) {
2228
2389
  // answers "does anything read it", from `LANE_RANK_STATES`' own words.
2229
2390
  state: st.state,
2230
2391
  state_reason: st.reason,
2231
- source: has(m.key) ? "overridden" : "default",
2392
+ source: st.source,
2232
2393
  desc: m.desc,
2233
2394
  options: m.options || null,
2234
2395
  control: {
@@ -2292,7 +2453,7 @@ function configListJson(claudeDir) {
2292
2453
  // lane-scoped answer asks `orc lane config <lane> --json`, which is the
2293
2454
  // command that owns it.
2294
2455
  families_resolved: laneFamilies(null, map, claudeDir),
2295
- rank_states: LANE_RANK_STATES,
2456
+ rank_states: require("./habit.js").habitsMode(map) !== "off" ? LANE_RANK_STATES.concat("learned") : LANE_RANK_STATES,
2296
2457
  // Permanently on and deliberately not a key — say so, or a reader hunts for
2297
2458
  // the switch (only the folder is configurable).
2298
2459
  behavior_trace: { always_on: true, configurable_key: "log_dir" },
@@ -2333,6 +2494,16 @@ function configSet(claudeDir, key, rawValue) {
2333
2494
  process.exit(1);
2334
2495
  }
2335
2496
  if (res.warn) console.error(` ${res.warn}`);
2497
+ if (key === "statusline_refresh") {
2498
+ const v = String(res.value);
2499
+ const r = slRefreshApply(claudeDir, SL_BOARDS.status, v === "off" ? null : Number(v));
2500
+ if (!r.ok) {
2501
+ console.error(`❌ ${r.hint}`);
2502
+ process.exit(1);
2503
+ }
2504
+ console.log(` ${ui.mark.ok(`statusline_refresh = ${v} (written to ${r.settings})`)}`);
2505
+ return;
2506
+ }
2336
2507
  // W8 (v0.51.0) — THE SETUP GATE. `extra_enabled` is the master switch on a
2337
2508
  // subsystem that sends this repo's source code to a third party, and arming it
2338
2509
  // before any connection has ever answered arms nothing: every dispatch falls
@@ -2358,6 +2529,14 @@ function configSet(claudeDir, key, rawValue) {
2358
2529
  // precedent: a renamed mechanism must never be a silent revert.
2359
2530
  if (key === "extra_roles") extraLegacyRoleWarn(res.value);
2360
2531
  if (key === "opus5_only") opus5Notice(String(res.value) === "true", claudeDir);
2532
+ // DE-27 — the v0.40.0 key stays so an existing file is never silently
2533
+ // overridden, but it is not a feature switch any more.
2534
+ if (key === "gotchas" && String(res.value) === "off")
2535
+ console.error(
2536
+ " ⚠ review learning is designed to stay on. `gotchas: off` is kept for compatibility: it stops the\n" +
2537
+ " gotcha block in EXECUTOR slices only. The reviewer card (`orc gotcha card`) is always built.\n" +
2538
+ " To make the card smaller, set gotcha_card_budget (min 200)."
2539
+ );
2361
2540
  if (key === "opus5_only" || key === "extra_enabled") extraShadowNotice(claudeDir);
2362
2541
  }
2363
2542
 
@@ -2394,7 +2573,7 @@ function extraShadowNotice(claudeDir) {
2394
2573
  return;
2395
2574
  }
2396
2575
  const bands = rows.map((r) => `[${r.from},${r.to >= 100 ? "100]" : r.to + ")"}`).join(", ");
2397
- const base = isTrue(map.opus5_only) ? "opus5_only" : map.rubric_bands_override ? "rubric_bands_override" : "the default 6-band table";
2576
+ const base = isTrue(map.opus5_only) ? "opus5_only" : map.rubric_bands_override ? "rubric_bands_override" : "the default 5-band table";
2398
2577
  console.error(
2399
2578
  ` ⚠ ${bands} ${rows.length === 1 ? "is routed to a non-Claude worker and no longer resolves" : "are routed to a non-Claude worker and no longer resolve"} on ${base}.\n` +
2400
2579
  " Every other score still does — Extra is an overlay, not a replacement.\n" +
@@ -2412,8 +2591,10 @@ const OPUS5_ONLY_ROLES = [
2412
2591
  ["mini plan", "orc-planner-mini-sonnet-5-high", "orc-planner-mini-opus-5-med"],
2413
2592
  ["scout", "orc-scout-sonnet-4-6-high", "orc-scout-opus-5-low"],
2414
2593
  ["pattern codify", "orc-pattern-codifier-sonnet-5-high", "orc-pattern-codifier-opus-5-med"],
2415
- ["wiki scan", "orc-wiki-scanner-opus-4-8-high", "orc-wiki-scanner-opus-5-med"],
2416
- ["claude write", "orc-claude-writer-opus-4-8-high", "orc-claude-writer-opus-5-med"],
2594
+ // v2.1.0 (DE-1): the deep scanner is already Opus 5.5 low, so only the LIGHT
2595
+ // half flips, and onto the deep agent. The CLAUDE.md writer is already Opus 5.5
2596
+ // low and has no row.
2597
+ ["wiki scan", "orc-wiki-scanner-sonnet-5-high", "orc-wiki-scanner-opus-5-low"],
2417
2598
  ["retro mine", "orc-retro-sonnet-5-high", "orc-retro-opus-5-med"],
2418
2599
  ];
2419
2600
 
@@ -2424,7 +2605,7 @@ const OPUS5_ONLY_ROLES = [
2424
2605
  function opus5Notice(on, claudeDir) {
2425
2606
  if (!on) {
2426
2607
  console.log(
2427
- "\n Every role is back to its default pin (executors → the 6-band mixed-model\n" +
2608
+ "\n Every role is back to its default pin (executors → the 5-band mixed-model\n" +
2428
2609
  " table). A hand-written rubric_bands_override is live again."
2429
2610
  );
2430
2611
  return;
@@ -2801,6 +2982,9 @@ const LANES = [
2801
2982
  { lane: "orc-doc", command: "orc-doc" },
2802
2983
  { lane: "orc-explain", command: "orc-explain" },
2803
2984
  { lane: "orc-export", command: "orc-export" },
2985
+ // v2.1.0 W6 — a RIDER, like orc-wait: it opens no run and never writes
2986
+ // `.current`. It records a fix into the run already in flight, or with none.
2987
+ { lane: "orc-fix", command: "orc-fix" },
2804
2988
  { lane: "orc-fast", command: "orc-fast" },
2805
2989
  { lane: "orc-grill", command: "orc-grill" },
2806
2990
  { lane: "orc-handoff", command: "orc-handoff" },
@@ -3234,9 +3418,26 @@ function laneConfig(lane, claudeDir) {
3234
3418
  inert_reason: hit ? hit.reason : gate ? `${m.gated_by} is off, so nothing in its block is consulted` : null,
3235
3419
  };
3236
3420
  });
3421
+ // v2.0.0 W2 — the `learned` rank (bin/habit.js, DE-6). `habits: off` (the
3422
+ // default) adds ZERO BYTES: the habit keys leave `not_read`, no key gains a
3423
+ // field, and there is no `habits` block — the answer is the one 1.9.2 gave.
3424
+ const H = require("./habit.js");
3425
+ const hmode = H.habitsMode(map);
3426
+ let hab = null;
3427
+ if (hmode !== "off") {
3428
+ hab = H.laneBlock(claudeDir, habitDeps(), map, lane);
3429
+ for (const k of keys) {
3430
+ const l = !has(k.key) && hab.learned[k.key];
3431
+ if (l) k.value = l.value;
3432
+ k.source = has(k.key) ? "overridden" : l ? `learned:${l.id}` : "default";
3433
+ k.state = k.is_inert ? "inert" : k.is_shadowed ? "not-read" : l ? "learned" : has(k.key) ? "overridden" : "default";
3434
+ }
3435
+ }
3237
3436
  const effective = {};
3238
3437
  for (const k of keys) effective[k.key] = k.value;
3239
- return {
3438
+ const announce = laneAnnounce(lane, map, claudeDir, families);
3439
+ if (hab) for (const k of keys) if (k.state === "learned") announce.push(H.announceLine(k.key, hab.learned[k.key]));
3440
+ const out = {
3240
3441
  lane,
3241
3442
  command: (LANES.find((l) => l.lane === lane) || {}).command || null,
3242
3443
  command_note: (LANES.find((l) => l.lane === lane) || {}).note || null,
@@ -3247,16 +3448,37 @@ function laneConfig(lane, claudeDir) {
3247
3448
  effective,
3248
3449
  families,
3249
3450
  roles: laneRoles(lane, map, claudeDir),
3250
- announce: laneAnnounce(lane, map, claudeDir, families),
3451
+ announce,
3251
3452
  stops: LANE_STOPS.filter((s) => rows.some((m) => m.key === s.key))
3252
3453
  .filter((s) => s.arms(has(s.key) ? map[s.key] : metaFor(s.key).def))
3253
3454
  .map((s) => ({ key: s.key, value: has(s.key) ? map[s.key] : metaFor(s.key).def, when: s.when, action: s.action })),
3254
3455
  // The keys this lane deliberately ignores. An empty answer is an ANSWER
3255
3456
  // (v0.43.0): "this lane does not read doc_language" is information, and it
3256
3457
  // 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,
3458
+ not_read: CONFIG_META.filter((m) => !(m.lanes || []).includes(lane))
3459
+ .filter((m) => hab || !H.HABIT_KEYS.includes(m.key))
3460
+ .map((m) => m.key),
3461
+ rank_states: hab ? LANE_RANK_STATES.concat("learned") : LANE_RANK_STATES,
3259
3462
  };
3463
+ if (hab) out.habits = hab.block;
3464
+ else for (const f of H.HABIT_FAMILIES) delete out.families[f];
3465
+ return out;
3466
+ }
3467
+
3468
+ // The helpers `bin/habit.js` borrows, so it REUSES the trace listing and the
3469
+ // timestamp parser `orc stats` / `orc run list` already use.
3470
+ function runUndoDeps() {
3471
+ return { args, wantsJson, emitJson, resolveClaudeDir, resolveRunDir, ensureRunDir, repoRootOf };
3472
+ }
3473
+
3474
+ function habitDeps() {
3475
+ return { flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, listTraces, traceTs };
3476
+ }
3477
+
3478
+ // v2.1.0 W7 — `orc clear logs` and the FINISH sweep (bin/clear-logs.js) REUSE
3479
+ // the trace-name parser and the per-trace counts `orc stats` adds up.
3480
+ function clearLogsDeps() {
3481
+ return { flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, resolveRunDir, readOverride, traceNameInfo, traceCountsOf, LOG_RETENTION_DAYS };
3260
3482
  }
3261
3483
 
3262
3484
  function laneList(claudeDir) {
@@ -3316,7 +3538,7 @@ const LANE_CALLS = {
3316
3538
  on_absent: "exit ≠ 0 → say the CLI is unavailable and use the documented defaults, out loud, treating every P0 forcing mode as OFF",
3317
3539
  canonical: "_shared/config-precedence.md",
3318
3540
  never: "never merge `.claude/orc.config.yaml` yourself, and never re-derive a precedence — the answer already carries it",
3319
- lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-pattern", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki"],
3541
+ lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-doc", "orc-explain", "orc-export", "orc-fast", "orc-fix", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-pattern", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki"],
3320
3542
  },
3321
3543
  "run-inflight": {
3322
3544
  cmd: "orc run inflight [--json]",
@@ -3330,6 +3552,19 @@ const LANE_CALLS = {
3330
3552
  never: "never read `clear` as proof that an AD-HOC dispatch finished — the hook writes no SPAWN for one, so no record exists",
3331
3553
  lanes: ["orc", "orc-doc", "orc-fast", "orc-mini", "orc-quick", "orc-test", "orc-wiki"],
3332
3554
  },
3555
+ // v2.0.0 T21 — the CLI holds the trace pen: one packet → the .txt AND the .jsonl.
3556
+ "trace-write": {
3557
+ cmd: "orc trace write --packet - [--json]",
3558
+ what: "write ONE phase packet (stdin, plain YAML or JSON) into the run's trace pair",
3559
+ exits: { 0: "written", 1: "usage or unreadable input", 2: "invalid packet — unknown verb, bad ts, parse error; nothing written", 3: "trace-file state — no `.current` on a later packet, or a rename target in the way; nothing written" },
3560
+ states: null,
3561
+ cost: "free",
3562
+ when: "at every phase close, in the same tool block as the next phase's first dispatch",
3563
+ on_absent: "exit ≠ 0 → dispatch `orc-trace-writer-haiku-4-5` with the SAME packet (the fallback)",
3564
+ canonical: "_shared/phases/trace.md",
3565
+ never: "never append trace lines by hand, and never retry a refused packet with a \"now\" timestamp",
3566
+ lanes: ["orc", "orc-fast", "orc-mini", "orc-quick", "orc-test"],
3567
+ },
3333
3568
  "lane-phases": {
3334
3569
  cmd: "orc lane phases <lane> [--json]",
3335
3570
  what: "which SHARED phases this lane runs, in order — the file, the layers to read, and when",
@@ -3385,6 +3620,37 @@ const LANE_CALLS = {
3385
3620
  never: "never inject the ledger unfiltered — only the entries whose `scope` glob matches the slice",
3386
3621
  lanes: ["orc", "orc-boundary", "orc-brainstorm", "orc-fast", "orc-grill", "orc-mini", "orc-retro", "orc-route"],
3387
3622
  },
3623
+ // v2.1.0 — the review-close record. It was shared PROSE only and every spine
3624
+ // says "make no other call", so no lane made it and Review Quality read
3625
+ // "0 so far". `orc gotcha quality` now also counts reviews from the traces;
3626
+ // this row is what gives a lane the call for the acceptance half.
3627
+ "gotcha-observe": {
3628
+ cmd: "orc gotcha observe - [--json]",
3629
+ what: "record ONE finding outcome (stdin: one JSON object) — the only writer of observations.jsonl",
3630
+ exits: { 0: "appended, promotion ran", 2: "malformed — the reply names the field" },
3631
+ states: null,
3632
+ cost: "free",
3633
+ when: "at review close, once per finding, after the fixes — `run` is required when author is orc",
3634
+ on_absent: "exit 2 → say it once and go on; it never blocks a run",
3635
+ canonical: "_shared/gotchas.md",
3636
+ never: "never edit `.claude/orc/observations.jsonl` by hand, and never record a clean review here — the trace `FINDING-OUTCOME` line already counts it",
3637
+ lanes: ["orc", "orc-mini", "orc-quick"],
3638
+ },
3639
+ // v2.1.0 — the project review rule. A lane never names it: the answer reaches
3640
+ // the lane as the `review-policy` probe inside `orc lane config`, so this row
3641
+ // is a USER command (`lanes: []`, the `orc graph audit` shape).
3642
+ "review-policy": {
3643
+ cmd: "orc review policy [--json]",
3644
+ what: "does the project's CLAUDE.md / AGENTS.md name its own review — the file, the line, the quote and the name",
3645
+ exits: { 0: "answered in every state — read `policy` (orc | project)" },
3646
+ states: ["orc", "project"],
3647
+ cost: "free",
3648
+ when: "at preflight, through the `review-policy` probe of `orc lane config`",
3649
+ on_absent: "`policy: orc` and `line: null` — no rule, nothing to ask",
3650
+ canonical: "_shared/review-slice.md",
3651
+ never: "never let the rule pick the reviewer — it only adds an OPTION to the §0 question the user answers",
3652
+ lanes: [],
3653
+ },
3388
3654
  // v1.8.0 — the code graph. Every row carries `--if-enabled`: the CLI resolves
3389
3655
  // `code_graph`, so no lane reads the key (and /orc-quick can take part).
3390
3656
  "graph-status": {
@@ -3849,7 +4115,7 @@ const LANE_CALLS = {
3849
4115
  // declared far below this catalogue, and reading it here is a temporal dead
3850
4116
  // zone that crashes the CLI on every command. `test/cli/rules.test.js`
3851
4117
  // asserts the two agree.
3852
- lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki"],
4118
+ lanes: ["context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini", "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude", "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-fix", "orc-grill", "orc-handoff", "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup", "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki"],
3853
4119
  },
3854
4120
  };
3855
4121
 
@@ -3938,7 +4204,7 @@ function laneCallsCmd(lane, claudeDir) {
3938
4204
  //
3939
4205
  // Per phase: \`file\` + \`layers\` (never a line number — /orc-doc rule 2, a
3940
4206
  // 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\`
4207
+ // \`read\` (the partial-read declarations registered in \`_shared/phases/README.md\`
3942
4208
  // at W10), \`optional_when\` (a config KEY, resolved through the priority ladder
3943
4209
  // by \`orc lane config\`, never read raw), and \`calls\` — catalogue ids from
3944
4210
  // \`LANE_CALLS\`, DERIVED rather than restated, so a lane can ask for one
@@ -3966,9 +4232,25 @@ const PHASE_FILES = {
3966
4232
  why_single_layer: null,
3967
4233
  why_two_layers:
3968
4234
  "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",
4235
+ // v2.0.0 T1 — the verbs are NOT here: every lane owes TRACE_ALWAYS_VERBS,
4236
+ // and the rest arrive with the phase that emits them.
4237
+ trace_verbs: [],
4238
+ },
4239
+ // v2.0.0 T1 — the full human verb table, split out of trace.md so a run
4240
+ // stops reading ~30 KB of grammar it never emits. `when: on-demand`: a lane
4241
+ // opens it only for a verb its `trace_grammar` does not carry. `via` names
4242
+ // the phase file that points at it — the lint accepts that pointer instead
4243
+ // of one per lane spine.
4244
+ "trace-verbs": {
4245
+ file: "_shared/phases/trace-verbs.md",
4246
+ layers: ["core"],
4247
+ via: "trace",
4248
+ 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`",
4249
+ trace_verbs: [],
3969
4250
  },
3970
4251
  preflight: {
3971
4252
  file: "_shared/phases/preflight.md",
4253
+ trace_verbs: ["GATE", "GRAPH-CONSULT"],
3972
4254
  layers: ["core", "full"],
3973
4255
  // W13 moved this off a hardcoded `lane !== "orc"` branch and onto the same
3974
4256
  // lane->layer map the build phases use — one mechanism, not two.
@@ -3983,6 +4265,7 @@ const PHASE_FILES = {
3983
4265
  },
3984
4266
  "plan-handoff": {
3985
4267
  file: "_shared/phases/plan-handoff.md",
4268
+ trace_verbs: ["PHASE", "GATE"],
3986
4269
  layers: ["core"],
3987
4270
  why_single_layer:
3988
4271
  "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 +4277,14 @@ const PHASE_FILES = {
3994
4277
  },
3995
4278
  "analyst-gates": {
3996
4279
  file: "_shared/phases/analyst-gates.md",
4280
+ trace_verbs: ["GATE"],
3997
4281
  layers: ["core"],
3998
4282
  why_single_layer:
3999
4283
  "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
4284
  },
4001
4285
  "wiki-consult": {
4002
4286
  file: "_shared/phases/wiki-consult.md",
4287
+ trace_verbs: ["WIKI-CONSULT", "CROSSLINK"],
4003
4288
  layers: ["core"],
4004
4289
  why_single_layer: "the precedence ladder (code > fresh wiki > stale wiki > model priors) does not bend for a faster lane",
4005
4290
  },
@@ -4024,6 +4309,7 @@ const PHASE_FILES = {
4024
4309
  },
4025
4310
  "stop-resume": {
4026
4311
  file: "_shared/phases/stop-resume.md",
4312
+ trace_verbs: ["GATE"],
4027
4313
  layers: ["core"],
4028
4314
  why_single_layer:
4029
4315
  "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 +4317,7 @@ const PHASE_FILES = {
4031
4317
 
4032
4318
  "planning": {
4033
4319
  file: "_shared/phases/planning.md",
4320
+ trace_verbs: ["PHASE", "CONFIG", "WIKI-CONSULT", "CROSSLINK", "GRAPH-MAP", "GRAPH-COCHANGE", "GATE", "PACT"],
4034
4321
  layers: ["full", "composed"],
4035
4322
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4036
4323
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4043,6 +4330,7 @@ const PHASE_FILES = {
4043
4330
  },
4044
4331
  "scoring": {
4045
4332
  file: "_shared/phases/scoring.md",
4333
+ trace_verbs: ["PHASE", "SCORE"],
4046
4334
  layers: ["full", "composed"],
4047
4335
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4048
4336
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4055,6 +4343,7 @@ const PHASE_FILES = {
4055
4343
  },
4056
4344
  "execution": {
4057
4345
  file: "_shared/phases/execution.md",
4346
+ trace_verbs: ["PHASE", "GATE", "OUTCOME", "TDD-RED", "TDD-GREEN", "REPRO", "REPLAN", "QUESTION", "CONTEXT-GAP", "BOUNDARY", "EXTRA", "GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-HINT"],
4058
4347
  layers: ["full", "composed"],
4059
4348
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4060
4349
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4067,6 +4356,7 @@ const PHASE_FILES = {
4067
4356
  },
4068
4357
  "review": {
4069
4358
  file: "_shared/phases/review.md",
4359
+ trace_verbs: ["PHASE", "FINDING", "GRAPH-CHANGES", "REVIEW-WHICH"],
4070
4360
  layers: ["full", "composed"],
4071
4361
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4072
4362
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4091,6 +4381,7 @@ const PHASE_FILES = {
4091
4381
  },
4092
4382
  "verify": {
4093
4383
  file: "_shared/phases/verify.md",
4384
+ trace_verbs: ["PHASE", "VERDICT", "PACT"],
4094
4385
  layers: ["full", "composed"],
4095
4386
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4096
4387
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4115,6 +4406,7 @@ const PHASE_FILES = {
4115
4406
  },
4116
4407
  "mock-example": {
4117
4408
  file: "_shared/phases/mock-example.md",
4409
+ trace_verbs: ["PHASE", "DRIFT"],
4118
4410
  layers: ["full", "composed"],
4119
4411
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4120
4412
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4127,6 +4419,7 @@ const PHASE_FILES = {
4127
4419
  },
4128
4420
  "ship": {
4129
4421
  file: "_shared/phases/ship.md",
4422
+ trace_verbs: ["PHASE", "GATE", "GRAPH-UPDATE", "GRAPH-GAIN"],
4130
4423
  layers: ["full", "composed"],
4131
4424
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4132
4425
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4139,6 +4432,7 @@ const PHASE_FILES = {
4139
4432
  },
4140
4433
  "summary": {
4141
4434
  file: "_shared/phases/summary.md",
4435
+ trace_verbs: ["PHASE", "FINDING-OUTCOME"],
4142
4436
  layers: ["full", "composed"],
4143
4437
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4144
4438
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4169,12 +4463,220 @@ const PREFLIGHT_CALL_IDS = new Set([
4169
4463
  "run-list",
4170
4464
  ]);
4171
4465
 
4466
+ // ── v2.0.0 T20 — `probes{}`: one preflight call instead of 4–6 ──────────────
4467
+ //
4468
+ // W0 counted 2–6 CLI calls at preflight before the first dispatch, each one a
4469
+ // round trip that re-sends the whole cached session. `orc lane config` is
4470
+ // already the one call every lane makes first, so it now carries the ANSWERS of
4471
+ // the probes that lane makes at start.
4472
+ //
4473
+ // Each probe runs the stand-alone command's OWN code, in this process, with its
4474
+ // own argv: the same function, the same exit code, the same fields — so the
4475
+ // entry cannot drift from the command. The entry is BRIEF (T17): `exit`, `line`
4476
+ // and the fields a lane branches on. `--probes-full` adds the whole stand-alone
4477
+ // answer as `full`; `--no-probes` leaves the block out.
4478
+ //
4479
+ // READ-ONLY, always. `graph status` runs WITHOUT `--heal` (a heal builds or
4480
+ // writes the index) — `heal_needed: true` says the lane still makes that one
4481
+ // call. `extra resolve` runs without the `orc extra` credential sweep (a
4482
+ // delete). A probe that throws or gives no answer is `{exit, error}` — never a
4483
+ // thrown error, and never a change to `lane config`'s own exit code.
4484
+ //
4485
+ // The set per lane is DERIVED: PREFLIGHT_CALL_IDS ∩ the lane's LANE_CALLS rows,
4486
+ // plus `graph-status` and `rules-slice` where the lane makes them, plus
4487
+ // /orc-quick's slot probe. `challenge-status` needs a slug, so it is not here.
4488
+ const PROBE_DEFS = {
4489
+ "wiki-status": {
4490
+ argv: () => ["wiki", "status"],
4491
+ run: () => wiki(),
4492
+ pick: ["state", "tier", "docs", "distance", "last_scan"],
4493
+ line: (j) =>
4494
+ j.state === "none"
4495
+ ? "wiki: none — no wiki yet"
4496
+ : j.state === "registered"
4497
+ ? `wiki: ${j.tier || "tier unknown"}${j.distance === null ? "" : ` (${j.distance}c)`} — ${plural(j.docs, "doc")} · last scan ${j.last_scan || "?"}`
4498
+ : `wiki: ${String(j.state).toUpperCase()} — ${plural(j.docs, "doc")} (run: orc wiki sync)`,
4499
+ },
4500
+ "pattern-status": {
4501
+ argv: () => ["pattern", "status"],
4502
+ run: () => pattern(),
4503
+ pick: ["cached"],
4504
+ derive: (j) => ({ langs: (j.patterns || []).map((p) => p.lang) }),
4505
+ line: (j) => (j.cached ? `pattern: cached — ${(j.patterns || []).map((p) => p.lang).join(", ")}` : "pattern: none cached"),
4506
+ },
4507
+ "gotcha-status": {
4508
+ argv: () => ["gotcha", "status"],
4509
+ run: () => gotcha(),
4510
+ pick: ["count", "gotchas_max"],
4511
+ line: (j) => (j.count ? `gotchas: ${j.count} live (cap ${j.gotchas_max})` : "gotchas: none recorded — nothing to inject"),
4512
+ },
4513
+ "pact-status": {
4514
+ argv: () => ["pact", "status"],
4515
+ run: () => pact(),
4516
+ pick: ["ok", "reason", "entries", "counts"],
4517
+ line: (j) => j.line || "pact: no ledger yet — a first run",
4518
+ },
4519
+ "boundary-status": {
4520
+ argv: () => ["boundary", "status"],
4521
+ run: () => boundary(),
4522
+ pick: ["ok", "reason", "counts", "stale"],
4523
+ line: (j) => j.line || "boundary: no cards yet — every area is UNKNOWN, never assumed safe",
4524
+ },
4525
+ "aftermath-status": {
4526
+ argv: () => ["aftermath", "status"],
4527
+ run: () => aftermath(),
4528
+ pick: ["ok", "reason", "window_days", "counts"],
4529
+ line: (j) =>
4530
+ j.ok
4531
+ ? `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"}`
4532
+ : j.reason === "shallow"
4533
+ ? `aftermath: no runs in the last ${j.window_days} days — too shallow to grade`
4534
+ : `aftermath: ${j.reason}`,
4535
+ },
4536
+ "diy-status": {
4537
+ argv: () => ["diy", "status"],
4538
+ run: () => diy(),
4539
+ pick: ["state", "reason"],
4540
+ line: (j) => `diy: ${j.state}${j.reason ? " — " + j.reason : ""}`,
4541
+ },
4542
+ "run-list": {
4543
+ argv: () => ["run", "list"],
4544
+ run: () => runCmd(),
4545
+ pick: ["total"],
4546
+ line: (j) => `runs: ${j.total}`,
4547
+ },
4548
+ "graph-status": {
4549
+ argv: () => ["graph", "status", "--if-enabled", "--brief"],
4550
+ run: () => graphCmd(),
4551
+ pick: ["state", "enabled", "exists", "files", "symbols", "generation", "auto_update", "notes", "trace"],
4552
+ derive: (j) => ({ heal_needed: !!(j.enabled && j.auto_update && (j.state === "none" || j.state === "drifted")) }),
4553
+ line: (j) => j.line,
4554
+ },
4555
+ "rules-slice": {
4556
+ argv: (lane) => ["rules", "slice", "--lane", lane],
4557
+ run: () => rulesCmd(),
4558
+ pick: ["packs", "overrides"],
4559
+ line: (j) => j.line,
4560
+ },
4561
+ // v2.1.0 — the project's own review rule. The line reaches the lane only when a
4562
+ // rule exists (preflight prints one line per probe), so it costs no
4563
+ // always-loaded byte and no spine text.
4564
+ "review-policy": {
4565
+ argv: () => ["review", "policy"],
4566
+ run: () => reviewCmd(),
4567
+ pick: ["policy", "found"],
4568
+ // Every probe carries ONE line (preflight prints one per probe). With no rule
4569
+ // the line says so and the lane changes nothing.
4570
+ line: (j) => j.line || "review: ORC review — no project review rule found",
4571
+ },
4572
+ "extra-slot": {
4573
+ argv: () => ["extra", "resolve", "--slot", "quick-executor"],
4574
+ run: () => extraResolveCmd(resolveClaudeDir(), undefined),
4575
+ pick: ["resolved", "via", "slot", "needs_reping", "held_back"],
4576
+ line: (j) => (j.resolved === "extra" ? `extra: ${j.announce}` : `extra: ${j.slot} → claude ${(j.claude && j.claude.agent) || ""}`.trim()),
4577
+ },
4578
+ };
4579
+
4580
+ function laneProbeIds(lane) {
4581
+ const ids = Object.entries(LANE_CALLS)
4582
+ .filter(([id, c]) => id !== "lane-config" && PREFLIGHT_CALL_IDS.has(id) && c.lanes.includes(lane) && PROBE_DEFS[id])
4583
+ .map(([id]) => id);
4584
+ if (LANE_CALLS["graph-status"].lanes.includes(lane)) ids.push("graph-status");
4585
+ if (LANE_CALLS["rules-slice"].lanes.includes(lane)) ids.push("rules-slice");
4586
+ if (lane === "orc-quick") ids.push("extra-slot");
4587
+ if (REVIEW_POLICY_LANES.includes(lane)) ids.push("review-policy");
4588
+ return ids;
4589
+ }
4590
+ // v2.1.0 (DE-8 a) — every coding lane that can change code asks "which review"
4591
+ // before ship when the project names its own review, so every one carries it.
4592
+ const REVIEW_POLICY_LANES = ["orc", "orc-mini", "orc-fast", "orc-quick", "orc-diy"];
4593
+
4594
+ // Run ONE stand-alone command in this process and take its answer. The argv is
4595
+ // swapped in for the call and put back after it; stdout, stderr and exit are
4596
+ // caught, so nothing reaches the terminal and nothing ends this process.
4597
+ function runProbe(argv, fn) {
4598
+ const saved = args.slice();
4599
+ const pass = [];
4600
+ const di = saved.indexOf("--dir");
4601
+ if (di !== -1 && saved[di + 1] !== undefined) pass.push("--dir", saved[di + 1]);
4602
+ if (saved.includes("--global")) pass.push("--global");
4603
+ args.splice(0, args.length, ...argv, "--json", ...pass);
4604
+ const cap = { obj: null, text: [], err: [] };
4605
+ const SENT = { probe: true };
4606
+ let code = null;
4607
+ let error = null;
4608
+ const keep = { exit: process.exit, log: console.log, error: console.error, warn: console.warn };
4609
+ process.exit = (c) => {
4610
+ code = c === undefined ? 0 : c;
4611
+ throw SENT;
4612
+ };
4613
+ console.log = (...a) => cap.text.push(a.map(String).join(" "));
4614
+ console.error = console.warn = (...a) => cap.err.push(a.map(String).join(" "));
4615
+ PROBE_CAPTURE = cap;
4616
+ try {
4617
+ fn();
4618
+ if (code === null) code = 0;
4619
+ } catch (e) {
4620
+ if (e !== SENT) {
4621
+ error = String((e && e.message) || e).split("\n")[0];
4622
+ if (code === null) code = 1;
4623
+ }
4624
+ } finally {
4625
+ PROBE_CAPTURE = null;
4626
+ process.exit = keep.exit;
4627
+ console.log = keep.log;
4628
+ console.error = keep.error;
4629
+ console.warn = keep.warn;
4630
+ args.splice(0, args.length, ...saved);
4631
+ }
4632
+ let obj = cap.obj;
4633
+ if (!obj && cap.text.length) {
4634
+ try {
4635
+ obj = JSON.parse(cap.text.join("\n"));
4636
+ } catch (_) {}
4637
+ }
4638
+ if (!obj && !error) error = String(cap.err.find((l) => l.trim()) || "no answer").trim().split("\n")[0];
4639
+ return { exit: code, obj, error };
4640
+ }
4641
+
4642
+ function laneProbes(lane, full) {
4643
+ const out = {};
4644
+ for (const id of laneProbeIds(lane)) {
4645
+ const def = PROBE_DEFS[id];
4646
+ const r = runProbe(def.argv(lane), def.run);
4647
+ if (r.error || !r.obj) {
4648
+ out[id] = { exit: r.exit, error: r.error };
4649
+ continue;
4650
+ }
4651
+ const j = r.obj;
4652
+ let line;
4653
+ try {
4654
+ line = def.line(j);
4655
+ } catch (e) {
4656
+ line = null;
4657
+ }
4658
+ const e = { exit: r.exit, line };
4659
+ for (const k of def.pick) if (Object.prototype.hasOwnProperty.call(j, k)) e[k] = j[k];
4660
+ if (def.derive) Object.assign(e, def.derive(j));
4661
+ if (full) e.full = j;
4662
+ out[id] = e;
4663
+ }
4664
+ return out;
4665
+ }
4666
+
4172
4667
  // tier + packet cadence per trace-owning lane. This is the same data the tier
4173
4668
  // table in \`_shared/phases/trace.md\` renders for a human; the lint asserts the
4174
4669
  // two agree, so a lane cannot be in one and absent from the other.
4175
4670
  const LANE_TRACE = {
4176
- orc: { tier: "Build lanes", token: "orc" },
4177
- "orc-mini": { tier: "Build lanes", token: "mini" },
4671
+ // `spine_verbs` (v2.0.0 T1): the verbs a lane's own SPINE emits outside
4672
+ // any declared phase row — orc's ultra gates, and orc-mini, whose pipeline is
4673
+ // still in-spine. They join `trace_grammar` like a phase's `trace_verbs`.
4674
+ orc: { tier: "Build lanes", token: "orc", spine_verbs: ["ADVISE", "JUDGE"] },
4675
+ "orc-mini": {
4676
+ tier: "Build lanes",
4677
+ token: "mini",
4678
+ spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "GRAPH-CONSULT", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-NOTES"],
4679
+ },
4178
4680
  "orc-fast": { tier: "Build lanes", token: "fast" },
4179
4681
  "orc-wiki": { tier: "Multi-dispatch", token: "wiki" },
4180
4682
  "orc-pr-driver": { tier: "Multi-dispatch", token: "prdriver" },
@@ -4202,12 +4704,98 @@ const LANE_TRACE = {
4202
4704
  "orc-export": { tier: "Single-dispatch", token: "export" },
4203
4705
  };
4204
4706
 
4707
+ // v2.0.0 T1 — the CLOSED trace verb set, as DATA (the `LANE_CALLS` shape).
4708
+ // `_shared/phases/trace-verbs.md` renders this table for a human, and
4709
+ // `bin/verify-contracts.js` holds the two together BOTH ways, plus every
4710
+ // `trace_verbs` / `spine_verbs` entry in the phase manifest: a verb a phase
4711
+ // lists that this registry lacks FAILS. `orc lane phases <lane> --json` hands a
4712
+ // lane `trace_grammar` = the grammar lines of exactly the verbs its phases emit
4713
+ // plus TRACE_ALWAYS_VERBS, so a run never reads the whole table.
4714
+ //
4715
+ // grammar — the line shape; ` · ` separates the sub-forms of one verb.
4716
+ // emitter — who produces it: `orc → writer` is a packet event, `hook` needs
4717
+ // no cooperation, `X→orc → writer` is a subagent-returned marker.
4718
+ // meaning — ONE line. The detail lives at `owner`.
4719
+ const TRACE_VERBS = {
4720
+ PHASE: { grammar: "PHASE <name> start|end", emitter: "orc → writer", meaning: "phase transition", owner: "_shared/phases/trace.md" },
4721
+ "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" },
4722
+ CONFIG: { grammar: "CONFIG <key=value …>", emitter: "orc → writer", meaning: "Phase 1 — the resolved config values this run will consume, ALWAYS with opus5_only", owner: "_shared/phases/trace-verbs.md" },
4723
+ "WIKI-CONSULT": { grammar: "WIKI-CONSULT <fresh|aging|stale|absent|empty> :: docs=<list|none>", emitter: "orc → writer", meaning: "project wiki consulted for grounding, with its staleness tier", owner: "_shared/phases/wiki-consult.md" },
4724
+ CROSSLINK: { grammar: "CROSSLINK <cached|configured-no-cache|none> :: boundaries=<n> peers=<names> · CROSSLINK inject task=<id> :: <boundary>", emitter: "orc → writer", meaning: "cross-repo peer-knowledge state at the consult point; `inject` when a slice receives a linked contract", owner: "_shared/phases/wiki-consult.md" },
4725
+ "GRAPH-CONSULT": { grammar: "GRAPH-CONSULT <fresh|updated|built|drifted|none|off> :: files=<n> symbols=<n>[ density=<n>[ thin=1]] · GRAPH-CONSULT card task=<id> :: targets=<…>", emitter: "orc → writer", meaning: "the code graph's preflight state, copied VERBATIM from the CLI `trace` field", owner: "_shared/code-graph.md" },
4726
+ "GRAPH-UPDATE": { grammar: "GRAPH-UPDATE <state> :: parsed=<n> reused=<n> deleted=<n> gen=<n> route=<n> ms=<n>", emitter: "orc → writer", meaning: "one graph update, the `trace` field of `orc graph update --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4727
+ "GRAPH-MAP": { grammar: "GRAPH-MAP <repo|focused> :: files=<shown>/<total> [focus=<a,b>] gen=<n>", emitter: "orc → writer", meaning: "ONE line at the start of planning, the `trace` field of `orc graph map --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4728
+ "GRAPH-CHANGES": { grammar: "GRAPH-CHANGES <found|none> :: symbols=<n> high=<n> medium=<n> low=<n> gen=<n>", emitter: "orc → writer", meaning: "one line at review, the `trace` field of `orc graph changes --json`, verbatim", owner: "_shared/phases/trace-verbs.md" },
4729
+ "GRAPH-COMPLEXITY": { grammar: "GRAPH-COMPLEXITY <mini-ok|recommend-orc> :: files=<n> callers=<n> caller_files=<n> maybe=<n> tests=<n> risk=<n> cochange=<n> gen=<n>", emitter: "orc → writer", meaning: "one line per /orc-mini run, the `trace` field of the `--complexity` impact call, verbatim", owner: "_shared/phases/trace-verbs.md" },
4730
+ "GRAPH-COCHANGE": { grammar: "GRAPH-COCHANGE <found|none> :: rows=<n> commits=<n>", emitter: "orc → writer", meaning: "one line per planning batch, for the file that produced the widest answer", owner: "_shared/phases/trace-verbs.md" },
4731
+ "GRAPH-HINT": { grammar: "GRAPH-HINT injected=<n> subagent_start=<n> read_notes=<n> updates=<n>", emitter: "orc → writer", meaning: "ONE line per phase close, the graph hook's counters; omitted when the counter file does not exist", owner: "_shared/phases/trace-verbs.md" },
4732
+ "GRAPH-GAIN": { grammar: "GRAPH-GAIN paid=<n> low=<n> high=<n> calls=<n>", emitter: "orc → writer", meaning: "ONE line per run at ship; `paid` is exact, `low`/`high` are an estimate never collapsed into one number", owner: "_shared/phases/trace-verbs.md" },
4733
+ "GRAPH-NOTES": { grammar: "GRAPH-NOTES <applied|below-min|none|deferred|off|skipped> :: <detail>", emitter: "orc → writer", meaning: "one notes batch; `applied` copies the noter's one-line return verbatim", owner: "_shared/phases/trace-verbs.md" },
4734
+ SPAWN: { grammar: "SPAWN <agent>", emitter: "hook", meaning: "an agent dispatch was observed (skeleton)", owner: "hooks/orc-trace.js" },
4735
+ 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" },
4736
+ 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" },
4737
+ 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" },
4738
+ 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" },
4739
+ 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" },
4740
+ QUESTION: { grammar: "QUESTION count=<n> :: <topic>", emitter: "subagent→orc → writer", meaning: "stopped to ask the user", owner: "_shared/phases/trace-verbs.md" },
4741
+ "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" },
4742
+ REPLAN: { grammar: "REPLAN wave=<n> :: <reason>", emitter: "orc → writer", meaning: "re-planned after a conflict/failure", owner: "_shared/phases/trace-verbs.md" },
4743
+ 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" },
4744
+ 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" },
4745
+ 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" },
4746
+ OUTCOME: { grammar: "OUTCOME task=<id> score=<n> band=<range> model=<m> retries=<n> requeues=<n> needs_context=<n> unmet=<n>", emitter: "orc → writer", meaning: "task closed — links the scoring band to what it actually took", owner: "_shared/phases/execution.md" },
4747
+ FINDING: { grammar: "FINDING p0=<n> p1=<n> p2=<n> p3=<n>[ pre=<n> suppressed=<n> folded=<n>]", emitter: "reviewer→orc → writer", meaning: "review outcome (P0–P3 severity ladder); the tail counts the after-filter's buckets", owner: "_shared/phases/review.md" },
4748
+ "REVIEW-WHICH": { grammar: "REVIEW-WHICH chose=<orc|project|skip> name=<the review the rule names> by=<user|ledger|learned> :: <file:line of the rule>", emitter: "orc → writer", meaning: "the answer to §0 \"which review\" when the project names its own review — written ALWAYS, also under habits off; the FINISH nudge reads it", owner: "_shared/phases/trace-verbs.md" },
4749
+ FIX: { grammar: "FIX source=<sonar|ci|defect|pr|review|other> introduced_by=<orc|ai|human|unknown> by=<user|evidence> obs=<id8>[ missed_by=<run>] :: <path:lines> <rule or finding>", emitter: "cli", meaning: "one fix recorded by `orc fix record` (/orc-fix) into the HOST run's trace — the CLI writes it, never a packet", owner: "orc-fix/SKILL.md" },
4750
+ "FINDING-OUTCOME": { grammar: "FINDING-OUTCOME addressed=<n> disputed=<n> wontfix=<n> open=<n> pre=<n> suppressed=<n> :: <cat>:<addressed>/<total>,…", emitter: "orc → writer", meaning: "ONE line at review close (a clean review too) — what became of each finding; `orc gotcha quality` counts it as a review", owner: "_shared/phases/trace-verbs.md" },
4751
+ VERDICT: { grammar: "VERDICT pass|fail :: <detail>", emitter: "verifier→orc → writer", meaning: "verification outcome", owner: "_shared/phases/verify.md" },
4752
+ DRIFT: { grammar: "DRIFT loop=<n> :: <user description, compressed>", emitter: "orc → writer", meaning: "mock-example drift-recovery loop opened (hard cap 2 loops)", owner: "_shared/drift-recovery.md" },
4753
+ "TDD-RED": { grammar: "TDD-RED task=<id> iter=<n> :: <failing tests>", emitter: "executor→orc → writer", meaning: "TDD repair-loop iteration — the plan's acceptance tests still red", owner: "_shared/phases/trace-verbs.md" },
4754
+ "TDD-GREEN": { grammar: "TDD-GREEN task=<id> iter=<n>", emitter: "executor→orc → writer", meaning: "the task's TDD acceptance tests pass", owner: "_shared/phases/trace-verbs.md" },
4755
+ REPRO: { grammar: "REPRO red|green :: <cmd> exit=<n> · REPRO none :: <reason>", emitter: "executor→orc → writer", meaning: "a DEFECT task's reproduction: RED before the fix, GREEN after", owner: "_shared/return-validation.md" },
4756
+ NOTE: { grammar: "NOTE :: <decisions>", emitter: "writer", meaning: "the packet's `decisions` field — the WHY layer; one line, only when non-empty", owner: "agents/orc-trace-writer-haiku-4-5.md" },
4757
+ STATS: { grammar: "STATS lane=<l> slug=<s> dispatches=<n> waves=<n> tasks=<n> bands=<h:n,m:n,l:n> downgrades=<n> duration_ms=<n>", emitter: "orc → writer", meaning: "ONE summary line per run, in the FINISH packet, immediately BEFORE the FINISH line", owner: "_shared/phases/trace-verbs.md" },
4758
+ PACT: { grammar: "PACT <state> :: <ids> · PACT inject task=<id> :: <PACT-id> · PACT recheck pass|fail :: <ids>", emitter: "orc → writer", meaning: "invariant-ledger state at the Phase-1 probe, a promise injected into a task, the Phase-6 recheck", owner: "orc-pact/references/gate.md" },
4759
+ BOUNDARY: { grammar: "BOUNDARY <EXECUTE|ESCALATE|REFUSE|unknown> task=<id> :: <area> · BOUNDARY lift task=<id> :: <area>", emitter: "orc → writer", meaning: "per-task boundary verdict; an uncarded area is `unknown`, never REFUSE", owner: "orc-boundary/references/gate.md" },
4760
+ 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" },
4761
+ EXTRA: { grammar: "EXTRA <profile>/<model> engine=<api|claude-shim|cli> task=<id> band=[lo,hi) tok=in/cw/cr/out outcome=<done|partial|failed|fallback> dur=<m>m<s>s · EXTRA fallback task=<id> :: <reason> → <agent> · EXTRA substitution task=<id> :: requested=<m> reported=<m> · EXTRA reroute task=<id> :: <providers> · EXTRA resume task=<id> attempt=<n> :: from=<reason> attribution=<verdict> target=<extra:profile|agent> files_preexisting=<n> · EXTRA orphan task=<id> :: attempt=<n> lease-expired files_changed=<n> state=<state> · EXTRA demote run=<slug> :: profile=<p> reason=<consecutive-stall|stale-live-attempt|manual> n=<k> → <ladder>", emitter: "orc → writer", meaning: "one line per FOREIGN dispatch — `orc extra dispatch`'s `trace_line`, verbatim", owner: "_shared/extra-dispatch.md" },
4762
+ FINISH: { grammar: "FINISH :: <detail>", emitter: "orc → writer", meaning: "run ended — mandatory, even on an abort", owner: "_shared/phases/trace.md" },
4763
+ };
4764
+
4765
+ // The verbs EVERY trace-owning lane owes, whatever its phases list. Every lane
4766
+ // sends at least the ONE end-of-run packet, and trace.md makes each of these
4767
+ // part of it: DISPATCH + VERIFY (the main return is dispatched and checked),
4768
+ // NOTE (the packet's `decisions`), STATS (every trace-owning lane emits it, not
4769
+ // just `orc`) and FINISH (mandatory, even on an abort). PHASE and CONFIG are
4770
+ // NOT here: a single-dispatch lane closes no named phase, and CONFIG is the
4771
+ // build lanes' Phase-1 line — both come in through the phase rows that emit them.
4772
+ const TRACE_ALWAYS_VERBS = ["DISPATCH", "VERIFY", "NOTE", "STATS", "FINISH"];
4773
+
4774
+ // `trace_grammar` for one lane: TRACE_VERBS order, hook verbs left out (a lane
4775
+ // never emits them), `null` for a lane that owns no trace.
4776
+ function laneTraceGrammar(lane) {
4777
+ const t = LANE_TRACE[lane];
4778
+ if (!t) return null;
4779
+ const want = new Set(TRACE_ALWAYS_VERBS);
4780
+ for (const id of LANE_PHASE_ORDER[lane] || PHASE_ORDER)
4781
+ if ((LANE_PHASES[id] || []).includes(lane)) for (const v of PHASE_FILES[id].trace_verbs || []) want.add(v);
4782
+ for (const p of LANE_OWN_PHASES[lane] || []) for (const v of p.trace_verbs || []) want.add(v);
4783
+ for (const v of t.spine_verbs || []) want.add(v);
4784
+ const out = {};
4785
+ for (const [v, d] of Object.entries(TRACE_VERBS)) if (want.has(v) && d.emitter !== "hook") out[v] = d.grammar;
4786
+ return out;
4787
+ }
4788
+
4205
4789
  // Which lanes run which SHARED phase. A lane absent from a row does not run
4206
4790
  // that phase — \`/orc-retro\` is in no trace row because it mines traces and
4207
4791
  // writes none (its hard rule 4), and \`context-combiner\` is in none because it
4208
4792
  // is a PHASE of the analyze run, not a lane (v0.42.0).
4209
4793
  const LANE_PHASES = {
4210
4794
  trace: Object.keys(LANE_TRACE),
4795
+ // v2.0.0 T1 — the verb table, ON DEMAND. Every lane that traces may need a
4796
+ // verb its `trace_grammar` does not carry, and it reaches the table through
4797
+ // trace.md (`via` in PHASE_FILES), never through its own spine.
4798
+ "trace-verbs": Object.keys(LANE_TRACE),
4211
4799
  preflight: [
4212
4800
  "orc",
4213
4801
  "orc-boundary",
@@ -4237,7 +4825,7 @@ const LANE_PHASES = {
4237
4825
  rules: [
4238
4826
  "context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini",
4239
4827
  "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude",
4240
- "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-grill", "orc-handoff",
4828
+ "orc-diy", "orc-explain", "orc-export", "orc-fast", "orc-fix", "orc-grill", "orc-handoff",
4241
4829
  "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup",
4242
4830
  "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki",
4243
4831
  ],
@@ -4262,6 +4850,7 @@ const LANE_PHASES = {
4262
4850
  const PHASE_ORDER = [
4263
4851
  "preflight",
4264
4852
  "trace",
4853
+ "trace-verbs",
4265
4854
  "intake",
4266
4855
  "plan-handoff",
4267
4856
  "wiki-consult",
@@ -4296,6 +4885,7 @@ const PHASE_ORDER = [
4296
4885
  const LANE_PHASE_ORDER = {
4297
4886
  "orc-diy": [
4298
4887
  "trace",
4888
+ "trace-verbs",
4299
4889
  "intake",
4300
4890
  "planning",
4301
4891
  "wave-grouping",
@@ -4320,7 +4910,7 @@ const LANE_PHASE_ORDER = {
4320
4910
  // A row names a FILE and, when the phase still lives in that lane's spine, the
4321
4911
  // HEADING inside it (plan rule: a manifest names a file, a layer and at most a
4322
4912
  // heading — never a line number, /orc-doc rule 2). `read: "section"` is the
4323
- // partial-read declaration registered in `_shared/read-ladder.md`.
4913
+ // partial-read declaration registered in `_shared/phases/README.md`.
4324
4914
  //
4325
4915
  // WHY MOST ROWS NAME A HEADING RATHER THAN A FILE (v1.0.0 W14, measured):
4326
4916
  // moving a phase into its own file pays only when a run does NOT reach it —
@@ -4412,7 +5002,7 @@ const LANE_OWN_PHASES = {
4412
5002
  { ord: 2, id: "phase-f2", file: "orc-fast/SKILL.md", heading: "## Phase F2 — Slice build + dispatch (ONE executor)", read: "section", trace_verbs: ["GATE", "VERIFY", "WIKI-CONSULT"] },
4413
5003
  { ord: 3, id: "phase-f3", file: "orc-fast/SKILL.md", heading: "## Phase F3 — Smoke gate (build + test; blocks ship on red)", read: "section", trace_verbs: [] },
4414
5004
  { ord: 4, id: "phase-f3-5", file: "orc-fast/SKILL.md", heading: "## Phase F3.5 — Mock example (config `mock_example`, default ask)", read: "section", trace_verbs: ["DRIFT", "PHASE"] },
4415
- { ord: 5, id: "phase-f4", file: "orc-fast/SKILL.md", heading: "## Phase F4 — Ship", read: "section", trace_verbs: ["FINISH", "OUTCOME"] },
5005
+ { ord: 5, id: "phase-f4", file: "orc-fast/SKILL.md", heading: "## Phase F4 — Ship", read: "section", trace_verbs: ["FINISH", "OUTCOME", "REVIEW-WHICH"] },
4416
5006
  ],
4417
5007
  "orc-learn": [
4418
5008
  { ord: 0, id: "mode-a", file: "orc-learn/SKILL.md", heading: "## Mode A — INIT", read: "section", trace_verbs: ["DISPATCH", "FINISH", "VERIFY", "WIKI-CONSULT"] },
@@ -4463,7 +5053,7 @@ const LANE_OWN_PHASES = {
4463
5053
  { 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
5054
  { ord: 1, id: "q1", file: "orc-quick/SKILL.md", heading: "## Q1 — LOOK (silent — no questions here)", read: "section", trace_verbs: ["GATE", "WIKI-CONSULT", "GRAPH-CONSULT", "GRAPH-MAP"] },
4465
5055
  { 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"] },
5056
+ { ord: 3, id: "q3", file: "orc-quick/SKILL.md", heading: "## Q3 — DO (dispatch → build/test → write the doc → offer)", read: "section", trace_verbs: ["DISPATCH", "VERIFY", "REPRO", "GRAPH-CHANGES", "GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-GAIN", "OUTCOME", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "FINISH"] },
4467
5057
  ],
4468
5058
  "orc-wiki": [
4469
5059
  { ord: 0, id: "phase-0", file: "orc-wiki/references/phases/phase-0.md", layers: ["full"], trace_verbs: [] },
@@ -4493,20 +5083,22 @@ function lanePhaseRows(lane) {
4493
5083
  file: def.file,
4494
5084
  layers,
4495
5085
  read: "whole",
4496
- // `always` must be justified (read-ladder.md, W10): a lane reads the
5086
+ // `always` must be justified (phases/README.md, W10): a lane reads the
4497
5087
  // config resolver and opens its trace pointer before it can do anything
4498
5088
  // at all. Every other phase is `on-phase` — most runs skip most phases.
4499
5089
  // `compile-time` is orc-diy and only orc-diy: it opens none of these
4500
5090
  // during a run. `orc diy compile` reads the `composed` layer once and
4501
5091
  // stitches it into FLOW-COMPILED.md, which is the only spine that run
4502
5092
  // follows. Calling that `on-phase` would describe a read that never
4503
- // happens (read-ladder.md rule 5).
5093
+ // happens (phases/README.md rule 5).
4504
5094
  when:
4505
5095
  lane === "orc-diy" && (PHASE_FILES[id].lane_layers || {})["orc-diy"]
4506
5096
  ? "compile-time"
4507
5097
  : id === "preflight" || id === "trace"
4508
5098
  ? "always"
4509
- : "on-phase",
5099
+ : PHASE_FILES[id].via
5100
+ ? "on-demand"
5101
+ : "on-phase",
4510
5102
  optional_when: null,
4511
5103
  // v1.7.0 — `rules` names its own call for the same reason `preflight`
4512
5104
  // does: the phase IS that command, and a phase row reading "none
@@ -4654,8 +5246,10 @@ function laneRailsManifest() {
4654
5246
  // is an answer, not a gap.
4655
5247
  if (!t || !t.token) continue;
4656
5248
  const rows = [];
5249
+ // An `on-demand` row is a reference a lane MAY open, never a phase a run
5250
+ // is IN — a rail step for it would be a phase the statusline can never show.
4657
5251
  for (const p of lanePhaseRows(lane))
4658
- rows.push({ id: p.id, label: railLabel(p), kind: railKind(p), verbs: [] });
5252
+ if (p.when !== "on-demand") rows.push({ id: p.id, label: railLabel(p), kind: railKind(p), verbs: [] });
4659
5253
  for (const p of LANE_OWN_PHASES[lane] || [])
4660
5254
  rows.push({
4661
5255
  id: p.id,
@@ -4700,6 +5294,9 @@ function lanePhasesCmd(lane, claudeDir) {
4700
5294
  lane: l,
4701
5295
  trace_tier: t ? t.tier : null,
4702
5296
  trace_token: t ? t.token : null,
5297
+ // v2.0.0 T1 — the grammar line of every verb this lane's phases emit,
5298
+ // plus TRACE_ALWAYS_VERBS. A verb not here → `_shared/phases/trace-verbs.md`.
5299
+ trace_grammar: laneTraceGrammar(l),
4703
5300
  phases,
4704
5301
  shared_phase_count: phases.length,
4705
5302
  // `null` is NOT `[]`: a lane whose own pipeline is still declared in its
@@ -4730,6 +5327,11 @@ function lanePhasesCmd(lane, claudeDir) {
4730
5327
  ? ui.color.gray(`tier ${r.trace_tier} · lane token \`${r.trace_token}\``)
4731
5328
  : ui.color.gray("this lane owns no trace (it is not a run entry point)"))
4732
5329
  );
5330
+ if (r.trace_grammar)
5331
+ console.log(
5332
+ " trace grammar " +
5333
+ ui.color.gray(`${Object.keys(r.trace_grammar).length} verbs: ${Object.keys(r.trace_grammar).join(" · ")} (--json → trace_grammar)`)
5334
+ );
4733
5335
  if (!r.phases.length) {
4734
5336
  // An empty answer is an ANSWER: this lane runs no SHARED phase yet.
4735
5337
  console.log(
@@ -4800,6 +5402,9 @@ function laneConfigCmd(lane, claudeDir) {
4800
5402
  return 2;
4801
5403
  }
4802
5404
  const d = laneConfig(lane, claudeDir);
5405
+ // v2.0.0 T20 — the preflight probes' answers ride on this one call. LAST, so
5406
+ // every 1.9.2 field keeps its position.
5407
+ if (!args.includes("--no-probes")) d.probes = laneProbes(lane, args.includes("--probes-full"));
4803
5408
  if (wantsJson()) {
4804
5409
  emitJson({ ok: true, ...d });
4805
5410
  return 0;
@@ -4816,6 +5421,31 @@ function laneConfigCmd(lane, claudeDir) {
4816
5421
  console.log("");
4817
5422
  }
4818
5423
 
5424
+ // v2.0.0 T20 — the probes this lane makes at start, one line each.
5425
+ if (d.probes && Object.keys(d.probes).length) {
5426
+ console.log(ui.header("Probes (the preflight answers — exit code is data)"));
5427
+ const pw = Math.max(...Object.keys(d.probes).map((k) => k.length));
5428
+ for (const [id, p] of Object.entries(d.probes))
5429
+ console.log(` ${ui.color.cyan(id.padEnd(pw))} exit ${p.exit} ${p.error ? ui.color.yellow("error: " + p.error) : p.line}`);
5430
+ console.log("");
5431
+ }
5432
+
5433
+ // v2.0.0 W2 — only when `habits` is not off; off prints nothing new.
5434
+ if (d.habits) {
5435
+ const h = d.habits;
5436
+ console.log(ui.header("Habits"));
5437
+ console.log(" " + h.line);
5438
+ for (const l of h.learned) console.log(` learned ${l.key}: ${l.value} (${l.id}, ${l.count} of ${l.total}) undo: ${l.undo}`);
5439
+ for (const x of h.suggestions) console.log(` usual ${x.qid}: ${x.option} ${x.mark} (${x.id})`);
5440
+ if (h.proposal) {
5441
+ console.log(` proposal ${h.proposal.id} (${h.proposal.kind}) — show at the END of the run only:`);
5442
+ console.log(" " + h.proposal.line);
5443
+ 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}`));
5444
+ console.log(ui.color.gray(` trace: ${h.proposal.trace}`));
5445
+ }
5446
+ console.log("");
5447
+ }
5448
+
4819
5449
  console.log(ui.header(`Keys this lane reads (${d.keys.length})`));
4820
5450
  if (!d.keys.length) console.log(ui.color.gray(" none — this lane reads no config key."));
4821
5451
  const pad = d.keys.length ? Math.max(...d.keys.map((k) => k.key.length)) : 0;
@@ -4826,7 +5456,9 @@ function laneConfigCmd(lane, claudeDir) {
4826
5456
  ? ui.color.yellow(" shadowed")
4827
5457
  : k.is_overridden
4828
5458
  ? ui.color.green(" overridden")
4829
- : ui.color.gray(" default");
5459
+ : k.state === "learned"
5460
+ ? ui.color.cyan(" learned") + ui.color.gray(` (${k.source})`)
5461
+ : ui.color.gray(" default");
4830
5462
  console.log(` ${ui.color.cyan(k.key.padEnd(pad))} ${String(k.value).padEnd(24)} ${k.prio} ${k.family}${mark}`);
4831
5463
  if (k.inert_reason) console.log(ui.color.gray(` ↳ ${k.inert_reason}`));
4832
5464
  else if (k.shadow_reason) console.log(ui.color.gray(` ↳ ${k.shadow_reason}`));
@@ -4984,10 +5616,11 @@ const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
4984
5616
  // The tier-clip roster. It must contain EVERY agent the default table names —
4985
5617
  // `diyScoreTable()` looks each row's agent up here, so a table row naming an
4986
5618
  // agent this map does not know is a crash, not a fallback. v1.0.0 W4 added the
4987
- // two Opus 5.5 rows for exactly that reason.
5619
+ // two Opus 5.5 rows for exactly that reason, and v2.1.0 the two Sonnet 5 rows
5620
+ // (`sonnet-5-low`, `sonnet-5-med`).
4988
5621
  //
4989
5622
  // It also feeds `fixed_executor`'s option list, so every entry here is offerable
4990
- // — which is what keeps the four agents no band names reachable (D14).
5623
+ // — which is what keeps the seven agents no band names reachable (D14).
4991
5624
  //
4992
5625
  // Effort ranks `low(0) < medium(1) < high(2) < xhigh(3) < max(4)`. There is no
4993
5626
  // session tier at 0: an agent at low effort fits every tier of its own model,
@@ -4996,6 +5629,8 @@ const DIY_EXECUTORS = {
4996
5629
  "orc-executor-haiku-4-5": { model: 1, effort: 1 },
4997
5630
  "orc-executor-sonnet-4-6-med": { model: 2, effort: 1 },
4998
5631
  "orc-executor-sonnet-4-6-high": { model: 2, effort: 2 },
5632
+ "orc-executor-sonnet-5-low": { model: 3, effort: 0 },
5633
+ "orc-executor-sonnet-5-med": { model: 3, effort: 1 },
4999
5634
  "orc-executor-sonnet-5-high": { model: 3, effort: 2 },
5000
5635
  "orc-executor-opus-4-7-med": { model: 4, effort: 1 },
5001
5636
  "orc-executor-opus-4-7-high": { model: 4, effort: 2 },
@@ -5269,7 +5904,7 @@ function diyValidate(cfg) {
5269
5904
  return { errors, warnings };
5270
5905
  }
5271
5906
 
5272
- // The single canonical 6-band score->model table (mirrors skills/orc/config.md
5907
+ // The single canonical 5-band score->model table (mirrors skills/orc/config.md
5273
5908
  // — documented drift). No more narrow/wide preset: rubric_bands is granularity
5274
5909
  // only, and this one table maps every score.
5275
5910
  //
@@ -5283,15 +5918,19 @@ function diyValidate(cfg) {
5283
5918
  // TABLE change, and an agent's model change is always a rename — conflating the
5284
5919
  // two is how a downgrade check breaks.
5285
5920
  //
5286
- // THE EDGE IS 90, AND IT IS ROUND (D13). Every other edge in this table is
5287
- // round and half-open, so `[65,90)` and `[90,101]` keep the shape; a score of
5288
- // exactly 90 resolves to `med`.
5921
+ // THE EDGE IS 90, AND IT IS ROUND (D13). `[41,90)` and `[90,101]` keep the
5922
+ // half-open shape; a score of exactly 90 resolves to `med`.
5923
+ //
5924
+ // v2.1.0 — FIVE BANDS. The score is always a whole number, so the user's closed
5925
+ // ranges 0–20 · 21–30 · 31–40 · 41–89 · 90–100 map exactly to these half-open
5926
+ // rows. The bottom three are Sonnet 5 with effort as the dial; from 41 every
5927
+ // task runs on Opus 5.5. `haiku-4-5`, `sonnet-4-6-med` and `sonnet-4-6-high` are
5928
+ // named by no band now and STAY ON DISK (the same D14 rule as above).
5289
5929
  const DIY_SCORE_TABLE = [
5290
- [0, 30, "orc-executor-haiku-4-5"],
5291
- [30, 40, "orc-executor-sonnet-4-6-med"],
5292
- [40, 55, "orc-executor-sonnet-4-6-high"],
5293
- [55, 65, "orc-executor-sonnet-5-high"],
5294
- [65, 90, "orc-executor-opus-5-low"],
5930
+ [0, 21, "orc-executor-sonnet-5-low"],
5931
+ [21, 31, "orc-executor-sonnet-5-med"],
5932
+ [31, 41, "orc-executor-sonnet-5-high"],
5933
+ [41, 90, "orc-executor-opus-5-low"],
5295
5934
  [90, 101, "orc-executor-opus-5-med"],
5296
5935
  ];
5297
5936
 
@@ -5358,8 +5997,18 @@ function diyExtraRows(cfg, claudeDir) {
5358
5997
  }
5359
5998
  if (!ledger || !ledger.routes.length) return null;
5360
5999
  const rows = [];
6000
+ const table = isTrue(map.opus5_only) ? OPUS5_SCORE_TABLE : DIY_SCORE_TABLE;
5361
6001
  for (const r of ledger.routes) {
5362
6002
  const res = extraResolveFor(claudeDir, r.from, { role: "executor" });
6003
+ // v2.1.0 — a user row can span more than one Claude band (a 40-55 row covers
6004
+ // [31,41) AND [41,90) of the five-band table). Name EVERY agent its scores
6005
+ // would fall back to, never only the agent at its first score.
6006
+ const fallbacks = [res.claude.agent];
6007
+ for (const [lo] of table)
6008
+ if (lo > r.from && lo < r.to) {
6009
+ const a = extraResolveFor(claudeDir, lo, { role: "executor" }).claude.agent;
6010
+ if (a && !fallbacks.includes(a)) fallbacks.push(a);
6011
+ }
5363
6012
  rows.push({
5364
6013
  from: r.from,
5365
6014
  to: r.to,
@@ -5368,7 +6017,7 @@ function diyExtraRows(cfg, claudeDir) {
5368
6017
  model: r.model,
5369
6018
  routes: res.resolved === "extra",
5370
6019
  why_short: res.held_back || "not routed",
5371
- claude_agent: res.claude.agent,
6020
+ claude_agent: fallbacks.join(" / "),
5372
6021
  });
5373
6022
  }
5374
6023
  // A gap is not a hole — it is Claude, split at the Claude table's own edges by
@@ -7625,20 +8274,19 @@ function usageFor(usage, file) {
7625
8274
  }
7626
8275
 
7627
8276
  // ── the scan tier ladder (B5) — the biggest single cut ───────────────────────
7628
- // Every scan-task used to dispatch `orc-wiki-scanner-opus-4-8-high` whether the
8277
+ // Every scan-task used to dispatch `orc-wiki-scanner-opus-5-low` whether the
7629
8278
  // delta was 2 lines or 2000. The ladder sends a small, no-new-surface delta to
7630
8279
  // the LIGHT scanner instead. Five rows, in order; first match wins.
7631
8280
  //
7632
8281
  // NEVER SILENT: the resolved tier is printed in `orc wiki plan` and in the
7633
8282
  // refresh confirmation. A cheaper model is a decision the user sees, never a
7634
8283
  // quiet substitution.
7635
- const WIKI_SCANNER_DEEP = "orc-wiki-scanner-opus-4-8-high";
8284
+ const WIKI_SCANNER_DEEP = "orc-wiki-scanner-opus-5-low";
7636
8285
  const WIKI_SCANNER_LIGHT = "orc-wiki-scanner-sonnet-5-high";
7637
- // `opus5_only` already forces the wiki scanner to the shipped
7638
- // orc-wiki-scanner-opus-5-med, so BOTH tiers collapse to that one agent while
7639
- // the flag is on. That is why the ladder adds NO row to OPUS5_ONLY_ROLES and no
7640
- // new agent pair: see _shared/opus5-only.md.
7641
- const WIKI_SCANNER_OPUS5 = "orc-wiki-scanner-opus-5-med";
8286
+ // `opus5_only` forces the wiki scanner to the deep agent: since v2.1.0 the deep
8287
+ // scanner is already Opus 5.5 low, so BOTH tiers collapse onto it while the flag
8288
+ // is on. The ladder adds no new agent pair: see _shared/opus5-only.md.
8289
+ const WIKI_SCANNER_OPUS5 = "orc-wiki-scanner-opus-5-low";
7642
8290
 
7643
8291
  const WIKI_TIER_LADDER = [
7644
8292
  { id: "first-scan", tier: "deep", why: "first scan of this area (no doc yet)" },
@@ -8404,6 +9052,11 @@ function gotchaStatus(claudeDir, verbose) {
8404
9052
  trigger: e.fields.trigger || null,
8405
9053
  fields: e.fields,
8406
9054
  })),
9055
+ // v2.0.0 W7 — the Behaviour panel's Gotchas tab: source, computed
9056
+ // status and a 7-week hits line per entry, plus the last sync state.
9057
+ // It READS the sync file; it never runs a sync.
9058
+ // `status` (a lane preflight) skips it: only `list` pays for the git call.
9059
+ ...(verbose ? { panel: require("./gotcha.js").panelView(claudeDir, gotchaDeps(), entries) } : {}),
8407
9060
  },
8408
9061
  entries.length ? 0 : 1
8409
9062
  );
@@ -9752,6 +10405,18 @@ ${line || ""}`;
9752
10405
  process.exit(1);
9753
10406
  }
9754
10407
 
10408
+ // The helpers `bin/gotcha.js` borrows: the v1 parser and paths stay HERE, so
10409
+ // `GOTCHA_HEAD` has one home and a 1.9.2 CLI still reads a 2.0 file.
10410
+ function gotchaDeps() {
10411
+ return { args, flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, repoRootOf, parseGotchas, gotchasPath, gotchasArchivePath, GOTCHA_HEAD, listTraces };
10412
+ }
10413
+
10414
+ // An async importer that throws: one line, exit 1, never a stack in a lane.
10415
+ function asyncFail(e) {
10416
+ console.error("❌ " + ((e && e.message) || String(e)));
10417
+ process.exit(1);
10418
+ }
10419
+
9755
10420
  function gotcha() {
9756
10421
  if (flag("--global")) {
9757
10422
  console.error("❌ orc gotcha is project-scoped — the memory is this repo's. Run it from the project (or with --dir <path>).");
@@ -9765,7 +10430,8 @@ function gotcha() {
9765
10430
  gotchaStatus(claudeDir, false);
9766
10431
  break;
9767
10432
  case "list":
9768
- if (args.includes("--archived")) gotchaArchived(claudeDir);
10433
+ if (args.includes("--candidates")) require("./gotcha.js").gotchaCmd(gotchaDeps(), "list");
10434
+ else if (args.includes("--archived")) gotchaArchived(claudeDir);
9769
10435
  else gotchaStatus(claudeDir, true);
9770
10436
  break;
9771
10437
  case "show":
@@ -9777,13 +10443,35 @@ function gotcha() {
9777
10443
  if (args.includes("--dry-run")) gotchaPruneDryRun(claudeDir);
9778
10444
  else gotchaPrune(claudeDir);
9779
10445
  break;
10446
+ // v2.0.0 W4 — the gotchas engine (bin/gotcha.js): the CLI assigns ids,
10447
+ // dedupes, matches, builds the reviewer card and computes promotion.
10448
+ case "add":
10449
+ case "match":
10450
+ case "card":
10451
+ case "filter":
10452
+ case "observe":
10453
+ case "accept":
10454
+ case "quality":
10455
+ case "why":
10456
+ case "export":
10457
+ require("./gotcha.js").gotchaCmd(gotchaDeps(), pos[1]);
10458
+ break;
10459
+ // v2.0.0 W6a — the importers and the review-step sync (bin/gotcha-import.js).
10460
+ // They FEED `observe`; they never write a gotchas.md entry themselves.
10461
+ case "import":
10462
+ require("./gotcha-import.js").importCmd(gotchaDeps()).catch(asyncFail);
10463
+ break;
10464
+ case "sync":
10465
+ require("./gotcha-import.js").syncCmd(gotchaDeps()).catch(asyncFail);
10466
+ break;
9780
10467
  default:
9781
10468
  console.error(
9782
10469
  `Unknown: orc gotcha ${pos[1]}\n` +
9783
10470
  "Usage: orc gotcha status | list [--archived] repair memory (exit 0 = entries, 1 = none)\n" +
9784
10471
  " orc gotcha show <id> ONE entry, every field (exit 0 / 3 unknown id)\n" +
9785
10472
  " orc gotcha prune [--dry-run] archive the low-value tail down to gotchas_max.\n" +
9786
- " --dry-run NAMES every entry and writes nothing"
10473
+ " --dry-run NAMES every entry and writes nothing\n" +
10474
+ require("./gotcha.js").USAGE.replace(/^Usage: /, " ")
9787
10475
  );
9788
10476
  process.exit(1);
9789
10477
  }
@@ -10227,9 +10915,12 @@ function pr(alias) {
10227
10915
  // alias `orc pr-stack-template [<slug>]`.
10228
10916
  if (alias === "pr-stack-template") return stackTemplate(claudeDir, pos[1]);
10229
10917
  if (alias === "pr-stack-status") return stackStatus(claudeDir, pos[1]);
10918
+ // v2.0.0 W6a (Q1) — the unresolved review threads, read-only, via `gh`.
10919
+ if (pos[1] === "threads") return require("./gotcha-import.js").prThreadsCmd(gotchaDeps());
10230
10920
  const usage =
10231
10921
  "Usage: orc pr stack template [<slug>] write a fill-in stack-plan skeleton\n" +
10232
10922
  " orc pr stack status [<slug>] is a plan READY? (exit 0 ready / 1 absent-or-unfilled)\n" +
10923
+ " orc pr threads <n> [--all] the unresolved review threads (read-only; exit 0 · 1 none open · 5 gh missing/not authed)\n" +
10233
10924
  " (aliases: orc pr-stack-template, orc pr-stack-status)";
10234
10925
  if (pos[1] !== "stack") {
10235
10926
  console.error(`Unknown: orc ${pos.slice(1).join(" ") || ""}\n${usage}`);
@@ -10943,8 +11634,10 @@ function wiki() {
10943
11634
 
10944
11635
  // ── Run state: `orc resume`, `orc run list|show`, `orc stats` (v0.42.0) ─────
10945
11636
  // Three read-only commands over state ORC already writes. The design constraint
10946
- // shared by all three is SCALE: `log_dir` and `run_dir` are never auto-deleted
10947
- // by design, so 100+ runs is a normal working directory, and a command that
11637
+ // shared by all three is SCALE: `log_dir` and `run_dir` are kept until you run
11638
+ // `orc clear logs --apply`, or until the automatic sweep removes them
11639
+ // (`log_retention_auto: on`, older than `log_retention_days`), so 100+ runs is
11640
+ // a normal working directory, and a command that
10948
11641
  // opened every file to build a list would get slower forever. So: enumerate with
10949
11642
  // readdir + stat ONLY, sort, then read a bounded HEAD of just the page being
10950
11643
  // displayed. `checkpoint.json` is never read for a listing — only `run show`
@@ -11386,6 +12079,9 @@ function runCmd() {
11386
12079
  // Read-only, and the ONE reader of the pending sidecar. 0 clear / 1 in-flight / 2 unknown.
11387
12080
  if (sub === "inflight") return runInflightCmd(claudeDir);
11388
12081
 
12082
+ // v2.0.0 W6b (Q2)  the pre-run tree, so `orc undo` can never eat a user edit.
12083
+ if (sub === "snapshot") return require("./run-undo.js").snapshotCmd(runUndoDeps());
12084
+
11389
12085
  if (sub === "show") {
11390
12086
  const arg = pos[2];
11391
12087
  const pick = /^\d+$/.test(String(arg)) ? runs[Number(arg) - 1] : runs.find((r) => r.slug === arg);
@@ -11473,7 +12169,7 @@ function runCmd() {
11473
12169
  if (sub !== "list") {
11474
12170
  console.error(
11475
12171
  `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>`
12172
+ `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
12173
  );
11478
12174
  process.exit(1);
11479
12175
  }
@@ -11641,6 +12337,57 @@ function bar(n, max, width = 12) {
11641
12337
  return "█".repeat(Math.max(1, Math.round((n / max) * width)));
11642
12338
  }
11643
12339
 
12340
+ // Lane and date from a trace NAME alone (no file read), or null for a file
12341
+ // `orc stats` does not count.
12342
+ function traceNameInfo(name) {
12343
+ const m = TRACE_NAME.exec(name);
12344
+ const g = m ? null : TRACE_GENERIC.exec(name);
12345
+ if (!m && !g) return null;
12346
+ return { lane: m ? m[1] : "unknown", date: traceDateIso(m ? m[3] : g[1]) };
12347
+ }
12348
+
12349
+ // v2.1.0 — ONE trace's counts, exactly as `orc stats` adds them up. Shared
12350
+ // with `orc clear logs` (bin/clear-logs.js), which folds these counts into
12351
+ // logs-rollup.json BEFORE it deletes the trace, so the totals do not drop.
12352
+ function traceCountsOf(dir, name) {
12353
+ const info = traceNameInfo(name);
12354
+ if (!info) return null;
12355
+ const out = { lane: info.lane, date: info.date, dispatches: 0, downgrades: 0, unfinished: 0, agents: {} };
12356
+ const addAgent = (a) => {
12357
+ const key = a.replace(/^\S+\s+/, "").replace(/-(haiku|sonnet|opus|fable)-.*$/, "");
12358
+ out.agents[key] = (out.agents[key] || 0) + 1;
12359
+ };
12360
+ const tail = readTail(path.join(dir, name));
12361
+ const st = parseStatsLine(tail);
12362
+ if (st) {
12363
+ out.dispatches += Number(st.dispatches) || 0;
12364
+ out.downgrades += Number(st.downgrades) || 0;
12365
+ for (const a of tail.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) addAgent(a);
12366
+ } else {
12367
+ // Legacy fallback: count DISPATCH lines. Cheaper detail, same headline.
12368
+ let body = "";
12369
+ try { body = fs.readFileSync(path.join(dir, name), "utf8"); } catch (_) {}
12370
+ out.dispatches += (body.match(/\bDISPATCH\s+\S/g) || []).length;
12371
+ out.downgrades += (body.match(/⛔ DOWNGRADE/g) || []).length;
12372
+ for (const a of body.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) addAgent(a);
12373
+ }
12374
+ if (!/\bFINISH\b/.test(tail)) out.unfinished = 1;
12375
+ return out;
12376
+ }
12377
+
12378
+ const LOGS_ROLLUP_FILE = "logs-rollup.json";
12379
+
12380
+ // The rollup `orc clear logs --apply` writes (DE-19 a). Class b: never deleted,
12381
+ // never in the install manifest. `null` when absent or unreadable.
12382
+ function readLogsRollup(claudeDir) {
12383
+ try {
12384
+ const j = JSON.parse(fs.readFileSync(path.join(claudeDir, "orc", LOGS_ROLLUP_FILE), "utf8"));
12385
+ return j && typeof j === "object" ? j : null;
12386
+ } catch (_) {
12387
+ return null;
12388
+ }
12389
+ }
12390
+
11644
12391
  function stats() {
11645
12392
  const claudeDir = resolveClaudeDir();
11646
12393
  const dir = resolveLogDir(claudeDir);
@@ -11653,7 +12400,7 @@ function stats() {
11653
12400
  const empty = (msg) => {
11654
12401
  if (asJson)
11655
12402
  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) },
12403
+ { log_dir: dir, runs: 0, from: null, to: null, lanes: {}, agents: {}, dispatches: 0, downgrades: 0, unfinished: 0, unknown_lane: 0, pruned_runs: 0, graph: statsGraph(claudeDir), questions: null },
11657
12404
  1
11658
12405
  );
11659
12406
  console.log(msg);
@@ -11661,60 +12408,63 @@ function stats() {
11661
12408
  };
11662
12409
 
11663
12410
  let names = [];
12411
+ let noDir = false;
11664
12412
  try {
11665
12413
  names = fs.readdirSync(dir).filter((f) => f.endsWith(".txt"));
11666
12414
  } catch (_) {
11667
- empty(`No traces yet under ${dir}. Run any ORC lane and they appear here.`);
12415
+ noDir = true;
11668
12416
  }
11669
12417
 
11670
12418
  const lanes = new Map();
11671
12419
  const agents = new Map();
11672
- let total = 0, dispatches = 0, downgrades = 0, unfinished = 0, unknownLane = 0;
12420
+ let total = 0, dispatches = 0, downgrades = 0, unfinished = 0, unknownLane = 0, prunedRuns = 0;
11673
12421
  let firstDate = null, lastDate = null;
12422
+ const sinceOk = (date) => !(typeof since === "string" && since && date < since);
12423
+ const add = (c, runs) => {
12424
+ total += runs;
12425
+ if (c.lane === "unknown") unknownLane += runs;
12426
+ lanes.set(c.lane, (lanes.get(c.lane) || 0) + runs);
12427
+ if (c.from && (!firstDate || c.from < firstDate)) firstDate = c.from;
12428
+ if (c.to && (!lastDate || c.to > lastDate)) lastDate = c.to;
12429
+ dispatches += Number(c.dispatches) || 0;
12430
+ downgrades += Number(c.downgrades) || 0;
12431
+ unfinished += Number(c.unfinished) || 0;
12432
+ for (const [a, n] of Object.entries(c.agents || {})) agents.set(a, (agents.get(a) || 0) + (Number(n) || 0));
12433
+ };
11674
12434
 
11675
12435
  for (const name of names) {
11676
- const m = TRACE_NAME.exec(name);
11677
- const g = m ? null : TRACE_GENERIC.exec(name);
11678
- if (!m && !g) continue;
11679
- const date = traceDateIso(m ? m[3] : g[1]);
12436
+ const info = traceNameInfo(name);
12437
+ if (!info) continue;
11680
12438
  // --since filters on the FILENAME date, before any file is opened.
11681
- if (typeof since === "string" && since && date < since) continue;
11682
-
11683
- const lane = m ? m[1] : "unknown";
11684
- if (!m) unknownLane++;
11685
- total++;
11686
- lanes.set(lane, (lanes.get(lane) || 0) + 1);
11687
- if (!firstDate || date < firstDate) firstDate = date;
11688
- if (!lastDate || date > lastDate) lastDate = date;
11689
-
11690
- const tail = readTail(path.join(dir, name));
11691
- const st = parseStatsLine(tail);
11692
- if (st) {
11693
- dispatches += Number(st.dispatches) || 0;
11694
- downgrades += Number(st.downgrades) || 0;
11695
- } else {
11696
- // Legacy fallback: count DISPATCH lines. Cheaper detail, same headline.
11697
- const body = fs.readFileSync(path.join(dir, name), "utf8");
11698
- dispatches += (body.match(/\bDISPATCH\s+\S/g) || []).length;
11699
- downgrades += (body.match(/⛔ DOWNGRADE/g) || []).length;
11700
- for (const a of body.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) {
11701
- const key = a.replace(/^\S+\s+/, "").replace(/-(haiku|sonnet|opus|fable)-.*$/, "");
11702
- agents.set(key, (agents.get(key) || 0) + 1);
11703
- }
11704
- }
11705
- if (st) {
11706
- for (const a of tail.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) {
11707
- const key = a.replace(/^\S+\s+/, "").replace(/-(haiku|sonnet|opus|fable)-.*$/, "");
11708
- agents.set(key, (agents.get(key) || 0) + 1);
11709
- }
11710
- }
11711
- if (!/\bFINISH\b/.test(tail)) unfinished++;
12439
+ if (!sinceOk(info.date)) continue;
12440
+ const c = traceCountsOf(dir, name);
12441
+ add(Object.assign(c, { from: c.date, to: c.date }), 1);
12442
+ }
12443
+
12444
+ // v2.1.0 (DE-19 a) — the runs `orc clear logs --apply` pruned. Their counts
12445
+ // were folded into logs-rollup.json before the delete, per lane and month.
12446
+ // Under --since a bucket counts only when its FIRST run is on or after it.
12447
+ const rollup = readLogsRollup(claudeDir);
12448
+ for (const b of Object.values((rollup && rollup.buckets) || {})) {
12449
+ if (!b || !(Number(b.runs) > 0) || !b.from || !sinceOk(b.from)) continue;
12450
+ add(b, Number(b.runs));
12451
+ prunedRuns += Number(b.runs);
11712
12452
  }
11713
12453
 
11714
- if (!total) empty(since ? `No traces on or after ${since}.` : `No traces yet under ${dir}.`);
12454
+ if (!total)
12455
+ empty(
12456
+ since ? `No traces on or after ${since}.` : noDir ? `No traces yet under ${dir}. Run any ORC lane and they appear here.` : `No traces yet under ${dir}.`
12457
+ );
11715
12458
 
11716
12459
  const laneRows = [...lanes.entries()].sort((a, b) => b[1] - a[1]);
11717
12460
  const agentRows = [...agents.entries()].sort((a, b) => b[1] - a[1]);
12461
+ // v2.0.0 W6b (Q7) — questions per run, from the habit engine's ONE pass over
12462
+ // the ASK / QUESTION lines. null = it could not be computed, never a guess.
12463
+ let questions = null;
12464
+ try {
12465
+ const H = require("./habit.js");
12466
+ questions = H.questionStats(H.collect(claudeDir, habitDeps()), { since: typeof since === "string" && since ? since : null });
12467
+ } catch (_) {}
11718
12468
 
11719
12469
  if (asJson) {
11720
12470
  console.log(
@@ -11730,7 +12480,9 @@ function stats() {
11730
12480
  downgrades,
11731
12481
  unfinished,
11732
12482
  unknown_lane: unknownLane,
12483
+ pruned_runs: prunedRuns,
11733
12484
  graph: statsGraph(claudeDir),
12485
+ questions,
11734
12486
  },
11735
12487
  null,
11736
12488
  2
@@ -11740,6 +12492,7 @@ function stats() {
11740
12492
  }
11741
12493
 
11742
12494
  console.log(ui.header(`ORC usage — ${plural(total, "run")}, ${firstDate} to ${lastDate}`));
12495
+ if (prunedRuns) console.log(ui.color.gray(`includes ${plural(prunedRuns, "pruned run")} (counted in ${LOGS_ROLLUP_FILE} before \`orc clear logs\` deleted the trace)`));
11743
12496
  console.log("\n" + ui.color.bold("Lanes"));
11744
12497
  const maxLane = laneRows[0][1];
11745
12498
  for (const [lane, n] of laneRows) {
@@ -11768,6 +12521,20 @@ function stats() {
11768
12521
  ["retrieval probably kept out", `~${kTok(gr.avoided_low)} – ${kTok(gr.avoided_high)} (an ESTIMATE — see \`orc graph gain\`)`],
11769
12522
  ]));
11770
12523
  }
12524
+ // Q7 — "fewer questions", measured. Every field --json carries is printed.
12525
+ if (questions && questions.runs) {
12526
+ console.log("\n" + ui.color.bold("Questions") + ` ${questions.asked} asked · ${questions.answered_for_you} answered for you`);
12527
+ console.log(ui.kv([
12528
+ ["per run (median)", String(questions.per_run_p50)],
12529
+ ["asked by a subagent", String(questions.subagent_questions)],
12530
+ ["how each was answered", Object.entries(questions.by).map(([k, v]) => `${k} ${v}`).join(" · ")],
12531
+ ["last runs, oldest first", questions.per_run_trend.map((r) => r.asked).join(" ")],
12532
+ ]));
12533
+ for (const [l, r] of Object.entries(questions.by_lane).sort((a, b) => b[1].asked - a[1].asked))
12534
+ 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`);
12535
+ const pts = Object.entries(questions.by_point).sort((a, b) => b[1].asked - a[1].asked);
12536
+ for (const [q, r] of pts) console.log(ui.color.gray(` ${q.padEnd(34)} ${r.asked} asked · ${r.answered_for_you} answered for you`));
12537
+ }
11771
12538
  console.log("\n" + ui.color.bold("Health"));
11772
12539
  console.log(ui.kv([
11773
12540
  ["model downgrades", String(downgrades)],
@@ -11778,8 +12545,10 @@ function stats() {
11778
12545
  "\n" +
11779
12546
  ui.color.gray(
11780
12547
  "Counts only what traces record. `/orc-retro` and `/orc-explain` never write one,\n" +
11781
- "so they never appear here. Deleting or moving log_dir resets these numbers;\n" +
11782
- "nothing auto-prunes traces."
12548
+ "so they never appear here. Deleting or moving log_dir resets these numbers.\n" +
12549
+ "Traces are kept until you run `orc clear logs --apply`, or until the automatic\n" +
12550
+ "sweep removes them (`log_retention_auto: on`, older than `log_retention_days`);\n" +
12551
+ "a pruned run still counts here."
11783
12552
  )
11784
12553
  );
11785
12554
  }
@@ -12256,7 +13025,7 @@ function scoreFromFacets(f, fanIn, fanOut) {
12256
13025
  }
12257
13026
 
12258
13027
  // The resolved score→model table. `opus5_only` outranks everything (3 bands);
12259
- // otherwise the default 6-band table. A hand-written `rubric_bands_override` is
13028
+ // otherwise the default 5-band table. A hand-written `rubric_bands_override` is
12260
13029
  // registry-less by design, so the forecast reports it as UNKNOWN rather than
12261
13030
  // pretending to resolve it.
12262
13031
  // v1.0.0 W4 — an ALIAS, not a second array. See OPUS5_SCORE_TABLE's comment:
@@ -12366,8 +13135,8 @@ function parsePlanTasks(text) {
12366
13135
  // Fixed roles a run dispatches besides executors. Named, not guessed: the
12367
13136
  // forecast has to say WHICH roles it priced or the total is unfalsifiable.
12368
13137
  const LANE_FIXED_ROLES = {
12369
- orc: ["orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-reviewer-opus-5-med", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
12370
- ultra: ["orc-advisor-opus-5-xhigh", "orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-judge-opus-5-xhigh", "orc-reviewer-opus-5-med", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
13138
+ orc: ["orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
13139
+ ultra: ["orc-advisor-opus-5-xhigh", "orc-system-analyst-opus-5-high", "orc-planner-opus-5-med", "orc-judge-opus-5-xhigh", "orc-reviewer-opus-5-low", "orc-verifier-opus-5-med", "orc-trace-writer-haiku-4-5"],
12371
13140
  mini: ["orc-analyze-mini-sonnet-5-high", "orc-planner-mini-sonnet-5-high", "orc-trace-writer-haiku-4-5"],
12372
13141
  fast: ["orc-trace-writer-haiku-4-5"],
12373
13142
  };
@@ -13804,6 +14573,82 @@ function exportCmd() {
13804
14573
 
13805
14574
  const IMPORT_CANDIDATES = ["AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md"];
13806
14575
 
14576
+ // ── v2.1.0 — `orc review policy`: does the PROJECT ask for its own review? ──
14577
+ //
14578
+ // Read-only and zero-token. It reads the instruction files `orc export` already
14579
+ // knows plus `.claude/CLAUDE.md`, and finds a line that has ALL of: a review
14580
+ // word, a duty word, and a NAME (a slash command, a `plugin:skill` id, a
14581
+ // backticked name, or "skill"/"command" next to a name). Fenced code is skipped,
14582
+ // and a line that names ORC's own reviewer or an ORC command does not count.
14583
+ //
14584
+ // It is a heuristic, so it NEVER answers anything: house rules "can never change
14585
+ // how a lane RUNS". It only adds an OPTION to the question the lane asks before
14586
+ // ship (`_shared/review-slice.md` §0). Exit 0 in every state.
14587
+ const REVIEW_POLICY_FILES = IMPORT_CANDIDATES.concat([".claude/CLAUDE.md"]);
14588
+ const RP_REVIEW = /\b(?:code[- ]?review|reviews?|reviewed)\b|\bCR\b/i;
14589
+ const RP_DUTY = /\b(?:must|always|required|needs?|should|only|use)\b/i;
14590
+ function reviewPolicyNames(text) {
14591
+ const names = [];
14592
+ const add = (n) => {
14593
+ if (!n) return;
14594
+ const v = n.replace(/[.,;:)]+$/, "");
14595
+ if (/^\/?orc(?:-|$)/.test(v) || /orc-reviewer-/.test(v)) return;
14596
+ if (!names.includes(v)) names.push(v);
14597
+ };
14598
+ for (const m of text.matchAll(/(?:^|[\s(`"'])(\/[a-z][\w:-]*)/g)) add(m[1]);
14599
+ for (const m of text.matchAll(/(?:^|[\s(`"'])([a-z0-9-]+:[a-z0-9-]+)(?=[\s`"').,;]|$)/g)) if (!/^https?:/.test(m[1])) add(m[1]);
14600
+ for (const m of text.matchAll(/`([^`\s]{2,60})`/g)) add(m[1]);
14601
+ for (const m of text.matchAll(/\b(?:skill|command)\s+["']?([a-z][\w:-]{1,60})/gi)) add(m[1]);
14602
+ for (const m of text.matchAll(/\b([a-z][\w:-]{1,60})\s+(?:skill|command)\b/gi)) if (!/^(?:the|a|an|this|that|our|your|its)$/i.test(m[1])) add(m[1]);
14603
+ return names;
14604
+ }
14605
+ function reviewPolicyCompute(claudeDir) {
14606
+ const root = repoRootOf(claudeDir);
14607
+ const found = [];
14608
+ for (const rel of REVIEW_POLICY_FILES) {
14609
+ let text;
14610
+ try {
14611
+ text = fs.readFileSync(path.join(root, rel), "utf8");
14612
+ } catch (_) {
14613
+ continue;
14614
+ }
14615
+ let fence = false;
14616
+ text.split(/\r?\n/).forEach((raw, i) => {
14617
+ if (/^\s*(```|~~~)/.test(raw)) {
14618
+ fence = !fence;
14619
+ return;
14620
+ }
14621
+ if (fence) return;
14622
+ if (/orc-reviewer-/.test(raw) || !RP_REVIEW.test(raw) || !RP_DUTY.test(raw)) return;
14623
+ const names = reviewPolicyNames(raw);
14624
+ if (!names.length) return;
14625
+ found.push({ file: rel, line: i + 1, quote: raw.trim().slice(0, 200), names });
14626
+ });
14627
+ }
14628
+ const first = found[0];
14629
+ return {
14630
+ ok: true,
14631
+ policy: found.length ? "project" : "orc",
14632
+ found,
14633
+ line: first
14634
+ ? `review: ${first.file}:${first.line} names ${first.names[0]} — before ship, ask which review (_shared/review-slice.md §0)`
14635
+ : null,
14636
+ };
14637
+ }
14638
+ function reviewCmd() {
14639
+ const pos = positionals();
14640
+ if (pos[1] !== "policy") {
14641
+ if (wantsJson()) emitJson({ ok: false, reason: "usage", message: "usage: orc review policy [--json]" }, 1);
14642
+ console.error("usage: orc review policy [--json]");
14643
+ process.exit(1);
14644
+ }
14645
+ const r = reviewPolicyCompute(resolveClaudeDir());
14646
+ if (wantsJson()) emitJson(r, 0);
14647
+ if (!r.found.length) console.log("review: no project review rule found — ORC review is the default");
14648
+ else for (const f of r.found) console.log(`review: ${f.file}:${f.line} names ${f.names.join(", ")} — "${f.quote}"`);
14649
+ process.exit(0);
14650
+ }
14651
+
13807
14652
  function exportImport(claudeDir) {
13808
14653
  const asJson = wantsJson();
13809
14654
  const root = repoRootOf(claudeDir);
@@ -25888,8 +26733,8 @@ const EXTRA_SLOTS = [
25888
26733
  {
25889
26734
  slot: "wiki-scanner-deep",
25890
26735
  lane: "/orc-wiki",
25891
- claude: ["orc-wiki-scanner-opus-4-8-high"],
25892
- claude_opus5: ["orc-wiki-scanner-opus-5-med"],
26736
+ claude: ["orc-wiki-scanner-opus-5-low"],
26737
+ claude_opus5: null,
25893
26738
  asks: false,
25894
26739
  announce: "per scan-batch, beside the resolved tier",
25895
26740
  why: "a scanner returns an evidence-anchored doc body the orchestrator writes; every claim in it is anchored to a file you can open.",
@@ -25898,7 +26743,7 @@ const EXTRA_SLOTS = [
25898
26743
  slot: "wiki-scanner-light",
25899
26744
  lane: "/orc-wiki",
25900
26745
  claude: ["orc-wiki-scanner-sonnet-5-high"],
25901
- claude_opus5: ["orc-wiki-scanner-opus-5-med"],
26746
+ claude_opus5: ["orc-wiki-scanner-opus-5-low"],
25902
26747
  asks: false,
25903
26748
  announce: "per scan-batch, beside the resolved tier",
25904
26749
  why: "the LIGHT tier is already a small no-new-surface delta on an existing doc — the cheapest work the wiki does.",
@@ -34251,7 +35096,7 @@ function doctor() {
34251
35096
  "global-skew",
34252
35097
  `GLOBAL install ~/.claude is ${globalV} but this project is ${localV} — ` +
34253
35098
  "the global copy can win skill resolution; run `orc update --global`",
34254
- { global_version: globalV, local_version: localV }
35099
+ { global_version: globalV, local_version: localV, fix_command: "orc update --global" }
34255
35100
  );
34256
35101
  } else ok(`global install ~/.claude matches (${globalV})`);
34257
35102
  // Agent files the global install still carries that this payload no
@@ -34401,6 +35246,10 @@ function doctor() {
34401
35246
  }
34402
35247
  })();
34403
35248
  const v = slValidate(slLoadOrDefault(claudeDir));
35249
+ const hk = slHookInfo(claudeDir, SL_BOARDS.status);
35250
+ const skew = !hk.match && hk.fix;
35251
+ if (skew)
35252
+ warn("statusline-hook-skew", slHookSentence(hk), { fix: hk.fix, fix_command: hk.fix });
34404
35253
  // An ORPHANED component — a layout naming an id this ORC no longer ships
34405
35254
  // — is REPORTED, NEVER AUTO-REPAIRED, and the fix names the item. Which
34406
35255
  // component replaces a retired one is a design decision, not a repair.
@@ -34415,9 +35264,36 @@ function doctor() {
34415
35264
  });
34416
35265
  else if (st && st.finding && Date.now() - (st.at || 0) < 24 * 60 * 60 * 1000)
34417
35266
  warn("statusline-layout-unreadable", `status line: the hook fell back to ORC's built-in lines (${st.finding})`, {
34418
- fix: "orc statusline compile",
35267
+ fix: hk.fix || "orc statusline compile",
35268
+ fix_command: hk.fix || "orc statusline compile",
34419
35269
  });
34420
- else ok("custom status line armed and valid");
35270
+ else if (!skew) ok("custom status line armed and valid");
35271
+ }
35272
+ } catch (_) {}
35273
+
35274
+ // 5a-ter) the refresh timer (v2.1.0 W8). Three checks, each a fact about the
35275
+ // settings file: a hand-edited value under Claude Code's 1 s floor (it never
35276
+ // starts the timer), a layout that MOVES with no timer to move it, and a timer
35277
+ // on the sub-agent board, where Claude Code documents no refreshInterval.
35278
+ try {
35279
+ for (const b of [SL_BOARDS.status, SL_BOARDS.subagent]) {
35280
+ const hk = slHookInfo(claudeDir, b);
35281
+ const st = hk && hk.settings ? slReadJson(hk.settings) : null;
35282
+ const raw = st && st[b.setting] ? st[b.setting].refreshInterval : undefined;
35283
+ if (b === SL_BOARDS.subagent) {
35284
+ if (raw !== undefined)
35285
+ warn("statusline-subagent-refresh", `${b.setting}.refreshInterval is set (${raw}), but Claude Code documents no refresh timer for the sub-agent board — it may do nothing`, { fix: `remove refreshInterval from ${b.setting} in ${hk.settings}` });
35286
+ continue;
35287
+ }
35288
+ if (typeof raw === "number" && raw < SL_REFRESH_FLOOR_S)
35289
+ warn("statusline-refresh-below-floor", `statusLine.refreshInterval is ${raw} — below the Claude Code floor (1 s), so the timer never starts`, { fix: "orc statusline refresh 1", fix_command: "orc statusline refresh 1" });
35290
+ let lock = null;
35291
+ try {
35292
+ lock = JSON.parse(fs.readFileSync(slPaths(claudeDir, b).lock, "utf8"));
35293
+ } catch (_) {}
35294
+ const need = lock && lock.needs_refresh_interval;
35295
+ if (need && raw === undefined && String(resolvedConfig(claudeDir).statusline_custom || "off") === "on")
35296
+ warn("statusline-refresh-needed", `your status line layout moves or shows the time, but no refresh timer is set — it changes only on events`, { fix: `orc statusline refresh ${need}`, fix_command: `orc statusline refresh ${need}` });
34421
35297
  }
34422
35298
  } catch (_) {}
34423
35299
 
@@ -34471,6 +35347,37 @@ function doctor() {
34471
35347
  }
34472
35348
  } catch (_) {}
34473
35349
 
35350
+ // 5a-bis2) the session hook (v2.0.0 Q8/Q9). TWO entries: `Stop` (the bell,
35351
+ // armed only by `notify: bell`) and `SessionStart` on `compact` (the run
35352
+ // pointer after a compaction, always armed). One finding when either is missing.
35353
+ try {
35354
+ const cfg = resolvedConfig(claudeDir);
35355
+ let hk = {};
35356
+ try {
35357
+ hk = (JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8")) || {}).hooks || {};
35358
+ } catch (_) {}
35359
+ const wiredOn = (event, matcher) =>
35360
+ Array.isArray(hk[event]) &&
35361
+ hk[event].some((e) => (e.matcher || null) === (matcher || null) && (e.hooks || []).some((h) => String(h.command || "").includes("orc-session-hook")));
35362
+ const missing = [["Stop", null], ["SessionStart", "compact"], ["SubagentStart", null]].filter(([e, m]) => !wiredOn(e, m)).map(([e, m]) => (m ? `${e} ${m}` : e));
35363
+ const bell = String(cfg.notify || "off") === "bell";
35364
+ if (missing.length)
35365
+ warn(
35366
+ "session-hook-unwired",
35367
+ `session hook not wired on ${missing.join(", ")} — ` +
35368
+ [
35369
+ missing.includes("SessionStart compact") ? "a compacted session is not told which run is in flight" : null,
35370
+ missing.includes("Stop") ? (bell ? "notify is bell, but the bell never rings" : "the bell cannot ring if notify is set to bell") : null,
35371
+ missing.includes("SubagentStart") ? "a reviewer gets no gotcha card" : null,
35372
+ ]
35373
+ .filter(Boolean)
35374
+ .join("; ") +
35375
+ "; run `orc update`",
35376
+ { fixable: true, fix: "orc update", fix_command: "orc update" }
35377
+ );
35378
+ else ok(`session hook wired (Stop + SessionStart compact + SubagentStart) · notify ${bell ? "bell" : "off"}`);
35379
+ } catch (_) {}
35380
+
34474
35381
  // 5a-ter) the code graph (v1.8.0). ONE finding, and only while `code_graph` is
34475
35382
  // on — the read-gate rule. DRIFTED is the only state worth a line: NONE is
34476
35383
  // built by the next code lane's preflight, and a doctor that warns about a
@@ -40852,6 +41759,8 @@ Usage:
40852
41759
  [--check] fail when the export is stale (exit 1)
40853
41760
  orc export import read an existing AGENTS.md/.cursorrules as EVIDENCE and
40854
41761
  propose ORC config + pact seeds (never applies anything)
41762
+ orc review policy [--json] does CLAUDE.md / AGENTS.md name the project's own review?
41763
+ read-only; it only adds an option to "which review"
40855
41764
  orc pattern [--dir <path>] cached code-patterns (project-scoped; no --global)
40856
41765
  orc pattern status [<lang>] whether a cached pattern exists — the deterministic
40857
41766
  existence probe every knowledge-gated lane runs first
@@ -40906,8 +41815,31 @@ Usage:
40906
41815
  --reason "<why>" moves RESUME.md aside (never deletes it) and records why,
40907
41816
  so it stops counting as waiting [--json]
40908
41817
  orc run reopen <slug|n> put it back — it is waiting again [--json]
41818
+ orc run snapshot --run <slug> at run start: what the tree looked like BEFORE the run
41819
+ (a git ref + the untracked files), so undo can tell your
41820
+ edits from the run's own [--json]
41821
+ orc undo --run <slug> [--files a,b] PRINTS the commands that revert ONLY the files this run
41822
+ [--apply] changed, back to the snapshot; --apply runs them. Never
41823
+ touches a file outside the run, keeps a run file you
41824
+ edited later (exit 4) [--json]
40909
41825
  orc stats [--since YYYY-MM-DD] [--json] how much you actually use each lane and agent, counted
40910
41826
  from the trace filenames — no model, instant, free
41827
+ orc clear logs [--older-than 30|60|90|120|240|360] [--apply] [--include-default-dir]
41828
+ PRINTS the finished traces and run folders (with their
41829
+ git ref) older than \`log_retention_days\`; --apply
41830
+ deletes them. Never user data, a waiting run or the
41831
+ active trace. \`orc stats\` keeps their counts [--json]
41832
+ orc habit show|log|points|why|accept|decline|forget|reset|doctor|export|purge your usual answers,
41833
+ learned from the ASK lines in your traces (config key
41834
+ \`habits\`, off by default). Never applied without your yes [--json]
41835
+ orc habit repo [accept|decline|forget <id>] soft preferences read from git history (commit,
41836
+ branch, test-file naming). An accepted one rides in
41837
+ \`orc rules slice\` as the LEARNED tier [--json]
41838
+ orc trace write --packet -|<file> write ONE phase packet into the run's trace pair (.txt +
41839
+ .jsonl). Exit 2 = a bad packet, nothing written [--json]
41840
+ orc fix classify|record|list the deterministic half of /orc-fix — classify a fix from
41841
+ evidence, record it (computes the review miss), list them.
41842
+ Writes ONE FIX line into the host run's trace [--json]
40911
41843
  orc wait the deterministic half of /orc-wait — a wait costs zero
40912
41844
  tokens, because a detached command does it, not a model
40913
41845
  orc wait lanes [--json] which lanes support a wait, what each one checkpoints,
@@ -41003,6 +41935,7 @@ const WAIT_LANE_SHAPES = [
41003
41935
  { lane: "/orc-pattern", checkpoint: "none", safe_point: "single dispatch" },
41004
41936
  { lane: "/orc-claude", checkpoint: "none", safe_point: "single dispatch" },
41005
41937
  { lane: "/orc-explain", checkpoint: "none", safe_point: "read-only, seconds long" },
41938
+ { lane: "/orc-fix", checkpoint: "none", safe_point: "after the record is written" },
41006
41939
  { lane: "/orc-route", checkpoint: "none", safe_point: "read-only, seconds long" },
41007
41940
  { lane: "/orc-boundary", checkpoint: "none", safe_point: "read-only, seconds long" },
41008
41941
  { lane: "/orc-budget", checkpoint: "none", safe_point: "read-only, seconds long" },
@@ -41991,6 +42924,10 @@ const STATUSLINE_RENDERERS = {
41991
42924
  // motion
41992
42925
  motif: { kind: "motif", needs: "motif" },
41993
42926
  pulse: { kind: "motif", sub: "pulse", needs: "motif" },
42927
+ // v2.0.4 — the animated sprites (pets). `width` is the TRACK in cells.
42928
+ run: { kind: "sprite", sub: "run", width: [6, 40], dflt_w: 16, needs: "sprite" },
42929
+ bounce: { kind: "sprite", sub: "bounce", width: [6, 40], dflt_w: 16, needs: "sprite" },
42930
+ idle: { kind: "sprite", sub: "idle", width: [6, 40], dflt_w: 16, needs: "sprite" },
41994
42931
  };
41995
42932
 
41996
42933
  // WHICH RENDERERS DRAW A LABEL. Seven of thirty-five, and the other
@@ -42027,7 +42964,18 @@ function slRendererUses(name) {
42027
42964
  out.push("ramp");
42028
42965
  if (!out.includes("width")) out.push("width");
42029
42966
  }
42030
- return out;
42967
+ // v2.0.4 — the decorations every shape can now carry, and the per-kind ones.
42968
+ // `case` styles the authored name, so every shape uses it.
42969
+ out.push("label", "label_pos", "caption", "caption_pos", "caption_align", "caption_color", "caption_case", "case", "brackets", "padding", "prefix", "suffix");
42970
+ if (rend.kind === "text" || rend.kind === "link") out.push("align", "sign", "max_len");
42971
+ // A bar or a series can draw the item's own value beside it (`value_pos`),
42972
+ // through the item's format with prefix/suffix removed, so it uses the number fields.
42973
+ if (rend.kind === "bar" || rend.kind === "series") out.push("format", "compact", "precision", "min_width", "sign", "align", "max_len");
42974
+ if (rend.kind === "bar") out.push("value_pos", "fill_color", "empty_color", "fill_char", "empty_char", "ramp_colors", "ramp_stops");
42975
+ if (name === "split" || name === "marker") out.push("threshold");
42976
+ if (rend.kind === "series") out.push("value_pos", "fill_color");
42977
+ if (["dot", "shape", "icon", "traffic"].includes(name)) out.push("glyph_by_state");
42978
+ return [...new Set(out)];
42031
42979
  }
42032
42980
 
42033
42981
  // ── The glyph sets (design-language.md §2) ──────────────────────────────────
@@ -42192,7 +43140,41 @@ const SL_THEME_ABOUT = {
42192
43140
 
42193
43141
  const SL_FORMATS = ["percent", "ratio", "fraction", "decimal", "plain"];
42194
43142
  const SL_COMPACT = ["off", "si", "bytes"];
42195
- const SL_CASES = ["none", "upper", "lower", "title"];
43143
+ const SL_CASES = ["none", "upper", "lower", "title", "small", "super", "sub"];
43144
+ // v2.0.4 — the closed sets of the design fields. The panel reads each one from
43145
+ // `components --json` and names none of them itself.
43146
+ const SL_LABEL_POS = ["before", "after", "above", "below"];
43147
+ const SL_VALUE_POS = ["none", "before", "after"];
43148
+ const SL_CAPTION_POS = ["above", "below"];
43149
+ const SL_CAPTION_ALIGN = ["left", "center", "right"];
43150
+ const SL_ALIGNS = ["left", "right"];
43151
+ const SL_SIGNS = ["auto", "always", "never"];
43152
+ const SL_UNKNOWNS = ["dash", "hide"];
43153
+ const SL_LINE_ALIGNS = ["left", "center", "right"];
43154
+ const SL_LIMITS = { caption: 40, max_len: 60, padding: 8, ramp_colors: [2, 8], brackets: 3, param_text: 60 };
43155
+
43156
+ // The engine owns cell width and the styled alphabets; the CLI borrows them so
43157
+ // the validator and the renderer can never disagree. The fallbacks only serve
43158
+ // an engine that predates v2.0.4.
43159
+ function slCellWidth(s) {
43160
+ const e = slEngine();
43161
+ if (typeof e.cellWidth === "function") return e.cellWidth(String(s));
43162
+ let w = 0;
43163
+ for (const ch of String(s)) w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1F000}-\u{1FAFF}]/u.test(ch) ? 2 : 1;
43164
+ return w;
43165
+ }
43166
+ function slCaseText(s, c) {
43167
+ if (c === "upper") return s.toUpperCase();
43168
+ if (c === "lower") return s.toLowerCase();
43169
+ if (c === "title") return s.replace(/\b\w/g, (m) => m.toUpperCase());
43170
+ if (c === "small" || c === "super" || c === "sub") return (slEngine().styleText || ((x) => x))(s, c);
43171
+ return s;
43172
+ }
43173
+ // A C0 or C1 control character in authored text is a terminal-escape
43174
+ // injection: the hook prints the layout to a terminal.
43175
+ function slHasControl(s) {
43176
+ return /[\u0000-\u001f\u007f-\u009f]/.test(String(s));
43177
+ }
42196
43178
  const SL_TRUNCATE = ["end", "middle", "none"];
42197
43179
 
42198
43180
  // ── The component registry ─────────────────────────────────────────────────
@@ -42247,6 +43229,26 @@ function slRow(o) {
42247
43229
  );
42248
43230
  }
42249
43231
 
43232
+ // v2.0.4 — THE SPRITES. Every glyph is exactly 1 cell and every frame of one
43233
+ // sprite has the same width; test/statusline-pets.test.js proves it. `a` is the
43234
+ // ASCII twin of each frame. `ahead` / `behind` fill the track in front of and
43235
+ // behind the sprite ("" = a space).
43236
+ const SL_SPRITES = {
43237
+ "pet-cat": { summary: "A pixel cat that runs along the line.", f: ["⠑⡤⡤⠤⡷⡃", "⠑⢤⢤⠤⣷⠃"], a: ["=^.^=", "=^o^="], ms: 1000, ahead: "", behind: "" },
43238
+ "pet-mouse": { summary: "A pixel mouse that runs along the line.", f: ["⢲⠾⡇", "⠲⣾⠇"], a: ["<:3", "<:3"], ms: 700, ahead: "", behind: "" },
43239
+ "pet-chase": { summary: "A pixel cat that chases a mouse along the line.", f: ["⠑⡤⡤⠤⡷⡃ ⢲⠾⡇", "⠑⢤⢤⠤⣷⠃ ⠲⣾⠇"], a: ["=^.^= <:3", "=^o^= <:3"], ms: 1000, ahead: "", behind: "" },
43240
+ "pet-cat-text": { summary: "A cat made of letters.", f: ["ᓚᘏᗢ", "ᓚᘏᗢ"], a: ["=^.^=", "=^.^="], ms: 1000, ahead: "", behind: "" },
43241
+ "pet-pacman": { summary: "Pac-Man eats the dots in front of him.", f: ["ᗧ", "●"], a: ["C", "O"], ms: 800, ahead: "·", behind: " ", ahead_a: "." },
43242
+ "pet-fish": { summary: "A fish that swims along the line.", f: [" ><>", "°><>"], a: [" ><>", "o><>"], ms: 1200, ahead: "", behind: "" },
43243
+ "pet-bird": { summary: "A bird that flies along the line.", f: ["\\v/", "-v-"], a: ["\\v/", "-v-"], ms: 600, ahead: "", behind: "" },
43244
+ "pet-dog": { summary: "A dog that runs along the line.", f: ["V•ᴥ•V", "V•ᴥ•V"], a: ["V.w.V", "V.w.V"], ms: 900, ahead: "", behind: "" },
43245
+ };
43246
+ const SL_SPRITE_SPEED = { slow: 2, normal: 1, fast: 0.5 };
43247
+
43248
+ // The weather parts share their parameters. "" = the provider finds the place
43249
+ // from your IP address.
43250
+ const SL_WEATHER_PARAMS = { location: { free: true, dflt: "", max: 40 }, units: { options: ["metric", "us"], dflt: "metric" } };
43251
+
42250
43252
  const STATUSLINE_COMPONENTS = [
42251
43253
  // ── Group A — Session and tier. Straight from the payload. Free. ─────────
42252
43254
  slRow({ id: "verdict", group: "A", summary: "The ORC-ready verdict icon.", renderers: ["icon", "shape", "dot", "word", "badge", "pill"], defaults: { render: "icon" }, states: ["ready", "boosted", "degrade"], shapes: { ready: "✅", boosted: "🚀", degrade: "⛔" }, binding: "verdict.state", state_binding: "verdict.state" }),
@@ -42278,9 +43280,9 @@ const STATUSLINE_COMPONENTS = [
42278
43280
  slRow({ id: "quota-spend-reset", group: "B", label: "reset", summary: "Minutes until the spend limit resets.", renderers: ["bare", "plain", "paren"], defaults: { render: "paren", suffix: "m" }, unknown: "hide", binding: "quota.spend.reset", time_based: true }),
42279
43281
  slRow({ id: "cost-usd", group: "B", summary: "Session cost in dollars, straight from the payload. No price table, no join.", renderers: ["bare", "plain", "label-value", "spark"], defaults: { render: "bare", prefix: "$", precision: 2 }, series: "cost", binding: "cost.usd" }),
42280
43282
  slRow({ id: "cost-rate", group: "B", label: "/h", summary: "Dollars per hour of session.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", prefix: "$", precision: 2 }, binding: "cost.rate_usd_h" }),
42281
- slRow({ id: "api-time", group: "B", label: "api", summary: "Time spent waiting on the API.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", suffix: "m" }, binding: "cost.api_ms" }),
43283
+ slRow({ id: "api-time", group: "B", label: "api", summary: "Time spent waiting on the API.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", suffix: "m" }, binding: "cost.api_min" }),
42282
43284
  slRow({ id: "api-ratio", group: "B", label: "api", summary: "How much of this session was waiting rather than thinking.", renderers: ["bar", "blocks", "gauge", "ring", "plain", "fine", "dots"], defaults: { render: "gauge" }, bounded: true, binding: "cost.api_ratio" }),
42283
- slRow({ id: "wall-time", group: "B", label: "wall", summary: "Total session wall time.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", suffix: "m" }, binding: "cost.wall_ms", time_based: true }),
43285
+ slRow({ id: "wall-time", group: "B", label: "wall", summary: "Total session wall time.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", suffix: "m" }, binding: "cost.wall_min", time_based: true }),
42284
43286
  slRow({ id: "tok-speed", group: "B", label: "tok/s", summary: "Main tokens per second of session.", renderers: ["bare", "plain", "label-value", "spark"], defaults: { render: "plain" }, series: "speed", cost: "scan", binding: "mtok.speed" }),
42285
43287
 
42286
43288
  // ── Group C — Run state. From the ONE throttled scan. ────────────────────
@@ -42331,7 +43333,7 @@ const STATUSLINE_COMPONENTS = [
42331
43333
  slRow({ id: "worktree", group: "H", summary: "Whether this is a worktree, and which kind.", renderers: ["bare", "shape", "badge", "word"], defaults: { render: "shape" }, states: ["main", "worktree", "detached"], shapes: { main: "·", worktree: "⑂", detached: "!" }, unknown: "hide", binding: "worktree.state", state_binding: "worktree.state" }),
42332
43334
  slRow({ id: "worktree-branch", group: "H", summary: "The worktree's branch.", renderers: ["bare", "plain", "paren"], defaults: { render: "bare" }, unknown: "hide", binding: "worktree.branch" }),
42333
43335
  slRow({ id: "worktree-origin", group: "H", label: "from", summary: "The branch the worktree came from.", renderers: ["bare", "plain", "paren"], defaults: { render: "paren" }, unknown: "hide", binding: "worktree.origin" }),
42334
- slRow({ id: "lines-added", group: "H", label: "+", summary: "Lines added this session — a diff size with no subprocess.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", sign: "always" }, binding: "cost.lines_added" }),
43336
+ slRow({ id: "lines-added", group: "H", label: "+", summary: "Lines added this session — a diff size with no subprocess.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", sign: "auto" }, binding: "cost.lines_added" }),
42335
43337
  slRow({ id: "lines-removed", group: "H", label: "−", summary: "Lines removed this session.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain" }, binding: "cost.lines_removed" }),
42336
43338
  slRow({ id: "lines-net", group: "H", label: "Δ", summary: "Net lines this session.", renderers: ["bare", "plain", "trend", "spark", "label-value"], defaults: { render: "plain", sign: "always" }, states: ["growing", "shrinking", "flat"], shapes: { growing: "▲", shrinking: "▼", flat: "▶" }, series: "lines", binding: "cost.lines_net", state_binding: "cost.lines_state" }),
42337
43339
  slRow({ id: "pr-number", group: "H", label: "PR", summary: "The pull request for this branch. The link is ALWAYS a decoration on text that reads fine without it — Terminal.app does not support OSC 8.", renderers: ["bare", "plain", "label-value", "badge", "link"], defaults: { render: "link", prefix: "#" }, unknown: "hide", binding: "pr.number" }),
@@ -42344,9 +43346,9 @@ const STATUSLINE_COMPONENTS = [
42344
43346
  slRow({ id: "text", group: "I", summary: "Any text you type. Also where a Nerd Font glyph goes, if you have one — ORC ships none, because it cannot detect the font.", renderers: ["bare", "badge", "pill", "bracket", "angle"], defaults: { render: "bare" }, unknown: "hide", params: { text: { free: true, dflt: "" } }, binding: "static.text" }),
42345
43347
  slRow({ id: "clock", group: "I", summary: "The wall clock. Needs `refreshInterval` to tick.", renderers: ["bare", "plain", "label-value"], defaults: { render: "bare" }, params: { format: { options: ["HH:mm", "HH:mm:ss", "h:mm a"], dflt: "HH:mm" } }, binding: "clock.now", time_based: true }),
42346
43348
  slRow({ id: "elapsed", group: "I", summary: "Minutes since this session opened.", renderers: ["bare", "plain", "label-value"], defaults: { render: "plain", suffix: "m" }, binding: "session.elapsed_min", time_based: true }),
42347
- slRow({ id: "spacer", group: "I", summary: "Literal spaces. Does not count against the 5-per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { width: { options: [1, 2, 3, 4, 6, 8], dflt: 1 } }, binding: "static.text" }),
42348
- slRow({ id: "divider", group: "I", summary: "A styled break between GROUPS of components — the second separator kind. Does not count against the 5-per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { text: { free: true, dflt: " │ " } }, binding: "static.text" }),
42349
- slRow({ id: "fill", group: "I", summary: "Pushes everything after it to the right edge. Exact, because Claude Code sets COLUMNS before the script runs. Does not count against the 5-per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { weight: { options: [1, 2, 3], dflt: 1 } }, binding: "static.text" }),
43349
+ slRow({ id: "spacer", group: "I", summary: "Literal spaces. Does not count against the per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { width: { options: [1, 2, 3, 4, 6, 8], dflt: 1 } }, binding: "static.text" }),
43350
+ slRow({ id: "divider", group: "I", summary: "A styled break between GROUPS of components — the second separator kind. Does not count against the per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { text: { free: true, dflt: " │ " } }, binding: "static.text" }),
43351
+ slRow({ id: "fill", group: "I", summary: "Pushes everything after it to the right edge. Exact, because Claude Code sets COLUMNS before the script runs. Does not count against the per-line limit.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { weight: { options: [1, 2, 3], dflt: 1 } }, binding: "static.text" }),
42350
43352
 
42351
43353
 
42352
43354
  // ── Group D — Knowledge. Every row here comes out of wiki-meta.json, the
@@ -42404,7 +43406,7 @@ const STATUSLINE_COMPONENTS = [
42404
43406
 
42405
43407
  // ── Group I remainder ───────────────────────────────────────────────────
42406
43408
  slRow({ id: "config", group: "I", summary: "Any ORC config key, as a component. It shows what is IN THE FILE — a hook has no lane, so it cannot resolve a key the way a lane would.", renderers: ["plain", "label-value", "bare", "badge"], defaults: { render: "label-value" }, cost: "new-read", params: { key: { free: true, dflt: "extra_enabled" } }, binding: "config.value" }),
42407
- slRow({ id: "group", group: "I", summary: "Two to four parts drawn as ONE object — shared brackets, one background, one emphasis. It counts as ONE slot against the five, because the limit is about how much a line SAYS and a group says one thing.", renderers: ["bracket", "badge", "pill", "angle"], defaults: { render: "pill" }, unknown: "hide", children: [2, 4], binding: "static.text" }),
43409
+ slRow({ id: "group", group: "I", summary: "Two to four parts drawn as ONE object — shared brackets, one background, one emphasis. It counts as ONE slot against the per-line limit, because the limit is about how much a line SAYS and a group says one thing.", renderers: ["bracket", "badge", "pill", "angle"], defaults: { render: "pill" }, unknown: "hide", children: [2, 4], binding: "static.text" }),
42408
43410
  slRow({ id: "icon-static", group: "I", summary: "One glyph you choose, from the shipped sets. Not an icon font — ORC ships none, because it cannot detect one.", renderers: ["bare"], defaults: { render: "bare" }, unknown: "hide", params: { glyph: { free: true, dflt: "●" } }, binding: "static.text" }),
42409
43411
 
42410
43412
  // ── REFUSED, with the measurement recorded ──────────────────────────────
@@ -42426,6 +43428,14 @@ const STATUSLINE_COMPONENTS = [
42426
43428
  slRow({ id: "task-elapsed", group: "T", board: "subagent", label: "for", summary: "How long this agent has been running.", renderers: ["plain", "label-value", "bare"], defaults: { render: "plain", min_width: 3, align: "right", suffix: "m" }, binding: "task.elapsed_min", time_based: true }),
42427
43429
  slRow({ id: "task-what", group: "T", board: "subagent", summary: "What this agent was asked to do.", renderers: ["bare", "plain"], defaults: { render: "bare", truncate: "end", max_len: 40 }, unknown: "hide", binding: "task.description" }),
42428
43430
 
43431
+ // ── Group M — Pets and motion (v2.0.4). A pet is a SPRITE: the frames are
43432
+ // data in SL_SPRITES, and the engine only moves them along a track. A
43433
+ // pet counts against the per-line limit — it is a thing the line shows.
43434
+ ...Object.entries(SL_SPRITES).map(([id, sp]) => slRow({ id, group: "M", summary: sp.summary, renderers: ["run", "bounce", "idle"], defaults: { render: "run", width: 16 }, unknown: "hide", params: { speed: { options: ["slow", "normal", "fast"], dflt: "normal" } }, binding: "clock.now", time_based: true, sprite: id })),
43435
+ slRow({ id: "weather", group: "M", label: "", summary: "The temperature where you are. It is read from wttr.in in the background, at most every 30 minutes.", renderers: ["plain", "bare", "label-value"], defaults: { render: "plain" }, cost: "new-read", params: SL_WEATHER_PARAMS, binding: "weather.temp" }),
43436
+ slRow({ id: "weather-icon", group: "M", summary: "The sky where you are, as one shape.", renderers: ["shape", "icon", "word"], defaults: { render: "shape" }, states: ["sun", "partly", "cloud", "rain", "snow", "storm", "fog"], shapes: { sun: "☼", partly: "◐", cloud: "☁", rain: "☂", snow: "❄", storm: "ϟ", fog: "≋" }, ascii_shapes: { sun: "*", partly: "o", cloud: "c", rain: "r", snow: "s", storm: "!", fog: "=" }, cost: "new-read", params: SL_WEATHER_PARAMS, binding: "weather.state", state_binding: "weather.state" }),
43437
+ slRow({ id: "weather-desc", group: "M", summary: "The short weather text from the provider (24 cells or less).", renderers: ["bare", "plain"], defaults: { render: "bare", truncate: "end", max_len: 24 }, cost: "new-read", params: SL_WEATHER_PARAMS, binding: "weather.desc" }),
43438
+
42429
43439
  // ── Group J — Composite shortcuts. One slot, several facts. ─────────────
42430
43440
  slRow({ id: "tier-block", group: "J", summary: "Verdict + version + model/effort as one object.", renderers: ["plain", "badge", "pill"], defaults: { render: "pill" }, states: ["ready", "boosted", "degrade"], binding: "tier.text", state_binding: "verdict.state", composite: ["verdict", "orc-version", "tier"] }),
42431
43441
  slRow({ id: "quota-block", group: "J", label: "quota", summary: "Both windows as one object.", renderers: ["plain", "badge", "pill"], defaults: { render: "plain" }, states: ["ok", "warn", "critical"], binding: "quota.worst.pct", state_binding: "quota.worst.state", composite: ["quota-5h", "quota-week"] }),
@@ -42604,6 +43614,7 @@ function slResolveItem(item, layout, line) {
42604
43614
 
42605
43615
  out.id = item.id;
42606
43616
  out.type = item.type;
43617
+ out._theme = themeName;
42607
43618
  out._comp = comp;
42608
43619
  out._from = from;
42609
43620
  return out;
@@ -42706,6 +43717,8 @@ function slValidate(layout) {
42706
43717
  E(`"${r}" on "${comp.id}" — ${comp.id} has no states, so there is nothing for a shape to say.`);
42707
43718
  if (rend.needs === "series" && !comp.series)
42708
43719
  E(`"${r}" on "${comp.id}" — no history is kept for ${comp.id}. Series are kept for: ${slSeriesComponents().join(", ")}.`);
43720
+ if (rend.needs === "sprite" && !comp.sprite)
43721
+ E(`"${r}" on "${comp.id}" — only a pet moves along the line.`);
42709
43722
  if (rend.needs === "motif" && !comp.state_binding)
42710
43723
  E(`"${r}" on "${comp.id}" — motion is only for a component with a phase kind.`);
42711
43724
  if (rend.width && item.width != null) {
@@ -42745,8 +43758,17 @@ function slValidate(layout) {
42745
43758
  const given = item.params || {};
42746
43759
  for (const k of Object.keys(given)) {
42747
43760
  if (!comp.params[k]) E(`"${comp.id}" takes no parameter "${k}" — it takes: ${Object.keys(comp.params).join(", ")}`);
43761
+ else if (comp.params[k].options && !comp.params[k].options.map(String).includes(String(given[k])))
43762
+ E(`parameter "${k}" on ${where} takes one of: ${comp.params[k].options.join(", ")}`);
43763
+ else if (comp.params[k].max && String(given[k]).length > comp.params[k].max)
43764
+ E(`parameter "${k}" on ${where} is longer than ${comp.params[k].max} characters`);
43765
+ else if (comp.params[k].free && slCellWidth(given[k]) > SL_LIMITS.param_text)
43766
+ E(`parameter "${k}" on ${where} is longer than ${SL_LIMITS.param_text} cells`);
42748
43767
  }
43768
+ } else if (item.params && Object.keys(item.params).length) {
43769
+ E(`"${comp.id}" takes no parameters (${where})`);
42749
43770
  }
43771
+ slValidateDesign(item, comp, where, board, E);
42750
43772
 
42751
43773
  // ── Tier 3 — design. The rules a human gets wrong and a machine can
42752
43774
  // check. WARNINGS NEVER BLOCK: an error is a refusal, a warning is a
@@ -42765,12 +43787,21 @@ function slValidate(layout) {
42765
43787
  });
42766
43788
  });
42767
43789
 
43790
+ layout.lines.forEach((line, li) => {
43791
+ const where = `line ${li + 1}`;
43792
+ for (const k of ["separator", "prefix"])
43793
+ if (line[k] != null && slHasControl(line[k])) E(`the ${k} on ${where} holds a control character — a layout is printed to a terminal, so it may not carry one`);
43794
+ if (line.align != null && !SL_LINE_ALIGNS.includes(line.align)) E(`unknown line align "${line.align}" on ${where} — use: ${SL_LINE_ALIGNS.join(", ")}`);
43795
+ if (line.align != null && line.align !== "left" && board.id === "subagent") E(`line align on ${where} — the subagent row is one terminal row and cannot be aligned`);
43796
+ if (line.sep_color != null && !slParseColor(line.sep_color)) E(`"${line.sep_color}" is not a colour (separator colour on ${where})`);
43797
+ });
43798
+
42768
43799
  // Cross-item design warnings.
42769
43800
  layout.lines.forEach((line, li) => {
42770
43801
  const bindings = new Map();
42771
43802
  for (const it of line.items || []) {
42772
43803
  const c = SL_BY_ID.get(it.type);
42773
- if (!c || !c.binding) continue;
43804
+ if (!c || !c.binding || c.binding === "static.text") continue;
42774
43805
  bindings.set(c.binding, (bindings.get(c.binding) || 0) + 1);
42775
43806
  }
42776
43807
  for (const [b, n] of bindings) {
@@ -42787,6 +43818,81 @@ function slValidate(layout) {
42787
43818
  return { ok: errors.length === 0, errors, warnings };
42788
43819
  }
42789
43820
 
43821
+ // v2.0.4 — the design fields of ONE item. Every problem is collected, and each
43822
+ // sentence names the position.
43823
+ function slValidateDesign(item, comp, where, board, E) {
43824
+ const oneOf = (k, set) => {
43825
+ if (item[k] != null && !set.includes(item[k])) E(`unknown ${k} "${item[k]}" on ${where} — use: ${set.join(", ")}`);
43826
+ };
43827
+ oneOf("label_pos", SL_LABEL_POS);
43828
+ oneOf("value_pos", SL_VALUE_POS);
43829
+ oneOf("caption_pos", SL_CAPTION_POS);
43830
+ oneOf("caption_align", SL_CAPTION_ALIGN);
43831
+ oneOf("caption_case", SL_CASES);
43832
+ oneOf("align", SL_ALIGNS);
43833
+ oneOf("sign", SL_SIGNS);
43834
+ oneOf("unknown", SL_UNKNOWNS);
43835
+ // SECURITY: no control character in any free text.
43836
+ const texts = [["label", item.label], ["prefix", item.prefix], ["suffix", item.suffix], ["caption", item.caption], ["fill_char", item.fill_char], ["empty_char", item.empty_char]];
43837
+ for (const b of Array.isArray(item.brackets) ? item.brackets : []) texts.push(["brackets", b]);
43838
+ for (const g of Object.values(item.glyph_by_state || {})) texts.push(["glyph_by_state", g]);
43839
+ for (const [k, v] of Object.entries(item.params || {})) texts.push(["parameter " + k, v]);
43840
+ for (const [k, v] of texts)
43841
+ if (v != null && slHasControl(v)) E(`the ${k} on ${where} holds a control character — a layout is printed to a terminal, so it may not carry one`);
43842
+ if (item.caption != null && slCellWidth(item.caption) > SL_LIMITS.caption) E(`the caption on ${where} is wider than ${SL_LIMITS.caption} cells`);
43843
+ const oneCell = (k, v) => {
43844
+ if (v == null) return;
43845
+ if ([...String(v)].length !== 1 || slCellWidth(v) !== 1) E(`${k} "${v}" on ${where} must be one character of exactly 1 cell`);
43846
+ };
43847
+ oneCell("fill_char", item.fill_char);
43848
+ oneCell("empty_char", item.empty_char);
43849
+ for (const [st, g] of Object.entries(item.glyph_by_state || {})) {
43850
+ if (!comp.states || !comp.states.includes(st)) E(`glyph_by_state key "${st}" — "${comp.id}" has states: ${(comp.states || []).join(", ") || "none"}`);
43851
+ oneCell(`glyph_by_state ${st}`, g);
43852
+ }
43853
+ for (const [st, c] of Object.entries(item.color_by_state || {}))
43854
+ if (c != null && !slParseColor(c)) E(`"${c}" is not a colour (color_by_state ${st} on ${where})`);
43855
+ for (const k of ["caption_color", "fill_color", "empty_color"]) {
43856
+ const v = item[k];
43857
+ if (v == null || v === "none") continue;
43858
+ if (!slParseColor(v)) E(`"${v}" is not a colour (${k} on ${where})`);
43859
+ }
43860
+ if (item.ramp_colors != null) {
43861
+ const rc = item.ramp_colors;
43862
+ const [lo, hi] = SL_LIMITS.ramp_colors;
43863
+ if (!Array.isArray(rc) || rc.length < lo || rc.length > hi) E(`ramp_colors on ${where} takes ${lo} to ${hi} colours`);
43864
+ else for (const c of rc) if (!slParseColor(c) || /^ramp:/.test(c)) E(`"${c}" is not a colour (ramp_colors on ${where})`);
43865
+ }
43866
+ if (item.ramp_stops != null) {
43867
+ const st = item.ramp_stops;
43868
+ const ok = Array.isArray(st) && st.every((n, i) => typeof n === "number" && isFinite(n) && n >= 0 && n <= 100 && (i === 0 || n > st[i - 1]));
43869
+ if (!ok) E(`ramp_stops on ${where} must be ascending numbers from 0 to 100`);
43870
+ else if (Array.isArray(item.ramp_colors) && st.length !== item.ramp_colors.length) E(`ramp_stops on ${where} has ${st.length} stops for ${item.ramp_colors.length} colours — give one stop per colour`);
43871
+ }
43872
+ if (item.brackets != null) {
43873
+ const b = item.brackets;
43874
+ if (!Array.isArray(b) || b.length !== 2 || b.some((x) => typeof x !== "string" || slCellWidth(x) > SL_LIMITS.brackets))
43875
+ E(`brackets on ${where} take "L,R", each 0 to ${SL_LIMITS.brackets} cells`);
43876
+ }
43877
+ const num = (k, lo, hi) => {
43878
+ const v = item[k];
43879
+ if (v == null) return;
43880
+ if (typeof v !== "number" || !Number.isInteger(v) || v < lo || v > hi) E(`${k} on ${where} takes a whole number from ${lo} to ${hi}`);
43881
+ };
43882
+ num("threshold", 0, 100);
43883
+ num("max_len", 0, SL_LIMITS.max_len);
43884
+ if (item.padding != null) {
43885
+ const p = item.padding;
43886
+ if (!Array.isArray(p) || p.length !== 2 || p.some((n) => !Number.isInteger(n) || n < 0 || n > SL_LIMITS.padding))
43887
+ E(`padding on ${where} takes two whole numbers from 0 to ${SL_LIMITS.padding}`);
43888
+ }
43889
+ // The subagent row is ONE terminal row by construction: no caption rows.
43890
+ if (board.id === "subagent") {
43891
+ if (item.caption != null) E(`a caption on ${where} — the subagent row is one terminal row, so it has no caption row`);
43892
+ if (item.label_pos === "above" || item.label_pos === "below") E(`label_pos ${item.label_pos} on ${where} — the subagent row is one terminal row`);
43893
+ }
43894
+ }
43895
+
42790
43896
  // spacer / divider / fill do not count against the 5-per-line limit: the limit
42791
43897
  // is about how much a line SAYS, and none of the three says anything.
42792
43898
  function slIsStructural(type) {
@@ -42885,7 +43991,8 @@ function slCompile(layout) {
42885
43991
  : b.startsWith("wiki.") ? "scan.wiki"
42886
43992
  : b.startsWith("diy.") ? "scan.diy"
42887
43993
  : b.startsWith("update.") ? "scan.update"
42888
- : "scan.trace";
43994
+ : b.startsWith("weather.") ? "scan.weather"
43995
+ : "scan.trace";
42889
43996
  // W3's groups all read files the shipped status line never opens, so they
42890
43997
  // share one gate — the extended scan — and each sub-read inside it carries
42891
43998
  // its own TTL. One gate, many clocks.
@@ -42933,10 +44040,21 @@ function slCompile(layout) {
42933
44040
  // takes its separator with it. This is Starship's conditional group, and
42934
44041
  // it is what stops a dangling ` · · `. No user ever configures it.
42935
44042
  const children = [];
42936
- if (visible > 0 && !slIsStructural(item.type)) children.push(slLit(sep));
44043
+ const lowered = slLowerItem(r, comp, { formats, glyphsets, statemaps, ramps, idxOf, layout });
44044
+ // An empty `text` part lowers to nothing, and takes no separator with it.
44045
+ if (!lowered.length && comp.id === "text") return;
44046
+ const sepd = visible > 0 && !slIsStructural(item.type);
44047
+ if (sepd) {
44048
+ // The separator colour wraps the lit ONLY when the line sets one, so
44049
+ // an unchanged line compiles to unchanged bytes.
44050
+ const sc = line.sep_color ? slSgr(line.sep_color, null, null) : null;
44051
+ if (sc) children.push({ op: "sgr", s: sc });
44052
+ children.push(slLit(sep));
44053
+ if (sc) children.push({ op: "reset" });
44054
+ }
42937
44055
  visible++;
42938
44056
 
42939
- for (const op of slLowerItem(r, comp, { formats, glyphsets, statemaps, ramps, idxOf, layout })) children.push(op);
44057
+ for (const op of lowered) children.push(op);
42940
44058
 
42941
44059
  const hide = (r.hide_when || []).filter((h) => h !== "never");
42942
44060
  const itemOp = {
@@ -42959,6 +44077,11 @@ function slCompile(layout) {
42959
44077
  if (slIsStructural(item.type)) itemOp.s = 1;
42960
44078
  if (r.min_cols) itemOp.min_cols = r.min_cols;
42961
44079
  if (r.max_cols) itemOp.max_cols = r.max_cols;
44080
+ if (lowered.cap) {
44081
+ itemOp.cap = lowered.cap;
44082
+ if (sepd) itemOp.sw = slCellWidth(sep);
44083
+ }
44084
+ if (lowered.p) itemOp.p = lowered.p;
42962
44085
  ops.push(itemOp);
42963
44086
 
42964
44087
  // The responsive drop order: priority first (1 = keep longest), then
@@ -42966,23 +44089,27 @@ function slCompile(layout) {
42966
44089
  dropCandidates.push({ id: item.id, line: li, pos: pi, priority: r.priority || 3, min_cols: r.min_cols || 0 });
42967
44090
  });
42968
44091
 
42969
- lines.push({ prefix: line.prefix || "", separator: sep, ops, static_width: slMeasure(ops, { formats, glyphsets, statemaps }) });
44092
+ const out = { prefix: line.prefix || "", separator: sep, ops, static_width: slMeasure(ops, { formats, glyphsets, statemaps }) };
44093
+ if (line.align === "center" || line.align === "right") out.align = line.align;
44094
+ lines.push(out);
42970
44095
  });
42971
44096
 
42972
44097
  // S5 — the responsive plans. Derived from each item's min_cols and priority;
42973
44098
  // the hook does ONE lookup and no measuring at render time.
42974
44099
  dropCandidates.sort((a, b) => b.priority - a.priority || b.pos - a.pos);
42975
44100
  const widths = [160, 120, 100, 80, 72];
42976
- const totalStatic = lines.reduce((m, l) => Math.max(m, l.static_width), 0);
44101
+ // PER LINE: only a line wider than w loses parts, and only its own parts.
42977
44102
  for (const w of widths) {
42978
- if (totalStatic <= w) continue;
42979
44103
  const drop = [];
42980
- let est = totalStatic;
42981
- for (const c of dropCandidates) {
42982
- if (est <= w) break;
42983
- drop.push(c.id);
42984
- est -= 8; // a conservative per-item estimate; the exact width is measured
42985
- }
44104
+ lines.forEach((l, li) => {
44105
+ let est = l.static_width;
44106
+ for (const c of dropCandidates) {
44107
+ if (est <= w) break;
44108
+ if (c.line !== li) continue;
44109
+ drop.push(c.id);
44110
+ est -= 8; // a conservative per-item estimate; the exact width is measured
44111
+ }
44112
+ });
42986
44113
  if (drop.length) plans.push({ cols: w, drop });
42987
44114
  }
42988
44115
  plans.sort((a, b) => a.cols - b.cols);
@@ -43019,7 +44146,8 @@ function slCompile(layout) {
43019
44146
  bindings: [...bindings].sort(),
43020
44147
  providers: [...providers].sort(),
43021
44148
  series: [...seriesWanted].sort(),
43022
- needs_refresh_interval: timeBased ? 5 : null,
44149
+ // Motion (a sprite, a motif, a pulse) needs the timer at its fastest.
44150
+ needs_refresh_interval: slHasMotion(compiled) ? 1 : timeBased ? 5 : null,
43023
44151
  // THE CAP TRAVELS WITH THE PROGRAM (v1.4.2). A hook cannot call this CLI —
43024
44152
  // a status line re-renders on every keystroke — so before this it simply
43025
44153
  // held its own `5`, and raising the cap here left every legal six-part line
@@ -43032,12 +44160,33 @@ function slCompile(layout) {
43032
44160
  return { compiled, lock };
43033
44161
  }
43034
44162
 
44163
+ // Whether a compiled program MOVES: a sprite, a motif or a pulse op anywhere.
44164
+ function slHasMotion(compiled) {
44165
+ const walk = (list) => (list || []).some((o) => o && (o.op === "sprite" || o.op === "motif" || walk(o.children) || (o.cap && walk(o.cap.ops))));
44166
+ return ((compiled && compiled.lines) || []).some((l) => walk(l.ops));
44167
+ }
44168
+
43035
44169
  function slLowerItem(r, comp, T) {
43036
44170
  const ops = [];
43037
44171
  const rend = STATUSLINE_RENDERERS[r.render] || STATUSLINE_RENDERERS.bare;
43038
- const label = r.label == null ? null : String(r.label);
44172
+ const rawLabel = r.label == null ? null : String(r.label);
44173
+ const from = r._from || {};
44174
+ // v2.0.4 — a label the ITEM moved. Only an authored position moves it, so
44175
+ // an existing layout compiles to the same bytes.
44176
+ // A non-text shape with an item-authored name and no authored position
44177
+ // draws the name before the shape — the Name box must not be a no-op.
44178
+ const shapeKind = rend.kind === "bar" || rend.kind === "series" || rend.kind === "state" || rend.kind === "motif" || rend.kind === "sprite";
44179
+ const lpos = from.label_pos === "item" ? r.label_pos : shapeKind && from.label === "item" && rawLabel ? "before" : null;
44180
+ const label = lpos && lpos !== "before" ? null : rawLabel;
43039
44181
  const caseOf = r.case || "none";
43040
- const applyCase = (s) => (caseOf === "upper" ? s.toUpperCase() : caseOf === "lower" ? s.toLowerCase() : caseOf === "title" ? s.replace(/\b\w/g, (m) => m.toUpperCase()) : s);
44182
+ const applyCase = (s) => slCaseText(s, caseOf);
44183
+ // A styled lit keeps its PLAIN text as the ASCII twin: `ᴄᴛx` falls back to
44184
+ // `ctx`, never to `???`.
44185
+ const styledLit = (plain, c) => {
44186
+ const t = slCaseText(plain, c);
44187
+ const a = slAsciiOf(slCaseText(plain, ["small", "super", "sub"].includes(c) ? "none" : c));
44188
+ return a === t ? { op: "lit", t } : { op: "lit", t, a };
44189
+ };
43041
44190
 
43042
44191
  const fmt = {
43043
44192
  kind: r.format || "plain",
@@ -43054,19 +44203,43 @@ function slLowerItem(r, comp, T) {
43054
44203
  prefix: r.prefix ? String(r.prefix) : "",
43055
44204
  suffix: r.suffix ? String(r.suffix) : "",
43056
44205
  };
44206
+ // v2.0.4 — a weather part carries WHAT to fetch; the temperature takes its
44207
+ // unit suffix from `units` unless the item set its own suffix.
44208
+ const isWeather = /^weather\./.test(comp.binding || "");
44209
+ const wp = isWeather ? { location: String(((r.params || {}).location != null ? r.params.location : comp.params.location.dflt)), units: String((r.params || {}).units || comp.params.units.dflt) } : null;
44210
+ if (comp.id === "weather" && from.suffix !== "item") fmt.suffix = wp.units === "us" ? "°F" : "°C";
43057
44211
  const fi = T.idxOf(T.formats, fmt);
43058
- const gs = STATUSLINE_GLYPHSETS[r.glyphs] || STATUSLINE_GLYPHSETS.blocks;
44212
+ let gs = STATUSLINE_GLYPHSETS[r.glyphs] || STATUSLINE_GLYPHSETS.blocks;
44213
+ // v2.0.4 — the user's own fill and empty cells, on a CLONE. The ASCII twins
44214
+ // keep their ASCII form.
44215
+ if (r.fill_char != null || r.empty_char != null) {
44216
+ gs = Object.assign({}, gs);
44217
+ if (r.fill_char != null) for (const k of ["fill", "meter_on", "on", "braille_fill"]) gs[k] = String(r.fill_char);
44218
+ if (r.empty_char != null) for (const k of ["empty", "meter_off", "off", "braille_empty", "track"]) gs[k] = String(r.empty_char);
44219
+ }
43059
44220
  const gi = T.idxOf(T.glyphsets, gs);
43060
44221
 
43061
44222
  const labelSgr = slSgr(r.label_color, r.emphasis, r.bg);
43062
44223
  const valueSgr = r.value_color && String(r.value_color).startsWith("ramp:") ? null : slSgr(r.value_color, r.emphasis, r.bg);
44224
+ // Emphasis alone, for a value op that carries its own colour.
44225
+ const emphSgr = slSgr(null, r.emphasis, r.bg);
43063
44226
  let ri = null;
43064
- const rampName = r.ramp || (String(r.value_color || "").startsWith("ramp:") ? String(r.value_color).slice(5) : null);
44227
+ const vFrom = (r._from || {}).value_color || "";
44228
+ const itemColor = (vFrom === "item" || vFrom === "item:color") && r.value_color && !String(r.value_color).startsWith("ramp:");
44229
+ const rampFromItem = (r._from || {}).ramp === "item";
44230
+ // A colour the user set beats a ramp the user did not set. Mono means no
44231
+ // colour, so a default ramp does not apply there either.
44232
+ let rampName = r.ramp && (rampFromItem || (!itemColor && r._theme !== "mono")) ? r.ramp : null;
44233
+ if (!rampName && String(r.value_color || "").startsWith("ramp:")) rampName = String(r.value_color).slice(5);
43065
44234
  if (rampName && STATUSLINE_RAMPS[rampName] && rampName !== "state") {
43066
44235
  const rp = STATUSLINE_RAMPS[rampName];
43067
44236
  const stops = r.ramp_stops || rp.stops;
43068
44237
  ri = T.idxOf(T.ramps, { stops, sgr: rp.colors.map((c, i) => slSgr(c, rp.emphasis ? [rp.emphasis[i]] : null, null) || "") });
43069
44238
  }
44239
+ // v2.0.4 — the item's own ramp. It is used wherever a ramp would be.
44240
+ const rampColors = Array.isArray(r.ramp_colors) && r.ramp_colors.length >= 2 ? r.ramp_colors.map(String) : null;
44241
+ const rampStops = rampColors ? (Array.isArray(r.ramp_stops) && r.ramp_stops.length === rampColors.length ? r.ramp_stops : rampColors.map((_, i) => Math.round((i * 100) / rampColors.length))) : null;
44242
+ if (rampColors) ri = T.idxOf(T.ramps, { stops: rampStops, sgr: rampColors.map((c) => slSgr(c, null, null) || "") });
43070
44243
 
43071
44244
  // The state map: glyph, ASCII twin and colour per state. Built ONCE here so
43072
44245
  // the `state` renderer and the `word` renderer can never disagree about what
@@ -43074,8 +44247,8 @@ function slLowerItem(r, comp, T) {
43074
44247
  const stateMap = () => {
43075
44248
  const map = { glyphs: {}, sgr: {}, ascii: {} };
43076
44249
  for (const st of comp.states || []) {
43077
- map.glyphs[st] = (comp.shapes && comp.shapes[st]) || st.slice(0, 1);
43078
- map.ascii[st] = slAsciiOf(map.glyphs[st]);
44250
+ map.glyphs[st] = (r.glyph_by_state && r.glyph_by_state[st]) || (comp.shapes && comp.shapes[st]) || st.slice(0, 1);
44251
+ map.ascii[st] = !(r.glyph_by_state && r.glyph_by_state[st]) && comp.ascii_shapes && comp.ascii_shapes[st] ? comp.ascii_shapes[st] : slAsciiOf(map.glyphs[st]);
43079
44252
  const c = (r.color_by_state && r.color_by_state[st]) || slStateColor(st, r, T.layout);
43080
44253
  const sg = slSgr(c, r.emphasis, r.bg);
43081
44254
  if (sg) map.sgr[st] = sg;
@@ -43094,10 +44267,65 @@ function slLowerItem(r, comp, T) {
43094
44267
  const pushLabel = (text, sep) => {
43095
44268
  if (!text) return;
43096
44269
  if (labelSgr) ops.push({ op: "sgr", s: labelSgr });
43097
- ops.push(slLit(applyCase(text) + sep));
44270
+ ops.push(styledLit(text + sep, caseOf));
44271
+ if (labelSgr) ops.push({ op: "reset" });
44272
+ };
44273
+ // The same label, drawn beside a non-text shape or after a text value. A
44274
+ // separate name from `pushLabel`, which mirrors SL_LABEL_RENDERERS.
44275
+ const sideLabel = (text, before, after) => {
44276
+ if (labelSgr) ops.push({ op: "sgr", s: labelSgr });
44277
+ ops.push(styledLit(before + text + after, caseOf));
43098
44278
  if (labelSgr) ops.push({ op: "reset" });
43099
44279
  };
43100
44280
 
44281
+ // v2.0.4 — THE CAPTION: ordinary ops in their own row, above or below.
44282
+ const capOps = [];
44283
+ const capLabel = lpos === "above" || lpos === "below";
44284
+ if (capLabel && rawLabel) {
44285
+ if (labelSgr) capOps.push({ op: "sgr", s: labelSgr });
44286
+ capOps.push(styledLit(rawLabel, caseOf));
44287
+ if (labelSgr) capOps.push({ op: "reset" });
44288
+ }
44289
+ if (r.caption != null && String(r.caption) !== "") {
44290
+ const cc = r.caption_case || "none";
44291
+ const csg = slSgr(r.caption_color, null, null);
44292
+ if (capOps.length) capOps.push(slLit(" "));
44293
+ if (csg) capOps.push({ op: "sgr", s: csg });
44294
+ for (const part of String(r.caption).split(/(\{value\}|\{label\})/)) {
44295
+ if (!part) continue;
44296
+ if (part === "{value}") capOps.push(Object.assign({ op: "val", b: comp.binding, f: cc === "none" ? fi : T.idxOf(T.formats, Object.assign({}, fmt, { case: cc })) }, wp ? { p: wp } : {}));
44297
+ else capOps.push(styledLit(part === "{label}" ? rawLabel || "" : part, cc));
44298
+ }
44299
+ if (csg) capOps.push({ op: "reset" });
44300
+ }
44301
+ if (capOps.length) ops.cap = { pos: capLabel ? lpos : r.caption_pos || "below", align: r.caption_align || "left", ops: capOps };
44302
+
44303
+ // v2.0.4 — the static parts draw their own text. They bound `static.text`,
44304
+ // which is null, so before this they drew nothing at all.
44305
+ if (comp.binding === "static.text" && !comp.children) {
44306
+ const prm = r.params || {};
44307
+ const dflt = (k) => (comp.params && comp.params[k] ? comp.params[k].dflt : undefined);
44308
+ if (comp.id === "fill") {
44309
+ ops.push({ op: "flex", w: Number(prm.weight != null ? prm.weight : dflt("weight")) || 1, min: 1 });
44310
+ return ops;
44311
+ }
44312
+ let t = "";
44313
+ if (comp.id === "text") t = String(prm.text != null ? prm.text : dflt("text") || "");
44314
+ else if (comp.id === "divider") t = String(prm.text != null ? prm.text : dflt("text"));
44315
+ else if (comp.id === "spacer") t = " ".repeat(Number(prm.width != null ? prm.width : dflt("width")) || 1);
44316
+ else if (comp.id === "icon-static") t = String(prm.glyph != null ? prm.glyph : dflt("glyph"));
44317
+ if (!t) return ops;
44318
+ const sb = r.brackets || rend.brackets || null;
44319
+ if (sb && sb[0]) ops.push(slLit(sb[0]));
44320
+ if (valueSgr) ops.push({ op: "sgr", s: valueSgr });
44321
+ ops.push(styledLit(t, caseOf));
44322
+ if (valueSgr) ops.push({ op: "reset" });
44323
+ if (sb && sb[1]) ops.push(slLit(sb[1]));
44324
+ return ops;
44325
+ }
44326
+ if (comp.id === "config") ops.p = { key: String((r.params && r.params.key) || comp.params.key.dflt) };
44327
+ if (wp) ops.p = wp;
44328
+
43101
44329
  // A GROUP: its children are lowered inside its brackets and share its
43102
44330
  // background, so a run of chips reads as one object. It is the one place an
43103
44331
  // item op has an item op inside it, and the nesting stops there.
@@ -43117,11 +44345,39 @@ function slLowerItem(r, comp, T) {
43117
44345
  return ops;
43118
44346
  }
43119
44347
 
43120
- if (open) ops.push(slLit(open));
44348
+ const isPill = r.render === "pill" && !!label;
44349
+ if (open && !isPill) ops.push(slLit(open));
44350
+
44351
+ // v2.0.4 — a bar's own cell colours. `gc` (gradient, all-hex ramp) beats
44352
+ // `fs`/`es`, which beat the ramp; the engine applies that order.
44353
+ const barExtras = (bop) => {
44354
+ const fs = r.fill_color ? slSgr(r.fill_color, null, null) : null;
44355
+ const es = r.empty_color ? slSgr(r.empty_color, null, null) : null;
44356
+ if (fs) bop.fs = fs;
44357
+ if (es) bop.es = es;
44358
+ if (bop.k === "gradient" && rampColors && rampColors.every((c) => /^#[0-9a-fA-F]{6}$/.test(c))) {
44359
+ const rgb = rampColors.map((c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)));
44360
+ const hex = (n) => Math.round(n).toString(16).padStart(2, "0");
44361
+ bop.gc = [];
44362
+ for (let i = 0; i < bop.w; i++) {
44363
+ const pos = ((i + 1) / bop.w) * 100;
44364
+ let k = 0;
44365
+ while (k < rampStops.length - 2 && pos > rampStops[k + 1]) k++;
44366
+ const span = rampStops[k + 1] - rampStops[k];
44367
+ const t = Math.max(0, Math.min(1, span > 0 ? (pos - rampStops[k]) / span : 1));
44368
+ const c = rgb[k].map((v, j) => v + (rgb[k + 1][j] - v) * t);
44369
+ bop.gc.push(slSgr("#" + c.map(hex).join(""), null, null));
44370
+ }
44371
+ }
44372
+ return bop;
44373
+ };
43121
44374
 
43122
44375
  const valueOp = () => {
43123
44376
  if (rend.kind === "bar") {
43124
- return { op: "bar", b: comp.binding, k: rend.sub, g: gi, w: r.width || rend.dflt_w || 10, r: ri, thr: r.threshold == null ? null : r.threshold };
44377
+ const bop = { op: "bar", b: comp.binding, k: rend.sub, g: gi, w: r.width || rend.dflt_w || 10, r: ri, thr: r.threshold == null ? null : r.threshold };
44378
+ // A state component on a bar: the engine maps the state to a percent.
44379
+ if (comp.states && !comp.bounded) bop.o = comp.states.filter((s) => s !== "unknown");
44380
+ return barExtras(bop);
43125
44381
  }
43126
44382
  if (rend.kind === "series") {
43127
44383
  return { op: "series", b: comp.binding, s: comp.series, k: rend.sub, g: gi, w: r.width || rend.dflt_w || 8 };
@@ -43132,11 +44388,20 @@ function slLowerItem(r, comp, T) {
43132
44388
  }
43133
44389
  if (rend.kind === "state") {
43134
44390
  const map = stateMap();
43135
- return { op: "state", b: comp.state_binding || comp.binding, m: T.idxOf(T.statemaps, map) };
44391
+ const st = { op: "state", b: comp.state_binding || comp.binding, m: T.idxOf(T.statemaps, map) };
44392
+ if (wp) st.p = wp;
44393
+ return st;
44394
+ }
44395
+ if (rend.kind === "sprite") {
44396
+ const sp = SL_SPRITES[comp.sprite];
44397
+ const k = SL_SPRITE_SPEED[(r.params || {}).speed] || 1;
44398
+ return { op: "sprite", w: r.width || rend.dflt_w, mode: rend.sub, ms: Math.round(sp.ms * k), f: sp.f.slice(), a: sp.a.slice(), ahead: sp.ahead, behind: sp.behind, ...(/[^ -~]/.test(sp.ahead || "") && sp.ahead_a != null ? { ahead_a: sp.ahead_a } : {}), ...(/[^ -~]/.test(sp.behind || "") && sp.behind_a != null ? { behind_a: sp.behind_a } : {}) };
43136
44399
  }
43137
44400
  // text
43138
44401
  const op = { op: "val", b: comp.binding, f: fi };
43139
44402
  if (r.render === "word" && comp.state_binding) op.b = comp.state_binding;
44403
+ if (comp.id === "clock") op.tf = (r.params && r.params.format) || comp.params.format.dflt;
44404
+ if (ops.p) op.p = ops.p;
43140
44405
  // A ramp derives the colour FROM THE VALUE, so it applies to a number just
43141
44406
  // as much as to a bar — `context 38%` green and `context 94%` red is the
43142
44407
  // whole point of one.
@@ -43152,13 +44417,52 @@ function slLowerItem(r, comp, T) {
43152
44417
  // A state, a motif, a ramped bar and a ramped/state-coloured number all
43153
44418
  // carry their OWN colour. Wrapping them in the theme's value colour as well
43154
44419
  // emits two sequences for one glyph, and the outer one is the wrong answer.
44420
+ const own = inner.op === "state" || inner.op === "motif" || (inner.op === "bar" && inner.r != null) ||
44421
+ (inner.op === "val" && (inner.r != null || inner.m != null)) || (inner.op === "bar" && (inner.fs || inner.es || inner.gc));
44422
+ if (own) return emphSgr ? [{ op: "sgr", s: emphSgr }, inner, { op: "reset" }] : [inner];
44423
+ // v2.0.4 — a fill colour paints the whole spark.
44424
+ if (inner.op === "series" && r.fill_color) {
44425
+ const ss = slSgr(r.fill_color, r.emphasis, r.bg);
44426
+ if (ss) return [{ op: "sgr", s: ss }, inner, { op: "reset" }];
44427
+ }
43155
44428
  if (!valueSgr) return [inner];
43156
- if (inner.op === "state" || inner.op === "motif") return [inner];
43157
- if (inner.op === "bar" && inner.r != null) return [inner];
43158
- if (inner.op === "val" && (inner.r != null || inner.m != null)) return [inner];
43159
44429
  return [{ op: "sgr", s: valueSgr }, inner, { op: "reset" }];
43160
44430
  };
43161
44431
 
44432
+ // v2.0.4 §7 — a NON-TEXT shape with decorations the ITEM authored:
44433
+ // [label before] [prefix] [value before] SHAPE [value after] [suffix] [label after]
44434
+ // Nothing here runs for a field the item did not set, so an existing layout
44435
+ // compiles to the same bytes.
44436
+ const vpos = from.value_pos === "item" ? r.value_pos : null;
44437
+ const dPre = from.prefix === "item" && r.prefix ? String(r.prefix) : "";
44438
+ const dSuf = from.suffix === "item" && r.suffix ? String(r.suffix) : "";
44439
+ const nonText = rend.kind === "bar" || rend.kind === "series" || rend.kind === "state" || rend.kind === "motif" || rend.kind === "sprite";
44440
+ if (nonText && ((lpos && rawLabel && !capLabel) || dPre || dSuf || vpos === "before" || vpos === "after")) {
44441
+ // The value beside a shape carries neither prefix nor suffix: they are
44442
+ // drawn once, around the whole part.
44443
+ const bareF = T.idxOf(T.formats, Object.assign({}, fmt, { prefix: "", suffix: "" }));
44444
+ const val = () => wrapValue({ op: "val", b: comp.binding, f: bareF });
44445
+ if (lpos === "before" && rawLabel) sideLabel(rawLabel, "", " ");
44446
+ if (dPre) ops.push(slLit(dPre));
44447
+ if (vpos === "before") {
44448
+ for (const o of val()) ops.push(o);
44449
+ ops.push(slLit(" "));
44450
+ }
44451
+ if (r.render === "bar") {
44452
+ for (const o of wrapValue({ op: "val", b: comp.binding, f: bareF })) ops.push(o);
44453
+ ops.push(slLit(" "));
44454
+ ops.push(barExtras({ op: "bar", b: comp.binding, k: "blocks", g: gi, w: r.width || 10, r: ri, thr: null }));
44455
+ } else for (const o of wrapValue(valueOp())) ops.push(o);
44456
+ if (vpos === "after") {
44457
+ ops.push(slLit(" "));
44458
+ for (const o of val()) ops.push(o);
44459
+ }
44460
+ if (dSuf) ops.push(slLit(dSuf));
44461
+ if (lpos === "after" && rawLabel) sideLabel(rawLabel, " ", "");
44462
+ if (close && !isPill) ops.push(slLit(close));
44463
+ return ops;
44464
+ }
44465
+
43162
44466
  switch (r.render) {
43163
44467
  case "bare":
43164
44468
  case "word":
@@ -43207,9 +44511,11 @@ function slLowerItem(r, comp, T) {
43207
44511
  // The two-tone chip: the label in reverse video, the value outside it.
43208
44512
  if (label) {
43209
44513
  const chip = slSgr(r.label_color, (r.emphasis || []).concat(["reverse"]), r.bg);
44514
+ if (open) ops.push(slLit(open));
43210
44515
  if (chip) ops.push({ op: "sgr", s: chip });
43211
44516
  ops.push(slLit(applyCase(label)));
43212
44517
  if (chip) ops.push({ op: "reset" });
44518
+ if (close) { ops.push(slLit(close)); }
43213
44519
  }
43214
44520
  for (const o of wrapValue(valueOp())) ops.push(o);
43215
44521
  break;
@@ -43224,7 +44530,7 @@ function slLowerItem(r, comp, T) {
43224
44530
  // The user's own worked example: number THEN bar.
43225
44531
  for (const o of wrapValue({ op: "val", b: comp.binding, f: fi })) ops.push(o);
43226
44532
  ops.push(slLit(" "));
43227
- ops.push({ op: "bar", b: comp.binding, k: "blocks", g: gi, w: r.width || 10, r: ri, thr: null });
44533
+ ops.push(barExtras({ op: "bar", b: comp.binding, k: "blocks", g: gi, w: r.width || 10, r: ri, thr: null }));
43228
44534
  break;
43229
44535
  }
43230
44536
  case "link": {
@@ -43237,7 +44543,9 @@ function slLowerItem(r, comp, T) {
43237
44543
  for (const o of wrapValue(valueOp())) ops.push(o);
43238
44544
  }
43239
44545
 
43240
- if (close) ops.push(slLit(close));
44546
+ // v2.0.4 — label_pos after on a text shape: the value, then the label.
44547
+ if (lpos === "after" && rawLabel) sideLabel(rawLabel, " ", "");
44548
+ if (close && !isPill) ops.push(slLit(close));
43241
44549
  return ops;
43242
44550
  }
43243
44551
 
@@ -43355,6 +44663,12 @@ function slMeasure(ops, T) {
43355
44663
  case "motif":
43356
44664
  w += 1;
43357
44665
  break;
44666
+ case "sprite":
44667
+ w += op.w || 16;
44668
+ break;
44669
+ case "flex":
44670
+ w += op.min || 1;
44671
+ break;
43358
44672
  case "item":
43359
44673
  w += (op.pad_l || 0) + (op.pad_r || 0);
43360
44674
  walk(op.children || []);
@@ -43424,6 +44738,25 @@ const STATUSLINE_PRESETS = {
43424
44738
  [],
43425
44739
  ],
43426
44740
  },
44741
+ // v2.0.4 — the design powers, all on one board.
44742
+ designer: {
44743
+ summary: "Shows what a part can draw: labels beside a bar, a caption under it, a colour gradient, your own bar cells, and a clock at the right edge.",
44744
+ build: () => [
44745
+ [
44746
+ slItem("text", { params: { text: "ORC" }, emphasis: ["bold"] }),
44747
+ slItem("quota-5h", { render: "bar-only", width: 8, label: "5h", label_pos: "before", value_pos: "after" }),
44748
+ slItem("context", { render: "gradient", width: 10, ramp_colors: ["#3fb950", "#d29922", "#f85149"], caption: "context", caption_case: "small" }),
44749
+ slItem("fill"),
44750
+ slItem("clock"),
44751
+ ],
44752
+ [
44753
+ slItem("quota-week", { render: "meter", width: 8, fill_char: "▰", empty_char: "▱", label: "wk", label_pos: "before" }),
44754
+ slItem("tier"),
44755
+ slItem("branch"),
44756
+ ],
44757
+ [],
44758
+ ],
44759
+ },
43427
44760
  // ── the second board (v1.4.0) ──────────────────────────────────────────
43428
44761
  "agent-default": {
43429
44762
  board: "subagent",
@@ -43623,13 +44956,13 @@ const SL_FIXTURES = {
43623
44956
  prompt_cache: { warm: true, ttl: "5m", hit_ratio: 0.91, requests: 214, misses: 19, cache_write_tokens: 48000, recache_tokens_if_cold: 45000 },
43624
44957
  workspace: { project_dir: "/repo", repo: { host: "github.com", owner: "azure-id", name: "orc" } },
43625
44958
  },
43626
- ledger: { five_hour: { baseline: 55, last: 61, accumulated: 0, resets: 0 }, tok: { input: 12000, cache_write: 40000, cache_read: 350000, output: 10000 }, series: { quota5h: [55, 56, 58, 59, 60, 61], ucs: [0, 1, 2, 4, 5, 6], mtok: [10, 40, 120, 260, 380, 412] } },
43627
- scan: { spawns: 7, running: 2, lanes: ["quick"], phase: { lane: "quick", label: "Q3 DO", kind: "do" }, branch: "main", inflight: "clear", wiki: { tier: "fresh", distance: 3 }, extra_enabled: true, trace_age_min: 0, trace_state: "live" },
44959
+ ledger: { five_hour: { baseline: 55, last: 61, accumulated: 0, resets: 0 }, tok: { input: 12000, cache_write: 40000, cache_read: 350000, output: 10000 }, series: { quota5h: [55, 56, 58, 59, 60, 61], ucs: [0, 1, 2, 4, 5, 6], mtok: [10, 40, 120, 260, 380, 412], agents: [0, 1, 3, 2, 2, 2], burn: [0.8, 1.1, 1.4, 1.2, 1.3, 1.5], cost: [0.05, 0.12, 0.2, 0.28, 0.35, 0.42], cachehit: [62, 74, 81, 86, 89, 91], cachewrite: [12000, 20000, 28000, 36000, 42000, 48000], lines: [40, 120, 180, 240, 290, 324], extraspend: [0, 0.02, 0.04, 0.07, 0.09, 0.11], speed: [38, 52, 47, 61, 55, 58], mtokkind: [8, 30, 90, 200, 300, 340], quotawk: [15, 15, 16, 17, 17, 18] } },
44960
+ scan: { spawns: 7, running: 2, lanes: ["quick"], phase: { lane: "quick", label: "Q3 DO", kind: "do" }, branch: "main", inflight: "clear", wiki: { tier: "fresh", distance: 3 }, extra_enabled: true, trace_age_min: 0, trace_state: "live", weather: { temp_c: 18, temp_f: 64, state: "partly", desc: "Partly cloudy" } },
43628
44961
  },
43629
44962
  degraded: {
43630
44963
  label: "the wrong tier, a full window, and nothing running",
43631
44964
  payload: {
43632
- model: { id: "claude-sonnet-5", display_name: "Sonnet 5" },
44965
+ model: { id: "claude-sonnet-5-5", display_name: "Sonnet 5" },
43633
44966
  effort: { level: "low" },
43634
44967
  context_window: { used_percentage: 94, remaining_percentage: 6 },
43635
44968
  rate_limits: { five_hour: { used_percentage: 93, resets_at: null }, seven_day: { used_percentage: 88, resets_at: null } },
@@ -43682,7 +45015,7 @@ function slDerived(payload) {
43682
45015
  return { verdict, reasons, version: currentVersion() };
43683
45016
  }
43684
45017
 
43685
- function slPreview(compiled, fixtureName, cols, overrides) {
45018
+ function slPreview(compiled, fixtureName, cols, overrides, at) {
43686
45019
  const fx = SL_FIXTURES[fixtureName] || SL_FIXTURES.healthy;
43687
45020
  const payload = JSON.parse(JSON.stringify(fx.payload));
43688
45021
  for (const [k, v] of Object.entries(overrides || {})) slDeepSet(payload, k, v);
@@ -43693,7 +45026,8 @@ function slPreview(compiled, fixtureName, cols, overrides) {
43693
45026
  scan: fx.scan || {},
43694
45027
  derived: slDerived(payload),
43695
45028
  task: SL_TASK_FIXTURES[fixtureName] || SL_TASK_FIXTURES.healthy,
43696
- now,
45029
+ // `at` moves ONLY the clock, so an animated preview draws the same session.
45030
+ now: at || now,
43697
45031
  cols: cols || 0,
43698
45032
  env: Object.assign({}, process.env, { COLUMNS: String(cols || 120) }),
43699
45033
  };
@@ -43755,6 +45089,8 @@ function statusline() {
43755
45089
  return slCloneCmd(claudeDir, p);
43756
45090
  case "compile":
43757
45091
  return slCompileCmd(claudeDir, true);
45092
+ case "refresh":
45093
+ return slRefreshCmd(claudeDir, p);
43758
45094
  default:
43759
45095
  console.error(`usage: orc statusline components|show|explain|validate|preview [--json]
43760
45096
  orc statusline set <line> <pos> <type> [--render R] [--label L] [--color C] [--width N] …
@@ -43766,6 +45102,8 @@ function statusline() {
43766
45102
  orc statusline expand <line>:<pos> a composite, or a group, back into its parts
43767
45103
  orc statusline clone <line>:<pos>
43768
45104
  orc statusline presets [--json] | apply <name> | reset | compile
45105
+ orc statusline refresh <off|1|2|3|5|10|N> the refresh timer in settings.json (1 s floor)
45106
+ orc statusline preview --frames N [--frame-ms M] an animated preview
43769
45107
 
43770
45108
  COMPOSE THIS IN \`orc ui\` ▸ CLI Hook Interface. These flags exist so the panel
43771
45109
  has something to shell — typing a three-line layout is worse than the board.`);
@@ -43822,7 +45160,9 @@ function slComponentsCmd() {
43822
45160
  renderers: Object.fromEntries(Object.entries(STATUSLINE_RENDERERS).map(([k, v]) => [k, { kind: v.kind, form: v.form || null, needs: v.needs, width: v.width || null, decoration: !!v.decoration, uses: slRendererUses(k) }])),
43823
45161
  // Which shapes DO draw a label, so a refusal can name them instead of
43824
45162
  // saying "not here".
43825
- label_renderers: [...SL_LABEL_RENDERERS],
45163
+ label_renderers: [...SL_LABEL_RENDERERS],
45164
+ // The value that clears a colour back to the shared one.
45165
+ inherit_token: "inherit",
43826
45166
  glyph_sets: Object.keys(STATUSLINE_GLYPHSETS),
43827
45167
  ramps: Object.fromEntries(Object.entries(STATUSLINE_RAMPS).map(([k, v]) => [k, { stops: v.stops, colors: v.colors, why: v.why }])),
43828
45168
  themes: STATUSLINE_THEMES,
@@ -43830,6 +45170,18 @@ function slComponentsCmd() {
43830
45170
  formats: SL_FORMATS,
43831
45171
  compact: SL_COMPACT,
43832
45172
  cases: SL_CASES,
45173
+ // v2.0.4 — what each case looks like, and the other closed sets.
45174
+ case_samples: Object.fromEntries(SL_CASES.map((c) => [c, slCaseText("Weekly 61", c)])),
45175
+ label_positions: SL_LABEL_POS,
45176
+ value_positions: SL_VALUE_POS,
45177
+ caption_positions: SL_CAPTION_POS,
45178
+ caption_aligns: SL_CAPTION_ALIGN,
45179
+ aligns: SL_ALIGNS,
45180
+ signs: SL_SIGNS,
45181
+ unknowns: SL_UNKNOWNS,
45182
+ line_aligns: SL_LINE_ALIGNS,
45183
+ caption_tokens: ["{value}", "{label}"],
45184
+ limits: SL_LIMITS,
43833
45185
  truncate: SL_TRUNCATE,
43834
45186
  emphasis: Object.keys(SL_EMPHASIS),
43835
45187
  refused_emphasis: SL_REFUSED_EMPHASIS,
@@ -43877,6 +45229,7 @@ const SL_GROUP_NAMES = {
43877
45229
  H: "Project and VCS",
43878
45230
  I: "Static and structural",
43879
45231
  J: "Composite shortcuts",
45232
+ M: "Pets and motion",
43880
45233
  };
43881
45234
 
43882
45235
  // A dropdown of the word `braille-bar` tells nobody anything, so every renderer
@@ -43911,11 +45264,17 @@ const SL_ITEM_FIELDS = [
43911
45264
  "glyphs", "format", "case", "truncate", "compact", "prefix", "suffix",
43912
45265
  "emphasis", "hide_when", "width", "precision", "min_width", "min_cols",
43913
45266
  "max_cols", "priority", "max_len", "align", "draw_empty", "unknown",
45267
+ // v2.0.4 — the design fields.
45268
+ "label_pos", "value_pos", "caption", "caption_pos", "caption_align",
45269
+ "caption_color", "caption_case", "fill_color", "empty_color", "ramp_colors",
45270
+ "ramp_stops", "fill_char", "empty_char", "brackets", "threshold", "padding",
45271
+ "sign", "color_by_state", "glyph_by_state", "params",
43914
45272
  ];
43915
45273
  // The ones whose absence is an empty LIST rather than a null — a control that
43916
45274
  // iterates a null throws, and a null here would read as "unknown" when it means
43917
45275
  // "nothing selected".
43918
- const SL_ITEM_LIST_FIELDS = new Set(["emphasis", "hide_when"]);
45276
+ const SL_ITEM_LIST_FIELDS = new Set(["emphasis", "hide_when", "ramp_colors", "ramp_stops", "brackets", "padding"]);
45277
+ const SL_ITEM_MAP_FIELDS = new Set(["color_by_state", "glyph_by_state", "params"]);
43919
45278
 
43920
45279
  function slAuthoredFields(it) {
43921
45280
  const out = {};
@@ -43937,11 +45296,31 @@ function slShowCmd(claudeDir) {
43937
45296
  try {
43938
45297
  preview = slPreview(slCompile(eff).compiled, "healthy", 120, {}).text;
43939
45298
  } catch (_) {}
45299
+ const hook = slHookInfo(claudeDir, slBoard());
45300
+ const lockNow = slReadJson(slPaths(claudeDir).lock);
45301
+ let animated = false;
45302
+ try {
45303
+ animated = slHasMotion(slCompile(eff).compiled);
45304
+ } catch (_) {}
45305
+ // ONE item alone, in its own saved design: what the chip looks like.
45306
+ const sampleOf = (it, l) => {
45307
+ try {
45308
+ const copy = JSON.parse(JSON.stringify(eff));
45309
+ copy.lines.forEach((x, i) => (x.items = []));
45310
+ copy.lines[0] = Object.assign({}, l, { items: [JSON.parse(JSON.stringify(it))], prefix: "" });
45311
+ return slPreview(slCompile(copy).compiled, "healthy", 120, {}).text;
45312
+ } catch (_) {
45313
+ return null;
45314
+ }
45315
+ };
43940
45316
  const lines = eff.lines.map((l, i) => ({
43941
45317
  line: i + 1,
43942
45318
  separator: l.separator,
43943
45319
  theme: l.theme,
43944
45320
  max_width: l.max_width || 0,
45321
+ align: l.align || "left",
45322
+ sep_color: l.sep_color || null,
45323
+ prefix: l.prefix || "",
43945
45324
  count: (l.items || []).length,
43946
45325
  counted: (l.items || []).filter((it) => !slIsStructural(it.type)).length,
43947
45326
  full: (l.items || []).filter((it) => !slIsStructural(it.type)).length >= SL_MAX_PER_LINE,
@@ -43963,8 +45342,9 @@ function slShowCmd(claudeDir) {
43963
45342
  // cannot tell "you chose the theme's colour" from "you chose nothing"
43964
45343
  // cannot offer to clear it.
43965
45344
  authored: slAuthoredFields(it),
45345
+ sample: sampleOf(it, l),
43966
45346
  };
43967
- for (const k of SL_ITEM_FIELDS) row[k] = r && r[k] !== undefined && r[k] !== null ? r[k] : SL_ITEM_LIST_FIELDS.has(k) ? [] : null;
45347
+ for (const k of SL_ITEM_FIELDS) row[k] = r && r[k] !== undefined && r[k] !== null ? r[k] : SL_ITEM_LIST_FIELDS.has(k) ? [] : SL_ITEM_MAP_FIELDS.has(k) ? {} : null;
43968
45348
  if (it.children) row.children = it.children.map((k) => ({ id: k.id, type: k.type, authored: slAuthoredFields(k) }));
43969
45349
  return row;
43970
45350
  }),
@@ -43983,11 +45363,16 @@ function slShowCmd(claudeDir) {
43983
45363
  errors: v.errors,
43984
45364
  warnings: v.warnings,
43985
45365
  preview,
45366
+ hook,
45367
+ refresh_interval: slRefreshInterval(hook, slBoard()),
45368
+ needs_refresh: (lockNow && lockNow.needs_refresh_interval) || null,
45369
+ animated,
43986
45370
  dense_prefix: "A line may hold a component only if every line above it holds at least one.",
43987
45371
  }, v.ok ? 0 : 1);
43988
45372
  console.log("");
43989
45373
  console.log(ui.header("Status line") + " " + (on ? ui.mark.ok("custom layout ON") : ui.color.gray("custom layout off — the shipped two lines are rendering")));
43990
45374
  if (!layout) console.log(ui.color.gray(" nothing saved yet — this is the shipped default"));
45375
+ if (!hook.match && hook.fix) console.log(" " + ui.mark.warn(slHookSentence(hook)));
43991
45376
  console.log("");
43992
45377
  for (const l of lines) {
43993
45378
  const cells = l.items.map((i) => ui.color.cyan(i.type) + ui.color.gray("/" + i.render)).join(ui.color.gray(" · "));
@@ -44073,8 +45458,18 @@ function slPreviewCmd(claudeDir) {
44073
45458
  if (k) overrides[k] = val;
44074
45459
  }
44075
45460
  }
45461
+ // v2.0.4 — an animated preview: the same program at successive instants.
45462
+ const nFrames = flag("--frames") === undefined ? 1 : Number(flag("--frames"));
45463
+ const frameMs = flag("--frame-ms") === undefined ? 1000 : Number(flag("--frame-ms"));
45464
+ if (!Number.isInteger(nFrames) || nFrames < 1 || nFrames > 24 || !Number.isInteger(frameMs) || frameMs < 1) {
45465
+ if (wantsJson()) return emitJson({ ok: false, reason: "bad-frames", hint: "--frames takes 1 to 24, --frame-ms a whole number of milliseconds" }, 2);
45466
+ console.error("--frames takes 1 to 24, and --frame-ms takes a whole number of milliseconds");
45467
+ process.exit(2);
45468
+ }
44076
45469
  const { compiled } = slCompile(layout);
44077
45470
  const base = slPreview(compiled, state, cols, overrides);
45471
+ const frames = [base.text];
45472
+ for (let i = 1; i < nFrames; i++) frames.push(slPreview(compiled, state, cols, overrides, 1767225600000 + i * frameMs).text);
44078
45473
  // R4: every design must survive three strippings, and the panel shows all
44079
45474
  // three as small rows under the main preview — not as an afterthought tab.
44080
45475
  const strip = (env) => {
@@ -44105,6 +45500,8 @@ function slPreviewCmd(claudeDir) {
44105
45500
  fixture: (SL_FIXTURES[state] || SL_FIXTURES.healthy).label,
44106
45501
  fixtures: Object.fromEntries(Object.entries(SL_FIXTURES).map(([k, f]) => [k, f.label])),
44107
45502
  text: base.text,
45503
+ frames,
45504
+ frame_ms: frameMs,
44108
45505
  strippings: {
44109
45506
  no_color: strip({ NO_COLOR: "1" }),
44110
45507
  ascii: strip({ ORC_STATUSLINE_ASCII: "1" }),
@@ -44153,20 +45550,95 @@ function slSetCmd(claudeDir, p) {
44153
45550
  process.exit(2);
44154
45551
  }
44155
45552
  const item = existing || { id: slNewId(layout), type };
45553
+ // A SWAP drops what the new component cannot carry, so the swap never fails
45554
+ // on the old shape.
45555
+ if (type && existing && existing.type !== type) {
45556
+ const nc = SL_BY_ID.get(type);
45557
+ if (nc) {
45558
+ if (item.render && !nc.renderers.includes(item.render)) delete item.render;
45559
+ delete item.label;
45560
+ delete item.params;
45561
+ if (!nc.bounded) delete item.ramp;
45562
+ const rn = item.render || (nc.defaults && nc.defaults.render) || nc.renderers[0];
45563
+ if (!(STATUSLINE_RENDERERS[rn] && STATUSLINE_RENDERERS[rn].width)) delete item.width;
45564
+ }
45565
+ }
44156
45566
  if (type) item.type = type;
45567
+ const COLOR_KEYS = new Set(["color", "label_color", "value_color", "bg"]);
44157
45568
  for (const [k, f] of [["render", "--render"], ["label", "--label"], ["color", "--color"], ["label_color", "--label-color"], ["value_color", "--value-color"], ["bg", "--bg"], ["ramp", "--ramp"], ["glyphs", "--glyphs"], ["format", "--format"], ["case", "--case"], ["truncate", "--truncate"], ["compact", "--compact"], ["prefix", "--prefix"], ["suffix", "--suffix"]]) {
44158
- const v = flag(f);
44159
- if (typeof v === "string") item[k] = v;
45569
+ const v = slFlag(f);
45570
+ if (typeof v !== "string") continue;
45571
+ // "" clears the key (null = inherit); `inherit` clears a colour too.
45572
+ if (v === "" || (COLOR_KEYS.has(k) && v === "inherit")) delete item[k];
45573
+ else item[k] = v;
44160
45574
  }
44161
45575
  for (const [k, f] of [["width", "--width"], ["precision", "--precision"], ["min_width", "--min-width"], ["min_cols", "--min-cols"], ["max_cols", "--max-cols"], ["priority", "--priority"]]) {
44162
- const v = flag(f);
44163
- if (typeof v === "string" && v !== "") item[k] = Number(v);
44164
- }
44165
- const emph = flag("--emphasis");
44166
- if (typeof emph === "string") item.emphasis = emph.split(",").map((s) => s.trim()).filter(Boolean);
44167
- const hide = flag("--hide-when");
44168
- if (typeof hide === "string") item.hide_when = hide === "never" ? [] : hide.split(",").map((s) => s.trim()).filter(Boolean);
45576
+ const v = slFlag(f);
45577
+ if (v === "") delete item[k];
45578
+ else if (typeof v === "string") item[k] = Number(v);
45579
+ }
45580
+ const emph = slFlag("--emphasis");
45581
+ if (emph === "") delete item.emphasis;
45582
+ else if (typeof emph === "string") item.emphasis = emph.split(",").map((s) => s.trim()).filter(Boolean);
45583
+ const hide = slFlag("--hide-when");
45584
+ if (hide === "") delete item.hide_when;
45585
+ else if (typeof hide === "string") item.hide_when = hide === "never" ? [] : hide.split(",").map((s) => s.trim()).filter(Boolean);
44169
45586
  if (flag("--draw-empty") === true) item.draw_empty = true;
45587
+ // v2.0.4 — the design fields. Same rules: "" clears, `inherit` clears a
45588
+ // colour, and the validator judges every value.
45589
+ const DESIGN_COLORS = new Set(["caption_color", "fill_color", "empty_color"]);
45590
+ for (const [k, f] of [["label_pos", "--label-pos"], ["value_pos", "--value-pos"], ["caption", "--caption"], ["caption_pos", "--caption-pos"], ["caption_align", "--caption-align"], ["caption_color", "--caption-color"], ["caption_case", "--caption-case"], ["fill_color", "--fill-color"], ["empty_color", "--empty-color"], ["fill_char", "--fill-char"], ["empty_char", "--empty-char"], ["align", "--align"], ["sign", "--sign"], ["unknown", "--unknown"]]) {
45591
+ const v = slFlag(f);
45592
+ if (typeof v !== "string") continue;
45593
+ if (v === "" || (DESIGN_COLORS.has(k) && v === "inherit")) delete item[k];
45594
+ else item[k] = v;
45595
+ }
45596
+ for (const [k, f] of [["threshold", "--threshold"], ["max_len", "--max-len"]]) {
45597
+ const v = slFlag(f);
45598
+ if (v === "") delete item[k];
45599
+ else if (typeof v === "string") item[k] = Number(v);
45600
+ }
45601
+ for (const [i, f] of [[0, "--pad-left"], [1, "--pad-right"]]) {
45602
+ const v = slFlag(f);
45603
+ if (typeof v !== "string") continue;
45604
+ const pad = Array.isArray(item.padding) ? item.padding.slice(0, 2) : [0, 0];
45605
+ pad[i] = v === "" ? 0 : Number(v);
45606
+ if (!pad[0] && !pad[1]) delete item.padding;
45607
+ else item.padding = [pad[0] || 0, pad[1] || 0];
45608
+ }
45609
+ const csv = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
45610
+ for (const [k, f, conv] of [["ramp_colors", "--ramp-colors", csv], ["ramp_stops", "--ramp-stops", (v) => csv(v).map(Number)], ["brackets", "--brackets", (v) => v.split(",")]]) {
45611
+ const v = slFlag(f);
45612
+ if (v === "") delete item[k];
45613
+ else if (typeof v === "string") item[k] = conv(v);
45614
+ }
45615
+ for (const [k, f] of [["color_by_state", "--state-color"], ["glyph_by_state", "--state-glyph"]]) {
45616
+ const v = slFlag(f);
45617
+ if (v === "") delete item[k];
45618
+ else if (typeof v === "string") {
45619
+ const map = {};
45620
+ for (const pair of csv(v)) {
45621
+ const eq = pair.indexOf("=");
45622
+ if (eq > 0) map[pair.slice(0, eq)] = pair.slice(eq + 1);
45623
+ else map[pair] = "";
45624
+ }
45625
+ item[k] = map;
45626
+ }
45627
+ }
45628
+ // `--param k=v` is REPEATABLE; `--param k=` deletes that one key.
45629
+ for (let i = 0; i < args.length; i++) {
45630
+ if (args[i] !== "--param" || typeof args[i + 1] !== "string") continue;
45631
+ const pv = args[i + 1];
45632
+ const eq = pv.indexOf("=");
45633
+ const key = eq === -1 ? pv : pv.slice(0, eq);
45634
+ if (!key) continue;
45635
+ const val = eq === -1 ? "" : pv.slice(eq + 1);
45636
+ const prm = Object.assign({}, item.params || {});
45637
+ if (val === "") delete prm[key];
45638
+ else prm[key] = val;
45639
+ if (Object.keys(prm).length) item.params = prm;
45640
+ else delete item.params;
45641
+ }
44170
45642
  if (!existing) {
44171
45643
  line.items = line.items || [];
44172
45644
  line.items.splice(Math.min(pi - 1, line.items.length), 0, item);
@@ -44225,13 +45697,20 @@ function slLineCmd(claudeDir, p) {
44225
45697
  process.exit(2);
44226
45698
  }
44227
45699
  const line = layout.lines[n - 1];
44228
- const sep = flag("--separator");
45700
+ const sep = slFlag("--separator");
44229
45701
  if (typeof sep === "string") line.separator = sep;
44230
- const pre = flag("--prefix");
45702
+ const pre = slFlag("--prefix");
44231
45703
  if (typeof pre === "string") line.prefix = pre;
44232
- const mw = flag("--max-width");
45704
+ const mw = slFlag("--max-width");
44233
45705
  if (typeof mw === "string") line.max_width = Number(mw) || 0;
44234
- const th = flag("--theme");
45706
+ // v2.0.4 — the line's alignment and its separator colour. "" clears.
45707
+ const al = slFlag("--align");
45708
+ if (al === "") delete line.align;
45709
+ else if (typeof al === "string") line.align = al;
45710
+ const sc = slFlag("--sep-color");
45711
+ if (sc === "" || sc === "inherit") delete line.sep_color;
45712
+ else if (typeof sc === "string") line.sep_color = sc;
45713
+ const th = slFlag("--theme");
44235
45714
  if (typeof th === "string") {
44236
45715
  if (!STATUSLINE_THEMES[th]) {
44237
45716
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-theme", theme: th, known: Object.keys(STATUSLINE_THEMES) }, 2);
@@ -44251,7 +45730,7 @@ function slLineCmd(claudeDir, p) {
44251
45730
  // per scope, and the panel calls the one that matches the control.
44252
45731
  function slDocCmd(claudeDir, p) {
44253
45732
  const layout = slLoadOrDefault(claudeDir);
44254
- const th = flag("--theme");
45733
+ const th = slFlag("--theme");
44255
45734
  if (typeof th === "string") {
44256
45735
  if (!STATUSLINE_THEMES[th]) {
44257
45736
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-theme", theme: th, known: Object.keys(STATUSLINE_THEMES) }, 2);
@@ -44265,7 +45744,7 @@ function slDocCmd(claudeDir, p) {
44265
45744
  // shadowed is the one failure this whole command exists to fix.
44266
45745
  for (const l of layout.lines) l.theme = null;
44267
45746
  }
44268
- const gl = flag("--glyphs");
45747
+ const gl = slFlag("--glyphs");
44269
45748
  if (typeof gl === "string") {
44270
45749
  if (!STATUSLINE_GLYPHSETS[gl]) {
44271
45750
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-glyphs", glyphs: gl, known: Object.keys(STATUSLINE_GLYPHSETS) }, 2);
@@ -44274,7 +45753,7 @@ function slDocCmd(claudeDir, p) {
44274
45753
  }
44275
45754
  layout.glyphs = gl;
44276
45755
  }
44277
- const an = flag("--ansi");
45756
+ const an = slFlag("--ansi");
44278
45757
  if (typeof an === "string") {
44279
45758
  if (!["auto", "off"].includes(an)) {
44280
45759
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-ansi", ansi: an, known: ["auto", "off"] }, 2);
@@ -44283,7 +45762,7 @@ function slDocCmd(claudeDir, p) {
44283
45762
  }
44284
45763
  layout.ansi = an;
44285
45764
  }
44286
- const align = flag("--align-columns");
45765
+ const align = slFlag("--align-columns");
44287
45766
  if (typeof align === "string") layout.align_columns = align === "on";
44288
45767
  return slSaveAndCompile(claudeDir, layout, { action: "doc", theme: layout.theme, glyphs: layout.glyphs, ansi: layout.ansi, align_columns: !!layout.align_columns });
44289
45768
  }
@@ -44366,14 +45845,18 @@ function slSaveAndCompile(claudeDir, layout, info, skipWrite) {
44366
45845
  if (!skipWrite) slWriteLayout(claudeDir, layout);
44367
45846
  fs.writeFileSync(paths.compiled, JSON.stringify(compiled, null, 2) + "\n");
44368
45847
  fs.writeFileSync(paths.lock, JSON.stringify(lock, null, 2) + "\n");
45848
+ // A fresh compile supersedes the hook's last recorded fallback.
45849
+ if (paths.board.id === "status") slClearFallback(claudeDir);
45850
+ const hook = slHookInfo(claudeDir, paths.board);
44369
45851
  let preview = null;
44370
45852
  try {
44371
45853
  preview = slPreview(compiled, "healthy", 120, {}).text;
44372
45854
  } catch (_) {}
44373
- const out = Object.assign({ ok: true, wrote: true, warnings: v.warnings, needs_refresh_interval: lock.needs_refresh_interval, static_width: compiled.lines.map((l) => l.static_width), preview }, info);
45855
+ const out = Object.assign({ ok: true, wrote: true, warnings: v.warnings, needs_refresh_interval: lock.needs_refresh_interval, static_width: compiled.lines.map((l) => l.static_width), preview, hook }, info);
44374
45856
  if (wantsJson()) return emitJson(out, 0);
44375
45857
  console.log("");
44376
45858
  console.log(" " + ui.mark.ok(`${info.action} — compiled`));
45859
+ if (!hook.match && hook.fix) console.log(" " + ui.mark.warn(slHookSentence(hook)));
44377
45860
  if (preview) for (const row of preview.split("\n")) console.log(" " + row);
44378
45861
  for (const w of v.warnings) console.log(" " + ui.mark.warn(w));
44379
45862
  if (lock.needs_refresh_interval)
@@ -44389,6 +45872,141 @@ function slSaveAndCompile(claudeDir, layout, info, skipWrite) {
44389
45872
  console.log("");
44390
45873
  }
44391
45874
 
45875
+ function slClearFallback(claudeDir) {
45876
+ try {
45877
+ fs.unlinkSync(path.join(claudeDir, "orc", "statusline-state.json"));
45878
+ } catch (_) {}
45879
+ }
45880
+
45881
+ function slReadJson(p) {
45882
+ try {
45883
+ return JSON.parse(fs.readFileSync(p, "utf8").replace(/^\uFEFF/, ""));
45884
+ } catch (_) {
45885
+ return null;
45886
+ }
45887
+ }
45888
+
45889
+ // WHICH HOOK WILL DRAW THIS LAYOUT, and does it speak this CLI's catalogue?
45890
+ // The panel said "your lines are showing" while the hook refused the lock.
45891
+ function slHookInfo(claudeDir, board) {
45892
+ const b = board || SL_BOARDS.status;
45893
+ const needle = b.id === "subagent" ? "orc-subagent-line" : "orc-statusline";
45894
+ let hookPath = null;
45895
+ let settings = null;
45896
+ for (const sp of [path.join(claudeDir, "settings.json"), path.join(os.homedir(), ".claude", "settings.json")]) {
45897
+ const st = slReadJson(sp);
45898
+ const cmd = st && st[b.setting] && typeof st[b.setting].command === "string" ? st[b.setting].command : null;
45899
+ if (!cmd || !cmd.includes(needle)) continue;
45900
+ const quoted = cmd.match(/"([^"]+)"/g);
45901
+ const q = quoted && quoted.map((x) => x.slice(1, -1)).find((x) => x.includes(needle));
45902
+ hookPath = path.resolve(q || cmd.trim().split(/\s+/).pop());
45903
+ settings = sp;
45904
+ break;
45905
+ }
45906
+ const inst = hookPath ? slReadJson(path.join(path.dirname(hookPath), "orc-version.json")) : null;
45907
+ const hash = slCatalogHash();
45908
+ const cli = currentVersion();
45909
+ const version = (inst && inst.version) || null;
45910
+ const catalog_hash = (inst && inst.catalog_hash) || null;
45911
+ const hookOk = !!hookPath && (catalog_hash ? catalog_hash === hash : version === cli);
45912
+ const lock = slReadJson(slPaths(claudeDir, b).lock);
45913
+ const lockOk = !lock || lock.catalog_hash === hash;
45914
+ const fallback = b.id === "status" ? slReadJson(path.join(claudeDir, "orc", "statusline-state.json")) : null;
45915
+ const match = hookOk && lockOk;
45916
+ let fix = null;
45917
+ if (hookPath && !hookOk) {
45918
+ const home = path.join(os.homedir(), ".claude").toLowerCase() + path.sep;
45919
+ fix = hookPath.toLowerCase().startsWith(home) ? "orc update --global" : "orc update";
45920
+ } else if (!hookPath) fix = null;
45921
+ else if (!lockOk || fallback) fix = "orc statusline compile";
45922
+ return { path: hookPath, version, catalog_hash, cli_version: cli, match, fallback, fix, settings };
45923
+ }
45924
+
45925
+ // The `refreshInterval` in force for this board, or null.
45926
+ function slRefreshInterval(hook, board) {
45927
+ const st = hook && hook.settings ? slReadJson(hook.settings) : null;
45928
+ const v = st && st[board.setting] ? st[board.setting].refreshInterval : null;
45929
+ return typeof v === "number" ? v : null;
45930
+ }
45931
+
45932
+ // v2.0.4 — `orc statusline refresh <seconds|off>`: set or remove the timer in
45933
+ // the settings file that holds ORC's hook. The rest of the file is kept.
45934
+ // v2.1.0 — ONE core (`slRefreshApply`) for the command, `orc config set
45935
+ // statusline_refresh` and the panel; a sub-second value is refused BY NAME
45936
+ // (measured: 0.25 s never starts Claude Code's timer).
45937
+ const SL_REFRESH_FLOOR_S = 1;
45938
+ function slRefreshFloorText(arg) {
45939
+ return `✗ refresh ${arg} s is below the Claude Code floor.\n Claude Code runs the status line at most once a second (refreshInterval minimum: 1).\n A lower value does not start the timer. Nearest allowed value: 1.`;
45940
+ }
45941
+ function slRefreshApply(claudeDir, board, secs) {
45942
+ const hook = slHookInfo(claudeDir, board);
45943
+ if (!hook.settings) return { ok: false, reason: "not-wired", settings: null, hint: `ORC's ${board.setting} hook is not in a settings.json. Run: orc update` };
45944
+ let st;
45945
+ try {
45946
+ st = JSON.parse(fs.readFileSync(hook.settings, "utf8").replace(/^\uFEFF/, ""));
45947
+ } catch (_) {
45948
+ return { ok: false, reason: "settings-unparsed", settings: hook.settings, hint: `${hook.settings} is not valid JSON. Correct the file, then try again.` };
45949
+ }
45950
+ if (!st || typeof st[board.setting] !== "object" || st[board.setting] === null)
45951
+ return { ok: false, reason: "not-wired", settings: hook.settings, hint: `ORC's ${board.setting} hook is not in ${hook.settings}. Run: orc update` };
45952
+ if (secs == null) delete st[board.setting].refreshInterval;
45953
+ else st[board.setting].refreshInterval = secs;
45954
+ fs.writeFileSync(hook.settings, JSON.stringify(st, null, 2) + "\n");
45955
+ return { ok: true, refresh_interval: secs, settings: hook.settings };
45956
+ }
45957
+ function slRefreshCmd(claudeDir, p) {
45958
+ const board = slBoard();
45959
+ const arg = String(p[2] == null ? "" : p[2]);
45960
+ const secs = arg === "off" ? null : Number(arg);
45961
+ if (arg !== "off" && Number.isFinite(secs) && secs > 0 && secs < SL_REFRESH_FLOOR_S) {
45962
+ if (wantsJson()) return emitJson({ ok: false, reason: "below-floor", floor_s: SL_REFRESH_FLOOR_S, nearest: 1, hint: slRefreshFloorText(arg) }, 2);
45963
+ console.error(slRefreshFloorText(arg));
45964
+ process.exit(2);
45965
+ }
45966
+ if (arg !== "off" && (!Number.isInteger(secs) || secs < 1 || secs > 60)) {
45967
+ if (wantsJson()) return emitJson({ ok: false, reason: "bad-arg", hint: "use a whole number of seconds from 1 to 60, or off" }, 2);
45968
+ console.error("usage: orc statusline refresh <off|1|2|3|5|10|N 1-60> [--board status|subagent]");
45969
+ process.exit(2);
45970
+ }
45971
+ const r = slRefreshApply(claudeDir, board, secs);
45972
+ if (!r.ok) {
45973
+ if (wantsJson()) return emitJson(r, 1);
45974
+ console.error(r.hint);
45975
+ process.exit(1);
45976
+ }
45977
+ if (wantsJson()) return emitJson(r, 0);
45978
+ console.log(" " + ui.mark.ok(secs == null ? `the refresh timer is off (${r.settings})` : `the status line refreshes every ${secs} s (${r.settings})`));
45979
+ }
45980
+
45981
+ function slHookSentence(h) {
45982
+ if (h.fix === "orc statusline compile")
45983
+ return `Your terminal does not show this layout yet. The compiled layout is old. Run: ${h.fix}`;
45984
+ return `Your terminal does not show this layout yet. The status line hook is ORC ${h.version || "unknown"} and this CLI is ORC ${h.cli_version}. Run: ${h.fix}`;
45985
+ }
45986
+
45987
+ // After `orc init|update`: recompile each EXISTING layout, so the lock matches
45988
+ // the hooks that were just installed. Never creates a layout.
45989
+ function slRecompileInstalled(claudeDir) {
45990
+ for (const b of [SL_BOARDS.status, SL_BOARDS.subagent]) {
45991
+ try {
45992
+ const paths = slPaths(claudeDir, b);
45993
+ if (!fs.existsSync(paths.layout)) continue;
45994
+ const layout = slReadLayout(claudeDir, b);
45995
+ const v = layout ? slValidate(layout) : { ok: false };
45996
+ if (!v.ok) {
45997
+ console.log(` skip orc/${b.files[0]} — the layout does not validate; run \`orc statusline validate\``);
45998
+ continue;
45999
+ }
46000
+ const { compiled, lock } = slCompile(layout);
46001
+ lock.warnings = v.warnings;
46002
+ fs.writeFileSync(paths.compiled, JSON.stringify(compiled, null, 2) + "\n");
46003
+ fs.writeFileSync(paths.lock, JSON.stringify(lock, null, 2) + "\n");
46004
+ console.log(` upd orc/${b.files[1]} (recompiled for v${currentVersion()})`);
46005
+ if (b.id === "status") slClearFallback(claudeDir);
46006
+ } catch (_) {}
46007
+ }
46008
+ }
46009
+
44392
46010
 
44393
46011
  // ── orc rules (v1.7.0) — the anti-slop rule surface ─────────────────────────
44394
46012
  //
@@ -44454,6 +46072,22 @@ const RULES_PRECEDENCE = [
44454
46072
  { rank: 3, layer: "ORC rules", scope: "everything", where: "<claude>/skills/_shared/rules/", beats: "nothing — it is the floor, and yours replaces it" },
44455
46073
  ];
44456
46074
 
46075
+ // v2.0.0 DE-8 — the LEARNED tier sits between your rules and the ORC rules.
46076
+ // It is named ONLY while `habits` is on: off = the ladder of the release before.
46077
+ function rulesPrecedence(claudeDir) {
46078
+ let on = false;
46079
+ try {
46080
+ on = require("./habit.js").habitsMode(readOverride(claudeDir).map) !== "off";
46081
+ } catch (_) {}
46082
+ if (!on) return RULES_PRECEDENCE;
46083
+ return [
46084
+ RULES_PRECEDENCE[0],
46085
+ RULES_PRECEDENCE[1],
46086
+ { 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" },
46087
+ Object.assign({}, RULES_PRECEDENCE[2], { rank: 4 }),
46088
+ ];
46089
+ }
46090
+
44457
46091
  // Which packs ride in which lane. `ui` is absent from every row ON PURPOSE: it
44458
46092
  // is added PER TASK by the orchestrator when a task's declared files are
44459
46093
  // front-end. A UI rule in a backend slice is tokens paid on every spawn for a
@@ -44488,6 +46122,8 @@ const RULE_LANE_PACKS = {
44488
46122
  "orc-test": ["writing", "delivery"],
44489
46123
  "orc-route": ["writing", "delivery"],
44490
46124
  "orc-explain": ["writing", "delivery"],
46125
+ // v2.1.0 W6 — with no run open, /orc-fix may write code (DE-12 c).
46126
+ "orc-fix": ["writing", "code", "delivery"],
44491
46127
  "context-combiner": ["writing", "delivery"],
44492
46128
  };
44493
46129
 
@@ -44722,6 +46358,10 @@ function rulesLine(packs, blocks, overrides) {
44722
46358
  // which are what a rule's body is mostly made of. Nothing is hidden: the pack
44723
46359
  // file is named beside the rules, exactly as the light tiers already are.
44724
46360
  const RULES_COMPACT_LANES = new Set(["orc-quick"]);
46361
+ // v2.0.0 DE-17 — the SAME compact card for orc-mini / orc-fast, behind
46362
+ // `rules_card_compact` (default off). It flips only when eval E4 passes
46363
+ // (eval/results/2.0.0/E4.md). Off = the full card, byte for byte.
46364
+ const RULES_COMPACT_OPT_IN = new Set(["orc-mini", "orc-fast"]);
44725
46365
 
44726
46366
  // One line out of a rule body: the first paragraph, whitespace collapsed, cut
44727
46367
  // at a word boundary. A rule whose instruction does not survive that is a rule
@@ -44742,7 +46382,16 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44742
46382
  const overridden = new Set(overrides.filter((o) => o.known).map((o) => o.id));
44743
46383
  const want = new Set([...(RULE_LANE_PACKS[lane] || []), ...(extraPacks || [])]);
44744
46384
  const chosen = packs.packs.filter((p) => want.has(p.id));
44745
- const compact = RULES_COMPACT_LANES.has(lane);
46385
+ let cfgMap = {};
46386
+ try {
46387
+ cfgMap = readOverride(claudeDir).map || {};
46388
+ } catch (_) {}
46389
+ const compact =
46390
+ 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)));
46391
+ // DE-8 — the LEARNED tier. `null` under `habits: off`: then the slice is
46392
+ // byte-identical to the release before, and the field is not even present.
46393
+ const learned = require("./habit.js").learnedRules(claudeDir, repoRootOf(claudeDir), cfgMap);
46394
+ const inForce = learned ? learned.filter((r) => r.state === "applied") : [];
44746
46395
 
44747
46396
  const L = [];
44748
46397
  if (!led.empty) {
@@ -44752,6 +46401,13 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44752
46401
  if (body) L.push(pr, body, "");
44753
46402
  }
44754
46403
  }
46404
+ if (inForce.length) {
46405
+ L.push(
46406
+ "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.",
46407
+ ...inForce.map((r) => `${r.id} · ${r.rule}`),
46408
+ ""
46409
+ );
46410
+ }
44755
46411
  if (chosen.length) {
44756
46412
  L.push(
44757
46413
  `ORC RULES — the anti-slop baseline. Shipped with ORC, read-only. ${packs.count} rules, ${plural(chosen.length, "pack")} in this slice.`,
@@ -44795,6 +46451,9 @@ function rulesSlice(claudeDir, lane, extraPacks) {
44795
46451
  // `compact` is REPORTED, never asked for. A reader that cannot tell a short
44796
46452
  // card from a stripped one cannot trust either.
44797
46453
  compact,
46454
+ // Present ONLY when habits is on (DE-16: off = zero bytes). Stale rows are
46455
+ // listed with their reason and are NOT in `text`.
46456
+ ...(learned ? { learned } : {}),
44798
46457
  text: L.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""),
44799
46458
  overrides,
44800
46459
  line: rulesLine(packs, led.blocks, overrides),
@@ -45071,7 +46730,7 @@ function rulesCmd() {
45071
46730
  },
45072
46731
  priorities: RULE_PRIORITIES,
45073
46732
  tiers: RULE_TIERS,
45074
- precedence: RULES_PRECEDENCE,
46733
+ precedence: rulesPrecedence(claudeDir),
45075
46734
  overrides,
45076
46735
  overrides_note:
45077
46736
  "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 +46743,7 @@ function rulesCmd() {
45084
46743
 
45085
46744
  console.log(`\norc rules — ${line}\n`);
45086
46745
  console.log(ui.color.bold(" Precedence") + ui.color.gray(" the order never changes"));
45087
- for (const r of RULES_PRECEDENCE)
46746
+ for (const r of rulesPrecedence(claudeDir))
45088
46747
  console.log(
45089
46748
  ` ${r.rank}. ${ui.color.cyan(r.layer.padEnd(12))} ${ui.color.gray(r.scope)}\n` +
45090
46749
  ` ${ui.color.gray(r.where)}\n` +
@@ -45247,7 +46906,7 @@ function lintScanFile(abs, rel, opts) {
45247
46906
  } catch (_) {
45248
46907
  return out;
45249
46908
  }
45250
- if (text.includes("")) return out; // binary
46909
+ if (text.includes("\u0000")) return out; // binary
45251
46910
  const lines = text.replace(/\r\n/g, "\n").split("\n");
45252
46911
 
45253
46912
  // THE CARVE-OUT, and it is not a convenience. A rule that bans a word has to
@@ -45587,9 +47246,31 @@ function jsonCrash(err) {
45587
47246
  case "gotcha":
45588
47247
  gotcha();
45589
47248
  break;
47249
+ // v2.1.0 W6 — the deterministic half of `/orc-fix`. It READS `.current`
47250
+ // (the host run) and never writes it: the lane opens no run of its own.
47251
+ case "fix":
47252
+ require("./fix.js").fixCmd(Object.assign(gotchaDeps(), { resolveLogDir, TRACE_NAME, inflightStaleMs: INFLIGHT_STALE_MS }));
47253
+ break;
45590
47254
  case "graph":
45591
47255
  graphCmd();
45592
47256
  break;
47257
+ // v2.0.0 W2 — the habit engine. Every subcommand is a READ except accept,
47258
+ // decline, forget, reset and purge, which are habits-state.json's only writers.
47259
+ case "habit":
47260
+ require("./habit.js").habitCmd(habitDeps());
47261
+ break;
47262
+ // v2.0.0 T21 — the CLI holds the trace pen: one packet → the .txt + .jsonl
47263
+ // pair. The Haiku writer is the fallback when this exits ≠ 0.
47264
+ case "trace":
47265
+ require("./trace-write.js").traceCmd({
47266
+ flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride, reviewPolicyCompute,
47267
+ logSweep: (claudeDir) => require("./clear-logs.js").sweep(claudeDir, clearLogsDeps()),
47268
+ });
47269
+ break;
47270
+ // v2.1.0 W7 — print by default; only --apply deletes (DE-16, DE-17).
47271
+ case "clear":
47272
+ require("./clear-logs.js").clearCmd(clearLogsDeps());
47273
+ break;
45593
47274
  case "mock":
45594
47275
  mock();
45595
47276
  break;
@@ -45620,6 +47301,10 @@ function jsonCrash(err) {
45620
47301
  case "export":
45621
47302
  exportCmd();
45622
47303
  break;
47304
+ // v2.1.0 — does the project ask for its own review? Read-only probe.
47305
+ case "review":
47306
+ reviewCmd();
47307
+ break;
45623
47308
  // v0.47.0 — the lane that refuses to produce. Every subcommand is a READ
45624
47309
  // with an exit-code contract except `init`, `record`, `accept`, `rebut`,
45625
47310
  // `template`, `goals` and `report`, which are the ledger's only writers.
@@ -45679,12 +47364,20 @@ function jsonCrash(err) {
45679
47364
  case "pr-stack-status":
45680
47365
  pr(cmd);
45681
47366
  break;
47367
+ // v2.0.0 W6a (Q3) — the failing CI steps, read-only, flaky re-attempts named.
47368
+ case "ci":
47369
+ require("./gotcha-import.js").ciFailedCmd(gotchaDeps());
47370
+ break;
45682
47371
  case "resume":
45683
47372
  resume();
45684
47373
  break;
45685
47374
  case "run":
45686
47375
  runCmd();
45687
47376
  break;
47377
+ // v2.0.0 W6b (Q2)  revert ONLY what a run changed, back to its snapshot.
47378
+ case "undo":
47379
+ require("./run-undo.js").undoCmd(runUndoDeps());
47380
+ break;
45688
47381
  case "stats":
45689
47382
  stats();
45690
47383
  break;