@kylecheng3146/agent-ops 0.3.0 → 0.3.1
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/bin.js +33 -77
- 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/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/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/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
|
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
|
|
9
9
|
import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
|
|
@@ -12,8 +12,6 @@ import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js"
|
|
|
12
12
|
import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
|
|
13
13
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
14
14
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
15
|
-
import { FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
16
|
-
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
17
15
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
18
16
|
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
19
17
|
import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
|
|
@@ -22,6 +20,7 @@ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn
|
|
|
22
20
|
import { runCli } from "./cli.js";
|
|
23
21
|
import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
|
|
24
22
|
import { runHookProcess } from "./hook-process.js";
|
|
23
|
+
import { gitRunner, trustStore, worktreeDependencies } from "./parallel-deps.js";
|
|
25
24
|
import { selectYesNo, writeBanner } from "./ui.js";
|
|
26
25
|
import { CLI_VERSION } from "./version.js";
|
|
27
26
|
import { createCommandRegistry } from "./commands/index.js";
|
|
@@ -43,6 +42,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
|
|
|
43
42
|
import { errorEnvelope } from "./output.js";
|
|
44
43
|
import { runAgyHeadless } from "./agy-headless.js";
|
|
45
44
|
import { runWorktreeCommand } from "./commands/parallel.js";
|
|
45
|
+
import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
|
|
46
46
|
import { listWorktrees, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
|
|
47
47
|
const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
|
|
48
48
|
async function readOptionalText(path) {
|
|
@@ -78,29 +78,6 @@ async function installedManifest(root) {
|
|
|
78
78
|
async function installedHarness(root) {
|
|
79
79
|
return (await installedManifest(root))?.harness ?? [...HARNESS_IDS];
|
|
80
80
|
}
|
|
81
|
-
function gitRunner(root) {
|
|
82
|
-
return {
|
|
83
|
-
run: async (gitArgs) => {
|
|
84
|
-
try {
|
|
85
|
-
return {
|
|
86
|
-
exitCode: 0,
|
|
87
|
-
stdout: execFileSync("git", [...gitArgs], {
|
|
88
|
-
cwd: root,
|
|
89
|
-
encoding: "buffer",
|
|
90
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
91
|
-
})
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
const failure = error;
|
|
96
|
-
return {
|
|
97
|
-
exitCode: failure.status ?? 1,
|
|
98
|
-
stdout: failure.stdout ?? new Uint8Array()
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
81
|
async function confirmInit(plan, trust, warnings = []) {
|
|
105
82
|
writeBanner({
|
|
106
83
|
isTTY: process.stdout.isTTY === true,
|
|
@@ -113,59 +90,12 @@ async function confirmPlan(text) {
|
|
|
113
90
|
process.stdout.write(text);
|
|
114
91
|
return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
|
|
115
92
|
}
|
|
116
|
-
function trustStore() {
|
|
117
|
-
const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
|
|
118
|
-
return new FileTrustStore(state.trustStore, state.anchorDirectory);
|
|
119
|
-
}
|
|
120
93
|
async function plannedTrustBinding(root, projectConfig) {
|
|
121
94
|
const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
|
|
122
95
|
return config.verification.commands.length === 0
|
|
123
96
|
? null
|
|
124
97
|
: await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
125
98
|
}
|
|
126
|
-
function worktreeDependencies() {
|
|
127
|
-
const gateFor = async (root, config) => new CompletionGateService({
|
|
128
|
-
root,
|
|
129
|
-
config,
|
|
130
|
-
gitRunner: gitRunner(root),
|
|
131
|
-
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
132
|
-
evidenceStore: new FileEvidenceStore(root, root)
|
|
133
|
-
});
|
|
134
|
-
return {
|
|
135
|
-
git: async (cwd, gitArgs) => await new Promise((resolve) => {
|
|
136
|
-
execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
|
|
137
|
-
exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
|
|
138
|
-
stdout,
|
|
139
|
-
stderr
|
|
140
|
-
}));
|
|
141
|
-
}),
|
|
142
|
-
loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
|
|
143
|
-
trust: {
|
|
144
|
-
status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
|
|
145
|
-
grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
|
|
146
|
-
revoke: async (root, config) => {
|
|
147
|
-
await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
gate: gateFor,
|
|
151
|
-
tasks: (root) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
152
|
-
processRunner: new NodeVerificationProcessRunner(),
|
|
153
|
-
runSetup: async (cwd, step) => await new Promise((resolve) => {
|
|
154
|
-
execFile(step.command, [...step.args], {
|
|
155
|
-
cwd,
|
|
156
|
-
encoding: "utf8",
|
|
157
|
-
timeout: step.timeoutMs,
|
|
158
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
159
|
-
...(process.platform === "win32" ? { shell: true } : {})
|
|
160
|
-
}, (error, stdout, stderr) => resolve({
|
|
161
|
-
exitCode: error === null
|
|
162
|
-
? 0
|
|
163
|
-
: typeof error.code === "number" && error.killed !== true ? error.code : null,
|
|
164
|
-
output: `${stdout}${stderr}`
|
|
165
|
-
}));
|
|
166
|
-
})
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
99
|
async function worktreeDoctorProbe(root) {
|
|
170
100
|
let statuses;
|
|
171
101
|
try {
|
|
@@ -222,7 +152,7 @@ else if (argv[0] === "hook") {
|
|
|
222
152
|
stdin: process.stdin,
|
|
223
153
|
writeStdout: (value) => process.stdout.write(value),
|
|
224
154
|
writeStderr: (value) => process.stderr.write(value)
|
|
225
|
-
}, CLI_VERSION);
|
|
155
|
+
}, CLI_VERSION, { worktree: worktreeDependencies() });
|
|
226
156
|
}
|
|
227
157
|
else {
|
|
228
158
|
process.exitCode = await runCli(argv, {
|
|
@@ -448,14 +378,40 @@ else {
|
|
|
448
378
|
// run inside a session is never told which session it is in.
|
|
449
379
|
const sessionId = process.env.AGENT_OPS_SESSION_ID ??
|
|
450
380
|
await readRecordedSessionId(root);
|
|
451
|
-
const
|
|
452
|
-
?
|
|
381
|
+
const createConfig = args.action === "create"
|
|
382
|
+
? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
|
|
453
383
|
: undefined;
|
|
384
|
+
const policyConfigHash = createConfig === undefined
|
|
385
|
+
? undefined
|
|
386
|
+
: calculateConfigHash(createConfig);
|
|
387
|
+
// Auto mode starts the work where it belongs: a task created from
|
|
388
|
+
// the main checkout lands in the session's own worktree.
|
|
389
|
+
const worktreeDeps = worktreeDependencies();
|
|
390
|
+
const fromMain = createConfig?.worktree?.mode === "auto" &&
|
|
391
|
+
args.scope !== "user" &&
|
|
392
|
+
await resolveCheckouts(worktreeDeps, root)
|
|
393
|
+
.then(({ mainRoot, currentRoot }) => mainRoot === currentRoot)
|
|
394
|
+
.catch(() => false);
|
|
454
395
|
return await runTaskCommand({
|
|
455
396
|
args,
|
|
456
397
|
service: taskService,
|
|
457
398
|
...(policyConfigHash === undefined ? {} : { policyConfigHash }),
|
|
458
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
399
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
400
|
+
...(fromMain
|
|
401
|
+
? {
|
|
402
|
+
sessionWorktree: async (session) => {
|
|
403
|
+
const record = await ensureSessionWorktree(worktreeDeps, {
|
|
404
|
+
cwd: root,
|
|
405
|
+
sessionId: session
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
path: record.path,
|
|
409
|
+
base: record.base,
|
|
410
|
+
service: new TaskService(new FileTaskStore(join(record.path, ".agent-ops", "tasks", "state.json"), record.path))
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
: {})
|
|
459
415
|
});
|
|
460
416
|
}
|
|
461
417
|
if (args.command === "review") {
|
|
@@ -28,16 +28,17 @@ function addText(result) {
|
|
|
28
28
|
"Work only inside that path from now on:",
|
|
29
29
|
`- Claude Code: EnterWorktree with path ${record.path}`,
|
|
30
30
|
`- Codex and agy: run every command with ${record.path} as its working directory`,
|
|
31
|
-
`Finish with: commit, agent-ops verify
|
|
32
|
-
`then agent-ops worktree finish ${record.name} from ${record.mainRoot}`,
|
|
33
|
-
"
|
|
31
|
+
`Finish with: commit, agent-ops verify and review with --base ${record.base},`,
|
|
32
|
+
`then agent-ops worktree finish ${record.name} from ${record.mainRoot}, which`,
|
|
33
|
+
"completes every task in the worktree and merges",
|
|
34
|
+
"(Claude Code: ExitWorktree with action keep before finishing; do not run task complete in the worktree)."
|
|
34
35
|
].join("\n");
|
|
35
36
|
}
|
|
36
37
|
function finishText(result) {
|
|
37
38
|
const { record } = result;
|
|
38
39
|
return [
|
|
39
40
|
`Merged ${record.branch} into ${record.targetBranch} at ${result.mergedHead}${result.rebased ? " after a clean rebase and re-verification" : ""}.`,
|
|
40
|
-
`
|
|
41
|
+
`Completed and recorded in git notes (refs/notes/agent-ops): ${result.taskIds.join(", ")}.`,
|
|
41
42
|
`Removed ${record.path}.`,
|
|
42
43
|
...result.warnings.map((warning) => `Warning: ${warning}`)
|
|
43
44
|
].join("\n");
|
|
@@ -273,7 +273,7 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
273
273
|
if (options.root === undefined) {
|
|
274
274
|
return null;
|
|
275
275
|
}
|
|
276
|
-
const attestation = await findReviewAttestation(options.root, sourceFingerprint);
|
|
276
|
+
const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
|
|
277
277
|
if (attestation === null || attestation.taskId !== context?.taskId) {
|
|
278
278
|
return null;
|
|
279
279
|
}
|
|
@@ -284,7 +284,7 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
284
284
|
context?.policyConfigHash !== options.policyConfigHash) {
|
|
285
285
|
return null;
|
|
286
286
|
}
|
|
287
|
-
const artifact = await readReviewReportArtifact(options.root, sourceFingerprint);
|
|
287
|
+
const artifact = await readReviewReportArtifact(options.root, sourceFingerprint, attestation.taskId);
|
|
288
288
|
if (artifact === null ||
|
|
289
289
|
artifact.taskId !== attestation.taskId ||
|
|
290
290
|
artifact.report === undefined ||
|
|
@@ -488,7 +488,7 @@ export async function runReviewCommand(options) {
|
|
|
488
488
|
})
|
|
489
489
|
});
|
|
490
490
|
}
|
|
491
|
-
await invalidateReviewAttestation(options.root, sourceFingerprint);
|
|
491
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
|
|
492
492
|
pendingInvalidation = false;
|
|
493
493
|
}
|
|
494
494
|
}
|
|
@@ -605,7 +605,9 @@ export async function runReviewCommand(options) {
|
|
|
605
605
|
await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
|
|
606
606
|
item.criterionId,
|
|
607
607
|
item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
|
|
608
|
-
]))
|
|
608
|
+
])),
|
|
609
|
+
// What `worktree finish` completes this task against.
|
|
610
|
+
scope?.mode === "base" ? scope.resolvedBase : null);
|
|
609
611
|
}
|
|
610
612
|
const message = finalResult.status === "PASS"
|
|
611
613
|
? "Independent review passed."
|
|
@@ -87,7 +87,10 @@ export async function runTaskCommand(options) {
|
|
|
87
87
|
if (options.args.title === undefined) {
|
|
88
88
|
throw new AgentOpsError("TASK_TITLE_REQUIRED", "Task creation requires --title.");
|
|
89
89
|
}
|
|
90
|
-
const
|
|
90
|
+
const worktree = sessionId === undefined || options.sessionWorktree === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: await options.sessionWorktree(sessionId);
|
|
93
|
+
const record = await (worktree?.service ?? options.service).create({
|
|
91
94
|
title: options.args.title,
|
|
92
95
|
criteria: (options.args.criteria ?? []).map(parseCriterion),
|
|
93
96
|
...(options.policyConfigHash === undefined
|
|
@@ -98,9 +101,30 @@ export async function runTaskCommand(options) {
|
|
|
98
101
|
: { parentTaskId: options.args.parentTaskId }),
|
|
99
102
|
...(sessionId === undefined ? {} : { sessionId })
|
|
100
103
|
});
|
|
101
|
-
|
|
104
|
+
const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
|
|
102
105
|
? `Created task ${record.task.id}.`
|
|
103
|
-
:
|
|
106
|
+
: record.task.parentTaskId === undefined
|
|
107
|
+
? `Created and attached task ${record.task.id}.`
|
|
108
|
+
: `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
|
|
109
|
+
if (worktree === undefined || created.data === null)
|
|
110
|
+
return created;
|
|
111
|
+
return {
|
|
112
|
+
...created,
|
|
113
|
+
data: {
|
|
114
|
+
...created.data,
|
|
115
|
+
text: [
|
|
116
|
+
`Created in this session's worktree ${worktree.path}.`,
|
|
117
|
+
"Work only inside that path from now on:",
|
|
118
|
+
`- Claude Code: EnterWorktree with path ${worktree.path}`,
|
|
119
|
+
`- Codex and agy: run every command with ${worktree.path} as its working directory`,
|
|
120
|
+
`Commit, then run verify and review there with --base ${worktree.base};`,
|
|
121
|
+
"then leave the worktree (Claude Code: ExitWorktree with action keep) and run",
|
|
122
|
+
"agent-ops worktree finish from the main checkout, which completes the task and merges.",
|
|
123
|
+
"",
|
|
124
|
+
created.data.text
|
|
125
|
+
].join("\n")
|
|
126
|
+
}
|
|
127
|
+
};
|
|
104
128
|
}
|
|
105
129
|
if (action === "status") {
|
|
106
130
|
if (options.args.taskId === undefined && sessionId === undefined) {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// node <this file> <harness> <event> --managed-by=agent-ops
|
|
4
4
|
import { CLI_VERSION } from "./version.js";
|
|
5
5
|
import { runHookProcess } from "./hook-process.js";
|
|
6
|
+
import { worktreeDependencies } from "./parallel-deps.js";
|
|
6
7
|
process.exitCode = await runHookProcess(process.argv.slice(2), {
|
|
7
8
|
stdin: process.stdin,
|
|
8
9
|
writeStdout: (value) => process.stdout.write(value),
|
|
9
10
|
writeStderr: (value) => process.stderr.write(value)
|
|
10
|
-
}, CLI_VERSION);
|
|
11
|
+
}, CLI_VERSION, { worktree: worktreeDependencies() });
|
|
@@ -10,6 +10,7 @@ import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
|
10
10
|
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
11
11
|
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
12
12
|
import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
|
|
13
|
+
import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
|
|
13
14
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
14
15
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
15
16
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
@@ -348,7 +349,9 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
348
349
|
const mainRoot = await resolveMainRoot(gitRunner);
|
|
349
350
|
return mainRoot === null
|
|
350
351
|
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
351
|
-
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId
|
|
352
|
+
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
|
|
353
|
+
? undefined
|
|
354
|
+
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
352
355
|
}
|
|
353
356
|
: undefined;
|
|
354
357
|
const output = await runHookCommand({
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
5
|
+
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
6
|
+
import { FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
7
|
+
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
8
|
+
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
9
|
+
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
10
|
+
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
11
|
+
import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
|
|
12
|
+
import { CLI_VERSION } from "./version.js";
|
|
13
|
+
// Shared by the CLI and the hook entry point: Claude Code runs hooks through
|
|
14
|
+
// hook-entry.js, never bin.js, and its worktree guard needs these too.
|
|
15
|
+
export function gitRunner(root) {
|
|
16
|
+
return {
|
|
17
|
+
run: async (gitArgs) => {
|
|
18
|
+
try {
|
|
19
|
+
return {
|
|
20
|
+
exitCode: 0,
|
|
21
|
+
stdout: execFileSync("git", [...gitArgs], {
|
|
22
|
+
cwd: root,
|
|
23
|
+
encoding: "buffer",
|
|
24
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
25
|
+
})
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const failure = error;
|
|
30
|
+
return {
|
|
31
|
+
exitCode: failure.status ?? 1,
|
|
32
|
+
stdout: failure.stdout ?? new Uint8Array()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function trustStore() {
|
|
39
|
+
const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
|
|
40
|
+
return new FileTrustStore(state.trustStore, state.anchorDirectory);
|
|
41
|
+
}
|
|
42
|
+
export function worktreeDependencies() {
|
|
43
|
+
const gateFor = async (root, config) => new CompletionGateService({
|
|
44
|
+
root,
|
|
45
|
+
config,
|
|
46
|
+
gitRunner: gitRunner(root),
|
|
47
|
+
taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
|
|
48
|
+
evidenceStore: new FileEvidenceStore(root, root)
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
git: async (cwd, gitArgs) => await new Promise((resolve) => {
|
|
52
|
+
execFile("git", [...gitArgs], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => resolve({
|
|
53
|
+
exitCode: error === null ? 0 : typeof error.code === "number" ? error.code : 1,
|
|
54
|
+
stdout,
|
|
55
|
+
stderr
|
|
56
|
+
}));
|
|
57
|
+
}),
|
|
58
|
+
loadConfig: async (root) => (await loadEffectiveConfig(root, "project")).config,
|
|
59
|
+
trust: {
|
|
60
|
+
status: async (root, config) => await repositoryTrust(root, config, CLI_VERSION),
|
|
61
|
+
grant: async (root, config) => await trustStore().grant(await repositoryTrustBinding(root, config, CLI_VERSION)),
|
|
62
|
+
revoke: async (root, config) => {
|
|
63
|
+
await trustStore().revoke(await repositoryTrustBinding(root, config, CLI_VERSION));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
gate: gateFor,
|
|
67
|
+
tasks: (root, base) => new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), base === undefined ? {} : { completion: {
|
|
68
|
+
root,
|
|
69
|
+
gitRunner: gitRunner(root),
|
|
70
|
+
base,
|
|
71
|
+
loadConfig: async () => (await loadEffectiveConfig(root, "project")).config
|
|
72
|
+
} }),
|
|
73
|
+
processRunner: new NodeVerificationProcessRunner(),
|
|
74
|
+
runSetup: async (cwd, step) => await new Promise((resolve) => {
|
|
75
|
+
execFile(step.command, [...step.args], {
|
|
76
|
+
cwd,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: step.timeoutMs,
|
|
79
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
80
|
+
...(process.platform === "win32" ? { shell: true } : {})
|
|
81
|
+
}, (error, stdout, stderr) => resolve({
|
|
82
|
+
exitCode: error === null
|
|
83
|
+
? 0
|
|
84
|
+
: typeof error.code === "number" && error.killed !== true ? error.code : null,
|
|
85
|
+
output: `${stdout}${stderr}`
|
|
86
|
+
}));
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -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.", "");
|
|
@@ -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
|
}
|
|
@@ -28,6 +28,17 @@ export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
|
|
|
28
28
|
* thinking hard is never cut off.
|
|
29
29
|
*/
|
|
30
30
|
export const DEFAULT_STALL_IDLE_MS = 90_000;
|
|
31
|
+
/**
|
|
32
|
+
* agy writes nothing to stdout, stderr or its log while one generation
|
|
33
|
+
* streams, so a long think is indistinguishable from a wedge. Its own logs
|
|
34
|
+
* show normal generations silent for up to ~177s; 90s cut real reviews off.
|
|
35
|
+
*/
|
|
36
|
+
export const AGY_STALL_IDLE_MS = 240_000;
|
|
37
|
+
/**
|
|
38
|
+
* A call the service dropped before answering. It produced no verdict, so
|
|
39
|
+
* one fresh retry is not review shopping.
|
|
40
|
+
*/
|
|
41
|
+
const TRANSIENT_NETWORK = /\b(?:network issue|503|service is currently unavailable|ECONNRESET|ETIMEDOUT|socket hang up)\b/iu;
|
|
31
42
|
/** Two rounds at the per-target default: the previous worst case, now bounded. */
|
|
32
43
|
export const DEFAULT_REVIEW_CHAIN_TIMEOUT_MS = 2 * DEFAULT_REVIEW_TIMEOUT_MS;
|
|
33
44
|
/**
|
|
@@ -425,7 +436,8 @@ async function attemptTarget(request, options) {
|
|
|
425
436
|
}
|
|
426
437
|
throwIfInterrupted(target, options);
|
|
427
438
|
options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
|
|
428
|
-
const stallIdleMs = options.stallIdleMs ??
|
|
439
|
+
const stallIdleMs = options.stallIdleMs ??
|
|
440
|
+
(target === "agy" ? AGY_STALL_IDLE_MS : DEFAULT_STALL_IDLE_MS);
|
|
429
441
|
const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
|
|
430
442
|
let spawned;
|
|
431
443
|
try {
|
|
@@ -481,8 +493,11 @@ async function attemptTarget(request, options) {
|
|
|
481
493
|
}
|
|
482
494
|
if (spawned.failureClass === "nonzero-exit") {
|
|
483
495
|
const output = `${spawned.stderr}\n${spawned.stdout}`;
|
|
484
|
-
return
|
|
485
|
-
|
|
496
|
+
return {
|
|
497
|
+
...skip(rejectedCallReason(output), firstComplaint(spawned.stderr, spawned.stdout) ??
|
|
498
|
+
`the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`),
|
|
499
|
+
...(TRANSIENT_NETWORK.test(output) ? { transient: true } : {})
|
|
500
|
+
};
|
|
486
501
|
}
|
|
487
502
|
if (spawned.failureClass === "signal-exit") {
|
|
488
503
|
return skip("capability-unavailable", `the reviewer exited after ${spawned.signal ?? "an unknown signal"}`);
|
|
@@ -522,6 +537,18 @@ async function attemptTarget(request, options) {
|
|
|
522
537
|
await rm(attemptDirectory, { recursive: true, force: true });
|
|
523
538
|
}
|
|
524
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* `attemptTarget`, retried once in a fresh session when the service dropped
|
|
542
|
+
* the call. Only once: a service that is down stays down.
|
|
543
|
+
*/
|
|
544
|
+
async function attemptTargetWithRetry(request, options) {
|
|
545
|
+
const first = await attemptTarget(request, options);
|
|
546
|
+
if (first.kind !== "skip" || first.transient !== true || budgetSpent(options)) {
|
|
547
|
+
return first;
|
|
548
|
+
}
|
|
549
|
+
options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
|
|
550
|
+
return attemptTarget(request, options);
|
|
551
|
+
}
|
|
525
552
|
/** Builds the `execute` callback with one necessary and one adversarial review. */
|
|
526
553
|
export function createReviewExecutor(options) {
|
|
527
554
|
const report = options.onProgress ?? (() => { });
|
|
@@ -630,7 +657,7 @@ export function createReviewExecutor(options) {
|
|
|
630
657
|
report("chain: review deadline reached before the first round");
|
|
631
658
|
return { status: "NOT_RUN", reason: "timeout", attempts: [] };
|
|
632
659
|
}
|
|
633
|
-
const primaryOutcome = await
|
|
660
|
+
const primaryOutcome = await attemptTargetWithRetry({
|
|
634
661
|
...shared,
|
|
635
662
|
target: primaryTarget,
|
|
636
663
|
label: `${primaryTarget}-primary`,
|
|
@@ -739,7 +766,7 @@ export function createReviewExecutor(options) {
|
|
|
739
766
|
};
|
|
740
767
|
}
|
|
741
768
|
// The second round draws on what is left, never a fresh full budget.
|
|
742
|
-
const adversarialOutcome = await
|
|
769
|
+
const adversarialOutcome = await attemptTargetWithRetry({
|
|
743
770
|
...shared,
|
|
744
771
|
target: adversarialTarget,
|
|
745
772
|
label: `${adversarialTarget}-adversarial`,
|
|
@@ -38,7 +38,7 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
const attestation = await findReviewAttestation(root, sourceFingerprint);
|
|
41
|
+
const attestation = await findReviewAttestation(root, sourceFingerprint, stored.task.id);
|
|
42
42
|
if (attestation === null || attestation.taskId !== stored.task.id) {
|
|
43
43
|
return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
|
|
44
44
|
}
|
|
@@ -130,9 +130,17 @@ export class TaskService {
|
|
|
130
130
|
state.tasks.push(record);
|
|
131
131
|
if (input.sessionId !== undefined) {
|
|
132
132
|
const currentIndex = state.sessions.findIndex(({ sessionId }) => sessionId === input.sessionId);
|
|
133
|
+
// A subtask keeps the session on the top of its tree: the completion
|
|
134
|
+
// gate checks the attached task and everything under it, so attaching
|
|
135
|
+
// the subtask would let a Stop through with its parent unfinished.
|
|
136
|
+
let rootId = record.task.id;
|
|
137
|
+
for (let parentId = record.task.parentTaskId; parentId !== undefined;) {
|
|
138
|
+
rootId = parentId;
|
|
139
|
+
parentId = state.tasks.find(({ task }) => task.id === parentId)?.task.parentTaskId;
|
|
140
|
+
}
|
|
133
141
|
const attachment = {
|
|
134
142
|
sessionId: input.sessionId,
|
|
135
|
-
taskId:
|
|
143
|
+
taskId: rootId,
|
|
136
144
|
attachedAt: now
|
|
137
145
|
};
|
|
138
146
|
if (currentIndex === -1) {
|
|
@@ -304,7 +312,12 @@ export class TaskService {
|
|
|
304
312
|
* task accepts evidence: a completed record must stay exactly as it was
|
|
305
313
|
* verified.
|
|
306
314
|
*/
|
|
307
|
-
async recordEvidence(taskId, evidenceInput
|
|
315
|
+
async recordEvidence(taskId, evidenceInput,
|
|
316
|
+
/**
|
|
317
|
+
* A PASS review's base: set records it, null clears it (the review covered
|
|
318
|
+
* uncommitted work), undefined leaves the record's value alone.
|
|
319
|
+
*/
|
|
320
|
+
reviewBase) {
|
|
308
321
|
const now = assertTimestamp(this.#now());
|
|
309
322
|
return await this.#store.mutate((state) => {
|
|
310
323
|
const current = findTask(state, taskId);
|
|
@@ -328,10 +341,13 @@ export class TaskService {
|
|
|
328
341
|
...new Set([...(evidence[criterionId] ?? []), ...references])
|
|
329
342
|
];
|
|
330
343
|
}
|
|
344
|
+
const { reviewBase: previousBase, ...rest } = current;
|
|
345
|
+
const base = reviewBase === undefined ? previousBase : reviewBase ?? undefined;
|
|
331
346
|
const updated = {
|
|
332
|
-
...
|
|
347
|
+
...rest,
|
|
333
348
|
evidence,
|
|
334
|
-
updatedAt: now
|
|
349
|
+
updatedAt: now,
|
|
350
|
+
...(base === undefined ? {} : { reviewBase: base })
|
|
335
351
|
};
|
|
336
352
|
replaceTask(state, updated);
|
|
337
353
|
return cloneRecord(updated);
|
|
@@ -66,7 +66,7 @@ function parseTaskRecord(value) {
|
|
|
66
66
|
];
|
|
67
67
|
// Every record written before an optional field existed must still parse, so
|
|
68
68
|
// the accepted shapes are every combination of them.
|
|
69
|
-
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
|
|
69
|
+
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
|
|
70
70
|
const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
|
|
71
71
|
...baseKeys,
|
|
72
72
|
...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
|
|
@@ -146,6 +146,10 @@ function parseTaskRecord(value) {
|
|
|
146
146
|
(typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
|
|
147
147
|
return invalidState("Task state contains an invalid completion base.");
|
|
148
148
|
}
|
|
149
|
+
if (value.reviewBase !== undefined &&
|
|
150
|
+
(typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
|
|
151
|
+
return invalidState("Task state contains an invalid review base.");
|
|
152
|
+
}
|
|
149
153
|
return {
|
|
150
154
|
task: task.value,
|
|
151
155
|
status,
|
|
@@ -156,7 +160,8 @@ function parseTaskRecord(value) {
|
|
|
156
160
|
archivedAt: value.archivedAt,
|
|
157
161
|
failureFingerprint,
|
|
158
162
|
policyConfigHash,
|
|
159
|
-
completionBase
|
|
163
|
+
completionBase,
|
|
164
|
+
...(value.reviewBase === undefined ? {} : { reviewBase: value.reviewBase })
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
function parseSession(value) {
|
|
@@ -152,6 +152,17 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
152
152
|
On Claude Code a direct Edit or Write of the main checkout outside
|
|
153
153
|
`.worktrees/` is denied (run `agent-ops update` once so the PreToolUse hook
|
|
154
154
|
matches the file tools). Absent or `"off"` keeps today's single checkout.
|
|
155
|
+
- `agent-ops task create` run from the main checkout with a known session
|
|
156
|
+
creates that session's worktree first (or reuses it) and records the task
|
|
157
|
+
there, so the work starts in the worktree. Its output names the path to
|
|
158
|
+
enter and the `--base` to verify against.
|
|
159
|
+
- As a fallback on Claude Code, the first denied edit creates that worktree:
|
|
160
|
+
`.worktrees/session-<first eight characters of the session id>`, setup
|
|
161
|
+
included, bound to the session. The denial names the path to enter with
|
|
162
|
+
EnterWorktree; a later denial for the same session reuses it. If creation
|
|
163
|
+
fails, the denial says why and falls back to the manual `worktree add`.
|
|
164
|
+
Run `agent-ops update` once so the PreToolUse hook gets the 600-second
|
|
165
|
+
timeout setup needs.
|
|
155
166
|
- `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` from the main
|
|
156
167
|
checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
|
|
157
168
|
never `.gitignore`. It copies the ignored files agent-ops installed and any
|
|
@@ -166,10 +177,20 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
166
177
|
- The session's completion gate follows it: Claude Code enters the worktree
|
|
167
178
|
with EnterWorktree, and a host that cannot move a session (agy) is judged by
|
|
168
179
|
the worktree's gate through a redirect the main checkout records.
|
|
180
|
+
- In a worktree, verify and review each task but do not run `task complete`
|
|
181
|
+
(Claude Code refuses it inside an isolated worktree session). `agent-ops
|
|
182
|
+
worktree finish <name>` completes every non-archived task in the worktree,
|
|
183
|
+
subtasks before their parents, each against the base its PASS review
|
|
184
|
+
recorded (the worktree's base when none was recorded), before any rebase.
|
|
185
|
+
If one cannot complete, nothing merges and the error names the task.
|
|
186
|
+
Creating a subtask keeps the session attached to the top of its tree, and
|
|
187
|
+
each task keeps its own review record, so a parent and a subtask reviewed
|
|
188
|
+
on the same source do not overwrite each other.
|
|
169
189
|
- `agent-ops worktree finish <name>` merges by fast-forward only, one finish
|
|
170
190
|
at a time. If the target moved, it rebases; a clean rebase whose own patch
|
|
171
191
|
is unchanged is re-verified before merging, and a conflict is reported with
|
|
172
|
-
the `refs/notes/agent-ops` intent of the work that landed first.
|
|
192
|
+
the `refs/notes/agent-ops` intent of the work that landed first. The note
|
|
193
|
+
records every task of the worktree, subtasks indented under their parent.
|
|
173
194
|
- `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
|
|
174
195
|
what is left; `remove --force` discards work and asks the user first.
|
|
175
196
|
`doctor` reports worktrees idle for more than seven days.
|
|
@@ -136,6 +136,14 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
136
136
|
Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
|
|
137
137
|
`agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
|
|
138
138
|
目前的單一 checkout。
|
|
139
|
+
- 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
|
|
140
|
+
該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
|
|
141
|
+
要進入的路徑與 verify 用的 `--base`。
|
|
142
|
+
- 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
143
|
+
`.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
|
|
144
|
+
拒絕訊息會列出要以 EnterWorktree 進入的路徑;同一 session 之後被拒絕時會重用它。
|
|
145
|
+
建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
|
|
146
|
+
`agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
|
|
139
147
|
- `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
|
|
140
148
|
`agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
|
|
141
149
|
`/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
|
|
@@ -148,10 +156,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
148
156
|
- Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
|
|
149
157
|
worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
|
|
150
158
|
由 worktree 的 gate 判定。
|
|
159
|
+
- 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
|
|
160
|
+
(Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
|
|
161
|
+
<name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask
|
|
162
|
+
後 parent,各自使用其 PASS review 記錄的 base(沒有記錄時使用 worktree 的 base)。
|
|
163
|
+
任一 task 無法完成時不會合併,錯誤訊息會指出是哪個 task。建立 subtask 時 session
|
|
164
|
+
維持附著在 task 樹最上層,且每個 task 各自保留 review 紀錄,所以在同一份原始碼上
|
|
165
|
+
review 的 parent 與 subtask 不會互相覆蓋。
|
|
151
166
|
- `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
|
|
152
167
|
finish。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
|
|
153
168
|
重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
|
|
154
|
-
回報。
|
|
169
|
+
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下。
|
|
155
170
|
- `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
|
|
156
171
|
worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
|
|
157
172
|
閒置超過七天的 worktree。
|