tickmarkr 2.6.6 → 2.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +6 -1
  6. package/dist/cli/commands/beat.js +123 -76
  7. package/dist/cli/commands/fleet.js +64 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/help.d.ts +1 -0
  10. package/dist/cli/help.js +10 -5
  11. package/dist/cli/index.d.ts +1 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/gates/baseline.d.ts +9 -0
  14. package/dist/gates/baseline.js +14 -3
  15. package/dist/gates/cache.js +1 -1
  16. package/dist/gates/review.js +15 -0
  17. package/dist/gates/run-gates.d.ts +18 -6
  18. package/dist/gates/run-gates.js +324 -204
  19. package/dist/gates/test-manifest.d.ts +8 -0
  20. package/dist/gates/test-manifest.js +151 -111
  21. package/dist/run/beat-lifecycle.d.ts +154 -0
  22. package/dist/run/beat-lifecycle.js +825 -0
  23. package/dist/run/daemon.js +18 -7
  24. package/dist/run/git-trust.d.ts +27 -0
  25. package/dist/run/git-trust.js +178 -0
  26. package/dist/run/git.js +34 -14
  27. package/dist/run/journal.js +15 -3
  28. package/dist/run/lease.d.ts +23 -0
  29. package/dist/run/lease.js +232 -38
  30. package/dist/run/merge.js +2 -2
  31. package/dist/run/stall.js +20 -1
  32. package/dist/run/supervision.d.ts +8 -0
  33. package/dist/run/supervision.js +24 -4
  34. package/dist/run/verification-job.d.ts +51 -0
  35. package/dist/run/verification-job.js +137 -0
  36. package/dist/tui/ink/fleet-app.d.ts +9 -0
  37. package/dist/tui/ink/fleet-app.js +209 -127
  38. package/package.json +1 -1
  39. package/skills/tickmarkr-auto/SKILL.md +19 -10
  40. package/skills/tickmarkr-loop/SKILL.md +19 -10
  41. package/skills/tickmarkr-overseer/SKILL.md +25 -16
package/dist/run/lease.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { execFile } from "node:child_process";
2
+ import { execFile, execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { createHash } from "node:crypto";
6
- import { join, resolve } from "node:path";
6
+ import { join } from "node:path";
7
+ import { trustedCommonDir } from "./git-trust.js";
7
8
  // The process census and command admission deliberately use the same command-head classifier.
8
9
  export function isRunnerCommand(command) {
9
10
  // Inspect executable positions only: inference prompts and shell script bodies may name runners.
@@ -119,14 +120,13 @@ export async function reentrantHolder(path, inherited) {
119
120
  if (!inherited)
120
121
  return undefined;
121
122
  const holder = readHolder(path);
122
- if (holder?.token !== inherited || holder.pid === process.pid || !alive(holder.pid))
123
+ if (holder?.token !== inherited || holder.pid === process.pid || !holderAlive(holder))
123
124
  return undefined;
124
125
  return (await ancestorPids()).has(holder.pid) ? holder : undefined;
125
126
  }
126
127
  const LEASE_FILE = "tickmarkr-runner.lease";
127
128
  export async function repositoryLeasePath(cwd) {
128
- const commonDir = await new Promise((ok, fail) => execFile("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => error ? fail(error) : ok(stdout.trim())));
129
- return join(resolve(cwd, commonDir), LEASE_FILE);
129
+ return join(trustedCommonDir(cwd), LEASE_FILE);
130
130
  }
131
131
  const alive = (pid) => {
132
132
  try {
@@ -303,9 +303,9 @@ const reclaimDeadLocked = (path, identity) => {
303
303
  };
304
304
  /** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
305
305
  export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
306
- const inspectAndReserve = (path, mine) => {
306
+ const inspectAndReserve = async (path, mine) => {
307
307
  let occupant = readOccupant(path);
308
- if (occupant && (!occupant.holder || !alive(occupant.holder.pid))) {
308
+ if (occupant && (!occupant.holder || (!holderAlive(occupant.holder) && !(occupant.holder.protectOrphans && (await protectedProcesses(occupant.holder)).length)))) {
309
309
  reclaimDeadLocked(path, occupant.identity);
310
310
  occupant = readOccupant(path);
311
311
  }
@@ -313,43 +313,237 @@ const inspectAndReserve = (path, mine) => {
313
313
  return { acquired: true };
314
314
  return { acquired: false, holder: occupant?.holder };
315
315
  };
316
- export async function withRepositoryLease(cwd, run, opts = {}) {
316
+ // A durable capability is also a crash owner: census checks the exact generation's inherited
317
+ // environment and recorded process groups, including live orphans reparented after owner death.
318
+ // An unreadable census refuses reclamation/release. No dead-pid shortcut admits over a live tree.
319
+ export async function protectedProcesses(holder, tokenEnv = REPOSITORY_LEASE_TOKEN_ENV, ownedOnly = false) {
320
+ let censusPid;
321
+ const rows = await new Promise((ok, fail) => {
322
+ const census = execFile("ps", ["eww", "-A", "-o", "pid=,ppid=,pgid=,stat=,command="], { encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => error ? fail(error) : ok(stdout));
323
+ censusPid = census.pid;
324
+ });
325
+ const token = `${tokenEnv}=${holder.token}`;
326
+ const lines = rows.split("\n");
327
+ const safeGroups = new Set((holder.roots ?? []).filter(pid => {
328
+ const leader = lines.some(line => new RegExp(`^\\s*${pid}\\s+\\d+\\s+${pid}\\s+`).test(line));
329
+ // A leaderless group still belongs to its recorded root; a reused leader must match its birth.
330
+ return !leader || sameProcessBirth(processBirth(pid), holder.rootBirths?.[pid]);
331
+ }));
332
+ const parsed = lines.flatMap(line => {
333
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
334
+ return match ? [{ pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3]), stat: match[4], command: match[5] }] : [];
335
+ });
336
+ const parents = new Map(parsed.map(row => [row.pid, row.parent]));
337
+ const descendant = (pid) => {
338
+ const seen = new Set();
339
+ for (let at = parents.get(pid) ?? 0; at > 1 && !seen.has(at); at = parents.get(at) ?? 0) {
340
+ if (at === holder.pid)
341
+ return true;
342
+ seen.add(at);
343
+ }
344
+ return false;
345
+ };
346
+ return parsed.flatMap(row => {
347
+ if (row.stat.startsWith("Z") || row.pid === holder.pid || row.pid === censusPid)
348
+ return [];
349
+ const group = safeGroups.has(row.group);
350
+ const capability = row.command.split(/\s+/).includes(token);
351
+ // Copied environment text outside the owned tree can conservatively block reclamation, but
352
+ // can never authorize signalling an unrelated process. Birth-checked groups survive reparenting.
353
+ return group || (capability && (!ownedOnly || descendant(row.pid))) ? [row.pid] : [];
354
+ });
355
+ }
356
+ const repositoryOwnership = new AsyncLocalStorage();
357
+ export const repositoryLeaseEnvironment = (env) => {
358
+ const own = repositoryOwnership.getStore();
359
+ if (!own)
360
+ return { ...env };
361
+ const child = { ...env };
362
+ if (own.active)
363
+ child[REPOSITORY_LEASE_TOKEN_ENV] = own.holder.token;
364
+ else
365
+ delete child[REPOSITORY_LEASE_TOKEN_ENV];
366
+ return child;
367
+ };
368
+ /** Called synchronously at spawn, before any user callback, and only on an owned generation. */
369
+ export function registerRepositoryChild(pid) {
370
+ const own = repositoryOwnership.getStore();
371
+ if (!own?.active || !own.holder.protectOrphans || pid === undefined)
372
+ return;
373
+ if (readHolder(own.path)?.token !== own.holder.token)
374
+ throw new Error("verification reservation superseded before child registration");
375
+ own.holder.roots = [...new Set([...(readHolder(own.path)?.roots ?? []), pid])];
376
+ const birth = processBirth(pid);
377
+ own.holder.rootBirths = { ...readHolder(own.path)?.rootBirths, ...(birth ? { [pid]: birth } : {}) };
378
+ const draft = `${own.path}.${own.holder.token}.${process.pid}.roots`;
379
+ try {
380
+ writeFileSync(draft, JSON.stringify(own.holder) + "\n");
381
+ renameSync(draft, own.path);
382
+ }
383
+ finally {
384
+ rmSync(draft, { force: true });
385
+ }
386
+ }
387
+ export const withFreshCommandLease = (command, run) => ownership.run({ token: "", active: false }, () => withCommandLease(command, run));
388
+ const repositoryQueues = new Map();
389
+ /** The standalone wrapper uses the same command-then-repository order when a scheduler exists.
390
+ * Without one (CLI/global setup), this remains exactly the outer file reservation API. */
391
+ export function withRepositoryLease(cwd, run, opts = {}) {
392
+ return opts.isolated ? repositoryLease(cwd, run, opts)
393
+ : withCommandLease("vitest run", () => repositoryLease(cwd, run, opts));
394
+ }
395
+ export const hasRepositoryLeaseOwnership = () => repositoryOwnership.getStore()?.active === true;
396
+ async function repositoryLease(cwd, run, opts) {
317
397
  const path = await repositoryLeasePath(cwd);
318
- // A descendant of the live holder runs inside its ancestor's reservation and releases nothing.
319
- if (await reentrantHolder(path, opts.inherited ?? process.env[REPOSITORY_LEASE_TOKEN_ENV]))
320
- return run();
398
+ const own = repositoryOwnership.getStore();
399
+ // Only the private async capability can reenter within this process. Environment text cannot.
400
+ if (!opts.independent && own?.active && own.path === path && readHolder(path)?.token === own.holder.token) {
401
+ opts.onAdmission?.(own.holder);
402
+ const value = await run();
403
+ opts.onRelease?.(); // this caller's span ends; the outer reservation stays owned
404
+ return value;
405
+ }
406
+ if (await reentrantHolder(path, opts.inherited ?? process.env[REPOSITORY_LEASE_TOKEN_ENV])) {
407
+ const holder = readHolder(path);
408
+ const inheritedOwner = { path, holder, active: true };
409
+ return repositoryOwnership.run(inheritedOwner, async () => {
410
+ opts.onAdmission?.(holder);
411
+ try {
412
+ const value = await run();
413
+ opts.onRelease?.();
414
+ return value;
415
+ }
416
+ finally {
417
+ inheritedOwner.active = false;
418
+ }
419
+ });
420
+ }
321
421
  const pollMs = opts.pollMs ?? 1_000;
322
- const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
422
+ const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID(),
423
+ ...(opts.protectOrphans !== false ? { protectOrphans: true, roots: [], identity: ownerIdentity() } : {}) };
424
+ const queue = repositoryQueues.get(path) ?? new Set();
425
+ repositoryQueues.set(path, queue);
426
+ const ticket = Symbol("repository job");
427
+ queue.add(ticket);
323
428
  let waitingOn;
324
- for (;;) {
325
- opts.signal?.throwIfAborted();
326
- const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
327
- if (attempt.acquired)
328
- break;
329
- if (attempt.holder && attempt.holder.pid !== waitingOn) {
330
- waitingOn = attempt.holder.pid;
331
- opts.onWait?.(attempt.holder);
429
+ const waitOn = (holder) => {
430
+ if (holder && holder.token !== waitingOn) {
431
+ waitingOn = holder.token;
432
+ opts.onWait?.(holder);
332
433
  }
333
- await new Promise((wake) => setTimeout(wake, pollMs));
334
- }
335
- // Exported before run() so every descendant forked or spawned during the hold can reenter; restored
336
- // at release unless another reservation in this process has since exported its own.
337
- const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
338
- process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
434
+ };
339
435
  try {
340
- return await run();
436
+ for (;;) {
437
+ opts.signal?.throwIfAborted();
438
+ if (queue.values().next().value === ticket) {
439
+ const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
440
+ if (attempt.acquired)
441
+ break;
442
+ waitOn(attempt.holder);
443
+ }
444
+ else
445
+ waitOn(readHolder(path));
446
+ await new Promise(wake => setTimeout(wake, pollMs));
447
+ }
448
+ const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
449
+ if (!opts.isolated)
450
+ process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
451
+ const owner = { path, holder: mine, active: true };
452
+ try {
453
+ return await repositoryOwnership.run(owner, async () => {
454
+ opts.signal?.throwIfAborted();
455
+ opts.onAdmission?.(mine);
456
+ return run();
457
+ });
458
+ }
459
+ finally {
460
+ owner.active = false;
461
+ if (!opts.isolated && process.env[REPOSITORY_LEASE_TOKEN_ENV] === mine.token) {
462
+ if (exported === undefined)
463
+ delete process.env[REPOSITORY_LEASE_TOKEN_ENV];
464
+ else
465
+ process.env[REPOSITORY_LEASE_TOKEN_ENV] = exported;
466
+ }
467
+ // All token-bound children, including escaped descendants, cease before release. Shell has
468
+ // already reaped its group; this closes the inherited-pipe/escaped-child boundary as well.
469
+ if (mine.protectOrphans)
470
+ await reapRepositoryChildren(mine, pollMs).catch(async (error) => {
471
+ // A failed census or reap deadline must not leave a settled job naming a live owner
472
+ // forever. Retain the exact generation and its roots for the orphan rule: admission
473
+ // still requires a successful census proving the protected tree has ceased.
474
+ await withMutationLock(path, pollMs, undefined, () => {
475
+ const holder = readHolder(path);
476
+ if (holder?.token !== mine.token)
477
+ return;
478
+ const draft = `${path}.${mine.token}.${process.pid}.released`;
479
+ try {
480
+ writeFileSync(draft, JSON.stringify({ ...holder, ownerReleased: true }) + "\n");
481
+ renameSync(draft, path);
482
+ }
483
+ finally {
484
+ rmSync(draft, { force: true });
485
+ }
486
+ });
487
+ throw error;
488
+ });
489
+ await withMutationLock(path, pollMs, undefined, () => {
490
+ if (readHolder(path)?.token === mine.token)
491
+ unlinkSync(path);
492
+ });
493
+ opts.onRelease?.();
494
+ }
341
495
  }
342
496
  finally {
343
- if (process.env[REPOSITORY_LEASE_TOKEN_ENV] === mine.token) {
344
- if (exported === undefined)
345
- delete process.env[REPOSITORY_LEASE_TOKEN_ENV];
346
- else
347
- process.env[REPOSITORY_LEASE_TOKEN_ENV] = exported;
497
+ queue.delete(ticket);
498
+ if (!queue.size)
499
+ repositoryQueues.delete(path);
500
+ // A waiter removes no repository generation, including cancellation before acquisition.
501
+ }
502
+ }
503
+ function processBirth(pid) {
504
+ try {
505
+ if (process.platform === "linux") {
506
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
507
+ return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
348
508
  }
349
- // Release is serialized with reclamation/acquisition and removes only this generation.
350
- await withMutationLock(path, pollMs, undefined, () => {
351
- if (readHolder(path)?.token === mine.token)
352
- unlinkSync(path);
353
- });
509
+ return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
354
510
  }
511
+ catch {
512
+ return undefined;
513
+ }
514
+ }
515
+ function ownerIdentity() {
516
+ const birth = processBirth(process.pid);
517
+ // Match git.ts's independent identity format without changing recorded raw root births.
518
+ return birth ? `${process.pid}:${canonicalBirth(birth)}` : undefined;
519
+ }
520
+ const canonicalBirth = (birth) => birth.trim().replace(/\s+/g, " ");
521
+ const sameProcessBirth = (actual, expected) => actual !== undefined && expected !== undefined && canonicalBirth(actual) === canonicalBirth(expected);
522
+ export async function reapRepositoryChildren(holder, pollMs, tokenEnv = REPOSITORY_LEASE_TOKEN_ENV) {
523
+ const deadline = Date.now() + 15_000;
524
+ for (const pid of await protectedProcesses(holder, tokenEnv, true)) {
525
+ try {
526
+ process.kill(pid, "SIGKILL");
527
+ }
528
+ catch (error) {
529
+ if (!isCode(error, "ESRCH"))
530
+ throw error;
531
+ }
532
+ }
533
+ while ((await protectedProcesses(holder, tokenEnv)).length) {
534
+ if (Date.now() >= deadline)
535
+ throw new Error("verification children did not cease; reservation retained");
536
+ await new Promise(wake => setTimeout(wake, pollMs));
537
+ }
538
+ }
539
+ function holderAlive(holder) {
540
+ if (holder.ownerReleased === true)
541
+ return false;
542
+ if (!alive(holder.pid))
543
+ return false;
544
+ if (!holder.identity)
545
+ return true; // legacy live holder remains protected
546
+ const birth = processBirth(holder.pid);
547
+ // Older live generations may contain ps's space-padded day. Formatting cannot prove owner death.
548
+ return birth === undefined || sameProcessBirth(`${holder.pid}:${birth}`, holder.identity);
355
549
  }
package/dist/run/merge.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
- import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
4
+ import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, classifiedFileDurations, fingerprint, freshFailures, } from "../gates/baseline.js";
5
5
  import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
6
6
  import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
7
7
  import { tickmarkrDir } from "../graph/graph.js";
@@ -248,7 +248,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
248
248
  const manifestedEvidence = { ...evidenceSetup, artifactDir: evidence.root };
249
249
  evidenceRoots.set(gate, evidence.root);
250
250
  const outcome = await evaluateManifestedTest(cmd, intWt, {
251
- baselineDurations: entry?.fileDurations,
251
+ baselineDurations: classifiedFileDurations(entry),
252
252
  longestFile: entry?.longestFile,
253
253
  overallCeilingMs: effectiveCeilingMs(entry),
254
254
  artifactDir: runDir,
package/dist/run/stall.js CHANGED
@@ -286,7 +286,26 @@ async function reapWorkerProcesses(group, cwd, ownership) {
286
286
  // OBS-1173 add.2: a FAILED recheck (null) is unknown too — it never reads as gone.
287
287
  if (!details) {
288
288
  const recheck = await workerReapHost.identity(row.pid);
289
- return recheck === row.identity || recheck === null ? "unknown" : "spared";
289
+ if (recheck === null)
290
+ return "unknown";
291
+ if (recheck !== row.identity)
292
+ return "spared";
293
+ // An exiting child loses its cwd before it is reaped yet keeps its birth identity (a zombie, or
294
+ // on its way to one). The snapshot drops Z rows, so a fresh snapshot that no longer lists this
295
+ // pid and identity says it has finished; a still-listed live row with no readable cwd stays
296
+ // unknown, and so does an unreadable snapshot (public CI 37302193795, D-1263). A process still
297
+ // exiting can stay listed for a moment after its cwd is gone, so read up to three fresh
298
+ // snapshots 100 ms apart before calling it unknown (public CI 37324617042, D-1278).
299
+ for (let read = 0; read < 3; read++) {
300
+ if (read)
301
+ await new Promise((resolve) => setTimeout(resolve, 100));
302
+ const fresh = await workerReapHost.snapshot();
303
+ if (!fresh)
304
+ return "unknown";
305
+ if (!fresh.some((r) => r.pid === row.pid && r.identity === row.identity))
306
+ return "spared";
307
+ }
308
+ return "unknown";
290
309
  }
291
310
  if (details.suiteParent === process.pid || excludedPaths.some((path) => below(path, details.cwd)))
292
311
  return "spared";
@@ -13,6 +13,11 @@ export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
13
13
  * to create fresh state there or to wander into an enclosing repository. This function is read-only.
14
14
  */
15
15
  export declare function resolveSupervisionRoot(cwd: string): string;
16
+ /** Read-only twin for readers: a repository without a state dir is a root with no state, not a refusal. */
17
+ export declare function locateSupervisionRoot(cwd: string): {
18
+ root: string;
19
+ state: boolean;
20
+ };
16
21
  export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
17
22
  export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
18
23
  /** Does this tier's record have to name the seat it speaks for? */
@@ -46,8 +51,11 @@ export interface SupervisionArm {
46
51
  markerFence: string;
47
52
  }
48
53
  export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
54
+ /** The durable arm on disk, undefined when none; a torn or invalid arm throws rather than reading as none. */
55
+ export declare function readSupervisionArm(repoRoot: string, tier: SupervisionTier): SupervisionArm | undefined;
49
56
  /** Explicit arming acknowledges the current marker without deleting it. */
50
57
  export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
58
+ export declare function publishStandDown(repoRoot: string, tier: SupervisionTier, seat: string): void;
51
59
  export declare const supervisionPresencePath: (repoRoot: string, tier: SupervisionTier, id: string) => string;
52
60
  export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
53
61
  arm?: SupervisionArm;
@@ -56,13 +56,19 @@ const pathKind = (path) => {
56
56
  * to create fresh state there or to wander into an enclosing repository. This function is read-only.
57
57
  */
58
58
  export function resolveSupervisionRoot(cwd) {
59
+ const { root, state } = locateSupervisionRoot(cwd);
60
+ if (!state)
61
+ throw new Error(`missing ${stateDirName(root)}/ state dir at repository root ${root}`);
62
+ return root;
63
+ }
64
+ /** Read-only twin for readers: a repository without a state dir is a root with no state, not a refusal. */
65
+ export function locateSupervisionRoot(cwd) {
59
66
  let current = resolve(cwd);
60
67
  for (;;) {
61
68
  if (pathKind(join(current, stateDirName(current))) === "directory")
62
- return current;
63
- if (pathKind(join(current, ".git")) !== "absent") {
64
- throw new Error(`missing ${stateDirName(current)}/ state dir at repository root ${current}`);
65
- }
69
+ return { root: current, state: true };
70
+ if (pathKind(join(current, ".git")) !== "absent")
71
+ return { root: current, state: false };
66
72
  const parent = dirname(current);
67
73
  if (parent === current) {
68
74
  throw new Error(`missing ${stateDirName(current)}/ state dir — no tickmarkr repository found from ${resolve(cwd)}`);
@@ -87,6 +93,10 @@ export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoR
87
93
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
88
94
  export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
89
95
  export const supervisionArmPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.arm`);
96
+ /** The durable arm on disk, undefined when none; a torn or invalid arm throws rather than reading as none. */
97
+ export function readSupervisionArm(repoRoot, tier) {
98
+ return readArm(repoRoot, tier);
99
+ }
90
100
  function readArm(repoRoot, tier) {
91
101
  try {
92
102
  const arm = JSON.parse(readFileSync(supervisionArmPath(repoRoot, tier), "utf8"));
@@ -143,6 +153,16 @@ export function newSupervisionArm(repoRoot, tier, armId = randomUUID(), initial
143
153
  }
144
154
  return readArm(repoRoot, tier);
145
155
  }
156
+ // Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
157
+ // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
158
+ // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
159
+ // deliberate hand-off as a death. The marker names the seat for the same reason the beat does.
160
+ export function publishStandDown(repoRoot, tier, seat) {
161
+ tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
162
+ atomicRecord(supervisionStandDownPath(repoRoot, tier), {
163
+ tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
164
+ });
165
+ }
146
166
  /** The independent latch a stand-down cannot overwrite or remove. */
147
167
  const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.clear-owed`);
148
168
  // SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
@@ -0,0 +1,51 @@
1
+ import type { ShellReceipt } from "./protocol.js";
2
+ import type { GateEvidenceOptions } from "../gates/baseline.js";
3
+ import { type RepositoryLeaseHolder } from "./lease.js";
4
+ export interface VerificationPhase {
5
+ phase: "discovery" | "first-pass" | "continuation-discovery" | "continuation";
6
+ nonce: string;
7
+ reportPath: string;
8
+ parentNonce?: string;
9
+ startedAt?: number;
10
+ endedAt?: number;
11
+ pid?: number;
12
+ outcome?: ShellReceipt["outcome"];
13
+ state: "pending" | "running" | "completed" | "cancelled" | "failed";
14
+ }
15
+ export interface VerificationJobReport {
16
+ version: 1;
17
+ id: string;
18
+ command: string;
19
+ cwd: string;
20
+ subject: {
21
+ commit: string | null;
22
+ runId: string;
23
+ taskId: string | null;
24
+ attempt: number | null;
25
+ };
26
+ scope: string;
27
+ reason: string;
28
+ queuedAt: number;
29
+ repositoryAdmittedAt?: number;
30
+ admittedAt?: number;
31
+ reapedAt?: number;
32
+ repositoryReleasedAt?: number;
33
+ releasedAt?: number;
34
+ reservationReused?: boolean;
35
+ reservation?: RepositoryLeaseHolder;
36
+ waitingOn?: RepositoryLeaseHolder;
37
+ state: "queued" | "running" | "completed" | "failed" | "cancelled";
38
+ phases: VerificationPhase[];
39
+ }
40
+ export declare const VERIFICATION_JOB_TOKEN_ENV = "TICKMARKR_VERIFICATION_JOB_TOKEN";
41
+ export declare function verificationJobEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
42
+ export declare const setVerificationJobClockForTests: (now: () => number) => void;
43
+ export declare const resetVerificationJobClockForTests: () => void;
44
+ export declare function observeVerificationSpawn(pid: number | undefined): void;
45
+ export declare function observeVerificationReceipt(receipt: ShellReceipt): void;
46
+ export declare function verificationPhase<T>(phase: VerificationPhase["phase"], nonce: string, reportPath: string, run: () => Promise<T>, parentNonce?: string): Promise<T>;
47
+ export declare function withVerificationJob<T extends {
48
+ pass: boolean;
49
+ meta: Record<string, unknown>;
50
+ reportPath?: string;
51
+ }>(command: string, cwd: string, reportPath: string, evidence: GateEvidenceOptions | undefined, run: () => Promise<T>): Promise<T>;
@@ -0,0 +1,137 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { execFileSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { assertGitTrust, protectedGitEnv } from "./git-trust.js";
6
+ import { executionSignal } from "./execution-budget.js";
7
+ import { hasRepositoryLeaseOwnership, REPOSITORY_LEASE_TOKEN_ENV, reentrantHolder, repositoryLeasePath, reapRepositoryChildren, withCommandLease, withFreshCommandLease, withRepositoryLease } from "./lease.js";
8
+ const jobs = new AsyncLocalStorage();
9
+ export const VERIFICATION_JOB_TOKEN_ENV = "TICKMARKR_VERIFICATION_JOB_TOKEN";
10
+ export function verificationJobEnvironment(env) {
11
+ const job = jobs.getStore();
12
+ return job ? { ...env, [VERIFICATION_JOB_TOKEN_ENV]: job.report.id } : { ...env };
13
+ }
14
+ let clock = () => Date.now();
15
+ export const setVerificationJobClockForTests = (now) => { clock = now; };
16
+ export const resetVerificationJobClockForTests = () => { clock = () => Date.now(); };
17
+ export function observeVerificationSpawn(pid) {
18
+ const job = jobs.getStore();
19
+ if (!job?.phase)
20
+ return;
21
+ job.phase.startedAt ??= clock();
22
+ job.phase.pid = pid;
23
+ job.phase.state = "running";
24
+ job.persist();
25
+ }
26
+ export function observeVerificationReceipt(receipt) {
27
+ const job = jobs.getStore();
28
+ if (!job?.phase)
29
+ return;
30
+ job.phase.outcome = receipt.outcome;
31
+ job.persist();
32
+ }
33
+ export async function verificationPhase(phase, nonce, reportPath, run, parentNonce) {
34
+ const job = jobs.getStore();
35
+ if (!job)
36
+ return run();
37
+ const span = { phase, nonce, reportPath, state: "pending", ...(parentNonce ? { parentNonce } : {}) };
38
+ job.report.phases.push(span);
39
+ job.phase = span;
40
+ job.persist();
41
+ try {
42
+ const value = await run();
43
+ span.state = executionSignalSafely()?.aborted || span.outcome === "cancelled" ? "cancelled" : span.outcome === "timed-out" || span.outcome === "spawn-failed" ? "failed" : "completed";
44
+ return value;
45
+ }
46
+ catch (error) {
47
+ span.state = executionSignalSafely()?.aborted ? "cancelled" : "failed";
48
+ throw error;
49
+ }
50
+ finally {
51
+ span.endedAt = clock();
52
+ job.phase = undefined;
53
+ job.persist();
54
+ }
55
+ }
56
+ // executionSignal() checks the budget and can throw during cleanup; the initial signal is retained.
57
+ const signals = new AsyncLocalStorage();
58
+ const executionSignalSafely = () => signals.getStore();
59
+ export async function withVerificationJob(command, cwd, reportPath, evidence, run) {
60
+ const signal = executionSignal();
61
+ let commit = evidence?.subjectCommit ?? null;
62
+ if (!commit) {
63
+ try {
64
+ assertGitTrust(cwd);
65
+ commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, env: protectedGitEnv(process.env), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
66
+ }
67
+ catch { /* unknown remains explicit */ }
68
+ }
69
+ const report = { version: 1, id: randomUUID(), command, cwd,
70
+ subject: { commit, runId: evidence?.runId ?? "standalone", taskId: evidence?.taskId ?? null, attempt: evidence?.attempt ?? null },
71
+ scope: command, reason: "manifest verification", queuedAt: clock(), state: "queued", phases: [] };
72
+ const path = `${reportPath}.job.json`;
73
+ const job = { report, path, persist: () => {
74
+ // Timing persistence is observational, just like gate receipts; failure cannot change proof.
75
+ const draft = `${path}.${report.id}.draft`;
76
+ try {
77
+ writeFileSync(draft, JSON.stringify(report, null, 2) + "\n");
78
+ renameSync(draft, path);
79
+ }
80
+ catch { /* report proof remains authoritative */ }
81
+ finally {
82
+ try {
83
+ rmSync(draft, { force: true });
84
+ }
85
+ catch { /* observational */ }
86
+ }
87
+ } };
88
+ job.persist();
89
+ let outcome;
90
+ try {
91
+ report.reservationReused = hasRepositoryLeaseOwnership() && !jobs.getStore();
92
+ // A process descendant may share its ancestor's scheduler capability only after the file
93
+ // generation and strict ancestry have been verified. A same-pid sibling always queues fresh.
94
+ const inherited = !hasRepositoryLeaseOwnership()
95
+ && await reentrantHolder(await repositoryLeasePath(cwd), process.env[REPOSITORY_LEASE_TOKEN_ENV]);
96
+ const reserveCommand = report.reservationReused || inherited ? withCommandLease : withFreshCommandLease;
97
+ outcome = await reserveCommand(command, () => withRepositoryLease(cwd, () => {
98
+ signal?.throwIfAborted();
99
+ report.admittedAt = clock();
100
+ report.state = "running";
101
+ job.persist();
102
+ return signals.run(signal, () => jobs.run(job, async () => {
103
+ try {
104
+ return await run();
105
+ }
106
+ finally {
107
+ await reapRepositoryChildren({ pid: process.pid, cwd, at: report.queuedAt, token: report.id }, 20, VERIFICATION_JOB_TOKEN_ENV);
108
+ report.reapedAt = clock();
109
+ job.persist();
110
+ }
111
+ }));
112
+ }, { isolated: true, protectOrphans: true, independent: !!jobs.getStore(), signal, pollMs: 20,
113
+ onWait: holder => { report.waitingOn = { ...holder }; job.persist(); },
114
+ onAdmission: holder => { report.repositoryAdmittedAt = clock(); report.reservation = { ...holder }; job.persist(); },
115
+ onRelease: () => { report.reapedAt ??= clock(); report.repositoryReleasedAt = clock(); job.persist(); } }));
116
+ report.releasedAt = clock();
117
+ report.state = signal?.aborted ? "cancelled" : outcome.pass ? "completed" : "failed";
118
+ outcome.meta = { ...outcome.meta, verificationJobPath: path, verificationJob: report };
119
+ // Existing tip callers retain only reportPath: its sidecar is the additive producer envelope.
120
+ if (outcome.reportPath && `${outcome.reportPath}.job.json` !== path) {
121
+ try {
122
+ writeFileSync(`${outcome.reportPath}.job.json`, JSON.stringify(report, null, 2) + "\n");
123
+ }
124
+ catch { /* observational */ }
125
+ }
126
+ return outcome;
127
+ }
128
+ catch (error) {
129
+ if (report.repositoryReleasedAt !== undefined)
130
+ report.releasedAt = clock();
131
+ report.state = signal?.aborted ? "cancelled" : "failed";
132
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), { verificationJobPath: path, verificationJob: report });
133
+ }
134
+ finally {
135
+ job.persist();
136
+ }
137
+ }
@@ -95,6 +95,13 @@ export type FleetStagedDeny = Record<StagedDenyKey, string[]> & {
95
95
  };
96
96
  /** every scope's staged list, empty — the "every policy scope open" probe the picker lifts against */
97
97
  export declare const openStagedDeny: () => FleetStagedDeny;
98
+ /** T5: one real channel a displayed row stands for — a collapsed variant or catalog-fold constituent doctor
99
+ * served, with its own recorded identity and failed-probe reason */
100
+ export type FleetGroupMember = {
101
+ model: string;
102
+ identity?: string;
103
+ unauthed?: string;
104
+ };
98
105
  export type FleetModelGroup = {
99
106
  adapter: string;
100
107
  vendor?: string;
@@ -109,6 +116,8 @@ export type FleetModelGroup = {
109
116
  classifyModel?: string;
110
117
  variants?: string[];
111
118
  foldedModels?: string[];
119
+ /** T5: every real member of a grouped row; absent ⇒ the row's own model is its one member */
120
+ members?: FleetGroupMember[];
112
121
  score?: number;
113
122
  }>;
114
123
  };