@azure-id/orc 1.7.1 → 1.8.1

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 (58) hide show
  1. package/CHANGELOG.md +3649 -3381
  2. package/README-id.md +923 -844
  3. package/README.md +836 -788
  4. package/bin/build-agents.js +43 -27
  5. package/bin/cli.js +701 -3
  6. package/bin/graph-extract.js +927 -0
  7. package/bin/graph-notes.js +188 -0
  8. package/bin/graph-query.js +808 -0
  9. package/bin/graph-resolve.js +178 -0
  10. package/bin/graph-signals.js +277 -0
  11. package/bin/graph.js +605 -0
  12. package/bin/verify-contracts.js +4669 -4553
  13. package/bin/verify-package.js +626 -616
  14. package/bin/webui/api.js +1419 -1414
  15. package/bin/webui/fixtures/index.js +579 -576
  16. package/bin/webui/fixtures/knowledge.js +316 -291
  17. package/bin/webui/i18n/en/knowledge.json +167 -151
  18. package/bin/webui/i18n/en/overview.json +101 -100
  19. package/bin/webui/i18n/id/knowledge.json +167 -151
  20. package/bin/webui/i18n/id/overview.json +101 -100
  21. package/bin/webui/js/panels/knowledge.js +1065 -1006
  22. package/bin/webui/js/panels/overview.js +492 -488
  23. package/package.json +39 -39
  24. package/templates/agents/MODEL-MAPPING.md +163 -158
  25. package/templates/agents/orc-executor-haiku-4-5.md +133 -121
  26. package/templates/agents/orc-executor-opus-4-7-high.md +134 -122
  27. package/templates/agents/orc-executor-opus-4-7-med.md +134 -122
  28. package/templates/agents/orc-executor-opus-4-8-high.md +134 -122
  29. package/templates/agents/orc-executor-opus-5-high.md +134 -122
  30. package/templates/agents/orc-executor-opus-5-low.md +134 -122
  31. package/templates/agents/orc-executor-opus-5-med.md +134 -122
  32. package/templates/agents/orc-executor-sonnet-4-6-high.md +134 -122
  33. package/templates/agents/orc-executor-sonnet-4-6-med.md +134 -122
  34. package/templates/agents/orc-executor-sonnet-5-high.md +134 -122
  35. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +86 -0
  36. package/templates/hooks/README.md +444 -396
  37. package/templates/hooks/orc-graph-hook.js +336 -0
  38. package/templates/hooks/orc-statusline-render.js +922 -921
  39. package/templates/hooks/orc-statusline.js +1596 -1545
  40. package/templates/skills/_shared/README.md +4 -0
  41. package/templates/skills/_shared/code-graph.md +220 -0
  42. package/templates/skills/_shared/opus5-only.md +4 -0
  43. package/templates/skills/_shared/phases/execution.md +166 -147
  44. package/templates/skills/_shared/phases/planning.md +142 -135
  45. package/templates/skills/_shared/phases/preflight.md +132 -118
  46. package/templates/skills/_shared/phases/review.md +63 -53
  47. package/templates/skills/_shared/phases/ship.md +96 -88
  48. package/templates/skills/_shared/phases/trace.md +6 -0
  49. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  50. package/templates/skills/_shared/read-ladder.md +124 -102
  51. package/templates/skills/_shared/return-validation.md +259 -250
  52. package/templates/skills/orc/SKILL.md +255 -254
  53. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  54. package/templates/skills/orc-fast/SKILL.md +236 -229
  55. package/templates/skills/orc-mini/SKILL.md +267 -259
  56. package/templates/skills/orc-quick/SKILL.md +378 -361
  57. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  58. package/templates/skills/orc-wiki/references/staleness.md +294 -288
package/bin/webui/api.js CHANGED
@@ -1,1414 +1,1419 @@
1
- "use strict";
2
- /**
3
- * api.js — the /api router for `orc ui`.
4
- *
5
- * THE ONE ARCHITECTURAL RULE: this file never re-implements CLI logic. Every
6
- * endpoint spawns `node bin/cli.js <cmd> --json` and forwards the parsed
7
- * object. That makes UI/CLI drift structurally impossible — the UI *is* the
8
- * CLI — and it means every write inherits the CLI's validators, the LEGACY_KEYS
9
- * aliasing and the shadowing announcements for free, with zero duplicated
10
- * logic.
11
- *
12
- * The alternative (requiring cli.js as a library) is off the table: cli.js ends
13
- * with a bare IIFE, has no require.main guard, prints and process.exit()s
14
- * directly, and is pinned by contract tokens with binFiles: ["bin/cli.js"].
15
- *
16
- * It also never RUNS a lane and never calls a model API. Since v0.43.4 there is
17
- * exactly one deliberate exception to "never spawns claude": the Experiment
18
- * panel can open a TERMINAL with `claude` in it and then forget about it (see
19
- * `launchClaude`). No model output ever flows back through this server, no
20
- * session is proxied, no API key is held — the panel is still not an AI client.
21
- */
22
-
23
- const { spawn, spawnSync } = require("child_process");
24
- const fs = require("fs");
25
- const os = require("os");
26
- const path = require("path");
27
- const zlib = require("zlib");
28
- const fixtures = require("./fixtures/index.js");
29
-
30
- const CLI = path.join(__dirname, "..", "cli.js");
31
-
32
- // A single-user localhost panel re-reads the same command several times while
33
- // one page renders. A short TTL collapses that without ever showing stale data
34
- // across a user action: every mutation clears the cache outright.
35
- const READ_TTL_MS = 2500;
36
- const chr10 = String.fromCharCode(10);
37
- const CLI_TIMEOUT_MS = 30_000;
38
-
39
- const cache = new Map();
40
-
41
- function cacheKey(argv) {
42
- return argv.join("\u0000");
43
- }
44
-
45
- function clearCache() {
46
- cache.clear();
47
- }
48
-
49
- // v1.4.0 — the board a statusline request is about. A value this server does
50
- // not recognise is DROPPED rather than forwarded: the CLI would refuse it, and
51
- // a query string is user input like any other.
52
- function slBoard(q) {
53
- const b = q && q.board ? String(q.board) : "";
54
- return b === "subagent" ? ["--board", "subagent"] : [];
55
- }
56
-
57
- // ── running the CLI ─────────────────────────────────────────────────────────
58
-
59
- // Several commands use a NON-ZERO exit as a normal answer, not a failure:
60
- // `pattern status` (1 = absent, 2 = unknown key), `gotcha list` (1 = none),
61
- // `wiki impact` (2 = delta, 3 = full), `pr stack status` (1 = not ready),
62
- // `doctor` (1 = issues found), `resume` (1 = nothing waiting). So the exit code
63
- // is DATA here, never an error condition — a run counts as failed only when it
64
- // produced no parseable object.
65
- // `input` (v0.50.0) is stdin for the child, and it exists for exactly one
66
- // caller: the connection test, which takes a pasted API key on line 1 and an
67
- // optional passphrase on line 2. It is a parameter rather than a second spawn
68
- // helper because a secret must travel the SAME path everything else does —
69
- // never argv (world-readable in a process list), never a temp file, never a log
70
- // line. Nothing here ever echoes it back, and `command` below is built from
71
- // argv alone.
72
- function runCli(argv, ctx, { json = false, input = undefined } = {}) {
73
- const args = [...argv];
74
- if (json) args.push("--json");
75
- // Always target the project explicitly: the server's cwd is not a reliable
76
- // way to reach the same .claude the launching command resolved.
77
- if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
78
- // ORC_NO_UPDATE_CHECK exists here to protect the --json contract: most
79
- // commands end with `maybeNudge()`, which prints an "update available" line to
80
- // STDOUT and would sit beside the object this parses.
81
- //
82
- // `version` and `changelog` are the exceptions, and forcing the flag on them
83
- // was a real bug: neither nudges, and for both the check IS the payload — so
84
- // the panel asked whether an update existed with the check switched off and
85
- // was told `check_disabled: true` forever. A blanket env var silenced the one
86
- // command whose entire job is to answer that question.
87
- const CHECKS_UPDATES = argv[0] === "version" || argv[0] === "changelog";
88
- const env = { ...process.env, NO_COLOR: "1" };
89
- if (!CHECKS_UPDATES) env.ORC_NO_UPDATE_CHECK = "1";
90
- const r = spawnSync(process.execPath, [CLI, ...args], {
91
- encoding: "utf8",
92
- timeout: CLI_TIMEOUT_MS,
93
- windowsHide: true,
94
- env,
95
- input,
96
- });
97
- const stdout = r.stdout || "";
98
- let data = null;
99
- try {
100
- data = JSON.parse(stdout);
101
- } catch (_) {}
102
- return {
103
- ok: data !== null,
104
- exit_code: r.status === null ? -1 : r.status,
105
- data,
106
- stderr: (r.stderr || "").trim(),
107
- stdout: data === null ? stdout.trim() : "",
108
- command: "orc " + argv.join(" "),
109
- };
110
- }
111
-
112
- // The one-line reason a read failed, in the CLI's own words. `crashed` is the
113
- // envelope the CLI itself emits when a `--json` route throws (v0.49.2); anything
114
- // else is a command that wrote nothing parseable at all.
115
- function readFailReason(out) {
116
- const first = (out.stderr || out.stdout || "")
117
- .split(chr10)
118
- .map((l) => l.trim())
119
- .filter(Boolean)[0];
120
- const tail = first ? " \u2014 " + first : "";
121
- return `${out.command} produced no JSON (exit ${out.exit_code})${tail}`;
122
- }
123
-
124
- function readCli(argv, ctx) {
125
- const key = cacheKey([ctx.projectRoot || "", ...argv]);
126
- const hit = cache.get(key);
127
- if (hit && Date.now() - hit.at < READ_TTL_MS) return hit.value;
128
- const value = runCli(argv, ctx, { json: true });
129
- cache.set(key, { at: Date.now(), value });
130
- return value;
131
- }
132
-
133
- // ── jobs (the mutation half) ────────────────────────────────────────────────
134
- // SINGLE-FLIGHT: one mutation at a time, process-wide. The client goes
135
- // read-only while a job runs, so a second one is a bug, not a race to win.
136
- // Output accumulates in memory and the client polls /api/job — which is what
137
- // makes `orc update` and `orc upgrade` show their real output as it happens
138
- // instead of a spinner and a verdict.
139
-
140
- let job = null;
141
- let jobSeq = 0;
142
-
143
- function jobView() {
144
- if (!job) return { id: null, running: false };
145
- return {
146
- id: job.id,
147
- command: job.command,
148
- running: job.running,
149
- exit_code: job.exit_code,
150
- output: job.output,
151
- started_ms: job.started_ms,
152
- // THE PANEL IS SERVING CODE THAT THIS JOB MAY HAVE JUST REPLACED. Reported
153
- // only on a job that SUCCEEDED and whose action is declared as touching the
154
- // install — a failed upgrade changed nothing, so restarting after one would
155
- // be motion with no reason. The client acts on it; the server does not
156
- // restart itself, so the job's output survives long enough to be read.
157
- restart_pending: !!(job.restart_ui && !job.running && job.exit_code === 0),
158
- };
159
- }
160
-
161
- function startJob(argv, ctx, opts) {
162
- if (job && job.running) return { error: "busy", job: jobView() };
163
- const args = [...argv];
164
- if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
165
- const id = ++jobSeq;
166
- job = {
167
- id,
168
- command: "orc " + argv.join(" "),
169
- running: true,
170
- exit_code: null,
171
- output: "",
172
- started_ms: Date.now(),
173
- restart_ui: !!(opts && opts.restartUi),
174
- };
175
- const child = spawn(process.execPath, [CLI, ...args], {
176
- windowsHide: true,
177
- env: { ...process.env, NO_COLOR: "1" },
178
- });
179
- const append = (buf) => {
180
- job.output += buf.toString("utf8");
181
- // A runaway job must not grow the server's heap without bound.
182
- if (job.output.length > 400_000) job.output = job.output.slice(-400_000);
183
- };
184
- child.stdout.on("data", append);
185
- child.stderr.on("data", append);
186
- child.on("error", (e) => {
187
- job.output += "\n" + String(e.message);
188
- job.exit_code = -1;
189
- job.running = false;
190
- });
191
- child.on("close", (code) => {
192
- job.exit_code = code === null ? -1 : code;
193
- job.running = false;
194
- clearCache(); // every panel refetches against the new truth
195
- });
196
- return { job: jobView() };
197
- }
198
-
199
- // ── the endpoint table ──────────────────────────────────────────────────────
200
- // READS map a route to CLI argv. WRITES are separate and POST-only — a GET can
201
- // never mutate, so a prefetch, a bookmark or a browser retry is always safe.
202
-
203
- const READS = {
204
- "/api/version": () => ["version"],
205
- "/api/changelog": () => ["changelog"],
206
- "/api/where": () => ["where"],
207
- "/api/doctor": () => ["doctor"],
208
- "/api/config": () => ["config", "list"],
209
- "/api/config/profiles": () => ["config", "profile"],
210
- "/api/config/recommend": () => ["config", "recommend"],
211
- // v1.0.0 W16 — the `orc lane` noun, rendered. All three are READS and all
212
- // three are answers the CLI already computes in full: which lanes exist and
213
- // how many keys each reads, which SHARED phases a lane runs and in what
214
- // order, and the whole call catalogue. The panel draws them and decides
215
- // nothing about them — the Flow-stepper rule, applied to the lane model.
216
- // `lane phases` and `lane config` both exit 2 on an unknown lane, which is
217
- // DATA here exactly like `pattern status` and `wiki impact` above.
218
- "/api/lanes": () => ["lane", "list"],
219
- "/api/lane/phases": (q) => ["lane", "phases", String(q.lane || "")],
220
- "/api/lane/calls": (q) => (q.lane ? ["lane", "calls", String(q.lane)] : ["lane", "calls", "--all"]),
221
- "/api/runs": (q) => ["run", "list", "--limit", String(Math.min(200, Number(q.limit) || 40))],
222
- "/api/run": (q) => ["run", "show", String(q.slug || "")],
223
- "/api/wiki": () => ["wiki", "status"],
224
- "/api/wiki/impact": () => ["wiki", "impact"],
225
- // v0.46.0. Every one is a READ with an exit-code contract, so the exit code is
226
- // DATA here exactly like `pattern status` and `wiki impact` above: pact 0/1/2/3,
227
- // boundary 0/1/2/3, handoff 0/1, budget 0/1/2/3, aftermath 0/1/2/3,
228
- // wiki plan 0/1/2/3, wiki debt 0/1/3, export --check 0/1.
229
- "/api/wiki/plan": () => ["wiki", "plan"],
230
- "/api/wiki/debt": () => ["wiki", "debt"],
231
- "/api/wiki/usage": () => ["wiki", "usage"],
232
- // v0.49.1 — the wiki's CONTENTS, not only its temperature. All three are
233
- // READS whose exit code is DATA (docs 0/1/3, show 0/2/3, coverage 0/1), and
234
- // `coverage` deliberately has no threshold: nothing branches on it, here or
235
- // anywhere else. `--body` is opt-in and one artifact at a time — the
236
- // /api/doc/section precedent.
237
- "/api/wiki/docs": () => ["wiki", "docs"],
238
- "/api/wiki/show": (q) => ["wiki", "show", String(q.doc || ""), ...(q.body ? ["--body"] : [])],
239
- "/api/wiki/coverage": () => ["wiki", "coverage"],
240
- // The pattern file is injected LITERALLY into every executor slice, and until
241
- // now nothing would show you a line of it. Same 0/1/2 contract `pattern
242
- // status` has had since v0.34.8.
243
- "/api/pattern/show": (q) => ["pattern", "show", String(q.lang || ""), ...(q.body ? ["--body"] : [])],
244
- "/api/gotcha/show": (q) => ["gotcha", "show", String(q.id || "")],
245
- "/api/gotchas/archived": () => ["gotcha", "list", "--archived"],
246
- // Preview-then-apply: A COUNT IS NOT CONSENT. The Apply button stays disabled
247
- // until this has been fetched, and it names every entry eviction would touch.
248
- "/api/gotcha/prune/preview": () => ["gotcha", "prune", "--dry-run"],
249
- // v1.1.0 — the wait. Three READS, and every one of them is a state the panel
250
- // renders and never derives: `usage check` 0/1/2 (and `unknown` is a state,
251
- // not a failure), the lane table, and the run's block. The panel CANNOT start
252
- // a wait — a wait lives in a Claude Code session, and `orc ui` never runs a
253
- // lane. It configures the defaults, shows a wait, and cancels one.
254
- "/api/usage": () => ["usage", "check"],
255
- // v1.3.0 — the CLI Hook Interface. THE PANEL DRAWS THE CATALOGUE AND DERIVES
256
- // NONE OF IT: the groups, the renderers, their option sets and the rendered
257
- // sample per renderer all come from `statusline components --json`, and a
258
- // test greps the panel for component ids, renderer names, glyph-set names,
259
- // ramp names, colour tokens and state words. It must name none of them.
260
- //
261
- // `preview` is the one that earns the panel its place: it renders through the
262
- // SAME engine the hook does, so what you see is what the bar will print.
263
- // v1.4.0 — TWO BOARDS through one set of routes. `--board` is passed
264
- // through verbatim; the CLI owns which boards exist and which components
265
- // each may hold, and the panel — as everywhere else — decides none of it.
266
- "/api/statusline/components": (q) => ["statusline", "components", ...slBoard(q)],
267
- "/api/statusline/show": (q) => ["statusline", "show", ...slBoard(q)],
268
- "/api/statusline/presets": (q) => ["statusline", "presets", ...slBoard(q)],
269
- "/api/statusline/preview": (q) => {
270
- const argv = ["statusline", "preview", ...slBoard(q)];
271
- if (q.width) argv.push("--width", String(q.width));
272
- if (q.state) argv.push("--state", String(q.state));
273
- // v1.4.2 — a RENDER-ONLY override, so the panel can draw the same layout
274
- // under each colour set and each symbol set. It writes nothing: the CLI
275
- // applies these to a copy of the saved layout before it compiles.
276
- if (q.theme) argv.push("--theme", String(q.theme));
277
- if (q.glyphs) argv.push("--glyphs", String(q.glyphs));
278
- return argv;
279
- },
280
- "/api/statusline/explain": (q) => ["statusline", "explain", String(q.at || "1:1"), ...slBoard(q)],
281
- "/api/wait/lanes": () => ["wait", "lanes"],
282
- "/api/wait/status": (q) => (q.slug ? ["wait", "status", String(q.slug)] : ["wait", "status"]),
283
- "/api/pact": () => ["pact", "status"],
284
- "/api/boundary": (q) => (q.path ? ["boundary", "status", String(q.path)] : ["boundary", "status"]),
285
- "/api/handoff": () => ["handoff", "surfaces"],
286
- "/api/budget/rates": () => ["budget", "rates"],
287
- // A forecast takes a PLAN PATH. The browser sends a path the folder picker
288
- // produced; the server passes it straight to the CLI and never opens the file
289
- // itself — /api/fs/list stays a directory LISTER, and widening it to read a
290
- // plan would be the one change that turns this panel into a file reader.
291
- "/api/budget/forecast": (q) => ["budget", "forecast", String(q.plan || "")],
292
- "/api/budget/actual": (q) => ["budget", "actual", String(q.slug || "")],
293
- "/api/aftermath": (q) => (q.since ? ["aftermath", "status", "--since", String(q.since)] : ["aftermath", "status"]),
294
- "/api/export": () => ["export", "--check"],
295
- // v0.47.0 — /orc-challenge. Same shape: every one is a READ whose exit code is
296
- // DATA (list 0/1/3, status 0/1/2/3, diff 0/1/2/3, lint 0/1/2), and the panel
297
- // derives nothing from them — not the state word, not the iteration order, not
298
- // the pass decision, not the expected revision path.
299
- "/api/challenge": () => ["challenge", "list"],
300
- "/api/challenge/one": (q) => ["challenge", "status", String(q.slug || "")],
301
- "/api/challenge/show": (q) => [
302
- "challenge",
303
- "show",
304
- String(q.slug || ""),
305
- ...(q.iteration ? ["--iteration", String(q.iteration)] : []),
306
- ],
307
- "/api/challenge/diff": (q) => ["challenge", "diff", String(q.slug || "")],
308
- // v0.49.1 — the council. `roles` is STATIC (it works with no cycle at all),
309
- // and it is the ONE catalogue: the panel names no lens, no class and no
310
- // disposition itself, exactly as it names no flow step. `council` is 0 set /
311
- // 1 unset / 3 unknown, and UNSET is an ANSWER, not an error.
312
- "/api/challenge/roles": (q) => ["challenge", "roles", ...(q.kind ? ["--kind", String(q.kind)] : [])],
313
- "/api/challenge/council": (q) => ["challenge", "council", String(q.slug || "")],
314
- "/api/challenge/lint": (q) => [
315
- "challenge",
316
- "lint",
317
- String(q.path || ""),
318
- ...(q.template ? ["--template", String(q.template)] : []),
319
- ],
320
- // v0.48.0 — /orc-doc. Same shape again: every one is a READ whose exit code is
321
- // DATA (list 0, status 0/1/2, map 0/2, plan 0/1, lint 0/1/2), and the panel
322
- // derives nothing from them — not the section order, not a line range, not a
323
- // state word, not the batching, not a lint rule name. It draws what the CLI
324
- // computed.
325
- //
326
- // `/api/doc/section` is the ONE route that returns any of the document's
327
- // prose, it returns exactly ONE section, and only on an explicit Reveal click.
328
- // The rule this lane lives by is that nothing HOLDS the document — not that
329
- // the text is secret — and the panel renders it as DOM through `renderMd`,
330
- // never as HTML.
331
- "/api/doc": () => ["doc", "list"],
332
- "/api/doc/one": (q) => ["doc", "status", String(q.slug || "")],
333
- "/api/doc/show": (q) => ["doc", "show", String(q.slug || "")],
334
- "/api/doc/section": (q) => ["doc", "show", String(q.slug || ""), "--section", String(q.section || "")],
335
- "/api/doc/map": (q) => ["doc", "map", String(q.slug || "")],
336
- "/api/doc/lint": (q) => [
337
- "doc",
338
- "lint",
339
- String(q.slug || ""),
340
- ...(q.target ? ["--target", String(q.target)] : []),
341
- ],
342
- "/api/doc/plan": (q) => ["doc", "plan", String(q.slug || ""), "--role", String(q.role || "write")],
343
- "/api/doc/templates": () => ["doc", "templates"],
344
- "/api/doc/targets": () => ["doc", "targets"],
345
- // v0.48.1 — the score, the drift report and the memory surface. Every one is
346
- // a subprocess of the real command: the panel decides nothing about the
347
- // pipeline order, nothing about which drift classes exist, and nothing about
348
- // which journal rows are the user's own words.
349
- //
350
- // There is deliberately NO route for `orc doc log`: the SKILL records a
351
- // request, because the skill is what took one. A panel that could write a
352
- // journal entry could write one nobody said.
353
- // v0.49.0 — the SECTION FILES. This is the one read that works before a
354
- // single compile has ever run, because the files ARE the progress.
355
- "/api/doc/parts": (q) => ["doc", "parts", String(q.slug || "")],
356
- "/api/doc/next": (q) => ["doc", "next", String(q.slug || "")],
357
- "/api/doc/audit": (q) => ["doc", "audit", String(q.slug || "")],
358
- "/api/doc/journal": (q) => ["doc", "journal", String(q.slug || "")],
359
- "/api/doc/context": (q) => ["doc", "context", String(q.slug || "")],
360
- "/api/doc/extra": (q) => ["doc", "extra", String(q.slug || "")],
361
- // v0.49.2 — the project's own house rules, the frozen set of one document,
362
- // the run map and the cost report. All four are READS; the panel decides
363
- // nothing about a priority, an order, a wave shape or a number.
364
- "/api/doc/rules": () => ["doc", "rules"],
365
- "/api/doc/rules/one": (q) => ["doc", "rules", String(q.slug || "")],
366
- // v1.7.0 — the anti-slop rule surface. Five READS. The panel decides nothing:
367
- // not the precedence order, not a rule's tier, not the override count, and
368
- // not which of the 65 rules the lint can actually prove.
369
- //
370
- // `lint` takes a path from the query string. It is user input like any other,
371
- // so it is forwarded as ONE argv element and never through a shell — the same
372
- // reason every other route here spawns an array rather than a string.
373
- "/api/rules": () => ["rules"],
374
- "/api/rules/user": () => ["rules", "user"],
375
- "/api/rules/packs": () => ["rules", "packs"],
376
- "/api/rules/credits": () => ["rules", "credits"],
377
- "/api/rules/lint": (q) => ["rules", "lint", String(q.path || ".")],
378
- // v1.7.0 — the wiki's one-doc-at-a-time probes. Both free, neither scans.
379
- "/api/wiki/resolve": (q) => ["wiki", "resolve", String(q.topic || "")],
380
- "/api/wiki/refs": () => ["wiki", "refs", "--check"],
381
- "/api/doc/forecast": (q) => ["doc", "forecast", String(q.slug || "")],
382
- "/api/doc/cost": (q) => ["doc", "cost", String(q.slug || "")],
383
- // v0.50.0 — `orc extra`. Every one is a READ, and every one is a subprocess of
384
- // the real command: the panel decides nothing about a provider, a model id, a
385
- // verification state, a band or a price. It renders what the CLI computed.
386
- //
387
- // There is deliberately NO read that returns a credential. `orc extra list`
388
- // and `orc extra show` go through `redactProfile`, which is an ALLOW-LIST, so
389
- // a field added later that carries a secret has to be let through on purpose.
390
- "/api/extra": () => ["extra", "list"],
391
- "/api/extra/providers": () => ["extra", "providers"],
392
- "/api/extra/show": (q) => ["extra", "show", String(q.profile || "")],
393
- "/api/extra/models": (q) => ["extra", "models", String(q.profile || "")],
394
- // v0.51.0 — the LOCAL TOOLS read, and the credential-route read. Both are the
395
- // real commands, so the panel decides nothing about an install command, a
396
- // platform, a version floor or which of three credential routes applies.
397
- // `tools` exits 1 when no tool is ready, which is exit-code-as-DATA like
398
- // `pattern status` — never an error.
399
- "/api/extra/tools": () => ["extra", "tools"],
400
- "/api/extra/keyhelp": (q) => ["extra", "keyhelp", String(q.profile || "")],
401
- "/api/extra/route": () => ["extra", "route"],
402
- // v0.55.0 — THE POSITIONS, the non-scored half of routing. It exits 1 when
403
- // nothing routes, which is exit-code-as-DATA like `pattern status` — an empty
404
- // result is an ANSWER and it still returns its whole object.
405
- "/api/extra/role": () => ["extra", "role", "list"],
406
- // v0.52.0 (D6) — WHICH LANE a band governs, computed through the same
407
- // resolver every dispatch uses. The panel renders it and derives nothing:
408
- // a band with no lane attached is not a routing decision.
409
- "/api/extra/lanes": () => ["extra", "lanes"],
410
- // `stats` exits 1 with a real object when no foreign dispatch has been traced
411
- // yet, and `rates` exits 1 when a pair has no price — both are exit-code-as-
412
- // DATA, like `pattern status` and `wiki impact`, never an error.
413
- "/api/extra/stats": (q) => (q.since ? ["extra", "stats", "--since", String(q.since)] : ["extra", "stats"]),
414
- "/api/extra/rates": () => ["extra", "rates"],
415
- "/api/extra/doctor": () => ["extra", "doctor"],
416
- // v0.54.0 — RECOVERY. Both are FREE reads (zero model tokens), which is why
417
- // both are real buttons; `resume-slice` and the dispatch that follows it cost
418
- // money and are copy-able commands instead. `reconcile` exits 0-4 as an
419
- // exit-code-as-DATA contract like `pattern status`, so no state here is an
420
- // error — including `in-flight`, which is a REFUSAL the panel must render as
421
- // one rather than as a dead control.
422
- "/api/extra/journal": () => ["extra", "journal", "list"],
423
- // v1.0.0 W16 — the RUN DEMOTION, read. Exit 0 armed · 1 demoted · 2 unknown
424
- // run, which is exit-code-as-DATA like every other gate command here. The
425
- // run is optional: with none given the CLI reads the trace pointer, which is
426
- // exactly what a panel wants — "the run that is open right now".
427
- "/api/extra/demotion": (q) => ["extra", "demotion", ...(q.run ? [String(q.run)] : [])],
428
- "/api/extra/reconcile": (q) => ["extra", "reconcile", String(q.task || "")],
429
- "/api/extra/journal/prune/preview": () => ["extra", "journal", "prune", "--dry-run"],
430
- // v1.5.0 — /orc-test, the lane that RUNS the test. THREE reads, and every one
431
- // of them is a READ in the strict sense this lane needs: none re-scans the
432
- // repository, none probes the target, and none writes the ledger. Opening a
433
- // page must never be a measurement — a measurement nobody asked for is
434
- // traffic nobody authorized.
435
- //
436
- // `orc test show` is the whole computed view (`--json is not a summary`), so
437
- // the surface, the case matrix, the closed OWASP set, the runs and the
438
- // findings all arrive in ONE object the panel renders and derives nothing
439
- // from: not a state word, not a severity, not an OWASP id, not the tier.
440
- // `status` 0 always, `show` 0 / 2 no run, `ui tools` 0 ready / 1 not ready /
441
- // 2 the driver is `none` — and 2 there is a SETTING, not a missing tool.
442
- "/api/test": () => ["test", "status"],
443
- "/api/test/one": (q) => ["test", "show", String(q.slug || "")],
444
- "/api/test/ui": () => ["test", "ui", "tools"],
445
- "/api/patterns": () => ["pattern", "status"],
446
- "/api/gotchas": () => ["gotcha", "list"],
447
- "/api/stats": (q) => (q.since ? ["stats", "--since", String(q.since)] : ["stats"]),
448
- "/api/diy": () => ["diy", "show"],
449
- "/api/crosslink": () => ["crosslink", "list"],
450
- "/api/crosslink/kinds": () => ["crosslink", "kinds"],
451
- "/api/mocks": () => ["mock", "list"],
452
- "/api/mock": (q) => ["mock", "show", String(q.slug || "")],
453
- "/api/stack": (q) => (q.slug ? ["pr", "stack", "status", String(q.slug)] : ["pr", "stack", "status"]),
454
- };
455
-
456
- // v0.46.0 writes. THE LINE THIS PANEL DOES NOT CROSS: a button exists only for an
457
- // action that costs NO model tokens. `orc pact check` runs the ledger's own cheap
458
- // proofs (a test, a command, a grep the user wrote); `orc handoff set` edits one
459
- // graded surface; `orc export` compiles files already on disk. Every one is
460
- // deterministic and every one is a real CLI command.
461
- //
462
- // The conversational half of each lane — reconciling a promise, deciding a
463
- // verdict, walking somebody through a change — costs model tokens, so the panel
464
- // COPIES those commands and never runs them. A test greps this object to make
465
- // sure no lane name ever appears inside it.
466
- const WRITES = {
467
- "/api/config/set": (b) => ["config", "set", String(b.key), String(b.value)],
468
- "/api/config/reset": (b) => (b.key ? ["config", "reset", String(b.key)] : ["config", "reset"]),
469
- "/api/config/profile": (b) => ["config", "profile", String(b.name)],
470
- "/api/diy/set": (b) => ["diy", "set", String(b.key), String(b.value)],
471
- "/api/diy/compile": () => ["diy", "compile"],
472
- // The bootstrap the TTY composer offers as its first question, and the one
473
- // piece of DIY the panel had no way to reach (v0.44.0). `--force` is what
474
- // makes it an ANSWER rather than an error on an already-configured project:
475
- // `orc diy init` refuses to overwrite without it. That is destructive, so the
476
- // UI confirms with the preset's own diff and the exact command on screen.
477
- // An empty name is the wizard's "full-lane defaults" — a real invocation,
478
- // not a synthesised one.
479
- "/api/diy/preset": (b) => {
480
- const argv = ["diy", "init", "--force"];
481
- if (b.name) argv.push("--preset", String(b.name));
482
- return argv;
483
- },
484
- // v1.1.0 — the only two wait mutations this panel may make, and neither
485
- // starts one. `unblock` restores a gate the user vetoed; `cancel` ends a wait
486
- // already running. A BLOCK cannot be created here on purpose: it needs a
487
- // reason typed in the moment, and a reason typed into a settings page days
488
- // later is not the record that makes the risk demonstrably the user's.
489
- // v1.3.0 — the layout's writers. Every one shells the same validator the CLI
490
- // uses, so an illegal placement is refused BY NAME here exactly as it is
491
- // there. The board makes the illegal drop impossible; this is the guarantee.
492
- "/api/statusline/set": (b) => {
493
- const argv = ["statusline", "set", String(b.line), String(b.pos)];
494
- if (b.type) argv.push(String(b.type));
495
- for (const [k, f] of [
496
- ["render", "--render"], ["label", "--label"], ["color", "--color"],
497
- ["label_color", "--label-color"], ["value_color", "--value-color"],
498
- ["bg", "--bg"], ["ramp", "--ramp"], ["glyphs", "--glyphs"],
499
- ["format", "--format"], ["case", "--case"], ["truncate", "--truncate"],
500
- ["compact", "--compact"], ["prefix", "--prefix"], ["suffix", "--suffix"],
501
- ["emphasis", "--emphasis"], ["hide_when", "--hide-when"],
502
- ["width", "--width"], ["precision", "--precision"],
503
- ["min_width", "--min-width"], ["min_cols", "--min-cols"],
504
- ["max_cols", "--max-cols"], ["priority", "--priority"],
505
- ]) {
506
- if (b[k] !== undefined && b[k] !== null && b[k] !== "") argv.push(f, String(b[k]));
507
- }
508
- if (b.draw_empty) argv.push("--draw-empty");
509
- return argv.concat(slBoard(b));
510
- },
511
- "/api/statusline/move": (b) => ["statusline", "move", String(b.from), String(b.to), ...slBoard(b)],
512
- "/api/statusline/remove": (b) => ["statusline", "remove", String(b.at), ...slBoard(b)],
513
- "/api/statusline/line": (b) => {
514
- const argv = ["statusline", "line", String(b.line)];
515
- if (b.separator !== undefined) argv.push("--separator", String(b.separator));
516
- if (b.theme) argv.push("--theme", String(b.theme));
517
- if (b.max_width !== undefined) argv.push("--max-width", String(b.max_width));
518
- return argv.concat(slBoard(b));
519
- },
520
- // A preset REPLACES the layout, so the panel always confirms it and names
521
- // the loss — the `orc diy init --force` rule.
522
- // THE DOCUMENT-LEVEL SETTINGS. `line` is per-LINE and `doc` is the whole
523
- // layout; the colour set is a document fact, and routing it through `line`
524
- // wrote one third of the bar while the picker read the other value back.
525
- "/api/statusline/doc": (b) => {
526
- const argv = ["statusline", "doc"];
527
- if (b.theme) argv.push("--theme", String(b.theme));
528
- if (b.glyphs) argv.push("--glyphs", String(b.glyphs));
529
- if (b.ansi) argv.push("--ansi", String(b.ansi));
530
- if (b.align_columns !== undefined) argv.push("--align-columns", b.align_columns ? "on" : "off");
531
- return argv.concat(slBoard(b));
532
- },
533
- "/api/statusline/apply": (b) => ["statusline", "apply", String(b.name), ...slBoard(b)],
534
- // v1.3.0 W5. `group` wraps 2-4 as one object, `expand` is its inverse and is
535
- // also how a composite becomes editable, `clone` is for two `config` chips on
536
- // different keys — a normal thing to want.
537
- "/api/statusline/group": (b) => ["statusline", "group", ...(b.refs || []).map(String), ...slBoard(b)],
538
- "/api/statusline/expand": (b) => ["statusline", "expand", String(b.at), ...slBoard(b)],
539
- "/api/statusline/clone": (b) => ["statusline", "clone", String(b.at), ...slBoard(b)],
540
- "/api/statusline/reset": (b) => ["statusline", "reset", ...slBoard(b)],
541
- "/api/statusline/compile": (b) => ["statusline", "compile", ...slBoard(b)],
542
- "/api/wait/unblock": (b) => (b.slug ? ["wait", "unblock", String(b.slug)] : ["wait", "unblock"]),
543
- "/api/wait/cancel": (b) => (b.slug ? ["wait", "cancel", String(b.slug)] : ["wait", "cancel"]),
544
- "/api/wiki/sync": () => ["wiki", "sync"],
545
- "/api/wiki/usage/rebuild": () => ["wiki", "usage", "--rebuild"],
546
- "/api/gotcha/prune": () => ["gotcha", "prune"],
547
- "/api/pact/check": (b) => (b.id ? ["pact", "check", String(b.id)] : ["pact", "check"]),
548
- "/api/pact/sync": () => ["pact", "sync"],
549
- // The surface id, the key and the value all come from the browser — and all
550
- // three are validated by the CLI, which refuses a RED surface, an unknown id,
551
- // a key that does not already exist, and a write at all when handoff_write is
552
- // false. There is no second idea of a safe edit anywhere in this panel.
553
- "/api/handoff/set": (b) => ["handoff", "set", String(b.id), String(b.key), String(b.value)],
554
- "/api/budget/calibrate": () => ["budget", "calibrate"],
555
- // v0.47.0. All three are FREE and deterministic, so all three get a button.
556
- // Running an ITERATION costs model tokens, so it is a copy-able command and
557
- // there is deliberately no route for it here. `accept` and `rebut` both refuse
558
- // without a reason — the CLI decides that, not the form.
559
- "/api/challenge/accept": (b) => ["challenge", "accept", String(b.slug), String(b.id), String(b.reason || "")],
560
- "/api/challenge/rebut": (b) => ["challenge", "rebut", String(b.slug), String(b.id), String(b.reason || "")],
561
- "/api/challenge/report": (b) => ["challenge", "report", String(b.slug)],
562
- // v0.49.1. Both are FREE and both REFUSE without a reason, so both are
563
- // buttons. There is deliberately NO route for `council --set`: changing the
564
- // roster mid-cycle is a decision with a recorded reason that the LANE takes in
565
- // the conversation — the same reasoning that keeps `orc doc log` and
566
- // `orc doc mode` off the panel. Adopting a premise needs a goals FILE, which
567
- // the panel must not invent, so that one stays a copy-able command too.
568
- "/api/challenge/premise": (b) => [
569
- "challenge", "premise", String(b.slug), String(b.id), "--dismiss", "--reason", String(b.reason || ""),
570
- ],
571
- "/api/challenge/opportunity": (b) => [
572
- "challenge", "opportunity", String(b.slug), String(b.id), b.take ? "--take" : "--drop", "--reason", String(b.reason || ""),
573
- ],
574
- // v0.48.0. `assemble` — now `compile` — is the ONE /orc-doc write that costs
575
- // nothing: it concatenates section files that are already on disk, in an order
576
- // the outline already fixed. Writing a section, checking one and editing one
577
- // all cost model tokens, so they are copy-able commands and there is
578
- // deliberately no route for any of them.
579
- "/api/doc/assemble": (b) => ["doc", "assemble", String(b.slug)],
580
- // v0.49.0. Both free, both non-destructive: `compile` rebuilds the artifact
581
- // from disk, and `migrate` never deletes document.md and refuses what it
582
- // cannot parse. `orc doc mode` deliberately has NO route — it is a USER
583
- // decision the skill asks (the `orc doc log` precedent).
584
- "/api/doc/compile": (b) => {
585
- const argv = ["doc", "compile", String(b.slug)];
586
- if (b.partial) argv.push("--partial");
587
- return argv;
588
- },
589
- "/api/doc/migrate": (b) => ["doc", "migrate", String(b.slug)],
590
- // v0.48.1. Shipping is a DECISION, so it is a write — and `--where` has no
591
- // default here either, because the CLI refuses without it and the panel must
592
- // never invent an argument the human path demands.
593
- "/api/doc/ship": (b) => {
594
- const argv = ["doc", "ship", String(b.slug), "--where", String(b.where || "")];
595
- if (b.note) argv.push("--note", String(b.note));
596
- if (b.force) argv.push("--force", "--reason", String(b.reason || ""));
597
- return argv;
598
- },
599
- "/api/doc/unship": (b) => ["doc", "unship", String(b.slug), "--reason", String(b.reason || "")],
600
- // v0.49.5 — the house-rule ledger is a PLAIN TEXT config, so the panel writes
601
- // it the way a text config is written: the whole file, once, from one
602
- // textarea. `set-all` is the only write route it needs — the per-priority
603
- // `set`/`add`/`clear` commands stay a CLI convenience, and `--reset` still has
604
- // no route because throwing away a project's standing rules is a CLI act.
605
- //
606
- // Every validator is still the CLI's. The panel has no second idea of what a
607
- // house rule looks like, and the argv is a plain array, so a multi-line value
608
- // needs no escaping and no temp file.
609
- "/api/doc/rules/setAll": (b) => ["doc", "rules", "set-all", "--text", String(b.text || "")],
610
- // v1.7.0 — the project's own anti-slop rules. ONE write route, for the same
611
- // reason the doc ledger has one: it is a plain text config, so the panel
612
- // writes the whole file from one textarea and the CLI is still the only
613
- // writer and the only validator.
614
- //
615
- // There is deliberately NO route for the SHIPPED packs. They are read-only,
616
- // `orc rules set --pack …` is refused by name, and a route that existed only
617
- // to be refused would be a control that lies about what it can do.
618
- // `--reset` also has no route: throwing away a project's standing rules is a
619
- // CLI act.
620
- "/api/rules/setAll": (b) => ["rules", "set-all", "--text", String(b.text || "")],
621
- "/api/doc/rules/sync": (b) => ["doc", "rules", String(b.slug), "--sync"],
622
- // v0.52.0 (D9) — per document, because a document's voice is the deliverable.
623
- // The CLI owns the resolution order and the shadowing announcement; the panel
624
- // renders both and decides neither.
625
- "/api/doc/extra/set": (b) => ["doc", "extra", String(b.slug), "--set", String(b.mode)],
626
- // v0.49.2. Closing a run is FREE, deterministic, reversible, and it DELETES
627
- // NOTHING — `RESUME.md` is moved aside, not removed. The CLI refuses without a
628
- // reason, so the form does not have to: there is one idea of a valid close and
629
- // it lives in `bin/cli.js`.
630
- "/api/run/close": (b) => ["run", "close", String(b.slug), "--reason", String(b.reason || "")],
631
- "/api/run/reopen": (b) => ["run", "reopen", String(b.slug)],
632
- // v0.50.0 — `orc extra`. Adding a connection writes a PROFILE and nothing
633
- // else: no key, no route, no change to how anything builds. Removing one
634
- // REFUSES without a reason — the same rule the promise ledger retires an
635
- // invariant under — and names the route rows it drops, so the form does not
636
- // have to: there is one idea of a valid removal and it lives in bin/cli.js.
637
- //
638
- // The routing table (W12). Both are STAGED in the panel and applied one at a
639
- // time in staged order, so a refused row never aborts the rest — and the CLI
640
- // is still what refuses an overlap, an unverified profile or a bad band spec,
641
- // BY NAME. There is deliberately no `--key <value>` anywhere, because the CLI
642
- // refuses it BY NAME — argv is world-readable. A pasted key travels on the
643
- // connection test's STDIN and nowhere else.
644
- "/api/extra/add": (b) => {
645
- const argv = ["extra", "add", String(b.name), "--provider", String(b.provider), "--engine", String(b.engine)];
646
- if (b.region && b.region !== "default") argv.push("--region", String(b.region));
647
- if (b.base_url) argv.push("--base-url", String(b.base_url));
648
- if (b.anthropic_base_url) argv.push("--anthropic-base-url", String(b.anthropic_base_url));
649
- if (b.cli_bin) argv.push("--cli", String(b.cli_bin));
650
- if (b.cli_agent) argv.push("--cli-agent", String(b.cli_agent));
651
- // v0.52.0 — the THIRD credential source, checked FIRST. A local tool that
652
- // already holds its own credential needs no key from ORC at all, and the
653
- // panel forcing such a profile into the vault is what locked a run that had
654
- // no business having a passphrase. ORC never writes another tool's
655
- // credential store; `--tool-auth` is how that is said out loud.
656
- if (b.tool_auth) argv.push("--tool-auth");
657
- else if (b.vault) argv.push("--key-stdin");
658
- else if (b.env_key) argv.push("--env-key", String(b.env_key));
659
- return argv;
660
- },
661
- "/api/extra/remove": (b) => ["extra", "remove", String(b.name), "--reason", String(b.reason || "")],
662
- // v0.52.0 — forgetting a saved passphrase carries no secret, so it is an
663
- // ordinary argv write. SAVING one does, and has its own handler below: the
664
- // passphrase travels on STDIN and `--passphrase <value>` is refused BY NAME in
665
- // the CLI, so there is no argv path on either side.
666
- "/api/extra/session/forget": (b) => ["extra", "session", String(b.profile), "--forget"],
667
- "/api/extra/route/set": (b) => {
668
- const argv = ["extra", "route", "set", String(b.band), String(b.target)];
669
- if (b.small_model) argv.push("--small-model", String(b.small_model));
670
- if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
671
- return argv;
672
- },
673
- "/api/extra/route/rm": (b) => ["extra", "route", "rm", String(b.band)],
674
- // A slot is a POINT, not an interval, so there is no overlap to refuse and
675
- // `set` on an occupied one REPLACES — which is why the panel confirms it and
676
- // names what it replaces. A count is not consent.
677
- "/api/extra/role/set": (b) => {
678
- const argv = ["extra", "role", "set", String(b.slot), String(b.target)];
679
- if (b.small_model) argv.push("--small-model", String(b.small_model));
680
- if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
681
- return argv;
682
- },
683
- "/api/extra/role/rm": (b) => ["extra", "role", "rm", String(b.slot)],
684
- // v0.51.0 — running the install in the USER'S OWN TERMINAL. It is a POST
685
- // because it launches something; it writes no config, stores no state and
686
- // never elevates. A launch that could not happen comes back exit 0 carrying
687
- // the command to paste (the `openBrowser` rule), so there is no failure path
688
- // that leaves the card without an answer.
689
- "/api/extra/install": (b) => {
690
- const argv = ["extra", "install", String(b.provider)];
691
- if (b.manager) argv.push("--manager", String(b.manager));
692
- return argv;
693
- },
694
- // A live model list is a FREE re-read of the provider's own catalogue, and a
695
- // per-model test is the PAID rung scoped to one id — the only thing that tells
696
- // a LISTED model from a WORKING one.
697
- "/api/extra/models/refresh": (b) => ["extra", "models", String(b.profile), "--refresh"],
698
- // Preview-then-apply, and the preview NAMES EVERY DIRECTORY — a count is not
699
- // consent. Only a journal whose every attempt closed `done` 30+ days ago is
700
- // ever a candidate, so this can never delete the record of a dispatch that
701
- // never reported back.
702
- "/api/extra/journal/prune": () => ["extra", "journal", "prune"],
703
- // A PROMOTE IS A HUMAN ACTION AND A REASON IS REQUIRED (v1.0.0 W5). The CLI
704
- // refuses without one — exit 2, `reason-required` — so the panel does not
705
- // validate it a second time; it collects it and lets the CLI decide, which is
706
- // the same contract every other write on this server keeps. There is
707
- // deliberately NO demote route: demoting by hand is a diagnostic somebody
708
- // reaches for at a terminal, and a button for it would invite muting a
709
- // provider instead of fixing it.
710
- "/api/extra/promote": (b) => ["extra", "promote", String(b.run || ""), "--reason", String(b.reason || "")],
711
- "/api/crosslink/remove": (b) => ["crosslink", "remove", String(b.name)],
712
- // The UI assembles no YAML. It hands the CLI the same arguments the
713
- // interactive prompt collects, and every rejection the user sees is the
714
- // CLI's own validator speaking — there is no second idea of a valid slug,
715
- // a valid kind or a valid edge target anywhere in this panel.
716
- // v1.5.0 — the THREE /orc-test mutations this panel may make, and NONE of
717
- // them sends a request to the system under test. `orc test run` is where the
718
- // traffic is, and it is a COPY-ABLE COMMAND here and never a button: a page
719
- // that can start a scan against a live host is a page that can start one by
720
- // accident.
721
- //
722
- // · `surface` is the FREE PASS — it reads the repository and the target's
723
- // own spec document, and it is what every later step is derived from.
724
- // · `env` is a HEALTH PROBE, the /orc-test equivalent of `orc extra ping`:
725
- // it asks the target whether it is up. It never starts, stops or fixes
726
- // anything, and on a REMOTE target it refuses BY NAME.
727
- // · `report` RENDERS the ledger into REPORT.md and derives nothing.
728
- //
729
- // Every one costs zero model tokens, which is the line this panel does not
730
- // cross. Nothing here dispatches an agent.
731
- "/api/test/surface": (b) => ["test", "surface", String(b.slug)],
732
- "/api/test/env": (b) => ["test", "env", String(b.slug)],
733
- "/api/test/report": (b) => ["test", "report", String(b.slug)],
734
- "/api/crosslink/add": (b) => {
735
- const argv = ["crosslink", "add", String(b.name), String(b.repo_path), "--kinds", String(b.kinds)];
736
- if (b.direction) argv.push("--direction", String(b.direction));
737
- if (b.via) argv.push("--via", String(b.via));
738
- if (b.target) argv.push("--target", String(b.target));
739
- return argv;
740
- },
741
- };
742
-
743
- // Maintenance: the safety-critical panel. Each action is a PAIR — a read-only
744
- // preview and the apply that the preview is consent for. The apply route can
745
- // never be reached without the UI having fetched the preview first, and the
746
- // exact command is part of the preview payload so it is always visible in the
747
- // confirmation, and always typeable by hand instead.
748
- // `restarts_ui` — DOES THIS ACTION REPLACE WHAT THE PANEL IS SERVING?
749
- //
750
- // `orc upgrade` installs a new package over the one this process is running
751
- // from, and `orc update` / `--prune` / `doctor --fix` rewrite the payload every
752
- // panel reads. Node loaded bin/webui at require time and `STATIC` is a one-time
753
- // walk at boot, so neither is visible until the server is replaced — which used
754
- // to mean stop the server, re-run `orc ui`, open the new URL. The flag is
755
- // DECLARED per action rather than inferred, because "this command changed the
756
- // code under me" is not something a command's output can be read for.
757
- //
758
- // `update-global` is deliberately FALSE: it re-copies the payload into
759
- // ~/.claude, which is not what this server is running and not what any panel
760
- // here reads.
761
- const MAINTENANCE = {
762
- update: {
763
- apply: ["update"],
764
- restarts_ui: true,
765
- label: "Re-copy this package's payload over the installed one",
766
- // `orc doctor --json` already itemises exactly what an update would change
767
- // (version skew, missing files, orphans) — a preview with no second engine.
768
- preview: ["doctor"],
769
- },
770
- prune: {
771
- apply: ["update", "--prune"],
772
- restarts_ui: true,
773
- label: "Update AND delete ORC-named orphans from a pre-manifest install",
774
- preview: ["doctor"],
775
- // A count is not consent for a deletion: the UI must name every file, and
776
- // doctor's findings carry `paths` for exactly the two orphan findings.
777
- names_files: true,
778
- },
779
- fix: {
780
- apply: ["doctor", "--fix"],
781
- restarts_ui: true,
782
- label: "Apply every fix orc doctor found (= update + prune + settings re-merge)",
783
- preview: ["doctor"],
784
- },
785
- upgrade: {
786
- apply: ["upgrade"],
787
- restarts_ui: true,
788
- label: "Fetch the LATEST package from the network, then apply it",
789
- preview: ["version"],
790
- network: true,
791
- },
792
- // v0.46.0 — the portable export. It belongs on Maintenance by the v0.43.6 rule
793
- // (a caution routes to the panel that can CLEAR it): `export-stale` is cleared
794
- // by `orc export`, which is a CLI write this panel can run. Its preview is the
795
- // `--check` report, which names WHICH source drifted — a count of stale sources
796
- // would not be consent to overwrite a committed file.
797
- export: {
798
- apply: ["export"],
799
- label: "Recompile AGENTS.md from the wiki, patterns, PACT.md and boundary cards",
800
- preview: ["export", "--check"],
801
- },
802
- // ADVANCED (v0.44.0) — the one action on this panel that does not target the
803
- // project. Every other route pins `--dir <projectRoot>`; `--global` outranks
804
- // `--dir` in `resolveClaudeDir`, so this pair reaches ~/.claude and its
805
- // preview reports on the same place it would write.
806
- //
807
- // It is here because a stale GLOBAL install is a real failure this panel
808
- // already REPORTS (the persistent banner, and doctor's global-skew finding)
809
- // and previously could not act on — the fix was "go type it in a terminal".
810
- // It stays boxed off as advanced, and it is still the only global reach the
811
- // panel has: config is never written globally, because config does not merge
812
- // and a global write would silently outrank the file every panel here edits.
813
- "update-global": {
814
- apply: ["update", "--global"],
815
- label: "Re-copy this package's payload over the GLOBAL install in ~/.claude",
816
- preview: ["doctor", "--global"],
817
- advanced: true,
818
- },
819
- };
820
-
821
- // ── the Experiment panel ────────────────────────────────────────────────────
822
- //
823
- // THE BOUNDARY MOVES EXACTLY ONE STEP, AND NO FURTHER. `orc ui` still renders
824
- // no model output, proxies no session and holds no API key — it does not become
825
- // an AI client. What it gains is a HANDOFF: it can open a terminal, in this
826
- // project, with `claude` running in it, and then forget about it. The spawned
827
- // process is not a child this server manages, reads or reports on; it is the
828
- // same detached-launch mechanism `openBrowser()` has always used.
829
- //
830
- // The lanes are a SERVER-SIDE catalog and the browser sends only an id. No
831
- // string typed in a browser ever reaches a shell — the cwd is always the
832
- // server's own projectRoot, and an unknown id is a 400. That constraint is the
833
- // only reason a launch button is safe on a write surface at all.
834
- const LANES = [
835
- { id: "orc", cmd: "/orc", what: "Full pipeline: intake → plan → scored parallel waves → review → verify → ship." },
836
- { id: "orc-quick", cmd: "/orc-quick", what: "Ask for anything. Look → ask once → do, and it always asks which agent." },
837
- { id: "orc-mini", cmd: "/orc-mini", what: "One executor, smoke gate, ship. No full review or verify phase." },
838
- { id: "orc-fast", cmd: "/orc-fast", what: "Fastest lane. Needs a fresh wiki AND a cached pattern, or it falls back." },
839
- { id: "orc-ultra", cmd: "/orc-ultra", what: "Maximum rigor: an advisor plus three judgment gates. Cost accepted." },
840
- { id: "orc-plan", cmd: "/orc-plan", what: "Turn a request or analyst spec into a grounded task plan. Plan only." },
841
- { id: "orc-analyze", cmd: "/orc-analyze", what: "Turn a document or a vague requirement into code-grounded requirements." },
842
- { id: "orc-wiki", cmd: "/orc-wiki", what: "Build or refresh the project wiki. Expensive; always asks first." },
843
- { id: "orc-pattern", cmd: "/orc-pattern", what: "Learn this project's real code conventions and cache them per language." },
844
- { id: "orc-verify", cmd: "/orc-verify", what: "Verify the git-modified changes in the working tree. Read-only." },
845
- { id: "orc-learn", cmd: "/orc-learn", what: "Generate per-feature onboarding docs. Local and git-ignored." },
846
- { id: "orc-retro", cmd: "/orc-retro", what: "Mine the behavior traces for calibration. Read-only, report-only." },
847
- ];
848
-
849
- // ── the folder picker ───────────────────────────────────────────────────────
850
- //
851
- // The THIRD endpoint with no CLI behind it (after /api/learn's shipped content
852
- // and /api/experiment's lane catalog), and for the same reason: there is no
853
- // `orc` command that lists directories, so there is nothing to shell. It exists
854
- // because a crosslink repo path typed by hand is the one field in this panel
855
- // where a typo is invisible until the edge silently resolves to nothing — the
856
- // CLI then saves it as a PENDING edge and you find out much later.
857
- //
858
- // It is a DIRECTORY LISTER and nothing more, and the limits are the design:
859
- // · directory names only — never a file list, never file contents, never a
860
- // stat beyond "does .git / .claude/wiki exist here";
861
- // · dotfolders are hidden (`.git`, `node_modules` and friends are noise here);
862
- // · it reads, it never writes, and no path it is handed can reach a shell;
863
- // · a path that cannot be read is an ANSWER (`error`), never a 500.
864
- // Nothing is copied out of the folders it lists, so a wrong click costs a
865
- // re-click. The browser is already loopback + token gated; this adds no reach
866
- // beyond what the person at the keyboard already has.
867
- const FS_LIST_MAX = 400;
868
-
869
- function fsList(dir, ctx) {
870
- const target = path.resolve(dir || ctx.projectRoot || os.homedir());
871
- let entries;
872
- try {
873
- entries = fs.readdirSync(target, { withFileTypes: true });
874
- } catch (e) {
875
- return { path: target, error: String(e.code || e.message), dirs: [] };
876
- }
877
- const dirs = [];
878
- for (const e of entries) {
879
- if (dirs.length >= FS_LIST_MAX) break;
880
- if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules") continue;
881
- const full = path.join(target, e.name);
882
- dirs.push({
883
- name: e.name,
884
- path: full,
885
- // The two facts that decide whether a folder is worth linking at all.
886
- // Both are a single existsSync — nothing inside either one is read.
887
- is_repo: fs.existsSync(path.join(full, ".git")),
888
- has_wiki: fs.existsSync(path.join(full, ".claude", "wiki")),
889
- });
890
- }
891
- dirs.sort((a, b) => a.name.localeCompare(b.name));
892
- const parent = path.dirname(target);
893
- return {
894
- path: target,
895
- parent: parent === target ? null : parent, // null AT a filesystem root
896
- sep: path.sep,
897
- home: os.homedir(),
898
- project_root: ctx.projectRoot || null,
899
- is_project_root: !!ctx.projectRoot && path.resolve(ctx.projectRoot) === target,
900
- // What the crosslink config actually stores. Computed here rather than in
901
- // the browser because only the server knows the real path separator, and a
902
- // Windows path assembled with "/" is the kind of thing that works until it
903
- // does not.
904
- relative: ctx.projectRoot ? path.relative(ctx.projectRoot, target).split(path.sep).join("/") || "." : null,
905
- truncated: dirs.length >= FS_LIST_MAX,
906
- dirs,
907
- };
908
- }
909
-
910
- // Open a terminal running `claude` in the project root. Best-effort and never
911
- // fatal — a failed launch is reported so the user can copy the command instead,
912
- // which is why the command is always on screen anyway.
913
- function launchClaude(ctx) {
914
- const cwd = ctx.projectRoot;
915
- let cmd, args;
916
- if (process.platform === "win32") {
917
- // `start` needs an empty title argument first, or a quoted path becomes it.
918
- cmd = "cmd";
919
- args = ["/c", "start", "", "cmd", "/k", "claude"];
920
- } else if (process.platform === "darwin") {
921
- cmd = "osascript";
922
- args = ["-e", `tell application "Terminal" to do script "cd ${JSON.stringify(cwd).slice(1, -1)} && claude"`, "-e", 'tell application "Terminal" to activate'];
923
- } else {
924
- cmd = "x-terminal-emulator";
925
- args = ["-e", "claude"];
926
- }
927
- try {
928
- const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", windowsHide: false });
929
- child.unref();
930
- return { ok: true };
931
- } catch (e) {
932
- return { ok: false, error: String(e && e.message) };
933
- }
934
- }
935
-
936
- // ── request handling ────────────────────────────────────────────────────────
937
-
938
- // A RESPONSE OVER 64 KiB IS A HAZARD ON WINDOWS LOOPBACK (v1.5.0).
939
- //
940
- // Measured, and NOT an ORC bug: a twenty-line plain `http.createServer` on this
941
- // platform loses the tail of a response larger than the 64 KiB socket buffer
942
- // roughly one time in six, after a handful of short-lived connections. The
943
- // client receives ~65,077 of 69,177 bytes, the server's `finish` fires and
944
- // `writableFinished` is true, and nineteen seconds later the client gets
945
- // ECONNRESET with nothing in any log to say why. Under 60,000 bytes it never
946
- // happened in any run.
947
- //
948
- // `/api/config` was 64,934 bytes — six hundred short of that line — so the
949
- // Settings tab was one config key away from failing with no message, and it
950
- // crossed on the release that added five. Two things answer it, and neither is
951
- // a size limit on what this API may say (`--json is not a summary`):
952
- //
953
- // 1. DECLARE THE LENGTH. Without `content-length` Node falls back to chunked
954
- // encoding, which is a worse shape for a body of known size and gives the
955
- // client no way to tell a truncated read from a complete one.
956
- // 2. COMPRESS IT WHEN THE CLIENT ASKS. Every browser sends
957
- // `accept-encoding: gzip`, and the panel IS a browser — gzip takes that
958
- // 69 KB answer to about 8 KB, far below the cliff. It is skipped for a
959
- // small body, where the CPU is not worth it, and skipped entirely for a
960
- // client that did not ask.
961
- const GZIP_MIN_BYTES = 32 * 1024;
962
- // `gzip` as a WHOLE token in the header's comma list, so `x-gzip-not-really`
963
- // never matches. Written out rather than with a regex word boundary, which is
964
- // not one beside a hyphen.
965
- const GZIP_ACCEPTED = /(^|,)\s*gzip\s*(;|,|$)/;
966
-
967
- // ONE implementation, and serve.js uses it for STATIC files too (v1.5.0). The
968
- // 64 KiB cliff is a property of the SOCKET, not of the content type: it does not
969
- // care whether the bytes are an API answer or a panel script. Leaving the static
970
- // path uncompressed left the two largest files in the app — `extra.js` at 138 KB
971
- // and `hookui.js` at 76 KB — riding the exact path this was written to fix. It
972
- // showed up as an intermittent ECONNRESET in the asset-walk test, and it would
973
- // show up in a real browser as a panel script that stops mid-function.
974
- //
975
- // Returns the bytes to send plus the headers that describe them. A caller that
976
- // forgets to declare the length is the other half of the same bug, so the length
977
- // is set HERE rather than left to each call site.
978
- function encodeBody(raw, acceptEncoding) {
979
- const headers = {};
980
- let body = raw;
981
- if (raw.length >= GZIP_MIN_BYTES && GZIP_ACCEPTED.test(String(acceptEncoding || ""))) {
982
- try {
983
- body = zlib.gzipSync(raw);
984
- headers["content-encoding"] = "gzip";
985
- // A cache keyed on the URL alone would hand a gzip body to a client that
986
- // cannot read one. Nothing caches on loopback today; saying so costs one
987
- // header and removes the whole class.
988
- headers["vary"] = "accept-encoding";
989
- } catch (_) {
990
- body = raw;
991
- }
992
- }
993
- headers["content-length"] = String(body.length);
994
- return { body, headers };
995
- }
996
-
997
- function json(res, status, obj) {
998
- const raw = Buffer.from(JSON.stringify(obj), "utf8");
999
- const { body, headers } = encodeBody(raw, res.req && res.req.headers && res.req.headers["accept-encoding"]);
1000
- res.writeHead(
1001
- status,
1002
- Object.assign(
1003
- {
1004
- "content-type": "application/json; charset=utf-8",
1005
- "cache-control": "no-store",
1006
- // Belt and braces on top of the loopback + token checks in serve.js.
1007
- "x-content-type-options": "nosniff",
1008
- },
1009
- headers
1010
- )
1011
- );
1012
- res.end(body);
1013
- }
1014
-
1015
- function readBody(req) {
1016
- return new Promise((resolve, reject) => {
1017
- let raw = "";
1018
- req.on("data", (c) => {
1019
- raw += c;
1020
- if (raw.length > 64_000) reject(new Error("body too large"));
1021
- });
1022
- req.on("end", () => {
1023
- if (!raw) return resolve({});
1024
- try {
1025
- resolve(JSON.parse(raw));
1026
- } catch (_) {
1027
- reject(new Error("body is not JSON"));
1028
- }
1029
- });
1030
- req.on("error", reject);
1031
- });
1032
- }
1033
-
1034
- // The Overview panel needs four commands at once. Doing that in one request
1035
- // keeps the first paint to a single round trip.
1036
- function overview(ctx) {
1037
- const doctor = readCli(["doctor"], ctx);
1038
- const runs = readCli(["run", "list", "--limit", "200"], ctx);
1039
- const waiting = runs.data ? runs.data.runs.filter((r) => r.status === "waiting") : [];
1040
- return {
1041
- where: readCli(["where"], ctx).data,
1042
- doctor: doctor.data,
1043
- wiki: readCli(["wiki", "status"], ctx).data,
1044
- patterns: readCli(["pattern", "status"], ctx).data,
1045
- runs_total: runs.data ? runs.data.total : 0,
1046
- // Rows, not bare slugs (v0.49.2). The Overview card has an age column and
1047
- // rendered it empty because the payload never carried the number — and the
1048
- // "mark as done" button needs the slug beside a real timestamp to be worth
1049
- // showing at all.
1050
- waiting: waiting.map((r) => ({ slug: r.slug, updated_ms: r.updated_ms, lane: r.lane || null })),
1051
- diy: readCli(["diy", "show"], ctx).data,
1052
- // v0.46.0 chips. Each is the CLI's OWN answer — the panel repeats the state
1053
- // words and never derives them. A chip with nothing to say still renders its
1054
- // good state, so "healthy" and "not measured" never look the same.
1055
- pact: readCli(["pact", "status"], ctx).data,
1056
- boundary: readCli(["boundary", "status"], ctx).data,
1057
- wiki_debt: readCli(["wiki", "debt"], ctx).data,
1058
- // v0.54.0 — foreign dispatches that never reported back. Money spent and
1059
- // work half-done that nothing will look at again unless somebody is told.
1060
- // It is a FINDING, never a stop, and the Overview never resumes one.
1061
- extra_journal: readCli(["extra", "journal", "list"], ctx).data,
1062
- };
1063
- }
1064
-
1065
- async function handleApi(req, res, url, ctx) {
1066
- const route = url.pathname;
1067
- const q = Object.fromEntries(url.searchParams);
1068
-
1069
- // Liveness. The page pings this every 15s; no ping from any client for the
1070
- // grace window and the server exits, so a closed tab does not leave a write
1071
- // surface holding a valid token.
1072
- if (route === "/api/ping") {
1073
- ctx.onHeartbeat();
1074
- return json(res, 200, { ok: true, job: jobView() });
1075
- }
1076
-
1077
- // sendBeacon on beforeunload — a best-effort fast path to the same shutdown
1078
- // the heartbeat timeout would reach a minute later.
1079
- if (route === "/api/bye") {
1080
- ctx.onBye();
1081
- return json(res, 200, { ok: true });
1082
- }
1083
-
1084
- if (route === "/api/meta") {
1085
- return json(res, 200, {
1086
- project_root: ctx.projectRoot,
1087
- fixtures: ctx.fixtures,
1088
- version: ctx.version,
1089
- port: ctx.port,
1090
- idle_minutes: ctx.idleMinutes,
1091
- started_ms: ctx.startedMs,
1092
- });
1093
- }
1094
-
1095
- if (route === "/api/job") return json(res, 200, jobView());
1096
-
1097
- // Fixture mode short-circuits every data route: canned JSON, no project, no
1098
- // spawn. This is what makes the STALE chip and the unhealthy doctor panel
1099
- // designable on a machine where everything is green (see the plan, §9).
1100
- if (ctx.fixtures) {
1101
- if (req.method !== "GET") {
1102
- // Almost every mutation answers "nothing ran", which is the honest reply
1103
- // in a mode that runs nothing. The ONE exception is the connection test:
1104
- // its two outcomes are states the Extra panel is largely about, and a
1105
- // state with no fixture is a state nobody has ever looked at. A canned
1106
- // answer carries `data` and NOT the `fixture` flag, so the panel renders
1107
- // the real result shape — the command string is what says it was canned.
1108
- let body = {};
1109
- try {
1110
- body = await readBody(req);
1111
- } catch (_) {}
1112
- const canned = fixtures.post(route, body);
1113
- if (canned)
1114
- return json(res, 200, {
1115
- ok: true,
1116
- exit_code: canned.exit_code,
1117
- data: canned.data,
1118
- command: "(fixtures — nothing ran)",
1119
- });
1120
- return json(res, 200, { ok: true, fixture: true, command: "(fixtures — nothing ran)" });
1121
- }
1122
- const canned = fixtures.get(route, q);
1123
- if (canned === undefined) return json(res, 404, { error: "no fixture for " + route });
1124
- return json(res, 200, { ok: true, exit_code: 0, data: canned, fixture: true });
1125
- }
1126
-
1127
- if (req.method === "GET") {
1128
- if (route === "/api/overview") return json(res, 200, { ok: true, exit_code: 0, data: overview(ctx) });
1129
- if (route === "/api/learn") {
1130
- // The only endpoint with no CLI behind it: the onboarding topics are
1131
- // static content already shipped as a module, so spawning to read them
1132
- // would be ceremony with a cost.
1133
- const { SECTIONS } = require("../onboarding-content.js");
1134
- return json(res, 200, { ok: true, exit_code: 0, data: { sections: SECTIONS } });
1135
- }
1136
- // The mocked runs (v0.46.x). Same shape as /api/learn above and for the
1137
- // same reason: this is static content that ships inside this package, so
1138
- // spawning a subprocess to read files sitting next to this one would be
1139
- // ceremony with a cost. `orc mock-run` reads the identical module, so the
1140
- // terminal and the panel cannot disagree.
1141
- if (route === "/api/mockruns") {
1142
- return json(res, 200, { ok: true, exit_code: 0, data: require("../mockrun-catalog.js").catalogue() });
1143
- }
1144
- if (route === "/api/mockrun") {
1145
- const doc = require("../mockrun-catalog.js").get(String(q.slug || ""));
1146
- if (!doc) return json(res, 200, { ok: true, exit_code: 1, data: { slug: String(q.slug || ""), found: false } });
1147
- return json(res, 200, { ok: true, exit_code: 0, data: { ...doc, found: true } });
1148
- }
1149
- if (route === "/api/fs/list") {
1150
- return json(res, 200, { ok: true, exit_code: 0, data: fsList(q.path, ctx) });
1151
- }
1152
- if (route === "/api/experiment") {
1153
- return json(res, 200, {
1154
- ok: true,
1155
- exit_code: 0,
1156
- data: {
1157
- lanes: LANES,
1158
- project_root: ctx.projectRoot,
1159
- platform: process.platform,
1160
- // Fixture mode must never spawn a real terminal on a machine that has
1161
- // no project — the button says so instead of lying about it.
1162
- can_launch: !ctx.fixtures,
1163
- },
1164
- });
1165
- }
1166
- if (route === "/api/maintenance") {
1167
- const actions = Object.entries(MAINTENANCE).map(([id, m]) => ({
1168
- id,
1169
- label: m.label,
1170
- command: "orc " + m.apply.join(" "),
1171
- network: !!m.network,
1172
- names_files: !!m.names_files,
1173
- advanced: !!m.advanced,
1174
- restarts_ui: !!m.restarts_ui,
1175
- }));
1176
- return json(res, 200, { ok: true, exit_code: 0, data: { actions } });
1177
- }
1178
- if (route === "/api/maintenance/preview") {
1179
- const m = MAINTENANCE[String(q.action)];
1180
- if (!m) return json(res, 400, { error: "unknown action" });
1181
- const probe = readCli(m.preview, ctx);
1182
- return json(res, 200, {
1183
- ok: true,
1184
- exit_code: 0,
1185
- data: {
1186
- action: String(q.action),
1187
- label: m.label,
1188
- command: "orc " + m.apply.join(" "),
1189
- network: !!m.network,
1190
- names_files: !!m.names_files,
1191
- advanced: !!m.advanced,
1192
- // Said in the confirmation, not discovered afterwards. A panel that
1193
- // reloads itself without warning reads as a crash.
1194
- restarts_ui: !!m.restarts_ui,
1195
- preview_command: "orc " + m.preview.join(" "),
1196
- preview: probe.data,
1197
- // Only the UI can know a run is mid-flight; updating changes the
1198
- // skills that run would resume into.
1199
- waiting_runs: (readCli(["run", "list", "--limit", "200"], ctx).data || { runs: [] }).runs
1200
- .filter((r) => r.status === "waiting")
1201
- .map((r) => r.slug),
1202
- dirty_tree: m.network ? isDirtyTree(ctx) : false,
1203
- },
1204
- });
1205
- }
1206
- const build = READS[route];
1207
- if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1208
- const out = readCli(build(q), ctx);
1209
- // v0.49.2 — a read that produced no parseable object still has to SAY why.
1210
- // The body already carried `stderr` and `stdout`; nothing named `error`, so
1211
- // the client fell through to "request failed (500)" and one corrupt ledger
1212
- // looked like a broken panel. The reason the CLI printed is what is shown.
1213
- return json(res, out.ok ? 200 : 500, out.ok ? out : { ...out, error: readFailReason(out) });
1214
- }
1215
-
1216
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1217
-
1218
- let body;
1219
- try {
1220
- body = await readBody(req);
1221
- } catch (e) {
1222
- return json(res, 400, { error: e.message });
1223
- }
1224
-
1225
- // The handoff. It takes NO command from the browser: the lane id is looked up
1226
- // in the server's own catalog and is used only to echo back what to type. The
1227
- // process spawned is always a bare `claude` in the server's own projectRoot,
1228
- // so there is no path by which browser input reaches a shell.
1229
- if (route === "/api/experiment/launch") {
1230
- if (ctx.fixtures) return json(res, 400, { error: "fixture mode never launches anything real" });
1231
- const lane = body.lane ? LANES.find((l) => l.id === String(body.lane)) : null;
1232
- if (body.lane && !lane) return json(res, 400, { error: "unknown lane" });
1233
- const r = launchClaude(ctx);
1234
- if (!r.ok) return json(res, 500, { error: "could not open a terminal: " + r.error });
1235
- return json(res, 200, {
1236
- ok: true,
1237
- // What to type once it is open. The UI shows this; the server never runs it.
1238
- type_this: lane ? lane.cmd : null,
1239
- cwd: ctx.projectRoot,
1240
- });
1241
- }
1242
-
1243
- // v0.50.0 — THE CONNECTION TEST, and the one place this panel does something
1244
- // model-shaped. It is a DIAGNOSTIC in the same family as `orc doctor`: rung 1
1245
- // lists models and costs nothing, rung 2 sends a one-token completion and
1246
- // costs a fraction of a cent, and the CLI decides which — never this file.
1247
- //
1248
- // It is POST because it MUTATES: a green test writes `verified_at` onto the
1249
- // profile, and a red one on a never-verified profile REMOVES that profile
1250
- // (the CLI's own test-first-then-store lifecycle). A GET that did that would
1251
- // be reachable by a prefetch.
1252
- //
1253
- // A pasted key arrives in the BODY and leaves on the child's STDIN — line 1
1254
- // the key, an optional line 2 the passphrase that encrypts it. It is never in
1255
- // argv, never written here, and never echoed back: the response is the CLI's
1256
- // own `--json` object, which carries no credential by construction.
1257
- if (route === "/api/extra/ping") {
1258
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1259
- const profile = String(body.profile || "");
1260
- if (!profile) return json(res, 400, { error: "missing argument" });
1261
- const argv = ["extra", "ping", profile];
1262
- // v0.51.0 — the PAID rung, opt-in and never a default. The panel quotes what
1263
- // it costs before the button; the CLI is what decides the rung and what it
1264
- // reports back.
1265
- if (body.live) argv.push("--live");
1266
- if (body.model) argv.push("--model", String(body.model));
1267
- let input;
1268
- if (body.key) {
1269
- // A NEW key: line 1 the key, an optional line 2 the passphrase that stores
1270
- // it after a green test.
1271
- argv.push("--key-stdin");
1272
- input = String(body.key) + chr10 + String(body.passphrase || "") + chr10;
1273
- } else if (body.passphrase) {
1274
- // A STORED key: the passphrase decrypts it into the CLI's memory for the
1275
- // probe. The two flags are mutually exclusive and the CLI refuses them
1276
- // together BY NAME, so this branch is an `else if` rather than a guess.
1277
- argv.push("--passphrase-stdin");
1278
- input = String(body.passphrase) + chr10;
1279
- }
1280
- const out = runCli(argv, ctx, { json: true, input });
1281
- clearCache();
1282
- // `ok` here is "did the CLI answer at all". Whether the CONNECTION worked is
1283
- // `data.ok` and the exit code, which are the CLI's answer and are passed
1284
- // through untouched — a failed probe is DATA, not a server error.
1285
- return json(res, out.ok ? 200 : 500, out.ok
1286
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1287
- : { ...out, error: readFailReason(out) });
1288
- }
1289
-
1290
- // v0.51.0 — F5's answer, scoped to ONE model id. A model that is LISTED can
1291
- // still be DEAD upstream, so a dropdown is a list of what is OFFERED and never
1292
- // a list of what WORKS. This is POST because it spends money.
1293
- if (route === "/api/extra/models/test") {
1294
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1295
- const profile = String(body.profile || "");
1296
- const model = String(body.model || "");
1297
- if (!profile || !model) return json(res, 400, { error: "missing argument" });
1298
- const argv = ["extra", "models", profile, "--test", model];
1299
- let input;
1300
- if (body.passphrase) {
1301
- argv.push("--passphrase-stdin");
1302
- input = String(body.passphrase) + chr10;
1303
- }
1304
- const out = runCli(argv, ctx, { json: true, input });
1305
- clearCache();
1306
- return json(res, out.ok ? 200 : 500, out.ok
1307
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1308
- : { ...out, error: readFailReason(out) });
1309
- }
1310
-
1311
- // v0.52.0 — SAVING THE PASSPHRASE WITH A DEADLINE. The CLI tests it against
1312
- // the vault before it stores anything (test first, then store), validates the
1313
- // TTL against the same closed set the config key publishes, and answers with
1314
- // the DATE. This route composes nothing: it hands over a profile, a number of
1315
- // days, and a passphrase on stdin.
1316
- if (route === "/api/extra/session/save") {
1317
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1318
- const profile = String(body.profile || "");
1319
- const ttl = String(body.ttl_days || "");
1320
- if (!profile || !ttl) return json(res, 400, { error: "missing argument" });
1321
- const out = runCli(["extra", "session", profile, "--save", "--ttl", ttl], ctx, {
1322
- json: true,
1323
- input: String(body.passphrase || "") + chr10,
1324
- });
1325
- clearCache();
1326
- return json(res, out.ok ? 200 : 500, out.ok
1327
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1328
- : { ...out, error: readFailReason(out) });
1329
- }
1330
-
1331
- // v0.50.0 — proving a passphrase, which is the ONE action that clears the
1332
- // vault's countdown. It NEVER yields the key: `orc extra unlock` answers one
1333
- // question with a yes or a no, and its `attempt N of 10` message is the whole
1334
- // point of the feature, so it is passed back verbatim.
1335
- if (route === "/api/extra/unlock") {
1336
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1337
- const profile = String(body.profile || "");
1338
- if (!profile) return json(res, 400, { error: "missing argument" });
1339
- const out = runCli(["extra", "unlock", profile], ctx, {
1340
- json: true,
1341
- input: String(body.passphrase || "") + chr10,
1342
- });
1343
- clearCache();
1344
- return json(res, out.ok ? 200 : 500, out.ok
1345
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1346
- : { ...out, error: readFailReason(out) });
1347
- }
1348
-
1349
- // Hand the panel over to a fresh process on the SAME port and token, so the
1350
- // open tab only has to reload. POST-only like every other mutation, and it is
1351
- // a mutation: the process answering the next request is not this one.
1352
- //
1353
- // The CLIENT asks for this — never the job's own close handler. The job's
1354
- // output lives in this process's memory, so restarting the instant a command
1355
- // finished would destroy the record of what it did before anyone read it.
1356
- if (route === "/api/ui/restart") {
1357
- if (ctx.fixtures)
1358
- return json(res, 400, { ok: false, reason: "fixtures", error: "fixture mode serves canned data; there is nothing to restart into." });
1359
- if (job && job.running) return json(res, 409, { ok: false, reason: "busy", error: "a command is still running.", job: jobView() });
1360
- const out = typeof ctx.restart === "function" ? ctx.restart() : { ok: false, reason: "unsupported" };
1361
- // A failed handover is NOT fatal and never takes the running panel down:
1362
- // the old server keeps serving, and the client is told to do it by hand.
1363
- return json(res, out.ok ? 200 : 500, out);
1364
- }
1365
-
1366
- if (route === "/api/maintenance/apply") {
1367
- const m = MAINTENANCE[String(body.action)];
1368
- if (!m) return json(res, 400, { error: "unknown action" });
1369
- const started = startJob(m.apply, ctx, { restartUi: !!m.restarts_ui });
1370
- if (started.error) return json(res, 409, started);
1371
- return json(res, 200, { ok: true, ...started });
1372
- }
1373
-
1374
- const build = WRITES[route];
1375
- if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1376
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1377
- let argv;
1378
- try {
1379
- argv = build(body);
1380
- } catch (_) {
1381
- return json(res, 400, { error: "bad request body" });
1382
- }
1383
- if (argv.some((a) => a === "undefined" || a === "null" || a === ""))
1384
- return json(res, 400, { error: "missing argument" });
1385
- const out = runCli(argv, ctx);
1386
- clearCache();
1387
- // A write's exit code is a REAL failure signal (validators exit 1), unlike a
1388
- // read's — so it is reported as such, with the CLI's own message.
1389
- return json(res, 200, {
1390
- ok: out.exit_code === 0,
1391
- exit_code: out.exit_code,
1392
- command: out.command,
1393
- // Writes print human text, not JSON — that IS the confirmation to show.
1394
- output: (out.stdout + (out.stderr ? "\n" + out.stderr : "")).trim(),
1395
- });
1396
- }
1397
-
1398
- // `orc upgrade` replaces the package while your working tree may hold changes.
1399
- // Worth a warning before, not a surprise after.
1400
- function isDirtyTree(ctx) {
1401
- try {
1402
- const r = spawnSync("git", ["status", "--porcelain"], {
1403
- cwd: ctx.projectRoot,
1404
- encoding: "utf8",
1405
- windowsHide: true,
1406
- timeout: 5000,
1407
- });
1408
- return r.status === 0 && !!(r.stdout || "").trim();
1409
- } catch (_) {
1410
- return false;
1411
- }
1412
- }
1413
-
1414
- module.exports = { handleApi, clearCache, encodeBody, READS, WRITES, MAINTENANCE };
1
+ "use strict";
2
+ /**
3
+ * api.js — the /api router for `orc ui`.
4
+ *
5
+ * THE ONE ARCHITECTURAL RULE: this file never re-implements CLI logic. Every
6
+ * endpoint spawns `node bin/cli.js <cmd> --json` and forwards the parsed
7
+ * object. That makes UI/CLI drift structurally impossible — the UI *is* the
8
+ * CLI — and it means every write inherits the CLI's validators, the LEGACY_KEYS
9
+ * aliasing and the shadowing announcements for free, with zero duplicated
10
+ * logic.
11
+ *
12
+ * The alternative (requiring cli.js as a library) is off the table: cli.js ends
13
+ * with a bare IIFE, has no require.main guard, prints and process.exit()s
14
+ * directly, and is pinned by contract tokens with binFiles: ["bin/cli.js"].
15
+ *
16
+ * It also never RUNS a lane and never calls a model API. Since v0.43.4 there is
17
+ * exactly one deliberate exception to "never spawns claude": the Experiment
18
+ * panel can open a TERMINAL with `claude` in it and then forget about it (see
19
+ * `launchClaude`). No model output ever flows back through this server, no
20
+ * session is proxied, no API key is held — the panel is still not an AI client.
21
+ */
22
+
23
+ const { spawn, spawnSync } = require("child_process");
24
+ const fs = require("fs");
25
+ const os = require("os");
26
+ const path = require("path");
27
+ const zlib = require("zlib");
28
+ const fixtures = require("./fixtures/index.js");
29
+
30
+ const CLI = path.join(__dirname, "..", "cli.js");
31
+
32
+ // A single-user localhost panel re-reads the same command several times while
33
+ // one page renders. A short TTL collapses that without ever showing stale data
34
+ // across a user action: every mutation clears the cache outright.
35
+ const READ_TTL_MS = 2500;
36
+ const chr10 = String.fromCharCode(10);
37
+ const CLI_TIMEOUT_MS = 30_000;
38
+
39
+ const cache = new Map();
40
+
41
+ function cacheKey(argv) {
42
+ return argv.join("\u0000");
43
+ }
44
+
45
+ function clearCache() {
46
+ cache.clear();
47
+ }
48
+
49
+ // v1.4.0 — the board a statusline request is about. A value this server does
50
+ // not recognise is DROPPED rather than forwarded: the CLI would refuse it, and
51
+ // a query string is user input like any other.
52
+ function slBoard(q) {
53
+ const b = q && q.board ? String(q.board) : "";
54
+ return b === "subagent" ? ["--board", "subagent"] : [];
55
+ }
56
+
57
+ // ── running the CLI ─────────────────────────────────────────────────────────
58
+
59
+ // Several commands use a NON-ZERO exit as a normal answer, not a failure:
60
+ // `pattern status` (1 = absent, 2 = unknown key), `gotcha list` (1 = none),
61
+ // `wiki impact` (2 = delta, 3 = full), `pr stack status` (1 = not ready),
62
+ // `doctor` (1 = issues found), `resume` (1 = nothing waiting). So the exit code
63
+ // is DATA here, never an error condition — a run counts as failed only when it
64
+ // produced no parseable object.
65
+ // `input` (v0.50.0) is stdin for the child, and it exists for exactly one
66
+ // caller: the connection test, which takes a pasted API key on line 1 and an
67
+ // optional passphrase on line 2. It is a parameter rather than a second spawn
68
+ // helper because a secret must travel the SAME path everything else does —
69
+ // never argv (world-readable in a process list), never a temp file, never a log
70
+ // line. Nothing here ever echoes it back, and `command` below is built from
71
+ // argv alone.
72
+ function runCli(argv, ctx, { json = false, input = undefined } = {}) {
73
+ const args = [...argv];
74
+ if (json) args.push("--json");
75
+ // Always target the project explicitly: the server's cwd is not a reliable
76
+ // way to reach the same .claude the launching command resolved.
77
+ if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
78
+ // ORC_NO_UPDATE_CHECK exists here to protect the --json contract: most
79
+ // commands end with `maybeNudge()`, which prints an "update available" line to
80
+ // STDOUT and would sit beside the object this parses.
81
+ //
82
+ // `version` and `changelog` are the exceptions, and forcing the flag on them
83
+ // was a real bug: neither nudges, and for both the check IS the payload — so
84
+ // the panel asked whether an update existed with the check switched off and
85
+ // was told `check_disabled: true` forever. A blanket env var silenced the one
86
+ // command whose entire job is to answer that question.
87
+ const CHECKS_UPDATES = argv[0] === "version" || argv[0] === "changelog";
88
+ const env = { ...process.env, NO_COLOR: "1" };
89
+ if (!CHECKS_UPDATES) env.ORC_NO_UPDATE_CHECK = "1";
90
+ const r = spawnSync(process.execPath, [CLI, ...args], {
91
+ encoding: "utf8",
92
+ timeout: CLI_TIMEOUT_MS,
93
+ windowsHide: true,
94
+ env,
95
+ input,
96
+ });
97
+ const stdout = r.stdout || "";
98
+ let data = null;
99
+ try {
100
+ data = JSON.parse(stdout);
101
+ } catch (_) {}
102
+ return {
103
+ ok: data !== null,
104
+ exit_code: r.status === null ? -1 : r.status,
105
+ data,
106
+ stderr: (r.stderr || "").trim(),
107
+ stdout: data === null ? stdout.trim() : "",
108
+ command: "orc " + argv.join(" "),
109
+ };
110
+ }
111
+
112
+ // The one-line reason a read failed, in the CLI's own words. `crashed` is the
113
+ // envelope the CLI itself emits when a `--json` route throws (v0.49.2); anything
114
+ // else is a command that wrote nothing parseable at all.
115
+ function readFailReason(out) {
116
+ const first = (out.stderr || out.stdout || "")
117
+ .split(chr10)
118
+ .map((l) => l.trim())
119
+ .filter(Boolean)[0];
120
+ const tail = first ? " \u2014 " + first : "";
121
+ return `${out.command} produced no JSON (exit ${out.exit_code})${tail}`;
122
+ }
123
+
124
+ function readCli(argv, ctx) {
125
+ const key = cacheKey([ctx.projectRoot || "", ...argv]);
126
+ const hit = cache.get(key);
127
+ if (hit && Date.now() - hit.at < READ_TTL_MS) return hit.value;
128
+ const value = runCli(argv, ctx, { json: true });
129
+ cache.set(key, { at: Date.now(), value });
130
+ return value;
131
+ }
132
+
133
+ // ── jobs (the mutation half) ────────────────────────────────────────────────
134
+ // SINGLE-FLIGHT: one mutation at a time, process-wide. The client goes
135
+ // read-only while a job runs, so a second one is a bug, not a race to win.
136
+ // Output accumulates in memory and the client polls /api/job — which is what
137
+ // makes `orc update` and `orc upgrade` show their real output as it happens
138
+ // instead of a spinner and a verdict.
139
+
140
+ let job = null;
141
+ let jobSeq = 0;
142
+
143
+ function jobView() {
144
+ if (!job) return { id: null, running: false };
145
+ return {
146
+ id: job.id,
147
+ command: job.command,
148
+ running: job.running,
149
+ exit_code: job.exit_code,
150
+ output: job.output,
151
+ started_ms: job.started_ms,
152
+ // THE PANEL IS SERVING CODE THAT THIS JOB MAY HAVE JUST REPLACED. Reported
153
+ // only on a job that SUCCEEDED and whose action is declared as touching the
154
+ // install — a failed upgrade changed nothing, so restarting after one would
155
+ // be motion with no reason. The client acts on it; the server does not
156
+ // restart itself, so the job's output survives long enough to be read.
157
+ restart_pending: !!(job.restart_ui && !job.running && job.exit_code === 0),
158
+ };
159
+ }
160
+
161
+ function startJob(argv, ctx, opts) {
162
+ if (job && job.running) return { error: "busy", job: jobView() };
163
+ const args = [...argv];
164
+ if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
165
+ const id = ++jobSeq;
166
+ job = {
167
+ id,
168
+ command: "orc " + argv.join(" "),
169
+ running: true,
170
+ exit_code: null,
171
+ output: "",
172
+ started_ms: Date.now(),
173
+ restart_ui: !!(opts && opts.restartUi),
174
+ };
175
+ const child = spawn(process.execPath, [CLI, ...args], {
176
+ windowsHide: true,
177
+ env: { ...process.env, NO_COLOR: "1" },
178
+ });
179
+ const append = (buf) => {
180
+ job.output += buf.toString("utf8");
181
+ // A runaway job must not grow the server's heap without bound.
182
+ if (job.output.length > 400_000) job.output = job.output.slice(-400_000);
183
+ };
184
+ child.stdout.on("data", append);
185
+ child.stderr.on("data", append);
186
+ child.on("error", (e) => {
187
+ job.output += "\n" + String(e.message);
188
+ job.exit_code = -1;
189
+ job.running = false;
190
+ });
191
+ child.on("close", (code) => {
192
+ job.exit_code = code === null ? -1 : code;
193
+ job.running = false;
194
+ clearCache(); // every panel refetches against the new truth
195
+ });
196
+ return { job: jobView() };
197
+ }
198
+
199
+ // ── the endpoint table ──────────────────────────────────────────────────────
200
+ // READS map a route to CLI argv. WRITES are separate and POST-only — a GET can
201
+ // never mutate, so a prefetch, a bookmark or a browser retry is always safe.
202
+
203
+ const READS = {
204
+ "/api/version": () => ["version"],
205
+ "/api/changelog": () => ["changelog"],
206
+ "/api/where": () => ["where"],
207
+ "/api/doctor": () => ["doctor"],
208
+ "/api/config": () => ["config", "list"],
209
+ "/api/config/profiles": () => ["config", "profile"],
210
+ "/api/config/recommend": () => ["config", "recommend"],
211
+ // v1.0.0 W16 — the `orc lane` noun, rendered. All three are READS and all
212
+ // three are answers the CLI already computes in full: which lanes exist and
213
+ // how many keys each reads, which SHARED phases a lane runs and in what
214
+ // order, and the whole call catalogue. The panel draws them and decides
215
+ // nothing about them — the Flow-stepper rule, applied to the lane model.
216
+ // `lane phases` and `lane config` both exit 2 on an unknown lane, which is
217
+ // DATA here exactly like `pattern status` and `wiki impact` above.
218
+ "/api/lanes": () => ["lane", "list"],
219
+ "/api/lane/phases": (q) => ["lane", "phases", String(q.lane || "")],
220
+ "/api/lane/calls": (q) => (q.lane ? ["lane", "calls", String(q.lane)] : ["lane", "calls", "--all"]),
221
+ "/api/runs": (q) => ["run", "list", "--limit", String(Math.min(200, Number(q.limit) || 40))],
222
+ "/api/run": (q) => ["run", "show", String(q.slug || "")],
223
+ "/api/wiki": () => ["wiki", "status"],
224
+ // v1.8.0 — the code graph. A READ whose exit code is DATA (0 fresh · 1 none ·
225
+ // 2 drifted · 3 off), exactly like `wiki status` above.
226
+ "/api/graph": () => ["graph", "status"],
227
+ "/api/wiki/impact": () => ["wiki", "impact"],
228
+ // v0.46.0. Every one is a READ with an exit-code contract, so the exit code is
229
+ // DATA here exactly like `pattern status` and `wiki impact` above: pact 0/1/2/3,
230
+ // boundary 0/1/2/3, handoff 0/1, budget 0/1/2/3, aftermath 0/1/2/3,
231
+ // wiki plan 0/1/2/3, wiki debt 0/1/3, export --check 0/1.
232
+ "/api/wiki/plan": () => ["wiki", "plan"],
233
+ "/api/wiki/debt": () => ["wiki", "debt"],
234
+ "/api/wiki/usage": () => ["wiki", "usage"],
235
+ // v0.49.1 — the wiki's CONTENTS, not only its temperature. All three are
236
+ // READS whose exit code is DATA (docs 0/1/3, show 0/2/3, coverage 0/1), and
237
+ // `coverage` deliberately has no threshold: nothing branches on it, here or
238
+ // anywhere else. `--body` is opt-in and one artifact at a time — the
239
+ // /api/doc/section precedent.
240
+ "/api/wiki/docs": () => ["wiki", "docs"],
241
+ "/api/wiki/show": (q) => ["wiki", "show", String(q.doc || ""), ...(q.body ? ["--body"] : [])],
242
+ "/api/wiki/coverage": () => ["wiki", "coverage"],
243
+ // The pattern file is injected LITERALLY into every executor slice, and until
244
+ // now nothing would show you a line of it. Same 0/1/2 contract `pattern
245
+ // status` has had since v0.34.8.
246
+ "/api/pattern/show": (q) => ["pattern", "show", String(q.lang || ""), ...(q.body ? ["--body"] : [])],
247
+ "/api/gotcha/show": (q) => ["gotcha", "show", String(q.id || "")],
248
+ "/api/gotchas/archived": () => ["gotcha", "list", "--archived"],
249
+ // Preview-then-apply: A COUNT IS NOT CONSENT. The Apply button stays disabled
250
+ // until this has been fetched, and it names every entry eviction would touch.
251
+ "/api/gotcha/prune/preview": () => ["gotcha", "prune", "--dry-run"],
252
+ // v1.1.0 — the wait. Three READS, and every one of them is a state the panel
253
+ // renders and never derives: `usage check` 0/1/2 (and `unknown` is a state,
254
+ // not a failure), the lane table, and the run's block. The panel CANNOT start
255
+ // a wait — a wait lives in a Claude Code session, and `orc ui` never runs a
256
+ // lane. It configures the defaults, shows a wait, and cancels one.
257
+ "/api/usage": () => ["usage", "check"],
258
+ // v1.3.0 — the CLI Hook Interface. THE PANEL DRAWS THE CATALOGUE AND DERIVES
259
+ // NONE OF IT: the groups, the renderers, their option sets and the rendered
260
+ // sample per renderer all come from `statusline components --json`, and a
261
+ // test greps the panel for component ids, renderer names, glyph-set names,
262
+ // ramp names, colour tokens and state words. It must name none of them.
263
+ //
264
+ // `preview` is the one that earns the panel its place: it renders through the
265
+ // SAME engine the hook does, so what you see is what the bar will print.
266
+ // v1.4.0 — TWO BOARDS through one set of routes. `--board` is passed
267
+ // through verbatim; the CLI owns which boards exist and which components
268
+ // each may hold, and the panel — as everywhere else — decides none of it.
269
+ "/api/statusline/components": (q) => ["statusline", "components", ...slBoard(q)],
270
+ "/api/statusline/show": (q) => ["statusline", "show", ...slBoard(q)],
271
+ "/api/statusline/presets": (q) => ["statusline", "presets", ...slBoard(q)],
272
+ "/api/statusline/preview": (q) => {
273
+ const argv = ["statusline", "preview", ...slBoard(q)];
274
+ if (q.width) argv.push("--width", String(q.width));
275
+ if (q.state) argv.push("--state", String(q.state));
276
+ // v1.4.2 — a RENDER-ONLY override, so the panel can draw the same layout
277
+ // under each colour set and each symbol set. It writes nothing: the CLI
278
+ // applies these to a copy of the saved layout before it compiles.
279
+ if (q.theme) argv.push("--theme", String(q.theme));
280
+ if (q.glyphs) argv.push("--glyphs", String(q.glyphs));
281
+ return argv;
282
+ },
283
+ "/api/statusline/explain": (q) => ["statusline", "explain", String(q.at || "1:1"), ...slBoard(q)],
284
+ "/api/wait/lanes": () => ["wait", "lanes"],
285
+ "/api/wait/status": (q) => (q.slug ? ["wait", "status", String(q.slug)] : ["wait", "status"]),
286
+ "/api/pact": () => ["pact", "status"],
287
+ "/api/boundary": (q) => (q.path ? ["boundary", "status", String(q.path)] : ["boundary", "status"]),
288
+ "/api/handoff": () => ["handoff", "surfaces"],
289
+ "/api/budget/rates": () => ["budget", "rates"],
290
+ // A forecast takes a PLAN PATH. The browser sends a path the folder picker
291
+ // produced; the server passes it straight to the CLI and never opens the file
292
+ // itself — /api/fs/list stays a directory LISTER, and widening it to read a
293
+ // plan would be the one change that turns this panel into a file reader.
294
+ "/api/budget/forecast": (q) => ["budget", "forecast", String(q.plan || "")],
295
+ "/api/budget/actual": (q) => ["budget", "actual", String(q.slug || "")],
296
+ "/api/aftermath": (q) => (q.since ? ["aftermath", "status", "--since", String(q.since)] : ["aftermath", "status"]),
297
+ "/api/export": () => ["export", "--check"],
298
+ // v0.47.0 — /orc-challenge. Same shape: every one is a READ whose exit code is
299
+ // DATA (list 0/1/3, status 0/1/2/3, diff 0/1/2/3, lint 0/1/2), and the panel
300
+ // derives nothing from them — not the state word, not the iteration order, not
301
+ // the pass decision, not the expected revision path.
302
+ "/api/challenge": () => ["challenge", "list"],
303
+ "/api/challenge/one": (q) => ["challenge", "status", String(q.slug || "")],
304
+ "/api/challenge/show": (q) => [
305
+ "challenge",
306
+ "show",
307
+ String(q.slug || ""),
308
+ ...(q.iteration ? ["--iteration", String(q.iteration)] : []),
309
+ ],
310
+ "/api/challenge/diff": (q) => ["challenge", "diff", String(q.slug || "")],
311
+ // v0.49.1 — the council. `roles` is STATIC (it works with no cycle at all),
312
+ // and it is the ONE catalogue: the panel names no lens, no class and no
313
+ // disposition itself, exactly as it names no flow step. `council` is 0 set /
314
+ // 1 unset / 3 unknown, and UNSET is an ANSWER, not an error.
315
+ "/api/challenge/roles": (q) => ["challenge", "roles", ...(q.kind ? ["--kind", String(q.kind)] : [])],
316
+ "/api/challenge/council": (q) => ["challenge", "council", String(q.slug || "")],
317
+ "/api/challenge/lint": (q) => [
318
+ "challenge",
319
+ "lint",
320
+ String(q.path || ""),
321
+ ...(q.template ? ["--template", String(q.template)] : []),
322
+ ],
323
+ // v0.48.0 — /orc-doc. Same shape again: every one is a READ whose exit code is
324
+ // DATA (list 0, status 0/1/2, map 0/2, plan 0/1, lint 0/1/2), and the panel
325
+ // derives nothing from them — not the section order, not a line range, not a
326
+ // state word, not the batching, not a lint rule name. It draws what the CLI
327
+ // computed.
328
+ //
329
+ // `/api/doc/section` is the ONE route that returns any of the document's
330
+ // prose, it returns exactly ONE section, and only on an explicit Reveal click.
331
+ // The rule this lane lives by is that nothing HOLDS the document — not that
332
+ // the text is secret — and the panel renders it as DOM through `renderMd`,
333
+ // never as HTML.
334
+ "/api/doc": () => ["doc", "list"],
335
+ "/api/doc/one": (q) => ["doc", "status", String(q.slug || "")],
336
+ "/api/doc/show": (q) => ["doc", "show", String(q.slug || "")],
337
+ "/api/doc/section": (q) => ["doc", "show", String(q.slug || ""), "--section", String(q.section || "")],
338
+ "/api/doc/map": (q) => ["doc", "map", String(q.slug || "")],
339
+ "/api/doc/lint": (q) => [
340
+ "doc",
341
+ "lint",
342
+ String(q.slug || ""),
343
+ ...(q.target ? ["--target", String(q.target)] : []),
344
+ ],
345
+ "/api/doc/plan": (q) => ["doc", "plan", String(q.slug || ""), "--role", String(q.role || "write")],
346
+ "/api/doc/templates": () => ["doc", "templates"],
347
+ "/api/doc/targets": () => ["doc", "targets"],
348
+ // v0.48.1 — the score, the drift report and the memory surface. Every one is
349
+ // a subprocess of the real command: the panel decides nothing about the
350
+ // pipeline order, nothing about which drift classes exist, and nothing about
351
+ // which journal rows are the user's own words.
352
+ //
353
+ // There is deliberately NO route for `orc doc log`: the SKILL records a
354
+ // request, because the skill is what took one. A panel that could write a
355
+ // journal entry could write one nobody said.
356
+ // v0.49.0 — the SECTION FILES. This is the one read that works before a
357
+ // single compile has ever run, because the files ARE the progress.
358
+ "/api/doc/parts": (q) => ["doc", "parts", String(q.slug || "")],
359
+ "/api/doc/next": (q) => ["doc", "next", String(q.slug || "")],
360
+ "/api/doc/audit": (q) => ["doc", "audit", String(q.slug || "")],
361
+ "/api/doc/journal": (q) => ["doc", "journal", String(q.slug || "")],
362
+ "/api/doc/context": (q) => ["doc", "context", String(q.slug || "")],
363
+ "/api/doc/extra": (q) => ["doc", "extra", String(q.slug || "")],
364
+ // v0.49.2 — the project's own house rules, the frozen set of one document,
365
+ // the run map and the cost report. All four are READS; the panel decides
366
+ // nothing about a priority, an order, a wave shape or a number.
367
+ "/api/doc/rules": () => ["doc", "rules"],
368
+ "/api/doc/rules/one": (q) => ["doc", "rules", String(q.slug || "")],
369
+ // v1.7.0 — the anti-slop rule surface. Five READS. The panel decides nothing:
370
+ // not the precedence order, not a rule's tier, not the override count, and
371
+ // not which of the 65 rules the lint can actually prove.
372
+ //
373
+ // `lint` takes a path from the query string. It is user input like any other,
374
+ // so it is forwarded as ONE argv element and never through a shell — the same
375
+ // reason every other route here spawns an array rather than a string.
376
+ "/api/rules": () => ["rules"],
377
+ "/api/rules/user": () => ["rules", "user"],
378
+ "/api/rules/packs": () => ["rules", "packs"],
379
+ "/api/rules/credits": () => ["rules", "credits"],
380
+ "/api/rules/lint": (q) => ["rules", "lint", String(q.path || ".")],
381
+ // v1.7.0 — the wiki's one-doc-at-a-time probes. Both free, neither scans.
382
+ "/api/wiki/resolve": (q) => ["wiki", "resolve", String(q.topic || "")],
383
+ "/api/wiki/refs": () => ["wiki", "refs", "--check"],
384
+ "/api/doc/forecast": (q) => ["doc", "forecast", String(q.slug || "")],
385
+ "/api/doc/cost": (q) => ["doc", "cost", String(q.slug || "")],
386
+ // v0.50.0 — `orc extra`. Every one is a READ, and every one is a subprocess of
387
+ // the real command: the panel decides nothing about a provider, a model id, a
388
+ // verification state, a band or a price. It renders what the CLI computed.
389
+ //
390
+ // There is deliberately NO read that returns a credential. `orc extra list`
391
+ // and `orc extra show` go through `redactProfile`, which is an ALLOW-LIST, so
392
+ // a field added later that carries a secret has to be let through on purpose.
393
+ "/api/extra": () => ["extra", "list"],
394
+ "/api/extra/providers": () => ["extra", "providers"],
395
+ "/api/extra/show": (q) => ["extra", "show", String(q.profile || "")],
396
+ "/api/extra/models": (q) => ["extra", "models", String(q.profile || "")],
397
+ // v0.51.0 — the LOCAL TOOLS read, and the credential-route read. Both are the
398
+ // real commands, so the panel decides nothing about an install command, a
399
+ // platform, a version floor or which of three credential routes applies.
400
+ // `tools` exits 1 when no tool is ready, which is exit-code-as-DATA like
401
+ // `pattern status` — never an error.
402
+ "/api/extra/tools": () => ["extra", "tools"],
403
+ "/api/extra/keyhelp": (q) => ["extra", "keyhelp", String(q.profile || "")],
404
+ "/api/extra/route": () => ["extra", "route"],
405
+ // v0.55.0 — THE POSITIONS, the non-scored half of routing. It exits 1 when
406
+ // nothing routes, which is exit-code-as-DATA like `pattern status` — an empty
407
+ // result is an ANSWER and it still returns its whole object.
408
+ "/api/extra/role": () => ["extra", "role", "list"],
409
+ // v0.52.0 (D6) — WHICH LANE a band governs, computed through the same
410
+ // resolver every dispatch uses. The panel renders it and derives nothing:
411
+ // a band with no lane attached is not a routing decision.
412
+ "/api/extra/lanes": () => ["extra", "lanes"],
413
+ // `stats` exits 1 with a real object when no foreign dispatch has been traced
414
+ // yet, and `rates` exits 1 when a pair has no price — both are exit-code-as-
415
+ // DATA, like `pattern status` and `wiki impact`, never an error.
416
+ "/api/extra/stats": (q) => (q.since ? ["extra", "stats", "--since", String(q.since)] : ["extra", "stats"]),
417
+ "/api/extra/rates": () => ["extra", "rates"],
418
+ "/api/extra/doctor": () => ["extra", "doctor"],
419
+ // v0.54.0 — RECOVERY. Both are FREE reads (zero model tokens), which is why
420
+ // both are real buttons; `resume-slice` and the dispatch that follows it cost
421
+ // money and are copy-able commands instead. `reconcile` exits 0-4 as an
422
+ // exit-code-as-DATA contract like `pattern status`, so no state here is an
423
+ // error — including `in-flight`, which is a REFUSAL the panel must render as
424
+ // one rather than as a dead control.
425
+ "/api/extra/journal": () => ["extra", "journal", "list"],
426
+ // v1.0.0 W16 — the RUN DEMOTION, read. Exit 0 armed · 1 demoted · 2 unknown
427
+ // run, which is exit-code-as-DATA like every other gate command here. The
428
+ // run is optional: with none given the CLI reads the trace pointer, which is
429
+ // exactly what a panel wants — "the run that is open right now".
430
+ "/api/extra/demotion": (q) => ["extra", "demotion", ...(q.run ? [String(q.run)] : [])],
431
+ "/api/extra/reconcile": (q) => ["extra", "reconcile", String(q.task || "")],
432
+ "/api/extra/journal/prune/preview": () => ["extra", "journal", "prune", "--dry-run"],
433
+ // v1.5.0 — /orc-test, the lane that RUNS the test. THREE reads, and every one
434
+ // of them is a READ in the strict sense this lane needs: none re-scans the
435
+ // repository, none probes the target, and none writes the ledger. Opening a
436
+ // page must never be a measurement — a measurement nobody asked for is
437
+ // traffic nobody authorized.
438
+ //
439
+ // `orc test show` is the whole computed view (`--json is not a summary`), so
440
+ // the surface, the case matrix, the closed OWASP set, the runs and the
441
+ // findings all arrive in ONE object the panel renders and derives nothing
442
+ // from: not a state word, not a severity, not an OWASP id, not the tier.
443
+ // `status` 0 always, `show` 0 / 2 no run, `ui tools` 0 ready / 1 not ready /
444
+ // 2 the driver is `none` — and 2 there is a SETTING, not a missing tool.
445
+ "/api/test": () => ["test", "status"],
446
+ "/api/test/one": (q) => ["test", "show", String(q.slug || "")],
447
+ "/api/test/ui": () => ["test", "ui", "tools"],
448
+ "/api/patterns": () => ["pattern", "status"],
449
+ "/api/gotchas": () => ["gotcha", "list"],
450
+ "/api/stats": (q) => (q.since ? ["stats", "--since", String(q.since)] : ["stats"]),
451
+ "/api/diy": () => ["diy", "show"],
452
+ "/api/crosslink": () => ["crosslink", "list"],
453
+ "/api/crosslink/kinds": () => ["crosslink", "kinds"],
454
+ "/api/mocks": () => ["mock", "list"],
455
+ "/api/mock": (q) => ["mock", "show", String(q.slug || "")],
456
+ "/api/stack": (q) => (q.slug ? ["pr", "stack", "status", String(q.slug)] : ["pr", "stack", "status"]),
457
+ };
458
+
459
+ // v0.46.0 writes. THE LINE THIS PANEL DOES NOT CROSS: a button exists only for an
460
+ // action that costs NO model tokens. `orc pact check` runs the ledger's own cheap
461
+ // proofs (a test, a command, a grep the user wrote); `orc handoff set` edits one
462
+ // graded surface; `orc export` compiles files already on disk. Every one is
463
+ // deterministic and every one is a real CLI command.
464
+ //
465
+ // The conversational half of each lane — reconciling a promise, deciding a
466
+ // verdict, walking somebody through a change — costs model tokens, so the panel
467
+ // COPIES those commands and never runs them. A test greps this object to make
468
+ // sure no lane name ever appears inside it.
469
+ const WRITES = {
470
+ "/api/config/set": (b) => ["config", "set", String(b.key), String(b.value)],
471
+ "/api/config/reset": (b) => (b.key ? ["config", "reset", String(b.key)] : ["config", "reset"]),
472
+ "/api/config/profile": (b) => ["config", "profile", String(b.name)],
473
+ "/api/diy/set": (b) => ["diy", "set", String(b.key), String(b.value)],
474
+ "/api/diy/compile": () => ["diy", "compile"],
475
+ // The bootstrap the TTY composer offers as its first question, and the one
476
+ // piece of DIY the panel had no way to reach (v0.44.0). `--force` is what
477
+ // makes it an ANSWER rather than an error on an already-configured project:
478
+ // `orc diy init` refuses to overwrite without it. That is destructive, so the
479
+ // UI confirms with the preset's own diff and the exact command on screen.
480
+ // An empty name is the wizard's "full-lane defaults" — a real invocation,
481
+ // not a synthesised one.
482
+ "/api/diy/preset": (b) => {
483
+ const argv = ["diy", "init", "--force"];
484
+ if (b.name) argv.push("--preset", String(b.name));
485
+ return argv;
486
+ },
487
+ // v1.1.0 — the only two wait mutations this panel may make, and neither
488
+ // starts one. `unblock` restores a gate the user vetoed; `cancel` ends a wait
489
+ // already running. A BLOCK cannot be created here on purpose: it needs a
490
+ // reason typed in the moment, and a reason typed into a settings page days
491
+ // later is not the record that makes the risk demonstrably the user's.
492
+ // v1.3.0 — the layout's writers. Every one shells the same validator the CLI
493
+ // uses, so an illegal placement is refused BY NAME here exactly as it is
494
+ // there. The board makes the illegal drop impossible; this is the guarantee.
495
+ "/api/statusline/set": (b) => {
496
+ const argv = ["statusline", "set", String(b.line), String(b.pos)];
497
+ if (b.type) argv.push(String(b.type));
498
+ for (const [k, f] of [
499
+ ["render", "--render"], ["label", "--label"], ["color", "--color"],
500
+ ["label_color", "--label-color"], ["value_color", "--value-color"],
501
+ ["bg", "--bg"], ["ramp", "--ramp"], ["glyphs", "--glyphs"],
502
+ ["format", "--format"], ["case", "--case"], ["truncate", "--truncate"],
503
+ ["compact", "--compact"], ["prefix", "--prefix"], ["suffix", "--suffix"],
504
+ ["emphasis", "--emphasis"], ["hide_when", "--hide-when"],
505
+ ["width", "--width"], ["precision", "--precision"],
506
+ ["min_width", "--min-width"], ["min_cols", "--min-cols"],
507
+ ["max_cols", "--max-cols"], ["priority", "--priority"],
508
+ ]) {
509
+ if (b[k] !== undefined && b[k] !== null && b[k] !== "") argv.push(f, String(b[k]));
510
+ }
511
+ if (b.draw_empty) argv.push("--draw-empty");
512
+ return argv.concat(slBoard(b));
513
+ },
514
+ "/api/statusline/move": (b) => ["statusline", "move", String(b.from), String(b.to), ...slBoard(b)],
515
+ "/api/statusline/remove": (b) => ["statusline", "remove", String(b.at), ...slBoard(b)],
516
+ "/api/statusline/line": (b) => {
517
+ const argv = ["statusline", "line", String(b.line)];
518
+ if (b.separator !== undefined) argv.push("--separator", String(b.separator));
519
+ if (b.theme) argv.push("--theme", String(b.theme));
520
+ if (b.max_width !== undefined) argv.push("--max-width", String(b.max_width));
521
+ return argv.concat(slBoard(b));
522
+ },
523
+ // A preset REPLACES the layout, so the panel always confirms it and names
524
+ // the loss — the `orc diy init --force` rule.
525
+ // THE DOCUMENT-LEVEL SETTINGS. `line` is per-LINE and `doc` is the whole
526
+ // layout; the colour set is a document fact, and routing it through `line`
527
+ // wrote one third of the bar while the picker read the other value back.
528
+ "/api/statusline/doc": (b) => {
529
+ const argv = ["statusline", "doc"];
530
+ if (b.theme) argv.push("--theme", String(b.theme));
531
+ if (b.glyphs) argv.push("--glyphs", String(b.glyphs));
532
+ if (b.ansi) argv.push("--ansi", String(b.ansi));
533
+ if (b.align_columns !== undefined) argv.push("--align-columns", b.align_columns ? "on" : "off");
534
+ return argv.concat(slBoard(b));
535
+ },
536
+ "/api/statusline/apply": (b) => ["statusline", "apply", String(b.name), ...slBoard(b)],
537
+ // v1.3.0 W5. `group` wraps 2-4 as one object, `expand` is its inverse and is
538
+ // also how a composite becomes editable, `clone` is for two `config` chips on
539
+ // different keys — a normal thing to want.
540
+ "/api/statusline/group": (b) => ["statusline", "group", ...(b.refs || []).map(String), ...slBoard(b)],
541
+ "/api/statusline/expand": (b) => ["statusline", "expand", String(b.at), ...slBoard(b)],
542
+ "/api/statusline/clone": (b) => ["statusline", "clone", String(b.at), ...slBoard(b)],
543
+ "/api/statusline/reset": (b) => ["statusline", "reset", ...slBoard(b)],
544
+ "/api/statusline/compile": (b) => ["statusline", "compile", ...slBoard(b)],
545
+ "/api/wait/unblock": (b) => (b.slug ? ["wait", "unblock", String(b.slug)] : ["wait", "unblock"]),
546
+ "/api/wait/cancel": (b) => (b.slug ? ["wait", "cancel", String(b.slug)] : ["wait", "cancel"]),
547
+ "/api/wiki/sync": () => ["wiki", "sync"],
548
+ // v1.8.0 — FREE (parser only, no model), so it is a button, the `wiki sync` rule.
549
+ "/api/graph/update": () => ["graph", "update"],
550
+ "/api/wiki/usage/rebuild": () => ["wiki", "usage", "--rebuild"],
551
+ "/api/gotcha/prune": () => ["gotcha", "prune"],
552
+ "/api/pact/check": (b) => (b.id ? ["pact", "check", String(b.id)] : ["pact", "check"]),
553
+ "/api/pact/sync": () => ["pact", "sync"],
554
+ // The surface id, the key and the value all come from the browser — and all
555
+ // three are validated by the CLI, which refuses a RED surface, an unknown id,
556
+ // a key that does not already exist, and a write at all when handoff_write is
557
+ // false. There is no second idea of a safe edit anywhere in this panel.
558
+ "/api/handoff/set": (b) => ["handoff", "set", String(b.id), String(b.key), String(b.value)],
559
+ "/api/budget/calibrate": () => ["budget", "calibrate"],
560
+ // v0.47.0. All three are FREE and deterministic, so all three get a button.
561
+ // Running an ITERATION costs model tokens, so it is a copy-able command and
562
+ // there is deliberately no route for it here. `accept` and `rebut` both refuse
563
+ // without a reason — the CLI decides that, not the form.
564
+ "/api/challenge/accept": (b) => ["challenge", "accept", String(b.slug), String(b.id), String(b.reason || "")],
565
+ "/api/challenge/rebut": (b) => ["challenge", "rebut", String(b.slug), String(b.id), String(b.reason || "")],
566
+ "/api/challenge/report": (b) => ["challenge", "report", String(b.slug)],
567
+ // v0.49.1. Both are FREE and both REFUSE without a reason, so both are
568
+ // buttons. There is deliberately NO route for `council --set`: changing the
569
+ // roster mid-cycle is a decision with a recorded reason that the LANE takes in
570
+ // the conversation — the same reasoning that keeps `orc doc log` and
571
+ // `orc doc mode` off the panel. Adopting a premise needs a goals FILE, which
572
+ // the panel must not invent, so that one stays a copy-able command too.
573
+ "/api/challenge/premise": (b) => [
574
+ "challenge", "premise", String(b.slug), String(b.id), "--dismiss", "--reason", String(b.reason || ""),
575
+ ],
576
+ "/api/challenge/opportunity": (b) => [
577
+ "challenge", "opportunity", String(b.slug), String(b.id), b.take ? "--take" : "--drop", "--reason", String(b.reason || ""),
578
+ ],
579
+ // v0.48.0. `assemble` — now `compile` — is the ONE /orc-doc write that costs
580
+ // nothing: it concatenates section files that are already on disk, in an order
581
+ // the outline already fixed. Writing a section, checking one and editing one
582
+ // all cost model tokens, so they are copy-able commands and there is
583
+ // deliberately no route for any of them.
584
+ "/api/doc/assemble": (b) => ["doc", "assemble", String(b.slug)],
585
+ // v0.49.0. Both free, both non-destructive: `compile` rebuilds the artifact
586
+ // from disk, and `migrate` never deletes document.md and refuses what it
587
+ // cannot parse. `orc doc mode` deliberately has NO route — it is a USER
588
+ // decision the skill asks (the `orc doc log` precedent).
589
+ "/api/doc/compile": (b) => {
590
+ const argv = ["doc", "compile", String(b.slug)];
591
+ if (b.partial) argv.push("--partial");
592
+ return argv;
593
+ },
594
+ "/api/doc/migrate": (b) => ["doc", "migrate", String(b.slug)],
595
+ // v0.48.1. Shipping is a DECISION, so it is a write — and `--where` has no
596
+ // default here either, because the CLI refuses without it and the panel must
597
+ // never invent an argument the human path demands.
598
+ "/api/doc/ship": (b) => {
599
+ const argv = ["doc", "ship", String(b.slug), "--where", String(b.where || "")];
600
+ if (b.note) argv.push("--note", String(b.note));
601
+ if (b.force) argv.push("--force", "--reason", String(b.reason || ""));
602
+ return argv;
603
+ },
604
+ "/api/doc/unship": (b) => ["doc", "unship", String(b.slug), "--reason", String(b.reason || "")],
605
+ // v0.49.5 — the house-rule ledger is a PLAIN TEXT config, so the panel writes
606
+ // it the way a text config is written: the whole file, once, from one
607
+ // textarea. `set-all` is the only write route it needs — the per-priority
608
+ // `set`/`add`/`clear` commands stay a CLI convenience, and `--reset` still has
609
+ // no route because throwing away a project's standing rules is a CLI act.
610
+ //
611
+ // Every validator is still the CLI's. The panel has no second idea of what a
612
+ // house rule looks like, and the argv is a plain array, so a multi-line value
613
+ // needs no escaping and no temp file.
614
+ "/api/doc/rules/setAll": (b) => ["doc", "rules", "set-all", "--text", String(b.text || "")],
615
+ // v1.7.0 — the project's own anti-slop rules. ONE write route, for the same
616
+ // reason the doc ledger has one: it is a plain text config, so the panel
617
+ // writes the whole file from one textarea and the CLI is still the only
618
+ // writer and the only validator.
619
+ //
620
+ // There is deliberately NO route for the SHIPPED packs. They are read-only,
621
+ // `orc rules set --pack …` is refused by name, and a route that existed only
622
+ // to be refused would be a control that lies about what it can do.
623
+ // `--reset` also has no route: throwing away a project's standing rules is a
624
+ // CLI act.
625
+ "/api/rules/setAll": (b) => ["rules", "set-all", "--text", String(b.text || "")],
626
+ "/api/doc/rules/sync": (b) => ["doc", "rules", String(b.slug), "--sync"],
627
+ // v0.52.0 (D9) — per document, because a document's voice is the deliverable.
628
+ // The CLI owns the resolution order and the shadowing announcement; the panel
629
+ // renders both and decides neither.
630
+ "/api/doc/extra/set": (b) => ["doc", "extra", String(b.slug), "--set", String(b.mode)],
631
+ // v0.49.2. Closing a run is FREE, deterministic, reversible, and it DELETES
632
+ // NOTHING — `RESUME.md` is moved aside, not removed. The CLI refuses without a
633
+ // reason, so the form does not have to: there is one idea of a valid close and
634
+ // it lives in `bin/cli.js`.
635
+ "/api/run/close": (b) => ["run", "close", String(b.slug), "--reason", String(b.reason || "")],
636
+ "/api/run/reopen": (b) => ["run", "reopen", String(b.slug)],
637
+ // v0.50.0 — `orc extra`. Adding a connection writes a PROFILE and nothing
638
+ // else: no key, no route, no change to how anything builds. Removing one
639
+ // REFUSES without a reason — the same rule the promise ledger retires an
640
+ // invariant under — and names the route rows it drops, so the form does not
641
+ // have to: there is one idea of a valid removal and it lives in bin/cli.js.
642
+ //
643
+ // The routing table (W12). Both are STAGED in the panel and applied one at a
644
+ // time in staged order, so a refused row never aborts the rest — and the CLI
645
+ // is still what refuses an overlap, an unverified profile or a bad band spec,
646
+ // BY NAME. There is deliberately no `--key <value>` anywhere, because the CLI
647
+ // refuses it BY NAME — argv is world-readable. A pasted key travels on the
648
+ // connection test's STDIN and nowhere else.
649
+ "/api/extra/add": (b) => {
650
+ const argv = ["extra", "add", String(b.name), "--provider", String(b.provider), "--engine", String(b.engine)];
651
+ if (b.region && b.region !== "default") argv.push("--region", String(b.region));
652
+ if (b.base_url) argv.push("--base-url", String(b.base_url));
653
+ if (b.anthropic_base_url) argv.push("--anthropic-base-url", String(b.anthropic_base_url));
654
+ if (b.cli_bin) argv.push("--cli", String(b.cli_bin));
655
+ if (b.cli_agent) argv.push("--cli-agent", String(b.cli_agent));
656
+ // v0.52.0 — the THIRD credential source, checked FIRST. A local tool that
657
+ // already holds its own credential needs no key from ORC at all, and the
658
+ // panel forcing such a profile into the vault is what locked a run that had
659
+ // no business having a passphrase. ORC never writes another tool's
660
+ // credential store; `--tool-auth` is how that is said out loud.
661
+ if (b.tool_auth) argv.push("--tool-auth");
662
+ else if (b.vault) argv.push("--key-stdin");
663
+ else if (b.env_key) argv.push("--env-key", String(b.env_key));
664
+ return argv;
665
+ },
666
+ "/api/extra/remove": (b) => ["extra", "remove", String(b.name), "--reason", String(b.reason || "")],
667
+ // v0.52.0 — forgetting a saved passphrase carries no secret, so it is an
668
+ // ordinary argv write. SAVING one does, and has its own handler below: the
669
+ // passphrase travels on STDIN and `--passphrase <value>` is refused BY NAME in
670
+ // the CLI, so there is no argv path on either side.
671
+ "/api/extra/session/forget": (b) => ["extra", "session", String(b.profile), "--forget"],
672
+ "/api/extra/route/set": (b) => {
673
+ const argv = ["extra", "route", "set", String(b.band), String(b.target)];
674
+ if (b.small_model) argv.push("--small-model", String(b.small_model));
675
+ if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
676
+ return argv;
677
+ },
678
+ "/api/extra/route/rm": (b) => ["extra", "route", "rm", String(b.band)],
679
+ // A slot is a POINT, not an interval, so there is no overlap to refuse and
680
+ // `set` on an occupied one REPLACES — which is why the panel confirms it and
681
+ // names what it replaces. A count is not consent.
682
+ "/api/extra/role/set": (b) => {
683
+ const argv = ["extra", "role", "set", String(b.slot), String(b.target)];
684
+ if (b.small_model) argv.push("--small-model", String(b.small_model));
685
+ if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
686
+ return argv;
687
+ },
688
+ "/api/extra/role/rm": (b) => ["extra", "role", "rm", String(b.slot)],
689
+ // v0.51.0 — running the install in the USER'S OWN TERMINAL. It is a POST
690
+ // because it launches something; it writes no config, stores no state and
691
+ // never elevates. A launch that could not happen comes back exit 0 carrying
692
+ // the command to paste (the `openBrowser` rule), so there is no failure path
693
+ // that leaves the card without an answer.
694
+ "/api/extra/install": (b) => {
695
+ const argv = ["extra", "install", String(b.provider)];
696
+ if (b.manager) argv.push("--manager", String(b.manager));
697
+ return argv;
698
+ },
699
+ // A live model list is a FREE re-read of the provider's own catalogue, and a
700
+ // per-model test is the PAID rung scoped to one id — the only thing that tells
701
+ // a LISTED model from a WORKING one.
702
+ "/api/extra/models/refresh": (b) => ["extra", "models", String(b.profile), "--refresh"],
703
+ // Preview-then-apply, and the preview NAMES EVERY DIRECTORY — a count is not
704
+ // consent. Only a journal whose every attempt closed `done` 30+ days ago is
705
+ // ever a candidate, so this can never delete the record of a dispatch that
706
+ // never reported back.
707
+ "/api/extra/journal/prune": () => ["extra", "journal", "prune"],
708
+ // A PROMOTE IS A HUMAN ACTION AND A REASON IS REQUIRED (v1.0.0 W5). The CLI
709
+ // refuses without one — exit 2, `reason-required` — so the panel does not
710
+ // validate it a second time; it collects it and lets the CLI decide, which is
711
+ // the same contract every other write on this server keeps. There is
712
+ // deliberately NO demote route: demoting by hand is a diagnostic somebody
713
+ // reaches for at a terminal, and a button for it would invite muting a
714
+ // provider instead of fixing it.
715
+ "/api/extra/promote": (b) => ["extra", "promote", String(b.run || ""), "--reason", String(b.reason || "")],
716
+ "/api/crosslink/remove": (b) => ["crosslink", "remove", String(b.name)],
717
+ // The UI assembles no YAML. It hands the CLI the same arguments the
718
+ // interactive prompt collects, and every rejection the user sees is the
719
+ // CLI's own validator speaking — there is no second idea of a valid slug,
720
+ // a valid kind or a valid edge target anywhere in this panel.
721
+ // v1.5.0 — the THREE /orc-test mutations this panel may make, and NONE of
722
+ // them sends a request to the system under test. `orc test run` is where the
723
+ // traffic is, and it is a COPY-ABLE COMMAND here and never a button: a page
724
+ // that can start a scan against a live host is a page that can start one by
725
+ // accident.
726
+ //
727
+ // · `surface` is the FREE PASS — it reads the repository and the target's
728
+ // own spec document, and it is what every later step is derived from.
729
+ // · `env` is a HEALTH PROBE, the /orc-test equivalent of `orc extra ping`:
730
+ // it asks the target whether it is up. It never starts, stops or fixes
731
+ // anything, and on a REMOTE target it refuses BY NAME.
732
+ // · `report` RENDERS the ledger into REPORT.md and derives nothing.
733
+ //
734
+ // Every one costs zero model tokens, which is the line this panel does not
735
+ // cross. Nothing here dispatches an agent.
736
+ "/api/test/surface": (b) => ["test", "surface", String(b.slug)],
737
+ "/api/test/env": (b) => ["test", "env", String(b.slug)],
738
+ "/api/test/report": (b) => ["test", "report", String(b.slug)],
739
+ "/api/crosslink/add": (b) => {
740
+ const argv = ["crosslink", "add", String(b.name), String(b.repo_path), "--kinds", String(b.kinds)];
741
+ if (b.direction) argv.push("--direction", String(b.direction));
742
+ if (b.via) argv.push("--via", String(b.via));
743
+ if (b.target) argv.push("--target", String(b.target));
744
+ return argv;
745
+ },
746
+ };
747
+
748
+ // Maintenance: the safety-critical panel. Each action is a PAIR — a read-only
749
+ // preview and the apply that the preview is consent for. The apply route can
750
+ // never be reached without the UI having fetched the preview first, and the
751
+ // exact command is part of the preview payload so it is always visible in the
752
+ // confirmation, and always typeable by hand instead.
753
+ // `restarts_ui` — DOES THIS ACTION REPLACE WHAT THE PANEL IS SERVING?
754
+ //
755
+ // `orc upgrade` installs a new package over the one this process is running
756
+ // from, and `orc update` / `--prune` / `doctor --fix` rewrite the payload every
757
+ // panel reads. Node loaded bin/webui at require time and `STATIC` is a one-time
758
+ // walk at boot, so neither is visible until the server is replaced — which used
759
+ // to mean stop the server, re-run `orc ui`, open the new URL. The flag is
760
+ // DECLARED per action rather than inferred, because "this command changed the
761
+ // code under me" is not something a command's output can be read for.
762
+ //
763
+ // `update-global` is deliberately FALSE: it re-copies the payload into
764
+ // ~/.claude, which is not what this server is running and not what any panel
765
+ // here reads.
766
+ const MAINTENANCE = {
767
+ update: {
768
+ apply: ["update"],
769
+ restarts_ui: true,
770
+ label: "Re-copy this package's payload over the installed one",
771
+ // `orc doctor --json` already itemises exactly what an update would change
772
+ // (version skew, missing files, orphans) — a preview with no second engine.
773
+ preview: ["doctor"],
774
+ },
775
+ prune: {
776
+ apply: ["update", "--prune"],
777
+ restarts_ui: true,
778
+ label: "Update AND delete ORC-named orphans from a pre-manifest install",
779
+ preview: ["doctor"],
780
+ // A count is not consent for a deletion: the UI must name every file, and
781
+ // doctor's findings carry `paths` for exactly the two orphan findings.
782
+ names_files: true,
783
+ },
784
+ fix: {
785
+ apply: ["doctor", "--fix"],
786
+ restarts_ui: true,
787
+ label: "Apply every fix orc doctor found (= update + prune + settings re-merge)",
788
+ preview: ["doctor"],
789
+ },
790
+ upgrade: {
791
+ apply: ["upgrade"],
792
+ restarts_ui: true,
793
+ label: "Fetch the LATEST package from the network, then apply it",
794
+ preview: ["version"],
795
+ network: true,
796
+ },
797
+ // v0.46.0 — the portable export. It belongs on Maintenance by the v0.43.6 rule
798
+ // (a caution routes to the panel that can CLEAR it): `export-stale` is cleared
799
+ // by `orc export`, which is a CLI write this panel can run. Its preview is the
800
+ // `--check` report, which names WHICH source drifted — a count of stale sources
801
+ // would not be consent to overwrite a committed file.
802
+ export: {
803
+ apply: ["export"],
804
+ label: "Recompile AGENTS.md from the wiki, patterns, PACT.md and boundary cards",
805
+ preview: ["export", "--check"],
806
+ },
807
+ // ADVANCED (v0.44.0) — the one action on this panel that does not target the
808
+ // project. Every other route pins `--dir <projectRoot>`; `--global` outranks
809
+ // `--dir` in `resolveClaudeDir`, so this pair reaches ~/.claude and its
810
+ // preview reports on the same place it would write.
811
+ //
812
+ // It is here because a stale GLOBAL install is a real failure this panel
813
+ // already REPORTS (the persistent banner, and doctor's global-skew finding)
814
+ // and previously could not act on — the fix was "go type it in a terminal".
815
+ // It stays boxed off as advanced, and it is still the only global reach the
816
+ // panel has: config is never written globally, because config does not merge
817
+ // and a global write would silently outrank the file every panel here edits.
818
+ "update-global": {
819
+ apply: ["update", "--global"],
820
+ label: "Re-copy this package's payload over the GLOBAL install in ~/.claude",
821
+ preview: ["doctor", "--global"],
822
+ advanced: true,
823
+ },
824
+ };
825
+
826
+ // ── the Experiment panel ────────────────────────────────────────────────────
827
+ //
828
+ // THE BOUNDARY MOVES EXACTLY ONE STEP, AND NO FURTHER. `orc ui` still renders
829
+ // no model output, proxies no session and holds no API key — it does not become
830
+ // an AI client. What it gains is a HANDOFF: it can open a terminal, in this
831
+ // project, with `claude` running in it, and then forget about it. The spawned
832
+ // process is not a child this server manages, reads or reports on; it is the
833
+ // same detached-launch mechanism `openBrowser()` has always used.
834
+ //
835
+ // The lanes are a SERVER-SIDE catalog and the browser sends only an id. No
836
+ // string typed in a browser ever reaches a shell — the cwd is always the
837
+ // server's own projectRoot, and an unknown id is a 400. That constraint is the
838
+ // only reason a launch button is safe on a write surface at all.
839
+ const LANES = [
840
+ { id: "orc", cmd: "/orc", what: "Full pipeline: intake → plan → scored parallel waves → review → verify → ship." },
841
+ { id: "orc-quick", cmd: "/orc-quick", what: "Ask for anything. Look → ask once → do, and it always asks which agent." },
842
+ { id: "orc-mini", cmd: "/orc-mini", what: "One executor, smoke gate, ship. No full review or verify phase." },
843
+ { id: "orc-fast", cmd: "/orc-fast", what: "Fastest lane. Needs a fresh wiki AND a cached pattern, or it falls back." },
844
+ { id: "orc-ultra", cmd: "/orc-ultra", what: "Maximum rigor: an advisor plus three judgment gates. Cost accepted." },
845
+ { id: "orc-plan", cmd: "/orc-plan", what: "Turn a request or analyst spec into a grounded task plan. Plan only." },
846
+ { id: "orc-analyze", cmd: "/orc-analyze", what: "Turn a document or a vague requirement into code-grounded requirements." },
847
+ { id: "orc-wiki", cmd: "/orc-wiki", what: "Build or refresh the project wiki. Expensive; always asks first." },
848
+ { id: "orc-pattern", cmd: "/orc-pattern", what: "Learn this project's real code conventions and cache them per language." },
849
+ { id: "orc-verify", cmd: "/orc-verify", what: "Verify the git-modified changes in the working tree. Read-only." },
850
+ { id: "orc-learn", cmd: "/orc-learn", what: "Generate per-feature onboarding docs. Local and git-ignored." },
851
+ { id: "orc-retro", cmd: "/orc-retro", what: "Mine the behavior traces for calibration. Read-only, report-only." },
852
+ ];
853
+
854
+ // ── the folder picker ───────────────────────────────────────────────────────
855
+ //
856
+ // The THIRD endpoint with no CLI behind it (after /api/learn's shipped content
857
+ // and /api/experiment's lane catalog), and for the same reason: there is no
858
+ // `orc` command that lists directories, so there is nothing to shell. It exists
859
+ // because a crosslink repo path typed by hand is the one field in this panel
860
+ // where a typo is invisible until the edge silently resolves to nothing — the
861
+ // CLI then saves it as a PENDING edge and you find out much later.
862
+ //
863
+ // It is a DIRECTORY LISTER and nothing more, and the limits are the design:
864
+ // · directory names only — never a file list, never file contents, never a
865
+ // stat beyond "does .git / .claude/wiki exist here";
866
+ // · dotfolders are hidden (`.git`, `node_modules` and friends are noise here);
867
+ // · it reads, it never writes, and no path it is handed can reach a shell;
868
+ // · a path that cannot be read is an ANSWER (`error`), never a 500.
869
+ // Nothing is copied out of the folders it lists, so a wrong click costs a
870
+ // re-click. The browser is already loopback + token gated; this adds no reach
871
+ // beyond what the person at the keyboard already has.
872
+ const FS_LIST_MAX = 400;
873
+
874
+ function fsList(dir, ctx) {
875
+ const target = path.resolve(dir || ctx.projectRoot || os.homedir());
876
+ let entries;
877
+ try {
878
+ entries = fs.readdirSync(target, { withFileTypes: true });
879
+ } catch (e) {
880
+ return { path: target, error: String(e.code || e.message), dirs: [] };
881
+ }
882
+ const dirs = [];
883
+ for (const e of entries) {
884
+ if (dirs.length >= FS_LIST_MAX) break;
885
+ if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules") continue;
886
+ const full = path.join(target, e.name);
887
+ dirs.push({
888
+ name: e.name,
889
+ path: full,
890
+ // The two facts that decide whether a folder is worth linking at all.
891
+ // Both are a single existsSync — nothing inside either one is read.
892
+ is_repo: fs.existsSync(path.join(full, ".git")),
893
+ has_wiki: fs.existsSync(path.join(full, ".claude", "wiki")),
894
+ });
895
+ }
896
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
897
+ const parent = path.dirname(target);
898
+ return {
899
+ path: target,
900
+ parent: parent === target ? null : parent, // null AT a filesystem root
901
+ sep: path.sep,
902
+ home: os.homedir(),
903
+ project_root: ctx.projectRoot || null,
904
+ is_project_root: !!ctx.projectRoot && path.resolve(ctx.projectRoot) === target,
905
+ // What the crosslink config actually stores. Computed here rather than in
906
+ // the browser because only the server knows the real path separator, and a
907
+ // Windows path assembled with "/" is the kind of thing that works until it
908
+ // does not.
909
+ relative: ctx.projectRoot ? path.relative(ctx.projectRoot, target).split(path.sep).join("/") || "." : null,
910
+ truncated: dirs.length >= FS_LIST_MAX,
911
+ dirs,
912
+ };
913
+ }
914
+
915
+ // Open a terminal running `claude` in the project root. Best-effort and never
916
+ // fatal — a failed launch is reported so the user can copy the command instead,
917
+ // which is why the command is always on screen anyway.
918
+ function launchClaude(ctx) {
919
+ const cwd = ctx.projectRoot;
920
+ let cmd, args;
921
+ if (process.platform === "win32") {
922
+ // `start` needs an empty title argument first, or a quoted path becomes it.
923
+ cmd = "cmd";
924
+ args = ["/c", "start", "", "cmd", "/k", "claude"];
925
+ } else if (process.platform === "darwin") {
926
+ cmd = "osascript";
927
+ args = ["-e", `tell application "Terminal" to do script "cd ${JSON.stringify(cwd).slice(1, -1)} && claude"`, "-e", 'tell application "Terminal" to activate'];
928
+ } else {
929
+ cmd = "x-terminal-emulator";
930
+ args = ["-e", "claude"];
931
+ }
932
+ try {
933
+ const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", windowsHide: false });
934
+ child.unref();
935
+ return { ok: true };
936
+ } catch (e) {
937
+ return { ok: false, error: String(e && e.message) };
938
+ }
939
+ }
940
+
941
+ // ── request handling ────────────────────────────────────────────────────────
942
+
943
+ // A RESPONSE OVER 64 KiB IS A HAZARD ON WINDOWS LOOPBACK (v1.5.0).
944
+ //
945
+ // Measured, and NOT an ORC bug: a twenty-line plain `http.createServer` on this
946
+ // platform loses the tail of a response larger than the 64 KiB socket buffer
947
+ // roughly one time in six, after a handful of short-lived connections. The
948
+ // client receives ~65,077 of 69,177 bytes, the server's `finish` fires and
949
+ // `writableFinished` is true, and nineteen seconds later the client gets
950
+ // ECONNRESET with nothing in any log to say why. Under 60,000 bytes it never
951
+ // happened in any run.
952
+ //
953
+ // `/api/config` was 64,934 bytes — six hundred short of that line — so the
954
+ // Settings tab was one config key away from failing with no message, and it
955
+ // crossed on the release that added five. Two things answer it, and neither is
956
+ // a size limit on what this API may say (`--json is not a summary`):
957
+ //
958
+ // 1. DECLARE THE LENGTH. Without `content-length` Node falls back to chunked
959
+ // encoding, which is a worse shape for a body of known size and gives the
960
+ // client no way to tell a truncated read from a complete one.
961
+ // 2. COMPRESS IT WHEN THE CLIENT ASKS. Every browser sends
962
+ // `accept-encoding: gzip`, and the panel IS a browser — gzip takes that
963
+ // 69 KB answer to about 8 KB, far below the cliff. It is skipped for a
964
+ // small body, where the CPU is not worth it, and skipped entirely for a
965
+ // client that did not ask.
966
+ const GZIP_MIN_BYTES = 32 * 1024;
967
+ // `gzip` as a WHOLE token in the header's comma list, so `x-gzip-not-really`
968
+ // never matches. Written out rather than with a regex word boundary, which is
969
+ // not one beside a hyphen.
970
+ const GZIP_ACCEPTED = /(^|,)\s*gzip\s*(;|,|$)/;
971
+
972
+ // ONE implementation, and serve.js uses it for STATIC files too (v1.5.0). The
973
+ // 64 KiB cliff is a property of the SOCKET, not of the content type: it does not
974
+ // care whether the bytes are an API answer or a panel script. Leaving the static
975
+ // path uncompressed left the two largest files in the app — `extra.js` at 138 KB
976
+ // and `hookui.js` at 76 KB — riding the exact path this was written to fix. It
977
+ // showed up as an intermittent ECONNRESET in the asset-walk test, and it would
978
+ // show up in a real browser as a panel script that stops mid-function.
979
+ //
980
+ // Returns the bytes to send plus the headers that describe them. A caller that
981
+ // forgets to declare the length is the other half of the same bug, so the length
982
+ // is set HERE rather than left to each call site.
983
+ function encodeBody(raw, acceptEncoding) {
984
+ const headers = {};
985
+ let body = raw;
986
+ if (raw.length >= GZIP_MIN_BYTES && GZIP_ACCEPTED.test(String(acceptEncoding || ""))) {
987
+ try {
988
+ body = zlib.gzipSync(raw);
989
+ headers["content-encoding"] = "gzip";
990
+ // A cache keyed on the URL alone would hand a gzip body to a client that
991
+ // cannot read one. Nothing caches on loopback today; saying so costs one
992
+ // header and removes the whole class.
993
+ headers["vary"] = "accept-encoding";
994
+ } catch (_) {
995
+ body = raw;
996
+ }
997
+ }
998
+ headers["content-length"] = String(body.length);
999
+ return { body, headers };
1000
+ }
1001
+
1002
+ function json(res, status, obj) {
1003
+ const raw = Buffer.from(JSON.stringify(obj), "utf8");
1004
+ const { body, headers } = encodeBody(raw, res.req && res.req.headers && res.req.headers["accept-encoding"]);
1005
+ res.writeHead(
1006
+ status,
1007
+ Object.assign(
1008
+ {
1009
+ "content-type": "application/json; charset=utf-8",
1010
+ "cache-control": "no-store",
1011
+ // Belt and braces on top of the loopback + token checks in serve.js.
1012
+ "x-content-type-options": "nosniff",
1013
+ },
1014
+ headers
1015
+ )
1016
+ );
1017
+ res.end(body);
1018
+ }
1019
+
1020
+ function readBody(req) {
1021
+ return new Promise((resolve, reject) => {
1022
+ let raw = "";
1023
+ req.on("data", (c) => {
1024
+ raw += c;
1025
+ if (raw.length > 64_000) reject(new Error("body too large"));
1026
+ });
1027
+ req.on("end", () => {
1028
+ if (!raw) return resolve({});
1029
+ try {
1030
+ resolve(JSON.parse(raw));
1031
+ } catch (_) {
1032
+ reject(new Error("body is not JSON"));
1033
+ }
1034
+ });
1035
+ req.on("error", reject);
1036
+ });
1037
+ }
1038
+
1039
+ // The Overview panel needs four commands at once. Doing that in one request
1040
+ // keeps the first paint to a single round trip.
1041
+ function overview(ctx) {
1042
+ const doctor = readCli(["doctor"], ctx);
1043
+ const runs = readCli(["run", "list", "--limit", "200"], ctx);
1044
+ const waiting = runs.data ? runs.data.runs.filter((r) => r.status === "waiting") : [];
1045
+ return {
1046
+ where: readCli(["where"], ctx).data,
1047
+ doctor: doctor.data,
1048
+ wiki: readCli(["wiki", "status"], ctx).data,
1049
+ patterns: readCli(["pattern", "status"], ctx).data,
1050
+ runs_total: runs.data ? runs.data.total : 0,
1051
+ // Rows, not bare slugs (v0.49.2). The Overview card has an age column and
1052
+ // rendered it empty because the payload never carried the number — and the
1053
+ // "mark as done" button needs the slug beside a real timestamp to be worth
1054
+ // showing at all.
1055
+ waiting: waiting.map((r) => ({ slug: r.slug, updated_ms: r.updated_ms, lane: r.lane || null })),
1056
+ diy: readCli(["diy", "show"], ctx).data,
1057
+ // v0.46.0 chips. Each is the CLI's OWN answer — the panel repeats the state
1058
+ // words and never derives them. A chip with nothing to say still renders its
1059
+ // good state, so "healthy" and "not measured" never look the same.
1060
+ pact: readCli(["pact", "status"], ctx).data,
1061
+ boundary: readCli(["boundary", "status"], ctx).data,
1062
+ wiki_debt: readCli(["wiki", "debt"], ctx).data,
1063
+ // v0.54.0 — foreign dispatches that never reported back. Money spent and
1064
+ // work half-done that nothing will look at again unless somebody is told.
1065
+ // It is a FINDING, never a stop, and the Overview never resumes one.
1066
+ extra_journal: readCli(["extra", "journal", "list"], ctx).data,
1067
+ };
1068
+ }
1069
+
1070
+ async function handleApi(req, res, url, ctx) {
1071
+ const route = url.pathname;
1072
+ const q = Object.fromEntries(url.searchParams);
1073
+
1074
+ // Liveness. The page pings this every 15s; no ping from any client for the
1075
+ // grace window and the server exits, so a closed tab does not leave a write
1076
+ // surface holding a valid token.
1077
+ if (route === "/api/ping") {
1078
+ ctx.onHeartbeat();
1079
+ return json(res, 200, { ok: true, job: jobView() });
1080
+ }
1081
+
1082
+ // sendBeacon on beforeunload — a best-effort fast path to the same shutdown
1083
+ // the heartbeat timeout would reach a minute later.
1084
+ if (route === "/api/bye") {
1085
+ ctx.onBye();
1086
+ return json(res, 200, { ok: true });
1087
+ }
1088
+
1089
+ if (route === "/api/meta") {
1090
+ return json(res, 200, {
1091
+ project_root: ctx.projectRoot,
1092
+ fixtures: ctx.fixtures,
1093
+ version: ctx.version,
1094
+ port: ctx.port,
1095
+ idle_minutes: ctx.idleMinutes,
1096
+ started_ms: ctx.startedMs,
1097
+ });
1098
+ }
1099
+
1100
+ if (route === "/api/job") return json(res, 200, jobView());
1101
+
1102
+ // Fixture mode short-circuits every data route: canned JSON, no project, no
1103
+ // spawn. This is what makes the STALE chip and the unhealthy doctor panel
1104
+ // designable on a machine where everything is green (see the plan, §9).
1105
+ if (ctx.fixtures) {
1106
+ if (req.method !== "GET") {
1107
+ // Almost every mutation answers "nothing ran", which is the honest reply
1108
+ // in a mode that runs nothing. The ONE exception is the connection test:
1109
+ // its two outcomes are states the Extra panel is largely about, and a
1110
+ // state with no fixture is a state nobody has ever looked at. A canned
1111
+ // answer carries `data` and NOT the `fixture` flag, so the panel renders
1112
+ // the real result shape — the command string is what says it was canned.
1113
+ let body = {};
1114
+ try {
1115
+ body = await readBody(req);
1116
+ } catch (_) {}
1117
+ const canned = fixtures.post(route, body);
1118
+ if (canned)
1119
+ return json(res, 200, {
1120
+ ok: true,
1121
+ exit_code: canned.exit_code,
1122
+ data: canned.data,
1123
+ command: "(fixtures — nothing ran)",
1124
+ });
1125
+ return json(res, 200, { ok: true, fixture: true, command: "(fixtures — nothing ran)" });
1126
+ }
1127
+ const canned = fixtures.get(route, q);
1128
+ if (canned === undefined) return json(res, 404, { error: "no fixture for " + route });
1129
+ return json(res, 200, { ok: true, exit_code: 0, data: canned, fixture: true });
1130
+ }
1131
+
1132
+ if (req.method === "GET") {
1133
+ if (route === "/api/overview") return json(res, 200, { ok: true, exit_code: 0, data: overview(ctx) });
1134
+ if (route === "/api/learn") {
1135
+ // The only endpoint with no CLI behind it: the onboarding topics are
1136
+ // static content already shipped as a module, so spawning to read them
1137
+ // would be ceremony with a cost.
1138
+ const { SECTIONS } = require("../onboarding-content.js");
1139
+ return json(res, 200, { ok: true, exit_code: 0, data: { sections: SECTIONS } });
1140
+ }
1141
+ // The mocked runs (v0.46.x). Same shape as /api/learn above and for the
1142
+ // same reason: this is static content that ships inside this package, so
1143
+ // spawning a subprocess to read files sitting next to this one would be
1144
+ // ceremony with a cost. `orc mock-run` reads the identical module, so the
1145
+ // terminal and the panel cannot disagree.
1146
+ if (route === "/api/mockruns") {
1147
+ return json(res, 200, { ok: true, exit_code: 0, data: require("../mockrun-catalog.js").catalogue() });
1148
+ }
1149
+ if (route === "/api/mockrun") {
1150
+ const doc = require("../mockrun-catalog.js").get(String(q.slug || ""));
1151
+ if (!doc) return json(res, 200, { ok: true, exit_code: 1, data: { slug: String(q.slug || ""), found: false } });
1152
+ return json(res, 200, { ok: true, exit_code: 0, data: { ...doc, found: true } });
1153
+ }
1154
+ if (route === "/api/fs/list") {
1155
+ return json(res, 200, { ok: true, exit_code: 0, data: fsList(q.path, ctx) });
1156
+ }
1157
+ if (route === "/api/experiment") {
1158
+ return json(res, 200, {
1159
+ ok: true,
1160
+ exit_code: 0,
1161
+ data: {
1162
+ lanes: LANES,
1163
+ project_root: ctx.projectRoot,
1164
+ platform: process.platform,
1165
+ // Fixture mode must never spawn a real terminal on a machine that has
1166
+ // no project — the button says so instead of lying about it.
1167
+ can_launch: !ctx.fixtures,
1168
+ },
1169
+ });
1170
+ }
1171
+ if (route === "/api/maintenance") {
1172
+ const actions = Object.entries(MAINTENANCE).map(([id, m]) => ({
1173
+ id,
1174
+ label: m.label,
1175
+ command: "orc " + m.apply.join(" "),
1176
+ network: !!m.network,
1177
+ names_files: !!m.names_files,
1178
+ advanced: !!m.advanced,
1179
+ restarts_ui: !!m.restarts_ui,
1180
+ }));
1181
+ return json(res, 200, { ok: true, exit_code: 0, data: { actions } });
1182
+ }
1183
+ if (route === "/api/maintenance/preview") {
1184
+ const m = MAINTENANCE[String(q.action)];
1185
+ if (!m) return json(res, 400, { error: "unknown action" });
1186
+ const probe = readCli(m.preview, ctx);
1187
+ return json(res, 200, {
1188
+ ok: true,
1189
+ exit_code: 0,
1190
+ data: {
1191
+ action: String(q.action),
1192
+ label: m.label,
1193
+ command: "orc " + m.apply.join(" "),
1194
+ network: !!m.network,
1195
+ names_files: !!m.names_files,
1196
+ advanced: !!m.advanced,
1197
+ // Said in the confirmation, not discovered afterwards. A panel that
1198
+ // reloads itself without warning reads as a crash.
1199
+ restarts_ui: !!m.restarts_ui,
1200
+ preview_command: "orc " + m.preview.join(" "),
1201
+ preview: probe.data,
1202
+ // Only the UI can know a run is mid-flight; updating changes the
1203
+ // skills that run would resume into.
1204
+ waiting_runs: (readCli(["run", "list", "--limit", "200"], ctx).data || { runs: [] }).runs
1205
+ .filter((r) => r.status === "waiting")
1206
+ .map((r) => r.slug),
1207
+ dirty_tree: m.network ? isDirtyTree(ctx) : false,
1208
+ },
1209
+ });
1210
+ }
1211
+ const build = READS[route];
1212
+ if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1213
+ const out = readCli(build(q), ctx);
1214
+ // v0.49.2 — a read that produced no parseable object still has to SAY why.
1215
+ // The body already carried `stderr` and `stdout`; nothing named `error`, so
1216
+ // the client fell through to "request failed (500)" and one corrupt ledger
1217
+ // looked like a broken panel. The reason the CLI printed is what is shown.
1218
+ return json(res, out.ok ? 200 : 500, out.ok ? out : { ...out, error: readFailReason(out) });
1219
+ }
1220
+
1221
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1222
+
1223
+ let body;
1224
+ try {
1225
+ body = await readBody(req);
1226
+ } catch (e) {
1227
+ return json(res, 400, { error: e.message });
1228
+ }
1229
+
1230
+ // The handoff. It takes NO command from the browser: the lane id is looked up
1231
+ // in the server's own catalog and is used only to echo back what to type. The
1232
+ // process spawned is always a bare `claude` in the server's own projectRoot,
1233
+ // so there is no path by which browser input reaches a shell.
1234
+ if (route === "/api/experiment/launch") {
1235
+ if (ctx.fixtures) return json(res, 400, { error: "fixture mode never launches anything real" });
1236
+ const lane = body.lane ? LANES.find((l) => l.id === String(body.lane)) : null;
1237
+ if (body.lane && !lane) return json(res, 400, { error: "unknown lane" });
1238
+ const r = launchClaude(ctx);
1239
+ if (!r.ok) return json(res, 500, { error: "could not open a terminal: " + r.error });
1240
+ return json(res, 200, {
1241
+ ok: true,
1242
+ // What to type once it is open. The UI shows this; the server never runs it.
1243
+ type_this: lane ? lane.cmd : null,
1244
+ cwd: ctx.projectRoot,
1245
+ });
1246
+ }
1247
+
1248
+ // v0.50.0 — THE CONNECTION TEST, and the one place this panel does something
1249
+ // model-shaped. It is a DIAGNOSTIC in the same family as `orc doctor`: rung 1
1250
+ // lists models and costs nothing, rung 2 sends a one-token completion and
1251
+ // costs a fraction of a cent, and the CLI decides which — never this file.
1252
+ //
1253
+ // It is POST because it MUTATES: a green test writes `verified_at` onto the
1254
+ // profile, and a red one on a never-verified profile REMOVES that profile
1255
+ // (the CLI's own test-first-then-store lifecycle). A GET that did that would
1256
+ // be reachable by a prefetch.
1257
+ //
1258
+ // A pasted key arrives in the BODY and leaves on the child's STDIN — line 1
1259
+ // the key, an optional line 2 the passphrase that encrypts it. It is never in
1260
+ // argv, never written here, and never echoed back: the response is the CLI's
1261
+ // own `--json` object, which carries no credential by construction.
1262
+ if (route === "/api/extra/ping") {
1263
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1264
+ const profile = String(body.profile || "");
1265
+ if (!profile) return json(res, 400, { error: "missing argument" });
1266
+ const argv = ["extra", "ping", profile];
1267
+ // v0.51.0 — the PAID rung, opt-in and never a default. The panel quotes what
1268
+ // it costs before the button; the CLI is what decides the rung and what it
1269
+ // reports back.
1270
+ if (body.live) argv.push("--live");
1271
+ if (body.model) argv.push("--model", String(body.model));
1272
+ let input;
1273
+ if (body.key) {
1274
+ // A NEW key: line 1 the key, an optional line 2 the passphrase that stores
1275
+ // it after a green test.
1276
+ argv.push("--key-stdin");
1277
+ input = String(body.key) + chr10 + String(body.passphrase || "") + chr10;
1278
+ } else if (body.passphrase) {
1279
+ // A STORED key: the passphrase decrypts it into the CLI's memory for the
1280
+ // probe. The two flags are mutually exclusive and the CLI refuses them
1281
+ // together BY NAME, so this branch is an `else if` rather than a guess.
1282
+ argv.push("--passphrase-stdin");
1283
+ input = String(body.passphrase) + chr10;
1284
+ }
1285
+ const out = runCli(argv, ctx, { json: true, input });
1286
+ clearCache();
1287
+ // `ok` here is "did the CLI answer at all". Whether the CONNECTION worked is
1288
+ // `data.ok` and the exit code, which are the CLI's answer and are passed
1289
+ // through untouched — a failed probe is DATA, not a server error.
1290
+ return json(res, out.ok ? 200 : 500, out.ok
1291
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1292
+ : { ...out, error: readFailReason(out) });
1293
+ }
1294
+
1295
+ // v0.51.0 — F5's answer, scoped to ONE model id. A model that is LISTED can
1296
+ // still be DEAD upstream, so a dropdown is a list of what is OFFERED and never
1297
+ // a list of what WORKS. This is POST because it spends money.
1298
+ if (route === "/api/extra/models/test") {
1299
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1300
+ const profile = String(body.profile || "");
1301
+ const model = String(body.model || "");
1302
+ if (!profile || !model) return json(res, 400, { error: "missing argument" });
1303
+ const argv = ["extra", "models", profile, "--test", model];
1304
+ let input;
1305
+ if (body.passphrase) {
1306
+ argv.push("--passphrase-stdin");
1307
+ input = String(body.passphrase) + chr10;
1308
+ }
1309
+ const out = runCli(argv, ctx, { json: true, input });
1310
+ clearCache();
1311
+ return json(res, out.ok ? 200 : 500, out.ok
1312
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1313
+ : { ...out, error: readFailReason(out) });
1314
+ }
1315
+
1316
+ // v0.52.0 — SAVING THE PASSPHRASE WITH A DEADLINE. The CLI tests it against
1317
+ // the vault before it stores anything (test first, then store), validates the
1318
+ // TTL against the same closed set the config key publishes, and answers with
1319
+ // the DATE. This route composes nothing: it hands over a profile, a number of
1320
+ // days, and a passphrase on stdin.
1321
+ if (route === "/api/extra/session/save") {
1322
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1323
+ const profile = String(body.profile || "");
1324
+ const ttl = String(body.ttl_days || "");
1325
+ if (!profile || !ttl) return json(res, 400, { error: "missing argument" });
1326
+ const out = runCli(["extra", "session", profile, "--save", "--ttl", ttl], ctx, {
1327
+ json: true,
1328
+ input: String(body.passphrase || "") + chr10,
1329
+ });
1330
+ clearCache();
1331
+ return json(res, out.ok ? 200 : 500, out.ok
1332
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1333
+ : { ...out, error: readFailReason(out) });
1334
+ }
1335
+
1336
+ // v0.50.0 — proving a passphrase, which is the ONE action that clears the
1337
+ // vault's countdown. It NEVER yields the key: `orc extra unlock` answers one
1338
+ // question with a yes or a no, and its `attempt N of 10` message is the whole
1339
+ // point of the feature, so it is passed back verbatim.
1340
+ if (route === "/api/extra/unlock") {
1341
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1342
+ const profile = String(body.profile || "");
1343
+ if (!profile) return json(res, 400, { error: "missing argument" });
1344
+ const out = runCli(["extra", "unlock", profile], ctx, {
1345
+ json: true,
1346
+ input: String(body.passphrase || "") + chr10,
1347
+ });
1348
+ clearCache();
1349
+ return json(res, out.ok ? 200 : 500, out.ok
1350
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1351
+ : { ...out, error: readFailReason(out) });
1352
+ }
1353
+
1354
+ // Hand the panel over to a fresh process on the SAME port and token, so the
1355
+ // open tab only has to reload. POST-only like every other mutation, and it is
1356
+ // a mutation: the process answering the next request is not this one.
1357
+ //
1358
+ // The CLIENT asks for this — never the job's own close handler. The job's
1359
+ // output lives in this process's memory, so restarting the instant a command
1360
+ // finished would destroy the record of what it did before anyone read it.
1361
+ if (route === "/api/ui/restart") {
1362
+ if (ctx.fixtures)
1363
+ return json(res, 400, { ok: false, reason: "fixtures", error: "fixture mode serves canned data; there is nothing to restart into." });
1364
+ if (job && job.running) return json(res, 409, { ok: false, reason: "busy", error: "a command is still running.", job: jobView() });
1365
+ const out = typeof ctx.restart === "function" ? ctx.restart() : { ok: false, reason: "unsupported" };
1366
+ // A failed handover is NOT fatal and never takes the running panel down:
1367
+ // the old server keeps serving, and the client is told to do it by hand.
1368
+ return json(res, out.ok ? 200 : 500, out);
1369
+ }
1370
+
1371
+ if (route === "/api/maintenance/apply") {
1372
+ const m = MAINTENANCE[String(body.action)];
1373
+ if (!m) return json(res, 400, { error: "unknown action" });
1374
+ const started = startJob(m.apply, ctx, { restartUi: !!m.restarts_ui });
1375
+ if (started.error) return json(res, 409, started);
1376
+ return json(res, 200, { ok: true, ...started });
1377
+ }
1378
+
1379
+ const build = WRITES[route];
1380
+ if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1381
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1382
+ let argv;
1383
+ try {
1384
+ argv = build(body);
1385
+ } catch (_) {
1386
+ return json(res, 400, { error: "bad request body" });
1387
+ }
1388
+ if (argv.some((a) => a === "undefined" || a === "null" || a === ""))
1389
+ return json(res, 400, { error: "missing argument" });
1390
+ const out = runCli(argv, ctx);
1391
+ clearCache();
1392
+ // A write's exit code is a REAL failure signal (validators exit 1), unlike a
1393
+ // read's — so it is reported as such, with the CLI's own message.
1394
+ return json(res, 200, {
1395
+ ok: out.exit_code === 0,
1396
+ exit_code: out.exit_code,
1397
+ command: out.command,
1398
+ // Writes print human text, not JSON — that IS the confirmation to show.
1399
+ output: (out.stdout + (out.stderr ? "\n" + out.stderr : "")).trim(),
1400
+ });
1401
+ }
1402
+
1403
+ // `orc upgrade` replaces the package while your working tree may hold changes.
1404
+ // Worth a warning before, not a surprise after.
1405
+ function isDirtyTree(ctx) {
1406
+ try {
1407
+ const r = spawnSync("git", ["status", "--porcelain"], {
1408
+ cwd: ctx.projectRoot,
1409
+ encoding: "utf8",
1410
+ windowsHide: true,
1411
+ timeout: 5000,
1412
+ });
1413
+ return r.status === 0 && !!(r.stdout || "").trim();
1414
+ } catch (_) {
1415
+ return false;
1416
+ }
1417
+ }
1418
+
1419
+ module.exports = { handleApi, clearCache, encodeBody, READS, WRITES, MAINTENANCE };