@azure-id/orc 2.0.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 (121) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/README-id.md +52 -23
  3. package/README.md +50 -22
  4. package/bin/build-agents.js +24 -25
  5. package/bin/clear-logs.js +620 -0
  6. package/bin/cli.js +1155 -158
  7. package/bin/fix.js +459 -0
  8. package/bin/gotcha.js +288 -8
  9. package/bin/habit.js +1538 -1453
  10. package/bin/mockrun-catalog.js +3 -0
  11. package/bin/onboarding-content.js +2 -1
  12. package/bin/pricing.json +28 -21
  13. package/bin/trace-write.js +869 -657
  14. package/bin/verify-contracts.js +108 -27
  15. package/bin/verify-package.js +17 -6
  16. package/bin/webui/api.js +125 -6
  17. package/bin/webui/css/panels/hookui.css +79 -0
  18. package/bin/webui/fixtures/behaviour.js +30 -0
  19. package/bin/webui/fixtures/extra.js +9 -9
  20. package/bin/webui/fixtures/flow.js +81 -81
  21. package/bin/webui/fixtures/hookui.js +506 -431
  22. package/bin/webui/fixtures/index.js +32 -3
  23. package/bin/webui/fixtures/knowledge.js +2 -2
  24. package/bin/webui/fixtures/maintenance.js +123 -42
  25. package/bin/webui/fixtures/rules.js +1007 -1002
  26. package/bin/webui/fixtures/settings.js +6 -7
  27. package/bin/webui/fixtures/stats.js +9 -6
  28. package/bin/webui/fixtures/wait.js +98 -97
  29. package/bin/webui/i18n/en/behaviour.json +13 -0
  30. package/bin/webui/i18n/en/hookui.json +290 -194
  31. package/bin/webui/i18n/en/maintenance.json +71 -52
  32. package/bin/webui/i18n/en/settings.json +60 -60
  33. package/bin/webui/i18n/id/behaviour.json +13 -0
  34. package/bin/webui/i18n/id/hookui.json +290 -194
  35. package/bin/webui/i18n/id/maintenance.json +71 -52
  36. package/bin/webui/i18n/id/settings.json +60 -60
  37. package/bin/webui/js/05-banners.js +181 -172
  38. package/bin/webui/js/06-edit.js +193 -183
  39. package/bin/webui/js/panels/behaviour.js +77 -12
  40. package/bin/webui/js/panels/hookui.js +2196 -1691
  41. package/bin/webui/js/panels/maintenance.js +336 -235
  42. package/bin/webui/js/panels/settings.js +726 -719
  43. package/mock-run/INDEX.md +1 -0
  44. package/mock-run/extra-slots.md +1 -1
  45. package/mock-run/habits.md +1 -1
  46. package/mock-run/orc-aftermath.md +392 -392
  47. package/mock-run/orc-budget.md +29 -33
  48. package/mock-run/orc-cli.md +200 -200
  49. package/mock-run/orc-explain.md +86 -86
  50. package/mock-run/orc-extra.md +389 -392
  51. package/mock-run/orc-fix.md +87 -0
  52. package/mock-run/orc-pattern.md +112 -112
  53. package/mock-run/orc.md +157 -157
  54. package/package.json +1 -1
  55. package/templates/agents/MODEL-MAPPING.md +32 -29
  56. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -2
  57. package/templates/agents/{orc-claude-writer-opus-5-med.md → orc-claude-writer-opus-5-low.md} +4 -4
  58. package/templates/agents/orc-executor-haiku-4-5.md +1 -1
  59. package/templates/agents/orc-executor-opus-5-low.md +1 -1
  60. package/templates/agents/orc-executor-sonnet-4-6-high.md +1 -1
  61. package/templates/agents/orc-executor-sonnet-4-6-med.md +1 -1
  62. package/templates/agents/orc-executor-sonnet-5-high.md +2 -2
  63. package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
  64. package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
  65. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +2 -2
  66. package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -2
  67. package/templates/agents/orc-retro-sonnet-5-high.md +2 -2
  68. package/templates/agents/{orc-reviewer-opus-5-med.md → orc-reviewer-opus-5-low.md} +4 -4
  69. package/templates/agents/{orc-wiki-scanner-opus-5-med.md → orc-wiki-scanner-opus-5-low.md} +3 -3
  70. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +2 -2
  71. package/templates/commands/orc-fix.md +18 -0
  72. package/templates/commands/orc-wiki.md +64 -64
  73. package/templates/hooks/README.md +75 -7
  74. package/templates/hooks/orc-session-hook.js +78 -3
  75. package/templates/hooks/orc-statusline-render.js +317 -23
  76. package/templates/hooks/orc-statusline.js +162 -49
  77. package/templates/hooks/orc-subagent-line.js +23 -5
  78. package/templates/hooks/orc-weather-fetch.js +123 -0
  79. package/templates/skills/_shared/extra-dispatch.md +5 -5
  80. package/templates/skills/_shared/gotchas.md +27 -7
  81. package/templates/skills/_shared/habits.md +2 -2
  82. package/templates/skills/_shared/opus5-only.md +14 -14
  83. package/templates/skills/_shared/phases/review.md +2 -2
  84. package/templates/skills/_shared/phases/trace-verbs.md +17 -3
  85. package/templates/skills/_shared/phases/trace.md +136 -136
  86. package/templates/skills/_shared/review-slice.md +28 -1
  87. package/templates/skills/_shared/wait.md +1 -0
  88. package/templates/skills/orc/README.md +2 -2
  89. package/templates/skills/orc/SKILL.md +1 -1
  90. package/templates/skills/orc/config.md +25 -27
  91. package/templates/skills/orc/examples/full-run-mock.md +2 -1
  92. package/templates/skills/orc/references/effort-and-mode.md +17 -17
  93. package/templates/skills/orc/references/preflight-report.md +3 -3
  94. package/templates/skills/orc/references/ultra-mode.md +1 -1
  95. package/templates/skills/orc/schemas/checkpoint.md +122 -122
  96. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +3 -3
  97. package/templates/skills/orc/subskills/orc-review-verify/core.md +1 -1
  98. package/templates/skills/orc-analyze/SKILL.md +4 -2
  99. package/templates/skills/orc-claude/SKILL.md +14 -14
  100. package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
  101. package/templates/skills/orc-diy/SKILL.md +5 -5
  102. package/templates/skills/orc-fast/SKILL.md +8 -6
  103. package/templates/skills/orc-fix/SKILL.md +110 -0
  104. package/templates/skills/orc-mini/SKILL.md +4 -4
  105. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
  106. package/templates/skills/orc-quick/README.md +1 -1
  107. package/templates/skills/orc-quick/SKILL.md +2 -2
  108. package/templates/skills/orc-quick/references/dispatch-gate.md +3 -2
  109. package/templates/skills/orc-quick/references/gh-mode.md +4 -2
  110. package/templates/skills/orc-retro/SKILL.md +1 -1
  111. package/templates/skills/orc-retro/examples/retro-mock.md +16 -16
  112. package/templates/skills/orc-wiki/README.md +325 -325
  113. package/templates/skills/orc-wiki/SKILL.md +195 -195
  114. package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
  115. package/templates/skills/orc-wiki/references/extra.md +2 -2
  116. package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
  117. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -1
  118. package/templates/skills/orc-wiki/references/phases/phase-2.md +3 -3
  119. package/templates/skills/orc-wiki/references/phases/phase-3c.md +3 -2
  120. package/templates/agents/orc-claude-writer-opus-4-8-high.md +0 -48
  121. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +0 -74
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
  }
@@ -248,7 +264,7 @@ function installGuards(claudeDir) {
248
264
  // EW3: the graph hook shells `orc graph update`, and a hook must never
249
265
  // guess at PATH — the installer is the one place that knows for certain
250
266
  // which cli.js this payload came from.
251
- 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"
252
268
  );
253
269
  } catch (_) {}
254
270
  // The statusline's phase rail (v1.2.1). Regenerated every install from the
@@ -759,6 +775,7 @@ function install({ overwrite, forcePrune }) {
759
775
  }
760
776
 
761
777
  installGuards(claudeDir);
778
+ slRecompileInstalled(claudeDir);
762
779
 
763
780
  // Record ORC's footprint, then (on update only) prune files that left the
764
781
  // payload. A fresh init has nothing to prune; update diffs against the
@@ -1110,6 +1127,8 @@ const KNOWN_MODELS = [
1110
1127
  "claude-opus-5",
1111
1128
  "claude-opus-4-8",
1112
1129
  "claude-opus-4-7",
1130
+ "claude-sonnet-5-5",
1131
+ // Kept so a config written before v2.1.0 still validates.
1113
1132
  "claude-sonnet-5",
1114
1133
  "claude-sonnet-4-6",
1115
1134
  "claude-haiku-4-5",
@@ -1236,6 +1255,8 @@ const vSonarUrl = tag((raw) => {
1236
1255
  // and no "forever": "forever" is the option that makes every other one
1237
1256
  // pointless, and a deadline that renews itself on use is not a deadline.
1238
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];
1239
1260
  const EXTRA_ROLES_ALL = [
1240
1261
  "executor",
1241
1262
  "reviewer",
@@ -1479,6 +1500,11 @@ const CONFIG_META = [
1479
1500
  // v1.8.2 W2 (G8) — the key the engine already understood and no config
1480
1501
  // exposed. Same `lanes: []` reason as every graph key: the CLI reads it.
1481
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." },
1482
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`." },
1483
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." },
1484
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." },
@@ -1526,7 +1552,7 @@ const CONFIG_META = [
1526
1552
  // decision with a recorded reason (the /orc-pact retirement rule).
1527
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." },
1528
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." },
1529
- { 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)." },
1530
1556
  // --- v0.46.0 — the six new lanes ------------------------------------------
1531
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." },
1532
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." },
@@ -1594,7 +1620,9 @@ const CONFIG_META = [
1594
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." },
1595
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)." },
1596
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)." },
1597
- { 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." },
1598
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." },
1599
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." },
1600
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." },
@@ -1972,8 +2000,17 @@ const STATE_PAINT = {
1972
2000
  learned: (s) => ui.color.cyan(s),
1973
2001
  };
1974
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
+ }
1975
2011
  function configList(claudeDir) {
1976
- const { path: p, map } = readOverride(claudeDir);
2012
+ const { path: p, map: raw } = readOverride(claudeDir);
2013
+ const map = withLiveKeys(Object.assign({}, raw), claudeDir);
1977
2014
  console.log(
1978
2015
  `\nORC config (override: ${p}${fs.existsSync(p) ? "" : " — not created yet"})\n`
1979
2016
  );
@@ -2045,7 +2082,7 @@ function configList(claudeDir) {
2045
2082
  } catch (_) {}
2046
2083
  console.log(ui.header("Resolve order (highest wins)"));
2047
2084
  console.log(
2048
- "\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"
2049
2086
  );
2050
2087
  if (rows.length)
2051
2088
  console.log(
@@ -2207,7 +2244,7 @@ function shadowReason(key, map, claudeDir) {
2207
2244
  // v1.0.0 W4 — TWO BANDS, not three, and the same 90 edge as the default table's
2208
2245
  // top two rows (D13). That symmetry is the point: once the default table's high
2209
2246
  // end is already Opus 5.5 with effort as the dial, the forcing mode differs from
2210
- // 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
2211
2248
  // table stopped making.
2212
2249
  //
2213
2250
  // THIS IS THE ONLY COPY. Until W4 there were two arrays with these rows in this
@@ -2249,7 +2286,7 @@ function scoreTableJson(map, claudeDir) {
2249
2286
  // an extra route row covering this score (only for the scores it covers)
2250
2287
  // > opus5_only
2251
2288
  // > rubric_bands_override
2252
- // > the default 6-band table
2289
+ // > the default 5-band table
2253
2290
  //
2254
2291
  // Extra is an OVERLAY, not a replacement, which is what makes "cheap grunt
2255
2292
  // work goes to DeepSeek, hard work stays on Opus 5.5" a two-command setup
@@ -2315,7 +2352,8 @@ function scoreTableJson(map, claudeDir) {
2315
2352
  // things only a renderer needs: the control shape (derived from the validator),
2316
2353
  // whether another key shadows this one, and the resolved score ladder.
2317
2354
  function configListJson(claudeDir) {
2318
- const { path: p, map } = readOverride(claudeDir);
2355
+ const { path: p, map: raw } = readOverride(claudeDir);
2356
+ const map = withLiveKeys(Object.assign({}, raw), claudeDir);
2319
2357
  const has = (k) => Object.prototype.hasOwnProperty.call(map, k);
2320
2358
  const learned = configLearned(map, claudeDir);
2321
2359
  const keys = CONFIG_META.map((m) => {
@@ -2456,6 +2494,16 @@ function configSet(claudeDir, key, rawValue) {
2456
2494
  process.exit(1);
2457
2495
  }
2458
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
+ }
2459
2507
  // W8 (v0.51.0) — THE SETUP GATE. `extra_enabled` is the master switch on a
2460
2508
  // subsystem that sends this repo's source code to a third party, and arming it
2461
2509
  // before any connection has ever answered arms nothing: every dispatch falls
@@ -2525,7 +2573,7 @@ function extraShadowNotice(claudeDir) {
2525
2573
  return;
2526
2574
  }
2527
2575
  const bands = rows.map((r) => `[${r.from},${r.to >= 100 ? "100]" : r.to + ")"}`).join(", ");
2528
- 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";
2529
2577
  console.error(
2530
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` +
2531
2579
  " Every other score still does — Extra is an overlay, not a replacement.\n" +
@@ -2543,8 +2591,10 @@ const OPUS5_ONLY_ROLES = [
2543
2591
  ["mini plan", "orc-planner-mini-sonnet-5-high", "orc-planner-mini-opus-5-med"],
2544
2592
  ["scout", "orc-scout-sonnet-4-6-high", "orc-scout-opus-5-low"],
2545
2593
  ["pattern codify", "orc-pattern-codifier-sonnet-5-high", "orc-pattern-codifier-opus-5-med"],
2546
- ["wiki scan", "orc-wiki-scanner-opus-4-8-high", "orc-wiki-scanner-opus-5-med"],
2547
- ["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"],
2548
2598
  ["retro mine", "orc-retro-sonnet-5-high", "orc-retro-opus-5-med"],
2549
2599
  ];
2550
2600
 
@@ -2555,7 +2605,7 @@ const OPUS5_ONLY_ROLES = [
2555
2605
  function opus5Notice(on, claudeDir) {
2556
2606
  if (!on) {
2557
2607
  console.log(
2558
- "\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" +
2559
2609
  " table). A hand-written rubric_bands_override is live again."
2560
2610
  );
2561
2611
  return;
@@ -2932,6 +2982,9 @@ const LANES = [
2932
2982
  { lane: "orc-doc", command: "orc-doc" },
2933
2983
  { lane: "orc-explain", command: "orc-explain" },
2934
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" },
2935
2988
  { lane: "orc-fast", command: "orc-fast" },
2936
2989
  { lane: "orc-grill", command: "orc-grill" },
2937
2990
  { lane: "orc-handoff", command: "orc-handoff" },
@@ -3422,6 +3475,12 @@ function habitDeps() {
3422
3475
  return { flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, listTraces, traceTs };
3423
3476
  }
3424
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 };
3482
+ }
3483
+
3425
3484
  function laneList(claudeDir) {
3426
3485
  const rows = LANES.map((l) => ({
3427
3486
  lane: l.lane,
@@ -3479,7 +3538,7 @@ const LANE_CALLS = {
3479
3538
  on_absent: "exit ≠ 0 → say the CLI is unavailable and use the documented defaults, out loud, treating every P0 forcing mode as OFF",
3480
3539
  canonical: "_shared/config-precedence.md",
3481
3540
  never: "never merge `.claude/orc.config.yaml` yourself, and never re-derive a precedence — the answer already carries it",
3482
- 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"],
3483
3542
  },
3484
3543
  "run-inflight": {
3485
3544
  cmd: "orc run inflight [--json]",
@@ -3561,6 +3620,37 @@ const LANE_CALLS = {
3561
3620
  never: "never inject the ledger unfiltered — only the entries whose `scope` glob matches the slice",
3562
3621
  lanes: ["orc", "orc-boundary", "orc-brainstorm", "orc-fast", "orc-grill", "orc-mini", "orc-retro", "orc-route"],
3563
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
+ },
3564
3654
  // v1.8.0 — the code graph. Every row carries `--if-enabled`: the CLI resolves
3565
3655
  // `code_graph`, so no lane reads the key (and /orc-quick can take part).
3566
3656
  "graph-status": {
@@ -4025,7 +4115,7 @@ const LANE_CALLS = {
4025
4115
  // declared far below this catalogue, and reading it here is a temporal dead
4026
4116
  // zone that crashes the CLI on every command. `test/cli/rules.test.js`
4027
4117
  // asserts the two agree.
4028
- 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"],
4029
4119
  },
4030
4120
  };
4031
4121
 
@@ -4266,7 +4356,7 @@ const PHASE_FILES = {
4266
4356
  },
4267
4357
  "review": {
4268
4358
  file: "_shared/phases/review.md",
4269
- trace_verbs: ["PHASE", "FINDING", "GRAPH-CHANGES"],
4359
+ trace_verbs: ["PHASE", "FINDING", "GRAPH-CHANGES", "REVIEW-WHICH"],
4270
4360
  layers: ["full", "composed"],
4271
4361
  // v1.0.0 W13 — orc-diy became the second reader, so the phase left
4272
4362
  // orc/references/phases/ for the library. `full` is /orc's procedure;
@@ -4468,6 +4558,17 @@ const PROBE_DEFS = {
4468
4558
  pick: ["packs", "overrides"],
4469
4559
  line: (j) => j.line,
4470
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
+ },
4471
4572
  "extra-slot": {
4472
4573
  argv: () => ["extra", "resolve", "--slot", "quick-executor"],
4473
4574
  run: () => extraResolveCmd(resolveClaudeDir(), undefined),
@@ -4483,8 +4584,12 @@ function laneProbeIds(lane) {
4483
4584
  if (LANE_CALLS["graph-status"].lanes.includes(lane)) ids.push("graph-status");
4484
4585
  if (LANE_CALLS["rules-slice"].lanes.includes(lane)) ids.push("rules-slice");
4485
4586
  if (lane === "orc-quick") ids.push("extra-slot");
4587
+ if (REVIEW_POLICY_LANES.includes(lane)) ids.push("review-policy");
4486
4588
  return ids;
4487
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"];
4488
4593
 
4489
4594
  // Run ONE stand-alone command in this process and take its answer. The argv is
4490
4595
  // swapped in for the call and put back after it; stdout, stderr and exit are
@@ -4570,7 +4675,7 @@ const LANE_TRACE = {
4570
4675
  "orc-mini": {
4571
4676
  tier: "Build lanes",
4572
4677
  token: "mini",
4573
- spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "GRAPH-CONSULT", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-NOTES"],
4678
+ spine_verbs: ["PHASE", "GATE", "OUTCOME", "DRIFT", "TDD-RED", "TDD-GREEN", "FINDING", "FINDING-OUTCOME", "REVIEW-WHICH", "GRAPH-CONSULT", "GRAPH-UPDATE", "GRAPH-COMPLEXITY", "GRAPH-NOTES"],
4574
4679
  },
4575
4680
  "orc-fast": { tier: "Build lanes", token: "fast" },
4576
4681
  "orc-wiki": { tier: "Multi-dispatch", token: "wiki" },
@@ -4640,7 +4745,9 @@ const TRACE_VERBS = {
4640
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" },
4641
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" },
4642
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" },
4643
- "FINDING-OUTCOME": { grammar: "FINDING-OUTCOME addressed=<n> disputed=<n> wontfix=<n> open=<n> pre=<n> suppressed=<n> :: <cat>:<addressed>/<total>,…", emitter: "orc → writer", meaning: "ONE line at review close — what became of each finding; `orc gotcha quality` reads it", owner: "_shared/phases/trace-verbs.md" },
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" },
4644
4751
  VERDICT: { grammar: "VERDICT pass|fail :: <detail>", emitter: "verifier→orc → writer", meaning: "verification outcome", owner: "_shared/phases/verify.md" },
4645
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" },
4646
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" },
@@ -4718,7 +4825,7 @@ const LANE_PHASES = {
4718
4825
  rules: [
4719
4826
  "context-combiner", "orc", "orc-aftermath", "orc-analyze", "orc-analyze-mini",
4720
4827
  "orc-boundary", "orc-brainstorm", "orc-budget", "orc-challenge", "orc-claude",
4721
- "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",
4722
4829
  "orc-learn", "orc-mini", "orc-pact", "orc-poly", "orc-pr-driver", "orc-pr-setup",
4723
4830
  "orc-quick", "orc-retro", "orc-route", "orc-test", "orc-verify", "orc-wiki",
4724
4831
  ],
@@ -4895,7 +5002,7 @@ const LANE_OWN_PHASES = {
4895
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"] },
4896
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: [] },
4897
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"] },
4898
- { 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"] },
4899
5006
  ],
4900
5007
  "orc-learn": [
4901
5008
  { ord: 0, id: "mode-a", file: "orc-learn/SKILL.md", heading: "## Mode A — INIT", read: "section", trace_verbs: ["DISPATCH", "FINISH", "VERIFY", "WIKI-CONSULT"] },
@@ -4946,7 +5053,7 @@ const LANE_OWN_PHASES = {
4946
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"] },
4947
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"] },
4948
5055
  { ord: 2, id: "q2", file: "orc-quick/SKILL.md", heading: "## Q2 — ASK (ONE user turn: questions + the gate together)", read: "section", trace_verbs: [] },
4949
- { ord: 3, id: "q3", file: "orc-quick/SKILL.md", heading: "## Q3 — DO (dispatch → build/test → write the doc → offer)", read: "section", trace_verbs: ["DISPATCH", "VERIFY", "REPRO", "GRAPH-CHANGES", "GRAPH-UPDATE", "GRAPH-NOTES", "GRAPH-GAIN", "OUTCOME", "FINDING", "FINDING-OUTCOME", "FINISH"] },
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"] },
4950
5057
  ],
4951
5058
  "orc-wiki": [
4952
5059
  { ord: 0, id: "phase-0", file: "orc-wiki/references/phases/phase-0.md", layers: ["full"], trace_verbs: [] },
@@ -5509,10 +5616,11 @@ const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
5509
5616
  // The tier-clip roster. It must contain EVERY agent the default table names —
5510
5617
  // `diyScoreTable()` looks each row's agent up here, so a table row naming an
5511
5618
  // agent this map does not know is a crash, not a fallback. v1.0.0 W4 added the
5512
- // 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`).
5513
5621
  //
5514
5622
  // It also feeds `fixed_executor`'s option list, so every entry here is offerable
5515
- // — 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).
5516
5624
  //
5517
5625
  // Effort ranks `low(0) < medium(1) < high(2) < xhigh(3) < max(4)`. There is no
5518
5626
  // session tier at 0: an agent at low effort fits every tier of its own model,
@@ -5521,6 +5629,8 @@ const DIY_EXECUTORS = {
5521
5629
  "orc-executor-haiku-4-5": { model: 1, effort: 1 },
5522
5630
  "orc-executor-sonnet-4-6-med": { model: 2, effort: 1 },
5523
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 },
5524
5634
  "orc-executor-sonnet-5-high": { model: 3, effort: 2 },
5525
5635
  "orc-executor-opus-4-7-med": { model: 4, effort: 1 },
5526
5636
  "orc-executor-opus-4-7-high": { model: 4, effort: 2 },
@@ -5794,7 +5904,7 @@ function diyValidate(cfg) {
5794
5904
  return { errors, warnings };
5795
5905
  }
5796
5906
 
5797
- // 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
5798
5908
  // — documented drift). No more narrow/wide preset: rubric_bands is granularity
5799
5909
  // only, and this one table maps every score.
5800
5910
  //
@@ -5808,15 +5918,19 @@ function diyValidate(cfg) {
5808
5918
  // TABLE change, and an agent's model change is always a rename — conflating the
5809
5919
  // two is how a downgrade check breaks.
5810
5920
  //
5811
- // THE EDGE IS 90, AND IT IS ROUND (D13). Every other edge in this table is
5812
- // round and half-open, so `[65,90)` and `[90,101]` keep the shape; a score of
5813
- // 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).
5814
5929
  const DIY_SCORE_TABLE = [
5815
- [0, 30, "orc-executor-haiku-4-5"],
5816
- [30, 40, "orc-executor-sonnet-4-6-med"],
5817
- [40, 55, "orc-executor-sonnet-4-6-high"],
5818
- [55, 65, "orc-executor-sonnet-5-high"],
5819
- [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"],
5820
5934
  [90, 101, "orc-executor-opus-5-med"],
5821
5935
  ];
5822
5936
 
@@ -5883,8 +5997,18 @@ function diyExtraRows(cfg, claudeDir) {
5883
5997
  }
5884
5998
  if (!ledger || !ledger.routes.length) return null;
5885
5999
  const rows = [];
6000
+ const table = isTrue(map.opus5_only) ? OPUS5_SCORE_TABLE : DIY_SCORE_TABLE;
5886
6001
  for (const r of ledger.routes) {
5887
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
+ }
5888
6012
  rows.push({
5889
6013
  from: r.from,
5890
6014
  to: r.to,
@@ -5893,7 +6017,7 @@ function diyExtraRows(cfg, claudeDir) {
5893
6017
  model: r.model,
5894
6018
  routes: res.resolved === "extra",
5895
6019
  why_short: res.held_back || "not routed",
5896
- claude_agent: res.claude.agent,
6020
+ claude_agent: fallbacks.join(" / "),
5897
6021
  });
5898
6022
  }
5899
6023
  // A gap is not a hole — it is Claude, split at the Claude table's own edges by
@@ -8150,20 +8274,19 @@ function usageFor(usage, file) {
8150
8274
  }
8151
8275
 
8152
8276
  // ── the scan tier ladder (B5) — the biggest single cut ───────────────────────
8153
- // 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
8154
8278
  // delta was 2 lines or 2000. The ladder sends a small, no-new-surface delta to
8155
8279
  // the LIGHT scanner instead. Five rows, in order; first match wins.
8156
8280
  //
8157
8281
  // NEVER SILENT: the resolved tier is printed in `orc wiki plan` and in the
8158
8282
  // refresh confirmation. A cheaper model is a decision the user sees, never a
8159
8283
  // quiet substitution.
8160
- const WIKI_SCANNER_DEEP = "orc-wiki-scanner-opus-4-8-high";
8284
+ const WIKI_SCANNER_DEEP = "orc-wiki-scanner-opus-5-low";
8161
8285
  const WIKI_SCANNER_LIGHT = "orc-wiki-scanner-sonnet-5-high";
8162
- // `opus5_only` already forces the wiki scanner to the shipped
8163
- // orc-wiki-scanner-opus-5-med, so BOTH tiers collapse to that one agent while
8164
- // the flag is on. That is why the ladder adds NO row to OPUS5_ONLY_ROLES and no
8165
- // new agent pair: see _shared/opus5-only.md.
8166
- 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";
8167
8290
 
8168
8291
  const WIKI_TIER_LADDER = [
8169
8292
  { id: "first-scan", tier: "deep", why: "first scan of this area (no doc yet)" },
@@ -10285,7 +10408,7 @@ ${line || ""}`;
10285
10408
  // The helpers `bin/gotcha.js` borrows: the v1 parser and paths stay HERE, so
10286
10409
  // `GOTCHA_HEAD` has one home and a 1.9.2 CLI still reads a 2.0 file.
10287
10410
  function gotchaDeps() {
10288
- return { args, flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, repoRootOf, parseGotchas, gotchasPath, gotchasArchivePath, GOTCHA_HEAD };
10411
+ return { args, flag, positionals, emitJson, wantsJson, resolveClaudeDir, readOverride, repoRootOf, parseGotchas, gotchasPath, gotchasArchivePath, GOTCHA_HEAD, listTraces };
10289
10412
  }
10290
10413
 
10291
10414
  // An async importer that throws: one line, exit 1, never a stack in a lane.
@@ -11511,8 +11634,10 @@ function wiki() {
11511
11634
 
11512
11635
  // ── Run state: `orc resume`, `orc run list|show`, `orc stats` (v0.42.0) ─────
11513
11636
  // Three read-only commands over state ORC already writes. The design constraint
11514
- // shared by all three is SCALE: `log_dir` and `run_dir` are never auto-deleted
11515
- // 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
11516
11641
  // opened every file to build a list would get slower forever. So: enumerate with
11517
11642
  // readdir + stat ONLY, sort, then read a bounded HEAD of just the page being
11518
11643
  // displayed. `checkpoint.json` is never read for a listing — only `run show`
@@ -12212,6 +12337,57 @@ function bar(n, max, width = 12) {
12212
12337
  return "█".repeat(Math.max(1, Math.round((n / max) * width)));
12213
12338
  }
12214
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
+
12215
12391
  function stats() {
12216
12392
  const claudeDir = resolveClaudeDir();
12217
12393
  const dir = resolveLogDir(claudeDir);
@@ -12224,7 +12400,7 @@ function stats() {
12224
12400
  const empty = (msg) => {
12225
12401
  if (asJson)
12226
12402
  emitJson(
12227
- { log_dir: dir, runs: 0, from: null, to: null, lanes: {}, agents: {}, dispatches: 0, downgrades: 0, unfinished: 0, unknown_lane: 0, graph: statsGraph(claudeDir), questions: null },
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 },
12228
12404
  1
12229
12405
  );
12230
12406
  console.log(msg);
@@ -12232,57 +12408,53 @@ function stats() {
12232
12408
  };
12233
12409
 
12234
12410
  let names = [];
12411
+ let noDir = false;
12235
12412
  try {
12236
12413
  names = fs.readdirSync(dir).filter((f) => f.endsWith(".txt"));
12237
12414
  } catch (_) {
12238
- empty(`No traces yet under ${dir}. Run any ORC lane and they appear here.`);
12415
+ noDir = true;
12239
12416
  }
12240
12417
 
12241
12418
  const lanes = new Map();
12242
12419
  const agents = new Map();
12243
- 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;
12244
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
+ };
12245
12434
 
12246
12435
  for (const name of names) {
12247
- const m = TRACE_NAME.exec(name);
12248
- const g = m ? null : TRACE_GENERIC.exec(name);
12249
- if (!m && !g) continue;
12250
- const date = traceDateIso(m ? m[3] : g[1]);
12436
+ const info = traceNameInfo(name);
12437
+ if (!info) continue;
12251
12438
  // --since filters on the FILENAME date, before any file is opened.
12252
- if (typeof since === "string" && since && date < since) continue;
12253
-
12254
- const lane = m ? m[1] : "unknown";
12255
- if (!m) unknownLane++;
12256
- total++;
12257
- lanes.set(lane, (lanes.get(lane) || 0) + 1);
12258
- if (!firstDate || date < firstDate) firstDate = date;
12259
- if (!lastDate || date > lastDate) lastDate = date;
12260
-
12261
- const tail = readTail(path.join(dir, name));
12262
- const st = parseStatsLine(tail);
12263
- if (st) {
12264
- dispatches += Number(st.dispatches) || 0;
12265
- downgrades += Number(st.downgrades) || 0;
12266
- } else {
12267
- // Legacy fallback: count DISPATCH lines. Cheaper detail, same headline.
12268
- const body = fs.readFileSync(path.join(dir, name), "utf8");
12269
- dispatches += (body.match(/\bDISPATCH\s+\S/g) || []).length;
12270
- downgrades += (body.match(/⛔ DOWNGRADE/g) || []).length;
12271
- for (const a of body.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) {
12272
- const key = a.replace(/^\S+\s+/, "").replace(/-(haiku|sonnet|opus|fable)-.*$/, "");
12273
- agents.set(key, (agents.get(key) || 0) + 1);
12274
- }
12275
- }
12276
- if (st) {
12277
- for (const a of tail.match(/\bDISPATCH\s+(orc-[\w.-]+)/g) || []) {
12278
- const key = a.replace(/^\S+\s+/, "").replace(/-(haiku|sonnet|opus|fable)-.*$/, "");
12279
- agents.set(key, (agents.get(key) || 0) + 1);
12280
- }
12281
- }
12282
- 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);
12283
12442
  }
12284
12443
 
12285
- if (!total) empty(since ? `No traces on or after ${since}.` : `No traces yet under ${dir}.`);
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);
12452
+ }
12453
+
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
+ );
12286
12458
 
12287
12459
  const laneRows = [...lanes.entries()].sort((a, b) => b[1] - a[1]);
12288
12460
  const agentRows = [...agents.entries()].sort((a, b) => b[1] - a[1]);
@@ -12308,6 +12480,7 @@ function stats() {
12308
12480
  downgrades,
12309
12481
  unfinished,
12310
12482
  unknown_lane: unknownLane,
12483
+ pruned_runs: prunedRuns,
12311
12484
  graph: statsGraph(claudeDir),
12312
12485
  questions,
12313
12486
  },
@@ -12319,6 +12492,7 @@ function stats() {
12319
12492
  }
12320
12493
 
12321
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)`));
12322
12496
  console.log("\n" + ui.color.bold("Lanes"));
12323
12497
  const maxLane = laneRows[0][1];
12324
12498
  for (const [lane, n] of laneRows) {
@@ -12371,8 +12545,10 @@ function stats() {
12371
12545
  "\n" +
12372
12546
  ui.color.gray(
12373
12547
  "Counts only what traces record. `/orc-retro` and `/orc-explain` never write one,\n" +
12374
- "so they never appear here. Deleting or moving log_dir resets these numbers;\n" +
12375
- "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."
12376
12552
  )
12377
12553
  );
12378
12554
  }
@@ -12849,7 +13025,7 @@ function scoreFromFacets(f, fanIn, fanOut) {
12849
13025
  }
12850
13026
 
12851
13027
  // The resolved score→model table. `opus5_only` outranks everything (3 bands);
12852
- // 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
12853
13029
  // registry-less by design, so the forecast reports it as UNKNOWN rather than
12854
13030
  // pretending to resolve it.
12855
13031
  // v1.0.0 W4 — an ALIAS, not a second array. See OPUS5_SCORE_TABLE's comment:
@@ -12959,8 +13135,8 @@ function parsePlanTasks(text) {
12959
13135
  // Fixed roles a run dispatches besides executors. Named, not guessed: the
12960
13136
  // forecast has to say WHICH roles it priced or the total is unfalsifiable.
12961
13137
  const LANE_FIXED_ROLES = {
12962
- 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"],
12963
- 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"],
12964
13140
  mini: ["orc-analyze-mini-sonnet-5-high", "orc-planner-mini-sonnet-5-high", "orc-trace-writer-haiku-4-5"],
12965
13141
  fast: ["orc-trace-writer-haiku-4-5"],
12966
13142
  };
@@ -14397,6 +14573,82 @@ function exportCmd() {
14397
14573
 
14398
14574
  const IMPORT_CANDIDATES = ["AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md"];
14399
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
+
14400
14652
  function exportImport(claudeDir) {
14401
14653
  const asJson = wantsJson();
14402
14654
  const root = repoRootOf(claudeDir);
@@ -26481,8 +26733,8 @@ const EXTRA_SLOTS = [
26481
26733
  {
26482
26734
  slot: "wiki-scanner-deep",
26483
26735
  lane: "/orc-wiki",
26484
- claude: ["orc-wiki-scanner-opus-4-8-high"],
26485
- claude_opus5: ["orc-wiki-scanner-opus-5-med"],
26736
+ claude: ["orc-wiki-scanner-opus-5-low"],
26737
+ claude_opus5: null,
26486
26738
  asks: false,
26487
26739
  announce: "per scan-batch, beside the resolved tier",
26488
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.",
@@ -26491,7 +26743,7 @@ const EXTRA_SLOTS = [
26491
26743
  slot: "wiki-scanner-light",
26492
26744
  lane: "/orc-wiki",
26493
26745
  claude: ["orc-wiki-scanner-sonnet-5-high"],
26494
- claude_opus5: ["orc-wiki-scanner-opus-5-med"],
26746
+ claude_opus5: ["orc-wiki-scanner-opus-5-low"],
26495
26747
  asks: false,
26496
26748
  announce: "per scan-batch, beside the resolved tier",
26497
26749
  why: "the LIGHT tier is already a small no-new-surface delta on an existing doc — the cheapest work the wiki does.",
@@ -34844,7 +35096,7 @@ function doctor() {
34844
35096
  "global-skew",
34845
35097
  `GLOBAL install ~/.claude is ${globalV} but this project is ${localV} — ` +
34846
35098
  "the global copy can win skill resolution; run `orc update --global`",
34847
- { global_version: globalV, local_version: localV }
35099
+ { global_version: globalV, local_version: localV, fix_command: "orc update --global" }
34848
35100
  );
34849
35101
  } else ok(`global install ~/.claude matches (${globalV})`);
34850
35102
  // Agent files the global install still carries that this payload no
@@ -34994,6 +35246,10 @@ function doctor() {
34994
35246
  }
34995
35247
  })();
34996
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 });
34997
35253
  // An ORPHANED component — a layout naming an id this ORC no longer ships
34998
35254
  // — is REPORTED, NEVER AUTO-REPAIRED, and the fix names the item. Which
34999
35255
  // component replaces a retired one is a design decision, not a repair.
@@ -35008,9 +35264,36 @@ function doctor() {
35008
35264
  });
35009
35265
  else if (st && st.finding && Date.now() - (st.at || 0) < 24 * 60 * 60 * 1000)
35010
35266
  warn("statusline-layout-unreadable", `status line: the hook fell back to ORC's built-in lines (${st.finding})`, {
35011
- fix: "orc statusline compile",
35267
+ fix: hk.fix || "orc statusline compile",
35268
+ fix_command: hk.fix || "orc statusline compile",
35012
35269
  });
35013
- 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}` });
35014
35297
  }
35015
35298
  } catch (_) {}
35016
35299
 
@@ -41476,6 +41759,8 @@ Usage:
41476
41759
  [--check] fail when the export is stale (exit 1)
41477
41760
  orc export import read an existing AGENTS.md/.cursorrules as EVIDENCE and
41478
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"
41479
41764
  orc pattern [--dir <path>] cached code-patterns (project-scoped; no --global)
41480
41765
  orc pattern status [<lang>] whether a cached pattern exists — the deterministic
41481
41766
  existence probe every knowledge-gated lane runs first
@@ -41539,6 +41824,11 @@ Usage:
41539
41824
  edited later (exit 4) [--json]
41540
41825
  orc stats [--since YYYY-MM-DD] [--json] how much you actually use each lane and agent, counted
41541
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]
41542
41832
  orc habit show|log|points|why|accept|decline|forget|reset|doctor|export|purge your usual answers,
41543
41833
  learned from the ASK lines in your traces (config key
41544
41834
  \`habits\`, off by default). Never applied without your yes [--json]
@@ -41547,6 +41837,9 @@ Usage:
41547
41837
  \`orc rules slice\` as the LEARNED tier [--json]
41548
41838
  orc trace write --packet -|<file> write ONE phase packet into the run's trace pair (.txt +
41549
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]
41550
41843
  orc wait the deterministic half of /orc-wait — a wait costs zero
41551
41844
  tokens, because a detached command does it, not a model
41552
41845
  orc wait lanes [--json] which lanes support a wait, what each one checkpoints,
@@ -41642,6 +41935,7 @@ const WAIT_LANE_SHAPES = [
41642
41935
  { lane: "/orc-pattern", checkpoint: "none", safe_point: "single dispatch" },
41643
41936
  { lane: "/orc-claude", checkpoint: "none", safe_point: "single dispatch" },
41644
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" },
41645
41939
  { lane: "/orc-route", checkpoint: "none", safe_point: "read-only, seconds long" },
41646
41940
  { lane: "/orc-boundary", checkpoint: "none", safe_point: "read-only, seconds long" },
41647
41941
  { lane: "/orc-budget", checkpoint: "none", safe_point: "read-only, seconds long" },
@@ -42630,6 +42924,10 @@ const STATUSLINE_RENDERERS = {
42630
42924
  // motion
42631
42925
  motif: { kind: "motif", needs: "motif" },
42632
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" },
42633
42931
  };
42634
42932
 
42635
42933
  // WHICH RENDERERS DRAW A LABEL. Seven of thirty-five, and the other
@@ -42666,7 +42964,18 @@ function slRendererUses(name) {
42666
42964
  out.push("ramp");
42667
42965
  if (!out.includes("width")) out.push("width");
42668
42966
  }
42669
- 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)];
42670
42979
  }
42671
42980
 
42672
42981
  // ── The glyph sets (design-language.md §2) ──────────────────────────────────
@@ -42831,7 +43140,41 @@ const SL_THEME_ABOUT = {
42831
43140
 
42832
43141
  const SL_FORMATS = ["percent", "ratio", "fraction", "decimal", "plain"];
42833
43142
  const SL_COMPACT = ["off", "si", "bytes"];
42834
- 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
+ }
42835
43178
  const SL_TRUNCATE = ["end", "middle", "none"];
42836
43179
 
42837
43180
  // ── The component registry ─────────────────────────────────────────────────
@@ -42886,6 +43229,26 @@ function slRow(o) {
42886
43229
  );
42887
43230
  }
42888
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
+
42889
43252
  const STATUSLINE_COMPONENTS = [
42890
43253
  // ── Group A — Session and tier. Straight from the payload. Free. ─────────
42891
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" }),
@@ -42917,9 +43280,9 @@ const STATUSLINE_COMPONENTS = [
42917
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 }),
42918
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" }),
42919
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" }),
42920
- 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" }),
42921
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" }),
42922
- 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 }),
42923
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" }),
42924
43287
 
42925
43288
  // ── Group C — Run state. From the ONE throttled scan. ────────────────────
@@ -42970,7 +43333,7 @@ const STATUSLINE_COMPONENTS = [
42970
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" }),
42971
43334
  slRow({ id: "worktree-branch", group: "H", summary: "The worktree's branch.", renderers: ["bare", "plain", "paren"], defaults: { render: "bare" }, unknown: "hide", binding: "worktree.branch" }),
42972
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" }),
42973
- 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" }),
42974
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" }),
42975
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" }),
42976
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" }),
@@ -42983,9 +43346,9 @@ const STATUSLINE_COMPONENTS = [
42983
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" }),
42984
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 }),
42985
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 }),
42986
- 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" }),
42987
- 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" }),
42988
- 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" }),
42989
43352
 
42990
43353
 
42991
43354
  // ── Group D — Knowledge. Every row here comes out of wiki-meta.json, the
@@ -43043,7 +43406,7 @@ const STATUSLINE_COMPONENTS = [
43043
43406
 
43044
43407
  // ── Group I remainder ───────────────────────────────────────────────────
43045
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" }),
43046
- 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" }),
43047
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" }),
43048
43411
 
43049
43412
  // ── REFUSED, with the measurement recorded ──────────────────────────────
@@ -43065,6 +43428,14 @@ const STATUSLINE_COMPONENTS = [
43065
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 }),
43066
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" }),
43067
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
+
43068
43439
  // ── Group J — Composite shortcuts. One slot, several facts. ─────────────
43069
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"] }),
43070
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"] }),
@@ -43243,6 +43614,7 @@ function slResolveItem(item, layout, line) {
43243
43614
 
43244
43615
  out.id = item.id;
43245
43616
  out.type = item.type;
43617
+ out._theme = themeName;
43246
43618
  out._comp = comp;
43247
43619
  out._from = from;
43248
43620
  return out;
@@ -43345,6 +43717,8 @@ function slValidate(layout) {
43345
43717
  E(`"${r}" on "${comp.id}" — ${comp.id} has no states, so there is nothing for a shape to say.`);
43346
43718
  if (rend.needs === "series" && !comp.series)
43347
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.`);
43348
43722
  if (rend.needs === "motif" && !comp.state_binding)
43349
43723
  E(`"${r}" on "${comp.id}" — motion is only for a component with a phase kind.`);
43350
43724
  if (rend.width && item.width != null) {
@@ -43384,8 +43758,17 @@ function slValidate(layout) {
43384
43758
  const given = item.params || {};
43385
43759
  for (const k of Object.keys(given)) {
43386
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`);
43387
43767
  }
43768
+ } else if (item.params && Object.keys(item.params).length) {
43769
+ E(`"${comp.id}" takes no parameters (${where})`);
43388
43770
  }
43771
+ slValidateDesign(item, comp, where, board, E);
43389
43772
 
43390
43773
  // ── Tier 3 — design. The rules a human gets wrong and a machine can
43391
43774
  // check. WARNINGS NEVER BLOCK: an error is a refusal, a warning is a
@@ -43404,12 +43787,21 @@ function slValidate(layout) {
43404
43787
  });
43405
43788
  });
43406
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
+
43407
43799
  // Cross-item design warnings.
43408
43800
  layout.lines.forEach((line, li) => {
43409
43801
  const bindings = new Map();
43410
43802
  for (const it of line.items || []) {
43411
43803
  const c = SL_BY_ID.get(it.type);
43412
- if (!c || !c.binding) continue;
43804
+ if (!c || !c.binding || c.binding === "static.text") continue;
43413
43805
  bindings.set(c.binding, (bindings.get(c.binding) || 0) + 1);
43414
43806
  }
43415
43807
  for (const [b, n] of bindings) {
@@ -43426,6 +43818,81 @@ function slValidate(layout) {
43426
43818
  return { ok: errors.length === 0, errors, warnings };
43427
43819
  }
43428
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
+
43429
43896
  // spacer / divider / fill do not count against the 5-per-line limit: the limit
43430
43897
  // is about how much a line SAYS, and none of the three says anything.
43431
43898
  function slIsStructural(type) {
@@ -43524,7 +43991,8 @@ function slCompile(layout) {
43524
43991
  : b.startsWith("wiki.") ? "scan.wiki"
43525
43992
  : b.startsWith("diy.") ? "scan.diy"
43526
43993
  : b.startsWith("update.") ? "scan.update"
43527
- : "scan.trace";
43994
+ : b.startsWith("weather.") ? "scan.weather"
43995
+ : "scan.trace";
43528
43996
  // W3's groups all read files the shipped status line never opens, so they
43529
43997
  // share one gate — the extended scan — and each sub-read inside it carries
43530
43998
  // its own TTL. One gate, many clocks.
@@ -43572,10 +44040,21 @@ function slCompile(layout) {
43572
44040
  // takes its separator with it. This is Starship's conditional group, and
43573
44041
  // it is what stops a dangling ` · · `. No user ever configures it.
43574
44042
  const children = [];
43575
- 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
+ }
43576
44055
  visible++;
43577
44056
 
43578
- 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);
43579
44058
 
43580
44059
  const hide = (r.hide_when || []).filter((h) => h !== "never");
43581
44060
  const itemOp = {
@@ -43598,6 +44077,11 @@ function slCompile(layout) {
43598
44077
  if (slIsStructural(item.type)) itemOp.s = 1;
43599
44078
  if (r.min_cols) itemOp.min_cols = r.min_cols;
43600
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;
43601
44085
  ops.push(itemOp);
43602
44086
 
43603
44087
  // The responsive drop order: priority first (1 = keep longest), then
@@ -43605,23 +44089,27 @@ function slCompile(layout) {
43605
44089
  dropCandidates.push({ id: item.id, line: li, pos: pi, priority: r.priority || 3, min_cols: r.min_cols || 0 });
43606
44090
  });
43607
44091
 
43608
- 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);
43609
44095
  });
43610
44096
 
43611
44097
  // S5 — the responsive plans. Derived from each item's min_cols and priority;
43612
44098
  // the hook does ONE lookup and no measuring at render time.
43613
44099
  dropCandidates.sort((a, b) => b.priority - a.priority || b.pos - a.pos);
43614
44100
  const widths = [160, 120, 100, 80, 72];
43615
- 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.
43616
44102
  for (const w of widths) {
43617
- if (totalStatic <= w) continue;
43618
44103
  const drop = [];
43619
- let est = totalStatic;
43620
- for (const c of dropCandidates) {
43621
- if (est <= w) break;
43622
- drop.push(c.id);
43623
- est -= 8; // a conservative per-item estimate; the exact width is measured
43624
- }
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
+ });
43625
44113
  if (drop.length) plans.push({ cols: w, drop });
43626
44114
  }
43627
44115
  plans.sort((a, b) => a.cols - b.cols);
@@ -43658,7 +44146,8 @@ function slCompile(layout) {
43658
44146
  bindings: [...bindings].sort(),
43659
44147
  providers: [...providers].sort(),
43660
44148
  series: [...seriesWanted].sort(),
43661
- 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,
43662
44151
  // THE CAP TRAVELS WITH THE PROGRAM (v1.4.2). A hook cannot call this CLI —
43663
44152
  // a status line re-renders on every keystroke — so before this it simply
43664
44153
  // held its own `5`, and raising the cap here left every legal six-part line
@@ -43671,12 +44160,33 @@ function slCompile(layout) {
43671
44160
  return { compiled, lock };
43672
44161
  }
43673
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
+
43674
44169
  function slLowerItem(r, comp, T) {
43675
44170
  const ops = [];
43676
44171
  const rend = STATUSLINE_RENDERERS[r.render] || STATUSLINE_RENDERERS.bare;
43677
- 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;
43678
44181
  const caseOf = r.case || "none";
43679
- 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
+ };
43680
44190
 
43681
44191
  const fmt = {
43682
44192
  kind: r.format || "plain",
@@ -43693,19 +44203,43 @@ function slLowerItem(r, comp, T) {
43693
44203
  prefix: r.prefix ? String(r.prefix) : "",
43694
44204
  suffix: r.suffix ? String(r.suffix) : "",
43695
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";
43696
44211
  const fi = T.idxOf(T.formats, fmt);
43697
- 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
+ }
43698
44220
  const gi = T.idxOf(T.glyphsets, gs);
43699
44221
 
43700
44222
  const labelSgr = slSgr(r.label_color, r.emphasis, r.bg);
43701
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);
43702
44226
  let ri = null;
43703
- 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);
43704
44234
  if (rampName && STATUSLINE_RAMPS[rampName] && rampName !== "state") {
43705
44235
  const rp = STATUSLINE_RAMPS[rampName];
43706
44236
  const stops = r.ramp_stops || rp.stops;
43707
44237
  ri = T.idxOf(T.ramps, { stops, sgr: rp.colors.map((c, i) => slSgr(c, rp.emphasis ? [rp.emphasis[i]] : null, null) || "") });
43708
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) || "") });
43709
44243
 
43710
44244
  // The state map: glyph, ASCII twin and colour per state. Built ONCE here so
43711
44245
  // the `state` renderer and the `word` renderer can never disagree about what
@@ -43713,8 +44247,8 @@ function slLowerItem(r, comp, T) {
43713
44247
  const stateMap = () => {
43714
44248
  const map = { glyphs: {}, sgr: {}, ascii: {} };
43715
44249
  for (const st of comp.states || []) {
43716
- map.glyphs[st] = (comp.shapes && comp.shapes[st]) || st.slice(0, 1);
43717
- 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]);
43718
44252
  const c = (r.color_by_state && r.color_by_state[st]) || slStateColor(st, r, T.layout);
43719
44253
  const sg = slSgr(c, r.emphasis, r.bg);
43720
44254
  if (sg) map.sgr[st] = sg;
@@ -43733,10 +44267,65 @@ function slLowerItem(r, comp, T) {
43733
44267
  const pushLabel = (text, sep) => {
43734
44268
  if (!text) return;
43735
44269
  if (labelSgr) ops.push({ op: "sgr", s: labelSgr });
43736
- 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));
43737
44278
  if (labelSgr) ops.push({ op: "reset" });
43738
44279
  };
43739
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
+
43740
44329
  // A GROUP: its children are lowered inside its brackets and share its
43741
44330
  // background, so a run of chips reads as one object. It is the one place an
43742
44331
  // item op has an item op inside it, and the nesting stops there.
@@ -43756,11 +44345,39 @@ function slLowerItem(r, comp, T) {
43756
44345
  return ops;
43757
44346
  }
43758
44347
 
43759
- 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
+ };
43760
44374
 
43761
44375
  const valueOp = () => {
43762
44376
  if (rend.kind === "bar") {
43763
- 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);
43764
44381
  }
43765
44382
  if (rend.kind === "series") {
43766
44383
  return { op: "series", b: comp.binding, s: comp.series, k: rend.sub, g: gi, w: r.width || rend.dflt_w || 8 };
@@ -43771,11 +44388,20 @@ function slLowerItem(r, comp, T) {
43771
44388
  }
43772
44389
  if (rend.kind === "state") {
43773
44390
  const map = stateMap();
43774
- 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 } : {}) };
43775
44399
  }
43776
44400
  // text
43777
44401
  const op = { op: "val", b: comp.binding, f: fi };
43778
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;
43779
44405
  // A ramp derives the colour FROM THE VALUE, so it applies to a number just
43780
44406
  // as much as to a bar — `context 38%` green and `context 94%` red is the
43781
44407
  // whole point of one.
@@ -43791,13 +44417,52 @@ function slLowerItem(r, comp, T) {
43791
44417
  // A state, a motif, a ramped bar and a ramped/state-coloured number all
43792
44418
  // carry their OWN colour. Wrapping them in the theme's value colour as well
43793
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
+ }
43794
44428
  if (!valueSgr) return [inner];
43795
- if (inner.op === "state" || inner.op === "motif") return [inner];
43796
- if (inner.op === "bar" && inner.r != null) return [inner];
43797
- if (inner.op === "val" && (inner.r != null || inner.m != null)) return [inner];
43798
44429
  return [{ op: "sgr", s: valueSgr }, inner, { op: "reset" }];
43799
44430
  };
43800
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
+
43801
44466
  switch (r.render) {
43802
44467
  case "bare":
43803
44468
  case "word":
@@ -43846,9 +44511,11 @@ function slLowerItem(r, comp, T) {
43846
44511
  // The two-tone chip: the label in reverse video, the value outside it.
43847
44512
  if (label) {
43848
44513
  const chip = slSgr(r.label_color, (r.emphasis || []).concat(["reverse"]), r.bg);
44514
+ if (open) ops.push(slLit(open));
43849
44515
  if (chip) ops.push({ op: "sgr", s: chip });
43850
44516
  ops.push(slLit(applyCase(label)));
43851
44517
  if (chip) ops.push({ op: "reset" });
44518
+ if (close) { ops.push(slLit(close)); }
43852
44519
  }
43853
44520
  for (const o of wrapValue(valueOp())) ops.push(o);
43854
44521
  break;
@@ -43863,7 +44530,7 @@ function slLowerItem(r, comp, T) {
43863
44530
  // The user's own worked example: number THEN bar.
43864
44531
  for (const o of wrapValue({ op: "val", b: comp.binding, f: fi })) ops.push(o);
43865
44532
  ops.push(slLit(" "));
43866
- 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 }));
43867
44534
  break;
43868
44535
  }
43869
44536
  case "link": {
@@ -43876,7 +44543,9 @@ function slLowerItem(r, comp, T) {
43876
44543
  for (const o of wrapValue(valueOp())) ops.push(o);
43877
44544
  }
43878
44545
 
43879
- 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));
43880
44549
  return ops;
43881
44550
  }
43882
44551
 
@@ -43994,6 +44663,12 @@ function slMeasure(ops, T) {
43994
44663
  case "motif":
43995
44664
  w += 1;
43996
44665
  break;
44666
+ case "sprite":
44667
+ w += op.w || 16;
44668
+ break;
44669
+ case "flex":
44670
+ w += op.min || 1;
44671
+ break;
43997
44672
  case "item":
43998
44673
  w += (op.pad_l || 0) + (op.pad_r || 0);
43999
44674
  walk(op.children || []);
@@ -44063,6 +44738,25 @@ const STATUSLINE_PRESETS = {
44063
44738
  [],
44064
44739
  ],
44065
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
+ },
44066
44760
  // ── the second board (v1.4.0) ──────────────────────────────────────────
44067
44761
  "agent-default": {
44068
44762
  board: "subagent",
@@ -44262,13 +44956,13 @@ const SL_FIXTURES = {
44262
44956
  prompt_cache: { warm: true, ttl: "5m", hit_ratio: 0.91, requests: 214, misses: 19, cache_write_tokens: 48000, recache_tokens_if_cold: 45000 },
44263
44957
  workspace: { project_dir: "/repo", repo: { host: "github.com", owner: "azure-id", name: "orc" } },
44264
44958
  },
44265
- 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] } },
44266
- 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" } },
44267
44961
  },
44268
44962
  degraded: {
44269
44963
  label: "the wrong tier, a full window, and nothing running",
44270
44964
  payload: {
44271
- model: { id: "claude-sonnet-5", display_name: "Sonnet 5" },
44965
+ model: { id: "claude-sonnet-5-5", display_name: "Sonnet 5" },
44272
44966
  effort: { level: "low" },
44273
44967
  context_window: { used_percentage: 94, remaining_percentage: 6 },
44274
44968
  rate_limits: { five_hour: { used_percentage: 93, resets_at: null }, seven_day: { used_percentage: 88, resets_at: null } },
@@ -44321,7 +45015,7 @@ function slDerived(payload) {
44321
45015
  return { verdict, reasons, version: currentVersion() };
44322
45016
  }
44323
45017
 
44324
- function slPreview(compiled, fixtureName, cols, overrides) {
45018
+ function slPreview(compiled, fixtureName, cols, overrides, at) {
44325
45019
  const fx = SL_FIXTURES[fixtureName] || SL_FIXTURES.healthy;
44326
45020
  const payload = JSON.parse(JSON.stringify(fx.payload));
44327
45021
  for (const [k, v] of Object.entries(overrides || {})) slDeepSet(payload, k, v);
@@ -44332,7 +45026,8 @@ function slPreview(compiled, fixtureName, cols, overrides) {
44332
45026
  scan: fx.scan || {},
44333
45027
  derived: slDerived(payload),
44334
45028
  task: SL_TASK_FIXTURES[fixtureName] || SL_TASK_FIXTURES.healthy,
44335
- now,
45029
+ // `at` moves ONLY the clock, so an animated preview draws the same session.
45030
+ now: at || now,
44336
45031
  cols: cols || 0,
44337
45032
  env: Object.assign({}, process.env, { COLUMNS: String(cols || 120) }),
44338
45033
  };
@@ -44394,6 +45089,8 @@ function statusline() {
44394
45089
  return slCloneCmd(claudeDir, p);
44395
45090
  case "compile":
44396
45091
  return slCompileCmd(claudeDir, true);
45092
+ case "refresh":
45093
+ return slRefreshCmd(claudeDir, p);
44397
45094
  default:
44398
45095
  console.error(`usage: orc statusline components|show|explain|validate|preview [--json]
44399
45096
  orc statusline set <line> <pos> <type> [--render R] [--label L] [--color C] [--width N] …
@@ -44405,6 +45102,8 @@ function statusline() {
44405
45102
  orc statusline expand <line>:<pos> a composite, or a group, back into its parts
44406
45103
  orc statusline clone <line>:<pos>
44407
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
44408
45107
 
44409
45108
  COMPOSE THIS IN \`orc ui\` ▸ CLI Hook Interface. These flags exist so the panel
44410
45109
  has something to shell — typing a three-line layout is worse than the board.`);
@@ -44461,7 +45160,9 @@ function slComponentsCmd() {
44461
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) }])),
44462
45161
  // Which shapes DO draw a label, so a refusal can name them instead of
44463
45162
  // saying "not here".
44464
- 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",
44465
45166
  glyph_sets: Object.keys(STATUSLINE_GLYPHSETS),
44466
45167
  ramps: Object.fromEntries(Object.entries(STATUSLINE_RAMPS).map(([k, v]) => [k, { stops: v.stops, colors: v.colors, why: v.why }])),
44467
45168
  themes: STATUSLINE_THEMES,
@@ -44469,6 +45170,18 @@ function slComponentsCmd() {
44469
45170
  formats: SL_FORMATS,
44470
45171
  compact: SL_COMPACT,
44471
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,
44472
45185
  truncate: SL_TRUNCATE,
44473
45186
  emphasis: Object.keys(SL_EMPHASIS),
44474
45187
  refused_emphasis: SL_REFUSED_EMPHASIS,
@@ -44516,6 +45229,7 @@ const SL_GROUP_NAMES = {
44516
45229
  H: "Project and VCS",
44517
45230
  I: "Static and structural",
44518
45231
  J: "Composite shortcuts",
45232
+ M: "Pets and motion",
44519
45233
  };
44520
45234
 
44521
45235
  // A dropdown of the word `braille-bar` tells nobody anything, so every renderer
@@ -44550,11 +45264,17 @@ const SL_ITEM_FIELDS = [
44550
45264
  "glyphs", "format", "case", "truncate", "compact", "prefix", "suffix",
44551
45265
  "emphasis", "hide_when", "width", "precision", "min_width", "min_cols",
44552
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",
44553
45272
  ];
44554
45273
  // The ones whose absence is an empty LIST rather than a null — a control that
44555
45274
  // iterates a null throws, and a null here would read as "unknown" when it means
44556
45275
  // "nothing selected".
44557
- 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"]);
44558
45278
 
44559
45279
  function slAuthoredFields(it) {
44560
45280
  const out = {};
@@ -44576,11 +45296,31 @@ function slShowCmd(claudeDir) {
44576
45296
  try {
44577
45297
  preview = slPreview(slCompile(eff).compiled, "healthy", 120, {}).text;
44578
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
+ };
44579
45316
  const lines = eff.lines.map((l, i) => ({
44580
45317
  line: i + 1,
44581
45318
  separator: l.separator,
44582
45319
  theme: l.theme,
44583
45320
  max_width: l.max_width || 0,
45321
+ align: l.align || "left",
45322
+ sep_color: l.sep_color || null,
45323
+ prefix: l.prefix || "",
44584
45324
  count: (l.items || []).length,
44585
45325
  counted: (l.items || []).filter((it) => !slIsStructural(it.type)).length,
44586
45326
  full: (l.items || []).filter((it) => !slIsStructural(it.type)).length >= SL_MAX_PER_LINE,
@@ -44602,8 +45342,9 @@ function slShowCmd(claudeDir) {
44602
45342
  // cannot tell "you chose the theme's colour" from "you chose nothing"
44603
45343
  // cannot offer to clear it.
44604
45344
  authored: slAuthoredFields(it),
45345
+ sample: sampleOf(it, l),
44605
45346
  };
44606
- 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;
44607
45348
  if (it.children) row.children = it.children.map((k) => ({ id: k.id, type: k.type, authored: slAuthoredFields(k) }));
44608
45349
  return row;
44609
45350
  }),
@@ -44622,11 +45363,16 @@ function slShowCmd(claudeDir) {
44622
45363
  errors: v.errors,
44623
45364
  warnings: v.warnings,
44624
45365
  preview,
45366
+ hook,
45367
+ refresh_interval: slRefreshInterval(hook, slBoard()),
45368
+ needs_refresh: (lockNow && lockNow.needs_refresh_interval) || null,
45369
+ animated,
44625
45370
  dense_prefix: "A line may hold a component only if every line above it holds at least one.",
44626
45371
  }, v.ok ? 0 : 1);
44627
45372
  console.log("");
44628
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")));
44629
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)));
44630
45376
  console.log("");
44631
45377
  for (const l of lines) {
44632
45378
  const cells = l.items.map((i) => ui.color.cyan(i.type) + ui.color.gray("/" + i.render)).join(ui.color.gray(" · "));
@@ -44712,8 +45458,18 @@ function slPreviewCmd(claudeDir) {
44712
45458
  if (k) overrides[k] = val;
44713
45459
  }
44714
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
+ }
44715
45469
  const { compiled } = slCompile(layout);
44716
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);
44717
45473
  // R4: every design must survive three strippings, and the panel shows all
44718
45474
  // three as small rows under the main preview — not as an afterthought tab.
44719
45475
  const strip = (env) => {
@@ -44744,6 +45500,8 @@ function slPreviewCmd(claudeDir) {
44744
45500
  fixture: (SL_FIXTURES[state] || SL_FIXTURES.healthy).label,
44745
45501
  fixtures: Object.fromEntries(Object.entries(SL_FIXTURES).map(([k, f]) => [k, f.label])),
44746
45502
  text: base.text,
45503
+ frames,
45504
+ frame_ms: frameMs,
44747
45505
  strippings: {
44748
45506
  no_color: strip({ NO_COLOR: "1" }),
44749
45507
  ascii: strip({ ORC_STATUSLINE_ASCII: "1" }),
@@ -44792,20 +45550,95 @@ function slSetCmd(claudeDir, p) {
44792
45550
  process.exit(2);
44793
45551
  }
44794
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
+ }
44795
45566
  if (type) item.type = type;
45567
+ const COLOR_KEYS = new Set(["color", "label_color", "value_color", "bg"]);
44796
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"]]) {
44797
- const v = flag(f);
44798
- 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;
44799
45574
  }
44800
45575
  for (const [k, f] of [["width", "--width"], ["precision", "--precision"], ["min_width", "--min-width"], ["min_cols", "--min-cols"], ["max_cols", "--max-cols"], ["priority", "--priority"]]) {
44801
- const v = flag(f);
44802
- if (typeof v === "string" && v !== "") item[k] = Number(v);
44803
- }
44804
- const emph = flag("--emphasis");
44805
- if (typeof emph === "string") item.emphasis = emph.split(",").map((s) => s.trim()).filter(Boolean);
44806
- const hide = flag("--hide-when");
44807
- 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);
44808
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
+ }
44809
45642
  if (!existing) {
44810
45643
  line.items = line.items || [];
44811
45644
  line.items.splice(Math.min(pi - 1, line.items.length), 0, item);
@@ -44864,13 +45697,20 @@ function slLineCmd(claudeDir, p) {
44864
45697
  process.exit(2);
44865
45698
  }
44866
45699
  const line = layout.lines[n - 1];
44867
- const sep = flag("--separator");
45700
+ const sep = slFlag("--separator");
44868
45701
  if (typeof sep === "string") line.separator = sep;
44869
- const pre = flag("--prefix");
45702
+ const pre = slFlag("--prefix");
44870
45703
  if (typeof pre === "string") line.prefix = pre;
44871
- const mw = flag("--max-width");
45704
+ const mw = slFlag("--max-width");
44872
45705
  if (typeof mw === "string") line.max_width = Number(mw) || 0;
44873
- 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");
44874
45714
  if (typeof th === "string") {
44875
45715
  if (!STATUSLINE_THEMES[th]) {
44876
45716
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-theme", theme: th, known: Object.keys(STATUSLINE_THEMES) }, 2);
@@ -44890,7 +45730,7 @@ function slLineCmd(claudeDir, p) {
44890
45730
  // per scope, and the panel calls the one that matches the control.
44891
45731
  function slDocCmd(claudeDir, p) {
44892
45732
  const layout = slLoadOrDefault(claudeDir);
44893
- const th = flag("--theme");
45733
+ const th = slFlag("--theme");
44894
45734
  if (typeof th === "string") {
44895
45735
  if (!STATUSLINE_THEMES[th]) {
44896
45736
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-theme", theme: th, known: Object.keys(STATUSLINE_THEMES) }, 2);
@@ -44904,7 +45744,7 @@ function slDocCmd(claudeDir, p) {
44904
45744
  // shadowed is the one failure this whole command exists to fix.
44905
45745
  for (const l of layout.lines) l.theme = null;
44906
45746
  }
44907
- const gl = flag("--glyphs");
45747
+ const gl = slFlag("--glyphs");
44908
45748
  if (typeof gl === "string") {
44909
45749
  if (!STATUSLINE_GLYPHSETS[gl]) {
44910
45750
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-glyphs", glyphs: gl, known: Object.keys(STATUSLINE_GLYPHSETS) }, 2);
@@ -44913,7 +45753,7 @@ function slDocCmd(claudeDir, p) {
44913
45753
  }
44914
45754
  layout.glyphs = gl;
44915
45755
  }
44916
- const an = flag("--ansi");
45756
+ const an = slFlag("--ansi");
44917
45757
  if (typeof an === "string") {
44918
45758
  if (!["auto", "off"].includes(an)) {
44919
45759
  if (wantsJson()) return emitJson({ ok: false, reason: "unknown-ansi", ansi: an, known: ["auto", "off"] }, 2);
@@ -44922,7 +45762,7 @@ function slDocCmd(claudeDir, p) {
44922
45762
  }
44923
45763
  layout.ansi = an;
44924
45764
  }
44925
- const align = flag("--align-columns");
45765
+ const align = slFlag("--align-columns");
44926
45766
  if (typeof align === "string") layout.align_columns = align === "on";
44927
45767
  return slSaveAndCompile(claudeDir, layout, { action: "doc", theme: layout.theme, glyphs: layout.glyphs, ansi: layout.ansi, align_columns: !!layout.align_columns });
44928
45768
  }
@@ -45005,14 +45845,18 @@ function slSaveAndCompile(claudeDir, layout, info, skipWrite) {
45005
45845
  if (!skipWrite) slWriteLayout(claudeDir, layout);
45006
45846
  fs.writeFileSync(paths.compiled, JSON.stringify(compiled, null, 2) + "\n");
45007
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);
45008
45851
  let preview = null;
45009
45852
  try {
45010
45853
  preview = slPreview(compiled, "healthy", 120, {}).text;
45011
45854
  } catch (_) {}
45012
- 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);
45013
45856
  if (wantsJson()) return emitJson(out, 0);
45014
45857
  console.log("");
45015
45858
  console.log(" " + ui.mark.ok(`${info.action} — compiled`));
45859
+ if (!hook.match && hook.fix) console.log(" " + ui.mark.warn(slHookSentence(hook)));
45016
45860
  if (preview) for (const row of preview.split("\n")) console.log(" " + row);
45017
45861
  for (const w of v.warnings) console.log(" " + ui.mark.warn(w));
45018
45862
  if (lock.needs_refresh_interval)
@@ -45028,6 +45872,141 @@ function slSaveAndCompile(claudeDir, layout, info, skipWrite) {
45028
45872
  console.log("");
45029
45873
  }
45030
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
+
45031
46010
 
45032
46011
  // ── orc rules (v1.7.0) — the anti-slop rule surface ─────────────────────────
45033
46012
  //
@@ -45143,6 +46122,8 @@ const RULE_LANE_PACKS = {
45143
46122
  "orc-test": ["writing", "delivery"],
45144
46123
  "orc-route": ["writing", "delivery"],
45145
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"],
45146
46127
  "context-combiner": ["writing", "delivery"],
45147
46128
  };
45148
46129
 
@@ -46265,6 +47246,11 @@ function jsonCrash(err) {
46265
47246
  case "gotcha":
46266
47247
  gotcha();
46267
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;
46268
47254
  case "graph":
46269
47255
  graphCmd();
46270
47256
  break;
@@ -46276,7 +47262,14 @@ function jsonCrash(err) {
46276
47262
  // v2.0.0 T21 — the CLI holds the trace pen: one packet → the .txt + .jsonl
46277
47263
  // pair. The Haiku writer is the fallback when this exits ≠ 0.
46278
47264
  case "trace":
46279
- require("./trace-write.js").traceCmd({ flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride });
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());
46280
47273
  break;
46281
47274
  case "mock":
46282
47275
  mock();
@@ -46308,6 +47301,10 @@ function jsonCrash(err) {
46308
47301
  case "export":
46309
47302
  exportCmd();
46310
47303
  break;
47304
+ // v2.1.0 — does the project ask for its own review? Read-only probe.
47305
+ case "review":
47306
+ reviewCmd();
47307
+ break;
46311
47308
  // v0.47.0 — the lane that refuses to produce. Every subcommand is a READ
46312
47309
  // with an exit-code contract except `init`, `record`, `accept`, `rebut`,
46313
47310
  // `template`, `goals` and `report`, which are the ledger's only writers.