@kylecheng3146/agent-ops 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +19 -1
- package/dist/packages/cli/src/bin.js +34 -77
- package/dist/packages/cli/src/cli.js +3 -0
- package/dist/packages/cli/src/commands/init.js +27 -3
- package/dist/packages/cli/src/commands/parallel.js +5 -4
- package/dist/packages/cli/src/commands/review.js +6 -4
- package/dist/packages/cli/src/commands/task.js +27 -3
- package/dist/packages/cli/src/commands/update.js +47 -3
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +4 -1
- package/dist/packages/cli/src/parallel-deps.js +89 -0
- package/dist/packages/cli/src/wizard.js +21 -2
- package/dist/runtime/src/adapters/claude/config.js +2 -1
- package/dist/runtime/src/hooks/completion-gate.js +1 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/parallel-setup.js +63 -0
- package/dist/runtime/src/install/plan.js +8 -7
- package/dist/runtime/src/install/update.js +4 -1
- package/dist/runtime/src/parallel/finish.js +47 -7
- package/dist/runtime/src/parallel/guard.js +20 -3
- package/dist/runtime/src/parallel/service.js +18 -0
- package/dist/runtime/src/review/attestation.js +125 -50
- package/dist/runtime/src/review/execute.js +32 -5
- package/dist/runtime/src/task/completion.js +1 -1
- package/dist/runtime/src/task/service.js +20 -4
- package/dist/runtime/src/task/store.js +7 -2
- package/docs/en/guides/configuration.md +22 -1
- package/docs/zh-TW/guides/configuration.md +16 -1
- package/package.json +1 -1
|
@@ -193,6 +193,20 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
193
193
|
const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
|
|
194
194
|
? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
|
|
195
195
|
: []);
|
|
196
|
+
const worktree = args.worktree ?? (scope === "project"
|
|
197
|
+
? (await selectOption("Enable Git worktree isolation for parallel agent sessions?", [
|
|
198
|
+
{
|
|
199
|
+
label: "yes",
|
|
200
|
+
value: "auto",
|
|
201
|
+
description: "Recommended. Isolates each agent session in a dedicated worktree."
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
label: "no",
|
|
205
|
+
value: "off",
|
|
206
|
+
description: "Keep a single shared checkout."
|
|
207
|
+
}
|
|
208
|
+
], selectorIo))
|
|
209
|
+
: undefined);
|
|
196
210
|
await probeReviewTargets(reviewTargets, setup);
|
|
197
211
|
return {
|
|
198
212
|
...args,
|
|
@@ -200,7 +214,8 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
200
214
|
harness,
|
|
201
215
|
profiles,
|
|
202
216
|
...(completionGate ? { completionGate: true } : {}),
|
|
203
|
-
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
217
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets }),
|
|
218
|
+
...(worktree !== undefined ? { worktree } : {})
|
|
204
219
|
};
|
|
205
220
|
}
|
|
206
221
|
const session = await createPromptSession(io);
|
|
@@ -220,13 +235,17 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
220
235
|
? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
|
|
221
236
|
: []);
|
|
222
237
|
await probeReviewTargets(reviewTargets, setup);
|
|
238
|
+
const worktree = args.worktree ?? (scope === "project"
|
|
239
|
+
? (!/^(n|no)$/i.test((await session.question("Enable Git worktree isolation for parallel agent sessions? [Y/n]: ")).trim()) ? "auto" : "off")
|
|
240
|
+
: undefined);
|
|
223
241
|
return {
|
|
224
242
|
...args,
|
|
225
243
|
scope,
|
|
226
244
|
harness,
|
|
227
245
|
profiles,
|
|
228
246
|
...(completionGate ? { completionGate: true } : {}),
|
|
229
|
-
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
247
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets }),
|
|
248
|
+
...(worktree !== undefined ? { worktree } : {})
|
|
230
249
|
};
|
|
231
250
|
}
|
|
232
251
|
finally {
|
|
@@ -42,7 +42,8 @@ function commandHook(event, runtimePath, completionGate = false) {
|
|
|
42
42
|
CLAUDE_HOOK_MARKER,
|
|
43
43
|
...(completionGate ? ["--completion-gate"] : [])
|
|
44
44
|
],
|
|
45
|
-
|
|
45
|
+
// PreToolUse may create the session's worktree, setup steps included.
|
|
46
|
+
timeout: event === "PreToolUse" ? 600 : 30
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
49
|
function matcherGroup(event, runtimePath, completionGate = false) {
|
|
@@ -228,7 +228,7 @@ export class CompletionGateService {
|
|
|
228
228
|
this.#options.config.worktree?.mode === "auto" &&
|
|
229
229
|
state.root === undefined &&
|
|
230
230
|
await readWorktreeRecord(this.#options.root) === null) {
|
|
231
|
-
return gateResult("continue", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MODE", `worktree.mode is auto. Session: ${sessionId}. Before your first edit run agent-ops worktree add <name> --session ${shellQuote(sessionId)} and work only inside the printed path.`);
|
|
231
|
+
return gateResult("continue", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MODE", `worktree.mode is auto. Session: ${sessionId}. Before your first edit run agent-ops task create from this checkout, which creates this session's worktree and puts the task there (or agent-ops worktree add <name> --session ${shellQuote(sessionId)}), and work only inside the printed path. Verify and review there, then finish with agent-ops worktree finish from this checkout, which completes the tasks; do not run task complete in the worktree.`);
|
|
232
232
|
}
|
|
233
233
|
return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
|
|
234
234
|
? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
|
|
@@ -311,7 +311,7 @@ export function managedRules(descriptor, context) {
|
|
|
311
311
|
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
312
312
|
}
|
|
313
313
|
if (context.capabilities.includes("task")) {
|
|
314
|
-
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops worktree add <name> --session <session-id>`
|
|
314
|
+
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
|
|
315
315
|
}
|
|
316
316
|
if (context.capabilities.includes("lifecycle-summary")) {
|
|
317
317
|
lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const LOCKFILES = [
|
|
4
|
+
{ path: "bun.lock", packageManager: "bun" },
|
|
5
|
+
{ path: "bun.lockb", packageManager: "bun" },
|
|
6
|
+
{ path: "npm-shrinkwrap.json", packageManager: "npm" },
|
|
7
|
+
{ path: "package-lock.json", packageManager: "npm" },
|
|
8
|
+
{ path: "pnpm-lock.yaml", packageManager: "pnpm" },
|
|
9
|
+
{ path: "yarn.lock", packageManager: "yarn" }
|
|
10
|
+
];
|
|
11
|
+
const SETUP_COMMANDS = {
|
|
12
|
+
npm: ["ci"],
|
|
13
|
+
pnpm: ["install", "--frozen-lockfile"],
|
|
14
|
+
yarn: ["install", "--immutable"],
|
|
15
|
+
bun: ["install", "--frozen-lockfile"]
|
|
16
|
+
};
|
|
17
|
+
function hasErrorCode(error, code) {
|
|
18
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
19
|
+
}
|
|
20
|
+
async function regularFileExists(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const status = await lstat(filePath);
|
|
23
|
+
return status.isFile() && !status.isSymbolicLink();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detects project lockfiles and proposes safe, deterministic dependency installation
|
|
34
|
+
* commands for freshly cloned worktrees. Returns an empty array if detection is
|
|
35
|
+
* ambiguous or unsupported.
|
|
36
|
+
*/
|
|
37
|
+
export async function detectWorktreeSetup(root) {
|
|
38
|
+
const detected = await Promise.all(LOCKFILES.map(async (def) => (await regularFileExists(join(root, def.path))) ? def.packageManager : null));
|
|
39
|
+
const managers = [...new Set(detected.filter((m) => m !== null))];
|
|
40
|
+
if (managers.length !== 1) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const manager = managers[0];
|
|
44
|
+
const args = SETUP_COMMANDS[manager];
|
|
45
|
+
return [{ command: manager, args: [...args] }];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns a warning message if local environment files (.env) are detected
|
|
49
|
+
* without a .worktreeinclude file to carry them into new worktrees.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkWorktreeEnvWarning(root) {
|
|
52
|
+
const hasWorktreeInclude = await regularFileExists(join(root, ".worktreeinclude"));
|
|
53
|
+
if (hasWorktreeInclude) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const envCandidates = [".env", ".env.local"];
|
|
57
|
+
for (const candidate of envCandidates) {
|
|
58
|
+
if (await regularFileExists(join(root, candidate))) {
|
|
59
|
+
return "Local environment files (.env) detected without .worktreeinclude; consider creating .worktreeinclude to copy them to new worktrees.";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
@@ -78,7 +78,7 @@ async function detectVerificationCommands(root) {
|
|
|
78
78
|
blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
-
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
|
|
81
|
+
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
|
|
82
82
|
// Absent reviewRoles means external review is disabled; an empty selection
|
|
83
83
|
// must therefore omit the field rather than write an empty array.
|
|
84
84
|
const reviewRoles = reviewTargets.length > 0
|
|
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
88
88
|
existing.verification.commands.length > 0
|
|
89
89
|
? existing.verification
|
|
90
90
|
: { commands: [...detectedCommands] };
|
|
91
|
+
const effectiveWorktree = worktree !== undefined
|
|
92
|
+
? (worktree === null ? undefined : structuredClone(worktree))
|
|
93
|
+
: (existing?.worktree === undefined ? undefined : structuredClone(existing.worktree));
|
|
91
94
|
return {
|
|
92
95
|
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
93
96
|
profiles: [...profiles],
|
|
@@ -103,12 +106,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
103
106
|
pathMappings: existing?.pathMappings ?? [],
|
|
104
107
|
securityExceptions: existing?.securityExceptions ?? [],
|
|
105
108
|
...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
|
|
106
|
-
...(
|
|
107
|
-
? {}
|
|
108
|
-
: { worktree: structuredClone(existing.worktree) })
|
|
109
|
+
...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
|
|
109
110
|
};
|
|
110
111
|
}
|
|
111
|
-
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
|
|
112
|
+
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
|
|
112
113
|
const current = await readCurrentFile(root, CONFIG_PATH);
|
|
113
114
|
const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
|
|
114
115
|
if (current !== null && owned === undefined) {
|
|
@@ -144,7 +145,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
144
145
|
existingConfig.verification.commands.length === 0
|
|
145
146
|
? await detectVerificationCommands(root)
|
|
146
147
|
: { commands: [], blockers: [] };
|
|
147
|
-
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
|
|
148
|
+
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
|
|
148
149
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
149
150
|
return {
|
|
150
151
|
operation: {
|
|
@@ -493,7 +494,7 @@ export async function createInstallPlan(options) {
|
|
|
493
494
|
: [];
|
|
494
495
|
const operations = [];
|
|
495
496
|
const artifacts = [];
|
|
496
|
-
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
|
|
497
|
+
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
|
|
497
498
|
operations.push(config.operation);
|
|
498
499
|
artifacts.push(config.record);
|
|
499
500
|
for (const artifact of contribution.artifacts) {
|
|
@@ -123,7 +123,10 @@ export async function createUpdatePlan(options) {
|
|
|
123
123
|
existingConfig: {
|
|
124
124
|
value: configPreview.migrated,
|
|
125
125
|
sourceHash: configPreview.sourceHash
|
|
126
|
-
}
|
|
126
|
+
},
|
|
127
|
+
...(options.worktree === undefined
|
|
128
|
+
? {}
|
|
129
|
+
: { worktree: options.worktree })
|
|
127
130
|
});
|
|
128
131
|
return {
|
|
129
132
|
targetVersion,
|
|
@@ -159,12 +159,35 @@ async function targetIntents(deps, cwd, target) {
|
|
|
159
159
|
}
|
|
160
160
|
return intents;
|
|
161
161
|
}
|
|
162
|
-
|
|
162
|
+
/** Each tree top first, its subtasks after it; `depth` counts parents. */
|
|
163
|
+
function taskTree(tasks) {
|
|
164
|
+
const ids = new Set(tasks.map(({ task }) => task.id));
|
|
165
|
+
const ordered = [];
|
|
166
|
+
const visit = (parent, depth) => {
|
|
167
|
+
for (const task of tasks) {
|
|
168
|
+
const own = task.task.parentTaskId !== undefined && ids.has(task.task.parentTaskId)
|
|
169
|
+
? task.task.parentTaskId
|
|
170
|
+
: undefined;
|
|
171
|
+
if (own === parent) {
|
|
172
|
+
ordered.push({ task, depth });
|
|
173
|
+
visit(task.task.id, depth + 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
visit(undefined, 0);
|
|
178
|
+
return ordered;
|
|
179
|
+
}
|
|
180
|
+
function noteText(record, tasks) {
|
|
163
181
|
return [
|
|
164
|
-
`agent-ops task ${task.task.id}: ${task.task.title}`,
|
|
165
182
|
`worktree: ${record.name} (${record.branch})`,
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
...tasks.flatMap(({ task, depth }) => {
|
|
184
|
+
const indent = " ".repeat(depth);
|
|
185
|
+
return [
|
|
186
|
+
`${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
|
|
187
|
+
`${indent}criteria:`,
|
|
188
|
+
...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
|
|
189
|
+
];
|
|
190
|
+
})
|
|
168
191
|
].join("\n");
|
|
169
192
|
}
|
|
170
193
|
/**
|
|
@@ -200,12 +223,29 @@ export async function finishWorktree(deps, options) {
|
|
|
200
223
|
throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
|
|
201
224
|
}
|
|
202
225
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
226
|
+
// The whole branch merges, so every task in the worktree completes first,
|
|
227
|
+
// subtasks before their parents, each over the range its review covered.
|
|
228
|
+
// Before any rebase: rebasing rewrites the commits those fingerprints name.
|
|
229
|
+
const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
|
|
230
|
+
if (tree.length === 0) {
|
|
231
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
|
|
232
|
+
}
|
|
233
|
+
for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
|
|
234
|
+
if (task.status === "complete")
|
|
235
|
+
continue;
|
|
236
|
+
const base = task.reviewBase ?? record.base;
|
|
237
|
+
try {
|
|
238
|
+
await deps.tasks(record.path, base).complete(task.task.id, {});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
203
244
|
const worktreeGate = await deps.gate(record.path, worktreeConfig);
|
|
204
245
|
const problem = await worktreeGate.validate(record.sessionId);
|
|
205
246
|
if (problem !== null) {
|
|
206
247
|
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
207
248
|
}
|
|
208
|
-
const task = await deps.tasks(record.path).status({ sessionId: record.sessionId });
|
|
209
249
|
const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
210
250
|
const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
|
|
211
251
|
if (!fastForward) {
|
|
@@ -248,7 +288,7 @@ export async function finishWorktree(deps, options) {
|
|
|
248
288
|
warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
289
|
}
|
|
250
290
|
};
|
|
251
|
-
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record,
|
|
291
|
+
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record, tree), mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
|
|
252
292
|
if (gateEnabled) {
|
|
253
293
|
await attempt("gate", async () => {
|
|
254
294
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
@@ -258,6 +298,6 @@ export async function finishWorktree(deps, options) {
|
|
|
258
298
|
}
|
|
259
299
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
260
300
|
await attempt("remove", async () => await removeCheckout(deps, record, true));
|
|
261
|
-
return { record, mergedHead, rebased: !fastForward,
|
|
301
|
+
return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings };
|
|
262
302
|
});
|
|
263
303
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, join, sep } from "node:path";
|
|
2
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
3
|
import { insideWorktreeDirectory } from "./service.js";
|
|
4
4
|
/** The main checkout of the repository `runner` runs in, or null. */
|
|
5
5
|
export async function resolveMainRoot(runner) {
|
|
@@ -41,17 +41,34 @@ export async function canonicalPath(path) {
|
|
|
41
41
|
* `.worktrees/<name>`. Paths outside the repository are not this guard's
|
|
42
42
|
* business.
|
|
43
43
|
*/
|
|
44
|
-
export async function evaluateWorktreeWrite(mainRoot, paths, sessionId
|
|
44
|
+
export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
|
|
45
|
+
/** Creates or reuses this session's worktree and returns its path. */
|
|
46
|
+
ensureWorktree) {
|
|
45
47
|
for (const path of paths) {
|
|
46
48
|
const target = await canonicalPath(path);
|
|
47
49
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
48
50
|
if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
|
|
51
|
+
let failure = "";
|
|
52
|
+
if (sessionId !== undefined && ensureWorktree !== undefined) {
|
|
53
|
+
try {
|
|
54
|
+
const worktree = await ensureWorktree(sessionId);
|
|
55
|
+
return {
|
|
56
|
+
action: "block",
|
|
57
|
+
status: "FAIL",
|
|
58
|
+
code: "WORKTREE_CREATED",
|
|
59
|
+
remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
failure = ` Creating this session's worktree failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
49
66
|
const session = sessionId ?? "<session-id>";
|
|
50
67
|
return {
|
|
51
68
|
action: "block",
|
|
52
69
|
status: "FAIL",
|
|
53
70
|
code: "WORKTREE_REQUIRED",
|
|
54
|
-
remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly
|
|
71
|
+
remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly.${failure} Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
|
|
55
72
|
};
|
|
56
73
|
}
|
|
57
74
|
}
|
|
@@ -261,3 +261,21 @@ export async function addWorktree(deps, options) {
|
|
|
261
261
|
throw error;
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
/** `session-` plus the session id's first eight name-safe characters. */
|
|
265
|
+
export function sessionWorktreeName(sessionId) {
|
|
266
|
+
const slug = sessionId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
267
|
+
return `session-${slug === "" ? "0" : slug}`;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The worktree `sessionId` works in, created on first use. One already bound
|
|
271
|
+
* to the same session is reused, so a session blocked twice before it moves
|
|
272
|
+
* still gets exactly one worktree.
|
|
273
|
+
*/
|
|
274
|
+
export async function ensureSessionWorktree(deps, options) {
|
|
275
|
+
const { mainRoot } = await resolveCheckouts(deps, options.cwd);
|
|
276
|
+
const name = sessionWorktreeName(options.sessionId);
|
|
277
|
+
const existing = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
278
|
+
if (existing?.sessionId === options.sessionId)
|
|
279
|
+
return existing;
|
|
280
|
+
return (await addWorktree(deps, { cwd: mainRoot, name, sessionId: options.sessionId })).record;
|
|
281
|
+
}
|
|
@@ -39,8 +39,18 @@ function safePreflight(attempt) {
|
|
|
39
39
|
...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A task's review records live under `<fingerprint>.<task id>`, so a parent
|
|
44
|
+
* and its subtask reviewed on the same source each keep their own PASS until
|
|
45
|
+
* `worktree finish` completes both. A bare `<fingerprint>` is a record with no
|
|
46
|
+
* task, or one written before records were kept per task; readers fall back
|
|
47
|
+
* to it.
|
|
48
|
+
*/
|
|
49
|
+
function recordKey(fingerprint, taskId) {
|
|
50
|
+
return taskId === undefined ? fingerprint : `${fingerprint}.${taskId}`;
|
|
51
|
+
}
|
|
52
|
+
function artifactPath(root, fingerprint, taskId) {
|
|
53
|
+
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`);
|
|
44
54
|
}
|
|
45
55
|
export function reviewReportDigest(report) {
|
|
46
56
|
return sha256(JSON.stringify(redactReviewReport(report)));
|
|
@@ -127,7 +137,8 @@ function matchingReportArtifact(value, attestation) {
|
|
|
127
137
|
attempts.every((attempt, index) => attempt.sessionId === attestation.reviewSessionIds[index]));
|
|
128
138
|
}
|
|
129
139
|
async function hasMatchingReportArtifact(root, attestation) {
|
|
130
|
-
|
|
140
|
+
// `reportArtifact` was validated to name this fingerprint's own record.
|
|
141
|
+
const source = await readPrivateFile(join(root, ...attestation.reportArtifact.split("/")), root);
|
|
131
142
|
if (source === null) {
|
|
132
143
|
return false;
|
|
133
144
|
}
|
|
@@ -150,12 +161,12 @@ export async function saveReviewReportArtifact(root, result, sourceFingerprint,
|
|
|
150
161
|
if (Buffer.byteLength(serialized, "utf8") > REPORT_ARTIFACT_MAX_BYTES) {
|
|
151
162
|
throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_TOO_LARGE", "Review report artifact exceeds the capture limit.");
|
|
152
163
|
}
|
|
153
|
-
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
|
|
154
|
-
await writePrivateFile(artifactPath(root, sourceFingerprint), serialized, root);
|
|
164
|
+
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(sourceFingerprint, taskId)}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
|
|
165
|
+
await writePrivateFile(artifactPath(root, sourceFingerprint, taskId), serialized, root);
|
|
155
166
|
return relativePath;
|
|
156
167
|
}
|
|
157
|
-
function attestationPath(root, fingerprint) {
|
|
158
|
-
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}.json`);
|
|
168
|
+
function attestationPath(root, fingerprint, taskId) {
|
|
169
|
+
return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${recordKey(fingerprint, taskId)}.json`);
|
|
159
170
|
}
|
|
160
171
|
function parseAttestation(value) {
|
|
161
172
|
if (typeof value !== "object" || value === null) {
|
|
@@ -183,8 +194,12 @@ function parseAttestation(value) {
|
|
|
183
194
|
typeof record.adversarialReportDigest !== "string" ||
|
|
184
195
|
!DIGEST_PATTERN.test(record.adversarialReportDigest) ||
|
|
185
196
|
typeof record.reportArtifact !== "string" ||
|
|
186
|
-
record.
|
|
187
|
-
|
|
197
|
+
// Its own per-task record, or the bare one an older review wrote.
|
|
198
|
+
![
|
|
199
|
+
recordKey(record.sourceFingerprint, record.taskId),
|
|
200
|
+
record.sourceFingerprint
|
|
201
|
+
].some((key) => record.reportArtifact ===
|
|
202
|
+
`${REVIEW_ATTESTATION_DIRECTORY}/${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`) ||
|
|
188
203
|
(record.hostTarget !== undefined && !isReviewTarget(record.hostTarget)) ||
|
|
189
204
|
typeof record.createdAt !== "string" ||
|
|
190
205
|
!Number.isFinite(Date.parse(record.createdAt))) {
|
|
@@ -214,33 +229,61 @@ export async function saveReviewAttestation(root, attestation) {
|
|
|
214
229
|
if (!(await hasMatchingReportArtifact(root, validated))) {
|
|
215
230
|
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Review attestation has no matching report artifact.");
|
|
216
231
|
}
|
|
217
|
-
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${validated.sourceFingerprint}.json`;
|
|
218
|
-
await writePrivateFile(attestationPath(root, validated.sourceFingerprint), `${JSON.stringify(validated, null, 2)}\n`, root);
|
|
232
|
+
const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${recordKey(validated.sourceFingerprint, validated.taskId)}.json`;
|
|
233
|
+
await writePrivateFile(attestationPath(root, validated.sourceFingerprint, validated.taskId), `${JSON.stringify(validated, null, 2)}\n`, root);
|
|
219
234
|
return relativePath;
|
|
220
235
|
}
|
|
221
236
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* treat it as a passing review.
|
|
237
|
+
* The record keys that may hold `taskId`'s review of this source: its own,
|
|
238
|
+
* then the bare one. Without a task, every task's record for the source.
|
|
225
239
|
*/
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
const source = await readPrivateFile(attestationPath(root, sourceFingerprint), root);
|
|
231
|
-
if (source === null) {
|
|
232
|
-
return null;
|
|
240
|
+
async function candidateKeys(root, sourceFingerprint, taskId, suffix) {
|
|
241
|
+
if (taskId !== undefined) {
|
|
242
|
+
return [recordKey(sourceFingerprint, taskId), sourceFingerprint];
|
|
233
243
|
}
|
|
244
|
+
let names = [];
|
|
234
245
|
try {
|
|
235
|
-
|
|
236
|
-
return attestation?.sourceFingerprint === sourceFingerprint &&
|
|
237
|
-
await hasMatchingReportArtifact(root, attestation)
|
|
238
|
-
? attestation
|
|
239
|
-
: null;
|
|
246
|
+
names = await readdir(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/")));
|
|
240
247
|
}
|
|
241
248
|
catch {
|
|
249
|
+
// No reviews yet.
|
|
250
|
+
}
|
|
251
|
+
const own = names
|
|
252
|
+
.filter((name) => name.startsWith(`${sourceFingerprint}.`) && name.endsWith(suffix))
|
|
253
|
+
.map((name) => name.slice(0, -suffix.length))
|
|
254
|
+
.filter((key) => key !== sourceFingerprint && !key.endsWith(".reports") &&
|
|
255
|
+
TASK_ID_PATTERN.test(key.slice(sourceFingerprint.length + 1)));
|
|
256
|
+
return [sourceFingerprint, ...own];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns the attestation recorded for this exact source state, or null. A
|
|
260
|
+
* malformed file reads as absent: a gate must fail closed on garbage, never
|
|
261
|
+
* treat it as a passing review. With `taskId`, only that task's record or the
|
|
262
|
+
* bare one; callers still check the task the attestation names.
|
|
263
|
+
*/
|
|
264
|
+
export async function findReviewAttestation(root, sourceFingerprint, taskId) {
|
|
265
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
266
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
242
267
|
return null;
|
|
243
268
|
}
|
|
269
|
+
for (const key of await candidateKeys(root, sourceFingerprint, taskId, ".json")) {
|
|
270
|
+
const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}.json`), root);
|
|
271
|
+
if (source === null) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const attestation = parseAttestation(JSON.parse(source));
|
|
276
|
+
if (attestation?.sourceFingerprint === sourceFingerprint &&
|
|
277
|
+
recordKey(sourceFingerprint, key === sourceFingerprint ? undefined : attestation.taskId) === key &&
|
|
278
|
+
await hasMatchingReportArtifact(root, attestation)) {
|
|
279
|
+
return attestation;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
244
287
|
}
|
|
245
288
|
/**
|
|
246
289
|
* The stored report behind an attestation, for a caller that wants to reuse
|
|
@@ -248,35 +291,64 @@ export async function findReviewAttestation(root, sourceFingerprint) {
|
|
|
248
291
|
* parses, matches this exact source and records a PASS: a reuse path must fail
|
|
249
292
|
* closed exactly like the gates that read the attestation.
|
|
250
293
|
*/
|
|
251
|
-
export async function readReviewReportArtifact(root, sourceFingerprint) {
|
|
252
|
-
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)
|
|
294
|
+
export async function readReviewReportArtifact(root, sourceFingerprint, taskId) {
|
|
295
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
296
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
253
297
|
return null;
|
|
254
298
|
}
|
|
255
|
-
const
|
|
299
|
+
const keys = taskId === undefined
|
|
300
|
+
? [sourceFingerprint]
|
|
301
|
+
: [recordKey(sourceFingerprint, taskId), sourceFingerprint];
|
|
302
|
+
for (const key of keys) {
|
|
303
|
+
const source = await readPrivateFile(join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${key}${REVIEW_REPORT_ARTIFACT_SUFFIX}`), root);
|
|
304
|
+
if (source === null) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const value = JSON.parse(source);
|
|
309
|
+
if (value.sourceFingerprint === sourceFingerprint &&
|
|
310
|
+
value.status === "PASS" &&
|
|
311
|
+
value.report !== undefined &&
|
|
312
|
+
value.adversarial !== undefined) {
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A new authorized attempt supersedes any earlier PASS for this source: the
|
|
324
|
+
* task's own record, and the bare one when it is this task's (or no task's).
|
|
325
|
+
* Another task's bare record from before per-task records is left alone.
|
|
326
|
+
*/
|
|
327
|
+
export async function invalidateReviewAttestation(root, sourceFingerprint, taskId) {
|
|
328
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint) ||
|
|
329
|
+
(taskId !== undefined && !TASK_ID_PATTERN.test(taskId))) {
|
|
330
|
+
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
331
|
+
}
|
|
332
|
+
if (taskId !== undefined) {
|
|
333
|
+
const own = attestationPath(root, sourceFingerprint, taskId);
|
|
334
|
+
if (await readPrivateFile(own, root) !== null) {
|
|
335
|
+
await writePrivateFile(own, "null\n", root);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const bare = attestationPath(root, sourceFingerprint);
|
|
339
|
+
const source = await readPrivateFile(bare, root);
|
|
256
340
|
if (source === null) {
|
|
257
|
-
return
|
|
341
|
+
return;
|
|
258
342
|
}
|
|
343
|
+
let owner;
|
|
259
344
|
try {
|
|
260
|
-
|
|
261
|
-
return value.sourceFingerprint === sourceFingerprint &&
|
|
262
|
-
value.status === "PASS" &&
|
|
263
|
-
value.report !== undefined &&
|
|
264
|
-
value.adversarial !== undefined
|
|
265
|
-
? value
|
|
266
|
-
: null;
|
|
345
|
+
owner = JSON.parse(source)?.taskId;
|
|
267
346
|
}
|
|
268
347
|
catch {
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
/** A new authorized attempt supersedes any earlier PASS for this source. */
|
|
273
|
-
export async function invalidateReviewAttestation(root, sourceFingerprint) {
|
|
274
|
-
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
275
|
-
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
348
|
+
owner = undefined;
|
|
276
349
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await writePrivateFile(path, "null\n", root);
|
|
350
|
+
if (taskId === undefined || owner === undefined || owner === taskId) {
|
|
351
|
+
await writePrivateFile(bare, "null\n", root);
|
|
280
352
|
}
|
|
281
353
|
}
|
|
282
354
|
/** Prior findings stay a short checklist, never a second report to digest. */
|
|
@@ -328,12 +400,15 @@ export async function findPriorFailingFindings(root, taskId) {
|
|
|
328
400
|
// ponytail: linear scan of every report; index by task if the directory grows large.
|
|
329
401
|
let latest;
|
|
330
402
|
for (const name of names) {
|
|
331
|
-
|
|
332
|
-
|
|
403
|
+
// `<fingerprint>` or `<fingerprint>.<task id>`, then the suffix.
|
|
404
|
+
const key = name.slice(0, -REVIEW_REPORT_ARTIFACT_SUFFIX.length);
|
|
405
|
+
const fingerprint = key.slice(0, 64);
|
|
406
|
+
if (!name.endsWith(REVIEW_REPORT_ARTIFACT_SUFFIX) || !FINGERPRINT_PATTERN.test(fingerprint) ||
|
|
407
|
+
(key !== fingerprint && key !== recordKey(fingerprint, taskId))) {
|
|
333
408
|
continue;
|
|
334
409
|
}
|
|
335
410
|
try {
|
|
336
|
-
const source = await readPrivateFile(
|
|
411
|
+
const source = await readPrivateFile(join(directory, name), root);
|
|
337
412
|
if (source === null) {
|
|
338
413
|
continue;
|
|
339
414
|
}
|