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
@@ -87,9 +87,50 @@ const PrefBlockSchema = z.object({
87
87
  adapters: z.array(z.string()).optional(),
88
88
  models: z.array(z.string()).optional(),
89
89
  });
90
- const DenyBlockSchema = PrefBlockSchema.extend({
90
+ // exported so a test can extend the PRODUCTION deny shape and watch the enumeration grow (OBS-1099 add.1)
91
+ export const DenyBlockSchema = PrefBlockSchema.extend({
91
92
  workers: PrefBlockSchema.optional(),
92
93
  });
94
+ export function denyScopesOf(schema, segments = []) {
95
+ const unwrap = (t) => t instanceof z.ZodOptional ? unwrap(t.unwrap()) : t;
96
+ return Object.entries(schema.shape).flatMap(([name, type]) => {
97
+ const inner = unwrap(type);
98
+ const below = [...segments, name];
99
+ if (inner instanceof z.ZodObject)
100
+ return denyScopesOf(inner, below);
101
+ if (!(inner instanceof z.ZodArray))
102
+ return [];
103
+ const path = ["routing", "deny", ...below];
104
+ return [{ key: `deny${below.map((s) => s[0].toUpperCase() + s.slice(1)).join("")}`, path, dotted: path.join(".") }];
105
+ });
106
+ }
107
+ export const DENY_SCOPES = denyScopesOf(DenyBlockSchema);
108
+ export const stagedDenyKeyOf = (scope) => (scope.key[4].toLowerCase() + scope.key.slice(5));
109
+ export const initialDenyPropOf = (scope) => `initial${scope.key[0].toUpperCase()}${scope.key.slice(1)}`;
110
+ /** the authored list at one deny scope of a routing block, undefined when the scope is absent */
111
+ export function denyEntriesAt(routing, scope) {
112
+ let cur = routing;
113
+ for (const segment of scope.path.slice(1)) {
114
+ if (cur === undefined || cur === null || typeof cur !== "object")
115
+ return undefined;
116
+ cur = cur[segment];
117
+ }
118
+ return Array.isArray(cur) ? cur : undefined;
119
+ }
120
+ /** a routing.deny block built from per-scope lists (absent or empty lists are left out) */
121
+ export function denyBlockFrom(lists) {
122
+ const block = {};
123
+ for (const scope of DENY_SCOPES) {
124
+ const entries = lists[scope.key];
125
+ if (!entries?.length)
126
+ continue;
127
+ let cur = block;
128
+ for (const segment of scope.path.slice(2, -1))
129
+ cur = (cur[segment] ??= {});
130
+ cur[scope.path[scope.path.length - 1]] = [...entries];
131
+ }
132
+ return block;
133
+ }
93
134
  // v1.20 REC-02: operator-maintained price table for the usage/cost report (src/report/cost.ts). Two
94
135
  // channel economics, never conflated (spec cost model): API = tokens × per-Mtok rate; sub = flat plan
95
136
  // amortized over a windows/month RANGE (+ an API-equivalent counterfactual when the model has a rate).
@@ -924,8 +965,8 @@ export function fleetEditableFromConfig(cfg, universe) {
924
965
  tiers[adapter][model] = { tier };
925
966
  }
926
967
  }
927
- const denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
928
- const denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
968
+ // every enumerated deny scope rides verbatim (sorted) under its own key
969
+ const denyLists = Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, [...(denyEntriesAt(cfg.routing, scope) ?? [])].sort()]));
929
970
  let allowOut;
930
971
  if (universe !== undefined) {
931
972
  const { allow } = cfg.routing;
@@ -947,13 +988,8 @@ export function fleetEditableFromConfig(cfg, universe) {
947
988
  allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
948
989
  }
949
990
  return {
950
- denyAdapters,
951
- denyModels,
991
+ ...denyLists,
952
992
  ...(allowOut !== undefined ? { allowOut } : {}),
953
- // OBS-994: workers-only deny is never folded into the all-seats complement above — it is
954
- // returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
955
- denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
956
- denyWorkersModels: [...(cfg.routing.deny?.workers?.models ?? [])].sort(),
957
993
  tiers,
958
994
  map: structuredClone(cfg.routing.map),
959
995
  floors: { ...cfg.routing.floors },
@@ -992,14 +1028,9 @@ export function formatFleetPrint(repoRoot, opts = {}) {
992
1028
  node.comment = ` ${fleetKeyLayer(repoRoot, dotted, opts)}`;
993
1029
  }
994
1030
  };
995
- if (effective.routing.deny?.adapters !== undefined)
996
- annotate(["routing", "deny", "adapters"]);
997
- if (effective.routing.deny?.models !== undefined)
998
- annotate(["routing", "deny", "models"]);
999
- if (effective.routing.deny?.workers?.adapters !== undefined)
1000
- annotate(["routing", "deny", "workers", "adapters"]);
1001
- if (effective.routing.deny?.workers?.models !== undefined)
1002
- annotate(["routing", "deny", "workers", "models"]);
1031
+ for (const scope of DENY_SCOPES)
1032
+ if (denyEntriesAt(effective.routing, scope) !== undefined)
1033
+ annotate(scope.path);
1003
1034
  for (const shape of Object.keys(effective.routing.map))
1004
1035
  annotate(["routing", "map", shape]);
1005
1036
  annotate(["routing", "floors"]);
@@ -1,6 +1,13 @@
1
1
  // Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
2
2
  import { isMap, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
3
- import { universeCovers, universeEntryMatches } from "./config.js";
3
+ import { DENY_SCOPES, universeCovers, universeEntryMatches } from "./config.js";
4
+ // OBS-1099 add.1: the writer ranges over the schema-derived scopes. The flat (all-seats) scopes
5
+ // take part in the membership/allow-form write; every nested scope (routing.deny.workers.*) is a
6
+ // literal deny list written on its own. ponytail: "flat" = directly under routing.deny. Read
7
+ // lazily: config.ts imports this module, so the enumeration is not initialized at load time.
8
+ const flatDenyScopes = () => DENY_SCOPES.filter((scope) => scope.path.length === 3);
9
+ const nestedDenyScopes = () => DENY_SCOPES.filter((scope) => scope.path.length > 3);
10
+ const listsOf = (write, scope) => [write.initial[scope.key] ?? [], write.edited[scope.key] ?? []];
4
11
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
5
12
  export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
6
13
  function fleetSubset(obj) {
@@ -24,7 +31,7 @@ function allowFormFromExclusions(universe, edited) {
24
31
  // LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
25
32
  // adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
26
33
  // OBS-1046: the staged allow complement is a reason of its own, beside the authored deny lists.
27
- const entries = [...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])];
34
+ const entries = [...flatDenyScopes().flatMap((scope) => edited[scope.key] ?? []), ...(edited.allowOut ?? [])];
28
35
  const adapters = [];
29
36
  const models = [];
30
37
  let excluded = false;
@@ -190,9 +197,9 @@ export function renderFleetOverlayWrite(priorBytes, write) {
190
197
  // OBS-1046: each flat scope (and the allow complement) is compared on its own — a scope the
191
198
  // session never edited keeps its node byte for byte, whatever its sibling did.
192
199
  const changed = (before = [], after = []) => sortedUnique(before).join() !== sortedUnique(after).join();
193
- const adaptersChanged = changed(initial.denyAdapters, edited.denyAdapters);
194
- const modelsChanged = changed(initial.denyModels, edited.denyModels);
195
- if (adaptersChanged || modelsChanged || changed(initial.allowOut, edited.allowOut)) {
200
+ const FLAT_DENY_SCOPES = flatDenyScopes();
201
+ const flatChanged = new Map(FLAT_DENY_SCOPES.map((scope) => [scope.key, changed(...listsOf(write, scope))]));
202
+ if ([...flatChanged.values()].some(Boolean) || changed(initial.allowOut, edited.allowOut)) {
196
203
  if (write.universe) {
197
204
  // Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
198
205
  // so a lower layer can never re-exclude behind the operator's back (workers untouched).
@@ -230,15 +237,13 @@ export function renderFleetOverlayWrite(priorBytes, write) {
230
237
  // LEG2-T3 finding 4: an authored entry the edit admits (staged nowhere any more) must go
231
238
  // even from a scope whose own set did not change, or the admitted channel stays excluded
232
239
  // behind the preserved bytes.
233
- const stagedAfter = new Set([...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])]);
234
- const admitted = [...initial.denyAdapters, ...initial.denyModels, ...(initial.allowOut ?? [])]
240
+ const stagedAfter = new Set([...FLAT_DENY_SCOPES.flatMap((scope) => edited[scope.key] ?? []), ...(edited.allowOut ?? [])]);
241
+ const admitted = [...FLAT_DENY_SCOPES.flatMap((scope) => initial[scope.key] ?? []), ...(initial.allowOut ?? [])]
235
242
  .filter((entry) => !stagedAfter.has(entry));
236
- const scopes = [
237
- ["adapters", initial.denyAdapters, edited.denyAdapters, adaptersChanged],
238
- ["models", initial.denyModels, edited.denyModels, modelsChanged],
239
- ];
240
- for (const [scope, before, after, touched] of scopes) {
241
- const path = ["routing", "deny", scope];
243
+ for (const scope of FLAT_DENY_SCOPES) {
244
+ const [before, after] = listsOf(write, scope);
245
+ const touched = flatChanged.get(scope.key);
246
+ const path = scope.path;
242
247
  const authored = authoredEntries(doc, path);
243
248
  const stale = authored.some((entry) => admitted.includes(entry));
244
249
  if (!touched && !stale)
@@ -254,28 +259,24 @@ export function renderFleetOverlayWrite(priorBytes, write) {
254
259
  }
255
260
  }
256
261
  else {
257
- if (adaptersChanged) {
258
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
259
- }
260
- if (modelsChanged) {
261
- setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
262
+ for (const scope of FLAT_DENY_SCOPES) {
263
+ if (!flatChanged.get(scope.key))
264
+ continue;
265
+ const after = edited[scope.key] ?? [];
266
+ setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
262
267
  }
263
268
  }
264
269
  }
265
270
  // OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
266
271
  // membership scope — it never routes through the allow-complement dance above, in either
267
- // branch. Same tombstone/comment-preserving rules as the flat scopes.
268
- const initialWorkersAdapters = initial.denyWorkersAdapters ?? [];
269
- const editedWorkersAdapters = edited.denyWorkersAdapters ?? [];
270
- const initialWorkersModels = initial.denyWorkersModels ?? [];
271
- const editedWorkersModels = edited.denyWorkersModels ?? [];
272
- // Each sub-path mutates independently — an untouched sibling must not be rewritten (a `null`
273
- // tombstone over an absent/untouched sibling would mask a lower layer's own workers scope).
274
- if (sortedUnique(initialWorkersAdapters).join() !== sortedUnique(editedWorkersAdapters).join()) {
275
- setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "adapters"], editedWorkersAdapters.length ? sortedUnique(editedWorkersAdapters) : null);
276
- }
277
- if (sortedUnique(initialWorkersModels).join() !== sortedUnique(editedWorkersModels).join()) {
278
- setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "models"], editedWorkersModels.length ? sortedUnique(editedWorkersModels) : null);
272
+ // branch. Same tombstone/comment-preserving rules as the flat scopes. Each nested scope
273
+ // mutates independently — an untouched sibling must not be rewritten (a `null` tombstone over
274
+ // an absent/untouched sibling would mask a lower layer's own workers scope).
275
+ for (const scope of nestedDenyScopes()) {
276
+ const [before, after] = listsOf(write, scope);
277
+ if (!changed(before, after))
278
+ continue;
279
+ setStringSequencePreservingComments(doc, scope.path, after.length ? sortedUnique(after) : null);
279
280
  }
280
281
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
281
282
  const before = initial.map[shape];
@@ -420,8 +421,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
420
421
  const out = structuredClone(existingRepo);
421
422
  const routing = { ...out.routing };
422
423
  let routingTouched = false;
423
- const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
424
- || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join()
424
+ // OBS-1099 add.1: the flat scopes (routing.deny.<leaf>) and the allow complement are one
425
+ // membership write; a list or its tombstone lands at every flat scope the enumeration names
426
+ const changedAt = (scope) => sortedUnique(initial[scope.key] ?? []).join() !== sortedUnique(edited[scope.key] ?? []).join();
427
+ const listOrTombstone = (entries) => entries.length ? entries : null;
428
+ const denyChanged = flatDenyScopes().some(changedAt)
425
429
  || sortedUnique(initial.allowOut ?? []).join() !== sortedUnique(edited.allowOut ?? []).join();
426
430
  if (denyChanged) {
427
431
  if (universe) {
@@ -434,44 +438,25 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
434
438
  }
435
439
  else
436
440
  delete routing.allow;
437
- const residualAdapters = residualDeny(universe, edited.denyAdapters);
438
- const residualModels = residualDeny(universe, edited.denyModels);
439
441
  routing.deny = {
440
442
  ...routing.deny,
441
- adapters: residualAdapters.length ? residualAdapters : null,
442
- models: residualModels.length ? residualModels : null,
443
+ ...Object.fromEntries(flatDenyScopes().map((scope) => [scope.path[scope.path.length - 1], listOrTombstone(residualDeny(universe, edited[scope.key] ?? []))])),
443
444
  };
444
445
  }
445
446
  else {
446
- routing.deny = {
447
- adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
448
- models: edited.denyModels.length ? edited.denyModels : null,
449
- };
447
+ routing.deny = Object.fromEntries(flatDenyScopes().map((scope) => [scope.path[scope.path.length - 1], listOrTombstone(edited[scope.key] ?? [])]));
450
448
  }
451
449
  routingTouched = true;
452
450
  }
453
- // OBS-994/FL-1: workers deny is a literal list, independent of the universe/allow dance above.
454
- // Each sub-path is included only when it actually changed — an untouched sibling must not be
455
- // rewritten as a `null` tombstone over whatever the existing repo overlay already held.
456
- const initialWorkersAdapters = initial.denyWorkersAdapters ?? [];
457
- const editedWorkersAdapters = edited.denyWorkersAdapters ?? [];
458
- const initialWorkersModels = initial.denyWorkersModels ?? [];
459
- const editedWorkersModels = edited.denyWorkersModels ?? [];
460
- const workersAdaptersChanged = sortedUnique(initialWorkersAdapters).join() !== sortedUnique(editedWorkersAdapters).join();
461
- const workersModelsChanged = sortedUnique(initialWorkersModels).join() !== sortedUnique(editedWorkersModels).join();
462
- if (workersAdaptersChanged || workersModelsChanged) {
463
- routing.deny = {
464
- ...routing.deny,
465
- workers: {
466
- ...(routing.deny?.workers),
467
- ...(workersAdaptersChanged
468
- ? { adapters: editedWorkersAdapters.length ? editedWorkersAdapters : null }
469
- : {}),
470
- ...(workersModelsChanged
471
- ? { models: editedWorkersModels.length ? editedWorkersModels : null }
472
- : {}),
473
- },
474
- };
451
+ // OBS-994/FL-1: a nested scope (routing.deny.workers.*) is a literal list, independent of the
452
+ // universe/allow dance above. Each sub-path is included only when it actually changed — an
453
+ // untouched sibling must not be rewritten as a `null` tombstone over whatever the existing repo
454
+ // overlay already held.
455
+ for (const scope of nestedDenyScopes().filter(changedAt)) {
456
+ let cur = (routing.deny = { ...routing.deny });
457
+ for (const segment of scope.path.slice(2, -1))
458
+ cur = (cur[segment] = { ...cur[segment] });
459
+ cur[scope.path[scope.path.length - 1]] = listOrTombstone(edited[scope.key] ?? []);
475
460
  routingTouched = true;
476
461
  }
477
462
  // pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
@@ -1,3 +1,4 @@
1
+ import { type EvidenceArtifact, type GateEvidenceReceipt, type ShellReceipt } from "../run/protocol.js";
1
2
  import type { TickmarkrConfig } from "../config/config.js";
2
3
  import type { AcceptanceItem } from "../graph/schema.js";
3
4
  import { type RunCapacity, type ShellOptions, type ShResult } from "../run/git.js";
@@ -13,8 +14,69 @@ import type { GateResult } from "./types.js";
13
14
  declare module "./types.js" {
14
15
  interface GateResult {
15
16
  capacity?: RunCapacity;
17
+ evidenceReceipt?: GateEvidenceReceipt;
18
+ evidenceReceipts?: GateEvidenceReceipt[];
16
19
  }
17
20
  }
21
+ /** Evidence is observational: the command verdict never depends on this store. */
22
+ export interface GateEvidenceOptions {
23
+ artifactDir?: string;
24
+ runId?: string;
25
+ taskId?: string;
26
+ attempt?: number;
27
+ subjectCommit?: string;
28
+ quotaBytes?: number;
29
+ env?: NodeJS.ProcessEnv;
30
+ /** Injectable storage boundary; mandatory manifest/reporter writes do not use it. */
31
+ write?: (path: string, bytes: Buffer) => void;
32
+ }
33
+ export declare const EVIDENCE_TAIL_BYTES: number;
34
+ export declare const EVIDENCE_RUN_QUOTA_BYTES: number;
35
+ export declare function redactGateOutput(text: string, env: NodeJS.ProcessEnv): string;
36
+ /** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
37
+ export declare function resolveEvidenceArtifact(root: string, ref: EvidenceArtifact): EvidenceArtifact;
38
+ export declare function beginGateEvidence(cwd: string, gate: string, command: string, opts?: GateEvidenceOptions, nonce?: string): {
39
+ root: string;
40
+ history: {
41
+ invocationId: string;
42
+ subject: {
43
+ runId: string;
44
+ taskId: string | null;
45
+ attempt: number | null;
46
+ gate: string;
47
+ subjectCommit: string | null;
48
+ };
49
+ termination: {
50
+ kind: "unknown" | "signal" | "not-started" | "exit" | "timeout";
51
+ exitCode: number | null;
52
+ signal: string | null;
53
+ timedOut: boolean | null;
54
+ };
55
+ availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
56
+ redaction: {
57
+ material: boolean;
58
+ };
59
+ stdout: {
60
+ path: string;
61
+ availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
62
+ sha256: string | null;
63
+ retainedBytes: number;
64
+ droppedBytes: number;
65
+ truncated: boolean;
66
+ };
67
+ stderr: {
68
+ path: string;
69
+ availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
70
+ sha256: string | null;
71
+ retainedBytes: number;
72
+ droppedBytes: number;
73
+ truncated: boolean;
74
+ };
75
+ nonce?: string | undefined;
76
+ }[];
77
+ observe(receipt: ShellReceipt): void;
78
+ finish(stdout?: string, stderr?: string): GateEvidenceReceipt;
79
+ };
18
80
  export interface BaselineCommand {
19
81
  /**
20
82
  * Absent when the capture returned no verdict — see `infra`. A pre-v1.90 baseline can also lack it
@@ -66,6 +128,8 @@ export interface BaselineFileDuration {
66
128
  durationMs: number;
67
129
  }
68
130
  export interface Baseline {
131
+ /** Observational capture receipts stay outside the forgiveness-bearing command entries. */
132
+ evidenceReceipts?: Record<string, GateEvidenceReceipt>;
69
133
  commands: Record<string, BaselineCommand>;
70
134
  warnings?: BaselineWarning[];
71
135
  }
@@ -158,6 +222,7 @@ export declare const CAPTURE_CEILING_MS = 1800000;
158
222
  * count is safe to apply as a deficit floor. A null count compares nothing and is safe as-is. */
159
223
  export declare function staleFileCountCommands(baseline: Baseline, commands: Record<string, string>, cwd: string): string[];
160
224
  export interface BaselineReceiptOptions {
225
+ evidence?: GateEvidenceOptions;
161
226
  onReceipt?: ShellOptions["onReceipt"];
162
227
  /** Only comparison's build command may carry task-build identity; capture never does. */
163
228
  taskBuildAttribution?: ShellOptions["receiptAttribution"];
@@ -1,9 +1,140 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { availableParallelism, loadavg } from "node:os";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
4
+ import { availableParallelism, loadavg, tmpdir } from "node:os";
3
5
  import { join } from "node:path";
4
6
  import { DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
5
7
  import { executionSignal } from "../run/execution-budget.js";
6
8
  import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
9
+ export const EVIDENCE_TAIL_BYTES = 16 * 1024;
10
+ export const EVIDENCE_RUN_QUOTA_BYTES = 8 * 1024 * 1024;
11
+ export function redactGateOutput(text, env) {
12
+ // One pass over the original bytes: replacing an environment value must not break a token
13
+ // recognizer, and a short environment value must not rewrite the redaction marker itself.
14
+ // Short operational values (0, true, vi, test) occur throughout ordinary diagnostics.
15
+ // Only secret-named keys justify redacting short values.
16
+ const values = [...new Set(Object.entries(env)
17
+ .filter(([key, value]) => !!value && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
18
+ .map(([, value]) => value))]
19
+ .sort((a, b) => b.length - a.length).map(v => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
20
+ const tokens = String.raw `\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`;
21
+ const assignments = String.raw `(?:authorization\s*:\s*bearer|(?:api[_-]?key|token|password|secret)\s*[:=])\s*[^\s"']+`;
22
+ return text.replace(new RegExp([tokens, assignments, ...values].join("|"), "gi"), "[REDACTED]");
23
+ }
24
+ /** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
25
+ export function resolveEvidenceArtifact(root, ref) {
26
+ if (ref.availability !== "available")
27
+ return ref;
28
+ if (ref.path.startsWith("/") || ref.path.includes("\\") || ref.path.split("/").includes(".."))
29
+ return { ...ref, availability: "missing" };
30
+ try {
31
+ const bytes = readFileSync(join(root, ref.path));
32
+ return bytes.length === ref.retainedBytes && createHash("sha256").update(bytes).digest("hex") === ref.sha256
33
+ ? ref : { ...ref, availability: "missing" };
34
+ }
35
+ catch {
36
+ return { ...ref, availability: "missing" };
37
+ }
38
+ }
39
+ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
40
+ let invocationId = nonce ?? randomUUID();
41
+ let artifactId = /^[a-zA-Z0-9-]+$/.test(invocationId) ? invocationId : createHash("sha256").update(invocationId).digest("hex");
42
+ let subjectCommit = opts.subjectCommit ?? null;
43
+ if (!subjectCommit) {
44
+ try {
45
+ subjectCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
46
+ }
47
+ catch { /* unknown subject */ }
48
+ }
49
+ const root = opts.artifactDir ?? join(tmpdir(), "tickmarkr-gate-evidence", createHash("sha256").update(cwd).digest("hex"));
50
+ const env = { ...process.env, ...opts.env };
51
+ for (const match of command.matchAll(/(?:^|\s)[A-Za-z_][A-Za-z_0-9]*=(?:'([^']*)'|"([^"$]*)"|([^\s;&|]+))/g)) {
52
+ env[`assignment_${match.index}`] = match[1] ?? match[2] ?? match[3];
53
+ }
54
+ let observed;
55
+ let pendingId;
56
+ const history = [];
57
+ return {
58
+ root, history,
59
+ observe(receipt) {
60
+ if (pendingId) {
61
+ history.push(this.finish());
62
+ invocationId = pendingId;
63
+ artifactId = pendingId;
64
+ pendingId = undefined;
65
+ }
66
+ observed = receipt;
67
+ // A shell-internal pre-spawn retry receives an id before it executes too. Keep the failed
68
+ // attempt current until a later observation proves that the shell actually tried again.
69
+ if (receipt.outcome === "spawn-failed")
70
+ pendingId = randomUUID();
71
+ },
72
+ finish(stdout = "", stderr = "") {
73
+ const termination = {
74
+ kind: !observed ? "unknown" : !observed.confirmedStart ? "not-started" : observed.outcome === "timed-out" ? "timeout" : observed.signal ? "signal" : observed.exitCode != null ? "exit" : "unknown",
75
+ exitCode: observed?.exitCode ?? null, signal: observed?.signal ?? null,
76
+ timedOut: observed ? observed.outcome === "timed-out" : null,
77
+ };
78
+ const clean = [redactGateOutput(stdout, env), redactGateOutput(stderr, env)];
79
+ const refs = clean.map((text, i) => {
80
+ const bytes = Buffer.from(text);
81
+ const tail = bytes.subarray(-EVIDENCE_TAIL_BYTES);
82
+ return { path: `gate-evidence/${artifactId}-${i === 0 ? "stdout" : "stderr"}.log`,
83
+ availability: termination.kind === "not-started" ? "not-started" : "available",
84
+ sha256: termination.kind === "not-started" ? null : createHash("sha256").update(tail).digest("hex"), retainedBytes: tail.length,
85
+ droppedBytes: bytes.length - tail.length, truncated: bytes.length > tail.length };
86
+ });
87
+ let availability = termination.kind === "not-started" ? "not-started" : "available";
88
+ if (availability === "available") {
89
+ let lock;
90
+ try {
91
+ const dir = join(root, "gate-evidence");
92
+ mkdirSync(dir, { recursive: true });
93
+ mkdirSync(join(dir, ".write-lock"));
94
+ lock = join(dir, ".write-lock");
95
+ const quota = opts.quotaBytes !== undefined && Number.isFinite(opts.quotaBytes)
96
+ ? Math.max(0, Math.floor(opts.quotaBytes)) : EVIDENCE_RUN_QUOTA_BYTES;
97
+ const files = readdirSync(dir).filter(f => /^[a-zA-Z0-9-]+-(stdout|stderr)\.log$/.test(f)).map(f => ({ path: join(dir, f), stat: statSync(join(dir, f)) }))
98
+ .sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.path.localeCompare(b.path));
99
+ let total = files.reduce((sum, f) => sum + f.stat.size, 0);
100
+ const incoming = refs.reduce((sum, ref) => sum + ref.retainedBytes, 0);
101
+ if (incoming > quota)
102
+ throw new Error("evidence quota cannot retain this invocation");
103
+ while (files.length && total + incoming > quota) {
104
+ const oldest = files.shift();
105
+ unlinkSync(oldest.path);
106
+ total -= oldest.stat.size;
107
+ }
108
+ for (const [i, ref] of refs.entries()) {
109
+ (opts.write ?? writeFileSync)(join(root, ref.path), Buffer.from(clean[i]).subarray(-EVIDENCE_TAIL_BYTES));
110
+ }
111
+ }
112
+ catch {
113
+ availability = "capture-failed";
114
+ for (const ref of refs) {
115
+ ref.availability = "capture-failed";
116
+ ref.sha256 = null;
117
+ try {
118
+ unlinkSync(join(root, ref.path));
119
+ }
120
+ catch { /* best effort cleanup */ }
121
+ }
122
+ }
123
+ finally {
124
+ if (lock) {
125
+ try {
126
+ rmdirSync(lock);
127
+ }
128
+ catch { /* observational cleanup */ }
129
+ }
130
+ }
131
+ }
132
+ return { invocationId, ...(nonce ? { nonce } : {}),
133
+ subject: { runId: opts.runId ?? "standalone", taskId: opts.taskId ?? null, attempt: opts.attempt ?? null, gate, subjectCommit },
134
+ termination, availability, redaction: { material: clean[0] !== stdout || clean[1] !== stderr }, stdout: refs[0], stderr: refs[1] };
135
+ },
136
+ };
137
+ }
7
138
  // incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
8
139
  // codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
9
140
  // a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
@@ -493,7 +624,9 @@ export async function captureBaseline(cwd, commands, opts = {}) {
493
624
  if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
494
625
  continue;
495
626
  }
496
- const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { onReceipt: opts.onReceipt });
627
+ const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
628
+ const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { env: opts.evidence?.env, onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); } });
629
+ (base.evidenceReceipts ??= {})[name] = evidence.finish(r.stdout, r.stderr);
497
630
  // ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
498
631
  // ponytail: a capture that was itself killed records the ceiling as its "measurement", which
499
632
  // scales the next ceiling up — the right direction for a suite that never finished once.
@@ -634,15 +767,36 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
634
767
  }
635
768
  const entry = baseline.commands[name];
636
769
  const ceilingMs = effectiveCeilingMs(entry);
637
- const r = await sh(cmd, cwd, ceilingMs, {
638
- onReceipt: opts.onReceipt,
639
- ...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
640
- });
770
+ const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
771
+ let r;
772
+ try {
773
+ r = await sh(cmd, cwd, ceilingMs, {
774
+ env: opts.evidence?.env,
775
+ onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); },
776
+ ...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
777
+ });
778
+ }
779
+ catch (error) {
780
+ const evidenceReceipt = evidence.finish();
781
+ // A launch refusal is infrastructure; cancellation and other thrown control flow still unwind.
782
+ if (evidenceReceipt.termination.kind !== "not-started" || executionSignal()?.aborted)
783
+ throw error;
784
+ results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${String(error)}`,
785
+ meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
786
+ continue;
787
+ }
788
+ const evidenceReceipt = evidence.finish(r.stdout, r.stderr);
789
+ if (evidenceReceipt.termination.kind === "not-started") {
790
+ results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${r.stderr}`,
791
+ meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
792
+ continue;
793
+ }
641
794
  // T7: every verdict below carries the capacity ITS OWN command ran under, taken off the shell
642
795
  // result rather than re-derived after the fact. The skip row above ran no command and therefore
643
796
  // states no capacity — a row that never divided the machine must not claim that it did.
644
797
  let recoveryBlocked;
645
798
  const record = (g) => {
799
+ g = { ...g, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] };
646
800
  if (recoveryBlocked)
647
801
  g = { ...g, meta: { ...g.meta, recoveryBlocked } };
648
802
  const withReap = r.reapedGroup ? { ...g, meta: { ...g.meta, reapedGroup: true } } : g;
@@ -735,7 +889,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
735
889
  }
736
890
  else {
737
891
  const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
738
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance }));
892
+ results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
739
893
  continue;
740
894
  }
741
895
  }
@@ -754,7 +908,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
754
908
  // Compatibility with OBS-896, not an infrastructure reclassification: a persistent
755
909
  // timeout remains the ordinary conservative verdict after the single bounded remeasure.
756
910
  const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
757
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance }));
911
+ results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
758
912
  continue;
759
913
  }
760
914
  }
@@ -14,6 +14,8 @@ export interface ReviewFinding {
14
14
  severity: ReviewSeverity;
15
15
  defer?: boolean;
16
16
  rationale?: string;
17
+ /** Copy an id from the top-level reraised list to identify this restatement. */
18
+ reraised?: string;
17
19
  }
18
20
  export interface ReviewVerdict {
19
21
  approve?: boolean;
@@ -58,19 +60,19 @@ export { modelProvider };
58
60
  * comparing both sides with every whitespace run removed (`s.replace(/\s+/g, "")`).
59
61
  */
60
62
  export declare function matchClosureId(candidate: unknown, fingerprint: string): boolean;
61
- export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string>): string | undefined;
63
+ export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string | StructuredFinding>): string | undefined;
62
64
  /**
63
65
  * Validates closure ids in a review verdict: membership, duplication, and coverage of every prior id
64
66
  * all route through matchClosureId.
65
67
  */
66
- export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
68
+ export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
67
69
  /**
68
70
  * OBS-1013 add.3: the reviewer ECHOED closure ids and at least one matches no carried fingerprint —
69
71
  * it answered about the materials and missed the id (a retyped, truncated or paraphrased fingerprint).
70
72
  * That is a no-verdict about the carried work (re-route), not a parse defect. A verdict that omits a
71
73
  * list, carries a non-string or duplicates an id stays malformed: its shape, not its ids, is wrong.
72
74
  */
73
- export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
75
+ export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
74
76
  export type ReviewerFloorCause = "author-tier" | "task-floor" | "config" | "prior-reviewer";
75
77
  /**
76
78
  * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
@@ -134,4 +136,4 @@ export declare function renderGoalSection(goal: string, repoRoot?: string): stri
134
136
  * closure on a typo and that read as malformed — the block is what a closure list is copied from.
135
137
  */
136
138
  export declare function renderPriorMaterials(priorMaterials: readonly StructuredFinding[]): string;
137
- export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[]): Promise<GateResult>;
139
+ export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[], operatorContext?: string): Promise<GateResult>;