@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.
- package/dist/packages/cli/src/args.js +25 -6
- package/dist/packages/cli/src/bin.js +6 -1
- package/dist/packages/cli/src/cli.js +136 -4
- package/dist/packages/cli/src/commands/hook.js +12 -13
- package/dist/packages/cli/src/commands/init.js +23 -10
- package/dist/packages/cli/src/commands/review.js +15 -5
- package/dist/packages/cli/src/hook-process.js +23 -5
- package/dist/runtime/src/adapters/claude/config.js +57 -7
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +21 -7
- package/dist/runtime/src/adapters/claude/output.js +24 -0
- package/dist/runtime/src/hooks/completion-gate.js +19 -55
- package/dist/runtime/src/hooks/dispatch.js +11 -3
- package/dist/runtime/src/install/doctor.js +47 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/ownership.js +7 -0
- package/dist/runtime/src/install/plan.js +24 -9
- package/dist/runtime/src/install/probes.js +18 -1
- package/dist/runtime/src/install/uninstall.js +2 -0
- package/dist/runtime/src/review/attestation.js +12 -1
- package/dist/runtime/src/review/execute.js +148 -24
- package/dist/runtime/src/review/host-sandbox.js +49 -0
- package/dist/runtime/src/review/invocation.js +18 -3
- package/dist/runtime/src/review/render.js +14 -0
- package/dist/runtime/src/security/trust.js +1 -2
- package/dist/runtime/src/task/completion.js +62 -0
- package/dist/runtime/src/task/service.js +76 -7
- package/dist/runtime/src/verify/change-surface.js +11 -2
- package/dist/runtime/src/verify/evidence.js +9 -1
- package/dist/runtime/src/verify/service.js +9 -1
- package/dist/runtime/src/verify/spawn.js +4 -3
- package/docs/en/guides/configuration.md +13 -9
- package/docs/en/spec/acceptance-and-evidence.md +25 -0
- package/docs/zh-TW/guides/configuration.md +10 -6
- package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
- 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
|
|
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
|
|
109
|
-
throw taskError("TASK_PARENT_NOT_ACTIVE", "
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
171
|
-
|
|
172
|
-
`
|
|
173
|
-
`agent-ops
|
|
174
|
-
|
|
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
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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;只有明確啟用的
|
|
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
|
|
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 輸出。
|