@kylecheng3146/agent-ops 0.9.0 → 0.10.2

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 (73) hide show
  1. package/README.md +3 -2
  2. package/dist/packages/cli/src/args.js +58 -14
  3. package/dist/packages/cli/src/bin.js +10 -3
  4. package/dist/packages/cli/src/cli.js +30 -6
  5. package/dist/packages/cli/src/commands/advance.js +2 -1
  6. package/dist/packages/cli/src/commands/escapes.js +109 -0
  7. package/dist/packages/cli/src/commands/parallel.js +1 -1
  8. package/dist/packages/cli/src/commands/review-show.js +1 -1
  9. package/dist/packages/cli/src/commands/review.js +63 -11
  10. package/dist/packages/cli/src/commands/run.js +10 -3
  11. package/dist/packages/cli/src/commands/task.js +41 -3
  12. package/dist/packages/cli/src/commands/update.js +16 -4
  13. package/dist/packages/cli/src/commands/verify.js +4 -1
  14. package/dist/packages/cli/src/context.js +22 -4
  15. package/dist/packages/cli/src/debug-intake.js +1 -1
  16. package/dist/packages/cli/src/office-entry.js +8 -4
  17. package/dist/packages/cli/src/owned-run-step.js +4 -4
  18. package/dist/packages/cli/src/parallel-deps.js +0 -2
  19. package/dist/packages/cli/src/run-deps.js +12 -9
  20. package/dist/packages/cli/src/run-entry.js +76 -17
  21. package/dist/packages/cli/src/run-setup-entry.js +2 -2
  22. package/dist/runtime/src/escape/link.js +317 -0
  23. package/dist/runtime/src/escape/origin.js +20 -0
  24. package/dist/runtime/src/escape/rules.js +252 -0
  25. package/dist/runtime/src/fs/temporary.js +10 -0
  26. package/dist/runtime/src/install/harness.js +7 -4
  27. package/dist/runtime/src/logging/local-log.js +44 -1
  28. package/dist/runtime/src/office/scene.js +1 -1
  29. package/dist/runtime/src/office/server.js +43 -16
  30. package/dist/runtime/src/office/snapshot.js +1 -1
  31. package/dist/runtime/src/parallel/finish.js +139 -65
  32. package/dist/runtime/src/parallel/manage.js +51 -1
  33. package/dist/runtime/src/parallel/receipt.js +3 -1
  34. package/dist/runtime/src/parallel/service.js +5 -1
  35. package/dist/runtime/src/review/execute.js +20 -12
  36. package/dist/runtime/src/review/packet.js +20 -7
  37. package/dist/runtime/src/review/probe.js +3 -2
  38. package/dist/runtime/src/review/runner.js +26 -3
  39. package/dist/runtime/src/review/slots.js +1 -1
  40. package/dist/runtime/src/run/controls.js +99 -1
  41. package/dist/runtime/src/run/hosts/codex.js +28 -1
  42. package/dist/runtime/src/run/hosts/protocol.js +5 -2
  43. package/dist/runtime/src/run/integration.js +35 -13
  44. package/dist/runtime/src/run/policy.js +65 -10
  45. package/dist/runtime/src/run/scheduler.js +10 -5
  46. package/dist/runtime/src/run/service.js +68 -21
  47. package/dist/runtime/src/run/supervisor.js +11 -2
  48. package/dist/runtime/src/run/transport.js +1 -0
  49. package/dist/runtime/src/schema/validate.js +6 -1
  50. package/dist/runtime/src/security/permissions.js +85 -0
  51. package/dist/runtime/src/surface/discovery.js +79 -2
  52. package/dist/runtime/src/surface/schema.js +43 -2
  53. package/dist/runtime/src/task/completion.js +20 -0
  54. package/dist/runtime/src/task/contract.js +3 -1
  55. package/dist/runtime/src/task/debug-state.js +9 -3
  56. package/dist/runtime/src/task/debug-user-event.js +82 -17
  57. package/dist/runtime/src/task/debug.js +59 -16
  58. package/dist/runtime/src/task/service.js +58 -6
  59. package/dist/runtime/src/verify/acceptance-replay.js +24 -11
  60. package/docs/en/guides/configuration.md +24 -5
  61. package/docs/en/guides/task-debug.md +26 -2
  62. package/docs/en/spec/acceptance-and-evidence.md +104 -2
  63. package/docs/en/spec/harness-adapters.md +26 -1
  64. package/docs/en/spec/loop-engineering.md +34 -6
  65. package/docs/en/spec/review.md +6 -0
  66. package/docs/zh-TW/guides/configuration.md +20 -4
  67. package/docs/zh-TW/guides/task-debug.md +19 -2
  68. package/docs/zh-TW/spec/acceptance-and-evidence.md +84 -4
  69. package/docs/zh-TW/spec/harness-adapters.md +21 -1
  70. package/docs/zh-TW/spec/loop-engineering.md +9 -3
  71. package/docs/zh-TW/spec/review.md +5 -0
  72. package/package.json +1 -1
  73. package/schemas/task.schema.json +8 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ timeouts produce UNKNOWN. Review examines the checks themselves and every fallba
32
32
  On macOS, a trusted clean main checkout can start an isolated native goal run:
33
33
 
34
34
  ```sh
35
- agent-ops run "Implement the requested behavior" --host codex --time-budget 60m --jobs 2
35
+ agent-ops run "Implement the requested behavior" --host codex --jobs 2
36
36
  agent-ops run status <run-id>
37
37
  agent-ops run logs <run-id>
38
38
  agent-ops run respond <run-id> --question-id <id> --answer "Clarification"
@@ -49,7 +49,8 @@ verification, two fresh reviews, completed tasks and a receipt bound to the targ
49
49
  Setup runs in the background under registered process groups. Stop cancels native
50
50
  writers, local verification and setup; interrupted setup requires explicit resume.
51
51
  The coordinator can assess additional non-dangerous fixed commands through a
52
- policy request. This authorization is bound to the run, runtime, lease and budget;
52
+ policy request. New runs have no total time cap; manual resume upgrades legacy finite runs while keeping accounting.
53
+ This finite authorization is bound to the run, runtime and lease;
53
54
  it creates no permanent repository trust. Policy transitions fence writers and
54
55
  journal delivery imports and contract synchronization before starting new leases.
55
56
  Native authorization decisions retain redacted command scope and rationale;
@@ -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 }),
@@ -916,7 +960,7 @@ function parseRunArgs(argv) {
916
960
  positional = token;
917
961
  continue;
918
962
  }
919
- if (seen.has(token))
963
+ if (seen.has(token) && token !== "--retry-approval")
920
964
  duplicate(token);
921
965
  seen.add(token);
922
966
  if (token === "--json") {
@@ -927,10 +971,15 @@ function parseRunArgs(argv) {
927
971
  args.wait = true;
928
972
  continue;
929
973
  }
930
- if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file"].includes(token))
974
+ if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval"].includes(token))
931
975
  throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
932
976
  const value = readOptionValue(argv, i++, token);
933
977
  switch (token) {
978
+ case "--retry-approval":
979
+ if (!/^auth-[a-f0-9]{64}$/u.test(value) || args.retryApprovalIds?.includes(value))
980
+ invalidValue(token, value);
981
+ args.retryApprovalIds = [...(args.retryApprovalIds ?? []), value];
982
+ break;
934
983
  case "--host":
935
984
  if (value !== "claude" && value !== "codex")
936
985
  invalidValue(token, value);
@@ -954,17 +1003,12 @@ function parseRunArgs(argv) {
954
1003
  args.jobs = Number(value);
955
1004
  break;
956
1005
  case "--time-budget": {
957
- const match = value.match(/^([1-9][0-9]*)(s|m|h)$/u);
958
- if (match === null)
959
- invalidValue(token, value);
960
- const ms = Number(match[1]) * ({ s: 1000, m: 60000, h: 3600000 }[match[2]]);
961
- if (!Number.isSafeInteger(ms) || ms > 86400000)
962
- invalidValue(token, value);
963
- args.timeBudgetMs = ms;
964
- break;
1006
+ throw new CliArgumentError("RUN_TIME_BUDGET_UNSUPPORTED", "New runs have no total time cap; omit --time-budget. Command and review timeouts remain effective.", token);
965
1007
  }
966
1008
  }
967
1009
  }
1010
+ if (args.retryApprovalIds !== undefined && action !== "resume")
1011
+ throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-approval requires run resume.");
968
1012
  if (action === "start") {
969
1013
  args.goal = positional;
970
1014
  if ((args.goal === undefined) === (args.goalFile === undefined))
@@ -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
@@ -89,13 +98,22 @@ browser once; later sessions reuse it. It exits after 10 minutes without active
89
98
  runs or sessions. Recently completed rooms remain for at most 2 hours.
90
99
  The URL carries an access token; do not share it.
91
100
  `,
92
- run: `Usage: agent-ops run [start] "<goal>" --host <claude|codex> [--jobs <1|2>] [--time-budget <60m>] [--wait] [--json]
101
+ run: `Usage: agent-ops run [start] "<goal>" --host <claude|codex> [--jobs <1|2>] [--wait] [--json]
93
102
  agent-ops run [start] --goal-file <path> --host <claude|codex>
94
103
  agent-ops run <status|logs|resume|stop> <run-id> [--json]
104
+ agent-ops run resume <run-id> [--retry-approval <auth-event-id>]... [--json]
95
105
  agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
96
106
 
107
+ New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
108
+ Command/review timeouts, provider limits, DEBUG budgets and stop controls remain effective.
97
109
  Native goal state is observational. Completion requires current verification,
98
110
  two fresh reviews, task completion and a target-bound integration receipt.
111
+ --retry-approval explicitly retries an intact Codex approval-review usage-limit
112
+ failure. Original denials remain recorded; every command needs fresh approval.
113
+ Status includes phase, original cause, recorded sealed proof, cleanup state,
114
+ resumability and remediation. Resume revalidates authority and sealed artifacts;
115
+ receipt-bound recovery executes only missing cleanup steps. Resolve the saved
116
+ blocker before retrying: unchanged cleanup inputs are refused.
99
117
  `,
100
118
  init: `Usage: agent-ops init [options]
101
119
 
@@ -179,8 +197,8 @@ Options:
179
197
  --title <text> create
180
198
  --goal <text> create: immutable original issue/goal
181
199
  --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
200
+ --debug-action <status|hypothesis|strategy|investigate|checkpoint|resume|extend|origin>
201
+ --payload-file <path> debug hypothesis/strategy/origin: bounded JSON
184
202
  --command-id <id> debug investigate: trusted configured command
185
203
  --event-id <native-event-id|latest> debug extend: authentic human continue event
186
204
  --intent <text> create: intended behavior and constraints before editing
@@ -189,6 +207,11 @@ Options:
189
207
  --criteria-file <path> revise: replacement criterion array
190
208
  --surface-file <path> create (required unless --debug), revise: EVIDENCE-SURFACE-001
191
209
  audit JSON, or {"covers":[surface-id]} under a surfaced parent
210
+ --escape-of <commit> create, revise: the commit this bug escaped from (Escape Loop)
211
+ --escape-label <label> with --escape-of: requirement|surface|scenario|criteria|verify|review|flagged|out-of-model
212
+ --escape-receipt <id> with --escape-of: link this local finish receipt instead of resolving one
213
+ --escape-fix <commit> with --escape-of: backfill; missed paths come from this past fix commit
214
+ --escape-promote <runner:check> with --escape-of: the red-to-green check proving a criteria/verify escape
192
215
  --plan-file <path> replan: split-task array with requirement mappings
193
216
  --expected-contract <hash> revise, pin-finding: current task contract from status
194
217
  --expected-tree-contract <hash> replan: current tree contract from status
@@ -294,9 +317,10 @@ voiding each other's verification and review. Run it from the main checkout.
294
317
  before Git runs; see agent-ops worktree list.
295
318
  finish <name>
296
319
  Once its task is complete, fast-forward the main checkout's
297
- branch to the worktree's (after a clean rebase and
298
- re-verification if the branch moved), record the task in
299
- git notes, and remove the worktree. One finish runs at a time.
320
+ branch to the worktree's, record the task in git notes, and
321
+ remove the worktree. If the branch moved, it rebases the
322
+ worktree and stops: verify and review again with the printed
323
+ --base, then finish again. One finish runs at a time.
300
324
  commit Stage every change in the current worktree and commit it with
301
325
  --message. Refuses the main checkout and a worktree another
302
326
  session owns, and needs no Git in the shell.
@@ -90,7 +90,8 @@ export async function runAdvanceCommand(options) {
90
90
  throw new AgentOpsError("ADVANCE_WORKTREE_MISSING", `No coordinator worktree for session ${options.sessionId}.`);
91
91
  }
92
92
  const savedProof = await readRunIntegrationProof(commonDir, record);
93
- if (savedProof !== null && await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) === savedProof.head) {
93
+ if (savedProof !== null && (await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) === savedProof.head ||
94
+ (await deps.git(mainRoot, ["merge-base", "--is-ancestor", savedProof.head, record.targetBranch])).exitCode === 0)) {
94
95
  const finished = await finishWorktree(deps, { cwd: mainRoot, name, finalProof: { ...savedProof, recovery: true } });
95
96
  return okEnvelope("TASK_ADVANCED", { ...finished, text: "Recovered the sealed integration without moving the target again." });
96
97
  }
@@ -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
+ }
@@ -41,7 +41,7 @@ function addText(result) {
41
41
  function finishText(result) {
42
42
  const { record } = result;
43
43
  return [
44
- `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
44
+ `Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}.`,
45
45
  `Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
46
46
  `Removed ${record.path}.`,
47
47
  ...(result.receipt === undefined ? [] : [`Receipt: ${result.receipt}`]),
@@ -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)
@@ -769,12 +816,14 @@ export async function runReviewCommand(options) {
769
816
  contractArtifacts.push({ path: executionPath, digest: sha256(execution), content: execution });
770
817
  }
771
818
  }
819
+ // Compact: indentation alone pushed a 494 KiB 0.10.2 manifest past the 512 KiB bound.
772
820
  const content = redactSecrets(JSON.stringify({ binding: context.binding, coverage: context.coverage,
773
821
  executionPolicy: options.config,
822
+ ...(surfacePacket === undefined ? {} : { surface: surfacePacket }),
774
823
  artifacts: contractArtifacts.map(({ path, digest }) => ({ path, digest })),
775
824
  tasks: await Promise.all(context.records.map(async (r) => ({ task: r.task, revisions: r.revisions ?? [],
776
825
  ...(r.task.debug === undefined ? {} : { debugProof: await loadDebugProof(options.root, r.task.debug.rootTaskId) }) }))),
777
- superseded: context.historyRecords ?? [] }, null, 2));
826
+ superseded: context.historyRecords ?? [] }));
778
827
  const digest = sha256(content);
779
828
  const path = ".agent-ops/tasks/review-contracts/" + digest + ".json";
780
829
  contractManifest = { path, digest, content };
@@ -784,6 +833,7 @@ export async function runReviewCommand(options) {
784
833
  }
785
834
  packet = buildReviewPacket({
786
835
  ...(surfacePacket === undefined ? {} : { surface: surfacePacket }),
836
+ ...(escapes.length === 0 ? {} : { escape: escapes }),
787
837
  ...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
788
838
  request: context?.title ?? GENERIC_REQUEST,
789
839
  criteria,
@@ -922,6 +972,8 @@ export async function runReviewCommand(options) {
922
972
  if (finalResult.status === "PASS") {
923
973
  return okEnvelope("REVIEW_RESULT", data);
924
974
  }
975
+ if (finalResult.status === "FAIL")
976
+ await logCatch(options, "REVIEW_FAIL", context?.taskId);
925
977
  const code = finalResult.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
926
978
  return {
927
979
  code,
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { AgentOpsError, resolveContainedPath } from "../../../../runtime/src/fs/paths.js";
3
+ import { describeRunRecovery } from "../../../../runtime/src/run/service.js";
3
4
  import { errorEnvelope, okEnvelope } from "../output.js";
4
5
  function dynamic(args) {
5
6
  return args;
@@ -19,13 +20,18 @@ function runIdOf(args) {
19
20
  return runId;
20
21
  }
21
22
  function textFor(action, state, message) {
23
+ const recovery = describeRunRecovery(state);
22
24
  const active = state.workers.filter((worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)).length;
23
25
  return [
24
26
  message,
25
27
  `Status: ${state.status}`,
28
+ `Phase: ${recovery.phase}`,
29
+ `Cause: ${recovery.cause === null ? "none recorded" : recovery.cause.code + ": " + recovery.cause.detail}`,
30
+ `Sealed proof: ${recovery.sealedProof}; cleanup: ${recovery.cleanup}; resumable: ${recovery.resumable ? "yes, after reconciliation" : "no"}`,
31
+ `Remediation: ${recovery.remediation}`,
26
32
  `Host: ${state.host}`,
27
33
  `Workers: ${active}/${state.jobs}`,
28
- `Budget: ${Math.max(0, state.budget.limitMs - state.budget.accumulatedMs)}ms remaining`,
34
+ state.budget.limitMs === null ? `Budget: unlimited; ${state.budget.accumulatedMs}ms active time recorded` : `Budget: ${Math.max(0, state.budget.limitMs - state.budget.accumulatedMs)}ms remaining (legacy; manual resume upgrades)`,
29
35
  `Usage: ${state.budget.usage.tokens ?? "unknown"} tokens; ${state.budget.usage.usd === null ? "unknown" : "$" + state.budget.usage.usd} cost (${state.budget.usage.completeness})`,
30
36
  action === "logs" ? "" : "Native goal completion is not final proof; verify, review, task state, and receipt are still required."
31
37
  ].filter((line) => line.length > 0).join("\n");
@@ -35,12 +41,13 @@ function data(action, result) {
35
41
  action,
36
42
  runId: result.state.runId,
37
43
  state: result.state,
44
+ recovery: describeRunRecovery(result.state),
38
45
  message: result.message,
39
46
  text: textFor(action, result.state, result.message)
40
47
  };
41
48
  }
42
49
  function stateData(action, state, message) {
43
- return { action, runId: state.runId, state, message, text: textFor(action, state, message) };
50
+ return { action, runId: state.runId, state, recovery: describeRunRecovery(state), message, text: textFor(action, state, message) };
44
51
  }
45
52
  async function goalFromArgs(args, root) {
46
53
  if (args.goal !== undefined && args.goalFile !== undefined)
@@ -99,7 +106,7 @@ export async function runRunCommand(options) {
99
106
  return okEnvelope("RUN_LOGS", { ...stateData(action, state, `Read ${events.length} event(s) for run ${runId}.`), events });
100
107
  }
101
108
  if (action === "resume")
102
- return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId)));
109
+ return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds)));
103
110
  if (action === "stop")
104
111
  return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
105
112
  const questionId = options.args.questionId;