tickmarkr 2.2.0 → 2.3.0
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/README.md +10 -9
- package/dist/adapters/model-lints.js +9 -0
- package/dist/adapters/pi.d.ts +1 -0
- package/dist/adapters/pi.js +15 -1
- package/dist/adapters/prompt.js +11 -3
- package/dist/adapters/registry.js +13 -4
- package/dist/adapters/types.d.ts +23 -1
- package/dist/adapters/types.js +43 -2
- package/dist/cli/commands/approve.d.ts +3 -7
- package/dist/cli/commands/approve.js +26 -20
- package/dist/cli/commands/beat.js +7 -4
- package/dist/cli/commands/doctor.d.ts +6 -2
- package/dist/cli/commands/doctor.js +79 -9
- package/dist/cli/commands/init.js +36 -21
- package/dist/cli/commands/plan.js +20 -3
- package/dist/cli/commands/report.js +37 -1
- package/dist/cli/commands/verify.d.ts +5 -0
- package/dist/cli/commands/verify.js +142 -25
- package/dist/cli/commands/version.d.ts +2 -1
- package/dist/cli/commands/version.js +25 -4
- package/dist/compile/collateral.js +15 -9
- package/dist/compile/native.js +5 -3
- package/dist/config/config.js +1 -1
- package/dist/drivers/index.d.ts +7 -0
- package/dist/drivers/index.js +40 -10
- package/dist/drivers/orca.d.ts +41 -1
- package/dist/drivers/orca.js +192 -15
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +16 -9
- package/dist/drivers/types.d.ts +2 -0
- package/dist/gates/baseline.d.ts +4 -0
- package/dist/gates/baseline.js +68 -16
- package/dist/gates/llm.d.ts +7 -1
- package/dist/gates/llm.js +66 -35
- package/dist/gates/review.d.ts +3 -1
- package/dist/gates/review.js +42 -12
- package/dist/gates/run-gates.d.ts +6 -0
- package/dist/gates/run-gates.js +25 -10
- package/dist/gates/verdict-cause.d.ts +6 -2
- package/dist/gates/verdict-cause.js +8 -4
- package/dist/run/consult.d.ts +7 -0
- package/dist/run/consult.js +21 -3
- package/dist/run/daemon.d.ts +14 -0
- package/dist/run/daemon.js +249 -27
- package/dist/run/git.d.ts +1 -0
- package/dist/run/git.js +4 -0
- package/dist/run/journal.d.ts +15 -2
- package/dist/run/journal.js +70 -12
- package/dist/run/supervision.d.ts +6 -0
- package/dist/run/supervision.js +29 -1
- package/dist/tui/ink/init-app.js +4 -4
- package/package.json +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -0
- package/skills/tickmarkr-overseer/SKILL.md +88 -18
- package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
- package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +35 -9
- package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
@@ -8,20 +9,20 @@ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../
|
|
|
8
9
|
import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
|
|
9
10
|
import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
|
|
10
11
|
import { tickmarkrDir } from "../../graph/graph.js";
|
|
12
|
+
import { orcaHostDetected } from "../../drivers/index.js";
|
|
11
13
|
import { Journal } from "../../run/journal.js";
|
|
12
14
|
import { doctor } from "./doctor.js";
|
|
13
15
|
import { assembleFleetEditor } from "./fleet.js";
|
|
14
16
|
const SCAFFOLD_SPEC = "tickmarkr.spec.md";
|
|
15
17
|
// Operator-approved (2026-07-17) environments footer — no npm install for herdr (npm package
|
|
16
|
-
// "herdr" is a reserved 0.0.0 placeholder as of that date).
|
|
17
|
-
//
|
|
18
|
-
// herdr-else-subprocess and never picks it, so the footer names it beside the other two choices.
|
|
18
|
+
// "herdr" is a reserved 0.0.0 placeholder as of that date). Auto resolves herdr → orca →
|
|
19
|
+
// subprocess; the orca leg is environment-only, using the two markers Orca stamps in its terminals.
|
|
19
20
|
const ENVIRONMENTS_FOOTER = [
|
|
20
21
|
"environments:",
|
|
21
|
-
" herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev",
|
|
22
|
-
" orca — visible terminals in the Orca app — an
|
|
22
|
+
" herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev · auto picks it first when HERDR_ENV=1",
|
|
23
|
+
" orca — visible terminals in the Orca app — auto picks it inside an Orca terminal after herdr and before subprocess; set driver: orca to force it · https://onorca.dev",
|
|
23
24
|
" claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
|
|
24
|
-
" anywhere — no herdr? same fail-closed gates, headless subprocess driver",
|
|
25
|
+
" anywhere — no herdr or Orca terminal? same fail-closed gates, headless subprocess driver (legacy: no herdr? same fail-closed gates, headless subprocess driver when Orca markers are absent)",
|
|
25
26
|
].join("\n");
|
|
26
27
|
/** Latest journal without a run-end event, if any. */
|
|
27
28
|
function activeRunId(cwd) {
|
|
@@ -140,17 +141,6 @@ you were handed the whole picture — list the repository root, open every guida
|
|
|
140
141
|
direction each moves, what makes a criterion real, and why an absence or a source-text grep is never a
|
|
141
142
|
criterion. Read it before authoring or repairing acceptance items.
|
|
142
143
|
- **Editing \`src/gates/\`, \`src/compile/\` or \`src/graph/\`?** Read \`docs/codebase/ARCHITECTURE.md\` first.
|
|
143
|
-
- **EVERY fix gets a ship/no-ship decision, recorded, at the moment it is made.** Ask one question of each
|
|
144
|
-
one: *does a user hit this defect?* If yes, the fix belongs in \`src/**\` or \`skills/**\` — the only trees
|
|
145
|
-
the package carries (\`files: [dist, schema, skills, fixtures]\`). A script, overlay, config entry or
|
|
146
|
-
operator-side workaround that resolves the symptom **locally is not the fix; it is a decision to leave
|
|
147
|
-
every other user broken**, and it must say so in writing and name the condition that removes it.
|
|
148
|
-
**The default answer is SHIP.** A local remedy is the exception and carries the burden of proof.
|
|
149
|
-
Watch for the three shapes this hides in: a fix applied where you happened to be standing rather than
|
|
150
|
-
where the defect lives; an observation filed with a product fix named in its own text and queued
|
|
151
|
-
nowhere; and a local tool that quietly grows into a product feature nobody shipped. **A defect and its
|
|
152
|
-
fix must be recorded in the same place, or the queue silently becomes a list of things everyone assumed
|
|
153
|
-
someone else had shipped.**
|
|
154
144
|
${DOCS_END}
|
|
155
145
|
`;
|
|
156
146
|
// Every applicable host location gets the skills, each paired with its own repository guidance
|
|
@@ -229,7 +219,28 @@ const describeDrift = (d) => [
|
|
|
229
219
|
// should not be asked again just because a copy drifted. Staleness is a different question with a
|
|
230
220
|
// different answer (`--agent --force`), and it is reported by name on every init path below.
|
|
231
221
|
const skillsInstalled = (cwd) => hostTargets(cwd).every((t) => AGENT_SKILLS.every((s) => existsSync(join(t.skillsDir, s, "SKILL.md"))));
|
|
232
|
-
const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : "auto";
|
|
222
|
+
const wizardDriverDefault = () => process.env.HERDR_ENV === "1" ? "herdr" : orcaHostDetected() ? "orca" : "auto";
|
|
223
|
+
function packageName(cwd) {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
226
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function gitTracked(cwd, path) {
|
|
233
|
+
const rel = relative(cwd, path);
|
|
234
|
+
if (rel.startsWith(".."))
|
|
235
|
+
return false;
|
|
236
|
+
try {
|
|
237
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd, stdio: "ignore" });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
233
244
|
async function installAgentFiles(cwd, force, docs, notes) {
|
|
234
245
|
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
235
246
|
let prompt;
|
|
@@ -257,13 +268,14 @@ async function installAgentFiles(cwd, force, docs, notes) {
|
|
|
257
268
|
notes.push(`kept current ${dest}`);
|
|
258
269
|
continue;
|
|
259
270
|
}
|
|
271
|
+
const tracked = exists && gitTracked(cwd, dest);
|
|
260
272
|
if (exists && !force && !(await confirm(`Overwrite ${dest} (${describeDrift(stale)})?`))) {
|
|
261
|
-
notes.push(`skipped existing ${dest} — ${describeDrift(stale)}; pass --force to overwrite it`);
|
|
273
|
+
notes.push(`skipped existing ${tracked ? "tracked " : ""}${dest} — ${describeDrift(stale)}; pass --force to overwrite it`);
|
|
262
274
|
continue;
|
|
263
275
|
}
|
|
264
276
|
// whole skill dir, not just SKILL.md — the overseer ships its pane-watcher script
|
|
265
277
|
cpSync(fileURLToPath(new URL(`../../../skills/${skill}`, import.meta.url)), join(skillsDir, skill), { recursive: true });
|
|
266
|
-
notes.push(`${exists ? "overwrote" : "wrote"} ${dest}`);
|
|
278
|
+
notes.push(`${exists ? "overwrote" : "wrote"} ${tracked ? "tracked " : ""}${dest}`);
|
|
267
279
|
}
|
|
268
280
|
const current = existsSync(docPath) ? readFileSync(docPath, "utf8") : "";
|
|
269
281
|
// The block is CANONICAL guidance ("stated once here, restated nowhere"), so a repo that has
|
|
@@ -341,6 +353,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
|
|
|
341
353
|
});
|
|
342
354
|
// banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
|
|
343
355
|
emitBanner();
|
|
356
|
+
if (packageName(cwd) === "tickmarkr") {
|
|
357
|
+
throw new Error("OBS-584: tickmarkr init refuses to run inside the tickmarkr source repository");
|
|
358
|
+
}
|
|
344
359
|
const gdir = values["global-dir"] ?? globalConfigDir();
|
|
345
360
|
mkdirSync(gdir, { recursive: true });
|
|
346
361
|
const notes = [];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
1
4
|
import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
|
|
2
5
|
import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
|
|
3
6
|
import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
4
|
-
import { parseArgs } from "node:util";
|
|
5
7
|
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
6
8
|
import { classifyContextPath } from "../../compile/native.js";
|
|
7
9
|
import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
@@ -17,6 +19,7 @@ import { loadRoutingProfile } from "../../run/journal.js";
|
|
|
17
19
|
import { harnessLine, resolveHarness } from "../harness.js";
|
|
18
20
|
import { shq } from "../../adapters/types.js";
|
|
19
21
|
import { shGit } from "../../run/git.js";
|
|
22
|
+
import { driverEvidence, pickDriver } from "../../drivers/index.js";
|
|
20
23
|
// T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
|
|
21
24
|
// markers carry the attention glyph, section labels dim to chrome (the doctor/status system).
|
|
22
25
|
// Gated on ttyVisual(): the non-TTY surface returns untouched (byte-pinned, machine-consumable).
|
|
@@ -99,16 +102,21 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
99
102
|
const DOCTOR_STALE_MS = 24 * 60 * 60 * 1000;
|
|
100
103
|
// v1.51 T2: plan previews any mode without a config edit — same source precedence as run
|
|
101
104
|
// (flag > spec front-matter > repo > global > default), same preset compiler.
|
|
102
|
-
const { values } = parseArgs({ args: argv, options: { mode: { type: "string" } }, allowPositionals: true });
|
|
105
|
+
const { values } = parseArgs({ args: argv, options: { mode: { type: "string" }, driver: { type: "string" } }, allowPositionals: true });
|
|
103
106
|
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
104
107
|
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
105
108
|
}
|
|
106
109
|
const g = loadGraph(cwd);
|
|
107
110
|
const oracleRefusals = new Map();
|
|
111
|
+
const oracleAdvisories = new Map();
|
|
108
112
|
if (g.tasks.some((task) => task.acceptance.some((item) => typeof item === "object" && item.oracle === "test"))) {
|
|
109
113
|
const listing = await (opts.listTests ?? listVitestTests)(cwd);
|
|
110
114
|
for (const task of g.tasks) {
|
|
111
115
|
const refusals = [];
|
|
116
|
+
const advisories = [];
|
|
117
|
+
const authoredTest = task.files
|
|
118
|
+
.map((path) => path.replace(/^\.\//, ""))
|
|
119
|
+
.find((path) => /^tests\/.+\.test\.ts$/.test(path) && !/[?*{[]/.test(path) && !existsSync(join(cwd, path)));
|
|
112
120
|
if (listing.status === "failed") {
|
|
113
121
|
if (task.acceptance.some((item) => typeof item === "object" && item.oracle === "test")) {
|
|
114
122
|
refusals.push(`acceptance oracle unresolved — runner listing failed: ${listing.error.split("\n")[0]}`);
|
|
@@ -116,7 +124,10 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
116
124
|
}
|
|
117
125
|
else {
|
|
118
126
|
for (const audit of auditNamedTestOracles(task.acceptance, listing.tests)) {
|
|
119
|
-
if (audit.matches.length === 0) {
|
|
127
|
+
if (audit.matches.length === 0 && authoredTest) {
|
|
128
|
+
advisories.push(`acceptance oracle ${JSON.stringify(audit.criterion)} not yet written (worker authors ${authoredTest})`);
|
|
129
|
+
}
|
|
130
|
+
else if (audit.matches.length === 0) {
|
|
120
131
|
refusals.push(`acceptance oracle ${JSON.stringify(audit.criterion)} matches zero runner-listed test names`);
|
|
121
132
|
}
|
|
122
133
|
else if (audit.matches.length > 1) {
|
|
@@ -126,6 +137,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
126
137
|
}
|
|
127
138
|
if (refusals.length)
|
|
128
139
|
oracleRefusals.set(task.id, refusals);
|
|
140
|
+
if (advisories.length)
|
|
141
|
+
oracleAdvisories.set(task.id, advisories);
|
|
129
142
|
}
|
|
130
143
|
}
|
|
131
144
|
const inputFindings = await taskInputFindings(g.tasks, cwd);
|
|
@@ -136,6 +149,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
136
149
|
inputRefusals.set(finding.taskId, [...(inputRefusals.get(finding.taskId) ?? []), finding.detail]);
|
|
137
150
|
}
|
|
138
151
|
const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
|
|
152
|
+
const selectedDriver = pickDriver(cfg, values.driver);
|
|
139
153
|
// readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
|
|
140
154
|
const cached = readDoctor(cwd);
|
|
141
155
|
const health = cached ?? (await probeAll(adapters));
|
|
@@ -157,6 +171,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
157
171
|
const lines = [
|
|
158
172
|
`tickmarkr plan — dry run (${channels.length} channels available)`,
|
|
159
173
|
`mode: ${mode.mode} (${source}) · explore ${cfg.routing.explore?.mode ?? "on"}`,
|
|
174
|
+
`driver: ${driverEvidence(cfg, selectedDriver, values.driver)}`,
|
|
160
175
|
harnessLine(resolveHarness(harnessFrom)),
|
|
161
176
|
"",
|
|
162
177
|
];
|
|
@@ -280,6 +295,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
280
295
|
lines.push(d);
|
|
281
296
|
lints.push(`${t.id}: unroutable — ${msg}`);
|
|
282
297
|
}
|
|
298
|
+
for (const advisory of oracleAdvisories.get(t.id) ?? [])
|
|
299
|
+
lines.push(` ! ${advisory}`);
|
|
283
300
|
}
|
|
284
301
|
const inputWarnings = inputFindings.filter((finding) => finding.severity === "warn");
|
|
285
302
|
if (inputWarnings.length) {
|
|
@@ -4,6 +4,7 @@ import { ttyVisual } from "../../adapters/model-lints.js";
|
|
|
4
4
|
import { addUsage } from "../../adapters/types.js";
|
|
5
5
|
import { dim, rule, title } from "../../brand.js";
|
|
6
6
|
import { loadConfig } from "../../config/config.js";
|
|
7
|
+
import { modelProvider } from "../../gates/review.js";
|
|
7
8
|
import { buildProofBundle, gateDeclined } from "../../report/bundle.js";
|
|
8
9
|
import { compareRuns } from "../../report/compare.js";
|
|
9
10
|
import { estimateCosts } from "../../report/cost.js";
|
|
@@ -35,6 +36,22 @@ const channelLabel = (data) => {
|
|
|
35
36
|
const { adapter, model } = a;
|
|
36
37
|
return typeof adapter === "string" && typeof model === "string" ? `${adapter}:${model}` : EM;
|
|
37
38
|
};
|
|
39
|
+
const modelFromChannel = (channel) => channel.slice(channel.indexOf(":") + 1);
|
|
40
|
+
const reviewerIdentity = (data) => {
|
|
41
|
+
const details = typeof data.details === "string" ? data.details : "";
|
|
42
|
+
const fromDetails = /reviewer\s+([^\s;()]+:[^\s;()]+)/i.exec(details)?.[1];
|
|
43
|
+
const reviewer = typeof data.reviewer === "string" ? data.reviewer : fromDetails;
|
|
44
|
+
if (!reviewer || !reviewer.includes(":"))
|
|
45
|
+
return undefined;
|
|
46
|
+
const vendor = typeof data.vendor === "string"
|
|
47
|
+
? data.vendor
|
|
48
|
+
: /vendor:\s*([^;()]+)/i.exec(details)?.[1]?.trim()
|
|
49
|
+
?? /\(([^;:()]+)\):/.exec(details)?.[1]?.trim();
|
|
50
|
+
const provider = typeof data.provider === "string"
|
|
51
|
+
? data.provider
|
|
52
|
+
: modelProvider(modelFromChannel(reviewer), vendor ?? "unknown");
|
|
53
|
+
return { reviewer, provider, ...(vendor ? { vendor } : {}) };
|
|
54
|
+
};
|
|
38
55
|
const rateBasis = (row) => {
|
|
39
56
|
if (!row.rate)
|
|
40
57
|
return EM;
|
|
@@ -245,6 +262,7 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
|
|
|
245
262
|
const dispatches = events.filter((e) => e.taskId === taskId && e.event === "task-dispatch");
|
|
246
263
|
const channels = dispatches.map((e) => channelLabel(e.data));
|
|
247
264
|
const gates = events.filter((e) => e.taskId === taskId && e.event === "gate-result");
|
|
265
|
+
const leg2 = events.filter((e) => e.taskId === taskId && e.event === "review-leg2");
|
|
248
266
|
const consults = events.filter((e) => e.taskId === taskId && e.event === "consult-verdict");
|
|
249
267
|
const deviations = events.filter((e) => e.taskId === taskId && e.event === "route-deviation");
|
|
250
268
|
const merge = [...events].reverse().find((e) => e.taskId === taskId && e.event === "merge");
|
|
@@ -267,10 +285,28 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
|
|
|
267
285
|
if (gates.length) {
|
|
268
286
|
for (const g of gates) {
|
|
269
287
|
// T11: a declined gate never ran — report it as declined, never as a pass.
|
|
270
|
-
|
|
288
|
+
let pass = gateDeclined(g.data) ? "declined" : g.data.pass === true ? "pass" : g.data.pass === false ? "fail" : EM;
|
|
271
289
|
const gate = typeof g.data.gate === "string" ? g.data.gate : EM;
|
|
290
|
+
if (gate === "review" && pass === "pass") {
|
|
291
|
+
const reviewer = reviewerIdentity(g.data);
|
|
292
|
+
const before = events.slice(0, events.indexOf(g) + 1).reverse()
|
|
293
|
+
.find((e) => e.taskId === taskId && e.event === "task-dispatch");
|
|
294
|
+
const author = channelLabel(before?.data ?? {});
|
|
295
|
+
const authorProvider = author === EM ? "unknown" : modelProvider(modelFromChannel(author));
|
|
296
|
+
if (reviewer && authorProvider !== "unknown" && authorProvider === reviewer.provider) {
|
|
297
|
+
pass = "pass (same-provider — independence not established)";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
272
300
|
lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}`);
|
|
273
301
|
}
|
|
302
|
+
for (const row of leg2) {
|
|
303
|
+
const pass = row.data.pass === true ? "pass" : row.data.pass === false ? "fail" : EM;
|
|
304
|
+
const identity = reviewerIdentity(row.data);
|
|
305
|
+
const identityText = identity
|
|
306
|
+
? `reviewer ${identity.reviewer}${identity.vendor ? ` (vendor: ${identity.vendor}; provider: ${identity.provider})` : ` (provider: ${identity.provider})`}`
|
|
307
|
+
: firstLine(row.data.details);
|
|
308
|
+
lines.push(` - review-leg2: ${pass} (supersedes daemon review) — ${identityText}`);
|
|
309
|
+
}
|
|
274
310
|
}
|
|
275
311
|
else
|
|
276
312
|
lines.push(` - ${EM}`);
|
|
@@ -4,6 +4,11 @@ export declare function parseCriteria(text: string): AcceptanceItem[];
|
|
|
4
4
|
export declare const HUMAN_CHANNEL: BillingChannel;
|
|
5
5
|
export declare const HUMAN_AUTHOR: Assignment;
|
|
6
6
|
export declare function verifyStateDir(cwd: string): string;
|
|
7
|
+
/** Linked worktrees share operational state with the repository that owns the common git dir. */
|
|
8
|
+
export declare function verifyStateRoot(cwd: string): Promise<string>;
|
|
9
|
+
/** Cache identity is repository content, never a checkout path. */
|
|
10
|
+
export declare function baselineCachePath(cwd: string, baseSha: string, commands: Record<string, string>): string;
|
|
11
|
+
export declare function excludeAuthorProvider(channels: BillingChannel[], author: BillingChannel): BillingChannel[];
|
|
7
12
|
export declare function verify(argv: string[], cwd?: string): Promise<{
|
|
8
13
|
out: string;
|
|
9
14
|
code: number;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
|
|
7
7
|
import { channelKey } from "../../adapters/types.js";
|
|
8
8
|
import { loadConfig } from "../../config/config.js";
|
|
9
9
|
import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
|
|
10
|
+
import { modelProvider } from "../../gates/review.js";
|
|
10
11
|
import { runGates } from "../../gates/run-gates.js";
|
|
11
12
|
import { getTask, loadGraph } from "../../graph/graph.js";
|
|
12
13
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
13
14
|
import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
|
|
15
|
+
import { Journal } from "../../run/journal.js";
|
|
14
16
|
/**
|
|
15
17
|
* tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
|
|
16
18
|
*
|
|
@@ -59,6 +61,70 @@ const DIRTY_WHY = `refusing to gate a dirty worktree: the shell gates run agains
|
|
|
59
61
|
export function verifyStateDir(cwd) {
|
|
60
62
|
return join(realpathSync(tmpdir()), "tickmarkr-verify", createHash("sha256").update(cwd).digest("hex").slice(0, 12));
|
|
61
63
|
}
|
|
64
|
+
const STATE_FILES = ["graph.json", "doctor.json", "config.yaml"];
|
|
65
|
+
const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
|
|
66
|
+
/** Linked worktrees share operational state with the repository that owns the common git dir. */
|
|
67
|
+
export async function verifyStateRoot(cwd) {
|
|
68
|
+
const commonDir = resolve(cwd, (await shGitOk("git rev-parse --git-common-dir", cwd)).trim());
|
|
69
|
+
const commonRoot = realpathSync(dirname(commonDir));
|
|
70
|
+
if (commonRoot === realpathSync(cwd))
|
|
71
|
+
return cwd;
|
|
72
|
+
const localState = join(cwd, ".tickmarkr");
|
|
73
|
+
const commonState = join(commonRoot, ".tickmarkr");
|
|
74
|
+
return STATE_FILES.some((file) => !existsSync(join(localState, file)) && existsSync(join(commonState, file)))
|
|
75
|
+
? commonRoot
|
|
76
|
+
: cwd;
|
|
77
|
+
}
|
|
78
|
+
const hashParts = (parts) => {
|
|
79
|
+
const hash = createHash("sha256");
|
|
80
|
+
for (const part of parts)
|
|
81
|
+
hash.update(part);
|
|
82
|
+
return hash.digest("hex").slice(0, 12);
|
|
83
|
+
};
|
|
84
|
+
/** Cache identity is repository content, never a checkout path. */
|
|
85
|
+
export function baselineCachePath(cwd, baseSha, commands) {
|
|
86
|
+
const lockParts = [];
|
|
87
|
+
for (const file of LOCKFILES) {
|
|
88
|
+
const path = join(cwd, file);
|
|
89
|
+
lockParts.push(file, existsSync(path) ? readFileSync(path) : "<absent>");
|
|
90
|
+
}
|
|
91
|
+
const commandParts = Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)).map(([name, command]) => `${name}\0${command}\0`);
|
|
92
|
+
return join(realpathSync(tmpdir()), "tickmarkr-verify", "cache", `baseline-${baseSha.slice(0, 12)}-${hashParts(lockParts)}-${hashParts(commandParts)}.json`);
|
|
93
|
+
}
|
|
94
|
+
const verdictlessCommands = (baseline, commands) => Object.keys(commands).filter((name) => baseline.commands[name]?.exitCode === undefined);
|
|
95
|
+
export function excludeAuthorProvider(channels, author) {
|
|
96
|
+
const provider = modelProvider(author.model, author.vendor);
|
|
97
|
+
return [author, ...channels.filter((candidate) => candidate !== author && modelProvider(candidate.model, candidate.vendor) !== provider)];
|
|
98
|
+
}
|
|
99
|
+
function recordedMerges(repoRoot) {
|
|
100
|
+
const runs = join(repoRoot, ".tickmarkr", "runs");
|
|
101
|
+
if (!existsSync(runs))
|
|
102
|
+
return [];
|
|
103
|
+
const rows = [];
|
|
104
|
+
for (const runId of readdirSync(runs).filter((name) => name.startsWith("run-")).sort().reverse()) {
|
|
105
|
+
const path = join(runs, runId, "journal.jsonl");
|
|
106
|
+
if (!existsSync(path))
|
|
107
|
+
continue;
|
|
108
|
+
for (const line of readFileSync(path, "utf8").split("\n").filter(Boolean)) {
|
|
109
|
+
try {
|
|
110
|
+
const row = JSON.parse(line);
|
|
111
|
+
if (row.event === "merge" && row.taskId && row.data?.commit)
|
|
112
|
+
rows.push({ runId, taskId: row.taskId, commit: row.data.commit });
|
|
113
|
+
}
|
|
114
|
+
catch { /* torn journal tail is not a merge record */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return rows;
|
|
118
|
+
}
|
|
119
|
+
async function warnWideTaskRange(cwd, stateRoot, taskId, mergeBase) {
|
|
120
|
+
const inRange = new Set((await shGitOk(`git rev-list --merges '${mergeBase}..HEAD'`, cwd)).trim().split("\n").filter(Boolean));
|
|
121
|
+
const merges = recordedMerges(stateRoot).filter((row) => inRange.has(row.commit));
|
|
122
|
+
const own = merges.find((row) => row.taskId === taskId);
|
|
123
|
+
const others = own ? merges.filter((row) => row.taskId !== taskId) : [];
|
|
124
|
+
if (own && others.length) {
|
|
125
|
+
console.error(`verify: WARNING --task ${taskId} range also carries merge commit(s) for ${others.map((row) => `${row.taskId} ${row.commit.slice(0, 12)}`).join(", ")}; ${taskId}'s own merge ${own.commit} is the intended HEAD`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
62
128
|
export async function verify(argv, cwd = process.cwd()) {
|
|
63
129
|
const { values } = parseArgs({
|
|
64
130
|
args: argv,
|
|
@@ -69,13 +135,18 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
69
135
|
files: { type: "string", multiple: true },
|
|
70
136
|
author: { type: "string" },
|
|
71
137
|
baseline: { type: "string" },
|
|
138
|
+
record: { type: "string" },
|
|
72
139
|
json: { type: "boolean", default: false },
|
|
73
140
|
"no-review": { type: "boolean", default: false },
|
|
74
141
|
"no-acceptance": { type: "boolean", default: false },
|
|
75
142
|
},
|
|
76
143
|
allowPositionals: false,
|
|
77
144
|
});
|
|
78
|
-
const
|
|
145
|
+
const stateRoot = await verifyStateRoot(cwd);
|
|
146
|
+
if (resolve(stateRoot) !== resolve(cwd)) {
|
|
147
|
+
console.error(`verify: state files graph.json, doctor.json and config.yaml resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree)`);
|
|
148
|
+
}
|
|
149
|
+
const cfg = loadConfig(stateRoot);
|
|
79
150
|
const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
|
|
80
151
|
const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
|
|
81
152
|
throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
|
|
@@ -89,9 +160,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
89
160
|
let files = values.files ?? [];
|
|
90
161
|
let goal = `independent verification of the ${values.base}..HEAD diff`;
|
|
91
162
|
if (values.task) {
|
|
92
|
-
const t = getTask(loadGraph(
|
|
93
|
-
if (!t)
|
|
94
|
-
throw new Error(`--task ${values.task}: no such task in .tickmarkr/graph.json`);
|
|
163
|
+
const t = getTask(loadGraph(stateRoot), values.task);
|
|
95
164
|
acceptance = t.acceptance;
|
|
96
165
|
if (!files.length)
|
|
97
166
|
files = t.files;
|
|
@@ -102,6 +171,8 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
102
171
|
if (!acceptance.length)
|
|
103
172
|
throw new Error(`--criteria ${values.criteria}: no criteria found (bullets or command:/test:/judge: lines)`);
|
|
104
173
|
}
|
|
174
|
+
if (values.task)
|
|
175
|
+
await warnWideTaskRange(cwd, stateRoot, values.task, mergeBase);
|
|
105
176
|
const wantAcceptance = acceptance.length > 0 && !values["no-acceptance"];
|
|
106
177
|
const wantReview = !values["no-review"];
|
|
107
178
|
// A gate that enforced nothing must not print a green row. `files` has exactly three sources —
|
|
@@ -145,7 +216,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
145
216
|
let author = HUMAN_AUTHOR;
|
|
146
217
|
const adapters = allAdapters();
|
|
147
218
|
if (wantAcceptance || wantReview) {
|
|
148
|
-
const health = readDoctor(
|
|
219
|
+
const health = readDoctor(stateRoot) ?? (await probeAll(adapters));
|
|
149
220
|
const pools = rolePools(cfg, adapters, health);
|
|
150
221
|
judgeChannels = pools.judge;
|
|
151
222
|
channels = pools.review;
|
|
@@ -157,6 +228,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
157
228
|
throw new Error(`--author ${values.author} does not name a discoverable review channel — one of: ${channels.map(channelKey).join(", ") || "(none)"}`);
|
|
158
229
|
}
|
|
159
230
|
author = { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
|
|
231
|
+
channels = excludeAuthorProvider(channels, c);
|
|
160
232
|
}
|
|
161
233
|
else {
|
|
162
234
|
channels = [...channels, HUMAN_CHANNEL];
|
|
@@ -173,28 +245,56 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
173
245
|
const stateDir = verifyStateDir(cwd);
|
|
174
246
|
mkdirSync(stateDir, { recursive: true });
|
|
175
247
|
let baseline;
|
|
176
|
-
const cachePath =
|
|
248
|
+
const cachePath = baselineCachePath(cwd, mergeBase, commands);
|
|
249
|
+
const verdictlessMarker = `${cachePath}.verdictless`;
|
|
250
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
177
251
|
if (values.baseline) {
|
|
178
252
|
baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
|
|
179
253
|
}
|
|
180
|
-
else if (existsSync(cachePath)) {
|
|
181
|
-
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
182
|
-
baseline = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
183
|
-
}
|
|
184
254
|
else {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
255
|
+
let cached;
|
|
256
|
+
if (existsSync(cachePath)) {
|
|
257
|
+
cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
258
|
+
const missing = verdictlessCommands(cached, commands);
|
|
259
|
+
if (missing.length) {
|
|
260
|
+
console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
|
|
261
|
+
rmSync(cachePath, { force: true });
|
|
262
|
+
cached = undefined;
|
|
263
|
+
}
|
|
193
264
|
}
|
|
194
|
-
|
|
195
|
-
|
|
265
|
+
if (cached) {
|
|
266
|
+
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
267
|
+
baseline = cached;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
if (existsSync(verdictlessMarker)) {
|
|
271
|
+
console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
|
|
275
|
+
}
|
|
276
|
+
const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
|
|
277
|
+
await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
|
|
278
|
+
await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
|
|
279
|
+
try {
|
|
280
|
+
linkNodeModules(cwd, baseDir, { force: true });
|
|
281
|
+
baseline = await captureBaseline(baseDir, commands);
|
|
282
|
+
const missing = verdictlessCommands(baseline, commands);
|
|
283
|
+
if (missing.length) {
|
|
284
|
+
writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
|
|
285
|
+
rmSync(cachePath, { force: true });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
|
|
289
|
+
rmSync(verdictlessMarker, { force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await removeWorktree(cwd, baseDir);
|
|
294
|
+
}
|
|
196
295
|
}
|
|
197
296
|
}
|
|
297
|
+
const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
|
|
198
298
|
const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
|
|
199
299
|
mkdirSync(artifactDir, { recursive: true });
|
|
200
300
|
const { results } = await runGates(task, {
|
|
@@ -205,17 +305,34 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
205
305
|
onGate: (e) => {
|
|
206
306
|
if (e.phase === "start")
|
|
207
307
|
console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
|
|
308
|
+
else if (e.phase === "note")
|
|
309
|
+
console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
|
|
208
310
|
else
|
|
209
311
|
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
210
312
|
},
|
|
211
313
|
});
|
|
212
314
|
const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
|
|
315
|
+
const reviewRows = results.filter((result) => result.gate === "review");
|
|
316
|
+
const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
|
|
317
|
+
const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
|
|
318
|
+
return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
|
|
319
|
+
}));
|
|
320
|
+
const artifactPath = join(artifactDir, "verify-results.json");
|
|
321
|
+
writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
|
|
322
|
+
console.error(`verify: artifacts written to ${artifactPath}`);
|
|
323
|
+
const review = reviewRows.at(-1);
|
|
324
|
+
if (recordJournal && review) {
|
|
325
|
+
recordJournal.append("review-leg2", values.task ?? "VERIFY", {
|
|
326
|
+
base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
|
|
327
|
+
...review,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
213
330
|
if (values.json) {
|
|
214
|
-
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, results }, null, 2), code: green ? 0 : 2 };
|
|
331
|
+
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
|
|
215
332
|
}
|
|
216
333
|
const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
217
334
|
const verdict = green
|
|
218
|
-
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision)`
|
|
219
|
-
: `verify RED — first failure decides; artifacts
|
|
335
|
+
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
|
|
336
|
+
: `verify RED — first failure decides; artifacts: ${artifactPath}`;
|
|
220
337
|
return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
|
|
221
338
|
}
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function distFingerprint(dir?: string): string;
|
|
2
|
+
export declare function version(argv?: string[], dir?: string): Promise<string>;
|
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json");
|
|
5
|
-
|
|
6
|
+
const distDir = join(dirname(pkgPath), "dist");
|
|
7
|
+
export function distFingerprint(dir = distDir) {
|
|
8
|
+
if (!existsSync(dir))
|
|
9
|
+
return "missing";
|
|
10
|
+
const files = [];
|
|
11
|
+
const visit = (path) => {
|
|
12
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
13
|
+
const child = join(path, entry.name);
|
|
14
|
+
if (entry.isDirectory())
|
|
15
|
+
visit(child);
|
|
16
|
+
else if (entry.isFile())
|
|
17
|
+
files.push(child);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
visit(dir);
|
|
21
|
+
const hash = createHash("sha256");
|
|
22
|
+
for (const file of files.sort())
|
|
23
|
+
hash.update(relative(dir, file)).update("\0").update(readFileSync(file)).update("\0");
|
|
24
|
+
return hash.digest("hex");
|
|
25
|
+
}
|
|
26
|
+
export async function version(argv = [], dir = distDir) {
|
|
6
27
|
const { version: v } = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
7
|
-
return v;
|
|
28
|
+
return argv.includes("--dist") ? `${v} dist:${distFingerprint(dir)}` : v;
|
|
8
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, join, relative } from "node:path";
|
|
3
3
|
import { filesGlob } from "../graph/files-glob.js";
|
|
4
4
|
import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
|
|
@@ -15,6 +15,7 @@ const MAX_WALK_FILES = 400;
|
|
|
15
15
|
const MAX_HITS_PER_TASK = 20;
|
|
16
16
|
/** Skip giant fixtures / snapshots. */
|
|
17
17
|
const MAX_READ_BYTES = 512 * 1024;
|
|
18
|
+
const EXPORT_MANIFEST_TEST = "tests/repo/export-manifest.test.ts";
|
|
18
19
|
const SRC_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
|
|
19
20
|
const CODE_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
|
|
20
21
|
function isSrcPath(p) {
|
|
@@ -112,15 +113,20 @@ export function collateralHits(tasks, repoRoot) {
|
|
|
112
113
|
const read = makeReader(repoRoot);
|
|
113
114
|
for (const t of tasks) {
|
|
114
115
|
// OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
const files = t.files.map((f) => f.replace(/^\.\//, ""));
|
|
117
|
+
const scoped = filesGlob(files);
|
|
118
|
+
const srcFiles = files.filter(isSrcPath);
|
|
119
|
+
const hits = [];
|
|
120
|
+
// Exact-enumerated scripts are an export-set contract: a new path has no name or symbol for the
|
|
121
|
+
// ordinary sweep to find, but the manifest test will reject it unless the task owns that oracle.
|
|
122
|
+
if (testFiles.includes(EXPORT_MANIFEST_TEST)
|
|
123
|
+
&& !scoped(EXPORT_MANIFEST_TEST)
|
|
124
|
+
&& files.some((path) => path.startsWith("scripts/") && !/[?*{[]/.test(path) && !existsSync(join(repoRoot, path))))
|
|
125
|
+
hits.push(EXPORT_MANIFEST_TEST);
|
|
119
126
|
// needles unioned across all src files in this task
|
|
120
127
|
const needles = [...new Set(srcFiles.flatMap(needlesFor))];
|
|
121
|
-
const hits = [];
|
|
122
128
|
for (const tf of testFiles) {
|
|
123
|
-
if (scoped(tf))
|
|
129
|
+
if (scoped(tf) || hits.includes(tf))
|
|
124
130
|
continue;
|
|
125
131
|
const text = read(tf);
|
|
126
132
|
if (text === null)
|
|
@@ -128,9 +134,9 @@ export function collateralHits(tasks, repoRoot) {
|
|
|
128
134
|
if (mentions(text, needles))
|
|
129
135
|
hits.push(tf);
|
|
130
136
|
}
|
|
131
|
-
// deterministic: walk
|
|
137
|
+
// deterministic: the special hit and walk are stable; sort their union for one canonical map.
|
|
132
138
|
if (hits.length)
|
|
133
|
-
map.set(t.id, hits);
|
|
139
|
+
map.set(t.id, hits.sort());
|
|
134
140
|
}
|
|
135
141
|
return map;
|
|
136
142
|
}
|