tickmarkr 2.5.6 → 2.5.8

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 (59) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +13 -6
  3. package/dist/cli/commands/compile.js +7 -0
  4. package/dist/cli/commands/plan.d.ts +5 -0
  5. package/dist/cli/commands/plan.js +28 -23
  6. package/dist/cli/commands/resume.js +1 -1
  7. package/dist/cli/commands/run.js +1 -1
  8. package/dist/cli/commands/status.js +95 -34
  9. package/dist/cli/commands/verify.js +6 -4
  10. package/dist/compile/native.js +68 -7
  11. package/dist/compile/retired-literals.d.ts +22 -0
  12. package/dist/compile/retired-literals.js +271 -0
  13. package/dist/drivers/index.d.ts +4 -2
  14. package/dist/drivers/index.js +54 -6
  15. package/dist/gates/baseline.d.ts +8 -3
  16. package/dist/gates/baseline.js +6 -3
  17. package/dist/gates/review.js +12 -1
  18. package/dist/gates/run-gates.d.ts +6 -1
  19. package/dist/gates/run-gates.js +67 -9
  20. package/dist/gates/test-manifest.d.ts +12 -0
  21. package/dist/gates/test-manifest.js +29 -3
  22. package/dist/gates/test-reporter.js +28 -2
  23. package/dist/graph/graph.d.ts +2 -0
  24. package/dist/graph/graph.js +5 -0
  25. package/dist/graph/schema.d.ts +28 -0
  26. package/dist/graph/schema.js +13 -1
  27. package/dist/run/activity.d.ts +28 -0
  28. package/dist/run/activity.js +194 -0
  29. package/dist/run/daemon.d.ts +28 -0
  30. package/dist/run/daemon.js +466 -110
  31. package/dist/run/git.d.ts +6 -1
  32. package/dist/run/git.js +63 -11
  33. package/dist/run/journal.d.ts +57 -5
  34. package/dist/run/journal.js +103 -9
  35. package/dist/run/merge.d.ts +2 -0
  36. package/dist/run/merge.js +1 -0
  37. package/dist/run/operator-page-summary.d.ts +56 -0
  38. package/dist/run/operator-page-summary.js +68 -0
  39. package/dist/run/operator-summary.d.ts +69 -0
  40. package/dist/run/operator-summary.js +77 -0
  41. package/dist/run/protocol.d.ts +71 -0
  42. package/dist/run/protocol.js +32 -0
  43. package/dist/run/repair-disposition.d.ts +41 -0
  44. package/dist/run/repair-disposition.js +77 -0
  45. package/dist/tui/cockpit/board.d.ts +9 -0
  46. package/dist/tui/cockpit/board.js +10 -0
  47. package/dist/tui/cockpit/derive.d.ts +35 -0
  48. package/dist/tui/cockpit/derive.js +152 -10
  49. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  50. package/dist/tui/cockpit/evidence-view.js +42 -12
  51. package/dist/tui/cockpit/home-view.js +45 -30
  52. package/dist/tui/cockpit/live-store.d.ts +18 -0
  53. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  54. package/dist/tui/cockpit/run-cockpit.js +95 -1
  55. package/dist/tui/cockpit/run-view.d.ts +37 -0
  56. package/dist/tui/cockpit/run-view.js +189 -2
  57. package/package.json +1 -1
  58. package/schema/rungraph.schema.json +54 -0
  59. package/skills/tickmarkr-overseer/SKILL.md +162 -3
package/dist/run/git.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CommandReceiptAttribution, ShellReceipt } from "./protocol.js";
1
2
  import { spawn } from "node:child_process";
2
3
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
3
4
  export { ROUTING_ENV_SEAMS };
@@ -150,10 +151,14 @@ export interface ShellOptions {
150
151
  signal?: AbortSignal;
151
152
  onSpawn?: (pid: number | undefined) => void;
152
153
  onTimeout?: () => void;
154
+ /** Independent of the legacy pid callback; started means the child emitted spawn. */
155
+ onReceipt?: (receipt: ShellReceipt) => void;
156
+ /** Called once per invocation (1-based), including each pre-spawn retry. */
157
+ receiptAttribution?: (invocation: number) => CommandReceiptAttribution;
153
158
  }
154
159
  /** Shared command seam, including invocation-bound manifested runners. */
155
160
  export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
156
- export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
161
+ export declare function sh(cmd: string, cwd: string, timeoutMs?: number, options?: ShellOptions): Promise<ShResult>;
157
162
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
158
163
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
159
164
  export declare function shGitOk(cmd: string, cwd: string): Promise<string>;
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) {
@@ -132,6 +132,42 @@ export declare function repairReachSinceApproval(events: JournalEvent[], taskId:
132
132
  export declare function repairsSinceApproval(events: JournalEvent[], taskId: string): number;
133
133
  /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
134
134
  export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
135
+ /**
136
+ * OBS-1075: what an accepted approval still authorises, typed by its release. `worker` funds one
137
+ * dispatch (plain, attempt-cap, review-upheld, scope-request); `battery` funds the recheck battery
138
+ * and no worker; `waiver` satisfies exactly its named gate; `inert` authorises nothing.
139
+ */
140
+ export type PendingApprovalAction = {
141
+ taskId: string;
142
+ ts: string;
143
+ authority: "worker";
144
+ release: "plain" | typeof ATTEMPT_CAP_RELEASE | typeof REVIEW_UPHELD_RELEASE | "scope-request";
145
+ } | {
146
+ taskId: string;
147
+ ts: string;
148
+ authority: "battery";
149
+ release: typeof RECHECK_RELEASE;
150
+ } | {
151
+ taskId: string;
152
+ ts: string;
153
+ authority: "waiver";
154
+ release: typeof GATE_SATISFIED_RELEASE;
155
+ gate: GateName;
156
+ } | {
157
+ taskId: string;
158
+ ts: string;
159
+ authority: "inert";
160
+ release: unknown;
161
+ };
162
+ /**
163
+ * Per task, the newest task-approved row no later enactment has consumed. Pure: events in, typed
164
+ * actions out. Acceptance is not enactment and a run boundary is not enactment — only the row the
165
+ * release causes consumes it (ENACTED_BY), so an approval accepted just before an abnormal exit
166
+ * survives every restart until it is enacted. A later approval supersedes an earlier one. An unknown
167
+ * release, or a waiver without a known gate, is inert: it supersedes, authorises nothing, and no row
168
+ * enacts it.
169
+ */
170
+ export declare function pendingApprovalActions(events: JournalEvent[]): Map<string, PendingApprovalAction>;
135
171
  /**
136
172
  * Why the last attempt failed, one row per journaled cause, in the daemon's own `source: details`
137
173
  * shape. The daemon builds that brief in a loop-local variable, which dies with the process: a resumed
@@ -144,8 +180,11 @@ export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
144
180
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
145
181
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
146
182
  * 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).
183
+ * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
184
+ * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
185
+ * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
186
+ * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
187
+ * because every approval erased exactly the finding the fresh attempt was funded to fix.
149
188
  */
150
189
  export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
151
190
  /**
@@ -237,6 +276,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
237
276
  "gate-fail": "gate-fail";
238
277
  quota: "quota";
239
278
  infra: "infra";
279
+ "scope-request": "scope-request";
240
280
  dispatch: "dispatch";
241
281
  "human-gate": "human-gate";
242
282
  "reroute-exhausted": "reroute-exhausted";
@@ -244,7 +284,6 @@ export declare const TelemetryRowSchema: z.ZodObject<{
244
284
  "merge-conflict": "merge-conflict";
245
285
  "tip-moved": "tip-moved";
246
286
  authoring: "authoring";
247
- "scope-request": "scope-request";
248
287
  "diff-cap": "diff-cap";
249
288
  }>>;
250
289
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
@@ -300,6 +339,13 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
300
339
  signalQuality: number;
301
340
  } & Record<string, unknown>;
302
341
  export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
342
+ /**
343
+ * OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
344
+ * daemon writes it on the run-resume row; every replay that runs inside that engagement from another
345
+ * process (the approve CLI) reads it back here. Without it the first in-run approval after a released
346
+ * launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
347
+ */
348
+ export declare function engagementReleased(events: JournalEvent[]): boolean;
303
349
  /** An approval is the durable authority; graph.json is only its materialized projection. */
304
350
  export declare const ScopeAmendmentSchema: z.ZodObject<{
305
351
  from: z.ZodString;
@@ -307,10 +353,16 @@ export declare const ScopeAmendmentSchema: z.ZodObject<{
307
353
  beforeFiles: z.ZodArray<z.ZodString>;
308
354
  files: z.ZodArray<z.ZodString>;
309
355
  parkLine: z.ZodNumber;
356
+ definition: z.ZodOptional<z.ZodString>;
310
357
  }, z.core.$strip>;
311
- export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
358
+ /**
359
+ * `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
360
+ * the definition fingerprint the amendment was granted against when the row itself carries none (rows
361
+ * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
362
+ */
363
+ export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[], release?: boolean, approvedDefinitions?: ReadonlyMap<string, string>): RunGraph;
312
364
  /** 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;
365
+ export declare function applyScopeAmendments(graph: RunGraph, journal: Journal, auditReplay?: boolean, release?: boolean): RunGraph;
314
366
  export type EngagementCompare = {
315
367
  comparable: true;
316
368
  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";
@@ -620,6 +620,49 @@ export function pendingRechecks(events) {
620
620
  }
621
621
  return pending;
622
622
  }
623
+ const ENACTED_BY = {
624
+ worker: ["task-dispatch", "worker-launch"],
625
+ battery: ["recheck-battery"],
626
+ waiver: ["worktree-recreation"],
627
+ };
628
+ /**
629
+ * Per task, the newest task-approved row no later enactment has consumed. Pure: events in, typed
630
+ * actions out. Acceptance is not enactment and a run boundary is not enactment — only the row the
631
+ * release causes consumes it (ENACTED_BY), so an approval accepted just before an abnormal exit
632
+ * survives every restart until it is enacted. A later approval supersedes an earlier one. An unknown
633
+ * release, or a waiver without a known gate, is inert: it supersedes, authorises nothing, and no row
634
+ * enacts it.
635
+ */
636
+ export function pendingApprovalActions(events) {
637
+ const pending = new Map();
638
+ for (const e of events) {
639
+ if (!e.taskId)
640
+ continue;
641
+ if (e.event === "task-approved") {
642
+ pending.set(e.taskId, approvalAction(e.taskId, e));
643
+ continue;
644
+ }
645
+ const action = pending.get(e.taskId);
646
+ if (action && action.authority !== "inert" && ENACTED_BY[action.authority].includes(e.event))
647
+ pending.delete(e.taskId);
648
+ }
649
+ return pending;
650
+ }
651
+ function approvalAction(taskId, e) {
652
+ const { release, gate } = e.data;
653
+ const base = { taskId, ts: e.ts };
654
+ if (release === undefined)
655
+ return { ...base, authority: "worker", release: "plain" };
656
+ if (release === ATTEMPT_CAP_RELEASE || release === REVIEW_UPHELD_RELEASE || release === "scope-request") {
657
+ return { ...base, authority: "worker", release };
658
+ }
659
+ if (release === RECHECK_RELEASE)
660
+ return { ...base, authority: "battery", release };
661
+ if (release === GATE_SATISFIED_RELEASE && typeof gate === "string" && GATE_NAMES.includes(gate)) {
662
+ return { ...base, authority: "waiver", release, gate: gate };
663
+ }
664
+ return { ...base, authority: "inert", release };
665
+ }
623
666
  // Both retry decisions below govern exactly ONE dispatch: the next one. So both are read back from the
624
667
  // journal at the moment that dispatch is built, never carried in a process variable — a stop between
625
668
  // the decision and the dispatch (OBS-254's shape, one layer up) would otherwise send a normal prompt
@@ -656,16 +699,25 @@ function decisionForNextDispatch(events, taskId, event) {
656
699
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
657
700
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
658
701
  * 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).
702
+ * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
703
+ * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
704
+ * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
705
+ * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
706
+ * because every approval erased exactly the finding the fresh attempt was funded to fix.
661
707
  */
662
708
  export function journaledFailureBrief(events, taskId) {
663
709
  let rows = [];
664
710
  for (const e of events) {
665
711
  if (e.taskId !== taskId)
666
712
  continue;
667
- if (e.event === "worker-launch" || e.event === "task-approved")
713
+ if (e.event === "worker-launch")
668
714
  rows = [];
715
+ else if (e.event === "task-approved") {
716
+ if (e.data.release === GATE_SATISFIED_RELEASE)
717
+ rows = [];
718
+ else if (typeof e.data.reason === "string" && e.data.reason.trim())
719
+ rows.push(`approval: ${e.data.reason.trim()}`);
720
+ }
669
721
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
670
722
  && typeof e.data.details === "string")
671
723
  rows.push(`${e.data.gate}: ${e.data.details}`);
@@ -983,12 +1035,37 @@ export function recordedGraphDefinitionHash(events) {
983
1035
  }
984
1036
  return recorded ?? undefined;
985
1037
  }
1038
+ /**
1039
+ * OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
1040
+ * daemon writes it on the run-resume row; every replay that runs inside that engagement from another
1041
+ * process (the approve CLI) reads it back here. Without it the first in-run approval after a released
1042
+ * launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
1043
+ */
1044
+ export function engagementReleased(events) {
1045
+ const engagement = events.filter((e) => e.event === "run-start" || e.event === "run-resume").at(-1);
1046
+ return engagement?.data.graphChanged === true;
1047
+ }
1048
+ /** The run's own graph.json (copied by the daemon at every start/resume) — the definitions the last engagement ran on. */
1049
+ function snapshotDefinitions(journal) {
1050
+ const path = join(journal.dir, "graph.json");
1051
+ if (!existsSync(path))
1052
+ return new Map();
1053
+ const snapshot = JSON.parse(readFileSync(path, "utf8"));
1054
+ return new Map((snapshot.tasks ?? []).map((t) => [t.id, taskDefinitionFingerprint(t)]));
1055
+ }
986
1056
  /** An approval is the durable authority; graph.json is only its materialized projection. */
987
1057
  export const ScopeAmendmentSchema = z.object({
988
1058
  from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
989
1059
  parkLine: z.number().int().positive(),
1060
+ /** OBS-1073: the amended task's definition minus files[] at approval; absent on rows older than v2.5.7. */
1061
+ definition: z.string().optional(),
990
1062
  });
991
- export function replayScopeAmendments(graph, events) {
1063
+ /**
1064
+ * `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
1065
+ * the definition fingerprint the amendment was granted against when the row itself carries none (rows
1066
+ * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
1067
+ */
1068
+ export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
992
1069
  const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
993
1070
  .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
994
1071
  if (!amendments.length)
@@ -1007,20 +1084,37 @@ export function replayScopeAmendments(graph, events) {
1007
1084
  }
1008
1085
  result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
1009
1086
  }
1087
+ // OBS-1073: `from`/`to` bind the WHOLE graph, so a later spec repair of ANOTHER task (a pin-sweep miss
1088
+ // found mid-run, D-57) makes them unmatchable forever and `--graph-changed` — applied only after this
1089
+ // replay — never reaches its own release. Under that audited release the amended task's files[]
1090
+ // recognition above still holds and the caller journals the rehash; only the whole-graph identity
1091
+ // asserts are waived. Without the release nothing changes: fail closed.
1010
1092
  for (const { event, amendment } of amendments) {
1011
- if (graphDefinitionHash(result) !== amendment.from) {
1093
+ if (release) {
1094
+ // The release waives the WHOLE-GRAPH identity (another task may lawfully have moved), never the
1095
+ // amended task's own definition: goal, acceptance, deps, floor must still be what was approved.
1096
+ const approved = amendment.definition ?? approvedDefinitions.get(event.taskId ?? "");
1097
+ const current = result.tasks.find((t) => t.id === event.taskId);
1098
+ if (approved === undefined || current === undefined) {
1099
+ throw new Error(`refusing scope amendment replay under --graph-changed: no approved definition fingerprint for ${event.taskId}`);
1100
+ }
1101
+ if (taskDefinitionFingerprint(current) !== approved) {
1102
+ throw new Error(`refusing scope amendment replay: ${event.taskId} definition changed beyond the approved amendment (goal, acceptance, deps or routing)`);
1103
+ }
1104
+ }
1105
+ if (!release && graphDefinitionHash(result) !== amendment.from) {
1012
1106
  throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
1013
1107
  }
1014
1108
  result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
1015
- if (graphDefinitionHash(result) !== amendment.to)
1109
+ if (!release && graphDefinitionHash(result) !== amendment.to)
1016
1110
  throw new Error("refusing invalid scope amendment hash");
1017
1111
  }
1018
1112
  return result;
1019
1113
  }
1020
1114
  /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
1021
- export function applyScopeAmendments(graph, journal, auditReplay = false) {
1115
+ export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
1022
1116
  const events = journal.read();
1023
- const result = replayScopeAmendments(graph, events); // validate all before moving any identity
1117
+ const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
1024
1118
  const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1025
1119
  for (const approval of approvals) {
1026
1120
  const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
@@ -12,6 +12,8 @@ export interface TipVerifyResult {
12
12
  spawnedCommand?: string;
13
13
  /** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
14
14
  forgiven?: boolean;
15
+ /** D-131: carried from a persisted per-gate verdict — this cycle did NOT execute the command. */
16
+ reused?: true;
15
17
  /**
16
18
  * OBS-534: what a nonzero exit is evidence OF, taken from the battery's own readers — `ceilingKillResult`
17
19
  * for a kill, the shared runner classifier for everything else. `infra` means nothing was verified.
package/dist/run/merge.js CHANGED
@@ -184,6 +184,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
184
184
  gate,
185
185
  cmd,
186
186
  pass: hit.pass,
187
+ reused: true,
187
188
  exitCode: hit.exitCode ?? 0,
188
189
  fingerprints: hit.meta?.fingerprints ?? [],
189
190
  ...(hit.meta?.forgiven ? { forgiven: true } : {}),
@@ -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[];