@kylecheng3146/agent-ops 0.8.6 → 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 +56 -4
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +18 -2
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/parallel.js +5 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +145 -4
- package/dist/packages/cli/src/commands/task.js +58 -7
- 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 +2 -2
- package/dist/packages/cli/src/run-entry.js +7 -5
- 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 +61 -1
- package/dist/runtime/src/parallel/finish.js +20 -3
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +9 -1
- package/dist/runtime/src/review/execute.js +12 -6
- package/dist/runtime/src/review/packet.js +15 -1
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/planning.js +5 -2
- package/dist/runtime/src/schema/validate.js +17 -1
- package/dist/runtime/src/surface/discovery.js +298 -0
- package/dist/runtime/src/surface/schema.js +168 -0
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +5 -1
- package/dist/runtime/src/task/debug.js +27 -1
- package/dist/runtime/src/task/service.js +72 -5
- 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 +152 -0
- package/docs/en/spec/judgment.md +7 -0
- package/docs/en/spec/loop-engineering.md +2 -2
- package/docs/en/spec/review.md +5 -0
- 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 +127 -0
- package/docs/zh-TW/spec/judgment.md +6 -0
- package/docs/zh-TW/spec/loop-engineering.md +2 -2
- package/docs/zh-TW/spec/review.md +1 -0
- package/package.json +1 -1
- package/schemas/task.schema.json +14 -0
|
@@ -13,7 +13,8 @@ export const COMMAND_NAMES = [
|
|
|
13
13
|
"allow-stop",
|
|
14
14
|
"worktree",
|
|
15
15
|
"run",
|
|
16
|
-
"office"
|
|
16
|
+
"office",
|
|
17
|
+
"escapes"
|
|
17
18
|
];
|
|
18
19
|
const COMMAND_SET = new Set(COMMAND_NAMES);
|
|
19
20
|
const SCOPES = new Set(["project", "user"]);
|
|
@@ -90,6 +91,12 @@ export function parseArgs(argv) {
|
|
|
90
91
|
const criteria = [];
|
|
91
92
|
const criterionFiles = [];
|
|
92
93
|
let criteriaFile;
|
|
94
|
+
let surfaceFile;
|
|
95
|
+
let escapeOf;
|
|
96
|
+
let escapeLabel;
|
|
97
|
+
let escapeReceipt;
|
|
98
|
+
let escapeFix;
|
|
99
|
+
let escapePromote;
|
|
93
100
|
let planFile;
|
|
94
101
|
let expectedContract;
|
|
95
102
|
let expectedTreeContract;
|
|
@@ -230,7 +237,7 @@ export function parseArgs(argv) {
|
|
|
230
237
|
if (debugAction !== undefined)
|
|
231
238
|
duplicate(token);
|
|
232
239
|
const value = readOptionValue(argv, index++, token);
|
|
233
|
-
if (!["status", "hypothesis", "strategy", "investigate", "checkpoint", "resume", "extend"].includes(value))
|
|
240
|
+
if (!["status", "hypothesis", "strategy", "investigate", "checkpoint", "resume", "extend", "origin"].includes(value))
|
|
234
241
|
invalidValue(token, value);
|
|
235
242
|
debugAction = value;
|
|
236
243
|
break;
|
|
@@ -264,6 +271,36 @@ export function parseArgs(argv) {
|
|
|
264
271
|
duplicate(token);
|
|
265
272
|
criteriaFile = readOptionValue(argv, index++, token);
|
|
266
273
|
break;
|
|
274
|
+
case "--surface-file":
|
|
275
|
+
if (surfaceFile !== undefined)
|
|
276
|
+
duplicate(token);
|
|
277
|
+
surfaceFile = readOptionValue(argv, index++, token);
|
|
278
|
+
break;
|
|
279
|
+
case "--escape-of":
|
|
280
|
+
if (escapeOf !== undefined)
|
|
281
|
+
duplicate(token);
|
|
282
|
+
escapeOf = readOptionValue(argv, index++, token);
|
|
283
|
+
break;
|
|
284
|
+
case "--escape-label":
|
|
285
|
+
if (escapeLabel !== undefined)
|
|
286
|
+
duplicate(token);
|
|
287
|
+
escapeLabel = readOptionValue(argv, index++, token);
|
|
288
|
+
break;
|
|
289
|
+
case "--escape-receipt":
|
|
290
|
+
if (escapeReceipt !== undefined)
|
|
291
|
+
duplicate(token);
|
|
292
|
+
escapeReceipt = readOptionValue(argv, index++, token);
|
|
293
|
+
break;
|
|
294
|
+
case "--escape-fix":
|
|
295
|
+
if (escapeFix !== undefined)
|
|
296
|
+
duplicate(token);
|
|
297
|
+
escapeFix = readOptionValue(argv, index++, token);
|
|
298
|
+
break;
|
|
299
|
+
case "--escape-promote":
|
|
300
|
+
if (escapePromote !== undefined)
|
|
301
|
+
duplicate(token);
|
|
302
|
+
escapePromote = readOptionValue(argv, index++, token);
|
|
303
|
+
break;
|
|
267
304
|
case "--plan-file":
|
|
268
305
|
if (planFile !== undefined)
|
|
269
306
|
duplicate(token);
|
|
@@ -681,19 +718,28 @@ export function parseArgs(argv) {
|
|
|
681
718
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Debug operation options apply only to task debug.");
|
|
682
719
|
if (command === "task" && action === "debug" && (taskId === undefined || debugAction === undefined || title !== undefined || intent !== undefined ||
|
|
683
720
|
parentTaskId !== undefined || criteria.length > 0 || evidence.length > 0 ||
|
|
684
|
-
((debugAction === "hypothesis" || debugAction === "strategy") !== (payloadFile !== undefined)) ||
|
|
721
|
+
((debugAction === "hypothesis" || debugAction === "strategy" || debugAction === "origin") !== (payloadFile !== undefined)) ||
|
|
685
722
|
((debugAction === "investigate") !== (commandId !== undefined)) || ((debugAction === "extend") !== (eventId !== undefined))))
|
|
686
723
|
throw new CliArgumentError("CLI_MISSING_VALUE", "Task debug requires --task, --debug-action and the operation's exact payload/command/event options.");
|
|
687
724
|
if ((criterionFiles.length > 0 && !(command === "task" && (action === "create" || action === "pin-finding"))) ||
|
|
688
725
|
(criteriaFile !== undefined && !(command === "task" && action === "revise")) ||
|
|
726
|
+
(surfaceFile !== undefined && !(command === "task" && (action === "create" || action === "revise"))) ||
|
|
689
727
|
(planFile !== undefined && !(command === "task" && action === "replan")) ||
|
|
690
728
|
(expectedContract !== undefined && !(command === "task" && (action === "revise" || action === "pin-finding"))) ||
|
|
691
729
|
(expectedTreeContract !== undefined && !(command === "task" && action === "replan")) ||
|
|
692
730
|
(reason !== undefined && !revisionAction) ||
|
|
693
731
|
(baseline !== undefined && !(command === "task" && (action === "create" || action === "revise"))))
|
|
694
732
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Acceptance options are not supported by this command.");
|
|
733
|
+
if ((escapeOf !== undefined || escapeLabel !== undefined || escapeReceipt !== undefined || escapeFix !== undefined || escapePromote !== undefined) &&
|
|
734
|
+
!(command === "task" && (action === "create" || action === "revise")))
|
|
735
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Escape options apply only to task create and revise.");
|
|
736
|
+
if ((escapeOf === undefined) !== (escapeLabel === undefined) ||
|
|
737
|
+
((escapeReceipt !== undefined || escapeFix !== undefined || escapePromote !== undefined) && escapeOf === undefined))
|
|
738
|
+
throw new CliArgumentError("CLI_MISSING_VALUE", "--escape-of needs --escape-label; --escape-label, --escape-receipt, --escape-fix and --escape-promote need --escape-of.");
|
|
739
|
+
if (command === "escapes" && argv.some(token => token !== "escapes" && token !== "--json"))
|
|
740
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "escapes accepts only --json; it is a read-only report.");
|
|
695
741
|
if (revisionAction && (taskId === undefined || evidence.length > 0 || title !== undefined || intent !== undefined || parentTaskId !== undefined || sessionId !== undefined || criteria.length > 0 || base !== undefined ||
|
|
696
|
-
(action === "revise" && (expectedContract === undefined || criteriaFile === undefined || reason === undefined)) ||
|
|
742
|
+
(action === "revise" && (expectedContract === undefined || (criteriaFile === undefined && surfaceFile === undefined && escapeOf === undefined) || reason === undefined)) ||
|
|
697
743
|
(action === "replan" && (expectedTreeContract === undefined || planFile === undefined || reason === undefined)) ||
|
|
698
744
|
(action === "pin-finding" && (expectedContract === undefined || findingReference === undefined || criterionFiles.length !== 1))))
|
|
699
745
|
throw new CliArgumentError("CLI_MISSING_VALUE", "Acceptance revision requires task, expected contract, payload and reason.");
|
|
@@ -867,6 +913,12 @@ export function parseArgs(argv) {
|
|
|
867
913
|
...(criteria.length === 0 ? {} : { criteria }),
|
|
868
914
|
...(criterionFiles.length === 0 ? {} : { criterionFiles }),
|
|
869
915
|
...(criteriaFile === undefined ? {} : { criteriaFile }),
|
|
916
|
+
...(surfaceFile === undefined ? {} : { surfaceFile }),
|
|
917
|
+
...(escapeOf === undefined ? {} : { escapeOf }),
|
|
918
|
+
...(escapeLabel === undefined ? {} : { escapeLabel }),
|
|
919
|
+
...(escapeReceipt === undefined ? {} : { escapeReceipt }),
|
|
920
|
+
...(escapeFix === undefined ? {} : { escapeFix }),
|
|
921
|
+
...(escapePromote === undefined ? {} : { escapePromote }),
|
|
870
922
|
...(planFile === undefined ? {} : { planFile }),
|
|
871
923
|
...(expectedContract === undefined ? {} : { expectedContract }),
|
|
872
924
|
...(expectedTreeContract === undefined ? {} : { expectedTreeContract }),
|
|
@@ -52,14 +52,15 @@ import { runTrustCommand } from "./commands/trust.js";
|
|
|
52
52
|
import { runVerifyCommand } from "./commands/verify.js";
|
|
53
53
|
import { runAdvanceCommand } from "./commands/advance.js";
|
|
54
54
|
import { runReviewShowCommand } from "./commands/review-show.js";
|
|
55
|
+
import { runEscapesCommand } from "./commands/escapes.js";
|
|
55
56
|
import { runAllowStopCommand } from "./commands/allow-stop.js";
|
|
56
57
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
57
|
-
import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
58
|
+
import { formatUpdatePlan, runUpdateCommand, worktreeSyncLine } from "./commands/update.js";
|
|
58
59
|
import { errorEnvelope } from "./output.js";
|
|
59
60
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
60
61
|
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
61
62
|
import { ensureSessionWorktree, readWorktreeRecord, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
62
|
-
import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
63
|
+
import { listWorktrees, branchLockDoctorResult, sessionWorktreeNames, syncSessionWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
63
64
|
import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
|
|
64
65
|
/** The repository's common Git directory, where display-only office records live; undefined when git cannot say. */
|
|
65
66
|
async function officeCommonDir(dir) {
|
|
@@ -264,6 +265,8 @@ else {
|
|
|
264
265
|
const context = await productionRunContext(root);
|
|
265
266
|
return await runRunCommand({ args, ...context, root: context.mainRoot, office: async () => await ensureBackgroundOffice(root, undefined, openOfficeBrowser) });
|
|
266
267
|
}
|
|
268
|
+
if (args.command === "escapes")
|
|
269
|
+
return await runEscapesCommand({ root });
|
|
267
270
|
if (args.command === "office") {
|
|
268
271
|
return await runOfficeCommand({ ensure: async () => await ensureBackgroundOffice(root, undefined, openOfficeBrowser),
|
|
269
272
|
foreground: async (onUrl) => await serveOffice(root, onUrl), writeStdout: (text) => { process.stdout.write(text); } });
|
|
@@ -422,7 +425,11 @@ else {
|
|
|
422
425
|
: { hookTargets: updateArgs.hookTargets }),
|
|
423
426
|
trustStore: store,
|
|
424
427
|
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
425
|
-
confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
|
|
428
|
+
confirm: async (plan, trust, worktrees) => await confirmPlan(formatUpdatePlan(plan, trust) + worktreeSyncLine(worktrees)),
|
|
429
|
+
sessionWorktrees: {
|
|
430
|
+
list: async () => await sessionWorktreeNames(worktreeDependencies(), root),
|
|
431
|
+
sync: async () => await syncSessionWorktrees(worktreeDependencies(), root)
|
|
432
|
+
},
|
|
426
433
|
promptOffice: async (message, enabled) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, enabled),
|
|
427
434
|
promptAutoRun: async (message, enabled) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, enabled),
|
|
428
435
|
promptWorktree: async (message) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, true),
|
|
@@ -31,6 +31,7 @@ Commands:
|
|
|
31
31
|
review Run an independent review or show recorded reports
|
|
32
32
|
run Start or control a supervised Claude/Codex native goal
|
|
33
33
|
office Open the local pixel office (Preview; opt-in)
|
|
34
|
+
escapes Report escaped bugs, gate catches and learned rules (read-only)
|
|
34
35
|
batch Verify and review a parent task's subtasks together
|
|
35
36
|
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
36
37
|
agy-run Run headless agy with a process-exit completion recheck
|
|
@@ -75,6 +76,14 @@ Options:
|
|
|
75
76
|
* rejected.
|
|
76
77
|
*/
|
|
77
78
|
export const COMMAND_HELP_TEXT = {
|
|
79
|
+
escapes: `Usage: agent-ops escapes [--json]
|
|
80
|
+
|
|
81
|
+
Read-only Escape Loop report: misses per gate from the tracked
|
|
82
|
+
.agent-ops-escapes ledger, gate catches from this machine's local log, origin
|
|
83
|
+
and link-state mix, and per-rule fire/included/excluded counts with retirement
|
|
84
|
+
candidates. Counts are raw with n; fewer than 10 observations are marked
|
|
85
|
+
insufficient-data. It never writes files and fails open.
|
|
86
|
+
`,
|
|
78
87
|
office: `Usage: agent-ops office [--json]
|
|
79
88
|
|
|
80
89
|
Office (Preview) is opt-in: select it during init/update or run
|
|
@@ -179,14 +188,21 @@ Options:
|
|
|
179
188
|
--title <text> create
|
|
180
189
|
--goal <text> create: immutable original issue/goal
|
|
181
190
|
--debug create: task-level investigation tag
|
|
182
|
-
--debug-action <status|hypothesis|strategy|investigate|checkpoint|resume|extend>
|
|
183
|
-
--payload-file <path> debug hypothesis/strategy: bounded JSON
|
|
191
|
+
--debug-action <status|hypothesis|strategy|investigate|checkpoint|resume|extend|origin>
|
|
192
|
+
--payload-file <path> debug hypothesis/strategy/origin: bounded JSON
|
|
184
193
|
--command-id <id> debug investigate: trusted configured command
|
|
185
194
|
--event-id <native-event-id|latest> debug extend: authentic human continue event
|
|
186
195
|
--intent <text> create: intended behavior and constraints before editing
|
|
187
196
|
--criterion <json> create, repeatable, two to five total
|
|
188
197
|
--criterion-file <path> create: one criterion object, repeatable; pin-finding: exactly one
|
|
189
198
|
--criteria-file <path> revise: replacement criterion array
|
|
199
|
+
--surface-file <path> create (required unless --debug), revise: EVIDENCE-SURFACE-001
|
|
200
|
+
audit JSON, or {"covers":[surface-id]} under a surfaced parent
|
|
201
|
+
--escape-of <commit> create, revise: the commit this bug escaped from (Escape Loop)
|
|
202
|
+
--escape-label <label> with --escape-of: requirement|surface|scenario|criteria|verify|review|flagged|out-of-model
|
|
203
|
+
--escape-receipt <id> with --escape-of: link this local finish receipt instead of resolving one
|
|
204
|
+
--escape-fix <commit> with --escape-of: backfill; missed paths come from this past fix commit
|
|
205
|
+
--escape-promote <runner:check> with --escape-of: the red-to-green check proving a criteria/verify escape
|
|
190
206
|
--plan-file <path> replan: split-task array with requirement mappings
|
|
191
207
|
--expected-contract <hash> revise, pin-finding: current task contract from status
|
|
192
208
|
--expected-tree-contract <hash> replan: current tree contract from status
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { LEDGER_DIRECTORY } from "../../../../runtime/src/escape/link.js";
|
|
4
|
+
import { loadRules } from "../../../../runtime/src/escape/rules.js";
|
|
5
|
+
import { readLocalLog } from "../../../../runtime/src/logging/local-log.js";
|
|
6
|
+
import { okEnvelope } from "../output.js";
|
|
7
|
+
/** Below this many observations a count is shown but not trusted. */
|
|
8
|
+
export const MIN_SAMPLE = 10;
|
|
9
|
+
const status = (n) => n < MIN_SAMPLE ? "insufficient-data" : "ok";
|
|
10
|
+
function section(rows) {
|
|
11
|
+
const counts = {};
|
|
12
|
+
for (const row of rows)
|
|
13
|
+
counts[row.key] = (counts[row.key] ?? 0) + 1;
|
|
14
|
+
const times = rows.flatMap(row => row.at === undefined ? [] : [row.at]).sort();
|
|
15
|
+
return { n: rows.length, status: status(rows.length), window: { first: times[0] ?? null, last: times.at(-1) ?? null }, counts };
|
|
16
|
+
}
|
|
17
|
+
/** Tracked ledger records; a symlinked or unreadable entry is skipped with a warning, never followed. */
|
|
18
|
+
async function ledger(root, warnings) {
|
|
19
|
+
const directory = join(root, LEDGER_DIRECTORY);
|
|
20
|
+
try {
|
|
21
|
+
if (!(await lstat(directory)).isDirectory()) {
|
|
22
|
+
warnings.push(`${LEDGER_DIRECTORY} is not a directory.`);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const records = [];
|
|
30
|
+
for (const name of (await readdir(directory)).filter(n => n.endsWith(".json")).sort()) {
|
|
31
|
+
try {
|
|
32
|
+
if (!(await lstat(join(directory, name))).isFile())
|
|
33
|
+
throw new Error("not a regular file");
|
|
34
|
+
const value = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
35
|
+
if (typeof value.label === "string" && typeof value.link?.state === "string")
|
|
36
|
+
records.push(value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
warnings.push(`Skipped unreadable ledger record ${name}.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return records;
|
|
43
|
+
}
|
|
44
|
+
/** Advisory, read-only and fail-open: every source that cannot be read becomes a warning. */
|
|
45
|
+
export async function buildEscapesReport(root, home) {
|
|
46
|
+
const warnings = [];
|
|
47
|
+
const records = await ledger(root, warnings).catch(() => { warnings.push("Ledger unreadable."); return []; });
|
|
48
|
+
const events = await readLocalLog(home);
|
|
49
|
+
let origins = [];
|
|
50
|
+
try {
|
|
51
|
+
const state = JSON.parse(await readFile(join(root, ".agent-ops", "tasks", "state.json"), "utf8"));
|
|
52
|
+
origins = (state.tasks ?? []).filter(r => r.task?.origin !== undefined || r.task?.debug !== undefined)
|
|
53
|
+
.map(r => ({ ...(r.createdAt === undefined ? {} : { at: r.createdAt }), key: r.task?.origin?.kind ?? "undeclared" }));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
warnings.push("Task state unreadable; origin mix is empty.");
|
|
57
|
+
}
|
|
58
|
+
let rules = [];
|
|
59
|
+
try {
|
|
60
|
+
rules = await loadRules(root);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
warnings.push(`Rules unreadable: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
64
|
+
}
|
|
65
|
+
const ruleEvents = events.flatMap(e => e.event.type === "escape-rule" ? [e.event] : []);
|
|
66
|
+
const ruleRows = rules.map(({ rule }) => {
|
|
67
|
+
const fired = ruleEvents.filter(e => e.ruleId === rule.id);
|
|
68
|
+
return { id: rule.id, label: rule.label, noExclude: rule.noExclude, fires: fired.length,
|
|
69
|
+
included: fired.reduce((n, e) => n + e.included, 0), excluded: fired.reduce((n, e) => n + e.excluded, 0), status: status(fired.length) };
|
|
70
|
+
});
|
|
71
|
+
const links = section(records.map(r => ({ at: r.declaredAt, key: r.link.state })));
|
|
72
|
+
const reasons = {};
|
|
73
|
+
for (const r of records)
|
|
74
|
+
if (r.link.state === "unresolved")
|
|
75
|
+
reasons[r.link.reason] = (reasons[r.link.reason] ?? 0) + 1;
|
|
76
|
+
return {
|
|
77
|
+
misses: section(records.map(r => ({ at: r.declaredAt, key: r.label }))),
|
|
78
|
+
catches: section(events.flatMap(e => e.event.type === "gate-catch" ? [{ at: e.timestamp, key: e.event.code }] : [])),
|
|
79
|
+
origins: section(origins),
|
|
80
|
+
links: { ...links, reasons },
|
|
81
|
+
rules: ruleRows,
|
|
82
|
+
// Retirement is never automatic: only rules with enough fires that never surfaced an included place are listed.
|
|
83
|
+
retirementCandidates: ruleRows.filter(r => r.fires >= MIN_SAMPLE && r.included === 0)
|
|
84
|
+
.map(r => ({ id: r.id, reason: `fired ${r.fires} times; every hit was excluded (${r.excluded}) or none was included.` })),
|
|
85
|
+
warnings
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function render(report) {
|
|
89
|
+
const line = (name, s) => `${name}: n=${s.n}${s.status === "ok" ? "" : " (insufficient-data)"}` +
|
|
90
|
+
`${s.window.first === null ? "" : ` ${s.window.first} .. ${s.window.last}`}` +
|
|
91
|
+
`${Object.keys(s.counts).length === 0 ? "" : ` - ${Object.entries(s.counts).sort().map(([k, v]) => `${k} ${v}`).join(", ")}`}`;
|
|
92
|
+
return [
|
|
93
|
+
"Escape Loop report (advisory; raw counts; catches and rule fires come from this machine's local log)",
|
|
94
|
+
line("Misses by gate (ledger)", report.misses),
|
|
95
|
+
line("Catches by code (local log)", report.catches),
|
|
96
|
+
line("Origin mix (tasks)", report.origins),
|
|
97
|
+
line("Link states (ledger)", report.links) +
|
|
98
|
+
(Object.keys(report.links.reasons).length === 0 ? "" : `; unresolved reasons: ${Object.entries(report.links.reasons).sort().map(([k, v]) => `${k} ${v}`).join(", ")}`),
|
|
99
|
+
`Rules: ${report.rules.length}`,
|
|
100
|
+
...report.rules.map(r => `- ${r.id} (${r.label}${r.noExclude ? ", noExclude" : ""}): fires ${r.fires}, included ${r.included}, excluded ${r.excluded}` +
|
|
101
|
+
`${r.status === "ok" ? "" : " (insufficient-data)"}`),
|
|
102
|
+
`Retirement candidates: ${report.retirementCandidates.length === 0 ? "none" : report.retirementCandidates.map(c => `${c.id} (${c.reason})`).join("; ")}`,
|
|
103
|
+
...report.warnings.map(w => `Warning: ${w}`)
|
|
104
|
+
].join("\n") + "\n";
|
|
105
|
+
}
|
|
106
|
+
export async function runEscapesCommand(options) {
|
|
107
|
+
const report = await buildEscapesReport(options.root, options.home);
|
|
108
|
+
return okEnvelope("ESCAPES_REPORT", { ...report, text: render(report) });
|
|
109
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redactSecrets } from "../../../../runtime/src/security/redact.js";
|
|
2
|
+
import { safeTaskText } from "../../../../runtime/src/task/render.js";
|
|
1
3
|
import { okEnvelope } from "../output.js";
|
|
2
4
|
import { resolveCommandSessionId, sessionIdFromEnvironment } from "../../../../runtime/src/hooks/codex-loop.js";
|
|
3
5
|
import { addWorktree } from "../../../../runtime/src/parallel/service.js";
|
|
@@ -42,6 +44,9 @@ function finishText(result) {
|
|
|
42
44
|
`Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
|
|
43
45
|
`Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
|
|
44
46
|
`Removed ${record.path}.`,
|
|
47
|
+
...(result.receipt === undefined ? [] : [`Receipt: ${result.receipt}`]),
|
|
48
|
+
...(result.residualRisks === undefined || result.residualRisks.length === 0 ? [] :
|
|
49
|
+
["Residual risks from review:", ...result.residualRisks.map((risk) => `- ${safeTaskText(redactSecrets(risk))}`)]),
|
|
45
50
|
...result.warnings.map((warning) => `Warning: ${warning}`)
|
|
46
51
|
].join("\n");
|
|
47
52
|
}
|
|
@@ -58,7 +58,7 @@ export async function readFinishedReview(deps, mainRoot, commonDir, path) {
|
|
|
58
58
|
catch {
|
|
59
59
|
return invalid("Final review receipt is malformed.");
|
|
60
60
|
}
|
|
61
|
-
if (receipt.schemaVersion !== 1 || !/^[a-f0-9]{40,64}$/u.test(receipt.candidateHead) ||
|
|
61
|
+
if ((receipt.schemaVersion !== 1 && receipt.schemaVersion !== 2) || !/^[a-f0-9]{40,64}$/u.test(receipt.candidateHead) ||
|
|
62
62
|
!Array.isArray(receipt.tasks) || receipt.tasks.length === 0 ||
|
|
63
63
|
!["tree", "per-task-fallback"].includes(receipt.reviewMode) ||
|
|
64
64
|
typeof receipt.reviews !== "object" || receipt.reviews === null) {
|
|
@@ -16,6 +16,10 @@ import { calculateSourceFingerprint } from "../../../../runtime/src/verify/sourc
|
|
|
16
16
|
import { calculateConfigHash, isPassingVerificationEvidence } from "../../../../runtime/src/verify/evidence.js";
|
|
17
17
|
import { validateEvidence } from "../../../../runtime/src/schema/validate.js";
|
|
18
18
|
import { loadDebugProof, debugProofDigest } from "../../../../runtime/src/task/debug-state.js";
|
|
19
|
+
import { logSurfaceResidualRisks, runSurfaceGate } from "../../../../runtime/src/surface/discovery.js";
|
|
20
|
+
import { escapeReviewData } from "../../../../runtime/src/escape/link.js";
|
|
21
|
+
import { logGateCatch } from "../../../../runtime/src/logging/local-log.js";
|
|
22
|
+
import { isCovers, surfaceDelta } from "../../../../runtime/src/surface/schema.js";
|
|
19
23
|
import { okEnvelope } from "../output.js";
|
|
20
24
|
/**
|
|
21
25
|
* Review runs against one target. Argument parsing already rejects a
|
|
@@ -309,6 +313,113 @@ function scopeReason(error) {
|
|
|
309
313
|
REVIEW_INVALID_BASE: "invalid-base"
|
|
310
314
|
}[error.code];
|
|
311
315
|
}
|
|
316
|
+
const SURFACE_REASONS = {
|
|
317
|
+
SURFACE_MISSING: "surface-missing",
|
|
318
|
+
SURFACE_UNCLASSIFIED: "surface-unclassified",
|
|
319
|
+
SURFACE_QUERY_TOO_BROAD: "surface-query-too-broad",
|
|
320
|
+
SURFACE_EXCLUSION_TOO_BROAD: "surface-exclusion-too-broad",
|
|
321
|
+
SCENARIO_MISSING: "scenario-missing",
|
|
322
|
+
SURFACE_BUDGET_EXCEEDED: "surface-budget-exceeded",
|
|
323
|
+
SURFACE_UNASSIGNED: "surface-unassigned",
|
|
324
|
+
SURFACE_DISMISSAL_TOO_BROAD: "surface-dismissal-too-broad",
|
|
325
|
+
SURFACE_DISMISSAL_INVALID: "surface-dismissal-invalid",
|
|
326
|
+
ESCAPE_RULE_INVALID: "escape-rule-invalid",
|
|
327
|
+
ESCAPE_RULE_UNSAFE: "escape-rule-unsafe",
|
|
328
|
+
ESCAPE_RULE_NO_EXCLUDE: "escape-rule-no-exclude"
|
|
329
|
+
};
|
|
330
|
+
/** Escape Loop: a declared escape-of origin gives a wrong label a criterion a blocking finding can name. */
|
|
331
|
+
export const ESCAPE_LABEL = {
|
|
332
|
+
id: "escape-label",
|
|
333
|
+
description: "Each declared escape's primary label names the earliest gate that could have caught it (requirement, surface, " +
|
|
334
|
+
"scenario, criteria, verify, review; flagged if a residual risk or finding already said it; out-of-model if no gate could), " +
|
|
335
|
+
"is consistent with the mechanical surface facts, and its tracked .agent-ops-escapes record is current."
|
|
336
|
+
};
|
|
337
|
+
/** Escape Loop packet rows for every task in scope that declares an origin; linking stays advisory. */
|
|
338
|
+
async function reviewEscapes(options, context, scope) {
|
|
339
|
+
if (options.root === undefined || options.gitRunner === undefined)
|
|
340
|
+
return [];
|
|
341
|
+
const rows = [];
|
|
342
|
+
for (const record of context.records) {
|
|
343
|
+
const origin = record.task.origin;
|
|
344
|
+
if (origin === undefined)
|
|
345
|
+
continue;
|
|
346
|
+
const base = record.debug?.baselineCommit ?? record.createdSourceCommit ?? (scope?.mode === "base" ? scope.resolvedBase : undefined);
|
|
347
|
+
try {
|
|
348
|
+
if (base === undefined)
|
|
349
|
+
throw new Error("no base");
|
|
350
|
+
rows.push(await escapeReviewData({ root: options.root, git: options.gitRunner, taskId: record.task.id, origin, base }));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
rows.push({ taskId: record.task.id, origin, link: { state: "unresolved", reason: "link-unavailable" } });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return rows;
|
|
357
|
+
}
|
|
358
|
+
async function logCatch(options, code, taskId) {
|
|
359
|
+
if (taskId !== undefined && options.config?.profiles.includes("advisory") === true)
|
|
360
|
+
await logGateCatch(code, taskId);
|
|
361
|
+
}
|
|
362
|
+
/** Review-only: gives an omission a criterion that a blocking finding can name. */
|
|
363
|
+
export const SURFACE_COVERAGE = {
|
|
364
|
+
id: "surface-coverage",
|
|
365
|
+
description: "Every place and scenario the change affects is listed in the surface audit and its listed behavior is " +
|
|
366
|
+
"implemented: recorded queries replayed, valueChanges consumers (both hops) read, the five scenario dimensions " +
|
|
367
|
+
"checked, and every exclusion shows why the surface is unaffected (post-block exclusions especially)."
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* EVIDENCE-SURFACE-001: the nearest surfaced task on the way to the tree root
|
|
371
|
+
* holds the audit; its direct children must cover its included surfaces.
|
|
372
|
+
*/
|
|
373
|
+
async function reviewSurface(options, context, scope) {
|
|
374
|
+
// Grandfathered standalone tasks never touch the discovery path.
|
|
375
|
+
if (context.records.every(r => r.task.surface === undefined && r.task.surfaceRequired !== true && r.task.parentTaskId === undefined))
|
|
376
|
+
return { gate: { status: "PASS", discovery: null } };
|
|
377
|
+
const all = await options.tasks.list();
|
|
378
|
+
const own = context.records.find(r => r.task.id === context.taskId);
|
|
379
|
+
let holder;
|
|
380
|
+
for (let record = own; record !== undefined && holder === undefined; record = all.find(r => r.task.id === record.task.parentTaskId)) {
|
|
381
|
+
if (record.task.surface !== undefined && !isCovers(record.task.surface))
|
|
382
|
+
holder = record;
|
|
383
|
+
if (record.task.parentTaskId === undefined)
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
const children = holder === undefined ? [] : all
|
|
387
|
+
.filter(r => r.task.parentTaskId === holder.task.id && r.status !== "archived" && r.supersededBy === undefined)
|
|
388
|
+
.map(r => ({ taskId: r.task.id, covers: r.task.surface !== undefined && isCovers(r.task.surface) ? r.task.surface.covers : [] }));
|
|
389
|
+
const gate = await runSurfaceGate({
|
|
390
|
+
root: options.root, taskId: context.taskId, surface: holder?.task.surface,
|
|
391
|
+
// Grandfathered roots keep reviewing even when a newer subtask sits under them.
|
|
392
|
+
required: own.task.surfaceRequired === true, children,
|
|
393
|
+
metrics: options.config?.profiles.includes("advisory") === true,
|
|
394
|
+
...(scope.mode === "base" ? { base: scope.resolvedBase } : {}), changedFiles: scope.changedFiles,
|
|
395
|
+
configHash: options.policyConfigHash ?? (options.config === undefined ? "" : calculateConfigHash(options.config))
|
|
396
|
+
});
|
|
397
|
+
if (gate.status === "NOT_RUN" || holder === undefined || gate.discovery === null)
|
|
398
|
+
return { gate };
|
|
399
|
+
return { gate, packet: {
|
|
400
|
+
audit: holder.task.surface,
|
|
401
|
+
// Deltas, not whole previous audits: a large audit revised a few times must still fit the review packet.
|
|
402
|
+
history: (holder.revisions ?? []).map((revision, i, all) => ({ at: revision.at, reason: revision.reason,
|
|
403
|
+
change: surfaceDelta(revision.previousTask.surface, (all[i + 1]?.previousTask ?? holder.task).surface) })).slice(-10),
|
|
404
|
+
discovery: { cached: gate.discovery.cached, sources: gate.discovery.sources, skipped: gate.discovery.skipped,
|
|
405
|
+
dismissed: gate.discovery.dismissed },
|
|
406
|
+
postBlockExclusions: gate.discovery.postBlockExclusions
|
|
407
|
+
} };
|
|
408
|
+
}
|
|
409
|
+
function surfaceNotRunEnvelope(result) {
|
|
410
|
+
const surface = result.surface;
|
|
411
|
+
const message = `${surface.code}: ${surface.message}`;
|
|
412
|
+
return {
|
|
413
|
+
code: surface.code,
|
|
414
|
+
status: "error",
|
|
415
|
+
data: {
|
|
416
|
+
message,
|
|
417
|
+
result,
|
|
418
|
+
text: [renderReviewResult(result), message, ...surface.details.slice(0, 50).map(detail => `- ${redactSecrets(JSON.stringify(detail))}`)].join("\n")
|
|
419
|
+
},
|
|
420
|
+
errors: [{ code: surface.code, message }]
|
|
421
|
+
};
|
|
422
|
+
}
|
|
312
423
|
function notRunEnvelope(result) {
|
|
313
424
|
const message = "Independent review was not run.";
|
|
314
425
|
return {
|
|
@@ -549,6 +660,7 @@ export async function runReviewCommand(options) {
|
|
|
549
660
|
let scope;
|
|
550
661
|
let sourceFingerprint;
|
|
551
662
|
let verification;
|
|
663
|
+
let surfacePacket;
|
|
552
664
|
if (options.root !== undefined && options.gitRunner !== undefined) {
|
|
553
665
|
try {
|
|
554
666
|
scope = await resolveReviewScope({
|
|
@@ -588,12 +700,29 @@ export async function runReviewCommand(options) {
|
|
|
588
700
|
});
|
|
589
701
|
}
|
|
590
702
|
if (context.policyConfigHash !== options.policyConfigHash) {
|
|
591
|
-
|
|
703
|
+
const result = { ...resultBase, status: "NOT_RUN", reason: "reviewer-policy-changed", scope };
|
|
704
|
+
const own = context.records.find(r => r.task.id === context.taskId).task;
|
|
705
|
+
const message = "Independent review was not run.";
|
|
706
|
+
return { code: "REVIEW_NOT_RUN", status: "error", errors: [{ code: "REVIEW_NOT_RUN", message }], data: { message, result,
|
|
707
|
+
text: renderReviewResult(result) +
|
|
708
|
+
`The configuration changed after task ${context.taskId} was created. Rebind it, then verify again:\n` +
|
|
709
|
+
` agent-ops task revise --task ${context.taskId} --expected-contract ${taskContractHash(own)} --reason "Rebind to the current configuration"\n` } };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (context !== undefined && options.tasks !== undefined) {
|
|
713
|
+
const surface = await reviewSurface(options, context, scope);
|
|
714
|
+
if (surface.gate.status === "NOT_RUN") {
|
|
715
|
+
await logCatch(options, surface.gate.code, context.taskId);
|
|
716
|
+
return surfaceNotRunEnvelope({
|
|
592
717
|
...resultBase,
|
|
593
|
-
status: "NOT_RUN",
|
|
594
|
-
|
|
718
|
+
status: "NOT_RUN",
|
|
719
|
+
reason: SURFACE_REASONS[surface.gate.code],
|
|
720
|
+
taskId: context.taskId,
|
|
721
|
+
scope,
|
|
722
|
+
surface: { code: surface.gate.code, message: surface.gate.message, details: surface.gate.details }
|
|
595
723
|
});
|
|
596
724
|
}
|
|
725
|
+
surfacePacket = surface.packet;
|
|
597
726
|
}
|
|
598
727
|
if (context !== undefined) {
|
|
599
728
|
const preflight = await preflightReview(options, context, sourceFingerprint);
|
|
@@ -649,9 +778,15 @@ export async function runReviewCommand(options) {
|
|
|
649
778
|
candidateCommit: await resolveGitCommit(options.gitRunner, "HEAD"), coverageDigest: coverageDigest(rows)
|
|
650
779
|
};
|
|
651
780
|
}
|
|
781
|
+
const escapes = context === undefined ? [] : await reviewEscapes(options, context, scope);
|
|
652
782
|
const criteria = [
|
|
653
|
-
...(context?.criteria ?? GENERIC_CRITERIA)
|
|
783
|
+
...(context?.criteria ?? GENERIC_CRITERIA),
|
|
784
|
+
...(surfacePacket === undefined || context?.criteria.some(c => c.id === SURFACE_COVERAGE.id) === true ? [] : [SURFACE_COVERAGE]),
|
|
785
|
+
...(!context?.records.some(r => r.task.origin?.kind === "escape-of") || context.criteria.some(c => c.id === ESCAPE_LABEL.id) ? [] : [ESCAPE_LABEL])
|
|
654
786
|
];
|
|
787
|
+
// Tree attestations bind the exact criterion list the reviewer answered.
|
|
788
|
+
if (context !== undefined)
|
|
789
|
+
context.criteria = criteria;
|
|
655
790
|
if (context?.treeTasks !== undefined && criteria.length > 128) {
|
|
656
791
|
return notRunEnvelope({ ...resultBase, status: "NOT_RUN", reason: "scope-too-large" });
|
|
657
792
|
}
|
|
@@ -695,6 +830,8 @@ export async function runReviewCommand(options) {
|
|
|
695
830
|
await writePrivateFile(options.root + "/" + path, content, options.root);
|
|
696
831
|
}
|
|
697
832
|
packet = buildReviewPacket({
|
|
833
|
+
...(surfacePacket === undefined ? {} : { surface: surfacePacket }),
|
|
834
|
+
...(escapes.length === 0 ? {} : { escape: escapes }),
|
|
698
835
|
...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
|
|
699
836
|
request: context?.title ?? GENERIC_REQUEST,
|
|
700
837
|
criteria,
|
|
@@ -742,6 +879,8 @@ export async function runReviewCommand(options) {
|
|
|
742
879
|
}))
|
|
743
880
|
});
|
|
744
881
|
const result = await executeReview();
|
|
882
|
+
if (surfacePacket !== undefined && result.report !== undefined && options.config?.profiles.includes("advisory") === true)
|
|
883
|
+
await logSurfaceResidualRisks(result.report.residualRisks.length + (result.adversarial?.report.residualRisks.length ?? 0));
|
|
745
884
|
if (scope !== undefined && result.report !== undefined && options.root !== undefined) {
|
|
746
885
|
try {
|
|
747
886
|
if (result.report.changedFilesInspected.length !== scope.changedFiles.length ||
|
|
@@ -831,6 +970,8 @@ export async function runReviewCommand(options) {
|
|
|
831
970
|
if (finalResult.status === "PASS") {
|
|
832
971
|
return okEnvelope("REVIEW_RESULT", data);
|
|
833
972
|
}
|
|
973
|
+
if (finalResult.status === "FAIL")
|
|
974
|
+
await logCatch(options, "REVIEW_FAIL", context?.taskId);
|
|
834
975
|
const code = finalResult.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
|
|
835
976
|
return {
|
|
836
977
|
code,
|