tickmarkr 2.5.3 → 2.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.d.ts +4 -0
  6. package/dist/cli/commands/doctor.js +44 -29
  7. package/dist/cli/commands/fleet.js +195 -33
  8. package/dist/cli/commands/init.js +196 -6
  9. package/dist/cli/commands/plan.js +144 -3
  10. package/dist/cli/commands/resume.js +6 -2
  11. package/dist/cli/commands/run.js +21 -2
  12. package/dist/cli/commands/verify.js +1 -1
  13. package/dist/cli/help.d.ts +2 -0
  14. package/dist/cli/help.js +3 -1
  15. package/dist/compile/collateral.d.ts +2 -0
  16. package/dist/compile/collateral.js +50 -9
  17. package/dist/compile/ownership.d.ts +7 -0
  18. package/dist/compile/ownership.js +59 -22
  19. package/dist/config/config.d.ts +23 -8
  20. package/dist/config/config.js +42 -28
  21. package/dist/config/fleet-overlay.d.ts +3 -9
  22. package/dist/config/fleet-overlay.js +68 -11
  23. package/dist/config/fleet-why.d.ts +7 -0
  24. package/dist/config/fleet-why.js +5 -0
  25. package/dist/drivers/herdr.d.ts +5 -0
  26. package/dist/drivers/herdr.js +14 -3
  27. package/dist/drivers/index.d.ts +15 -1
  28. package/dist/drivers/index.js +38 -10
  29. package/dist/drivers/orca.d.ts +99 -10
  30. package/dist/drivers/orca.js +586 -97
  31. package/dist/drivers/types.d.ts +1 -0
  32. package/dist/gates/baseline.d.ts +3 -0
  33. package/dist/gates/baseline.js +2 -1
  34. package/dist/gates/cache.d.ts +100 -0
  35. package/dist/gates/cache.js +389 -0
  36. package/dist/gates/review.d.ts +35 -2
  37. package/dist/gates/review.js +64 -16
  38. package/dist/gates/run-gates.d.ts +5 -0
  39. package/dist/gates/run-gates.js +134 -18
  40. package/dist/gates/test-manifest.d.ts +99 -0
  41. package/dist/gates/test-manifest.js +389 -0
  42. package/dist/gates/test-reporter.d.ts +4 -0
  43. package/dist/gates/test-reporter.js +49 -0
  44. package/dist/route/preference.d.ts +22 -1
  45. package/dist/route/preference.js +123 -25
  46. package/dist/route/router.d.ts +13 -0
  47. package/dist/route/router.js +95 -17
  48. package/dist/run/daemon.d.ts +13 -0
  49. package/dist/run/daemon.js +398 -96
  50. package/dist/run/git.d.ts +35 -1
  51. package/dist/run/git.js +111 -10
  52. package/dist/run/journal.d.ts +14 -2
  53. package/dist/run/journal.js +78 -10
  54. package/dist/run/lease.d.ts +14 -0
  55. package/dist/run/lease.js +87 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/tui/cockpit/board.d.ts +96 -0
  61. package/dist/tui/cockpit/board.js +346 -0
  62. package/dist/tui/cockpit/decision-actions.js +2 -0
  63. package/dist/tui/cockpit/layout.d.ts +5 -1
  64. package/dist/tui/cockpit/layout.js +8 -3
  65. package/dist/tui/cockpit/live-runtime.js +83 -31
  66. package/dist/tui/cockpit/run-view.d.ts +7 -5
  67. package/dist/tui/cockpit/run-view.js +12 -11
  68. package/dist/tui/ink/fleet-app.d.ts +41 -27
  69. package/dist/tui/ink/fleet-app.js +204 -31
  70. package/package.json +1 -1
  71. package/skills/tickmarkr-overseer/SKILL.md +180 -99
@@ -106,6 +106,7 @@ export interface ExecutorDriver {
106
106
  narrateWith?(narrate: (event: JournalEvent) => void): void;
107
107
  worktree(repo: string, branch: string, baseRef: string): Promise<string>;
108
108
  narrator?: (cwd: string, command: string, runId?: string) => Promise<Slot>;
109
+ retireLostWatch?: (slot: Slot) => Promise<void>;
109
110
  /** Best-effort projection of a task's lifecycle onto the execution host. */
110
111
  project?: (taskId: string, state: "in-progress" | "in-review" | "completed") => Promise<void>;
111
112
  reconcile?: (desired: Set<string>, runId: string, opts?: PanesToCloseOpts) => Promise<void>;
@@ -37,6 +37,9 @@ export interface BaselineCommand {
37
37
  impliedParallelism?: number | null;
38
38
  /** The slowest per-file entry named by the runner; null when per-file timing is unavailable. */
39
39
  longestFile?: BaselineFileDuration | null;
40
+ /** VL-1: every per-file duration the runner named, so a later per-file hang budget (test-manifest.ts)
41
+ * can be derived per file rather than from one slowest-file number; null when unavailable. */
42
+ fileDurations?: BaselineFileDuration[] | null;
40
43
  /** The ceiling that measurement implies, persisted so every later battery uses the same number. */
41
44
  ceilingMs?: number;
42
45
  /**
@@ -230,13 +230,14 @@ function fileTiming(output, wallClockMs) {
230
230
  files.push({ file: match[1], durationMs });
231
231
  }
232
232
  if (!files.length)
233
- return { fileDurationSumMs: null, impliedParallelism: null, longestFile: null };
233
+ return { fileDurationSumMs: null, impliedParallelism: null, longestFile: null, fileDurations: null };
234
234
  const fileDurationSumMs = files.reduce((sum, entry) => sum + entry.durationMs, 0);
235
235
  const longestFile = files.reduce((longest, entry) => entry.durationMs > longest.durationMs ? entry : longest);
236
236
  return {
237
237
  fileDurationSumMs,
238
238
  impliedParallelism: wallClockMs > 0 ? fileDurationSumMs / wallClockMs : null,
239
239
  longestFile,
240
+ fileDurations: files,
240
241
  };
241
242
  }
242
243
  // A failing command whose output holds no shape any runner here names. The marker is content-free and
@@ -0,0 +1,100 @@
1
+ import type { Baseline } from "./baseline.js";
2
+ import type { GateResult } from "./types.js";
3
+ import { type RunCapacity } from "../run/git.js";
4
+ export declare const DEFAULT_VERDICT_CACHE_BOUND = 128;
5
+ export declare function setVerdictCacheBoundForTests(bound: number | undefined): void;
6
+ export declare function resetVerdictCacheBoundForTests(): void;
7
+ export declare const LOCKFILES: readonly ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
8
+ export declare function lockfileHash(worktree: string): string;
9
+ export declare function canonicalJson(obj: unknown): string;
10
+ export declare function baselineIdentity(baseline?: Baseline): string;
11
+ export declare function getWorktreeTree(worktree: string): Promise<string>;
12
+ export interface GateEnvironmentInput {
13
+ nodeRuntime?: string;
14
+ lockfile?: string;
15
+ worktree?: string;
16
+ capacity?: RunCapacity;
17
+ selectedSet?: readonly string[];
18
+ scope?: VerificationScope;
19
+ }
20
+ export declare function environmentFingerprint(env: GateEnvironmentInput): {
21
+ fingerprint: string;
22
+ parts: {
23
+ nodeRuntime: string;
24
+ lockfile: string;
25
+ capacity: RunCapacity;
26
+ selectedSet?: readonly string[];
27
+ };
28
+ };
29
+ export type VerificationScope = "battery" | "tip" | "standalone";
30
+ export interface VerificationIdentity {
31
+ /** the gate the verdict answers — build/lint/test may share one command and are still three verdicts */
32
+ gate?: string;
33
+ /** which verifier produced it: a battery gate row or an integration-tip row (own report, own forgiveness rule) */
34
+ scope?: VerificationScope;
35
+ /** Checkout location is diagnostic metadata; equal trees share verdicts across worktrees. */
36
+ worktree?: string;
37
+ tree: string;
38
+ command: string;
39
+ baseline: string;
40
+ environment: string;
41
+ envParts?: {
42
+ nodeRuntime: string;
43
+ lockfile: string;
44
+ capacity: RunCapacity;
45
+ selectedSet?: readonly string[];
46
+ };
47
+ }
48
+ export declare function computeVerificationIdentity(params: {
49
+ worktree: string;
50
+ gate: string;
51
+ scope?: VerificationScope;
52
+ command: string;
53
+ baseline?: Baseline;
54
+ selectedSet?: readonly string[];
55
+ capacity?: RunCapacity;
56
+ tree?: string;
57
+ lockfile?: string;
58
+ nodeRuntime?: string;
59
+ }): Promise<VerificationIdentity | undefined>;
60
+ export declare function verificationIdentityKey(id: VerificationIdentity): string;
61
+ export declare function formatReusedDetails(originalDetails: string, id: VerificationIdentity): string;
62
+ export interface CachedVerdict {
63
+ gate: string;
64
+ pass: boolean;
65
+ details: string;
66
+ exitCode?: number;
67
+ meta?: Record<string, unknown>;
68
+ capacity?: RunCapacity;
69
+ }
70
+ export declare function formatReusedRow(cached: CachedVerdict, id: VerificationIdentity): GateResult;
71
+ export declare function reusedIdentity(id: VerificationIdentity): Record<string, unknown>;
72
+ export declare function isInfraResult(result: GateResult | {
73
+ meta?: Record<string, unknown>;
74
+ details?: string;
75
+ cause?: string;
76
+ exitCode?: number;
77
+ }): boolean;
78
+ export declare function resolveStateDir(worktree: string, artifactDir?: string): string;
79
+ export declare function resolveVerdictStoreDir(stateDir: string): string;
80
+ export interface StoredVerdictRecord {
81
+ key: string;
82
+ identity: VerificationIdentity;
83
+ verdict: CachedVerdict;
84
+ timestamp: number;
85
+ sequence: number;
86
+ }
87
+ export declare function resetGlobalStoreSequenceForTests(): void;
88
+ export declare class VerdictStore {
89
+ readonly dir: string;
90
+ constructor(dir: string);
91
+ private initSequenceFromDisk;
92
+ get(id?: VerificationIdentity): CachedVerdict | undefined;
93
+ set(id: VerificationIdentity | undefined, verdict: GateResult | CachedVerdict): boolean;
94
+ size(): number;
95
+ clear(): void;
96
+ evictOldest(bound?: number): number;
97
+ }
98
+ export declare function getVerdictStore(stateDir: string, options?: {
99
+ bound?: number;
100
+ }): VerdictStore;
@@ -0,0 +1,389 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { describeCapacity, resolvedCapacity, shGit } from "../run/git.js";
7
+ import { shq } from "../adapters/types.js";
8
+ export const DEFAULT_VERDICT_CACHE_BOUND = 128;
9
+ let testCacheBound;
10
+ export function setVerdictCacheBoundForTests(bound) {
11
+ testCacheBound = bound;
12
+ }
13
+ export function resetVerdictCacheBoundForTests() {
14
+ testCacheBound = undefined;
15
+ globalStoreSequence = 0;
16
+ }
17
+ export const LOCKFILES = [
18
+ "package-lock.json",
19
+ "npm-shrinkwrap.json",
20
+ "pnpm-lock.yaml",
21
+ "yarn.lock",
22
+ "bun.lock",
23
+ "bun.lockb",
24
+ ];
25
+ export function lockfileHash(worktree) {
26
+ const hash = createHash("sha256");
27
+ let found = false;
28
+ for (const file of LOCKFILES) {
29
+ const p = join(worktree, file);
30
+ if (existsSync(p)) {
31
+ found = true;
32
+ try {
33
+ hash.update(file).update("\0").update(readFileSync(p)).update("\0");
34
+ }
35
+ catch {
36
+ hash.update(file).update("\0unreadable\0");
37
+ }
38
+ }
39
+ }
40
+ return found ? hash.digest("hex").slice(0, 16) : "no-lockfile";
41
+ }
42
+ export function canonicalJson(obj) {
43
+ if (obj === null || typeof obj !== "object")
44
+ return JSON.stringify(obj);
45
+ if (Array.isArray(obj))
46
+ return "[" + obj.map(canonicalJson).join(",") + "]";
47
+ const keys = Object.keys(obj).sort();
48
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}";
49
+ }
50
+ export function baselineIdentity(baseline) {
51
+ if (!baseline)
52
+ return "none";
53
+ // Measurements control execution budgets, not which failures the baseline forgives.
54
+ const commands = Object.fromEntries(Object.entries(baseline.commands).map(([gate, entry]) => {
55
+ const { durationMs: _duration, fileDurationSumMs: _sum, impliedParallelism: _parallelism, longestFile: _longest, fileDurations: _files, ceilingMs: _ceiling, ...evidence } = entry;
56
+ return [gate, evidence];
57
+ }));
58
+ return createHash("sha256").update(canonicalJson(commands)).digest("hex").slice(0, 16);
59
+ }
60
+ // The tree the command actually ran against: HEAD plus every tracked change and ordinary untracked
61
+ // path (a fixture that flips an uncommitted package.json changes the verdict). Built through a
62
+ // disposable index, as preserveWorktree does, so the checkout's real index is never touched.
63
+ export async function getWorktreeTree(worktree) {
64
+ const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-verdict-index-"));
65
+ const withIndex = (cmd) => `GIT_INDEX_FILE=${shq(join(scratch, "index"))} ${cmd}`;
66
+ try {
67
+ const read = await shGit(withIndex("git read-tree HEAD"), worktree);
68
+ if (read.code !== 0)
69
+ return "";
70
+ const add = await shGit(withIndex("git add -A -- ."), worktree);
71
+ if (add.code !== 0)
72
+ return "";
73
+ const tree = await shGit(withIndex("git write-tree"), worktree);
74
+ return tree.code === 0 ? tree.stdout.trim() : "";
75
+ }
76
+ finally {
77
+ rmSync(scratch, { recursive: true, force: true });
78
+ }
79
+ }
80
+ export function environmentFingerprint(env) {
81
+ const nodeRuntime = env.nodeRuntime ?? process.version;
82
+ const lockfile = env.lockfile ?? (env.worktree ? lockfileHash(env.worktree) : "no-lockfile");
83
+ const cap = env.capacity ?? resolvedCapacity();
84
+ const capacity = { forkCap: cap.forkCap, cores: cap.cores };
85
+ const selectedSet = env.selectedSet ? [...env.selectedSet].sort() : undefined;
86
+ const payload = canonicalJson({
87
+ nodeRuntime,
88
+ lockfile,
89
+ capacity,
90
+ selectedSet: selectedSet ?? null,
91
+ scope: env.scope ?? "battery",
92
+ });
93
+ const fingerprint = createHash("sha256").update(payload).digest("hex").slice(0, 16);
94
+ return {
95
+ fingerprint,
96
+ parts: { nodeRuntime, lockfile, capacity, selectedSet },
97
+ };
98
+ }
99
+ export async function computeVerificationIdentity(params) {
100
+ const tree = params.tree ?? (await getWorktreeTree(params.worktree));
101
+ if (!tree)
102
+ return undefined;
103
+ const baseline = baselineIdentity(params.baseline);
104
+ const env = environmentFingerprint({
105
+ worktree: params.worktree,
106
+ selectedSet: params.selectedSet,
107
+ capacity: params.capacity,
108
+ lockfile: params.lockfile,
109
+ nodeRuntime: params.nodeRuntime,
110
+ scope: params.scope,
111
+ });
112
+ return {
113
+ gate: params.gate,
114
+ scope: params.scope ?? "battery",
115
+ worktree: realpathSync(params.worktree),
116
+ tree,
117
+ command: params.command,
118
+ baseline,
119
+ environment: env.fingerprint,
120
+ envParts: env.parts,
121
+ };
122
+ }
123
+ export function verificationIdentityKey(id) {
124
+ const cmdHash = createHash("sha256").update(id.command).digest("hex").slice(0, 16);
125
+ const gate = id.gate ?? "gate";
126
+ // Checkout location is diagnostic only. Scope separates each verifier's evidence policy.
127
+ const workingTree = createHash("sha256").update(id.tree).digest("hex");
128
+ return `${id.scope ?? "battery"}-${gate}-${workingTree}-${cmdHash}-${id.baseline}-${id.environment}`;
129
+ }
130
+ export function formatReusedDetails(originalDetails, id) {
131
+ const unadorned = originalDetails.replace(/^reused verdict \(identity: [^)]+\):\s*/, "");
132
+ const envDesc = id.envParts
133
+ ? ` [node=${id.envParts.nodeRuntime}, lockfile=${id.envParts.lockfile}, capacity=${describeCapacity(id.envParts.capacity)}${id.envParts.selectedSet ? `, selected=${id.envParts.selectedSet.join(",")}` : ""}]`
134
+ : "";
135
+ const gate = id.gate ?? "gate";
136
+ const prefix = `reused ${id.scope === "tip" ? "tip " : ""}verdict (identity: gate=${gate} tree=${id.tree}${id.worktree ? ` worktree=${id.worktree}` : ""} command=${id.command} baseline=${id.baseline} env=${id.environment}${envDesc})`;
137
+ return `${prefix}: ${unadorned}`;
138
+ }
139
+ // A reused GREEN names the reuse in its details. A reused RED keeps the fresh verdict's details
140
+ // byte-for-byte: the daemon's fingerprint cap (identicalGateFailures) compares normalized details
141
+ // across attempts, so a red whose details carried a reuse note would never read as the identical
142
+ // failure it is and would buy a free retry — the reuse is named on the row's meta and on the
143
+ // gate-reused-verdict note run-gates emits beside it, which the daemon journals as its own row.
144
+ export function formatReusedRow(cached, id) {
145
+ const reusedDetails = formatReusedDetails(cached.details, id);
146
+ return {
147
+ gate: id.gate ?? cached.gate,
148
+ pass: cached.pass,
149
+ details: cached.pass ? reusedDetails : cached.details,
150
+ capacity: cached.capacity,
151
+ meta: {
152
+ ...cached.meta,
153
+ reused: true,
154
+ reusedDetails,
155
+ verificationIdentity: reusedIdentity(id),
156
+ },
157
+ };
158
+ }
159
+ export function reusedIdentity(id) {
160
+ return {
161
+ gate: id.gate ?? "gate",
162
+ scope: id.scope ?? "battery",
163
+ tree: id.tree,
164
+ ...(id.worktree ? { worktree: id.worktree } : {}),
165
+ command: id.command,
166
+ baseline: id.baseline,
167
+ environment: id.environment,
168
+ key: verificationIdentityKey(id),
169
+ };
170
+ }
171
+ export function isInfraResult(result) {
172
+ if (result.meta?.infra === true)
173
+ return true;
174
+ if (result.meta?.classification === "infra")
175
+ return true;
176
+ if (result.meta?.kind === "ceiling-kill" || result.meta?.kind === "hang")
177
+ return true;
178
+ if ("cause" in result && result.cause === "infra")
179
+ return true;
180
+ if (typeof result.details === "string") {
181
+ const d = result.details.toLowerCase();
182
+ if (d.startsWith("infra;") || d.startsWith("infra: ") || d.includes("infrastructure blocked execution")) {
183
+ return true;
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+ export function resolveStateDir(worktree, artifactDir) {
189
+ if (artifactDir) {
190
+ const idx = artifactDir.lastIndexOf("/.tickmarkr");
191
+ if (idx !== -1) {
192
+ return artifactDir.slice(0, idx + "/.tickmarkr".length);
193
+ }
194
+ }
195
+ const idx = worktree.lastIndexOf("/.tickmarkr");
196
+ if (idx !== -1) {
197
+ return worktree.slice(0, idx + "/.tickmarkr".length);
198
+ }
199
+ if (existsSync(join(worktree, ".tickmarkr"))) {
200
+ return join(worktree, ".tickmarkr");
201
+ }
202
+ try {
203
+ const commonDir = execSync("git rev-parse --git-common-dir", { cwd: worktree, encoding: "utf8" }).trim();
204
+ const resolved = resolve(worktree, commonDir);
205
+ const commonRoot = realpathSync(dirname(resolved));
206
+ if (existsSync(join(commonRoot, ".tickmarkr"))) {
207
+ return join(commonRoot, ".tickmarkr");
208
+ }
209
+ }
210
+ catch {
211
+ // ignore
212
+ }
213
+ return join(worktree, ".tickmarkr");
214
+ }
215
+ export function resolveVerdictStoreDir(stateDir) {
216
+ mkdirSync(stateDir, { recursive: true });
217
+ const gi = join(stateDir, ".gitignore");
218
+ if (!existsSync(gi)) {
219
+ try {
220
+ writeFileSync(gi, "*\n");
221
+ }
222
+ catch {
223
+ // ignore
224
+ }
225
+ }
226
+ const dir = join(stateDir, "verdicts");
227
+ mkdirSync(dir, { recursive: true });
228
+ return dir;
229
+ }
230
+ let globalStoreSequence = 0;
231
+ export function resetGlobalStoreSequenceForTests() {
232
+ globalStoreSequence = 0;
233
+ }
234
+ export class VerdictStore {
235
+ dir;
236
+ constructor(dir) {
237
+ this.dir = dir;
238
+ }
239
+ initSequenceFromDisk() {
240
+ if (!existsSync(this.dir))
241
+ return;
242
+ try {
243
+ for (const f of readdirSync(this.dir)) {
244
+ if (f.startsWith("verdict-") && f.endsWith(".json")) {
245
+ try {
246
+ const raw = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
247
+ if (raw && typeof raw.sequence === "number" && raw.sequence > globalStoreSequence) {
248
+ globalStoreSequence = raw.sequence;
249
+ }
250
+ }
251
+ catch {
252
+ // ignore
253
+ }
254
+ }
255
+ }
256
+ }
257
+ catch {
258
+ // ignore
259
+ }
260
+ }
261
+ get(id) {
262
+ if (!id || !id.tree)
263
+ return undefined;
264
+ const key = verificationIdentityKey(id);
265
+ const p = join(this.dir, `verdict-${key}.json`);
266
+ if (existsSync(p)) {
267
+ try {
268
+ const raw = JSON.parse(readFileSync(p, "utf8"));
269
+ if (raw && raw.verdict)
270
+ return raw.verdict;
271
+ }
272
+ catch {
273
+ return undefined;
274
+ }
275
+ }
276
+ return undefined;
277
+ }
278
+ set(id, verdict) {
279
+ if (!id || !id.tree)
280
+ return false;
281
+ if (isInfraResult(verdict))
282
+ return false;
283
+ mkdirSync(this.dir, { recursive: true });
284
+ this.initSequenceFromDisk();
285
+ const key = verificationIdentityKey(id);
286
+ const bound = testCacheBound ?? DEFAULT_VERDICT_CACHE_BOUND;
287
+ const record = {
288
+ key,
289
+ identity: id,
290
+ verdict: {
291
+ gate: verdict.gate ?? id.gate ?? "gate",
292
+ pass: verdict.pass,
293
+ details: verdict.details,
294
+ ...("exitCode" in verdict ? { exitCode: verdict.exitCode } : {}),
295
+ ...(verdict.capacity ? { capacity: verdict.capacity } : {}),
296
+ ...(verdict.meta ? { meta: verdict.meta } : {}),
297
+ },
298
+ timestamp: Date.now(),
299
+ sequence: ++globalStoreSequence,
300
+ };
301
+ const finalPath = join(this.dir, `verdict-${key}.json`);
302
+ const tmpPath = join(this.dir, `verdict-${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
303
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
304
+ try {
305
+ unlinkSync(finalPath);
306
+ }
307
+ catch {
308
+ // ignore
309
+ }
310
+ writeFileSync(finalPath, readFileSync(tmpPath));
311
+ try {
312
+ unlinkSync(tmpPath);
313
+ }
314
+ catch {
315
+ // ignore
316
+ }
317
+ this.evictOldest(bound);
318
+ return true;
319
+ }
320
+ size() {
321
+ if (!existsSync(this.dir))
322
+ return 0;
323
+ return readdirSync(this.dir).filter((f) => f.startsWith("verdict-") && f.endsWith(".json")).length;
324
+ }
325
+ clear() {
326
+ globalStoreSequence = 0;
327
+ if (!existsSync(this.dir))
328
+ return;
329
+ for (const f of readdirSync(this.dir)) {
330
+ if (f.startsWith("verdict-")) {
331
+ try {
332
+ unlinkSync(join(this.dir, f));
333
+ }
334
+ catch {
335
+ // ignore
336
+ }
337
+ }
338
+ }
339
+ }
340
+ evictOldest(bound) {
341
+ const limit = bound ?? testCacheBound ?? DEFAULT_VERDICT_CACHE_BOUND;
342
+ const dir = this.dir;
343
+ if (!existsSync(dir))
344
+ return 0;
345
+ const files = readdirSync(dir).filter((f) => f.startsWith("verdict-") && f.endsWith(".json"));
346
+ if (files.length <= limit)
347
+ return 0;
348
+ const entries = [];
349
+ for (const file of files) {
350
+ const p = join(dir, file);
351
+ try {
352
+ const raw = JSON.parse(readFileSync(p, "utf8"));
353
+ entries.push({
354
+ file,
355
+ sequence: typeof raw.sequence === "number" ? raw.sequence : 0,
356
+ timestamp: typeof raw.timestamp === "number" ? raw.timestamp : 0,
357
+ });
358
+ }
359
+ catch {
360
+ entries.push({ file, sequence: -1, timestamp: -1 });
361
+ }
362
+ }
363
+ entries.sort((a, b) => {
364
+ // Each write seeds its sequence from disk, including in a fresh process.
365
+ // Wall-clock corrections must not make the newest write the oldest entry.
366
+ if (a.sequence !== b.sequence)
367
+ return a.sequence - b.sequence;
368
+ return a.timestamp - b.timestamp;
369
+ });
370
+ let evicted = 0;
371
+ while (entries.length > limit) {
372
+ const oldest = entries.shift();
373
+ try {
374
+ unlinkSync(join(dir, oldest.file));
375
+ evicted++;
376
+ }
377
+ catch {
378
+ // ignore
379
+ }
380
+ }
381
+ return evicted;
382
+ }
383
+ }
384
+ export function getVerdictStore(stateDir, options) {
385
+ if (options?.bound !== undefined) {
386
+ setVerdictCacheBoundForTests(options.bound);
387
+ }
388
+ return new VerdictStore(resolveVerdictStoreDir(stateDir));
389
+ }
@@ -64,9 +64,42 @@ export declare function matchClosureId(candidate: unknown, fingerprints: Iterabl
64
64
  * all route through matchClosureId.
65
65
  */
66
66
  export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
67
+ export type ReviewerFloorCause = "author-tier" | "task-floor" | "config" | "prior-reviewer";
68
+ /**
69
+ * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
70
+ * task-declared floor, a configured `review.floor` tier and, on a second round or a retry, the prior
71
+ * reviewer's tier. The cause names the input that reached the maximum (earlier inputs win a tie, so a
72
+ * floor the author's tier already satisfies is attributed to the author).
73
+ */
74
+ export declare function resolveReviewerFloor(authorTier: Tier, taskFloor?: Tier, configFloor?: Tier, priorReviewerTier?: Tier): {
75
+ floor: Tier;
76
+ cause: ReviewerFloorCause;
77
+ };
78
+ /** A prior reviewer of THIS task: a channel key, or a journaled row's key plus the tier it was DISPATCHED at. */
79
+ export type PriorReviewer = string | {
80
+ reviewer: string;
81
+ tier?: unknown;
82
+ };
83
+ /**
84
+ * The highest tier among the task's prior reviewers — the prior reviewer's tier for RF-1. A recorded
85
+ * dispatch tier is historical evidence and wins over the current pool; an unrecorded one falls back to
86
+ * the seat's channel; a seat neither establishes (it left the pool on resume, or the journal holds
87
+ * garbage) holds frontier — fail closed, never silently dropped.
88
+ */
89
+ export declare function priorReviewerTier(channels: BillingChannel[], priorReviewers?: readonly PriorReviewer[]): Tier | undefined;
90
+ /**
91
+ * The gate's floor: author tier, task floor, review.floor (a tier — `worker` names none) and the seats
92
+ * the caller names as THIS TASK's prior reviewers (earlier rounds' seats, a flaked seat). Eligibility
93
+ * exclusions are NOT evidence — a retry bans a flaked seat's whole adapter, and those sibling channels
94
+ * never reviewed — and neither is the run-scoped LRU rotation history, which names unrelated tasks' seats.
95
+ */
96
+ export declare function gateReviewerFloor(task: Pick<Task, "routingHints">, cfg: TickmarkrConfig, author: Assignment, channels: BillingChannel[], priorReviewers?: readonly PriorReviewer[]): {
97
+ floor: Tier;
98
+ cause: ReviewerFloorCause;
99
+ };
67
100
  export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
68
101
  prefer?: string[], // v1.53 T2: review.prefer — reorders eligible channels, never changes eligibility
69
- floor?: Tier, // task-declared only; config floors govern workers and must not silently move review seats
102
+ floor?: Tier, // task/config/prior floor from the caller; the author's own tier is ALWAYS applied here (RF-1)
70
103
  history?: string[], // run-scoped picks, oldest to newest; empty preserves the established ranking
71
104
  onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>): BillingChannel | null;
72
105
  export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-started" | "truncated" | "silent";
@@ -76,4 +109,4 @@ export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-sta
76
109
  * judgement rather than a guarantee made by this renderer.
77
110
  */
78
111
  export declare function renderDeclaredWriteScope(files: ReadonlyArray<string>): string;
79
- 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[]): Promise<GateResult>;
112
+ 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[]): Promise<GateResult>;