@kylecheng3146/agent-ops 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +19 -0
  2. package/dist/packages/cli/src/args.js +78 -3
  3. package/dist/packages/cli/src/bin.js +19 -1
  4. package/dist/packages/cli/src/cli.js +33 -0
  5. package/dist/packages/cli/src/commands/advance.js +3 -2
  6. package/dist/packages/cli/src/commands/followup.js +74 -0
  7. package/dist/packages/cli/src/commands/parallel.js +4 -0
  8. package/dist/packages/cli/src/commands/run.js +1 -1
  9. package/dist/packages/cli/src/commands/task.js +22 -5
  10. package/dist/packages/cli/src/context.js +13 -9
  11. package/dist/packages/cli/src/criteria-review.js +63 -0
  12. package/dist/packages/cli/src/followup-deps.js +56 -0
  13. package/dist/packages/cli/src/owned-run-step.js +1 -1
  14. package/dist/packages/cli/src/run-deps.js +16 -5
  15. package/dist/packages/cli/src/run-entry.js +283 -145
  16. package/dist/runtime/src/followup/intake.js +75 -0
  17. package/dist/runtime/src/followup/process.js +273 -0
  18. package/dist/runtime/src/followup/receipt.js +116 -0
  19. package/dist/runtime/src/followup/store.js +133 -0
  20. package/dist/runtime/src/install/harness.js +1 -1
  21. package/dist/runtime/src/install/preauth.js +11 -4
  22. package/dist/runtime/src/logging/local-log.js +12 -0
  23. package/dist/runtime/src/parallel/finish.js +10 -2
  24. package/dist/runtime/src/review/attestation.js +10 -0
  25. package/dist/runtime/src/review/criteria.js +252 -0
  26. package/dist/runtime/src/review/followup.js +233 -0
  27. package/dist/runtime/src/review/invocation.js +3 -3
  28. package/dist/runtime/src/review/runner.js +20 -0
  29. package/dist/runtime/src/run/service.js +59 -6
  30. package/dist/runtime/src/run/usage.js +5 -2
  31. package/dist/runtime/src/security/permissions.js +16 -1
  32. package/dist/runtime/src/verify/acceptance-coverage.js +2 -2
  33. package/dist/runtime/src/verify/service.js +62 -2
  34. package/dist/runtime/src/verify/spawn.js +67 -4
  35. package/docs/en/guides/configuration.md +5 -0
  36. package/docs/en/guides/task-debug.md +36 -0
  37. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  38. package/docs/en/spec/loop-engineering.md +36 -0
  39. package/docs/en/spec/review.md +16 -0
  40. package/docs/zh-TW/guides/configuration.md +3 -0
  41. package/docs/zh-TW/guides/task-debug.md +31 -0
  42. package/docs/zh-TW/spec/acceptance-and-evidence.md +39 -0
  43. package/docs/zh-TW/spec/loop-engineering.md +21 -0
  44. package/docs/zh-TW/spec/review.md +17 -1
  45. package/package.json +1 -1
@@ -322,10 +322,32 @@ const RECOVERABLE = new Set(["RUN_RECOVERY_DIRTY", "RUN_NATIVE_VERSION_CHANGED",
322
322
  function resumeBlocker(current, acknowledged = new Set()) {
323
323
  return [...current.events].reverse().find(e => !acknowledged.has(e.id) && (RECOVERABLE.has(e.code) || ["RUN_NO_PROGRESS_REPEAT", "RUN_COMMAND_DENIED"].includes(e.code)));
324
324
  }
325
- function cannotResume(current, acknowledged = new Set()) {
325
+ function cannotResume(current, acknowledged = new Set(), retryingDelivery = false) {
326
326
  return current.status === "complete" || current.events.some(e => e.code === "RUN_COMMAND_DENIED" && !acknowledged.has(e.id)) ||
327
327
  (current.integration?.proofDigest === undefined && (current.workers.some(w => w.workerId === current.coordinatorId && w.status === "blocked" && w.lastFailure !== null && w.lastFailure.failureClass !== "native-start") ||
328
- (current.status === "blocked" && !RECOVERABLE.has(resumeBlocker(current, acknowledged)?.code ?? ""))));
328
+ (current.status === "blocked" && !RECOVERABLE.has(resumeBlocker(current, acknowledged)?.code ?? "") &&
329
+ !(retryingDelivery && resumeBlocker(current, acknowledged) === undefined))));
330
+ }
331
+ /** A manual retry grants a fresh attempt, never proof or command authorization. */
332
+ export function validateDeliveryRetry(state, taskId, now = Date.now()) {
333
+ const task = state.tasks.find(t => t.taskId === taskId);
334
+ const worker = state.workers.find(w => w.workerId === task?.workerId && w.taskId === taskId);
335
+ const intent = worker?.stopIntent;
336
+ const dead = (w) => !["assigned", "starting", "running", "idle", "handing-off"].includes(w.status) &&
337
+ (w.nativeSessionId === null && w.processId === null || w.stopIntent?.runId === state.runId &&
338
+ w.stopIntent.workerId === w.workerId && w.stopIntent.generation === w.generation &&
339
+ w.stopIntent.nativeSessionId === w.nativeSessionId && w.stopIntent.confirmedDeadAt !== null &&
340
+ Number.isFinite(Date.parse(w.stopIntent.confirmedDeadAt)) && Date.parse(w.stopIntent.confirmedDeadAt) <= now);
341
+ if (["complete", "failed", "stopping"].includes(state.status) || state.integration !== null || state.proofProcess != null ||
342
+ task === undefined || task.taskId === state.rootTaskId || task.status !== "blocked" || task.blockedReason !== "RUN_FINAL_PROOF_INTERRUPTED" ||
343
+ task.deliveryDigest !== null || task.sourceCommit !== null || worker === undefined || worker.workerId === state.coordinatorId ||
344
+ worker.status !== "blocked" || worker.lastFailure?.failureClass !== "RUN_FINAL_PROOF_INTERRUPTED" ||
345
+ worker.lastFailure.checkId !== "RUN_FINAL_PROOF_INTERRUPTED" || worker.lastFailure.pinId !== null ||
346
+ intent?.reason !== "handoff" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(intent.sourceCommit ?? "") ||
347
+ !/^[a-f0-9]{64}$/u.test(intent.contractDigest ?? "") || !/^[a-f0-9]{64}$/u.test(intent.deliveryDigest ?? "") ||
348
+ state.workers.some(w => w.proofProcess != null || !dead(w)))
349
+ throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Retry requires an interrupted child handoff, unchanged identity and confirmed stopped writers/proof; inspect run status first.");
350
+ return worker;
329
351
  }
330
352
  /** Observations are not proof validation; resume still reconciles every authority and sealed artifact. */
331
353
  export function describeRunRecovery(state, now = Date.now()) {
@@ -418,12 +440,21 @@ export class RunService {
418
440
  }));
419
441
  return { state, message: `Run ${runId} is paused.` };
420
442
  }
421
- async resume(runId, retryApprovalIds = [], upgradeBudget = true) {
443
+ async resume(runId, retryApprovalIds = [], upgradeBudget = true, retryDeliveryTaskId, expectedFailureId) {
422
444
  const state = await this.#repository.mutate(runId, async (current) => {
423
- const acknowledged = await approvalRetryEvents(current, retryApprovalIds);
445
+ if (retryDeliveryTaskId !== undefined && retryApprovalIds.length > 0)
446
+ throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "Delivery and approval retries require separate explicit controls.");
447
+ const retry = retryDeliveryTaskId === undefined ? null : validateDeliveryRetry(current, retryDeliveryTaskId, Date.parse(this.#now()));
448
+ if (retry !== null && expectedFailureId !== undefined && retry.lastFailure.id !== expectedFailureId)
449
+ throw new AgentOpsError("RUN_DELIVERY_RETRY_INVALID", "The interrupted attempt changed during recovery.");
450
+ const acknowledged = new Set(await approvalRetryEvents(current, retryApprovalIds));
451
+ if (retry !== null)
452
+ for (const e of current.events)
453
+ if (e.code === "RUN_NO_PROGRESS_REPEAT" && e.workerId === retry.workerId && e.taskId === retry.taskId)
454
+ acknowledged.add(e.id);
424
455
  const recoverable = RECOVERABLE;
425
456
  const blocker = resumeBlocker(current, acknowledged);
426
- if (cannotResume(current, acknowledged)) {
457
+ if (cannotResume(current, acknowledged, retry !== null)) {
427
458
  throw new AgentOpsError("RUN_NOT_RESUMABLE", `Run ${runId} is ${current.status}.`);
428
459
  }
429
460
  if (current.integration?.proofDigest === undefined && !upgradeBudget && budgetExpired(current, Date.parse(this.#now())))
@@ -439,11 +470,17 @@ export class RunService {
439
470
  ...(recover ? { workers: current.workers.map(w => w.status === "blocked" && (w.lastFailure === null || w.lastFailure.failureClass === "native-start") &&
440
471
  !current.tasks.some(t => t.taskId === w.taskId && t.status === "blocked" && !recoverable.has(t.blockedReason ?? "")) ? { ...w, status: "stopped" } : w),
441
472
  tasks: current.tasks.map(t => t.status === "blocked" && recoverable.has(t.blockedReason ?? "") ? { ...t, status: "ready", blockedReason: null } : t) } : {}),
473
+ ...(retry === null ? {} : {
474
+ workers: current.workers.map(w => w.workerId === retry.workerId ? { ...w, status: "stopped", lastFailure: null } : w),
475
+ tasks: current.tasks.map(t => t.taskId === retry.taskId ? { ...t, status: "ready", blockedReason: null } : t)
476
+ }),
442
477
  status: "active",
443
478
  awaitingResume: false,
444
479
  disableRestart: false,
445
480
  controls: [...current.controls, {
446
- action: "resume", actor: "user", reason: retryApprovalIds.length === 0 ? null : "retry-approval:" + JSON.stringify(retryApprovalIds), requestedAt: this.#now(), expiresAt: null, appliedAt: null
481
+ action: "resume", actor: "user", reason: retry !== null ? "retry-delivery:" + JSON.stringify({ taskId: retry.taskId,
482
+ workerId: retry.workerId, generation: retry.generation, failure: retry.lastFailure }) :
483
+ retryApprovalIds.length === 0 ? null : "retry-approval:" + JSON.stringify(retryApprovalIds), requestedAt: this.#now(), expiresAt: null, appliedAt: null
447
484
  }]
448
485
  };
449
486
  });
@@ -461,9 +498,25 @@ export class RunService {
461
498
  if (question.answeredAt !== null)
462
499
  throw new AgentOpsError("RUN_QUESTION_ANSWERED", `Question already answered: ${cleanQuestion}`);
463
500
  const answeredAt = this.#now();
501
+ const coordinator = current.workers.find(worker => worker.workerId === current.coordinatorId);
502
+ const intent = coordinator?.stopIntent;
503
+ const wake = coordinator?.status === "stopped" && coordinator.taskId === current.rootTaskId &&
504
+ coordinator.nativeSessionId === null && coordinator.nativeJobId === null && coordinator.nativeGoalState === "inactive" &&
505
+ coordinator.proofProcess == null && intent?.reason === "handoff" && intent.runId === runId &&
506
+ intent.workerId === coordinator.workerId && intent.generation === coordinator.generation &&
507
+ intent.confirmedDeadAt !== null && Number.isFinite(Date.parse(intent.confirmedDeadAt)) &&
508
+ Date.parse(intent.confirmedDeadAt) <= Date.parse(answeredAt);
464
509
  return {
465
510
  ...current,
466
511
  status: "active",
512
+ // Consume this wait once; ordinary resume still requires death reconciliation and a fresh lease.
513
+ ...(wake ? {
514
+ workers: current.workers.map(worker => worker.workerId === current.coordinatorId
515
+ ? { ...worker, stopIntent: { ...intent, reason: "stop" } } : worker),
516
+ events: [...current.events, event({ type: "diagnostic", code: "RUN_COORDINATOR_RESPONSE_WAKE",
517
+ workerId: coordinator.workerId, taskId: coordinator.taskId,
518
+ detail: `Answer ${cleanQuestion} released coordinator wait at generation ${coordinator.generation}; normal recovery is required.` }, answeredAt)].slice(-MAX_EVENTS)
519
+ } : {}),
467
520
  questions: current.questions.map((item) => item.questionId === cleanQuestion
468
521
  ? { ...item, answeredAt, answerDigest: sha256(cleanAnswer) }
469
522
  : item),
@@ -66,7 +66,9 @@ export async function recordNativeRunUsage(repository, event) {
66
66
  }
67
67
  else if (event.host === "codex" && plain(payload.tokenUsage) && plain(payload.tokenUsage.total)) {
68
68
  const usage = payload.tokenUsage.total;
69
- incoming = { source: "codex", epoch: `${worker.workerId}:${worker.nativeSessionId}:thread`, inputTokens: count(usage.inputTokens),
69
+ const threadKey = worker.nativeJobId !== null && worker.nativeJobId !== worker.nativeSessionId
70
+ ? `${worker.nativeSessionId}:${worker.nativeJobId}` : worker.nativeSessionId;
71
+ incoming = { source: "codex", epoch: `${worker.workerId}:${threadKey}:thread`, inputTokens: count(usage.inputTokens),
70
72
  outputTokens: count(usage.outputTokens), totalTokens: count(usage.totalTokens), costUsd: null,
71
73
  completeness: "partial", observedAt: event.at };
72
74
  }
@@ -74,7 +76,8 @@ export async function recordNativeRunUsage(repository, event) {
74
76
  return;
75
77
  await repository.mutate(event.runId, current => {
76
78
  const saved = current.workers.find(w => w.workerId === event.workerId);
77
- if (saved?.generation !== event.generation || saved.nativeSessionId !== worker.nativeSessionId)
79
+ if (saved?.generation !== event.generation || saved.nativeSessionId !== worker.nativeSessionId ||
80
+ saved.nativeJobId !== worker.nativeJobId)
78
81
  return current;
79
82
  return appendUsage(current, incoming);
80
83
  });
@@ -607,7 +607,22 @@ export async function writePrivateFile(path, content, anchorDirectory) {
607
607
  await handle.close();
608
608
  handle = undefined;
609
609
  await chmod(temporaryPath, 0o600);
610
- await rename(temporaryPath, path);
610
+ for (let attempt = 0;; attempt += 1) {
611
+ try {
612
+ await rename(temporaryPath, path);
613
+ break;
614
+ }
615
+ catch (error) {
616
+ // Windows readers can temporarily deny replacement of an existing file.
617
+ if (process.platform !== "win32" || attempt >= 4 ||
618
+ typeof error !== "object" || error === null || !("code" in error) ||
619
+ !["EPERM", "EACCES", "EBUSY"].includes(String(error.code))) {
620
+ throw error;
621
+ }
622
+ await delay(50 * (attempt + 1));
623
+ await inspectPath(path, anchorDirectory, "file");
624
+ }
625
+ }
611
626
  await chmod(path, 0o600);
612
627
  await syncDirectory(parent);
613
628
  }
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { sha256 } from "../fs/hash.js";
5
5
  import { criterionContractHash, evidenceMatchesContract } from "../task/contract.js";
6
6
  /** Only current, paired check evidence discharges a mechanical contract. */
7
- export async function acceptanceCoverage(record, config, sourceFingerprint, store) {
7
+ export async function acceptanceCoverage(record, config, sourceFingerprint, store, readExecution = path => readPrivateFile(join(store.root, ...path.split("/")), store.root)) {
8
8
  const configHash = calculateConfigHash(config);
9
9
  const rows = [];
10
10
  for (const criterion of record.task.criteria) {
@@ -26,7 +26,7 @@ export async function acceptanceCoverage(record, config, sourceFingerprint, stor
26
26
  for (const item of matching) {
27
27
  const proof = item.evidence.acceptance;
28
28
  try {
29
- const source = await readPrivateFile(join(store.root, ...proof.executionArtifact.split("/")), store.root);
29
+ const source = await readExecution(proof.executionArtifact);
30
30
  if (source === null || proof.executionArtifact !== ".agent-ops/tasks/acceptance/" + sha256(source) + ".json")
31
31
  continue;
32
32
  const artifact = JSON.parse(source);
@@ -5,7 +5,7 @@ import { validateTaskAgainstConfig } from "../schema/validate.js";
5
5
  import { collectChangeSurface } from "./change-surface.js";
6
6
  import { resolveReviewScope, reviewScopeSignature } from "../review/scope.js";
7
7
  import { calculateSourceFingerprint } from "./source-fingerprint.js";
8
- import { buildVerificationEvidence } from "./evidence.js";
8
+ import { buildVerificationEvidence, calculateConfigHash, isPassingVerificationEvidence } from "./evidence.js";
9
9
  import { createFailureFingerprint } from "./fingerprint.js";
10
10
  import { aggregateVerificationStatus, executeConfiguredCommand } from "./command-executor.js";
11
11
  import { selectVerificationScope } from "./scope.js";
@@ -70,6 +70,64 @@ export class VerificationService {
70
70
  }
71
71
  return references;
72
72
  }
73
+ async #sharedTreeCommandReport(task, command, sourceFingerprint) {
74
+ if (!this.#options.trusted)
75
+ return null;
76
+ const active = (await this.#options.taskService.list()).filter((record) => record.status === "active" && record.supersededBy === undefined);
77
+ const byId = new Map(active.map((record) => [record.task.id, record]));
78
+ const rootOf = (id) => {
79
+ let current = id;
80
+ const seen = new Set();
81
+ while (!seen.has(current)) {
82
+ seen.add(current);
83
+ const parentId = byId.get(current)?.task.parentTaskId;
84
+ if (parentId === undefined || !byId.has(parentId))
85
+ return current;
86
+ current = parentId;
87
+ }
88
+ return current;
89
+ };
90
+ const rootId = rootOf(task.id);
91
+ const peers = active.filter((record) => record.task.id !== task.id &&
92
+ rootOf(record.task.id) === rootId);
93
+ if (peers.length === 0)
94
+ return null;
95
+ const configHash = calculateConfigHash(this.#options.config);
96
+ for (const peer of peers) {
97
+ for (const reference of new Set(Object.values(peer.evidence).flat())) {
98
+ let loaded;
99
+ try {
100
+ loaded = await this.#options.evidenceStore.load(reference);
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ if (loaded === null || typeof loaded !== "object")
106
+ continue;
107
+ const evidence = loaded;
108
+ if (evidence.commandId === command.id &&
109
+ evidence.sourceFingerprint === sourceFingerprint &&
110
+ evidence.configHash === configHash &&
111
+ evidence.scope === this.#options.scope &&
112
+ isPassingVerificationEvidence(command, evidence)) {
113
+ return {
114
+ commandId: command.id,
115
+ required: command.required,
116
+ status: "PASS",
117
+ failureClass: "none",
118
+ exitCode: evidence.exitCode,
119
+ timedOut: false,
120
+ testCount: evidence.testCount,
121
+ diagnostic: "",
122
+ evidenceReferences: [],
123
+ startedAt: evidence.startedAt,
124
+ finishedAt: evidence.finishedAt
125
+ };
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
73
131
  async #runCommand(task, command) {
74
132
  const startedAt = (this.#options.now ?? (() => new Date().toISOString()))();
75
133
  const result = await executeConfiguredCommand(command, {
@@ -144,7 +202,9 @@ export class VerificationService {
144
202
  };
145
203
  const results = [];
146
204
  for (const commandId of selection.verifierIds) {
147
- results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
205
+ const command = commandById(this.#options.config, commandId);
206
+ const shared = await this.#sharedTreeCommandReport(validation.value, command, sourceFingerprint);
207
+ results.push(shared ?? (await this.#runCommand(validation.value, command)));
148
208
  }
149
209
  const acceptance = validation.value.criteria.some(c => c.acceptance !== undefined && c.acceptance.mode !== "review-only")
150
210
  ? await replayAcceptance({ ...this.#options, task: validation.value, sourceFingerprint }) : [];
@@ -1,6 +1,6 @@
1
1
  import { execFile as execFileCallback, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
3
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
4
4
  const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
5
5
  const MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024;
6
6
  const DEFAULT_TERMINATION_GRACE_MS = 500;
@@ -137,6 +137,64 @@ async function terminatePosixTree(processId, completion, graceMs) {
137
137
  }
138
138
  }
139
139
  }
140
+ async function collectPosixSubtree(seen, excluded) {
141
+ try {
142
+ const { stdout } = await execFile("ps", ["-axo", "pid=,ppid="], {
143
+ encoding: "utf8"
144
+ });
145
+ const rows = stdout
146
+ .trim()
147
+ .split("\n")
148
+ .map((line) => {
149
+ const [pid, ppid] = line.trim().split(/\s+/u).map(Number);
150
+ return { pid, ppid };
151
+ })
152
+ .filter((row) => Number.isSafeInteger(row.pid) &&
153
+ row.pid > 0 &&
154
+ Number.isSafeInteger(row.ppid) &&
155
+ row.ppid >= 0);
156
+ for (let added = true; added;) {
157
+ added = false;
158
+ for (const { pid, ppid } of rows) {
159
+ if (!excluded.has(pid) && !seen.has(pid) && seen.has(ppid)) {
160
+ seen.add(pid);
161
+ added = true;
162
+ }
163
+ }
164
+ }
165
+ const alive = new Set(rows.map((row) => row.pid));
166
+ const root = [...seen][0];
167
+ return [...seen].filter((pid) => !excluded.has(pid) && (pid === root || alive.has(pid)));
168
+ }
169
+ catch {
170
+ return [...seen].filter((pid) => !excluded.has(pid));
171
+ }
172
+ }
173
+ async function terminatePosixSubtree(processId, processGroupId, completion, graceMs) {
174
+ const excluded = new Set([process.pid, processGroupId]);
175
+ const seen = new Set([processId]);
176
+ for (const pid of (await collectPosixSubtree(seen, excluded)).reverse()) {
177
+ try {
178
+ process.kill(pid, "SIGTERM");
179
+ }
180
+ catch (error) {
181
+ if (errorCode(error) !== "ESRCH") {
182
+ throw error;
183
+ }
184
+ }
185
+ }
186
+ await waitForCompletion(completion, graceMs);
187
+ for (const pid of (await collectPosixSubtree(seen, excluded)).reverse()) {
188
+ try {
189
+ process.kill(pid, "SIGKILL");
190
+ }
191
+ catch (error) {
192
+ if (errorCode(error) !== "ESRCH") {
193
+ throw error;
194
+ }
195
+ }
196
+ }
197
+ }
140
198
  async function defaultTerminateWindowsTree(processId, graceMs) {
141
199
  await execFile("taskkill", ["/pid", String(processId), "/T", "/F"], {
142
200
  encoding: "utf8",
@@ -208,7 +266,11 @@ export class NodeVerificationProcessRunner {
208
266
  await this.#terminateWindowsTree(processId, graceMs);
209
267
  return;
210
268
  }
211
- await terminatePosixTree(this.#processGroupId ?? processId, completion, graceMs);
269
+ if (this.#processGroupId !== undefined) {
270
+ await terminatePosixSubtree(processId, this.#processGroupId, completion, graceMs);
271
+ return;
272
+ }
273
+ await terminatePosixTree(processId, completion, graceMs);
212
274
  }
213
275
  };
214
276
  }
@@ -283,7 +345,7 @@ export async function runVerificationCommand(command, options) {
283
345
  }
284
346
  const outputLimit = boundedPositiveInteger(options.outputLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, MAX_OUTPUT_LIMIT_BYTES, "outputLimitBytes");
285
347
  const terminationGrace = boundedPositiveInteger(options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, MAX_TERMINATION_GRACE_MS, "terminationGraceMs");
286
- const timeoutMs = boundedPositiveInteger(command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
348
+ const timeoutMs = options.timeoutMs === null ? null : boundedPositiveInteger(options.timeoutMs ?? command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
287
349
  const runner = options.runner ?? new NodeVerificationProcessRunner();
288
350
  let running;
289
351
  try {
@@ -304,7 +366,8 @@ export async function runVerificationCommand(command, options) {
304
366
  const stderr = captureOutput(running.stderr, outputLimit, options.onActivity);
305
367
  let timer;
306
368
  const timeout = new Promise((resolve) => {
307
- timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
369
+ if (timeoutMs !== null)
370
+ timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
308
371
  });
309
372
  let removeAbortListener = () => { };
310
373
  const aborted = new Promise((resolve) => {
@@ -263,6 +263,11 @@ evidence. Give each editing conversation its own worktree instead:
263
263
  are never pre-authorized. Turning auto mode off, or `uninstall`, takes
264
264
  exactly those entries back out; a rules file of that name agent-ops did not
265
265
  write is left alone.
266
+ - The opt-in `run` profile additionally authorizes `agent-ops run` and the
267
+ selected `agent-ops followup process` action on Claude and Codex. Normal
268
+ init/update plans disclose the exact entries; dropping the profile removes
269
+ them. Followup metadata actions grant no native execution authority. Processing
270
+ still enforces current repository trust and all ordinary native start controls.
266
271
  - `review` and `doctor` take `--output <file>`: the JSON envelope is written
267
272
  there (mode 0600) and one summary line is printed. Use it instead of a shell
268
273
  redirect, which Codex wraps in `zsh -lc` and can only allow by matching the
@@ -39,3 +39,39 @@ gone. `task debug` now fails with `TASK_DEBUG_REMOVED`.
39
39
  Older state still loads: tasks keep their `debug` tag, stored budget and
40
40
  hypothesis state is ignored, `originRequired` no longer blocks finish, and a
41
41
  config's `features.debug` is accepted and dropped by the next `update`.
42
+
43
+ ## Retained review followups
44
+
45
+ After finish, inspect `agent-ops followup list` and `followup show <followup-id>`
46
+ (add `--json` for agents). `followup import` / `followup sync` retries metadata-only local
47
+ history collection without models or repairs. Ledger state is private in the
48
+ Git common directory and shared by linked worktrees. Collection warnings leave
49
+ original completion intact; retry after fixing the reported source/state problem.
50
+
51
+ A free-text observation needs explicit clarification first:
52
+
53
+ ```sh
54
+ agent-ops followup clarify <followup-id> --finding-file finding.json --reason 'Concrete claim and supporting source'
55
+ agent-ops followup defer <followup-id> --reason 'Revisit after the dependency upgrade'
56
+ agent-ops followup accept <followup-id> --reason 'Accepted tradeoff for this release'
57
+ agent-ops followup reopen <followup-id>
58
+ env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops followup process <followup-id> --host codex
59
+ ```
60
+
61
+ `finding.json` is an ordinary nonblocking ReviewFinding: severity, blocking=false,
62
+ title, details, locations (`path`, `line`), evidence, recommendation and criterionIds.
63
+ Accept/defer require reasons and never mean resolved. Reopen and process again
64
+ preserve source/proof history. Process is an explicit authorization for two fresh
65
+ isolated read-only current-source reviews and, on conclusive PRESENT only, a new
66
+ bounded production repair run. UNKNOWN, drift or reviewer errors stay unresolved.
67
+ Native macOS/clean main/checked-out target/trust/run-policy controls remain; a
68
+ refusal preserves its exact code and never triggers a nested-run workaround.
69
+
70
+ Use the trusted outer host for process; `update --auto-run on` can propose scoped
71
+ run-profile permissions. Do not modify trust or installed settings to bypass a
72
+ refusal. A non-executable claim may use `--review-only-reason 'specific reason'`;
73
+ ABSENT then needs two fresh reasoned reviews. Reproducible bugs need corresponding
74
+ behavioral red-to-green checks. Repair closure uses the exact original finding,
75
+ `followup-resolution` contract, delivered receipt, exact commit and evidence
76
+ digests; generic suite PASS, changed paths or run completion alone cannot close it.
77
+ This workflow adds no DEBUG investigation ceremony or SessionStart summary.
@@ -270,3 +270,57 @@ Before committing, use the default worktree scope for verify, review and complet
270
270
  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.
271
271
 
272
272
  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.
273
+
274
+ ## EVIDENCE-FOLLOWUP-001
275
+
276
+ Retained nonblocking findings and free-text residual risks MUST keep their original
277
+ provenance and MUST NOT be treated as resolved by generic passing checks.
278
+
279
+ - Trigger: Successful receipt-bound delivery, local history import or explicit selected processing.
280
+ - Action: Collect metadata, preserve provenance/dispositions and assess the current source before any bounded repair.
281
+ - Evidence: Resolution binds the original finding, exact commit and corresponding acceptance artifacts or a reasoned review-only contract with two fresh reviews.
282
+ - Positive: A selected PRESENT finding starts a new native repair; its corresponding red-to-green proof and sealed delivery resolve the exact item.
283
+ - Negative: A generic passing suite, changed path or completed run resolves a retained finding without its specific proof.
284
+
285
+ - Successful ordinary finish, native final delivery and recovery collect from
286
+ authenticated local receipts, Git-note digests and full sealed reports. Ordinary
287
+ repository CLI use retries collection and linked-result reconciliation. Historical
288
+ `agent-ops followup import` (alias `sync`) is metadata-only: no model calls,
289
+ assessment, repair or provider quota. Invalid sources warn conservatively;
290
+ collection is advisory and fail-open after valid completion.
291
+ - Versioned private state lives under the repository Git common directory at
292
+ `agent-ops/followup/`, shared by linked worktrees, never committed. Exact
293
+ provenance identities make repeated import idempotent; dispositions, assessment
294
+ links, repair runs and proof history survive retries. Schema 1 readable receipts
295
+ retain an explicit legacy contract provenance limit. Source tasks, runs and
296
+ receipts remain immutable.
297
+ - `followup list` and `show <followup-id>` expose text or `--json` state. Concrete
298
+ findings are candidates; text risks are observations. `clarify <followup-id>
299
+ --finding-file <json> --reason <text>` retains the original observation and
300
+ supplies a concrete nonblocking ReviewFinding with locations and evidence.
301
+ `accept` and `defer` require nonempty reasons, retain history and are distinct
302
+ from resolution. `reopen` retains proof history; `process` may be selected again.
303
+ - `followup process <followup-id> --host <claude|codex>` explicitly authorizes
304
+ current-source revalidation and, only on conclusive PRESENT, a fresh bounded
305
+ production native repair run without another confirmation. Two independent
306
+ read-only sessions inspect isolated current source and the immutable finding.
307
+ UNKNOWN (including reviewer failure, invalid output, timeout, source/config
308
+ drift or insufficient evidence) cannot launch repair or resolve. Fingerprints,
309
+ changed paths and path existence alone prove no semantic conclusion.
310
+ - Native repair preserves macOS support, clean main checkout, checked-out target,
311
+ current trust, run policy, native controls and worktree ownership. Refusals
312
+ retain the item and exact code; there is no nested-run workaround or restart
313
+ of the completed source run. An exclusive private claim and reserved run
314
+ identity prevent duplicate starts; ambiguous interrupted startup stays pending.
315
+ - ABSENT can resolve only under `--review-only-reason <text>` with a specific
316
+ justified contract and two fresh reviews. Reproducible bugs need corresponding
317
+ behavioral red-to-green acceptance. Completed repair closure validates the
318
+ exact original finding's `followup-resolution` criterion, baseline, target,
319
+ source/config, paired checks or reasoned review-only contract, two fresh sealed
320
+ reviews, delivered receipt and exact commit/evidence digests. Generic test PASS,
321
+ run status, acceptance or deferral cannot close it; stale/unlinked proof stays
322
+ unresolved. Cleanup recovery must retain links.
323
+ - CLI permissions for `followup process` follow the opt-in run profile and appear
324
+ in normal init/update plans. They grant no trust/policy changes. No Office
325
+ write UI, SessionStart summary, cloud service or additional mandatory gate is
326
+ introduced.
@@ -62,6 +62,17 @@
62
62
  Repeat the flag for multiple failures. Successful acknowledgements are audited
63
63
  and revalidated on later resumes. This grants no command permission: native
64
64
  approval runs again. Existing writer-death, checkout, trust and budget checks apply.
65
+ `run resume <id> --retry-delivery <task-id>` MAY explicitly requeue one child
66
+ blocked by `RUN_FINAL_PROOF_INTERRUPTED` after the harness cause is repaired.
67
+ It MUST require the matching interrupted handoff, confirmed stopped writers
68
+ and proof, clean owned checkouts and current trust/policy. It MUST preserve
69
+ run/task/goal/baselines/commits, accounting and failure history, record the
70
+ acknowledged attempt, and require a fresh native generation and normal proof.
71
+ It MUST NOT mark delivery, reset another blocker or acknowledge command denial;
72
+ approval retry is a separate control. Ordinary resume retains no-progress blocks.
73
+ Interrupted controlled proof MUST record bounded redacted exit/signal, result
74
+ failure stage and subprocess diagnostics without treating invalid output or a
75
+ nonzero successful envelope as proof.
65
76
  Initial recovery after a current manual resume MAY ignore an owned Codex limit
66
77
  snapshot received before activation whose native update second strictly predates
67
78
  that resume. Automatic restarts, unknown/equal/future timestamps, clock rollback,
@@ -83,6 +94,31 @@ status and delivered commit. With `jobs=1`, child execution MUST wait for that
83
94
  confirmed death. Native registration, recovery and start MUST enforce at most
84
95
  `jobs` active agents; changing task slot accounting alone is insufficient.
85
96
 
97
+ An explicit answer to a pending run question MAY release a confirmed stopped
98
+ coordinator wait before all children finish. The response consumes only this
99
+ run's current-generation wait, records `RUN_COORDINATOR_RESPONSE_WAKE`, and starts
100
+ no process. Normal resume still reconciles death, trust and policy, then issues a
101
+ fresh generation within the job limit. Ordinary resume and replayed answers do
102
+ not consume a new wait. Child delivery, failure history and policy bindings remain
103
+ unchanged; an answer itself grants no additional command capability.
104
+
105
+ A run-level proof MUST match its persisted proof PID, process group and process
106
+ start identity, plus the current coordinator ID and generation. That actor may
107
+ read the run policy for the main root and registered worker checkouts, including
108
+ delivered children during integration, without changing its worker environment.
109
+ Worker-local proof remains bound to its own checkout and worker identity; its
110
+ record MUST NOT shadow an independently authenticated run-level proof. Ordinary
111
+ checkout record checks and integration ownership checks remain authoritative.
112
+ Invalid actor, group, generation or unregistered checkout claims return
113
+ `RUN_WORKER_STALE`; an inactive, disabled or awaiting-resume run still returns
114
+ `RUN_POLICY_DISABLED` after identity validation. Policy reads grant no additional
115
+ capability and do not alter config, trust, registration or delivery state.
116
+
117
+ Known limitation: an inner verification command timeout can terminate the shared
118
+ proof process group before the evidence CLI emits JSON. Response recovery does
119
+ not fix this process isolation defect; a bounded run-only command timeout may be
120
+ assessed through the existing coordinator policy flow, with the failure retained.
121
+
86
122
  This module defines the bounded loop used to plan, implement, verify, and hand off work.
87
123
 
88
124
  ## LOOP-START-001
@@ -1,5 +1,21 @@
1
1
  # Review
2
2
 
3
+ ## REVIEW-CRITERIA-001
4
+
5
+ - Trigger: Production task criteria become ready before implementation.
6
+ - Action: Await one scoped advisory and let the writer record adoption/rejection reasons.
7
+ - Evidence: Private snapshot report plus safe local status/usage log; no completion attestation.
8
+ - Positive: `A revised task shows historical advice and only an explicit rerun spends another round.`
9
+ - Negative: `Automatically re-review every revise or treat advice as final PASS proof.`
10
+
11
+ Every production task creation (including DEBUG, replan children and native run root/child tasks) MUST wait for one independent criteria advisory before implementation. A fresh session uses one configured independent-review target, preferring a different target from the host. It receives only the original goal, assigned intent/covers, criteria and surface audit in an empty disposable workspace. It MUST NOT inspect repository files or run tests. Missing goals are insufficient information; child completeness is scoped to its assignment. Verification materials/import validity remain deferred until implementation; reasoned review-only acceptance is valid.
12
+
13
+ The advisory has no agent-ops execution deadline, stall cutoff or token budget. Provider limitations, genuine launch/auth/output errors and explicit cancellation remain effective. Errors produce NOT_RUN and permit work; cancellation preserves the task and MUST stop implementation. Native review processes remain registered and stoppable. Normal verification and final two-round review retain their existing deadlines and gates.
14
+
15
+ Task creation shows advice and a private report reference. The writer decides which recommendations to adopt and records acceptance/rejection reasons through task revise (a same-criteria revision may record rejected advice). Requirements that are ambiguous or expand scope require user clarification. Reports bind the original task/goal/criteria/surface snapshot; task status and revise show historical reports as stale when those inputs change. A revision MUST NOT automatically re-review. Repeated initial requests reuse the first attempt, including NOT_RUN; only explicit `agent-ops review --criteria --task <id> --rerun --yes` starts another fresh single round. Without --rerun the same command reads/waits for the current attempt.
16
+
17
+ Detailed redacted reports remain private in `.agent-ops/reviews/criteria-<task-id>*.json`; the local log contains only status, duration, counts, reported usage and report reference. CLI invocation counts are distinct from provider model calls, which may be unreported. Report/log failures MUST NOT roll back a task or fabricate evidence. These advisory artifacts MUST NOT satisfy verify, final review or finish.
18
+
3
19
  ## REVIEW-INDEPENDENT-001
4
20
 
5
21
  An independent reviewer MUST receive a minimal packet containing the request, criteria, artifact references, and evidence requirements.
@@ -227,6 +227,9 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
227
227
  `npx lint-staged` 的 allow 規則,讓 pre-commit hook 能寫入主 `.git`。`trust`、
228
228
  `allow-stop`、`init`、`update`、`uninstall` 永遠不會被預先授權。關閉 auto 模式或
229
229
  `uninstall` 會精確移除這些項目;同名但不是 agent-ops 寫的規則檔不會被動到。
230
+ - opt-in `run` profile 另外在 Claude 與 Codex 授權 `agent-ops run` 與明確選取的
231
+ `agent-ops followup process`。一般 init/update 計畫會列出精確項目;移除 profile
232
+ 會移除這些權限。中繼資料操作不授予原生執行權限,process 仍遵守目前儲存庫信任與所有原生啟動限制。
230
233
  - `review` 與 `doctor` 支援 `--output <file>`:JSON envelope 寫入該檔(權限 0600),
231
234
  並只印出一行摘要。請用它取代 shell 重導向;Codex 會把重導向包成 `zsh -lc`,只能
232
235
  以整串指令比對來放行。
@@ -35,3 +35,34 @@ prompt hook 自動建立 DEBUG、DEBUG 專用 Stop 阻擋,以及 Codex `Interr
35
35
  `originRequired` 不再阻擋 finish,config 的 `features.debug` 仍被接受並在下次 `update` 時移除。
36
36
 
37
37
  完整說明見英文 [Task DEBUG](../../en/guides/task-debug.md)。
38
+
39
+ ## 保留的審查 followup
40
+
41
+ finish 後以 `agent-ops followup list`、`followup show <followup-id>` 檢視,agent 可加
42
+ `--json`。`followup import` / `followup sync` 只回填本機歷史中繼資料,不呼叫模型或修復。
43
+ 私有狀態在 Git common directory,所有連結 worktree 共用。收集警告不影響原始完成;
44
+ 修正來源或狀態問題後可重試。
45
+
46
+ 自由文字觀察需先明確釐清:
47
+
48
+ ```sh
49
+ agent-ops followup clarify <followup-id> --finding-file finding.json --reason '具體主張與來源證據'
50
+ agent-ops followup defer <followup-id> --reason '相依套件升級後再處理'
51
+ agent-ops followup accept <followup-id> --reason '接受此版本的取捨'
52
+ agent-ops followup reopen <followup-id>
53
+ env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops followup process <followup-id> --host codex
54
+ ```
55
+
56
+ `finding.json` 是一般非阻擋 ReviewFinding:severity、blocking=false、title、details、
57
+ locations(path、line)、evidence、recommendation、criterionIds。接受與延後必須有理由,
58
+ 不等於結案;reopen 與再次 process 均保留來源及證據歷史。process 明確授權兩次隔離、
59
+ 唯讀、針對目前原始碼的新鮮審查,且只在確定 PRESENT 時啟動全新有界的正式原生修復 run。
60
+ UNKNOWN、漂移與 reviewer 錯誤維持未解決。仍遵守 macOS、乾淨主 checkout、目標分支、
61
+ 信任與 run policy;拒絕保留原始碼,不繞過 nested-run 限制。
62
+
63
+ process 必須透過可信任外層 host;`update --auto-run on` 可提出限定 run profile 權限。
64
+ 不可自行修改信任或已安裝設定繞過拒絕。無法用可執行檢查判定的主張可加
65
+ `--review-only-reason '具體理由'`;ABSENT 仍需兩次新鮮且有理由的審查。
66
+ 可重現錯誤仍需對應 behavioral 紅轉綠。修復結案驗證原始 finding、`followup-resolution`
67
+ 契約、交付 receipt、精確 commit 與證據摘要;一般測試 PASS、路徑變動或 run 完成不能結案。
68
+ 本流程不新增 DEBUG 調查手續或 SessionStart 摘要。