@kylecheng3146/agent-ops 0.1.22 → 0.2.0

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 (36) hide show
  1. package/dist/packages/cli/src/args.js +25 -6
  2. package/dist/packages/cli/src/bin.js +6 -1
  3. package/dist/packages/cli/src/cli.js +136 -4
  4. package/dist/packages/cli/src/commands/hook.js +12 -13
  5. package/dist/packages/cli/src/commands/init.js +23 -10
  6. package/dist/packages/cli/src/commands/review.js +15 -5
  7. package/dist/packages/cli/src/hook-process.js +23 -5
  8. package/dist/runtime/src/adapters/claude/config.js +57 -7
  9. package/dist/runtime/src/adapters/claude/events.js +8 -0
  10. package/dist/runtime/src/adapters/claude/input.js +21 -7
  11. package/dist/runtime/src/adapters/claude/output.js +24 -0
  12. package/dist/runtime/src/hooks/completion-gate.js +19 -55
  13. package/dist/runtime/src/hooks/dispatch.js +11 -3
  14. package/dist/runtime/src/install/doctor.js +47 -1
  15. package/dist/runtime/src/install/harness.js +1 -1
  16. package/dist/runtime/src/install/ownership.js +7 -0
  17. package/dist/runtime/src/install/plan.js +24 -9
  18. package/dist/runtime/src/install/probes.js +18 -1
  19. package/dist/runtime/src/install/uninstall.js +2 -0
  20. package/dist/runtime/src/review/attestation.js +12 -1
  21. package/dist/runtime/src/review/execute.js +148 -24
  22. package/dist/runtime/src/review/host-sandbox.js +49 -0
  23. package/dist/runtime/src/review/invocation.js +18 -3
  24. package/dist/runtime/src/review/render.js +14 -0
  25. package/dist/runtime/src/security/trust.js +1 -2
  26. package/dist/runtime/src/task/completion.js +62 -0
  27. package/dist/runtime/src/task/service.js +76 -7
  28. package/dist/runtime/src/verify/change-surface.js +11 -2
  29. package/dist/runtime/src/verify/evidence.js +9 -1
  30. package/dist/runtime/src/verify/service.js +9 -1
  31. package/dist/runtime/src/verify/spawn.js +4 -3
  32. package/docs/en/guides/configuration.md +13 -9
  33. package/docs/en/spec/acceptance-and-evidence.md +25 -0
  34. package/docs/zh-TW/guides/configuration.md +10 -6
  35. package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
  36. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ import { createServer } from "node:net";
2
+ /** Targets that need a loopback listener of their own to answer at all. */
3
+ export const BIND_DEPENDENT_TARGETS = ["agy"];
4
+ const BIND_PROBE_TIMEOUT_MS = 2_000;
5
+ /**
6
+ * Opens and immediately closes a loopback listener on an ephemeral port.
7
+ * Cheap enough to run before every review, and it exercises exactly the
8
+ * capability a sandboxed host withholds.
9
+ */
10
+ export async function probeLoopbackBind() {
11
+ return await new Promise((resolve) => {
12
+ const server = createServer();
13
+ let settled = false;
14
+ const finish = (value) => {
15
+ if (settled) {
16
+ return;
17
+ }
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ server.close(() => resolve(value));
21
+ };
22
+ const timer = setTimeout(() => finish(false), BIND_PROBE_TIMEOUT_MS);
23
+ timer.unref();
24
+ server.once("error", () => {
25
+ if (!settled) {
26
+ settled = true;
27
+ clearTimeout(timer);
28
+ resolve(false);
29
+ }
30
+ });
31
+ server.listen(0, "127.0.0.1", () => finish(true));
32
+ });
33
+ }
34
+ /**
35
+ * The host's restriction, read from what the host publishes about itself and
36
+ * then, when that says nothing, from what this process can actually do. Codex
37
+ * declares both facts in the environment; nothing else does, so the probe is
38
+ * what covers every other host.
39
+ */
40
+ export async function detectHostRestriction(options = {}) {
41
+ const env = options.env ?? process.env;
42
+ // Declared and total: no reviewer can reach its own API, so probing the
43
+ // narrower loopback capability would only add latency to a settled answer.
44
+ if (env.CODEX_SANDBOX_NETWORK_DISABLED === "1") {
45
+ return "network-blocked";
46
+ }
47
+ const probe = options.probeBind ?? probeLoopbackBind;
48
+ return (await probe()) ? "none" : "bind-blocked";
49
+ }
@@ -66,6 +66,23 @@ export const READ_ONLY_ARGS = {
66
66
  claude: ["--permission-mode", "plan"],
67
67
  codex: ["-s", "read-only"]
68
68
  };
69
+ /**
70
+ * Where a target writes its running log, when it has one. This is the only
71
+ * heartbeat available for a target whose stdout stays silent until the answer
72
+ * arrives: the file grows while the reviewer works, and stops growing when it
73
+ * is wedged. claude's flag is passed opportunistically by the caller, so an
74
+ * install that predates `--debug-file` still reviews — it only loses the
75
+ * heartbeat.
76
+ */
77
+ function logArgs(target, logFile) {
78
+ if (logFile === undefined) {
79
+ return [];
80
+ }
81
+ if (target === "agy") {
82
+ return ["--log-file", logFile];
83
+ }
84
+ return target === "claude" ? ["--debug-file", logFile] : [];
85
+ }
69
86
  /** Per-target customization suppression. */
70
87
  function isolationArgs(target) {
71
88
  return target === "claude"
@@ -131,9 +148,7 @@ export function buildTargetInvocation(request) {
131
148
  ...(request.repositoryRoot === undefined
132
149
  ? []
133
150
  : ["--add-dir", request.repositoryRoot]),
134
- ...(request.target !== "agy" || request.logFile === undefined
135
- ? []
136
- : ["--log-file", request.logFile]),
151
+ ...logArgs(request.target, request.logFile),
137
152
  ...isolationArgs(request.target),
138
153
  ...shared
139
154
  ],
@@ -51,6 +51,20 @@ export function renderReviewResult(result) {
51
51
  result.attempts?.some((attempt) => attempt.reason === "login-required")) {
52
52
  lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
53
53
  }
54
+ if (result.reason === "host-sandboxed") {
55
+ lines.push("No target ran: the sandbox around this process blocks the network a " +
56
+ "reviewer needs. Run agent-ops review outside the sandbox, or grant " +
57
+ "this command escalated execution and run it again.");
58
+ }
59
+ // Deliberately not the authentication line: a stalled reviewer started and
60
+ // then went silent, so re-running it with more permission only spends the
61
+ // same wait again.
62
+ if (result.reason === "stalled" ||
63
+ result.attempts?.some((attempt) => attempt.reason === "stalled")) {
64
+ lines.push("A stalled reviewer is usually blocked by the host sandbox. Escalating " +
65
+ "permission and retrying does not help; run agent-ops review outside " +
66
+ "the sandbox instead.");
67
+ }
54
68
  return `${lines.join("\n")}\n`;
55
69
  }
56
70
  const report = result.report;
@@ -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,75 @@ 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
+ // Supplying nothing means "the evidence this task already carries".
211
+ // Re-typing it changes no outcome — the union below adds the recorded
212
+ // references to whatever was submitted, so evidence can never be dropped
213
+ // by naming less of it — and forcing a caller to copy references back out
214
+ // of the task store buys nothing but the chance to mistype them.
215
+ const submitted = normalizeEvidence(current.task, Object.keys(evidenceInput).length === 0 ? current.evidence : evidenceInput);
216
+ // A caller cannot hide a recorded failure by submitting only older PASS references.
217
+ const evidence = normalizeEvidence(current.task, Object.fromEntries(Object.entries(submitted).map(([criterionId, references]) => [criterionId,
218
+ [...new Set([...(current.evidence[criterionId] ?? []), ...references])]])));
219
+ const unfinished = findIncompleteSubtask(snapshot.tasks, taskId);
220
+ if (unfinished !== undefined) {
221
+ throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
222
+ }
223
+ const completion = this.#completion;
224
+ if (completion === undefined) {
225
+ throw taskError("TASK_COMPLETION_UNAVAILABLE", "Task completion requires repository config, source, verification evidence and review validation.");
226
+ }
227
+ const evidenceStore = new FileEvidenceStore(completion.root, completion.root);
228
+ for (const [criterionId, references] of Object.entries(evidence)) {
229
+ for (const reference of references) {
230
+ if (reference.startsWith("review:") && current.evidence[criterionId]?.includes(reference))
231
+ continue;
232
+ const validation = validateEvidence(await evidenceStore.load(reference));
233
+ if (!validation.ok || validation.value.taskId !== taskId || validation.value.criterionId !== criterionId) {
234
+ throw taskError("TASK_EVIDENCE_INVALID", `Criterion ${criterionId} requires resolvable evidence belonging to this task and criterion.`);
235
+ }
236
+ }
237
+ }
238
+ const fingerprint = async () => {
239
+ try {
240
+ const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
241
+ ...(completion.base === undefined ? {} : { base: completion.base }) });
242
+ return await calculateSourceFingerprint(completion.root, scope, completion.gitRunner);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof AgentOpsError && error.code === "REVIEW_NO_CHANGE_SURFACE") {
246
+ throw taskError("TASK_COMPLETION_SCOPE_REQUIRED", completion.base === undefined
247
+ ? "No changed worktree scope. For committed work, run verify, review and task complete with the same --base <git-ref>."
248
+ : "The requested base range has no changed paths; choose a base that precedes the committed work.");
249
+ }
250
+ throw error;
251
+ }
252
+ };
253
+ const config = await completion.loadConfig();
254
+ const sourceFingerprint = await fingerprint();
255
+ const problem = await checkTaskCompletionEvidence({ ...current, evidence }, {
256
+ root: completion.root, config, sourceFingerprint,
257
+ evidenceStore
258
+ });
259
+ if (problem !== null) {
260
+ throw taskError(`TASK_COMPLETION_${problem.code}`, problem.remedy);
261
+ }
262
+ if (sourceFingerprint !== await fingerprint() ||
263
+ calculateConfigHash(config) !== calculateConfigHash(await completion.loadConfig())) {
264
+ throw taskError("TASK_COMPLETION_SOURCE_CHANGED", "Source or config changed during completion; verify and review again.");
265
+ }
199
266
  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.");
267
+ if (JSON.stringify(findTask(state, taskId)) !== JSON.stringify(current)) {
268
+ throw taskError("TASK_COMPLETION_STATE_CHANGED", "Task changed during completion; retry against its current evidence and status.");
269
+ }
270
+ const unfinished = findIncompleteSubtask(state.tasks, taskId);
271
+ if (unfinished !== undefined) {
272
+ throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
203
273
  }
204
- const evidence = normalizeEvidence(current.task, evidenceInput);
205
274
  if (current.status === "complete") {
206
275
  if (JSON.stringify(current.evidence) !== JSON.stringify(evidence)) {
207
276
  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)));
@@ -45,7 +45,7 @@ async function* readableBytes(stream) {
45
45
  * last, and its failure list just before it, so head-truncating a large run
46
46
  * discards exactly the part that carries the evidence.
47
47
  */
48
- async function captureOutput(stream, limit) {
48
+ async function captureOutput(stream, limit, onActivity) {
49
49
  const chunks = [];
50
50
  let storedBytes = 0;
51
51
  let truncated = false;
@@ -72,6 +72,7 @@ async function captureOutput(stream, limit) {
72
72
  try {
73
73
  for await (const value of stream) {
74
74
  retain(Buffer.from(value));
75
+ onActivity?.();
75
76
  }
76
77
  }
77
78
  catch {
@@ -295,8 +296,8 @@ export async function runVerificationCommand(command, options) {
295
296
  catch {
296
297
  return emptyResult(command.id, "spawn-failed", elapsedMilliseconds(startedAt, now()));
297
298
  }
298
- const stdout = captureOutput(running.stdout, outputLimit);
299
- const stderr = captureOutput(running.stderr, outputLimit);
299
+ const stdout = captureOutput(running.stdout, outputLimit, options.onActivity);
300
+ const stderr = captureOutput(running.stderr, outputLimit, options.onActivity);
300
301
  let timer;
301
302
  const timeout = new Promise((resolve) => {
302
303
  timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -161,17 +161,21 @@ user hooks live in `.gemini/config/hooks.json`. User-scope rules modify the
161
161
  shared Gemini rule surface at `.gemini/GEMINI.md`. agy 1.1.12 or newer is
162
162
  required for machine-readable `/hooks` diagnostics.
163
163
 
164
- For `agy` plus `loop`, the interactive installer recommends
164
+ For `agy` or `claude` plus `loop`, the interactive installer recommends
165
165
  `features.completionGate.enabled`; non-interactive installs require the explicit
166
- `--completion-gate` flag. The gate uses the documented `conversationId`,
166
+ `--completion-gate` flag. These are the two hosts whose Stop hook can refuse a
167
+ stop: codex never fires its Stop hook under `codex exec` and rejects
168
+ `permissionDecision: ask`, so its permit could not be user-approved, and
169
+ OpenCode's plugin can only deny a tool call. The gate uses the documented `conversationId`,
167
170
  `terminationReason`, and `fullyIdle` Stop fields and returns the documented
168
171
  `decision: "continue"` only for a final changed conversation that lacks current
169
172
  task, verification, or review proof. Pure Q&A, analysis, read-only diagnostics,
170
- error stops, max-step stops, and non-idle stops continue normally. It does not
171
- change Codex, Claude Code, or OpenCode Stop behavior. For headless execution use
172
- `agent-ops agy-run -- <agy arguments>`; a user-approved one-time escape is
173
- `agent-ops allow-stop --session <conversationId>` and is guarded by agy's
174
- documented `force_ask` decision.
173
+ error stops, max-step stops, and non-idle stops continue normally. Claude Code
174
+ enforces the same gate through its own Stop contract, answering a refusal with
175
+ `decision: "block"`. It does not change Codex or OpenCode Stop behavior. For
176
+ headless execution use `agent-ops agy-run -- <agy arguments>`; a user-approved
177
+ one-time escape is `agent-ops allow-stop --session <conversationId>`, guarded by
178
+ agy's documented `force_ask` decision and by Claude's `permissionDecision: ask`.
175
179
 
176
180
  Official references: [agy CLI workspace rule files](https://www.antigravity.google/docs/cli/best-practices/)
177
181
  and [Antigravity hook contracts](https://www.antigravity.google/docs/hooks/).
@@ -213,8 +217,8 @@ classified invalid installed configuration. The managed OpenCode
213
217
  unavailable-runtime error for its supported Bash surface. Codex is explicitly
214
218
  non-enforcing (`unknown`). These are agent-ops output and plugin contracts, not
215
219
  proof that a host honors a denial. `SessionStart` and ordinary Stop verification
216
- failure paths stay fail-open. Only the explicitly enabled agy completion gate
217
- fails closed at final Stop.
220
+ failure paths stay fail-open. Only the explicitly enabled completion gate fails
221
+ closed at final Stop, on agy and Claude Code.
218
222
 
219
223
  Claude's invalid-config fallback has four safeguards: (1) an absent project
220
224
  configuration stays fail-open, so only an invalid `.agent-ops/config.json` can
@@ -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.
@@ -143,16 +143,19 @@ agy 會安裝原生 `PreInvocation` 與 `PreToolUse(run_command)` 子集,docto
143
143
  位於 `.gemini/config/hooks.json`;user scope 會修改共享 Gemini rule surface
144
144
  `.gemini/GEMINI.md`。機器可讀的 `/hooks` 診斷要求 agy 1.1.12 以上。
145
145
 
146
- `agy` 搭配 `loop` 時,互動式 installer 會建議啟用
146
+ `agy` 或 `claude` 搭配 `loop` 時,互動式 installer 會建議啟用
147
147
  `features.completionGate.enabled`;非互動安裝必須明確傳入
148
- `--completion-gate`。閘門使用官方定義的 `conversationId`、
148
+ `--completion-gate`。這兩者是 Stop hook 能真正拒絕收工的 host:codex 在
149
+ `codex exec` 下不會觸發 Stop hook,且拒絕 `permissionDecision: ask`,其 permit
150
+ 無法交由使用者核准;OpenCode plugin 只能拒絕單一 tool call。閘門使用官方定義的 `conversationId`、
149
151
  `terminationReason` 與 `fullyIdle` Stop 欄位,只有在本次 conversation 產生
150
152
  Git-visible net change 且缺少當前 task、驗證或 review 證據時,才回傳官方定義的
151
153
  `decision: "continue"`。純問答、分析、唯讀診斷、錯誤、max-step 與 non-idle Stop
152
- 都正常結束;本版不改變 Codex、Claude Code OpenCode Stop 行為。Headless
154
+ 都正常結束。Claude Code 以自身的 Stop contract 執行同一道閘門,拒絕時回傳
155
+ `decision: "block"`;本版不改變 Codex 或 OpenCode 的 Stop 行為。Headless
153
156
  請使用 `agent-ops agy-run -- <agy arguments>`;使用者可核准一次
154
- `agent-ops allow-stop --session <conversationId>`,該命令由官方定義的
155
- `force_ask` 強制詢問。
157
+ `agent-ops allow-stop --session <conversationId>`,該命令在 agy 由官方定義的
158
+ `force_ask` 強制詢問,在 Claude Code 則由 `permissionDecision: ask` 強制詢問。
156
159
 
157
160
  官方依據:[agy CLI workspace rule file](https://www.antigravity.google/docs/cli/best-practices/)
158
161
  與 [Antigravity hook contract](https://www.antigravity.google/docs/hooks/)。
@@ -191,7 +194,8 @@ OpenCode `tool.execute.before` plugin 可在其支援的 Bash surface
191
194
  上 throw 文件化的 command-policy denial 或 unavailable-runtime error。Codex 明確
192
195
  不執行強制措施(`unknown`)。這些是 agent-ops 的 output 與 plugin contract,不
193
196
  證明 host 會實際遵守 denial。所有 `SessionStart` 與一般 Stop verification failure
194
- path 都維持 fail-open;只有明確啟用的 agy completion gate 會在 final Stop fail-closed。
197
+ path 都維持 fail-open;只有明確啟用的 completion gate 會在 final Stop
198
+ fail-closed,適用於 agy 與 Claude Code。
195
199
 
196
200
  Claude 的無效 config fallback 有四項防護:(1) 缺少 project configuration 時保持
197
201
  fail-open,因此只有無效的 `.agent-ops/config.json` 能進入 fallback;(2) manifest
@@ -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 輸出。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.22",
3
+ "version": "0.2.0",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",