@kylecheng3146/agent-ops 0.9.0 → 0.10.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/dist/packages/cli/src/args.js +48 -4
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +16 -2
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +60 -10
- package/dist/packages/cli/src/commands/task.js +41 -3
- package/dist/packages/cli/src/commands/update.js +16 -4
- package/dist/packages/cli/src/commands/verify.js +4 -1
- package/dist/packages/cli/src/debug-intake.js +1 -1
- package/dist/runtime/src/escape/link.js +317 -0
- package/dist/runtime/src/escape/origin.js +20 -0
- package/dist/runtime/src/escape/rules.js +252 -0
- package/dist/runtime/src/fs/temporary.js +10 -0
- package/dist/runtime/src/install/harness.js +2 -2
- package/dist/runtime/src/logging/local-log.js +44 -1
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +3 -1
- package/dist/runtime/src/review/execute.js +12 -6
- package/dist/runtime/src/review/packet.js +3 -1
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +24 -3
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/schema/validate.js +6 -1
- package/dist/runtime/src/surface/discovery.js +79 -2
- package/dist/runtime/src/surface/schema.js +43 -2
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +3 -1
- package/dist/runtime/src/task/debug.js +24 -1
- package/dist/runtime/src/task/service.js +58 -6
- package/dist/runtime/src/verify/acceptance-replay.js +24 -11
- package/docs/en/guides/configuration.md +16 -1
- package/docs/en/guides/task-debug.md +12 -0
- package/docs/en/spec/acceptance-and-evidence.md +100 -2
- package/docs/zh-TW/guides/configuration.md +14 -1
- package/docs/zh-TW/guides/task-debug.md +10 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +82 -3
- package/package.json +1 -1
- package/schemas/task.schema.json +8 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { NOTES_REF } from "../parallel/service.js";
|
|
7
|
+
import { readPrivateFile } from "../security/permissions.js";
|
|
8
|
+
import { globMatcher, isCovers } from "../surface/schema.js";
|
|
9
|
+
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
10
|
+
import { RECEIPT_ID, validOrigin } from "./origin.js";
|
|
11
|
+
import { fixPaths, gitRunner, proveEscape } from "./rules.js";
|
|
12
|
+
/** Git-tracked, data only: one record per task so parallel worktrees never conflict. */
|
|
13
|
+
export const LEDGER_DIRECTORY = ".agent-ops-escapes";
|
|
14
|
+
const out = (result) => new TextDecoder().decode(result.stdout);
|
|
15
|
+
function gitFailed(what) {
|
|
16
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", `git ${what} failed while linking the escape.`);
|
|
17
|
+
}
|
|
18
|
+
/** Resolve a declared commit-ish to its full SHA (argv only), then validate the whole origin. */
|
|
19
|
+
export async function resolveOrigin(git, value) {
|
|
20
|
+
const raw = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
21
|
+
let origin = raw;
|
|
22
|
+
if (raw.kind === "escape-of") {
|
|
23
|
+
const ref = raw.commit;
|
|
24
|
+
if (typeof ref !== "string" || !/^[A-Za-z0-9._/^~][A-Za-z0-9._/^~-]{0,255}$/u.test(ref))
|
|
25
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "escape-of needs a commit.");
|
|
26
|
+
const resolved = await git.run(["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`]);
|
|
27
|
+
if (resolved.exitCode !== 0)
|
|
28
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", `Commit ${ref} does not exist in this repository.`);
|
|
29
|
+
origin = { ...raw, commit: out(resolved).trim() };
|
|
30
|
+
if (raw.fix !== undefined) {
|
|
31
|
+
if (typeof raw.fix !== "string" || !/^[A-Za-z0-9._/^~][A-Za-z0-9._/^~-]{0,255}$/u.test(raw.fix))
|
|
32
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "escape fix needs a commit.");
|
|
33
|
+
const fix = await git.run(["rev-parse", "--verify", "--quiet", "--end-of-options", `${raw.fix}^{commit}`]);
|
|
34
|
+
if (fix.exitCode !== 0)
|
|
35
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", `Fix commit ${raw.fix} does not exist in this repository.`);
|
|
36
|
+
origin = { ...origin, fix: out(fix).trim() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!validOrigin(origin))
|
|
40
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "origin is {kind: escape-of, commit, label, receipt?, fix?, promote? (runnerId:checkId)} with label requirement|surface|scenario|criteria|verify|review|flagged|out-of-model, or {kind: pre-existing|external, reason}.");
|
|
41
|
+
return origin;
|
|
42
|
+
}
|
|
43
|
+
/** Per-file diff identity that survives squash: blob ids and hunk positions are dropped. */
|
|
44
|
+
export async function fileFingerprints(git, from, to) {
|
|
45
|
+
const names = await git.run(["diff", "--name-only", "-z", "--no-renames", from, to, "--"]);
|
|
46
|
+
const diff = await git.run(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "-U0",
|
|
47
|
+
"--src-prefix=a/", "--dst-prefix=b/", from, to, "--"]);
|
|
48
|
+
if (names.exitCode !== 0 || diff.exitCode !== 0)
|
|
49
|
+
gitFailed("diff");
|
|
50
|
+
const paths = out(names).split("\0").filter(Boolean);
|
|
51
|
+
const chunks = ("\n" + out(diff)).split("\ndiff --git ").slice(1);
|
|
52
|
+
if (chunks.length !== paths.length)
|
|
53
|
+
gitFailed("diff");
|
|
54
|
+
return paths.map((path, i) => ({ path, digest: sha256(chunks[i].split("\n").slice(1)
|
|
55
|
+
.filter(line => !line.startsWith("index ")).map(line => line.startsWith("@@") ? "@@" : line).join("\n")) }));
|
|
56
|
+
}
|
|
57
|
+
/** Receipt schema v2 fields: every commit in target..candidate and per-file fingerprints. */
|
|
58
|
+
export async function receiptRange(git, target, candidate) {
|
|
59
|
+
const list = await git.run(["rev-list", "--reverse", `${target}..${candidate}`, "--"]);
|
|
60
|
+
if (list.exitCode !== 0)
|
|
61
|
+
gitFailed("rev-list");
|
|
62
|
+
return { commits: out(list).split("\n").filter(Boolean), fileFingerprints: await fileFingerprints(git, target, candidate) };
|
|
63
|
+
}
|
|
64
|
+
export async function gitCommonDir(git, root) {
|
|
65
|
+
const result = await git.run(["rev-parse", "--git-common-dir"]);
|
|
66
|
+
if (result.exitCode !== 0)
|
|
67
|
+
gitFailed("rev-parse");
|
|
68
|
+
return resolve(root, out(result).trim());
|
|
69
|
+
}
|
|
70
|
+
/** Unreadable or foreign files are skipped: linking is advisory. */
|
|
71
|
+
export async function loadReceipts(commonDir) {
|
|
72
|
+
const directory = join(commonDir, "agent-ops", "receipts");
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = await readdir(directory);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === "ENOENT")
|
|
79
|
+
return [];
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const loaded = [];
|
|
83
|
+
for (const name of names.filter(n => n.endsWith(".json") && RECEIPT_ID.test(n.slice(0, -5))).sort()) {
|
|
84
|
+
try {
|
|
85
|
+
const source = await readPrivateFile(join(directory, name), commonDir);
|
|
86
|
+
const receipt = source === null ? null : JSON.parse(source);
|
|
87
|
+
if (receipt !== null && (receipt.schemaVersion === 1 || receipt.schemaVersion === 2) && typeof receipt.candidateHead === "string")
|
|
88
|
+
loaded.push({ id: name.slice(0, -5), receipt });
|
|
89
|
+
}
|
|
90
|
+
catch { /* not a receipt this version can read */ }
|
|
91
|
+
}
|
|
92
|
+
return loaded;
|
|
93
|
+
}
|
|
94
|
+
const newest = (list) => [...list].sort((a, b) => String(b.receipt.createdAt).localeCompare(String(a.receipt.createdAt)))[0];
|
|
95
|
+
/** Override, then range (v1: exact head), then per-file fingerprint, then unresolved or ungated. */
|
|
96
|
+
export async function resolveEscape(git, receipts, commit, override) {
|
|
97
|
+
if (override !== undefined)
|
|
98
|
+
return receipts.some(r => r.id === override)
|
|
99
|
+
? { state: "gated", receipt: override, matchedBy: "override" } : { state: "unresolved", reason: "receipt-not-local", receipt: override };
|
|
100
|
+
const range = receipts.filter(r => r.receipt.schemaVersion === 2 && r.receipt.commits?.includes(commit));
|
|
101
|
+
if (range.length > 0)
|
|
102
|
+
return { state: "gated", receipt: newest(range).id, matchedBy: "range" };
|
|
103
|
+
const head = receipts.filter(r => r.receipt.schemaVersion === 1 && r.receipt.candidateHead === commit);
|
|
104
|
+
if (head.length > 0)
|
|
105
|
+
return { state: "gated", receipt: newest(head).id, matchedBy: "head" };
|
|
106
|
+
const parent = await git.run(["rev-parse", "--verify", "--quiet", `${commit}^`]);
|
|
107
|
+
if (parent.exitCode === 0) {
|
|
108
|
+
const own = await fileFingerprints(git, out(parent).trim(), commit);
|
|
109
|
+
const scored = receipts.map(r => ({ r, score: own.filter(f => r.receipt.fileFingerprints?.some(g => g.path === f.path && g.digest === f.digest)).length }));
|
|
110
|
+
const best = Math.max(0, ...scored.map(s => s.score));
|
|
111
|
+
const winners = scored.filter(s => s.score === best && best > 0);
|
|
112
|
+
if (winners.length === 1)
|
|
113
|
+
return { state: "gated", receipt: winners[0].r.id, matchedBy: "fingerprint" };
|
|
114
|
+
if (winners.length > 1)
|
|
115
|
+
return { state: "unresolved", reason: "ambiguous-receipt" };
|
|
116
|
+
}
|
|
117
|
+
// finish notes the merged commit with its receipt path; a note without the receipt means another machine.
|
|
118
|
+
const note = await git.run(["notes", `--ref=${NOTES_REF}`, "show", commit]);
|
|
119
|
+
return note.exitCode === 0 ? { state: "unresolved", reason: "receipt-not-local" } : { state: "ungated" };
|
|
120
|
+
}
|
|
121
|
+
/** git grep -F replay of the receipt's recorded queries at its candidate. */
|
|
122
|
+
export async function surfaceFacts(git, receipt, paths) {
|
|
123
|
+
const audits = receipt.tasks.map(t => t.task.surface).filter((s) => s !== undefined && !isCovers(s));
|
|
124
|
+
if (audits.length === 0)
|
|
125
|
+
return paths.map(path => ({ path, surface: "no-audit" }));
|
|
126
|
+
const head = receipt.candidateHead;
|
|
127
|
+
const hits = new Set();
|
|
128
|
+
const queries = new Set(audits.flatMap(a => Object.values(a.axes).flatMap(axis => "queries" in axis ? axis.queries : [])));
|
|
129
|
+
for (const query of queries) {
|
|
130
|
+
const result = await git.run(["grep", "-F", "-l", "-z", "-I", "--no-color", "-e", query, head, "--"]);
|
|
131
|
+
if (result.exitCode > 1)
|
|
132
|
+
return paths.map(path => ({ path, surface: "unavailable" }));
|
|
133
|
+
for (const hit of out(result).split("\0").filter(Boolean))
|
|
134
|
+
hits.add(hit.slice(head.length + 1));
|
|
135
|
+
}
|
|
136
|
+
const included = audits.flatMap(a => a.included.map(row => globMatcher(row.path)));
|
|
137
|
+
const excluded = audits.flatMap(a => a.excluded.map(row => globMatcher(row.glob)));
|
|
138
|
+
const facts = [];
|
|
139
|
+
for (const path of paths) {
|
|
140
|
+
const surface = included.some(m => m(path)) ? "included" : excluded.some(m => m(path)) ? "excluded" :
|
|
141
|
+
(await git.run(["cat-file", "-e", `${head}:${path}`])).exitCode !== 0 ? "absent" : hits.has(path) ? "hit" : "unseen";
|
|
142
|
+
facts.push({ path, surface });
|
|
143
|
+
}
|
|
144
|
+
return facts;
|
|
145
|
+
}
|
|
146
|
+
/** Mechanical replay outranks the agent unless it named the only earlier gate. */
|
|
147
|
+
export function primaryLabel(agentLabel, facts) {
|
|
148
|
+
if (agentLabel === "requirement")
|
|
149
|
+
return agentLabel;
|
|
150
|
+
if (facts.some(f => f.surface === "excluded"))
|
|
151
|
+
return "surface-excluded";
|
|
152
|
+
if (facts.some(f => f.surface === "unseen"))
|
|
153
|
+
return "surface-unseen";
|
|
154
|
+
return agentLabel;
|
|
155
|
+
}
|
|
156
|
+
/** Product files the fix touched since its base (tests and agent-ops data are not missed surfaces). */
|
|
157
|
+
export async function missedPaths(git, base) {
|
|
158
|
+
const paths = new Set([...await collectBaseChangePaths(git, base), ...(await collectChangeSurface(git)).paths]);
|
|
159
|
+
return [...paths].filter(path => !path.startsWith(".agent-ops") &&
|
|
160
|
+
!/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort().slice(0, 200);
|
|
161
|
+
}
|
|
162
|
+
/** A hint only: which commits last wrote the lines the fix changed or removed. Omissions have none. */
|
|
163
|
+
export async function blameHints(git, base) {
|
|
164
|
+
const diff = await git.run(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "-U0",
|
|
165
|
+
"--src-prefix=a/", "--dst-prefix=b/", base, "--"]);
|
|
166
|
+
if (diff.exitCode !== 0)
|
|
167
|
+
return [];
|
|
168
|
+
const counts = new Map();
|
|
169
|
+
let path = null, hunks = 0;
|
|
170
|
+
for (const line of out(diff).split("\n")) {
|
|
171
|
+
if (line.startsWith("--- ")) {
|
|
172
|
+
path = line.startsWith("--- a/") ? line.slice(6) : null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? /u.exec(line);
|
|
176
|
+
if (hunk === null || path === null || hunks >= 200)
|
|
177
|
+
continue;
|
|
178
|
+
const start = Number(hunk[1]), length = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
179
|
+
if (length === 0)
|
|
180
|
+
continue;
|
|
181
|
+
hunks += 1;
|
|
182
|
+
const blame = await git.run(["blame", "--porcelain", "-L", `${start},+${length}`, base, "--", path]);
|
|
183
|
+
if (blame.exitCode !== 0)
|
|
184
|
+
continue;
|
|
185
|
+
for (const header of out(blame).matchAll(/^([0-9a-f]{40,64}) \d+ \d+/gmu))
|
|
186
|
+
counts.set(header[1], (counts.get(header[1]) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
return [...counts].map(([commit, lines]) => ({ commit, lines })).sort((a, b) => b.lines - a.lines || a.commit.localeCompare(b.commit)).slice(0, 10);
|
|
189
|
+
}
|
|
190
|
+
/** Linking is advisory: a Git failure leaves the escape unresolved, never blocks the declaration. */
|
|
191
|
+
export async function assessEscape(input) {
|
|
192
|
+
let receipts = [], link;
|
|
193
|
+
try {
|
|
194
|
+
receipts = await loadReceipts(await gitCommonDir(input.git, input.root));
|
|
195
|
+
link = await resolveEscape(input.git, receipts, input.origin.commit, input.origin.receipt);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
link = { state: "unresolved", reason: "link-unavailable" };
|
|
199
|
+
}
|
|
200
|
+
// A backfill names the past fix: its diff, not this task's, is what the gates missed.
|
|
201
|
+
const paths = input.origin.fix === undefined ? await missedPaths(input.git, input.base) : await fixPaths(input.root, input.origin.fix);
|
|
202
|
+
const linked = link.state === "gated" ? receipts.find(r => r.id === link.receipt)?.receipt : undefined;
|
|
203
|
+
let facts = [];
|
|
204
|
+
if (linked !== undefined) {
|
|
205
|
+
try {
|
|
206
|
+
facts = await surfaceFacts(input.git, linked, paths);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
facts = paths.map(path => ({ path, surface: "unavailable" }));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { link, agentLabel: input.origin.label, label: primaryLabel(input.origin.label, facts), missedPaths: paths, facts,
|
|
213
|
+
residualRisks: linked?.residualRisks ?? [] };
|
|
214
|
+
}
|
|
215
|
+
export function ledgerPath(root, taskId) {
|
|
216
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(taskId))
|
|
217
|
+
throw new AgentOpsError("ESCAPE_TASK_INVALID", "Invalid task ID for the escape ledger.");
|
|
218
|
+
return join(root, LEDGER_DIRECTORY, `${taskId}.json`);
|
|
219
|
+
}
|
|
220
|
+
/** The ledger is repository content: never follow a checked-in symlink out of the worktree. */
|
|
221
|
+
async function unsafe(target, kind) {
|
|
222
|
+
try {
|
|
223
|
+
const stat = await lstat(target);
|
|
224
|
+
return stat.isSymbolicLink() || (kind === "directory" ? !stat.isDirectory() : !stat.isFile());
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code === "ENOENT")
|
|
228
|
+
return false;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function safeLedgerPath(root, taskId) {
|
|
233
|
+
const path = ledgerPath(root, taskId);
|
|
234
|
+
if (await unsafe(join(root, LEDGER_DIRECTORY), "directory") || await unsafe(path, "file"))
|
|
235
|
+
throw new AgentOpsError("ESCAPE_LEDGER_UNSAFE", `${LEDGER_DIRECTORY} must be a real directory of regular files.`);
|
|
236
|
+
return path;
|
|
237
|
+
}
|
|
238
|
+
/** One record per task; re-declaring replaces it. Commit it with the fix. */
|
|
239
|
+
export async function declareEscape(input) {
|
|
240
|
+
const assessment = await assessEscape(input);
|
|
241
|
+
const record = { schemaVersion: 1, taskId: input.taskId, escapeOf: input.origin.commit,
|
|
242
|
+
...(input.origin.fix === undefined ? {} : { fix: input.origin.fix }), link: assessment.link,
|
|
243
|
+
agentLabel: assessment.agentLabel, label: assessment.label, missedPaths: assessment.missedPaths, facts: assessment.facts,
|
|
244
|
+
residualRisks: assessment.residualRisks.length, proof: await proof(input.root, input.origin, assessment), declaredAt: input.now };
|
|
245
|
+
await safeLedgerPath(input.root, input.taskId);
|
|
246
|
+
await mkdir(join(input.root, LEDGER_DIRECTORY), { recursive: true });
|
|
247
|
+
const path = await safeLedgerPath(input.root, input.taskId);
|
|
248
|
+
// O_NOFOLLOW closes the lstat-to-open race on POSIX; Windows has no such flag.
|
|
249
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW ?? 0), 0o644);
|
|
250
|
+
try {
|
|
251
|
+
await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await handle.close();
|
|
255
|
+
}
|
|
256
|
+
return { record, path, blame: await blameHints(input.git, input.base) };
|
|
257
|
+
}
|
|
258
|
+
/** Proof never throws at declaration: a broken rule is recorded as unproven and finish refuses it. */
|
|
259
|
+
async function proof(root, origin, assessment) {
|
|
260
|
+
try {
|
|
261
|
+
return await proveEscape({ root, escapeOf: origin.commit, label: assessment.label, missedPaths: assessment.missedPaths,
|
|
262
|
+
...(origin.promote === undefined ? {} : { promote: origin.promote }) });
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN",
|
|
266
|
+
reason: error instanceof AgentOpsError ? `${error.code}: ${error.message}` : "The rule proof could not run." };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const proofKey = (value) => value === undefined ? null : JSON.stringify([value.status,
|
|
270
|
+
"rule" in value ? value.rule ?? null : null, "ruleHash" in value ? value.ruleHash : null, "check" in value ? value.check : null]);
|
|
271
|
+
/**
|
|
272
|
+
* Fails closed at finish: a fresh replay must prove the escape and match the
|
|
273
|
+
* committed ledger record. A criteria/verify label returns the check to prove.
|
|
274
|
+
*/
|
|
275
|
+
export async function escapeCompletionProblem(input) {
|
|
276
|
+
let assessment, current;
|
|
277
|
+
try {
|
|
278
|
+
assessment = await assessEscape({ ...input, git: gitRunner(input.root) });
|
|
279
|
+
current = await proveEscape({ root: input.root, escapeOf: input.origin.commit, label: assessment.label, missedPaths: assessment.missedPaths,
|
|
280
|
+
...(input.origin.promote === undefined ? {} : { promote: input.origin.promote }) });
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const code = error instanceof AgentOpsError && ["ESCAPE_RULE_INVALID", "ESCAPE_RULE_UNSAFE"].includes(error.code) ? error.code : "ESCAPE_RULE_UNPROVEN";
|
|
284
|
+
return { code, reason: error instanceof Error ? error.message : "The escape proof could not run." };
|
|
285
|
+
}
|
|
286
|
+
if (current.status === "unproven")
|
|
287
|
+
return { code: current.code, reason: current.reason };
|
|
288
|
+
const recorded = await readEscapeRecord(input.root, input.taskId);
|
|
289
|
+
if (recorded === null || recorded.label !== assessment.label || proofKey(recorded.proof) !== proofKey(current))
|
|
290
|
+
return { code: "ESCAPE_RULE_UNPROVEN", reason: `The tracked ${LEDGER_DIRECTORY}/${input.taskId}.json record is missing or stale; re-declare the origin and commit the record.` };
|
|
291
|
+
if (current.status === "check")
|
|
292
|
+
return current.check === null
|
|
293
|
+
? { code: "ESCAPE_RULE_UNPROVEN", reason: "A criteria/verify escape names its red-to-green check: --escape-promote <runnerId:checkId> (DEBUG payload promote)." }
|
|
294
|
+
: { check: current.check };
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
export async function removeEscapeRecord(root, taskId) {
|
|
298
|
+
await rm(await safeLedgerPath(root, taskId), { force: true });
|
|
299
|
+
}
|
|
300
|
+
export async function readEscapeRecord(root, taskId) {
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(await readFile(await safeLedgerPath(root, taskId), "utf8"));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Review packet data: the declaration, a fresh assessment, and whether the tracked record still matches it. */
|
|
309
|
+
export async function escapeReviewData(input) {
|
|
310
|
+
if (input.origin.kind !== "escape-of")
|
|
311
|
+
return { taskId: input.taskId, origin: input.origin };
|
|
312
|
+
const assessment = await assessEscape({ ...input, origin: input.origin });
|
|
313
|
+
const recorded = await readEscapeRecord(input.root, input.taskId);
|
|
314
|
+
const current = recorded !== null && JSON.stringify([recorded.escapeOf, recorded.link, recorded.label, recorded.missedPaths, recorded.facts]) ===
|
|
315
|
+
JSON.stringify([input.origin.commit, assessment.link, assessment.label, assessment.missedPaths, assessment.facts]);
|
|
316
|
+
return { taskId: input.taskId, origin: input.origin, ...assessment, recordCurrent: current, ...(recorded?.proof === undefined ? {} : { proof: recorded.proof }) };
|
|
317
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Escape Loop R1: where a bug came from, declared on the task that fixes it. */
|
|
2
|
+
export const ESCAPE_GATES = ["requirement", "surface", "scenario", "criteria", "verify", "review"];
|
|
3
|
+
/** Agent-proposed label: the earliest gate that could have caught it, or flagged / out-of-model. */
|
|
4
|
+
export const ESCAPE_LABELS = [...ESCAPE_GATES, "flagged", "out-of-model"];
|
|
5
|
+
export const PROMOTE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
6
|
+
export const RECEIPT_ID = /^[a-f0-9]{64}-[a-f0-9]{40,64}$/u;
|
|
7
|
+
const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
const keys = (v, required, optional = []) => required.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => required.includes(k) || optional.includes(k));
|
|
9
|
+
export function validOrigin(value) {
|
|
10
|
+
if (!record(value))
|
|
11
|
+
return false;
|
|
12
|
+
if (value.kind === "escape-of")
|
|
13
|
+
return keys(value, ["kind", "commit", "label"], ["receipt", "fix", "promote"]) && typeof value.commit === "string" &&
|
|
14
|
+
/^[a-f0-9]{40,64}$/u.test(value.commit) && ESCAPE_LABELS.includes(value.label) &&
|
|
15
|
+
(value.receipt === undefined || (typeof value.receipt === "string" && RECEIPT_ID.test(value.receipt))) &&
|
|
16
|
+
(value.fix === undefined || (typeof value.fix === "string" && /^[a-f0-9]{40,64}$/u.test(value.fix) && value.fix !== value.commit)) &&
|
|
17
|
+
(value.promote === undefined || (typeof value.promote === "string" && PROMOTE.test(value.promote)));
|
|
18
|
+
return (value.kind === "pre-existing" || value.kind === "external") && keys(value, ["kind", "reason"]) &&
|
|
19
|
+
typeof value.reason === "string" && value.reason.trim().length >= 10 && value.reason.length <= 2048 && !value.reason.includes("\0");
|
|
20
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { canonicalJson } from "../config/hash.js";
|
|
6
|
+
import { sha256 } from "../fs/hash.js";
|
|
7
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
8
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
9
|
+
import { globMatcher } from "../surface/schema.js";
|
|
10
|
+
import { ESCAPE_LABELS } from "./origin.js";
|
|
11
|
+
/** Escape Loop R2: learned, tracked, data-only rules. They can only add places a task must classify. */
|
|
12
|
+
export const RULES_DIRECTORY = ".agent-ops-escapes/rules";
|
|
13
|
+
export const RULE_LABELS = [...ESCAPE_LABELS, "surface-excluded", "surface-unseen"];
|
|
14
|
+
/** Labels whose proof is a rule replayed at the escaped commit. */
|
|
15
|
+
export const SURFACE_LABELS = ["surface", "surface-excluded", "surface-unseen"];
|
|
16
|
+
/** Labels whose proof is a promoted red-to-green behavioral check of the fixing task. */
|
|
17
|
+
export const CHECK_LABELS = ["criteria", "verify"];
|
|
18
|
+
export const RULE_LIMITS = { backtestCommits: 200, maxFireRate: 0.3, backtestBudgetMs: 60_000, queryTimeoutMs: 30_000 };
|
|
19
|
+
/** argv only: rule strings are repository data and must never meet a shell or a regex engine. */
|
|
20
|
+
export function runGit(root, args, timeoutMs) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
execFile("git", [...args], { cwd: root, encoding: "buffer", timeout: Math.max(1, timeoutMs), maxBuffer: 256 * 1024 * 1024, shell: false }, (error, stdout) => {
|
|
23
|
+
const failure = error;
|
|
24
|
+
resolve({ code: failure === null ? 0 : typeof failure.code === "number" ? failure.code : null,
|
|
25
|
+
stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.alloc(0), timedOut: failure?.killed === true });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export const gitRunner = (root) => ({ run: async (args) => {
|
|
30
|
+
const result = await runGit(root, args, RULE_LIMITS.queryTimeoutMs);
|
|
31
|
+
return { exitCode: result.code ?? 128, stdout: result.stdout };
|
|
32
|
+
} });
|
|
33
|
+
const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
34
|
+
const exact = (v, keys) => keys.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => keys.includes(k));
|
|
35
|
+
const line = (v, min) => typeof v === "string" && v.trim().length >= min && v.length <= 256 && !/[\0\n\r]/u.test(v);
|
|
36
|
+
const glob = (v) => line(v, 1) && !v.startsWith("/") && !v.includes("\\") &&
|
|
37
|
+
v.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
|
38
|
+
const strings = (v, check) => Array.isArray(v) && v.length <= 32 && v.every(check) && new Set(v).size === v.length;
|
|
39
|
+
function invalid(file, message) {
|
|
40
|
+
throw new AgentOpsError("ESCAPE_RULE_INVALID", `${RULES_DIRECTORY}/${file}: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
/** Strict: there is no field that removes a query, adds an exclusion or relaxes a gate. */
|
|
43
|
+
export function parseRule(value, file) {
|
|
44
|
+
if (!record(value) || !exact(value, ["id", "escape", "label", "trigger", "expand", "noExclude"]))
|
|
45
|
+
invalid(file, "a rule is exactly {id, escape, label, trigger: {paths, tokens}, expand: {paths, queries}, noExclude}.");
|
|
46
|
+
if (typeof value.id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(value.id) || file !== `${value.id}.json`)
|
|
47
|
+
invalid(file, "id is lowercase kebab-case and names the file <id>.json.");
|
|
48
|
+
if (typeof value.escape !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.escape))
|
|
49
|
+
invalid(file, "escape is the full escaped commit SHA.");
|
|
50
|
+
if (!RULE_LABELS.includes(value.label))
|
|
51
|
+
invalid(file, `label is one of ${RULE_LABELS.join(", ")}.`);
|
|
52
|
+
const { trigger, expand } = value;
|
|
53
|
+
if (!record(trigger) || !exact(trigger, ["paths", "tokens"]) || !strings(trigger.paths, glob) || !strings(trigger.tokens, v => line(v, 3)) ||
|
|
54
|
+
trigger.paths.length + trigger.tokens.length === 0)
|
|
55
|
+
invalid(file, "trigger needs 1-32 path globs and/or fixed-string tokens (3+ characters).");
|
|
56
|
+
if (!record(expand) || !exact(expand, ["paths", "queries"]) || !strings(expand.paths, glob) || !strings(expand.queries, v => line(v, 3)) ||
|
|
57
|
+
expand.paths.length + expand.queries.length === 0)
|
|
58
|
+
invalid(file, "expand needs 1-32 path globs and/or fixed-string queries (3+ characters).");
|
|
59
|
+
if (typeof value.noExclude !== "boolean")
|
|
60
|
+
invalid(file, "noExclude is true or false.");
|
|
61
|
+
return { id: value.id, escape: value.escape, label: value.label,
|
|
62
|
+
trigger: { paths: [...trigger.paths], tokens: [...trigger.tokens] }, expand: { paths: [...expand.paths], queries: [...expand.queries] },
|
|
63
|
+
noExclude: value.noExclude };
|
|
64
|
+
}
|
|
65
|
+
async function kind(path) {
|
|
66
|
+
try {
|
|
67
|
+
const stat = await lstat(path);
|
|
68
|
+
return stat.isSymbolicLink() ? "other" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other";
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
return "missing";
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const unsafe = () => {
|
|
77
|
+
throw new AgentOpsError("ESCAPE_RULE_UNSAFE", `${RULES_DIRECTORY} must be a real directory of regular <id>.json files.`);
|
|
78
|
+
};
|
|
79
|
+
/** Fails closed: a broken or symlinked rule never silently stops tightening. */
|
|
80
|
+
export async function loadRules(root) {
|
|
81
|
+
for (const directory of [".agent-ops-escapes", RULES_DIRECTORY]) {
|
|
82
|
+
const found = await kind(join(root, directory));
|
|
83
|
+
if (found === "missing")
|
|
84
|
+
return [];
|
|
85
|
+
if (found !== "directory")
|
|
86
|
+
unsafe();
|
|
87
|
+
}
|
|
88
|
+
const rules = [];
|
|
89
|
+
for (const name of (await readdir(join(root, RULES_DIRECTORY))).sort()) {
|
|
90
|
+
const path = join(root, RULES_DIRECTORY, name);
|
|
91
|
+
if (await kind(path) !== "file")
|
|
92
|
+
unsafe();
|
|
93
|
+
if (!name.endsWith(".json"))
|
|
94
|
+
invalid(name, "only <id>.json rule files belong here.");
|
|
95
|
+
// O_NOFOLLOW closes the lstat-to-open race on POSIX; Windows has no such flag.
|
|
96
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
97
|
+
let source;
|
|
98
|
+
try {
|
|
99
|
+
source = await handle.readFile("utf8");
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await handle.close();
|
|
103
|
+
}
|
|
104
|
+
let value;
|
|
105
|
+
try {
|
|
106
|
+
value = JSON.parse(source);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
invalid(name, "not JSON.");
|
|
110
|
+
}
|
|
111
|
+
rules.push({ rule: parseRule(value, name), hash: sha256(source) });
|
|
112
|
+
}
|
|
113
|
+
return rules;
|
|
114
|
+
}
|
|
115
|
+
/** Added and removed lines only: headers and context never trigger. */
|
|
116
|
+
function changedLines(diff) {
|
|
117
|
+
return diff.split("\n").filter(l => /^[+-]/u.test(l) && !l.startsWith("+++") && !l.startsWith("---")).map(l => l.slice(1));
|
|
118
|
+
}
|
|
119
|
+
/** A rule fires when a changed path matches a trigger glob or a changed line contains a trigger token. */
|
|
120
|
+
export function ruleFires(rule, paths, diff) {
|
|
121
|
+
const globs = rule.trigger.paths.map(globMatcher);
|
|
122
|
+
if (paths.some(path => globs.some(match => match(path))))
|
|
123
|
+
return true;
|
|
124
|
+
if (rule.trigger.tokens.length === 0)
|
|
125
|
+
return false;
|
|
126
|
+
const lines = changedLines(diff);
|
|
127
|
+
return rule.trigger.tokens.some(token => lines.some(l => l.includes(token)));
|
|
128
|
+
}
|
|
129
|
+
const zList = (value) => value.toString("utf8").split("\0").filter(Boolean);
|
|
130
|
+
/** Files a fired rule puts in front of the task: path globs over tracked files plus git grep -F queries; ref absent = working tree. */
|
|
131
|
+
export async function ruleHits(root, rule, ref, timeoutMs = RULE_LIMITS.queryTimeoutMs) {
|
|
132
|
+
const hits = new Set();
|
|
133
|
+
const fail = () => { throw new AgentOpsError("SURFACE_BUDGET_EXCEEDED", `Rule ${rule.id} could not list its expand hits in time.`); };
|
|
134
|
+
if (rule.expand.paths.length > 0) {
|
|
135
|
+
const files = await runGit(root, ref === undefined ? ["ls-files", "-z"] : ["ls-tree", "-r", "-z", "--name-only", ref], timeoutMs);
|
|
136
|
+
if (files.code !== 0)
|
|
137
|
+
fail();
|
|
138
|
+
const globs = rule.expand.paths.map(globMatcher);
|
|
139
|
+
for (const path of zList(files.stdout))
|
|
140
|
+
if (globs.some(match => match(path)))
|
|
141
|
+
hits.add(path);
|
|
142
|
+
}
|
|
143
|
+
for (const query of rule.expand.queries) {
|
|
144
|
+
const result = await runGit(root, ["grep", "-F", "-l", "-z", "-I", "--no-color", "-e", query, ...(ref === undefined ? [] : [ref]), "--"], timeoutMs);
|
|
145
|
+
if (result.timedOut || (result.code !== 0 && result.code !== 1))
|
|
146
|
+
fail();
|
|
147
|
+
for (const hit of zList(result.stdout))
|
|
148
|
+
hits.add(ref === undefined ? hit : hit.slice(ref.length + 1));
|
|
149
|
+
}
|
|
150
|
+
// A rule's own text names its queries; the ledger is never a product surface.
|
|
151
|
+
return [...hits].filter(path => !path.startsWith(".agent-ops-escapes/")).sort();
|
|
152
|
+
}
|
|
153
|
+
/** One commit's own change against its first parent (the empty tree for a root commit). */
|
|
154
|
+
export async function commitChange(root, commit) {
|
|
155
|
+
const names = await runGit(root, ["show", "-m", "--first-parent", "--format=", "--name-only", "-z", "--no-renames", commit, "--"], RULE_LIMITS.queryTimeoutMs);
|
|
156
|
+
const diff = await runGit(root, ["show", "-m", "--first-parent", "--format=", "-U0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
157
|
+
"--no-renames", commit, "--"], RULE_LIMITS.queryTimeoutMs);
|
|
158
|
+
if (names.code !== 0 || diff.code !== 0)
|
|
159
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", `git show ${commit} failed.`);
|
|
160
|
+
return { paths: zList(names.stdout), diff: diff.stdout.toString("utf8") };
|
|
161
|
+
}
|
|
162
|
+
/** Product files a past fix commit touched (tests and agent-ops data are not missed surfaces). */
|
|
163
|
+
export async function fixPaths(root, fix) {
|
|
164
|
+
return (await commitChange(root, fix)).paths.filter(path => !path.startsWith(".agent-ops") &&
|
|
165
|
+
!/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort().slice(0, 200);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The trigger against the last 200 first-parent commits, in one argv git call.
|
|
169
|
+
* ponytail: one `git log -p` buffered in memory (256 MiB cap); stream it if huge histories need it.
|
|
170
|
+
*/
|
|
171
|
+
export async function backtestRule(root, loaded, budgetMs = RULE_LIMITS.backtestBudgetMs) {
|
|
172
|
+
const head = (await runGit(root, ["rev-parse", "HEAD"], RULE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
|
|
173
|
+
const cachePath = join(root, ".agent-ops", "tasks", "escape", "backtest", sha256(canonicalJson({ head, rule: loaded.hash, limit: RULE_LIMITS.backtestCommits })) + ".json");
|
|
174
|
+
try {
|
|
175
|
+
const cached = await readPrivateFile(cachePath, root);
|
|
176
|
+
if (cached !== null)
|
|
177
|
+
return JSON.parse(cached);
|
|
178
|
+
}
|
|
179
|
+
catch { /* recompute */ }
|
|
180
|
+
const log = await runGit(root, ["log", "--first-parent", "-m", "-n", String(RULE_LIMITS.backtestCommits), "-p", "-U0", "--no-color",
|
|
181
|
+
"--no-ext-diff", "--no-textconv", "--no-renames", "--format=%x00commit %H", "HEAD", "--"], budgetMs);
|
|
182
|
+
if (log.timedOut)
|
|
183
|
+
throw new AgentOpsError("SURFACE_BUDGET_EXCEEDED", `Backtest of rule ${loaded.rule.id} exceeded its ${budgetMs} ms budget.`);
|
|
184
|
+
if (log.code !== 0)
|
|
185
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", "git log failed during the rule backtest.");
|
|
186
|
+
const commits = log.stdout.toString("utf8").split("\0commit ").slice(1);
|
|
187
|
+
let fires = 0;
|
|
188
|
+
for (const chunk of commits) {
|
|
189
|
+
const paths = [...chunk.matchAll(/^diff --git a\/.+? b\/(.+)$/gmu)].map(m => m[1]);
|
|
190
|
+
if (ruleFires(loaded.rule, paths, chunk))
|
|
191
|
+
fires += 1;
|
|
192
|
+
}
|
|
193
|
+
const result = { commits: commits.length, fires, rate: commits.length === 0 ? 0 : Math.round(fires / commits.length * 1000) / 1000 };
|
|
194
|
+
await writePrivateFile(cachePath, JSON.stringify(result), root).catch(() => undefined);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Proof per label. Surface labels need a rule for this escape that fires on the
|
|
199
|
+
* escaped commit's own diff and whose hits at that commit cover every missed
|
|
200
|
+
* path that existed there; no test or review ever runs at the escaped commit.
|
|
201
|
+
*/
|
|
202
|
+
export async function proveEscape(input) {
|
|
203
|
+
if (CHECK_LABELS.includes(input.label))
|
|
204
|
+
return { status: "check", check: input.promote ?? null };
|
|
205
|
+
if (!SURFACE_LABELS.includes(input.label))
|
|
206
|
+
return { status: "lesson", reason: `${input.label}: no mechanical replay exists for this gate; recorded as an unproven lesson.` };
|
|
207
|
+
const rules = (await loadRules(input.root)).filter(r => r.rule.escape === input.escapeOf);
|
|
208
|
+
if (rules.length === 0)
|
|
209
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN",
|
|
210
|
+
reason: `No ${RULES_DIRECTORY}/<id>.json rule names escape ${input.escapeOf}.` };
|
|
211
|
+
const change = await commitChange(input.root, input.escapeOf);
|
|
212
|
+
const required = [];
|
|
213
|
+
for (const path of input.missedPaths)
|
|
214
|
+
if ((await runGit(input.root, ["cat-file", "-e", `${input.escapeOf}:${path}`], RULE_LIMITS.queryTimeoutMs)).code === 0)
|
|
215
|
+
required.push(path);
|
|
216
|
+
const reasons = [];
|
|
217
|
+
for (const loaded of rules) {
|
|
218
|
+
if (!ruleFires(loaded.rule, change.paths, change.diff)) {
|
|
219
|
+
reasons.push(`${loaded.rule.id} does not fire on the escaped commit`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const hits = new Set(await ruleHits(input.root, loaded.rule, input.escapeOf));
|
|
223
|
+
const missing = required.filter(path => !hits.has(path));
|
|
224
|
+
if (missing.length > 0) {
|
|
225
|
+
reasons.push(`${loaded.rule.id} misses ${missing.slice(0, 5).join(", ")}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
let backtest;
|
|
229
|
+
try {
|
|
230
|
+
backtest = await backtestRule(input.root, loaded, input.budgetMs);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error instanceof AgentOpsError && error.code === "SURFACE_BUDGET_EXCEEDED")
|
|
234
|
+
return { status: "unproven", code: "SURFACE_BUDGET_EXCEEDED", reason: error.message, rule: loaded.rule.id };
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
if (backtest.rate > RULE_LIMITS.maxFireRate)
|
|
238
|
+
return { status: "unproven", code: "ESCAPE_RULE_TOO_BROAD", rule: loaded.rule.id, backtest,
|
|
239
|
+
reason: `${loaded.rule.id} fires on ${backtest.fires} of the last ${backtest.commits} first-parent commits (> ${RULE_LIMITS.maxFireRate * 100}%).` };
|
|
240
|
+
return { status: "proven", rule: loaded.rule.id, ruleHash: loaded.hash, backtest };
|
|
241
|
+
}
|
|
242
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN", reason: reasons.join("; ") };
|
|
243
|
+
}
|
|
244
|
+
/** Printed only: agent-ops never writes .agent-ops/config.json for an executable rule. */
|
|
245
|
+
export function configProposal(promote, config) {
|
|
246
|
+
const [runnerId, checkId] = promote.split(":");
|
|
247
|
+
const runner = config?.verification.acceptanceRunners?.find(r => r.id === runnerId);
|
|
248
|
+
return { note: "Proposal only: review it, then apply it yourself and confirm trust with agent-ops update; agent-ops never writes this config.",
|
|
249
|
+
promote: { runnerId, checkId },
|
|
250
|
+
verification: { commands: [{ id: `escape-${checkId}`.slice(0, 64), command: runner?.command ?? "<runner command>", args: [...runner?.args ?? []],
|
|
251
|
+
cwd: runner?.cwd ?? ".", required: true, evidence: { kind: "exit-code" } }] } };
|
|
252
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Removes a scratch directory without letting cleanup hide the result it served.
|
|
4
|
+
* Windows can hold a just-killed child's files open for a moment (EBUSY), so rm
|
|
5
|
+
* retries; a directory that still cannot be removed stays in the OS temp dir.
|
|
6
|
+
*/
|
|
7
|
+
export async function removeTemporary(path) {
|
|
8
|
+
// ponytail: a leaked temp directory is the ceiling; the OS temp cleaner reclaims it.
|
|
9
|
+
await rm(path, { recursive: true, force: true, maxRetries: 10 }).catch(() => undefined);
|
|
10
|
+
}
|