@kylecheng3146/agent-ops 0.11.1 → 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.
- package/README.md +6 -0
- package/dist/packages/cli/src/args.js +68 -2
- package/dist/packages/cli/src/bin.js +9 -0
- package/dist/packages/cli/src/cli.js +29 -0
- package/dist/packages/cli/src/commands/advance.js +3 -2
- package/dist/packages/cli/src/commands/followup.js +74 -0
- package/dist/packages/cli/src/commands/parallel.js +4 -0
- package/dist/packages/cli/src/commands/run.js +1 -1
- package/dist/packages/cli/src/context.js +13 -9
- package/dist/packages/cli/src/followup-deps.js +56 -0
- package/dist/packages/cli/src/run-deps.js +16 -5
- package/dist/packages/cli/src/run-entry.js +159 -64
- package/dist/runtime/src/followup/intake.js +75 -0
- package/dist/runtime/src/followup/process.js +273 -0
- package/dist/runtime/src/followup/receipt.js +116 -0
- package/dist/runtime/src/followup/store.js +133 -0
- package/dist/runtime/src/install/preauth.js +11 -4
- package/dist/runtime/src/parallel/finish.js +10 -2
- package/dist/runtime/src/review/attestation.js +10 -0
- package/dist/runtime/src/review/followup.js +233 -0
- package/dist/runtime/src/review/runner.js +20 -0
- package/dist/runtime/src/run/service.js +59 -6
- package/dist/runtime/src/run/usage.js +5 -2
- package/dist/runtime/src/security/permissions.js +16 -1
- package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
- package/dist/runtime/src/verify/service.js +62 -2
- package/dist/runtime/src/verify/spawn.js +64 -2
- package/docs/en/guides/configuration.md +5 -0
- package/docs/en/guides/task-debug.md +36 -0
- package/docs/en/spec/acceptance-and-evidence.md +54 -0
- package/docs/en/spec/loop-engineering.md +36 -0
- package/docs/zh-TW/guides/configuration.md +3 -0
- package/docs/zh-TW/guides/task-debug.md +31 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
- package/docs/zh-TW/spec/loop-engineering.md +21 -0
- 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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withActivity } from "../office/activity.js";
|
|
2
|
+
import { collectFollowupReceipt } from "../followup/intake.js";
|
|
2
3
|
import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -82,6 +83,7 @@ async function finishRunCleanup(deps, commonDir, record, proof, children) {
|
|
|
82
83
|
await action();
|
|
83
84
|
await markRunIntegration(commonDir, record, "receipt-written", name);
|
|
84
85
|
};
|
|
86
|
+
let followup;
|
|
85
87
|
try {
|
|
86
88
|
await step("note", async () => {
|
|
87
89
|
if (!noteBound)
|
|
@@ -90,6 +92,8 @@ async function finishRunCleanup(deps, commonDir, record, proof, children) {
|
|
|
90
92
|
proof.head], "WORKTREE_NOTE_FAILED", "Cannot bind the sealed receipt to its candidate.");
|
|
91
93
|
});
|
|
92
94
|
const config = await deps.loadConfig(record.mainRoot);
|
|
95
|
+
followup = await collectFollowupReceipt({ git: deps.git, mainRoot: record.mainRoot, commonDir, config,
|
|
96
|
+
loadConfig: async () => await deps.loadConfig(record.mainRoot), path: integration.receiptPath });
|
|
93
97
|
await step("gate", async () => {
|
|
94
98
|
if (!config.features.completionGate.enabled)
|
|
95
99
|
return;
|
|
@@ -118,7 +122,7 @@ async function finishRunCleanup(deps, commonDir, record, proof, children) {
|
|
|
118
122
|
throw cause;
|
|
119
123
|
}
|
|
120
124
|
await recoverRunIntegrationAfterCleanup(commonDir, record.runId, deps.git);
|
|
121
|
-
return { record, mergedHead: proof.head, taskIds: (receipt.tasks ?? []).map(item => item.task.id), warnings: [],
|
|
125
|
+
return { record, mergedHead: proof.head, taskIds: (receipt.tasks ?? []).map(item => item.task.id), warnings: followup?.warnings ?? [], followup,
|
|
122
126
|
receipt: integration.receiptPath, residualRisks: receipt.residualRisks ?? [] };
|
|
123
127
|
}
|
|
124
128
|
/**
|
|
@@ -562,6 +566,10 @@ export async function finishWorktree(deps, options) {
|
|
|
562
566
|
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", finishNote, mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
|
|
563
567
|
if (receiptOptions !== undefined)
|
|
564
568
|
await markRunIntegration(commonDir, record, "receipt-written", "note");
|
|
569
|
+
const followup = receipt === undefined ? undefined : await collectFollowupReceipt({ git: deps.git, mainRoot, commonDir, config: mainConfig,
|
|
570
|
+
loadConfig: async () => await deps.loadConfig(mainRoot), path: receipt.path });
|
|
571
|
+
if (followup !== undefined)
|
|
572
|
+
warnings.push(...followup.warnings);
|
|
565
573
|
if (gateEnabled) {
|
|
566
574
|
await attempt("gate", async () => {
|
|
567
575
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
@@ -598,7 +606,7 @@ export async function finishWorktree(deps, options) {
|
|
|
598
606
|
await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
|
|
599
607
|
if (receiptOptions !== undefined)
|
|
600
608
|
await markRunIntegration(commonDir, record, "cleaned", "coordinator");
|
|
601
|
-
return { record, mergedHead, taskIds: tree.map(({ task }) => task.task.id), warnings,
|
|
609
|
+
return { record, mergedHead, taskIds: tree.map(({ task }) => task.task.id), warnings, followup,
|
|
602
610
|
...(receipt === undefined ? {} : { receipt: receipt.path, residualRisks: receipt.receipt.residualRisks }) };
|
|
603
611
|
}));
|
|
604
612
|
}
|
|
@@ -274,6 +274,16 @@ function parseAttestation(value) {
|
|
|
274
274
|
createdAt: record.createdAt
|
|
275
275
|
};
|
|
276
276
|
}
|
|
277
|
+
/** Validate a sealed pair without depending on a retired review checkout. */
|
|
278
|
+
export function validateReviewPair(attestation, report) {
|
|
279
|
+
try {
|
|
280
|
+
const checked = parseAttestation(attestation);
|
|
281
|
+
return checked !== null && matchingReportArtifact(report, checked) ? checked : null;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
277
287
|
export async function saveReviewAttestation(root, attestation) {
|
|
278
288
|
const validated = parseAttestation(attestation);
|
|
279
289
|
if (validated === null) {
|