@kylecheng3146/agent-ops 0.3.2 → 0.3.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/dist/packages/cli/src/args.js +34 -1
- package/dist/packages/cli/src/bin.js +48 -3
- package/dist/packages/cli/src/cli.js +13 -4
- package/dist/packages/cli/src/commands/parallel.js +3 -1
- package/dist/packages/cli/src/commands/review.js +11 -3
- package/dist/packages/cli/src/commands/verify.js +1 -1
- package/dist/runtime/src/adapters/opencode/config.js +1 -1
- package/dist/runtime/src/adapters/opencode/input.js +67 -1
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/install/doctor.js +31 -1
- package/dist/runtime/src/parallel/guard.js +2 -2
- package/dist/runtime/src/parallel/manage.js +30 -1
- package/dist/runtime/src/parallel/service.js +69 -4
- package/dist/runtime/src/review/probe.js +26 -2
- package/dist/runtime/src/schema/validate.js +1 -1
- package/dist/runtime/src/task/completion.js +2 -0
- package/dist/runtime/src/task/service.js +77 -1
- package/dist/runtime/src/verify/command-executor.js +6 -2
- package/dist/runtime/src/verify/evidence.js +2 -1
- package/dist/runtime/src/verify/service.js +5 -2
- package/dist/runtime/src/verify/test-count.js +89 -89
- package/docs/en/guides/configuration.md +4 -3
- package/docs/en/spec/acceptance-and-evidence.md +5 -1
- package/docs/zh-TW/guides/configuration.md +4 -3
- package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -2
- package/package.json +1 -1
- package/schemas/evidence.schema.json +1 -1
|
@@ -86,6 +86,8 @@ export function parseArgs(argv) {
|
|
|
86
86
|
let versionSeen = false;
|
|
87
87
|
let worktreeName;
|
|
88
88
|
let force = false;
|
|
89
|
+
let worktreeFrom;
|
|
90
|
+
let worktreeTargetBranch;
|
|
89
91
|
let worktree;
|
|
90
92
|
for (let index = 0; index < argv.length; index += 1) {
|
|
91
93
|
const token = argv[index];
|
|
@@ -266,6 +268,22 @@ export function parseArgs(argv) {
|
|
|
266
268
|
}
|
|
267
269
|
force = true;
|
|
268
270
|
break;
|
|
271
|
+
case "--from": {
|
|
272
|
+
if (worktreeFrom !== undefined) {
|
|
273
|
+
duplicate(token);
|
|
274
|
+
}
|
|
275
|
+
worktreeFrom = readOptionValue(argv, index, token);
|
|
276
|
+
index += 1;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "--target-branch": {
|
|
280
|
+
if (worktreeTargetBranch !== undefined) {
|
|
281
|
+
duplicate(token);
|
|
282
|
+
}
|
|
283
|
+
worktreeTargetBranch = readOptionValue(argv, index, token);
|
|
284
|
+
index += 1;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
269
287
|
case "--rerun":
|
|
270
288
|
if (rerun) {
|
|
271
289
|
duplicate(token);
|
|
@@ -381,6 +399,8 @@ export function parseArgs(argv) {
|
|
|
381
399
|
yes ||
|
|
382
400
|
rerun ||
|
|
383
401
|
worktree !== undefined ||
|
|
402
|
+
worktreeFrom !== undefined ||
|
|
403
|
+
worktreeTargetBranch !== undefined ||
|
|
384
404
|
force) {
|
|
385
405
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
|
|
386
406
|
}
|
|
@@ -536,11 +556,22 @@ export function parseArgs(argv) {
|
|
|
536
556
|
dryRun ||
|
|
537
557
|
yes ||
|
|
538
558
|
rerun)) {
|
|
539
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session and --json.");
|
|
559
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch and --json.");
|
|
540
560
|
}
|
|
541
561
|
if (force && !(command === "worktree" && action === "remove")) {
|
|
542
562
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
|
|
543
563
|
}
|
|
564
|
+
if ((worktreeFrom !== undefined || worktreeTargetBranch !== undefined) &&
|
|
565
|
+
!(command === "worktree" && action === "add")) {
|
|
566
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--from and --target-branch may be used only with worktree add.");
|
|
567
|
+
}
|
|
568
|
+
if (worktreeFrom !== undefined && worktreeFrom.trim() === "") {
|
|
569
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --from.", "--from");
|
|
570
|
+
}
|
|
571
|
+
if (worktreeTargetBranch !== undefined &&
|
|
572
|
+
!/^[A-Za-z0-9._/-]{1,128}$/u.test(worktreeTargetBranch)) {
|
|
573
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid value for --target-branch: ${worktreeTargetBranch}`, "--target-branch");
|
|
574
|
+
}
|
|
544
575
|
if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
|
|
545
576
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
|
|
546
577
|
}
|
|
@@ -563,6 +594,8 @@ export function parseArgs(argv) {
|
|
|
563
594
|
...(evidence.length === 0 ? {} : { evidence }),
|
|
564
595
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
565
596
|
...(base === undefined ? {} : { base }),
|
|
597
|
+
...(worktreeFrom === undefined ? {} : { worktreeFrom }),
|
|
598
|
+
...(worktreeTargetBranch === undefined ? {} : { worktreeTargetBranch }),
|
|
566
599
|
...(checkAuth ? { checkAuth } : {}),
|
|
567
600
|
...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
|
|
568
601
|
rerun,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -43,7 +43,8 @@ import { errorEnvelope } from "./output.js";
|
|
|
43
43
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
44
44
|
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
45
45
|
import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
46
|
-
import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
46
|
+
import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
47
|
+
import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
|
|
47
48
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
48
49
|
async function readOptionalText(path) {
|
|
49
50
|
try {
|
|
@@ -106,6 +107,48 @@ async function worktreeDoctorProbe(root) {
|
|
|
106
107
|
}
|
|
107
108
|
return worktreeDoctorResult(statuses, Date.now());
|
|
108
109
|
}
|
|
110
|
+
const GHOST_SCAN_ALLOWLIST = new Set([
|
|
111
|
+
".git", ".worktrees", ".agent-ops", ".tmp", "node_modules", "dist"
|
|
112
|
+
]);
|
|
113
|
+
async function worktreeBranchLockProbe(root) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await worktreeDependencies().git(root, ["worktree", "list", "--porcelain"]);
|
|
116
|
+
if (result.exitCode !== 0) {
|
|
117
|
+
return { status: "UNKNOWN", message: "Git could not list worktrees." };
|
|
118
|
+
}
|
|
119
|
+
return branchLockDoctorResult(result.stdout);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { status: "UNKNOWN", message: "Git could not list worktrees." };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function rootGhostFilesProbe(root) {
|
|
126
|
+
try {
|
|
127
|
+
const listing = await worktreeDependencies().git(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
128
|
+
const untracked = listing.exitCode === 0
|
|
129
|
+
? new Set(listing.stdout.split("\0").filter((entry) => entry.length > 0 && !entry.includes("/")))
|
|
130
|
+
: new Set();
|
|
131
|
+
const names = (await readdir(root)).filter((name) => !GHOST_SCAN_ALLOWLIST.has(name)).slice(0, 512);
|
|
132
|
+
const candidates = await Promise.all(names.map(async (name) => {
|
|
133
|
+
try {
|
|
134
|
+
const info = await stat(join(root, name));
|
|
135
|
+
return {
|
|
136
|
+
name,
|
|
137
|
+
size: info.size,
|
|
138
|
+
isFile: info.isFile(),
|
|
139
|
+
tracked: !untracked.has(name)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { name, size: -1, isFile: false, tracked: true };
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
return ghostFilesDoctorResult(detectGhostFiles(candidates.filter((candidate) => candidate.size >= 0)));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { status: "UNKNOWN", message: "The repository root could not be scanned." };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
109
152
|
const argv = process.argv.slice(2);
|
|
110
153
|
function runAgy(args, options = {}) {
|
|
111
154
|
return execFileSync("agy", [...args], {
|
|
@@ -301,7 +344,9 @@ else {
|
|
|
301
344
|
: { status };
|
|
302
345
|
},
|
|
303
346
|
reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
|
|
304
|
-
worktrees: async () => await worktreeDoctorProbe(root)
|
|
347
|
+
worktrees: async () => await worktreeDoctorProbe(root),
|
|
348
|
+
worktreeBranchLock: async () => await worktreeBranchLockProbe(root),
|
|
349
|
+
rootGhostFiles: async () => await rootGhostFilesProbe(root)
|
|
305
350
|
},
|
|
306
351
|
...(args.checkAuth === true
|
|
307
352
|
? { checkReviewTargetAuth: true }
|
|
@@ -191,10 +191,15 @@ Options:
|
|
|
191
191
|
Give one writing session its own Git worktree, so parallel sessions stop
|
|
192
192
|
voiding each other's verification and review. Run it from the main checkout.
|
|
193
193
|
|
|
194
|
-
add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD
|
|
195
|
-
copy the ignored agent-ops
|
|
196
|
-
inherit trust for an
|
|
197
|
-
|
|
194
|
+
add <name> Create .worktrees/<name> on branch agent-ops/<name> from HEAD
|
|
195
|
+
(or --from <commit|branch|tag>), copy the ignored agent-ops
|
|
196
|
+
files and .worktreeinclude matches, inherit trust for an
|
|
197
|
+
identical config, run worktree.setup, and point the session's
|
|
198
|
+
completion gate at the new worktree. When the main checkout is
|
|
199
|
+
detached, --target-branch <branch> is required and names the
|
|
200
|
+
branch finish merges back into.
|
|
201
|
+
A branch already checked out in another worktree is rejected
|
|
202
|
+
before Git runs; see agent-ops worktree list.
|
|
198
203
|
finish <name>
|
|
199
204
|
Once its task is complete, fast-forward the main checkout's
|
|
200
205
|
branch to the worktree's (after a clean rebase and
|
|
@@ -211,6 +216,10 @@ voiding each other's verification and review. Run it from the main checkout.
|
|
|
211
216
|
|
|
212
217
|
Options:
|
|
213
218
|
--session <id> add, resume: the conversation that will work there (required)
|
|
219
|
+
--from <ref> add: branch the new worktree from this commit, branch or tag (default HEAD)
|
|
220
|
+
--target-branch <branch>
|
|
221
|
+
add: branch finish merges back into (defaults to the current
|
|
222
|
+
branch; required when the main checkout is detached)
|
|
214
223
|
--force remove: discard uncommitted or unmerged work
|
|
215
224
|
--json
|
|
216
225
|
`
|
|
@@ -82,7 +82,9 @@ export async function runWorktreeCommand(options) {
|
|
|
82
82
|
const result = await addWorktree(options.deps, {
|
|
83
83
|
cwd: options.cwd,
|
|
84
84
|
name: args.worktreeName,
|
|
85
|
-
sessionId
|
|
85
|
+
sessionId,
|
|
86
|
+
...(args.worktreeFrom === undefined ? {} : { from: args.worktreeFrom }),
|
|
87
|
+
...(args.worktreeTargetBranch === undefined ? {} : { targetBranch: args.worktreeTargetBranch })
|
|
86
88
|
});
|
|
87
89
|
return okEnvelope("WORKTREE_CREATED", { ...result, text: addText(result) });
|
|
88
90
|
}
|
|
@@ -111,8 +111,7 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
113
|
const evidence = validation.value;
|
|
114
|
-
if (evidence.
|
|
115
|
-
evidence.taskId !== context.taskId ||
|
|
114
|
+
if (evidence.taskId !== context.taskId ||
|
|
116
115
|
evidence.criterionId !== criterionId ||
|
|
117
116
|
evidence.commandId !== commandId ||
|
|
118
117
|
evidence.configHash !== configHash ||
|
|
@@ -137,6 +136,9 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
137
136
|
options.gitRunner === undefined) {
|
|
138
137
|
return { ok: false, reason: "stale-verification" };
|
|
139
138
|
}
|
|
139
|
+
if (context.criteria.some((criterion) => !options.config.verification.commands.some(({ id, required }) => required && (criterion.verifierIds ?? []).includes(id)))) {
|
|
140
|
+
return { ok: false, reason: "missing-verification-evidence" };
|
|
141
|
+
}
|
|
140
142
|
const configHash = calculateConfigHash(options.config);
|
|
141
143
|
const commands = [];
|
|
142
144
|
for (const criterion of context.criteria) {
|
|
@@ -146,7 +148,13 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
146
148
|
return { ok: false, reason: "missing-verification-evidence" };
|
|
147
149
|
}
|
|
148
150
|
const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
|
|
149
|
-
const
|
|
151
|
+
const compatible = command.evidence.kind === "test-count"
|
|
152
|
+
? found.current.filter(({ evidence }) => evidence.schemaVersion === 3)
|
|
153
|
+
: found.current;
|
|
154
|
+
if (command.required && found.current.length > 0 && compatible.length === 0) {
|
|
155
|
+
return { ok: false, reason: "stale-verification" };
|
|
156
|
+
}
|
|
157
|
+
const selected = newestEvidence(compatible);
|
|
150
158
|
if (command.required !== true) {
|
|
151
159
|
if (selected !== undefined) {
|
|
152
160
|
commands.push({
|
|
@@ -57,7 +57,7 @@ function publicReport(report) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
function formatResult(result) {
|
|
60
|
-
const testCount = result.testCount === null ? "" : ` (tests: ${result.testCount})`;
|
|
60
|
+
const testCount = result.testCount === null ? "" : ` (passed tests: ${result.testCount})`;
|
|
61
61
|
const lines = [
|
|
62
62
|
`- ${result.status} ${safe(result.commandId)}${testCount}`
|
|
63
63
|
];
|
|
@@ -131,7 +131,7 @@ export function buildOpencodePlugin(capabilities, runtimePath) {
|
|
|
131
131
|
lines.push(" // opencode initializes plugins once per app, not once per session.", " await runManagedHook($, \"SessionStart\", {", " event: \"SessionStart\",", " projectRoot", " });");
|
|
132
132
|
}
|
|
133
133
|
if (events.includes("PreToolUse")) {
|
|
134
|
-
lines.push(' hooks["tool.execute.before"] = async (input, output) => {', '
|
|
134
|
+
lines.push(' const WRITE_TOOLS = ["bash", "edit", "write", "patch", "apply_patch"];', ' hooks["tool.execute.before"] = async (input, output) => {', ' const tool = typeof input?.tool === "string" ? input.tool.toLowerCase() : "";', " if (!WRITE_TOOLS.includes(tool)) return;", " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
|
|
135
135
|
}
|
|
136
136
|
if (events.includes("Stop")) {
|
|
137
137
|
lines.push(" hooks.event = async ({ event }) => {", ' if (event?.type !== "session.idle") return;', " await runManagedHook($, \"Stop\", { event: \"Stop\", projectRoot });", " };");
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
3
|
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
4
|
+
/**
|
|
5
|
+
* opencode tools that write a file without a shell (lowercase tool names).
|
|
6
|
+
* `apply_patch` is the GPT-model replacement for `edit`/`write`; older
|
|
7
|
+
* servers still report `patch`.
|
|
8
|
+
*/
|
|
9
|
+
export const OPENCODE_FILE_TOOLS = new Set([
|
|
10
|
+
"edit",
|
|
11
|
+
"write",
|
|
12
|
+
"patch",
|
|
13
|
+
"apply_patch"
|
|
14
|
+
]);
|
|
3
15
|
function isRecord(value) {
|
|
4
16
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
17
|
}
|
|
18
|
+
function sessionIdOf(value) {
|
|
19
|
+
const sessionId = value.sessionID;
|
|
20
|
+
return typeof sessionId === "string" && sessionId.length > 0
|
|
21
|
+
? sessionId
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Paths embedded in `apply_patch` marker lines, relative to the project root:
|
|
26
|
+
* `*** Add File:`, `*** Update File:`, `*** Move to:`, `*** Delete File:`.
|
|
27
|
+
*/
|
|
28
|
+
function patchTextPaths(patchText) {
|
|
29
|
+
const paths = [];
|
|
30
|
+
const marker = /^\*\*\*\s+(?:Add File|Update File|Move to|Delete File):\s*(.+?)\s*$/gmu;
|
|
31
|
+
for (const match of patchText.matchAll(marker)) {
|
|
32
|
+
const target = match[1]?.trim();
|
|
33
|
+
if (target !== undefined && target.length > 0 && !target.includes("\0")) {
|
|
34
|
+
paths.push(target);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return paths;
|
|
38
|
+
}
|
|
39
|
+
function fileWritePaths(args, projectRoot) {
|
|
40
|
+
const direct = args.filePath ?? args.file_path ?? args.path;
|
|
41
|
+
if (typeof direct === "string" && direct.length > 0 && !direct.includes("\0")) {
|
|
42
|
+
return [resolve(projectRoot, direct)];
|
|
43
|
+
}
|
|
44
|
+
if (typeof args.patchText === "string") {
|
|
45
|
+
const marked = patchTextPaths(args.patchText);
|
|
46
|
+
if (marked.length > 0) {
|
|
47
|
+
return marked.map((target) => resolve(projectRoot, target));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
6
52
|
/**
|
|
7
53
|
* The managed plugin forwards opencode's own hook payload untouched under
|
|
8
54
|
* `input`, so every field this adapter reads is documented plugin input rather
|
|
@@ -21,7 +67,27 @@ export function normalizeOpencodeHookInput(input) {
|
|
|
21
67
|
}
|
|
22
68
|
if (input.event === "PreToolUse" &&
|
|
23
69
|
isRecord(input.input) &&
|
|
24
|
-
input.input.tool === "
|
|
70
|
+
typeof input.input.tool === "string" &&
|
|
71
|
+
OPENCODE_FILE_TOOLS.has(input.input.tool.toLowerCase()) &&
|
|
72
|
+
isRecord(input.output) &&
|
|
73
|
+
isRecord(input.output.args) &&
|
|
74
|
+
typeof projectRoot === "string" &&
|
|
75
|
+
projectRoot.length > 0) {
|
|
76
|
+
const paths = fileWritePaths(input.output.args, projectRoot);
|
|
77
|
+
if (paths !== null) {
|
|
78
|
+
const sessionId = sessionIdOf(input.input);
|
|
79
|
+
return {
|
|
80
|
+
event: "file-write",
|
|
81
|
+
projectRoot,
|
|
82
|
+
paths,
|
|
83
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (input.event === "PreToolUse" &&
|
|
88
|
+
isRecord(input.input) &&
|
|
89
|
+
typeof input.input.tool === "string" &&
|
|
90
|
+
input.input.tool.toLowerCase() === "bash" &&
|
|
25
91
|
isRecord(input.output) &&
|
|
26
92
|
isRecord(input.output.args) &&
|
|
27
93
|
typeof input.output.args.command === "string" &&
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const CONFIG_SCHEMA_VERSION = 3;
|
|
2
2
|
export const TASK_SCHEMA_VERSION = 1;
|
|
3
|
-
export const EVIDENCE_SCHEMA_VERSION =
|
|
3
|
+
export const EVIDENCE_SCHEMA_VERSION = 3;
|
|
4
4
|
/** @deprecated Use the document-specific schema version constants. */
|
|
5
5
|
export const SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
|
|
6
6
|
/**
|
|
@@ -396,6 +396,30 @@ function checkVerificationCommands(config) {
|
|
|
396
396
|
}
|
|
397
397
|
return check("verification-commands", "PASS", `Required verifiers: ${required.map(({ id }) => id).join(", ")}.`);
|
|
398
398
|
}
|
|
399
|
+
const GHOST_NAME_PATTERN = /[\s\x00-\x1f\x7f\u2000-\u206f\ufeff]/u;
|
|
400
|
+
/** Names doctor treats as unparseable pollution rather than user files. Contentful tracked files never reach this predicate. */
|
|
401
|
+
export function isGhostFileName(name) {
|
|
402
|
+
return name.length === 0 || GHOST_NAME_PATTERN.test(name);
|
|
403
|
+
}
|
|
404
|
+
/** Root entries worth flagging: untracked regular files that are empty or unparseably named. Warns, never blocks. */
|
|
405
|
+
export function detectGhostFiles(entries) {
|
|
406
|
+
return entries
|
|
407
|
+
.filter((entry) => entry.isFile && !entry.tracked && (entry.size === 0 || isGhostFileName(entry.name)))
|
|
408
|
+
.map((entry) => entry.name)
|
|
409
|
+
.sort();
|
|
410
|
+
}
|
|
411
|
+
/** Doctor's reading of the repository root: stray pollution is worth a look, not a failure. */
|
|
412
|
+
export function ghostFilesDoctorResult(names) {
|
|
413
|
+
if (names.length === 0) {
|
|
414
|
+
return { status: "PASS", message: "No stray files at the repository root." };
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
status: "DEGRADED",
|
|
418
|
+
message: `Unexpected file(s) at the repository root: ${names.join(", ")}.`,
|
|
419
|
+
code: "ROOT_GHOST_FILE",
|
|
420
|
+
remediation: `Inspect and remove them: ${names.map((name) => `rm ${JSON.stringify(name)}`).join("; ")}.`
|
|
421
|
+
};
|
|
422
|
+
}
|
|
399
423
|
/**
|
|
400
424
|
* What the surrounding host withholds from a reviewer. Reported separately
|
|
401
425
|
* from `review-targets` on purpose: a sandbox that blocks the network makes an
|
|
@@ -498,7 +522,13 @@ export async function doctorInstallation(options) {
|
|
|
498
522
|
checkVerificationCommands(config.config),
|
|
499
523
|
...(options.probes?.worktrees === undefined
|
|
500
524
|
? []
|
|
501
|
-
: [await checkProbe("worktrees", options.probes.worktrees)])
|
|
525
|
+
: [await checkProbe("worktrees", options.probes.worktrees)]),
|
|
526
|
+
...(options.probes?.worktreeBranchLock === undefined
|
|
527
|
+
? []
|
|
528
|
+
: [await checkProbe("worktree-branch-lock", options.probes.worktreeBranchLock)]),
|
|
529
|
+
...(options.probes?.rootGhostFiles === undefined
|
|
530
|
+
? []
|
|
531
|
+
: [await checkProbe("root-ghost-files", options.probes.rootGhostFiles)])
|
|
502
532
|
];
|
|
503
533
|
return {
|
|
504
534
|
checks,
|
|
@@ -56,7 +56,7 @@ ensureWorktree) {
|
|
|
56
56
|
action: "block",
|
|
57
57
|
status: "FAIL",
|
|
58
58
|
code: "WORKTREE_CREATED",
|
|
59
|
-
remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
|
|
59
|
+
remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}; otherwise use the worktree path as every command's working directory) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
catch (error) {
|
|
@@ -68,7 +68,7 @@ ensureWorktree) {
|
|
|
68
68
|
action: "block",
|
|
69
69
|
status: "FAIL",
|
|
70
70
|
code: "WORKTREE_REQUIRED",
|
|
71
|
-
remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
|
|
71
|
+
remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path; otherwise use it as every command's working directory), and edit there.`
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
-
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
6
|
+
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
7
7
|
/**
|
|
8
8
|
* A baseline no source state hashes to. A resumed session inherits whatever
|
|
9
9
|
* the last one left in the worktree, so none of it counts as "unchanged".
|
|
@@ -95,6 +95,35 @@ export function worktreeDoctorResult(statuses, now) {
|
|
|
95
95
|
remediation: "Resume, finish or remove them; see agent-ops worktree list."
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
+
/** Branches checked out in more than one worktree. Scans every worktree: Git's branch lock does not distinguish agent-ops checkouts from foreign ones. */
|
|
99
|
+
export function detectDuplicateBranchCheckouts(porcelain) {
|
|
100
|
+
const byBranch = new Map();
|
|
101
|
+
for (const entry of parseWorktreeListPorcelain(porcelain)) {
|
|
102
|
+
if (entry.branch === null)
|
|
103
|
+
continue;
|
|
104
|
+
const paths = byBranch.get(entry.branch) ?? [];
|
|
105
|
+
paths.push(entry.path);
|
|
106
|
+
byBranch.set(entry.branch, paths);
|
|
107
|
+
}
|
|
108
|
+
return [...byBranch]
|
|
109
|
+
.filter(([, paths]) => paths.length > 1)
|
|
110
|
+
.map(([branch, paths]) => ({ branch, paths: [...paths].sort() }))
|
|
111
|
+
.sort((left, right) => left.branch.localeCompare(right.branch));
|
|
112
|
+
}
|
|
113
|
+
/** Doctor's reading of the branch lock: a duplicated checkout blocks switching back, so it fails. */
|
|
114
|
+
export function branchLockDoctorResult(porcelain) {
|
|
115
|
+
const duplicates = detectDuplicateBranchCheckouts(porcelain);
|
|
116
|
+
if (duplicates.length === 0) {
|
|
117
|
+
return { status: "PASS", message: "No branch is checked out in more than one worktree." };
|
|
118
|
+
}
|
|
119
|
+
const detail = duplicates.map(({ branch, paths }) => `${branch} (${paths.join(", ")})`).join("; ");
|
|
120
|
+
return {
|
|
121
|
+
status: "FAIL",
|
|
122
|
+
message: `Branch checked out more than once: ${detail}.`,
|
|
123
|
+
code: "WORKTREE_BRANCH_LOCKED",
|
|
124
|
+
remediation: "Run git worktree list; switch the extra checkout to another branch, or remove it (agent-ops worktree remove <name> --force discards that work)."
|
|
125
|
+
};
|
|
126
|
+
}
|
|
98
127
|
async function requireRecord(mainRoot, name) {
|
|
99
128
|
const record = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
100
129
|
if (record === null) {
|
|
@@ -17,6 +17,7 @@ export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
|
|
|
17
17
|
const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
|
|
18
18
|
const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
19
19
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
20
|
+
const TARGET_BRANCH = /^[A-Za-z0-9._/-]{1,128}$/u;
|
|
20
21
|
const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
22
|
function worktreeError(code, message) {
|
|
22
23
|
return new AgentOpsError(code, message);
|
|
@@ -61,6 +62,40 @@ export function worktreePath(mainRoot, name) {
|
|
|
61
62
|
export function insideWorktreeDirectory(mainRoot, path) {
|
|
62
63
|
return path.startsWith(`${join(mainRoot, WORKTREE_DIRECTORY)}${sep}`);
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Parses `git worktree list --porcelain` into path/branch pairs.
|
|
67
|
+
* Detached checkouts yield a null branch.
|
|
68
|
+
*/
|
|
69
|
+
export function parseWorktreeListPorcelain(output) {
|
|
70
|
+
const entries = [];
|
|
71
|
+
let currentPath;
|
|
72
|
+
let currentBranch;
|
|
73
|
+
const flush = () => {
|
|
74
|
+
if (currentPath !== undefined) {
|
|
75
|
+
entries.push({ path: currentPath, branch: currentBranch ?? null });
|
|
76
|
+
}
|
|
77
|
+
currentPath = undefined;
|
|
78
|
+
currentBranch = undefined;
|
|
79
|
+
};
|
|
80
|
+
for (const line of output.split("\n")) {
|
|
81
|
+
if (line.startsWith("worktree ")) {
|
|
82
|
+
flush();
|
|
83
|
+
currentPath = line.slice("worktree ".length).trim();
|
|
84
|
+
}
|
|
85
|
+
else if (line.startsWith("branch ")) {
|
|
86
|
+
const ref = line.slice("branch ".length).trim();
|
|
87
|
+
currentBranch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
88
|
+
}
|
|
89
|
+
else if (line === "detached") {
|
|
90
|
+
currentBranch = null;
|
|
91
|
+
}
|
|
92
|
+
else if (line === "") {
|
|
93
|
+
flush();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
return entries.filter((entry) => entry.path.length > 0);
|
|
98
|
+
}
|
|
64
99
|
async function exists(path) {
|
|
65
100
|
try {
|
|
66
101
|
await lstat(path);
|
|
@@ -186,18 +221,37 @@ export async function removeCheckout(deps, record, force) {
|
|
|
186
221
|
await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
|
|
187
222
|
}
|
|
188
223
|
/**
|
|
189
|
-
* Creates `.worktrees/<name>` on `agent-ops/<name>` from
|
|
190
|
-
*
|
|
224
|
+
* Creates `.worktrees/<name>` on `agent-ops/<name>` from `from` (default HEAD)
|
|
225
|
+
* and makes it a working agent-ops checkout for `sessionId`. When the main
|
|
226
|
+
* checkout is detached, `targetBranch` names the branch `finish` merges back
|
|
227
|
+
* into. A branch already checked out in another worktree is rejected before
|
|
228
|
+
* Git runs, so the failure names the conflicting path.
|
|
191
229
|
*/
|
|
192
230
|
export async function addWorktree(deps, options) {
|
|
193
231
|
const name = assertWorktreeName(options.name);
|
|
194
232
|
const sessionId = assertSessionId(options.sessionId);
|
|
233
|
+
if (options.targetBranch !== undefined && !TARGET_BRANCH.test(options.targetBranch)) {
|
|
234
|
+
throw worktreeError("WORKTREE_TARGET_INVALID", `Invalid --target-branch ${options.targetBranch}; use 1-128 letters, digits, '.', '_', '/' or '-'.`);
|
|
235
|
+
}
|
|
236
|
+
if (options.from !== undefined && options.from.trim() === "") {
|
|
237
|
+
throw worktreeError("WORKTREE_BASE_INVALID", "Invalid --from: a commit hash, branch or tag is required.");
|
|
238
|
+
}
|
|
195
239
|
const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
|
|
196
240
|
if (currentRoot !== mainRoot) {
|
|
197
241
|
throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
|
|
198
242
|
}
|
|
199
|
-
const
|
|
200
|
-
const
|
|
243
|
+
const headRef = await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
244
|
+
const targetBranch = options.targetBranch ?? headRef.stdout.trim();
|
|
245
|
+
if (targetBranch === "") {
|
|
246
|
+
throw worktreeError("WORKTREE_TARGET_REQUIRED", "The main checkout is detached; pass --target-branch <branch> naming the branch finish merges back into.");
|
|
247
|
+
}
|
|
248
|
+
if (!TARGET_BRANCH.test(targetBranch)) {
|
|
249
|
+
throw worktreeError("WORKTREE_TARGET_INVALID", `Invalid target branch ${targetBranch}; use 1-128 letters, digits, '.', '_', '/' or '-'.`);
|
|
250
|
+
}
|
|
251
|
+
const fromRef = options.from ?? "HEAD";
|
|
252
|
+
const base = await git(deps, mainRoot, ["rev-parse", "--verify", `${fromRef}^{commit}`], options.from === undefined ? "WORKTREE_NO_COMMIT" : "WORKTREE_BASE_INVALID", options.from === undefined
|
|
253
|
+
? "The main checkout has no commit to branch from."
|
|
254
|
+
: `Cannot resolve --from ${options.from} to a commit.`);
|
|
201
255
|
const path = worktreePath(mainRoot, name);
|
|
202
256
|
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
203
257
|
if (await exists(path)) {
|
|
@@ -206,6 +260,17 @@ export async function addWorktree(deps, options) {
|
|
|
206
260
|
if ((await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).exitCode === 0) {
|
|
207
261
|
throw worktreeError("WORKTREE_EXISTS", `Branch ${branch} already exists; pick another name.`);
|
|
208
262
|
}
|
|
263
|
+
const listing = await deps.git(mainRoot, ["worktree", "list", "--porcelain"]);
|
|
264
|
+
if (listing.exitCode === 0) {
|
|
265
|
+
for (const entry of parseWorktreeListPorcelain(listing.stdout)) {
|
|
266
|
+
if (entry.branch !== targetBranch && entry.branch !== branch)
|
|
267
|
+
continue;
|
|
268
|
+
const listed = await realpath(entry.path).catch(() => entry.path);
|
|
269
|
+
if (listed === mainRoot)
|
|
270
|
+
continue;
|
|
271
|
+
throw worktreeError("WORKTREE_BRANCH_CHECKED_OUT", `Branch ${entry.branch} is already checked out at ${entry.path}; switch it back, finish or remove that worktree first (see agent-ops worktree list).`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
209
274
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
210
275
|
const mainTrust = await deps.trust.status(mainRoot, mainConfig);
|
|
211
276
|
const setupSteps = mainConfig.worktree?.setup ?? [];
|
|
@@ -34,6 +34,30 @@ export async function probeReviewTarget(target, options) {
|
|
|
34
34
|
? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
|
|
35
35
|
: options.cwd;
|
|
36
36
|
try {
|
|
37
|
+
// agy's sandbox only answers inside a project-shaped directory: an empty
|
|
38
|
+
// temp dir hangs or denies file access, which this probe then misreads as
|
|
39
|
+
// unauthenticated. Cloning first matches the review attempt, which already
|
|
40
|
+
// runs inside a disposable clone; a failed clone falls back to the plain
|
|
41
|
+
// temp dir, which is exactly today's behavior.
|
|
42
|
+
let invocationCwd = directory;
|
|
43
|
+
if (deep && target === "agy") {
|
|
44
|
+
const clone = join(directory, "repository");
|
|
45
|
+
const cloned = await runVerificationCommand({
|
|
46
|
+
id: "review-probe-clone",
|
|
47
|
+
command: "git",
|
|
48
|
+
args: ["clone", "--no-hardlinks", "--quiet", "--", options.cwd, clone],
|
|
49
|
+
cwd: directory,
|
|
50
|
+
required: true,
|
|
51
|
+
evidence: { kind: "exit-code" },
|
|
52
|
+
timeoutMs: Math.min(probeTimeoutMs(options.timeoutMs), 30_000)
|
|
53
|
+
}, {
|
|
54
|
+
cwd: directory,
|
|
55
|
+
...(options.runner === undefined ? {} : { runner: options.runner })
|
|
56
|
+
});
|
|
57
|
+
if (cloned.status === "PASS") {
|
|
58
|
+
invocationCwd = clone;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
37
61
|
const invocation = buildProbeInvocation({
|
|
38
62
|
target,
|
|
39
63
|
prompt: PROBE_PROMPT,
|
|
@@ -48,12 +72,12 @@ export async function probeReviewTarget(target, options) {
|
|
|
48
72
|
id: `review-probe-${target}`,
|
|
49
73
|
command: invocation.command,
|
|
50
74
|
args: deep ? [...invocation.args] : ["--version"],
|
|
51
|
-
cwd:
|
|
75
|
+
cwd: invocationCwd,
|
|
52
76
|
required: true,
|
|
53
77
|
evidence: { kind: "exit-code" },
|
|
54
78
|
timeoutMs: probeTimeoutMs(options.timeoutMs)
|
|
55
79
|
}, {
|
|
56
|
-
cwd:
|
|
80
|
+
cwd: invocationCwd,
|
|
57
81
|
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
58
82
|
...(deep
|
|
59
83
|
? {
|
|
@@ -593,7 +593,7 @@ export function validateEvidence(value) {
|
|
|
593
593
|
"taskId",
|
|
594
594
|
"testCount",
|
|
595
595
|
"toolVersions"
|
|
596
|
-
], EVIDENCE_SCHEMA_VERSION);
|
|
596
|
+
], isRecord(value) && value.schemaVersion === 2 ? 2 : EVIDENCE_SCHEMA_VERSION);
|
|
597
597
|
if (isFailure(root)) {
|
|
598
598
|
return root;
|
|
599
599
|
}
|
|
@@ -25,6 +25,8 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
25
25
|
if (!validation.ok)
|
|
26
26
|
continue;
|
|
27
27
|
const evidence = validation.value;
|
|
28
|
+
if (command.evidence.kind === "test-count" && evidence.schemaVersion === 2)
|
|
29
|
+
continue;
|
|
28
30
|
if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
|
|
29
31
|
evidence.commandId === command.id && evidence.configHash === configHash &&
|
|
30
32
|
evidence.sourceFingerprint === sourceFingerprint &&
|
|
@@ -4,7 +4,7 @@ import { AgentOpsError } from "../fs/paths.js";
|
|
|
4
4
|
import { validateEvidence, validateTask } from "../schema/validate.js";
|
|
5
5
|
import { renderTaskMarkdown } from "./render.js";
|
|
6
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
|
|
7
|
-
import { calculateConfigHash, FileEvidenceStore } from "../verify/evidence.js";
|
|
7
|
+
import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
8
8
|
import { resolveReviewScope } from "../review/scope.js";
|
|
9
9
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
10
10
|
import { advanceFailureFingerprint } from "../verify/fingerprint.js";
|
|
@@ -353,6 +353,82 @@ export class TaskService {
|
|
|
353
353
|
return cloneRecord(updated);
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
+
/** Append a fresh verification to a completed task without reopening it. */
|
|
357
|
+
async recordVerificationEvidence(snapshot, evidenceInput, sourceFingerprint) {
|
|
358
|
+
if (snapshot.status === "active") {
|
|
359
|
+
const result = await this.recordEvidence(snapshot.task.id, evidenceInput);
|
|
360
|
+
await this.clearFailure(snapshot.task.id);
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
const completion = this.#completion;
|
|
364
|
+
if (snapshot.status !== "complete" || completion === undefined) {
|
|
365
|
+
throw taskError("TASK_NOT_ACTIVE", "Completed reverification requires repository context; archived tasks are immutable.");
|
|
366
|
+
}
|
|
367
|
+
const config = await completion.loadConfig();
|
|
368
|
+
const configHash = calculateConfigHash(config);
|
|
369
|
+
if (snapshot.policyConfigHash !== configHash) {
|
|
370
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task config changed; create a new task for the new scope.");
|
|
371
|
+
}
|
|
372
|
+
const store = new FileEvidenceStore(completion.root, completion.root);
|
|
373
|
+
const matches = (value, criterionId, commandId) => {
|
|
374
|
+
const validation = validateEvidence(value);
|
|
375
|
+
return validation.ok && validation.value.taskId === snapshot.task.id &&
|
|
376
|
+
validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
|
|
377
|
+
validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint
|
|
378
|
+
? validation.value : null;
|
|
379
|
+
};
|
|
380
|
+
for (const criterion of snapshot.task.criteria) {
|
|
381
|
+
const commands = config.verification.commands.filter(({ id, required }) => required && criterion.verifierIds.includes(id));
|
|
382
|
+
if (commands.length === 0)
|
|
383
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
|
|
384
|
+
for (const command of commands) {
|
|
385
|
+
let original = false;
|
|
386
|
+
let fresh = false;
|
|
387
|
+
for (const reference of snapshot.evidence[criterion.id] ?? []) {
|
|
388
|
+
if (reference.startsWith("review:"))
|
|
389
|
+
continue;
|
|
390
|
+
const evidence = matches(await store.load(reference), criterion.id, command.id);
|
|
391
|
+
// Legacy counts identify the original scope only; they do not prove fresh PASS.
|
|
392
|
+
if (evidence !== null && isPassingVerificationEvidence({ ...command, evidence: { kind: "exit-code" } }, evidence))
|
|
393
|
+
original = true;
|
|
394
|
+
}
|
|
395
|
+
for (const reference of evidenceInput[criterion.id] ?? []) {
|
|
396
|
+
const evidence = matches(await store.load(reference), criterion.id, command.id);
|
|
397
|
+
if (evidence !== null && evidence.schemaVersion === 3 &&
|
|
398
|
+
!snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
|
|
399
|
+
fresh = true;
|
|
400
|
+
}
|
|
401
|
+
if (!original || !fresh)
|
|
402
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task source or evidence changed; fresh PASS must cover the original scope and every required verifier.");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const appended = normalizeEvidence(snapshot.task, evidenceInput);
|
|
406
|
+
for (const [criterionId, references] of Object.entries(appended)) {
|
|
407
|
+
for (const reference of references) {
|
|
408
|
+
const validation = validateEvidence(await store.load(reference));
|
|
409
|
+
if (!validation.ok || !snapshot.task.criteria.find(({ id }) => id === criterionId)?.verifierIds.includes(validation.value.commandId) || matches(validation.value, criterionId, validation.value.commandId) === null) {
|
|
410
|
+
throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
415
|
+
...(completion.base === undefined ? {} : { base: completion.base }) });
|
|
416
|
+
if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
|
|
417
|
+
configHash !== calculateConfigHash(await completion.loadConfig())) {
|
|
418
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
|
|
419
|
+
}
|
|
420
|
+
const now = assertTimestamp(this.#now());
|
|
421
|
+
return await this.#store.mutate((state) => {
|
|
422
|
+
if (JSON.stringify(findTask(state, snapshot.task.id)) !== JSON.stringify(snapshot)) {
|
|
423
|
+
throw taskError("TASK_REVERIFICATION_STATE_CHANGED", "Task changed during reverification; retry without discarding the concurrent state.");
|
|
424
|
+
}
|
|
425
|
+
const updated = { ...snapshot, updatedAt: now, failureFingerprint: null,
|
|
426
|
+
evidence: Object.fromEntries(snapshot.task.criteria.map(({ id }) => [id,
|
|
427
|
+
[...new Set([...(snapshot.evidence[id] ?? []), ...appended[id]])]])) };
|
|
428
|
+
replaceTask(state, updated);
|
|
429
|
+
return cloneRecord(updated);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
356
432
|
async archive(taskId) {
|
|
357
433
|
const now = assertTimestamp(this.#now());
|
|
358
434
|
return await this.#store.mutate((state) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { runVerificationCommand } from "./spawn.js";
|
|
2
|
-
import { evaluateTestCount,
|
|
2
|
+
import { evaluateTestCount, parseTestSummary } from "./test-count.js";
|
|
3
3
|
function classifyTestCountCode(code) {
|
|
4
4
|
const classes = {
|
|
5
5
|
TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
|
|
@@ -43,7 +43,8 @@ function classifyCommand(command, spawned) {
|
|
|
43
43
|
testCount: null
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
const
|
|
46
|
+
const summary = parseTestSummary(`${spawned.stdout}\n${spawned.stderr}`);
|
|
47
|
+
const testCount = summary.passed;
|
|
47
48
|
if (spawned.status !== "PASS") {
|
|
48
49
|
return {
|
|
49
50
|
status: spawned.status,
|
|
@@ -51,6 +52,9 @@ function classifyCommand(command, spawned) {
|
|
|
51
52
|
testCount
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
if (summary.failed > 0) {
|
|
56
|
+
return { status: "FAIL", failureClass: "test-failures", testCount };
|
|
57
|
+
}
|
|
54
58
|
const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
|
|
55
59
|
return {
|
|
56
60
|
status: evaluation.status,
|
|
@@ -54,7 +54,8 @@ export function isPassingVerificationEvidence(command, evidence) {
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
return command.evidence.kind !== "test-count" ||
|
|
57
|
-
|
|
57
|
+
(evidence.schemaVersion === EVIDENCE_SCHEMA_VERSION &&
|
|
58
|
+
evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS");
|
|
58
59
|
}
|
|
59
60
|
export class FileEvidenceStore {
|
|
60
61
|
#root;
|
|
@@ -113,6 +113,10 @@ export class VerificationService {
|
|
|
113
113
|
throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
|
|
114
114
|
"Task and verification configuration are incompatible.");
|
|
115
115
|
}
|
|
116
|
+
const missingCriteria = validation.value.criteria.filter((criterion) => !this.#options.config.verification.commands.some(({ id, required }) => required && criterion.verifierIds.includes(id)));
|
|
117
|
+
if (missingCriteria.length > 0) {
|
|
118
|
+
throw verificationError("VERIFICATION_UNKNOWN", `Criteria without required coverage: ${missingCriteria.map(({ id }) => id).join(", ")}. Configure at least one required verifier for each criterion and recreate the task; no verifier commands were run.`);
|
|
119
|
+
}
|
|
116
120
|
const reviewScope = await resolveReviewScope({
|
|
117
121
|
root: this.#options.root,
|
|
118
122
|
runner: this.#options.gitRunner,
|
|
@@ -167,9 +171,8 @@ export class VerificationService {
|
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
if (Object.keys(taskEvidence).length > 0) {
|
|
170
|
-
await this.#options.taskService.
|
|
174
|
+
await this.#options.taskService.recordVerificationEvidence(stored, taskEvidence, sourceFingerprint);
|
|
171
175
|
}
|
|
172
|
-
await this.#options.taskService.clearFailure(taskId);
|
|
173
176
|
}
|
|
174
177
|
else {
|
|
175
178
|
const required = results.filter((result) => result.required);
|
|
@@ -1,108 +1,108 @@
|
|
|
1
1
|
const MAX_SUMMARY_BYTES = 1024 * 1024;
|
|
2
|
-
const NUMBER_SOURCE = String.raw `\d
|
|
2
|
+
const NUMBER_SOURCE = String.raw `\d+`;
|
|
3
3
|
function safeCount(value) {
|
|
4
4
|
const parsed = Number(value);
|
|
5
5
|
return Number.isSafeInteger(parsed) && parsed >= 0
|
|
6
6
|
? parsed
|
|
7
7
|
: null;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
9
|
+
/** Counts outcomes, never discovery totals. Conflicting summaries fail closed. */
|
|
10
|
+
export function parseTestSummary(output) {
|
|
11
|
+
if (output.includes("\0") || Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
|
|
12
|
+
return { passed: null, failed: 0 };
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
const summaries = [];
|
|
15
|
+
let nodePassed = null;
|
|
16
|
+
let nodeFailed = 0;
|
|
17
|
+
let nodeSeen = false;
|
|
18
|
+
let invalid = false;
|
|
19
|
+
const nodeCounts = new Map();
|
|
20
|
+
for (const raw of output.replace(/\u001b\[[0-9;]*m/gu, "").split(/\r?\n/u)) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
const node = new RegExp(`^(?:#|ℹ) (tests|pass|fail|cancelled|skipped|todo) (${NUMBER_SOURCE})$`, "u").exec(line);
|
|
23
|
+
if (node !== null) {
|
|
24
|
+
nodeSeen = true;
|
|
25
|
+
const count = safeCount(node[2]);
|
|
26
|
+
if (count === null || (nodeCounts.has(node[1]) && nodeCounts.get(node[1]) !== count))
|
|
27
|
+
invalid = true;
|
|
28
|
+
else
|
|
29
|
+
nodeCounts.set(node[1], count);
|
|
30
|
+
continue;
|
|
27
31
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
total > Number.MAX_SAFE_INTEGER - count)) {
|
|
32
|
-
return null;
|
|
32
|
+
if (/^(?:#|ℹ) (?:tests|pass|fail|cancelled|skipped|todo)\b/u.test(line)) {
|
|
33
|
+
invalid = true;
|
|
34
|
+
continue;
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const rust = new RegExp(`^test result: (ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed;`, "u").exec(line);
|
|
37
|
+
if (rust !== null) {
|
|
38
|
+
const passed = safeCount(rust[2]);
|
|
39
|
+
const failed = safeCount(rust[3]);
|
|
40
|
+
if (passed === null || failed === null)
|
|
41
|
+
invalid = true;
|
|
42
|
+
summaries.push({ passed, failed: Math.max(failed ?? 0, rust[1] === "FAILED" ? 1 : 0) });
|
|
43
|
+
continue;
|
|
36
44
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
const isJest = /^Tests:/u.test(line);
|
|
46
|
+
const isVitest = /^Tests\s+/u.test(line) && /\(\d+\)\s*$/u.test(line);
|
|
47
|
+
const isPytest = /\bin \d+(?:\.\d+)?s(?:\s|=|$)/u.test(line);
|
|
48
|
+
if (isJest || isVitest || isPytest) {
|
|
49
|
+
let passed = 0;
|
|
50
|
+
let failed = 0;
|
|
51
|
+
let matched = false;
|
|
52
|
+
let total = 0;
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const match of line.matchAll(new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|todo|error|errors|cancelled|xfailed|xpassed|deselected)\\b`, "gu"))) {
|
|
55
|
+
matched = true;
|
|
56
|
+
const count = safeCount(match[1]);
|
|
57
|
+
const status = match[2];
|
|
58
|
+
if (count === null || seen.has(status)) {
|
|
59
|
+
invalid = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(status);
|
|
63
|
+
if (status !== "deselected")
|
|
64
|
+
total += count;
|
|
65
|
+
if (!Number.isSafeInteger(total))
|
|
66
|
+
invalid = true;
|
|
67
|
+
if (status === "passed")
|
|
68
|
+
passed = count;
|
|
69
|
+
if (["failed", "error", "errors", "cancelled"].includes(status)) {
|
|
70
|
+
failed += count;
|
|
71
|
+
if (!Number.isSafeInteger(failed))
|
|
72
|
+
invalid = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const totalMatch = isJest ? /\b(\d+) total(?:\s|$)/u.exec(line) : isVitest ? /\((\d+)\)\s*$/u.exec(line) : null;
|
|
76
|
+
if (matched && totalMatch !== null && safeCount(totalMatch[1]) !== total)
|
|
77
|
+
invalid = true;
|
|
78
|
+
// Missing passed in a recognized outcome summary means zero passes;
|
|
79
|
+
// a discovery/total-only line gives no outcome evidence.
|
|
80
|
+
summaries.push({ passed: matched ? passed : null, failed });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (/^collected 0 items?$/u.test(line))
|
|
84
|
+
summaries.push({ passed: 0, failed: 0 });
|
|
45
85
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
if (nodeSeen) {
|
|
87
|
+
nodePassed = nodeCounts.get("pass") ?? (nodeCounts.get("tests") === 0 ? 0 : null);
|
|
88
|
+
nodeFailed = (nodeCounts.get("fail") ?? 0) + (nodeCounts.get("cancelled") ?? 0);
|
|
89
|
+
if (!Number.isSafeInteger(nodeFailed))
|
|
90
|
+
invalid = true;
|
|
91
|
+
const total = nodeCounts.get("tests");
|
|
92
|
+
const outcomes = (nodePassed ?? 0) + nodeFailed + (nodeCounts.get("skipped") ?? 0) + (nodeCounts.get("todo") ?? 0);
|
|
93
|
+
if (!Number.isSafeInteger(outcomes) || (total !== undefined && outcomes > total))
|
|
94
|
+
invalid = true;
|
|
95
|
+
summaries.push({ passed: nodePassed, failed: nodeFailed });
|
|
50
96
|
}
|
|
51
|
-
const
|
|
52
|
-
const failed =
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
passed > Number.MAX_SAFE_INTEGER - failed) {
|
|
56
|
-
return null;
|
|
97
|
+
const first = summaries[0];
|
|
98
|
+
const failed = summaries.some((summary) => summary.failed > 0) ? 1 : 0;
|
|
99
|
+
if (invalid || first === undefined || summaries.some((summary) => summary.passed !== first.passed || summary.failed !== first.failed)) {
|
|
100
|
+
return { passed: null, failed };
|
|
57
101
|
}
|
|
58
|
-
return passed
|
|
102
|
+
return { passed: first.passed, failed: first.failed };
|
|
59
103
|
}
|
|
60
104
|
export function parseTestCount(output) {
|
|
61
|
-
|
|
62
|
-
Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
const candidates = [];
|
|
66
|
-
const nodePattern = new RegExp(`^# tests (${NUMBER_SOURCE})$`, "u");
|
|
67
|
-
const collectedPattern = new RegExp(`^collected (${NUMBER_SOURCE}) items?$`, "u");
|
|
68
|
-
const jestPattern = new RegExp(`^Tests:\\s+.*\\b(${NUMBER_SOURCE}) total(?:\\s|$)`, "u");
|
|
69
|
-
const vitestPattern = new RegExp(`^Tests\\s+.*\\((${NUMBER_SOURCE})\\)\\s*$`, "u");
|
|
70
|
-
for (const rawLine of output.split(/\r?\n/u)) {
|
|
71
|
-
const line = rawLine.trim();
|
|
72
|
-
if (line.length === 0) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const node = nodePattern.exec(line);
|
|
76
|
-
if (node?.[1] !== undefined) {
|
|
77
|
-
addCandidate(candidates, node[1]);
|
|
78
|
-
}
|
|
79
|
-
const collected = collectedPattern.exec(line);
|
|
80
|
-
if (collected?.[1] !== undefined) {
|
|
81
|
-
addCandidate(candidates, collected[1]);
|
|
82
|
-
}
|
|
83
|
-
const jest = jestPattern.exec(line);
|
|
84
|
-
if (jest?.[1] !== undefined) {
|
|
85
|
-
addCandidate(candidates, jest[1]);
|
|
86
|
-
}
|
|
87
|
-
const vitest = vitestPattern.exec(line);
|
|
88
|
-
if (vitest?.[1] !== undefined) {
|
|
89
|
-
addCandidate(candidates, vitest[1]);
|
|
90
|
-
}
|
|
91
|
-
const pytest = parsePytestSummary(line);
|
|
92
|
-
if (pytest !== null) {
|
|
93
|
-
candidates.push(pytest);
|
|
94
|
-
}
|
|
95
|
-
const rust = parseRustSummary(line);
|
|
96
|
-
if (rust !== null) {
|
|
97
|
-
candidates.push(rust);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const first = candidates[0];
|
|
101
|
-
if (first === undefined ||
|
|
102
|
-
candidates.some((candidate) => candidate !== first)) {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
return first;
|
|
105
|
+
return parseTestSummary(output).passed;
|
|
106
106
|
}
|
|
107
107
|
export function evaluateTestCount(testCount, minimum = 1) {
|
|
108
108
|
if (!Number.isSafeInteger(minimum) || minimum < 0) {
|
|
@@ -149,17 +149,18 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
149
149
|
|
|
150
150
|
- `mode: "auto"` routes every editing conversation through
|
|
151
151
|
`agent-ops worktree add <name> --session <id>`, run from the main checkout.
|
|
152
|
-
On Claude Code a direct Edit or Write of the main checkout outside
|
|
152
|
+
On Claude Code and opencode a direct Edit or Write of the main checkout outside
|
|
153
153
|
`.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
|
|
154
154
|
matches the file tools). Absent or `"off"` keeps today's single checkout.
|
|
155
155
|
- `agent-ops task create` run from the main checkout with a known session
|
|
156
156
|
creates that session's worktree first (or reuses it) and records the task
|
|
157
157
|
there, so the work starts in the worktree. Its output names the path to
|
|
158
158
|
enter and the `--base` to verify against.
|
|
159
|
-
- As a fallback on Claude Code, the first denied edit creates that worktree:
|
|
159
|
+
- As a fallback on Claude Code and opencode, the first denied edit creates that worktree:
|
|
160
160
|
`.worktrees/session-<first eight characters of the session id>`, setup
|
|
161
161
|
included, bound to the session. The denial names the path to enter with
|
|
162
|
-
EnterWorktree
|
|
162
|
+
EnterWorktree on Claude Code, or to use as every command's working directory
|
|
163
|
+
otherwise; a later denial for the same session reuses it. If creation
|
|
163
164
|
fails, the denial says why and falls back to the manual `worktree add`.
|
|
164
165
|
Run `agent-ops update` once so the PreToolUse hook gets the 600-second
|
|
165
166
|
timeout setup needs.
|
|
@@ -33,10 +33,14 @@ The verifier MUST return FAIL when a criterion is missing, duplicated, unknown,
|
|
|
33
33
|
- Every criterion needs at least one required verifier and current PASS evidence for every required verifier it names. Verify always includes those task-required commands, even when path mappings select a narrower set. Supply the evidence file references produced by `agent-ops verify --task <id>`, not command strings or a written assertion of PASS.
|
|
34
34
|
- The task's config baseline and evidence must match the current effective config and source fingerprint in the selected scope. Outstanding verification failures block completion. A newer failing result cannot be replaced by an older PASS; contradictory results at the same timestamp fail closed.
|
|
35
35
|
- Run `agent-ops review --task <id> --yes` for the whole task. Completion requires its current, task-bound PASS attestation; generic reviews and partial-criterion reviews do not satisfy it.
|
|
36
|
-
- Starting
|
|
36
|
+
- An unchanged task, source, criteria and config may reuse its recorded PASS review after fresh verification. Starting a fresh authorized review (`--rerun`) invalidates the previous attestation for that source fingerprint. If the reviewer cannot run or does not pass, run a fresh successful whole-task review before completion.
|
|
37
37
|
- All subtasks and descendants must have completed without an outstanding failure. Archiving unfinished work does not satisfy this requirement. A completed or archived parent cannot accept new subtasks. If a subtask is abandoned, recreate the parent task without that cancelled branch and verify/review the new task tree.
|
|
38
38
|
- Evidence, source and config validation run outside the task store lock. After rechecking source and config, completion briefly locks state, rejects a changed task snapshot (`TASK_COMPLETION_STATE_CHANGED`), rechecks descendants, and writes completion atomically. Slow validation does not block task reads or other task mutations; a concurrent mutation remains intact when completion is rejected.
|
|
39
39
|
|
|
40
|
+
`test-count.minimum` counts explicitly passed tests, excluding skipped, todo, expected failures and discovery totals. Zero passed tests fail even with minimum zero; an unrecognizable passed summary is UNKNOWN. Explicit failed, error or cancelled outcomes fail even when the process exits zero. Exit-code verifiers retain their existing behavior. Task verify returns `VERIFICATION_UNKNOWN`, names uncovered criteria and runs no verifier commands when any criterion lacks a required verifier; optional-only results cannot establish formal task verification.
|
|
41
|
+
|
|
42
|
+
New verification evidence uses schema 3. Schema 2 remains readable and its exit-code evidence remains valid; schema 2 test-count evidence cannot establish PASS and requires fresh verification. Old references are retained. A completed task may append fresh verification only for its original source, criteria and config, preserving completedAt and review references. Its entire state snapshot is compared atomically before appending and clearing failure; concurrent changes are preserved and rejected. Fresh FAIL or UNKNOWN blocks completion until resolved; archived tasks remain immutable. Source or config changes require a new task.
|
|
43
|
+
|
|
40
44
|
Existing task files remain readable without migration. Legacy tasks without a config baseline, arbitrary evidence references, or unfinished archived children must be recreated and verified/reviewed before completion. Repeating completion also revalidates the evidence; an old completed status alone is insufficient. Runtime callers must supply `TaskServiceOptions.completion`; omitting this repository context fails closed for completion while leaving read-only task operations available.
|
|
41
45
|
|
|
42
46
|
Before committing, use the default worktree scope for verify, review and completion. After committing, use the same `--base <git-ref>` on all three commands to produce fresh proof for that committed range; `TaskServiceOptions.completion.base` selects it for runtime callers. Base mode requires a clean worktree. Without a worktree change surface or an explicit base, completion returns `TASK_COMPLETION_SCOPE_REQUIRED`. The agy Stop gate still checks worktree-scoped proof: complete and stop before committing, or use its user-approved one-time permit after inspecting a completed base-scoped task.
|
|
@@ -132,16 +132,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
- `mode: "auto"` 讓每個修改中的對話都先在主 checkout 執行
|
|
135
|
-
`agent-ops worktree add <name> --session <id>`。Claude Code 直接以 Edit 或
|
|
135
|
+
`agent-ops worktree add <name> --session <id>`。Claude Code 與 opencode 直接以 Edit 或
|
|
136
136
|
Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
|
|
137
137
|
`agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
|
|
138
138
|
目前的單一 checkout。
|
|
139
139
|
- 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
|
|
140
140
|
該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
|
|
141
141
|
要進入的路徑與 verify 用的 `--base`。
|
|
142
|
-
- 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
142
|
+
- 作為 Claude Code 與 opencode 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
143
143
|
`.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
|
|
144
|
-
|
|
144
|
+
拒絕訊息會列出要進入的路徑(Claude Code 以 EnterWorktree 進入,其他 harness
|
|
145
|
+
則作為每條命令的工作目錄);同一 session 之後被拒絕時會重用它。
|
|
145
146
|
建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
|
|
146
147
|
`agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
|
|
147
148
|
- `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 驗收與證據
|
|
2
2
|
|
|
3
|
-
English source version: 2026-09-
|
|
3
|
+
English source version: 2026-09-27. Revalidate: when the English specification changes.
|
|
4
4
|
|
|
5
5
|
## EVIDENCE-CRITERION-001
|
|
6
6
|
|
|
@@ -35,10 +35,14 @@ English source version: 2026-09-07. Revalidate: when the English specification c
|
|
|
35
35
|
- 每項 criterion 至少需要一個 required verifier,且其列出的每個 required verifier 都要有目前有效的 PASS evidence。即使 path mapping 選出較小範圍,verify 也會包含 task 明列的 required 命令。請提供 `agent-ops verify --task <id>` 產生的證據檔參照,不能只填命令文字或自行宣稱 PASS。
|
|
36
36
|
- 任務的 config baseline、evidence 必須符合目前有效設定與所選 scope 的來源指紋。尚未解決的驗證失敗會阻擋完成;舊 PASS 不能蓋過較新的失敗,同時間戳的矛盾結果也會阻擋。
|
|
37
37
|
- 使用 `agent-ops review --task <id> --yes` 審查整個任務。完成時必須有目前有效且綁定該 task 的 PASS attestation;generic review 與只涵蓋部分 criterion 的 review 不算。
|
|
38
|
-
-
|
|
38
|
+
- task、來源、criteria 與 config 不變時,重新驗證後可沿用原 PASS review。開始新的已授權 review(`--rerun`)才會撤銷該來源指紋先前的 attestation。若 reviewer 未能執行或未通過,完成前必須重新取得成功的全任務 review。
|
|
39
39
|
- 所有子任務與後代任務都必須已完成,且沒有尚未解決的失敗。封存未完成工作不算完成;已完成或封存的 parent 不能新增子任務。若放棄某個 subtask,請重建不含該取消分支的 parent task,並重新驗證與審查新的 task tree。
|
|
40
40
|
- 證據、來源與設定檢查在 task store 鎖外執行。再次確認來源與設定後,完成操作才短暫鎖住狀態,比對 task 快照(改變時回傳 `TASK_COMPLETION_STATE_CHANGED`)、重查後代任務並原子寫入。慢速檢查不會阻擋 task 讀取或其他修改;完成被拒絕時,併發修改仍會保留。
|
|
41
41
|
|
|
42
|
+
`test-count.minimum` 只計明確 passed 的測試,排除 skipped、todo、預期失敗與 discovery total。零 passed 即使 minimum 為零也會 FAIL;無法辨識 passed 摘要時為 UNKNOWN。摘要有 failed、error 或 cancelled,即使 process exit code 為零也會 FAIL。exit-code verifier 保留既有行為。任一 criterion 缺少 required verifier 時,task verify 回傳 `VERIFICATION_UNKNOWN`、列出缺少 coverage 的 criterion,且不執行 verifier 命令;只有 optional 結果不能建立正式 task 的驗證 PASS。
|
|
43
|
+
|
|
44
|
+
新 verification evidence 使用 schema 3。Schema 2 仍可讀取,其 exit-code 證據仍有效;schema 2 test-count 證據不能建立 PASS,必須重新驗證。舊參照會保留。Completed task 只可為原來源、criteria 與 config 追加 fresh verification,保留 completedAt 與 review 參照。追加與清除 failure 前,會原子比對完整狀態快照;併發修改保留並拒絕本次追加。Fresh FAIL 或 UNKNOWN 在修正前阻擋完成;archived task 不可修改。來源或 config 改變時必須建立新 task。
|
|
45
|
+
|
|
42
46
|
既有 task 檔案仍可直接讀取,不需資料格式遷移。缺少 config baseline、使用任意文字證據,或含有未完成但已封存子任務的舊任務,必須重建並完成驗證與審查。重複執行 complete 也會重新驗證證據;舊的 completed 狀態本身不夠。Runtime 呼叫端必須提供 `TaskServiceOptions.completion`;缺少此 repository context 時,complete 會拒絕執行,但仍可讀取任務。
|
|
43
47
|
|
|
44
48
|
Commit 前,verify、review、complete 使用預設 worktree scope。Commit 後,三個命令都使用相同的 `--base <git-ref>`,為已提交範圍重新產生證據;runtime 呼叫端使用 `TaskServiceOptions.completion.base`。Base 模式要求 worktree 乾淨。沒有 worktree 變更又未指定 base 時,complete 會回傳 `TASK_COMPLETION_SCOPE_REQUIRED`。agy Stop gate 仍檢查 worktree scope 的證據:請在 commit 前完成並停止,或由使用者檢查已完成的 base-scope 任務後,核准一次性的 Stop permit。
|
package/package.json
CHANGED