@kylecheng3146/agent-ops 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +4 -4
- package/dist/packages/cli/src/bin.js +6 -1
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/commands/hook.js +7 -12
- package/dist/packages/cli/src/commands/review.js +8 -3
- package/dist/packages/cli/src/hook-process.js +9 -0
- package/dist/runtime/src/hooks/completion-gate.js +18 -54
- package/dist/runtime/src/hooks/dispatch.js +11 -3
- package/dist/runtime/src/install/ownership.js +7 -0
- package/dist/runtime/src/install/plan.js +1 -0
- package/dist/runtime/src/install/uninstall.js +2 -0
- package/dist/runtime/src/review/attestation.js +12 -1
- 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 +71 -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/docs/en/spec/acceptance-and-evidence.md +25 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
- package/package.json +1 -1
|
@@ -336,8 +336,9 @@ export function parseArgs(argv) {
|
|
|
336
336
|
if (command !== "update" && targetVersion !== undefined) {
|
|
337
337
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--target-version may be used only with update.");
|
|
338
338
|
}
|
|
339
|
-
if (base !== undefined && command !== "verify" && command !== "review"
|
|
340
|
-
|
|
339
|
+
if (base !== undefined && command !== "verify" && command !== "review" &&
|
|
340
|
+
!(command === "task" && action === "complete")) {
|
|
341
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review or task complete.");
|
|
341
342
|
}
|
|
342
343
|
if (hookTargets.length > 0 &&
|
|
343
344
|
command !== "init" &&
|
|
@@ -401,9 +402,8 @@ export function parseArgs(argv) {
|
|
|
401
402
|
evidence.length > 0 ||
|
|
402
403
|
dryRun ||
|
|
403
404
|
yes ||
|
|
404
|
-
base !== undefined ||
|
|
405
405
|
(taskId !== undefined && sessionId !== undefined))) {
|
|
406
|
-
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, and json options.");
|
|
406
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, base, and json options.");
|
|
407
407
|
}
|
|
408
408
|
if (command === "review" &&
|
|
409
409
|
(title !== undefined || sessionId !== undefined)) {
|
|
@@ -353,7 +353,12 @@ else {
|
|
|
353
353
|
: { targetVersion: updateArgs.targetVersion })
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
-
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)
|
|
356
|
+
const taskService = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), { completion: {
|
|
357
|
+
root,
|
|
358
|
+
gitRunner: gitRunner(root),
|
|
359
|
+
...(args.base === undefined ? {} : { base: args.base }),
|
|
360
|
+
loadConfig: async () => (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
|
|
361
|
+
} });
|
|
357
362
|
if (args.command === "allow-stop") {
|
|
358
363
|
const config = (await loadEffectiveConfig(root, "project")).config;
|
|
359
364
|
return await runAllowStopCommand({
|
|
@@ -48,7 +48,7 @@ Options:
|
|
|
48
48
|
--criterion <json> Repeatable
|
|
49
49
|
--evidence <criterion-id=reference> Repeatable
|
|
50
50
|
--session <id>
|
|
51
|
-
--base <git-ref> Verify/review a clean committed range
|
|
51
|
+
--base <git-ref> Verify/review/complete a clean committed range
|
|
52
52
|
--dry-run
|
|
53
53
|
--json
|
|
54
54
|
--yes
|
|
@@ -20,25 +20,20 @@ export function normalizeHookInput(harness, input) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Advisory failures stay fail-open; an enabled completion Stop fails closed
|
|
24
|
+
* through the host's native decision protocol (still exit code 0).
|
|
25
25
|
*/
|
|
26
26
|
export async function runHookCommand(options) {
|
|
27
27
|
try {
|
|
28
28
|
const { capabilities } = options.config.profiles.length === 0
|
|
29
29
|
? { capabilities: [] }
|
|
30
30
|
: resolveCapabilities(options.config);
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
input = JSON.parse(options.stdin);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return { exitCode: 0, stdout: "", stderr: "" };
|
|
37
|
-
}
|
|
31
|
+
const input = JSON.parse(options.stdin);
|
|
38
32
|
const descriptor = harnessDescriptor(options.harness);
|
|
39
33
|
const normalized = normalizeHookInput(options.harness, input);
|
|
40
|
-
if (normalized === null
|
|
41
|
-
|
|
34
|
+
if (normalized === null ||
|
|
35
|
+
(options.completionGate !== undefined && options.event === "Stop" && normalized.event !== "stop")) {
|
|
36
|
+
throw new Error("Invalid hook input.");
|
|
42
37
|
}
|
|
43
38
|
const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
|
|
44
39
|
const stopVerification = options.stopVerification !== undefined &&
|
|
@@ -57,7 +52,7 @@ export async function runHookCommand(options) {
|
|
|
57
52
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
58
53
|
}
|
|
59
54
|
catch {
|
|
60
|
-
if (options.harness === "agy" && options.completionGate !== undefined) {
|
|
55
|
+
if (options.harness === "agy" && options.event === "Stop" && options.completionGate !== undefined) {
|
|
61
56
|
return harnessDescriptor("agy").runtime.formatOutput(options.event, {
|
|
62
57
|
action: "block",
|
|
63
58
|
status: "UNKNOWN",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
|
|
2
2
|
import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
|
|
3
3
|
import { renderReviewResult } from "../../../../runtime/src/review/render.js";
|
|
4
|
-
import { saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
4
|
+
import { invalidateReviewAttestation, saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
5
5
|
import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
6
6
|
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
7
7
|
import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
|
|
@@ -75,7 +75,8 @@ async function taskContext(options) {
|
|
|
75
75
|
policyConfigHash: record.policyConfigHash,
|
|
76
76
|
evidence: record.evidence,
|
|
77
77
|
failureFingerprint: record.failureFingerprint,
|
|
78
|
-
criteria
|
|
78
|
+
criteria,
|
|
79
|
+
allCriteriaReviewed: criteria.length === record.task.criteria.length
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
function newestEvidence(values) {
|
|
@@ -287,6 +288,9 @@ export async function runReviewCommand(options) {
|
|
|
287
288
|
});
|
|
288
289
|
}
|
|
289
290
|
sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
291
|
+
if (options.authorized) {
|
|
292
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint);
|
|
293
|
+
}
|
|
290
294
|
if (context !== undefined && options.policyConfigHash !== undefined) {
|
|
291
295
|
if (context.policyConfigHash === null) {
|
|
292
296
|
return notRunEnvelope({
|
|
@@ -422,7 +426,8 @@ export async function runReviewCommand(options) {
|
|
|
422
426
|
// changes again.
|
|
423
427
|
if (options.root !== undefined &&
|
|
424
428
|
result.status === "PASS" &&
|
|
425
|
-
sourceFingerprint !== undefined
|
|
429
|
+
sourceFingerprint !== undefined &&
|
|
430
|
+
(context === undefined || context.allCriteriaReviewed)) {
|
|
426
431
|
await saveReviewAttestation(options.root, {
|
|
427
432
|
schemaVersion: 1,
|
|
428
433
|
...(context === undefined ? {} : { taskId: context.taskId }),
|
|
@@ -267,6 +267,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
267
267
|
return 0;
|
|
268
268
|
}
|
|
269
269
|
const config = configOutcome.config;
|
|
270
|
+
if (completionGateInstalled && !config.features.completionGate.enabled) {
|
|
271
|
+
writeHookOutput(io, harnessDescriptor("agy").runtime.formatOutput("Stop", {
|
|
272
|
+
action: "block",
|
|
273
|
+
status: "UNKNOWN",
|
|
274
|
+
code: "COMPLETION_GATE_CONFIG_DISABLED",
|
|
275
|
+
remedy: "Restore completionGate.enabled or explicitly uninstall the completion gate."
|
|
276
|
+
}));
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
270
279
|
const trustStatus = dependencies.trust === undefined
|
|
271
280
|
? await repositoryTrust(root, config, cliVersion)
|
|
272
281
|
: await dependencies.trust(root, config, cliVersion);
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { calculateConfigHash } from "../config/hash.js";
|
|
3
2
|
import { sha256 } from "../fs/hash.js";
|
|
4
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
-
import { findReviewAttestation } from "../review/attestation.js";
|
|
6
|
-
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
7
4
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
|
-
import {
|
|
5
|
+
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
9
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
7
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
11
8
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
@@ -101,34 +98,10 @@ export class CompletionGateService {
|
|
|
101
98
|
return { ...state, permitFingerprint: fingerprint };
|
|
102
99
|
});
|
|
103
100
|
}
|
|
104
|
-
#isPermitCommand(event
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const commandIndex = tokens.indexOf("allow-stop");
|
|
109
|
-
return commandIndex >= 0 &&
|
|
110
|
-
tokens[commandIndex + 1] === "--session" &&
|
|
111
|
-
(tokens[commandIndex + 2] === sessionId ||
|
|
112
|
-
tokens[commandIndex + 2] === "$AGENT_OPS_SESSION_ID");
|
|
113
|
-
}
|
|
114
|
-
async #hasCurrentEvidence(taskId, criterionId, command, references, configHash, sourceFingerprint) {
|
|
115
|
-
for (const reference of references) {
|
|
116
|
-
if (reference.startsWith("review:"))
|
|
117
|
-
continue;
|
|
118
|
-
const validation = validateEvidence(await this.#options.evidenceStore.load(reference));
|
|
119
|
-
if (!validation.ok)
|
|
120
|
-
continue;
|
|
121
|
-
const evidence = validation.value;
|
|
122
|
-
if (evidence.taskId === taskId &&
|
|
123
|
-
evidence.criterionId === criterionId &&
|
|
124
|
-
evidence.commandId === command.id &&
|
|
125
|
-
evidence.configHash === configHash &&
|
|
126
|
-
evidence.sourceFingerprint === sourceFingerprint &&
|
|
127
|
-
isPassingVerificationEvidence(command, evidence)) {
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
101
|
+
#isPermitCommand(event) {
|
|
102
|
+
const commands = event.event === "command" ? [event] :
|
|
103
|
+
event.event === "command-batch" ? event.commands : [];
|
|
104
|
+
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
132
105
|
}
|
|
133
106
|
async #validateTask(sessionId, sourceFingerprint) {
|
|
134
107
|
let stored;
|
|
@@ -143,30 +116,20 @@ export class CompletionGateService {
|
|
|
143
116
|
if (stored.status !== "complete") {
|
|
144
117
|
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_INCOMPLETE", "Complete the attached task after verification and review.");
|
|
145
118
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_STALE", "Recreate or re-verify the task against the current config.");
|
|
150
|
-
}
|
|
151
|
-
for (const criterion of stored.task.criteria) {
|
|
152
|
-
for (const commandId of criterion.verifierIds) {
|
|
153
|
-
const command = this.#options.config.verification.commands.find(({ id }) => id === commandId);
|
|
154
|
-
if (command === undefined) {
|
|
155
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_EVIDENCE_UNAVAILABLE", "Configured task evidence cannot be resolved.");
|
|
156
|
-
}
|
|
157
|
-
if (command.required &&
|
|
158
|
-
!(await this.#hasCurrentEvidence(stored.task.id, criterion.id, command, stored.evidence[criterion.id] ?? [], configHash, sourceFingerprint))) {
|
|
159
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_EVIDENCE_REQUIRED", "Run agent-ops verify and complete the task with current PASS evidence.");
|
|
160
|
-
}
|
|
161
|
-
}
|
|
119
|
+
const unfinished = findIncompleteSubtask(await this.#options.taskService.list(), stored.task.id);
|
|
120
|
+
if (unfinished !== undefined) {
|
|
121
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
162
122
|
}
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
return gateResult("block",
|
|
123
|
+
const problem = await checkTaskCompletionEvidence(stored, { ...this.#options, sourceFingerprint });
|
|
124
|
+
if (problem !== null) {
|
|
125
|
+
return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
|
|
166
126
|
}
|
|
167
127
|
return null;
|
|
168
128
|
}
|
|
169
129
|
async handle(event) {
|
|
130
|
+
if (this.#isPermitCommand(event)) {
|
|
131
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
132
|
+
}
|
|
170
133
|
const sessionId = event.sessionId;
|
|
171
134
|
if (sessionId === undefined) {
|
|
172
135
|
return event.event === "stop"
|
|
@@ -176,11 +139,12 @@ export class CompletionGateService {
|
|
|
176
139
|
if (event.event === "session-start") {
|
|
177
140
|
return await this.initialize(sessionId);
|
|
178
141
|
}
|
|
179
|
-
if (this.#isPermitCommand(event, sessionId)) {
|
|
180
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
181
|
-
}
|
|
182
142
|
if (event.event !== "stop")
|
|
183
143
|
return null;
|
|
144
|
+
if (event.terminationReason === undefined ||
|
|
145
|
+
(event.terminationReason === "model_stop" && event.fullyIdle === undefined)) {
|
|
146
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_STOP_INPUT_INVALID", "Restore the Stop termination reason and fullyIdle metadata before stopping.");
|
|
147
|
+
}
|
|
184
148
|
if (event.terminationReason !== "model_stop" || event.fullyIdle !== true) {
|
|
185
149
|
return gateResult("continue", "PASS", "COMPLETION_GATE_NON_FINAL_STOP");
|
|
186
150
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
1
2
|
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
2
3
|
import { runStopVerification } from "./stop-verify.js";
|
|
3
4
|
function continueWith(status, code) {
|
|
@@ -36,9 +37,16 @@ function evaluateCommands(commands, scope) {
|
|
|
36
37
|
}
|
|
37
38
|
export async function dispatchHookEvent(event, options) {
|
|
38
39
|
if (options.completionGate !== undefined) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
try {
|
|
41
|
+
const result = await options.completionGate.handle(event);
|
|
42
|
+
if (result !== null)
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof AgentOpsError && error.code === "CHANGE_SURFACE_TRACKED_RUNTIME") {
|
|
47
|
+
return { action: "block", status: "FAIL", code: "COMPLETION_GATE_TRACKED_RUNTIME", remedy: error.message };
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
if (event.event === "unsupported") {
|
|
@@ -86,6 +86,13 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
86
86
|
pathKey(".agent-ops/config.json")
|
|
87
87
|
]);
|
|
88
88
|
const optionalArtifactPaths = new Set();
|
|
89
|
+
// Optional for manifests installed before runtime output was ignored.
|
|
90
|
+
if (manifest.scope === "project") {
|
|
91
|
+
expectedArtifactPaths.set(".agent-ops/.gitignore", {
|
|
92
|
+
path: ".agent-ops/.gitignore", ids: new Set(["runtime-ignore"])
|
|
93
|
+
});
|
|
94
|
+
optionalArtifactPaths.add(".agent-ops/.gitignore");
|
|
95
|
+
}
|
|
89
96
|
const expectedMarkers = new Map();
|
|
90
97
|
const expectedMarkerPaths = new Set();
|
|
91
98
|
const loopHarnesses = selectedLoopHarnesses(harnesses);
|
|
@@ -435,6 +435,7 @@ export async function createInstallPlan(options) {
|
|
|
435
435
|
});
|
|
436
436
|
const contribution = {
|
|
437
437
|
artifacts: [
|
|
438
|
+
...(options.scope === "project" ? [{ id: "runtime-ignore", path: ".agent-ops/.gitignore", content: "/tasks/\n/reviews/\n" }] : []),
|
|
438
439
|
...baseContribution.artifacts,
|
|
439
440
|
...loopContribution.artifacts
|
|
440
441
|
],
|
|
@@ -22,6 +22,8 @@ function selectedSet(harnesses) {
|
|
|
22
22
|
function artifactOwners(manifest, artifact) {
|
|
23
23
|
if (artifact.id === "config")
|
|
24
24
|
return [];
|
|
25
|
+
if (artifact.id === "runtime-ignore")
|
|
26
|
+
return manifest.harness;
|
|
25
27
|
if (artifact.id === "opencode-plugin")
|
|
26
28
|
return ["opencode"];
|
|
27
29
|
if (artifact.id === "claude-loop-launcher" ||
|
|
@@ -57,9 +57,20 @@ export async function findReviewAttestation(root, sourceFingerprint) {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
try {
|
|
60
|
-
|
|
60
|
+
const attestation = parseAttestation(JSON.parse(source));
|
|
61
|
+
return attestation?.sourceFingerprint === sourceFingerprint ? attestation : null;
|
|
61
62
|
}
|
|
62
63
|
catch {
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
/** A new authorized attempt supersedes any earlier PASS for this source. */
|
|
68
|
+
export async function invalidateReviewAttestation(root, sourceFingerprint) {
|
|
69
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
70
|
+
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
71
|
+
}
|
|
72
|
+
const path = attestationPath(root, sourceFingerprint);
|
|
73
|
+
if (await readPrivateFile(path, root) !== null) {
|
|
74
|
+
await writePrivateFile(path, "null\n", root);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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,70 @@ export class TaskService {
|
|
|
196
202
|
}
|
|
197
203
|
async complete(taskId, evidenceInput) {
|
|
198
204
|
const now = assertTimestamp(this.#now());
|
|
205
|
+
const snapshot = await this.#store.read();
|
|
206
|
+
const current = findTask(snapshot, taskId);
|
|
207
|
+
if (current.status === "archived") {
|
|
208
|
+
throw taskError("TASK_NOT_ACTIVE", "An archived task cannot be completed.");
|
|
209
|
+
}
|
|
210
|
+
const submitted = normalizeEvidence(current.task, evidenceInput);
|
|
211
|
+
// A caller cannot hide a recorded failure by submitting only older PASS references.
|
|
212
|
+
const evidence = normalizeEvidence(current.task, Object.fromEntries(Object.entries(submitted).map(([criterionId, references]) => [criterionId,
|
|
213
|
+
[...new Set([...(current.evidence[criterionId] ?? []), ...references])]])));
|
|
214
|
+
const unfinished = findIncompleteSubtask(snapshot.tasks, taskId);
|
|
215
|
+
if (unfinished !== undefined) {
|
|
216
|
+
throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
|
|
217
|
+
}
|
|
218
|
+
const completion = this.#completion;
|
|
219
|
+
if (completion === undefined) {
|
|
220
|
+
throw taskError("TASK_COMPLETION_UNAVAILABLE", "Task completion requires repository config, source, verification evidence and review validation.");
|
|
221
|
+
}
|
|
222
|
+
const evidenceStore = new FileEvidenceStore(completion.root, completion.root);
|
|
223
|
+
for (const [criterionId, references] of Object.entries(evidence)) {
|
|
224
|
+
for (const reference of references) {
|
|
225
|
+
if (reference.startsWith("review:") && current.evidence[criterionId]?.includes(reference))
|
|
226
|
+
continue;
|
|
227
|
+
const validation = validateEvidence(await evidenceStore.load(reference));
|
|
228
|
+
if (!validation.ok || validation.value.taskId !== taskId || validation.value.criterionId !== criterionId) {
|
|
229
|
+
throw taskError("TASK_EVIDENCE_INVALID", `Criterion ${criterionId} requires resolvable evidence belonging to this task and criterion.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const fingerprint = async () => {
|
|
234
|
+
try {
|
|
235
|
+
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
236
|
+
...(completion.base === undefined ? {} : { base: completion.base }) });
|
|
237
|
+
return await calculateSourceFingerprint(completion.root, scope, completion.gitRunner);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error instanceof AgentOpsError && error.code === "REVIEW_NO_CHANGE_SURFACE") {
|
|
241
|
+
throw taskError("TASK_COMPLETION_SCOPE_REQUIRED", completion.base === undefined
|
|
242
|
+
? "No changed worktree scope. For committed work, run verify, review and task complete with the same --base <git-ref>."
|
|
243
|
+
: "The requested base range has no changed paths; choose a base that precedes the committed work.");
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const config = await completion.loadConfig();
|
|
249
|
+
const sourceFingerprint = await fingerprint();
|
|
250
|
+
const problem = await checkTaskCompletionEvidence({ ...current, evidence }, {
|
|
251
|
+
root: completion.root, config, sourceFingerprint,
|
|
252
|
+
evidenceStore
|
|
253
|
+
});
|
|
254
|
+
if (problem !== null) {
|
|
255
|
+
throw taskError(`TASK_COMPLETION_${problem.code}`, problem.remedy);
|
|
256
|
+
}
|
|
257
|
+
if (sourceFingerprint !== await fingerprint() ||
|
|
258
|
+
calculateConfigHash(config) !== calculateConfigHash(await completion.loadConfig())) {
|
|
259
|
+
throw taskError("TASK_COMPLETION_SOURCE_CHANGED", "Source or config changed during completion; verify and review again.");
|
|
260
|
+
}
|
|
199
261
|
return await this.#store.mutate((state) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
262
|
+
if (JSON.stringify(findTask(state, taskId)) !== JSON.stringify(current)) {
|
|
263
|
+
throw taskError("TASK_COMPLETION_STATE_CHANGED", "Task changed during completion; retry against its current evidence and status.");
|
|
264
|
+
}
|
|
265
|
+
const unfinished = findIncompleteSubtask(state.tasks, taskId);
|
|
266
|
+
if (unfinished !== undefined) {
|
|
267
|
+
throw taskError("TASK_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent; archiving unfinished work does not satisfy completion.`);
|
|
203
268
|
}
|
|
204
|
-
const evidence = normalizeEvidence(current.task, evidenceInput);
|
|
205
269
|
if (current.status === "complete") {
|
|
206
270
|
if (JSON.stringify(current.evidence) !== JSON.stringify(evidence)) {
|
|
207
271
|
throw taskError("TASK_ALREADY_COMPLETE", "Completed task evidence cannot be replaced.");
|
|
@@ -60,6 +60,9 @@ async function collectPaths(runner, args) {
|
|
|
60
60
|
return parseNulPaths(result.stdout);
|
|
61
61
|
}
|
|
62
62
|
export async function collectChangeSurface(runner) {
|
|
63
|
+
const trackedRuntime = await collectPaths(runner, [
|
|
64
|
+
"ls-files", "--cached", "-z", "--", ".agent-ops/tasks/", ".agent-ops/reviews/"
|
|
65
|
+
]);
|
|
63
66
|
const staged = await collectPaths(runner, [
|
|
64
67
|
"diff",
|
|
65
68
|
"--cached",
|
|
@@ -69,6 +72,9 @@ export async function collectChangeSurface(runner) {
|
|
|
69
72
|
"--no-textconv",
|
|
70
73
|
"-z"
|
|
71
74
|
]);
|
|
75
|
+
if (trackedRuntime.length > 0 || staged.some((path) => path.startsWith(".agent-ops/tasks/") || path.startsWith(".agent-ops/reviews/"))) {
|
|
76
|
+
throw new AgentOpsError("CHANGE_SURFACE_TRACKED_RUNTIME", "Runtime output is staged or tracked under .agent-ops/tasks/ or .agent-ops/reviews/. Move any source files out of these runtime directories, add ignore rules (agent-ops update), then remove runtime files from the Git index with git rm -r --cached --ignore-unmatch -- .agent-ops/tasks/ .agent-ops/reviews/; keep local files, commit any staged runtime removals, then rerun verify/review.");
|
|
77
|
+
}
|
|
72
78
|
const unstaged = await collectPaths(runner, [
|
|
73
79
|
"diff",
|
|
74
80
|
"--name-only",
|
|
@@ -77,12 +83,15 @@ export async function collectChangeSurface(runner) {
|
|
|
77
83
|
"--no-textconv",
|
|
78
84
|
"-z"
|
|
79
85
|
]);
|
|
80
|
-
const untracked = await collectPaths(runner, [
|
|
86
|
+
const untracked = (await collectPaths(runner, [
|
|
81
87
|
"ls-files",
|
|
82
88
|
"--others",
|
|
83
89
|
"--exclude-standard",
|
|
84
90
|
"-z"
|
|
85
|
-
])
|
|
91
|
+
])).filter(
|
|
92
|
+
// Indexed runtime was rejected above; only generated, untracked output is excluded.
|
|
93
|
+
(path) => !path.startsWith(".agent-ops/tasks/") &&
|
|
94
|
+
!path.startsWith(".agent-ops/reviews/"));
|
|
86
95
|
return {
|
|
87
96
|
staged,
|
|
88
97
|
unstaged,
|
|
@@ -47,6 +47,9 @@ export function isPassingVerificationEvidence(command, evidence) {
|
|
|
47
47
|
if (evidence.status !== "PASS" ||
|
|
48
48
|
evidence.exitCode !== 0 ||
|
|
49
49
|
evidence.failureClass !== "none" ||
|
|
50
|
+
evidence.cwd !== command.cwd ||
|
|
51
|
+
JSON.stringify(evidence.argv) !==
|
|
52
|
+
JSON.stringify([command.command, ...command.args].map(redactSecrets)) ||
|
|
50
53
|
command.evidence.kind === "file") {
|
|
51
54
|
return false;
|
|
52
55
|
}
|
|
@@ -96,7 +99,12 @@ export class FileEvidenceStore {
|
|
|
96
99
|
return null;
|
|
97
100
|
}
|
|
98
101
|
try {
|
|
99
|
-
|
|
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)));
|
|
@@ -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.
|
|
@@ -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 輸出。
|