@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.
Files changed (39) hide show
  1. package/dist/packages/cli/src/args.js +48 -4
  2. package/dist/packages/cli/src/bin.js +10 -3
  3. package/dist/packages/cli/src/cli.js +16 -2
  4. package/dist/packages/cli/src/commands/escapes.js +109 -0
  5. package/dist/packages/cli/src/commands/review-show.js +1 -1
  6. package/dist/packages/cli/src/commands/review.js +60 -10
  7. package/dist/packages/cli/src/commands/task.js +41 -3
  8. package/dist/packages/cli/src/commands/update.js +16 -4
  9. package/dist/packages/cli/src/commands/verify.js +4 -1
  10. package/dist/packages/cli/src/debug-intake.js +1 -1
  11. package/dist/runtime/src/escape/link.js +317 -0
  12. package/dist/runtime/src/escape/origin.js +20 -0
  13. package/dist/runtime/src/escape/rules.js +252 -0
  14. package/dist/runtime/src/fs/temporary.js +10 -0
  15. package/dist/runtime/src/install/harness.js +2 -2
  16. package/dist/runtime/src/logging/local-log.js +44 -1
  17. package/dist/runtime/src/parallel/manage.js +51 -1
  18. package/dist/runtime/src/parallel/receipt.js +3 -1
  19. package/dist/runtime/src/review/execute.js +12 -6
  20. package/dist/runtime/src/review/packet.js +3 -1
  21. package/dist/runtime/src/review/probe.js +3 -2
  22. package/dist/runtime/src/review/runner.js +24 -3
  23. package/dist/runtime/src/review/slots.js +1 -1
  24. package/dist/runtime/src/schema/validate.js +6 -1
  25. package/dist/runtime/src/surface/discovery.js +79 -2
  26. package/dist/runtime/src/surface/schema.js +43 -2
  27. package/dist/runtime/src/task/completion.js +20 -0
  28. package/dist/runtime/src/task/contract.js +3 -1
  29. package/dist/runtime/src/task/debug.js +24 -1
  30. package/dist/runtime/src/task/service.js +58 -6
  31. package/dist/runtime/src/verify/acceptance-replay.js +24 -11
  32. package/docs/en/guides/configuration.md +16 -1
  33. package/docs/en/guides/task-debug.md +12 -0
  34. package/docs/en/spec/acceptance-and-evidence.md +100 -2
  35. package/docs/zh-TW/guides/configuration.md +14 -1
  36. package/docs/zh-TW/guides/task-debug.md +10 -0
  37. package/docs/zh-TW/spec/acceptance-and-evidence.md +82 -3
  38. package/package.json +1 -1
  39. package/schemas/task.schema.json +8 -1
@@ -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"]);
@@ -91,6 +92,11 @@ export function parseArgs(argv) {
91
92
  const criterionFiles = [];
92
93
  let criteriaFile;
93
94
  let surfaceFile;
95
+ let escapeOf;
96
+ let escapeLabel;
97
+ let escapeReceipt;
98
+ let escapeFix;
99
+ let escapePromote;
94
100
  let planFile;
95
101
  let expectedContract;
96
102
  let expectedTreeContract;
@@ -231,7 +237,7 @@ export function parseArgs(argv) {
231
237
  if (debugAction !== undefined)
232
238
  duplicate(token);
233
239
  const value = readOptionValue(argv, index++, token);
234
- if (!["status", "hypothesis", "strategy", "investigate", "checkpoint", "resume", "extend"].includes(value))
240
+ if (!["status", "hypothesis", "strategy", "investigate", "checkpoint", "resume", "extend", "origin"].includes(value))
235
241
  invalidValue(token, value);
236
242
  debugAction = value;
237
243
  break;
@@ -270,6 +276,31 @@ export function parseArgs(argv) {
270
276
  duplicate(token);
271
277
  surfaceFile = readOptionValue(argv, index++, token);
272
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;
273
304
  case "--plan-file":
274
305
  if (planFile !== undefined)
275
306
  duplicate(token);
@@ -687,7 +718,7 @@ export function parseArgs(argv) {
687
718
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Debug operation options apply only to task debug.");
688
719
  if (command === "task" && action === "debug" && (taskId === undefined || debugAction === undefined || title !== undefined || intent !== undefined ||
689
720
  parentTaskId !== undefined || criteria.length > 0 || evidence.length > 0 ||
690
- ((debugAction === "hypothesis" || debugAction === "strategy") !== (payloadFile !== undefined)) ||
721
+ ((debugAction === "hypothesis" || debugAction === "strategy" || debugAction === "origin") !== (payloadFile !== undefined)) ||
691
722
  ((debugAction === "investigate") !== (commandId !== undefined)) || ((debugAction === "extend") !== (eventId !== undefined))))
692
723
  throw new CliArgumentError("CLI_MISSING_VALUE", "Task debug requires --task, --debug-action and the operation's exact payload/command/event options.");
693
724
  if ((criterionFiles.length > 0 && !(command === "task" && (action === "create" || action === "pin-finding"))) ||
@@ -699,8 +730,16 @@ export function parseArgs(argv) {
699
730
  (reason !== undefined && !revisionAction) ||
700
731
  (baseline !== undefined && !(command === "task" && (action === "create" || action === "revise"))))
701
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.");
702
741
  if (revisionAction && (taskId === undefined || evidence.length > 0 || title !== undefined || intent !== undefined || parentTaskId !== undefined || sessionId !== undefined || criteria.length > 0 || base !== undefined ||
703
- (action === "revise" && (expectedContract === undefined || (criteriaFile === undefined && surfaceFile === undefined) || reason === undefined)) ||
742
+ (action === "revise" && (expectedContract === undefined || (criteriaFile === undefined && surfaceFile === undefined && escapeOf === undefined) || reason === undefined)) ||
704
743
  (action === "replan" && (expectedTreeContract === undefined || planFile === undefined || reason === undefined)) ||
705
744
  (action === "pin-finding" && (expectedContract === undefined || findingReference === undefined || criterionFiles.length !== 1))))
706
745
  throw new CliArgumentError("CLI_MISSING_VALUE", "Acceptance revision requires task, expected contract, payload and reason.");
@@ -875,6 +914,11 @@ export function parseArgs(argv) {
875
914
  ...(criterionFiles.length === 0 ? {} : { criterionFiles }),
876
915
  ...(criteriaFile === undefined ? {} : { criteriaFile }),
877
916
  ...(surfaceFile === undefined ? {} : { surfaceFile }),
917
+ ...(escapeOf === undefined ? {} : { escapeOf }),
918
+ ...(escapeLabel === undefined ? {} : { escapeLabel }),
919
+ ...(escapeReceipt === undefined ? {} : { escapeReceipt }),
920
+ ...(escapeFix === undefined ? {} : { escapeFix }),
921
+ ...(escapePromote === undefined ? {} : { escapePromote }),
878
922
  ...(planFile === undefined ? {} : { planFile }),
879
923
  ...(expectedContract === undefined ? {} : { expectedContract }),
880
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,8 +188,8 @@ 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
@@ -189,6 +198,11 @@ Options:
189
198
  --criteria-file <path> revise: replacement criterion array
190
199
  --surface-file <path> create (required unless --debug), revise: EVIDENCE-SURFACE-001
191
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
192
206
  --plan-file <path> replan: split-task array with requirement mappings
193
207
  --expected-contract <hash> revise, pin-finding: current task contract from status
194
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
+ }
@@ -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) {
@@ -17,7 +17,9 @@ import { calculateConfigHash, isPassingVerificationEvidence } from "../../../../
17
17
  import { validateEvidence } from "../../../../runtime/src/schema/validate.js";
18
18
  import { loadDebugProof, debugProofDigest } from "../../../../runtime/src/task/debug-state.js";
19
19
  import { logSurfaceResidualRisks, runSurfaceGate } from "../../../../runtime/src/surface/discovery.js";
20
- import { isCovers } from "../../../../runtime/src/surface/schema.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";
21
23
  import { okEnvelope } from "../output.js";
22
24
  /**
23
25
  * Review runs against one target. Argument parsing already rejects a
@@ -318,8 +320,45 @@ const SURFACE_REASONS = {
318
320
  SURFACE_EXCLUSION_TOO_BROAD: "surface-exclusion-too-broad",
319
321
  SCENARIO_MISSING: "scenario-missing",
320
322
  SURFACE_BUDGET_EXCEEDED: "surface-budget-exceeded",
321
- SURFACE_UNASSIGNED: "surface-unassigned"
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"
322
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
+ }
323
362
  /** Review-only: gives an omission a criterion that a blocking finding can name. */
324
363
  export const SURFACE_COVERAGE = {
325
364
  id: "surface-coverage",
@@ -359,8 +398,11 @@ async function reviewSurface(options, context, scope) {
359
398
  return { gate };
360
399
  return { gate, packet: {
361
400
  audit: holder.task.surface,
362
- history: (holder.revisions ?? []).slice(-10).map(revision => ({ at: revision.at, reason: revision.reason, surface: revision.previousTask.surface ?? null })),
363
- discovery: { cached: gate.discovery.cached, sources: gate.discovery.sources, skipped: gate.discovery.skipped },
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 },
364
406
  postBlockExclusions: gate.discovery.postBlockExclusions
365
407
  } };
366
408
  }
@@ -658,16 +700,19 @@ export async function runReviewCommand(options) {
658
700
  });
659
701
  }
660
702
  if (context.policyConfigHash !== options.policyConfigHash) {
661
- return notRunEnvelope({
662
- ...resultBase,
663
- status: "NOT_RUN", reason: "reviewer-policy-changed",
664
- scope
665
- });
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` } };
666
710
  }
667
711
  }
668
712
  if (context !== undefined && options.tasks !== undefined) {
669
713
  const surface = await reviewSurface(options, context, scope);
670
714
  if (surface.gate.status === "NOT_RUN") {
715
+ await logCatch(options, surface.gate.code, context.taskId);
671
716
  return surfaceNotRunEnvelope({
672
717
  ...resultBase,
673
718
  status: "NOT_RUN",
@@ -733,9 +778,11 @@ export async function runReviewCommand(options) {
733
778
  candidateCommit: await resolveGitCommit(options.gitRunner, "HEAD"), coverageDigest: coverageDigest(rows)
734
779
  };
735
780
  }
781
+ const escapes = context === undefined ? [] : await reviewEscapes(options, context, scope);
736
782
  const criteria = [
737
783
  ...(context?.criteria ?? GENERIC_CRITERIA),
738
- ...(surfacePacket === undefined || context?.criteria.some(c => c.id === SURFACE_COVERAGE.id) === true ? [] : [SURFACE_COVERAGE])
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])
739
786
  ];
740
787
  // Tree attestations bind the exact criterion list the reviewer answered.
741
788
  if (context !== undefined)
@@ -784,6 +831,7 @@ export async function runReviewCommand(options) {
784
831
  }
785
832
  packet = buildReviewPacket({
786
833
  ...(surfacePacket === undefined ? {} : { surface: surfacePacket }),
834
+ ...(escapes.length === 0 ? {} : { escape: escapes }),
787
835
  ...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
788
836
  request: context?.title ?? GENERIC_REQUEST,
789
837
  criteria,
@@ -922,6 +970,8 @@ export async function runReviewCommand(options) {
922
970
  if (finalResult.status === "PASS") {
923
971
  return okEnvelope("REVIEW_RESULT", data);
924
972
  }
973
+ if (finalResult.status === "FAIL")
974
+ await logCatch(options, "REVIEW_FAIL", context?.taskId);
925
975
  const code = finalResult.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
926
976
  return {
927
977
  code,
@@ -30,6 +30,35 @@ async function jsonFile(path) {
30
30
  throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Invalid JSON payload.");
31
31
  }
32
32
  }
33
+ async function escapeOrigin(args, service) {
34
+ return args.escapeOf === undefined ? undefined : await service.resolveOrigin({ kind: "escape-of", commit: args.escapeOf,
35
+ label: args.escapeLabel, ...(args.escapeReceipt === undefined ? {} : { receipt: args.escapeReceipt }),
36
+ ...(args.escapeFix === undefined ? {} : { fix: args.escapeFix }), ...(args.escapePromote === undefined ? {} : { promote: args.escapePromote }) });
37
+ }
38
+ /** Link state, label and blame hints are printed; the ledger record is committed with the fix. */
39
+ async function withEscape(envelope, escape, service) {
40
+ if (escape === undefined || envelope.data === null)
41
+ return envelope;
42
+ const { link, label, agentLabel, facts, proof } = escape.record;
43
+ const proposal = proof?.status === "check" && proof.check !== null ? await service.escapeConfigProposal(proof.check) : undefined;
44
+ const lines = [
45
+ `Escape of ${escape.record.escapeOf}: ${link.state}${link.state === "unresolved" ? ` (${link.reason})` : ""}` +
46
+ `${link.state === "gated" ? ` via receipt ${link.receipt} (${link.matchedBy})` : ""}; label ${label}${label === agentLabel ? "" : ` (agent proposed ${agentLabel})`}.`,
47
+ ...facts.map(fact => `- ${fact.path}: ${fact.surface}`),
48
+ ...(proof === undefined ? [] : [proof.status === "proven"
49
+ ? `Proof: rule ${proof.rule} replays at the escaped commit; backtest ${proof.backtest.fires}/${proof.backtest.commits} commits (${proof.backtest.rate}).`
50
+ : proof.status === "unproven" ? `Proof: ${proof.code} - ${proof.reason} Finish refuses this until a tracked rule proves it.`
51
+ : proof.status === "lesson" ? `Lesson (unproven, does not block): ${proof.reason}`
52
+ : proof.check === null ? "Proof: ESCAPE_RULE_UNPROVEN - name the red-to-green check with --escape-promote <runnerId:checkId>."
53
+ : `Proof: promoted check ${proof.check} must be a proven redCheckId of this task. Config proposal (not written):\n${JSON.stringify(proposal, null, 2)}`]),
54
+ `Ledger record: ${escape.path} (commit it with the fix).`,
55
+ escape.blame.length === 0 ? "Blame hints: none (the fix only adds lines; an omission has no author)."
56
+ : `Blame hints (a hint only): ${escape.blame.map(hint => `${hint.commit.slice(0, 12)} (${hint.lines} line${hint.lines === 1 ? "" : "s"})`).join(", ")}`
57
+ ];
58
+ return { ...envelope, data: { ...envelope.data, escape: { ...escape.record, blame: escape.blame, path: escape.path,
59
+ ...(proposal === undefined ? {} : { configProposal: proposal }) },
60
+ text: `${envelope.data.text ?? ""}${envelope.data.text?.endsWith("\n") === false ? "\n" : ""}${lines.join("\n")}\n` } };
61
+ }
33
62
  /** An unreadable surface file is a missing surface, not a generic payload error. */
34
63
  async function surfaceFile(path) {
35
64
  try {
@@ -105,6 +134,7 @@ export async function runTaskCommand(options) {
105
134
  const worktree = sessionId === undefined || options.sessionWorktree === undefined
106
135
  ? undefined
107
136
  : await options.sessionWorktree(sessionId);
137
+ const origin = await escapeOrigin(options.args, worktree?.service ?? options.service);
108
138
  const record = await (worktree?.service ?? options.service).create({
109
139
  title: options.args.title,
110
140
  ...(options.args.goal === undefined ? {} : { goal: options.args.goal }),
@@ -122,19 +152,21 @@ export async function runTaskCommand(options) {
122
152
  : { parentTaskId: options.args.parentTaskId }),
123
153
  ...(sessionId === undefined ? {} : { sessionId }),
124
154
  ...(options.args.surfaceFile === undefined ? {} : { surface: await surfaceFile(options.args.surfaceFile) }),
155
+ ...(origin === undefined ? {} : { origin }),
125
156
  requireSurface: true
126
157
  });
158
+ const escape = origin === undefined ? undefined : await (worktree?.service ?? options.service).declareEscape(record.task.id);
127
159
  // An explicit CLI DEBUG intake may be the only start signal on hosts
128
160
  // without a prompt hook (notably agy). The native session owns the clock;
129
161
  // foregroundStart is idempotent for a repeated hook or command.
130
162
  if (options.args.debug && sessionId !== undefined) {
131
163
  await (worktree?.service ?? options.service).debugService().foregroundStart(record.task.id, sessionId);
132
164
  }
133
- const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
165
+ const created = await withEscape(taskEnvelope(action, "TASK_CREATED", sessionId === undefined
134
166
  ? `Created task ${record.task.id}.`
135
167
  : record.task.parentTaskId === undefined
136
168
  ? `Created and attached task ${record.task.id}.`
137
- : `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
169
+ : `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record), escape, worktree?.service ?? options.service);
138
170
  if (worktree === undefined || created.data === null)
139
171
  return created;
140
172
  return {
@@ -157,6 +189,10 @@ export async function runTaskCommand(options) {
157
189
  }
158
190
  if (action === "debug") {
159
191
  const id = requireTaskId(options.args), service = options.service.debugService();
192
+ if (options.args.debugAction === "origin") {
193
+ const declared = await service.origin(id, await jsonFile(options.args.payloadFile));
194
+ return await withEscape(taskEnvelope(action, "TASK_DEBUG_ORIGIN_DECLARED", "Origin bound into the contract; re-verify, re-record the confirmed hypothesis, then review.", declared.record), declared.escape, options.service);
195
+ }
160
196
  switch (options.args.debugAction) {
161
197
  case "hypothesis":
162
198
  await service.hypothesis(id, await jsonFile(options.args.payloadFile));
@@ -191,13 +227,15 @@ export async function runTaskCommand(options) {
191
227
  const payload = options.args.criteriaFile === undefined ? undefined : await jsonFile(options.args.criteriaFile);
192
228
  if (payload !== undefined && !Array.isArray(payload))
193
229
  throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Criteria file must contain an array.");
230
+ const origin = await escapeOrigin(options.args, options.service);
194
231
  const record = await options.service.revise(requireTaskId(options.args), {
195
232
  expectedContractHash: options.args.expectedContract,
196
233
  ...(payload === undefined ? {} : { criteria: payload }),
197
234
  ...(options.args.surfaceFile === undefined ? {} : { surface: await surfaceFile(options.args.surfaceFile) }),
235
+ ...(origin === undefined ? {} : { origin }),
198
236
  reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
199
237
  });
200
- return taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record);
238
+ return await withEscape(taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record), origin === undefined ? undefined : await options.service.declareEscape(record.task.id), options.service);
201
239
  }
202
240
  if (action === "replan") {
203
241
  const payload = await jsonFile(options.args.planFile);
@@ -8,6 +8,11 @@ import { okEnvelope } from "../output.js";
8
8
  import { formatOperationPlan } from "../plan-output.js";
9
9
  import { toPublicUpdatePlan } from "../public-plan.js";
10
10
  import { formatTrustChange, planTrustGrant } from "./trust.js";
11
+ /** One plan line, so the user approves the worktrees together with the commands. */
12
+ export function worktreeSyncLine(worktrees) {
13
+ return worktrees.length === 0 ? "" :
14
+ `Session worktrees that receive this config and its trust: ${worktrees.join(", ")}\n`;
15
+ }
11
16
  function repairedLines(plan) {
12
17
  return plan.installation.repaired.map(({ path, reason }) => `repaired: ${path} (${reason.replace("-", " ")})`);
13
18
  }
@@ -146,19 +151,21 @@ export async function runUpdateCommand(options) {
146
151
  ...(worktree === undefined ? {} : { worktree })
147
152
  });
148
153
  const trust = await trustChange(options, plan);
154
+ const worktrees = await options.sessionWorktrees?.list().catch(() => []) ?? [];
149
155
  if (options.args.dryRun) {
150
156
  return okEnvelope("UPDATE_PLAN_READY", {
151
157
  applied: false,
152
158
  plan: toPublicUpdatePlan(plan, trust),
153
159
  message: "Update plan calculated; no files were changed.",
154
- text: formatUpdatePlan(plan, trust)
160
+ text: formatUpdatePlan(plan, trust) + worktreeSyncLine(worktrees),
161
+ ...(worktrees.length === 0 ? {} : { worktrees })
155
162
  });
156
163
  }
157
164
  if (!options.args.yes && !options.isTTY) {
158
165
  return updateError("UPDATE_CONFIRMATION_REQUIRED", "Non-interactive update requires --yes.", plan, trust);
159
166
  }
160
167
  if (!options.args.yes &&
161
- !(await options.confirm(plan, trust))) {
168
+ !(await options.confirm(plan, trust, worktrees))) {
162
169
  return updateError("UPDATE_CANCELLED", "Update was cancelled; no files were changed.", plan, trust);
163
170
  }
164
171
  await applyUpdatePlan(options.root, plan);
@@ -173,16 +180,21 @@ export async function runUpdateCommand(options) {
173
180
  return updateError("UPDATE_TRUST_FAILED", "Update was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
174
181
  }
175
182
  }
183
+ // Fail-open: a worktree that cannot be synced keeps its old trust and says so via trust status.
184
+ const synced = worktrees.length === 0 ? [] : await options.sessionWorktrees?.sync().catch(() => []) ?? [];
185
+ const syncedLines = synced.length === 0 ? [] : [`Synced config and trust into session worktrees: ${synced.join(", ")}.`];
176
186
  return okEnvelope("UPDATE_APPLIED", {
177
187
  applied: true,
178
188
  plan: toPublicUpdatePlan(plan, trust),
179
189
  message: `Managed installation updated to ${plan.targetVersion}.`,
180
- ...(plan.installation.repaired.length === 0
190
+ ...(synced.length === 0 ? {} : { worktrees: synced }),
191
+ ...(plan.installation.repaired.length === 0 && synced.length === 0
181
192
  ? {}
182
193
  : {
183
194
  text: [
184
195
  `Managed installation updated to ${plan.targetVersion}.`,
185
- ...repairedLines(plan)
196
+ ...repairedLines(plan),
197
+ ...syncedLines
186
198
  ].join("\n")
187
199
  })
188
200
  });
@@ -72,7 +72,10 @@ function formatReport(report) {
72
72
  const lines = [
73
73
  `Verification task ${safe(report.taskId)}: ${report.status}`,
74
74
  `Scope: ${report.selection.reason} (${report.selection.fallback ? "fallback" : "bounded"})`,
75
- ...report.results.flatMap(formatResult)
75
+ ...report.results.flatMap(formatResult),
76
+ // A timed-out runner step says which step and which limit; plain UNKNOWN left people guessing.
77
+ ...(report.acceptance ?? []).flatMap(row => row.detail === undefined ? [] :
78
+ [`- ${row.status} ${safe(row.runnerId)} (${row.criterionId}, ${row.phase}): ${safe(row.detail)}`])
76
79
  ];
77
80
  if (report.signal !== null) {
78
81
  lines.push(`Signal: ${report.signal}`);
@@ -171,7 +171,7 @@ export async function routeDebugPrompt(root, input, host = "claude") {
171
171
  }
172
172
  else
173
173
  clockNote = " Native foreground timing is unsupported on this host; controlled operations remain evidence-timed and do not claim a complete active-time budget.";
174
- return `DEBUG issue ${created.task.id} created in ${path}. Preserve the original goal. Investigate, record falsifiable hypotheses, revise provisional criteria to relevant behavioral regression, add the surface audit (task revise --surface-file) before the first repair, repair, verify, obtain two fresh independent reviews, then finish. A clean HEAD baseline is fixed automatically; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Defaults: 60 active minutes / 5 material repair candidates. No user-authored debug plan is required.${clockNote}`;
174
+ return `DEBUG issue ${created.task.id} created in ${path}. Preserve the original goal. Investigate, record falsifiable hypotheses, revise provisional criteria to relevant behavioral regression, add the surface audit (task revise --surface-file) before the first repair, repair, declare the origin (task debug --debug-action origin) once a hypothesis is confirmed, verify, obtain two fresh independent reviews, then finish. A clean HEAD baseline is fixed automatically; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Defaults: 60 active minutes / 5 material repair candidates. No user-authored debug plan is required.${clockNote}`;
175
175
  }
176
176
  export async function interruptDebugSession(root, sessionId, nativeInput) {
177
177
  const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId)));