@kylecheng3146/agent-ops 0.1.21 → 0.1.24

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.
@@ -15,6 +15,14 @@ import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
15
15
  * review while looking like an unavailable target.
16
16
  */
17
17
  export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
18
+ export class ReviewInterruptedError extends Error {
19
+ signal;
20
+ constructor(signal) {
21
+ super("Independent review was interrupted.");
22
+ this.name = "ReviewInterruptedError";
23
+ this.signal = signal;
24
+ }
25
+ }
18
26
  // USER is load-bearing, not cosmetic: a credential store keyed by account name
19
27
  // — the macOS keychain claude reads — cannot be opened without it, and its
20
28
  // absence surfaces as "Not logged in" on an install that is logged in.
@@ -81,8 +89,7 @@ const REQUIRED_HELP_FLAGS = {
81
89
  */
82
90
  const ADVANCING = new Set([
83
91
  "missing-executable",
84
- "spawn-failed",
85
- "timeout"
92
+ "spawn-failed"
86
93
  ]);
87
94
  const DIAGNOSTIC_MAX_CHARS = 200;
88
95
  /**
@@ -114,6 +121,16 @@ function rejectedCallReason(output) {
114
121
  }
115
122
  return "capability-unavailable";
116
123
  }
124
+ function throwIfInterrupted(target, options, failureClass) {
125
+ if (failureClass !== "aborted" && options.signal?.aborted !== true) {
126
+ return;
127
+ }
128
+ const signal = typeof options.signal?.reason === "string"
129
+ ? options.signal.reason
130
+ : undefined;
131
+ options.onProgress?.(`${target}: review interrupted${signal === undefined ? "" : ` by ${signal}`}`);
132
+ throw new ReviewInterruptedError(signal);
133
+ }
117
134
  async function snapshotRepository(request, destination, options) {
118
135
  const cloned = await runVerificationCommand({
119
136
  id: `review-snapshot-${request.label}`,
@@ -125,8 +142,10 @@ async function snapshotRepository(request, destination, options) {
125
142
  timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 60_000)
126
143
  }, {
127
144
  cwd: dirname(destination),
128
- ...(options.runner === undefined ? {} : { runner: options.runner })
145
+ ...(options.runner === undefined ? {} : { runner: options.runner }),
146
+ ...(options.signal === undefined ? {} : { signal: options.signal })
129
147
  });
148
+ throwIfInterrupted(request.target, options, cloned.failureClass);
130
149
  if (cloned.status !== "PASS") {
131
150
  return firstComplaint(cloned.stderr, cloned.stdout) ??
132
151
  `git clone failed (${cloned.failureClass})`;
@@ -184,6 +203,8 @@ async function attemptTarget(request, options) {
184
203
  return skip("capability-unavailable", "no read-only mode is available for this target", "skipping");
185
204
  }
186
205
  const environment = isolatedReviewEnvironment(target, attemptDirectory, options.env ?? process.env);
206
+ throwIfInterrupted(target, options);
207
+ options.onProgress?.(`${target}: checking reviewer capability`);
187
208
  const capability = await runVerificationCommand({
188
209
  id: `review-capability-${request.label}`,
189
210
  command: invocation.command,
@@ -196,8 +217,10 @@ async function attemptTarget(request, options) {
196
217
  cwd: attemptDirectory,
197
218
  ...(options.runner === undefined ? {} : { runner: options.runner }),
198
219
  env: environment,
199
- replaceEnv: true
220
+ replaceEnv: true,
221
+ ...(options.signal === undefined ? {} : { signal: options.signal })
200
222
  });
223
+ throwIfInterrupted(target, options, capability.failureClass);
201
224
  const help = `${capability.stdout}\n${capability.stderr}`;
202
225
  const missingFlags = (REQUIRED_HELP_FLAGS[target] ?? []).filter((flag) => !help.includes(flag));
203
226
  if (capability.status !== "PASS" ||
@@ -238,6 +261,8 @@ async function attemptTarget(request, options) {
238
261
  if (invocation === undefined) {
239
262
  return skip("capability-unavailable", "review invocation disappeared", "skipping");
240
263
  }
264
+ throwIfInterrupted(target, options);
265
+ options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
241
266
  const spawned = await runVerificationCommand({
242
267
  id: `review-${request.label}`,
243
268
  command: invocation.command,
@@ -254,8 +279,13 @@ async function attemptTarget(request, options) {
254
279
  : { outputLimitBytes: options.outputLimitBytes }),
255
280
  stdin: invocation.stdin,
256
281
  env: environment,
257
- replaceEnv: true
282
+ replaceEnv: true,
283
+ ...(options.signal === undefined ? {} : { signal: options.signal })
258
284
  });
285
+ throwIfInterrupted(target, options, spawned.failureClass);
286
+ if (spawned.failureClass === "timeout") {
287
+ return skip("timeout", "the reviewer exceeded its timeout");
288
+ }
259
289
  if (ADVANCING.has(spawned.failureClass)) {
260
290
  return {
261
291
  ...skip("missing-cli", `the process did not complete: ${spawned.failureClass}`),
@@ -273,6 +303,9 @@ async function attemptTarget(request, options) {
273
303
  return skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
274
304
  `the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`);
275
305
  }
306
+ if (spawned.failureClass === "signal-exit") {
307
+ return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
308
+ }
276
309
  const payload = extractReviewObject(target, spawned.stdout);
277
310
  const parsed = payload === undefined
278
311
  ? undefined
@@ -343,8 +376,8 @@ export function createReviewExecutor(options) {
343
376
  prompt: buildAdversarialPrompt({ ...request.invocation, harness: target }, primary)
344
377
  }, options);
345
378
  if (outcome.kind === "skip") {
346
- // Recorded, not just reported: progress is suppressed under --json,
347
- // and without this a PASS with no `adversarial` field cannot be told
379
+ // Recorded, not just reported: stderr progress is transient, and
380
+ // without this a PASS with no `adversarial` field cannot be told
348
381
  // apart from a PASS that had no second target to challenge it.
349
382
  attempts.push({
350
383
  target,
@@ -7,9 +7,11 @@ function lineList(values) {
7
7
  return values.length === 0 ? ["- none"] : values.map((value) => `- ${safe(value)}`);
8
8
  }
9
9
  export function renderReviewResult(result) {
10
+ const plannedTargets = result.plannedTargets ?? [result.harness];
10
11
  const lines = [
11
12
  `Independent review: ${result.status}`,
12
- `Reviewer: ${result.harness}; model: ${safe(result.model)}; effort: ${safe(result.effort)}.`
13
+ `Reviewer: ${result.harness}; model: ${safe(result.model)}; effort: ${safe(result.effort)}.`,
14
+ `Planned reviewers: ${plannedTargets.length === 0 ? "none" : plannedTargets.join(" → ")}.`
13
15
  ];
14
16
  if (result.scope !== undefined) {
15
17
  lines.push(result.scope.mode === "base"
@@ -160,6 +160,7 @@ function safeReport(report) {
160
160
  export async function runIndependentReview(options) {
161
161
  const base = {
162
162
  harness: options.invocation.harness,
163
+ plannedTargets: options.invocation.plannedTargets ?? [options.invocation.harness],
163
164
  model: options.invocation.model,
164
165
  effort: options.invocation.effort,
165
166
  prompt: buildReviewPrompt(options.invocation),
@@ -188,8 +188,7 @@ export class FileTrustStore {
188
188
  }
189
189
  await withPrivateFileLock(this.#path, this.#anchorDirectory, async () => {
190
190
  const store = await this.#read();
191
- const records = store.records.filter((record) => record.binding.canonicalPath !== binding.canonicalPath &&
192
- record.binding.remoteIdentity !== binding.remoteIdentity);
191
+ const records = store.records.filter((record) => record.binding.canonicalPath !== binding.canonicalPath);
193
192
  records.push({ binding: structuredClone(binding), grantedAt });
194
193
  await this.#write({ schemaVersion: 1, records });
195
194
  });
@@ -0,0 +1,62 @@
1
+ import { calculateConfigHash } from "../config/hash.js";
2
+ import { findReviewAttestation } from "../review/attestation.js";
3
+ import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
4
+ import { isPassingVerificationEvidence } from "../verify/evidence.js";
5
+ export async function checkTaskCompletionEvidence(stored, options) {
6
+ const { root, config, sourceFingerprint, evidenceStore } = options;
7
+ if (stored.failureFingerprint !== null) {
8
+ return { status: "FAIL", code: "VERIFICATION_FAILED", remedy: "Resolve the latest verification failure before completing the task." };
9
+ }
10
+ const configHash = calculateConfigHash(config);
11
+ if (!validateTaskAgainstConfig(stored.task, config).ok || stored.policyConfigHash !== configHash) {
12
+ return { status: "FAIL", code: "TASK_STALE", remedy: "Recreate the task against the current config, then verify and review it." };
13
+ }
14
+ for (const criterion of stored.task.criteria) {
15
+ const commands = config.verification.commands.filter(({ id, required }) => criterion.verifierIds.includes(id) && required);
16
+ if (commands.length === 0) {
17
+ return { status: "FAIL", code: "REQUIRED_VERIFIER_MISSING", remedy: "Configure a required verifier for every criterion, then recreate, verify and review the task." };
18
+ }
19
+ for (const command of commands) {
20
+ let latest;
21
+ for (const reference of stored.evidence[criterion.id] ?? []) {
22
+ if (reference.startsWith("review:"))
23
+ continue;
24
+ const validation = validateEvidence(await evidenceStore.load(reference));
25
+ if (!validation.ok)
26
+ continue;
27
+ const evidence = validation.value;
28
+ if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
29
+ evidence.commandId === command.id && evidence.configHash === configHash &&
30
+ evidence.sourceFingerprint === sourceFingerprint &&
31
+ (latest === undefined || Date.parse(evidence.finishedAt) > Date.parse(latest.finishedAt) ||
32
+ (Date.parse(evidence.finishedAt) === Date.parse(latest.finishedAt) && !isPassingVerificationEvidence(command, evidence)))) {
33
+ latest = evidence;
34
+ }
35
+ }
36
+ if (latest === undefined || !isPassingVerificationEvidence(command, latest)) {
37
+ return { status: "FAIL", code: "EVIDENCE_REQUIRED", remedy: "Run agent-ops verify and supply current PASS evidence for every required verifier." };
38
+ }
39
+ }
40
+ }
41
+ const attestation = await findReviewAttestation(root, sourceFingerprint);
42
+ if (attestation === null || attestation.taskId !== stored.task.id) {
43
+ return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: "Run agent-ops review --yes for the whole task and current source." };
44
+ }
45
+ return null;
46
+ }
47
+ /** Include descendants so legacy completed children cannot hide unfinished work. */
48
+ export function findIncompleteSubtask(records, taskId) {
49
+ const descendants = new Set([taskId]);
50
+ let previousSize = 0;
51
+ while (previousSize !== descendants.size) {
52
+ previousSize = descendants.size;
53
+ for (const record of records) {
54
+ if (record.task.parentTaskId !== undefined && descendants.has(record.task.parentTaskId)) {
55
+ descendants.add(record.task.id);
56
+ if (record.status === "active" || record.completedAt === null || record.failureFingerprint !== null)
57
+ return record;
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
@@ -1,8 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { TASK_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { AgentOpsError } from "../fs/paths.js";
4
- import { validateTask } from "../schema/validate.js";
4
+ import { validateEvidence, validateTask } from "../schema/validate.js";
5
5
  import { renderTaskMarkdown } from "./render.js";
6
+ import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
7
+ import { calculateConfigHash, FileEvidenceStore } from "../verify/evidence.js";
8
+ import { resolveReviewScope } from "../review/scope.js";
9
+ import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
6
10
  import { advanceFailureFingerprint } from "../verify/fingerprint.js";
7
11
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
8
12
  function defaultTaskId() {
@@ -67,10 +71,12 @@ export class TaskService {
67
71
  #store;
68
72
  #generateId;
69
73
  #now;
74
+ #completion;
70
75
  constructor(store, options = {}) {
71
76
  this.#store = store;
72
77
  this.#generateId = options.generateId ?? defaultTaskId;
73
78
  this.#now = options.now ?? (() => new Date().toISOString());
79
+ this.#completion = options.completion;
74
80
  }
75
81
  async create(input) {
76
82
  if (input.policyConfigHash !== undefined &&
@@ -105,8 +111,8 @@ export class TaskService {
105
111
  if (parent === undefined) {
106
112
  throw taskError("TASK_PARENT_NOT_FOUND", `Parent task not found: ${input.parentTaskId}`);
107
113
  }
108
- if (parent.status === "archived") {
109
- throw taskError("TASK_PARENT_NOT_ACTIVE", "An archived task cannot take new subtasks.");
114
+ if (parent.status !== "active") {
115
+ throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active task can take new subtasks.");
110
116
  }
111
117
  }
112
118
  const record = {
@@ -196,12 +202,70 @@ export class TaskService {
196
202
  }
197
203
  async complete(taskId, evidenceInput) {
198
204
  const now = assertTimestamp(this.#now());
205
+ const snapshot = await this.#store.read();
206
+ const current = findTask(snapshot, taskId);
207
+ if (current.status === "archived") {
208
+ throw taskError("TASK_NOT_ACTIVE", "An archived task cannot be completed.");
209
+ }
210
+ const submitted = normalizeEvidence(current.task, evidenceInput);
211
+ // A caller cannot hide a recorded failure by submitting only older PASS references.
212
+ const evidence = normalizeEvidence(current.task, Object.fromEntries(Object.entries(submitted).map(([criterionId, references]) => [criterionId,
213
+ [...new Set([...(current.evidence[criterionId] ?? []), ...references])]])));
214
+ const unfinished = findIncompleteSubtask(snapshot.tasks, taskId);
215
+ if (unfinished !== undefined) {
216
+ throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
217
+ }
218
+ const completion = this.#completion;
219
+ if (completion === undefined) {
220
+ throw taskError("TASK_COMPLETION_UNAVAILABLE", "Task completion requires repository config, source, verification evidence and review validation.");
221
+ }
222
+ const evidenceStore = new FileEvidenceStore(completion.root, completion.root);
223
+ for (const [criterionId, references] of Object.entries(evidence)) {
224
+ for (const reference of references) {
225
+ if (reference.startsWith("review:") && current.evidence[criterionId]?.includes(reference))
226
+ continue;
227
+ const validation = validateEvidence(await evidenceStore.load(reference));
228
+ if (!validation.ok || validation.value.taskId !== taskId || validation.value.criterionId !== criterionId) {
229
+ throw taskError("TASK_EVIDENCE_INVALID", `Criterion ${criterionId} requires resolvable evidence belonging to this task and criterion.`);
230
+ }
231
+ }
232
+ }
233
+ const fingerprint = async () => {
234
+ try {
235
+ const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
236
+ ...(completion.base === undefined ? {} : { base: completion.base }) });
237
+ return await calculateSourceFingerprint(completion.root, scope, completion.gitRunner);
238
+ }
239
+ catch (error) {
240
+ if (error instanceof AgentOpsError && error.code === "REVIEW_NO_CHANGE_SURFACE") {
241
+ throw taskError("TASK_COMPLETION_SCOPE_REQUIRED", completion.base === undefined
242
+ ? "No changed worktree scope. For committed work, run verify, review and task complete with the same --base <git-ref>."
243
+ : "The requested base range has no changed paths; choose a base that precedes the committed work.");
244
+ }
245
+ throw error;
246
+ }
247
+ };
248
+ const config = await completion.loadConfig();
249
+ const sourceFingerprint = await fingerprint();
250
+ const problem = await checkTaskCompletionEvidence({ ...current, evidence }, {
251
+ root: completion.root, config, sourceFingerprint,
252
+ evidenceStore
253
+ });
254
+ if (problem !== null) {
255
+ throw taskError(`TASK_COMPLETION_${problem.code}`, problem.remedy);
256
+ }
257
+ if (sourceFingerprint !== await fingerprint() ||
258
+ calculateConfigHash(config) !== calculateConfigHash(await completion.loadConfig())) {
259
+ throw taskError("TASK_COMPLETION_SOURCE_CHANGED", "Source or config changed during completion; verify and review again.");
260
+ }
199
261
  return await this.#store.mutate((state) => {
200
- const current = findTask(state, taskId);
201
- if (current.status === "archived") {
202
- throw taskError("TASK_NOT_ACTIVE", "An archived task cannot be completed.");
262
+ if (JSON.stringify(findTask(state, taskId)) !== JSON.stringify(current)) {
263
+ throw taskError("TASK_COMPLETION_STATE_CHANGED", "Task changed during completion; retry against its current evidence and status.");
264
+ }
265
+ const unfinished = findIncompleteSubtask(state.tasks, taskId);
266
+ if (unfinished !== undefined) {
267
+ throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
203
268
  }
204
- const evidence = normalizeEvidence(current.task, evidenceInput);
205
269
  if (current.status === "complete") {
206
270
  if (JSON.stringify(current.evidence) !== JSON.stringify(evidence)) {
207
271
  throw taskError("TASK_ALREADY_COMPLETE", "Completed task evidence cannot be replaced.");
@@ -60,6 +60,9 @@ async function collectPaths(runner, args) {
60
60
  return parseNulPaths(result.stdout);
61
61
  }
62
62
  export async function collectChangeSurface(runner) {
63
+ const trackedRuntime = await collectPaths(runner, [
64
+ "ls-files", "--cached", "-z", "--", ".agent-ops/tasks/", ".agent-ops/reviews/"
65
+ ]);
63
66
  const staged = await collectPaths(runner, [
64
67
  "diff",
65
68
  "--cached",
@@ -69,6 +72,9 @@ export async function collectChangeSurface(runner) {
69
72
  "--no-textconv",
70
73
  "-z"
71
74
  ]);
75
+ if (trackedRuntime.length > 0 || staged.some((path) => path.startsWith(".agent-ops/tasks/") || path.startsWith(".agent-ops/reviews/"))) {
76
+ throw new AgentOpsError("CHANGE_SURFACE_TRACKED_RUNTIME", "Runtime output is staged or tracked under .agent-ops/tasks/ or .agent-ops/reviews/. Move any source files out of these runtime directories, add ignore rules (agent-ops update), then remove runtime files from the Git index with git rm -r --cached --ignore-unmatch -- .agent-ops/tasks/ .agent-ops/reviews/; keep local files, commit any staged runtime removals, then rerun verify/review.");
77
+ }
72
78
  const unstaged = await collectPaths(runner, [
73
79
  "diff",
74
80
  "--name-only",
@@ -77,12 +83,15 @@ export async function collectChangeSurface(runner) {
77
83
  "--no-textconv",
78
84
  "-z"
79
85
  ]);
80
- const untracked = await collectPaths(runner, [
86
+ const untracked = (await collectPaths(runner, [
81
87
  "ls-files",
82
88
  "--others",
83
89
  "--exclude-standard",
84
90
  "-z"
85
- ]);
91
+ ])).filter(
92
+ // Indexed runtime was rejected above; only generated, untracked output is excluded.
93
+ (path) => !path.startsWith(".agent-ops/tasks/") &&
94
+ !path.startsWith(".agent-ops/reviews/"));
86
95
  return {
87
96
  staged,
88
97
  unstaged,
@@ -47,6 +47,9 @@ export function isPassingVerificationEvidence(command, evidence) {
47
47
  if (evidence.status !== "PASS" ||
48
48
  evidence.exitCode !== 0 ||
49
49
  evidence.failureClass !== "none" ||
50
+ evidence.cwd !== command.cwd ||
51
+ JSON.stringify(evidence.argv) !==
52
+ JSON.stringify([command.command, ...command.args].map(redactSecrets)) ||
50
53
  command.evidence.kind === "file") {
51
54
  return false;
52
55
  }
@@ -96,7 +99,12 @@ export class FileEvidenceStore {
96
99
  return null;
97
100
  }
98
101
  try {
99
- return JSON.parse(source);
102
+ const validation = validateEvidence(JSON.parse(source));
103
+ if (!validation.ok)
104
+ return null;
105
+ const evidence = validation.value;
106
+ const expected = `.agent-ops/tasks/evidence/${evidence.taskId}/${evidence.commandId}-${sha256(source).slice(0, 16)}.json`;
107
+ return reference === expected ? evidence : null;
100
108
  }
101
109
  catch {
102
110
  throw new AgentOpsError("EVIDENCE_INVALID", "Stored evidence is not valid JSON.");
@@ -123,7 +123,15 @@ export class VerificationService {
123
123
  ? await collectChangeSurface(this.#options.gitRunner)
124
124
  : { staged: [], unstaged: [], untracked: [], paths: reviewScope.changedFiles };
125
125
  const surface = worktreeSurface;
126
- const selection = selectVerificationScope(surface.paths, this.#options.config);
126
+ const mappedSelection = selectVerificationScope(surface.paths, this.#options.config);
127
+ const taskVerifierIds = new Set(validation.value.criteria.flatMap((criterion) => criterion.verifierIds));
128
+ const selection = {
129
+ ...mappedSelection,
130
+ verifierIds: [...new Set([...mappedSelection.verifierIds,
131
+ ...this.#options.config.verification.commands
132
+ .filter(({ id, required }) => required && taskVerifierIds.has(id))
133
+ .map(({ id }) => id)])]
134
+ };
127
135
  const results = [];
128
136
  for (const commandId of selection.verifierIds) {
129
137
  results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
@@ -270,6 +270,9 @@ function hasAcknowledgedShell(command) {
270
270
  export async function runVerificationCommand(command, options) {
271
271
  const now = options.now ?? Date.now;
272
272
  const startedAt = now();
273
+ if (options.signal?.aborted === true) {
274
+ return emptyResult(command.id, "aborted", elapsedMilliseconds(startedAt, now()));
275
+ }
273
276
  if (!hasAcknowledgedShell(command)) {
274
277
  return emptyResult(command.id, "shell-risk-unacknowledged", elapsedMilliseconds(startedAt, now()));
275
278
  }
@@ -298,21 +301,35 @@ export async function runVerificationCommand(command, options) {
298
301
  const timeout = new Promise((resolve) => {
299
302
  timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
300
303
  });
304
+ let removeAbortListener = () => { };
305
+ const aborted = new Promise((resolve) => {
306
+ const signal = options.signal;
307
+ if (signal === undefined) {
308
+ return;
309
+ }
310
+ const onAbort = () => resolve({ kind: "abort" });
311
+ signal.addEventListener("abort", onAbort, { once: true });
312
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
313
+ });
301
314
  const outcome = await Promise.race([
302
315
  running.completion.then((completion) => ({
303
316
  kind: "completion",
304
317
  completion
305
318
  })),
306
- timeout
319
+ timeout,
320
+ aborted
307
321
  ]);
322
+ removeAbortListener();
308
323
  if (timer !== undefined) {
309
324
  clearTimeout(timer);
310
325
  }
311
326
  let completion;
312
327
  let timedOut = false;
328
+ let wasAborted = false;
313
329
  let terminationFailed = false;
314
- if (outcome.kind === "timeout") {
315
- timedOut = true;
330
+ if (outcome.kind === "timeout" || outcome.kind === "abort") {
331
+ timedOut = outcome.kind === "timeout";
332
+ wasAborted = outcome.kind === "abort";
316
333
  try {
317
334
  await running.terminateTree(terminationGrace);
318
335
  }
@@ -330,14 +347,19 @@ export async function runVerificationCommand(command, options) {
330
347
  settleCapturedOutput(stdout, terminationGrace),
331
348
  settleCapturedOutput(stderr, terminationGrace)
332
349
  ]);
333
- const classified = timedOut
350
+ const classified = wasAborted
334
351
  ? {
335
352
  status: terminationFailed ? "UNKNOWN" : "FAIL",
336
- failureClass: terminationFailed
337
- ? "termination-failed"
338
- : "timeout"
353
+ failureClass: "aborted"
339
354
  }
340
- : classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
355
+ : timedOut
356
+ ? {
357
+ status: terminationFailed ? "UNKNOWN" : "FAIL",
358
+ failureClass: terminationFailed
359
+ ? "termination-failed"
360
+ : "timeout"
361
+ }
362
+ : classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
341
363
  return {
342
364
  commandId: command.id,
343
365
  status: classified.status,
@@ -55,6 +55,12 @@ A bare review uses the built-in `change-quality` criterion; `--task` uses the
55
55
  task criteria and requires fresh PASS evidence for required checks. The full
56
56
  report is printed, and PASS persists only a source-fingerprint attestation.
57
57
 
58
+ `--review-target` belongs to `init` and configures that persistent chain.
59
+ For one run, `review --harness <target>` narrows the chain to exactly one
60
+ already-configured target; it never enables a target absent from project
61
+ policy. The configured model, effort, and timeout still apply. Review JSON
62
+ includes `plannedTargets` in the actual host-adjusted order.
63
+
58
64
  Every attempt starts from a fresh session and disposable repository clone with
59
65
  native read-only mode.
60
66
  Claude uses complete safe-mode isolation. Codex and Agy preserve their existing
@@ -88,6 +94,12 @@ failure, oversized output, or unparseable output. Every attempt and reason is
88
94
  preserved in human and JSON output. A `PASS` or `FAIL` verdict is **terminal**,
89
95
  so the chain cannot shop for a passing review.
90
96
 
97
+ Capability checks and model starts are reported on stderr, including under
98
+ `--json`; stdout remains one final JSON envelope and raw reviewer output is
99
+ never streamed. SIGINT or SIGTERM terminates the active reviewer process tree,
100
+ does not advance the fallback chain, and never writes an attestation. An
101
+ exhausted timeout chain reports `timeout`, not `missing-cli`.
102
+
91
103
  If Claude Code is the host (`CLAUDECODE` is set), `claude` is moved to the end
92
104
  of the chain. It still runs when it is the only configured target, with a
93
105
  `reviewer == host` warning.
@@ -19,3 +19,28 @@ The verifier MUST return FAIL when a criterion is missing, duplicated, unknown,
19
19
  - Evidence: The aggregate lists the criterion IDs and their evidence references.
20
20
  - Positive: `tests PASS [report.json]; scope PASS [diff.txt]`.
21
21
  - Negative: `tests PASS; tests PASS; extra PASS`.
22
+
23
+ ## EVIDENCE-COMPLETION-001
24
+
25
+ `agent-ops task complete` MUST validate completion independently of the host, active profiles, and Stop hooks.
26
+
27
+ - Trigger: Completing a task, including repeated completion requests.
28
+ - Action: Check required verifiers, current evidence, whole-task review, and subtask completion before writing state.
29
+ - Evidence: `TASK_COMPLETED` is returned only after these checks pass; rejected requests preserve state.
30
+ - Positive: Current verification evidence plus a task-bound review allows an otherwise complete task to finish.
31
+ - Negative: Supplying `criterion=PASS`, an old review, or archiving an unfinished child to bypass completion.
32
+
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
+ - 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
+ - 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.
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
+ - 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
+
40
+ 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
+
42
+ 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.
43
+
44
+ This enforces the toolkit's completion command, not a host's natural-language completion claim. It does not prevent a process with the same filesystem authority from editing task/evidence files directly, and it does not lock Git or external source writers.
45
+
46
+ Project init/update installs `.agent-ops/.gitignore` for `/tasks/` and `/reviews/` on every harness and profile. An existing user-owned `.agent-ops/.gitignore` blocks init/update with `UNMANAGED_INSTALL_PATH`: first back it up outside that path and move its custom rules to the root `.gitignore` (prefix them with `/.agent-ops/`), then retry. Git collection fails with `CHANGE_SURFACE_TRACKED_RUNTIME` if these directories contain indexed files (even clean tracked files), or staged runtime removals. Move any real source files out of the runtime directories, install ignore rules, then use `git rm -r --cached --ignore-unmatch -- .agent-ops/tasks/ .agent-ops/reviews/` to keep local files while removing them from the index. Commit any staged removals before producing fresh verification/review evidence. The active completion Stop gate also blocks this condition with `COMPLETION_GATE_TRACKED_RUNTIME` and the recovery message. Run verify/review from the Git repository root; nested project roots are not supported by the current path/fingerprint collector. Uninstall removes the managed ignore file but preserves runtime output.
@@ -31,6 +31,12 @@ when an installation supports multiple harnesses.
31
31
  - Positive: `review --harness claude` resolves one target.
32
32
  - Negative: `Run one review invocation against every installed harness implicitly.`
33
33
 
34
+ The explicit target MUST already exist in the configured independent-review
35
+ role. It narrows the configured chain to one target while preserving model,
36
+ effort, and timeout policy. Without `--harness`, host-aware ordering applies to
37
+ the complete configured chain. Every result carries that order as
38
+ `plannedTargets`.
39
+
34
40
  ## REVIEW-READONLY-001
35
41
 
36
42
  A review target MUST be launched with its own read-only mechanism, and a target
@@ -48,6 +54,11 @@ different CLI from the hosting CLI; when no other usable target exists,
48
54
  same-target fresh review is allowed but MUST render as `DEGRADED: isolated
49
55
  self-review`. A resumed development session is never an independent review.
50
56
 
57
+ Capability and model-start progress goes to stderr even when stdout is JSON.
58
+ Raw reviewer output remains bounded and unstreamed. SIGINT or SIGTERM aborts
59
+ the active process tree without fallback or attestation; timeout remains a
60
+ distinct NOT_RUN reason rather than being flattened to `missing-cli`.
61
+
51
62
  ## REVIEW-CHAIN-001
52
63
 
53
64
  Configured targets form an ordered fallback chain that MUST advance only when
@@ -49,6 +49,11 @@ Claude 與 Codex lifecycle support 為 `supported`,OpenCode 從 app initializa
49
49
  `--task` 使用 task criteria,並要求必要驗證的最新 PASS evidence。完整 report
50
50
  會顯示給人看,PASS 後只持久化 source-fingerprint attestation。
51
51
 
52
+ `--review-target` 只屬於 `init`,用來設定持久 fallback chain。單次執行可用
53
+ `review --harness <target>`,將 chain 縮窄為一個已在 project policy 啟用的
54
+ target;它不會臨時啟用未設定的 reviewer。既有 model、effort 與 timeout 仍會
55
+ 沿用,review JSON 的 `plannedTargets` 會列出經 host 調整後的實際順序。
56
+
52
57
  每次嘗試都從全新 session、一次性 repository clone 與原生唯讀模式啟動。Claude 使用完整 safe-mode
53
58
  隔離;Codex 與 Agy 為了支援既有 OAuth 登入而保留登入環境,因此 context
54
59
  隔離較弱。Agy 會取得一次性 clone,即使 sandboxed plan mode 寫入 cwd,也無法
@@ -77,6 +82,11 @@ agent-ops 刻意不傳會繞過權限邊界的 `--dangerously-skip-permissions`
77
82
  都會保留每次 attempt 及原因。`PASS` 或 `FAIL` 判定是**終局**,因此不會產生
78
83
  自動化的 review shopping。
79
84
 
85
+ Capability check 與模型啟動進度都寫到 stderr,包括 `--json` 模式;stdout
86
+ 仍只有最終 JSON envelope,且不會串流 reviewer 原始輸出。SIGINT 或 SIGTERM
87
+ 會終止目前 reviewer 的完整 process tree、不進入 fallback,也不寫入
88
+ attestation。整條 chain 逾時時回報 `timeout`,不會誤報 `missing-cli`。
89
+
80
90
  若 host 是 Claude Code(`CLAUDECODE` 已設定),`claude` 會被移到鏈尾。
81
91
  當它是唯一設定的目標時仍會執行,並附上 `reviewer == host` 警告。
82
92
 
@@ -1,6 +1,6 @@
1
1
  # 驗收與證據
2
2
 
3
- English source version: 2026-07-23. Revalidate: when the English specification changes.
3
+ English source version: 2026-09-07. Revalidate: when the English specification changes.
4
4
 
5
5
  ## EVIDENCE-CRITERION-001
6
6
 
@@ -21,3 +21,28 @@ English source version: 2026-07-23. Revalidate: when the English specification c
21
21
  - Evidence: 聚合結果列出 criterion ID 與證據參照。
22
22
  - Positive: `tests PASS [report.json];scope PASS [diff.txt]`。
23
23
  - Negative: `tests PASS;tests PASS;extra PASS`。
24
+
25
+ ## EVIDENCE-COMPLETION-001
26
+
27
+ `agent-ops task complete` MUST 獨立於 host、啟用的 profile 與 Stop hook,驗證任務是否可完成。
28
+
29
+ - Trigger: 完成任務,包括重複提交完成請求。
30
+ - Action: 寫入狀態前檢查 required verifier、目前有效的證據、全任務審查與子任務完成狀態。
31
+ - Evidence: 僅在檢查通過後回傳 `TASK_COMPLETED`;拒絕請求時保留原狀態。
32
+ - Positive: 有目前有效的驗證證據與 task-bound review,且子任務已完成,才允許收口。
33
+ - Negative: 填入 `criterion=PASS`、提供舊 review,或封存未完成子任務來繞過檢查。
34
+
35
+ - 每項 criterion 至少需要一個 required verifier,且其列出的每個 required verifier 都要有目前有效的 PASS evidence。即使 path mapping 選出較小範圍,verify 也會包含 task 明列的 required 命令。請提供 `agent-ops verify --task <id>` 產生的證據檔參照,不能只填命令文字或自行宣稱 PASS。
36
+ - 任務的 config baseline、evidence 必須符合目前有效設定與所選 scope 的來源指紋。尚未解決的驗證失敗會阻擋完成;舊 PASS 不能蓋過較新的失敗,同時間戳的矛盾結果也會阻擋。
37
+ - 使用 `agent-ops review --task <id> --yes` 審查整個任務。完成時必須有目前有效且綁定該 task 的 PASS attestation;generic review 與只涵蓋部分 criterion 的 review 不算。
38
+ - 開始一次已授權的 review 會撤銷該來源指紋先前的 attestation。若 reviewer 未能執行或未通過,完成前必須重新取得成功的全任務 review。
39
+ - 所有子任務與後代任務都必須已完成,且沒有尚未解決的失敗。封存未完成工作不算完成;已完成或封存的 parent 不能新增子任務。若放棄某個 subtask,請重建不含該取消分支的 parent task,並重新驗證與審查新的 task tree。
40
+ - 證據、來源與設定檢查在 task store 鎖外執行。再次確認來源與設定後,完成操作才短暫鎖住狀態,比對 task 快照(改變時回傳 `TASK_COMPLETION_STATE_CHANGED`)、重查後代任務並原子寫入。慢速檢查不會阻擋 task 讀取或其他修改;完成被拒絕時,併發修改仍會保留。
41
+
42
+ 既有 task 檔案仍可直接讀取,不需資料格式遷移。缺少 config baseline、使用任意文字證據,或含有未完成但已封存子任務的舊任務,必須重建並完成驗證與審查。重複執行 complete 也會重新驗證證據;舊的 completed 狀態本身不夠。Runtime 呼叫端必須提供 `TaskServiceOptions.completion`;缺少此 repository context 時,complete 會拒絕執行,但仍可讀取任務。
43
+
44
+ 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。
45
+
46
+ 這項機制強制的是 toolkit 的完成命令,無法阻止 host 直接用自然語言宣稱完成,也無法阻止具有相同檔案權限的 process 直接改寫 task/evidence 檔案;它不會鎖住 Git 或其他來源檔案寫入者。
47
+
48
+ Project init/update 在所有 harness 與 profile 安裝 `.agent-ops/.gitignore`,忽略 `/tasks/` 與 `/reviews/`;若已有使用者自建的 `.agent-ops/.gitignore`,init/update 會回傳 `UNMANAGED_INSTALL_PATH`:先將原檔備份到其他位置,並將自訂規則移到根目錄 `.gitignore`(加上 `/.agent-ops/` 前綴),再重試。這些目錄若包含 index 中的檔案(即使 tracked 且未修改)或 staged 的 runtime 刪除,Git 範圍收集會回傳 `CHANGE_SURFACE_TRACKED_RUNTIME`。請先移出真正的程式來源、安裝 ignore 規則,再執行 `git rm -r --cached --ignore-unmatch -- .agent-ops/tasks/ .agent-ops/reviews/`,保留本機檔案並解除追蹤。若有 staged 刪除,先提交清理 commit,再重新產生 verify/review 證據。啟用的 completion Stop gate 也會以 `COMPLETION_GATE_TRACKED_RUNTIME` 阻擋此狀況並提供修復訊息。請從 Git repository 根目錄執行 verify/review;目前路徑與指紋收集不支援巢狀 project root。Uninstall 會移除 managed ignore 檔,但保留 runtime 輸出。