tickmarkr 2.5.5 → 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 (69) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/prompt.js +21 -1
  3. package/dist/cli/commands/approve.js +69 -8
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/fleet.js +4 -0
  6. package/dist/cli/commands/plan.js +20 -4
  7. package/dist/cli/commands/status.js +95 -34
  8. package/dist/cli/commands/verify.js +108 -85
  9. package/dist/config/config.d.ts +11 -0
  10. package/dist/config/config.js +21 -12
  11. package/dist/config/fleet-overlay.js +55 -17
  12. package/dist/drivers/index.js +2 -1
  13. package/dist/drivers/orca.d.ts +21 -1
  14. package/dist/drivers/orca.js +209 -27
  15. package/dist/gates/baseline.d.ts +21 -5
  16. package/dist/gates/baseline.js +67 -17
  17. package/dist/gates/cache.d.ts +14 -13
  18. package/dist/gates/cache.js +17 -5
  19. package/dist/gates/llm.d.ts +3 -0
  20. package/dist/gates/llm.js +11 -0
  21. package/dist/gates/review.d.ts +28 -3
  22. package/dist/gates/review.js +118 -15
  23. package/dist/gates/run-gates.d.ts +10 -2
  24. package/dist/gates/run-gates.js +362 -108
  25. package/dist/gates/test-manifest.d.ts +33 -1
  26. package/dist/gates/test-manifest.js +132 -40
  27. package/dist/gates/test-reporter.js +20 -7
  28. package/dist/graph/graph.d.ts +4 -0
  29. package/dist/graph/graph.js +50 -1
  30. package/dist/run/activity.d.ts +28 -0
  31. package/dist/run/activity.js +194 -0
  32. package/dist/run/consult.js +5 -4
  33. package/dist/run/daemon.d.ts +11 -0
  34. package/dist/run/daemon.js +663 -128
  35. package/dist/run/execution-budget.d.ts +25 -0
  36. package/dist/run/execution-budget.js +142 -0
  37. package/dist/run/git.d.ts +46 -1
  38. package/dist/run/git.js +149 -12
  39. package/dist/run/journal.d.ts +23 -5
  40. package/dist/run/journal.js +98 -25
  41. package/dist/run/lease.d.ts +44 -0
  42. package/dist/run/lease.js +226 -3
  43. package/dist/run/operator-page-summary.d.ts +56 -0
  44. package/dist/run/operator-page-summary.js +68 -0
  45. package/dist/run/operator-summary.d.ts +69 -0
  46. package/dist/run/operator-summary.js +77 -0
  47. package/dist/run/protocol.d.ts +71 -0
  48. package/dist/run/protocol.js +32 -0
  49. package/dist/run/recovery.d.ts +8 -0
  50. package/dist/run/recovery.js +25 -0
  51. package/dist/run/repair-selection.d.ts +12 -0
  52. package/dist/run/repair-selection.js +56 -0
  53. package/dist/run/stall.d.ts +6 -1
  54. package/dist/run/stall.js +60 -3
  55. package/dist/tui/cockpit/board.d.ts +9 -0
  56. package/dist/tui/cockpit/board.js +10 -0
  57. package/dist/tui/cockpit/derive.d.ts +35 -0
  58. package/dist/tui/cockpit/derive.js +152 -10
  59. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  60. package/dist/tui/cockpit/evidence-view.js +42 -12
  61. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  62. package/dist/tui/cockpit/run-cockpit.js +95 -1
  63. package/dist/tui/cockpit/run-view.d.ts +37 -0
  64. package/dist/tui/cockpit/run-view.js +189 -2
  65. package/dist/tui/ink/fleet-app.d.ts +10 -2
  66. package/dist/tui/ink/fleet-app.js +33 -15
  67. package/package.json +2 -2
  68. package/skills/tickmarkr-overseer/SKILL.md +55 -3
  69. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -0,0 +1,25 @@
1
+ export declare class ExecutionBudgetExceeded extends Error {
2
+ readonly taskId: string;
3
+ constructor(taskId: string, reason?: string);
4
+ }
5
+ export interface ExecutionBudgetEvent {
6
+ event: string;
7
+ taskId?: string;
8
+ data: Record<string, unknown>;
9
+ }
10
+ export interface ExecutionBudgetOptions {
11
+ limitMs: number;
12
+ taskId: string;
13
+ readEvents: () => readonly ExecutionBudgetEvent[];
14
+ append: (event: string, taskId: string, data: Record<string, unknown>) => void;
15
+ }
16
+ export declare const executionSignal: () => AbortSignal | undefined;
17
+ /** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
18
+ export declare const withoutExecutionBudget: <T>(run: () => T) => T;
19
+ export declare const remainingExecutionMs: () => number | undefined;
20
+ /** One wall-clock interval around task execution, shared by parallel children. Cancellation is
21
+ * cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
22
+ * Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
23
+ * by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
24
+ * charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
25
+ export declare function withExecutionBudget<T>(opts: ExecutionBudgetOptions, run: () => Promise<T>): Promise<T>;
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ export class ExecutionBudgetExceeded extends Error {
4
+ taskId;
5
+ constructor(taskId, reason = "automated execution ceiling exhausted") {
6
+ super(`${taskId}: ${reason}`);
7
+ this.taskId = taskId;
8
+ this.name = "ExecutionBudgetExceeded";
9
+ }
10
+ }
11
+ const context = new AsyncLocalStorage();
12
+ const MIN_SLICE_MS = 60_000;
13
+ const finite = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
14
+ function consumed(opts) {
15
+ const reservations = new Map();
16
+ for (const e of opts.readEvents()) {
17
+ if (e.taskId !== opts.taskId || !["execution-budget-reserved", "execution-budget-settled"].includes(e.event))
18
+ continue;
19
+ const d = e.data;
20
+ if (!d || d.limitMs !== opts.limitMs || typeof d.id !== "string" || !d.id) {
21
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution budget accounting");
22
+ }
23
+ if (e.event === "execution-budget-reserved") {
24
+ if (!finite(d.reservedMs) || d.reservedMs === 0 || reservations.has(d.id)) {
25
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution reservation");
26
+ }
27
+ reservations.set(d.id, { reserved: d.reservedMs });
28
+ }
29
+ else {
30
+ const prior = reservations.get(d.id);
31
+ if (!prior || prior.used !== undefined || !finite(d.usedMs) || d.usedMs > prior.reserved) {
32
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution settlement");
33
+ }
34
+ prior.used = d.usedMs;
35
+ }
36
+ }
37
+ const total = [...reservations.values()].reduce((sum, r) => sum + (r.used ?? r.reserved), 0);
38
+ if (!finite(total) || total > opts.limitMs)
39
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution total");
40
+ return total;
41
+ }
42
+ export const executionSignal = () => {
43
+ const budget = context.getStore();
44
+ budget?.check();
45
+ return budget?.signal;
46
+ };
47
+ /** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
48
+ export const withoutExecutionBudget = (run) => context.exit(run);
49
+ export const remainingExecutionMs = () => context.getStore()?.remaining();
50
+ /** One wall-clock interval around task execution, shared by parallel children. Cancellation is
51
+ * cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
52
+ * Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
53
+ * by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
54
+ * charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
55
+ export async function withExecutionBudget(opts, run) {
56
+ if (!finite(opts.limitMs) || opts.limitMs === 0 || !opts.taskId)
57
+ throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution ceiling");
58
+ const parent = context.getStore();
59
+ if (parent) {
60
+ if (parent.taskId !== opts.taskId || parent.limitMs !== opts.limitMs)
61
+ throw new ExecutionBudgetExceeded(opts.taskId, "nested execution budget mismatch");
62
+ parent.check();
63
+ parent.signal.throwIfAborted();
64
+ return run();
65
+ }
66
+ const prior = consumed(opts);
67
+ const available = opts.limitMs - prior;
68
+ const sliceMs = Math.max(MIN_SLICE_MS, opts.limitMs / 100);
69
+ if (available <= 0)
70
+ throw new ExecutionBudgetExceeded(opts.taskId);
71
+ const controller = new AbortController();
72
+ const start = performance.now();
73
+ let reserved = 0;
74
+ let current;
75
+ let timer;
76
+ const elapsed = () => Math.max(0, performance.now() - start);
77
+ const remaining = () => Math.max(0, available - elapsed());
78
+ const stop = (error) => controller.abort(error instanceof Error ? error : new ExecutionBudgetExceeded(opts.taskId));
79
+ const buy = (amount) => {
80
+ if (amount <= 0)
81
+ return;
82
+ const next = { id: randomUUID(), before: reserved, amount };
83
+ opts.append("execution-budget-reserved", opts.taskId, { id: next.id, limitMs: opts.limitMs, reservedMs: amount });
84
+ current = next;
85
+ reserved += amount;
86
+ };
87
+ // JS timers cannot preempt a blocked event loop. Charge elapsed overruns before another
88
+ // observable operation or completion; expiration consumes the whole allowance across resume.
89
+ const check = () => {
90
+ try {
91
+ const used = Math.min(available, elapsed());
92
+ if (used > reserved)
93
+ buy(used - reserved);
94
+ if (used >= available)
95
+ stop(new ExecutionBudgetExceeded(opts.taskId));
96
+ }
97
+ catch (error) {
98
+ stop(error);
99
+ }
100
+ };
101
+ const reserve = () => {
102
+ if (elapsed() >= available)
103
+ check();
104
+ if (controller.signal.aborted)
105
+ return;
106
+ buy(Math.min(available - reserved, Math.max(sliceMs, elapsed() - reserved + sliceMs)));
107
+ timer = setTimeout(() => {
108
+ try {
109
+ reserve();
110
+ }
111
+ catch (error) {
112
+ stop(error);
113
+ }
114
+ }, Math.max(0, reserved - elapsed()));
115
+ };
116
+ reserve();
117
+ controller.signal.throwIfAborted();
118
+ const budget = { taskId: opts.taskId, limitMs: opts.limitMs, signal: controller.signal, remaining, check };
119
+ try {
120
+ return await context.run(budget, async () => {
121
+ controller.signal.throwIfAborted();
122
+ const result = await run();
123
+ check();
124
+ controller.signal.throwIfAborted();
125
+ return result;
126
+ });
127
+ }
128
+ catch (error) {
129
+ check();
130
+ controller.signal.throwIfAborted();
131
+ throw error;
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ check();
136
+ if (current) {
137
+ const usedMs = Math.min(current.amount, Math.max(0, elapsed() - current.before));
138
+ opts.append("execution-budget-settled", opts.taskId, { id: current.id, limitMs: opts.limitMs, usedMs });
139
+ }
140
+ controller.signal.throwIfAborted();
141
+ }
142
+ }
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 };
@@ -76,6 +77,46 @@ export declare function readCapacity(value: unknown): CapacityRead;
76
77
  * different capacity, or a current capacity the caller could not state → no.
77
78
  */
78
79
  export declare function sameCapacity(recorded: unknown, current: RunCapacity | undefined): boolean;
80
+ /**
81
+ * R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
82
+ * discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
83
+ * Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
84
+ * the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
85
+ * sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
86
+ * one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
87
+ */
88
+ export declare const VERIFICATION_PROTOCOL = "vl1.2";
89
+ export type RunnerLifecycle = "hooks" | "ignore-scripts" | "unknown";
90
+ export interface VerificationProtocol {
91
+ protocol: string;
92
+ /** The EFFECTIVE npm lifecycle policy a runner child receives: `hooks` (pretest/prebuild run),
93
+ * `ignore-scripts` (npm skips them), or `unknown` (could not be measured — never comparable). */
94
+ lifecycle: RunnerLifecycle;
95
+ /** Provenance only, never part of compatibility: an explicit process-scoped env export, or npm's
96
+ * own resolved config for that checkout (user/project npmrc). */
97
+ source: "explicit" | "npm-config" | "unknown";
98
+ }
99
+ /**
100
+ * R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
101
+ * process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
102
+ * recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
103
+ * (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
104
+ * npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
105
+ * unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
106
+ */
107
+ export declare function runnerLifecycle(env?: NodeJS.ProcessEnv, cwd?: string): Pick<VerificationProtocol, "lifecycle" | "source">;
108
+ export declare function verificationProtocol(env?: NodeJS.ProcessEnv, cwd?: string): VerificationProtocol;
109
+ /**
110
+ * May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
111
+ * `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
112
+ * the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
113
+ * the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
114
+ * `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
115
+ * `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
116
+ * are the same effective policy.
117
+ */
118
+ export declare function sameVerification(recorded: unknown, current: VerificationProtocol): boolean;
119
+ export declare const describeVerification: (value: unknown) => string;
79
120
  export declare const describeCapacity: (value: unknown) => string;
80
121
  /**
81
122
  * The capacity a child spawned on THIS async context would receive: the same precedence `shell`
@@ -110,10 +151,14 @@ export interface ShellOptions {
110
151
  signal?: AbortSignal;
111
152
  onSpawn?: (pid: number | undefined) => void;
112
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;
113
158
  }
114
159
  /** Shared command seam, including invocation-bound manifested runners. */
115
160
  export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
116
- 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>;
117
162
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
118
163
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
119
164
  export declare function shGitOk(cmd: string, cwd: string): Promise<string>;
package/dist/run/git.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { executionSignal } from "./execution-budget.js";
1
2
  import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
2
3
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { spawn } from "node:child_process";
4
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
- import { availableParallelism, tmpdir } from "node:os";
4
+ import { execFileSync, spawn } from "node:child_process";
5
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { availableParallelism, homedir, tmpdir } from "node:os";
6
7
  import { join, resolve } from "node:path";
7
8
  import { StringDecoder } from "node:string_decoder";
8
9
  import { shq } from "../adapters/types.js";
@@ -82,6 +83,86 @@ export function sameCapacity(recorded, current) {
82
83
  return false;
83
84
  return read.capacity.forkCap === current.forkCap && read.capacity.cores === current.cores;
84
85
  }
86
+ /**
87
+ * R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
88
+ * discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
89
+ * Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
90
+ * the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
91
+ * sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
92
+ * one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
93
+ */
94
+ export const VERIFICATION_PROTOCOL = "vl1.2";
95
+ const lifecycleOf = (raw) => {
96
+ const v = raw?.trim().toLowerCase();
97
+ return v === "false" ? "hooks" : v === "true" ? "ignore-scripts" : undefined;
98
+ };
99
+ // Measured effective policy, keyed on everything npm resolves it from that this process can see:
100
+ // the checkout, its project npmrc and the user npmrc (path + mtime), and the explicit env. A
101
+ // rewritten npmrc changes the key, so a later battery in the same process re-measures.
102
+ const measuredLifecycle = new Map();
103
+ const mtimeOf = (p) => { try {
104
+ return String(statSync(p).mtimeMs);
105
+ }
106
+ catch {
107
+ return "-";
108
+ } };
109
+ /**
110
+ * R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
111
+ * process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
112
+ * recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
113
+ * (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
114
+ * npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
115
+ * unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
116
+ */
117
+ export function runnerLifecycle(env = process.env, cwd = process.cwd()) {
118
+ const explicit = lifecycleOf(env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS);
119
+ if (explicit)
120
+ return { lifecycle: explicit, source: "explicit" };
121
+ const userconfig = env.NPM_CONFIG_USERCONFIG ?? env.npm_config_userconfig ?? join(homedir(), ".npmrc");
122
+ const key = [cwd, mtimeOf(join(cwd, ".npmrc")), userconfig, mtimeOf(userconfig),
123
+ env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS ?? ""].join("\0");
124
+ let lifecycle = measuredLifecycle.get(key);
125
+ if (lifecycle === undefined) {
126
+ try {
127
+ const out = execFileSync("npm", ["config", "get", "ignore-scripts"], {
128
+ cwd: existsSync(cwd) ? cwd : process.cwd(), env, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"],
129
+ });
130
+ lifecycle = lifecycleOf(out) ?? "unknown";
131
+ }
132
+ catch {
133
+ lifecycle = "unknown";
134
+ }
135
+ measuredLifecycle.set(key, lifecycle);
136
+ }
137
+ return { lifecycle, source: lifecycle === "unknown" ? "unknown" : "npm-config" };
138
+ }
139
+ export function verificationProtocol(env = process.env, cwd = process.cwd()) {
140
+ return { protocol: VERIFICATION_PROTOCOL, ...runnerLifecycle(env, cwd) };
141
+ }
142
+ /**
143
+ * May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
144
+ * `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
145
+ * the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
146
+ * the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
147
+ * `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
148
+ * `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
149
+ * are the same effective policy.
150
+ */
151
+ export function sameVerification(recorded, current) {
152
+ if (recorded === null || typeof recorded !== "object")
153
+ return false;
154
+ const { protocol, lifecycle } = recorded;
155
+ if (lifecycle === "unknown" || current.lifecycle === "unknown")
156
+ return false;
157
+ return protocol === current.protocol && lifecycle === current.lifecycle;
158
+ }
159
+ export const describeVerification = (value) => {
160
+ if (value === null || typeof value !== "object")
161
+ return "an unrecorded verification protocol";
162
+ const { protocol, lifecycle, source } = value;
163
+ return typeof protocol === "string" && typeof lifecycle === "string"
164
+ ? `protocol ${protocol}, lifecycle ${lifecycle}${typeof source === "string" ? ` (${source})` : ""}` : "a malformed verification protocol";
165
+ };
85
166
  export const describeCapacity = (value) => {
86
167
  const read = readCapacity(value);
87
168
  return read.state === "present"
@@ -131,10 +212,45 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
131
212
  const SHELL_REAP_GRACE_MS = 2000;
132
213
  /** Shared command seam, including invocation-bound manifested runners. */
133
214
  export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
134
- return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, options));
215
+ const inherited = executionSignal();
216
+ const signal = inherited && options.signal ? AbortSignal.any([inherited, options.signal]) : inherited ?? 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
+ });
135
251
  }
136
- function executeShell(cmd, cwd, timeoutMs, login, options) {
137
- options.signal?.throwIfAborted();
252
+ function executeShell(cmd, cwd, timeoutMs, login, options, observation) {
253
+ observation.checkAbort();
138
254
  // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
139
255
  // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
140
256
  // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
@@ -161,13 +277,22 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
161
277
  // detached: bash gets its own process group so a timeout can kill the whole tree —
162
278
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
163
279
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
164
- 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
+ }
165
288
  let stdout = "", stderr = "";
166
289
  const stdoutDecoder = new StringDecoder("utf8");
167
290
  const stderrDecoder = new StringDecoder("utf8");
168
291
  let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
169
292
  let reapError, exitedCode;
170
293
  let signalExit = false;
294
+ let exitSignal = null;
295
+ let spawnError;
171
296
  let reapTimer;
172
297
  let drainTimer;
173
298
  const finish = (code, err) => {
@@ -180,6 +305,12 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
180
305
  options.signal?.removeEventListener("abort", abort);
181
306
  stdout += stdoutDecoder.end();
182
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
+ });
183
314
  resolve({
184
315
  code,
185
316
  ...(signalExit ? { signalExit: true } : {}),
@@ -232,7 +363,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
232
363
  options.onSpawn?.(p.pid);
233
364
  if (options.signal?.aborted)
234
365
  abort();
235
- 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
236
367
  // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
237
368
  // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
238
369
  // kernel-chosen boundaries. A deterministic fixture proves this decoder correct rather than
@@ -249,21 +380,24 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
249
380
  stderr += stderrDecoder.write(d);
250
381
  });
251
382
  p.on("error", (e) => {
383
+ spawnError = String(e);
252
384
  if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
253
385
  done = true;
254
386
  clearTimeout(timer);
255
387
  options.signal?.removeEventListener("abort", abort);
388
+ observation.emit({ outcome: "spawn-failed", exitCode: null, signal: null, error: String(e), durationMs: Date.now() - startedAt });
256
389
  resolve({ refused: e });
257
390
  return;
258
391
  }
259
392
  finish(127, String(e));
260
393
  });
261
- p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
394
+ p.on("close", (code, signal) => { signalExit = code === null; exitSignal = signal; finish(code ?? 1); });
262
395
  // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
263
396
  // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
264
397
  // cannot hold this promise until the command ceiling. A real timeout wins first and is never
265
398
  // reclassified as a grace reap.
266
- p.on("exit", (code) => {
399
+ p.on("exit", (code, signal) => {
400
+ exitSignal = signal;
267
401
  signalExit = code === null;
268
402
  exitedCode = code ?? 1;
269
403
  // Allow a short pipe drain after killing, bounded even for an escaped descendant.
@@ -289,6 +423,9 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
289
423
  return (async () => {
290
424
  const startedAt = Date.now();
291
425
  for (let n = 1;; n++) {
426
+ if (n > 1)
427
+ observation.begin(n);
428
+ observation.checkAbort();
292
429
  const r = await attempt();
293
430
  if (!("refused" in r))
294
431
  return r;
@@ -301,8 +438,8 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
301
438
  }
302
439
  })();
303
440
  }
304
- export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
305
- 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);
306
443
  }
307
444
  // Git plumbing never needs an operator profile; skip login-shell startup and its side effects.
308
445
  export function shGit(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
@@ -60,6 +60,7 @@ export interface ConsultGuidanceCarry {
60
60
  */
61
61
  export declare function outstandingConsultGuidance(events: JournalEvent[], taskId: string): ConsultGuidanceCarry | undefined;
62
62
  export declare const UNIDENTIFIED = "<unidentified>";
63
+ export declare function boundedSymbol(note: string): string;
63
64
  /**
64
65
  * Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
65
66
  * already writes (D-03: no gate-module change, so an older gate's prose degrades to one unclassified
@@ -143,8 +144,11 @@ export declare function pendingRechecks(events: JournalEvent[]): Set<string>;
143
144
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
144
145
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
145
146
  * task-dispatch meant `--retry-failed` after exactly that death rebuilt the prompt without the gate
146
- * failures OR the delivery failure that preceded it. `task-approved` also clears (an operator approval
147
- * 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.
148
152
  */
149
153
  export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
150
154
  /**
@@ -192,7 +196,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
192
196
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
193
197
  */
194
198
  export declare function activeRetryBan(events: JournalEvent[], taskId: string, channel: string): string | undefined;
195
- export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request"];
199
+ export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request", "diff-cap"];
196
200
  export type ParkKind = (typeof PARK_KINDS)[number];
197
201
  export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
198
202
  export type RetryMode = (typeof RETRY_MODES)[number];
@@ -244,6 +248,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
244
248
  "tip-moved": "tip-moved";
245
249
  authoring: "authoring";
246
250
  "scope-request": "scope-request";
251
+ "diff-cap": "diff-cap";
247
252
  }>>;
248
253
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
249
254
  input: z.ZodNumber;
@@ -298,6 +303,13 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
298
303
  signalQuality: number;
299
304
  } & Record<string, unknown>;
300
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;
301
313
  /** An approval is the durable authority; graph.json is only its materialized projection. */
302
314
  export declare const ScopeAmendmentSchema: z.ZodObject<{
303
315
  from: z.ZodString;
@@ -305,10 +317,16 @@ export declare const ScopeAmendmentSchema: z.ZodObject<{
305
317
  beforeFiles: z.ZodArray<z.ZodString>;
306
318
  files: z.ZodArray<z.ZodString>;
307
319
  parkLine: z.ZodNumber;
320
+ definition: z.ZodOptional<z.ZodString>;
308
321
  }, z.core.$strip>;
309
- 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;
310
328
  /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
311
- 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;
312
330
  export type EngagementCompare = {
313
331
  comparable: true;
314
332
  recorded: string;