tickmarkr 2.5.3 → 2.5.5

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 (71) hide show
  1. package/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.d.ts +4 -0
  6. package/dist/cli/commands/doctor.js +44 -29
  7. package/dist/cli/commands/fleet.js +195 -33
  8. package/dist/cli/commands/init.js +196 -6
  9. package/dist/cli/commands/plan.js +144 -3
  10. package/dist/cli/commands/resume.js +6 -2
  11. package/dist/cli/commands/run.js +21 -2
  12. package/dist/cli/commands/verify.js +1 -1
  13. package/dist/cli/help.d.ts +2 -0
  14. package/dist/cli/help.js +3 -1
  15. package/dist/compile/collateral.d.ts +2 -0
  16. package/dist/compile/collateral.js +50 -9
  17. package/dist/compile/ownership.d.ts +7 -0
  18. package/dist/compile/ownership.js +59 -22
  19. package/dist/config/config.d.ts +23 -8
  20. package/dist/config/config.js +42 -28
  21. package/dist/config/fleet-overlay.d.ts +3 -9
  22. package/dist/config/fleet-overlay.js +68 -11
  23. package/dist/config/fleet-why.d.ts +7 -0
  24. package/dist/config/fleet-why.js +5 -0
  25. package/dist/drivers/herdr.d.ts +5 -0
  26. package/dist/drivers/herdr.js +14 -3
  27. package/dist/drivers/index.d.ts +15 -1
  28. package/dist/drivers/index.js +38 -10
  29. package/dist/drivers/orca.d.ts +99 -10
  30. package/dist/drivers/orca.js +586 -97
  31. package/dist/drivers/types.d.ts +1 -0
  32. package/dist/gates/baseline.d.ts +3 -0
  33. package/dist/gates/baseline.js +2 -1
  34. package/dist/gates/cache.d.ts +100 -0
  35. package/dist/gates/cache.js +389 -0
  36. package/dist/gates/review.d.ts +35 -2
  37. package/dist/gates/review.js +64 -16
  38. package/dist/gates/run-gates.d.ts +5 -0
  39. package/dist/gates/run-gates.js +134 -18
  40. package/dist/gates/test-manifest.d.ts +99 -0
  41. package/dist/gates/test-manifest.js +389 -0
  42. package/dist/gates/test-reporter.d.ts +4 -0
  43. package/dist/gates/test-reporter.js +49 -0
  44. package/dist/route/preference.d.ts +22 -1
  45. package/dist/route/preference.js +123 -25
  46. package/dist/route/router.d.ts +13 -0
  47. package/dist/route/router.js +95 -17
  48. package/dist/run/daemon.d.ts +13 -0
  49. package/dist/run/daemon.js +398 -96
  50. package/dist/run/git.d.ts +35 -1
  51. package/dist/run/git.js +111 -10
  52. package/dist/run/journal.d.ts +14 -2
  53. package/dist/run/journal.js +78 -10
  54. package/dist/run/lease.d.ts +14 -0
  55. package/dist/run/lease.js +87 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/tui/cockpit/board.d.ts +96 -0
  61. package/dist/tui/cockpit/board.js +346 -0
  62. package/dist/tui/cockpit/decision-actions.js +2 -0
  63. package/dist/tui/cockpit/layout.d.ts +5 -1
  64. package/dist/tui/cockpit/layout.js +8 -3
  65. package/dist/tui/cockpit/live-runtime.js +83 -31
  66. package/dist/tui/cockpit/run-view.d.ts +7 -5
  67. package/dist/tui/cockpit/run-view.js +12 -11
  68. package/dist/tui/ink/fleet-app.d.ts +41 -27
  69. package/dist/tui/ink/fleet-app.js +204 -31
  70. package/package.json +1 -1
  71. package/skills/tickmarkr-overseer/SKILL.md +180 -99
@@ -0,0 +1,346 @@
1
+ import { GATE_NAMES } from "../../graph/schema.js";
2
+ import { cellWidth, sliceCells } from "./width.js";
3
+ /* ------------------------------------------------------------------------ */
4
+ /* BD-1 — the approved task board (RULING-231-19 §2, OBS-965 add.2). */
5
+ /* */
6
+ /* A pure, Ink-free port of .overseer/design-prototypes/tasks-redesign.mjs. */
7
+ /* Palette, glyphs and column arithmetic are the prototype's constants; the */
8
+ /* two contract corrections are the declaration-order subtitle and the */
9
+ /* four-bucket green run-ended header. Facts come from the fold's snapshot */
10
+ /* and the graph the Run view already receives — never from the journal. */
11
+ /* ------------------------------------------------------------------------ */
12
+ // The prototype closes every span with a full reset (ESC[0m). Inside Ink that reset would also drop
13
+ // the shell's own foreground and background for the rest of the row, so spans close with the default
14
+ // foreground/background codes instead — chalk re-opens the enclosing colour after each of them.
15
+ const E = "\x1b[", R = `${E}0m`, CLOSE = `${E}39m`;
16
+ const c = (n) => (s) => `${E}38;5;${n}m${s}${CLOSE}`;
17
+ const bgc = (f, b) => (s) => `${E}38;5;${f};48;5;${b}m${s}${E}39;49m`;
18
+ const boldc = (s) => `${E}1m${s}${E}22m`;
19
+ const plain = (s) => s;
20
+ /** The prototype's palette (tasks-redesign.mjs:24-30): xterm-256 indices, stated once. */
21
+ export const BOARD_PALETTE = { chrome: 238, mute: 243, body: 252, faint: 240, accent: 75, pass: 71, fail: 174, warn: 179, park: 140 };
22
+ /** The seven gates, declaration order; every cell is exactly CELL columns wide. */
23
+ export const BOARD_GATES = GATE_NAMES;
24
+ export const BOARD_CELL = 3;
25
+ export const BOARD_AREA_W = 16;
26
+ export const BOARD_DEPS_W = 14;
27
+ export const BOARD_NOTE_RESERVE = 28;
28
+ export const BOARD_FIXED = 4 + 5 + BOARD_AREA_W + BOARD_DEPS_W + BOARD_CELL * BOARD_GATES.length + 2 + 5;
29
+ export const BOARD_EFFORT_BAR_MAX = 46;
30
+ /** The board's own key hints — only the ones the Run view handles; nothing advertised is inert. */
31
+ export const BOARD_KEYS = ["↑↓ move", "↵ task detail", "q quit"];
32
+ export const boardBand = (columns) => (columns >= 150 ? "full" : columns >= 110 ? "wrap" : "stacked");
33
+ export const stripBoardAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
34
+ const vw = (s) => cellWidth(stripBoardAnsi(s));
35
+ const pad = (s, n) => s + " ".repeat(Math.max(0, n - vw(s)));
36
+ /** ANSI-aware clip at `n` visible cells (prototype `clip`), cut on a grapheme boundary so a
37
+ * double-cell glyph never crosses the limit and an emoji is never emitted as a lone surrogate;
38
+ * a clipped line closes its own colour. */
39
+ export function clipBoard(s, n) {
40
+ const { head, tail } = sliceCells(s, n);
41
+ return tail && head.includes("\x1b") ? `${head}${R}` : head;
42
+ }
43
+ // ── AREA — derived from files[], never declared (prototype :48-94) ────────────────────────────────
44
+ const AREAS = [
45
+ ["SPEC", [/^src\/(compile|graph|plan)\//]],
46
+ ["FLEET", [/^src\/(route|config|adapters)\//]],
47
+ ["RUN", [/^src\/(run|drivers|eval)\//]],
48
+ ["GATE", [/^src\/gates\//]],
49
+ ["UI", [/^src\/(cli|tui|report)\//, /^src\/brand\.ts/]],
50
+ ["DOCS", [/^(docs|skills)\//]],
51
+ ["REPO", [/^(scripts|schema)\//, /^\.github\//, /^tests\/(lint|repo|helpers)\//, /^(vitest\.config|package\.json|tsconfig)/]],
52
+ ];
53
+ export const BOARD_AREA_ORDER = AREAS.map(([n]) => n);
54
+ const expandBraces = (p) => {
55
+ const m = /^(.*?)\{([^}]*)\}(.*)$/.exec(p);
56
+ return m ? m[2].split(",").flatMap((alt) => expandBraces(`${m[1]}${alt.trim()}${m[3]}`)) : [p];
57
+ };
58
+ const areaOf = (path) => {
59
+ for (const [name, res] of AREAS)
60
+ if (res.some((re) => re.test(path)))
61
+ return name;
62
+ return null;
63
+ };
64
+ /** {codes, unmapped} — abstentions applied, set semantics, misses reported rather than dropped. */
65
+ export function areasFor(files) {
66
+ const unmapped = [];
67
+ const hit = new Set(), softTest = new Set(), softCli = new Set();
68
+ for (const f of files.flatMap(expandBraces)) {
69
+ const a = areaOf(f) ?? areaOf(f.replace(/^tests\//, "src/"));
70
+ if (!a) {
71
+ unmapped.push(f);
72
+ continue;
73
+ }
74
+ if (/^tests\//.test(f))
75
+ softTest.add(a);
76
+ else if (/^src\/cli\//.test(f))
77
+ softCli.add(a);
78
+ else
79
+ hit.add(a);
80
+ }
81
+ let codes = [...hit];
82
+ if (!codes.length)
83
+ codes = [...softCli];
84
+ if (!codes.length)
85
+ codes = [...softTest];
86
+ codes.sort((x, y) => BOARD_AREA_ORDER.indexOf(x) - BOARD_AREA_ORDER.indexOf(y));
87
+ return { codes, unmapped };
88
+ }
89
+ const hm = (ms) => `${Math.floor(ms / 3.6e6)}h${String(Math.floor((ms % 3.6e6) / 6e4)).padStart(2, "0")}m`;
90
+ const ago = (ms) => (ms < 6e4 ? "just now" : ms < 3.6e6 ? `${Math.round(ms / 6e4)}m ago` : `${Math.round(ms / 3.6e6)}h ago`);
91
+ const isDone = (t) => t !== undefined && (t.merged || t.state === "completed");
92
+ /** The prototype's frame, sectioned. `renderBoard` joins it row for row. */
93
+ export function boardFrame(input, width) {
94
+ const W = Math.max(1, Math.floor(width));
95
+ const col = input.colour === false ? () => plain : c;
96
+ const bold = input.colour === false ? plain : boldc;
97
+ const bg = input.colour === false ? () => plain : bgc;
98
+ const chrome = col(BOARD_PALETTE.chrome), mute = col(BOARD_PALETTE.mute), body = col(BOARD_PALETTE.body), faint = col(BOARD_PALETTE.faint);
99
+ const accent = col(BOARD_PALETTE.accent), pass = col(BOARD_PALETTE.pass), fail = col(BOARD_PALETTE.fail), warn = col(BOARD_PALETTE.warn), park = col(BOARD_PALETTE.park);
100
+ const rule = (n = W) => chrome("─".repeat(n));
101
+ const s = input.snapshot;
102
+ const byId = new Map(s.tasks.map((t) => [t.id, t]));
103
+ // The fold's comparability boundary: a graph whose identity the journal did not record (a
104
+ // recompile) lends nothing — no titles, deps, declarations or denominator — so the rows are the
105
+ // journal's tasks alone and every gate reads as declared.
106
+ const graphTasks = input.graph?.tasks && s.comparable ? input.graph.tasks : s.tasks.map((t) => ({ id: t.id, title: t.title ?? "", deps: [], files: [], gates: undefined }));
107
+ const uncomparable = input.graph !== undefined && !s.comparable;
108
+ const doneIds = new Set(s.tasks.filter(isDone).map((t) => t.id));
109
+ const t0 = s.firstEventAt ? Date.parse(s.firstEventAt) : input.now;
110
+ const t1 = s.lastEventAt ? Date.parse(s.lastEventAt) : input.now;
111
+ const wide = W >= 118;
112
+ const top = [];
113
+ top.push(`${bg(16, 2)(" tickmarkr ")} ${bold(input.runId)} ${chrome("│")} ` +
114
+ `${pass(`${doneIds.size}/${graphTasks.length} done`)} ` +
115
+ (wide
116
+ ? `${chrome("│")} ${warn(`${s.resumes} restarts`)} ${chrome("│")} ${fail(`${s.escalations} escalations`)} `
117
+ : `${chrome("│")} ${fail(`${s.escalations} esc`)} `) +
118
+ `${chrome("│")} ${mute(`${hm(t1 - t0)}${wide ? ` · ${ago(input.now - t1)}` : ""}`)}` +
119
+ `${input.live ? ` ${chrome("│")} ${pass("● LIVE")}` : ""}`);
120
+ top.push("");
121
+ // RUN ENDED — the four-bucket rule (RULING-231-19 §2): green needs failed, human, blocked and
122
+ // pending every one empty AND the tip not failed; "tip verified" alone never borrows the colour.
123
+ if (s.latestRunEnd) {
124
+ const buckets = ["failed", "human", "blocked", "pending"].map((k) => [k, s.buckets[k]]);
125
+ const unknown = buckets.filter(([, v]) => v === undefined).map(([k]) => k);
126
+ const parked = buckets.filter(([, v]) => v !== undefined && v.length > 0).map(([k, v]) => `${k} ${v.join(",")}`);
127
+ const tally = `${doneIds.size} merged · ${(s.buckets.human ?? []).length} parked · ${(s.buckets.blocked ?? []).length} dep-blocked`;
128
+ const green = !unknown.length && !parked.length && (s.currentTip === "passed" || s.currentTip === "not required");
129
+ const now = green
130
+ ? `${pass("RUN ENDED — GREEN")} ${mute(`tip ${s.currentTip} · ${tally}`)}`
131
+ : s.currentTip === "failed"
132
+ ? `${fail("RUN ENDED — TIP VERIFY FAILED")} ${mute(tally)}`
133
+ : parked.length || unknown.length
134
+ ? `${fail("RUN ENDED — NOT GREEN")} ${mute(`${[...parked, ...unknown.map((k) => `${k} bucket unknown`)].join(" · ")} · ${tally}`)}`
135
+ : `${warn("RUN ENDED — TIP NOT VERIFIED")} ${mute(`tip ${s.currentTip} · ${tally}`)}`;
136
+ top.push(` ${accent("▌")} ${bold("NOW")} ${now}`);
137
+ top.push("");
138
+ }
139
+ top.push(` ${accent("▌")} ${bold("TASKS")} ${mute(uncomparable ? `${doneIds.size} of ${graphTasks.length} journal tasks merged · graph not comparable` : `${doneIds.size} of ${graphTasks.length} merged in this graph`)}` +
140
+ (wide ? mute(" · rows in dispatch order · gates left→right in declaration order") : ""));
141
+ top.push("");
142
+ // ── aligned gate header: each label truncated to the cell it owns (prototype :382-394) ──
143
+ const elastic = Math.max(30, W - BOARD_FIXED - BOARD_NOTE_RESERVE);
144
+ const LBL = Math.min(46, Math.max(18, Math.round(elastic * 0.62)));
145
+ const CHAN = Math.min(26, Math.max(12, elastic - LBL));
146
+ const gateHead = BOARD_GATES.map((g) => faint(pad(g.slice(0, BOARD_CELL - 1), BOARD_CELL))).join("");
147
+ const gateHeader = {
148
+ head: ` ${pad("", 5)}${pad(faint("area"), BOARD_AREA_W)}${pad(faint("deps"), BOARD_DEPS_W)}${pad(faint("task"), LBL)}`,
149
+ strip: gateHead,
150
+ tail: ` ${pad(faint("channel"), CHAN)}${pad(faint("att"), 5)}${faint("note")}`,
151
+ };
152
+ const headerRule = ` ${chrome("─".repeat(Math.min(W - 4, 5 + LBL + BOARD_CELL * BOARD_GATES.length + 2 + CHAN + 5 + 30)))}`;
153
+ const areaTally = new Set(), unmappedPaths = new Set();
154
+ const rows = [];
155
+ for (const g of graphTasks) {
156
+ const t = byId.get(g.id);
157
+ const declared = new Set(g.gates ?? BOARD_GATES);
158
+ const cell = (k) => {
159
+ const st = t?.gates[k]?.state;
160
+ return st === "passed" ? true : st === "failed" ? false : undefined;
161
+ };
162
+ const strip = BOARD_GATES.map((k) => {
163
+ const v = cell(k);
164
+ return pad(v === true ? pass("✔") : v === false ? fail("✖") : !declared.has(k) ? faint("─") : faint("·"), BOARD_CELL);
165
+ }).join("");
166
+ const unrun = BOARD_GATES.filter((k) => declared.has(k) && cell(k) === undefined).length;
167
+ const failedGates = BOARD_GATES.filter((k) => cell(k) === false);
168
+ const parks = t?.parks ?? 0, reviews = t?.reviewRounds ?? 0, attempts = t?.dispatches ?? 0;
169
+ const hot = parks > 3 || failedGates.length > 0 || unrun > 2;
170
+ const marker = input.selection === g.id ? ` ${accent("❯")} ` : " ";
171
+ const id = hot ? accent(bold(pad(g.id, 5))) : mute(pad(g.id, 5));
172
+ const { codes: acodes, unmapped: aun } = areasFor(g.files ?? []);
173
+ acodes.forEach((a) => areaTally.add(a));
174
+ aun.forEach((u) => unmappedPaths.add(u));
175
+ let alabel = acodes.length ? acodes.join("+") : (aun.length ? "UNMAPPED" : "—");
176
+ if (alabel.length > BOARD_AREA_W - 2) {
177
+ const keep = [];
178
+ for (const a of acodes) {
179
+ if ([...keep, a].join("+").length + 3 > BOARD_AREA_W - 2)
180
+ break;
181
+ keep.push(a);
182
+ }
183
+ alabel = `${keep.join("+")}+${acodes.length - keep.length}`;
184
+ }
185
+ const area = pad(hot ? body(alabel) : faint(alabel), BOARD_AREA_W);
186
+ const depIds = g.deps ?? [];
187
+ const depText = depIds.length ? depIds.join(",") : "—";
188
+ const depLabel = depText.length <= BOARD_DEPS_W - 2
189
+ ? depText
190
+ : depIds.length > 1 ? `${depIds[0]}+${depIds.length - 1}` : `${depIds[0].slice(0, BOARD_DEPS_W - 3)}…`;
191
+ const deps = pad(faint(depLabel), BOARD_DEPS_W);
192
+ const titleText = sliceCells(g.title ?? "", LBL - 2).head;
193
+ const title = hot ? body(pad(titleText, LBL)) : faint(pad(titleText, LBL));
194
+ const note = [
195
+ parks >= 3 ? park(`◍ parked ×${parks}`) : "",
196
+ reviews > 2 ? warn(`↻ ${reviews} review rounds`) : "",
197
+ failedGates.length ? fail(`✖ ${failedGates.join(",")}`) : "",
198
+ !attempts
199
+ ? (() => {
200
+ const blockers = depIds.filter((d) => !doneIds.has(d));
201
+ if (!blockers.length)
202
+ return faint("queued");
203
+ const room = Math.max(10, W - BOARD_FIXED - LBL - CHAN);
204
+ let shown = blockers, dropped = 0;
205
+ while (shown.length > 1 && `waiting on ${shown.join(", ")}`.length + (dropped ? 5 : 0) > room) {
206
+ shown = shown.slice(0, -1);
207
+ dropped = blockers.length - shown.length;
208
+ }
209
+ return faint(`waiting on ${shown.join(", ")}${dropped ? ` +${dropped}` : ""}`);
210
+ })()
211
+ : unrun === declared.size ? faint("in flight")
212
+ : unrun > 2 ? faint(`${declared.size - unrun}/${declared.size} gates run`) : "",
213
+ ].filter(Boolean);
214
+ rows.push({
215
+ id: g.id,
216
+ head: `${marker}${id}${area}${deps}${title}`,
217
+ strip,
218
+ tail: ` ${pad(mute(sliceCells(t?.channel ?? "—", CHAN - 2).head), CHAN)}${pad(mute(String(attempts)), 5)}`,
219
+ note: note.join(mute(" · ")),
220
+ noteParts: note,
221
+ });
222
+ }
223
+ const bottom = [];
224
+ bottom.push("");
225
+ bottom.push(` ${faint("area")} ${BOARD_AREA_ORDER.map((a) => (areaTally.has(a) ? body(a) : faint(a))).join(mute(" · "))}${unmappedPaths.size ? mute(" ") + fail(`${unmappedPaths.size} unmapped`) : ""}`);
226
+ if (unmappedPaths.size)
227
+ bottom.push(` ${pad("", 8)}${fail("UNMAPPED")} ${mute([...unmappedPaths].slice(0, 3).join(" "))}${unmappedPaths.size > 3 ? mute(` +${unmappedPaths.size - 3}`) : ""}`);
228
+ bottom.push(` ${faint("gates")} ${BOARD_GATES.map((k) => `${body(k.slice(0, BOARD_CELL - 1))} ${mute(k)}`).join(" ")}`);
229
+ bottom.push(` ${faint(" ")} ${pass("✔")} ${mute("passed")} ${fail("✖")} ${mute("failed")} ${faint("·")} ${mute("pending")} ${faint("─")} ${mute("not declared (acceptance and review are the optional two)")}`);
230
+ bottom.push("");
231
+ // the gap is stated, never silently skipped — derived, so it cannot go stale
232
+ const retired = s.tasks.map((t) => t.id).filter((id) => !graphTasks.some((g) => g.id === id)).sort();
233
+ // A retired task is still one of the fold's tasks, so the selection can land on it: its row carries
234
+ // the same marker the graph rows do — paint and navigation cannot diverge.
235
+ for (const id of retired)
236
+ bottom.push(`${input.selection === id ? ` ${accent("❯")} ` : " "}${faint(id)} ${faint("— not in this graph (retired at compile)")}`);
237
+ if (retired.length)
238
+ bottom.push("");
239
+ bottom.push(rule());
240
+ bottom.push("");
241
+ // ── where the run actually spent itself, per task (prototype :477-491) ──
242
+ const worst = graphTasks.map((g) => { const t = byId.get(g.id); return { id: g.id, p: t?.parks ?? 0, r: t?.reviewRounds ?? 0, a: t?.dispatches ?? 0 }; })
243
+ .sort((x, y) => (y.p + y.r + y.a) - (x.p + x.r + x.a)).slice(0, 4);
244
+ bottom.push(` ${accent("▌")} ${bold("WHERE THE EFFORT WENT")} ${mute(`dispatches · review rounds · human parks — top ${worst.length} of ${graphTasks.length} tasks`)}`);
245
+ bottom.push("");
246
+ const maxTot = Math.max(...worst.map((w) => w.a + w.r + w.p), 1);
247
+ const barw = Math.min(BOARD_EFFORT_BAR_MAX, Math.max(16, W - 60));
248
+ for (const w of worst) {
249
+ // The prototype rounds each segment on its own; the three share one budget, so the cells rounding
250
+ // pushes past barw come off the longest segment (4·1·1 at 46 rounds to 31+8+8) — never past the maximum.
251
+ const len = [w.a, w.r, w.p].map((n) => Math.round((n / maxTot) * barw));
252
+ for (let over = len.reduce((x, y) => x + y, 0) - barw; over > 0; over--)
253
+ len[len.indexOf(Math.max(...len))]--;
254
+ const seg = (n, paint) => paint("█".repeat(n));
255
+ bottom.push(` ${accent(pad(w.id, 6))}${seg(len[0], accent)}${seg(len[1], warn)}${seg(len[2], park)} ` +
256
+ `${mute(`${w.a} dispatch`)}${mute(" · ")}${warn(`${w.r} review`)}${mute(" · ")}${park(`${w.p} park`)}`);
257
+ }
258
+ bottom.push("");
259
+ bottom.push(` ${mute("legend")} ${accent("█")} ${faint("dispatch")} ${warn("█")} ${faint("review round")} ${park("█")} ${faint("human park")}`);
260
+ bottom.push("");
261
+ if (input.keys !== false)
262
+ bottom.push(boardFooter(input.keys ?? BOARD_KEYS, input.colour !== false));
263
+ return { top: [...top, `${gateHeader.head}${gateHeader.strip}${gateHeader.tail}`, headerRule], rows, gateHeader, bottom, width: W, lbl: LBL, chan: CHAN };
264
+ }
265
+ /** The footer row: the key hints in chrome, the prototype's leading space kept. */
266
+ export const boardFooter = (keys, colour = true) => (colour ? c(BOARD_PALETTE.chrome) : plain)(` ${keys.join(" · ")}`);
267
+ /** The prototype's frame as lines, one row per task, unclipped — exactly what the design signed off. */
268
+ export function renderBoard(input, width) {
269
+ const f = boardFrame(input, width);
270
+ return [...f.top, ...f.rows.map((r) => `${r.head}${r.strip}${r.tail}${r.note}`), ...f.bottom];
271
+ }
272
+ /**
273
+ * The Run view's three width bands over the same frame: full draws the prototype's rows; wrap
274
+ * puts a note that does not fit on its own second row; stacked draws the gate cells, channel and
275
+ * attempts under the row so nothing is truncated away.
276
+ */
277
+ export function renderBoardLines(input, width) {
278
+ const band = boardBand(width);
279
+ const f = boardFrame(input, width);
280
+ const IND = 9, ind = " ".repeat(IND);
281
+ const sep = (input.colour === false ? plain : c(BOARD_PALETTE.mute))(" · ");
282
+ const lines = [...f.top];
283
+ if (band === "stacked") {
284
+ // The gate header rides with the cells it labels; the title header stays with the title column.
285
+ lines.splice(lines.length - 2, 1, f.gateHeader.head, `${ind}${f.gateHeader.strip}${f.gateHeader.tail}`);
286
+ }
287
+ // A note never truncates: what does not fit beside its row continues on indented rows below it.
288
+ const noteRows = (r, lead) => {
289
+ const [first, ...rest] = wrapNote(r.noteParts, sep, Math.max(1, width - vw(lead)), Math.max(1, width - IND));
290
+ lines.push(`${lead}${first ?? ""}`, ...rest.map((l) => `${ind}${l}`));
291
+ };
292
+ for (const r of f.rows) {
293
+ if (band === "stacked") {
294
+ lines.push(r.head, `${ind}${r.strip}${r.tail}`);
295
+ if (r.note)
296
+ noteRows(r, ind);
297
+ continue;
298
+ }
299
+ const row = `${r.head}${r.strip}${r.tail}`;
300
+ if (!r.note || vw(row) + vw(r.note) <= width)
301
+ lines.push(`${row}${r.note}`);
302
+ else if (band === "full" && vw(row) < width - 12)
303
+ noteRows(r, row);
304
+ else {
305
+ lines.push(row);
306
+ noteRows(r, ind);
307
+ }
308
+ }
309
+ lines.push(...f.bottom);
310
+ return lines.map((l) => clipBoard(l, width));
311
+ }
312
+ /** Pack note segments greedily into rows of `avail` cells; a segment wider than a row splits at the cell. */
313
+ export function wrapNote(parts, sep, firstAvail, avail) {
314
+ const out = [];
315
+ let cur = "";
316
+ const room = () => (out.length ? avail : firstAvail);
317
+ const flush = () => { if (cur)
318
+ out.push(cur); cur = ""; };
319
+ for (const part of parts) {
320
+ const joined = cur ? `${cur}${sep}${part}` : part;
321
+ if (vw(joined) <= room()) {
322
+ cur = joined;
323
+ continue;
324
+ }
325
+ flush();
326
+ let rest = part;
327
+ while (vw(rest) > room()) {
328
+ const [head, tail] = splitBoard(rest, room());
329
+ if (!head)
330
+ break;
331
+ out.push(head);
332
+ rest = tail;
333
+ }
334
+ cur = rest;
335
+ }
336
+ flush();
337
+ return out;
338
+ }
339
+ /** ANSI-aware split at `n` visible cells on a grapheme boundary; the tail re-opens the colours the head left active. */
340
+ function splitBoard(s, n) {
341
+ const { head, tail } = sliceCells(s, n);
342
+ let active = "";
343
+ for (const [code] of head.matchAll(/\x1b\[[0-9;]*m/g))
344
+ active = code === CLOSE || code === R ? "" : active + code;
345
+ return [head + (active ? CLOSE : ""), active + tail];
346
+ }
@@ -3,6 +3,8 @@ import { APPROVAL_ENACTS, approvalDispositionForRelease, approvalEnactment, appr
3
3
  import { stateDirName } from "../../graph/graph.js";
4
4
  import { Journal } from "../../run/journal.js";
5
5
  const parkDiagnostic = (park) => {
6
+ if (park.kind === "scope-request")
7
+ return park.approveCommand ?? "scope-request requires tickmarkr approve with --files <glob,…>";
6
8
  if (park.tombstone)
7
9
  return "tombstone — permanent by design; no verb releases it";
8
10
  if (park.kind === "gate-fail" && park.failedGate === undefined) {
@@ -304,7 +304,11 @@ export type FramePlan = PlannedFrame | PlainFallbackPlan;
304
304
  */
305
305
  export declare function planFrame(size: MeasuredSize, view?: FrameView, state?: FrameState): FramePlan;
306
306
  export declare function resolveCockpitLayout(columns: number, rows: number): CockpitLayout;
307
- /** FINAL §3.1: one geometry for paint, focus and pointer hit testing. */
307
+ /**
308
+ * FINAL §3.1: one geometry for paint, focus and pointer hit testing. A ZERO shortcut budget is the
309
+ * rail-less mount (BD-1, RULING-231-19 §2): the daemon-placed board is a one-view observer, so it
310
+ * plans no rail and no shortcuts at any width — the budget is honoured, never floored back to 20.
311
+ */
308
312
  export declare function planShell(columns: number, rows: number, shortcutColumns?: number): {
309
313
  columns: number;
310
314
  rows: number;
@@ -389,12 +389,17 @@ export function resolveCockpitLayout(columns, rows) {
389
389
  },
390
390
  };
391
391
  }
392
- /** FINAL §3.1: one geometry for paint, focus and pointer hit testing. */
392
+ /**
393
+ * FINAL §3.1: one geometry for paint, focus and pointer hit testing. A ZERO shortcut budget is the
394
+ * rail-less mount (BD-1, RULING-231-19 §2): the daemon-placed board is a one-view observer, so it
395
+ * plans no rail and no shortcuts at any width — the budget is honoured, never floored back to 20.
396
+ */
393
397
  export function planShell(columns, rows, shortcutColumns = 22) {
394
398
  columns = Math.max(0, Math.floor(columns));
395
399
  rows = Math.max(0, Math.floor(rows));
396
- const rail = columns >= 90 ? 15 : 0;
397
- const shortcuts = columns >= 120 ? Math.max(20, Math.min(columns - 59, Math.floor(shortcutColumns))) : 0;
400
+ const railless = shortcutColumns === 0;
401
+ const rail = columns >= 90 && !railless ? 15 : 0;
402
+ const shortcuts = columns >= 120 && !railless ? Math.max(20, Math.min(columns - 59, Math.floor(shortcutColumns))) : 0;
398
403
  const bodyColumn = 1 + (rail ? rail + 1 : 0);
399
404
  return {
400
405
  columns, rows, refused: columns < 40 || rows < 14,
@@ -9,7 +9,8 @@ import { render, useInput, useStdin } from "ink";
9
9
  import { useLayoutEffect, useSyncExternalStore } from "react";
10
10
  import { createLiveStore } from "./live-store.js";
11
11
  import { HomeView, deriveHomeView, selectNeedsYouTarget } from "./home-view.js";
12
- import { RunView, applyRunViewKey, initialRunViewSession, runGateCells, evidenceLookup, runTaskRow, GATE_CELL_LETTERS } from "./run-view.js";
12
+ import { RunView, applyRunViewKey, initialRunViewSession, runGateCells, evidenceLookup } from "./run-view.js";
13
+ import { renderBoardLines, stripBoardAnsi } from "./board.js";
13
14
  import { EvidenceView, deriveEvidenceView, defaultExportPath, planEvidenceExport, writeEvidenceExport } from "./evidence-view.js";
14
15
  import { ShellGridFlow, ShellJournalSelection } from "./components.js";
15
16
  import { CockpitShell, initialShellState } from "./shell.js";
@@ -18,12 +19,11 @@ import { shellBindings } from "./keys.js";
18
19
  import { createPointerReportReader, POINTER_TRACKING_ON, POINTER_TRACKING_OFF } from "./pointer.js";
19
20
  import { deriveRunDecisions, previewDecision, executeDecision, withDecisionPreview, withDecisionReceipt, decisionConfirmLines } from "./decision-actions.js";
20
21
  import { approvalRunOwner } from "../../cli/commands/approve.js";
21
- import { GATE_NAMES } from "../../graph/schema.js";
22
22
  import { GLYPHS } from "../../brand.js";
23
23
  import { Journal } from "../../run/journal.js";
24
- import { observeNamedRun } from "../../run/supervision.js";
24
+ import { observeNamedRun, WATCH_OWNER_ENV } from "../../run/supervision.js";
25
25
  import { formatOwnedName } from "../../drivers/types.js";
26
- import { cellWidth, fitCells, wrapCells } from "./width.js";
26
+ import { cellWidth, wrapCells } from "./width.js";
27
27
  import { resolveShellColourMode } from "./theme.js";
28
28
  let timelineUsers = 0;
29
29
  let timelineObserver;
@@ -100,7 +100,10 @@ export async function runConsolidatedCockpit(options) {
100
100
  const source = store;
101
101
  if (options.observeRun !== false)
102
102
  observation = observeNamedRun(cwd, runId, options.environment);
103
- let state = { ...initialShellState(), view: options.initialView ?? "home" };
103
+ // BD-1 (RULING-231-19 §2): the daemon-placed board carries the watch owner token and mounts
104
+ // rail-less — a zero shortcut budget the plan honours at every width. The manual cockpit keeps its rail.
105
+ const railless = Boolean((options.environment ?? process.env)[WATCH_OWNER_ENV]);
106
+ let state = { ...initialShellState(), view: options.initialView ?? "home", ...(railless ? { shortcutColumns: 0 } : {}) };
104
107
  let runSession = initialRunViewSession();
105
108
  if (options.initialParks) {
106
109
  runSession = { ...runSession, selection: Math.max(0, source.snapshot().operator.tasks.findIndex(t => t.state === "human" || t.state === "blocked")) };
@@ -172,6 +175,29 @@ export async function runConsolidatedCockpit(options) {
172
175
  state = { ...state, overlay: [result.status === "focused" ? `Focused ${task.id}` : `Pane ${result.status}`, result.reason, "Enter opens task evidence after Esc"], overlayOffset: 0 };
173
176
  publish();
174
177
  };
178
+ // The board is taller than a small body; a selection moved by keys or a park opened from Home
179
+ // scrolls the shell's viewport just far enough that the selected row (and the rows that wrap
180
+ // under it) is seen. The rows are the very lines the Run view draws at this body width.
181
+ const followSelection = () => {
182
+ const snap = source.snapshot();
183
+ const task = snap.operator.tasks[runSession.selection];
184
+ if (!task)
185
+ return;
186
+ const g = geometry();
187
+ const lines = renderBoardLines({ runId, snapshot: snap.operator, graph: snap.graph.value, now: (options.now ?? Date.now)(), selection: task.id, keys: false, colour: false }, g.bodyColumns);
188
+ const first = lines.findIndex(l => l.startsWith(" ❯ "));
189
+ if (first < 0)
190
+ return;
191
+ let last = first;
192
+ while (last + 1 < lines.length && lines[last + 1].startsWith(" ".repeat(9)))
193
+ last++;
194
+ const top = first + 1, bottom = last + 1; // the RUN summary line sits above the board
195
+ const scroll = state.scroll;
196
+ if (top < scroll)
197
+ state = { ...state, scroll: top };
198
+ else if (bottom >= scroll + g.bodyRows)
199
+ state = { ...state, scroll: bottom - g.bodyRows + 1 };
200
+ };
175
201
  const openDetail = () => {
176
202
  const snap = source.snapshot();
177
203
  const task = state.view === "run" ? snap.operator.tasks[runSession.selection] : undefined;
@@ -189,6 +215,7 @@ export async function runConsolidatedCockpit(options) {
189
215
  // from the snapshot. The guard prevents the shell from dispatching its echo.
190
216
  let leafInput;
191
217
  let forwarding = false;
218
+ let lastRawInput = "";
192
219
  const forwardLeaf = (bytes) => {
193
220
  forwarding = true;
194
221
  try {
@@ -347,12 +374,15 @@ export async function runConsolidatedCockpit(options) {
347
374
  const task = snap.operator.tasks[runSession.selection];
348
375
  const cells = task ? runGateCells(task, evidenceLookup(snap.journal.history, source.page)) : [];
349
376
  runSession = applyRunViewKey(runSession, event, { tasks: snap.operator.tasks, decisions, verdictLines: cells[runSession.verdictGate]?.verdict.length ?? 0 }).session;
377
+ if (k.upArrow || k.downArrow)
378
+ followSelection();
350
379
  if (k.pageDown || k.pageUp)
351
380
  state = {
352
381
  ...state,
353
- // The Run leaf owns verdict paging; the shell only moves its
354
- // viewport far enough for that leaf's verdict panel to be seen.
355
- scroll: k.pageDown ? geometry().bodyRows : 0,
382
+ // The Run leaf owns verdict paging; the shell only moves its viewport so that leaf's
383
+ // verdict panel is seen — it sits below the board and the selected task's cells, so the
384
+ // viewport goes to the leaf's end (clamped by the shell), not one page down.
385
+ scroll: k.pageDown ? Number.MAX_SAFE_INTEGER : 0,
356
386
  };
357
387
  if (runSession.decisions.menu)
358
388
  state = { ...state, overlay: ["Actions — Enter reviews; Esc cancels", ...runSession.decisions.menu.verbs], overlayOffset: 0 };
@@ -467,18 +497,20 @@ export async function runConsolidatedCockpit(options) {
467
497
  const reader = createPointerReportReader();
468
498
  const stdin = new Proxy(input, { get(target, property) {
469
499
  if (property === "read")
470
- return (...args) => {
500
+ return () => {
471
501
  try {
472
- const chunk = target.read(...args);
473
- if (chunk == null)
474
- return null;
475
- const result = reader(String(chunk));
476
502
  let keys = "";
477
- for (const token of result.tokens) {
478
- if (token.type === "pointer")
479
- pointer(token.report);
480
- else
481
- keys += token.bytes;
503
+ // OBS-965: drain until the stream itself says empty. A real tty (highWaterMark 0) stops its
504
+ // handle after every chunk and only a read() that finds the buffer EMPTY restarts it; Ink's
505
+ // loop stops at the first null, so returning null after ONE pointer-only chunk left that
506
+ // empty read unmade and every later key queued in the kernel — the deaf board.
507
+ for (let chunk = target.read(); chunk != null; chunk = target.read()) {
508
+ for (const token of reader(String(chunk)).tokens) {
509
+ if (token.type === "pointer")
510
+ pointer(token.report);
511
+ else
512
+ keys += token.bytes;
513
+ }
482
514
  }
483
515
  // A standalone Escape is unambiguous after Ink's own input grace.
484
516
  if (reader.pending() === "\x1b")
@@ -499,16 +531,35 @@ export async function runConsolidatedCockpit(options) {
499
531
  useSyncExternalStore(listener => { listeners.add(listener); return () => { listeners.delete(listener); }; }, () => revision);
500
532
  const stdinContext = useStdin();
501
533
  useLayoutEffect(() => {
502
- leafInput = stdinContext.internal_eventEmitter;
503
- return () => { leafInput = undefined; };
534
+ const emitter = stdinContext.internal_eventEmitter;
535
+ leafInput = emitter;
536
+ // Ink strips an escape sequence's ESC before useInput sees it, so an unnamed CSI and typed
537
+ // "[..." look alike there. Its parser emits every escape sequence as its own event, so the RAW
538
+ // event says which it was. Registered in a layout effect, this listener runs before useInput's.
539
+ const raw = (data) => { lastRawInput = data; };
540
+ emitter?.on("input", raw);
541
+ return () => { leafInput = undefined; emitter?.off("input", raw); };
504
542
  }, [stdinContext.internal_eventEmitter]);
505
543
  const renderedView = state.view;
506
544
  const leafActive = state.editor === undefined && !state.overlay && !state.help && !state.reviewing;
507
545
  useInput((text, k) => {
508
546
  if (forwarding || (leafActive && renderedView !== "run" && (k.return || (renderedView === "evidence" && text === "e"))))
509
547
  return;
548
+ // OBS-1002: Ink hands a coalesced chunk ("a\r", "na\r" — a paste, key repeat under load, a
549
+ // `pane run` message) to the handler as ONE string with no key flags, and a single-key map
550
+ // ignores it. Split every non-escape chunk into one event per code point; an escape sequence
551
+ // (arrows, page keys) is one key and already carries its flags.
552
+ const flagged = k.ctrl || k.meta || k.upArrow || k.downArrow || k.leftArrow || k.rightArrow || k.pageUp || k.pageDown || k.return || k.escape || k.tab || k.backspace || k.delete;
553
+ const points = !flagged && !lastRawInput.startsWith("\x1b") && [...text].length > 1 ? [...text] : [text];
510
554
  try {
511
- key({ input: text, key: k }, true);
555
+ for (const point of points) {
556
+ const event = points.length === 1 ? { input: text, key: k }
557
+ : point === "\r" || point === "\n" ? { input: "", key: { return: true } }
558
+ : point === "\t" ? { input: "", key: { tab: true } }
559
+ : point === "\x1b" ? { input: "", key: { escape: true } }
560
+ : { input: point, key: { shift: /[A-Z]/.test(point) } };
561
+ key(event, true);
562
+ }
512
563
  }
513
564
  catch (error) {
514
565
  stop(error);
@@ -562,10 +613,9 @@ export async function runConsolidatedCockpit(options) {
562
613
  return _jsx(CockpitShell, { snapshot: options.observeRun === false ? { ...snap, journal: { ...snap.journal, source: "no run/journal.jsonl" } } : snap, state: state, columns: p.columns, rows: p.rows, version: options.binaryVersion, colour: colour, onCommit: plan => {
563
614
  committed = plan;
564
615
  const targets = [];
565
- const taskRows = snap.operator.tasks.map((task, index) => ({
566
- id: task.id,
567
- text: fitCells(`${index === Math.min(runSession.selection, snap.operator.tasks.length - 1) ? `${GLYPHS.pointer} ` : " "}${runTaskRow(task, GATE_NAMES.map(gate => ({ letter: GATE_CELL_LETTERS[task.gates[gate].state] })))}`, Math.max(1, plan.bodyColumns - 4)),
568
- }));
616
+ // A board row opens with the prototype's four-cell indent or the focus marker, then the id;
617
+ // the first painted row naming a task is that task's target (the effort fold names it again, later).
618
+ const boardRowId = (text) => /^(?: {4}| {2}❯ )(\S+)/u.exec(stripBoardAnsi(text))?.[1];
569
619
  const usedTasks = new Set();
570
620
  for (const row of plan.paintedRows) {
571
621
  if (row.row < plan.bodyRow && (row.text.startsWith("1 Home") || row.text.startsWith("1H"))) {
@@ -597,10 +647,12 @@ export async function runConsolidatedCockpit(options) {
597
647
  continue;
598
648
  }
599
649
  if (state.view === "run") {
600
- const task = taskRows.find(task => !usedTasks.has(task.id) && row.text === task.text);
601
- if (task) {
602
- usedTasks.add(task.id);
603
- targets.push({ ...row, kind: "task", id: task.id });
650
+ const drawn = boardRowId(row.text);
651
+ // A narrow body clips the row inside a long id; the drawn prefix still names one task.
652
+ const id = drawn === undefined ? undefined : (snap.operator.tasks.find(task => task.id === drawn) ?? snap.operator.tasks.find(task => task.id.startsWith(drawn)))?.id;
653
+ if (id !== undefined && !usedTasks.has(id)) {
654
+ usedTasks.add(id);
655
+ targets.push({ ...row, kind: "task", id });
604
656
  }
605
657
  }
606
658
  else {
@@ -623,8 +675,8 @@ export async function runConsolidatedCockpit(options) {
623
675
  }
624
676
  }
625
677
  committedTargets = targets;
626
- }, children: state.view === "home" ? _jsx(ShellGridFlow, { width: p.bodyColumns, columns: p.columns < 120 ? 2 : 3, rowHeight: p.rows < 20 ? 1 : p.columns < 120 ? 2 : 3, children: _jsx(HomeView, { width: p.bodyColumns, model: homeModel, focused: focused, onOpenPark: id => { runSession = { ...runSession, selection: Math.max(0, snap.operator.tasks.findIndex(t => t.id === id)) }; state = { ...state, view: "run", scroll: 0 }; publish(); }, onOpenDiagnostic: id => { state = { ...state, overlay: [id], overlayOffset: 0 }; publish(); }, onOpenEvidence: openEvidence, onSelectNeedsYou: reportHomeNeedsYouSelection }, String(homeModel.needsYou.length > 0)) }) :
627
- state.view === "run" ? _jsx(RunView, { snapshot: snap.operator, rows: snap.journal.history, page: source.page, graph: snap.graph.value, decisions: visibleDecisions, session: runSession, columns: p.bodyColumns, run: approvalRunOwner(cwd, runId) }) :
678
+ }, children: state.view === "home" ? _jsx(ShellGridFlow, { width: p.bodyColumns, columns: p.columns < 120 ? 2 : 3, rowHeight: p.rows < 20 ? 1 : p.columns < 120 ? 2 : 3, children: _jsx(HomeView, { width: p.bodyColumns, model: homeModel, focused: focused, onOpenPark: id => { runSession = { ...runSession, selection: Math.max(0, snap.operator.tasks.findIndex(t => t.id === id)) }; state = { ...state, view: "run", scroll: 0 }; followSelection(); publish(); }, onOpenDiagnostic: id => { state = { ...state, overlay: [id], overlayOffset: 0 }; publish(); }, onOpenEvidence: openEvidence, onSelectNeedsYou: reportHomeNeedsYouSelection }, String(homeModel.needsYou.length > 0)) }) :
679
+ state.view === "run" ? _jsx(RunView, { snapshot: snap.operator, rows: snap.journal.history, page: source.page, graph: snap.graph.value, decisions: visibleDecisions, session: runSession, columns: p.bodyColumns, run: approvalRunOwner(cwd, runId), now: options.now }) :
628
680
  _jsx(ShellJournalSelection.Provider, { value: reportEvidenceSelection, children: _jsx(EvidenceView, { width: p.bodyColumns, model: { ...model, journal: evidenceRows }, focusEvidence: selectedEvidence, focused: focused, onExport: beginExport, onSelect: id => { selectedEvidence = id; openDetail(); publish(); } }, `${state.query}:${evidenceNavigation}`) }) });
629
681
  }
630
682
  app = render(_jsx(App, {}), { stdin, stdout: output, debug: options.debug, exitOnCtrlC: false, patchConsole: false });
@@ -1,4 +1,4 @@
1
- import type { ReactElement } from "react";
1
+ import { type ReactElement } from "react";
2
2
  import type { ApprovalRunOwner } from "../../cli/commands/approve.js";
3
3
  import { type GateName, type RunGraph } from "../../graph/schema.js";
4
4
  import type { JournalEvent } from "../../run/journal.js";
@@ -83,8 +83,8 @@ export declare function applyRunViewKey(session: RunViewSession, event: RunViewK
83
83
  * a closed run whose record lacks a bucket says so rather than reading absence as empty.
84
84
  */
85
85
  export declare function runSummaryLine(s: OperatorSnapshot): string;
86
- /** One matrix row: id, state, the seven letters in declaration order, the recorded attempt. */
87
- export declare function runTaskRow(t: OperatorTask, cells: readonly Pick<RunGateCell, "letter">[]): string;
86
+ /** The board's footer keys merged with the cockpit's own — every one of them handled by this view or the shell. */
87
+ export declare const RUN_VIEW_KEYS: readonly string[];
88
88
  export interface RunViewProps {
89
89
  readonly snapshot: OperatorSnapshot;
90
90
  /** Journal rows the store retains (C2 `journal.history`); evidence outside them reaches `page`. */
@@ -95,6 +95,8 @@ export interface RunViewProps {
95
95
  readonly session: RunViewSession;
96
96
  readonly columns: number;
97
97
  readonly run: ApprovalRunOwner;
98
+ /** The clock the board header reads; injectable so a pinned frame does not drift. */
99
+ readonly now?: () => number;
98
100
  }
99
- /** The Run body. Every line is measured through the width module; nothing overflows its column. */
100
- export declare function RunView({ snapshot, rows, page, graph, decisions, session, columns, run }: RunViewProps): ReactElement;
101
+ /** The Run body: the approved board (BD-1) over the fold, then the selected task's detail panels. */
102
+ export declare function RunView({ snapshot, rows, page, graph, decisions, session, columns, run, now }: RunViewProps): ReactElement;