@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
@@ -0,0 +1,273 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { calculateConfigHash, canonicalJson } from "../config/hash.js";
4
+ import { sha256 } from "../fs/hash.js";
5
+ import { AgentOpsError } from "../fs/paths.js";
6
+ import { assessFollowup, followupFindingDigest, isFollowupAssessmentCurrent, readFollowupAssessment } from "../review/followup.js";
7
+ import { FileRunRepository } from "../run/service.js";
8
+ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
9
+ import { evidenceMatchesContract } from "../task/contract.js";
10
+ import { validateEvidence } from "../schema/validate.js";
11
+ import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
12
+ import { collectChangeSurface, resolveGitCommit } from "../verify/change-surface.js";
13
+ import { isPassingVerificationEvidence } from "../verify/evidence.js";
14
+ import { readDeliveredReceipt } from "./receipt.js";
15
+ import { FileFollowupStore } from "./store.js";
16
+ function invalid(code = "FOLLOWUP_PROGRESS_INVALID") {
17
+ throw new AgentOpsError(code, "Followup progress cannot authorize this operation; preserve it for recovery.");
18
+ }
19
+ function progressPath(commonDir, itemId) {
20
+ if (!/^followup-[a-f0-9]{64}$/u.test(itemId))
21
+ invalid();
22
+ return join(commonDir, "agent-ops", "followup", "progress", `${itemId}.json`);
23
+ }
24
+ function reference(value, commonDir) {
25
+ return value !== undefined && typeof value.digest === "string" && /^[a-f0-9]{64}$/u.test(value.digest) &&
26
+ value.path === join(commonDir, "agent-ops", "followup", "assessments", `${value.digest}.json`);
27
+ }
28
+ export async function readFollowupProgress(commonDir, itemId, currentItem) {
29
+ const source = await readPrivateFile(progressPath(commonDir, itemId), commonDir);
30
+ if (source === null)
31
+ return { schemaVersion: 1, itemId, status: "unresolved", code: "FOLLOWUP_PENDING", attempts: [], resolutions: [] };
32
+ try {
33
+ const state = JSON.parse(source);
34
+ if (Buffer.byteLength(source) > 4 * 1024 * 1024 || state.schemaVersion !== 1 || state.itemId !== itemId ||
35
+ !["assessing", "unresolved", "starting", "linked", "resolved"].includes(state.status) ||
36
+ typeof state.code !== "string" || !/^[A-Z0-9_-]{1,128}$/u.test(state.code) ||
37
+ (state.assessment !== undefined && !reference(state.assessment, commonDir)) ||
38
+ !Array.isArray(state.attempts) || !Array.isArray(state.resolutions) ||
39
+ state.attempts.some(a => !a || !/^run-[a-z0-9-]{4,60}$/u.test(a.runId) || !["codex", "claude"].includes(a.host) ||
40
+ !["starting", "linked", "refused"].includes(a.status) || !/^[a-f0-9]{64}$/u.test(a.findingDigest) ||
41
+ typeof a.goal !== "string" || a.goal.trim() === "" || a.goal.length > 1024 * 1024 || !reference(a.assessment, commonDir)) ||
42
+ state.resolutions.some(r => !r || !Number.isFinite(Date.parse(r.at)) || !/^[a-f0-9]{40,64}$/u.test(r.commit) ||
43
+ !/^[a-f0-9]{64}$/u.test(r.findingDigest) || !["behavioral", "review-only"].includes(r.proofKind) ||
44
+ !reference(r.assessment, commonDir) || !/^[a-f0-9]{64}$/u.test(r.configHash) || typeof r.targetBranch !== "string" || r.targetBranch.trim() === "" ||
45
+ (r.receipt !== undefined && (typeof r.receipt.path !== "string" || !/^[a-f0-9]{64}$/u.test(r.receipt.digest) ||
46
+ typeof r.receipt.taskId !== "string" || !/^run-[a-z0-9-]{4,60}$/u.test(r.receipt.runId))) ||
47
+ !Array.isArray(r.proofDigests) || r.proofDigests.length === 0 || r.proofDigests.some((d) => !/^[a-f0-9]{64}$/u.test(d))))
48
+ invalid();
49
+ const item = currentItem ?? await new FileFollowupStore(commonDir).get(itemId), resolution = state.resolutions.at(-1);
50
+ if (state.status === "resolved" && (resolution === undefined || resolution.findingDigest !== followupFindingDigest(item) ||
51
+ item.history.some(event => ["reopened", "clarified"].includes(event.action) && Date.parse(event.at) >= Date.parse(resolution.at))))
52
+ return { ...state, status: "unresolved", code: "FOLLOWUP_REOPENED" };
53
+ return state;
54
+ }
55
+ catch {
56
+ return invalid();
57
+ }
58
+ }
59
+ async function save(commonDir, state) {
60
+ const content = `${JSON.stringify(state, null, 2)}\n`;
61
+ if (Buffer.byteLength(content) > 4 * 1024 * 1024)
62
+ invalid();
63
+ await writePrivateFile(progressPath(commonDir, state.itemId), content, commonDir);
64
+ return state;
65
+ }
66
+ export function followupResolutionDescription(itemId, findingDigest) {
67
+ return `Resolve the exact original finding ${itemId} (finding-sha256: ${findingDigest}) with corresponding evidence; generic test PASS or changed paths are insufficient.`;
68
+ }
69
+ /** The ordinary run goal is the immutable link; no historical run is resumed or rewritten. */
70
+ function repairGoal(item, proof, reference) {
71
+ return ["Repair this selected, conclusively PRESENT followup and nothing unrelated.",
72
+ canonicalJson({ itemId: item.id, findingDigest: proof.findingDigest, original: item.original, provenance: item.provenance,
73
+ finding: proof.finding, assessment: reference, binding: proof.binding }),
74
+ "Keep the original goal fixed. Preserve normal task advisory, mandatory verification, trust, permissions, two fresh reviews and finish.",
75
+ "Include a root acceptance criterion with id followup-resolution and this exact description:",
76
+ followupResolutionDescription(item.id, proof.findingDigest),
77
+ proof.reviewOnlyReason === undefined
78
+ ? `Use behavioral acceptance for followup-resolution, baselineCommit ${proof.binding.commit}, with designated failing baseline checks for the original finding and corresponding passing candidate checks. Invariant or generic suite PASS cannot resolve it.`
79
+ : `Use an explicit review-only acceptance contract for followup-resolution, baselineCommit ${proof.binding.commit}, reviewOnlyReason ${JSON.stringify(proof.reviewOnlyReason)}. Both fresh reviewers must assess this exact finding and the fallback reason. A reproducible bug still requires behavioral red-to-green; reject this fallback if executable checks can establish the claim.`,
80
+ "Do not declare the followup resolved yourself. Its delivered sealed receipt is reconciled against the original finding, exact candidate and evidence."].join("\n\n");
81
+ }
82
+ /** An exclusive private claim spans assessment and durable start intent, including competing CLI processes. */
83
+ export async function processFollowup(options) {
84
+ const { commonDir, itemId } = options;
85
+ if (!["codex", "claude"].includes(options.host))
86
+ invalid("RUN_HOST_INVALID");
87
+ return await withPrivateFileLock(progressPath(commonDir, itemId), commonDir, async () => {
88
+ const store = new FileFollowupStore(commonDir), item = await store.get(itemId);
89
+ if (item.kind !== "candidate")
90
+ invalid("FOLLOWUP_CLARIFICATION_REQUIRED");
91
+ let state = await readFollowupProgress(commonDir, itemId, item);
92
+ const runs = new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir);
93
+ const prior = state.attempts.at(-1);
94
+ if (prior !== undefined && prior.status !== "refused") {
95
+ const existing = await runs.read(prior.runId);
96
+ if (existing !== null) {
97
+ if (existing.goal !== prior.goal || existing.host !== prior.host)
98
+ invalid("FOLLOWUP_RUN_CHANGED");
99
+ const unsettledWriters = existing.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status) ||
100
+ ((worker.processId !== null || worker.nativeSessionId !== null || worker.nativeJobId !== null) && worker.stopIntent?.confirmedDeadAt == null));
101
+ if (!["failed", "complete"].includes(existing.status) || existing.proofProcess != null || unsettledWriters)
102
+ return await save(commonDir, { ...state, status: "linked", code: "FOLLOWUP_RUN_LINKED",
103
+ attempts: [...state.attempts.slice(0, -1), { ...prior, status: "linked" }] });
104
+ }
105
+ else if (prior.status === "starting") {
106
+ // An interrupted start has one reserved identity. Never issue a second identity while its outcome is ambiguous.
107
+ return await save(commonDir, { ...state, status: "starting", code: "FOLLOWUP_START_PENDING" });
108
+ }
109
+ else
110
+ invalid("FOLLOWUP_RUN_MISSING");
111
+ }
112
+ state = await save(commonDir, { ...state, status: "assessing", code: "FOLLOWUP_ASSESSING" });
113
+ const { assessment, reference } = await assessFollowup(options);
114
+ state = await save(commonDir, { ...state, assessment: reference, status: "unresolved", code: "FOLLOWUP_" + assessment.conclusion });
115
+ if (assessment.conclusion === "UNKNOWN")
116
+ return state;
117
+ if (!await isFollowupAssessmentCurrent(options, assessment))
118
+ return await save(commonDir, { ...state, code: "FOLLOWUP_SOURCE_CHANGED" });
119
+ if (assessment.conclusion === "ABSENT") {
120
+ if (assessment.reviewOnlyReason === undefined)
121
+ return state;
122
+ const resolution = { at: new Date().toISOString(), findingDigest: assessment.findingDigest,
123
+ commit: assessment.binding.commit, targetBranch: assessment.binding.targetBranch, configHash: assessment.binding.configHash,
124
+ proofKind: "review-only", assessment: reference, proofDigests: [reference.digest,
125
+ sha256(canonicalJson(assessment.review.report)), sha256(canonicalJson(assessment.review.adversarial.report))] };
126
+ return await save(commonDir, { ...state, status: "resolved", code: "FOLLOWUP_ABSENCE_PROVEN", resolutions: [...state.resolutions, resolution] });
127
+ }
128
+ const attempt = { runId: `run-${randomUUID().replaceAll("-", "").slice(0, 16)}`,
129
+ host: options.host, findingDigest: assessment.findingDigest, assessment: reference,
130
+ goal: repairGoal(item, assessment, reference), status: "starting" };
131
+ state = await save(commonDir, { ...state, status: "starting", code: "FOLLOWUP_START_PENDING", attempts: [...state.attempts, attempt] });
132
+ try {
133
+ if (!await isFollowupAssessmentCurrent(options, assessment))
134
+ invalid("FOLLOWUP_SOURCE_CHANGED");
135
+ const run = await options.start({ runId: attempt.runId, goal: attempt.goal, host: attempt.host }, assessment);
136
+ if (run.runId !== attempt.runId || run.goal !== attempt.goal)
137
+ invalid("FOLLOWUP_RUN_CHANGED");
138
+ state = { ...state, status: "linked", code: "FOLLOWUP_RUN_STARTED", attempts: [...state.attempts.slice(0, -1), { ...attempt, status: "linked" }] };
139
+ }
140
+ catch (error) {
141
+ const code = error instanceof AgentOpsError && /^[A-Z0-9_]{1,128}$/u.test(error.code) ? error.code : "FOLLOWUP_START_FAILED";
142
+ const created = await runs.read(attempt.runId);
143
+ if (created !== null && (created.goal !== attempt.goal || created.host !== attempt.host))
144
+ invalid("FOLLOWUP_RUN_CHANGED");
145
+ state = { ...state, status: created === null ? "unresolved" : "linked", code,
146
+ attempts: [...state.attempts.slice(0, -1), { ...attempt, status: created === null ? "refused" : "linked", code }] };
147
+ }
148
+ return await save(commonDir, state);
149
+ });
150
+ }
151
+ /** Recheck sealed specific acceptance after cleanup, using existing paired-evidence rules. */
152
+ export async function reconcileFollowupRepairs(options) {
153
+ const warnings = [];
154
+ try {
155
+ const store = new FileFollowupStore(options.commonDir);
156
+ const runs = new FileRunRepository(join(options.commonDir, "agent-ops", "runs"), options.commonDir);
157
+ const gitRunner = { run: async (args) => {
158
+ const result = await options.git(options.mainRoot, args);
159
+ return { ...result, stdout: Buffer.from(result.stdout) };
160
+ } };
161
+ for (const item of (await store.read()).items) {
162
+ await withPrivateFileLock(progressPath(options.commonDir, item.id), options.commonDir, async () => {
163
+ const state = await readFollowupProgress(options.commonDir, item.id, item), attempt = state.attempts.at(-1);
164
+ if (attempt === undefined || attempt.status === "refused")
165
+ return;
166
+ const run = await runs.read(attempt.runId);
167
+ if (run === null)
168
+ return;
169
+ if (run.goal !== attempt.goal || run.host !== attempt.host || run.root !== options.mainRoot ||
170
+ run.commonDir !== options.commonDir || attempt.findingDigest !== followupFindingDigest(item))
171
+ invalid("FOLLOWUP_RUN_CHANGED");
172
+ if (run.integration?.receiptPath == null || run.integration.receiptDigest === null ||
173
+ (options.path !== undefined && options.path !== run.integration.receiptPath))
174
+ return;
175
+ const proof = await readFollowupAssessment(options.commonDir, attempt.assessment);
176
+ if (proof.conclusion !== "PRESENT" || proof.findingDigest !== attempt.findingDigest || proof.binding === null ||
177
+ proof.itemId !== item.id || repairGoal(item, proof, attempt.assessment) !== attempt.goal)
178
+ invalid();
179
+ const original = await readDeliveredReceipt({ ...options, path: item.provenance.receiptPath });
180
+ if (original.digest !== item.provenance.receiptDigest)
181
+ invalid("FOLLOWUP_ORIGINAL_CHANGED");
182
+ const delivered = await readDeliveredReceipt({ ...options, path: run.integration.receiptPath });
183
+ const receipt = delivered.receipt;
184
+ if (delivered.digest !== run.integration.receiptDigest || receipt.sessionId !== run.ownerSessionId ||
185
+ receipt.integrationJournal?.transactionId !== run.integration.transactionId || receipt.candidateHead !== run.integration.candidate ||
186
+ receipt.targetCommit !== run.integration.expectedTarget ||
187
+ run.targetBranch !== proof.binding.targetBranch || run.rootTaskId === null ||
188
+ (await collectChangeSurface(gitRunner)).paths.length !== 0 ||
189
+ await resolveGitCommit(gitRunner, "HEAD") !== receipt.candidateHead ||
190
+ (await gitRunner.run(["merge-base", "--is-ancestor", proof.binding.commit, receipt.candidateHead])).exitCode !== 0)
191
+ return;
192
+ const branch = await gitRunner.run(["symbolic-ref", "--short", "HEAD"]);
193
+ if (branch.exitCode !== 0 || Buffer.from(branch.stdout).toString("utf8").trim() !== run.targetBranch)
194
+ return;
195
+ const root = receipt.tasks.find(r => r.task.id === run.rootTaskId);
196
+ const criterion = root?.task.criteria.find(c => c.id === "followup-resolution");
197
+ if (root === undefined || root.task.goal !== attempt.goal || criterion?.description !== followupResolutionDescription(item.id, attempt.findingDigest) ||
198
+ criterion.acceptance === undefined || criterion.acceptance.baselineCommit !== proof.binding.commit ||
199
+ (proof.reviewOnlyReason === undefined ? criterion.acceptance.mode !== "behavioral" :
200
+ criterion.acceptance.mode !== "review-only" || criterion.acceptance.reviewOnlyReason !== proof.reviewOnlyReason))
201
+ return;
202
+ const configHash = calculateConfigHash(options.config);
203
+ const load = async (reference) => receipt.verification[reference]?.value ?? null;
204
+ const readExecution = async (path) => {
205
+ const seal = receipt.executionArtifacts?.[path];
206
+ if (seal === undefined || typeof seal.value !== "string" || sha256(JSON.stringify(seal.value)) !== seal.digest)
207
+ return null;
208
+ return seal.value;
209
+ };
210
+ const proofDigests = [delivered.digest, attempt.assessment.digest];
211
+ for (const record of receipt.tasks) {
212
+ if (record.policyConfigHash !== configHash)
213
+ return;
214
+ for (const c of record.task.criteria)
215
+ for (const command of options.config.verification.commands.filter(c => c.required)) {
216
+ const evidence = (record.evidence[c.id] ?? []).filter(ref => !ref.startsWith("review:")).map(ref => {
217
+ const seal = receipt.verification[ref];
218
+ const result = validateEvidence(seal?.value);
219
+ if (seal === undefined || sha256(JSON.stringify(seal.value)) !== seal.digest || !result.ok)
220
+ return null;
221
+ return { value: result.value, digest: seal.digest };
222
+ }).filter(e => e !== null && e.value.acceptance === undefined && e.value.taskId === record.task.id &&
223
+ e.value.criterionId === c.id && e.value.commandId === command.id && e.value.configHash === configHash &&
224
+ e.value.sourceFingerprint === receipt.sourceFingerprint && evidenceMatchesContract(e.value.taskContractHash, record.task))
225
+ .sort((a, b) => Date.parse(b.value.finishedAt) - Date.parse(a.value.finishedAt) || (a.value.status === "PASS" ? 1 : -1))[0];
226
+ if (evidence == null || !isPassingVerificationEvidence(command, evidence.value))
227
+ return;
228
+ proofDigests.push(evidence.digest);
229
+ }
230
+ const coverage = await acceptanceCoverage(record, options.config, receipt.sourceFingerprint, { root: options.mainRoot, load }, readExecution);
231
+ if (coverage.some(c => ["behavioral", "invariant"].includes(c.mode) && c.status !== "proven"))
232
+ return;
233
+ for (const c of record.task.criteria)
234
+ for (const ref of record.evidence[c.id] ?? []) {
235
+ const seal = receipt.verification[ref];
236
+ const checked = validateEvidence(seal?.value);
237
+ if (!checked.ok || checked.value.acceptance === undefined || checked.value.sourceFingerprint !== receipt.sourceFingerprint ||
238
+ checked.value.configHash !== configHash || !evidenceMatchesContract(checked.value.taskContractHash, record.task))
239
+ continue;
240
+ if (checked.value.acceptance.phase === "candidate" && checked.value.acceptance.commit !== receipt.candidateHead)
241
+ return;
242
+ proofDigests.push(seal.digest);
243
+ const execution = await readExecution(checked.value.acceptance.executionArtifact);
244
+ if (execution === null)
245
+ return;
246
+ proofDigests.push(sha256(execution));
247
+ }
248
+ }
249
+ for (const seals of Object.values(receipt.reviews)) {
250
+ if (seals.attestation.value.schemaVersion !== 3)
251
+ return;
252
+ proofDigests.push(seals.attestation.digest, seals.report.digest);
253
+ }
254
+ if (state.resolutions.some(r => r.receipt?.digest === delivered.digest && r.findingDigest === attempt.findingDigest))
255
+ return;
256
+ const resolution = { at: new Date().toISOString(), findingDigest: attempt.findingDigest,
257
+ commit: receipt.candidateHead, targetBranch: run.targetBranch, configHash, proofKind: criterion.acceptance.mode,
258
+ assessment: attempt.assessment, receipt: { path: run.integration.receiptPath, digest: delivered.digest, taskId: root.task.id, runId: run.runId },
259
+ proofDigests: [...new Set(proofDigests)] };
260
+ if (followupFindingDigest(await store.get(item.id)) !== attempt.findingDigest ||
261
+ await resolveGitCommit(gitRunner, "HEAD") !== receipt.candidateHead || (await collectChangeSurface(gitRunner)).paths.length !== 0 ||
262
+ (options.loadConfig !== undefined && calculateConfigHash(await options.loadConfig()) !== configHash))
263
+ return;
264
+ await save(options.commonDir, { ...state, status: "resolved", code: "FOLLOWUP_REPAIR_PROVEN", resolutions: [...state.resolutions, resolution] });
265
+ });
266
+ }
267
+ }
268
+ catch (error) {
269
+ const code = error instanceof AgentOpsError ? error.code : "FOLLOWUP_RECONCILIATION_FAILED";
270
+ warnings.push(`followup: ${code}; completion is unchanged and the item remains retryable.`);
271
+ }
272
+ return warnings;
273
+ }
@@ -0,0 +1,116 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import { sha256 } from "../fs/hash.js";
3
+ import { AgentOpsError } from "../fs/paths.js";
4
+ import { NOTES_REF } from "../parallel/service.js";
5
+ import { validateReviewPair } from "../review/attestation.js";
6
+ import { validateReviewReport, reviewReportStatus } from "../review/report.js";
7
+ import { validateTask, validateEvidence } from "../schema/validate.js";
8
+ import { readPrivateFile } from "../security/permissions.js";
9
+ import { goalHash, taskContractHash, treeContractHash } from "../task/contract.js";
10
+ import { validateRunIntegrationReceipt } from "../run/integration.js";
11
+ function invalid() {
12
+ throw new AgentOpsError("FOLLOWUP_RECEIPT_INVALID", "Followup source is missing, changed, incomplete or not bound to delivered completion.");
13
+ }
14
+ function unseal(sealed) {
15
+ if (sealed === undefined || typeof sealed.digest !== "string" || sealed.digest !== sha256(JSON.stringify(sealed.value)))
16
+ invalid();
17
+ return sealed.value;
18
+ }
19
+ /** The final note binds exact receipt bytes; preliminary and active-task receipts are never intake sources. */
20
+ export async function readDeliveredReceipt(options) {
21
+ const { git, mainRoot, commonDir, path } = options;
22
+ if (dirname(path) !== join(commonDir, "agent-ops", "receipts") ||
23
+ !/^[a-f0-9]{64}-[a-f0-9]{40,64}\.json$/u.test(basename(path)))
24
+ invalid();
25
+ const source = await readPrivateFile(path, commonDir);
26
+ if (source === null || Buffer.byteLength(source) > 16 * 1024 * 1024)
27
+ invalid();
28
+ let receipt;
29
+ try {
30
+ receipt = JSON.parse(source);
31
+ }
32
+ catch {
33
+ return invalid();
34
+ }
35
+ if (receipt === null || ![1, 2].includes(receipt.schemaVersion) ||
36
+ typeof receipt.sessionId !== "string" || !/^[a-f0-9]{40,64}$/u.test(receipt.candidateHead) ||
37
+ !/^[a-f0-9]{40,64}$/u.test(receipt.targetCommit) || !/^[a-f0-9]{64}$/u.test(receipt.sourceFingerprint) ||
38
+ basename(path) !== `${sha256(receipt.sessionId)}-${receipt.candidateHead}.json` ||
39
+ !["tree", "per-task-fallback"].includes(receipt.reviewMode) ||
40
+ !Array.isArray(receipt.tasks) || receipt.tasks.length === 0 ||
41
+ new Set(receipt.tasks.map(record => record?.task?.id)).size !== receipt.tasks.length ||
42
+ typeof receipt.reviews !== "object" || receipt.reviews === null ||
43
+ typeof receipt.verification !== "object" || receipt.verification === null)
44
+ invalid();
45
+ const digest = sha256(source);
46
+ const note = await git(mainRoot, ["notes", `--ref=${NOTES_REF}`, "show", receipt.candidateHead]);
47
+ if (note.exitCode !== 0 || !note.stdout.trimEnd().endsWith(`\nreceipt: ${path}\nreceipt-sha256: ${digest}`) ||
48
+ (await git(mainRoot, ["merge-base", "--is-ancestor", receipt.targetCommit, receipt.candidateHead])).exitCode !== 0)
49
+ invalid();
50
+ if (receipt.integrationJournal !== undefined)
51
+ validateRunIntegrationReceipt(receipt.integrationJournal, receipt);
52
+ const reviews = [];
53
+ const tasks = receipt.tasks;
54
+ for (const record of tasks) {
55
+ if (record?.status !== "complete" || record.failureFingerprint !== null || typeof record.completedAt !== "string" ||
56
+ !Number.isFinite(Date.parse(record.completedAt)) || !validateTask(record.task).ok)
57
+ invalid();
58
+ const task = record.task;
59
+ const sealed = receipt.reviews[task.id];
60
+ if (sealed === undefined)
61
+ invalid();
62
+ const raw = unseal(sealed.report);
63
+ const attestation = validateReviewPair(unseal(sealed.attestation), raw);
64
+ if (attestation === null || attestation.taskId !== task.id ||
65
+ attestation.sourceFingerprint !== receipt.sourceFingerprint || new Set(attestation.reviewSessionIds).size !== 2)
66
+ invalid();
67
+ const artifact = raw;
68
+ const tree = attestation.tree;
69
+ if ((receipt.reviewMode === "tree") !== (tree !== undefined))
70
+ invalid();
71
+ const ids = tree?.criterionIds ?? artifact.report?.results?.map(result => result.criterionId);
72
+ if (ids === undefined || (tree !== undefined && (tree.taskIds.length !== tasks.length ||
73
+ tasks.some(item => !tree.taskIds.includes(item.task.id)))))
74
+ invalid();
75
+ const requiredIds = tree === undefined ? task.criteria.map(c => c.id) : tasks.flatMap(item => item.task.criteria.map(c => `${item.task.id}:${c.id}`));
76
+ if (attestation.schemaVersion === 3 && requiredIds.some(id => !ids.includes(id)))
77
+ invalid();
78
+ for (const report of [artifact.report, artifact.adversarial?.report]) {
79
+ if (!validateReviewReport(report, ids).ok || reviewReportStatus(report) !== "PASS")
80
+ invalid();
81
+ }
82
+ if (attestation.schemaVersion === 3 && (attestation.candidateCommit !== receipt.candidateHead ||
83
+ attestation.taskContractHash !== taskContractHash(task) ||
84
+ attestation.goalHash !== goalHash(tree === undefined ? task : tasks.find(item => item.task.id === tree.rootTaskId).task) ||
85
+ Object.keys(attestation.taskContracts ?? {}).length !== (tree === undefined ? 1 : receipt.tasks.length) ||
86
+ (tree === undefined ? [record] : receipt.tasks).some(item => attestation.taskContracts?.[item.task.id] !== taskContractHash(item.task)) ||
87
+ (tree !== undefined && attestation.treeContractHash !== treeContractHash(receipt.tasks.map(item => item.task)))))
88
+ invalid();
89
+ // Historical failures and behavioral red phases are legitimate sealed history, never the final PASS.
90
+ for (const criterion of task.criteria) {
91
+ const references = record.evidence?.[criterion.id] ?? [];
92
+ const evidence = references.filter(reference => !reference.startsWith("review:")).map(reference => {
93
+ const checked = validateEvidence(unseal(receipt.verification[reference]));
94
+ if (!checked.ok || checked.value.taskId !== task.id || checked.value.criterionId !== criterion.id)
95
+ return invalid();
96
+ return checked.value;
97
+ });
98
+ for (const commandId of criterion.verifierIds) {
99
+ const latest = evidence.filter(item => item.commandId === commandId && item.acceptance === undefined &&
100
+ item.sourceFingerprint === receipt.sourceFingerprint && item.configHash === record.policyConfigHash)
101
+ .sort((a, b) => Date.parse(b.finishedAt) - Date.parse(a.finishedAt) || (a.status === "PASS" ? 1 : -1))[0];
102
+ if (latest === undefined || latest.status !== "PASS" || latest.exitCode !== 0 || latest.failureClass !== "none")
103
+ invalid();
104
+ }
105
+ }
106
+ reviews.push({ taskId: task.id, artifact, reportDigest: sealed.report.digest,
107
+ provenanceLimit: attestation.schemaVersion === 3 ? "contract-bound" : "legacy-unbound-contract" });
108
+ }
109
+ if (receipt.reviewMode === "tree") {
110
+ const root = reviews.find(review => review.taskId === review.artifact.tree?.rootTaskId);
111
+ if (root === undefined || reviews.some(review => JSON.stringify(review.artifact.tree) !== JSON.stringify(root.artifact.tree)))
112
+ invalid();
113
+ return { receipt, digest, reviews: [root] };
114
+ }
115
+ return { receipt, digest, reviews };
116
+ }
@@ -0,0 +1,133 @@
1
+ import { join } from "node:path";
2
+ import { AgentOpsError } from "../fs/paths.js";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
5
+ import { validateReviewReport } from "../review/report.js";
6
+ export function followupIdentity(provenance) {
7
+ return `followup-${sha256(JSON.stringify([provenance.receiptDigest, provenance.taskId,
8
+ provenance.reportDigest, provenance.round, provenance.sourceKind, provenance.index]))}`;
9
+ }
10
+ function invalid() {
11
+ throw new AgentOpsError("FOLLOWUP_STATE_INVALID", "Followup state is malformed; preserve it for recovery.");
12
+ }
13
+ function text(value) {
14
+ return typeof value === "string" && value.trim() !== "" && !value.includes("\0") && Buffer.byteLength(value) <= 16 * 1024;
15
+ }
16
+ /** Reuse ordinary finding validation, including safe locations and nonempty evidence. */
17
+ export function validateFollowupFinding(finding) {
18
+ return finding !== null && typeof finding === "object" && finding.blocking === false &&
19
+ Array.isArray(finding.criterionIds) && Array.isArray(finding.locations) && validateReviewReport({
20
+ summary: "Explicit followup clarification", results: finding.criterionIds.map(criterionId => ({ criterionId, status: "PASS", summary: "Clarified", evidence: ["Explicit user clarification"] })),
21
+ findings: [finding], residualRisks: [], changedFilesInspected: [],
22
+ supportingFilesInspected: [...new Set(finding.locations.map(location => location?.path))]
23
+ }, finding.criterionIds).ok;
24
+ }
25
+ function checked(value) {
26
+ if (typeof value !== "object" || value === null)
27
+ invalid();
28
+ const state = value;
29
+ if (state.schemaVersion !== 1 || !Array.isArray(state.items) ||
30
+ new Set(state.items.map(item => item?.id)).size !== state.items.length)
31
+ invalid();
32
+ const items = state.items;
33
+ for (const item of items) {
34
+ const p = item?.provenance;
35
+ if (p === undefined || !["candidate", "observation"].includes(item.kind) ||
36
+ !["pending", "accepted", "deferred"].includes(item.disposition) ||
37
+ ![p.receiptDigest, p.reportDigest, p.sourceFingerprint].every(hash => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)) ||
38
+ ![p.candidateHead, p.targetCommit].every(hash => typeof hash === "string" && /^[a-f0-9]{40,64}$/u.test(hash)) ||
39
+ ![1, 2].includes(p.receiptSchemaVersion) || !text(p.taskId) || !text(p.receiptPath) ||
40
+ !["primary", "adversarial"].includes(p.round) || !["finding", "residual-risk"].includes(p.sourceKind) ||
41
+ !["contract-bound", "legacy-unbound-contract"].includes(p.provenanceLimit) ||
42
+ !Number.isSafeInteger(p.index) || p.index < 0 || item.id !== followupIdentity(p) ||
43
+ (p.sourceKind === "finding" ? typeof item.original === "string" || !validateFollowupFinding(item.original) : !text(item.original)) ||
44
+ (item.clarification !== undefined && !validateFollowupFinding(item.clarification)) ||
45
+ (item.kind === "observation" && (p.sourceKind !== "residual-risk" || item.clarification !== undefined)) ||
46
+ (item.kind === "candidate" && p.sourceKind === "residual-risk" && item.clarification === undefined) ||
47
+ !Array.isArray(item.history) || item.history.length === 0 || item.history[0].action !== "collected" ||
48
+ item.history.some((event) => event === null || typeof event.at !== "string" || !Number.isFinite(Date.parse(event.at)) ||
49
+ !["collected", "clarified", "accepted", "deferred", "reopened"].includes(event.action) ||
50
+ (["accepted", "deferred", "clarified"].includes(event.action) && !text(event.reason)) ||
51
+ (event.finding !== undefined && !validateFollowupFinding(event.finding))))
52
+ invalid();
53
+ }
54
+ return state;
55
+ }
56
+ /** One repository-local, private ledger shared by every linked checkout. */
57
+ export class FileFollowupStore {
58
+ commonDir;
59
+ path;
60
+ constructor(commonDir) {
61
+ this.commonDir = commonDir;
62
+ this.path = join(commonDir, "agent-ops", "followup", "ledger.json");
63
+ }
64
+ async read() {
65
+ const source = await readPrivateFile(this.path, this.commonDir);
66
+ if (source === null)
67
+ return { schemaVersion: 1, items: [] };
68
+ if (Buffer.byteLength(source) > 16 * 1024 * 1024)
69
+ invalid();
70
+ try {
71
+ return checked(JSON.parse(source));
72
+ }
73
+ catch (error) {
74
+ if (error instanceof AgentOpsError)
75
+ throw error;
76
+ return invalid();
77
+ }
78
+ }
79
+ async mutate(action) {
80
+ return await withPrivateFileLock(this.path, this.commonDir, async () => {
81
+ const state = await this.read();
82
+ const items = structuredClone([...state.items]);
83
+ const before = JSON.stringify(items);
84
+ const result = await action(items);
85
+ checked({ schemaVersion: 1, items });
86
+ if (JSON.stringify(items) !== before) {
87
+ const content = `${JSON.stringify({ schemaVersion: 1, items }, null, 2)}\n`;
88
+ if (Buffer.byteLength(content) > 16 * 1024 * 1024)
89
+ invalid();
90
+ await writePrivateFile(this.path, content, this.commonDir);
91
+ }
92
+ return structuredClone(result);
93
+ });
94
+ }
95
+ async get(id) {
96
+ const item = (await this.read()).items.find(item => item.id === id);
97
+ if (item === undefined)
98
+ throw new AgentOpsError("FOLLOWUP_NOT_FOUND", "No followup item has that identity.");
99
+ return item;
100
+ }
101
+ }
102
+ export async function setFollowupDisposition(store, id, action, reason) {
103
+ if (!["accept", "defer", "reopen"].includes(action) ||
104
+ (action !== "reopen" && !text(reason)) || (reason !== undefined && !text(reason)))
105
+ throw new AgentOpsError("FOLLOWUP_REASON_REQUIRED", "Accepting or deferring a followup requires a nonempty bounded reason.");
106
+ return await store.mutate(items => {
107
+ const index = items.findIndex(item => item.id === id);
108
+ if (index < 0)
109
+ throw new AgentOpsError("FOLLOWUP_NOT_FOUND", "No followup item has that identity.");
110
+ const item = items[index];
111
+ const next = { ...item, disposition: action === "accept" ? "accepted" : action === "defer" ? "deferred" : "pending",
112
+ history: [...item.history, { at: new Date().toISOString(), action: action === "accept" ? "accepted" : action === "defer" ? "deferred" : "reopened",
113
+ ...(reason === undefined ? {} : { reason: reason.trim() }) }] };
114
+ items[index] = next;
115
+ return next;
116
+ });
117
+ }
118
+ export async function clarifyFollowup(store, id, finding, reason) {
119
+ if (!text(reason) || !validateFollowupFinding(finding))
120
+ throw new AgentOpsError("FOLLOWUP_CLARIFICATION_INVALID", "Clarification requires a concrete nonblocking finding and a nonempty reason.");
121
+ return await store.mutate(items => {
122
+ const index = items.findIndex(item => item.id === id);
123
+ if (index < 0)
124
+ throw new AgentOpsError("FOLLOWUP_NOT_FOUND", "No followup item has that identity.");
125
+ const item = items[index];
126
+ if (item.provenance.sourceKind !== "residual-risk")
127
+ throw new AgentOpsError("FOLLOWUP_NOT_OBSERVATION", "Only an original residual-risk observation can be clarified.");
128
+ const next = { ...item, kind: "candidate", clarification: structuredClone(finding), disposition: "pending",
129
+ history: [...item.history, { at: new Date().toISOString(), action: "clarified", reason: reason.trim(), finding: structuredClone(finding) }] };
130
+ items[index] = next;
131
+ return next;
132
+ });
133
+ }
@@ -367,7 +367,7 @@ export function managedRules(descriptor, context) {
367
367
  if (context.capabilities.includes("task"))
368
368
  lines.push("To fix a bug, run `agent-ops task create --debug --goal <verbatim issue>` with a", "behavioral criterion whose `redCheckIds` fail at the baseline and pass on the", "candidate, then verify, review and finish as for any task. If you know where the", "bug came from, record it with `task revise --escape-of <commit> --escape-label", "<label>`; the origin is advisory and never blocks finish.", "");
369
369
  if (context.capabilities.includes("rules")) {
370
- lines.push("For every change:", "", "1. Audit the change's surfaces first (EVIDENCE-SURFACE-001): dependents,", " duplicates, other paths to the same concept, and shared state/data", " contracts, each with recorded fixed-string queries or n/a: reason; pass", " them with `task create --surface-file <json>`. An exclusion says why", " the surface is unaffected; \"out of scope\" is not a reason. A derived", " discovery query whose hits are all incidental may be dismissed once with", " a reason in `dismissed` instead of excluding each file. Then define", " two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope within the audited surfaces and", " preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
370
+ lines.push(" Every production task creation waits for one independent criteria advisory", " before implementation (also DEBUG and native run parents/children). It has", " no time/token budget; real launch/auth/output errors report NOT_RUN and", " allow work, while explicit cancellation means stop. Read the private report,", " decide which advice to adopt, and use task revise with reasons (including", " rejections). Revise does not rerun it. Explicit rerun only:", " `agent-ops review --criteria --task <id> --rerun --yes`.", " Start task creation/criteria review through the trusted outer host when", " the host sandbox blocks reviewer auth/network, using the same outer-host", " invocation described below for review. Never bypass a native denial.", " This advisory never replaces verify, final two-round review or finish.", "", "For every change:", "", "1. Audit the change's surfaces first (EVIDENCE-SURFACE-001): dependents,", " duplicates, other paths to the same concept, and shared state/data", " contracts, each with recorded fixed-string queries or n/a: reason; pass", " them with `task create --surface-file <json>`. An exclusion says why", " the surface is unaffected; \"out of scope\" is not a reason. A derived", " discovery query whose hits are all incidental may be dismissed once with", " a reason in `dismissed` instead of excluding each file. Then define", " two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope within the audited surfaces and", " preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
371
371
  }
372
372
  if (context.capabilities.includes("task")) {
373
373
  lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", ...autoRunInstruction(descriptor, context), "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
@@ -41,8 +41,8 @@ export const CLAUDE_PREAUTH_UNSANDBOXED = [
41
41
  "npx lint-staged *"
42
42
  ];
43
43
  /** Added with the `run` profile: `agent-ops run` starts a launchd supervisor. */
44
- export const CLAUDE_PREAUTH_RUN_ALLOW = ["Bash(agent-ops run *)"];
45
- export const CLAUDE_PREAUTH_RUN_UNSANDBOXED = ["agent-ops run", "agent-ops run *"];
44
+ export const CLAUDE_PREAUTH_RUN_ALLOW = ["Bash(agent-ops run *)", "Bash(agent-ops followup process *)"];
45
+ export const CLAUDE_PREAUTH_RUN_UNSANDBOXED = ["agent-ops run", "agent-ops run *", "agent-ops followup process", "agent-ops followup process *"];
46
46
  export const CODEX_RULES_PATH = "rules/agent-ops.rules";
47
47
  export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
48
48
  const HOSTS = ["AGENT_OPS_HOST=codex", "AGENT_OPS_HOST=claude", "AGENT_OPS_HOST=agy"];
@@ -143,11 +143,18 @@ export const CODEX_RUN_RULE = rule({
143
143
  match: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops run --goal-file .agent-ops/state/run-goal.md --host codex --wait",
144
144
  notMatch: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops trust grant --scope project --yes"
145
145
  });
146
+ export const CODEX_FOLLOWUP_RULE = rule({
147
+ withHost: false,
148
+ command: ["followup", "process"],
149
+ justification: "agent-ops followup process assesses one selected item and may start a native repair outside the sandbox.",
150
+ match: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops followup process followup-example --host codex",
151
+ notMatch: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops followup accept followup-example --reason accepted"
152
+ });
146
153
  /** The rules file for worktree auto mode and the `run` profile, or null for neither. */
147
154
  export function codexRulesContent(worktree, run) {
148
155
  if (!run)
149
156
  return worktree ? CODEX_RULES_CONTENT : null;
150
- return `${worktree ? CODEX_RULES_CONTENT : `${CODEX_RULES_MARKER}\n`}${CODEX_RUN_RULE}\n`;
157
+ return `${worktree ? CODEX_RULES_CONTENT : `${CODEX_RULES_MARKER}\n`}${CODEX_RUN_RULE}\n${CODEX_FOLLOWUP_RULE}\n`;
151
158
  }
152
159
  export function codexHomeDirectory(env = process.env) {
153
160
  return env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
@@ -306,7 +313,7 @@ async function codexChange(codexHome, desired, run) {
306
313
  content,
307
314
  expectedHash: current?.hash ?? null
308
315
  },
309
- summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for ${desired ? `agent-ops review, batch, ${run ? "run, " : ""}doctor --check-auth, worktree commit and lint-staged` : "agent-ops run"}.`,
316
+ summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for ${desired ? `agent-ops review, batch, ${run ? "run, followup process, " : ""}doctor --check-auth, worktree commit and lint-staged` : "agent-ops run and followup process"}.`,
310
317
  ownedContent: true
311
318
  };
312
319
  }
@@ -29,6 +29,18 @@ function sanitizeEvent(value) {
29
29
  if (!isRecord(value) || typeof value.type !== "string") {
30
30
  return invalidEvent();
31
31
  }
32
+ if (value.type === "criteria-review") {
33
+ if (!hasExactKeys(value, ["type", "taskId", "attemptId", "status", "code", "durationMs", "invocations", "recommendations", "report", "usage"]) ||
34
+ typeof value.taskId !== "string" || !ID_PATTERN.test(value.taskId) ||
35
+ typeof value.attemptId !== "string" || !/^[a-f0-9-]{36}$/u.test(value.attemptId) ||
36
+ !["RUNNING", "COMPLETED", "NOT_RUN", "CANCELLED"].includes(String(value.status)) ||
37
+ typeof value.code !== "string" || !ID_PATTERN.test(value.code) || !isBoundedString(value.report) ||
38
+ ![value.durationMs, value.invocations, value.recommendations].every(n => Number.isSafeInteger(n) && n >= 0) ||
39
+ !isRecord(value.usage) || Object.entries(value.usage).some(([key, n]) => !["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens", "costUsd"].includes(key) ||
40
+ typeof n !== "number" || !Number.isFinite(n) || n < 0))
41
+ return invalidEvent();
42
+ return { ...value, report: redactSecrets(value.report) };
43
+ }
32
44
  if (value.type === "diagnostic") {
33
45
  if (!hasExactKeys(value, ["code", "type"]) ||
34
46
  typeof value.code !== "string" ||