tickmarkr 2.5.3 → 2.5.5

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 (71) hide show
  1. package/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.d.ts +4 -0
  6. package/dist/cli/commands/doctor.js +44 -29
  7. package/dist/cli/commands/fleet.js +195 -33
  8. package/dist/cli/commands/init.js +196 -6
  9. package/dist/cli/commands/plan.js +144 -3
  10. package/dist/cli/commands/resume.js +6 -2
  11. package/dist/cli/commands/run.js +21 -2
  12. package/dist/cli/commands/verify.js +1 -1
  13. package/dist/cli/help.d.ts +2 -0
  14. package/dist/cli/help.js +3 -1
  15. package/dist/compile/collateral.d.ts +2 -0
  16. package/dist/compile/collateral.js +50 -9
  17. package/dist/compile/ownership.d.ts +7 -0
  18. package/dist/compile/ownership.js +59 -22
  19. package/dist/config/config.d.ts +23 -8
  20. package/dist/config/config.js +42 -28
  21. package/dist/config/fleet-overlay.d.ts +3 -9
  22. package/dist/config/fleet-overlay.js +68 -11
  23. package/dist/config/fleet-why.d.ts +7 -0
  24. package/dist/config/fleet-why.js +5 -0
  25. package/dist/drivers/herdr.d.ts +5 -0
  26. package/dist/drivers/herdr.js +14 -3
  27. package/dist/drivers/index.d.ts +15 -1
  28. package/dist/drivers/index.js +38 -10
  29. package/dist/drivers/orca.d.ts +99 -10
  30. package/dist/drivers/orca.js +586 -97
  31. package/dist/drivers/types.d.ts +1 -0
  32. package/dist/gates/baseline.d.ts +3 -0
  33. package/dist/gates/baseline.js +2 -1
  34. package/dist/gates/cache.d.ts +100 -0
  35. package/dist/gates/cache.js +389 -0
  36. package/dist/gates/review.d.ts +35 -2
  37. package/dist/gates/review.js +64 -16
  38. package/dist/gates/run-gates.d.ts +5 -0
  39. package/dist/gates/run-gates.js +134 -18
  40. package/dist/gates/test-manifest.d.ts +99 -0
  41. package/dist/gates/test-manifest.js +389 -0
  42. package/dist/gates/test-reporter.d.ts +4 -0
  43. package/dist/gates/test-reporter.js +49 -0
  44. package/dist/route/preference.d.ts +22 -1
  45. package/dist/route/preference.js +123 -25
  46. package/dist/route/router.d.ts +13 -0
  47. package/dist/route/router.js +95 -17
  48. package/dist/run/daemon.d.ts +13 -0
  49. package/dist/run/daemon.js +398 -96
  50. package/dist/run/git.d.ts +35 -1
  51. package/dist/run/git.js +111 -10
  52. package/dist/run/journal.d.ts +14 -2
  53. package/dist/run/journal.js +78 -10
  54. package/dist/run/lease.d.ts +14 -0
  55. package/dist/run/lease.js +87 -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/tui/cockpit/board.d.ts +96 -0
  61. package/dist/tui/cockpit/board.js +346 -0
  62. package/dist/tui/cockpit/decision-actions.js +2 -0
  63. package/dist/tui/cockpit/layout.d.ts +5 -1
  64. package/dist/tui/cockpit/layout.js +8 -3
  65. package/dist/tui/cockpit/live-runtime.js +83 -31
  66. package/dist/tui/cockpit/run-view.d.ts +7 -5
  67. package/dist/tui/cockpit/run-view.js +12 -11
  68. package/dist/tui/ink/fleet-app.d.ts +41 -27
  69. package/dist/tui/ink/fleet-app.js +204 -31
  70. package/package.json +1 -1
  71. package/skills/tickmarkr-overseer/SKILL.md +180 -99
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,
@@ -89,6 +89,7 @@ export declare const resolvedCapacity: () => RunCapacity;
89
89
  export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
90
90
  export interface ShResult {
91
91
  code: number;
92
+ signalExit?: boolean;
92
93
  stdout: string;
93
94
  stderr: string;
94
95
  timedOut?: boolean;
@@ -104,6 +105,14 @@ export declare const setSpawnForTests: (fn: typeof spawn) => void;
104
105
  export declare const resetSpawnForTests: () => void;
105
106
  export declare const SPAWN_ATTEMPT_LIMIT = 4;
106
107
  export declare const SPAWN_RETRY_BACKOFF_MS = 50;
108
+ export interface ShellOptions {
109
+ env?: NodeJS.ProcessEnv;
110
+ signal?: AbortSignal;
111
+ onSpawn?: (pid: number | undefined) => void;
112
+ onTimeout?: () => void;
113
+ }
114
+ /** Shared command seam, including invocation-bound manifested runners. */
115
+ export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
107
116
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
108
117
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
109
118
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
@@ -179,3 +188,28 @@ export type BaseContainment = {
179
188
  */
180
189
  export declare function declaredBaseContainment(cwd: string, declaredRef: string, targetRef?: string): Promise<BaseContainment>;
181
190
  export declare function removeWorktree(repo: string, dir: string): Promise<void>;
191
+ export declare const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
192
+ export declare const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
193
+ export interface RefsProbeOk {
194
+ ok: true;
195
+ path: string;
196
+ refsDir: string;
197
+ }
198
+ export interface RefsProbeRefusal {
199
+ ok: false;
200
+ path: string;
201
+ refsDir: string;
202
+ error: string;
203
+ }
204
+ export type RefsProbeResult = RefsProbeOk | RefsProbeRefusal;
205
+ export declare function refsRefusalMessage(action: "run" | "resume", path: string, error: string): string;
206
+ /**
207
+ * OBS-983/984: prove the repository's common git directory accepts a ref write before starting
208
+ * the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
209
+ * Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
210
+ * nothing behind on success.
211
+ */
212
+ export declare function probeRefsWritable(cwd?: string): Promise<RefsProbeResult>;
213
+ export declare const probeRefs: typeof probeRefsWritable;
214
+ export declare const refsProbe: typeof probeRefsWritable;
215
+ export declare function assertRefsWritable(cwd?: string, action?: "run" | "resume"): Promise<RefsProbeResult>;
package/dist/run/git.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { spawn } from "node:child_process";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
5
  import { availableParallelism, tmpdir } from "node:os";
5
6
  import { join, resolve } from "node:path";
6
7
  import { StringDecoder } from "node:string_decoder";
@@ -18,7 +19,7 @@ export const DEFAULT_FORK_CAP = "6";
18
19
  /** Worker concurrency is resolved once per run and isolated from other async runs. */
19
20
  const forkBudget = new AsyncLocalStorage();
20
21
  const verificationBudget = new AsyncLocalStorage();
21
- /** Verification owns the serialized suite window; workers keep their concurrency budget. */
22
+ /** Verification uses command leases; workers keep their concurrency budget. */
22
23
  export const runWithVerificationBudget = (capacity, fn) => verificationBudget.run(capacity, fn);
23
24
  /**
24
25
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
@@ -128,14 +129,17 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
128
129
  // RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
129
130
  /** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
130
131
  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) {
132
+ /** Shared command seam, including invocation-bound manifested runners. */
133
+ export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
134
+ return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, options));
135
+ }
136
+ function executeShell(cmd, cwd, timeoutMs, login, options) {
137
+ options.signal?.throwIfAborted();
134
138
  // OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
135
139
  // TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
136
140
  // repo's route() tests red inside the gates. Scrub a copy at this one choke point so
137
141
  // children are hermetic by construction; the daemon's own process.env stays unchanged.
138
- const env = { ...process.env };
142
+ const env = commandLeaseEnvironment(options.env ?? process.env);
139
143
  for (const k of ROUTING_ENV_SEAMS)
140
144
  delete env[k];
141
145
  // A run freezes the operator override and cores at startup; admission can lower the round cap.
@@ -163,17 +167,22 @@ function shell(cmd, cwd, timeoutMs, login) {
163
167
  const stderrDecoder = new StringDecoder("utf8");
164
168
  let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
165
169
  let reapError, exitedCode;
170
+ let signalExit = false;
166
171
  let reapTimer;
172
+ let drainTimer;
167
173
  const finish = (code, err) => {
168
174
  if (done)
169
175
  return;
170
176
  done = true;
171
177
  clearTimeout(timer);
172
178
  clearTimeout(reapTimer);
179
+ clearTimeout(drainTimer);
180
+ options.signal?.removeEventListener("abort", abort);
173
181
  stdout += stdoutDecoder.end();
174
182
  stderr += stderrDecoder.end();
175
183
  resolve({
176
184
  code,
185
+ ...(signalExit ? { signalExit: true } : {}),
177
186
  stdout,
178
187
  stderr: err ?? stderr,
179
188
  timedOut,
@@ -183,8 +192,18 @@ function shell(cmd, cwd, timeoutMs, login) {
183
192
  ...(reapError ? { reapError } : {}),
184
193
  });
185
194
  };
195
+ // Escaped descendants can retain pipes even after the shell and its group are dead.
196
+ const boundDrain = () => {
197
+ if (!done && !drainTimer)
198
+ drainTimer = setTimeout(() => {
199
+ p.stdout?.destroy();
200
+ p.stderr?.destroy();
201
+ finish(exitedCode ?? 1);
202
+ }, 100);
203
+ };
186
204
  const timer = setTimeout(() => {
187
205
  timedOut = true;
206
+ options.onTimeout?.();
188
207
  try {
189
208
  process.kill(-p.pid, "SIGKILL");
190
209
  }
@@ -198,7 +217,21 @@ function shell(cmd, cwd, timeoutMs, login) {
198
217
  if (code !== "ESRCH")
199
218
  finish(exitedCode ?? 1);
200
219
  }
220
+ boundDrain();
201
221
  }, timeoutMs);
222
+ const abort = () => {
223
+ try {
224
+ process.kill(-p.pid, "SIGKILL");
225
+ }
226
+ catch {
227
+ p.kill("SIGKILL");
228
+ }
229
+ boundDrain();
230
+ };
231
+ options.signal?.addEventListener("abort", abort, { once: true });
232
+ options.onSpawn?.(p.pid);
233
+ if (options.signal?.aborted)
234
+ abort();
202
235
  p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
203
236
  // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
204
237
  // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
@@ -219,22 +252,23 @@ function shell(cmd, cwd, timeoutMs, login) {
219
252
  if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
220
253
  done = true;
221
254
  clearTimeout(timer);
255
+ options.signal?.removeEventListener("abort", abort);
222
256
  resolve({ refused: e });
223
257
  return;
224
258
  }
225
259
  finish(127, String(e));
226
260
  });
227
- p.on("close", (code) => finish(code ?? 1));
261
+ p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
228
262
  // "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
229
263
  // to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
230
264
  // cannot hold this promise until the command ceiling. A real timeout wins first and is never
231
265
  // reclassified as a grace reap.
232
266
  p.on("exit", (code) => {
267
+ signalExit = code === null;
233
268
  exitedCode = code ?? 1;
234
- if (timedOut) {
235
- finish(exitedCode);
269
+ // Allow a short pipe drain after killing, bounded even for an escaped descendant.
270
+ if (timedOut)
236
271
  return;
237
- }
238
272
  reapTimer = setTimeout(() => {
239
273
  if (done)
240
274
  return;
@@ -565,3 +599,70 @@ export async function removeWorktree(repo, dir) {
565
599
  await shGit(`rm -rf ${shq(dir)}`, repo);
566
600
  await shGit("git worktree prune", repo);
567
601
  }
602
+ export const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
603
+ export const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
604
+ export function refsRefusalMessage(action, path, error) {
605
+ return `refusing to ${action}: repository refs directory ${path} is not writable (${error}). `
606
+ + `Remedy: ${REFS_PROBE_REMEDY}.`;
607
+ }
608
+ /**
609
+ * OBS-983/984: prove the repository's common git directory accepts a ref write before starting
610
+ * the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
611
+ * Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
612
+ * nothing behind on success.
613
+ */
614
+ export async function probeRefsWritable(cwd = process.cwd()) {
615
+ const refsPathRes = await shGit("git rev-parse --git-path refs", cwd);
616
+ if (refsPathRes.code !== 0) {
617
+ const error = (refsPathRes.stderr || refsPathRes.stdout).trim() || "git rev-parse --git-path refs failed";
618
+ return { ok: false, path: "", refsDir: "", error };
619
+ }
620
+ const raw = refsPathRes.stdout.trim();
621
+ let refsDir = resolve(cwd, raw);
622
+ try {
623
+ refsDir = realpathSync(refsDir);
624
+ }
625
+ catch {
626
+ // keep resolved path if realpath fails
627
+ }
628
+ const headRes = await shGit("git rev-parse HEAD", cwd);
629
+ const target = headRes.code === 0 && headRes.stdout.trim()
630
+ ? headRes.stdout.trim()
631
+ : "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
632
+ const probeId = `probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
633
+ const ref = `${REFS_PREFLIGHT_PREFIX}/${probeId}`;
634
+ const createRes = await shGit(`git update-ref ${shq(ref)} ${shq(target)}`, cwd);
635
+ if (createRes.code !== 0) {
636
+ const error = (createRes.stderr || createRes.stdout).trim() || `git update-ref failed (${createRes.code})`;
637
+ return { ok: false, path: refsDir, refsDir, error };
638
+ }
639
+ const delRes = await shGit(`git update-ref -d ${shq(ref)}`, cwd);
640
+ if (delRes.code !== 0) {
641
+ const error = (delRes.stderr || delRes.stdout).trim() || `git update-ref -d failed (${delRes.code})`;
642
+ return { ok: false, path: refsDir, refsDir, error };
643
+ }
644
+ // Clean up empty directories left behind by git
645
+ try {
646
+ const preflightDir = join(refsDir, "tickmarkr", "preflight");
647
+ if (existsSync(preflightDir) && readdirSync(preflightDir).length === 0) {
648
+ rmSync(preflightDir, { recursive: true, force: true });
649
+ }
650
+ const tmDir = join(refsDir, "tickmarkr");
651
+ if (existsSync(tmDir) && readdirSync(tmDir).length === 0) {
652
+ rmSync(tmDir, { recursive: true, force: true });
653
+ }
654
+ }
655
+ catch {
656
+ // best-effort cleanup of empty dirs
657
+ }
658
+ return { ok: true, path: refsDir, refsDir };
659
+ }
660
+ export const probeRefs = probeRefsWritable;
661
+ export const refsProbe = probeRefsWritable;
662
+ export async function assertRefsWritable(cwd = process.cwd(), action = "run") {
663
+ const probe = await probeRefsWritable(cwd);
664
+ if (!probe.ok) {
665
+ throw new Error(refsRefusalMessage(action, probe.path, probe.error));
666
+ }
667
+ return probe;
668
+ }
@@ -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 {
@@ -192,7 +192,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
192
192
  * a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
193
193
  */
194
194
  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"];
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"];
196
196
  export type ParkKind = (typeof PARK_KINDS)[number];
197
197
  export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
198
198
  export type RetryMode = (typeof RETRY_MODES)[number];
@@ -243,6 +243,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
243
243
  "merge-conflict": "merge-conflict";
244
244
  "tip-moved": "tip-moved";
245
245
  authoring: "authoring";
246
+ "scope-request": "scope-request";
246
247
  }>>;
247
248
  tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
248
249
  input: z.ZodNumber;
@@ -297,6 +298,17 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
297
298
  signalQuality: number;
298
299
  } & Record<string, unknown>;
299
300
  export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
301
+ /** An approval is the durable authority; graph.json is only its materialized projection. */
302
+ export declare const ScopeAmendmentSchema: z.ZodObject<{
303
+ from: z.ZodString;
304
+ to: z.ZodString;
305
+ beforeFiles: z.ZodArray<z.ZodString>;
306
+ files: z.ZodArray<z.ZodString>;
307
+ parkLine: z.ZodNumber;
308
+ }, z.core.$strip>;
309
+ export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
310
+ /** 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;
300
312
  export type EngagementCompare = {
301
313
  comparable: true;
302
314
  recorded: string;
@@ -3,7 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
6
- import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
6
+ import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
7
7
  import { GATE_NAMES, TIERS } from "../graph/schema.js";
8
8
  import { channelRouteIdentity } from "../route/preference.js";
9
9
  import { buildProfile, classify } from "../route/profile.js";
@@ -773,7 +773,7 @@ const DispatchAssignmentSchema = z.object({
773
773
  // routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
774
774
  export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
775
775
  "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
776
- "authoring"];
776
+ "authoring", "scope-request"];
777
777
  // v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
778
778
  // carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
779
779
  // ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
@@ -971,6 +971,63 @@ export function recordedGraphDefinitionHash(events) {
971
971
  }
972
972
  return recorded ?? undefined;
973
973
  }
974
+ /** An approval is the durable authority; graph.json is only its materialized projection. */
975
+ export const ScopeAmendmentSchema = z.object({
976
+ from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
977
+ parkLine: z.number().int().positive(),
978
+ });
979
+ export function replayScopeAmendments(graph, events) {
980
+ const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
981
+ .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
982
+ if (!amendments.length)
983
+ return graph;
984
+ let result = graph;
985
+ // Accept any materialized revision, including a fresh compilation of the original definition.
986
+ // Undo only recognized files[] values. Everything else must still hash to the approved definition.
987
+ const taskIds = new Set(amendments.map(({ event }) => event.taskId));
988
+ for (const id of taskIds) {
989
+ const history = amendments.filter(({ event }) => event.taskId === id);
990
+ const task = result.tasks.find((t) => t.id === id);
991
+ const original = history[0].amendment.beforeFiles;
992
+ if (!task || ![original, ...history.map(({ amendment }) => amendment.files)]
993
+ .some((files) => JSON.stringify(files) === JSON.stringify(task.files))) {
994
+ throw new Error(`refusing scope amendment replay: ${id} files[] changed beyond the approved amendment`);
995
+ }
996
+ result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
997
+ }
998
+ for (const { event, amendment } of amendments) {
999
+ if (graphDefinitionHash(result) !== amendment.from) {
1000
+ throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
1001
+ }
1002
+ result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
1003
+ if (graphDefinitionHash(result) !== amendment.to)
1004
+ throw new Error("refusing invalid scope amendment hash");
1005
+ }
1006
+ return result;
1007
+ }
1008
+ /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
1009
+ export function applyScopeAmendments(graph, journal, auditReplay = false) {
1010
+ const events = journal.read();
1011
+ const result = replayScopeAmendments(graph, events); // validate all before moving any identity
1012
+ const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1013
+ for (const approval of approvals) {
1014
+ const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
1015
+ if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
1016
+ && e.data.from === amendment.from && e.data.to === amendment.to)) {
1017
+ journal.append("graph-rehash", approval.taskId, {
1018
+ from: amendment.from, to: amendment.to, approval: approval.ts, by: approval.data.by, source: "approval",
1019
+ });
1020
+ }
1021
+ }
1022
+ if (auditReplay && graphDefinitionHash(graph) !== graphDefinitionHash(result) && approvals.length) {
1023
+ const recorded = recordedGraphDefinitionHash(journal.read());
1024
+ journal.append("graph-rehash", approvals.at(-1).taskId, {
1025
+ from: recorded, to: graphDefinitionHash(result), approval: approvals.at(-1).ts, source: "approval", replay: true,
1026
+ loaded: graphDefinitionHash(graph),
1027
+ });
1028
+ }
1029
+ return result;
1030
+ }
974
1031
  // THE shared comparator (criterion: status and resume decide through one comparator). status reads
975
1032
  // .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
976
1033
  export function engagementComparable(events, loadedHash) {
@@ -1243,13 +1300,24 @@ export class Journal {
1243
1300
  : { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data: persistedData };
1244
1301
  // T3 secret redaction: only the persisted bytes are masked — the caller's data stays untouched in
1245
1302
  // memory. The narrator receives the persisted (masked) row so a pane sink never shows a credential.
1246
- const line = redactSecrets(JSON.stringify(row));
1247
- appendFileSync(this.journalPath, line + "\n");
1248
- try {
1249
- this.narrate?.(JSON.parse(line));
1303
+ const rows = [row];
1304
+ if (event === "task-approved" && row.data.release === "scope-request") {
1305
+ const amendment = ScopeAmendmentSchema.parse(row.data.amendment);
1306
+ // Publish permission and its graph audit in one append. Otherwise a live sweep can read
1307
+ // permission between the two writes, race the CLI's rehash, and audit the revision twice.
1308
+ rows.push({ ts: row.ts, event: "graph-rehash", taskId: row.taskId, data: {
1309
+ from: amendment.from, to: amendment.to, approval: row.ts, by: row.data.by, source: "approval",
1310
+ } });
1250
1311
  }
1251
- catch {
1252
- // narration is observational; a broken sink must not affect the journal or run
1312
+ const lines = rows.map((entry) => redactSecrets(JSON.stringify(entry)));
1313
+ appendFileSync(this.journalPath, lines.join("\n") + "\n");
1314
+ for (const line of lines) {
1315
+ try {
1316
+ this.narrate?.(JSON.parse(line));
1317
+ }
1318
+ catch {
1319
+ // narration is observational; a broken sink must not affect the journal or run
1320
+ }
1253
1321
  }
1254
1322
  if (evidence && !evidence.written && (event === "judge-retry" || event === "gate-result")) {
1255
1323
  evidence.written = true;
@@ -1337,7 +1405,7 @@ export class Journal {
1337
1405
  outstanding = parsed.success ? { assignment: parsed.data } : {}; // malformed: closable, unattributable
1338
1406
  classified = null;
1339
1407
  }
1340
- else if (e.event === "scope-authoring" && outstanding) {
1408
+ else if ((e.event === "scope-authoring" || e.event === "scope-request") && outstanding) {
1341
1409
  classified = outstanding; // a duplicate classification closes nothing more (same rule as the replay)
1342
1410
  outstanding = null;
1343
1411
  }
@@ -1391,7 +1459,7 @@ export class Journal {
1391
1459
  st.lastAssignment = undefined;
1392
1460
  }
1393
1461
  }
1394
- else if (e.event === "scope-authoring") {
1462
+ else if (e.event === "scope-authoring" || e.event === "scope-request") {
1395
1463
  // OBS-547: the dispatch this event closes was an authoring defect — the spec is missing a
1396
1464
  // files[] line and the worker was never at fault. Rewind its accounting so a resume neither
1397
1465
  // charges the attempt nor treats its channel as burned.
@@ -0,0 +1,14 @@
1
+ export declare function isRunnerCommand(command: string): boolean;
2
+ export declare const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
3
+ export type CommandLease = <T>(command: string, run: (token?: string) => Promise<T>) => Promise<T>;
4
+ /** Async descendants share the reservation; a shell's descendants inherit the same token. */
5
+ export declare function currentCommandLeaseToken(): string | undefined;
6
+ /** Never export a command's token through process.env: concurrent sibling commands must queue. */
7
+ export declare function commandLeaseEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
8
+ export declare const runWithCommandLease: <T>(lease: CommandLease, run: () => Promise<T>) => Promise<T>;
9
+ export declare const withCommandLease: <T>(command: string, run: () => Promise<T>) => Promise<T>;
10
+ /** Each reservation releases only itself, including an aborted waiter. */
11
+ export declare class CommandLeases {
12
+ private readonly owners;
13
+ run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
14
+ }
@@ -0,0 +1,87 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ // The process census and command admission deliberately use the same command-head classifier.
4
+ export function isRunnerCommand(command) {
5
+ // Inspect executable positions only: inference prompts and shell script bodies may name runners.
6
+ let head = command.trim().replace(/['"]/g, "");
7
+ for (;;) {
8
+ const unwrapped = head
9
+ .replace(/^(?:\S*\/)?(?:ba|z)?sh\s+-[a-z]*c\s+/i, "")
10
+ .replace(/^cd\s+[^;&|]+\s*&&\s*/, "")
11
+ .replace(/^(?:env\s+)?(?:[A-Za-z_]\w*=\S+\s+)+/, "");
12
+ if (unwrapped === head)
13
+ break;
14
+ head = unwrapped;
15
+ }
16
+ const binary = "(?:\\S*/)?";
17
+ const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
18
+ const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
19
+ const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
20
+ // Test scripts may be scoped (test:unit); runner binaries may be launched through a manager.
21
+ // Keep each match anchored to that executable position so prompt/argument mentions stay out.
22
+ return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test[\\w:.-]*(?:\\s|$)`, "i").test(head)
23
+ || new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
24
+ || new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
25
+ || /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
26
+ || /^(?:(?:\S*\/)?(?:node|npx)\s+)?(?:\S*\/)?(?:vitest(?:\.mjs)?|jest|mocha|pytest(?:\d+(?:\.\d+)*)?)(?:\s|$)/i.test(head)
27
+ || /^(?:\S*\/)?python[\d.]*\s+-m\s+pytest(?:\s|$)/i.test(head);
28
+ }
29
+ export const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
30
+ const context = new AsyncLocalStorage();
31
+ const ownership = new AsyncLocalStorage();
32
+ /** Async descendants share the reservation; a shell's descendants inherit the same token. */
33
+ export function currentCommandLeaseToken() {
34
+ const owner = ownership.getStore();
35
+ return owner ? (owner.active ? owner.token : undefined) : process.env[COMMAND_LEASE_TOKEN_ENV] || undefined;
36
+ }
37
+ /** Never export a command's token through process.env: concurrent sibling commands must queue. */
38
+ export function commandLeaseEnvironment(env) {
39
+ const child = { ...env };
40
+ const token = currentCommandLeaseToken();
41
+ if (token)
42
+ child[COMMAND_LEASE_TOKEN_ENV] = token;
43
+ else
44
+ delete child[COMMAND_LEASE_TOKEN_ENV];
45
+ return child;
46
+ }
47
+ export const runWithCommandLease = (lease, run) => context.run(lease, run);
48
+ export const withCommandLease = (command, run) => {
49
+ const lease = context.getStore();
50
+ if (!lease || !isRunnerCommand(command) || currentCommandLeaseToken())
51
+ return run();
52
+ return lease(command, token => {
53
+ const owner = { token: token ?? randomUUID(), active: true };
54
+ return ownership.run(owner, async () => {
55
+ try {
56
+ return await run();
57
+ }
58
+ finally {
59
+ owner.active = false;
60
+ } // A continuation after release must acquire a new lease.
61
+ });
62
+ });
63
+ };
64
+ /** Each reservation releases only itself, including an aborted waiter. */
65
+ export class CommandLeases {
66
+ owners = new Set();
67
+ async run(run, waiting, pollMs, signal) {
68
+ const owner = Symbol("command");
69
+ this.owners.add(owner);
70
+ try {
71
+ let reported = false;
72
+ while (this.owners.values().next().value !== owner) {
73
+ signal?.throwIfAborted();
74
+ if (!reported) {
75
+ waiting(this.owners.size - 1);
76
+ reported = true;
77
+ }
78
+ await new Promise((wake) => setTimeout(wake, pollMs));
79
+ }
80
+ signal?.throwIfAborted();
81
+ return await run();
82
+ }
83
+ finally {
84
+ this.owners.delete(owner);
85
+ }
86
+ }
87
+ }
@@ -8,6 +8,8 @@ export interface TipVerifyResult {
8
8
  fingerprints: string[];
9
9
  details: string;
10
10
  artifact?: string;
11
+ reportPath?: string;
12
+ spawnedCommand?: string;
11
13
  /** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
12
14
  forgiven?: boolean;
13
15
  /**