cortad 0.2.3 → 0.3.0-rc.3
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 +11 -2
- package/lib/cli.mjs +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/spec.mjs +20 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +234 -0
- package/lib/verbs.mjs +142 -107
- package/lib/words.mjs +37 -0
- package/local.mjs +16 -7
- package/package.json +6 -1
- package/skill/SKILL.md +73 -31
- package/skill/references/results.md +175 -34
package/lib/stick.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { cliSpec, npxName } from "./spec.mjs";
|
|
4
|
+
|
|
5
|
+
// `npx cortad stick`: one line in each coding agent's own instructions file in this repository, and
|
|
6
|
+
// a hook in the two clients that run one after an edit. `unstick` takes out exactly what stick put
|
|
7
|
+
// in. Both are idempotent, print what they changed, and never touch git.
|
|
8
|
+
//
|
|
9
|
+
// The hook command carries --hook: Claude Code and Codex both drop plain stdout from a PostToolUse
|
|
10
|
+
// hook, and read only hookSpecificOutput.additionalContext. The command follows lib/spec.mjs, and
|
|
11
|
+
// unstick knows the line and the hook under any spec.
|
|
12
|
+
export const lineFor = (name) => `After changing prompts, tools, models or retrieval, check with Cortad before committing: npx ${name} status`;
|
|
13
|
+
export const hookFor = (name) => `npx ${name} status --changed --hook`;
|
|
14
|
+
const IS_LINE = /^After changing prompts, tools, models or retrieval, check with Cortad before committing: npx \S+ status$/;
|
|
15
|
+
const IS_HOOK = /^npx \S+ status --changed --hook$/;
|
|
16
|
+
const hasLine = (text) => text.split("\n").some((l) => IS_LINE.test(l.trim()));
|
|
17
|
+
const ours = (entry) => entry?.hooks?.some((h) => IS_HOOK.test(h.command ?? ""));
|
|
18
|
+
const LINE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"];
|
|
19
|
+
const HOOK_FILES = [".claude/settings.json", ".codex/hooks.json"];
|
|
20
|
+
const RULE_FILE = ".cursor/rules/cortad.mdc";
|
|
21
|
+
|
|
22
|
+
export const hookOutput = (text) => JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } });
|
|
23
|
+
|
|
24
|
+
export function stick(root, { spec = cliSpec() } = {}) {
|
|
25
|
+
const line = lineFor(npxName(spec));
|
|
26
|
+
const hook = hookFor(npxName(spec));
|
|
27
|
+
const rule = `---\ndescription: Checking AI behavior with Cortad\nalwaysApply: true\n---\n${line}\n`;
|
|
28
|
+
const said = LINE_FILES.map((rel, i) => write(root, rel, (text) => {
|
|
29
|
+
if (hasLine(text)) return null;
|
|
30
|
+
return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${line}\n`;
|
|
31
|
+
}, i === 0 ? `added "${line}"` : "added the same line"));
|
|
32
|
+
said.push(write(root, RULE_FILE, (text) => (hasLine(text) ? null : rule), "written, with the same line, applied always"));
|
|
33
|
+
said.push(...HOOK_FILES.map((rel, i) => write(root, rel, (text) => {
|
|
34
|
+
const config = parse(text);
|
|
35
|
+
if (config === undefined) return undefined;
|
|
36
|
+
const list = config.hooks?.PostToolUse ?? [];
|
|
37
|
+
if (list.some(ours)) return null;
|
|
38
|
+
config.hooks = { ...config.hooks, PostToolUse: [...list, { matcher: "Edit|Write", hooks: [{ type: "command", command: hook }] }] };
|
|
39
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
40
|
+
}, i === 0 ? `added a PostToolUse hook on Edit|Write that runs ${hook}` : "added the same hook")));
|
|
41
|
+
return said;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unstick(root) {
|
|
45
|
+
const said = LINE_FILES.map((rel) => write(root, rel, (text) => {
|
|
46
|
+
if (!hasLine(text)) return null;
|
|
47
|
+
return text.split("\n").filter((l) => !IS_LINE.test(l.trim())).join("\n").replace(/\n+$/, "\n");
|
|
48
|
+
}, "removed the line"));
|
|
49
|
+
said.push(write(root, RULE_FILE, (text) => (text ? "" : null), "removed"));
|
|
50
|
+
said.push(...HOOK_FILES.map((rel) => write(root, rel, (text) => {
|
|
51
|
+
const config = parse(text);
|
|
52
|
+
if (config === undefined) return undefined;
|
|
53
|
+
const list = config.hooks?.PostToolUse;
|
|
54
|
+
if (!list?.some(ours)) return null;
|
|
55
|
+
const kept = list.map((e) => ({ ...e, hooks: (e.hooks ?? []).filter((h) => !IS_HOOK.test(h.command ?? "")) })).filter((e) => e.hooks.length);
|
|
56
|
+
if (kept.length) config.hooks.PostToolUse = kept;
|
|
57
|
+
else delete config.hooks.PostToolUse;
|
|
58
|
+
if (!Object.keys(config.hooks).length) delete config.hooks;
|
|
59
|
+
return Object.keys(config).length ? `${JSON.stringify(config, null, 2)}\n` : "";
|
|
60
|
+
}, "removed the hook")));
|
|
61
|
+
return said;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One file: `change` gets its text ("" when absent) and returns the new text, null for nothing to
|
|
65
|
+
// do, or undefined when it cannot be read. An empty result removes the file, and the folders it
|
|
66
|
+
// leaves empty. A path that leaves the repository, through a symbolic link or otherwise, is left alone.
|
|
67
|
+
function write(root, rel, change, done) {
|
|
68
|
+
const file = resolve(root, rel);
|
|
69
|
+
if (!inside(root, file)) return `${rel}: left alone, it points outside this repository`;
|
|
70
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
71
|
+
const next = change(text);
|
|
72
|
+
if (next === undefined) return `${rel}: left alone, it is not valid JSON`;
|
|
73
|
+
if (next === null) return `${rel}: nothing to change`;
|
|
74
|
+
if (next.trim() === "") {
|
|
75
|
+
rmSync(file);
|
|
76
|
+
for (let dir = dirname(file); dir.startsWith(`${resolve(root)}${sep}`) && !readdirSync(dir).length; dir = dirname(dir)) rmdirSync(dir);
|
|
77
|
+
return `${rel}: removed, it held only what stick wrote`;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, next);
|
|
81
|
+
return `${rel}: ${done}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inside(root, file) {
|
|
85
|
+
const base = realpathSync(root);
|
|
86
|
+
let dir = dirname(file);
|
|
87
|
+
while (!existsSync(dir)) dir = dirname(dir);
|
|
88
|
+
const real = realpathSync(dir);
|
|
89
|
+
if (real !== base && !real.startsWith(base + sep)) return false;
|
|
90
|
+
try { return !lstatSync(file).isSymbolicLink(); } catch { return true; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parse(text) {
|
|
94
|
+
if (!text.trim()) return {};
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(text);
|
|
97
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
98
|
+
} catch { return undefined; }
|
|
99
|
+
}
|
package/lib/text.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readLines } from "./read-text.mjs";
|
|
2
|
+
import { at, clip, has, num, PAGE_CHARS, pageOf, plural, SIDE, upper } from "./words.mjs";
|
|
3
|
+
|
|
4
|
+
// What each verb prints. A result is data for the coding agent: a line meant for the person starts
|
|
5
|
+
// with "For the person:", and a run_status result ends with the next call. A field the API leaves
|
|
6
|
+
// out prints nothing, so the text follows the API as it grows.
|
|
7
|
+
|
|
8
|
+
const DONE = new Set(["succeeded", "failed", "canceled"]);
|
|
9
|
+
const UNIT = { sweeps: ["run", "runs"], runs: ["run", "runs"], trials: ["verify trial", "verify trials"] };
|
|
10
|
+
export const STARTING = "Starting your app for the run. Call run_status; it answers as soon as the run has an id.";
|
|
11
|
+
|
|
12
|
+
const signed = (x) => (Math.round(x) > 0 ? `+${num(x)}` : num(x));
|
|
13
|
+
const units = (n, unit) => { const [one, many] = UNIT[unit] ?? [unit, unit]; return `${num(n)} ${n === 1 ? one : many}`; };
|
|
14
|
+
const kn = (x) => (has(x?.k) ? `${num(x.k)} of ${num(x.n)}` : num(x));
|
|
15
|
+
const interval = (ci) => (ci && has(ci.low) ? `, interval ${num(ci.low)} to ${num(ci.high)}` : "");
|
|
16
|
+
|
|
17
|
+
export const finished = (run) => Boolean(run) && (run.finished ?? DONE.has(run.status));
|
|
18
|
+
|
|
19
|
+
export function statusText(d) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
if (d.repository?.name) lines.push(`Cortad · ${d.repository.name}`);
|
|
22
|
+
const plan = planLine(d.plan);
|
|
23
|
+
if (plan) lines.push(plan);
|
|
24
|
+
if (d.app?.said) lines.push(`App: ${d.app.said}`);
|
|
25
|
+
if (d.read) lines.push(...readLines(d.read));
|
|
26
|
+
if (d.run === null) lines.push("No run yet.");
|
|
27
|
+
else if (d.run) lines.push(...runLines(d.run, "latest "));
|
|
28
|
+
if (d.field) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
|
|
29
|
+
if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
|
|
30
|
+
if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function planLine(p) {
|
|
35
|
+
if (!p?.name) return null;
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (has(p.runsLeft) && has(p.runsAllowed)) parts.push(`${num(p.runsLeft)} of ${plural(p.runsAllowed, "run")} left this month`);
|
|
38
|
+
if (has(p.verifyTrialsLeft) && has(p.verifyTrialsAllowed)) parts.push(`${num(p.verifyTrialsLeft)} of ${plural(p.verifyTrialsAllowed, "verify trial")} left`);
|
|
39
|
+
return parts.length ? `${p.name}: ${parts.join(", ")}.` : `Plan: ${p.name}.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runLines(d, prefix = "") {
|
|
43
|
+
const done = finished(d);
|
|
44
|
+
const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
|
|
45
|
+
const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
|
|
46
|
+
if (done && typeof d.score === "number") lines.push(`Score ${num(d.score)} of 100${interval(d.ci)}.`);
|
|
47
|
+
if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
|
|
48
|
+
const reads = readingsLine(d);
|
|
49
|
+
if (reads) lines.push(reads);
|
|
50
|
+
if (d.stopped) lines.push(stoppedLine(d));
|
|
51
|
+
for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
|
|
52
|
+
if (d.error) lines.push(`Error: ${d.error}`);
|
|
53
|
+
if (d.verify) lines.push(...verifyLines(d.verify));
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A reading is one question checked against one reply.
|
|
58
|
+
function readingsLine(d) {
|
|
59
|
+
if (!has(d.readings) && !has(d.questionsAsked)) return null;
|
|
60
|
+
const r = has(d.readings) ? plural(d.readings, "reading") : null;
|
|
61
|
+
const q = has(d.questionsAsked) ? plural(d.questionsAsked, "question") : null;
|
|
62
|
+
const split = [has(d.decided) && `${num(d.decided)} decided`, has(d.unclear) && `${num(d.unclear)} unclear`].filter(Boolean).join(", ");
|
|
63
|
+
return `${r && q ? `${r} of ${q}` : r ?? `${q} asked`}${split ? `: ${split}` : ""}.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A run that stopped before its last trial says where; one that stopped after it is a note.
|
|
67
|
+
function stoppedLine(d) {
|
|
68
|
+
const s = d.stopped;
|
|
69
|
+
const turn = has(s.after) ? ` at turn ${s.after}` : "";
|
|
70
|
+
const fix = s.fix ? ` ${s.fix}` : "";
|
|
71
|
+
if (d.of && d.played < d.of) return `Stopped at ${num(d.played)} of ${plural(d.of, "trial")}${SIDE[s.side] ? `, ${SIDE[s.side]}` : ""}: ${s.why}${turn}.${fix}`;
|
|
72
|
+
return `${upper(s.why)}${turn}${SIDE[s.side] ? `, ${SIDE[s.side]}` : ""}.${fix}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
|
|
76
|
+
|
|
77
|
+
function verifyLines(v) {
|
|
78
|
+
const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
|
|
79
|
+
if (v.visible) lines.push(`Visible trials: ${moveText(v.visible)}`);
|
|
80
|
+
if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
|
|
81
|
+
else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
|
|
82
|
+
if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
|
|
83
|
+
if (v.said) lines.push(v.said);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function nextCall(d) {
|
|
88
|
+
if (!finished(d)) return `run_status ${d.jobId}`;
|
|
89
|
+
return d.kind === "verify" || d.findings > 0 ? "findings" : "status";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function runText(d) {
|
|
93
|
+
const lines = runLines(d);
|
|
94
|
+
if (finished(d) && d.url) lines.push(`For the person: the report is at ${d.url}`);
|
|
95
|
+
lines.push(`next: ${nextCall(d)}`);
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function startedText(d, kind, findingId) {
|
|
100
|
+
const verify = (d.kind ?? kind) === "verify";
|
|
101
|
+
const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
|
|
102
|
+
return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.\nnext: run_status ${d.jobId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const waitingText = (p, now, limitMs) =>
|
|
106
|
+
`Your app is still starting for the ${p.kind === "verify" ? "verify" : "run"}: ${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds.\nnext: run_status pending`;
|
|
107
|
+
|
|
108
|
+
// A spent plan: what the last run found, what was fixed since, what the next run would play.
|
|
109
|
+
export function refusedText(d) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
if (has(d.used) && has(d.allowed) && d.unit) lines.push(`Refused: ${num(d.used)} of ${units(d.allowed, d.unit)} used${d.plan?.name ? ` on the ${d.plan.name} plan` : ""}.`);
|
|
112
|
+
else if (d.why) lines.push(`Refused: ${d.why}`);
|
|
113
|
+
if (d.ledger) lines.push(...ledgerLines(d.ledger));
|
|
114
|
+
if (!d.ledger?.plan && d.plans?.length) lines.push(`Plans: ${d.plans.map((p) => `${p.name} $${p.monthlyUsd} a month`).join(", ")}.`);
|
|
115
|
+
lines.push("Nothing ran.");
|
|
116
|
+
if (d.checkout) lines.push(`For the person: plans and checkout at ${d.checkout}`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ledgerLines(l) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
const r = l.lastRun;
|
|
123
|
+
if (r) {
|
|
124
|
+
const parts = [has(r.score) && `score ${num(r.score)} of 100${interval(r.ci)}`, has(r.findings) && plural(r.findings, "finding"), r.of && `${num(r.played ?? 0)} of ${plural(r.of, "trial")} played`].filter(Boolean);
|
|
125
|
+
lines.push(`Last run ${r.jobId}: ${parts.join(", ")}.`);
|
|
126
|
+
}
|
|
127
|
+
if (l.fixed) {
|
|
128
|
+
lines.push(`${plural(l.fixed.length, "fix", "fixes")} verified since that run${l.fixed.length ? ":" : "."}`);
|
|
129
|
+
for (const f of l.fixed) lines.push(` ${at(f.path, f.line)} (${f.findingId}): held ${kn(f.before)} readings before, ${kn(f.after)} after; ${f.moved}, ${signed(f.move)} points${interval(f)}.`);
|
|
130
|
+
}
|
|
131
|
+
const n = l.nextRun;
|
|
132
|
+
if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)} held out`, has(n.newFromChanges) && `${num(n.newFromChanges)} new from the changes`].filter(Boolean).join(", ")}.`);
|
|
133
|
+
const p = l.plan;
|
|
134
|
+
if (p) lines.push(`The ${p.name} plan, $${p.monthlyUsd} a month${has(p.allowed) ? `, includes ${units(p.allowed, p.unit)}` : ""}.`);
|
|
135
|
+
if (l.field === null) lines.push("Production: not connected.");
|
|
136
|
+
else if (l.field) lines.push(`Production, last ${plural(l.field.days, "day")}: ${plural(l.field.conversations, "conversation")}${has(l.field.brokenRate) ? `, ${Math.round(l.field.brokenRate * 100)}% of them broke a rule` : ""}.`);
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Findings grouped by the line they live at: the server's byLine, or the findings' own file and
|
|
141
|
+
// line when it sends none. The same line from two server pages is one group.
|
|
142
|
+
export function linesOf(d) {
|
|
143
|
+
const rows = d.byLine ?? (d.findings ?? []).filter((f) => f.file).map((f) => ({ path: f.file, line: f.line, findingIds: [f.id] }));
|
|
144
|
+
const merged = new Map();
|
|
145
|
+
for (const r of rows) {
|
|
146
|
+
const key = at(r.path, r.line);
|
|
147
|
+
if (merged.has(key)) merged.get(key).findingIds.push(...r.findingIds);
|
|
148
|
+
else merged.set(key, { path: r.path, line: r.line, findingIds: [...r.findingIds] });
|
|
149
|
+
}
|
|
150
|
+
return [...merged.values()];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
|
|
154
|
+
|
|
155
|
+
function findingsHead(d) {
|
|
156
|
+
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
157
|
+
if (typeof d.score === "number") lines.push(`Score ${num(d.score)} of 100${interval(d.ci)}.`);
|
|
158
|
+
const reads = readingsLine(d);
|
|
159
|
+
if (reads) lines.push(reads);
|
|
160
|
+
if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
|
|
161
|
+
for (const s of [d.read, d.heldBack, d.findings?.length ? null : d.why]) if (s) lines.push(s);
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Whole findings, worst first as the server orders them, packed into pages that stay under
|
|
166
|
+
// PAGE_CHARS. A group that crosses a page carries its header again.
|
|
167
|
+
function findingsPages(d, budget = PAGE_CHARS - 60) {
|
|
168
|
+
const head = findingsHead(d);
|
|
169
|
+
if (!d.findings?.length) return [head];
|
|
170
|
+
const byId = new Map(d.findings.map((f) => [f.id, f]));
|
|
171
|
+
const groups = linesOf(d).map((g) => ({ label: `${plural(g.findingIds.length, "finding")} at ${at(g.path, g.line)}`, placed: true, items: g.findingIds.map((id) => byId.get(id)).filter(Boolean) }));
|
|
172
|
+
const grouped = new Set(groups.flatMap((g) => g.items.map((f) => f.id)));
|
|
173
|
+
const rest = d.findings.filter((f) => !grouped.has(f.id));
|
|
174
|
+
if (rest.length) groups.push({ label: `${plural(rest.length, "finding")} without a line`, placed: false, items: rest });
|
|
175
|
+
|
|
176
|
+
const pages = [];
|
|
177
|
+
let page = head;
|
|
178
|
+
let filled = false;
|
|
179
|
+
let open = null;
|
|
180
|
+
for (const g of groups) {
|
|
181
|
+
g.items.forEach((f, i) => {
|
|
182
|
+
const block = findingBlock(f, g.placed);
|
|
183
|
+
const label = () => (open === g ? "" : `\n\n${i === 0 ? g.label : `${g.label}, continued`}`);
|
|
184
|
+
if (filled && page.length + label().length + block.length + 1 > budget) {
|
|
185
|
+
pages.push(page);
|
|
186
|
+
page = `Run ${d.runId}, findings continued.`;
|
|
187
|
+
filled = false;
|
|
188
|
+
open = null;
|
|
189
|
+
}
|
|
190
|
+
page += `${label()}\n${block}`;
|
|
191
|
+
filled = true;
|
|
192
|
+
open = g;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
pages.push(page);
|
|
196
|
+
return pages;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findingBlock(f, placed) {
|
|
200
|
+
const lines = [`${f.id} ${f.asks}`];
|
|
201
|
+
if (!placed && f.file) lines.push(` At ${at(f.file, f.line)}`);
|
|
202
|
+
if (f.criteria) lines.push(` Criteria: ${clip(f.criteria, 600)}`);
|
|
203
|
+
if (f.door) lines.push(` Endpoint: ${f.door}`);
|
|
204
|
+
if (f.where) lines.push(` Situation: ${f.where}`);
|
|
205
|
+
const r = f.rate;
|
|
206
|
+
if (r) lines.push(` Held in ${num(r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * r.k) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round(r.lo * 100)}% to ${Math.round(r.hi * 100)}%` : ""}.`);
|
|
207
|
+
if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
|
|
208
|
+
const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
|
|
209
|
+
if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
|
|
210
|
+
for (const q of (f.quotes ?? []).slice(0, 3)) {
|
|
211
|
+
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
|
|
212
|
+
lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
213
|
+
}
|
|
214
|
+
for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
|
|
215
|
+
if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function fieldText(d) {
|
|
220
|
+
const t = d.totals;
|
|
221
|
+
const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
|
|
222
|
+
const link = d.url ? [`For the person: ${d.url}`] : [];
|
|
223
|
+
if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
|
|
224
|
+
const lines = [
|
|
225
|
+
`Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
|
|
226
|
+
`Rule checks held: ${pct(t.rulingsHeld, t.rulings)} of ${num(t.rulings)}${t.rulingsUnsure ? ` (${num(t.rulingsUnsure)} unsure)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
|
|
227
|
+
];
|
|
228
|
+
const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
|
|
229
|
+
if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${num(r.broke)})`).join(", ")}.`);
|
|
230
|
+
if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conversation")}, ${pct(j.rulingsHeld, j.rulings)} held`).join("; ")}.`);
|
|
231
|
+
return [...lines, ...link].join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const fieldConnectText = (d) => [d.connected ? "Production is connected." : "Production is not connected.", ...(d.steps ?? []).map((s, i) => `${i + 1}. ${s}`)].join("\n");
|
package/lib/verbs.mjs
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath, sep } from "node:path";
|
|
3
|
+
import { SHOW, showText } from "./read-text.mjs";
|
|
4
|
+
import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
5
|
+
|
|
6
|
+
// The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
7
|
+
// and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
|
|
8
|
+
// functions, so both faces say the same thing.
|
|
9
|
+
//
|
|
10
|
+
// Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run` and `verify` answer
|
|
11
|
+
// within a second; `run_status` holds for up to 45 seconds on the server and 58 in all.
|
|
12
|
+
|
|
13
|
+
const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
|
|
14
|
+
const HOLD_S = 45;
|
|
15
|
+
// What the process that starts a run for an app that is still coming up allows it; lib/cli.mjs.
|
|
16
|
+
export const READY_MS = 240_000;
|
|
17
|
+
|
|
18
|
+
// `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
|
|
19
|
+
// startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
|
|
20
|
+
// the latest run at the time, so a newer run on the server outranks a stale file.
|
|
21
|
+
export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
22
|
+
const call = async (method, path, body, timeoutMs = 30_000) => {
|
|
9
23
|
const key = typeof token === "function" ? token() : token;
|
|
10
24
|
if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
|
|
11
25
|
let res;
|
|
@@ -14,10 +28,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
14
28
|
method,
|
|
15
29
|
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
16
30
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
17
|
-
signal: AbortSignal.timeout(
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
18
32
|
});
|
|
19
33
|
} catch (err) {
|
|
20
|
-
return { status: 0, ok: false, data: { error: `
|
|
34
|
+
return { status: 0, ok: false, data: { error: `Could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}.` } };
|
|
21
35
|
}
|
|
22
36
|
const text = await res.text();
|
|
23
37
|
let data = null;
|
|
@@ -25,37 +39,102 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
25
39
|
return { status: res.status, ok: res.ok, data };
|
|
26
40
|
};
|
|
27
41
|
const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
|
|
42
|
+
const iso = () => new Date(now()).toISOString();
|
|
43
|
+
|
|
44
|
+
// With show, one section of the read in full, paged; a name that is not a section is the plain status.
|
|
45
|
+
const status = async ({ show, page } = {}) => {
|
|
46
|
+
const section = SHOW.includes(show) ? show : null;
|
|
47
|
+
const res = await call("GET", `/mcp/status${section ? `?show=${section}` : ""}`);
|
|
48
|
+
if (!res.ok) return failed(res);
|
|
49
|
+
return { text: section ? showText(res.data, section, page) : statusText(res.data), data: res.data };
|
|
50
|
+
};
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
// Asks the server for the run and keeps its id here, so run_status finds it without one.
|
|
53
|
+
const post = async (args, kind) => {
|
|
54
|
+
const res = await call("POST", "/mcp/run", args);
|
|
55
|
+
if (res.status === 202 || (res.ok && res.data?.joined)) {
|
|
56
|
+
pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
|
|
57
|
+
return { text: startedText(res.data, kind, args.findingId), data: res.data };
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
60
|
+
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
|
|
61
|
+
return failed(res);
|
|
32
62
|
};
|
|
33
63
|
|
|
34
|
-
//
|
|
35
|
-
// is
|
|
64
|
+
// An app that is up gets the run now. One that is not is started in the background, and the run
|
|
65
|
+
// is posted from there once it answers.
|
|
36
66
|
const start = async (args, kind) => {
|
|
37
67
|
const before = await call("GET", "/mcp/status");
|
|
38
68
|
if (!before.ok) return failed(before);
|
|
39
|
-
if (before.data.app?.state
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
if (before.data.app?.state === "ready-to-test") return post(args, kind);
|
|
70
|
+
pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
|
|
71
|
+
const began = await startApp(args, kind);
|
|
72
|
+
if (!began.ok) {
|
|
73
|
+
pending.write({ kind, startedAt: iso(), error: began.why });
|
|
74
|
+
return { text: began.why, isError: true };
|
|
42
75
|
}
|
|
43
|
-
|
|
44
|
-
if (res.status === 202 || (res.ok && res.data?.joined)) return { text: startedText(res.data, kind), data: res.data };
|
|
45
|
-
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
46
|
-
return failed(res);
|
|
76
|
+
return { text: STARTING, data: { pending: true } };
|
|
47
77
|
};
|
|
48
78
|
const run = () => start({}, "run");
|
|
49
|
-
const verify = ({ findingId, jobId }) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
79
|
+
const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
80
|
+
|
|
81
|
+
// With no id: the run this machine is still starting, held here until it has an id, or else the
|
|
82
|
+
// latest run on the server.
|
|
83
|
+
const resolve = async (until) => {
|
|
84
|
+
const latest = await call("GET", "/mcp/status");
|
|
85
|
+
if (!latest.ok) return failed(latest);
|
|
86
|
+
const newest = latest.data.run?.jobId ?? null;
|
|
87
|
+
let p = pending.read();
|
|
88
|
+
if (!p || p.jobId || (p.after ?? null) !== newest) return newest ? { id: newest } : { text: "No run yet.\nnext: status" };
|
|
89
|
+
const stale = () => now() - Date.parse(p.startedAt) > READY_MS + 30_000;
|
|
90
|
+
while (!p.jobId && !p.error && !stale() && now() < until) {
|
|
91
|
+
await sleep(1000);
|
|
92
|
+
p = pending.read() ?? p;
|
|
93
|
+
}
|
|
94
|
+
if (p.jobId) return { id: p.jobId };
|
|
95
|
+
if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
|
|
96
|
+
if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
|
|
97
|
+
return { text: waitingText(p, now(), READY_MS) };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const runStatus = async ({ jobId } = {}) => {
|
|
101
|
+
const began = now();
|
|
102
|
+
let id = jobId && jobId !== "pending" ? String(jobId) : null;
|
|
103
|
+
if (!id) {
|
|
104
|
+
const found = await resolve(began + HOLD_S * 1000);
|
|
105
|
+
if (!found.id) return found;
|
|
106
|
+
id = found.id;
|
|
107
|
+
}
|
|
108
|
+
const hold = Math.max(0, HOLD_S - Math.ceil((now() - began) / 1000));
|
|
109
|
+
const res = await call("GET", `/mcp/run/${encodeURIComponent(id)}${hold ? `?wait=${hold}` : ""}`, undefined, (hold + 13) * 1000);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const out = failed(res);
|
|
112
|
+
return { ...out, text: `${out.text}\nnext: run_status ${id}` };
|
|
113
|
+
}
|
|
114
|
+
return { text: runText(res.data), data: res.data };
|
|
115
|
+
};
|
|
50
116
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
117
|
+
// Every server page of a run's findings, so the pages the agent reads are cut by size here.
|
|
118
|
+
const gather = async (jobId) => {
|
|
119
|
+
const path = (page, id) => {
|
|
120
|
+
const q = new URLSearchParams({ ...(id ? { jobId: id } : {}), ...(page > 1 ? { page: String(page) } : {}) }).toString();
|
|
121
|
+
return `/mcp/findings${q ? `?${q}` : ""}`;
|
|
122
|
+
};
|
|
123
|
+
const first = await call("GET", path(1, jobId));
|
|
124
|
+
if (!first.ok) return first;
|
|
125
|
+
const d = first.data;
|
|
126
|
+
for (let page = 2; page <= Math.min(d.pages ?? 1, 20); page += 1) {
|
|
127
|
+
const more = await call("GET", path(page, d.runId ?? jobId));
|
|
128
|
+
if (!more.ok) return more;
|
|
129
|
+
d.findings = [...(d.findings ?? []), ...(more.data.findings ?? [])];
|
|
130
|
+
if (more.data.byLine) d.byLine = [...(d.byLine ?? []), ...more.data.byLine];
|
|
131
|
+
}
|
|
132
|
+
return first;
|
|
54
133
|
};
|
|
55
134
|
|
|
56
|
-
const findings = async ({ jobId } = {}) => {
|
|
57
|
-
const res = await
|
|
58
|
-
return res.ok ? { text: findingsText(res.data), data: res.data } : failed(res);
|
|
135
|
+
const findings = async ({ jobId, page } = {}) => {
|
|
136
|
+
const res = await gather(jobId);
|
|
137
|
+
return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
|
|
59
138
|
};
|
|
60
139
|
|
|
61
140
|
const dispute = async ({ findingId, why, question, jobId }) => {
|
|
@@ -65,9 +144,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
65
144
|
|
|
66
145
|
const fieldConnect = async () => {
|
|
67
146
|
const res = await call("POST", "/mcp/field/connect");
|
|
68
|
-
|
|
69
|
-
const d = res.data;
|
|
70
|
-
return { text: [d.connected ? "Production is connected." : "Production is not connected yet.", ...d.steps.map((s, i) => `${i + 1}. ${s}`)].join("\n"), data: d };
|
|
147
|
+
return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
|
|
71
148
|
};
|
|
72
149
|
|
|
73
150
|
const field = async ({ days } = {}) => {
|
|
@@ -75,82 +152,40 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
75
152
|
return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
|
|
76
153
|
};
|
|
77
154
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function startedText(d, kind) {
|
|
97
|
-
if (d.joined) return `A ${d.kind ?? kind} is already in flight: ${d.jobId}. Poll run_status every 30 seconds. Watch it: ${d.url}`;
|
|
98
|
-
return `${kind === "verify" ? "Verify" : "Run"} started: ${d.jobId}. Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: ${d.url}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function refusedText(d) {
|
|
102
|
-
const plans = (d.plans ?? []).map((p) => `${p.name} ($${p.monthlyUsd}/month)`).join(" or ");
|
|
103
|
-
if (d.refused === "plan") return `The first run was free. Another needs ${plans || "a plan"}: ${d.checkout}\nShow this link to the person in one sentence and wait for them.`;
|
|
104
|
-
return `${d.why} More on ${plans || "a bigger plan"}: ${d.checkout}\nShow this link to the person and wait.`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function runText(d) {
|
|
108
|
-
const head = `${d.kind} ${d.jobId} ${d.status}: ${d.of ? `played ${d.played} of ${d.of}.` : "starting, nothing played yet."}`;
|
|
109
|
-
const tail = [];
|
|
110
|
-
if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
|
|
111
|
-
if (d.finished && typeof d.findings === "number") tail.push(d.findings ? `${plural(d.findings, "finding")}; call findings.` : "No findings stood out.");
|
|
112
|
-
if (d.stopped) {
|
|
113
|
-
const side = d.stopped.side === "theirs" ? " (their side)" : d.stopped.side === "ours" ? " (Cortad's side)" : "";
|
|
114
|
-
const why = `${d.stopped.why[0].toUpperCase()}${d.stopped.why.slice(1)} at turn ${d.stopped.after}${side}. ${d.stopped.fix}`;
|
|
115
|
-
tail.push(d.of && d.played < d.of ? `Stopped at ${d.played} of ${d.of}: ${why}` : why);
|
|
116
|
-
}
|
|
117
|
-
for (const f of d.faults ?? []) tail.push(`${f.what} ${f.fix}`);
|
|
118
|
-
if (d.error) tail.push(`Error: ${d.error}`);
|
|
119
|
-
if (d.verify) tail.push(verifyText(d.verify));
|
|
120
|
-
if (!d.finished) tail.push("Poll again in 30 seconds.");
|
|
121
|
-
return [head, ...tail, d.url].join(" ");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function verifyText(v) {
|
|
125
|
-
const move = v.visible;
|
|
126
|
-
const line = move
|
|
127
|
-
? `${v.file}:${v.line} · held ${move.before.k} of ${move.before.n} before, ${move.after.k} of ${move.after.n} after · move ${move.point} (${move.low} to ${move.high}) · ${move.moved}${move.insideNoise ? ", inside the noise" : ""}.`
|
|
128
|
-
: "";
|
|
129
|
-
const held = v.holdout ? ` Held-out situations: ${v.holdout.moved}.` : " Held-out situations: no pair.";
|
|
130
|
-
return `Verify of ${v.findingId}: ${line}${held}${v.overfit ? " OVERFIT: the visible cases moved and the held-out ones did not." : ""} ${v.said}`;
|
|
131
|
-
}
|
|
155
|
+
// `status --changed`: the files Cortad cites that changed since the latest run started, with the
|
|
156
|
+
// findings at them. Empty when there is nothing to say, or nothing could be asked.
|
|
157
|
+
const changed = async () => {
|
|
158
|
+
const [s, f] = await Promise.all([call("GET", "/mcp/status"), gather()]);
|
|
159
|
+
const r = s.ok ? s.data?.run : null;
|
|
160
|
+
if (!r) return { text: "" };
|
|
161
|
+
const kept = pending.read();
|
|
162
|
+
const since = r.startedAt ?? (kept?.jobId === r.jobId ? kept.startedAt : null);
|
|
163
|
+
const lines = f.ok ? linesOf(f.data) : [];
|
|
164
|
+
const read = s.data.read ?? {};
|
|
165
|
+
const paths = [
|
|
166
|
+
...lines.map((l) => l.path),
|
|
167
|
+
...(read.rules?.examples ?? []).map((e) => e.path),
|
|
168
|
+
...(read.machine?.misses ?? []).map((m) => m.path),
|
|
169
|
+
...(Array.isArray(read.rules?.files) ? read.rules.files : []),
|
|
170
|
+
].filter((p) => typeof p === "string" && p);
|
|
171
|
+
return { text: changedLine({ root, since, runId: r.jobId, paths, lines }) };
|
|
172
|
+
};
|
|
132
173
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const rows = d.findings.map((f, i) => [
|
|
136
|
-
`${i + 1}. ${f.id} · ${f.asks}`,
|
|
137
|
-
` held ${f.rate.k} of ${f.rate.n} (${pct(f.rate.k, f.rate.n)}, interval ${pct(Math.round(f.rate.lo * f.rate.n), f.rate.n)} to ${pct(Math.round(f.rate.hi * f.rate.n), f.rate.n)}) · ${f.file}:${f.line} · ${f.where}`,
|
|
138
|
-
...f.quotes.slice(0, 1).map((q) => ` reply ${q.reply}: "${q.quote}" (p=${q.p.toFixed(2)})`),
|
|
139
|
-
` replay: ${plural(f.replay.trials, "trial")} · verify ${f.id}`,
|
|
140
|
-
].join("\n"));
|
|
141
|
-
return [`Run ${d.runId}. ${d.read} ${d.heldBack}`, ...rows, `Fix one finding at a time, in the file it names, then verify it. ${d.url}`].join("\n");
|
|
174
|
+
// `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
|
|
175
|
+
return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field, post, changed };
|
|
142
176
|
}
|
|
143
177
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
178
|
+
// Only paths inside the repository are looked at; a path from the server never reaches outside it.
|
|
179
|
+
export function changedLine({ root, since, runId, paths, lines }) {
|
|
180
|
+
const t = Date.parse(since ?? "");
|
|
181
|
+
if (!Number.isFinite(t)) return "";
|
|
182
|
+
const base = realpathSync(root);
|
|
183
|
+
const changedFiles = [...new Set(paths)].filter((rel) => {
|
|
184
|
+
const abs = resolvePath(base, rel);
|
|
185
|
+
if (!abs.startsWith(base + sep)) return false;
|
|
186
|
+
try { return statSync(abs).mtimeMs > t; } catch { return false; }
|
|
187
|
+
});
|
|
188
|
+
if (!changedFiles.length) return "";
|
|
189
|
+
const at = (rel) => lines.filter((l) => l.path === rel).flatMap((l) => l.findingIds);
|
|
190
|
+
return `Changed since run ${runId}: ${changedFiles.map((rel) => (at(rel).length ? `${rel} (${at(rel).join(", ")})` : rel)).join(", ")}.`;
|
|
156
191
|
}
|