tickmarkr 2.5.8 → 2.5.9

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 (39) hide show
  1. package/dist/adapters/qwen.js +30 -3
  2. package/dist/cli/commands/approve.js +15 -3
  3. package/dist/cli/commands/beat.d.ts +2 -0
  4. package/dist/cli/commands/beat.js +28 -29
  5. package/dist/cli/commands/compile.js +11 -0
  6. package/dist/cli/commands/fleet.js +61 -53
  7. package/dist/cli/commands/verify.d.ts +4 -1
  8. package/dist/cli/commands/verify.js +16 -5
  9. package/dist/cli/help.d.ts +2 -0
  10. package/dist/cli/help.js +6 -4
  11. package/dist/config/config.d.ts +41 -3
  12. package/dist/config/config.js +48 -17
  13. package/dist/config/fleet-overlay.js +47 -62
  14. package/dist/gates/baseline.d.ts +65 -0
  15. package/dist/gates/baseline.js +163 -9
  16. package/dist/gates/review.d.ts +6 -4
  17. package/dist/gates/review.js +62 -21
  18. package/dist/gates/run-gates.d.ts +4 -1
  19. package/dist/gates/run-gates.js +21 -11
  20. package/dist/gates/test-manifest.d.ts +11 -1
  21. package/dist/gates/test-manifest.js +41 -21
  22. package/dist/run/daemon.js +84 -41
  23. package/dist/run/journal.d.ts +17 -2
  24. package/dist/run/journal.js +99 -11
  25. package/dist/run/merge.d.ts +13 -2
  26. package/dist/run/merge.js +74 -12
  27. package/dist/run/protocol.d.ts +82 -0
  28. package/dist/run/protocol.js +35 -0
  29. package/dist/run/receipt-resolver.d.ts +18 -0
  30. package/dist/run/receipt-resolver.js +132 -0
  31. package/dist/run/supervision.d.ts +14 -1
  32. package/dist/run/supervision.js +122 -24
  33. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  34. package/dist/tui/cockpit/evidence-view.js +37 -5
  35. package/dist/tui/ink/fleet-app.d.ts +12 -22
  36. package/dist/tui/ink/fleet-app.js +520 -131
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-overseer/SKILL.md +61 -36
  39. package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/run/merge.js CHANGED
@@ -1,11 +1,34 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
- import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
4
+ import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, 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";
8
8
  import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
9
+ import { executionSignal } from "./execution-budget.js";
10
+ // Diagnostic convenience logs are observational, just like receipt persistence.
11
+ function writeTipLog(path, text) {
12
+ try {
13
+ writeFileSync(path, text);
14
+ }
15
+ catch { /* the command verdict remains authoritative */ }
16
+ }
17
+ /** Carry execution evidence without minting an invocation or rebasing its artifact paths. */
18
+ export function reusedTipEvidence(source) {
19
+ return {
20
+ reused: true,
21
+ ...(source.evidenceReceipt ? { evidenceReceipt: source.evidenceReceipt } : {}),
22
+ ...(source.evidenceReceipts ? { evidenceReceipts: source.evidenceReceipts } : {}),
23
+ ...(source.originRunRoot ? { originRunRoot: source.originRunRoot } : {}),
24
+ ...(!source.evidenceReceipt
25
+ ? { evidenceAbsence: source.evidenceAbsence ?? "historical-cache" }
26
+ : source.evidenceAbsence ? { evidenceAbsence: source.evidenceAbsence } : {}),
27
+ ...(source.nonce !== undefined ? { nonce: source.nonce } : {}),
28
+ ...(source.stdoutPath !== undefined ? { stdoutPath: source.stdoutPath } : {}),
29
+ ...(source.stderrPath !== undefined ? { stderrPath: source.stderrPath } : {}),
30
+ };
31
+ }
9
32
  export function integrationBranch(cfg, runId) {
10
33
  return `${cfg.integrationBranchPrefix}${runId}`;
11
34
  }
@@ -52,7 +75,7 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
52
75
  // terminus unreachable by construction and misattributed the red to the last merged task.
53
76
  // Fail-closed edges kept: no baseline → strict; baseline green for that gate → strict; any fresh
54
77
  // fingerprint, or output with no recognizable failure shape, → failed.
55
- export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
78
+ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}) {
56
79
  const results = [];
57
80
  let runStartCommands;
58
81
  let journalFound = false;
@@ -109,7 +132,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
109
132
  for (const [gate, cmd] of gatesToRun) {
110
133
  const artifact = join(runDir, `tip-verify-${gate}.log`);
111
134
  const details = `tip verify could not establish command provenance: run-start evidence missing or malformed in journal`;
112
- writeFileSync(artifact, details + "\n");
135
+ writeTipLog(artifact, details + "\n");
113
136
  results.push({
114
137
  gate,
115
138
  cmd,
@@ -117,6 +140,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
117
140
  exitCode: 1,
118
141
  fingerprints: [],
119
142
  details,
143
+ evidenceAbsence: "provenance-refused",
120
144
  artifact,
121
145
  });
122
146
  }
@@ -135,7 +159,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
135
159
  if (expectedCmd === undefined || cmd !== expectedCmd) {
136
160
  const artifact = join(runDir, `tip-verify-${gate}.log`);
137
161
  const details = `tip verify command "${cmd}" for gate "${gate}" was not named in run-start row`;
138
- writeFileSync(artifact, details + "\n");
162
+ writeTipLog(artifact, details + "\n");
139
163
  results.push({
140
164
  gate,
141
165
  cmd,
@@ -143,6 +167,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
143
167
  exitCode: 1,
144
168
  fingerprints: [],
145
169
  details,
170
+ evidenceAbsence: "provenance-refused",
146
171
  artifact,
147
172
  });
148
173
  continue;
@@ -184,7 +209,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
184
209
  gate,
185
210
  cmd,
186
211
  pass: hit.pass,
187
- reused: true,
212
+ ...reusedTipEvidence(hit.meta ?? {}),
188
213
  exitCode: hit.exitCode ?? 0,
189
214
  fingerprints: hit.meta?.fingerprints ?? [],
190
215
  ...(hit.meta?.forgiven ? { forgiven: true } : {}),
@@ -207,14 +232,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
207
232
  longestFile: entry?.longestFile,
208
233
  overallCeilingMs: effectiveCeilingMs(entry),
209
234
  artifactDir: runDir,
235
+ evidence: { artifactDir: runDir, runId: runDir, ...evidenceOptions },
210
236
  });
211
237
  const artifact = join(runDir, `tip-verify-${gate}.log`);
212
238
  if (!outcome.pass)
213
- writeFileSync(artifact, outcome.details);
239
+ writeTipLog(artifact, outcome.details);
214
240
  results.push({
215
241
  gate,
216
242
  cmd,
217
243
  pass: outcome.pass,
244
+ evidenceReceipt: outcome.evidenceReceipt,
245
+ evidenceReceipts: outcome.evidenceReceipts,
246
+ ...(!outcome.evidenceReceipt ? { evidenceAbsence: "not-started" } : {}),
247
+ nonce: outcome.meta.nonce,
248
+ stdoutPath: outcome.meta.stdoutPath,
249
+ stderrPath: outcome.meta.stderrPath,
218
250
  exitCode: outcome.exitCode,
219
251
  reportPath: outcome.reportPath,
220
252
  spawnedCommand: outcome.meta.spawnedCommand,
@@ -230,18 +262,41 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
230
262
  // `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
231
263
  // running it under 600000ms here SIGKILLed a green tip three times while every per-task gate passed.
232
264
  const ceilingMs = effectiveCeilingMs(entry);
233
- let r = await sh(cmd, intWt, ceilingMs);
265
+ const evidenceReceipts = [];
266
+ const execute = async () => {
267
+ const evidence = beginGateEvidence(intWt, gate, cmd, { artifactDir: runDir, runId: runDir, ...evidenceOptions });
268
+ try {
269
+ const result = await sh(cmd, intWt, ceilingMs, { env: evidenceOptions.env, onReceipt: receipt => evidence.observe(receipt) });
270
+ evidenceReceipts.push(...evidence.history, evidence.finish(result.stdout, result.stderr));
271
+ return result;
272
+ }
273
+ catch (error) {
274
+ const receipt = evidence.finish();
275
+ if (receipt.termination.kind !== "not-started" || executionSignal()?.aborted)
276
+ throw error;
277
+ evidenceReceipts.push(...evidence.history, receipt);
278
+ return { code: -1, stdout: "", stderr: String(error) };
279
+ }
280
+ };
281
+ let r = await execute();
234
282
  let raw = r.stdout + "\n" + r.stderr;
235
283
  let stripped = raw.split(intWt).join("");
236
284
  let rerun;
237
- if (gate === "test" && !r.timedOut && !fileCountDeficit(entry, stripped)
285
+ if (evidenceReceipts.at(-1)?.termination.kind !== "not-started" && gate === "test" && !r.timedOut && !fileCountDeficit(entry, stripped)
238
286
  && classifyFreshRunnerOutput(entry, stripped, r.code) === "infra") {
239
287
  const waitedMs = await waitForCalmWindow();
240
288
  rerun = `runner-infra rerun after waiting ${waitedMs}ms for a calm load window`;
241
- r = await sh(cmd, intWt, ceilingMs);
289
+ r = await execute();
242
290
  raw = r.stdout + "\n" + r.stderr;
243
291
  stripped = raw.split(intWt).join("");
244
292
  }
293
+ const evidenceReceipt = evidenceReceipts.at(-1);
294
+ if (evidenceReceipt.termination.kind === "not-started") {
295
+ results.push({ gate, cmd, pass: false, exitCode: r.code, fingerprints: [],
296
+ details: `infra; command failed to launch: ${r.stderr}`, cause: "infra",
297
+ evidenceReceipt, evidenceReceipts, evidenceAbsence: "not-started" });
298
+ continue;
299
+ }
245
300
  const artifact = join(runDir, `tip-verify-${gate}.log`);
246
301
  // Battery parity on the ceiling too (baseline.ts Q24): the kill is read BEFORE the exit code is
247
302
  // interpreted at all. A SIGKILLed battery never returned a verdict, so no line of its partial
@@ -249,11 +304,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
249
304
  // operator cannot act on. The kill reader's own text (ceiling + elapsed) and cause replace it.
250
305
  const killed = ceilingKillResult(gate, r, ceilingMs);
251
306
  if (killed) {
252
- writeFileSync(artifact, raw);
307
+ writeTipLog(artifact, raw);
253
308
  results.push({
254
309
  gate,
255
310
  cmd,
256
311
  pass: false,
312
+ evidenceReceipt, evidenceReceipts,
257
313
  exitCode: r.code,
258
314
  fingerprints: [],
259
315
  details: `${rerun ? `infra; ${rerun}: ` : ""}${killed.details}`,
@@ -290,11 +346,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
290
346
  && comparable;
291
347
  const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
292
348
  if (!pass)
293
- writeFileSync(artifact, raw);
349
+ writeTipLog(artifact, raw);
294
350
  const tipResult = {
295
351
  gate,
296
352
  cmd,
297
353
  pass,
354
+ evidenceReceipt, evidenceReceipts,
298
355
  exitCode: r.code,
299
356
  fingerprints: r.code !== 0 && !greenTeardown ? fingerprint(stripped) : [],
300
357
  details: `${cause === "infra" ? "infra; " : ""}${rerun ? `${rerun}: ` : ""}` + (deficit?.replace(/^infra; /, "") ?? (r.code === 0 ? "exit 0"
@@ -315,12 +372,17 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
315
372
  // spawns in the daemon child, and a command that changed the tree invalidates reuse.
316
373
  const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
317
374
  for (const result of results) {
375
+ if (!result.reused && result.evidenceReceipt)
376
+ result.originRunRoot = resolve(evidenceOptions.artifactDir ?? runDir);
318
377
  const id = pending.get(result.gate);
319
378
  if (id && id.tree === completedTree && !isInfraResult(result)) {
320
379
  store.set(id, { ...result, meta: {
321
380
  fingerprints: result.fingerprints, forgiven: result.forgiven,
322
381
  reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
323
382
  artifact: result.artifact,
383
+ evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
384
+ evidenceAbsence: result.evidenceAbsence, originRunRoot: result.originRunRoot,
385
+ nonce: result.nonce, stdoutPath: result.stdoutPath, stderrPath: result.stderrPath,
324
386
  } });
325
387
  }
326
388
  }
@@ -37,6 +37,88 @@ export declare const ShellReceiptSchema: z.ZodObject<{
37
37
  durationMs: z.ZodOptional<z.ZodNumber>;
38
38
  }, z.core.$strict>;
39
39
  export type ShellReceipt = z.infer<typeof ShellReceiptSchema>;
40
+ export declare const EVIDENCE_AVAILABILITIES: readonly ["available", "not-started", "capture-failed", "killed", "expired", "missing"];
41
+ export declare const EvidenceArtifactSchema: z.ZodObject<{
42
+ path: z.ZodString;
43
+ availability: z.ZodEnum<{
44
+ available: "available";
45
+ "not-started": "not-started";
46
+ "capture-failed": "capture-failed";
47
+ killed: "killed";
48
+ expired: "expired";
49
+ missing: "missing";
50
+ }>;
51
+ sha256: z.ZodNullable<z.ZodString>;
52
+ retainedBytes: z.ZodNumber;
53
+ droppedBytes: z.ZodNumber;
54
+ truncated: z.ZodBoolean;
55
+ }, z.core.$strict>;
56
+ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
57
+ invocationId: z.ZodString;
58
+ nonce: z.ZodOptional<z.ZodString>;
59
+ subject: z.ZodObject<{
60
+ runId: z.ZodString;
61
+ taskId: z.ZodNullable<z.ZodString>;
62
+ attempt: z.ZodNullable<z.ZodNumber>;
63
+ gate: z.ZodString;
64
+ subjectCommit: z.ZodNullable<z.ZodString>;
65
+ }, z.core.$strict>;
66
+ termination: z.ZodObject<{
67
+ kind: z.ZodEnum<{
68
+ unknown: "unknown";
69
+ signal: "signal";
70
+ "not-started": "not-started";
71
+ exit: "exit";
72
+ timeout: "timeout";
73
+ }>;
74
+ exitCode: z.ZodNullable<z.ZodNumber>;
75
+ signal: z.ZodNullable<z.ZodString>;
76
+ timedOut: z.ZodNullable<z.ZodBoolean>;
77
+ }, z.core.$strict>;
78
+ availability: z.ZodEnum<{
79
+ available: "available";
80
+ "not-started": "not-started";
81
+ "capture-failed": "capture-failed";
82
+ killed: "killed";
83
+ expired: "expired";
84
+ missing: "missing";
85
+ }>;
86
+ redaction: z.ZodObject<{
87
+ material: z.ZodBoolean;
88
+ }, z.core.$strict>;
89
+ stdout: z.ZodObject<{
90
+ path: z.ZodString;
91
+ availability: z.ZodEnum<{
92
+ available: "available";
93
+ "not-started": "not-started";
94
+ "capture-failed": "capture-failed";
95
+ killed: "killed";
96
+ expired: "expired";
97
+ missing: "missing";
98
+ }>;
99
+ sha256: z.ZodNullable<z.ZodString>;
100
+ retainedBytes: z.ZodNumber;
101
+ droppedBytes: z.ZodNumber;
102
+ truncated: z.ZodBoolean;
103
+ }, z.core.$strict>;
104
+ stderr: z.ZodObject<{
105
+ path: z.ZodString;
106
+ availability: z.ZodEnum<{
107
+ available: "available";
108
+ "not-started": "not-started";
109
+ "capture-failed": "capture-failed";
110
+ killed: "killed";
111
+ expired: "expired";
112
+ missing: "missing";
113
+ }>;
114
+ sha256: z.ZodNullable<z.ZodString>;
115
+ retainedBytes: z.ZodNumber;
116
+ droppedBytes: z.ZodNumber;
117
+ truncated: z.ZodBoolean;
118
+ }, z.core.$strict>;
119
+ }, z.core.$strict>;
120
+ export type GateEvidenceReceipt = z.infer<typeof GateEvidenceReceiptSchema>;
121
+ export type EvidenceArtifact = z.infer<typeof EvidenceArtifactSchema>;
40
122
  /** A task-build row must carry the entire caller-owned correlation, even before spawn. */
41
123
  export declare const CommandReceiptSchema: z.ZodObject<{
42
124
  outcome: z.ZodEnum<{
@@ -33,6 +33,41 @@ export const ShellReceiptSchema = z.object({
33
33
  error: z.string().optional(),
34
34
  durationMs: z.number().nonnegative().optional(),
35
35
  }).strict();
36
+ export const EVIDENCE_AVAILABILITIES = ["available", "not-started", "capture-failed", "killed", "expired", "missing"];
37
+ export const EvidenceArtifactSchema = z.object({
38
+ path: z.string().refine(p => p.length > 0 && !p.startsWith("/") && !p.includes("\\") && !p.split("/").includes("..")),
39
+ availability: z.enum(EVIDENCE_AVAILABILITIES),
40
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
41
+ retainedBytes: z.number().int().min(0).max(16 * 1024),
42
+ droppedBytes: z.number().int().nonnegative(),
43
+ truncated: z.boolean(),
44
+ }).strict().superRefine((ref, ctx) => {
45
+ if (ref.truncated !== (ref.droppedBytes > 0))
46
+ ctx.addIssue({ code: "custom", message: "truncation must agree with dropped bytes" });
47
+ if (ref.availability === "available" && ref.sha256 === null)
48
+ ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
49
+ });
50
+ export const GateEvidenceReceiptSchema = z.object({
51
+ invocationId: NonEmptyStringSchema,
52
+ nonce: NonEmptyStringSchema.optional(),
53
+ subject: z.object({
54
+ runId: NonEmptyStringSchema,
55
+ taskId: NonEmptyStringSchema.nullable(),
56
+ attempt: AttemptSchema.nullable(),
57
+ gate: NonEmptyStringSchema,
58
+ subjectCommit: NonEmptyStringSchema.nullable(),
59
+ }).strict(),
60
+ termination: z.object({
61
+ kind: z.enum(["exit", "signal", "timeout", "not-started", "unknown"]),
62
+ exitCode: z.number().int().nullable(),
63
+ signal: NonEmptyStringSchema.nullable(),
64
+ timedOut: z.boolean().nullable(),
65
+ }).strict(),
66
+ availability: z.enum(EVIDENCE_AVAILABILITIES),
67
+ redaction: z.object({ material: z.boolean() }).strict(),
68
+ stdout: EvidenceArtifactSchema,
69
+ stderr: EvidenceArtifactSchema,
70
+ }).strict();
36
71
  /** A task-build row must carry the entire caller-owned correlation, even before spawn. */
37
72
  export const CommandReceiptSchema = ShellReceiptSchema.extend({
38
73
  attribution: CommandReceiptAttributionSchema,
@@ -0,0 +1,18 @@
1
+ import { type GateEvidenceReceipt } from "./protocol.js";
2
+ export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
3
+ export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
4
+ export type ReceiptResolution = {
5
+ readonly ok: true;
6
+ readonly path: string;
7
+ readonly sha256: string;
8
+ } | {
9
+ readonly ok: false;
10
+ readonly path: string;
11
+ readonly reason: ReceiptUnavailableReason;
12
+ };
13
+ /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
14
+ export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
15
+ /** Both artifacts of a receipt, stdout first, each resolved independently. */
16
+ export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
17
+ /** One line per artifact for a human surface: the reference, then its verified availability. */
18
+ export declare function formatReceiptResolution(r: ReceiptResolution): string;
@@ -0,0 +1,132 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { EvidenceArtifactSchema } from "./protocol.js";
5
+ // OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
6
+ // from the bytes on disk under the run root the receipt was minted in and nothing else — no
7
+ // re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
8
+ // reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
9
+ // a root reached through any link is refused outright.
10
+ export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
11
+ /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
12
+ export function resolveReceipt(reference, runRoot) {
13
+ const parsed = EvidenceArtifactSchema.safeParse(reference);
14
+ const path = typeof reference?.path === "string" ? reference.path : "";
15
+ if (!parsed.success) {
16
+ const escapes = path && (isAbsolute(path) || path.includes("\\") || path.split("/").includes(".."));
17
+ return { ok: false, path, reason: escapes ? "outside-root" : "missing" };
18
+ }
19
+ const ref = parsed.data;
20
+ if (ref.availability === "expired")
21
+ return { ok: false, path: ref.path, reason: "expired" };
22
+ if (ref.availability !== "available" || ref.sha256 === null)
23
+ return { ok: false, path: ref.path, reason: "missing" };
24
+ const root = resolve(runRoot);
25
+ const full = resolve(root, ref.path);
26
+ const rel = relative(root, full);
27
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
28
+ return { ok: false, path: ref.path, reason: "outside-root" };
29
+ const read = readUnderRoot(root, rel.split(sep));
30
+ if (typeof read === "string")
31
+ return { ok: false, path: ref.path, reason: read };
32
+ const sha256 = createHash("sha256").update(read).digest("hex");
33
+ if (sha256 !== ref.sha256 || read.length !== ref.retainedBytes)
34
+ return { ok: false, path: ref.path, reason: "hash-mismatch" };
35
+ return { ok: true, path: ref.path, sha256 };
36
+ }
37
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
38
+ /** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
39
+ function ancestors(root) {
40
+ const out = [];
41
+ for (let p = dirname(root); p !== dirname(p); p = dirname(p))
42
+ out.push(p);
43
+ return out;
44
+ }
45
+ /**
46
+ * Walk `parts` below `root` one descriptor at a time and return the leaf's bytes. Every component —
47
+ * the root's ancestors, the root, each directory, the leaf — is opened O_NOFOLLOW and must be the
48
+ * same inode lstat reports for that name before AND after the open, so a component swapped for a
49
+ * link mid-walk is refused rather than followed. Every platform takes the same pathname open: an
50
+ * ancestor swap by a same-privilege writer can only ever yield hash-identical bytes (D-269), so no
51
+ * descriptor-anchored traversal is layered on top (D-279: the boundary is the run root).
52
+ */
53
+ function readUnderRoot(root, parts) {
54
+ for (const p of ancestors(root)) {
55
+ let st;
56
+ try {
57
+ st = lstatSync(p);
58
+ }
59
+ catch {
60
+ return "missing";
61
+ }
62
+ if (st.isSymbolicLink())
63
+ return "symlink";
64
+ }
65
+ const fds = [];
66
+ try {
67
+ let parent = openComponent(root, true);
68
+ if (typeof parent === "string")
69
+ return parent;
70
+ fds.push(parent);
71
+ let at = root;
72
+ for (let i = 0; i < parts.length; i++) {
73
+ at = resolve(at, parts[i]);
74
+ const fd = openComponent(at, i < parts.length - 1);
75
+ if (typeof fd === "string")
76
+ return fd;
77
+ fds.push(fd);
78
+ parent = fd;
79
+ }
80
+ return readFileSync(parent);
81
+ }
82
+ catch (e) {
83
+ return e.code === "ELOOP" ? "symlink" : "missing";
84
+ }
85
+ finally {
86
+ for (const fd of fds)
87
+ closeSync(fd);
88
+ }
89
+ }
90
+ function openComponent(path, dir) {
91
+ let before;
92
+ try {
93
+ before = lstatSync(path);
94
+ }
95
+ catch {
96
+ return "missing";
97
+ }
98
+ if (before.isSymbolicLink())
99
+ return "symlink";
100
+ if (dir ? !before.isDirectory() : !before.isFile())
101
+ return "missing";
102
+ const flags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (dir ? constants.O_DIRECTORY : 0);
103
+ const fd = openSync(path, flags);
104
+ let after;
105
+ try {
106
+ after = lstatSync(path);
107
+ }
108
+ catch {
109
+ closeSync(fd);
110
+ return "missing";
111
+ }
112
+ const opened = fstatSync(fd);
113
+ if (after.isSymbolicLink() || !same(before, opened) || !same(after, opened)) {
114
+ closeSync(fd);
115
+ return "symlink";
116
+ }
117
+ // A leaf reachable under a second name (a hard link) is an alias the inode check cannot see through;
118
+ // evidence is written once under one name, so any aliased leaf is refused.
119
+ if (!dir && opened.nlink !== 1) {
120
+ closeSync(fd);
121
+ return "symlink";
122
+ }
123
+ return fd;
124
+ }
125
+ /** Both artifacts of a receipt, stdout first, each resolved independently. */
126
+ export function resolveReceiptArtifacts(receipt, runRoot) {
127
+ return [receipt.stdout, receipt.stderr].map(ref => resolveReceipt(ref, runRoot));
128
+ }
129
+ /** One line per artifact for a human surface: the reference, then its verified availability. */
130
+ export function formatReceiptResolution(r) {
131
+ return r.ok ? `${r.path} available sha256=${r.sha256}` : `${r.path} unavailable (${r.reason})`;
132
+ }
@@ -39,7 +39,20 @@ export interface SupervisionBeatObservation {
39
39
  export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
40
40
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
41
41
  export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
42
- export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation): void;
42
+ /** Durable CLI arm; ticks never advance its epoch or its stand-down fence. */
43
+ export interface SupervisionArm {
44
+ armId: string;
45
+ armEpoch: string;
46
+ markerFence: string;
47
+ }
48
+ export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
49
+ /** Explicit arming acknowledges the current marker without deleting it. */
50
+ export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
51
+ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
52
+ arm?: SupervisionArm;
53
+ armId?: string;
54
+ loop?: boolean;
55
+ }): boolean;
43
56
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
44
57
  export interface ArmedSupervision {
45
58
  disarm: () => void;