tickmarkr 2.5.9 → 2.6.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 (67) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.d.ts +2 -1
  3. package/dist/adapters/prompt.js +11 -1
  4. package/dist/adapters/types.d.ts +4 -0
  5. package/dist/adapters/types.js +10 -0
  6. package/dist/cli/commands/fleet.js +26 -1
  7. package/dist/cli/commands/init.js +1 -1
  8. package/dist/cli/commands/plan.js +7 -3
  9. package/dist/cli/commands/report.js +11 -1
  10. package/dist/cli/commands/status.js +13 -3
  11. package/dist/cli/commands/verify.js +2 -0
  12. package/dist/cli/help.d.ts +2 -0
  13. package/dist/cli/help.js +2 -0
  14. package/dist/compile/native.js +39 -4
  15. package/dist/drivers/orca.d.ts +1 -1
  16. package/dist/drivers/orca.js +27 -5
  17. package/dist/gates/baseline.d.ts +2 -0
  18. package/dist/gates/baseline.js +9 -1
  19. package/dist/gates/cache.d.ts +11 -1
  20. package/dist/gates/cache.js +35 -15
  21. package/dist/gates/llm.js +4 -1
  22. package/dist/gates/review.d.ts +2 -3
  23. package/dist/gates/review.js +28 -35
  24. package/dist/gates/run-gates.d.ts +4 -0
  25. package/dist/gates/run-gates.js +17 -4
  26. package/dist/gates/test-manifest.d.ts +17 -0
  27. package/dist/gates/test-manifest.js +109 -9
  28. package/dist/gates/test-reporter.js +4 -0
  29. package/dist/graph/graph.d.ts +7 -3
  30. package/dist/graph/graph.js +21 -5
  31. package/dist/graph/schema.d.ts +2 -0
  32. package/dist/graph/schema.js +2 -0
  33. package/dist/route/preference.d.ts +1 -1
  34. package/dist/route/preference.js +10 -39
  35. package/dist/route/role-pick.d.ts +16 -0
  36. package/dist/route/role-pick.js +15 -0
  37. package/dist/route/router.d.ts +14 -0
  38. package/dist/route/router.js +9 -1
  39. package/dist/run/consult.js +5 -9
  40. package/dist/run/daemon.d.ts +16 -1
  41. package/dist/run/daemon.js +554 -77
  42. package/dist/run/git.d.ts +46 -1
  43. package/dist/run/git.js +138 -6
  44. package/dist/run/host-health.d.ts +20 -0
  45. package/dist/run/host-health.js +64 -0
  46. package/dist/run/journal.d.ts +1 -1
  47. package/dist/run/journal.js +51 -6
  48. package/dist/run/merge.d.ts +1 -1
  49. package/dist/run/merge.js +30 -6
  50. package/dist/run/operator-state.d.ts +24 -2
  51. package/dist/run/operator-state.js +41 -5
  52. package/dist/run/stall.d.ts +38 -2
  53. package/dist/run/stall.js +276 -6
  54. package/dist/tui/cockpit/board.d.ts +1 -1
  55. package/dist/tui/cockpit/board.js +27 -19
  56. package/dist/tui/cockpit/derive.d.ts +2 -0
  57. package/dist/tui/cockpit/derive.js +4 -0
  58. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  59. package/dist/tui/cockpit/live-runtime.js +37 -3
  60. package/dist/tui/cockpit/live-store.d.ts +3 -0
  61. package/dist/tui/cockpit/live-store.js +31 -8
  62. package/dist/tui/cockpit/run-cockpit.js +2 -1
  63. package/dist/tui/cockpit/run-view.d.ts +2 -4
  64. package/dist/tui/cockpit/run-view.js +9 -8
  65. package/package.json +1 -1
  66. package/schema/rungraph.schema.json +7 -0
  67. package/skills/tickmarkr-overseer/SKILL.md +76 -14
@@ -5,6 +5,12 @@ const bucketNames = ["failed", "human", "blocked", "pending"];
5
5
  const strings = (value) => Array.isArray(value) && value.every(v => typeof v === "string") ? [...value] : undefined;
6
6
  const identity = ({ source, line, id, generation }) => ({ source, line, id, generation });
7
7
  const emptyGates = () => Object.fromEntries(GATE_NAMES.map(g => [g, { state: "not-run" }]));
8
+ /** Author channels recorded on a task-done row. A row written before authors were recorded carries none and
9
+ * projects nothing (never the dispatch); unattributed preserved work arrives as an explicit "unknown" entry. */
10
+ export const doneAuthors = (data) => strings(data.authors);
11
+ /** The note a status row or board row shows beside the last-dispatched channel when the merged
12
+ * authors are not exactly that channel (a carry-only seat, a mixed subject, or legacy unknown). */
13
+ export const authorsNote = (authors, channel) => authors === undefined || (authors.length === 1 && authors[0] === channel) ? undefined : `authors ${authors.join("+")}`;
8
14
  /** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
9
15
  export class OperatorStateFold {
10
16
  tasks = new Map();
@@ -75,6 +81,7 @@ export class OperatorStateFold {
75
81
  t.dispatches++;
76
82
  t.parkKind = undefined;
77
83
  t.gates = emptyGates();
84
+ t.gateActivity = undefined;
78
85
  t.attempt = typeof e.data.attempt === "number" ? e.data.attempt : undefined;
79
86
  // These are recorded values only: no guessed directory or universal silence threshold.
80
87
  t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
@@ -84,8 +91,10 @@ export class OperatorStateFold {
84
91
  if (a && typeof a.adapter === "string" && typeof a.model === "string")
85
92
  t.channel = `${a.adapter}:${a.model}`;
86
93
  }
87
- if (e.event === "task-done")
94
+ if (e.event === "task-done") {
88
95
  t.state = "completed";
96
+ t.authors = doneAuthors(e.data);
97
+ }
89
98
  if (e.event === "merge") {
90
99
  t.state = "merged";
91
100
  t.merged = true;
@@ -104,12 +113,39 @@ export class OperatorStateFold {
104
113
  if (e.event === "task-approved") {
105
114
  t.state = "pending";
106
115
  t.parkKind = undefined;
116
+ t.gateActivity = undefined;
107
117
  this.approved = !this.active;
118
+ if (e.data.release === "recheck")
119
+ t.gates = emptyGates();
120
+ }
121
+ if (e.event === "phase-start" && e.data.phase === "gates")
122
+ t.gateActivity = undefined;
123
+ if (e.event === "suite-wait" || e.event === "host-degraded") {
124
+ // Only this row's attribution is authority. Legacy task-only waits must not borrow a red gate.
125
+ const gate = typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate) ? e.data.gate : undefined;
126
+ t.gateActivity = { state: "queued", gate, reason: e.event, evidence: ref,
127
+ ...(typeof e.data.count === "number" ? { count: e.data.count } : {}) };
128
+ if (gate)
129
+ t.gates[gate] = { state: "queued", evidence: ref };
130
+ }
131
+ if (e.event === "suite-admitted")
132
+ t.gateActivity = undefined;
133
+ if (["task-done", "merge", "task-failed", "task-human", "task-blocked"].includes(e.event))
134
+ t.gateActivity = undefined;
135
+ const phaseGate = e.event === "phase-start" && typeof e.data.phase === "string"
136
+ ? e.data.phase.startsWith("gate:") ? e.data.phase.slice(5) : e.data.phase === "judge" ? "acceptance" : e.data.phase === "review" ? "review" : undefined
137
+ : undefined;
138
+ const gate = e.data.gate ?? phaseGate;
139
+ if ((e.event === "phase-start" && (phaseGate !== undefined || e.data.gate !== undefined)) || e.event === "gate-start" || e.event === "gate-phase-start") {
140
+ t.gateActivity = { state: "running", evidence: ref };
108
141
  }
109
- const gate = e.data.gate;
110
142
  if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
111
- if (e.event === "gate-start")
143
+ if (e.event === "gate-start" || e.event === "gate-phase-start" || e.event === "phase-start") {
112
144
  t.gates[gate] = { state: "running", evidence: ref };
145
+ t.gateActivity = { state: "running", gate, evidence: ref };
146
+ }
147
+ if (e.event === "gate-result" && t.gateActivity?.gate === gate)
148
+ t.gateActivity = undefined;
113
149
  if (e.event === "gate-result" && gate === "review")
114
150
  t.reviewRounds++;
115
151
  if (e.event === "gate-result")
@@ -126,7 +162,7 @@ export class OperatorStateFold {
126
162
  }
127
163
  return t;
128
164
  }
129
- snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true } = {}) {
165
+ snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true, graphAvailability } = {}) {
130
166
  const hash = graph && graphDefinitionHash(graph);
131
167
  const comparable = this.comparableTo(hash);
132
168
  const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
@@ -150,7 +186,7 @@ export class OperatorStateFold {
150
186
  return {
151
187
  sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
152
188
  label: this.approved ? "approved; resume required" : lifecycle,
153
- green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
189
+ green, currentTip, graphAvailability, comparable, comparison: comparable ? "matching graph" : "not comparable",
154
190
  merged,
155
191
  planned, tasks, buckets,
156
192
  gatesRan: { passed: this.passed, total: this.total }, latestRunEnd: this.end?.evidence,
@@ -13,7 +13,42 @@ export declare function resetHarvestCpuFlatMsForTests(): void;
13
13
  /** The dispatch shell records its foreground group in an attempt-unique artifact. */
14
14
  export declare function readOwnedProcessGroup(path: string): number | undefined;
15
15
  /** Never infer ownership from a task name or scan another attempt's marker when reaping. */
16
- export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string): Promise<number[] | null>;
16
+ export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string, ownership?: WorkerReapOwnership): Promise<number[] | null>;
17
+ export interface ReapProcess {
18
+ pid: number;
19
+ ppid: number;
20
+ group: number;
21
+ session: string;
22
+ identity: string;
23
+ command: string;
24
+ startedAt?: string;
25
+ }
26
+ export interface WorkerReapOwnership {
27
+ marker: string;
28
+ /** Session recorded by the dispatch shell before its children can detach. */
29
+ session?: string;
30
+ parent?: {
31
+ pid: number;
32
+ startedAt: string;
33
+ };
34
+ identities?: Map<number, string>;
35
+ /** Descendants observed while the dispatch root was still alive. */
36
+ descendants?: Map<number, string>;
37
+ excludedGroups: number[];
38
+ excludedWorktrees: string[];
39
+ /** Discovery evidence is distinct from the final survivor census. */
40
+ strays: number[];
41
+ }
42
+ /** Bounded host boundary, also used to inject unreadability and pid reuse in regressions. */
43
+ export declare const workerReapHost: {
44
+ snapshot(): Promise<ReapProcess[] | undefined>;
45
+ inspect(pid: number): Promise<{
46
+ cwd: string;
47
+ suiteParent?: number;
48
+ } | undefined>;
49
+ identity: (pid: number) => Promise<string | null | undefined>;
50
+ kill(pid: number): void;
51
+ };
17
52
  export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
18
53
  ms: number;
19
54
  resolutionMs: number;
@@ -22,6 +57,7 @@ export declare class WorkerTreeCpuAccountant {
22
57
  private marker;
23
58
  private cwd;
24
59
  private group?;
60
+ private identities?;
25
61
  private active;
26
62
  private loop;
27
63
  private live;
@@ -29,7 +65,7 @@ export declare class WorkerTreeCpuAccountant {
29
65
  private gaps;
30
66
  private consecutiveGaps;
31
67
  private latest;
32
- constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined);
68
+ constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined, identities?: Map<number, string> | undefined);
33
69
  private sample;
34
70
  start(): Promise<void>;
35
71
  read(): {
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();
@@ -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). */
@@ -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();
@@ -115,6 +115,8 @@ export type TaskRow = {
115
115
  readonly state?: TaskState;
116
116
  readonly attempts?: number;
117
117
  readonly actor?: string;
118
+ /** Distinct merged-author channels from task-done; legacy rows say ["unknown"]. Not the actor. */
119
+ readonly authors?: readonly string[];
118
120
  readonly lastEventTime?: string;
119
121
  /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
120
122
  readonly lastEventTimestamp?: string;
@@ -1,5 +1,6 @@
1
1
  // Consolidated C1/C6 readers use the incremental lifecycle contract; legacy captures keep their API.
2
2
  export { readOperatorState as deriveOperatorState } from "../../run/operator-state.js";
3
+ import { doneAuthors } from "../../run/operator-state.js";
3
4
  import { channelKey } from "../../adapters/types.js";
4
5
  import { newestPark, permittedDecisionVerbs } from "../../cli/commands/approve.js";
5
6
  import { parseOwnedName } from "../../drivers/types.js";
@@ -276,6 +277,8 @@ function deriveTasks(events, interrupted) {
276
277
  if (state) {
277
278
  const fact = task(event.taskId, index, event.ts);
278
279
  fact.state = state;
280
+ if (event.event === "task-done")
281
+ fact.authors = doneAuthors(event.data);
279
282
  // The park kind travels with the park: `task-human` records it, and the
280
283
  // next recorded state — approved, done, failed — retires it with the park
281
284
  // it named. A resolved state must not leave its badge behind (OBS-256).
@@ -641,6 +644,7 @@ function taskRows(events, tasks, graph, runId) {
641
644
  ...(state === undefined ? {} : { state }),
642
645
  ...(attempts === 0 ? {} : { attempts }),
643
646
  ...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
647
+ ...(fact?.authors === undefined ? {} : { authors: fact.authors }),
644
648
  ...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
645
649
  ...(lastTs === undefined ? {} : { lastEventTimestamp: lastTs }),
646
650
  ...(title === undefined ? {} : { title }),
@@ -22,6 +22,10 @@ export interface ShellDelivery {
22
22
  diagnostics: LiveStore["diagnostics"];
23
23
  stage: (edits: readonly string[]) => void;
24
24
  geometry: () => ShellCommit | undefined;
25
+ /** Application render commits since mount (OBS-1132: an idle board adds none). */
26
+ frames: () => number;
27
+ /** Model derivations (the App body) since mount. */
28
+ derivations: () => number;
25
29
  }
26
30
  export interface ConsolidatedOptions {
27
31
  input: NodeJS.ReadStream;