@kylecheng3146/agent-ops 0.11.1 → 0.11.3

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 (45) hide show
  1. package/README.md +9 -1
  2. package/dist/packages/cli/src/args.js +68 -2
  3. package/dist/packages/cli/src/bin.js +11 -1
  4. package/dist/packages/cli/src/cli.js +29 -0
  5. package/dist/packages/cli/src/commands/advance.js +3 -2
  6. package/dist/packages/cli/src/commands/followup.js +74 -0
  7. package/dist/packages/cli/src/commands/parallel.js +4 -0
  8. package/dist/packages/cli/src/commands/review.js +15 -1
  9. package/dist/packages/cli/src/commands/run.js +1 -1
  10. package/dist/packages/cli/src/commands/task.js +8 -2
  11. package/dist/packages/cli/src/context.js +13 -9
  12. package/dist/packages/cli/src/criteria-review.js +8 -3
  13. package/dist/packages/cli/src/followup-deps.js +56 -0
  14. package/dist/packages/cli/src/run-deps.js +16 -5
  15. package/dist/packages/cli/src/run-entry.js +159 -64
  16. package/dist/runtime/src/followup/intake.js +75 -0
  17. package/dist/runtime/src/followup/process.js +273 -0
  18. package/dist/runtime/src/followup/receipt.js +116 -0
  19. package/dist/runtime/src/followup/store.js +133 -0
  20. package/dist/runtime/src/install/harness.js +1 -1
  21. package/dist/runtime/src/install/preauth.js +11 -4
  22. package/dist/runtime/src/parallel/finish.js +10 -2
  23. package/dist/runtime/src/review/attestation.js +45 -13
  24. package/dist/runtime/src/review/criteria.js +22 -5
  25. package/dist/runtime/src/review/followup.js +233 -0
  26. package/dist/runtime/src/review/report.js +8 -4
  27. package/dist/runtime/src/review/runner.js +30 -2
  28. package/dist/runtime/src/run/service.js +59 -6
  29. package/dist/runtime/src/run/usage.js +5 -2
  30. package/dist/runtime/src/security/permissions.js +16 -1
  31. package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
  32. package/dist/runtime/src/verify/service.js +62 -2
  33. package/dist/runtime/src/verify/spawn.js +64 -2
  34. package/docs/en/guides/configuration.md +5 -0
  35. package/docs/en/guides/task-debug.md +36 -0
  36. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  37. package/docs/en/spec/loop-engineering.md +36 -0
  38. package/docs/en/spec/review.md +37 -3
  39. package/docs/zh-TW/guides/configuration.md +3 -0
  40. package/docs/zh-TW/guides/task-debug.md +31 -0
  41. package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
  42. package/docs/zh-TW/spec/loop-engineering.md +21 -0
  43. package/docs/zh-TW/spec/review.md +24 -4
  44. package/package.json +1 -1
  45. package/schemas/review-report.schema.json +2 -1
package/README.md CHANGED
@@ -17,7 +17,9 @@ command behavior may change before 1.0.
17
17
  Every new task, including DEBUG and native run parent/child tasks, waits for one
18
18
  independent advisory review of its goal, criteria and assigned surface before
19
19
  implementation. Read the suggestions, decide which to adopt, and record reasons
20
- through `task revise`. Revisions do not automatically re-review.
20
+ through `task revise`. The first revision that changes criteria, intent or
21
+ goal reruns the advisory once; surface-only revisions do not, and later
22
+ reruns are explicit (`review --criteria --rerun`).
21
23
 
22
24
  The advisory has no time/token budget; explicit cancellation stops work. Genuine
23
25
  execution errors report NOT_RUN and allow work. Detailed reports remain private;
@@ -521,6 +523,12 @@ as its first and only invocation, with `--output` instead of a shell redirect.
521
523
 
522
524
  See [Configuration](docs/en/guides/configuration.md) for the full contract.
523
525
 
526
+ Retained nonblocking review findings and residual risks are collected privately
527
+ across linked worktrees. Use `agent-ops followup list` / `show`, metadata-only
528
+ `import`, reasoned `accept` / `defer`, and explicit `process <followup-id> --host
529
+ codex` for current-source revalidation and bounded repair. Observations need
530
+ `clarify`; resolution needs finding-specific proof. See [followup guidance](docs/en/guides/task-debug.md#retained-review-followups).
531
+
524
532
  ## Project principles
525
533
 
526
534
  - Define verifiable success before making changes.
@@ -14,7 +14,8 @@ export const COMMAND_NAMES = [
14
14
  "worktree",
15
15
  "run",
16
16
  "office",
17
- "escapes"
17
+ "escapes",
18
+ "followup"
18
19
  ];
19
20
  const COMMAND_SET = new Set(COMMAND_NAMES);
20
21
  const SCOPES = new Set(["project", "user"]);
@@ -64,6 +65,8 @@ function parseHookTarget(option, value) {
64
65
  return { harness: harness, surfaceId };
65
66
  }
66
67
  export function parseArgs(argv) {
68
+ if (argv[0] === "followup")
69
+ return parseFollowupArgs(argv.slice(1));
67
70
  if (argv[0] === "run")
68
71
  return parseRunArgs(argv.slice(1));
69
72
  if (argv[0] === "task" && argv[1] === "debug")
@@ -945,10 +948,15 @@ function parseRunArgs(argv) {
945
948
  args.wait = true;
946
949
  continue;
947
950
  }
948
- if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval"].includes(token))
951
+ if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery"].includes(token))
949
952
  throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
950
953
  const value = readOptionValue(argv, i++, token);
951
954
  switch (token) {
955
+ case "--retry-delivery":
956
+ if (!/^task-[a-z0-9-]{1,128}$/u.test(value))
957
+ invalidValue(token, value);
958
+ args.retryDeliveryTaskId = value;
959
+ break;
952
960
  case "--retry-approval":
953
961
  if (!/^auth-[a-f0-9]{64}$/u.test(value) || args.retryApprovalIds?.includes(value))
954
962
  invalidValue(token, value);
@@ -983,6 +991,8 @@ function parseRunArgs(argv) {
983
991
  }
984
992
  if (args.retryApprovalIds !== undefined && action !== "resume")
985
993
  throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-approval requires run resume.");
994
+ if (args.retryDeliveryTaskId !== undefined && (action !== "resume" || args.retryApprovalIds !== undefined))
995
+ throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-delivery requires run resume without --retry-approval.");
986
996
  if (action === "start") {
987
997
  args.goal = positional;
988
998
  if ((args.goal === undefined) === (args.goalFile === undefined))
@@ -1007,3 +1017,59 @@ function parseRunArgs(argv) {
1007
1017
  }
1008
1018
  return args;
1009
1019
  }
1020
+ function parseFollowupArgs(argv) {
1021
+ const args = { command: "followup", profiles: [], dryRun: false, json: false, yes: false, rerun: false };
1022
+ if (argv.includes("--help") || argv.includes("-h"))
1023
+ return { ...args, command: "help", helpTopic: "followup", json: argv.includes("--json") };
1024
+ const actions = ["list", "show", "import", "sync", "clarify", "accept", "defer", "reopen", "process"];
1025
+ const action = argv[0] === undefined || argv[0].startsWith("-") ? "list" : argv[0];
1026
+ if (!actions.includes(action))
1027
+ invalidValue("followup action", action);
1028
+ args.action = action;
1029
+ const seen = new Set();
1030
+ for (let i = argv[0] === action ? 1 : 0; i < argv.length; i++) {
1031
+ const token = argv[i];
1032
+ if (!token.startsWith("-")) {
1033
+ if (args.followupId !== undefined)
1034
+ throw new CliArgumentError("CLI_UNEXPECTED_ARGUMENT", "Select exactly one followup identity.");
1035
+ if (!/^followup-[a-f0-9]{64}$/u.test(token))
1036
+ invalidValue("followup identity", token);
1037
+ args.followupId = token;
1038
+ continue;
1039
+ }
1040
+ if (seen.has(token))
1041
+ duplicate(token);
1042
+ seen.add(token);
1043
+ if (token === "--json") {
1044
+ args.json = true;
1045
+ continue;
1046
+ }
1047
+ if (!["--reason", "--finding-file", "--host", "--review-only-reason"].includes(token))
1048
+ throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown followup option: ${token}`, token);
1049
+ const value = readOptionValue(argv, i++, token);
1050
+ if (value.trim() === "" || value.includes("\0"))
1051
+ invalidValue(token, value);
1052
+ if (token === "--reason")
1053
+ args.reason = value;
1054
+ if (token === "--finding-file")
1055
+ args.findingFile = value;
1056
+ if (token === "--review-only-reason")
1057
+ args.reviewOnlyReason = value;
1058
+ if (token === "--host") {
1059
+ if (value !== "claude" && value !== "codex")
1060
+ invalidValue(token, value);
1061
+ args.host = value;
1062
+ }
1063
+ }
1064
+ const selectsItem = !["list", "import", "sync"].includes(action);
1065
+ if (selectsItem !== (args.followupId !== undefined))
1066
+ throw new CliArgumentError("CLI_MISSING_VALUE", "This action requires exactly one followup identity; list/import/sync take none.");
1067
+ if ((args.reason !== undefined && !["accept", "defer", "reopen", "clarify"].includes(action)) ||
1068
+ (args.findingFile !== undefined && action !== "clarify") ||
1069
+ ((args.host !== undefined || args.reviewOnlyReason !== undefined) && action !== "process"))
1070
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Option does not apply to this followup action.");
1071
+ if ((["accept", "defer", "clarify"].includes(action) && args.reason === undefined) ||
1072
+ (action === "clarify" && args.findingFile === undefined) || (action === "process" && args.host === undefined))
1073
+ throw new CliArgumentError("CLI_MISSING_VALUE", "Accept/defer/clarify need --reason; clarify needs --finding-file; process needs --host.");
1074
+ return args;
1075
+ }
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { reviewCreatedCriteria, criteriaReviewStatus } from "./criteria-review.js";
2
+ import { reviewCreatedCriteria, reviewRevisedCriteria, criteriaReviewStatus } from "./criteria-review.js";
3
3
  import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
4
4
  import { advancePhaseObserver, productionRunContext } from "./run-deps.js";
5
5
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
6
6
  import { recordRunVerification } from "../../../runtime/src/run/verification.js";
7
7
  import { runOwnedLocalProof } from "./owned-run-step.js";
8
8
  import { runRunCommand } from "./commands/run.js";
9
+ import { runFollowupCommand } from "./commands/followup.js";
10
+ import { reconcileRepositoryFollowups } from "./followup-deps.js";
9
11
  import { runOfficeCommand } from "./commands/office.js";
10
12
  import { ensureBackgroundOffice, observeOfficeSession, officePhaseReporter, openOfficeBrowser, serveOffice } from "./office-entry.js";
11
13
  import { readFile, readdir, stat } from "node:fs/promises";
@@ -259,6 +261,11 @@ else {
259
261
  const root = args.scope === "user"
260
262
  ? process.env.AGENT_OPS_HOME ?? homedir()
261
263
  : process.cwd();
264
+ if (args.scope !== "user" && !["init", "update", "uninstall", "followup", "escapes", "office"].includes(args.command)) {
265
+ const collected = await reconcileRepositoryFollowups(root);
266
+ for (const warning of collected.warnings)
267
+ process.stderr.write(`Warning: ${warning}\n`);
268
+ }
262
269
  // Committed work is judged as a range by default; see defaultBase.
263
270
  if ((args.command === "verify" || (args.command === "review" && args.action !== "show" && !args.criteriaReview)) && args.base === undefined && args.scope !== "user") {
264
271
  const taskId = args.taskId;
@@ -279,6 +286,8 @@ else {
279
286
  const context = await productionRunContext(root);
280
287
  return await runRunCommand({ args, ...context, root: context.mainRoot, office: async () => await ensureBackgroundOffice(root, undefined, openOfficeBrowser) });
281
288
  }
289
+ if (args.command === "followup")
290
+ return await runFollowupCommand({ args, cwd: root });
282
291
  if (args.command === "escapes")
283
292
  return await runEscapesCommand({ root });
284
293
  if (args.command === "office") {
@@ -511,6 +520,7 @@ else {
511
520
  service: taskService,
512
521
  onTaskCreated: async (record, createdRoot) => await reviewCreatedCriteria(createdRoot ?? root, record.task),
513
522
  criteriaStatus: async (record) => await criteriaReviewStatus(root, record.task),
523
+ onTaskRevised: async (record) => await reviewRevisedCriteria(root, record.task),
514
524
  ...(policyConfigHash === undefined ? {} : { policyConfigHash }),
515
525
  ...(sessionId === undefined ? {} : { sessionId }),
516
526
  ...(fromMain
@@ -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
+ followup Collect and process retained review findings and risk observations
34
35
  escapes Report escaped bugs, gate catches and learned rules (read-only)
35
36
  batch Verify and review a parent task's subtasks together
36
37
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
@@ -77,6 +78,30 @@ Options:
77
78
  * rejected.
78
79
  */
79
80
  export const COMMAND_HELP_TEXT = {
81
+ followup: `Usage: agent-ops followup [list] [--json]
82
+ agent-ops followup <show|reopen> <followup-id> [--json]
83
+ agent-ops followup <import|sync> [--json]
84
+ agent-ops followup <accept|defer> <followup-id> --reason <text> [--json]
85
+ agent-ops followup clarify <followup-id> --finding-file <json> --reason <text> [--json]
86
+ agent-ops followup process <followup-id> --host <claude|codex> [--review-only-reason <text>] [--json]
87
+
88
+ Receipt collection and history import are metadata-only: no model calls or repairs.
89
+ State is private under the Git common directory, shared by linked worktrees.
90
+ Text observations require a concrete nonblocking ReviewFinding JSON clarification
91
+ (severity, blocking=false, title, details, locations, evidence, recommendation,
92
+ criterionIds). Accept/defer require a reason; neither resolves the finding.
93
+ Reopen retains history. Process again to revalidate a selected candidate.
94
+ Process authorizes independent current-source assessment and a new bounded native
95
+ repair only when conclusively PRESENT. UNKNOWN stays unresolved. Native start
96
+ refusals retain the item and exact reason; macOS, clean main, checked-out target,
97
+ trust, run policy and native controls still apply. Run through the trusted outer
98
+ host as: env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops followup process ...
99
+ ABSENT closure requires an explicit justified review-only contract and two fresh
100
+ reviews. Reproducible bugs require corresponding behavioral red-to-green proof.
101
+ Closure records the exact commit and finding-specific evidence; generic PASS,
102
+ changed paths, completed run status, acceptance and deferral cannot resolve.
103
+ Collection warnings are advisory and retryable with followup import.
104
+ `,
80
105
  escapes: `Usage: agent-ops escapes [--json]
81
106
 
82
107
  Read-only Escape Loop report: misses per gate from the tracked
@@ -103,6 +128,7 @@ The URL carries an access token; do not share it.
103
128
  agent-ops run [start] --goal-file <path> --host <claude|codex>
104
129
  agent-ops run <status|logs|resume|stop> <run-id> [--json]
105
130
  agent-ops run resume <run-id> [--retry-approval <auth-event-id>]... [--json]
131
+ agent-ops run resume <run-id> --retry-delivery <task-id> [--json]
106
132
  agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
107
133
 
108
134
  New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
@@ -111,6 +137,9 @@ Native goal state is observational. Completion requires current verification,
111
137
  two fresh reviews, task completion and a target-bound integration receipt.
112
138
  --retry-approval explicitly retries an intact Codex approval-review usage-limit
113
139
  failure. Original denials remain recorded; every command needs fresh approval.
140
+ --retry-delivery requeues one stopped child blocked by interrupted handoff proof.
141
+ Inspect and repair the harness cause first. History is retained; fresh native
142
+ generation, verification and delivery are required. It grants no command approval.
114
143
  Status includes phase, original cause, recorded sealed proof, cleanup state,
115
144
  resumability and remediation. Resume revalidates authority and sealed artifacts;
116
145
  receipt-bound recovery executes only missing cleanup steps. Resolve the saved
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
4
4
  import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
5
+ import { followupFinishText } from "./parallel.js";
5
6
  import { integrateSessionChildren } from "../../../../runtime/src/parallel/integrate.js";
6
7
  import { readWorktreeRecord, resolveCheckouts, sessionWorktreeName, worktreePath } from "../../../../runtime/src/parallel/service.js";
7
8
  import { resolveReviewScope } from "../../../../runtime/src/review/scope.js";
@@ -93,7 +94,7 @@ export async function runAdvanceCommand(options) {
93
94
  if (savedProof !== null && (await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) === savedProof.head ||
94
95
  (await deps.git(mainRoot, ["merge-base", "--is-ancestor", savedProof.head, record.targetBranch])).exitCode === 0)) {
95
96
  const finished = await finishWorktree(deps, { cwd: mainRoot, name, finalProof: { ...savedProof, recovery: true } });
96
- return okEnvelope("TASK_ADVANCED", { ...finished, text: "Recovered the sealed integration without moving the target again." });
97
+ return okEnvelope("TASK_ADVANCED", { ...finished, text: `Recovered the sealed integration without moving the target again.\n${followupFinishText(finished)}${finished.warnings.join("\n")}` });
97
98
  }
98
99
  if (savedProof !== null && await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) !== savedProof.target)
99
100
  await abandonPreparedRunIntegration(commonDir, record, deps.git);
@@ -176,7 +177,7 @@ export async function runAdvanceCommand(options) {
176
177
  reviewMode,
177
178
  sourceFingerprint,
178
179
  ...finished,
179
- text: `Final candidate ${head} passed all task verifiers and ${reviewMode} review, then finished into ${record.targetBranch}. Receipt: ${finished.receipt}.\n${highlights}Full reports: agent-ops review show --task ${parent.task.id}`
180
+ text: `Final candidate ${head} passed all task verifiers and ${reviewMode} review, then finished into ${record.targetBranch}. Receipt: ${finished.receipt}.\n${followupFinishText(finished)}${finished.warnings.map(w => `Warning: ${w}\n`).join("")}${highlights}Full reports: agent-ops review show --task ${parent.task.id}`
180
181
  });
181
182
  }
182
183
  catch (failure) {
@@ -0,0 +1,74 @@
1
+ import { readFile, lstat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
4
+ import { reconcileFollowups } from "../../../../runtime/src/followup/intake.js";
5
+ import { readFollowupProgress } from "../../../../runtime/src/followup/process.js";
6
+ import { FileFollowupStore, clarifyFollowup, setFollowupDisposition } from "../../../../runtime/src/followup/store.js";
7
+ import { readFollowupAssessment } from "../../../../runtime/src/review/followup.js";
8
+ import { redactSecrets } from "../../../../runtime/src/security/redact.js";
9
+ import { safeTaskText } from "../../../../runtime/src/task/render.js";
10
+ import { productionFollowupContext, followupRepositoryContext } from "../followup-deps.js";
11
+ import { errorEnvelope, okEnvelope } from "../output.js";
12
+ /** Redact strings individually so multiline credentials cannot hide behind JSON escapes. */
13
+ function display(value) {
14
+ if (typeof value === "string")
15
+ return safeTaskText(redactSecrets(value));
16
+ if (Array.isArray(value))
17
+ return value.map(display);
18
+ if (value !== null && typeof value === "object")
19
+ return Object.fromEntries(Object.entries(value).map(([key, field]) => [key, display(field)]));
20
+ return value;
21
+ }
22
+ export async function runFollowupCommand(options) {
23
+ const { args, cwd } = options;
24
+ const source = await followupRepositoryContext(cwd);
25
+ const collection = await reconcileFollowups(source);
26
+ const store = new FileFollowupStore(source.commonDir);
27
+ const action = args.action ?? "list";
28
+ const warnings = collection.warnings.map(w => `Warning: ${w}`);
29
+ if (action === "import" || action === "sync")
30
+ return okEnvelope("FOLLOWUP_IMPORTED", { collection,
31
+ text: [`Followup: collected ${collection.candidates} candidate(s), ${collection.observations} observation(s).`, ...warnings].join("\n") });
32
+ if (action === "list") {
33
+ const items = await Promise.all((await store.read()).items.map(async (item) => {
34
+ const progress = await readFollowupProgress(source.commonDir, item.id, item);
35
+ return { id: item.id, kind: item.kind, disposition: item.disposition, status: progress.status, code: progress.code,
36
+ title: typeof item.original === "string" ? item.original : item.original.title };
37
+ }));
38
+ const safe = display(items);
39
+ return okEnvelope("FOLLOWUP_LIST", { items: safe, collection,
40
+ text: [safe.length === 0 ? "No followup items." : safe.map(i => `${i.id} ${i.kind} ${i.disposition} ${i.status} (${i.code})\n ${i.title}`).join("\n"), ...warnings].join("\n") });
41
+ }
42
+ const id = args.followupId;
43
+ if (action === "clarify") {
44
+ const path = resolve(cwd, args.findingFile);
45
+ if (!(await lstat(path)).isFile() || (await lstat(path)).size > 128 * 1024)
46
+ throw new AgentOpsError("FOLLOWUP_CLARIFICATION_INVALID", "Finding JSON must be a regular file no larger than 128 KiB.");
47
+ let finding;
48
+ try {
49
+ finding = JSON.parse(await readFile(path, "utf8"));
50
+ }
51
+ catch {
52
+ throw new AgentOpsError("FOLLOWUP_CLARIFICATION_INVALID", "Finding file must contain concrete ReviewFinding JSON.");
53
+ }
54
+ await clarifyFollowup(store, id, finding, args.reason);
55
+ }
56
+ else if (action === "accept" || action === "defer" || action === "reopen") {
57
+ await setFollowupDisposition(store, id, action, args.reason);
58
+ }
59
+ else if (action === "process") {
60
+ const progress = await (await productionFollowupContext(cwd)).process(id, args.host, args.reviewOnlyReason);
61
+ // A persisted refusal remains an error to scripts, with the durable state available to show.
62
+ if (progress.attempts.at(-1)?.code === progress.code)
63
+ return { ...errorEnvelope(progress.code, `Repair start refused: ${progress.code}. Item retained; inspect with followup show ${id}.`),
64
+ data: display({ progress, collection }) };
65
+ }
66
+ const item = await store.get(id), progress = await readFollowupProgress(source.commonDir, id, item);
67
+ const assessment = progress.assessment === undefined ? undefined : await readFollowupAssessment(source.commonDir, progress.assessment);
68
+ const safe = display({ item, progress, ...(assessment === undefined ? {} : {
69
+ assessment: { conclusion: assessment.conclusion, reason: assessment.reason, binding: assessment.binding }
70
+ }) });
71
+ return okEnvelope("FOLLOWUP_DETAIL", { ...safe, collection,
72
+ text: [`${id}: ${safe.item.kind}, ${safe.item.disposition}, ${progress.status} (${progress.code})`,
73
+ JSON.stringify(safe, null, 2), ...warnings].join("\n") });
74
+ }
@@ -38,6 +38,9 @@ function addText(result) {
38
38
  "(Claude Code: ExitWorktree with action keep before finishing; do not run task complete in the worktree)."
39
39
  ].join("\n");
40
40
  }
41
+ export function followupFinishText(result) {
42
+ return result.followup === undefined ? "" : `Followup: collected ${result.followup.candidates} candidate(s), ${result.followup.observations} observation(s). Inspect with agent-ops followup list.\n`;
43
+ }
41
44
  function finishText(result) {
42
45
  const { record } = result;
43
46
  return [
@@ -45,6 +48,7 @@ function finishText(result) {
45
48
  `Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
46
49
  `Removed ${record.path}.`,
47
50
  ...(result.receipt === undefined ? [] : [`Receipt: ${result.receipt}`]),
51
+ ...followupFinishText(result).trimEnd().split("\n").filter(Boolean),
48
52
  ...(result.residualRisks === undefined || result.residualRisks.length === 0 ? [] :
49
53
  ["Residual risks from review:", ...result.residualRisks.map((risk) => `- ${safeTaskText(redactSecrets(risk))}`)]),
50
54
  ...result.warnings.map((warning) => `Warning: ${warning}`)
@@ -2,7 +2,7 @@ import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { renderReviewResult } from "../../../../runtime/src/review/render.js";
4
4
  import { reviewReportResults } from "../../../../runtime/src/review/report.js";
5
- import { findPriorFailingFindings, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
5
+ import { countFailedReviewRounds, findPriorFailingFindings, REVIEW_ROUND_CAP, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
6
6
  import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
7
7
  import { evidenceMatchesContract, taskContractHash, treeContractHash, goalHash } from "../../../../runtime/src/task/contract.js";
8
8
  import { acceptanceCoverage, coverageDigest } from "../../../../runtime/src/verify/acceptance-coverage.js";
@@ -649,6 +649,20 @@ export async function runReviewCommand(options) {
649
649
  ? await treeContext(options)
650
650
  : await taskContext(options);
651
651
  options = { ...options, noChangePaths: options.noChangePaths ?? context?.records.find(r => r.task.id === context.taskId)?.noChangePaths };
652
+ // Before any scope, surface or reviewer cost: a contract that keeps failing
653
+ // needs a new approach, not another round of one-by-one fixes.
654
+ if (context !== undefined && options.root !== undefined) {
655
+ const own = context.records.find(r => r.task.id === context.taskId).task;
656
+ if (await countFailedReviewRounds(options.root, context.taskId, taskContractHash(own)) >= REVIEW_ROUND_CAP) {
657
+ const result = { ...resultBase, status: "NOT_RUN", reason: "round-cap", taskId: context.taskId };
658
+ const message = "Independent review was not run.";
659
+ return { code: "REVIEW_NOT_RUN", status: "error", errors: [{ code: "REVIEW_NOT_RUN", message }], data: { message, result,
660
+ text: renderReviewResult(result) +
661
+ `Review failed ${REVIEW_ROUND_CAP} times under this contract. Stop fixing findings one by one: reconsider ` +
662
+ "the approach, then record the new plan (criteria or intent) with a reason, or ask the user how to proceed:\n" +
663
+ ` agent-ops task revise --task ${context.taskId} --expected-contract ${taskContractHash(own)} --reason "<what changed in the approach>"\n` } };
664
+ }
665
+ }
652
666
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
653
667
  const separator = value.indexOf("=");
654
668
  return {
@@ -108,7 +108,7 @@ export async function runRunCommand(options) {
108
108
  return okEnvelope("RUN_LOGS", { ...stateData(action, state, `Read ${events.length} event(s) for run ${runId}.`), events });
109
109
  }
110
110
  if (action === "resume")
111
- return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds)));
111
+ return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds, true, options.args.retryDeliveryTaskId)));
112
112
  if (action === "stop")
113
113
  return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
114
114
  const questionId = options.args.questionId;
@@ -6,7 +6,12 @@ import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/r
6
6
  import { errorEnvelope, okEnvelope } from "../output.js";
7
7
  async function withCriteriaStatus(envelope, options) {
8
8
  const item = envelope.data?.record;
9
- const advice = item === undefined ? null : await options.criteriaStatus?.(item).catch(() => null);
9
+ // A status read is best effort; a cancelled advisory round must still stop the caller.
10
+ const advice = item === undefined ? null : await options.criteriaStatus?.(item).catch((error) => {
11
+ if (error instanceof AgentOpsError && error.code === "CRITERIA_REVIEW_CANCELLED")
12
+ throw error;
13
+ return null;
14
+ });
10
15
  return advice == null || envelope.data === null ? envelope : { ...envelope, data: { ...envelope.data, criteriaReview: advice,
11
16
  text: envelope.data.text + "\n\n" + renderCriteriaReview(advice) } };
12
17
  }
@@ -202,7 +207,8 @@ export async function runTaskCommand(options) {
202
207
  ...(origin === undefined ? {} : { origin }),
203
208
  reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
204
209
  });
205
- return await withCriteriaStatus(await withEscape(taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain a fresh review, and new verification if the criteria or configuration changed.", record), origin === undefined ? undefined : await options.service.declareEscape(record.task.id), options.service), options);
210
+ // The revision is saved first: an advisory NOT_RUN keeps TASK_REVISED, and a cancelled round throws with it kept.
211
+ return await withCriteriaStatus(await withEscape(taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain a fresh review, and new verification if the criteria or configuration changed.", record), origin === undefined ? undefined : await options.service.declareEscape(record.task.id), options.service), options.onTaskRevised === undefined ? options : { ...options, criteriaStatus: options.onTaskRevised });
206
212
  }
207
213
  if (action === "replan") {
208
214
  const payload = await jsonFile(options.args.planFile);
@@ -236,22 +236,26 @@ export async function runPolicyContext(root, renew = false) {
236
236
  return null;
237
237
  const here = realpathSync(root);
238
238
  const worker = saved.workers.find(w => w.worktree === here);
239
- const proofProcess = worker?.proofProcess ?? saved.proofProcess;
240
- let proof = false;
241
- if (proofProcess != null && String(proofProcess.processId) === process.env.AGENT_OPS_RUN_PROOF_PID) {
239
+ const matchesProof = (registered) => {
240
+ if (registered == null || String(registered.processId) !== process.env.AGENT_OPS_RUN_PROOF_PID)
241
+ return false;
242
242
  try {
243
- proof = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === String(proofProcess.processId) &&
244
- execFileSync("ps", ["-o", "lstart=", "-p", String(proofProcess.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === proofProcess.processIdentity;
243
+ return execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === String(registered.processId) &&
244
+ execFileSync("ps", ["-o", "lstart=", "-p", String(registered.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim() === registered.processIdentity;
245
245
  }
246
246
  catch {
247
- proof = false;
247
+ return false;
248
248
  }
249
- }
249
+ };
250
+ const globalProof = matchesProof(saved.proofProcess);
251
+ const proof = globalProof || matchesProof(worker?.proofProcess);
252
+ // Reading another registered checkout does not change a run-level proof's coordinator actor.
253
+ const actor = globalProof || worker === undefined ? saved.workers.find(w => w.workerId === saved.coordinatorId) : worker;
250
254
  if ((!proof && (worker === undefined || record?.runId !== runId || record.workerId !== worker.workerId ||
251
255
  record.ownerSessionId !== worker.ownerSessionId || record.coordinatorId !== saved.coordinatorId || record.workerGeneration !== worker.generation)) ||
252
256
  (worker === undefined && !(proof && saved.root === here)) ||
253
- (process.env.AGENT_OPS_WORKER_ID !== undefined && (process.env.AGENT_OPS_WORKER_ID !== (worker?.workerId ?? saved.coordinatorId) ||
254
- process.env.AGENT_OPS_WORKER_GENERATION !== String((worker ?? saved.workers.find(w => w.workerId === saved.coordinatorId))?.generation))))
257
+ ((globalProof || process.env.AGENT_OPS_WORKER_ID !== undefined) && (actor === undefined ||
258
+ process.env.AGENT_OPS_WORKER_ID !== actor.workerId || process.env.AGENT_OPS_WORKER_GENERATION !== String(actor.generation))))
255
259
  throw new AgentOpsError("RUN_WORKER_STALE", "Run policy requires the current registered worker generation or proof process.");
256
260
  if (saved.status !== "active" || saved.disableRestart || saved.awaitingResume)
257
261
  throw new AgentOpsError("RUN_POLICY_DISABLED", "Run execution policy is inactive; explicit resume is required.");
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
4
- import { runCriteriaReview, readCriteriaReview, renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
4
+ import { runCriteriaReview, readCriteriaReview, renderCriteriaReview, revisionReviewDue } from "../../../runtime/src/review/criteria.js";
5
5
  import { registeredRunProofRunner } from "../../../runtime/src/verify/spawn.js";
6
6
  import { appendLocalLog } from "../../../runtime/src/logging/local-log.js";
7
7
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
@@ -24,7 +24,7 @@ export async function criteriaReviewStatus(root, task) {
24
24
  return await readCriteriaReview(root, task, await parentScope(root, task).catch(() => undefined));
25
25
  }
26
26
  /** Only production creation/explicit review installs this hook; TaskService stays model-free. */
27
- export async function reviewCreatedCriteria(root, task, rerun = false) {
27
+ export async function reviewCreatedCriteria(root, task, rerun = false, trigger) {
28
28
  const controller = new AbortController();
29
29
  const interrupt = (signal) => { controller.abort(signal); };
30
30
  const int = () => interrupt("SIGINT"), term = () => interrupt("SIGTERM");
@@ -38,7 +38,7 @@ export async function reviewCreatedCriteria(root, task, rerun = false) {
38
38
  const parent = await parentScope(root, task).catch(() => undefined);
39
39
  process.stderr.write(`Criteria advisory for ${task.id}: waiting for one independent reviewer (no time budget; Ctrl-C cancels).\n`);
40
40
  let result = await runCriteriaReview({ root, task, parent, target, model: role?.model, effort: role?.effort,
41
- rerun, signal: controller.signal, runner: registeredRunProofRunner(),
41
+ rerun, ...(trigger === undefined ? {} : { trigger }), signal: controller.signal, runner: registeredRunProofRunner(),
42
42
  unavailableCode: config === undefined ? "CRITERIA_REVIEW_CONFIG_UNAVAILABLE" : target === undefined && role !== undefined ? "CRITERIA_REVIEW_NO_INDEPENDENT_TARGET" : "CRITERIA_REVIEW_UNCONFIGURED",
43
43
  log: async (report, path) => {
44
44
  const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
@@ -61,3 +61,8 @@ export async function reviewCreatedCriteria(root, task, rerun = false) {
61
61
  process.removeListener("SIGTERM", term);
62
62
  }
63
63
  }
64
+ /** A revise that changed what the advice judged reruns it once; otherwise it reports the current advisory. */
65
+ export async function reviewRevisedCriteria(root, task) {
66
+ const view = await criteriaReviewStatus(root, task);
67
+ return revisionReviewDue(view) ? await reviewCreatedCriteria(root, task, true, "revise") : view;
68
+ }
@@ -0,0 +1,56 @@
1
+ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
2
+ import { processFollowup, reconcileFollowupRepairs } from "../../../runtime/src/followup/process.js";
3
+ import { isFollowupAssessmentCurrent } from "../../../runtime/src/review/followup.js";
4
+ import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
5
+ import { worktreeDependencies, gitRunner } from "./parallel-deps.js";
6
+ import { productionRunContext } from "./run-deps.js";
7
+ import { resolveCheckouts } from "../../../runtime/src/parallel/service.js";
8
+ import { reconcileFollowups } from "../../../runtime/src/followup/intake.js";
9
+ export async function followupRepositoryContext(cwd) {
10
+ const deps = worktreeDependencies(), checkouts = await resolveCheckouts(deps, cwd);
11
+ return { git: deps.git, mainRoot: checkouts.mainRoot, commonDir: checkouts.commonDir,
12
+ config: await deps.loadConfig(checkouts.mainRoot), loadConfig: async () => await deps.loadConfig(checkouts.mainRoot) };
13
+ }
14
+ /** Ordinary CLI reconciliation is advisory and never launches a provider or repair. */
15
+ export async function reconcileRepositoryFollowups(cwd) {
16
+ try {
17
+ return await reconcileFollowups(await followupRepositoryContext(cwd));
18
+ }
19
+ catch (error) {
20
+ const code = error instanceof AgentOpsError && /^[A-Z0-9_]{1,80}$/u.test(error.code) ? error.code : "FOLLOWUP_COLLECTION_FAILED";
21
+ return { added: 0, candidates: 0, observations: 0,
22
+ warnings: code === "WORKTREE_NOT_GIT" ? [] : [`followup: ${code}; retry with followup import.`] };
23
+ }
24
+ }
25
+ /** Uses the ordinary production authority, including its exact native/platform/trust refusals. */
26
+ export async function productionFollowupContext(cwd) {
27
+ const deps = worktreeDependencies(), checkouts = await resolveCheckouts(deps, cwd);
28
+ const branch = await deps.git(checkouts.currentRoot, ["symbolic-ref", "--short", "HEAD"]);
29
+ if (branch.exitCode !== 0)
30
+ throw new AgentOpsError("RUN_TARGET_REQUIRED", "Followup processing requires a checked-out target branch.");
31
+ const loadConfig = async () => await deps.loadConfig(checkouts.currentRoot);
32
+ const assessmentOptions = async (itemId, reviewOnlyReason) => {
33
+ const config = await loadConfig(), role = resolveReviewRole("independent-review", config.reviewRoles ?? []);
34
+ return {
35
+ root: checkouts.currentRoot, commonDir: checkouts.commonDir, itemId, targetBranch: branch.stdout.trim(),
36
+ gitRunner: gitRunner(checkouts.currentRoot), loadConfig,
37
+ review: { targets: role?.targets ?? [], ...(role?.timeoutMs === undefined ? {} : { timeoutMs: role.timeoutMs }) },
38
+ ...(reviewOnlyReason === undefined ? {} : { reviewOnlyReason })
39
+ };
40
+ };
41
+ return { ...checkouts,
42
+ assessmentOptions,
43
+ process: async (itemId, host, reviewOnlyReason) => {
44
+ const options = await assessmentOptions(itemId, reviewOnlyReason);
45
+ return await processFollowup({ ...options, host, start: async (input, proof) => {
46
+ const run = await productionRunContext(cwd, { beforeStart: async () => {
47
+ if (!await isFollowupAssessmentCurrent(options, proof))
48
+ throw new AgentOpsError("FOLLOWUP_SOURCE_CHANGED", "The finding, target, source or config changed before repair startup.");
49
+ } });
50
+ return (await run.service.start({ ...input, root: run.mainRoot, commonDir: run.commonDir,
51
+ targetBranch: run.targetBranch, ownerSessionId: run.ownerSessionId })).state;
52
+ } });
53
+ },
54
+ reconcile: async () => await reconcileFollowupRepairs({ git: deps.git, mainRoot: checkouts.mainRoot,
55
+ commonDir: checkouts.commonDir, config: await loadConfig(), loadConfig }) };
56
+ }
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
7
7
  import { setTimeout as delay } from "node:timers/promises";
8
8
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
9
9
  import { sha256 } from "../../../runtime/src/fs/hash.js";
10
- import { resolveCheckouts, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
10
+ import { resolveCheckouts, readWorktreeRecord, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
11
11
  import { createLaunchdDescriptor, LaunchdController, readBootIdentity, readGuiLoginIdentity } from "../../../runtime/src/run/macOS.js";
12
- import { FileRunRepository, RunService } from "../../../runtime/src/run/service.js";
12
+ import { FileRunRepository, RunService, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
13
13
  import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
14
14
  import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
15
15
  import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
@@ -182,8 +182,10 @@ export async function productionRunContext(cwd, options = {}) {
182
182
  if (await deps.trust.status(checkouts.mainRoot, config) !== "TRUSTED")
183
183
  throw new AgentOpsError("RUN_REPO_UNTRUSTED", "The run requires current repository verifier trust.");
184
184
  const ownerSessionId = randomUUID();
185
+ const bootIdentity = await readBootIdentity(), loginDomain = await readGuiLoginIdentity();
186
+ await options.beforeStart?.();
185
187
  const result = await service.start({ ...input, root: checkouts.mainRoot, commonDir: checkouts.commonDir,
186
- targetBranch: target.stdout.trim(), ownerSessionId, bootIdentity: await readBootIdentity(), loginDomain: await readGuiLoginIdentity() });
188
+ targetBranch: target.stdout.trim(), ownerSessionId, bootIdentity, loginDomain });
187
189
  try {
188
190
  await bindRunPolicy(repository, result.state.runId, { config,
189
191
  baseTrustBinding: await repositoryTrustBinding(checkouts.mainRoot, config, CLI_VERSION), runtimeHash: await runRuntimeHash() });
@@ -209,8 +211,9 @@ export async function productionRunContext(cwd, options = {}) {
209
211
  throw cause;
210
212
  }
211
213
  };
212
- const resume = async (runId, retryApprovalIds = [], upgradeBudget = true) => {
214
+ const resume = async (runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId) => {
213
215
  const before = await service.status(runId);
216
+ const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(before, retryDeliveryTaskId);
214
217
  const runtimeHash = await runRuntimeHash();
215
218
  const policy = await readRunPolicy(before, before.policyBinding?.runtimeHash ?? runtimeHash, Date.now(), true);
216
219
  if (policy !== null && await deps.trust.status(checkouts.mainRoot, policy.baseConfig) !== "TRUSTED")
@@ -236,7 +239,15 @@ export async function productionRunContext(cwd, options = {}) {
236
239
  if (exists === null || (await collectChangeSurface(gitRunner(worker.worktree))).paths.length > 0)
237
240
  throw new AgentOpsError("RUN_RECOVERY_DIRTY", "Commit or preserve every writer checkout before explicit resume.");
238
241
  }
239
- const result = await service.resume(runId, retryApprovalIds, upgradeBudget);
242
+ if (retry !== null) {
243
+ const record = retry.worktree === null ? null : await readWorktreeRecord(retry.worktree);
244
+ const head = retry.worktree === null ? null : await deps.git(retry.worktree, ["rev-parse", "HEAD"]);
245
+ if (record === null || record.runId !== runId || record.workerId !== retry.workerId ||
246
+ record.ownerSessionId !== retry.ownerSessionId || record.workerGeneration !== retry.generation ||
247
+ head?.exitCode !== 0 || head.stdout.trim() !== retry.stopIntent.sourceCommit)
248
+ throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Interrupted checkout ownership or source changed; preserve it and reconcile before retrying.");
249
+ }
250
+ const result = await service.resume(runId, retryApprovalIds, upgradeBudget, retryDeliveryTaskId, retry?.lastFailure?.id);
240
251
  try {
241
252
  if (pending !== null)
242
253
  await renewPendingRunPolicy(repository, runId, pending);