tickmarkr 2.5.6 → 2.5.7

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 (40) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +5 -5
  3. package/dist/cli/commands/status.js +95 -34
  4. package/dist/cli/commands/verify.js +6 -4
  5. package/dist/gates/baseline.d.ts +8 -3
  6. package/dist/gates/baseline.js +6 -3
  7. package/dist/gates/review.js +12 -1
  8. package/dist/gates/run-gates.d.ts +4 -1
  9. package/dist/gates/run-gates.js +65 -9
  10. package/dist/gates/test-manifest.d.ts +3 -0
  11. package/dist/gates/test-manifest.js +20 -2
  12. package/dist/gates/test-reporter.js +6 -1
  13. package/dist/graph/graph.d.ts +2 -0
  14. package/dist/graph/graph.js +5 -0
  15. package/dist/run/activity.d.ts +28 -0
  16. package/dist/run/activity.js +194 -0
  17. package/dist/run/daemon.d.ts +9 -0
  18. package/dist/run/daemon.js +200 -46
  19. package/dist/run/git.d.ts +6 -1
  20. package/dist/run/git.js +63 -11
  21. package/dist/run/journal.d.ts +20 -4
  22. package/dist/run/journal.js +60 -9
  23. package/dist/run/operator-page-summary.d.ts +56 -0
  24. package/dist/run/operator-page-summary.js +68 -0
  25. package/dist/run/operator-summary.d.ts +69 -0
  26. package/dist/run/operator-summary.js +77 -0
  27. package/dist/run/protocol.d.ts +71 -0
  28. package/dist/run/protocol.js +32 -0
  29. package/dist/tui/cockpit/board.d.ts +9 -0
  30. package/dist/tui/cockpit/board.js +10 -0
  31. package/dist/tui/cockpit/derive.d.ts +35 -0
  32. package/dist/tui/cockpit/derive.js +152 -10
  33. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  34. package/dist/tui/cockpit/evidence-view.js +42 -12
  35. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  36. package/dist/tui/cockpit/run-cockpit.js +95 -1
  37. package/dist/tui/cockpit/run-view.d.ts +37 -0
  38. package/dist/tui/cockpit/run-view.js +189 -2
  39. package/package.json +1 -1
  40. package/skills/tickmarkr-overseer/SKILL.md +55 -3
package/dist/run/git.js CHANGED
@@ -214,11 +214,43 @@ const SHELL_REAP_GRACE_MS = 2000;
214
214
  export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
215
215
  const inherited = executionSignal();
216
216
  const signal = inherited && options.signal ? AbortSignal.any([inherited, options.signal]) : inherited ?? options.signal;
217
- signal?.throwIfAborted();
218
- return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, { ...options, signal }));
217
+ let attribution;
218
+ let confirmedStart = false;
219
+ let terminal = false;
220
+ const begin = (invocation) => {
221
+ attribution = options.receiptAttribution?.(invocation);
222
+ confirmedStart = false;
223
+ terminal = false;
224
+ };
225
+ const emit = (receipt) => {
226
+ if (terminal)
227
+ return;
228
+ if (receipt.outcome === "started")
229
+ confirmedStart = true;
230
+ else
231
+ terminal = true;
232
+ // Observational callbacks must not change process cleanup, retries or cancellation.
233
+ try {
234
+ options.onReceipt?.({ ...receipt, confirmedStart, ...(attribution ? { attribution: { ...attribution } } : {}) });
235
+ }
236
+ catch { /* receipt sinks are observational */ }
237
+ };
238
+ const checkAbort = () => {
239
+ if (signal?.aborted)
240
+ emit({ outcome: "cancelled", exitCode: null, signal: null });
241
+ signal?.throwIfAborted();
242
+ };
243
+ begin(1);
244
+ checkAbort();
245
+ return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, { ...options, signal }, { begin, emit, checkAbort }))
246
+ .catch((error) => {
247
+ if (signal?.aborted)
248
+ emit({ outcome: "cancelled", exitCode: null, signal: null });
249
+ throw error;
250
+ });
219
251
  }
220
- function executeShell(cmd, cwd, timeoutMs, login, options) {
221
- options.signal?.throwIfAborted();
252
+ function executeShell(cmd, cwd, timeoutMs, login, options, observation) {
253
+ observation.checkAbort();
222
254
  // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
223
255
  // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
224
256
  // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
@@ -245,13 +277,22 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
245
277
  // detached: bash gets its own process group so a timeout can kill the whole tree —
246
278
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
247
279
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
248
- const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
280
+ let p;
281
+ try {
282
+ p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
283
+ }
284
+ catch (error) {
285
+ observation.emit({ outcome: "spawn-failed", exitCode: null, signal: null, error: String(error) });
286
+ throw error;
287
+ }
249
288
  let stdout = "", stderr = "";
250
289
  const stdoutDecoder = new StringDecoder("utf8");
251
290
  const stderrDecoder = new StringDecoder("utf8");
252
291
  let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
253
292
  let reapError, exitedCode;
254
293
  let signalExit = false;
294
+ let exitSignal = null;
295
+ let spawnError;
255
296
  let reapTimer;
256
297
  let drainTimer;
257
298
  const finish = (code, err) => {
@@ -264,6 +305,12 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
264
305
  options.signal?.removeEventListener("abort", abort);
265
306
  stdout += stdoutDecoder.end();
266
307
  stderr += stderrDecoder.end();
308
+ observation.emit({
309
+ outcome: options.signal?.aborted ? "cancelled" : timedOut ? "timed-out" : spawnError ? "spawn-failed" : "completed",
310
+ pid: p.pid, exitCode: signalExit || spawnError ? null : code,
311
+ signal: exitSignal, ...(spawnError ? { error: spawnError } : {}),
312
+ durationMs: Date.now() - startedAt,
313
+ });
267
314
  resolve({
268
315
  code,
269
316
  ...(signalExit ? { signalExit: true } : {}),
@@ -316,7 +363,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
316
363
  options.onSpawn?.(p.pid);
317
364
  if (options.signal?.aborted)
318
365
  abort();
319
- p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
366
+ p.on("spawn", () => { started = true; observation.emit({ outcome: "started", pid: p.pid }); }); // the command exists from here on — never retryable past it
320
367
  // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
321
368
  // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
322
369
  // kernel-chosen boundaries. A deterministic fixture proves this decoder correct rather than
@@ -333,21 +380,24 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
333
380
  stderr += stderrDecoder.write(d);
334
381
  });
335
382
  p.on("error", (e) => {
383
+ spawnError = String(e);
336
384
  if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
337
385
  done = true;
338
386
  clearTimeout(timer);
339
387
  options.signal?.removeEventListener("abort", abort);
388
+ observation.emit({ outcome: "spawn-failed", exitCode: null, signal: null, error: String(e), durationMs: Date.now() - startedAt });
340
389
  resolve({ refused: e });
341
390
  return;
342
391
  }
343
392
  finish(127, String(e));
344
393
  });
345
- p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
394
+ p.on("close", (code, signal) => { signalExit = code === null; exitSignal = signal; finish(code ?? 1); });
346
395
  // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
347
396
  // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
348
397
  // cannot hold this promise until the command ceiling. A real timeout wins first and is never
349
398
  // reclassified as a grace reap.
350
- p.on("exit", (code) => {
399
+ p.on("exit", (code, signal) => {
400
+ exitSignal = signal;
351
401
  signalExit = code === null;
352
402
  exitedCode = code ?? 1;
353
403
  // Allow a short pipe drain after killing, bounded even for an escaped descendant.
@@ -373,7 +423,9 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
373
423
  return (async () => {
374
424
  const startedAt = Date.now();
375
425
  for (let n = 1;; n++) {
376
- options.signal?.throwIfAborted();
426
+ if (n > 1)
427
+ observation.begin(n);
428
+ observation.checkAbort();
377
429
  const r = await attempt();
378
430
  if (!("refused" in r))
379
431
  return r;
@@ -386,8 +438,8 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
386
438
  }
387
439
  })();
388
440
  }
389
- export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
390
- return shell(cmd, cwd, timeoutMs, true);
441
+ export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, options = {}) {
442
+ return shell(cmd, cwd, timeoutMs, true, options);
391
443
  }
392
444
  // Git plumbing never needs an operator profile; skip login-shell startup and its side effects.
393
445
  export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
@@ -144,8 +144,11 @@ export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
144
144
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
145
145
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
146
146
  * task-dispatch meant `--retry-failed` after exactly that death rebuilt the prompt without the gate
147
- * failures OR the delivery failure that preceded it. `task-approved` also clears (an operator approval
148
- * retires the findings it settled — the uphold case re-derives its own brief separately).
147
+ * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
148
+ * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
149
+ * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
150
+ * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
151
+ * because every approval erased exactly the finding the fresh attempt was funded to fix.
149
152
  */
150
153
  export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
151
154
  /**
@@ -300,6 +303,13 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
300
303
  signalQuality: number;
301
304
  } & Record<string, unknown>;
302
305
  export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
306
+ /**
307
+ * OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
308
+ * daemon writes it on the run-resume row; every replay that runs inside that engagement from another
309
+ * process (the approve CLI) reads it back here. Without it the first in-run approval after a released
310
+ * launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
311
+ */
312
+ export declare function engagementReleased(events: JournalEvent[]): boolean;
303
313
  /** An approval is the durable authority; graph.json is only its materialized projection. */
304
314
  export declare const ScopeAmendmentSchema: z.ZodObject<{
305
315
  from: z.ZodString;
@@ -307,10 +317,16 @@ export declare const ScopeAmendmentSchema: z.ZodObject<{
307
317
  beforeFiles: z.ZodArray<z.ZodString>;
308
318
  files: z.ZodArray<z.ZodString>;
309
319
  parkLine: z.ZodNumber;
320
+ definition: z.ZodOptional<z.ZodString>;
310
321
  }, z.core.$strip>;
311
- export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
322
+ /**
323
+ * `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
324
+ * the definition fingerprint the amendment was granted against when the row itself carries none (rows
325
+ * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
326
+ */
327
+ export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[], release?: boolean, approvedDefinitions?: ReadonlyMap<string, string>): RunGraph;
312
328
  /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
313
- export declare function applyScopeAmendments(graph: RunGraph, journal: Journal, auditReplay?: boolean): RunGraph;
329
+ export declare function applyScopeAmendments(graph: RunGraph, journal: Journal, auditReplay?: boolean, release?: boolean): RunGraph;
314
330
  export type EngagementCompare = {
315
331
  comparable: true;
316
332
  recorded: string;
@@ -4,7 +4,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
7
- import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
7
+ import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
8
8
  import { GATE_NAMES, TIERS } from "../graph/schema.js";
9
9
  import { channelRouteIdentity } from "../route/preference.js";
10
10
  import { buildProfile, classify } from "../route/profile.js";
@@ -656,16 +656,25 @@ function decisionForNextDispatch(events, taskId, event) {
656
656
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
657
657
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
658
658
  * task-dispatch meant `--retry-failed` after exactly that death rebuilt the prompt without the gate
659
- * failures OR the delivery failure that preceded it. `task-approved` also clears (an operator approval
660
- * retires the findings it settled — the uphold case re-derives its own brief separately).
659
+ * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
660
+ * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
661
+ * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
662
+ * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
663
+ * because every approval erased exactly the finding the fresh attempt was funded to fix.
661
664
  */
662
665
  export function journaledFailureBrief(events, taskId) {
663
666
  let rows = [];
664
667
  for (const e of events) {
665
668
  if (e.taskId !== taskId)
666
669
  continue;
667
- if (e.event === "worker-launch" || e.event === "task-approved")
670
+ if (e.event === "worker-launch")
668
671
  rows = [];
672
+ else if (e.event === "task-approved") {
673
+ if (e.data.release === GATE_SATISFIED_RELEASE)
674
+ rows = [];
675
+ else if (typeof e.data.reason === "string" && e.data.reason.trim())
676
+ rows.push(`approval: ${e.data.reason.trim()}`);
677
+ }
669
678
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
670
679
  && typeof e.data.details === "string")
671
680
  rows.push(`${e.data.gate}: ${e.data.details}`);
@@ -983,12 +992,37 @@ export function recordedGraphDefinitionHash(events) {
983
992
  }
984
993
  return recorded ?? undefined;
985
994
  }
995
+ /**
996
+ * OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
997
+ * daemon writes it on the run-resume row; every replay that runs inside that engagement from another
998
+ * process (the approve CLI) reads it back here. Without it the first in-run approval after a released
999
+ * launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
1000
+ */
1001
+ export function engagementReleased(events) {
1002
+ const engagement = events.filter((e) => e.event === "run-start" || e.event === "run-resume").at(-1);
1003
+ return engagement?.data.graphChanged === true;
1004
+ }
1005
+ /** The run's own graph.json (copied by the daemon at every start/resume) — the definitions the last engagement ran on. */
1006
+ function snapshotDefinitions(journal) {
1007
+ const path = join(journal.dir, "graph.json");
1008
+ if (!existsSync(path))
1009
+ return new Map();
1010
+ const snapshot = JSON.parse(readFileSync(path, "utf8"));
1011
+ return new Map((snapshot.tasks ?? []).map((t) => [t.id, taskDefinitionFingerprint(t)]));
1012
+ }
986
1013
  /** An approval is the durable authority; graph.json is only its materialized projection. */
987
1014
  export const ScopeAmendmentSchema = z.object({
988
1015
  from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
989
1016
  parkLine: z.number().int().positive(),
1017
+ /** OBS-1073: the amended task's definition minus files[] at approval; absent on rows older than v2.5.7. */
1018
+ definition: z.string().optional(),
990
1019
  });
991
- export function replayScopeAmendments(graph, events) {
1020
+ /**
1021
+ * `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
1022
+ * the definition fingerprint the amendment was granted against when the row itself carries none (rows
1023
+ * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
1024
+ */
1025
+ export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
992
1026
  const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
993
1027
  .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
994
1028
  if (!amendments.length)
@@ -1007,20 +1041,37 @@ export function replayScopeAmendments(graph, events) {
1007
1041
  }
1008
1042
  result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
1009
1043
  }
1044
+ // OBS-1073: `from`/`to` bind the WHOLE graph, so a later spec repair of ANOTHER task (a pin-sweep miss
1045
+ // found mid-run, D-57) makes them unmatchable forever and `--graph-changed` — applied only after this
1046
+ // replay — never reaches its own release. Under that audited release the amended task's files[]
1047
+ // recognition above still holds and the caller journals the rehash; only the whole-graph identity
1048
+ // asserts are waived. Without the release nothing changes: fail closed.
1010
1049
  for (const { event, amendment } of amendments) {
1011
- if (graphDefinitionHash(result) !== amendment.from) {
1050
+ if (release) {
1051
+ // The release waives the WHOLE-GRAPH identity (another task may lawfully have moved), never the
1052
+ // amended task's own definition: goal, acceptance, deps, floor must still be what was approved.
1053
+ const approved = amendment.definition ?? approvedDefinitions.get(event.taskId ?? "");
1054
+ const current = result.tasks.find((t) => t.id === event.taskId);
1055
+ if (approved === undefined || current === undefined) {
1056
+ throw new Error(`refusing scope amendment replay under --graph-changed: no approved definition fingerprint for ${event.taskId}`);
1057
+ }
1058
+ if (taskDefinitionFingerprint(current) !== approved) {
1059
+ throw new Error(`refusing scope amendment replay: ${event.taskId} definition changed beyond the approved amendment (goal, acceptance, deps or routing)`);
1060
+ }
1061
+ }
1062
+ if (!release && graphDefinitionHash(result) !== amendment.from) {
1012
1063
  throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
1013
1064
  }
1014
1065
  result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
1015
- if (graphDefinitionHash(result) !== amendment.to)
1066
+ if (!release && graphDefinitionHash(result) !== amendment.to)
1016
1067
  throw new Error("refusing invalid scope amendment hash");
1017
1068
  }
1018
1069
  return result;
1019
1070
  }
1020
1071
  /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
1021
- export function applyScopeAmendments(graph, journal, auditReplay = false) {
1072
+ export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
1022
1073
  const events = journal.read();
1023
- const result = replayScopeAmendments(graph, events); // validate all before moving any identity
1074
+ const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
1024
1075
  const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1025
1076
  for (const approval of approvals) {
1026
1077
  const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure operator-page grouping over the `operator-page` rows the daemon journals (one row per
3
+ * delivered page, carrying the producer's `suppressed` count). This module consumes that count and
4
+ * never throttles a second time: it only coalesces EQUIVALENT rows for display while keeping every
5
+ * source line, both evidence timestamps, and the producer's suppressed count separate from what was
6
+ * actually observed. Folding is incremental and pure; `fold(fold(empty, a), b)` equals
7
+ * `fold(empty, [...a, ...b])` and no journal byte is touched.
8
+ */
9
+ import type { JournalEvent } from "./journal.js";
10
+ export interface OperatorPageRow {
11
+ /** Source reference (journal line number or any stable locator). */
12
+ readonly line: number;
13
+ readonly ts: string;
14
+ readonly runId: string;
15
+ readonly taskId: string;
16
+ /** Current park identity (e.g. park kind + attempt); part of the group key. */
17
+ readonly park: string;
18
+ /** Decision state (e.g. blocked / idle / failed / resolved); part of the group key. */
19
+ readonly status: string;
20
+ /** Producer-suppressed page count; absent on rows written before suppression metadata existed. */
21
+ readonly suppressed?: number;
22
+ readonly blocker?: string;
23
+ readonly owner?: string;
24
+ readonly requiredAction?: string;
25
+ readonly permittedActions?: readonly string[];
26
+ }
27
+ export interface OperatorPageGroup {
28
+ readonly key: string;
29
+ readonly runId: string;
30
+ readonly taskId: string;
31
+ readonly park: string;
32
+ readonly status: string;
33
+ readonly blocker?: string;
34
+ readonly owner?: string;
35
+ readonly requiredAction?: string;
36
+ readonly permittedActions: readonly string[];
37
+ readonly firstEvidenceAt: string;
38
+ readonly lastEvidenceAt: string;
39
+ /** Rows actually present in the journal. */
40
+ readonly observedCount: number;
41
+ /** Sum of the producer's `suppressed` counts; never added to observedCount. */
42
+ readonly suppressedCount: number;
43
+ /** True when at least one row lacked suppression metadata (pre-metadata journal). */
44
+ readonly rawOnly: boolean;
45
+ readonly lines: readonly number[];
46
+ }
47
+ export interface OperatorPageSummary {
48
+ readonly groups: readonly OperatorPageGroup[];
49
+ }
50
+ export declare const EMPTY_OPERATOR_PAGE_SUMMARY: OperatorPageSummary;
51
+ /** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
52
+ export declare function operatorPageRow(event: JournalEvent, line: number, runId: string): OperatorPageRow | undefined;
53
+ /** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
54
+ export declare function foldOperatorPages(prior: OperatorPageSummary, rows: readonly OperatorPageRow[]): OperatorPageSummary;
55
+ /** Full replay from journal events. */
56
+ export declare function summarizeOperatorPages(events: readonly JournalEvent[], runId: string): OperatorPageSummary;
@@ -0,0 +1,68 @@
1
+ export const EMPTY_OPERATOR_PAGE_SUMMARY = { groups: [] };
2
+ const str = (v) => (typeof v === "string" ? v : undefined);
3
+ /** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
4
+ export function operatorPageRow(event, line, runId) {
5
+ if (event.event !== "operator-page" || event.taskId === undefined)
6
+ return undefined;
7
+ const d = event.data;
8
+ const permitted = Array.isArray(d.permittedActions) ? d.permittedActions.filter((a) => typeof a === "string") : undefined;
9
+ return {
10
+ line, ts: event.ts, runId, taskId: event.taskId,
11
+ park: str(d.park) ?? `${str(d.slot) ?? ""}#${typeof d.attempt === "number" ? d.attempt : ""}`,
12
+ status: str(d.status) ?? "unknown",
13
+ ...(typeof d.suppressed === "number" ? { suppressed: d.suppressed } : {}),
14
+ ...(str(d.blocker) !== undefined ? { blocker: str(d.blocker) } : {}),
15
+ ...(str(d.owner) !== undefined ? { owner: str(d.owner) } : {}),
16
+ ...(str(d.requiredAction) !== undefined ? { requiredAction: str(d.requiredAction) } : {}),
17
+ ...(permitted !== undefined ? { permittedActions: permitted } : {}),
18
+ };
19
+ }
20
+ const groupKey = (r) => [r.runId, r.taskId, r.park, r.status].join("\u0000");
21
+ const openKey = (r) => `${r.runId}\u0000${r.taskId}`;
22
+ /** Permitted actions compare as a SET: order and duplicates never open a new group. */
23
+ const actionSet = (a) => [...new Set(a ?? [])].sort().join("\u0000");
24
+ const sameDecision = (g, r) => g.key === groupKey(r)
25
+ && g.blocker === r.blocker && g.owner === r.owner && g.requiredAction === r.requiredAction
26
+ && actionSet(g.permittedActions) === actionSet(r.permittedActions);
27
+ /** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
28
+ export function foldOperatorPages(prior, rows) {
29
+ const groups = [...prior.groups];
30
+ // One open group per run/task: ANY change in park, status or decision closes it, so a later
31
+ // transition can never merge backward into an earlier group across an intervening change.
32
+ const open = new Map();
33
+ groups.forEach((g, i) => open.set(openKey(g), i));
34
+ for (const row of rows) {
35
+ const key = groupKey(row);
36
+ const idx = open.get(openKey(row));
37
+ const g = idx === undefined ? undefined : groups[idx];
38
+ if (g !== undefined && sameDecision(g, row)) {
39
+ groups[idx] = {
40
+ ...g,
41
+ firstEvidenceAt: row.ts < g.firstEvidenceAt ? row.ts : g.firstEvidenceAt,
42
+ lastEvidenceAt: row.ts > g.lastEvidenceAt ? row.ts : g.lastEvidenceAt,
43
+ observedCount: g.observedCount + 1,
44
+ suppressedCount: g.suppressedCount + (row.suppressed ?? 0),
45
+ rawOnly: g.rawOnly || row.suppressed === undefined,
46
+ lines: [...g.lines, row.line],
47
+ };
48
+ continue;
49
+ }
50
+ groups.push({
51
+ key, runId: row.runId, taskId: row.taskId, park: row.park, status: row.status,
52
+ ...(row.blocker !== undefined ? { blocker: row.blocker } : {}),
53
+ ...(row.owner !== undefined ? { owner: row.owner } : {}),
54
+ ...(row.requiredAction !== undefined ? { requiredAction: row.requiredAction } : {}),
55
+ permittedActions: [...(row.permittedActions ?? [])],
56
+ firstEvidenceAt: row.ts, lastEvidenceAt: row.ts,
57
+ observedCount: 1, suppressedCount: row.suppressed ?? 0,
58
+ rawOnly: row.suppressed === undefined, lines: [row.line],
59
+ });
60
+ open.set(openKey(row), groups.length - 1);
61
+ }
62
+ return { groups };
63
+ }
64
+ /** Full replay from journal events. */
65
+ export function summarizeOperatorPages(events, runId) {
66
+ const rows = events.flatMap((e, i) => { const r = operatorPageRow(e, i + 1, runId); return r ? [r] : []; });
67
+ return foldOperatorPages(EMPTY_OPERATOR_PAGE_SUMMARY, rows);
68
+ }
@@ -0,0 +1,69 @@
1
+ /** Labels describe blockers, not journal park kinds or commands. */
2
+ export declare const BLOCKER_KINDS: readonly ["human-decision", "retry", "cooldown", "dependency-wait", "stale-receipt", "missing-capability", "unknown"];
3
+ export type BlockerKind = (typeof BLOCKER_KINDS)[number];
4
+ /**
5
+ * Structural boundary: callers compute park with newestPark and verbs with
6
+ * permittedDecisionVerbs in cli/commands/approve. RunDecision also fits this
7
+ * shape. This module neither imports a surface nor repeats its decision table.
8
+ * Supply only current parks, from the same snapshot as the task statuses.
9
+ */
10
+ export interface OperatorDecisionSnapshot {
11
+ readonly taskId: string;
12
+ readonly park: {
13
+ readonly kind: string | undefined;
14
+ readonly tombstone: boolean;
15
+ readonly reason?: string;
16
+ };
17
+ readonly verbs: readonly string[];
18
+ readonly diagnostic?: string;
19
+ }
20
+ export interface RecordedResponsibility {
21
+ readonly role?: string;
22
+ readonly agent?: string;
23
+ }
24
+ /**
25
+ * Current, already-reduced evidence supplied by the reader. Optional facts stay
26
+ * absent: a wait duration is not a wake timestamp, a channel is not an owner,
27
+ * and elapsed wall time alone cannot establish that a receipt is stale.
28
+ */
29
+ export interface OperatorSummaryTask {
30
+ readonly id: string;
31
+ readonly status: string;
32
+ readonly deps: readonly string[];
33
+ readonly phase?: string;
34
+ readonly lastEvidenceAt?: string;
35
+ readonly responsible?: RecordedResponsibility;
36
+ readonly wait?: {
37
+ readonly kind: "retry" | "cooldown";
38
+ readonly wakeAt?: string;
39
+ readonly diagnostic?: string;
40
+ };
41
+ readonly staleReceipt?: {
42
+ readonly diagnostic: string;
43
+ };
44
+ readonly missingCapability?: {
45
+ readonly capability: string;
46
+ readonly diagnostic?: string;
47
+ };
48
+ }
49
+ export interface OperatorBlocker {
50
+ kind: BlockerKind;
51
+ permittedActions: string[];
52
+ humanInterventionRequired: boolean;
53
+ decisionRequired: boolean;
54
+ diagnostic?: string;
55
+ nextAction: string | null;
56
+ prerequisites?: string[];
57
+ wakeAt?: string;
58
+ capability?: string;
59
+ }
60
+ export interface OperatorTaskSummary {
61
+ taskId: string;
62
+ phase: string | null;
63
+ lastEvidenceAt: string | null;
64
+ /** null explicitly means unknown; never inferred from a park or wait. */
65
+ responsible: RecordedResponsibility | null;
66
+ blocker: OperatorBlocker | null;
67
+ }
68
+ /** Pure projection in current-task order; no clock, host access, or retained state. */
69
+ export declare function projectOperatorSummary(tasks: readonly OperatorSummaryTask[], decisions: readonly OperatorDecisionSnapshot[]): OperatorTaskSummary[];
@@ -0,0 +1,77 @@
1
+ /** Labels describe blockers, not journal park kinds or commands. */
2
+ export const BLOCKER_KINDS = [
3
+ "human-decision", "retry", "cooldown", "dependency-wait", "stale-receipt",
4
+ "missing-capability", "unknown",
5
+ ];
6
+ const SETTLED = new Set(["done", "completed", "merged"]);
7
+ const emptyBlocker = (kind) => ({
8
+ kind, permittedActions: [], humanInterventionRequired: false,
9
+ decisionRequired: false, nextAction: null,
10
+ });
11
+ /** Pure projection in current-task order; no clock, host access, or retained state. */
12
+ export function projectOperatorSummary(tasks, decisions) {
13
+ const statuses = new Map(tasks.map(task => [task.id, task.status]));
14
+ const parks = new Map(decisions.map(decision => [decision.taskId, decision]));
15
+ return tasks.map(task => {
16
+ let blocker = null;
17
+ const decision = task.status === "human" ? parks.get(task.id) : undefined;
18
+ const terminal = SETTLED.has(task.status) || task.status === "failed";
19
+ if (decision) {
20
+ const { park, verbs, diagnostic } = decision;
21
+ blocker = {
22
+ ...emptyBlocker(park.tombstone ? "unknown" : "human-decision"),
23
+ permittedActions: [...verbs],
24
+ humanInterventionRequired: !park.tombstone,
25
+ decisionRequired: verbs.length > 0,
26
+ ...(diagnostic !== undefined || park.reason !== undefined
27
+ ? { diagnostic: diagnostic ?? park.reason } : {}),
28
+ nextAction: verbs.length > 0 ? `Choose a decision: ${verbs.join(", ")}` : null,
29
+ };
30
+ if (!park.tombstone && park.kind === "infra" && task.missingCapability) {
31
+ const { capability, diagnostic: capabilityDiagnostic } = task.missingCapability;
32
+ blocker.kind = "missing-capability";
33
+ blocker.capability = capability;
34
+ blocker.nextAction = `Diagnose host capability: ${capability}`;
35
+ if (diagnostic === undefined && capabilityDiagnostic !== undefined)
36
+ blocker.diagnostic = capabilityDiagnostic;
37
+ }
38
+ }
39
+ else if (task.status === "human") {
40
+ // Missing decision evidence must not make a parked task look automatic.
41
+ blocker = { ...emptyBlocker("unknown"), humanInterventionRequired: true };
42
+ }
43
+ else if (!terminal) {
44
+ const unmet = task.status === "pending" ? task.deps.filter(id => !SETTLED.has(statuses.get(id))) : [];
45
+ if (unmet.length > 0) {
46
+ blocker = {
47
+ ...emptyBlocker("dependency-wait"), prerequisites: unmet,
48
+ nextAction: `Wait for prerequisites: ${unmet.join(", ")}`,
49
+ };
50
+ }
51
+ else if (task.staleReceipt) {
52
+ blocker = {
53
+ ...emptyBlocker("stale-receipt"), diagnostic: task.staleReceipt.diagnostic,
54
+ nextAction: "Inspect the stale receipt and refresh its evidence",
55
+ };
56
+ }
57
+ else if (task.wait) {
58
+ blocker = {
59
+ ...emptyBlocker(task.wait.kind),
60
+ ...(task.wait.diagnostic === undefined ? {} : { diagnostic: task.wait.diagnostic }),
61
+ ...(task.wait.wakeAt === undefined ? {} : { wakeAt: task.wait.wakeAt }),
62
+ };
63
+ }
64
+ else if (task.status === "blocked" || task.status === "unknown") {
65
+ blocker = emptyBlocker("unknown");
66
+ }
67
+ }
68
+ const owner = task.responsible;
69
+ return {
70
+ taskId: task.id, phase: task.phase ?? null, lastEvidenceAt: task.lastEvidenceAt ?? null,
71
+ responsible: owner?.role !== undefined || owner?.agent !== undefined
72
+ ? { ...(owner.role === undefined ? {} : { role: owner.role }),
73
+ ...(owner.agent === undefined ? {} : { agent: owner.agent }) } : null,
74
+ blocker,
75
+ };
76
+ });
77
+ }
@@ -1,6 +1,77 @@
1
1
  import { z } from "zod";
2
2
  import { type GateName } from "../graph/schema.js";
3
3
  import { type GateOutcome } from "./outcome.js";
4
+ /** Shared lifecycle vocabulary for shell evidence and later build-result emitters. */
5
+ export declare const COMMAND_RECEIPT_OUTCOMES: readonly ["started", "completed", "spawn-failed", "timed-out", "cancelled", "reused-result", "skipped", "refused"];
6
+ export declare const CommandReceiptAttributionSchema: z.ZodObject<{
7
+ runId: z.ZodString;
8
+ taskId: z.ZodString;
9
+ attempt: z.ZodNumber;
10
+ gateRound: z.ZodNumber;
11
+ invocation: z.ZodString;
12
+ }, z.core.$strict>;
13
+ export type CommandReceiptAttribution = z.infer<typeof CommandReceiptAttributionSchema>;
14
+ export declare const ShellReceiptSchema: z.ZodObject<{
15
+ outcome: z.ZodEnum<{
16
+ skipped: "skipped";
17
+ started: "started";
18
+ completed: "completed";
19
+ "spawn-failed": "spawn-failed";
20
+ "timed-out": "timed-out";
21
+ cancelled: "cancelled";
22
+ "reused-result": "reused-result";
23
+ refused: "refused";
24
+ }>;
25
+ confirmedStart: z.ZodBoolean;
26
+ attribution: z.ZodOptional<z.ZodObject<{
27
+ runId: z.ZodString;
28
+ taskId: z.ZodString;
29
+ attempt: z.ZodNumber;
30
+ gateRound: z.ZodNumber;
31
+ invocation: z.ZodString;
32
+ }, z.core.$strict>>;
33
+ pid: z.ZodOptional<z.ZodNumber>;
34
+ exitCode: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
35
+ signal: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
+ error: z.ZodOptional<z.ZodString>;
37
+ durationMs: z.ZodOptional<z.ZodNumber>;
38
+ }, z.core.$strict>;
39
+ export type ShellReceipt = z.infer<typeof ShellReceiptSchema>;
40
+ /** A task-build row must carry the entire caller-owned correlation, even before spawn. */
41
+ export declare const CommandReceiptSchema: z.ZodObject<{
42
+ outcome: z.ZodEnum<{
43
+ skipped: "skipped";
44
+ started: "started";
45
+ completed: "completed";
46
+ "spawn-failed": "spawn-failed";
47
+ "timed-out": "timed-out";
48
+ cancelled: "cancelled";
49
+ "reused-result": "reused-result";
50
+ refused: "refused";
51
+ }>;
52
+ confirmedStart: z.ZodBoolean;
53
+ pid: z.ZodOptional<z.ZodNumber>;
54
+ exitCode: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
55
+ signal: z.ZodOptional<z.ZodNullable<z.ZodString>>;
56
+ error: z.ZodOptional<z.ZodString>;
57
+ durationMs: z.ZodOptional<z.ZodNumber>;
58
+ attribution: z.ZodObject<{
59
+ runId: z.ZodString;
60
+ taskId: z.ZodString;
61
+ attempt: z.ZodNumber;
62
+ gateRound: z.ZodNumber;
63
+ invocation: z.ZodString;
64
+ }, z.core.$strict>;
65
+ }, z.core.$strict>;
66
+ export type CommandReceipt = z.infer<typeof CommandReceiptSchema>;
67
+ export declare function readCommandReceipt(raw: unknown): {
68
+ kind: "receipt";
69
+ receipt: CommandReceipt;
70
+ } | {
71
+ kind: "protocol-issue";
72
+ issues: string[];
73
+ raw: unknown;
74
+ };
4
75
  /** The T31 outcome vocabulary, made executable and strict at the persistence boundary. */
5
76
  export declare const GateOutcomeSchema: z.ZodType<GateOutcome>;
6
77
  export declare const ROLE_INVOCATION_ROLES: readonly ["worker", "judge", "review", "consult"];