@kylecheng3146/agent-ops 0.11.2 → 0.11.4
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 +11 -2
- package/dist/packages/cli/src/args.js +18 -3
- package/dist/packages/cli/src/bin.js +2 -1
- package/dist/packages/cli/src/cli.js +5 -0
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/init.js +2 -5
- package/dist/packages/cli/src/commands/review.js +15 -1
- package/dist/packages/cli/src/commands/run.js +9 -1
- package/dist/packages/cli/src/commands/task.js +8 -2
- package/dist/packages/cli/src/criteria-review.js +8 -3
- package/dist/packages/cli/src/run-deps.js +18 -2
- package/dist/packages/cli/src/run-entry.js +1 -1
- package/dist/runtime/src/install/doctor.js +3 -5
- package/dist/runtime/src/install/harness.js +3 -2
- package/dist/runtime/src/install/plan.js +1 -1
- package/dist/runtime/src/parallel/finish.js +7 -4
- package/dist/runtime/src/parallel/integrate.js +6 -4
- package/dist/runtime/src/review/attestation.js +35 -13
- package/dist/runtime/src/review/criteria.js +22 -5
- package/dist/runtime/src/review/report.js +8 -4
- package/dist/runtime/src/review/runner.js +10 -2
- package/dist/runtime/src/run/land.js +145 -0
- package/dist/runtime/src/run/service.js +34 -0
- package/docs/en/spec/harness-adapters.md +2 -1
- package/docs/en/spec/loop-engineering.md +13 -0
- package/docs/en/spec/review.md +37 -3
- package/docs/zh-TW/spec/harness-adapters.md +2 -2
- package/docs/zh-TW/spec/loop-engineering.md +1 -0
- package/docs/zh-TW/spec/review.md +24 -4
- package/package.json +1 -1
- package/schemas/review-report.schema.json +2 -1
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ command behavior may change before 1.0.
|
|
|
17
17
|
Every new task, including DEBUG and native run parent/child tasks, waits for one
|
|
18
18
|
independent advisory review of its goal, criteria and assigned surface before
|
|
19
19
|
implementation. Read the suggestions, decide which to adopt, and record reasons
|
|
20
|
-
through `task revise`.
|
|
20
|
+
through `task revise`. The first revision that changes criteria, intent or
|
|
21
|
+
goal reruns the advisory once; surface-only revisions do not, and later
|
|
22
|
+
reruns are explicit (`review --criteria --rerun`).
|
|
21
23
|
|
|
22
24
|
The advisory has no time/token budget; explicit cancellation stops work. Genuine
|
|
23
25
|
execution errors report NOT_RUN and allow work. Detailed reports remain private;
|
|
@@ -51,8 +53,14 @@ agent-ops run logs <run-id>
|
|
|
51
53
|
agent-ops run respond <run-id> --question-id <id> --answer "Clarification"
|
|
52
54
|
agent-ops run stop <run-id>
|
|
53
55
|
agent-ops run resume <run-id>
|
|
56
|
+
agent-ops run land <run-id> [--task <task-id>]... --yes
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
`run land` merges the delivered children of a paused, blocked or budget-limited
|
|
60
|
+
run ahead of the rest: it verifies them, runs the two-round review and finishes
|
|
61
|
+
with a receipt in a separate landing worktree, while the run stays resumable and
|
|
62
|
+
its final proof re-verifies the whole goal.
|
|
63
|
+
|
|
56
64
|
`--host claude` uses Claude Code; `--wait` waits for a terminal or input state.
|
|
57
65
|
The coordinator counts toward the two writer slots. Native processes write only
|
|
58
66
|
under current leases in separate worktrees. Active time counts once across parallel
|
|
@@ -266,7 +274,8 @@ question through `agent-ops run respond`. A start refused with
|
|
|
266
274
|
`WORKTREE_NESTED` falls back to the subtask flow; `RUN_REPO_UNTRUSTED` stops and
|
|
267
275
|
asks you. A run that ends without completing is reported with its status, last
|
|
268
276
|
log events, and the `agent-ops run resume <id>` / `agent-ops run stop <id>`
|
|
269
|
-
commands
|
|
277
|
+
commands (plus `agent-ops run land <id> --yes` when it has delivered tasks); it
|
|
278
|
+
is never resumed or landed automatically. agy keeps the subtask flow.
|
|
270
279
|
Init and update pre-authorize `agent-ops run` for Claude Code and Codex.
|
|
271
280
|
|
|
272
281
|
```bash
|
|
@@ -922,7 +922,7 @@ export function parseArgs(argv) {
|
|
|
922
922
|
};
|
|
923
923
|
}
|
|
924
924
|
function parseRunArgs(argv) {
|
|
925
|
-
const actions = ["start", "status", "logs", "resume", "stop", "respond"];
|
|
925
|
+
const actions = ["start", "status", "logs", "resume", "stop", "respond", "land"];
|
|
926
926
|
if (argv.includes("--help") || argv.includes("-h"))
|
|
927
927
|
return { command: "help", helpTopic: "run", profiles: [], dryRun: false, json: false, yes: false, rerun: false };
|
|
928
928
|
const action = actions.includes(argv[0]) ? argv[0] : "start";
|
|
@@ -937,21 +937,30 @@ function parseRunArgs(argv) {
|
|
|
937
937
|
positional = token;
|
|
938
938
|
continue;
|
|
939
939
|
}
|
|
940
|
-
if (seen.has(token) && token !== "--retry-approval")
|
|
940
|
+
if (seen.has(token) && token !== "--retry-approval" && token !== "--task")
|
|
941
941
|
duplicate(token);
|
|
942
942
|
seen.add(token);
|
|
943
943
|
if (token === "--json") {
|
|
944
944
|
args.json = true;
|
|
945
945
|
continue;
|
|
946
946
|
}
|
|
947
|
+
if (token === "--yes") {
|
|
948
|
+
args.yes = true;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
947
951
|
if (token === "--wait") {
|
|
948
952
|
args.wait = true;
|
|
949
953
|
continue;
|
|
950
954
|
}
|
|
951
|
-
if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery"].includes(token))
|
|
955
|
+
if (!["--host", "--goal-file", "--time-budget", "--jobs", "--question-id", "--answer", "--answer-file", "--retry-approval", "--retry-delivery", "--task"].includes(token))
|
|
952
956
|
throw new CliArgumentError("CLI_UNKNOWN_OPTION", `Unknown run option: ${token}`, token);
|
|
953
957
|
const value = readOptionValue(argv, i++, token);
|
|
954
958
|
switch (token) {
|
|
959
|
+
case "--task":
|
|
960
|
+
if (!/^[A-Za-z0-9._:/-]{1,256}$/u.test(value) || args.landTaskIds?.includes(value))
|
|
961
|
+
invalidValue(token, value);
|
|
962
|
+
args.landTaskIds = [...(args.landTaskIds ?? []), value];
|
|
963
|
+
break;
|
|
955
964
|
case "--retry-delivery":
|
|
956
965
|
if (!/^task-[a-z0-9-]{1,128}$/u.test(value))
|
|
957
966
|
invalidValue(token, value);
|
|
@@ -991,6 +1000,12 @@ function parseRunArgs(argv) {
|
|
|
991
1000
|
}
|
|
992
1001
|
if (args.retryApprovalIds !== undefined && action !== "resume")
|
|
993
1002
|
throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-approval requires run resume.");
|
|
1003
|
+
if (args.landTaskIds !== undefined && action !== "land")
|
|
1004
|
+
throw new CliArgumentError("CLI_INVALID_OPTION", "--task requires run land.");
|
|
1005
|
+
if (args.yes !== (action === "land"))
|
|
1006
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", action === "land"
|
|
1007
|
+
? "Run land requires --yes to authorize its verification and both reviewer sessions."
|
|
1008
|
+
: "--yes is accepted only by run land.");
|
|
994
1009
|
if (args.retryDeliveryTaskId !== undefined && (action !== "resume" || args.retryApprovalIds !== undefined))
|
|
995
1010
|
throw new CliArgumentError("CLI_INVALID_OPTION", "--retry-delivery requires run resume without --retry-approval.");
|
|
996
1011
|
if (action === "start") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { reviewCreatedCriteria, criteriaReviewStatus } from "./criteria-review.js";
|
|
2
|
+
import { reviewCreatedCriteria, reviewRevisedCriteria, criteriaReviewStatus } from "./criteria-review.js";
|
|
3
3
|
import { renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
|
|
4
4
|
import { advancePhaseObserver, productionRunContext } from "./run-deps.js";
|
|
5
5
|
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
@@ -520,6 +520,7 @@ else {
|
|
|
520
520
|
service: taskService,
|
|
521
521
|
onTaskCreated: async (record, createdRoot) => await reviewCreatedCriteria(createdRoot ?? root, record.task),
|
|
522
522
|
criteriaStatus: async (record) => await criteriaReviewStatus(root, record.task),
|
|
523
|
+
onTaskRevised: async (record) => await reviewRevisedCriteria(root, record.task),
|
|
523
524
|
...(policyConfigHash === undefined ? {} : { policyConfigHash }),
|
|
524
525
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
525
526
|
...(fromMain
|
|
@@ -130,6 +130,7 @@ The URL carries an access token; do not share it.
|
|
|
130
130
|
agent-ops run resume <run-id> [--retry-approval <auth-event-id>]... [--json]
|
|
131
131
|
agent-ops run resume <run-id> --retry-delivery <task-id> [--json]
|
|
132
132
|
agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
|
|
133
|
+
agent-ops run land <run-id> [--task <task-id>]... --yes [--json]
|
|
133
134
|
|
|
134
135
|
New runs have no total time cap. Manual resume upgrades legacy finite runs and keeps accounting.
|
|
135
136
|
Command/review timeouts, provider limits and stop controls remain effective.
|
|
@@ -144,6 +145,10 @@ Status includes phase, original cause, recorded sealed proof, cleanup state,
|
|
|
144
145
|
resumability and remediation. Resume revalidates authority and sealed artifacts;
|
|
145
146
|
receipt-bound recovery executes only missing cleanup steps. Resolve the saved
|
|
146
147
|
blocker before retrying: unchanged cleanup inputs are refused.
|
|
148
|
+
land merges delivered children of a paused, blocked or budget-limited run ahead
|
|
149
|
+
of the rest: it verifies them, runs the two-round review and finishes with a
|
|
150
|
+
receipt, leaving the run resumable. --task picks a subset; each one's
|
|
151
|
+
dependencies must be picked too or already landed.
|
|
147
152
|
`,
|
|
148
153
|
init: `Usage: agent-ops init [options]
|
|
149
154
|
|
|
@@ -102,7 +102,7 @@ export async function runAdvanceCommand(options) {
|
|
|
102
102
|
if (parent.status !== "active" || parent.task.parentTaskId !== undefined) {
|
|
103
103
|
throw new AgentOpsError("ADVANCE_PARENT_INVALID", "Advance requires an active root task in the coordinator worktree.");
|
|
104
104
|
}
|
|
105
|
-
const children = await integrateSessionChildren(deps, record, parent.task.id);
|
|
105
|
+
const children = await integrateSessionChildren(deps, record, parent.task.id, undefined, options.children);
|
|
106
106
|
const step = options.step ?? runAdvanceStep;
|
|
107
107
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
108
108
|
const target = await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
|
|
@@ -159,6 +159,7 @@ export async function runAdvanceCommand(options) {
|
|
|
159
159
|
...(noChangePaths === undefined ? {} : { noChangePaths }) });
|
|
160
160
|
const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
|
|
161
161
|
const finalProof = { target, head, sourceFingerprint, children,
|
|
162
|
+
...(options.children === undefined ? {} : { keepChildren: true }),
|
|
162
163
|
...(noChangePaths === undefined ? {} : { noChange: { sourceCommit: head,
|
|
163
164
|
contractDigest: await deps.tasks(record.path).treeContract(parent.task.id), reviewScope: scope,
|
|
164
165
|
artifactRefs: Object.values((await deps.tasks(record.path).status({ taskId: parent.task.id })).evidence).flat() } }) };
|
|
@@ -127,13 +127,10 @@ export async function runInitCommand(options) {
|
|
|
127
127
|
: { worktree: worktreeConfig })
|
|
128
128
|
});
|
|
129
129
|
const trust = await trustChange(options, plan);
|
|
130
|
-
//
|
|
131
|
-
// completion needs current PASS evidence from a required verifier, so the
|
|
132
|
-
// loop can never close. Detection stays conservative on purpose — it will
|
|
133
|
-
// not guess a test command — which makes saying so out loud the whole fix.
|
|
130
|
+
// Empty commands block legacy criteria, not typed acceptance backed by runners.
|
|
134
131
|
const verificationWarnings = plan.config.verification.commands.length === 0
|
|
135
132
|
? [
|
|
136
|
-
"No verification command is configured
|
|
133
|
+
"No verification command is configured; legacy criteria need a required verifier. " +
|
|
137
134
|
"Add verification.commands to .agent-ops/config.json." +
|
|
138
135
|
(plan.verificationBlockers.length === 0
|
|
139
136
|
? ""
|
|
@@ -2,7 +2,7 @@ import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
|
|
|
2
2
|
import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
|
|
3
3
|
import { renderReviewResult } from "../../../../runtime/src/review/render.js";
|
|
4
4
|
import { reviewReportResults } from "../../../../runtime/src/review/report.js";
|
|
5
|
-
import { findPriorFailingFindings, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
|
|
5
|
+
import { countFailedReviewRounds, findPriorFailingFindings, REVIEW_ROUND_CAP, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
|
|
6
6
|
import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
7
7
|
import { evidenceMatchesContract, taskContractHash, treeContractHash, goalHash } from "../../../../runtime/src/task/contract.js";
|
|
8
8
|
import { acceptanceCoverage, coverageDigest } from "../../../../runtime/src/verify/acceptance-coverage.js";
|
|
@@ -649,6 +649,20 @@ export async function runReviewCommand(options) {
|
|
|
649
649
|
? await treeContext(options)
|
|
650
650
|
: await taskContext(options);
|
|
651
651
|
options = { ...options, noChangePaths: options.noChangePaths ?? context?.records.find(r => r.task.id === context.taskId)?.noChangePaths };
|
|
652
|
+
// Before any scope, surface or reviewer cost: a contract that keeps failing
|
|
653
|
+
// needs a new approach, not another round of one-by-one fixes.
|
|
654
|
+
if (context !== undefined && options.root !== undefined) {
|
|
655
|
+
const own = context.records.find(r => r.task.id === context.taskId).task;
|
|
656
|
+
if (await countFailedReviewRounds(options.root, context.taskId, taskContractHash(own)) >= REVIEW_ROUND_CAP) {
|
|
657
|
+
const result = { ...resultBase, status: "NOT_RUN", reason: "round-cap", taskId: context.taskId };
|
|
658
|
+
const message = "Independent review was not run.";
|
|
659
|
+
return { code: "REVIEW_NOT_RUN", status: "error", errors: [{ code: "REVIEW_NOT_RUN", message }], data: { message, result,
|
|
660
|
+
text: renderReviewResult(result) +
|
|
661
|
+
`Review failed ${REVIEW_ROUND_CAP} times under this contract. Stop fixing findings one by one: reconsider ` +
|
|
662
|
+
"the approach, then record the new plan (criteria or intent) with a reason, or ask the user how to proceed:\n" +
|
|
663
|
+
` agent-ops task revise --task ${context.taskId} --expected-contract ${taskContractHash(own)} --reason "<what changed in the approach>"\n` } };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
652
666
|
const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
|
|
653
667
|
const separator = value.indexOf("=");
|
|
654
668
|
return {
|
|
@@ -9,7 +9,7 @@ function actionOf(args) {
|
|
|
9
9
|
const value = dynamic(args).runAction ?? dynamic(args).action;
|
|
10
10
|
if (value === undefined || value === "start")
|
|
11
11
|
return "start";
|
|
12
|
-
if (["status", "logs", "resume", "stop", "respond"].includes(String(value)))
|
|
12
|
+
if (["status", "logs", "resume", "stop", "respond", "land"].includes(String(value)))
|
|
13
13
|
return value;
|
|
14
14
|
throw new AgentOpsError("RUN_ACTION_INVALID", `Unsupported run action: ${String(value)}`);
|
|
15
15
|
}
|
|
@@ -109,6 +109,14 @@ export async function runRunCommand(options) {
|
|
|
109
109
|
}
|
|
110
110
|
if (action === "resume")
|
|
111
111
|
return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId, options.args.retryApprovalIds, true, options.args.retryDeliveryTaskId)));
|
|
112
|
+
if (action === "land") {
|
|
113
|
+
if (options.service.land === undefined)
|
|
114
|
+
throw new AgentOpsError("RUN_LAND_UNSUPPORTED", "This CLI cannot land run deliveries.");
|
|
115
|
+
const landed = await options.service.land(runId, options.args.landTaskIds);
|
|
116
|
+
const message = `Landed ${landed.taskIds.join(", ")} from run ${runId}. Receipt: ${landed.receipt}.\n` +
|
|
117
|
+
`The run stays ${landed.state.status}; continue the rest with: agent-ops run resume ${runId}`;
|
|
118
|
+
return okEnvelope("RUN_LANDED", { ...data(action, { state: landed.state, message }), landedTaskIds: landed.taskIds, receipt: landed.receipt });
|
|
119
|
+
}
|
|
112
120
|
if (action === "stop")
|
|
113
121
|
return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
|
|
114
122
|
const questionId = options.args.questionId;
|
|
@@ -6,7 +6,12 @@ import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/r
|
|
|
6
6
|
import { errorEnvelope, okEnvelope } from "../output.js";
|
|
7
7
|
async function withCriteriaStatus(envelope, options) {
|
|
8
8
|
const item = envelope.data?.record;
|
|
9
|
-
|
|
9
|
+
// A status read is best effort; a cancelled advisory round must still stop the caller.
|
|
10
|
+
const advice = item === undefined ? null : await options.criteriaStatus?.(item).catch((error) => {
|
|
11
|
+
if (error instanceof AgentOpsError && error.code === "CRITERIA_REVIEW_CANCELLED")
|
|
12
|
+
throw error;
|
|
13
|
+
return null;
|
|
14
|
+
});
|
|
10
15
|
return advice == null || envelope.data === null ? envelope : { ...envelope, data: { ...envelope.data, criteriaReview: advice,
|
|
11
16
|
text: envelope.data.text + "\n\n" + renderCriteriaReview(advice) } };
|
|
12
17
|
}
|
|
@@ -202,7 +207,8 @@ export async function runTaskCommand(options) {
|
|
|
202
207
|
...(origin === undefined ? {} : { origin }),
|
|
203
208
|
reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
|
|
204
209
|
});
|
|
205
|
-
|
|
210
|
+
// The revision is saved first: an advisory NOT_RUN keeps TASK_REVISED, and a cancelled round throws with it kept.
|
|
211
|
+
return await withCriteriaStatus(await withEscape(taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain a fresh review, and new verification if the criteria or configuration changed.", record), origin === undefined ? undefined : await options.service.declareEscape(record.task.id), options.service), options.onTaskRevised === undefined ? options : { ...options, criteriaStatus: options.onTaskRevised });
|
|
206
212
|
}
|
|
207
213
|
if (action === "replan") {
|
|
208
214
|
const payload = await jsonFile(options.args.planFile);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
4
|
-
import { runCriteriaReview, readCriteriaReview, renderCriteriaReview } from "../../../runtime/src/review/criteria.js";
|
|
4
|
+
import { runCriteriaReview, readCriteriaReview, renderCriteriaReview, revisionReviewDue } from "../../../runtime/src/review/criteria.js";
|
|
5
5
|
import { registeredRunProofRunner } from "../../../runtime/src/verify/spawn.js";
|
|
6
6
|
import { appendLocalLog } from "../../../runtime/src/logging/local-log.js";
|
|
7
7
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
@@ -24,7 +24,7 @@ export async function criteriaReviewStatus(root, task) {
|
|
|
24
24
|
return await readCriteriaReview(root, task, await parentScope(root, task).catch(() => undefined));
|
|
25
25
|
}
|
|
26
26
|
/** Only production creation/explicit review installs this hook; TaskService stays model-free. */
|
|
27
|
-
export async function reviewCreatedCriteria(root, task, rerun = false) {
|
|
27
|
+
export async function reviewCreatedCriteria(root, task, rerun = false, trigger) {
|
|
28
28
|
const controller = new AbortController();
|
|
29
29
|
const interrupt = (signal) => { controller.abort(signal); };
|
|
30
30
|
const int = () => interrupt("SIGINT"), term = () => interrupt("SIGTERM");
|
|
@@ -38,7 +38,7 @@ export async function reviewCreatedCriteria(root, task, rerun = false) {
|
|
|
38
38
|
const parent = await parentScope(root, task).catch(() => undefined);
|
|
39
39
|
process.stderr.write(`Criteria advisory for ${task.id}: waiting for one independent reviewer (no time budget; Ctrl-C cancels).\n`);
|
|
40
40
|
let result = await runCriteriaReview({ root, task, parent, target, model: role?.model, effort: role?.effort,
|
|
41
|
-
rerun, signal: controller.signal, runner: registeredRunProofRunner(),
|
|
41
|
+
rerun, ...(trigger === undefined ? {} : { trigger }), signal: controller.signal, runner: registeredRunProofRunner(),
|
|
42
42
|
unavailableCode: config === undefined ? "CRITERIA_REVIEW_CONFIG_UNAVAILABLE" : target === undefined && role !== undefined ? "CRITERIA_REVIEW_NO_INDEPENDENT_TARGET" : "CRITERIA_REVIEW_UNCONFIGURED",
|
|
43
43
|
log: async (report, path) => {
|
|
44
44
|
const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
|
|
@@ -61,3 +61,8 @@ export async function reviewCreatedCriteria(root, task, rerun = false) {
|
|
|
61
61
|
process.removeListener("SIGTERM", term);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/** A revise that changed what the advice judged reruns it once; otherwise it reports the current advisory. */
|
|
65
|
+
export async function reviewRevisedCriteria(root, task) {
|
|
66
|
+
const view = await criteriaReviewStatus(root, task);
|
|
67
|
+
return revisionReviewDue(view) ? await reviewCreatedCriteria(root, task, true, "revise") : view;
|
|
68
|
+
}
|
|
@@ -9,7 +9,8 @@ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
|
9
9
|
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
10
10
|
import { resolveCheckouts, readWorktreeRecord, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
|
|
11
11
|
import { createLaunchdDescriptor, LaunchdController, readBootIdentity, readGuiLoginIdentity } from "../../../runtime/src/run/macOS.js";
|
|
12
|
-
import { FileRunRepository, RunService, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
|
|
12
|
+
import { FileRunRepository, RunService, landingMarkerLive, validateDeliveryRetry } from "../../../runtime/src/run/service.js";
|
|
13
|
+
import { landRun } from "../../../runtime/src/run/land.js";
|
|
13
14
|
import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
|
|
14
15
|
import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
|
|
15
16
|
import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
|
|
@@ -28,6 +29,7 @@ import { nativeProcessIdentity } from "../../../runtime/src/run/transport.js";
|
|
|
28
29
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
29
30
|
import { RunControlService } from "../../../runtime/src/run/controls.js";
|
|
30
31
|
import { recordRunPhase } from "../../../runtime/src/run/phase.js";
|
|
32
|
+
import { runAdvanceCommand } from "./commands/advance.js";
|
|
31
33
|
/** Worktree construction happens before a native lease exists; it must never write global trust. */
|
|
32
34
|
export async function runWorktreeDependencies(deps, state) {
|
|
33
35
|
state = await ensureRunPolicy(new FileRunRepository(join(state.commonDir, "agent-ops/runs"), state.commonDir), state);
|
|
@@ -213,6 +215,8 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
213
215
|
};
|
|
214
216
|
const resume = async (runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId) => {
|
|
215
217
|
const before = await service.status(runId);
|
|
218
|
+
if (landingMarkerLive(before.landing))
|
|
219
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${runId} is landing delivered work; wait for run land to finish.`);
|
|
216
220
|
const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(before, retryDeliveryTaskId);
|
|
217
221
|
const runtimeHash = await runRuntimeHash();
|
|
218
222
|
const policy = await readRunPolicy(before, before.policyBinding?.runtimeHash ?? runtimeHash, Date.now(), true);
|
|
@@ -315,6 +319,18 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
315
319
|
}
|
|
316
320
|
return { state, message: `Run ${runId} stopped; registered native process death was confirmed.` };
|
|
317
321
|
};
|
|
322
|
+
const land = async (runId, taskIds) => {
|
|
323
|
+
if (checkouts.currentRoot !== checkouts.mainRoot)
|
|
324
|
+
throw new AgentOpsError("WORKTREE_NESTED", "Land a run from the main checkout.");
|
|
325
|
+
return await landRun({ repository, runId, ...(taskIds === undefined ? {} : { taskIds }), deps,
|
|
326
|
+
advance: async (input) => {
|
|
327
|
+
const result = await runAdvanceCommand({ ...input, deps });
|
|
328
|
+
const receipt = result.data?.receipt;
|
|
329
|
+
if (result.status !== "ok" || typeof receipt !== "string")
|
|
330
|
+
throw new AgentOpsError(result.code, "Landing proof did not finish with a receipt.");
|
|
331
|
+
return { receipt };
|
|
332
|
+
} });
|
|
333
|
+
};
|
|
318
334
|
const respond = async (runId, questionId, answer) => {
|
|
319
335
|
const state = await service.status(runId);
|
|
320
336
|
if (!state.questions.some(q => q.questionId === questionId && q.answeredAt === null))
|
|
@@ -331,7 +347,7 @@ export async function productionRunContext(cwd, options = {}) {
|
|
|
331
347
|
}
|
|
332
348
|
};
|
|
333
349
|
return { ...checkouts, targetBranch: target.stdout.trim(), ownerSessionId: randomUUID(), repository,
|
|
334
|
-
service: { start, resume, stop, respond, status: service.status.bind(service), logs: service.logs.bind(service) },
|
|
350
|
+
service: { start, resume, stop, respond, land, status: service.status.bind(service), logs: service.logs.bind(service) },
|
|
335
351
|
waitForCompletion: async (runId) => {
|
|
336
352
|
for (;;) {
|
|
337
353
|
const state = await service.status(runId);
|
|
@@ -113,7 +113,7 @@ export function coordinatorWakeNote(state, coordinator) {
|
|
|
113
113
|
export function workerInstructionLines() {
|
|
114
114
|
return ["Keep the original goal fixed. Read this repository's AGENTS.md and use its existing verifier authority.",
|
|
115
115
|
"Plan 2–5 substantive acceptance criteria. Submit a plan request before changing product files; the supervisor creates the task with the fixed goal.",
|
|
116
|
-
"Read the criteria advisory before implementation. Decide which suggestions to adopt and use task revise with reasons (including rejected advice).
|
|
116
|
+
"Read the criteria advisory before implementation. Decide which suggestions to adopt and use task revise with reasons (including rejected advice). The first revision that changes criteria, intent or goal reruns criteria review once and waits for it; later ones do not. Formal final proof remains required.",
|
|
117
117
|
"For every mechanically testable requirement use a configured acceptance runner; explicit review-only fallback requires a recorded reason and fresh goal review.",
|
|
118
118
|
"Implement, run targeted fast checks on changed code, commit and submit action=handoff so the supervisor runs full verification outside your context window. Do not poll full-suite verification in a loop. Repair failed checks and pin mechanically testable review findings into regression criteria. Never disable a mandatory policy verifier.",
|
|
119
119
|
"Only registered run workers may write. Use isolated worktrees and supervisor-issued leases; do not launch an unregistered writing subagent.",
|
|
@@ -380,17 +380,15 @@ async function checkRegistrationDrift(root, manifest, config) {
|
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* configuration — it is a loop that cannot close, and an installation reaches
|
|
386
|
-
* that state quietly whenever stack detection declines to guess.
|
|
383
|
+
* Legacy criteria need required verifiers; typed acceptance can use runners
|
|
384
|
+
* even when no ordinary verification command is configured.
|
|
387
385
|
*/
|
|
388
386
|
function checkVerificationCommands(config) {
|
|
389
387
|
if (config === undefined) {
|
|
390
388
|
return check("verification-commands", "UNKNOWN", "Configuration could not be read, so verifiers are unknown.");
|
|
391
389
|
}
|
|
392
390
|
if (config.verification.commands.length === 0) {
|
|
393
|
-
return check("verification-commands", "DEGRADED", "No verification command is configured
|
|
391
|
+
return check("verification-commands", "DEGRADED", "No verification command is configured; legacy criteria need a required verifier.",
|
|
394
392
|
// Codeless on purpose: the fix is an edit to the configuration file, not
|
|
395
393
|
// an agent-ops command, and a code here would force a non-zero exit on
|
|
396
394
|
// every installation whose stack detection declined to guess.
|
|
@@ -347,7 +347,8 @@ function autoRunInstruction(descriptor, context) {
|
|
|
347
347
|
"report the `agent-ops run status",
|
|
348
348
|
"<id>` state and code, the last `agent-ops run logs <id>` events, and the",
|
|
349
349
|
"exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
|
|
350
|
-
"
|
|
350
|
+
"plus `agent-ops run land <id> --yes` when it has delivered tasks,",
|
|
351
|
+
"then stop: never resume or land automatically or take over the run's work.",
|
|
351
352
|
""
|
|
352
353
|
];
|
|
353
354
|
}
|
|
@@ -367,7 +368,7 @@ export function managedRules(descriptor, context) {
|
|
|
367
368
|
if (context.capabilities.includes("task"))
|
|
368
369
|
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
370
|
if (context.capabilities.includes("rules")) {
|
|
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).
|
|
371
|
+
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). The first revise that changes criteria, intent or goal", " reruns it once and waits; surface-only revises do not. Later reruns are", " explicit only: `agent-ops review --criteria --task <id> --rerun --yes`.", " Start task creation/revise/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
372
|
}
|
|
372
373
|
if (context.capabilities.includes("task")) {
|
|
373
374
|
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.", "");
|
|
@@ -88,7 +88,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
88
88
|
const verification = existing?.verification !== undefined &&
|
|
89
89
|
existing.verification.commands.length > 0
|
|
90
90
|
? existing.verification
|
|
91
|
-
: { commands: [...detectedCommands] };
|
|
91
|
+
: { ...existing?.verification, commands: [...detectedCommands] };
|
|
92
92
|
// Pre-0.11 DEBUG budget settings are dropped on rewrite.
|
|
93
93
|
const existingFeatures = existing === undefined ? undefined : (({ debug: _legacyDebug, ...rest }) => rest)(existing.features);
|
|
94
94
|
const effectiveWorktree = worktree !== undefined
|
|
@@ -318,9 +318,12 @@ export async function finishWorktree(deps, options) {
|
|
|
318
318
|
return await withActivity(commonDir, { kind: "finish", root: record.path, sessionId: record.sessionId, worktree: name }, async () => await withFinishLock(deps, commonDir, async () => {
|
|
319
319
|
const listing = await git(deps, mainRoot, ["worktree", "list", "--porcelain"], "WORKTREE_LIST_FAILED", "Cannot list cleanup worktrees.");
|
|
320
320
|
const records = await Promise.all(parseWorktreeListPorcelain(listing).map(child => readWorktreeRecord(child.path)));
|
|
321
|
+
const keepChildren = options.finalProof?.keepChildren === true;
|
|
321
322
|
const sessionChildren = records
|
|
322
|
-
.filter((child) => child !== null && child.path !== record.path &&
|
|
323
|
-
|
|
323
|
+
.filter((child) => child !== null && child.path !== record.path && (keepChildren
|
|
324
|
+
? options.finalProof.children.some(({ name }) => name === child.name)
|
|
325
|
+
: child.sessionId === record.sessionId &&
|
|
326
|
+
(child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId))));
|
|
324
327
|
if (options.finalProof?.recovery === true && record.runId !== undefined) {
|
|
325
328
|
const saved = await new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir).read(record.runId);
|
|
326
329
|
if (["receipt-written", "cleaned"].includes(saved?.integration?.status ?? ""))
|
|
@@ -579,7 +582,7 @@ export async function finishWorktree(deps, options) {
|
|
|
579
582
|
// this session began would leave it reading the merge as its own change.
|
|
580
583
|
// Only this worktree leaves the session; its other roots stay covered.
|
|
581
584
|
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
582
|
-
if (options.finalProof !== undefined) {
|
|
585
|
+
if (options.finalProof !== undefined && !keepChildren) {
|
|
583
586
|
for (const child of sessionChildren) {
|
|
584
587
|
await mainGate.afterFinish(record.sessionId, child.path, after);
|
|
585
588
|
}
|
|
@@ -593,7 +596,7 @@ export async function finishWorktree(deps, options) {
|
|
|
593
596
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
594
597
|
if (receiptOptions !== undefined)
|
|
595
598
|
await markRunIntegration(commonDir, record, "receipt-written", "trust");
|
|
596
|
-
if (options.finalProof !== undefined) {
|
|
599
|
+
if (options.finalProof !== undefined && !keepChildren) {
|
|
597
600
|
for (const child of sessionChildren) {
|
|
598
601
|
await attempt(`child ${child.name}`, async () => {
|
|
599
602
|
const pinnedHead = await assertPinnedChild(child);
|
|
@@ -156,11 +156,13 @@ async function previous(record) {
|
|
|
156
156
|
return value;
|
|
157
157
|
}
|
|
158
158
|
/** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
|
|
159
|
-
export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds,
|
|
160
|
+
/** Another session's children chosen by the caller (`run land`); replaces the session filter only. */
|
|
161
|
+
explicitChildren) {
|
|
162
|
+
const statuses = explicitChildren === undefined ? await listWorktrees(deps, coordinator.mainRoot) : [];
|
|
163
|
+
const children = (explicitChildren ?? statuses.map(({ record }) => record)
|
|
162
164
|
.filter((record) => record.name !== coordinator.name && record.sessionId === coordinator.sessionId &&
|
|
163
|
-
(record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId)))
|
|
165
|
+
(record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId))))
|
|
164
166
|
.filter(record => eligibleRunWorkerIds === undefined || (record.workerId !== undefined && eligibleRunWorkerIds.includes(record.workerId)))
|
|
165
167
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
166
168
|
const integrated = await previous(coordinator);
|
|
@@ -444,13 +444,11 @@ function blockingFindings(artifact) {
|
|
|
444
444
|
return findings;
|
|
445
445
|
}
|
|
446
446
|
/**
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
* record is ignored: these are a hint for the next reviewer, and without them
|
|
451
|
-
* the review simply runs as it always has.
|
|
447
|
+
* This task's decided reviews: PASS, or FAIL with a report. NOT_RUN artifacts
|
|
448
|
+
* decide nothing and are skipped, as is any unreadable record: callers use
|
|
449
|
+
* these as hints, and without them review simply runs as it always has.
|
|
452
450
|
*/
|
|
453
|
-
|
|
451
|
+
async function decidedTaskReviews(root, taskId) {
|
|
454
452
|
const directory = join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"));
|
|
455
453
|
let names;
|
|
456
454
|
try {
|
|
@@ -460,7 +458,7 @@ export async function findPriorFailingFindings(root, taskId) {
|
|
|
460
458
|
return [];
|
|
461
459
|
}
|
|
462
460
|
// ponytail: linear scan of every report; index by task if the directory grows large.
|
|
463
|
-
|
|
461
|
+
const decided = [];
|
|
464
462
|
for (const name of names) {
|
|
465
463
|
// `<fingerprint>` or `<fingerprint>.<task id>`, then the suffix.
|
|
466
464
|
const key = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
|
|
@@ -475,23 +473,47 @@ export async function findPriorFailingFindings(root, taskId) {
|
|
|
475
473
|
continue;
|
|
476
474
|
}
|
|
477
475
|
const value = JSON.parse(source);
|
|
478
|
-
const decided = value.status === "PASS" ||
|
|
479
|
-
(value.status === "FAIL" && value.report !== undefined);
|
|
480
476
|
if (![1, 2].includes(value?.schemaVersion) ||
|
|
481
477
|
value.sourceFingerprint !== fingerprint ||
|
|
482
478
|
value.taskId !== taskId ||
|
|
483
|
-
!
|
|
479
|
+
!(value.status === "PASS" || (value.status === "FAIL" && value.report !== undefined)) ||
|
|
484
480
|
!Number.isFinite(Date.parse(value.createdAt))) {
|
|
485
481
|
continue;
|
|
486
482
|
}
|
|
487
|
-
|
|
488
|
-
latest = value;
|
|
489
|
-
}
|
|
483
|
+
decided.push(value);
|
|
490
484
|
}
|
|
491
485
|
catch {
|
|
492
486
|
continue;
|
|
493
487
|
}
|
|
494
488
|
}
|
|
489
|
+
return decided;
|
|
490
|
+
}
|
|
491
|
+
/** After this many failed reviews of one contract, review stops and asks for a rethink. */
|
|
492
|
+
export const REVIEW_ROUND_CAP = 3;
|
|
493
|
+
/**
|
|
494
|
+
* Failed reviews of this task under its current contract since its latest
|
|
495
|
+
* PASS. Each source fingerprint keeps one report, so re-reviewing unchanged
|
|
496
|
+
* code does not add a round; a contract revision starts the count again.
|
|
497
|
+
*/
|
|
498
|
+
export async function countFailedReviewRounds(root, taskId, contractHash) {
|
|
499
|
+
const reviews = await decidedTaskReviews(root, taskId);
|
|
500
|
+
const lastPass = Math.max(-Infinity, ...reviews.filter(review => review.status === "PASS")
|
|
501
|
+
.map(review => Date.parse(review.createdAt)));
|
|
502
|
+
return reviews.filter(review => review.status === "FAIL" && review.taskContractHash === contractHash &&
|
|
503
|
+
Date.parse(review.createdAt) > lastPass).length;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Blocking findings of this task's most recent decided review, when that
|
|
507
|
+
* review failed. A newer PASS settles the earlier findings, so none are
|
|
508
|
+
* returned.
|
|
509
|
+
*/
|
|
510
|
+
export async function findPriorFailingFindings(root, taskId) {
|
|
511
|
+
let latest;
|
|
512
|
+
for (const value of await decidedTaskReviews(root, taskId)) {
|
|
513
|
+
if (latest === undefined || Date.parse(value.createdAt) > Date.parse(latest.createdAt)) {
|
|
514
|
+
latest = value;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
495
517
|
if (latest?.status !== "FAIL") {
|
|
496
518
|
return [];
|
|
497
519
|
}
|
|
@@ -74,13 +74,14 @@ async function readReport(path, root) {
|
|
|
74
74
|
if (Buffer.byteLength(source) > 512 * 1024)
|
|
75
75
|
throw new Error("Advisory artifact exceeds limit.");
|
|
76
76
|
const value = JSON.parse(source);
|
|
77
|
-
if (!record(value) || Object.keys(value).some(key => !["kind", "taskId", "attemptId", "snapshotHash", "snapshot", "status", "code", "startedAt", "durationMs", "invocations", "ownerPid", "ownerIdentity", "target", "advice", "usage"].includes(key)) ||
|
|
77
|
+
if (!record(value) || Object.keys(value).some(key => !["kind", "taskId", "attemptId", "snapshotHash", "snapshot", "status", "code", "startedAt", "durationMs", "invocations", "ownerPid", "ownerIdentity", "target", "advice", "usage", "trigger"].includes(key)) ||
|
|
78
78
|
value.kind !== "criteria-advisory" || !validText(value.taskId) || typeof value.attemptId !== "string" || !/^[a-f0-9-]{36}$/u.test(value.attemptId) ||
|
|
79
79
|
!Number.isSafeInteger(value.ownerPid) || value.ownerPid < 1 ||
|
|
80
80
|
(value.ownerIdentity !== null && !validText(value.ownerIdentity)) ||
|
|
81
81
|
!validText(value.code) || !validText(value.startedAt) || !Number.isFinite(Date.parse(value.startedAt)) ||
|
|
82
82
|
!Number.isSafeInteger(value.durationMs) || value.durationMs < 0 || ![0, 1].includes(value.invocations) ||
|
|
83
83
|
(value.target !== undefined && !["agy", "claude", "codex"].includes(String(value.target))) ||
|
|
84
|
+
(value.trigger !== undefined && !["create", "rerun", "revise"].includes(String(value.trigger))) ||
|
|
84
85
|
(value.usage !== undefined && (!record(value.usage) || Object.entries(value.usage).some(([key, n]) => !["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens", "costUsd"].includes(key) || typeof n !== "number" || !Number.isFinite(n) || n < 0))) ||
|
|
85
86
|
typeof value.snapshotHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.snapshotHash) || !record(value.snapshot) ||
|
|
86
87
|
!["RUNNING", "COMPLETED", "NOT_RUN", "CANCELLED"].includes(String(value.status)) ||
|
|
@@ -101,12 +102,28 @@ async function ownerAlive(report) {
|
|
|
101
102
|
return error.code !== "ESRCH";
|
|
102
103
|
}
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The surface audit is reviewed but is not what the advice is about: classifying
|
|
107
|
+
* one more discovery hit must not make the criteria look unreviewed. The stored
|
|
108
|
+
* snapshot is redacted, so the current one is redacted the same way.
|
|
109
|
+
*/
|
|
110
|
+
function criteriaCore(snapshot) {
|
|
111
|
+
const { surface: _surface, ...core } = snapshot;
|
|
112
|
+
return canonicalJson(core);
|
|
113
|
+
}
|
|
114
|
+
function coreChanged(report, snapshot) {
|
|
115
|
+
return criteriaCore(report.snapshot) !== criteriaCore(redactData(snapshot));
|
|
116
|
+
}
|
|
117
|
+
/** One automatic round after a substantive revise; after it only an explicit --rerun makes another due. */
|
|
118
|
+
export function revisionReviewDue(view) {
|
|
119
|
+
return view !== null && view.stale && view.report.trigger !== "revise" && view.report.status !== "RUNNING";
|
|
120
|
+
}
|
|
104
121
|
export async function readCriteriaReview(root, task, parent) {
|
|
105
122
|
try {
|
|
106
123
|
const path = reportPath(root, task.id), report = await readReport(path, root);
|
|
107
124
|
if (report === null || report.taskId !== task.id)
|
|
108
125
|
return null;
|
|
109
|
-
return { report, reportPath: path, stale: report
|
|
126
|
+
return { report, reportPath: path, stale: coreChanged(report, criteriaSnapshot(task, parent)), warnings: [] };
|
|
110
127
|
}
|
|
111
128
|
catch {
|
|
112
129
|
return null;
|
|
@@ -119,7 +136,7 @@ export function renderCriteriaReview(view) {
|
|
|
119
136
|
"Private report: " + view.reportPath, (report.advice === undefined ? undefined : safeTaskText(report.advice.summary)) ?? "No completed advice is available.",
|
|
120
137
|
...(report.advice?.recommendations ?? []).map(r => `[${safeTaskText(r.criterionId)}] ${safeTaskText(r.issue)}\nSuggestion: ${safeTaskText(r.recommendation)}\nWhy: ${safeTaskText(r.reason)}`),
|
|
121
138
|
...(report.advice?.limitations ?? []).map(s => "Limitation: " + safeTaskText(s)), ...view.warnings,
|
|
122
|
-
"Advisory only. Decide which suggestions to adopt; use task revise and record reasons, including rejections.
|
|
139
|
+
"Advisory only. Decide which suggestions to adopt; use task revise and record reasons, including rejections. The first revise that changes criteria, intent or goal reruns this review once; later ones need an explicit --rerun. Final verification and two-round review are still required."
|
|
123
140
|
].join("\n");
|
|
124
141
|
}
|
|
125
142
|
function cancelled(signal) {
|
|
@@ -134,10 +151,10 @@ export async function runCriteriaReview(options) {
|
|
|
134
151
|
let report = { kind: "criteria-advisory", taskId: task.id, attemptId: randomUUID(), snapshotHash,
|
|
135
152
|
snapshot: redactData(snapshot), status: "RUNNING", code: "CRITERIA_REVIEW_RUNNING",
|
|
136
153
|
startedAt: new Date().toISOString(), durationMs: 0, invocations: 0, ownerPid: process.pid, ownerIdentity: await nativeProcessIdentity(process.pid),
|
|
137
|
-
...(options.target === undefined ? {} : { target: options.target }) };
|
|
154
|
+
...(options.target === undefined ? {} : { target: options.target }), trigger: options.trigger ?? (options.rerun ? "rerun" : "create") };
|
|
138
155
|
const newAttempt = report;
|
|
139
156
|
const warnings = [];
|
|
140
|
-
const view = () => ({ report, reportPath: path, stale: report
|
|
157
|
+
const view = () => ({ report, reportPath: path, stale: coreChanged(report, snapshot), warnings });
|
|
141
158
|
let claimed = false;
|
|
142
159
|
try {
|
|
143
160
|
for (;;) {
|
|
@@ -64,7 +64,8 @@ function location(value, path, errors) {
|
|
|
64
64
|
}
|
|
65
65
|
function finding(value, path, errors) {
|
|
66
66
|
if (!isRecord(value) || !exactFields(value, [
|
|
67
|
-
"severity", "blocking", "title", "details", "locations", "evidence", "recommendation", "criterionIds"
|
|
67
|
+
"severity", "blocking", "title", "details", "locations", "evidence", "recommendation", "criterionIds",
|
|
68
|
+
...("origin" in value ? ["origin"] : [])
|
|
68
69
|
], path, errors)) {
|
|
69
70
|
return undefined;
|
|
70
71
|
}
|
|
@@ -73,8 +74,10 @@ function finding(value, path, errors) {
|
|
|
73
74
|
!Array.isArray(value.locations) || value.locations.length > MAX_ARRAY ||
|
|
74
75
|
!textArray(value.evidence) || value.evidence.length === 0 || !isText(value.recommendation) ||
|
|
75
76
|
!textArray(value.criterionIds) || new Set(value.criterionIds).size !== value.criterionIds.length ||
|
|
76
|
-
(value.
|
|
77
|
-
(value.
|
|
77
|
+
(value.origin !== undefined && value.origin !== "introduced" && value.origin !== "pre-existing") ||
|
|
78
|
+
(value.origin === "pre-existing" ? value.blocking !== false :
|
|
79
|
+
(value.severity === "critical" && value.blocking !== true) ||
|
|
80
|
+
(value.severity === "minor" && value.blocking !== false))) {
|
|
78
81
|
invalid(errors, path, "INVALID_FINDING", "Finding is invalid.");
|
|
79
82
|
return undefined;
|
|
80
83
|
}
|
|
@@ -90,7 +93,8 @@ function finding(value, path, errors) {
|
|
|
90
93
|
locations: locations,
|
|
91
94
|
evidence: [...value.evidence],
|
|
92
95
|
recommendation: value.recommendation,
|
|
93
|
-
criterionIds: [...value.criterionIds]
|
|
96
|
+
criterionIds: [...value.criterionIds],
|
|
97
|
+
...(value.origin === undefined ? {} : { origin: value.origin })
|
|
94
98
|
};
|
|
95
99
|
}
|
|
96
100
|
export function validateReviewReport(value, requestedCriterionIds, changedFiles) {
|
|
@@ -22,6 +22,12 @@ const CONTRACT_INSTRUCTIONS = [
|
|
|
22
22
|
"every changed path is still required and is checked on your reply.",
|
|
23
23
|
"Report every blocking defect you find in this one reply, not only the " +
|
|
24
24
|
"first: each defect left for a later round costs another full review.",
|
|
25
|
+
"A defect is pre-existing when it is present identically at the base and " +
|
|
26
|
+
"this change did not introduce, expose, worsen or depend on it. Report " +
|
|
27
|
+
"only pre-existing defects you meet while reading for this review; do not " +
|
|
28
|
+
"search for them. Give each one origin 'pre-existing' and blocking false, " +
|
|
29
|
+
"whatever its severity: it goes to followup, not to this change. Every " +
|
|
30
|
+
"other finding has origin 'introduced'.",
|
|
25
31
|
"Every FAIL criterion must have at least one blocking finding whose " +
|
|
26
32
|
"criterionIds includes it. Blocking findings may reference only FAIL " +
|
|
27
33
|
"criteria.",
|
|
@@ -30,7 +36,8 @@ const CONTRACT_INSTRUCTIONS = [
|
|
|
30
36
|
"summary:string,evidence:string[]}],findings:[{severity:'critical'|" +
|
|
31
37
|
"'important'|'minor',blocking:boolean,title:string,details:string," +
|
|
32
38
|
"locations:[{path:string,line?:integer}],evidence:string[]," +
|
|
33
|
-
"recommendation:string,criterionIds:string[]
|
|
39
|
+
"recommendation:string,criterionIds:string[]," +
|
|
40
|
+
"origin?:'introduced'|'pre-existing'}],residualRisks:string[]," +
|
|
34
41
|
"changedFilesInspected:string[],supportingFilesInspected:string[]}. " +
|
|
35
42
|
"All descriptive strings and evidence arrays must be non-empty."
|
|
36
43
|
];
|
|
@@ -290,7 +297,8 @@ export function redactReviewReport(report) {
|
|
|
290
297
|
})),
|
|
291
298
|
evidence: finding.evidence.map((value) => safeTaskText(redactSecrets(value))),
|
|
292
299
|
recommendation: safeTaskText(redactSecrets(finding.recommendation)),
|
|
293
|
-
criterionIds: finding.criterionIds.map((value) => safeTaskText(redactSecrets(value)))
|
|
300
|
+
criterionIds: finding.criterionIds.map((value) => safeTaskText(redactSecrets(value))),
|
|
301
|
+
...(finding.origin === undefined ? {} : { origin: finding.origin })
|
|
294
302
|
})),
|
|
295
303
|
residualRisks: report.residualRisks.map((value) => safeTaskText(redactSecrets(value))),
|
|
296
304
|
changedFilesInspected: report.changedFilesInspected.map((value) => safeTaskText(redactSecrets(value))),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { calculateConfigHash } from "../config/hash.js";
|
|
5
|
+
import { sha256 } from "../fs/hash.js";
|
|
6
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
7
|
+
import { listWorktrees } from "../parallel/manage.js";
|
|
8
|
+
import { addWorktree, readWorktreeRecord, removeCheckout, sessionWorktreeName } from "../parallel/service.js";
|
|
9
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
10
|
+
import { landingMarkerLive, MAX_EVENTS } from "./service.js";
|
|
11
|
+
const LIVE_WORKER = ["assigned", "starting", "running", "idle", "handing-off"];
|
|
12
|
+
const LANDABLE = ["paused", "blocked", "budget-limited"];
|
|
13
|
+
/** Inside the landing worktree; names the run that owns it so another run never reuses it. */
|
|
14
|
+
const OWNER_FILE = join(".agent-ops", "tasks", "run-landing.json");
|
|
15
|
+
export function assertLandable(state) {
|
|
16
|
+
if (!LANDABLE.includes(state.status) || state.rootTaskId === null ||
|
|
17
|
+
state.workers.some(worker => LIVE_WORKER.includes(worker.status)))
|
|
18
|
+
throw new AgentOpsError("RUN_LAND_STATE_INVALID", `Run ${state.runId} is ${state.status}; land needs a paused, blocked or budget-limited run with a root task and no live worker.`);
|
|
19
|
+
}
|
|
20
|
+
/** Unlanded delivered children, or the requested ones; every dependency must be selected or already landed. */
|
|
21
|
+
export function selectLanding(state, requested) {
|
|
22
|
+
const landed = new Set((state.landings ?? []).flatMap(landing => landing.taskIds));
|
|
23
|
+
const eligible = state.tasks.filter(task => task.taskId !== state.rootTaskId && task.status === "delivered" &&
|
|
24
|
+
task.sourceCommit !== null && !landed.has(task.taskId));
|
|
25
|
+
for (const id of requested ?? [])
|
|
26
|
+
if (!eligible.some(task => task.taskId === id))
|
|
27
|
+
throw new AgentOpsError("RUN_LAND_TASK_INVALID", `${id} is not an unlanded delivered task of run ${state.runId}.`);
|
|
28
|
+
const selected = requested === undefined ? eligible : eligible.filter(task => requested.includes(task.taskId));
|
|
29
|
+
if (selected.length === 0)
|
|
30
|
+
throw new AgentOpsError("RUN_LAND_NOTHING", `Run ${state.runId} has no unlanded delivered task.`);
|
|
31
|
+
const ids = new Set(selected.map(task => task.taskId));
|
|
32
|
+
for (const task of selected)
|
|
33
|
+
for (const dependency of task.dependencies)
|
|
34
|
+
if (!ids.has(dependency) && !landed.has(dependency))
|
|
35
|
+
throw new AgentOpsError("RUN_LAND_DEPENDENCY_OPEN", `${task.taskId} depends on ${dependency}, which is neither selected nor landed.`);
|
|
36
|
+
return selected;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One landing session per attempt: a non-hex first letter keeps it apart from
|
|
40
|
+
* UUID sessions, and a fresh path per attempt never reuses a removed checkout.
|
|
41
|
+
*/
|
|
42
|
+
export function landingSessionId(runId, nonce = randomUUID()) {
|
|
43
|
+
return `l${sha256(`${runId}:${nonce}`).slice(0, 7)}${LANDING_SUFFIX}${runId}`;
|
|
44
|
+
}
|
|
45
|
+
const LANDING_SUFFIX = "-land-";
|
|
46
|
+
async function git(deps, cwd, args) {
|
|
47
|
+
const result = await deps.git(cwd, args);
|
|
48
|
+
if (result.exitCode !== 0)
|
|
49
|
+
throw new AgentOpsError("RUN_LAND_GIT_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
|
|
50
|
+
return result.stdout.trim();
|
|
51
|
+
}
|
|
52
|
+
/** The worker checkout whose committed head is the frozen delivery. */
|
|
53
|
+
async function deliveredCheckout(deps, state, task) {
|
|
54
|
+
for (const worker of state.workers.filter(worker => worker.taskId === task.taskId && worker.worktree !== null)) {
|
|
55
|
+
const record = await readWorktreeRecord(worker.worktree);
|
|
56
|
+
if (record !== null && await git(deps, record.path, ["rev-parse", "HEAD"]) === task.sourceCommit)
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
throw new AgentOpsError("RUN_LAND_DELIVERY_MISSING", `No preserved checkout of ${task.taskId} is at its delivered commit ${task.sourceCommit}.`);
|
|
60
|
+
}
|
|
61
|
+
async function ownerOf(path) {
|
|
62
|
+
try {
|
|
63
|
+
const owner = JSON.parse(await readPrivateFile(join(path, OWNER_FILE), path) ?? "null");
|
|
64
|
+
return typeof owner?.runId === "string" ? owner.runId : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Worktrees kept by this run's failed attempts are removed first; one that claims the run without proof is refused. */
|
|
71
|
+
async function freshLandingWorktree(deps, state, sessionId) {
|
|
72
|
+
const claimed = (await listWorktrees(deps, state.root)).map(({ record }) => record)
|
|
73
|
+
.filter(record => record.sessionId.endsWith(LANDING_SUFFIX + state.runId));
|
|
74
|
+
for (const record of claimed)
|
|
75
|
+
if (await ownerOf(record.path) !== state.runId)
|
|
76
|
+
throw new AgentOpsError("RUN_LAND_WORKTREE_FOREIGN", `${record.path} claims run ${state.runId} without its owner record; it was left untouched.`);
|
|
77
|
+
for (const record of claimed)
|
|
78
|
+
await removeCheckout(deps, record, true);
|
|
79
|
+
const record = (await addWorktree(deps, { cwd: state.root, name: sessionWorktreeName(sessionId), sessionId,
|
|
80
|
+
from: state.targetBranch, targetBranch: state.targetBranch })).record;
|
|
81
|
+
await writePrivateFile(join(record.path, OWNER_FILE), JSON.stringify({ runId: state.runId }), record.path);
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
async function createLandingRoot(deps, state, record, selected) {
|
|
85
|
+
const config = await deps.loadConfig(record.path);
|
|
86
|
+
const required = config.verification.commands.filter(command => command.required).map(command => command.id);
|
|
87
|
+
if (required.length === 0)
|
|
88
|
+
throw new AgentOpsError("RUN_LAND_VERIFIER_REQUIRED", "Landing proof needs at least one required verification command.");
|
|
89
|
+
const coordinator = state.workers.find(worker => worker.workerId === state.coordinatorId)?.worktree ?? null;
|
|
90
|
+
const root = coordinator === null ? null : await deps.tasks(coordinator).status({ taskId: state.rootTaskId }).catch(() => null);
|
|
91
|
+
const ids = selected.map(task => task.taskId);
|
|
92
|
+
const base = await git(deps, record.path, ["rev-parse", "HEAD"]);
|
|
93
|
+
const criteria = [
|
|
94
|
+
{ id: "landed-verifiers", description: "Every required project verifier passes on the integrated candidate of the landed tasks.", verifierIds: required },
|
|
95
|
+
{ id: "landed-coherent", verifierIds: [],
|
|
96
|
+
description: "The landed subset is coherent on its own: it exposes no half-finished behavior and references no work of an undelivered run task.",
|
|
97
|
+
acceptance: { mode: "review-only", baselineCommit: base, bindings: [],
|
|
98
|
+
reviewOnlyReason: "partial-landing coherence: whether a subset stands alone without the undelivered tasks is a reviewer judgment, not a mechanical check" } }
|
|
99
|
+
];
|
|
100
|
+
// Typed acceptance needs repository context; the target head is the landing baseline.
|
|
101
|
+
const created = await deps.tasks(record.path, base).create({
|
|
102
|
+
title: `Land ${ids.join(", ")} from ${state.runId}`,
|
|
103
|
+
goal: `Land delivered tasks ${ids.join(", ")} of run ${state.runId} ahead of the rest of its goal:\n${state.goal}`.slice(0, 64_000),
|
|
104
|
+
intent: "Merge only the frozen deliveries of the listed run tasks; the run, its root task and its checkouts stay as they are.",
|
|
105
|
+
criteria, createdSourceCommit: base, policyConfigHash: calculateConfigHash(config), sessionId: record.sessionId,
|
|
106
|
+
...(root?.task.surface === undefined ? {} : { surface: root.task.surface })
|
|
107
|
+
});
|
|
108
|
+
return created.task.id;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `agent-ops run land`: prove and merge delivered children of a stopped run
|
|
112
|
+
* while the run stays resumable. Landed tasks remain delivered, so a later
|
|
113
|
+
* resume and finalize re-verify the whole goal on the new target.
|
|
114
|
+
*/
|
|
115
|
+
export async function landRun(options) {
|
|
116
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
117
|
+
let selected = [];
|
|
118
|
+
const held = await options.repository.mutate(options.runId, current => {
|
|
119
|
+
if (landingMarkerLive(current.landing))
|
|
120
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${current.runId} is already landing delivered work.`);
|
|
121
|
+
assertLandable(current);
|
|
122
|
+
selected = selectLanding(current, options.taskIds);
|
|
123
|
+
return { ...current, landing: { processId: process.pid, hostname: hostname(), startedAt: now() } };
|
|
124
|
+
});
|
|
125
|
+
const taskIds = selected.map(task => task.taskId);
|
|
126
|
+
try {
|
|
127
|
+
const children = [];
|
|
128
|
+
for (const task of selected)
|
|
129
|
+
children.push(await deliveredCheckout(options.deps, held, task));
|
|
130
|
+
const sessionId = landingSessionId(held.runId);
|
|
131
|
+
const record = await freshLandingWorktree(options.deps, held, sessionId);
|
|
132
|
+
const parentTaskId = await createLandingRoot(options.deps, held, record, selected);
|
|
133
|
+
const { receipt } = await options.advance({ cwd: held.root, sessionId, parentTaskId, children });
|
|
134
|
+
const target = await git(options.deps, held.root, ["rev-parse", `${held.targetBranch}^{commit}`]);
|
|
135
|
+
const state = await options.repository.mutate(options.runId, current => ({ ...current, landing: null,
|
|
136
|
+
landings: [...(current.landings ?? []), { taskIds, receipt, target, landedAt: now() }],
|
|
137
|
+
events: [...current.events, { id: randomUUID(), at: now(), type: "integration", code: "RUN_PARTIAL_LANDED",
|
|
138
|
+
workerId: null, taskId: null, detail: `Landed ${taskIds.join(", ")} at ${target}; receipt ${receipt}.` }].slice(-MAX_EVENTS) }));
|
|
139
|
+
return { state, taskIds, receipt };
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
await options.repository.mutate(options.runId, current => ({ ...current, landing: null })).catch(() => undefined);
|
|
143
|
+
throw cause;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
3
4
|
import { basename, join } from "node:path";
|
|
4
5
|
import { sha256 } from "../fs/hash.js";
|
|
5
6
|
import { AgentOpsError } from "../fs/paths.js";
|
|
@@ -10,6 +11,28 @@ import { redactSecrets } from "../security/redact.js";
|
|
|
10
11
|
export const RUN_PHASES = ["planning", "implementing", "verifying", "reviewing", "integrating"];
|
|
11
12
|
/** Writer slots are deliberately bounded; the coordinator may occupy one. */
|
|
12
13
|
export const MAX_ACTIVE_WORKERS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* A marker from another host is treated as live: its process cannot be probed.
|
|
16
|
+
*
|
|
17
|
+
* ponytail: a reused pid on this host reads as live; clear the marker by hand if that ever bites.
|
|
18
|
+
*/
|
|
19
|
+
export function landingMarkerLive(marker) {
|
|
20
|
+
if (marker == null)
|
|
21
|
+
return false;
|
|
22
|
+
if (marker.hostname !== hostname())
|
|
23
|
+
return true;
|
|
24
|
+
try {
|
|
25
|
+
process.kill(marker.processId, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
return cause.code === "EPERM";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function refuseWhileLanding(state) {
|
|
33
|
+
if (landingMarkerLive(state.landing))
|
|
34
|
+
throw new AgentOpsError("RUN_LANDING_IN_PROGRESS", `Run ${state.runId} is landing delivered work; wait for run land to finish.`);
|
|
35
|
+
}
|
|
13
36
|
const RUN_ID = /^[a-z][a-z0-9-]{7,63}$/u;
|
|
14
37
|
const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
|
15
38
|
const SHA = /^[a-f0-9]{40,64}$/u;
|
|
@@ -127,6 +150,15 @@ export function assertRunState(value) {
|
|
|
127
150
|
return invalid("Run phase or task progress is invalid.");
|
|
128
151
|
if (state.policyBinding !== undefined && !validPolicyBinding(state.policyBinding))
|
|
129
152
|
return invalid("Invalid run policy binding.");
|
|
153
|
+
if (state.landings !== undefined && (!boundedArray(state.landings, 512) || state.landings.some(l => !plain(l) ||
|
|
154
|
+
!boundedArray(l.taskIds, 512) || l.taskIds.length === 0 || l.taskIds.some(id => !ID.test(String(id))) ||
|
|
155
|
+
typeof l.receipt !== "string" || l.receipt.length === 0 || l.receipt.length > 4096 ||
|
|
156
|
+
!SHA.test(String(l.target)) || !Number.isFinite(Date.parse(String(l.landedAt))))))
|
|
157
|
+
return invalid("Invalid run landing record.");
|
|
158
|
+
if (state.landing != null && (!plain(state.landing) || !Number.isSafeInteger(state.landing.processId) ||
|
|
159
|
+
state.landing.processId < 1 || typeof state.landing.hostname !== "string" || state.landing.hostname.length > 256 ||
|
|
160
|
+
!Number.isFinite(Date.parse(String(state.landing.startedAt)))))
|
|
161
|
+
return invalid("Invalid run landing marker.");
|
|
130
162
|
if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
|
|
131
163
|
state.proofProcess.processId < 1 || typeof state.proofProcess.processIdentity !== "string" ||
|
|
132
164
|
state.proofProcess.processIdentity.length === 0 || state.proofProcess.processIdentity.length > 256))
|
|
@@ -442,6 +474,7 @@ export class RunService {
|
|
|
442
474
|
}
|
|
443
475
|
async resume(runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId, expectedFailureId) {
|
|
444
476
|
const state = await this.#repository.mutate(runId, async (current) => {
|
|
477
|
+
refuseWhileLanding(current);
|
|
445
478
|
if (retryDeliveryTaskId !== undefined && retryApprovalIds.length > 0)
|
|
446
479
|
throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Delivery and approval retries require separate explicit controls.");
|
|
447
480
|
const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(current, retryDeliveryTaskId, Date.parse(this.#now()));
|
|
@@ -491,6 +524,7 @@ export class RunService {
|
|
|
491
524
|
const cleanAnswer = bounded(answer.trim(), "answer", MAX_GOAL_LENGTH);
|
|
492
525
|
const state = await this.#repository.mutate(runId, (current) => {
|
|
493
526
|
const question = current.questions.find((item) => item.questionId === cleanQuestion);
|
|
527
|
+
refuseWhileLanding(current);
|
|
494
528
|
if (current.status !== "awaiting-input" || current.disableRestart)
|
|
495
529
|
throw new AgentOpsError("RUN_NOT_AWAITING_INPUT", "Respond cannot restart a stopped or terminal run.");
|
|
496
530
|
if (question === undefined)
|
|
@@ -174,7 +174,8 @@ removes only `run` and keeps `loop`.
|
|
|
174
174
|
ends without completing (blocked, budget exhausted, stopped, failing review)
|
|
175
175
|
is reported with its `run status` state and code, its last `run logs` events,
|
|
176
176
|
and the exact `agent-ops run resume <id>` and `agent-ops run stop <id>`
|
|
177
|
-
commands,
|
|
177
|
+
commands, plus `agent-ops run land <id> --yes` when it has delivered tasks,
|
|
178
|
+
and is never resumed or landed automatically or taken over by the session.
|
|
178
179
|
With worktree auto mode, the Claude `PreToolUse` worktree guard allows a
|
|
179
180
|
write to exactly `.agent-ops/state/run-goal.md` in the main checkout without
|
|
180
181
|
creating a worktree. Confirmed init/update pre-authorizes `agent-ops run`:
|
|
@@ -73,6 +73,19 @@
|
|
|
73
73
|
Interrupted controlled proof MUST record bounded redacted exit/signal, result
|
|
74
74
|
failure stage and subprocess diagnostics without treating invalid output or a
|
|
75
75
|
nonzero successful envelope as proof.
|
|
76
|
+
- `run land <id> [--task <task-id>]... --yes` MAY merge delivered children of a
|
|
77
|
+
paused, blocked or budget-limited run with no live worker ahead of the rest.
|
|
78
|
+
Every selected task's dependencies MUST be selected or already landed
|
|
79
|
+
(`RUN_LAND_DEPENDENCY_OPEN`). Proof runs in a fresh landing worktree from the
|
|
80
|
+
target under a generated root task (required verifiers plus a review-only
|
|
81
|
+
coherence criterion, the run root's surface), then the usual verification,
|
|
82
|
+
two-round tree review and receipt-bound finish. The run's checkouts are pinned
|
|
83
|
+
and never removed; landed tasks stay delivered, the run stays resumable and
|
|
84
|
+
records `landings[]` and `RUN_PARTIAL_LANDED`, and its final proof re-verifies
|
|
85
|
+
the whole goal on the new target. While landing, a marker refuses resume,
|
|
86
|
+
respond and another land (`RUN_LANDING_IN_PROGRESS`); a marker of a process
|
|
87
|
+
that no longer exists does not block. A failure leaves the target unchanged
|
|
88
|
+
and keeps the landing worktree until the next land removes it.
|
|
76
89
|
Initial recovery after a current manual resume MAY ignore an owned Codex limit
|
|
77
90
|
snapshot received before activation whose native update second strictly predates
|
|
78
91
|
that resume. Automatic restarts, unknown/equal/future timestamps, clock rollback,
|
package/docs/en/spec/review.md
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
- Trigger: Production task criteria become ready before implementation.
|
|
6
6
|
- Action: Await one scoped advisory and let the writer record adoption/rejection reasons.
|
|
7
7
|
- Evidence: Private snapshot report plus safe local status/usage log; no completion attestation.
|
|
8
|
-
- Positive: `
|
|
9
|
-
- Negative: `
|
|
8
|
+
- Positive: `Adopting advice rewrites a criterion; that revise reruns the advisory once and shows the new advice.`
|
|
9
|
+
- Negative: `Re-review every revise, including surface-only ones, or treat advice as final PASS proof.`
|
|
10
10
|
|
|
11
11
|
Every production task creation (including DEBUG, replan children and native run root/child tasks) MUST wait for one independent criteria advisory before implementation. A fresh session uses one configured independent-review target, preferring a different target from the host. It receives only the original goal, assigned intent/covers, criteria and surface audit in an empty disposable workspace. It MUST NOT inspect repository files or run tests. Missing goals are insufficient information; child completeness is scoped to its assignment. Verification materials/import validity remain deferred until implementation; reasoned review-only acceptance is valid.
|
|
12
12
|
|
|
13
13
|
The advisory has no agent-ops execution deadline, stall cutoff or token budget. Provider limitations, genuine launch/auth/output errors and explicit cancellation remain effective. Errors produce NOT_RUN and permit work; cancellation preserves the task and MUST stop implementation. Native review processes remain registered and stoppable. Normal verification and final two-round review retain their existing deadlines and gates.
|
|
14
14
|
|
|
15
|
-
Task creation shows advice and a private report reference. The writer decides which recommendations to adopt and records acceptance/rejection reasons through task revise (a same-criteria revision may record rejected advice). Requirements that are ambiguous or expand scope require user clarification. Reports bind the original task/goal/criteria/surface snapshot
|
|
15
|
+
Task creation shows advice and a private report reference. The writer decides which recommendations to adopt and records acceptance/rejection reasons through task revise (a same-criteria revision may record rejected advice). Requirements that are ambiguous or expand scope require user clarification. Reports bind the original task/goal/criteria/surface snapshot and record their trigger (`create`, `rerun` or `revise`; absent on older reports). Task status and revise show a report as stale when the goal, intent, criteria or assigned scope change; a surface-only change leaves it current. The first saved revision that makes the current report stale, when that report was not itself revise-triggered, MUST wait for one automatic fresh round (trigger `revise`) and show its advice; the revision is kept whether that round completes, reports NOT_RUN or is cancelled. Further revisions MUST NOT automatically re-review until an explicit rerun. Repeated initial requests reuse the first attempt, including NOT_RUN; outside that one automatic round, only explicit `agent-ops review --criteria --task <id> --rerun --yes` starts another fresh single round. Without --rerun the same command reads/waits for the current attempt.
|
|
16
16
|
|
|
17
17
|
Detailed redacted reports remain private in `.agent-ops/reviews/criteria-<task-id>*.json`; the local log contains only status, duration, counts, reported usage and report reference. CLI invocation counts are distinct from provider model calls, which may be unreported. Report/log failures MUST NOT roll back a task or fabricate evidence. These advisory artifacts MUST NOT satisfy verify, final review or finish.
|
|
18
18
|
|
|
@@ -112,6 +112,40 @@ successful refutation MUST make the run FAIL.
|
|
|
112
112
|
replays the recorded queries and reads every listed `valueChanges` consumer;
|
|
113
113
|
this is the only exception to "do not survey the repository".
|
|
114
114
|
|
|
115
|
+
## REVIEW-ORIGIN-001
|
|
116
|
+
|
|
117
|
+
A defect the change did not cause MUST NOT block the change; it MUST be
|
|
118
|
+
reported as `origin: "pre-existing"` with `blocking: false` and flow to the
|
|
119
|
+
followup ledger.
|
|
120
|
+
|
|
121
|
+
- Trigger: A reviewer meets a defect while reading for the review.
|
|
122
|
+
- Action: Mark it pre-existing only when it is present identically at the base
|
|
123
|
+
and the change did not introduce, expose, worsen or depend on it. Report only
|
|
124
|
+
defects met on the normal read path; do not search for old ones.
|
|
125
|
+
- Evidence: The report validator rejects a pre-existing finding with
|
|
126
|
+
`blocking: true` at any severity, including critical. A finding without
|
|
127
|
+
`origin` means introduced and keeps the old severity rules, so stored reports
|
|
128
|
+
stay valid. Nonblocking findings of a delivered PASS reach followup as before.
|
|
129
|
+
- Positive: `A critical injection in an untouched helper is reported as pre-existing, nonblocking, and collected into followup.`
|
|
130
|
+
- Negative: `A caller in an unchanged file breaks because of this change and is marked pre-existing because its lines were not edited.`
|
|
131
|
+
|
|
132
|
+
## REVIEW-ROUNDS-001
|
|
133
|
+
|
|
134
|
+
Review MUST stop after three failed reviews of one task contract and ask for a
|
|
135
|
+
new approach instead of another fix round.
|
|
136
|
+
|
|
137
|
+
- Trigger: `review` (directly or through `batch`, `task advance` or a run) for a
|
|
138
|
+
task with three or more FAIL reports bound to its current task contract hash
|
|
139
|
+
and newer than its latest PASS.
|
|
140
|
+
- Action: Return NOT_RUN `round-cap` before resolving scope or starting any
|
|
141
|
+
reviewer, printing the exact `task revise` command. Reconsider the approach and
|
|
142
|
+
revise the criteria or intent with a reason, or ask the user.
|
|
143
|
+
- Evidence: NOT_RUN reports never count; each source fingerprint keeps one
|
|
144
|
+
report, so re-reviewing unchanged code adds nothing; a contract revision or a
|
|
145
|
+
newer PASS starts the count again. The cap is a constant, not configuration.
|
|
146
|
+
- Positive: `Third FAIL on the same contract; the agent revises the intent to a different design and reviews again.`
|
|
147
|
+
- Negative: `Keep committing one fix per finding through a fifth review of the same contract.`
|
|
148
|
+
|
|
115
149
|
## REVIEW-HOST-001
|
|
116
150
|
|
|
117
151
|
A host that cannot provide network and loopback capability MUST fail closed
|
|
@@ -150,8 +150,8 @@ byte-identical。Update MUST 保留此 profile,除非提供 `--auto-run on|off
|
|
|
150
150
|
一行說明該 code,並退回 subtask flow;`RUN_REPO_UNTRUSTED` 則停止並詢問使用者。
|
|
151
151
|
未完成即結束的 run(blocked、budget exhausted、stopped、review 失敗)須回報其
|
|
152
152
|
`run status` state 與 code、最後的 `run logs` event,以及確切的
|
|
153
|
-
`agent-ops run resume <id>` 與 `agent-ops run stop <id>` command
|
|
154
|
-
resume 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
|
|
153
|
+
`agent-ops run resume <id>` 與 `agent-ops run stop <id>` command;有已交付 task
|
|
154
|
+
時另附 `agent-ops run land <id> --yes`,且永不自動 resume、land 或由 session 接手。在 worktree auto mode 下,Claude `PreToolUse`
|
|
155
155
|
worktree guard 允許在 main checkout 寫入恰為 `.agent-ops/state/run-goal.md`
|
|
156
156
|
的路徑,且不建立 worktree。確認後的 init/update 會預先授權 `agent-ops run`:
|
|
157
157
|
Claude Code 為 `Bash(agent-ops run *)`,Codex 為
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
- 執行中的 run 僅在原始 repository trust、config、可執行 runtime 與操作範圍完全相同時,自動續期過期的有限授權。續期以 digest 綁定前一版本並保留既有評估,拒絕無效/倒退時鐘;並行 stop、denial 或 scope 變更使續期失敗。權限已變更或撤銷時須沿既有 trust/resume 流程取得真正授權;續期不授予永久 trust,也不消除 denial。明確 resume 先核對不可變 renewal lineage,再同步 policy。回答問題也沿用相同恢復檢查;兩者皆不得復活已被明確拒絕的工作。
|
|
23
23
|
- `run resume <id> --retry-approval <auth-event-id>` MAY 明確重試以額度不足 wrapper 失敗的 Codex 自動審批。每筆指定失敗 MUST 保有原始 private artifact、digest、worker identity 與 policy lineage;真正或未指定的拒絕仍阻擋。多筆失敗可重複旗標。成功確認會留存並在後續 resume 重新驗證;此操作不授予命令權限,原生審批必須重跑,既有 writer 死亡、checkout、trust 與預算檢查仍適用。
|
|
24
24
|
- `run resume <id> --retry-delivery <task-id>` MAY 在修復 harness 原因後,明確重新排程一個被 `RUN_FINAL_PROOF_INTERRUPTED` 阻擋的 child。MUST 核對同一筆中斷 handoff、所有 writer 與 proof 已確認停止、所屬 checkout 乾淨及目前 trust/policy;保留 run、task、goal、baselines、commits、耗用與失敗歷史,記錄已確認的 attempt,並要求新 native generation 及正常交付驗證。MUST NOT 直接標記交付、解除其他阻擋或確認命令拒絕;approval retry 是獨立操作。一般 resume 保留 no-progress 阻擋。受控 proof 中斷 MUST 記錄有界且已遮蔽的 exit/signal、失敗階段與子程序診斷;無效輸出或非零退出的成功 envelope 不得視為證據。
|
|
25
|
+
- `run land <id> [--task <task-id>]... --yes` MAY 在 run 為 paused、blocked 或 budget-limited 且無存活 worker 時,先行合併已交付的 child。每個選取 task 的依賴 MUST 已選取或已落地(`RUN_LAND_DEPENDENCY_OPEN`)。證明在由 target 建立的全新 landing worktree 中進行,使用自動產生的 root task(required verifier 加上 review-only 一致性條件,surface 沿用 run root),再走一般驗證、兩輪 tree review 與綁定 receipt 的 finish。run 的 checkout 只被釘住、永不移除;已落地 task 維持 delivered,run 仍可 resume 並記錄 `landings[]` 與 `RUN_PARTIAL_LANDED`,其最終證明會在新 target 上重新驗證整個 goal。落地期間的標記會拒絕 resume、respond 與另一次 land(`RUN_LANDING_IN_PROGRESS`);已不存在的程序留下的標記不會阻擋。失敗時 target 不變,landing worktree 保留到下次 land 移除。
|
|
25
26
|
- 當次人工 resume 的初始恢復 MAY 忽略啟用前收到、且 native 更新秒數嚴格早於該次 resume 的本執行緒 Codex 額度快照。自動重啟、未知/同秒/未來時間、時鐘倒退,以及啟用開始後收到的所有額度事件仍有效。Native 啟用 MUST 確認 goal 為 active,才能啟動或轉向 turn。人工 resume 可解除衍生的 `RUN_POLICY_DISABLED` task 阻擋,但保留 ID、耗用、歷史及其他產品失敗。
|
|
26
27
|
- 收斂判斷比較實際 per-check 狀態及未解除的 finding pins;source fingerprint、時間或診斷文字本身不能算進展。
|
|
27
28
|
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# 審查
|
|
2
2
|
|
|
3
|
-
English source version: 2026-10-
|
|
3
|
+
English source version: 2026-10-27. Revalidate: when the English specification changes.
|
|
4
4
|
|
|
5
5
|
## REVIEW-CRITERIA-001
|
|
6
6
|
|
|
7
7
|
- Trigger: 正式 task 的 criteria 就緒、尚未開始實作。
|
|
8
8
|
- Action: 等待一輪限定範圍的 advisory,由 writer 記錄採納或拒絕理由。
|
|
9
9
|
- Evidence: 私人快照報告與安全 local 狀態/用量 log;不產生完成證明。
|
|
10
|
-
- Positive:
|
|
11
|
-
- Negative: `每次 revise
|
|
10
|
+
- Positive: `依建議改寫 criterion;該次 revise 自動重跑一輪 advisory 並顯示新建議。`
|
|
11
|
+
- Negative: `每次 revise(包含只改 surface)都重審,或把建議當正式 PASS 證明。`
|
|
12
12
|
|
|
13
13
|
每個正式 task 建立入口(含 DEBUG、replan 新子任務及 native run root/child)MUST 在首次實作前等待一輪獨立 criteria advisory。使用設定的 independent-review target 與全新 session,優先選擇不同於 host 的 target;只提供原始 goal、指派 intent/covers、criteria 與 surface audit,並在空白暫存工作區執行。MUST NOT 掃描 repository 或跑測試。缺 goal 須明示資訊不足;子任務只對指派範圍判完整性。材料是否存在、是否載入產品程式留待實作驗證;有理由的 review-only 條件合法。
|
|
14
14
|
|
|
15
15
|
advisory 不設 agent-ops 執行 deadline、stall cutoff 或 token budget。provider 限制、真正啟動/認證/輸出錯誤與人工取消仍有效。錯誤回報 NOT_RUN 後可繼續;人工取消保留 task 並 MUST 停止實作。native 審查程序仍受註冊與停止機制管理。一般 verify 與正式雙輪 review 的既有時間限制及 gate 保持有效。
|
|
16
16
|
|
|
17
|
-
task 建立回應呈現建議與私人報告位置。writer 自行判斷採納,透過 task revise 記錄採納及拒絕理由(可用相同 criteria 修訂記錄拒絕建議)。需求歧義或擴大範圍才向使用者澄清。報告綁定 task/goal/criteria/surface
|
|
17
|
+
task 建立回應呈現建議與私人報告位置。writer 自行判斷採納,透過 task revise 記錄採納及拒絕理由(可用相同 criteria 修訂記錄拒絕建議)。需求歧義或擴大範圍才向使用者澄清。報告綁定 task/goal/criteria/surface 快照,並記錄觸發來源(`create`、`rerun` 或 `revise`;舊報告沒有此欄位)。goal、intent、criteria 或指派範圍改變後,status 與 revise 顯示報告已過期;只改 surface 時報告仍視為最新。第一次讓目前報告過期的已儲存修訂,若該報告本身不是 revise 觸發,MUST 等待一輪自動的新審查(觸發來源 `revise`)並顯示建議;不論該輪完成、回報 NOT_RUN 或被取消,修訂都會保留。之後的修訂 MUST NOT 自動重審,直到明確 rerun 為止。重複初審請求重用第一次嘗試,包含 NOT_RUN;除了那一輪自動審查,只有明確執行 `agent-ops review --criteria --task <id> --rerun --yes` 才開始新的一輪。省略 --rerun 時讀取/等待目前嘗試。
|
|
18
18
|
|
|
19
19
|
詳細報告經遮蔽後私存於 `.agent-ops/reviews/criteria-<task-id>*.json`;local log 只記狀態、耗時、數量、可得用量及報告參照。CLI 呼叫數與 provider 模型呼叫數分開,後者可能未回報。報告/log 故障 MUST NOT 回滾 task 或偽造證據。advisory 產物 MUST NOT 滿足 verify、正式 review 或 finish。
|
|
20
20
|
|
|
@@ -100,6 +100,26 @@ PASS MUST 交給第二個規劃的 target 嘗試反駁,且反駁成立時 MUST
|
|
|
100
100
|
- Note: 只有一個設定 target 時,會在全新 session 再呼叫同一 target。FAIL 已是終局,不再複查。
|
|
101
101
|
- Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
|
|
102
102
|
|
|
103
|
+
## REVIEW-ORIGIN-001
|
|
104
|
+
|
|
105
|
+
不是本次變更造成的缺陷 MUST NOT 擋下變更;MUST 以 `origin: "pre-existing"`、`blocking: false` 回報,並流入 followup ledger。
|
|
106
|
+
|
|
107
|
+
- Trigger: reviewer 在審查閱讀過程中碰到缺陷。
|
|
108
|
+
- Action: 只有當缺陷在 base 以相同形式存在,且本次變更沒有引入、暴露、加劇或依賴它時,才標為 pre-existing。只回報正常閱讀路徑上碰到的缺陷;不主動搜尋舊問題。
|
|
109
|
+
- Evidence: report validator 拒絕任何 severity(包含 critical)的 pre-existing finding 帶 `blocking: true`。沒有 `origin` 的 finding 視為 introduced,沿用原本的 severity 規則,因此既有 report 仍然有效。已交付 PASS 的 nonblocking finding 照舊進入 followup。
|
|
110
|
+
- Positive: `未改動 helper 裡的 critical injection 標為 pre-existing、nonblocking,並收進 followup。`
|
|
111
|
+
- Negative: `未改動檔案中的 caller 因本次變更而壞掉,卻因為那幾行沒被修改就標為 pre-existing。`
|
|
112
|
+
|
|
113
|
+
## REVIEW-ROUNDS-001
|
|
114
|
+
|
|
115
|
+
同一份 task contract 的 review 失敗三次後 MUST 停止,要求換方案,而不是再修一輪。
|
|
116
|
+
|
|
117
|
+
- Trigger: 對某 task 執行 `review`(直接執行,或經由 `batch`、`task advance`、run),且該 task 在目前 task contract hash 下、最近一次 PASS 之後已有三份以上 FAIL report。
|
|
118
|
+
- Action: 在解析 scope 或啟動任何 reviewer 之前回傳 NOT_RUN `round-cap`,並印出完整的 `task revise` 指令。重新檢討做法,附上理由修訂 criteria 或 intent,或詢問使用者。
|
|
119
|
+
- Evidence: NOT_RUN report 不計入;每個 source fingerprint 只保留一份 report,所以對未改動的程式碼重跑不會增加次數;contract 修訂或較新的 PASS 會讓計數歸零。上限是常數,不是設定項。
|
|
120
|
+
- Positive: `同一份 contract 第三次 FAIL;agent 把 intent 改成另一種設計後再審。`
|
|
121
|
+
- Negative: `同一份 contract 審到第五次,仍然每個 finding 修一個 commit。`
|
|
122
|
+
|
|
103
123
|
## REVIEW-HOST-001
|
|
104
124
|
|
|
105
125
|
Host 缺少 network 或 loopback 能力時,MUST 在任何 reviewer invocation 前
|
package/package.json
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"locations": { "type": "array", "items": { "$ref": "#/$defs/location" } },
|
|
42
42
|
"evidence": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/text" } },
|
|
43
43
|
"recommendation": { "$ref": "#/$defs/text" },
|
|
44
|
-
"criterionIds": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/text" } }
|
|
44
|
+
"criterionIds": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/text" } },
|
|
45
|
+
"origin": { "enum": ["introduced", "pre-existing"] }
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
}
|