@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.
Files changed (49) hide show
  1. package/dist/packages/cli/src/args.js +56 -4
  2. package/dist/packages/cli/src/bin.js +10 -3
  3. package/dist/packages/cli/src/cli.js +18 -2
  4. package/dist/packages/cli/src/commands/escapes.js +109 -0
  5. package/dist/packages/cli/src/commands/parallel.js +5 -0
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +145 -4
  8. package/dist/packages/cli/src/commands/task.js +58 -7
  9. package/dist/packages/cli/src/commands/update.js +16 -4
  10. package/dist/packages/cli/src/commands/verify.js +4 -1
  11. package/dist/packages/cli/src/debug-intake.js +2 -2
  12. package/dist/packages/cli/src/run-entry.js +7 -5
  13. package/dist/runtime/src/escape/link.js +317 -0
  14. package/dist/runtime/src/escape/origin.js +20 -0
  15. package/dist/runtime/src/escape/rules.js +252 -0
  16. package/dist/runtime/src/fs/temporary.js +10 -0
  17. package/dist/runtime/src/install/harness.js +2 -2
  18. package/dist/runtime/src/logging/local-log.js +61 -1
  19. package/dist/runtime/src/parallel/finish.js +20 -3
  20. package/dist/runtime/src/parallel/manage.js +51 -1
  21. package/dist/runtime/src/parallel/receipt.js +9 -1
  22. package/dist/runtime/src/review/execute.js +12 -6
  23. package/dist/runtime/src/review/packet.js +15 -1
  24. package/dist/runtime/src/review/probe.js +3 -2
  25. package/dist/runtime/src/review/runner.js +45 -0
  26. package/dist/runtime/src/review/slots.js +1 -1
  27. package/dist/runtime/src/run/planning.js +5 -2
  28. package/dist/runtime/src/schema/validate.js +17 -1
  29. package/dist/runtime/src/surface/discovery.js +298 -0
  30. package/dist/runtime/src/surface/schema.js +168 -0
  31. package/dist/runtime/src/task/completion.js +20 -0
  32. package/dist/runtime/src/task/contract.js +5 -1
  33. package/dist/runtime/src/task/debug.js +27 -1
  34. package/dist/runtime/src/task/service.js +72 -5
  35. package/dist/runtime/src/verify/acceptance-replay.js +24 -11
  36. package/docs/en/guides/configuration.md +16 -1
  37. package/docs/en/guides/task-debug.md +12 -0
  38. package/docs/en/spec/acceptance-and-evidence.md +152 -0
  39. package/docs/en/spec/judgment.md +7 -0
  40. package/docs/en/spec/loop-engineering.md +2 -2
  41. package/docs/en/spec/review.md +5 -0
  42. package/docs/zh-TW/guides/configuration.md +14 -1
  43. package/docs/zh-TW/guides/task-debug.md +10 -0
  44. package/docs/zh-TW/spec/acceptance-and-evidence.md +127 -0
  45. package/docs/zh-TW/spec/judgment.md +6 -0
  46. package/docs/zh-TW/spec/loop-engineering.md +2 -2
  47. package/docs/zh-TW/spec/review.md +1 -0
  48. package/package.json +1 -1
  49. 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
- return notRunEnvelope({
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", reason: "reviewer-policy-changed",
594
- scope
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,