tickmarkr 2.6.1 → 2.6.3

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 (105) hide show
  1. package/README.md +16 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +1 -0
  6. package/dist/adapters/prompt.js +14 -6
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +12 -4
  9. package/dist/adapters/types.js +6 -0
  10. package/dist/cli/commands/approve.d.ts +11 -4
  11. package/dist/cli/commands/approve.js +82 -27
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +8 -2
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +87 -11
  16. package/dist/cli/commands/plan.js +13 -8
  17. package/dist/cli/commands/report.d.ts +2 -1
  18. package/dist/cli/commands/report.js +74 -8
  19. package/dist/cli/commands/resume.js +4 -2
  20. package/dist/cli/commands/status.js +43 -20
  21. package/dist/cli/help.d.ts +2 -0
  22. package/dist/cli/help.js +9 -2
  23. package/dist/compile/native.js +7 -0
  24. package/dist/config/config.d.ts +35 -2
  25. package/dist/config/config.js +86 -10
  26. package/dist/config/fleet-overlay.d.ts +13 -2
  27. package/dist/config/fleet-overlay.js +60 -0
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +35 -2
  31. package/dist/drivers/orca.js +222 -67
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/eval/canary.d.ts +2 -1
  35. package/dist/eval/canary.js +2 -2
  36. package/dist/eval/dispatch.js +1 -0
  37. package/dist/gates/acceptance.d.ts +9 -1
  38. package/dist/gates/acceptance.js +31 -4
  39. package/dist/gates/baseline.d.ts +32 -2
  40. package/dist/gates/baseline.js +111 -24
  41. package/dist/gates/cache.d.ts +8 -0
  42. package/dist/gates/cache.js +12 -2
  43. package/dist/gates/llm.d.ts +11 -4
  44. package/dist/gates/llm.js +40 -21
  45. package/dist/gates/review.d.ts +14 -1
  46. package/dist/gates/review.js +160 -34
  47. package/dist/gates/run-gates.d.ts +56 -4
  48. package/dist/gates/run-gates.js +358 -58
  49. package/dist/gates/test-manifest.d.ts +45 -1
  50. package/dist/gates/test-manifest.js +78 -12
  51. package/dist/graph/schema.d.ts +2 -0
  52. package/dist/graph/schema.js +2 -0
  53. package/dist/plan/scope.js +2 -2
  54. package/dist/route/preference.d.ts +20 -2
  55. package/dist/route/preference.js +48 -13
  56. package/dist/route/router.d.ts +12 -1
  57. package/dist/route/router.js +56 -24
  58. package/dist/run/consult.d.ts +15 -1
  59. package/dist/run/consult.js +18 -7
  60. package/dist/run/daemon.d.ts +38 -2
  61. package/dist/run/daemon.js +895 -192
  62. package/dist/run/git.d.ts +8 -0
  63. package/dist/run/git.js +14 -0
  64. package/dist/run/interactive-seed.d.ts +4 -0
  65. package/dist/run/interactive-seed.js +35 -9
  66. package/dist/run/journal.d.ts +152 -3
  67. package/dist/run/journal.js +551 -50
  68. package/dist/run/lease.d.ts +13 -0
  69. package/dist/run/lease.js +45 -0
  70. package/dist/run/merge.d.ts +3 -1
  71. package/dist/run/merge.js +3 -2
  72. package/dist/run/operator-summary.d.ts +3 -0
  73. package/dist/run/operator-summary.js +3 -1
  74. package/dist/run/protocol.d.ts +46 -1
  75. package/dist/run/protocol.js +14 -2
  76. package/dist/run/receipt-resolver.d.ts +22 -0
  77. package/dist/run/receipt-resolver.js +40 -1
  78. package/dist/run/repair-selection.d.ts +11 -1
  79. package/dist/run/repair-selection.js +17 -9
  80. package/dist/run/supervision.d.ts +7 -1
  81. package/dist/run/supervision.js +5 -2
  82. package/dist/run/wall-budget.d.ts +48 -0
  83. package/dist/run/wall-budget.js +280 -0
  84. package/dist/tui/cockpit/board.js +3 -3
  85. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  86. package/dist/tui/cockpit/decision-actions.js +55 -32
  87. package/dist/tui/cockpit/derive.js +13 -2
  88. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  89. package/dist/tui/cockpit/live-runtime.js +50 -3
  90. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  91. package/dist/tui/cockpit/run-cockpit.js +27 -2
  92. package/dist/tui/cockpit/run-view.d.ts +9 -2
  93. package/dist/tui/cockpit/run-view.js +66 -9
  94. package/dist/tui/cockpit/setup-cockpit.d.ts +6 -0
  95. package/dist/tui/cockpit/setup-cockpit.js +10 -3
  96. package/dist/tui/ink/fleet-app.d.ts +15 -3
  97. package/dist/tui/ink/fleet-app.js +91 -22
  98. package/package.json +3 -1
  99. package/schema/config.schema.json +825 -0
  100. package/skills/tickmarkr-loop/SKILL.md +15 -3
  101. package/skills/tickmarkr-overseer/SKILL.md +42 -0
  102. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +91 -0
  103. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  104. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  105. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -33,7 +33,20 @@ export interface RepositoryLeaseOptions {
33
33
  signal?: AbortSignal;
34
34
  /** Called once per distinct holder the waiter is queued behind. */
35
35
  onWait?: (holder: RepositoryLeaseHolder) => void;
36
+ /** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
37
+ inherited?: string;
36
38
  }
39
+ /** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
40
+ * so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
41
+ * reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
42
+ * installed daemon clears and rewrites that in-process token, and a command-lease token never names
43
+ * this file. Environment text alone proves nothing: see `reentrantHolder`. */
44
+ export declare const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
45
+ /** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
46
+ * right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
47
+ * forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
48
+ * an in-process sibling is not a descendant — each of those waits like any other runner. */
49
+ export declare function reentrantHolder(path: string, inherited: string | undefined): Promise<RepositoryLeaseHolder | undefined>;
37
50
  export declare function repositoryLeasePath(cwd: string): Promise<string>;
38
51
  /** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
39
52
  * number straight to the next file, so a stale observation of a dead or corrupt reservation matched
package/dist/run/lease.js CHANGED
@@ -91,6 +91,38 @@ export class CommandLeases {
91
91
  }
92
92
  }
93
93
  }
94
+ /** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
95
+ * so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
96
+ * reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
97
+ * installed daemon clears and rewrites that in-process token, and a command-lease token never names
98
+ * this file. Environment text alone proves nothing: see `reentrantHolder`. */
99
+ export const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
100
+ /** The strict ancestors of this process, from one `ps` snapshot; empty when unreadable (fail closed). */
101
+ const ancestorPids = () => new Promise((ok) => execFile("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }, (error, stdout) => {
102
+ const parent = new Map();
103
+ if (!error)
104
+ for (const line of stdout.split("\n")) {
105
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
106
+ if (Number.isInteger(pid) && Number.isInteger(ppid))
107
+ parent.set(pid, ppid);
108
+ }
109
+ const seen = new Set();
110
+ for (let pid = error ? 0 : process.ppid; pid > 1 && !seen.has(pid); pid = parent.get(pid) ?? 0)
111
+ seen.add(pid);
112
+ ok(seen);
113
+ }));
114
+ /** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
115
+ * right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
116
+ * forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
117
+ * an in-process sibling is not a descendant — each of those waits like any other runner. */
118
+ export async function reentrantHolder(path, inherited) {
119
+ if (!inherited)
120
+ return undefined;
121
+ const holder = readHolder(path);
122
+ if (holder?.token !== inherited || holder.pid === process.pid || !alive(holder.pid))
123
+ return undefined;
124
+ return (await ancestorPids()).has(holder.pid) ? holder : undefined;
125
+ }
94
126
  const LEASE_FILE = "tickmarkr-runner.lease";
95
127
  export async function repositoryLeasePath(cwd) {
96
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())));
@@ -283,6 +315,9 @@ const inspectAndReserve = (path, mine) => {
283
315
  };
284
316
  export async function withRepositoryLease(cwd, run, opts = {}) {
285
317
  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();
286
321
  const pollMs = opts.pollMs ?? 1_000;
287
322
  const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
288
323
  let waitingOn;
@@ -297,10 +332,20 @@ export async function withRepositoryLease(cwd, run, opts = {}) {
297
332
  }
298
333
  await new Promise((wake) => setTimeout(wake, pollMs));
299
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;
300
339
  try {
301
340
  return await run();
302
341
  }
303
342
  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;
348
+ }
304
349
  // Release is serialized with reclamation/acquisition and removes only this generation.
305
350
  await withMutationLock(path, pollMs, undefined, () => {
306
351
  if (readHolder(path)?.token === mine.token)
@@ -1,5 +1,5 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
- import { type Baseline, type GateEvidenceOptions, type FailureClassification } from "../gates/baseline.js";
2
+ import { type Baseline, type BaselineProvenance, type GateEvidenceOptions, type FailureClassification } from "../gates/baseline.js";
3
3
  import type { GateEvidenceReceipt } from "./protocol.js";
4
4
  export interface TipVerifyResult {
5
5
  evidenceReceipt?: GateEvidenceReceipt;
@@ -21,6 +21,8 @@ export interface TipVerifyResult {
21
21
  spawnedCommand?: string;
22
22
  /** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
23
23
  forgiven?: boolean;
24
+ /** OBS-1123: the capture that recorded the forgiven fingerprints; absent for a legacy baseline that never said. */
25
+ baselineProvenance?: BaselineProvenance;
24
26
  /** D-131: carried from a persisted per-gate verdict — this cycle did NOT execute the command. */
25
27
  reused?: true;
26
28
  /**
package/dist/run/merge.js CHANGED
@@ -225,6 +225,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
225
225
  exitCode: hit.exitCode ?? 0,
226
226
  fingerprints: hit.meta?.fingerprints ?? [],
227
227
  ...(hit.meta?.forgiven ? { forgiven: true } : {}),
228
+ ...(hit.meta?.forgiven && hit.meta.baselineProvenance ? { baselineProvenance: hit.meta.baselineProvenance } : {}),
228
229
  details: formatReusedDetails(hit.details, id),
229
230
  ...(hit.meta?.reportPath ? { reportPath: hit.meta.reportPath } : {}),
230
231
  ...(hit.meta?.spawnedCommand ? { spawnedCommand: hit.meta.spawnedCommand } : {}),
@@ -382,7 +383,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
382
383
  + `${describeCapacity(entry?.capacity)} and this verification ran under ${describeCapacity(r.capacity)} `
383
384
  + `— forgiveness across a changed capacity is not evidence`
384
385
  : `exit ${r.code}`)),
385
- ...(forgiven && !deficit ? { forgiven: true } : {}),
386
+ ...(forgiven && !deficit ? { forgiven: true, ...(baseline?.provenance ? { baselineProvenance: baseline.provenance } : {}) } : {}),
386
387
  ...(cause ? { cause } : {}),
387
388
  ...(pass ? {} : { artifact }),
388
389
  };
@@ -401,7 +402,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
401
402
  const id = pending.get(result.gate);
402
403
  if (id && id.tree === completedTree && !isInfraResult(result)) {
403
404
  store.set(id, { ...result, meta: {
404
- fingerprints: result.fingerprints, forgiven: result.forgiven,
405
+ fingerprints: result.fingerprints, forgiven: result.forgiven, baselineProvenance: result.baselineProvenance,
405
406
  reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
406
407
  artifact: result.artifact,
407
408
  evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
@@ -13,6 +13,9 @@ export interface OperatorDecisionSnapshot {
13
13
  readonly kind: string | undefined;
14
14
  readonly tombstone: boolean;
15
15
  readonly reason?: string;
16
+ /** OBS-1178: the park row's physical line and timestamp — the token a decision binds to. */
17
+ readonly line?: number;
18
+ readonly ts?: string;
16
19
  };
17
20
  readonly verbs: readonly string[];
18
21
  readonly diagnostic?: string;
@@ -25,7 +25,9 @@ export function projectOperatorSummary(tasks, decisions) {
25
25
  decisionRequired: verbs.length > 0,
26
26
  ...(diagnostic !== undefined || park.reason !== undefined
27
27
  ? { diagnostic: diagnostic ?? park.reason } : {}),
28
- nextAction: verbs.length > 0 ? `Choose a decision: ${verbs.join(", ")}` : null,
28
+ nextAction: verbs.length > 0
29
+ ? `Choose a decision: ${verbs.join(", ")}${park.line !== undefined && park.ts !== undefined ? ` (--park ${park.line}@${park.ts})` : ""}`
30
+ : null,
29
31
  };
30
32
  if (!park.tombstone && park.kind === "infra" && task.missingCapability) {
31
33
  const { capability, diagnostic: capabilityDiagnostic } = task.missingCapability;
@@ -53,6 +53,15 @@ export declare const EvidenceArtifactSchema: z.ZodObject<{
53
53
  droppedBytes: z.ZodNumber;
54
54
  truncated: z.ZodBoolean;
55
55
  }, z.core.$strict>;
56
+ export declare const RedactionSchema: z.ZodObject<{
57
+ material: z.ZodBoolean;
58
+ counts: z.ZodOptional<z.ZodObject<{
59
+ token: z.ZodNumber;
60
+ assignment: z.ZodNumber;
61
+ secretEnv: z.ZodNumber;
62
+ benignEnv: z.ZodNumber;
63
+ }, z.core.$strict>>;
64
+ }, z.core.$strict>;
56
65
  export declare const GateEvidenceReceiptSchema: z.ZodObject<{
57
66
  invocationId: z.ZodString;
58
67
  nonce: z.ZodOptional<z.ZodString>;
@@ -66,10 +75,10 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
66
75
  termination: z.ZodObject<{
67
76
  kind: z.ZodEnum<{
68
77
  unknown: "unknown";
78
+ timeout: "timeout";
69
79
  signal: "signal";
70
80
  "not-started": "not-started";
71
81
  exit: "exit";
72
- timeout: "timeout";
73
82
  }>;
74
83
  exitCode: z.ZodNullable<z.ZodNumber>;
75
84
  signal: z.ZodNullable<z.ZodString>;
@@ -85,6 +94,12 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
85
94
  }>;
86
95
  redaction: z.ZodObject<{
87
96
  material: z.ZodBoolean;
97
+ counts: z.ZodOptional<z.ZodObject<{
98
+ token: z.ZodNumber;
99
+ assignment: z.ZodNumber;
100
+ secretEnv: z.ZodNumber;
101
+ benignEnv: z.ZodNumber;
102
+ }, z.core.$strict>>;
88
103
  }, z.core.$strict>;
89
104
  stdout: z.ZodObject<{
90
105
  path: z.ZodString;
@@ -222,6 +237,11 @@ export declare const RoleInvocationStartEventSchema: z.ZodObject<{
222
237
  adapter: z.ZodOptional<z.ZodString>;
223
238
  model: z.ZodOptional<z.ZodString>;
224
239
  vendor: z.ZodOptional<z.ZodString>;
240
+ effort: z.ZodOptional<z.ZodEnum<{
241
+ low: "low";
242
+ medium: "medium";
243
+ high: "high";
244
+ }>>;
225
245
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
226
246
  }, z.core.$strict>;
227
247
  }, z.core.$strict>;
@@ -240,6 +260,11 @@ export declare const RoleInvocationTerminalEventSchema: z.ZodObject<{
240
260
  adapter: z.ZodOptional<z.ZodString>;
241
261
  model: z.ZodOptional<z.ZodString>;
242
262
  vendor: z.ZodOptional<z.ZodString>;
263
+ effort: z.ZodOptional<z.ZodEnum<{
264
+ low: "low";
265
+ medium: "medium";
266
+ high: "high";
267
+ }>>;
243
268
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
244
269
  outcome: z.ZodEnum<{
245
270
  failed: "failed";
@@ -352,6 +377,11 @@ export declare const DecisionEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
352
377
  adapter: z.ZodOptional<z.ZodString>;
353
378
  model: z.ZodOptional<z.ZodString>;
354
379
  vendor: z.ZodOptional<z.ZodString>;
380
+ effort: z.ZodOptional<z.ZodEnum<{
381
+ low: "low";
382
+ medium: "medium";
383
+ high: "high";
384
+ }>>;
355
385
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
356
386
  }, z.core.$strict>;
357
387
  }, z.core.$strict>, z.ZodObject<{
@@ -369,6 +399,11 @@ export declare const DecisionEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
369
399
  adapter: z.ZodOptional<z.ZodString>;
370
400
  model: z.ZodOptional<z.ZodString>;
371
401
  vendor: z.ZodOptional<z.ZodString>;
402
+ effort: z.ZodOptional<z.ZodEnum<{
403
+ low: "low";
404
+ medium: "medium";
405
+ high: "high";
406
+ }>>;
372
407
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
373
408
  outcome: z.ZodEnum<{
374
409
  failed: "failed";
@@ -473,6 +508,11 @@ export declare const DecisionEventWriteSchema: z.ZodDiscriminatedUnion<[z.ZodObj
473
508
  adapter: z.ZodOptional<z.ZodString>;
474
509
  model: z.ZodOptional<z.ZodString>;
475
510
  vendor: z.ZodOptional<z.ZodString>;
511
+ effort: z.ZodOptional<z.ZodEnum<{
512
+ low: "low";
513
+ medium: "medium";
514
+ high: "high";
515
+ }>>;
476
516
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
477
517
  }, z.core.$strict>;
478
518
  event: z.ZodLiteral<"role-invocation-start">;
@@ -489,6 +529,11 @@ export declare const DecisionEventWriteSchema: z.ZodDiscriminatedUnion<[z.ZodObj
489
529
  adapter: z.ZodOptional<z.ZodString>;
490
530
  model: z.ZodOptional<z.ZodString>;
491
531
  vendor: z.ZodOptional<z.ZodString>;
532
+ effort: z.ZodOptional<z.ZodEnum<{
533
+ low: "low";
534
+ medium: "medium";
535
+ high: "high";
536
+ }>>;
492
537
  evidence: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
493
538
  outcome: z.ZodEnum<{
494
539
  failed: "failed";
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { GATE_NAMES } from "../graph/schema.js";
2
+ import { EFFORTS, GATE_NAMES } from "../graph/schema.js";
3
3
  import { normalizeGateOutcome } from "./outcome.js";
4
4
  // T32 (F2): the decision protocol is deliberately smaller than the journal. Existing journals contain
5
5
  // many observational event types and must remain readable; only the five event names below assert a
@@ -47,6 +47,16 @@ export const EvidenceArtifactSchema = z.object({
47
47
  if (ref.availability === "available" && ref.sha256 === null)
48
48
  ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
49
49
  });
50
+ // OBS-1139: one count per original span, never a value. A legacy receipt carries `material` alone and
51
+ // keeps it as recorded; a counted one must agree — benign HOME/TMPDIR substitutions are never material.
52
+ const RedactionCountsSchema = z.object({
53
+ token: z.number().int().nonnegative(),
54
+ assignment: z.number().int().nonnegative(),
55
+ secretEnv: z.number().int().nonnegative(),
56
+ benignEnv: z.number().int().nonnegative(),
57
+ }).strict();
58
+ export const RedactionSchema = z.object({ material: z.boolean(), counts: RedactionCountsSchema.optional() }).strict()
59
+ .refine(r => !r.counts || r.material === (r.counts.token + r.counts.assignment + r.counts.secretEnv > 0), { message: "material must agree with the token, assignment and secret-environment counts" });
50
60
  export const GateEvidenceReceiptSchema = z.object({
51
61
  invocationId: NonEmptyStringSchema,
52
62
  nonce: NonEmptyStringSchema.optional(),
@@ -64,7 +74,7 @@ export const GateEvidenceReceiptSchema = z.object({
64
74
  timedOut: z.boolean().nullable(),
65
75
  }).strict(),
66
76
  availability: z.enum(EVIDENCE_AVAILABILITIES),
67
- redaction: z.object({ material: z.boolean() }).strict(),
77
+ redaction: RedactionSchema,
68
78
  stdout: EvidenceArtifactSchema,
69
79
  stderr: EvidenceArtifactSchema,
70
80
  }).strict();
@@ -133,6 +143,8 @@ const RoleInvocationIdentityDataSchema = z.object({
133
143
  adapter: NonEmptyStringSchema.optional(),
134
144
  model: NonEmptyStringSchema.optional(),
135
145
  vendor: NonEmptyStringSchema.optional(),
146
+ // OBS-1182: the effort the seat actually launched at; absent = the CLI's own default, never a level.
147
+ effort: z.enum(EFFORTS).optional(),
136
148
  evidence: EvidenceSchema.optional(),
137
149
  });
138
150
  const RoleInvocationStartDataSchema = RoleInvocationIdentityDataSchema.strict();
@@ -1,6 +1,18 @@
1
1
  import { type GateEvidenceReceipt } from "./protocol.js";
2
2
  export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
3
3
  export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
4
+ /** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
5
+ export declare const EVICTION_TOMBSTONE_LIMIT = 256;
6
+ /** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
7
+ export declare const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
8
+ /** Bound to the evicted bytes themselves, so a replacement artifact at the same path never inherits it. */
9
+ export interface EvictionTombstone {
10
+ readonly path: string;
11
+ readonly sha256: string;
12
+ readonly retainedBytes: number;
13
+ }
14
+ /** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
15
+ export declare function parseEvictionTombstones(bytes: Buffer): EvictionTombstone[];
4
16
  export type ReceiptResolution = {
5
17
  readonly ok: true;
6
18
  readonly path: string;
@@ -12,6 +24,16 @@ export type ReceiptResolution = {
12
24
  };
13
25
  /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
14
26
  export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
27
+ export type ReceiptRedaction = {
28
+ readonly material: boolean;
29
+ readonly counts: GateEvidenceReceipt["redaction"]["counts"] | null;
30
+ };
31
+ /**
32
+ * OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
33
+ * one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
34
+ */
35
+ export declare function resolveReceiptRedaction(receipt: unknown): ReceiptRedaction;
36
+ export declare function formatReceiptRedaction(r: ReceiptRedaction): string;
15
37
  /** Both artifacts of a receipt, stdout first, each resolved independently. */
16
38
  export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
17
39
  /** One line per artifact for a human surface: the reference, then its verified availability. */
@@ -1,13 +1,28 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
- import { EvidenceArtifactSchema } from "./protocol.js";
4
+ import { EvidenceArtifactSchema, RedactionSchema } from "./protocol.js";
5
5
  // OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
6
6
  // from the bytes on disk under the run root the receipt was minted in and nothing else — no
7
7
  // re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
8
8
  // reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
9
9
  // a root reached through any link is refused outright.
10
10
  export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
11
+ /** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
12
+ export const EVICTION_TOMBSTONE_LIMIT = 256;
13
+ /** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
14
+ export const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
15
+ /** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
16
+ export function parseEvictionTombstones(bytes) {
17
+ try {
18
+ const raw = JSON.parse(bytes.toString("utf8"));
19
+ return Array.isArray(raw) ? raw.filter((t) => typeof t?.path === "string"
20
+ && typeof t.sha256 === "string" && Number.isInteger(t.retainedBytes)).slice(-EVICTION_TOMBSTONE_LIMIT) : [];
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
11
26
  /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
12
27
  export function resolveReceipt(reference, runRoot) {
13
28
  const parsed = EvidenceArtifactSchema.safeParse(reference);
@@ -27,6 +42,8 @@ export function resolveReceipt(reference, runRoot) {
27
42
  if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
28
43
  return { ok: false, path: ref.path, reason: "outside-root" };
29
44
  const read = readUnderRoot(root, rel.split(sep));
45
+ if (read === "missing" && evictedByQuota(root, ref))
46
+ return { ok: false, path: ref.path, reason: "expired" };
30
47
  if (typeof read === "string")
31
48
  return { ok: false, path: ref.path, reason: read };
32
49
  const sha256 = createHash("sha256").update(read).digest("hex");
@@ -34,6 +51,28 @@ export function resolveReceipt(reference, runRoot) {
34
51
  return { ok: false, path: ref.path, reason: "hash-mismatch" };
35
52
  return { ok: true, path: ref.path, sha256 };
36
53
  }
54
+ /** A deletion is explained only by a tombstone naming this exact path, hash and length (OBS-1140). */
55
+ function evictedByQuota(root, ref) {
56
+ const bytes = readUnderRoot(root, [EVICTION_TOMBSTONES_FILE]);
57
+ return typeof bytes !== "string" && parseEvictionTombstones(bytes)
58
+ .some(t => t.path === ref.path && t.sha256 === ref.sha256 && t.retainedBytes === ref.retainedBytes);
59
+ }
60
+ /**
61
+ * OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
62
+ * one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
63
+ */
64
+ export function resolveReceiptRedaction(receipt) {
65
+ const parsed = RedactionSchema.safeParse(receipt?.redaction);
66
+ if (!parsed.success)
67
+ return { material: true, counts: null };
68
+ return { material: parsed.data.material, counts: parsed.data.counts ?? null };
69
+ }
70
+ export function formatReceiptRedaction(r) {
71
+ const head = r.material ? "material" : "nonmaterial";
72
+ return r.counts
73
+ ? `redaction ${head} token=${r.counts.token} assignment=${r.counts.assignment} secret-env=${r.counts.secretEnv} benign-env=${r.counts.benignEnv}`
74
+ : `redaction ${head} (uncounted)`;
75
+ }
37
76
  const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
38
77
  /** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
39
78
  function ancestors(root) {
@@ -2,9 +2,19 @@ import type { JournalEvent } from "./journal.js";
2
2
  export interface RepairSelectionDecision {
3
3
  selectTests: boolean;
4
4
  requiredFiles: string[];
5
- reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure" | "full-suite-failure";
5
+ reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure";
6
6
  }
7
7
  type SelectionEvent = Pick<JournalEvent, "event" | "taskId" | "data">;
8
+ /** OBS-1199: repair selection is on unless a config says otherwise, independent of the optional
9
+ * execution budget (which stays subprocess-only). Either explicit `false` turns it off. */
10
+ export declare function repairSelectionEnabled(cfg?: {
11
+ executionPolicy?: {
12
+ repairSelection?: unknown;
13
+ };
14
+ gates?: {
15
+ repairSelection?: unknown;
16
+ };
17
+ }): boolean;
8
18
  /** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
9
19
  * Distrust and known failing files survive later green results, approvals and resume. A historical
10
20
  * ambiguous failure is never reinterpreted using a newly enabled policy. */
@@ -1,3 +1,8 @@
1
+ /** OBS-1199: repair selection is on unless a config says otherwise, independent of the optional
2
+ * execution budget (which stays subprocess-only). Either explicit `false` turns it off. */
3
+ export function repairSelectionEnabled(cfg) {
4
+ return cfg?.executionPolicy?.repairSelection !== false && cfg?.gates?.repairSelection !== false;
5
+ }
1
6
  /** These are literal repository-relative identities. Existence and selection support are checked
2
7
  * against the current worktree by the gate; this journal fold cannot establish either. */
3
8
  function fileIdentities(value) {
@@ -32,16 +37,19 @@ export function repairSelectionDecision(events, taskId, enabled) {
32
37
  distrust ??= "unattributed-test-failure";
33
38
  continue;
34
39
  }
35
- // A replacement full-suite failure follows a passing screen in the current pipeline. No
36
- // ordinary full-suite failure establishes that a selector safely covers the failed behavior.
37
- if (data.fullSuite === true) {
38
- distrust ??= "full-suite-failure";
39
- continue;
40
- }
41
- const selected = fileIdentities(data.selectedTests);
40
+ // OBS-1199: a full-suite red counts only when it positively says it was full — the replacement
41
+ // after a screen (`fullSuite`), or an ordinary full run whose recorded selection decision says
42
+ // so — and names its failing files. Those files join every later screen even when no selector
43
+ // reaches them; the merge candidate still runs the complete suite. No attribution means full.
42
44
  const failing = fileIdentities(data.failingFiles);
43
- if (!selected || !failing || failing.some((file) => !selected.includes(file))
44
- || (data.fullSuite !== undefined && data.fullSuite !== false)) {
45
+ const selected = fileIdentities(data.selectedTests);
46
+ const decision = data.selectionDecision;
47
+ const replacement = data.fullSuite === true;
48
+ const ordinaryFull = data.fullSuite === undefined && data.selectedTests === undefined
49
+ && typeof decision === "object" && decision !== null && decision.scope === "full";
50
+ const screen = (data.fullSuite === undefined || data.fullSuite === false) && selected !== undefined
51
+ && failing !== undefined && failing.every((file) => selected.includes(file));
52
+ if (!failing || !(replacement || ordinaryFull || screen)) {
45
53
  distrust ??= "unattributed-test-failure";
46
54
  continue;
47
55
  }
@@ -48,6 +48,7 @@ export interface SupervisionArm {
48
48
  export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
49
49
  /** Explicit arming acknowledges the current marker without deleting it. */
50
50
  export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
51
+ export declare const supervisionPresencePath: (repoRoot: string, tier: SupervisionTier, id: string) => string;
51
52
  export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
52
53
  arm?: SupervisionArm;
53
54
  armId?: string;
@@ -69,7 +70,10 @@ export declare function supervisionStatus(repoRoot: string, tier: SupervisionTie
69
70
  export declare const readSupervision: (repoRoot: string, now?: number) => TierLiveness[];
70
71
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
71
72
  export declare const supervisionText: (tiers: readonly TierLiveness[], divider?: string) => string;
72
- /** A board's durable identity is independent of its short title and of other observers. */
73
+ /** A board's durable identity is independent of its short title and of other observers.
74
+ * `handle` is the terminal the claiming observer reports running in, when its host exports one: the
75
+ * one fact that names the board's pane when the placing split's receipt never arrived (OBS-1172).
76
+ * `retired` marks a tombstone a driver keeps in the record's place; it is never answered again. */
73
77
  export interface WatchBoardOwner {
74
78
  repo: string;
75
79
  runId: string;
@@ -80,6 +84,8 @@ export interface WatchBoardOwner {
80
84
  token: string;
81
85
  pid?: number;
82
86
  armId?: string;
87
+ handle?: string;
88
+ retired?: true;
83
89
  }
84
90
  export declare const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
85
91
  export declare function readWatchBoard(repo: string, runId: string): WatchBoardOwner | undefined;
@@ -154,7 +154,7 @@ const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRo
154
154
  // Freshness decides presence, never a process table (SUP-02): a watcher that is killed cannot remove
155
155
  // its own file, and an unremoved file ages past the same ceiling a beat does and stops counting.
156
156
  const presencePrefix = (tier) => `${tier}.live.`;
157
- const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
157
+ export const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
158
158
  /** Every presence file on this tier, by name. Missing directory ⇒ nobody is present. */
159
159
  const presenceNames = (repoRoot, tier) => {
160
160
  try {
@@ -567,6 +567,8 @@ export const supervisionText = (tiers, divider = " · ") => `supervision: ${tier
567
567
  `${t.clearOwedSince ? ` CLEAR-OWED since ${t.clearOwedSince}` : ""}` +
568
568
  `${t.clearOwedUnreadable ? " CLEAR-OWED unreadable" : ""}`).join(divider)}`;
569
569
  export const WATCH_OWNER_ENV = "TICKMARKR_WATCH_OWNER";
570
+ /** Orca exports each terminal's own handle into the processes it runs. */
571
+ const TERMINAL_HANDLE_ENV = "ORCA_TERMINAL_HANDLE";
570
572
  const boardPath = (repo, runId) => join(supervisionDir(repo), `watch-board.${parseRunId(runId)}.json`);
571
573
  const boardMessagePath = (owner, kind) => {
572
574
  if (!/^[a-f0-9-]{36}$/.test(owner.token))
@@ -642,7 +644,8 @@ export function observeNamedRun(repo, runId, env = process.env) {
642
644
  const candidate = readWatchBoard(repo, runId);
643
645
  if (!candidate || candidate.token !== token || candidate.pid !== undefined)
644
646
  throw new Error("watch ownership unknown; refusing narrator acknowledgement");
645
- owner = { ...candidate, pid: process.pid, armId: armed.id };
647
+ const handle = env[TERMINAL_HANDLE_ENV]?.trim();
648
+ owner = { ...candidate, pid: process.pid, armId: armed.id, ...(handle ? { handle } : {}) };
646
649
  atomicRecord(boardPath(repo, runId), owner);
647
650
  }
648
651
  }
@@ -0,0 +1,48 @@
1
+ import type { JournalEvent } from "./journal.js";
2
+ /**
3
+ * OBS-1201: where a run's wall time went. Every instant of the engagement window lands in exactly ONE
4
+ * bucket — the first of WALL_PRIORITY with a span covering it — and what no span covers is residual,
5
+ * so the exposed buckets always sum to the wall. Task-time sums every span as recorded, so concurrent
6
+ * work (two workers, four suites waiting on one lease) stays visible without inflating the wall.
7
+ *
8
+ * Only journaled observations become spans: a fresh gate row's measured duration less the matched
9
+ * suite waits inside it, a worker launch matched to its result, a suite wait matched to its admission,
10
+ * and the gap a restart left between one engagement's last row and the next run-resume. A copy of
11
+ * evidence — a cache reuse, a journal replay, a row naming an invocation already counted — never adds
12
+ * a span; a reused full suite's row still times the fresh screen it carries. A row without a duration, a launch, wait or gate start with no matching end, and one cut by
13
+ * a restart are counted, never timed. Residual
14
+ * is unattributed time, not a claim of idleness. Pure: journal rows in, numbers out.
15
+ */
16
+ export declare const WALL_PRIORITY: readonly ["interruption", "test", "semantics", "worker", "queue", "other-gate"];
17
+ export type WallBucket = (typeof WALL_PRIORITY)[number];
18
+ type PerBucket = Record<WallBucket, number>;
19
+ export interface WallBudget {
20
+ wallMs: number;
21
+ exposedMs: PerBucket;
22
+ residualMs: number;
23
+ taskMs: PerBucket;
24
+ /** Gate-result rows by provenance. A declined gate never ran and is none of these. */
25
+ evidence: {
26
+ fresh: number;
27
+ replay: number;
28
+ reuse: number;
29
+ unknown: number;
30
+ };
31
+ /** Evidence that exists but carries no timing: rows without a duration, unmatched and interrupted starts. */
32
+ untimed: Record<WallBucket, {
33
+ unknown: number;
34
+ unmatched: number;
35
+ interrupted: number;
36
+ }>;
37
+ }
38
+ /** Undefined when the journal names no measurable window (no run-start, or unreadable timestamps). */
39
+ export declare function wallBudget(events: readonly JournalEvent[]): WallBudget | undefined;
40
+ /** Whole seconds past a minute, one decimal below ten seconds — never a rounded-away "0s" for a real span. */
41
+ export declare const formatSpan: (ms: number) => string;
42
+ /**
43
+ * One fact per bucket, then residual and the evidence tally — the words every surface prints. A
44
+ * bucket whose only evidence is untimed reads `unknown`, never a zero it did not measure.
45
+ */
46
+ export declare function wallBudgetFacts(b: WallBudget): Array<[label: string, fact: string]>;
47
+ export declare const WALL_PRIORITY_TEXT: string;
48
+ export {};