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.
- package/dist/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +13 -6
- package/dist/cli/commands/compile.js +7 -0
- package/dist/cli/commands/plan.d.ts +5 -0
- package/dist/cli/commands/plan.js +28 -23
- package/dist/cli/commands/resume.js +1 -1
- package/dist/cli/commands/run.js +1 -1
- package/dist/cli/commands/status.js +95 -34
- package/dist/cli/commands/verify.js +6 -4
- package/dist/compile/native.js +68 -7
- package/dist/compile/retired-literals.d.ts +22 -0
- package/dist/compile/retired-literals.js +271 -0
- package/dist/drivers/index.d.ts +4 -2
- package/dist/drivers/index.js +54 -6
- package/dist/gates/baseline.d.ts +8 -3
- package/dist/gates/baseline.js +6 -3
- package/dist/gates/review.js +12 -1
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +67 -9
- package/dist/gates/test-manifest.d.ts +12 -0
- package/dist/gates/test-manifest.js +29 -3
- package/dist/gates/test-reporter.js +28 -2
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +5 -0
- package/dist/graph/schema.d.ts +28 -0
- package/dist/graph/schema.js +13 -1
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/daemon.d.ts +28 -0
- package/dist/run/daemon.js +466 -110
- package/dist/run/git.d.ts +6 -1
- package/dist/run/git.js +63 -11
- package/dist/run/journal.d.ts +57 -5
- package/dist/run/journal.js +103 -9
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +1 -0
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- package/dist/run/repair-disposition.d.ts +41 -0
- package/dist/run/repair-disposition.js +77 -0
- package/dist/tui/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/home-view.js +45 -30
- package/dist/tui/cockpit/live-store.d.ts +18 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- package/package.json +1 -1
- package/schema/rungraph.schema.json +54 -0
- 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
|
+
}
|
package/dist/drivers/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
package/dist/drivers/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { HerdrDriver } from "./herdr.js";
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
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
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/gates/baseline.js
CHANGED
|
@@ -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.
|
package/dist/gates/review.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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[];
|
package/dist/gates/run-gates.js
CHANGED
|
@@ -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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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);
|