tickmarkr 2.5.4 → 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 (59) 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.js +44 -29
  6. package/dist/cli/commands/fleet.js +195 -33
  7. package/dist/cli/commands/init.js +196 -6
  8. package/dist/cli/commands/resume.js +4 -2
  9. package/dist/cli/commands/run.js +11 -2
  10. package/dist/cli/commands/verify.js +1 -1
  11. package/dist/cli/help.d.ts +2 -0
  12. package/dist/cli/help.js +3 -1
  13. package/dist/config/config.d.ts +14 -8
  14. package/dist/config/config.js +29 -26
  15. package/dist/config/fleet-overlay.d.ts +3 -9
  16. package/dist/config/fleet-overlay.js +68 -11
  17. package/dist/config/fleet-why.d.ts +7 -0
  18. package/dist/config/fleet-why.js +5 -0
  19. package/dist/drivers/index.d.ts +15 -1
  20. package/dist/drivers/index.js +38 -10
  21. package/dist/drivers/orca.d.ts +99 -10
  22. package/dist/drivers/orca.js +586 -97
  23. package/dist/gates/baseline.d.ts +3 -0
  24. package/dist/gates/baseline.js +2 -1
  25. package/dist/gates/cache.d.ts +100 -0
  26. package/dist/gates/cache.js +389 -0
  27. package/dist/gates/run-gates.d.ts +3 -0
  28. package/dist/gates/run-gates.js +117 -10
  29. package/dist/gates/test-manifest.d.ts +99 -0
  30. package/dist/gates/test-manifest.js +389 -0
  31. package/dist/gates/test-reporter.d.ts +4 -0
  32. package/dist/gates/test-reporter.js +49 -0
  33. package/dist/route/preference.d.ts +22 -1
  34. package/dist/route/preference.js +123 -25
  35. package/dist/route/router.js +31 -6
  36. package/dist/run/daemon.d.ts +13 -0
  37. package/dist/run/daemon.js +160 -70
  38. package/dist/run/git.d.ts +10 -1
  39. package/dist/run/git.js +43 -9
  40. package/dist/run/journal.d.ts +14 -2
  41. package/dist/run/journal.js +78 -10
  42. package/dist/run/lease.d.ts +14 -0
  43. package/dist/run/lease.js +87 -0
  44. package/dist/run/merge.d.ts +2 -0
  45. package/dist/run/merge.js +91 -3
  46. package/dist/run/operator-state.d.ts +11 -0
  47. package/dist/run/operator-state.js +17 -3
  48. package/dist/tui/cockpit/board.d.ts +96 -0
  49. package/dist/tui/cockpit/board.js +346 -0
  50. package/dist/tui/cockpit/decision-actions.js +2 -0
  51. package/dist/tui/cockpit/layout.d.ts +5 -1
  52. package/dist/tui/cockpit/layout.js +8 -3
  53. package/dist/tui/cockpit/live-runtime.js +71 -21
  54. package/dist/tui/cockpit/run-view.d.ts +7 -5
  55. package/dist/tui/cockpit/run-view.js +12 -11
  56. package/dist/tui/ink/fleet-app.d.ts +41 -27
  57. package/dist/tui/ink/fleet-app.js +204 -31
  58. package/package.json +1 -1
  59. package/skills/tickmarkr-overseer/SKILL.md +173 -113
package/dist/run/git.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { spawn } from "node:child_process";
3
4
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -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;
@@ -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
  /**
package/dist/run/merge.js CHANGED
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
5
+ import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
6
+ import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
5
7
  import { tickmarkrDir } from "../graph/graph.js";
6
- import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
8
+ import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
7
9
  export function integrationBranch(cfg, runId) {
8
10
  return `${cfg.integrationBranchPrefix}${runId}`;
9
11
  }
@@ -120,6 +122,11 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
120
122
  }
121
123
  return results;
122
124
  }
125
+ const stateDir = resolveStateDir(intWt, runDir);
126
+ const store = getVerdictStore(stateDir);
127
+ // Publish only after the entire verification completes. The daemon kills a cancelled
128
+ // verifier process, so completed early gates must remain in memory until then.
129
+ const pending = new Map();
123
130
  for (const [gate, cmd] of gatesToRun) {
124
131
  if (runStartCommands !== undefined) {
125
132
  const expectedCmd = gate === "test"
@@ -150,6 +157,73 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
150
157
  const entry = gate === "test" && (hasTipCommand || (!identity.test && identity.tipTest))
151
158
  ? baseline?.commands.tipTest
152
159
  : baseline?.commands[gate];
160
+ // A distinct tipTest selects a different forgiveness entry even when the command
161
+ // converges with the task command. Include that effective entry in the identity.
162
+ const verificationBaseline = baseline ? { commands: { ...baseline.commands } } : undefined;
163
+ if (verificationBaseline) {
164
+ if (entry)
165
+ verificationBaseline.commands[gate] = entry;
166
+ else
167
+ delete verificationBaseline.commands[gate];
168
+ }
169
+ // A tip verifier may reuse only a completed green from its own scope.
170
+ const id = await computeVerificationIdentity({
171
+ worktree: intWt,
172
+ gate,
173
+ scope: "tip",
174
+ command: cmd,
175
+ baseline: verificationBaseline,
176
+ selectedSet: undefined,
177
+ capacity: resolvedCapacity(),
178
+ });
179
+ // Persistent reuse requires the run's durable journal. Without it there is no
180
+ // recorded cycle provenance to reconcile with the caller's capacity checks.
181
+ const hit = journalFound ? store.get(id) : undefined;
182
+ if (hit && id && hit.pass && !isInfraResult(hit)) {
183
+ results.push({
184
+ gate,
185
+ cmd,
186
+ pass: hit.pass,
187
+ exitCode: hit.exitCode ?? 0,
188
+ fingerprints: hit.meta?.fingerprints ?? [],
189
+ ...(hit.meta?.forgiven ? { forgiven: true } : {}),
190
+ details: formatReusedDetails(hit.details, id),
191
+ ...(hit.meta?.reportPath ? { reportPath: hit.meta.reportPath } : {}),
192
+ ...(hit.meta?.spawnedCommand ? { spawnedCommand: hit.meta.spawnedCommand } : {}),
193
+ ...(hit.meta?.artifact ? { artifact: hit.meta.artifact } : {}),
194
+ });
195
+ continue;
196
+ }
197
+ if (id && journalFound)
198
+ pending.set(gate, id);
199
+ // VL-1: tip verify and standalone verify read the SAME invocation-bound report the gate would —
200
+ // a detected vitest test command never reaches the stdout-count/file-count path below (this
201
+ // gate's own manifest is always the FULL suite; verify has no selected screen to hold). A
202
+ // scripted test command that is not the detected runner falls through unchanged.
203
+ if (gate === "test" && isVitestTestCommand(cmd, intWt)) {
204
+ const outcome = await evaluateManifestedTest(cmd, intWt, {
205
+ baselineDurations: entry?.fileDurations,
206
+ longestFile: entry?.longestFile,
207
+ overallCeilingMs: effectiveCeilingMs(entry),
208
+ artifactDir: runDir,
209
+ });
210
+ const artifact = join(runDir, `tip-verify-${gate}.log`);
211
+ if (!outcome.pass)
212
+ writeFileSync(artifact, outcome.details);
213
+ results.push({
214
+ gate,
215
+ cmd,
216
+ pass: outcome.pass,
217
+ exitCode: outcome.exitCode,
218
+ reportPath: outcome.reportPath,
219
+ spawnedCommand: outcome.meta.spawnedCommand,
220
+ fingerprints: outcome.meta.failingTests ?? [],
221
+ details: outcome.details,
222
+ ...(outcome.classification ? { cause: outcome.classification } : {}),
223
+ ...(outcome.pass ? {} : { artifact }),
224
+ });
225
+ continue;
226
+ }
153
227
  // OBS-534: the ceiling is the BATTERY's, derived by effectiveCeilingMs from the same baseline entry
154
228
  // this loop already reads for forgiveness two lines down — never the flat DEFAULT_SHELL_TIMEOUT_MS
155
229
  // `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
@@ -216,7 +290,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
216
290
  const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
217
291
  if (!pass)
218
292
  writeFileSync(artifact, raw);
219
- results.push({
293
+ const tipResult = {
220
294
  gate,
221
295
  cmd,
222
296
  pass,
@@ -233,7 +307,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
233
307
  ...(forgiven && !deficit ? { forgiven: true } : {}),
234
308
  ...(cause ? { cause } : {}),
235
309
  ...(pass ? {} : { artifact }),
236
- });
310
+ };
311
+ results.push(tipResult);
312
+ }
313
+ // Recheck through the Git runner before publishing: cancellation disables further
314
+ // spawns in the daemon child, and a command that changed the tree invalidates reuse.
315
+ const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
316
+ for (const result of results) {
317
+ const id = pending.get(result.gate);
318
+ if (id && id.tree === completedTree && !isInfraResult(result)) {
319
+ store.set(id, { ...result, meta: {
320
+ fingerprints: result.fingerprints, forgiven: result.forgiven,
321
+ reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
322
+ artifact: result.artifact,
323
+ } });
324
+ }
237
325
  }
238
326
  return results;
239
327
  }
@@ -30,11 +30,19 @@ export interface OperatorTask {
30
30
  evidence?: EvidenceIdentity;
31
31
  mergeEvidence?: EvidenceIdentity;
32
32
  gates: Record<string, OperatorGate>;
33
+ /** BD-1: the board's per-task counters — latest dispatch channel, review verdicts drawn, human parks. */
34
+ channel?: string;
35
+ reviewRounds: number;
36
+ parks: number;
33
37
  }
34
38
  export interface OperatorSnapshot {
35
39
  sequence: number;
36
40
  observedAt: number;
37
41
  lastEventAt?: string;
42
+ firstEventAt?: string;
43
+ /** BD-1 header counts: run-resume and escalation rows seen. */
44
+ resumes: number;
45
+ escalations: number;
38
46
  lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
39
47
  label: string;
40
48
  green: boolean;
@@ -63,6 +71,9 @@ export declare class OperatorStateFold {
63
71
  private approved;
64
72
  private tipFailed;
65
73
  private lastEventAt?;
74
+ private firstEventAt?;
75
+ private resumes;
76
+ private escalations;
66
77
  private passed;
67
78
  private total;
68
79
  apply(record: OperatorRecord): void;
@@ -16,11 +16,19 @@ export class OperatorStateFold {
16
16
  approved = false;
17
17
  tipFailed = false;
18
18
  lastEventAt;
19
+ firstEventAt;
20
+ resumes = 0;
21
+ escalations = 0;
19
22
  passed = 0;
20
23
  total = 0;
21
24
  apply(record) {
22
25
  const { event: e } = record;
23
26
  this.lastEventAt = e.ts;
27
+ this.firstEventAt ??= e.ts;
28
+ if (e.event === "run-resume")
29
+ this.resumes++;
30
+ if (e.event === "escalation")
31
+ this.escalations++;
24
32
  const ref = identity(record);
25
33
  if (e.event === "run-start" || e.event === "run-resume") {
26
34
  if (e.event === "run-start" && !this.start) {
@@ -72,6 +80,9 @@ export class OperatorStateFold {
72
80
  t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
73
81
  t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
74
82
  t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
83
+ const a = e.data.assignment;
84
+ if (a && typeof a.adapter === "string" && typeof a.model === "string")
85
+ t.channel = `${a.adapter}:${a.model}`;
75
86
  }
76
87
  if (e.event === "task-done")
77
88
  t.state = "completed";
@@ -85,6 +96,7 @@ export class OperatorStateFold {
85
96
  t.state = "failed";
86
97
  if (e.event === "task-human") {
87
98
  t.state = "human";
99
+ t.parks++;
88
100
  t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
89
101
  }
90
102
  if (e.event === "task-blocked")
@@ -98,6 +110,8 @@ export class OperatorStateFold {
98
110
  if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
99
111
  if (e.event === "gate-start")
100
112
  t.gates[gate] = { state: "running", evidence: ref };
113
+ if (e.event === "gate-result" && gate === "review")
114
+ t.reviewRounds++;
101
115
  if (e.event === "gate-result")
102
116
  t.gates[gate] = {
103
117
  state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
@@ -107,7 +121,7 @@ export class OperatorStateFold {
107
121
  task(id) {
108
122
  let t = this.tasks.get(id);
109
123
  if (!t) {
110
- t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
124
+ t = { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
111
125
  this.tasks.set(id, t);
112
126
  }
113
127
  return t;
@@ -117,7 +131,7 @@ export class OperatorStateFold {
117
131
  const comparable = this.comparableTo(hash);
118
132
  const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
119
133
  const tasks = ids.map(id => {
120
- const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
134
+ const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
121
135
  return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
122
136
  });
123
137
  const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
@@ -134,7 +148,7 @@ export class OperatorStateFold {
134
148
  && bucketNames.every(k => buckets[k]?.length === 0);
135
149
  const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
136
150
  return {
137
- sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
151
+ sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
138
152
  label: this.approved ? "approved; resume required" : lifecycle,
139
153
  green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
140
154
  merged,