@kylecheng3146/agent-ops 0.3.3 → 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.
@@ -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 files and .worktreeinclude matches,
196
- inherit trust for an identical config, run worktree.setup, and
197
- point the session's completion gate at the new worktree.
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.schemaVersion !== 2 ||
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 selected = newestEvidence(found.current);
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
  ];
@@ -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 = 2;
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,
@@ -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 the main checkout's
190
- * HEAD and makes it a working agent-ops checkout for `sessionId`.
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 targetBranch = await git(deps, mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"], "WORKTREE_DETACHED_HEAD", "The main checkout must be on a branch to merge back into.");
200
- const base = await git(deps, mainRoot, ["rev-parse", "--verify", "HEAD^{commit}"], "WORKTREE_NO_COMMIT", "The main checkout has no commit to branch from.");
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 ?? [];
@@ -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, parseTestCount } from "./test-count.js";
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 testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
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
- evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS";
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.recordEvidence(taskId, taskEvidence);
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{1,16}`;
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
- function addCandidate(candidates, source) {
10
- const parsed = safeCount(source);
11
- if (parsed !== null) {
12
- candidates.push(parsed);
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
- function parsePytestSummary(line) {
16
- if (!/\bin \d+(?:\.\d+)?s(?:\s|$)/u.test(line)) {
17
- return null;
18
- }
19
- const statusPattern = new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|error|errors|xfailed|xpassed|deselected)`, "gu");
20
- let total = 0;
21
- let matched = false;
22
- for (const match of line.matchAll(statusPattern)) {
23
- const source = match[1];
24
- const status = match[2];
25
- if (source === undefined || status === undefined) {
26
- return null;
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
- const count = safeCount(source);
29
- if (count === null ||
30
- (status !== "deselected" &&
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
- if (status !== "deselected") {
35
- total += count;
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
- matched = true;
38
- }
39
- return matched ? total : null;
40
- }
41
- function parseRustSummary(line) {
42
- const match = new RegExp(`^test result: (?:ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed; ${NUMBER_SOURCE} ignored; ${NUMBER_SOURCE} measured; ${NUMBER_SOURCE} filtered out$`, "u").exec(line);
43
- if (match === null) {
44
- return null;
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
- const passedSource = match[1];
47
- const failedSource = match[2];
48
- if (passedSource === undefined || failedSource === undefined) {
49
- return null;
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 passed = safeCount(passedSource);
52
- const failed = safeCount(failedSource);
53
- if (passed === null ||
54
- failed === null ||
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 + failed;
102
+ return { passed: first.passed, failed: first.failed };
59
103
  }
60
104
  export function parseTestCount(output) {
61
- if (output.includes("\0") ||
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) {
@@ -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 an authorized review 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.
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.
@@ -1,6 +1,6 @@
1
1
  # 驗收與證據
2
2
 
3
- English source version: 2026-09-07. Revalidate: when the English specification changes.
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
- - 開始一次已授權的 review 會撤銷該來源指紋先前的 attestation。若 reviewer 未能執行或未通過,完成前必須重新取得成功的全任務 review。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "properties": {
26
26
  "schemaVersion": {
27
- "const": 2
27
+ "enum": [2, 3]
28
28
  },
29
29
  "taskId": {
30
30
  "$ref": "#/$defs/id"