@kylecheng3146/agent-ops 0.1.6 → 0.1.8
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/README.md +27 -0
- package/dist/packages/cli/src/args.js +47 -0
- package/dist/packages/cli/src/bin.js +74 -25
- package/dist/packages/cli/src/cli.js +13 -1
- package/dist/packages/cli/src/commands/init.js +4 -1
- package/dist/packages/cli/src/commands/review.js +371 -27
- package/dist/packages/cli/src/commands/task.js +4 -1
- package/dist/packages/cli/src/commands/verify.js +13 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +62 -3
- package/dist/runtime/src/config/merge.js +17 -2
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/install/doctor.js +42 -1
- package/dist/runtime/src/install/plan.js +11 -5
- package/dist/runtime/src/review/execute.js +180 -0
- package/dist/runtime/src/review/extract.js +69 -0
- package/dist/runtime/src/review/invocation.js +116 -0
- package/dist/runtime/src/review/packet.js +42 -5
- package/dist/runtime/src/review/probe.js +72 -0
- package/dist/runtime/src/review/render.js +62 -0
- package/dist/runtime/src/review/report.js +183 -0
- package/dist/runtime/src/review/result.js +2 -2
- package/dist/runtime/src/review/roles.js +35 -0
- package/dist/runtime/src/review/runner.js +98 -12
- package/dist/runtime/src/review/scope.js +123 -0
- package/dist/runtime/src/schema/validate.js +80 -0
- package/dist/runtime/src/task/service.js +46 -1
- package/dist/runtime/src/task/store.js +16 -4
- package/dist/runtime/src/verify/change-surface.js +38 -2
- package/dist/runtime/src/verify/command-executor.js +4 -1
- package/dist/runtime/src/verify/evidence.js +36 -0
- package/dist/runtime/src/verify/scope.js +1 -2
- package/dist/runtime/src/verify/service.js +66 -9
- package/dist/runtime/src/verify/source-fingerprint.js +49 -0
- package/dist/runtime/src/verify/spawn.js +9 -3
- package/docs/en/guides/configuration.md +68 -0
- package/docs/en/spec/review.md +37 -4
- package/docs/zh-TW/guides/configuration.md +60 -0
- package/docs/zh-TW/spec/review.md +33 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +29 -0
- package/schemas/evidence.schema.json +16 -1
- package/schemas/review-report.schema.json +48 -0
|
@@ -73,6 +73,10 @@ export class TaskService {
|
|
|
73
73
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
74
74
|
}
|
|
75
75
|
async create(input) {
|
|
76
|
+
if (input.policyConfigHash !== undefined &&
|
|
77
|
+
!/^[a-f0-9]{64}$/u.test(input.policyConfigHash)) {
|
|
78
|
+
throw taskError("TASK_POLICY_CONFIG_INVALID", "Policy config hash must be a lowercase SHA-256 digest.");
|
|
79
|
+
}
|
|
76
80
|
const task = {
|
|
77
81
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
78
82
|
id: this.#generateId(),
|
|
@@ -96,7 +100,8 @@ export class TaskService {
|
|
|
96
100
|
updatedAt: now,
|
|
97
101
|
completedAt: null,
|
|
98
102
|
archivedAt: null,
|
|
99
|
-
failureFingerprint: null
|
|
103
|
+
failureFingerprint: null,
|
|
104
|
+
policyConfigHash: input.policyConfigHash ?? null
|
|
100
105
|
};
|
|
101
106
|
state.tasks.push(record);
|
|
102
107
|
return cloneRecord(record);
|
|
@@ -181,6 +186,46 @@ export class TaskService {
|
|
|
181
186
|
return cloneRecord(completed);
|
|
182
187
|
});
|
|
183
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Append evidence for some criteria without completing the task. Unlike
|
|
191
|
+
* `complete`, the input may be partial — an independent review covers the
|
|
192
|
+
* criteria it was asked about, not necessarily all of them. Only an active
|
|
193
|
+
* task accepts evidence: a completed record must stay exactly as it was
|
|
194
|
+
* verified.
|
|
195
|
+
*/
|
|
196
|
+
async recordEvidence(taskId, evidenceInput) {
|
|
197
|
+
const now = assertTimestamp(this.#now());
|
|
198
|
+
return await this.#store.mutate((state) => {
|
|
199
|
+
const current = findTask(state, taskId);
|
|
200
|
+
if (current.status !== "active") {
|
|
201
|
+
throw taskError("TASK_NOT_ACTIVE", "Only an active task can record additional evidence.");
|
|
202
|
+
}
|
|
203
|
+
const criterionIds = new Set(current.task.criteria.map((criterion) => criterion.id));
|
|
204
|
+
const evidence = Object.fromEntries(Object.entries(current.evidence).map(([criterionId, references]) => [
|
|
205
|
+
criterionId,
|
|
206
|
+
[...references]
|
|
207
|
+
]));
|
|
208
|
+
for (const [criterionId, references] of Object.entries(evidenceInput)) {
|
|
209
|
+
if (!criterionIds.has(criterionId)) {
|
|
210
|
+
throw taskError("TASK_EVIDENCE_UNKNOWN_CRITERION", `Unknown criterion: ${criterionId}`);
|
|
211
|
+
}
|
|
212
|
+
if (references.length === 0 ||
|
|
213
|
+
references.some((reference) => typeof reference !== "string" || reference.trim().length === 0)) {
|
|
214
|
+
throw taskError("TASK_EVIDENCE_INVALID", `Evidence for ${criterionId} must be non-empty references.`);
|
|
215
|
+
}
|
|
216
|
+
evidence[criterionId] = [
|
|
217
|
+
...new Set([...(evidence[criterionId] ?? []), ...references])
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
const updated = {
|
|
221
|
+
...current,
|
|
222
|
+
evidence,
|
|
223
|
+
updatedAt: now
|
|
224
|
+
};
|
|
225
|
+
replaceTask(state, updated);
|
|
226
|
+
return cloneRecord(updated);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
184
229
|
async archive(taskId) {
|
|
185
230
|
const now = assertTimestamp(this.#now());
|
|
186
231
|
return await this.#store.mutate((state) => {
|
|
@@ -64,9 +64,13 @@ function parseTaskRecord(value) {
|
|
|
64
64
|
"task",
|
|
65
65
|
"updatedAt"
|
|
66
66
|
];
|
|
67
|
-
|
|
68
|
-
(
|
|
69
|
-
|
|
67
|
+
const allowedKeys = new Set([
|
|
68
|
+
baseKeys.join("\0"),
|
|
69
|
+
[...baseKeys, "failureFingerprint"].sort().join("\0"),
|
|
70
|
+
[...baseKeys, "policyConfigHash"].sort().join("\0"),
|
|
71
|
+
[...baseKeys, "failureFingerprint", "policyConfigHash"].sort().join("\0")
|
|
72
|
+
]);
|
|
73
|
+
if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
|
|
70
74
|
return invalidState("Task state contains an invalid task record.");
|
|
71
75
|
}
|
|
72
76
|
const task = validateTask(value.task);
|
|
@@ -127,6 +131,13 @@ function parseTaskRecord(value) {
|
|
|
127
131
|
recordedAt: fingerprint.recordedAt
|
|
128
132
|
};
|
|
129
133
|
}
|
|
134
|
+
const policyConfigHash = value.policyConfigHash === undefined
|
|
135
|
+
? null
|
|
136
|
+
: value.policyConfigHash;
|
|
137
|
+
if (policyConfigHash !== null &&
|
|
138
|
+
(typeof policyConfigHash !== "string" || !/^[a-f0-9]{64}$/u.test(policyConfigHash))) {
|
|
139
|
+
return invalidState("Task state contains an invalid policy config hash.");
|
|
140
|
+
}
|
|
130
141
|
return {
|
|
131
142
|
task: task.value,
|
|
132
143
|
status,
|
|
@@ -135,7 +146,8 @@ function parseTaskRecord(value) {
|
|
|
135
146
|
updatedAt: value.updatedAt,
|
|
136
147
|
completedAt: value.completedAt,
|
|
137
148
|
archivedAt: value.archivedAt,
|
|
138
|
-
failureFingerprint
|
|
149
|
+
failureFingerprint,
|
|
150
|
+
policyConfigHash
|
|
139
151
|
};
|
|
140
152
|
}
|
|
141
153
|
function parseSession(value) {
|
|
@@ -4,7 +4,7 @@ const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)
|
|
|
4
4
|
function sortedUnique(values) {
|
|
5
5
|
return [...new Set(values)].sort();
|
|
6
6
|
}
|
|
7
|
-
function normalizePortablePath(path) {
|
|
7
|
+
export function normalizePortablePath(path) {
|
|
8
8
|
if (path.length === 0 ||
|
|
9
9
|
path.includes("\\") ||
|
|
10
10
|
path.startsWith("/") ||
|
|
@@ -31,7 +31,7 @@ function normalizePortablePath(path) {
|
|
|
31
31
|
}
|
|
32
32
|
return normalizedSegments.join("/");
|
|
33
33
|
}
|
|
34
|
-
function parseNulPaths(stdout) {
|
|
34
|
+
export function parseNulPaths(stdout) {
|
|
35
35
|
if (stdout.byteLength === 0) {
|
|
36
36
|
return [];
|
|
37
37
|
}
|
|
@@ -64,11 +64,19 @@ export async function collectChangeSurface(runner) {
|
|
|
64
64
|
"diff",
|
|
65
65
|
"--cached",
|
|
66
66
|
"--name-only",
|
|
67
|
+
"--full-name",
|
|
68
|
+
"--no-renames",
|
|
69
|
+
"--no-ext-diff",
|
|
70
|
+
"--no-textconv",
|
|
67
71
|
"-z"
|
|
68
72
|
]);
|
|
69
73
|
const unstaged = await collectPaths(runner, [
|
|
70
74
|
"diff",
|
|
71
75
|
"--name-only",
|
|
76
|
+
"--full-name",
|
|
77
|
+
"--no-renames",
|
|
78
|
+
"--no-ext-diff",
|
|
79
|
+
"--no-textconv",
|
|
72
80
|
"-z"
|
|
73
81
|
]);
|
|
74
82
|
const untracked = await collectPaths(runner, [
|
|
@@ -84,3 +92,31 @@ export async function collectChangeSurface(runner) {
|
|
|
84
92
|
paths: sortedUnique([...staged, ...unstaged, ...untracked])
|
|
85
93
|
};
|
|
86
94
|
}
|
|
95
|
+
function decodeCommit(stdout) {
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(stdout).trim();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new AgentOpsError("CHANGE_SURFACE_INVALID_OUTPUT", "Git commit output is not valid UTF-8.", { cause: error });
|
|
102
|
+
}
|
|
103
|
+
if (!/^[a-f0-9]{40,64}$/u.test(text)) {
|
|
104
|
+
throw new AgentOpsError("CHANGE_SURFACE_INVALID_OUTPUT", "Git did not return one commit object ID.");
|
|
105
|
+
}
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
export async function resolveGitCommit(runner, ref) {
|
|
109
|
+
const result = await runner.run([
|
|
110
|
+
"rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`
|
|
111
|
+
]);
|
|
112
|
+
if (result.exitCode !== 0) {
|
|
113
|
+
throw new AgentOpsError("CHANGE_SURFACE_GIT_FAILED", "The requested base ref does not resolve to a commit.");
|
|
114
|
+
}
|
|
115
|
+
return decodeCommit(result.stdout);
|
|
116
|
+
}
|
|
117
|
+
export async function collectBaseChangePaths(runner, base) {
|
|
118
|
+
return await collectPaths(runner, [
|
|
119
|
+
"diff", "--name-only", "--full-name", "--no-renames", "--no-ext-diff",
|
|
120
|
+
"--no-textconv", "-z", `${base}...HEAD`
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
@@ -102,7 +102,10 @@ export async function executeConfiguredCommand(command, options) {
|
|
|
102
102
|
}
|
|
103
103
|
export function aggregateVerificationStatus(results) {
|
|
104
104
|
const required = results.filter((result) => result.required);
|
|
105
|
-
|
|
105
|
+
if (required.length === 0) {
|
|
106
|
+
return "PASS";
|
|
107
|
+
}
|
|
108
|
+
const gating = required;
|
|
106
109
|
if (gating.some((result) => result.status === "FAIL")) {
|
|
107
110
|
return "FAIL";
|
|
108
111
|
}
|
|
@@ -3,6 +3,7 @@ import { EVIDENCE_SCHEMA_VERSION } from "../contracts.js";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { calculateConfigHash } from "../config/hash.js";
|
|
6
|
+
import { evaluateTestCount } from "./test-count.js";
|
|
6
7
|
export { calculateConfigHash } from "../config/hash.js";
|
|
7
8
|
import { validateEvidence } from "../schema/validate.js";
|
|
8
9
|
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
@@ -34,10 +35,24 @@ export function buildVerificationEvidence(input) {
|
|
|
34
35
|
finishedAt: input.finishedAt,
|
|
35
36
|
exitCode: input.exitCode,
|
|
36
37
|
testCount: input.testCount,
|
|
38
|
+
status: input.status,
|
|
39
|
+
failureClass: redactSecrets(input.failureClass),
|
|
40
|
+
sourceFingerprint: input.sourceFingerprint,
|
|
37
41
|
toolVersions: redactRecord(input.toolVersions),
|
|
38
42
|
configHash: calculateConfigHash(input.config)
|
|
39
43
|
});
|
|
40
44
|
}
|
|
45
|
+
/** Confirms that a persisted PASS still matches the current command contract. */
|
|
46
|
+
export function isPassingVerificationEvidence(command, evidence) {
|
|
47
|
+
if (evidence.status !== "PASS" ||
|
|
48
|
+
evidence.exitCode !== 0 ||
|
|
49
|
+
evidence.failureClass !== "none" ||
|
|
50
|
+
command.evidence.kind === "file") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return command.evidence.kind !== "test-count" ||
|
|
54
|
+
evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS";
|
|
55
|
+
}
|
|
41
56
|
export class FileEvidenceStore {
|
|
42
57
|
#root;
|
|
43
58
|
#anchorDirectory;
|
|
@@ -66,4 +81,25 @@ export class FileEvidenceStore {
|
|
|
66
81
|
}
|
|
67
82
|
return relativePath;
|
|
68
83
|
}
|
|
84
|
+
async load(reference) {
|
|
85
|
+
const segments = reference.split("/");
|
|
86
|
+
if (!reference.startsWith(".agent-ops/tasks/evidence/") ||
|
|
87
|
+
segments.some((segment) => segment.length === 0 ||
|
|
88
|
+
segment === "." ||
|
|
89
|
+
segment === ".." ||
|
|
90
|
+
!/^[A-Za-z0-9._-]+$/u.test(segment))) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const path = join(this.#root, ...segments);
|
|
94
|
+
const source = await readPrivateFile(path, this.#anchorDirectory);
|
|
95
|
+
if (source === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(source);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new AgentOpsError("EVIDENCE_INVALID", "Stored evidence is not valid JSON.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
69
105
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { AgentOpsError } from "../fs/paths.js";
|
|
2
1
|
function sortedUnique(values) {
|
|
3
2
|
return [...new Set(values)].sort();
|
|
4
3
|
}
|
|
@@ -18,7 +17,7 @@ function sameVerifierIds(left, right) {
|
|
|
18
17
|
}
|
|
19
18
|
function fallbackSelection(reason, evidence) {
|
|
20
19
|
if (evidence.requiredVerifierIds.length === 0) {
|
|
21
|
-
|
|
20
|
+
return { verifierIds: [], fallback: true, reason, evidence };
|
|
22
21
|
}
|
|
23
22
|
return {
|
|
24
23
|
verifierIds: evidence.requiredVerifierIds,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
2
2
|
import { validateTaskAgainstConfig } from "../schema/validate.js";
|
|
3
3
|
import { collectChangeSurface } from "./change-surface.js";
|
|
4
|
+
import { resolveReviewScope, reviewScopeSignature } from "../review/scope.js";
|
|
5
|
+
import { calculateSourceFingerprint } from "./source-fingerprint.js";
|
|
4
6
|
import { buildVerificationEvidence } from "./evidence.js";
|
|
5
7
|
import { createFailureFingerprint } from "./fingerprint.js";
|
|
6
8
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "./command-executor.js";
|
|
@@ -41,7 +43,7 @@ export class VerificationService {
|
|
|
41
43
|
}
|
|
42
44
|
return await resolveContainedPath(this.#options.root, command.cwd);
|
|
43
45
|
}
|
|
44
|
-
async #persistEvidence(task, command, startedAt, finishedAt, result,
|
|
46
|
+
async #persistEvidence(task, command, startedAt, finishedAt, result, sourceFingerprint) {
|
|
45
47
|
const references = [];
|
|
46
48
|
for (const criterion of relevantCriteria(task, command.id)) {
|
|
47
49
|
const evidence = buildVerificationEvidence({
|
|
@@ -51,8 +53,11 @@ export class VerificationService {
|
|
|
51
53
|
scope: this.#options.scope,
|
|
52
54
|
startedAt,
|
|
53
55
|
finishedAt,
|
|
54
|
-
exitCode,
|
|
56
|
+
exitCode: result.exitCode,
|
|
55
57
|
testCount: result.testCount,
|
|
58
|
+
status: result.status,
|
|
59
|
+
failureClass: result.failureClass,
|
|
60
|
+
sourceFingerprint,
|
|
56
61
|
toolVersions: this.#options.toolVersions ?? {},
|
|
57
62
|
config: this.#options.config
|
|
58
63
|
});
|
|
@@ -84,7 +89,6 @@ export class VerificationService {
|
|
|
84
89
|
diagnostics: result.diagnostic
|
|
85
90
|
});
|
|
86
91
|
const finishedAt = (this.#options.now ?? (() => new Date().toISOString()))();
|
|
87
|
-
const evidenceReferences = await this.#persistEvidence(task, command, startedAt, finishedAt, result, result.exitCode);
|
|
88
92
|
return {
|
|
89
93
|
commandId: command.id,
|
|
90
94
|
required: command.required,
|
|
@@ -94,7 +98,9 @@ export class VerificationService {
|
|
|
94
98
|
timedOut: result.timedOut,
|
|
95
99
|
testCount: result.testCount,
|
|
96
100
|
diagnostic: fingerprint?.diagnostics ?? "",
|
|
97
|
-
evidenceReferences
|
|
101
|
+
evidenceReferences: [],
|
|
102
|
+
startedAt,
|
|
103
|
+
finishedAt
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
async verify(taskId) {
|
|
@@ -107,23 +113,72 @@ export class VerificationService {
|
|
|
107
113
|
throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
|
|
108
114
|
"Task and verification configuration are incompatible.");
|
|
109
115
|
}
|
|
110
|
-
const
|
|
116
|
+
const reviewScope = await resolveReviewScope({
|
|
117
|
+
root: this.#options.root,
|
|
118
|
+
runner: this.#options.gitRunner,
|
|
119
|
+
...(this.#options.base === undefined ? {} : { base: this.#options.base })
|
|
120
|
+
});
|
|
121
|
+
const sourceFingerprint = await calculateSourceFingerprint(this.#options.root, reviewScope, this.#options.gitRunner);
|
|
122
|
+
const worktreeSurface = reviewScope.mode === "worktree"
|
|
123
|
+
? await collectChangeSurface(this.#options.gitRunner)
|
|
124
|
+
: { staged: [], unstaged: [], untracked: [], paths: reviewScope.changedFiles };
|
|
125
|
+
const surface = worktreeSurface;
|
|
111
126
|
const selection = selectVerificationScope(surface.paths, this.#options.config);
|
|
112
127
|
const results = [];
|
|
113
128
|
for (const commandId of selection.verifierIds) {
|
|
114
129
|
results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
|
|
115
130
|
}
|
|
116
|
-
|
|
131
|
+
let status = aggregateVerificationStatus(results);
|
|
132
|
+
let sourceChanged = false;
|
|
133
|
+
const postflightScope = await resolveReviewScope({
|
|
134
|
+
root: this.#options.root,
|
|
135
|
+
runner: this.#options.gitRunner,
|
|
136
|
+
...(this.#options.base === undefined ? {} : { base: this.#options.base })
|
|
137
|
+
});
|
|
138
|
+
const postflightFingerprint = await calculateSourceFingerprint(this.#options.root, postflightScope, this.#options.gitRunner);
|
|
139
|
+
if (reviewScopeSignature(reviewScope) !== reviewScopeSignature(postflightScope) ||
|
|
140
|
+
sourceFingerprint !== postflightFingerprint) {
|
|
141
|
+
status = "UNKNOWN";
|
|
142
|
+
sourceChanged = true;
|
|
143
|
+
}
|
|
117
144
|
let signal = null;
|
|
118
145
|
if (status === "PASS") {
|
|
146
|
+
const taskEvidence = {};
|
|
147
|
+
for (const [index, result] of results.entries()) {
|
|
148
|
+
const command = commandById(this.#options.config, result.commandId);
|
|
149
|
+
const references = await this.#persistEvidence(validation.value, command, result.startedAt, result.finishedAt, result, sourceFingerprint);
|
|
150
|
+
results[index] = { ...result, evidenceReferences: references };
|
|
151
|
+
for (const [criterionIndex, criterion] of relevantCriteria(validation.value, result.commandId).entries()) {
|
|
152
|
+
const reference = references[criterionIndex];
|
|
153
|
+
if (reference !== undefined) {
|
|
154
|
+
taskEvidence[criterion.id] = [
|
|
155
|
+
...(taskEvidence[criterion.id] ?? []),
|
|
156
|
+
reference
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (Object.keys(taskEvidence).length > 0) {
|
|
162
|
+
await this.#options.taskService.recordEvidence(taskId, taskEvidence);
|
|
163
|
+
}
|
|
119
164
|
await this.#options.taskService.clearFailure(taskId);
|
|
120
165
|
}
|
|
121
166
|
else {
|
|
122
167
|
const required = results.filter((result) => result.required);
|
|
123
|
-
const gating = required
|
|
168
|
+
const gating = required;
|
|
124
169
|
const failed = gating.find((result) => result.status !== "PASS");
|
|
125
170
|
if (failed === undefined) {
|
|
126
|
-
|
|
171
|
+
if (!sourceChanged) {
|
|
172
|
+
throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a required result.");
|
|
173
|
+
}
|
|
174
|
+
const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
|
|
175
|
+
commandId: "source-snapshot",
|
|
176
|
+
failureClass: "source-changed-during-verification",
|
|
177
|
+
exitCategory: "no-exit",
|
|
178
|
+
diagnostics: "source changed during verification"
|
|
179
|
+
}));
|
|
180
|
+
signal = advanced.signal;
|
|
181
|
+
return { taskId, status, surface, selection, results, signal, reviewScope, sourceFingerprint };
|
|
127
182
|
}
|
|
128
183
|
const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
|
|
129
184
|
commandId: failed.commandId,
|
|
@@ -139,7 +194,9 @@ export class VerificationService {
|
|
|
139
194
|
surface,
|
|
140
195
|
selection,
|
|
141
196
|
results,
|
|
142
|
-
signal
|
|
197
|
+
signal,
|
|
198
|
+
reviewScope,
|
|
199
|
+
sourceFingerprint
|
|
143
200
|
};
|
|
144
201
|
}
|
|
145
202
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile, lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
import { resolveGitCommit } from "./change-surface.js";
|
|
6
|
+
export async function calculateSourceFingerprint(root, scope, runner) {
|
|
7
|
+
const head = await resolveGitCommit(runner, "HEAD");
|
|
8
|
+
if (scope.mode === "base") {
|
|
9
|
+
return sha256(JSON.stringify({
|
|
10
|
+
domain: "agent-ops-source-v1",
|
|
11
|
+
mode: "base",
|
|
12
|
+
head,
|
|
13
|
+
base: scope.resolvedBase,
|
|
14
|
+
paths: [...scope.changedFiles]
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
const paths = [];
|
|
18
|
+
for (const path of scope.changedFiles) {
|
|
19
|
+
const absolute = join(root, ...path.split("/"));
|
|
20
|
+
let entry;
|
|
21
|
+
try {
|
|
22
|
+
entry = await lstat(absolute);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
26
|
+
paths.push({ path, kind: "deleted" });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
if (!entry.isFile()) {
|
|
32
|
+
throw new AgentOpsError("SOURCE_SNAPSHOT_UNAVAILABLE", "Review source contains an unsupported entry.");
|
|
33
|
+
}
|
|
34
|
+
const bytes = await readFile(absolute);
|
|
35
|
+
paths.push({
|
|
36
|
+
path,
|
|
37
|
+
kind: "file",
|
|
38
|
+
executable: (entry.mode & 0o111) !== 0,
|
|
39
|
+
hash: sha256(bytes)
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return sha256(JSON.stringify({
|
|
43
|
+
domain: "agent-ops-source-v1",
|
|
44
|
+
mode: "worktree",
|
|
45
|
+
head,
|
|
46
|
+
base: null,
|
|
47
|
+
paths
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
@@ -139,13 +139,17 @@ export class NodeVerificationProcessRunner {
|
|
|
139
139
|
cwd: request.cwd,
|
|
140
140
|
detached: this.#platform !== "win32",
|
|
141
141
|
env: {
|
|
142
|
-
...process.env,
|
|
142
|
+
...(request.replaceEnv === true ? {} : process.env),
|
|
143
143
|
...(request.env ?? {})
|
|
144
144
|
},
|
|
145
145
|
shell: request.shell,
|
|
146
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
146
|
+
stdio: [request.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
147
147
|
windowsHide: true
|
|
148
148
|
});
|
|
149
|
+
if (request.stdin !== undefined && child.stdin !== null) {
|
|
150
|
+
child.stdin.on("error", () => { });
|
|
151
|
+
child.stdin.end(request.stdin);
|
|
152
|
+
}
|
|
149
153
|
let settled = false;
|
|
150
154
|
const completion = new Promise((resolve) => {
|
|
151
155
|
child.once("error", (error) => {
|
|
@@ -263,7 +267,9 @@ export async function runVerificationCommand(command, options) {
|
|
|
263
267
|
args: [...command.args],
|
|
264
268
|
cwd: options.cwd,
|
|
265
269
|
shell: command.shell === true,
|
|
266
|
-
...(options.env === undefined ? {} : { env: options.env })
|
|
270
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
271
|
+
...(options.replaceEnv === true ? { replaceEnv: true } : {}),
|
|
272
|
+
...(options.stdin === undefined ? {} : { stdin: options.stdin })
|
|
267
273
|
});
|
|
268
274
|
}
|
|
269
275
|
catch {
|
|
@@ -31,6 +31,74 @@ implies them. Advisory runs through the real SessionStart path and is
|
|
|
31
31
|
fail-open. Claude and Codex lifecycle support is `supported`; OpenCode begins
|
|
32
32
|
at app initialization and is honestly reported as `degraded`.
|
|
33
33
|
|
|
34
|
+
### External review targets
|
|
35
|
+
|
|
36
|
+
`agent-ops review` can call another agent CLI to review your work. It is
|
|
37
|
+
disabled by default: an absent `reviewRoles` field, an absent
|
|
38
|
+
`--review-target` flag, and the interactive question's default all mean off.
|
|
39
|
+
Enable it during `agent-ops init`, or by hand:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"reviewRoles": [
|
|
44
|
+
{ "role": "independent-review", "targets": ["claude"] }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`targets` is an **ordered fallback chain**. Every review locks to the
|
|
50
|
+
staged/unstaged/untracked surface (or a clean `--base <ref>...HEAD` range),
|
|
51
|
+
requires fresh PASS evidence for required checks, and prints a complete native
|
|
52
|
+
schema report without persisting it.
|
|
53
|
+
|
|
54
|
+
Every attempt starts from a fresh temporary cwd with a narrow environment. The
|
|
55
|
+
following target identities may be configured; only Claude currently meets the
|
|
56
|
+
complete context-isolation contract and can execute automatically:
|
|
57
|
+
|
|
58
|
+
| Target | Invocation | Read-only |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `codex` | `codex exec` | retained; `capability-unavailable` |
|
|
61
|
+
| `agy` (Antigravity) | `agy -p` | retained; `capability-unavailable` |
|
|
62
|
+
| `claude` | `claude -p` | `--permission-mode plan --safe-mode` |
|
|
63
|
+
|
|
64
|
+
`opencode` is **not** a review target even though it is a supported harness.
|
|
65
|
+
Its `--agent plan` is rejected as a subagent and silently falls back to a
|
|
66
|
+
writable agent, so it cannot satisfy the read-only precondition. A target with
|
|
67
|
+
no read-only flag is skipped rather than run unsandboxed.
|
|
68
|
+
|
|
69
|
+
The chain advances only when no review happened — the executable is missing,
|
|
70
|
+
the spawn failed, or the attempt timed out (120s per target by default,
|
|
71
|
+
overridable with `timeoutMs`). A `FAIL` verdict is **terminal**: the chain
|
|
72
|
+
never retries another target after a real verdict, because that would be
|
|
73
|
+
automated review shopping. Unparseable output is terminal too, since it points
|
|
74
|
+
at a prompt or CLI-version mismatch worth surfacing.
|
|
75
|
+
|
|
76
|
+
If Claude Code is the host (`CLAUDECODE` is set), `claude` is moved to the end
|
|
77
|
+
of the chain. It still runs when it is the only configured target, with a
|
|
78
|
+
`reviewer == host` warning.
|
|
79
|
+
|
|
80
|
+
Criterion descriptions come from the task bound to the current session, so a
|
|
81
|
+
review needs an attached task created under the current policy configuration;
|
|
82
|
+
`--criterion` filters those ids. Run `agent-ops verify` first: review rejects
|
|
83
|
+
stale, failed, or source-mismatched required evidence before any model call.
|
|
84
|
+
Compact PASS evidence is appended with a `review:<target>:` prefix; the full
|
|
85
|
+
human-readable report is transient. Completed tasks are never rewritten.
|
|
86
|
+
|
|
87
|
+
`--yes` is still required for every review run: init selection decides which
|
|
88
|
+
targets are permitted, `--yes` decides whether to spend money now.
|
|
89
|
+
|
|
90
|
+
Because target authentication is not sniffed from stderr, an unauthenticated
|
|
91
|
+
CLI surfaces as one review failure. Diagnose it with:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
agent-ops doctor # presence only: no tokens, no network
|
|
95
|
+
agent-ops doctor --check-auth # one real print call per target
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`--check-auth` is a dedicated flag; `--yes` stays inert for doctor. Doctor
|
|
99
|
+
reports what to do but never fixes it: every target authenticates through
|
|
100
|
+
interactive OAuth, so there is no `--fix`. Run `<target> login` yourself.
|
|
101
|
+
|
|
34
102
|
### Project-local loop profile
|
|
35
103
|
|
|
36
104
|
`--profile loop` is an opt-in project-scope profile. Select `codex`, `claude`,
|
package/docs/en/spec/review.md
CHANGED
|
@@ -22,11 +22,44 @@ A review result MUST preserve PASS, FAIL, or NOT_RUN and MUST NOT convert NOT_RU
|
|
|
22
22
|
|
|
23
23
|
## REVIEW-HARNESS-001
|
|
24
24
|
|
|
25
|
-
A review invocation MUST resolve to exactly one concrete
|
|
26
|
-
installation supports multiple harnesses.
|
|
25
|
+
A review invocation MUST resolve to exactly one concrete review target, even
|
|
26
|
+
when an installation supports multiple harnesses.
|
|
27
27
|
|
|
28
28
|
- Trigger: Running `review` with a harness selection.
|
|
29
|
-
- Action: Select one of `codex`, `
|
|
29
|
+
- Action: Select one of `codex`, `agy`, or `claude`; keep multi-harness installation separate from review execution.
|
|
30
30
|
- Evidence: Argument parsing rejects `all`, `both`, and comma-separated multi-harness values for review.
|
|
31
|
-
- Positive: `review --harness
|
|
31
|
+
- Positive: `review --harness claude` resolves one target.
|
|
32
32
|
- Negative: `Run one review invocation against every installed harness implicitly.`
|
|
33
|
+
|
|
34
|
+
## REVIEW-READONLY-001
|
|
35
|
+
|
|
36
|
+
A review target MUST be launched with its own read-only mechanism, and a target
|
|
37
|
+
without one MUST be skipped rather than run unsandboxed.
|
|
38
|
+
|
|
39
|
+
- Trigger: Building a review invocation for a configured target.
|
|
40
|
+
- Action: Pass `-s read-only` (codex), `--sandbox --mode plan` (agy), or `--permission-mode plan` (claude); treat any other target as ineligible.
|
|
41
|
+
- Evidence: The spawned argv contains the target's read-only flags.
|
|
42
|
+
- Positive: `opencode is not a review target: --agent plan silently falls back to a writable agent.`
|
|
43
|
+
- Negative: `Trust the prompt to stop the reviewer from editing files.`
|
|
44
|
+
|
|
45
|
+
## REVIEW-CHAIN-001
|
|
46
|
+
|
|
47
|
+
Configured targets form an ordered fallback chain that MUST advance only when
|
|
48
|
+
no review happened, and MUST NOT advance past a verdict.
|
|
49
|
+
|
|
50
|
+
- Trigger: A configured target is missing, fails to spawn, or times out.
|
|
51
|
+
- Action: Try the next target; on PASS, FAIL, or unparseable output, stop and report that outcome.
|
|
52
|
+
- Evidence: The number of spawned attempts matches the failures that preceded the verdict.
|
|
53
|
+
- Positive: `codex FAIL is final; agy is never asked for a second opinion.`
|
|
54
|
+
- Negative: `Retry other targets after a FAIL until one reports PASS.`
|
|
55
|
+
|
|
56
|
+
## REVIEW-CONTRACT-001
|
|
57
|
+
|
|
58
|
+
A response that breaks the reply contract MUST be reported as NOT_RUN, not as
|
|
59
|
+
FAIL.
|
|
60
|
+
|
|
61
|
+
- Trigger: The reviewer omits, duplicates, or invents a criterion, or returns blank evidence.
|
|
62
|
+
- Action: Report `NOT_RUN` with reason `unparseable-output`, write no evidence, and keep FAIL for judged inadequacy.
|
|
63
|
+
- Evidence: The result reason distinguishes a protocol violation from a verdict.
|
|
64
|
+
- Positive: `NOT_RUN: unparseable-output; one criterion was missing.`
|
|
65
|
+
- Negative: `Record a failed review because the model's JSON was malformed.`
|