@kylecheng3146/agent-ops 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +48 -2
- package/dist/packages/cli/src/bin.js +67 -3
- package/dist/packages/cli/src/cli.js +31 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/parallel.js +87 -0
- package/dist/packages/cli/src/hook-process.js +31 -7
- package/dist/runtime/src/adapters/agy/output.js +2 -1
- package/dist/runtime/src/adapters/claude/config.js +4 -1
- package/dist/runtime/src/adapters/claude/input.js +24 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/config/merge.js +10 -2
- package/dist/runtime/src/hooks/codex-loop.js +11 -1
- package/dist/runtime/src/hooks/completion-gate.js +141 -7
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/doctor.js +4 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/plan.js +4 -1
- package/dist/runtime/src/parallel/finish.js +263 -0
- package/dist/runtime/src/parallel/guard.js +59 -0
- package/dist/runtime/src/parallel/manage.js +147 -0
- package/dist/runtime/src/parallel/service.js +263 -0
- package/dist/runtime/src/schema/validate.js +50 -1
- package/docs/en/guides/configuration.md +42 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/zh-TW/guides/configuration.md +40 -0
- package/docs/zh-TW/spec/delegation.md +11 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +35 -0
|
@@ -1,20 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
2
3
|
import { sha256 } from "../fs/hash.js";
|
|
3
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
5
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
5
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
6
7
|
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
7
8
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
9
|
+
import { readWorktreeRecord } from "../parallel/service.js";
|
|
8
10
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
9
11
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
12
|
+
function gateDirectory(root) {
|
|
13
|
+
return join(root, ".agent-ops", "tasks", "completion-gate");
|
|
14
|
+
}
|
|
10
15
|
function shellQuote(value) {
|
|
11
16
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
12
17
|
}
|
|
18
|
+
async function isDirectory(path) {
|
|
19
|
+
try {
|
|
20
|
+
return (await lstat(path)).isDirectory();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
function gateResult(action, status, code, remedy) {
|
|
14
27
|
return { action, status, code, ...(remedy === undefined ? {} : { remedy }) };
|
|
15
28
|
}
|
|
16
29
|
function statePath(root, sessionId) {
|
|
17
|
-
return join(root,
|
|
30
|
+
return join(gateDirectory(root), `${sha256(sessionId)}.json`);
|
|
31
|
+
}
|
|
32
|
+
function validRoot(value) {
|
|
33
|
+
return typeof value === "string" && value.length > 0 && value.length <= 4096 &&
|
|
34
|
+
!value.includes("\0") && isAbsolute(value);
|
|
18
35
|
}
|
|
19
36
|
function parseState(source, sessionId) {
|
|
20
37
|
if (source === null)
|
|
@@ -30,8 +47,9 @@ function parseState(source, sessionId) {
|
|
|
30
47
|
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
|
|
31
48
|
}
|
|
32
49
|
const record = value;
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const keys = Object.keys(record).filter((key) => key !== "root").sort().join(",");
|
|
51
|
+
if (keys !== "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
|
|
52
|
+
("root" in record && !validRoot(record.root)) ||
|
|
35
53
|
record.schemaVersion !== 1 ||
|
|
36
54
|
record.sessionId !== sessionId ||
|
|
37
55
|
typeof record.baselineFingerprint !== "string" ||
|
|
@@ -55,6 +73,32 @@ export class FileCompletionGateStore {
|
|
|
55
73
|
const path = statePath(this.#root, sessionId);
|
|
56
74
|
return await withPrivateFileLock(path, this.#root, async () => parseState(await readPrivateFile(path, this.#root), sessionId));
|
|
57
75
|
}
|
|
76
|
+
/** Every recorded session. An unreadable record is skipped, not repaired. */
|
|
77
|
+
async all() {
|
|
78
|
+
let names;
|
|
79
|
+
try {
|
|
80
|
+
names = await readdir(gateDirectory(this.#root));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const states = [];
|
|
86
|
+
for (const name of names.filter((entry) => /^[a-f0-9]{64}\.json$/u.test(entry))) {
|
|
87
|
+
try {
|
|
88
|
+
const source = await readPrivateFile(join(gateDirectory(this.#root), name), this.#root);
|
|
89
|
+
const record = source === null ? null : JSON.parse(source);
|
|
90
|
+
if (typeof record?.sessionId === "string") {
|
|
91
|
+
const state = await this.read(record.sessionId);
|
|
92
|
+
if (state !== null)
|
|
93
|
+
states.push(state);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return states;
|
|
101
|
+
}
|
|
58
102
|
async mutate(sessionId, action) {
|
|
59
103
|
if (!SESSION.test(sessionId)) {
|
|
60
104
|
throw new AgentOpsError("COMPLETION_GATE_SESSION_INVALID", "Completion gate requires a valid session identity.");
|
|
@@ -68,6 +112,11 @@ export class FileCompletionGateStore {
|
|
|
68
112
|
});
|
|
69
113
|
}
|
|
70
114
|
}
|
|
115
|
+
/** What the gate measures: the whole Git-visible change surface of `root`. */
|
|
116
|
+
export async function currentGateFingerprint(root, gitRunner) {
|
|
117
|
+
const surface = await collectChangeSurface(gitRunner);
|
|
118
|
+
return await calculateSourceFingerprint(root, { mode: "worktree", changedFiles: surface.paths }, gitRunner);
|
|
119
|
+
}
|
|
71
120
|
export class CompletionGateService {
|
|
72
121
|
#options;
|
|
73
122
|
#store;
|
|
@@ -76,8 +125,63 @@ export class CompletionGateService {
|
|
|
76
125
|
this.#store = options.stateStore ?? new FileCompletionGateStore(options.root);
|
|
77
126
|
}
|
|
78
127
|
async #fingerprint() {
|
|
79
|
-
|
|
80
|
-
|
|
128
|
+
return await currentGateFingerprint(this.#options.root, this.#options.gitRunner);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Starts (or restarts) a session's record at a known baseline: the current
|
|
132
|
+
* source by default, or an explicit one a caller wants every Stop measured
|
|
133
|
+
* against.
|
|
134
|
+
*/
|
|
135
|
+
async seed(sessionId, baselineFingerprint) {
|
|
136
|
+
const baseline = baselineFingerprint ?? await this.#fingerprint();
|
|
137
|
+
await this.#store.mutate(sessionId, () => ({
|
|
138
|
+
schemaVersion: 1,
|
|
139
|
+
sessionId,
|
|
140
|
+
baselineFingerprint: baseline,
|
|
141
|
+
permitFingerprint: null
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
/** Points this checkout's record for a session at another worktree, or clears it. */
|
|
145
|
+
async redirect(sessionId, root) {
|
|
146
|
+
if (root === null && (await this.#store.read(sessionId))?.root === undefined) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const fingerprint = await this.#fingerprint();
|
|
150
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
151
|
+
const { root: _previous, ...base } = current ?? {
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
sessionId,
|
|
154
|
+
baselineFingerprint: fingerprint,
|
|
155
|
+
permitFingerprint: null
|
|
156
|
+
};
|
|
157
|
+
return root === null ? base : { ...base, root };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Moves every session whose baseline is `from` to `to`: a merge that moved
|
|
162
|
+
* HEAD under a session which changed nothing must not read as its change.
|
|
163
|
+
*/
|
|
164
|
+
async rebase(from, to) {
|
|
165
|
+
let moved = 0;
|
|
166
|
+
for (const state of await this.#store.all()) {
|
|
167
|
+
if (state.baselineFingerprint !== from)
|
|
168
|
+
continue;
|
|
169
|
+
await this.#store.mutate(state.sessionId, (current) => current === null || current.baselineFingerprint !== from
|
|
170
|
+
? current ?? state
|
|
171
|
+
: { ...current, baselineFingerprint: to });
|
|
172
|
+
moved += 1;
|
|
173
|
+
}
|
|
174
|
+
return moved;
|
|
175
|
+
}
|
|
176
|
+
async sessions() {
|
|
177
|
+
return await this.#store.all();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether the session's attached task would let it stop right now: complete,
|
|
181
|
+
* with current evidence and review. Null means it would.
|
|
182
|
+
*/
|
|
183
|
+
async validate(sessionId) {
|
|
184
|
+
return await this.#validateTask(sessionId);
|
|
81
185
|
}
|
|
82
186
|
/**
|
|
83
187
|
* The fingerprint the task's evidence should carry. Committed work leaves an
|
|
@@ -120,6 +224,12 @@ export class CompletionGateService {
|
|
|
120
224
|
permitFingerprint: null
|
|
121
225
|
});
|
|
122
226
|
const changed = state.baselineFingerprint !== fingerprint;
|
|
227
|
+
if (!changed &&
|
|
228
|
+
this.#options.config.worktree?.mode === "auto" &&
|
|
229
|
+
state.root === undefined &&
|
|
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.`);
|
|
232
|
+
}
|
|
123
233
|
return gateResult("continue", changed ? "UNKNOWN" : "PASS", changed ? "COMPLETION_GATE_CHANGED" : "COMPLETION_GATE_READY", changed
|
|
124
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}.`
|
|
125
235
|
: undefined);
|
|
@@ -165,7 +275,19 @@ export class CompletionGateService {
|
|
|
165
275
|
}
|
|
166
276
|
return null;
|
|
167
277
|
}
|
|
278
|
+
/** `agent-ops worktree remove --force`: discards work that exists nowhere else. */
|
|
279
|
+
#isForcedRemove(event) {
|
|
280
|
+
const commands = event.event === "command" ? [event] :
|
|
281
|
+
event.event === "command-batch" ? event.commands : [];
|
|
282
|
+
return commands.some(({ command, args }) => {
|
|
283
|
+
const words = [command, ...args];
|
|
284
|
+
return words.includes("worktree") && words.includes("remove") && words.includes("--force");
|
|
285
|
+
});
|
|
286
|
+
}
|
|
168
287
|
async handle(event) {
|
|
288
|
+
if (this.#isForcedRemove(event)) {
|
|
289
|
+
return gateResult("block", "UNKNOWN", "WORKTREE_REMOVE_CONFIRMATION", "Allow this only if the user wants the worktree's uncommitted or unmerged work discarded.");
|
|
290
|
+
}
|
|
169
291
|
if (this.#isPermitCommand(event)) {
|
|
170
292
|
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
171
293
|
}
|
|
@@ -192,7 +314,19 @@ export class CompletionGateService {
|
|
|
192
314
|
if (state === null) {
|
|
193
315
|
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
|
|
194
316
|
}
|
|
195
|
-
|
|
317
|
+
const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
|
|
318
|
+
if (state.root !== undefined && state.root !== this.#options.root) {
|
|
319
|
+
// This checkout only has to stay untouched; the work, its task and its
|
|
320
|
+
// evidence live in the worktree, and that gate decides.
|
|
321
|
+
if (changed) {
|
|
322
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${state.root}, but this checkout changed too. Move those edits into the worktree, or ask the user for a one-time permit.`);
|
|
323
|
+
}
|
|
324
|
+
if (!await isDirectory(state.root) || this.#options.forRoot === undefined) {
|
|
325
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MISSING", `The worktree ${state.root} is unavailable. Run agent-ops worktree list, then resume or remove it.`);
|
|
326
|
+
}
|
|
327
|
+
return await (await this.#options.forRoot(state.root)).handle(event);
|
|
328
|
+
}
|
|
329
|
+
if (changed) {
|
|
196
330
|
const failure = await this.#validateTask(sessionId);
|
|
197
331
|
if (failure !== null)
|
|
198
332
|
return failure;
|
|
@@ -52,6 +52,11 @@ export async function dispatchHookEvent(event, options) {
|
|
|
52
52
|
if (event.event === "unsupported") {
|
|
53
53
|
return continueWith("UNKNOWN", "HOOK_EVENT_UNSUPPORTED");
|
|
54
54
|
}
|
|
55
|
+
if (event.event === "file-write") {
|
|
56
|
+
return options.worktreeGuard === undefined
|
|
57
|
+
? continueWith("PASS", "HOOK_NOOP")
|
|
58
|
+
: await options.worktreeGuard(event);
|
|
59
|
+
}
|
|
55
60
|
if (event.event === "session-start" &&
|
|
56
61
|
options.capabilities.includes("lifecycle-summary") &&
|
|
57
62
|
options.advisory !== undefined) {
|
|
@@ -495,7 +495,10 @@ export async function doctorInstallation(options) {
|
|
|
495
495
|
await checkProbe("smoke-availability", options.probes?.smokeAvailability),
|
|
496
496
|
await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true, options.checkAuthTargets),
|
|
497
497
|
await checkHostSandbox(options.probes?.hostRestriction),
|
|
498
|
-
checkVerificationCommands(config.config)
|
|
498
|
+
checkVerificationCommands(config.config),
|
|
499
|
+
...(options.probes?.worktrees === undefined
|
|
500
|
+
? []
|
|
501
|
+
: [await checkProbe("worktrees", options.probes.worktrees)])
|
|
499
502
|
];
|
|
500
503
|
return {
|
|
501
504
|
checks,
|
|
@@ -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.", "");
|
|
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>` from the main", "checkout before your first edit, and 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, review and", "`task complete` with the printed `--base`; leave the worktree (Claude", "Code: ExitWorktree with action keep) and run `agent-ops worktree finish", "<name>` from the main checkout. 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.", "");
|
|
@@ -102,7 +102,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
102
102
|
},
|
|
103
103
|
pathMappings: existing?.pathMappings ?? [],
|
|
104
104
|
securityExceptions: existing?.securityExceptions ?? [],
|
|
105
|
-
...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] })
|
|
105
|
+
...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
|
|
106
|
+
...(existing?.worktree === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { worktree: structuredClone(existing.worktree) })
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
|
+
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
5
|
+
import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
6
|
+
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
|
+
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
|
+
import { assertWorktreeName, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
|
+
export const NOTES_REF = "agent-ops";
|
|
10
|
+
const LOCK_NAME = "agent-ops-finish.lock";
|
|
11
|
+
const LOCK_POLL_MS = 2_000;
|
|
12
|
+
const LOCK_WAIT_MS = 30 * 60 * 1000;
|
|
13
|
+
// A holder that has not finished in this long has died without its pid being
|
|
14
|
+
// reused by something we can see; the lock is reclaimed.
|
|
15
|
+
const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
|
|
16
|
+
const MAX_INTENTS = 20;
|
|
17
|
+
/**
|
|
18
|
+
* The work and the moved target touch the same lines. The rebase was undone;
|
|
19
|
+
* this carries what the agent needs to redo it deliberately: the files, and
|
|
20
|
+
* what the tasks already merged into the target meant to achieve.
|
|
21
|
+
*/
|
|
22
|
+
export class WorktreeConflictError extends AgentOpsError {
|
|
23
|
+
files;
|
|
24
|
+
intents;
|
|
25
|
+
target;
|
|
26
|
+
constructor(record, target, head, files, intents) {
|
|
27
|
+
super("WORKTREE_REBASE_CONFLICT", [
|
|
28
|
+
`Rebasing ${record.branch} onto ${record.targetBranch} (${target}) conflicts in: ${files.join(", ")}.`,
|
|
29
|
+
"The rebase was aborted and the branch is unchanged.",
|
|
30
|
+
intents.length === 0
|
|
31
|
+
? "No agent-ops notes describe the target-side commits."
|
|
32
|
+
: ["What the target-side work intended:", ...intents.map(({ commit, note }) => `--- ${commit}\n${note}`)].join("\n"),
|
|
33
|
+
`Resolve it in ${record.path}: git rebase ${target}, satisfy both intents, git rebase --continue;`,
|
|
34
|
+
"then create a new task whose criteria cover both intents, attach it to this session,",
|
|
35
|
+
`run verify, review and task complete with --base ${target}, and finish again.`,
|
|
36
|
+
`One attempt only: if that verification or review fails, git reset --hard ${head}`,
|
|
37
|
+
"and stop to report instead of retrying."
|
|
38
|
+
].join("\n"));
|
|
39
|
+
this.files = files;
|
|
40
|
+
this.intents = intents;
|
|
41
|
+
this.target = target;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function finishError(code, message) {
|
|
45
|
+
return new AgentOpsError(code, message);
|
|
46
|
+
}
|
|
47
|
+
async function git(deps, cwd, args, code, message) {
|
|
48
|
+
const result = await deps.git(cwd, args);
|
|
49
|
+
if (result.exitCode !== 0) {
|
|
50
|
+
const detail = result.stderr.trim().split("\n")[0];
|
|
51
|
+
throw finishError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
|
|
52
|
+
}
|
|
53
|
+
return result.stdout.trim();
|
|
54
|
+
}
|
|
55
|
+
function runner(deps, cwd) {
|
|
56
|
+
return {
|
|
57
|
+
run: async (args) => {
|
|
58
|
+
const result = await deps.git(cwd, args);
|
|
59
|
+
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function alive(pid) {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return error.code === "EPERM";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function staleLock(path) {
|
|
73
|
+
try {
|
|
74
|
+
const owner = JSON.parse(await readFile(join(path, "owner.json"), "utf8"));
|
|
75
|
+
if (typeof owner.pid !== "number" || typeof owner.at !== "number")
|
|
76
|
+
return true;
|
|
77
|
+
return !alive(owner.pid) || Date.now() - owner.at > LOCK_STALE_MS;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// A holder between mkdir and writing its owner file looks like this too;
|
|
81
|
+
// only an old ownerless lock is abandoned.
|
|
82
|
+
try {
|
|
83
|
+
return Date.now() - (await stat(path)).mtimeMs > 60_000;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One finish at a time per repository: each one moves the target, and every
|
|
92
|
+
* later one must rebase onto that. The lock lives in the Git common directory
|
|
93
|
+
* so every worktree of the repository sees the same one.
|
|
94
|
+
*/
|
|
95
|
+
export async function withFinishLock(deps, commonDir, action) {
|
|
96
|
+
const path = join(commonDir, LOCK_NAME);
|
|
97
|
+
const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
|
|
98
|
+
const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
99
|
+
for (;;) {
|
|
100
|
+
try {
|
|
101
|
+
await mkdir(path);
|
|
102
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== "EEXIST")
|
|
107
|
+
throw error;
|
|
108
|
+
if (await staleLock(path)) {
|
|
109
|
+
await rm(path, { recursive: true, force: true });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (Date.now() >= deadline) {
|
|
113
|
+
throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
|
|
114
|
+
}
|
|
115
|
+
await sleep(LOCK_POLL_MS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return await action();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await rm(path, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The branch's own change, independent of where it is based: hunk positions
|
|
127
|
+
* and blob ids move with a rebase, the change itself does not.
|
|
128
|
+
*/
|
|
129
|
+
async function patchHash(deps, cwd, target) {
|
|
130
|
+
const diff = await git(deps, cwd, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", `${target}...HEAD`], "WORKTREE_DIFF_FAILED", "Git could not diff the branch.");
|
|
131
|
+
return sha256(diff.split("\n")
|
|
132
|
+
.filter((line) => !line.startsWith("index "))
|
|
133
|
+
.map((line) => line.startsWith("@@") ? "@@" : line)
|
|
134
|
+
.join("\n"));
|
|
135
|
+
}
|
|
136
|
+
async function reverify(deps, root, config) {
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const command of config.verification.commands) {
|
|
139
|
+
results.push(await executeConfiguredCommand(command, {
|
|
140
|
+
cwd: command.cwd === "." ? root : await resolveContainedPath(root, command.cwd),
|
|
141
|
+
trusted: true,
|
|
142
|
+
...(deps.processRunner === undefined ? {} : { runner: deps.processRunner })
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
return aggregateVerificationStatus(results) === "PASS"
|
|
146
|
+
? []
|
|
147
|
+
: results.filter((result) => result.required && result.status !== "PASS").map(({ commandId }) => commandId);
|
|
148
|
+
}
|
|
149
|
+
async function targetIntents(deps, cwd, target) {
|
|
150
|
+
const commits = (await git(deps, cwd, ["log", "--format=%H", `HEAD..${target}`], "WORKTREE_LOG_FAILED", "Git could not list the target-side commits.")).split("\n").filter(Boolean);
|
|
151
|
+
const intents = [];
|
|
152
|
+
for (const commit of commits) {
|
|
153
|
+
const note = await deps.git(cwd, ["notes", `--ref=${NOTES_REF}`, "show", commit]);
|
|
154
|
+
if (note.exitCode === 0 && note.stdout.trim() !== "") {
|
|
155
|
+
intents.push({ commit, note: note.stdout.trim() });
|
|
156
|
+
if (intents.length >= MAX_INTENTS)
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return intents;
|
|
161
|
+
}
|
|
162
|
+
function noteText(record, task) {
|
|
163
|
+
return [
|
|
164
|
+
`agent-ops task ${task.task.id}: ${task.task.title}`,
|
|
165
|
+
`worktree: ${record.name} (${record.branch})`,
|
|
166
|
+
"criteria:",
|
|
167
|
+
...task.task.criteria.map(({ id, description }) => `- ${id}: ${description}`)
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Brings a worktree's reviewed work into the branch the main checkout is on,
|
|
172
|
+
* by fast-forward only, then retires the worktree. Run from the main checkout.
|
|
173
|
+
*/
|
|
174
|
+
export async function finishWorktree(deps, options) {
|
|
175
|
+
const name = assertWorktreeName(options.name);
|
|
176
|
+
const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, options.cwd);
|
|
177
|
+
if (currentRoot !== mainRoot) {
|
|
178
|
+
throw finishError("WORKTREE_NESTED", `Run worktree finish from the main checkout (${mainRoot}); Claude Code: ExitWorktree with action keep first.`);
|
|
179
|
+
}
|
|
180
|
+
const record = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
181
|
+
if (record === null) {
|
|
182
|
+
throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
|
|
183
|
+
}
|
|
184
|
+
return await withFinishLock(deps, commonDir, async () => {
|
|
185
|
+
const mainRunner = runner(deps, mainRoot);
|
|
186
|
+
const worktreeRunner = runner(deps, record.path);
|
|
187
|
+
if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
|
|
188
|
+
throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
|
|
189
|
+
}
|
|
190
|
+
const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
191
|
+
if (onBranch !== record.targetBranch) {
|
|
192
|
+
throw finishError("WORKTREE_TARGET_MOVED", `The main checkout is on ${onBranch || "a detached HEAD"}; switch it back to ${record.targetBranch} to finish.`);
|
|
193
|
+
}
|
|
194
|
+
if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
|
|
195
|
+
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
196
|
+
}
|
|
197
|
+
const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
|
|
198
|
+
const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${target}..HEAD`], "WORKTREE_LOG_FAILED", "Git could not compare the branch with its target."));
|
|
199
|
+
if (ahead === 0) {
|
|
200
|
+
throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
|
|
201
|
+
}
|
|
202
|
+
const worktreeConfig = await deps.loadConfig(record.path);
|
|
203
|
+
const worktreeGate = await deps.gate(record.path, worktreeConfig);
|
|
204
|
+
const problem = await worktreeGate.validate(record.sessionId);
|
|
205
|
+
if (problem !== null) {
|
|
206
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
207
|
+
}
|
|
208
|
+
const task = await deps.tasks(record.path).status({ sessionId: record.sessionId });
|
|
209
|
+
const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
210
|
+
const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
|
|
211
|
+
if (!fastForward) {
|
|
212
|
+
const before = await patchHash(deps, record.path, target);
|
|
213
|
+
const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
|
|
214
|
+
if (rebase.exitCode !== 0) {
|
|
215
|
+
const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout
|
|
216
|
+
.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
217
|
+
await deps.git(record.path, ["rebase", "--abort"]);
|
|
218
|
+
await deps.git(record.path, ["reset", "--hard", head]);
|
|
219
|
+
throw new WorktreeConflictError(record, target, head, files, await targetIntents(deps, record.path, target));
|
|
220
|
+
}
|
|
221
|
+
const reset = async () => { await deps.git(record.path, ["reset", "--hard", head]); };
|
|
222
|
+
if (await patchHash(deps, record.path, target) !== before) {
|
|
223
|
+
await reset();
|
|
224
|
+
throw finishError("WORKTREE_REBASE_CHANGED", `Rebasing onto ${target} changed the branch's own diff, so the review no longer covers it. The branch was reset; rebase it yourself and review the result as a new task.`);
|
|
225
|
+
}
|
|
226
|
+
if (await deps.trust.status(record.path, worktreeConfig) !== "TRUSTED") {
|
|
227
|
+
await reset();
|
|
228
|
+
throw finishError("WORKTREE_UNTRUSTED", "Re-verifying the rebased branch runs repository commands; run agent-ops trust grant in the worktree.");
|
|
229
|
+
}
|
|
230
|
+
const failed = await reverify(deps, record.path, worktreeConfig);
|
|
231
|
+
if (failed.length > 0) {
|
|
232
|
+
await reset();
|
|
233
|
+
throw finishError("WORKTREE_REVERIFY_FAILED", `After rebasing onto ${target}, verification failed: ${failed.join(", ")}. The branch was reset; fix it as a new task and finish again.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const mainConfig = await deps.loadConfig(mainRoot);
|
|
237
|
+
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
238
|
+
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
239
|
+
await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
|
|
240
|
+
const mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
241
|
+
// The merge happened; everything below is cleanup, reported, never undone.
|
|
242
|
+
const warnings = [];
|
|
243
|
+
const attempt = async (label, step) => {
|
|
244
|
+
try {
|
|
245
|
+
await step();
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record, task), mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
|
|
252
|
+
if (gateEnabled) {
|
|
253
|
+
await attempt("gate", async () => {
|
|
254
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
255
|
+
await mainGate.redirect(record.sessionId, null);
|
|
256
|
+
await mainGate.rebase(before, await currentGateFingerprint(mainRoot, mainRunner));
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
260
|
+
await attempt("remove", async () => await removeCheckout(deps, record, true));
|
|
261
|
+
return { record, mergedHead, rebased: !fastForward, taskId: task.task.id, warnings };
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
import { insideWorktreeDirectory } from "./service.js";
|
|
4
|
+
/** The main checkout of the repository `runner` runs in, or null. */
|
|
5
|
+
export async function resolveMainRoot(runner) {
|
|
6
|
+
const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
7
|
+
if (result.exitCode !== 0)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const commonDir = await realpath(new TextDecoder().decode(result.stdout).trim());
|
|
11
|
+
return basename(commonDir) === ".git" ? dirname(commonDir) : null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The real path of `path`, resolved through its deepest existing ancestor: a
|
|
19
|
+
* Write creates files that do not exist yet, and a symlinked spelling of the
|
|
20
|
+
* repository must not decide whether an edit lands in the main checkout.
|
|
21
|
+
*/
|
|
22
|
+
export async function canonicalPath(path) {
|
|
23
|
+
const pending = [];
|
|
24
|
+
let current = path;
|
|
25
|
+
for (;;) {
|
|
26
|
+
try {
|
|
27
|
+
return join(await realpath(current), ...pending.reverse());
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
const parent = dirname(current);
|
|
31
|
+
if (parent === current)
|
|
32
|
+
return path;
|
|
33
|
+
pending.push(basename(current));
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* In worktree auto mode the main checkout is shared by every session, so no
|
|
40
|
+
* session writes it directly: each edit belongs in that session's own
|
|
41
|
+
* `.worktrees/<name>`. Paths outside the repository are not this guard's
|
|
42
|
+
* business.
|
|
43
|
+
*/
|
|
44
|
+
export async function evaluateWorktreeWrite(mainRoot, paths, sessionId) {
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
const target = await canonicalPath(path);
|
|
47
|
+
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
48
|
+
if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
|
|
49
|
+
const session = sessionId ?? "<session-id>";
|
|
50
|
+
return {
|
|
51
|
+
action: "block",
|
|
52
|
+
status: "FAIL",
|
|
53
|
+
code: "WORKTREE_REQUIRED",
|
|
54
|
+
remedy: `worktree.mode is auto, so the main checkout is shared and not edited directly. Run \`agent-ops worktree add <name> --session ${session}\` in ${mainRoot}, enter the printed path (Claude Code: EnterWorktree with that path), and edit there.`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { action: "continue", status: "PASS", code: "WORKTREE_WRITE_ALLOWED" };
|
|
59
|
+
}
|