@kylecheng3146/agent-ops 0.2.8 → 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/args.js +48 -2
- package/dist/packages/cli/src/bin.js +55 -35
- 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 +88 -0
- 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 +34 -7
- package/dist/packages/cli/src/parallel-deps.js +89 -0
- package/dist/runtime/src/adapters/agy/output.js +2 -1
- package/dist/runtime/src/adapters/claude/config.js +6 -2
- 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 +303 -0
- package/dist/runtime/src/parallel/guard.js +76 -0
- package/dist/runtime/src/parallel/manage.js +147 -0
- package/dist/runtime/src/parallel/service.js +281 -0
- package/dist/runtime/src/review/attestation.js +125 -50
- package/dist/runtime/src/review/execute.js +32 -5
- package/dist/runtime/src/schema/validate.js +50 -1
- 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 +63 -0
- package/docs/en/spec/delegation.md +10 -0
- package/docs/zh-TW/guides/configuration.md +55 -0
- package/docs/zh-TW/spec/delegation.md +11 -1
- package/package.json +1 -1
- package/schemas/config.schema.json +35 -0
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,8 @@ export function agyHookOutput(event, result) {
|
|
|
12
12
|
? `agent-ops: ${result.code}`
|
|
13
13
|
: `agent-ops: ${result.code}: ${result.remedy}`;
|
|
14
14
|
const value = event === "PreToolUse"
|
|
15
|
-
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
15
|
+
? result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
|
|
16
|
+
result.code === "WORKTREE_REMOVE_CONFIRMATION"
|
|
16
17
|
? { decision: "force_ask", reason }
|
|
17
18
|
: result.action === "block"
|
|
18
19
|
? { decision: "deny", reason }
|
|
@@ -11,6 +11,7 @@ const CLAUDE_LOOP_EVENTS = [
|
|
|
11
11
|
"SubagentStop"
|
|
12
12
|
];
|
|
13
13
|
const CLAUDE_HOOK_MARKER = "--managed-by=agent-ops";
|
|
14
|
+
export const CLAUDE_PRE_TOOL_MATCHER = "Bash|Edit|MultiEdit|NotebookEdit|Write";
|
|
14
15
|
const CLAUDE_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.sh";
|
|
15
16
|
const CLAUDE_WINDOWS_LOOP_LAUNCHER = "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-ops-loop.ps1";
|
|
16
17
|
export function claudeSettingsTarget(scope) {
|
|
@@ -41,12 +42,15 @@ function commandHook(event, runtimePath, completionGate = false) {
|
|
|
41
42
|
CLAUDE_HOOK_MARKER,
|
|
42
43
|
...(completionGate ? ["--completion-gate"] : [])
|
|
43
44
|
],
|
|
44
|
-
|
|
45
|
+
// PreToolUse may create the session's worktree, setup steps included.
|
|
46
|
+
timeout: event === "PreToolUse" ? 600 : 30
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
function matcherGroup(event, runtimePath, completionGate = false) {
|
|
48
50
|
return {
|
|
49
|
-
|
|
51
|
+
// File tools as well as Bash: in worktree auto mode the guard must see a
|
|
52
|
+
// direct edit of the shared checkout, which never passes through a shell.
|
|
53
|
+
...(event === "PreToolUse" ? { matcher: CLAUDE_PRE_TOOL_MATCHER } : {}),
|
|
50
54
|
hooks: [commandHook(event, runtimePath, completionGate)]
|
|
51
55
|
};
|
|
52
56
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
3
|
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
4
|
+
/** Claude's tools that write a file without a shell. */
|
|
5
|
+
export const CLAUDE_FILE_TOOLS = new Set([
|
|
6
|
+
"Edit",
|
|
7
|
+
"MultiEdit",
|
|
8
|
+
"NotebookEdit",
|
|
9
|
+
"Write"
|
|
10
|
+
]);
|
|
3
11
|
function isRecord(value) {
|
|
4
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
13
|
}
|
|
@@ -39,6 +47,22 @@ export function normalizeClaudeHookInput(input) {
|
|
|
39
47
|
fullyIdle: input.stop_hook_active !== true
|
|
40
48
|
};
|
|
41
49
|
}
|
|
50
|
+
if (input.hook_event_name === "PreToolUse" &&
|
|
51
|
+
typeof input.tool_name === "string" &&
|
|
52
|
+
CLAUDE_FILE_TOOLS.has(input.tool_name) &&
|
|
53
|
+
isRecord(input.tool_input) &&
|
|
54
|
+
typeof projectRoot === "string" &&
|
|
55
|
+
projectRoot.length > 0) {
|
|
56
|
+
const target = input.tool_input.file_path ?? input.tool_input.notebook_path;
|
|
57
|
+
if (typeof target === "string" && target.length > 0 && !target.includes("\0")) {
|
|
58
|
+
return {
|
|
59
|
+
event: "file-write",
|
|
60
|
+
projectRoot,
|
|
61
|
+
paths: [resolve(projectRoot, target)],
|
|
62
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
if (input.hook_event_name === "PreToolUse" &&
|
|
43
67
|
input.tool_name === "Bash" &&
|
|
44
68
|
isRecord(input.tool_input) &&
|
|
@@ -19,7 +19,8 @@ export function claudeHookOutput(event, result) {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
if (event === "PreToolUse" &&
|
|
22
|
-
result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION"
|
|
22
|
+
(result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION" ||
|
|
23
|
+
result.code === "WORKTREE_REMOVE_CONFIRMATION")) {
|
|
23
24
|
// Asked, never allowed: a one-time Stop permit is the user's to grant, and
|
|
24
25
|
// an agent that could answer this for itself would hold the key to its own
|
|
25
26
|
// gate.
|
|
@@ -110,9 +110,15 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
110
110
|
const reviewRoles = new Map();
|
|
111
111
|
let schemaVersion;
|
|
112
112
|
let features;
|
|
113
|
+
let worktree;
|
|
113
114
|
for (const layer of layers) {
|
|
114
115
|
schemaVersion = effective(layer.config.schemaVersion, layer);
|
|
115
116
|
features = effective(layer.config.features, layer);
|
|
117
|
+
// Whole-block override: a workflow choice, not a guardrail, so a project
|
|
118
|
+
// may turn a user-level `auto` off.
|
|
119
|
+
if (layer.config.worktree !== undefined) {
|
|
120
|
+
worktree = effective(layer.config.worktree, layer);
|
|
121
|
+
}
|
|
116
122
|
for (const profile of layer.config.profiles) {
|
|
117
123
|
profiles.set(profile, effective(profile, layer));
|
|
118
124
|
}
|
|
@@ -158,7 +164,8 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
158
164
|
verificationCommands: [...commands.values()],
|
|
159
165
|
pathMappings: [...mappings.values()],
|
|
160
166
|
securityExceptions: [...exceptions.values()],
|
|
161
|
-
reviewRoles: [...reviewRoles.values()]
|
|
167
|
+
reviewRoles: [...reviewRoles.values()],
|
|
168
|
+
...(worktree === undefined ? {} : { worktree })
|
|
162
169
|
};
|
|
163
170
|
const config = {
|
|
164
171
|
schemaVersion: schemaVersion.value,
|
|
@@ -175,7 +182,8 @@ export function mergeConfigLayers(inputLayers) {
|
|
|
175
182
|
? {}
|
|
176
183
|
: {
|
|
177
184
|
reviewRoles: provenance.reviewRoles.map(({ value }) => value)
|
|
178
|
-
})
|
|
185
|
+
}),
|
|
186
|
+
...(worktree === undefined ? {} : { worktree: worktree.value })
|
|
179
187
|
};
|
|
180
188
|
const validation = validateConfig(config);
|
|
181
189
|
if (!validation.ok) {
|
|
@@ -407,7 +407,7 @@ function parseSessionMarker(source) {
|
|
|
407
407
|
* ponytail: last writer wins. Two concurrent sessions in one checkout already
|
|
408
408
|
* violate the one-writer rule; pass `--session` explicitly if you need that.
|
|
409
409
|
*/
|
|
410
|
-
async function writeSessionMarker(options) {
|
|
410
|
+
export async function writeSessionMarker(options) {
|
|
411
411
|
const path = loopPath(options.root, options.harness, "loop-state.md");
|
|
412
412
|
await withPrivateFileLock(path, options.root, async () => {
|
|
413
413
|
const source = await readPrivateFile(path, options.root);
|
|
@@ -491,6 +491,8 @@ async function restoreState(options) {
|
|
|
491
491
|
function clamp(value, limit) {
|
|
492
492
|
return value.length <= limit ? value : `${value.slice(0, limit - 14)}\n[truncated]`;
|
|
493
493
|
}
|
|
494
|
+
/** Printed only when it is a plain token: it lands verbatim in agent context. */
|
|
495
|
+
const PRINTABLE_SESSION = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
494
496
|
function sessionContext(options) {
|
|
495
497
|
// Task state is assembled first and clamped on its own budget, so a long
|
|
496
498
|
// goal can no longer crowd out the one thing recovery exists to carry.
|
|
@@ -516,8 +518,15 @@ function sessionContext(options) {
|
|
|
516
518
|
return [];
|
|
517
519
|
})();
|
|
518
520
|
const telemetry = `Telemetry: ${options.telemetryEntries} recent redacted event(s).`;
|
|
521
|
+
// The id every `--session` flag wants. Hooks hear it; the agent otherwise
|
|
522
|
+
// never does, and a shared checkout's recorded marker belongs to whichever
|
|
523
|
+
// session started last.
|
|
524
|
+
const session = options.sessionId !== undefined && PRINTABLE_SESSION.test(options.sessionId)
|
|
525
|
+
? [`Session: ${options.sessionId}`]
|
|
526
|
+
: [];
|
|
519
527
|
const fixed = [
|
|
520
528
|
"agent-ops project loop is active.",
|
|
529
|
+
...session,
|
|
521
530
|
"",
|
|
522
531
|
...restored,
|
|
523
532
|
telemetry
|
|
@@ -648,6 +657,7 @@ export async function runProjectLoop(options) {
|
|
|
648
657
|
telemetryCount(root, options.harness)
|
|
649
658
|
]);
|
|
650
659
|
return sessionOutput(options.harness, sessionContext({
|
|
660
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
651
661
|
goal: safeGoalContext(goal),
|
|
652
662
|
telemetryEntries,
|
|
653
663
|
mode: restoreMode(options.input),
|
|
@@ -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 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
|
+
}
|
|
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 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.", "");
|
|
@@ -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) {
|