tickmarkr 2.5.4 → 2.5.6

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 (78) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. 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
@@ -4,7 +4,7 @@ export { ROUTING_ENV_SEAMS };
4
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
5
5
  export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
6
6
  export declare const DEFAULT_FORK_CAP = "6";
7
- /** Verification owns the serialized suite window; workers keep their concurrency budget. */
7
+ /** Verification uses command leases; workers keep their concurrency budget. */
8
8
  export declare const runWithVerificationBudget: <T>(capacity: RunCapacity, fn: () => Promise<T>) => Promise<T>;
9
9
  /**
10
10
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
@@ -76,6 +76,46 @@ export declare function readCapacity(value: unknown): CapacityRead;
76
76
  * different capacity, or a current capacity the caller could not state → no.
77
77
  */
78
78
  export declare function sameCapacity(recorded: unknown, current: RunCapacity | undefined): boolean;
79
+ /**
80
+ * R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
81
+ * discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
82
+ * Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
83
+ * the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
84
+ * sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
85
+ * one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
86
+ */
87
+ export declare const VERIFICATION_PROTOCOL = "vl1.2";
88
+ export type RunnerLifecycle = "hooks" | "ignore-scripts" | "unknown";
89
+ export interface VerificationProtocol {
90
+ protocol: string;
91
+ /** The EFFECTIVE npm lifecycle policy a runner child receives: `hooks` (pretest/prebuild run),
92
+ * `ignore-scripts` (npm skips them), or `unknown` (could not be measured — never comparable). */
93
+ lifecycle: RunnerLifecycle;
94
+ /** Provenance only, never part of compatibility: an explicit process-scoped env export, or npm's
95
+ * own resolved config for that checkout (user/project npmrc). */
96
+ source: "explicit" | "npm-config" | "unknown";
97
+ }
98
+ /**
99
+ * R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
100
+ * process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
101
+ * recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
102
+ * (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
103
+ * npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
104
+ * unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
105
+ */
106
+ export declare function runnerLifecycle(env?: NodeJS.ProcessEnv, cwd?: string): Pick<VerificationProtocol, "lifecycle" | "source">;
107
+ export declare function verificationProtocol(env?: NodeJS.ProcessEnv, cwd?: string): VerificationProtocol;
108
+ /**
109
+ * May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
110
+ * `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
111
+ * the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
112
+ * the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
113
+ * `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
114
+ * `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
115
+ * are the same effective policy.
116
+ */
117
+ export declare function sameVerification(recorded: unknown, current: VerificationProtocol): boolean;
118
+ export declare const describeVerification: (value: unknown) => string;
79
119
  export declare const describeCapacity: (value: unknown) => string;
80
120
  /**
81
121
  * The capacity a child spawned on THIS async context would receive: the same precedence `shell`
@@ -89,6 +129,7 @@ export declare const resolvedCapacity: () => RunCapacity;
89
129
  export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
90
130
  export interface ShResult {
91
131
  code: number;
132
+ signalExit?: boolean;
92
133
  stdout: string;
93
134
  stderr: string;
94
135
  timedOut?: boolean;
@@ -104,6 +145,14 @@ export declare const setSpawnForTests: (fn: typeof spawn) => void;
104
145
  export declare const resetSpawnForTests: () => void;
105
146
  export declare const SPAWN_ATTEMPT_LIMIT = 4;
106
147
  export declare const SPAWN_RETRY_BACKOFF_MS = 50;
148
+ export interface ShellOptions {
149
+ env?: NodeJS.ProcessEnv;
150
+ signal?: AbortSignal;
151
+ onSpawn?: (pid: number | undefined) => void;
152
+ onTimeout?: () => void;
153
+ }
154
+ /** Shared command seam, including invocation-bound manifested runners. */
155
+ export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
107
156
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
108
157
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
109
158
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
package/dist/run/git.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { executionSignal } from "./execution-budget.js";
2
+ import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
1
3
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { spawn } from "node:child_process";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
- 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";
5
7
  import { join, resolve } from "node:path";
6
8
  import { StringDecoder } from "node:string_decoder";
7
9
  import { shq } from "../adapters/types.js";
@@ -18,7 +20,7 @@ export const DEFAULT_FORK_CAP = "6";
18
20
  /** Worker concurrency is resolved once per run and isolated from other async runs. */
19
21
  const forkBudget = new AsyncLocalStorage();
20
22
  const verificationBudget = new AsyncLocalStorage();
21
- /** Verification owns the serialized suite window; workers keep their concurrency budget. */
23
+ /** Verification uses command leases; workers keep their concurrency budget. */
22
24
  export const runWithVerificationBudget = (capacity, fn) => verificationBudget.run(capacity, fn);
23
25
  /**
24
26
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
@@ -81,6 +83,86 @@ export function sameCapacity(recorded, current) {
81
83
  return false;
82
84
  return read.capacity.forkCap === current.forkCap && read.capacity.cores === current.cores;
83
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
+ };
84
166
  export const describeCapacity = (value) => {
85
167
  const read = readCapacity(value);
86
168
  return read.state === "present"
@@ -128,14 +210,20 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
128
210
  // RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
129
211
  /** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
130
212
  const SHELL_REAP_GRACE_MS = 2000;
131
- // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
132
- // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
133
- function shell(cmd, cwd, timeoutMs, login) {
213
+ /** Shared command seam, including invocation-bound manifested runners. */
214
+ export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
215
+ const inherited = executionSignal();
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 }));
219
+ }
220
+ function executeShell(cmd, cwd, timeoutMs, login, options) {
221
+ options.signal?.throwIfAborted();
134
222
  // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
135
223
  // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
136
224
  // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
137
225
  // children are hermetic by construction; the daemon's own process.env stays unchanged.
138
- const env = { ...process.env };
226
+ const env = commandLeaseEnvironment(options.env ?? process.env);
139
227
  for (const k of ROUTING_ENV_SEAMS)
140
228
  delete env[k];
141
229
  // A run freezes the operator override and cores at startup; admission can lower the round cap.
@@ -163,17 +251,22 @@ function shell(cmd, cwd, timeoutMs, login) {
163
251
  const stderrDecoder = new StringDecoder("utf8");
164
252
  let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
165
253
  let reapError, exitedCode;
254
+ let signalExit = false;
166
255
  let reapTimer;
256
+ let drainTimer;
167
257
  const finish = (code, err) => {
168
258
  if (done)
169
259
  return;
170
260
  done = true;
171
261
  clearTimeout(timer);
172
262
  clearTimeout(reapTimer);
263
+ clearTimeout(drainTimer);
264
+ options.signal?.removeEventListener("abort", abort);
173
265
  stdout += stdoutDecoder.end();
174
266
  stderr += stderrDecoder.end();
175
267
  resolve({
176
268
  code,
269
+ ...(signalExit ? { signalExit: true } : {}),
177
270
  stdout,
178
271
  stderr: err ?? stderr,
179
272
  timedOut,
@@ -183,8 +276,18 @@ function shell(cmd, cwd, timeoutMs, login) {
183
276
  ...(reapError ? { reapError } : {}),
184
277
  });
185
278
  };
279
+ // Escaped descendants can retain pipes even after the shell and its group are dead.
280
+ const boundDrain = () => {
281
+ if (!done && !drainTimer)
282
+ drainTimer = setTimeout(() => {
283
+ p.stdout?.destroy();
284
+ p.stderr?.destroy();
285
+ finish(exitedCode ?? 1);
286
+ }, 100);
287
+ };
186
288
  const timer = setTimeout(() => {
187
289
  timedOut = true;
290
+ options.onTimeout?.();
188
291
  try {
189
292
  process.kill(-p.pid, "SIGKILL");
190
293
  }
@@ -198,7 +301,21 @@ function shell(cmd, cwd, timeoutMs, login) {
198
301
  if (code !== "ESRCH")
199
302
  finish(exitedCode ?? 1);
200
303
  }
304
+ boundDrain();
201
305
  }, timeoutMs);
306
+ const abort = () => {
307
+ try {
308
+ process.kill(-p.pid, "SIGKILL");
309
+ }
310
+ catch {
311
+ p.kill("SIGKILL");
312
+ }
313
+ boundDrain();
314
+ };
315
+ options.signal?.addEventListener("abort", abort, { once: true });
316
+ options.onSpawn?.(p.pid);
317
+ if (options.signal?.aborted)
318
+ abort();
202
319
  p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
203
320
  // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
204
321
  // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
@@ -219,22 +336,23 @@ function shell(cmd, cwd, timeoutMs, login) {
219
336
  if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
220
337
  done = true;
221
338
  clearTimeout(timer);
339
+ options.signal?.removeEventListener("abort", abort);
222
340
  resolve({ refused: e });
223
341
  return;
224
342
  }
225
343
  finish(127, String(e));
226
344
  });
227
- p.on("close", (code) => finish(code ?? 1));
345
+ p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
228
346
  // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
229
347
  // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
230
348
  // cannot hold this promise until the command ceiling. A real timeout wins first and is never
231
349
  // reclassified as a grace reap.
232
350
  p.on("exit", (code) => {
351
+ signalExit = code === null;
233
352
  exitedCode = code ?? 1;
234
- if (timedOut) {
235
- finish(exitedCode);
353
+ // Allow a short pipe drain after killing, bounded even for an escaped descendant.
354
+ if (timedOut)
236
355
  return;
237
- }
238
356
  reapTimer = setTimeout(() => {
239
357
  if (done)
240
358
  return;
@@ -255,6 +373,7 @@ function shell(cmd, cwd, timeoutMs, login) {
255
373
  return (async () => {
256
374
  const startedAt = Date.now();
257
375
  for (let n = 1;; n++) {
376
+ options.signal?.throwIfAborted();
258
377
  const r = await attempt();
259
378
  if (!("refused" in r))
260
379
  return r;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type Assignment } from "../adapters/types.js";
3
3
  import type { TickmarkrConfig } from "../config/config.js";
4
- import { type GateName, type Task, type TaskStatus } from "../graph/schema.js";
4
+ import { type GateName, type RunGraph, type Task, type TaskStatus } from "../graph/schema.js";
5
5
  import { type ProfileDiscount, type RoutingProfile } from "../route/profile.js";
6
6
  import { type DecisionEventWrite, type TrackedJournalRow } from "./protocol.js";
7
7
  export interface JournalEvent {
@@ -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
@@ -192,7 +193,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
192
193
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
193
194
  */
194
195
  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"];
196
+ 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
197
  export type ParkKind = (typeof PARK_KINDS)[number];
197
198
  export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
198
199
  export type RetryMode = (typeof RETRY_MODES)[number];
@@ -243,6 +244,8 @@ export declare const TelemetryRowSchema: z.ZodObject<{
243
244
  "merge-conflict": "merge-conflict";
244
245
  "tip-moved": "tip-moved";
245
246
  authoring: "authoring";
247
+ "scope-request": "scope-request";
248
+ "diff-cap": "diff-cap";
246
249
  }>>;
247
250
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
248
251
  input: z.ZodNumber;
@@ -297,6 +300,17 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
297
300
  signalQuality: number;
298
301
  } & Record<string, unknown>;
299
302
  export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
303
+ /** An approval is the durable authority; graph.json is only its materialized projection. */
304
+ export declare const ScopeAmendmentSchema: z.ZodObject<{
305
+ from: z.ZodString;
306
+ to: z.ZodString;
307
+ beforeFiles: z.ZodArray<z.ZodString>;
308
+ files: z.ZodArray<z.ZodString>;
309
+ parkLine: z.ZodNumber;
310
+ }, z.core.$strip>;
311
+ export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
312
+ /** 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;
300
314
  export type EngagementCompare = {
301
315
  comparable: true;
302
316
  recorded: string;