tickmarkr 2.5.6 → 2.5.8

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 (59) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +13 -6
  3. package/dist/cli/commands/compile.js +7 -0
  4. package/dist/cli/commands/plan.d.ts +5 -0
  5. package/dist/cli/commands/plan.js +28 -23
  6. package/dist/cli/commands/resume.js +1 -1
  7. package/dist/cli/commands/run.js +1 -1
  8. package/dist/cli/commands/status.js +95 -34
  9. package/dist/cli/commands/verify.js +6 -4
  10. package/dist/compile/native.js +68 -7
  11. package/dist/compile/retired-literals.d.ts +22 -0
  12. package/dist/compile/retired-literals.js +271 -0
  13. package/dist/drivers/index.d.ts +4 -2
  14. package/dist/drivers/index.js +54 -6
  15. package/dist/gates/baseline.d.ts +8 -3
  16. package/dist/gates/baseline.js +6 -3
  17. package/dist/gates/review.js +12 -1
  18. package/dist/gates/run-gates.d.ts +6 -1
  19. package/dist/gates/run-gates.js +67 -9
  20. package/dist/gates/test-manifest.d.ts +12 -0
  21. package/dist/gates/test-manifest.js +29 -3
  22. package/dist/gates/test-reporter.js +28 -2
  23. package/dist/graph/graph.d.ts +2 -0
  24. package/dist/graph/graph.js +5 -0
  25. package/dist/graph/schema.d.ts +28 -0
  26. package/dist/graph/schema.js +13 -1
  27. package/dist/run/activity.d.ts +28 -0
  28. package/dist/run/activity.js +194 -0
  29. package/dist/run/daemon.d.ts +28 -0
  30. package/dist/run/daemon.js +466 -110
  31. package/dist/run/git.d.ts +6 -1
  32. package/dist/run/git.js +63 -11
  33. package/dist/run/journal.d.ts +57 -5
  34. package/dist/run/journal.js +103 -9
  35. package/dist/run/merge.d.ts +2 -0
  36. package/dist/run/merge.js +1 -0
  37. package/dist/run/operator-page-summary.d.ts +56 -0
  38. package/dist/run/operator-page-summary.js +68 -0
  39. package/dist/run/operator-summary.d.ts +69 -0
  40. package/dist/run/operator-summary.js +77 -0
  41. package/dist/run/protocol.d.ts +71 -0
  42. package/dist/run/protocol.js +32 -0
  43. package/dist/run/repair-disposition.d.ts +41 -0
  44. package/dist/run/repair-disposition.js +77 -0
  45. package/dist/tui/cockpit/board.d.ts +9 -0
  46. package/dist/tui/cockpit/board.js +10 -0
  47. package/dist/tui/cockpit/derive.d.ts +35 -0
  48. package/dist/tui/cockpit/derive.js +152 -10
  49. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  50. package/dist/tui/cockpit/evidence-view.js +42 -12
  51. package/dist/tui/cockpit/home-view.js +45 -30
  52. package/dist/tui/cockpit/live-store.d.ts +18 -0
  53. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  54. package/dist/tui/cockpit/run-cockpit.js +95 -1
  55. package/dist/tui/cockpit/run-view.d.ts +37 -0
  56. package/dist/tui/cockpit/run-view.js +189 -2
  57. package/package.json +1 -1
  58. package/schema/rungraph.schema.json +54 -0
  59. package/skills/tickmarkr-overseer/SKILL.md +162 -3
@@ -0,0 +1,22 @@
1
+ import type { Task } from "../graph/schema.js";
2
+ /** The file-system seam: tests substitute it to stage an unreadable file or a truncated read. */
3
+ export interface PinScanIo {
4
+ size(abs: string): number;
5
+ read(abs: string): Buffer;
6
+ /** stat-following; throws like statSync (ENOENT for a broken link). */
7
+ isDirectory(abs: string): boolean;
8
+ /** stat-following: a regular file (or a link resolving to one) — never a FIFO, socket or device. */
9
+ isFile(abs: string): boolean;
10
+ }
11
+ export interface LiteralHit {
12
+ path: string;
13
+ line: number;
14
+ }
15
+ /** Each line of `content` holding `text`, 1-based. */
16
+ export declare function literalHits(path: string, content: string, text: string): LiteralHit[];
17
+ /**
18
+ * One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
19
+ * by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
20
+ * named in the diagnostic and is no substitute.
21
+ */
22
+ export declare function retiredLiteralErrors(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "pins">>, repoRoot: string, io?: PinScanIo): string[];
@@ -0,0 +1,271 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { filesGlob, literalParens } from "../graph/files-glob.js";
4
+ const NODE_IO = {
5
+ size: (abs) => statSync(abs).size,
6
+ read: (abs) => readFileSync(abs),
7
+ isDirectory: (abs) => statSync(abs).isDirectory(),
8
+ isFile: (abs) => statSync(abs).isFile(),
9
+ };
10
+ const cause = (error) => (error instanceof Error ? error.message : String(error));
11
+ const patternsOf = (pin) => (pin.kind === "literal" ? [pin.glob] : pin.paths);
12
+ /** Leading segments with no glob syntax at all (never the last): every match of the pattern sits under them. */
13
+ const literalBase = (pattern) => {
14
+ const segs = literalParens(pattern).split("/");
15
+ const wild = segs.findIndex((s) => !/^[\w.-]+$/.test(s) || s === "..");
16
+ return segs.slice(0, Math.min(wild < 0 ? segs.length : wild, segs.length - 1)).join("/");
17
+ };
18
+ /**
19
+ * picomatch's brace semantics are expansion (`{a,b/c}/x` ≡ `a/x`, `b/c/x`; `\,` and `\{` are literal):
20
+ * every top-level comma-holding brace is expanded until none remains, so a slash inside a brace is an
21
+ * honest segment boundary. A brace with no top-level comma (`{a}`, `{1..3}`) is left for filesGlob.
22
+ */
23
+ function expandBraces(pattern) {
24
+ let depth = 0;
25
+ let open = -1;
26
+ let alts = [];
27
+ let last = -1;
28
+ for (let i = 0; i < pattern.length; i++) {
29
+ const c = pattern[i];
30
+ if (c === "\\")
31
+ i++;
32
+ else if (c === "{") {
33
+ if (depth++ === 0) {
34
+ open = i;
35
+ last = i + 1;
36
+ alts = [];
37
+ }
38
+ }
39
+ else if (c === "," && depth === 1) {
40
+ alts.push(pattern.slice(last, i));
41
+ last = i + 1;
42
+ }
43
+ else if (c === "}" && depth > 0 && --depth === 0) {
44
+ if (!alts.length)
45
+ continue; // no top-level comma: a literal or range brace, not an alternation
46
+ alts.push(pattern.slice(last, i));
47
+ const head = pattern.slice(0, open);
48
+ const tail = pattern.slice(i + 1);
49
+ return alts.flatMap((a) => expandBraces(head + a + tail));
50
+ }
51
+ }
52
+ return [pattern];
53
+ }
54
+ /** Split on "/" where it is a segment boundary; null when a slash is escaped or sits inside brackets. */
55
+ function segmentsOf(pattern) {
56
+ const out = [""];
57
+ let depth = 0;
58
+ for (let i = 0; i < pattern.length; i++) {
59
+ const c = pattern[i];
60
+ if (c === "\\") {
61
+ if (pattern[i + 1] === "/")
62
+ return null;
63
+ out[out.length - 1] += c + (pattern[++i] ?? "");
64
+ }
65
+ else if (c === "/") {
66
+ if (depth)
67
+ return null;
68
+ out.push("");
69
+ }
70
+ else {
71
+ if (c === "[")
72
+ depth++;
73
+ else if (c === "]" && depth)
74
+ depth--;
75
+ out[out.length - 1] += c;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Does `alt` (brace-free) match EVERY path beneath `d`? Only a trailing globstar right after a matched prefix
82
+ * proves it; a globstar before that may swallow any run of `d`'s segments (`**\/cache/**` covers fixtures/cache/loop).
83
+ */
84
+ function coversBeneath(alt, d) {
85
+ const segs = segmentsOf(alt);
86
+ if (!segs)
87
+ return false;
88
+ const covers = (si, di) => {
89
+ if (si >= segs.length)
90
+ return false;
91
+ if (segs[si] === "**") {
92
+ if (si === segs.length - 1)
93
+ return true;
94
+ for (let k = di; k <= d.length; k++)
95
+ if (covers(si + 1, k))
96
+ return true;
97
+ return false;
98
+ }
99
+ if (di >= d.length)
100
+ return false;
101
+ // the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
102
+ return filesGlob(segs.slice(0, si + 1).join("/"))(d.slice(0, di + 1).join("/")) && covers(si + 1, di + 1);
103
+ };
104
+ return covers(0, 0);
105
+ }
106
+ /**
107
+ * Could `pattern` match a path strictly beneath directory `dir`? Matcher-consistent by construction:
108
+ * braces are expanded the way picomatch reads them, then every segment prefix is decided by filesGlob itself,
109
+ * and only an exact `**` segment is a globstar (`**.txt` is one ordinary segment). A leading `!` matches
110
+ * every path its body does not, so it reaches beneath `dir` unless some branch of the body provably covers
111
+ * all of it. What still cannot be split soundly (an escaped or bracketed slash) answers yes rather than certify blind.
112
+ */
113
+ function reachesBeneath(pattern, dir) {
114
+ const p = pattern.replace(/^\.\//, "");
115
+ const d = dir ? dir.split("/") : [];
116
+ if (p.startsWith("!"))
117
+ return !expandBraces(literalParens(p.slice(1))).some((alt) => coversBeneath(alt, d));
118
+ return expandBraces(literalParens(p)).some((alt) => {
119
+ const segs = segmentsOf(alt);
120
+ if (!segs)
121
+ return true;
122
+ for (let i = 0; i < d.length; i++) {
123
+ if (i >= segs.length)
124
+ return false;
125
+ if (segs[i] === "**")
126
+ return true;
127
+ // the prefix is matched as ONE pattern: a standalone `!cache` segment would read as a negation
128
+ if (!filesGlob(segs.slice(0, i + 1).join("/"))(d.slice(0, i + 1).join("/")))
129
+ return false;
130
+ }
131
+ return segs.length > d.length;
132
+ });
133
+ }
134
+ /**
135
+ * Every non-directory path at or under `base`, decided later by filesGlob alone. NOTHING is pruned:
136
+ * not .git, not node_modules, not ignored files, not the state dir — the declared glob is the only
137
+ * narrowing (the literal base is exact: no match can sit outside it). A directory symlink is
138
+ * followed so matches behind it are listed under the symlink's own path; a link back into its own
139
+ * ancestry has no finite listing, so it refuses by name instead of being skipped.
140
+ * ponytail: a rootless glob such as ** / *.txt walks node_modules too; narrow the glob if that is slow.
141
+ */
142
+ function walkAll(repoRoot, base, io) {
143
+ const out = { paths: [], causes: [] };
144
+ const visit = (rel, chain) => {
145
+ const abs = join(repoRoot, rel);
146
+ let isDir;
147
+ try {
148
+ isDir = io.isDirectory(abs);
149
+ }
150
+ catch (error) {
151
+ try {
152
+ // only a confirmed ENOENT is a known non-directory: a broken link stays a candidate whose read names the cause
153
+ if (error.code === "ENOENT") {
154
+ if (lstatSync(abs, { throwIfNoEntry: false }))
155
+ out.paths.push(rel);
156
+ return;
157
+ }
158
+ }
159
+ catch { /* the lstat failed too: fall through to the refusal */ }
160
+ // anything else may be a directory hiding obligations: never a candidate that a glob could filter away
161
+ out.causes.push({ at: rel, why: `unstattable path ${rel || "."} (${cause(error)})`, maybeFile: true });
162
+ return;
163
+ }
164
+ if (!isDir) {
165
+ if (rel)
166
+ out.paths.push(rel);
167
+ return;
168
+ }
169
+ try {
170
+ const real = realpathSync(abs);
171
+ if (chain.includes(real)) {
172
+ out.causes.push({ at: rel, why: `symlink cycle at ${rel} cannot be enumerated`, maybeFile: false });
173
+ return;
174
+ }
175
+ for (const name of readdirSync(abs).sort())
176
+ visit(rel ? `${rel}/${name}` : name, [...chain, real]);
177
+ }
178
+ catch (error) {
179
+ out.causes.push({ at: rel, why: `unlistable directory ${rel || "."} (${cause(error)})`, maybeFile: false });
180
+ }
181
+ };
182
+ visit(base, []);
183
+ return out;
184
+ }
185
+ /** Whole-file read, or a thrown cause: a short read is never evidence of absence. */
186
+ function readWhole(repoRoot, rel, io) {
187
+ const abs = join(repoRoot, rel);
188
+ let size;
189
+ let bytes;
190
+ try {
191
+ // a FIFO, socket or device has no finite whole to read: refuse it by kind before any read could block
192
+ if (!io.isFile(abs))
193
+ throw new Error(`unsupported file ${rel} (not a regular file)`);
194
+ size = io.size(abs);
195
+ bytes = io.read(abs);
196
+ }
197
+ catch (error) {
198
+ throw new Error(`unreadable file ${rel} (${cause(error)})`);
199
+ }
200
+ if (bytes.length !== size)
201
+ throw new Error(`truncated read of ${rel} (${bytes.length} of ${size} bytes)`);
202
+ return bytes.toString("utf8");
203
+ }
204
+ /** Each line of `content` holding `text`, 1-based. */
205
+ export function literalHits(path, content, text) {
206
+ return content.split("\n").flatMap((line, i) => (line.includes(text) ? [{ path, line: i + 1 }] : []));
207
+ }
208
+ const pinName = (pin) => pin.kind === "literal" ? `literal ${JSON.stringify(pin.text)} in ${pin.glob}` : `fixture pin ${pin.paths.join(", ")}`;
209
+ /**
210
+ * One line per uncovered obligation or uncertifiable scan; empty means every declared pin is covered
211
+ * by the task that declares it. Ownership by any OTHER task — merged predecessor or downstream — is
212
+ * named in the diagnostic and is no substitute.
213
+ */
214
+ export function retiredLiteralErrors(tasks, repoRoot, io = NODE_IO) {
215
+ const declaring = tasks.filter((t) => t.pins?.length);
216
+ if (!declaring.length)
217
+ return [];
218
+ const walks = new Map();
219
+ const walked = (base) => walks.get(base) ?? walks.set(base, walkAll(repoRoot, base, io)).get(base);
220
+ const owns = new Map(tasks.map((t) => [t.id, filesGlob(t.files.map((f) => f.replace(/^\.\//, "")))]));
221
+ const errors = [];
222
+ for (const task of declaring) {
223
+ const owner = (path) => {
224
+ const others = tasks.filter((t) => t.id !== task.id && owns.get(t.id)(path)).map((t) => t.id);
225
+ return others.length
226
+ ? `owned by ${others.join(", ")}, which is no substitute: add it to ${task.id}.files[]`
227
+ : `owned by no task: add it to ${task.id}.files[]`;
228
+ };
229
+ for (const pin of task.pins) {
230
+ const matches = filesGlob(patternsOf(pin));
231
+ const found = [...new Set(patternsOf(pin).map((p) => literalBase(p.replace(/^\.\//, ""))))].map(walked);
232
+ // a cause refuses only a pin that could match at or beneath it: a cycle under fixtures/sub never refuses fixtures/*.txt
233
+ const affects = (c) => (c.maybeFile && matches(c.at)) || patternsOf(pin).some((p) => reachesBeneath(p, c.at));
234
+ const causes = [...new Set(found.flatMap((w) => w.causes).filter(affects).map((c) => c.why))];
235
+ for (const why of causes)
236
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${why}`);
237
+ const refused = causes.length > 0;
238
+ const surface = [...new Set(found.flatMap((w) => w.paths))].filter(matches).sort();
239
+ if (!surface.length) {
240
+ if (refused)
241
+ continue; // the refusal above already names why nothing was certified
242
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: empty search surface (the pattern matches no file)`);
243
+ continue;
244
+ }
245
+ for (const path of surface) {
246
+ const owned = owns.get(task.id)(path);
247
+ // both kinds certify a whole-file read first: an owned fixture nobody could read is not evidence
248
+ let content;
249
+ try {
250
+ content = readWhole(repoRoot, path, io);
251
+ }
252
+ catch (error) {
253
+ errors.push(`${task.id}: ${pinName(pin)} refuses certification: ${cause(error)}`);
254
+ continue;
255
+ }
256
+ if (pin.kind === "fixture") {
257
+ if (!owned)
258
+ errors.push(`${task.id}: ${pinName(pin)} obligates ${path}, outside ${task.id}.files[] (${owner(path)})`);
259
+ continue;
260
+ }
261
+ const hits = literalHits(path, content, pin.text);
262
+ if (owned)
263
+ continue;
264
+ for (const hit of hits) {
265
+ errors.push(`${task.id}: ${pinName(pin)} still appears at ${hit.path}:${hit.line}, outside ${task.id}.files[] (${owner(path)})`);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ return errors;
271
+ }
@@ -13,9 +13,11 @@ export declare function classifyHost(env?: NodeJS.ProcessEnv): ClassifiedHost;
13
13
  /**
14
14
  * A config driver of herdr or orca whose host is not the classified one is refused naming the
15
15
  * detected host, the config line and the --driver remedy. Any explicit --driver value bypasses
16
- * this; auto and subprocess are never refused.
16
+ * that; auto and subprocess are never refused. Then, when the RESOLVED driver is orca (config,
17
+ * auto on an Orca host, or an explicit flag), the run root is admitted by one probe before any run
18
+ * directory, journal row or dispatch exists. Subprocess and herdr selections probe nothing.
17
19
  */
18
- export declare function preflightHostDriver(cfg: TickmarkrConfig, driverOverride: string | undefined, host: ClassifiedHost): void;
20
+ export declare function preflightHostDriver(cfg: TickmarkrConfig, driverOverride: string | undefined, host: ClassifiedHost, runRoot: string, env?: NodeJS.ProcessEnv): Promise<void>;
19
21
  /**
20
22
  * Orca authors both markers on every terminal it creates. Requiring the pair avoids treating an
21
23
  * unrelated TERM_PROGRAM value or a copied terminal handle as host identity. This is deliberately
@@ -1,5 +1,7 @@
1
1
  import { HerdrDriver } from "./herdr.js";
2
- import { OrcaDriver } from "./orca.js";
2
+ import { shq } from "../adapters/types.js";
3
+ import { sh } from "../run/git.js";
4
+ import { canonicalWorktreePath, OrcaDriver, OrcaError, parseEnvelope, resolveOrcaCliBinary, terminalWorktree } from "./orca.js";
3
5
  import { SubprocessDriver } from "./subprocess.js";
4
6
  export const DRIVER_CHOICES = ["auto", "herdr", "subprocess", "orca"];
5
7
  const overrideByDriver = new WeakMap();
@@ -20,18 +22,64 @@ export function classifyHost(env = process.env) {
20
22
  return "orca";
21
23
  return "none";
22
24
  }
25
+ const UNMANAGED_CHECKOUT = "checkout is not Orca-managed; run from an orca worktree create checkout or pass --driver subprocess";
26
+ /**
27
+ * OBS-1061: the ONE admission probe — `worktree current` asked from the run root. Like the driver's
28
+ * own per-checkout query it accepts an enclosing tracked checkout after path canonicalisation. An
29
+ * untracked checkout refuses with both remedies; any other failure refuses naming its reason, never
30
+ * read as managed.
31
+ */
32
+ async function admitOrcaCheckout(runRoot, env) {
33
+ const bin = resolveOrcaCliBinary(runRoot, { env }) ?? "orca";
34
+ const refuse = (reason) => new Error(`refusing driver 'orca': Orca admission probe of ${runRoot} failed — ${reason}`);
35
+ let reported;
36
+ // The transport's own diagnostics (timeout, exit, stderr): a stderr-only failure such as a missing
37
+ // executable parses as 'empty response', which alone names no cause.
38
+ let transport = "";
39
+ try {
40
+ // Config values flow into a shell here: every argv element is quoted, always.
41
+ const r = await sh([bin, "worktree", "current", "--json"].map(shq).join(" "), runRoot);
42
+ const raw = [r.stdout, r.stderr ? `STDERR: ${r.stderr}` : ""].filter(Boolean).join("\n");
43
+ transport = [r.timedOut ? "probe timed out" : r.code !== 0 ? `orca exited ${r.code}` : "", r.stderr.trim() ? `stderr: ${r.stderr.trim()}` : ""]
44
+ .filter(Boolean).join("; ");
45
+ const envelope = parseEnvelope("worktree-current", r.stdout, raw);
46
+ // An ok:true body on a nonzero exit is a transport failure (stale stdout), as in the driver.
47
+ if (r.code !== 0)
48
+ throw new OrcaError("worktree-current", `orca exited ${r.code}`, raw);
49
+ const worktree = envelope.result.worktree;
50
+ reported = typeof worktree === "object" && worktree !== null && !Array.isArray(worktree)
51
+ ? terminalWorktree(worktree)
52
+ : undefined;
53
+ if (!reported)
54
+ throw new OrcaError("worktree-current", "response carries no worktree path", raw);
55
+ }
56
+ catch (e) {
57
+ if (e instanceof OrcaError && e.code === "selector_not_found")
58
+ throw new Error(`refusing driver 'orca': ${UNMANAGED_CHECKOUT}`);
59
+ const reason = e instanceof OrcaError ? e.reason : e.message;
60
+ throw refuse(transport.startsWith(reason) ? transport : [reason, transport].filter(Boolean).join("; "));
61
+ }
62
+ const tracked = canonicalWorktreePath(reported);
63
+ const root = canonicalWorktreePath(runRoot);
64
+ if (tracked !== root && !root.startsWith(`${tracked}/`))
65
+ throw new Error(`refusing driver 'orca': ${UNMANAGED_CHECKOUT}`);
66
+ }
23
67
  /**
24
68
  * A config driver of herdr or orca whose host is not the classified one is refused naming the
25
69
  * detected host, the config line and the --driver remedy. Any explicit --driver value bypasses
26
- * this; auto and subprocess are never refused.
70
+ * that; auto and subprocess are never refused. Then, when the RESOLVED driver is orca (config,
71
+ * auto on an Orca host, or an explicit flag), the run root is admitted by one probe before any run
72
+ * directory, journal row or dispatch exists. Subprocess and herdr selections probe nothing.
27
73
  */
28
- export function preflightHostDriver(cfg, driverOverride, host) {
29
- if (driverOverride !== undefined)
30
- return;
31
- if ((cfg.driver === "herdr" || cfg.driver === "orca") && cfg.driver !== host) {
74
+ export async function preflightHostDriver(cfg, driverOverride, host, runRoot, env = process.env) {
75
+ if (driverOverride === undefined && (cfg.driver === "herdr" || cfg.driver === "orca") && cfg.driver !== host) {
32
76
  const remedy = host === "none" ? "subprocess" : host;
33
77
  throw new Error(`refusing driver '${cfg.driver}' (config line 'driver: ${cfg.driver}'): detected host is ${host}; use --driver ${remedy} to override`);
34
78
  }
79
+ const want = parseDriverOverride(driverOverride) ?? cfg.driver;
80
+ const resolved = want === "auto" ? (host === "none" ? "subprocess" : host) : want;
81
+ if (resolved === "orca")
82
+ await admitOrcaCheckout(runRoot, env);
35
83
  }
36
84
  /**
37
85
  * Orca authors both markers on every terminal it creates. Requiring the pair avoids treating an
@@ -1,6 +1,6 @@
1
1
  import type { TickmarkrConfig } from "../config/config.js";
2
2
  import type { AcceptanceItem } from "../graph/schema.js";
3
- import { type RunCapacity, type ShResult } from "../run/git.js";
3
+ import { type RunCapacity, type ShellOptions, type ShResult } from "../run/git.js";
4
4
  import type { GateResult } from "./types.js";
5
5
  /**
6
6
  * T7: the capacity a gate's own command ran under rides the RESULT, beside the verdict it explains,
@@ -157,7 +157,12 @@ export declare const CAPTURE_CEILING_MS = 1800000;
157
157
  /** OBS-1044: a cached vitest entry whose count is a stdout sum may be inflated; only a manifest-derived
158
158
  * count is safe to apply as a deficit floor. A null count compares nothing and is safe as-is. */
159
159
  export declare function staleFileCountCommands(baseline: Baseline, commands: Record<string, string>, cwd: string): string[];
160
- export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
160
+ export interface BaselineReceiptOptions {
161
+ onReceipt?: ShellOptions["onReceipt"];
162
+ /** Only comparison's build command may carry task-build identity; capture never does. */
163
+ taskBuildAttribution?: ShellOptions["receiptAttribution"];
164
+ }
165
+ export declare function captureBaseline(cwd: string, commands: Record<string, string>, opts?: BaselineReceiptOptions): Promise<Baseline>;
161
166
  export interface VacuousOracleWarning {
162
167
  kind: "vacuous-oracle";
163
168
  taskId: string;
@@ -171,7 +176,7 @@ export declare function detectVacuousOracles(cwd: string, tasks: ReadonlyArray<{
171
176
  export interface RetryOptions {
172
177
  authorizeRetry?: (cause: "infra" | "host-starved") => boolean;
173
178
  }
174
- export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: RetryOptions & {
179
+ export declare function compareToBaseline(cwd: string, commands: Record<string, string>, baseline: Baseline, enabled: string[], opts?: RetryOptions & BaselineReceiptOptions & {
175
180
  rerunOf?: HostStarvedRerun;
176
181
  infraRerun?: HostStarvedRerun;
177
182
  selected?: readonly string[];
@@ -487,13 +487,13 @@ export function staleFileCountCommands(baseline, commands, cwd) {
487
487
  })
488
488
  .map(([name]) => name);
489
489
  }
490
- export async function captureBaseline(cwd, commands) {
490
+ export async function captureBaseline(cwd, commands, opts = {}) {
491
491
  const base = { commands: {} };
492
492
  for (const [name, cmd] of Object.entries(commands)) {
493
493
  if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
494
494
  continue;
495
495
  }
496
- const r = await sh(cmd, cwd, CAPTURE_CEILING_MS);
496
+ const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { onReceipt: opts.onReceipt });
497
497
  // ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
498
498
  // ponytail: a capture that was itself killed records the ceiling as its "measurement", which
499
499
  // scales the next ceiling up — the right direction for a suite that never finished once.
@@ -634,7 +634,10 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
634
634
  }
635
635
  const entry = baseline.commands[name];
636
636
  const ceilingMs = effectiveCeilingMs(entry);
637
- const r = await sh(cmd, cwd, ceilingMs);
637
+ const r = await sh(cmd, cwd, ceilingMs, {
638
+ onReceipt: opts.onReceipt,
639
+ ...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
640
+ });
638
641
  // T7: every verdict below carries the capacity ITS OWN command ran under, taken off the shell
639
642
  // result rather than re-derived after the fact. The skip row above ran no command and therefore
640
643
  // states no capacity — a row that never divided the machine must not claim that it did.
@@ -185,7 +185,10 @@ export { modelProvider };
185
185
  export function matchClosureId(candidate, target) {
186
186
  if (typeof candidate !== "string")
187
187
  return typeof target === "string" ? false : undefined;
188
- const normCandidate = candidate.replace(/\s+/g, "");
188
+ // OBS-1068: the brief's copy block printed each id as `Fingerprint: <id>` and told the seat to copy
189
+ // it EXACTLY — three faithful approvals in one run were discarded as closure-mismatch. A leading
190
+ // `Fingerprint:` label is the brief's own wording, never the reviewer's id; strip it before comparing.
191
+ const normCandidate = candidate.replace(/^\s*fingerprint\s*:\s*/i, "").replace(/\s+/g, "");
189
192
  if (typeof target === "string") {
190
193
  return normCandidate === target.replace(/\s+/g, "");
191
194
  }
@@ -476,6 +479,11 @@ carriedAuthors = []) {
476
479
  return capFail;
477
480
  const nonce = generateVerdictNonce();
478
481
  const repoRoot = daemonRepoRoot(worktree, artifactDir);
482
+ // OBS-880 add.1: guidance only. Do not expand scope globs into permission to run suites.
483
+ const ownTestFiles = [...new Set(task.files.filter((file) => !/[*?[\]{}()!]/.test(file) && /(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)))];
484
+ const suiteBudget = ownTestFiles.length
485
+ ? `You may run at most the task's own test files explicitly named in files[]; these are the only suites you may run: ${ownTestFiles.map((file) => `\`${file}\``).join(", ")}.`
486
+ : "No suite may be run: files[] names no explicit test file owned by this task.";
479
487
  const prompt = `TICKMARKR-REVIEW
480
488
  You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
481
489
  Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
@@ -489,6 +497,9 @@ ${task.acceptance.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
489
497
 
490
498
  ${renderDeclaredWriteScope(task.files)}
491
499
 
500
+ ## Reviewer suite budget
501
+ ${suiteBudget} Never run the whole suite (including an unfiltered npm test or vitest run). The gate suite owns the runner lease; a parallel full suite starves the gate.
502
+
492
503
  ${priorMaterials.length ? `${renderPriorMaterials(priorMaterials)}
493
504
 
494
505
  ` : ""}## Diff
@@ -1,3 +1,4 @@
1
+ import type { CommandReceiptAttribution } from "../run/protocol.js";
1
2
  import { type Assignment, type BillingChannel, type WorkerAdapter, type WorkerResult } from "../adapters/types.js";
2
3
  import { type TickmarkrConfig } from "../config/config.js";
3
4
  import { type GateName, type Task } from "../graph/schema.js";
@@ -42,9 +43,10 @@ export type GateEvent = {
42
43
  gate: GateName;
43
44
  name: string;
44
45
  payload: Record<string, unknown>;
45
- result: GateResult;
46
+ result?: GateResult;
46
47
  };
47
48
  export interface GateContext {
49
+ buildReceiptIdentity?: Omit<CommandReceiptAttribution, "invocation">;
48
50
  authorizeInfraRetry?: (subject: string, cause?: VerificationRetryCause) => boolean;
49
51
  verificationScope?: VerificationScope;
50
52
  worktree: string;
@@ -62,6 +64,9 @@ export interface GateContext {
62
64
  excludeReviewers?: string[];
63
65
  demotedReviewers?: Set<string>;
64
66
  reviewNoVerdicts?: Map<string, string[]>;
67
+ recheck?: boolean;
68
+ /** Explicit worker funding requires fresh red measurements, never a gate waiver. */
69
+ cachedRedBypass?: "operator-rerun";
65
70
  carriedAuthors?: readonly string[];
66
71
  reviewHistory?: string[];
67
72
  priorReviewers?: PriorReviewer[];
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
2
3
  import { loadavg, tmpdir } from "node:os";
3
4
  import { join, posix } from "node:path";
@@ -258,6 +259,40 @@ function classifySignalOnlyTest(g) {
258
259
  }
259
260
  export async function runGates(task, ctx) {
260
261
  const results = [];
262
+ // Receipt identity belongs to this round, never to a cached verdict. Each call from the shell
263
+ // allocates a new invocation, including retries whose local spawn counter starts at one again.
264
+ let currentBuild;
265
+ let buildStarted = false;
266
+ let receiptNotes = Promise.resolve();
267
+ const beginBuild = () => {
268
+ buildStarted = false;
269
+ currentBuild = { ...(ctx.buildReceiptIdentity ?? {
270
+ runId: ctx.artifactDir ?? "standalone", taskId: task.id, attempt: 0, gateRound: 0,
271
+ }), invocation: randomUUID() };
272
+ return { ...currentBuild };
273
+ };
274
+ const buildReceipt = (receipt, reason) => {
275
+ const matches = currentBuild !== undefined && receipt.attribution !== undefined
276
+ && Object.keys(currentBuild)
277
+ .every((key) => receipt.attribution[key] === currentBuild[key]);
278
+ const attributed = matches && (receipt.outcome === "started" || !receipt.confirmedStart || buildStarted);
279
+ if (attributed)
280
+ buildStarted = receipt.outcome === "started";
281
+ const { attribution, ...observation } = receipt;
282
+ const payload = attributed ? { ...receipt, gate: "build", ...(reason ? { reason, freshBuildRan: false } : {}) } : {
283
+ ...observation, gate: "build", attributionStatus: "unattributed",
284
+ reportedAttribution: attribution,
285
+ };
286
+ // Shell observers are synchronous. Serialize asynchronous note sinks and drain before the
287
+ // verdict (also on cancellation), without letting an observer change execution or retry policy.
288
+ receiptNotes = receiptNotes.then(async () => {
289
+ await ctx.onGate?.({ phase: "note", gate: "build", name: "build-receipt", payload });
290
+ }).catch(() => { });
291
+ };
292
+ const noBuild = async (outcome, reason) => {
293
+ buildReceipt({ outcome, confirmedStart: false, attribution: beginBuild() }, reason);
294
+ await receiptNotes;
295
+ };
261
296
  let selectionDecision;
262
297
  let commits = [];
263
298
  const stateDir = ctx.stateDir ?? resolveStateDir(ctx.worktree, ctx.artifactDir);
@@ -272,6 +307,16 @@ export async function runGates(task, ctx) {
272
307
  // the ledger names the reuse and the identity even where the gate-result row's details must stay
273
308
  // the fresh verdict's (see formatReusedRow).
274
309
  const noteReuse = (gate, r, id) => ctx.onGate?.({ phase: "note", gate, name: "gate-reused-verdict", payload: { gate, pass: r.pass, details: r.meta?.reusedDetails, ...reusedIdentity(id) }, result: r });
310
+ // OBS-1055: on a recheck a cached red is the answer the operator just said was wrongly given; it is
311
+ // journaled as discarded and the gate runs. Returns true when the hit must NOT be reused.
312
+ const discardCachedRed = async (gate, hit) => {
313
+ const reason = ctx.recheck ? "recheck" : ctx.cachedRedBypass;
314
+ if (!reason || hit.pass)
315
+ return false;
316
+ await ctx.onGate?.({ phase: "note", gate, name: reason === "recheck" ? "recheck-rerun" : "gate-rerun",
317
+ payload: { gate, reason: "cached-red-discarded", ...(reason === "recheck" ? {} : { bypass: reason }) }, result: hit });
318
+ return true;
319
+ };
275
320
  const shapeGates = ctx.cfg.gates.byShape?.[task.shape];
276
321
  const enabled = (g) => task.gates.includes(g) && (g !== "acceptance" && g !== "review" || shapeGates?.[g] !== false);
277
322
  const failed = () => results.some((r) => !r.pass);
@@ -595,20 +640,30 @@ export async function runGates(task, ctx) {
595
640
  const hit = verdictStore.get(identity);
596
641
  if (hit)
597
642
  classifySignalOnlyTest(hit); // Older entries predate classification at the write seam.
598
- if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")) {
643
+ if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
644
+ && !(await discardCachedRed(g, hit))) {
599
645
  r = formatReusedRow(hit, identity);
600
646
  cached = true;
647
+ if (g === "build")
648
+ await noBuild("reused-result", "verdict reused; no fresh build ran in this invocation");
601
649
  await noteReuse(g, r, identity);
602
650
  }
603
651
  }
604
652
  if (!r) {
605
- // VL-1: a detected vitest test command is judged by its own invocation-bound report — the
606
- // stdout-count/file-count path (compareToBaseline's fileCountDeficit) never runs for it. Any
607
- // other scripted test command keeps today's exit-code contract byte-identically.
608
- const useManifest = g === "test" && commands.test !== undefined && isVitestTestCommand(commands.test, ctx.worktree);
609
- r = useManifest
610
- ? await measure(g, () => runVitestManifestGate(ctx.worktree, commands.test, ctx.baseline, selected, ctx.artifactDir, retryOptions(identity)))
611
- : (await measure(g, () => compareToBaseline(ctx.worktree, commands, ctx.baseline, [g], { ...retryOptions(identity), ...(g === "test" && selected ? { selected } : {}) })))[0];
653
+ if (g === "build" && !cmd)
654
+ await noBuild("skipped", "no build command detected");
655
+ try {
656
+ // VL-1: a detected vitest test command is judged by its own invocation-bound report — the
657
+ // stdout-count/file-count path (compareToBaseline's fileCountDeficit) never runs for it. Any
658
+ // other scripted test command keeps today's exit-code contract byte-identically.
659
+ const useManifest = g === "test" && commands.test !== undefined && isVitestTestCommand(commands.test, ctx.worktree);
660
+ r = useManifest
661
+ ? await measure(g, () => runVitestManifestGate(ctx.worktree, commands.test, ctx.baseline, selected, ctx.artifactDir, retryOptions(identity)))
662
+ : (await measure(g, () => compareToBaseline(ctx.worktree, commands, ctx.baseline, [g], { ...retryOptions(identity), ...(g === "build" ? { onReceipt: buildReceipt, taskBuildAttribution: beginBuild } : {}), ...(g === "test" && selected ? { selected } : {}) })))[0];
663
+ }
664
+ finally {
665
+ await receiptNotes;
666
+ }
612
667
  }
613
668
  // the screen's interval IS the test gate's first interval, so the split needs no second clock
614
669
  if (g === "test" && selected)
@@ -929,6 +984,8 @@ export async function runGates(task, ctx) {
929
984
  if (entryDirt) {
930
985
  addMeasurement(sequence[0], entryMeasurement);
931
986
  await emitStart(sequence[0]);
987
+ if (enabled("build"))
988
+ await noBuild("refused", "dirty worktree; no build start confirmed");
932
989
  await record(await dirtyRefusal(sequence[0], entryDirt));
933
990
  return done();
934
991
  }
@@ -1037,7 +1094,8 @@ export async function runGates(task, ctx) {
1037
1094
  const hit = verdictStore.get(identity);
1038
1095
  if (hit)
1039
1096
  classifySignalOnlyTest(hit);
1040
- if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")) {
1097
+ if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
1098
+ && !(await discardCachedRed("test", hit))) {
1041
1099
  full = formatReusedRow(hit, identity);
1042
1100
  cached = true;
1043
1101
  await noteReuse("test", full, identity);