tickmarkr 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +14 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
package/dist/run/stall.js CHANGED
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { shGit } from "./git.js";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { isAbsolute, relative, sep } from "node:path";
4
+ import { activeShellIdentities, processIdentity, resolveActiveShellIdentities, shGit, SUITE_PARENT_ENV } from "./git.js";
3
5
  // OBS-82: normalize known presentation tokens before measuring transcript extent or filtering an
4
6
  // LLM-bound transcript. This remains a closed allowlist — ANSI/VT escapes, braille-range spinner
5
7
  // glyphs, and elapsed-time tokens bound to time-unit suffixes. Every other byte passes through
@@ -64,7 +66,9 @@ export function readOwnedProcessGroup(path) {
64
66
  }
65
67
  }
66
68
  /** Never infer ownership from a task name or scan another attempt's marker when reaping. */
67
- export async function reapOwnedProcessGroup(group, cwd) {
69
+ export async function reapOwnedProcessGroup(group, cwd, ownership) {
70
+ if (ownership)
71
+ return reapWorkerProcesses(group, cwd, ownership);
68
72
  if (group === undefined)
69
73
  return null;
70
74
  const own = await shGit(`ps -o pgid= -p ${process.pid}`, cwd, 5_000);
@@ -97,6 +101,259 @@ export async function reapOwnedProcessGroup(group, cwd) {
97
101
  }
98
102
  return survivors;
99
103
  }
104
+ const below = (root, path) => {
105
+ const rel = relative(root, path);
106
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
107
+ };
108
+ // Every probe is async and bounded like the CPU accountant's ps: a loaded host is slow, not unreadable.
109
+ const probeExec = (command, args) => new Promise((resolve, reject) => execFile(command, args, { encoding: "utf8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(stdout))));
110
+ /** Bounded host boundary, also used to inject unreadability and pid reuse in regressions. */
111
+ export const workerReapHost = {
112
+ async snapshot() {
113
+ try {
114
+ const out = await probeExec("ps", ["-Awwo", "pid=,ppid=,pgid=,sess=,stat=,lstart=,command="]);
115
+ const rows = [];
116
+ const lines = out.split("\n").filter((line) => line.trim());
117
+ if (!lines.length || lines.length > 20_000)
118
+ return undefined;
119
+ for (const line of lines) {
120
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.*)$/.exec(line);
121
+ if (!m)
122
+ return undefined;
123
+ if (m[5].startsWith("Z"))
124
+ continue;
125
+ const pid = Number(m[1]);
126
+ const identity = process.platform === "linux" ? await processIdentity(pid) : `${pid}:${m[6].trim().replace(/\s+/g, " ")}`;
127
+ // A process disappearing during ps is normal; a present, unreadable identity is not.
128
+ if (!identity) {
129
+ try {
130
+ process.kill(pid, 0);
131
+ return undefined;
132
+ }
133
+ catch (error) {
134
+ if (error.code !== "ESRCH")
135
+ return undefined;
136
+ }
137
+ continue;
138
+ }
139
+ rows.push({ pid, ppid: Number(m[2]), group: Number(m[3]), session: m[4], identity,
140
+ startedAt: m[6].trim().replace(/\s+/g, " "), command: m[7] });
141
+ }
142
+ return rows;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ },
148
+ async inspect(pid) {
149
+ try {
150
+ let cwd;
151
+ let env;
152
+ if (process.platform === "linux") {
153
+ cwd = realpathSync(`/proc/${pid}/cwd`);
154
+ env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
155
+ }
156
+ else {
157
+ const [open, command] = await Promise.all([
158
+ probeExec("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]),
159
+ probeExec("ps", ["eww", "-p", String(pid), "-o", "command="]),
160
+ ]);
161
+ const path = open.split("\n").find((line) => line.startsWith("n"))?.slice(1);
162
+ if (!path)
163
+ return undefined;
164
+ cwd = realpathSync(path);
165
+ env = command.split(/\s+/);
166
+ }
167
+ const parent = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
168
+ return { cwd, ...(parent && /^\d+$/.test(parent) ? { suiteParent: Number(parent) } : {}) };
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ },
174
+ // Lazy: a partial module mock of git.js must not fail at import time.
175
+ identity: (pid) => processIdentity(pid),
176
+ kill(pid) { process.kill(pid, "SIGKILL"); },
177
+ };
178
+ async function reapWorkerProcesses(group, cwd, ownership) {
179
+ let root;
180
+ let excludedPaths;
181
+ try {
182
+ root = realpathSync(cwd);
183
+ excludedPaths = ownership.excludedWorktrees.map((path) => realpathSync(path));
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ let rows = await workerReapHost.snapshot();
189
+ if (!rows)
190
+ return null;
191
+ const roots = rows.filter((row) => row.command === `bash ${ownership.marker}` || row.command === `/bin/bash ${ownership.marker}`);
192
+ // Ownership evidence: the recorded group, a live dispatch root, or descendants observed while it
193
+ // lived. cwd alone is never authority, and neither is the session id (OBS-1170): on Linux a
194
+ // same-session SIBLING that never descended from the dispatch — an operator job in a kept pane
195
+ // whose cwd is the worktree — is not ours to signal. `ownership.session` stays recorded as
196
+ // evidence only. With none of these in a readable snapshot nothing owned is observable: the
197
+ // dispatch has retired, so the census is empty rather than unknown (a finished root leaves no row).
198
+ const groupRows = group === undefined ? [] : rows.filter((row) => row.group === group && row.pid !== process.pid);
199
+ if (roots.length === 0 && !ownership.descendants?.size && groupRows.length === 0)
200
+ return [];
201
+ const owned = new Map(ownership.descendants);
202
+ const protectedIds = new Map();
203
+ const recordedParent = rows.find((row) => row.pid === ownership.parent?.pid && row.startedAt === ownership.parent.startedAt);
204
+ // Never reap a pane's shell (an ancestor of the dispatch root), or the daemon.
205
+ for (const start of [...roots.map((row) => row.ppid), ...(recordedParent ? [recordedParent.pid] : []), process.pid]) {
206
+ const seen = new Set();
207
+ for (let pid = start; pid && !seen.has(pid);) {
208
+ seen.add(pid);
209
+ const row = rows.find((p) => p.pid === pid);
210
+ if (!row)
211
+ break;
212
+ protectedIds.set(pid, row.identity);
213
+ pid = row.ppid;
214
+ }
215
+ }
216
+ const targets = new Map();
217
+ const observed = ownership.identities ?? new Map();
218
+ for (const [pid, identity] of owned)
219
+ if (!observed.has(pid))
220
+ observed.set(pid, identity);
221
+ for (const row of rows)
222
+ if (!observed.has(row.pid))
223
+ observed.set(row.pid, row.identity);
224
+ let unknown = false;
225
+ // Bounds the whole sweep; each probe is bounded on its own (15 s) and never blocks the loop.
226
+ const deadline = Date.now() + 60_000;
227
+ // One cwd verdict per identity: a later round never re-probes a process it already classified.
228
+ const inspected = new Set();
229
+ const discover = async (snapshot) => {
230
+ const children = new Map();
231
+ for (const row of snapshot) {
232
+ const siblings = children.get(row.ppid) ?? [];
233
+ siblings.push(row.pid);
234
+ children.set(row.ppid, siblings);
235
+ }
236
+ const descendants = (seeds, stop = new Set()) => {
237
+ const queue = [...seeds];
238
+ for (let index = 0; index < queue.length; index++) {
239
+ for (const pid of children.get(queue[index]) ?? [])
240
+ if (!seeds.has(pid) && !stop.has(pid)) {
241
+ seeds.add(pid);
242
+ queue.push(pid);
243
+ }
244
+ }
245
+ };
246
+ const tree = new Set(snapshot.filter((row) => owned.get(row.pid) === row.identity || roots.some((r) => r.pid === row.pid && r.identity === row.identity)
247
+ || (group !== undefined && row.group === group)).map((row) => row.pid));
248
+ descendants(tree);
249
+ await resolveActiveShellIdentities();
250
+ const receipts = activeShellIdentities();
251
+ for (const [pid, identity] of receipts)
252
+ if (identity === undefined && tree.has(pid))
253
+ unknown = true;
254
+ const excluded = new Set(snapshot.filter((row) => row.pid === process.pid
255
+ || ownership.excludedGroups.includes(row.group)
256
+ || (receipts.has(row.pid) && (receipts.get(row.pid) === undefined || receipts.get(row.pid) === row.identity))).map((row) => row.pid));
257
+ // A daemon ancestor is protected, but the explicitly owned dispatch branch is not.
258
+ descendants(excluded, new Set(snapshot.filter((row) => roots.some((r) => r.pid === row.pid && r.identity === row.identity)).map((row) => row.pid)));
259
+ for (const row of snapshot)
260
+ if (protectedIds.get(row.pid) === row.identity)
261
+ excluded.add(row.pid);
262
+ const candidates = [];
263
+ for (const row of snapshot) {
264
+ if (!tree.has(row.pid) || excluded.has(row.pid))
265
+ continue;
266
+ if (observed.has(row.pid) && observed.get(row.pid) !== row.identity) {
267
+ unknown = true;
268
+ continue;
269
+ }
270
+ observed.set(row.pid, row.identity);
271
+ owned.set(row.pid, row.identity);
272
+ if (!inspected.has(row.identity)) {
273
+ inspected.add(row.identity);
274
+ candidates.push(row);
275
+ }
276
+ }
277
+ if (candidates.length && Date.now() > deadline) {
278
+ unknown = true;
279
+ return;
280
+ }
281
+ // Probe concurrently, then record verdicts in snapshot order.
282
+ const verdicts = await Promise.all(candidates.map(async (row) => {
283
+ const details = await workerReapHost.inspect(row.pid);
284
+ // A child that exited between the snapshot and this probe is a normal race, not
285
+ // unreadable ownership; only a still-live identity with no readable cwd is unknown.
286
+ // OBS-1173 add.2: a FAILED recheck (null) is unknown too — it never reads as gone.
287
+ if (!details) {
288
+ const recheck = await workerReapHost.identity(row.pid);
289
+ return recheck === row.identity || recheck === null ? "unknown" : "spared";
290
+ }
291
+ if (details.suiteParent === process.pid || excludedPaths.some((path) => below(path, details.cwd)))
292
+ return "spared";
293
+ if (!below(root, details.cwd))
294
+ return "spared";
295
+ // OBS-1173: the same race after a readable cwd — a short-lived tool child that exits between
296
+ // inspect and this recheck is gone, not unreadable. Only a live, different identity — or a
297
+ // failed probe (null, OBS-1173 add.2), which is never gone — is unknown.
298
+ const current = await workerReapHost.identity(row.pid);
299
+ if (current === row.identity)
300
+ return "target";
301
+ return current === undefined ? "spared" : "unknown";
302
+ }));
303
+ candidates.forEach((row, index) => {
304
+ if (verdicts[index] === "unknown")
305
+ unknown = true;
306
+ if (verdicts[index] !== "target")
307
+ return;
308
+ targets.set(row.pid, row.identity);
309
+ if (row.group !== group && !ownership.strays.includes(row.pid))
310
+ ownership.strays.push(row.pid);
311
+ });
312
+ };
313
+ await discover(rows); // capture detached ancestry before killing its parents
314
+ // A protected/foreign group member rules out a group signal. Signal only proven identities.
315
+ const members = group === undefined ? [] : rows.filter((row) => row.group === group);
316
+ if (group !== undefined && members.length && members.every((row) => targets.get(row.pid) === row.identity)
317
+ && (await Promise.all(members.map((row) => workerReapHost.identity(row.pid)))).every((identity, index) => identity === members[index].identity)) {
318
+ try {
319
+ workerReapHost.kill(-group);
320
+ }
321
+ catch (error) {
322
+ if (error.code !== "ESRCH")
323
+ unknown = true;
324
+ }
325
+ }
326
+ let survivors = [];
327
+ if (targets.size === 0)
328
+ return unknown ? null : [];
329
+ for (let probe = 0; probe < 5; probe++) {
330
+ // Revalidate immediately before each signal, including after any slow cwd probe.
331
+ await Promise.all([...targets].map(async ([pid, identity]) => {
332
+ const current = await workerReapHost.identity(pid);
333
+ if (current !== identity) {
334
+ if (current !== undefined)
335
+ unknown = true;
336
+ return;
337
+ }
338
+ try {
339
+ workerReapHost.kill(pid);
340
+ }
341
+ catch (error) {
342
+ if (error.code !== "ESRCH")
343
+ unknown = true;
344
+ }
345
+ }));
346
+ await new Promise((resolve) => setTimeout(resolve, probe === 0 ? 10 : 50));
347
+ rows = await workerReapHost.snapshot();
348
+ if (!rows || Date.now() > deadline)
349
+ return null;
350
+ await discover(rows);
351
+ survivors = rows.filter((row) => targets.get(row.pid) === row.identity).map((row) => row.pid);
352
+ if (!survivors.length)
353
+ return unknown ? null : [];
354
+ }
355
+ return unknown ? null : survivors;
356
+ }
100
357
  let linuxClockTickMs;
101
358
  function linuxProcessCpuMs(pid, cwd) {
102
359
  if (!existsSync("/proc/self/stat"))
@@ -126,7 +383,7 @@ function linuxProcessCpuMs(pid, cwd) {
126
383
  // Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
127
384
  // finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
128
385
  // snapshot is undefined because missing evidence can never prove inactivity.
129
- async function workerTreeCpuSnapshot(marker, cwd, group) {
386
+ async function workerTreeCpuSnapshot(marker, cwd, group, identities) {
130
387
  const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
131
388
  if (snapshot.code !== 0)
132
389
  return undefined;
@@ -142,6 +399,8 @@ async function workerTreeCpuSnapshot(marker, cwd, group) {
142
399
  if (rows.length === 0)
143
400
  return undefined;
144
401
  const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
402
+ // CPU presentation matching is intentionally broader than authority to signal a process.
403
+ const owned = new Set(rows.filter((p) => p.cmd === `bash ${marker}` || p.cmd === `/bin/bash ${marker}`).map((p) => p.pid));
145
404
  if (group !== undefined) {
146
405
  const groups = await shGit("ps -Awwo pid=,pgid=", cwd, 15_000);
147
406
  if (groups.code !== 0)
@@ -156,6 +415,10 @@ async function workerTreeCpuSnapshot(marker, cwd, group) {
156
415
  for (let grew = true; grew;) {
157
416
  grew = false;
158
417
  for (const p of rows) {
418
+ if (!owned.has(p.pid) && owned.has(p.ppid)) {
419
+ owned.add(p.pid);
420
+ grew = true;
421
+ }
159
422
  if (!tree.has(p.pid) && tree.has(p.ppid)) {
160
423
  tree.add(p.pid);
161
424
  grew = true;
@@ -167,6 +430,11 @@ async function workerTreeCpuSnapshot(marker, cwd, group) {
167
430
  for (const p of rows) {
168
431
  if (!tree.has(p.pid))
169
432
  continue;
433
+ if (identities && owned.has(p.pid) && !identities.has(Number(p.pid))) {
434
+ const identity = await processIdentity(Number(p.pid));
435
+ if (identity)
436
+ identities.set(Number(p.pid), identity);
437
+ }
170
438
  const cpu = await linuxProcessCpuMs(p.pid, cwd);
171
439
  precise.set(p.pid, cpu?.ms ?? p.cpuMs);
172
440
  if (cpu !== undefined)
@@ -197,6 +465,7 @@ export class WorkerTreeCpuAccountant {
197
465
  marker;
198
466
  cwd;
199
467
  group;
468
+ identities;
200
469
  active = false;
201
470
  loop;
202
471
  live = new Map();
@@ -204,13 +473,14 @@ export class WorkerTreeCpuAccountant {
204
473
  gaps = 0;
205
474
  consecutiveGaps = 0;
206
475
  latest;
207
- constructor(marker, cwd, group) {
476
+ constructor(marker, cwd, group, identities) {
208
477
  this.marker = marker;
209
478
  this.cwd = cwd;
210
479
  this.group = group;
480
+ this.identities = identities;
211
481
  }
212
482
  async sample() {
213
- const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd, this.group?.());
483
+ const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd, this.group?.(), this.identities);
214
484
  if (snapshot === undefined) {
215
485
  this.gaps++;
216
486
  this.live.clear();
@@ -48,6 +48,7 @@ export interface SupervisionArm {
48
48
  export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
49
49
  /** Explicit arming acknowledges the current marker without deleting it. */
50
50
  export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
51
+ export declare const supervisionPresencePath: (repoRoot: string, tier: SupervisionTier, id: string) => string;
51
52
  export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
52
53
  arm?: SupervisionArm;
53
54
  armId?: string;
@@ -69,7 +70,10 @@ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTie
69
70
  export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
70
71
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
71
72
  export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
72
- /** A board's durable identity is independent of its short title and of other observers. */
73
+ /** A board's durable identity is independent of its short title and of other observers.
74
+ * `handle` is the terminal the claiming observer reports running in, when its host exports one: the
75
+ * one fact that names the board's pane when the placing split's receipt never arrived (OBS-1172).
76
+ * `retired` marks a tombstone a driver keeps in the record's place; it is never answered again. */
73
77
  export interface WatchBoardOwner {
74
78
  repo: string;
75
79
  runId: string;
@@ -80,6 +84,8 @@ export interface WatchBoardOwner {
80
84
  token: string;
81
85
  pid?: number;
82
86
  armId?: string;
87
+ handle?: string;
88
+ retired?: true;
83
89
  }
84
90
  export declare const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
85
91
  export declare function readWatchBoard(repo: string, runId: string): WatchBoardOwner | undefined;
@@ -154,7 +154,7 @@ const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRo
154
154
  // Freshness decides presence, never a process table (SUP-02): a watcher that is killed cannot remove
155
155
  // its own file, and an unremoved file ages past the same ceiling a beat does and stops counting.
156
156
  const presencePrefix = (tier) => `${tier}.live.`;
157
- const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
157
+ export const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
158
158
  /** Every presence file on this tier, by name. Missing directory ⇒ nobody is present. */
159
159
  const presenceNames = (repoRoot, tier) => {
160
160
  try {
@@ -567,6 +567,8 @@ export const supervisionText = (tiers, divider = " · ") => `supervision: ${tier
567
567
  `${t.clearOwedSince ? ` CLEAR-OWED since ${t.clearOwedSince}` : ""}` +
568
568
  `${t.clearOwedUnreadable ? " CLEAR-OWED unreadable" : ""}`).join(divider)}`;
569
569
  export const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
570
+ /** Orca exports each terminal's own handle into the processes it runs. */
571
+ const TERMINAL_HANDLE_ENV = "ORCA_TERMINAL_HANDLE";
570
572
  const boardPath = (repo, runId) => join(supervisionDir(repo), `watch-board.${parseRunId(runId)}.json`);
571
573
  const boardMessagePath = (owner, kind) => {
572
574
  if (!/^[a-f0-9-]{36}$/.test(owner.token))
@@ -642,7 +644,8 @@ export function observeNamedRun(repo, runId, env = process.env) {
642
644
  const candidate = readWatchBoard(repo, runId);
643
645
  if (!candidate || candidate.token !== token || candidate.pid !== undefined)
644
646
  throw new Error("watch ownership unknown; refusing narrator acknowledgement");
645
- owner = { ...candidate, pid: process.pid, armId: armed.id };
647
+ const handle = env[TERMINAL_HANDLE_ENV]?.trim();
648
+ owner = { ...candidate, pid: process.pid, armId: armed.id, ...(handle ? { handle } : {}) };
646
649
  atomicRecord(boardPath(repo, runId), owner);
647
650
  }
648
651
  }
@@ -1,5 +1,5 @@
1
1
  import { type RunGraph } from "../../graph/schema.js";
2
- import type { OperatorSnapshot } from "../../run/operator-state.js";
2
+ import { type OperatorSnapshot } from "../../run/operator-state.js";
3
3
  import type { AttemptHarvest, TaskRow } from "./derive.js";
4
4
  /** The prototype's palette (tasks-redesign.mjs:24-30): xterm-256 indices, stated once. */
5
5
  export declare const BOARD_PALETTE: {
@@ -1,4 +1,5 @@
1
1
  import { GATE_NAMES } from "../../graph/schema.js";
2
+ import { authorsNote } from "../../run/operator-state.js";
2
3
  import { cellWidth, sliceCells } from "./width.js";
3
4
  /* ------------------------------------------------------------------------ */
4
5
  /* BD-1 — the approved task board (RULING-231-19 §2, OBS-965 add.2). */
@@ -46,9 +47,9 @@ const AREAS = [
46
47
  ["FLEET", [/^src\/(route|config|adapters)\//]],
47
48
  ["RUN", [/^src\/(run|drivers|eval)\//]],
48
49
  ["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)/]],
50
+ ["UI", [/^src\/(cli|tui|report)\//, /^src\/brand\.ts/, /^tests\/cockpit\//]],
51
+ ["DOCS", [/^(docs|skills)\//, /^\.claude\/skills\//]],
52
+ ["REPO", [/^(scripts|schema)\//, /^\.github\//, /^tests\/(lint|repo|helpers|fixtures)\//, /^(vitest\.config|package\.json|tsconfig)/]],
52
53
  ];
53
54
  export const BOARD_AREA_ORDER = AREAS.map(([n]) => n);
54
55
  const expandBraces = (p) => {
@@ -113,14 +114,15 @@ export function boardFrame(input, width) {
113
114
  // recompile) lends nothing — no titles, deps, declarations or denominator — so the rows are the
114
115
  // journal's tasks alone and every gate reads as declared.
115
116
  const graphTasks = input.graph?.tasks && s.comparable ? input.graph.tasks : s.tasks.map((t) => ({ id: t.id, title: t.title ?? "", deps: [], files: [], gates: undefined }));
116
- const uncomparable = input.graph !== undefined && !s.comparable;
117
+ const uncomparable = !s.comparable;
118
+ const graphStatus = s.graphAvailability?.status ?? (input.graph ? "readable" : "absent");
117
119
  const doneIds = new Set(s.tasks.filter(isDone).map((t) => t.id));
118
120
  const t0 = s.firstEventAt ? Date.parse(s.firstEventAt) : input.now;
119
121
  const t1 = s.lastEventAt ? Date.parse(s.lastEventAt) : input.now;
120
122
  const wide = W >= 118;
121
123
  const top = [];
122
124
  top.push(`${bg(16, 2)(" tickmarkr ")} ${bold(input.runId)} ${chrome("│")} ` +
123
- `${pass(`${doneIds.size}/${graphTasks.length} done`)} ` +
125
+ `${pass(`${doneIds.size}/${s.comparable ? graphTasks.length : "?"} done`)} ` +
124
126
  (wide
125
127
  ? `${chrome("│")} ${warn(`${s.resumes} restarts`)} ${chrome("│")} ${fail(`${s.escalations} escalations`)} `
126
128
  : `${chrome("│")} ${fail(`${s.escalations} esc`)} `) +
@@ -145,7 +147,7 @@ export function boardFrame(input, width) {
145
147
  top.push(` ${accent("▌")} ${bold("NOW")} ${now}`);
146
148
  top.push("");
147
149
  }
148
- 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`)}` +
150
+ top.push(` ${accent("▌")} ${bold("TASKS")} ${mute(uncomparable ? `${doneIds.size} journal tasks merged · ${graphStatus === "readable" ? "graph not comparable" : `graph ${graphStatus}${s.graphAvailability?.error ? `: ${s.graphAvailability.error}` : ""}`}` : `${doneIds.size} of ${graphTasks.length} merged in this graph`)}` +
149
151
  (wide ? mute(" · rows in dispatch order · gates left→right in declaration order") : ""));
150
152
  top.push("");
151
153
  // ── aligned gate header: each label truncated to the cell it owns (prototype :382-394) ──
@@ -170,7 +172,7 @@ export function boardFrame(input, width) {
170
172
  };
171
173
  const strip = BOARD_GATES.map((k) => {
172
174
  const v = cell(k);
173
- return pad(v === true ? pass("✔") : v === false ? fail("✖") : !declared.has(k) ? faint("─") : faint("·"), BOARD_CELL);
175
+ return pad(v === true ? pass("✔") : v === false ? fail("✖") : t?.gates[k]?.state === "queued" ? warn("Q") : t?.gates[k]?.state === "running" ? accent("R") : t?.gates[k]?.state === "unknown" ? warn("?") : t?.gates[k]?.state === "disabled" ? faint("D") : !declared.has(k) ? faint("─") : faint("·"), BOARD_CELL);
174
176
  }).join("");
175
177
  const unrun = BOARD_GATES.filter((k) => declared.has(k) && cell(k) === undefined).length;
176
178
  const failedGates = BOARD_GATES.filter((k) => cell(k) === false);
@@ -204,21 +206,25 @@ export function boardFrame(input, width) {
204
206
  parks >= 3 ? park(`◍ parked ×${parks}`) : "",
205
207
  reviews > 2 ? warn(`↻ ${reviews} review rounds`) : "",
206
208
  failedGates.length ? fail(`✖ ${failedGates.join(",")}`) : "",
207
- !attempts
208
- ? (() => {
209
- const blockers = depIds.filter((d) => !doneIds.has(d));
210
- if (!blockers.length)
211
- return faint("queued");
212
- const room = Math.max(10, W - BOARD_FIXED - LBL - CHAN);
213
- let shown = blockers, dropped = 0;
214
- while (shown.length > 1 && `waiting on ${shown.join(", ")}`.length + (dropped ? 5 : 0) > room) {
215
- shown = shown.slice(0, -1);
216
- dropped = blockers.length - shown.length;
217
- }
218
- return faint(`waiting on ${shown.join(", ")}${dropped ? ` +${dropped}` : ""}`);
219
- })()
220
- : unrun === declared.size ? faint("in flight")
221
- : unrun > 2 ? faint(`${declared.size - unrun}/${declared.size} gates run`) : "",
209
+ // Merged authors beside the last-dispatched channel; equal-to-channel says nothing new.
210
+ authorsNote(t?.authors, t?.channel) ? faint(authorsNote(t?.authors, t?.channel)) : "",
211
+ t?.gateActivity
212
+ ? `${t.gateActivity.state} · ${t.gateActivity.gate ?? "gate unknown"}${t.gateActivity.reason ? ` · ${t.gateActivity.reason}` : ""}${t.gateActivity.count === undefined ? "" : ` (${t.gateActivity.count} suites)`}`
213
+ : !attempts
214
+ ? (() => {
215
+ const blockers = depIds.filter((d) => !doneIds.has(d));
216
+ if (!blockers.length)
217
+ return faint("queued");
218
+ const room = Math.max(10, W - BOARD_FIXED - LBL - CHAN);
219
+ let shown = blockers, dropped = 0;
220
+ while (shown.length > 1 && `waiting on ${shown.join(", ")}`.length + (dropped ? 5 : 0) > room) {
221
+ shown = shown.slice(0, -1);
222
+ dropped = blockers.length - shown.length;
223
+ }
224
+ return faint(`waiting on ${shown.join(", ")}${dropped ? ` +${dropped}` : ""}`);
225
+ })()
226
+ : unrun === declared.size ? faint("in flight")
227
+ : unrun > 2 ? faint(`${declared.size - unrun}/${declared.size} gates run`) : "",
222
228
  ].filter(Boolean);
223
229
  rows.push({
224
230
  id: g.id,
@@ -237,6 +243,8 @@ export function boardFrame(input, width) {
237
243
  bottom.push(` ${pad("", 8)}${fail("UNMAPPED")} ${mute([...unmappedPaths].slice(0, 3).join(" "))}${unmappedPaths.size > 3 ? mute(` +${unmappedPaths.size - 3}`) : ""}`);
238
244
  bottom.push(` ${faint("gates")} ${BOARD_GATES.map((k) => `${body(k.slice(0, BOARD_CELL - 1))} ${mute(k)}`).join(" ")}`);
239
245
  bottom.push(` ${faint(" ")} ${pass("✔")} ${mute("passed")} ${fail("✖")} ${mute("failed")} ${faint("·")} ${mute("pending")} ${faint("─")} ${mute("not declared (acceptance and review are the optional two)")}`);
246
+ if (s.tasks.some(t => Object.values(t.gates).some(cell => ["queued", "running", "unknown", "disabled"].includes(cell.state))))
247
+ bottom.push(` ${accent("R")} running ${warn("Q")} queued ${warn("?")} unknown ${faint("D")} disabled`);
240
248
  bottom.push("");
241
249
  // the gap is stated, never silently skipped — derived, so it cannot go stale
242
250
  const retired = s.tasks.map((t) => t.id).filter((id) => !graphTasks.some((g) => g.id === id)).sort();
@@ -25,6 +25,10 @@ export interface DecisionCommand {
25
25
  readonly reason?: string;
26
26
  /** `--review-rounds`: the review-round ceiling the release carries. */
27
27
  readonly reviewRounds?: number;
28
+ /** OBS-1178: `--park <line>@<ts>` — the displayed park this decision binds to. */
29
+ readonly park?: string;
30
+ /** OBS-1178: `--gate <gate>` — the failed gate a waive binds to. */
31
+ readonly gate?: string;
28
32
  }
29
33
  /** The exact argv the production command receives — built only from the closed verb set. */
30
34
  export declare function decisionArgv(command: DecisionCommand, { runId, by }: {
@@ -57,13 +61,11 @@ export declare const journalFileFor: (cwd: string, runId: string) => string;
57
61
  * the named verb is outside the park's table, so a confirm inset can never promise a decision the
58
62
  * command would refuse. The enactor is read from the run lock now, and read again at the receipt.
59
63
  */
60
- export declare function previewDecision(command: DecisionCommand, { cwd, runId, by }: {
64
+ export declare function previewDecision(named: DecisionCommand, { cwd, runId, by }: {
61
65
  cwd: string;
62
66
  runId: string;
63
67
  by: string;
64
68
  }): DecisionPreviewResult;
65
- /** Who enacts it — the three lock states, each stated by the command's own sentence. */
66
- export declare function enactmentLines(run: ApprovalRunOwner, disposition: ApprovalDisposition): string[];
67
69
  /** The confirm inset: run/task, the newest park line, actor/reason, exact argv, consequence, enactor. */
68
70
  export declare function decisionConfirmLines(preview: DecisionPreview): readonly string[];
69
71
  /** The task-approved row the file gained, located by its physical line. */
@@ -135,8 +137,7 @@ export interface DecisionKeyResult {
135
137
  /**
136
138
  * The reducer never writes and never reads a file; it names commands. `a` opens the menu for the
137
139
  * selected park (a diagnostic park opens a menu with no verbs, stating why); ↑↓ move; Enter picks a
138
- * verb — which OPENS the confirm inset, never confirms; `y` on a confirm names the write; `n`/Esc
139
- * cancel the deepest layer. Enter on a confirm is inert by construction.
140
+ * verb opens the confirm inset; `y` names the write; `n`/Esc cancel the deepest layer. Enter on a confirm is inert.
140
141
  */
141
142
  export declare function applyDecisionKey(session: DecisionSession, event: DecisionKeyEvent, selected: RunDecision | undefined): DecisionKeyResult;
142
143
  /** Store the preview a picked verb produced — or its refusal as the notice. */
@@ -145,3 +146,5 @@ export declare function withDecisionPreview(session: DecisionSession, result: De
145
146
  export declare function withDecisionReceipt(session: DecisionSession, receipt: DecisionReceipt): DecisionSession;
146
147
  /** The verbs the keybar may advertise for the current layer — nothing the flow cannot honour. */
147
148
  export declare function decisionKeybar(session: DecisionSession, selected: RunDecision | undefined): string;
149
+ /** Who enacts it — the three lock states, each stated by the command's own sentence. */
150
+ export declare function enactmentLines(run: ApprovalRunOwner, disposition: ApprovalDisposition): string[];