@kylecheng3146/agent-ops 0.11.0 → 0.11.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 (45) hide show
  1. package/README.md +19 -0
  2. package/dist/packages/cli/src/args.js +78 -3
  3. package/dist/packages/cli/src/bin.js +19 -1
  4. package/dist/packages/cli/src/cli.js +33 -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/run.js +1 -1
  9. package/dist/packages/cli/src/commands/task.js +22 -5
  10. package/dist/packages/cli/src/context.js +13 -9
  11. package/dist/packages/cli/src/criteria-review.js +63 -0
  12. package/dist/packages/cli/src/followup-deps.js +56 -0
  13. package/dist/packages/cli/src/owned-run-step.js +1 -1
  14. package/dist/packages/cli/src/run-deps.js +16 -5
  15. package/dist/packages/cli/src/run-entry.js +283 -145
  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/logging/local-log.js +12 -0
  23. package/dist/runtime/src/parallel/finish.js +10 -2
  24. package/dist/runtime/src/review/attestation.js +10 -0
  25. package/dist/runtime/src/review/criteria.js +252 -0
  26. package/dist/runtime/src/review/followup.js +233 -0
  27. package/dist/runtime/src/review/invocation.js +3 -3
  28. package/dist/runtime/src/review/runner.js +20 -0
  29. package/dist/runtime/src/run/service.js +59 -6
  30. package/dist/runtime/src/run/usage.js +5 -2
  31. package/dist/runtime/src/security/permissions.js +16 -1
  32. package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
  33. package/dist/runtime/src/verify/service.js +62 -2
  34. package/dist/runtime/src/verify/spawn.js +67 -4
  35. package/docs/en/guides/configuration.md +5 -0
  36. package/docs/en/guides/task-debug.md +36 -0
  37. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  38. package/docs/en/spec/loop-engineering.md +36 -0
  39. package/docs/en/spec/review.md +16 -0
  40. package/docs/zh-TW/guides/configuration.md +3 -0
  41. package/docs/zh-TW/guides/task-debug.md +31 -0
  42. package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
  43. package/docs/zh-TW/spec/loop-engineering.md +21 -0
  44. package/docs/zh-TW/spec/review.md +17 -1
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,19 @@ pre-1.0 interface.
12
12
  The CLI is published as `@kylecheng3146/agent-ops` as a pre-1.0 interface;
13
13
  command behavior may change before 1.0.
14
14
 
15
+ ## Criteria advisory before implementation
16
+
17
+ Every new task, including DEBUG and native run parent/child tasks, waits for one
18
+ independent advisory review of its goal, criteria and assigned surface before
19
+ implementation. Read the suggestions, decide which to adopt, and record reasons
20
+ through `task revise`. Revisions do not automatically re-review.
21
+
22
+ The advisory has no time/token budget; explicit cancellation stops work. Genuine
23
+ execution errors report NOT_RUN and allow work. Detailed reports remain private;
24
+ `task status` shows their snapshot and whether they are historical. Explicitly
25
+ rerun with `agent-ops review --criteria --task <id> --rerun --yes`.
26
+ Final verification, two-round review and finish remain required.
27
+
15
28
  ## Executable acceptance and native goal runs (0.6.0)
16
29
 
17
30
  Criteria can use `behavioral`, `invariant` or `review-only` acceptance contracts.
@@ -508,6 +521,12 @@ as its first and only invocation, with `--output` instead of a shell redirect.
508
521
 
509
522
  See [Configuration](docs/en/guides/configuration.md) for the full contract.
510
523
 
524
+ Retained nonblocking review findings and residual risks are collected privately
525
+ across linked worktrees. Use `agent-ops followup list` / `show`, metadata-only
526
+ `import`, reasoned `accept` / `defer`, and explicit `process <followup-id> --host
527
+ codex` for current-source revalidation and bounded repair. Observations need
528
+ `clarify`; resolution needs finding-specific proof. See [followup guidance](docs/en/guides/task-debug.md#retained-review-followups).
529
+
511
530
  ## Project principles
512
531
 
513
532
  - 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")
@@ -113,6 +116,7 @@ export function parseArgs(argv) {
113
116
  let json = false;
114
117
  let yes = false;
115
118
  let rerun = false;
119
+ let criteriaReview = false;
116
120
  let tree = false;
117
121
  let helpSeen = false;
118
122
  let versionSeen = false;
@@ -461,6 +465,11 @@ export function parseArgs(argv) {
461
465
  index += 1;
462
466
  break;
463
467
  }
468
+ case "--criteria":
469
+ if (criteriaReview)
470
+ throw new CliArgumentError("CLI_DUPLICATE_OPTION", "--criteria may appear only once.");
471
+ criteriaReview = true;
472
+ break;
464
473
  case "--rerun":
465
474
  if (rerun) {
466
475
  duplicate(token);
@@ -687,6 +696,8 @@ export function parseArgs(argv) {
687
696
  if (worktree !== undefined && command !== "init" && command !== "update") {
688
697
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--worktree may be used only with init or update.");
689
698
  }
699
+ if (criteriaReview && (command !== "review" || action !== undefined || tree || base !== undefined || output !== undefined))
700
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--criteria requires review --task <id> --yes; omit show, tree, base and output.");
690
701
  const revisionAction = command === "task" && ["revise", "replan", "pin-finding"].includes(action ?? "");
691
702
  if ((taskGoal !== undefined || debug) && !(command === "task" && action === "create"))
692
703
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--goal and --debug apply only to task create.");
@@ -786,7 +797,7 @@ export function parseArgs(argv) {
786
797
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--intent may be used only with task create.");
787
798
  }
788
799
  if (command === "review" && action !== "show" && !yes) {
789
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --yes to authorize both reviewer sessions.");
800
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", criteriaReview ? "Criteria advisory requires --yes to authorize one reviewer session." : "Review requires --yes to authorize both reviewer sessions.");
790
801
  }
791
802
  if (command === "review" && harness !== undefined) {
792
803
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review uses the configured reviewer pair; omit --harness.");
@@ -872,6 +883,7 @@ export function parseArgs(argv) {
872
883
  ...(taskGoal === undefined ? {} : { goal: taskGoal }),
873
884
  ...(debug ? { debug: true } : {}),
874
885
  ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
886
+ ...(criteriaReview ? { criteriaReview: true } : {}),
875
887
  ...(completionGate === undefined ? {} : { completionGate }),
876
888
  ...(office === undefined ? {} : { office }),
877
889
  ...(autoRun === undefined ? {} : { autoRun }),
@@ -936,10 +948,15 @@ function parseRunArgs(argv) {
936
948
  args.wait = true;
937
949
  continue;
938
950
  }
939
- 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))
940
952
  throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
941
953
  const value = readOptionValue(argv, i++, token);
942
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;
943
960
  case "--retry-approval":
944
961
  if (!/^auth-[a-f0-9]{64}$/u.test(value) || args.retryApprovalIds?.includes(value))
945
962
  invalidValue(token, value);
@@ -974,6 +991,8 @@ function parseRunArgs(argv) {
974
991
  }
975
992
  if (args.retryApprovalIds !== undefined && action !== "resume")
976
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.");
977
996
  if (action === "start") {
978
997
  args.goal = positional;
979
998
  if ((args.goal === undefined) === (args.goalFile === undefined))
@@ -998,3 +1017,59 @@ function parseRunArgs(argv) {
998
1017
  }
999
1018
  return args;
1000
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,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { reviewCreatedCriteria, criteriaReviewStatus } from "./criteria-review.js";
3
+ import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
2
4
  import { advancePhaseObserver, productionRunContext } from "./run-deps.js";
3
5
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
4
6
  import { recordRunVerification } from "../../../runtime/src/run/verification.js";
5
7
  import { runOwnedLocalProof } from "./owned-run-step.js";
6
8
  import { runRunCommand } from "./commands/run.js";
9
+ import { runFollowupCommand } from "./commands/followup.js";
10
+ import { reconcileRepositoryFollowups } from "./followup-deps.js";
7
11
  import { runOfficeCommand } from "./commands/office.js";
8
12
  import { ensureBackgroundOffice, observeOfficeSession, officePhaseReporter, openOfficeBrowser, serveOffice } from "./office-entry.js";
9
13
  import { readFile, readdir, stat } from "node:fs/promises";
@@ -257,8 +261,13 @@ else {
257
261
  const root = args.scope === "user"
258
262
  ? process.env.AGENT_OPS_HOME ?? homedir()
259
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
+ }
260
269
  // Committed work is judged as a range by default; see defaultBase.
261
- if ((args.command === "verify" || (args.command === "review" && args.action !== "show")) && args.base === undefined && args.scope !== "user") {
270
+ if ((args.command === "verify" || (args.command === "review" && args.action !== "show" && !args.criteriaReview)) && args.base === undefined && args.scope !== "user") {
262
271
  const taskId = args.taskId;
263
272
  const base = await defaultBase(root, gitRunner(root), async () => taskId === undefined ? undefined :
264
273
  (await new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)).status({ taskId })).createdSourceCommit)
@@ -277,6 +286,8 @@ else {
277
286
  const context = await productionRunContext(root);
278
287
  return await runRunCommand({ args, ...context, root: context.mainRoot, office: async () => await ensureBackgroundOffice(root, undefined, openOfficeBrowser) });
279
288
  }
289
+ if (args.command === "followup")
290
+ return await runFollowupCommand({ args, cwd: root });
280
291
  if (args.command === "escapes")
281
292
  return await runEscapesCommand({ root });
282
293
  if (args.command === "office") {
@@ -507,6 +518,8 @@ else {
507
518
  return await runTaskCommand({
508
519
  args,
509
520
  service: taskService,
521
+ onTaskCreated: async (record, createdRoot) => await reviewCreatedCriteria(createdRoot ?? root, record.task),
522
+ criteriaStatus: async (record) => await criteriaReviewStatus(root, record.task),
510
523
  ...(policyConfigHash === undefined ? {} : { policyConfigHash }),
511
524
  ...(sessionId === undefined ? {} : { sessionId }),
512
525
  ...(fromMain
@@ -527,6 +540,11 @@ else {
527
540
  : {})
528
541
  });
529
542
  }
543
+ if (args.command === "review" && args.criteriaReview) {
544
+ const record = await taskService.status({ taskId: args.taskId });
545
+ const criteriaReview = await reviewCreatedCriteria(root, record.task, args.rerun);
546
+ return { status: "ok", code: "CRITERIA_REVIEW_ADVISORY", data: { criteriaReview, text: renderCriteriaReview(criteriaReview) }, errors: [] };
547
+ }
530
548
  const probeTarget = async (target, budget) => await probeReviewTargetDetailed(target, {
531
549
  cwd: root,
532
550
  deep: 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
+ 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)
@@ -59,6 +60,7 @@ Options:
59
60
  --width <n> Batch only: reviews allowed at once (default 2)
60
61
  --target-version <version> Update target version (offline-capable)
61
62
  --title <text>
63
+ --criteria Review only: one pre-edit advisory, no time budget
62
64
  --criterion <json> Repeatable
63
65
  --evidence <criterion-id=reference> Repeatable
64
66
  --session <id>
@@ -76,6 +78,30 @@ Options:
76
78
  * rejected.
77
79
  */
78
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
+ `,
79
105
  escapes: `Usage: agent-ops escapes [--json]
80
106
 
81
107
  Read-only Escape Loop report: misses per gate from the tracked
@@ -102,6 +128,7 @@ The URL carries an access token; do not share it.
102
128
  agent-ops run [start] --goal-file <path> --host <claude|codex>
103
129
  agent-ops run <status|logs|resume|stop> <run-id> [--json]
104
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]
105
132
  agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
106
133
 
107
134
  New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
@@ -110,6 +137,9 @@ Native goal state is observational. Completion requires current verification,
110
137
  two fresh reviews, task completion and a target-bound integration receipt.
111
138
  --retry-approval explicitly retries an intact Codex approval-review usage-limit
112
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.
113
143
  Status includes phase, original cause, recorded sealed proof, cleanup state,
114
144
  resumability and remediation. Resume revalidates authority and sealed artifacts;
115
145
  receipt-bound recovery executes only missing cleanup steps. Resolve the saved
@@ -252,11 +282,14 @@ Options:
252
282
  --json
253
283
  `,
254
284
  review: `Usage: agent-ops review --task <id> --yes [options]
285
+ agent-ops review --criteria --task <id> --yes [--rerun]
255
286
  agent-ops review show --task <id> [--json]
256
287
 
257
288
  Run the complete task-bound review: one necessary reviewer followed by one
258
289
  fresh adversarial reviewer from the configured pair. Show reads recorded
259
290
  reports without running a reviewer, including from the main checkout.
291
+ Criteria mode runs/reads one pre-edit advisory, with no time/token budget;
292
+ only --rerun starts another attempt. It never replaces final proof.
260
293
 
261
294
  Options:
262
295
  --task <id> Required task whose original criteria are reviewed
@@ -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}`)
@@ -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;
@@ -1,8 +1,15 @@
1
+ import { renderCriteriaReview } from "../../../../runtime/src/review/criteria.js";
1
2
  import { lstat, readFile } from "node:fs/promises";
2
3
  import { taskContractHash } from "../../../../runtime/src/task/contract.js";
3
4
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
4
5
  import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/render.js";
5
6
  import { errorEnvelope, okEnvelope } from "../output.js";
7
+ async function withCriteriaStatus(envelope, options) {
8
+ const item = envelope.data?.record;
9
+ const advice = item === undefined ? null : await options.criteriaStatus?.(item).catch(() => null);
10
+ return advice == null || envelope.data === null ? envelope : { ...envelope, data: { ...envelope.data, criteriaReview: advice,
11
+ text: envelope.data.text + "\n\n" + renderCriteriaReview(advice) } };
12
+ }
6
13
  function isRecord(value) {
7
14
  return typeof value === "object" && value !== null && !Array.isArray(value);
8
15
  }
@@ -154,11 +161,15 @@ export async function runTaskCommand(options) {
154
161
  requireSurface: true
155
162
  });
156
163
  const escape = origin === undefined ? undefined : await (worktree?.service ?? options.service).declareEscape(record.task.id);
157
- const created = await withEscape(taskEnvelope(action, "TASK_CREATED", sessionId === undefined
164
+ const initialAdvice = await options.onTaskCreated?.(record, worktree?.path);
165
+ let created = await withEscape(taskEnvelope(action, "TASK_CREATED", sessionId === undefined
158
166
  ? `Created task ${record.task.id}.`
159
167
  : record.task.parentTaskId === undefined
160
168
  ? `Created and attached task ${record.task.id}.`
161
169
  : `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record), escape, worktree?.service ?? options.service);
170
+ if (initialAdvice !== undefined && created.data !== null)
171
+ created = { ...created, data: { ...created.data, criteriaReview: initialAdvice,
172
+ text: created.data.text + "\n\n" + renderCriteriaReview(initialAdvice) } };
162
173
  if (worktree === undefined || created.data === null)
163
174
  return created;
164
175
  return {
@@ -191,7 +202,7 @@ export async function runTaskCommand(options) {
191
202
  ...(origin === undefined ? {} : { origin }),
192
203
  reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
193
204
  });
194
- return 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);
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);
195
206
  }
196
207
  if (action === "replan") {
197
208
  const payload = await jsonFile(options.args.planFile);
@@ -201,7 +212,13 @@ export async function runTaskCommand(options) {
201
212
  expectedTreeContractHash: options.args.expectedTreeContract, reason: options.args.reason,
202
213
  tasks: payload
203
214
  });
204
- return okEnvelope("TASK_REPLANNED", { action, message: "Task tree split atomically; reverify and review.", records, text: renderTaskList(records) });
215
+ const advice = [];
216
+ for (const record of records) {
217
+ const result = await options.onTaskCreated?.(record);
218
+ if (result !== undefined)
219
+ advice.push(renderCriteriaReview(result));
220
+ }
221
+ return okEnvelope("TASK_REPLANNED", { action, message: "Task tree split atomically; reverify and review.", records, text: [renderTaskList(records), ...advice].join("\n\n") });
205
222
  }
206
223
  if (action === "pin-finding") {
207
224
  const criterion = parseCriterion(JSON.stringify(await jsonFile(options.args.criterionFiles[0])));
@@ -234,9 +251,9 @@ export async function runTaskCommand(options) {
234
251
  const reviewOnly = coverage.rows.filter(row => row.mode === "review-only").length;
235
252
  const legacy = coverage.rows.filter(row => row.mode === "legacy").length;
236
253
  const envelope = taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
237
- return { ...envelope, data: { ...envelope.data, coverage, treeContractHash: await options.service.treeContract(record.task.id),
254
+ return await withCriteriaStatus({ ...envelope, data: { ...envelope.data, coverage, treeContractHash: await options.service.treeContract(record.task.id),
238
255
  text: envelope.data.text + "\nAcceptance: " + proven + "/" + mechanical + " mechanical proven; " + reviewOnly + " review-only; " + legacy + " legacy; originally mechanical " + coverage.originalMechanical +
239
- "; reviewed " + coverage.reviewed + "; undischarged " + coverage.rows.filter(row => row.status !== "proven" && !coverage.reviewed).length } };
256
+ "; reviewed " + coverage.reviewed + "; undischarged " + coverage.rows.filter(row => row.status !== "proven" && !coverage.reviewed).length } }, options);
240
257
  }
241
258
  if (action === "attach") {
242
259
  if (sessionId === undefined) {
@@ -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.");
@@ -0,0 +1,63 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
4
+ import { runCriteriaReview, readCriteriaReview, renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
5
+ import { registeredRunProofRunner } from "../../../runtime/src/verify/spawn.js";
6
+ import { appendLocalLog } from "../../../runtime/src/logging/local-log.js";
7
+ import { localStatePaths } from "../../../runtime/src/security/permissions.js";
8
+ import { TaskService } from "../../../runtime/src/task/service.js";
9
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
10
+ import { loadEffectiveConfig, runPolicyContext } from "./context.js";
11
+ async function parentScope(root, task) {
12
+ const read = async (path, id) => (await new TaskService(new FileTaskStore(join(path, ".agent-ops/tasks/state.json"), path)).status({ taskId: id })).task;
13
+ if (task.parentTaskId !== undefined)
14
+ return await read(root, task.parentTaskId);
15
+ if (task.surface !== undefined && "covers" in task.surface) {
16
+ const run = await runPolicyContext(root);
17
+ const coordinator = run?.state.workers.find(worker => worker.workerId === run.state.coordinatorId);
18
+ if (run?.state.rootTaskId != null && coordinator?.worktree != null)
19
+ return await read(coordinator.worktree, run.state.rootTaskId);
20
+ }
21
+ return undefined;
22
+ }
23
+ export async function criteriaReviewStatus(root, task) {
24
+ return await readCriteriaReview(root, task, await parentScope(root, task).catch(() => undefined));
25
+ }
26
+ /** Only production creation/explicit review installs this hook; TaskService stays model-free. */
27
+ export async function reviewCreatedCriteria(root, task, rerun = false) {
28
+ const controller = new AbortController();
29
+ const interrupt = (signal) => { controller.abort(signal); };
30
+ const int = () => interrupt("SIGINT"), term = () => interrupt("SIGTERM");
31
+ process.once("SIGINT", int);
32
+ process.once("SIGTERM", term);
33
+ try {
34
+ const config = await loadEffectiveConfig(root, "project").catch(() => undefined);
35
+ const role = config?.config.reviewRoles?.find(role => role.role === "independent-review");
36
+ const host = process.env.AGENT_OPS_HOST;
37
+ const target = role?.targets.find(target => target !== host) ?? role?.targets[0];
38
+ const parent = await parentScope(root, task).catch(() => undefined);
39
+ process.stderr.write(`Criteria advisory for ${task.id}: waiting for one independent reviewer (no time budget; Ctrl-C cancels).\n`);
40
+ let result = await runCriteriaReview({ root, task, parent, target, model: role?.model, effort: role?.effort,
41
+ rerun, signal: controller.signal, runner: registeredRunProofRunner(),
42
+ unavailableCode: config === undefined ? "CRITERIA_REVIEW_CONFIG_UNAVAILABLE" : target === undefined && role !== undefined ? "CRITERIA_REVIEW_NO_INDEPENDENT_TARGET" : "CRITERIA_REVIEW_UNCONFIGURED",
43
+ log: async (report, path) => {
44
+ const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
45
+ await appendLocalLog(join(state.logDirectory, "events.ndjson"), { type: "criteria-review", taskId: task.id,
46
+ attemptId: report.attemptId, status: report.status, code: report.code, durationMs: report.durationMs,
47
+ invocations: report.invocations, recommendations: report.advice?.recommendations.length ?? 0,
48
+ report: path, usage: { ...report.usage } }, { anchorDirectory: state.anchorDirectory });
49
+ } });
50
+ const latest = await new TaskService(new FileTaskStore(join(root, ".agent-ops/tasks/state.json"), root)).status({ taskId: task.id }).catch(() => undefined);
51
+ const currentView = latest === undefined ? null : await criteriaReviewStatus(root, latest.task);
52
+ if (currentView?.report.attemptId === result.report.attemptId)
53
+ result = { ...result, stale: currentView.stale };
54
+ if (controller.signal.aborted)
55
+ throw new AgentOpsError("CRITERIA_REVIEW_CANCELLED", "Criteria review cancelled; stop before implementation.");
56
+ process.stderr.write(renderCriteriaReview(result) + "\n");
57
+ return result;
58
+ }
59
+ finally {
60
+ process.removeListener("SIGINT", int);
61
+ process.removeListener("SIGTERM", term);
62
+ }
63
+ }