@kylecheng3146/agent-ops 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +19 -1
- package/dist/packages/cli/src/bin.js +1 -0
- package/dist/packages/cli/src/cli.js +3 -0
- package/dist/packages/cli/src/commands/init.js +27 -3
- package/dist/packages/cli/src/commands/update.js +47 -3
- package/dist/packages/cli/src/wizard.js +21 -2
- package/dist/runtime/src/adapters/opencode/config.js +1 -1
- package/dist/runtime/src/adapters/opencode/input.js +67 -1
- package/dist/runtime/src/install/parallel-setup.js +63 -0
- package/dist/runtime/src/install/plan.js +8 -7
- package/dist/runtime/src/install/update.js +4 -1
- package/dist/runtime/src/parallel/guard.js +2 -2
- package/dist/runtime/src/review/probe.js +26 -2
- package/docs/en/guides/configuration.md +4 -3
- package/docs/zh-TW/guides/configuration.md +4 -3
- package/package.json +1 -1
|
@@ -86,6 +86,7 @@ export function parseArgs(argv) {
|
|
|
86
86
|
let versionSeen = false;
|
|
87
87
|
let worktreeName;
|
|
88
88
|
let force = false;
|
|
89
|
+
let worktree;
|
|
89
90
|
for (let index = 0; index < argv.length; index += 1) {
|
|
90
91
|
const token = argv[index];
|
|
91
92
|
if (token === undefined) {
|
|
@@ -229,6 +230,18 @@ export function parseArgs(argv) {
|
|
|
229
230
|
}
|
|
230
231
|
completionGate = true;
|
|
231
232
|
break;
|
|
233
|
+
case "--worktree": {
|
|
234
|
+
if (worktree !== undefined) {
|
|
235
|
+
duplicate(token);
|
|
236
|
+
}
|
|
237
|
+
const value = readOptionValue(argv, index, token);
|
|
238
|
+
if (value !== "auto" && value !== "off") {
|
|
239
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", `Worktree mode must be auto or off: ${value}`, token);
|
|
240
|
+
}
|
|
241
|
+
worktree = value;
|
|
242
|
+
index += 1;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
232
245
|
case "--dry-run":
|
|
233
246
|
if (dryRun) {
|
|
234
247
|
duplicate(token);
|
|
@@ -367,6 +380,7 @@ export function parseArgs(argv) {
|
|
|
367
380
|
dryRun ||
|
|
368
381
|
yes ||
|
|
369
382
|
rerun ||
|
|
383
|
+
worktree !== undefined ||
|
|
370
384
|
force) {
|
|
371
385
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
|
|
372
386
|
}
|
|
@@ -432,6 +446,9 @@ export function parseArgs(argv) {
|
|
|
432
446
|
if (completionGate !== undefined && command !== "init") {
|
|
433
447
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--completion-gate may be used only with init.");
|
|
434
448
|
}
|
|
449
|
+
if (worktree !== undefined && command !== "init" && command !== "update") {
|
|
450
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--worktree may be used only with init or update.");
|
|
451
|
+
}
|
|
435
452
|
if (command === "task") {
|
|
436
453
|
if (harness !== undefined ||
|
|
437
454
|
profiles.length > 0 ||
|
|
@@ -551,6 +568,7 @@ export function parseArgs(argv) {
|
|
|
551
568
|
rerun,
|
|
552
569
|
dryRun,
|
|
553
570
|
json,
|
|
554
|
-
yes
|
|
571
|
+
yes,
|
|
572
|
+
...(worktree === undefined ? {} : { worktree })
|
|
555
573
|
};
|
|
556
574
|
}
|
|
@@ -348,6 +348,7 @@ else {
|
|
|
348
348
|
trustStore: store,
|
|
349
349
|
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
350
350
|
confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
|
|
351
|
+
promptWorktree: async (message) => await selectYesNo(message, { input: process.stdin, output: process.stdout }, true),
|
|
351
352
|
...(updateArgs.targetVersion === undefined
|
|
352
353
|
? {}
|
|
353
354
|
: { targetVersion: updateArgs.targetVersion })
|
|
@@ -39,6 +39,7 @@ Options:
|
|
|
39
39
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
40
40
|
--review-target <codex|agy|claude> Repeatable init option; review pair order
|
|
41
41
|
--completion-gate Init only: enable the project-loop completion gate
|
|
42
|
+
--worktree <auto|off> Init/update: configure session worktree isolation
|
|
42
43
|
--check-auth Doctor only: probe selected review targets'
|
|
43
44
|
authentication with one real call
|
|
44
45
|
--check-auth-target <target> Repeatable doctor filter with --check-auth
|
|
@@ -75,6 +76,7 @@ Options:
|
|
|
75
76
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
76
77
|
--review-target <codex|agy|claude> Repeatable, in fallback-chain order
|
|
77
78
|
--completion-gate Enable the project-loop completion gate
|
|
79
|
+
--worktree <auto|off> Configure session worktree isolation
|
|
78
80
|
--dry-run Print the plan without writing
|
|
79
81
|
--json
|
|
80
82
|
--yes
|
|
@@ -114,6 +116,7 @@ Options:
|
|
|
114
116
|
--scope <project|user>
|
|
115
117
|
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
116
118
|
--target-version <version> Offline-capable update target
|
|
119
|
+
--worktree <auto|off> Configure session worktree isolation
|
|
117
120
|
--dry-run
|
|
118
121
|
--json
|
|
119
122
|
--yes
|
|
@@ -5,6 +5,7 @@ import { okEnvelope } from "../output.js";
|
|
|
5
5
|
import { formatOperationPlan } from "../plan-output.js";
|
|
6
6
|
import { toPublicInstallPlan } from "../public-plan.js";
|
|
7
7
|
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
8
|
+
import { checkWorktreeEnvWarning, detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
|
|
8
9
|
export function formatInstallPlan(plan, trust, warnings = []) {
|
|
9
10
|
const hooks = plan.manifest.hooks ?? [];
|
|
10
11
|
const detectedVerification = plan.detectedVerification;
|
|
@@ -14,6 +15,13 @@ export function formatInstallPlan(plan, trust, warnings = []) {
|
|
|
14
15
|
`Scope: ${plan.scope}`,
|
|
15
16
|
`Harness: ${plan.harness}`,
|
|
16
17
|
`Profiles: ${plan.profiles.join(", ")}`,
|
|
18
|
+
...(plan.config.worktree === undefined
|
|
19
|
+
? []
|
|
20
|
+
: [
|
|
21
|
+
`Worktree: mode=${plan.config.worktree.mode}${plan.config.worktree.setup && plan.config.worktree.setup.length > 0
|
|
22
|
+
? `, setup=${plan.config.worktree.setup.map((s) => `${s.command} ${s.args.join(" ")}`.trim()).join("; ")}`
|
|
23
|
+
: ""}`
|
|
24
|
+
]),
|
|
17
25
|
...(plan.scope === "user" && plan.harness.includes("agy")
|
|
18
26
|
? ["Notice: .gemini/GEMINI.md is a shared Gemini rule surface."]
|
|
19
27
|
: []),
|
|
@@ -85,6 +93,11 @@ export async function runInitCommand(options) {
|
|
|
85
93
|
args.profiles.length === 0) {
|
|
86
94
|
throw new AgentOpsError("INIT_CHOICES_REQUIRED", "Init requires complete scope, harness, and profile choices.");
|
|
87
95
|
}
|
|
96
|
+
const worktreeConfig = args.worktree === "auto"
|
|
97
|
+
? { mode: "auto", setup: await detectWorktreeSetup(options.root) }
|
|
98
|
+
: args.worktree === "off"
|
|
99
|
+
? { mode: "off" }
|
|
100
|
+
: undefined;
|
|
88
101
|
const plan = await createInstallPlan({
|
|
89
102
|
root: options.root,
|
|
90
103
|
scope: args.scope,
|
|
@@ -105,7 +118,10 @@ export async function runInitCommand(options) {
|
|
|
105
118
|
: { reviewTargets: args.reviewTargets }),
|
|
106
119
|
...(args.completionGate === undefined
|
|
107
120
|
? {}
|
|
108
|
-
: { completionGateEnabled: args.completionGate })
|
|
121
|
+
: { completionGateEnabled: args.completionGate }),
|
|
122
|
+
...(worktreeConfig === undefined
|
|
123
|
+
? {}
|
|
124
|
+
: { worktree: worktreeConfig })
|
|
109
125
|
});
|
|
110
126
|
const trust = await trustChange(options, plan);
|
|
111
127
|
// An installation with no verifier looks finished and is not: every task
|
|
@@ -121,7 +137,14 @@ export async function runInitCommand(options) {
|
|
|
121
137
|
: ` Detection stopped because — ${plan.verificationBlockers.join("; ")}`)
|
|
122
138
|
]
|
|
123
139
|
: [];
|
|
124
|
-
const
|
|
140
|
+
const worktreeEnvWarning = plan.config.worktree?.mode === "auto"
|
|
141
|
+
? await checkWorktreeEnvWarning(options.root)
|
|
142
|
+
: undefined;
|
|
143
|
+
const worktreeWarnings = worktreeEnvWarning === undefined ? [] : [worktreeEnvWarning];
|
|
144
|
+
const warnings = [
|
|
145
|
+
...verificationWarnings,
|
|
146
|
+
...worktreeWarnings,
|
|
147
|
+
...(plan.harness.includes("agy") && options.agyWarning !== undefined
|
|
125
148
|
? (() => {
|
|
126
149
|
try {
|
|
127
150
|
const warning = options.agyWarning();
|
|
@@ -131,7 +154,8 @@ export async function runInitCommand(options) {
|
|
|
131
154
|
return ["agy could not be probed; run `agent-ops doctor` to verify it."];
|
|
132
155
|
}
|
|
133
156
|
})()
|
|
134
|
-
: [])
|
|
157
|
+
: [])
|
|
158
|
+
];
|
|
135
159
|
if (args.dryRun) {
|
|
136
160
|
return okEnvelope("INIT_PLAN_READY", {
|
|
137
161
|
applied: false,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
|
|
1
4
|
import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
|
|
2
5
|
import { okEnvelope } from "../output.js";
|
|
3
6
|
import { formatOperationPlan } from "../plan-output.js";
|
|
@@ -10,6 +13,13 @@ export function formatUpdatePlan(plan, trust) {
|
|
|
10
13
|
metadata: [
|
|
11
14
|
`Target version: ${plan.targetVersion}`,
|
|
12
15
|
`Harness: ${plan.installation.harness.join(", ")}`,
|
|
16
|
+
...(plan.installation.config.worktree === undefined
|
|
17
|
+
? []
|
|
18
|
+
: [
|
|
19
|
+
`Worktree: mode=${plan.installation.config.worktree.mode}${plan.installation.config.worktree.setup && plan.installation.config.worktree.setup.length > 0
|
|
20
|
+
? `, setup=${plan.installation.config.worktree.setup.map((s) => `${s.command} ${s.args.join(" ")}`.trim()).join("; ")}`
|
|
21
|
+
: ""}`
|
|
22
|
+
]),
|
|
13
23
|
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
14
24
|
`Schema migrations: ${plan.migrationSteps.length === 0
|
|
15
25
|
? "none"
|
|
@@ -47,7 +57,40 @@ async function trustChange(options, plan) {
|
|
|
47
57
|
? { action: "skipped", reason: "no-verification-commands" }
|
|
48
58
|
: await planTrustGrant(binding, options.trustStore);
|
|
49
59
|
}
|
|
60
|
+
async function existingConfigHasWorktree(root) {
|
|
61
|
+
try {
|
|
62
|
+
const content = await readFile(join(root, ".agent-ops", "config.json"), "utf8");
|
|
63
|
+
const parsed = JSON.parse(content);
|
|
64
|
+
return parsed.worktree !== undefined;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
50
70
|
export async function runUpdateCommand(options) {
|
|
71
|
+
let worktree;
|
|
72
|
+
if (options.args.worktree === "auto") {
|
|
73
|
+
worktree = {
|
|
74
|
+
mode: "auto",
|
|
75
|
+
setup: await detectWorktreeSetup(options.root)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
else if (options.args.worktree === "off") {
|
|
79
|
+
worktree = { mode: "off" };
|
|
80
|
+
}
|
|
81
|
+
else if (options.args.worktree === undefined &&
|
|
82
|
+
options.promptWorktree !== undefined &&
|
|
83
|
+
options.isTTY &&
|
|
84
|
+
!options.args.yes &&
|
|
85
|
+
!(await existingConfigHasWorktree(options.root))) {
|
|
86
|
+
const shouldEnable = await options.promptWorktree("Enable Git worktree isolation for parallel agent sessions?");
|
|
87
|
+
if (shouldEnable) {
|
|
88
|
+
worktree = {
|
|
89
|
+
mode: "auto",
|
|
90
|
+
setup: await detectWorktreeSetup(options.root)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
51
94
|
const plan = await createUpdatePlan({
|
|
52
95
|
root: options.root,
|
|
53
96
|
adapters: options.adapters,
|
|
@@ -57,9 +100,9 @@ export async function runUpdateCommand(options) {
|
|
|
57
100
|
...(options.registry === undefined
|
|
58
101
|
? {}
|
|
59
102
|
: { registry: options.registry }),
|
|
60
|
-
...(options.targetVersion === undefined
|
|
103
|
+
...((options.targetVersion ?? options.args.targetVersion) === undefined
|
|
61
104
|
? {}
|
|
62
|
-
: { targetVersion: options.targetVersion }),
|
|
105
|
+
: { targetVersion: options.targetVersion ?? options.args.targetVersion }),
|
|
63
106
|
...(options.toolkitVersion === undefined
|
|
64
107
|
? {}
|
|
65
108
|
: { toolkitVersion: options.toolkitVersion }),
|
|
@@ -68,7 +111,8 @@ export async function runUpdateCommand(options) {
|
|
|
68
111
|
: { hookRuntimePath: options.hookRuntimePath }),
|
|
69
112
|
...((options.hookTargets ?? options.args.hookTargets) === undefined
|
|
70
113
|
? {}
|
|
71
|
-
: { hookTargets: options.hookTargets ?? options.args.hookTargets })
|
|
114
|
+
: { hookTargets: options.hookTargets ?? options.args.hookTargets }),
|
|
115
|
+
...(worktree === undefined ? {} : { worktree })
|
|
72
116
|
});
|
|
73
117
|
const trust = await trustChange(options, plan);
|
|
74
118
|
if (options.args.dryRun) {
|
|
@@ -193,6 +193,20 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
193
193
|
const reviewTargets = args.reviewTargets ?? (enabled && eligibleReviewChoices.length > 0
|
|
194
194
|
? await selectOptions("Review targets (multi-select: tried in listed order)", eligibleReviewChoices, selectorIo, REVIEW_TARGET_ORDER.filter((target) => harness.includes(target)), { startEmpty: true })
|
|
195
195
|
: []);
|
|
196
|
+
const worktree = args.worktree ?? (scope === "project"
|
|
197
|
+
? (await selectOption("Enable Git worktree isolation for parallel agent sessions?", [
|
|
198
|
+
{
|
|
199
|
+
label: "yes",
|
|
200
|
+
value: "auto",
|
|
201
|
+
description: "Recommended. Isolates each agent session in a dedicated worktree."
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
label: "no",
|
|
205
|
+
value: "off",
|
|
206
|
+
description: "Keep a single shared checkout."
|
|
207
|
+
}
|
|
208
|
+
], selectorIo))
|
|
209
|
+
: undefined);
|
|
196
210
|
await probeReviewTargets(reviewTargets, setup);
|
|
197
211
|
return {
|
|
198
212
|
...args,
|
|
@@ -200,7 +214,8 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
200
214
|
harness,
|
|
201
215
|
profiles,
|
|
202
216
|
...(completionGate ? { completionGate: true } : {}),
|
|
203
|
-
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
217
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets }),
|
|
218
|
+
...(worktree !== undefined ? { worktree } : {})
|
|
204
219
|
};
|
|
205
220
|
}
|
|
206
221
|
const session = await createPromptSession(io);
|
|
@@ -220,13 +235,17 @@ export async function completeInitChoices(args, io, setup = {}) {
|
|
|
220
235
|
? selectReviewTargets(await session.question(`Review targets (${defaultReviewTargets.join(",") || "none"}): `), defaultReviewTargets)
|
|
221
236
|
: []);
|
|
222
237
|
await probeReviewTargets(reviewTargets, setup);
|
|
238
|
+
const worktree = args.worktree ?? (scope === "project"
|
|
239
|
+
? (!/^(n|no)$/i.test((await session.question("Enable Git worktree isolation for parallel agent sessions? [Y/n]: ")).trim()) ? "auto" : "off")
|
|
240
|
+
: undefined);
|
|
223
241
|
return {
|
|
224
242
|
...args,
|
|
225
243
|
scope,
|
|
226
244
|
harness,
|
|
227
245
|
profiles,
|
|
228
246
|
...(completionGate ? { completionGate: true } : {}),
|
|
229
|
-
...(reviewTargets.length === 0 ? {} : { reviewTargets })
|
|
247
|
+
...(reviewTargets.length === 0 ? {} : { reviewTargets }),
|
|
248
|
+
...(worktree !== undefined ? { worktree } : {})
|
|
230
249
|
};
|
|
231
250
|
}
|
|
232
251
|
finally {
|
|
@@ -131,7 +131,7 @@ export function buildOpencodePlugin(capabilities, runtimePath) {
|
|
|
131
131
|
lines.push(" // opencode initializes plugins once per app, not once per session.", " await runManagedHook($, \"SessionStart\", {", " event: \"SessionStart\",", " projectRoot", " });");
|
|
132
132
|
}
|
|
133
133
|
if (events.includes("PreToolUse")) {
|
|
134
|
-
lines.push(' hooks["tool.execute.before"] = async (input, output) => {', '
|
|
134
|
+
lines.push(' const WRITE_TOOLS = ["bash", "edit", "write", "patch", "apply_patch"];', ' hooks["tool.execute.before"] = async (input, output) => {', ' const tool = typeof input?.tool === "string" ? input.tool.toLowerCase() : "";', " if (!WRITE_TOOLS.includes(tool)) return;", " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
|
|
135
135
|
}
|
|
136
136
|
if (events.includes("Stop")) {
|
|
137
137
|
lines.push(" hooks.event = async ({ event }) => {", ' if (event?.type !== "session.idle") return;', " await runManagedHook($, \"Stop\", { event: \"Stop\", projectRoot });", " };");
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
3
|
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
4
|
+
/**
|
|
5
|
+
* opencode tools that write a file without a shell (lowercase tool names).
|
|
6
|
+
* `apply_patch` is the GPT-model replacement for `edit`/`write`; older
|
|
7
|
+
* servers still report `patch`.
|
|
8
|
+
*/
|
|
9
|
+
export const OPENCODE_FILE_TOOLS = new Set([
|
|
10
|
+
"edit",
|
|
11
|
+
"write",
|
|
12
|
+
"patch",
|
|
13
|
+
"apply_patch"
|
|
14
|
+
]);
|
|
3
15
|
function isRecord(value) {
|
|
4
16
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
17
|
}
|
|
18
|
+
function sessionIdOf(value) {
|
|
19
|
+
const sessionId = value.sessionID;
|
|
20
|
+
return typeof sessionId === "string" && sessionId.length > 0
|
|
21
|
+
? sessionId
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Paths embedded in `apply_patch` marker lines, relative to the project root:
|
|
26
|
+
* `*** Add File:`, `*** Update File:`, `*** Move to:`, `*** Delete File:`.
|
|
27
|
+
*/
|
|
28
|
+
function patchTextPaths(patchText) {
|
|
29
|
+
const paths = [];
|
|
30
|
+
const marker = /^\*\*\*\s+(?:Add File|Update File|Move to|Delete File):\s*(.+?)\s*$/gmu;
|
|
31
|
+
for (const match of patchText.matchAll(marker)) {
|
|
32
|
+
const target = match[1]?.trim();
|
|
33
|
+
if (target !== undefined && target.length > 0 && !target.includes("\0")) {
|
|
34
|
+
paths.push(target);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return paths;
|
|
38
|
+
}
|
|
39
|
+
function fileWritePaths(args, projectRoot) {
|
|
40
|
+
const direct = args.filePath ?? args.file_path ?? args.path;
|
|
41
|
+
if (typeof direct === "string" && direct.length > 0 && !direct.includes("\0")) {
|
|
42
|
+
return [resolve(projectRoot, direct)];
|
|
43
|
+
}
|
|
44
|
+
if (typeof args.patchText === "string") {
|
|
45
|
+
const marked = patchTextPaths(args.patchText);
|
|
46
|
+
if (marked.length > 0) {
|
|
47
|
+
return marked.map((target) => resolve(projectRoot, target));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
6
52
|
/**
|
|
7
53
|
* The managed plugin forwards opencode's own hook payload untouched under
|
|
8
54
|
* `input`, so every field this adapter reads is documented plugin input rather
|
|
@@ -21,7 +67,27 @@ export function normalizeOpencodeHookInput(input) {
|
|
|
21
67
|
}
|
|
22
68
|
if (input.event === "PreToolUse" &&
|
|
23
69
|
isRecord(input.input) &&
|
|
24
|
-
input.input.tool === "
|
|
70
|
+
typeof input.input.tool === "string" &&
|
|
71
|
+
OPENCODE_FILE_TOOLS.has(input.input.tool.toLowerCase()) &&
|
|
72
|
+
isRecord(input.output) &&
|
|
73
|
+
isRecord(input.output.args) &&
|
|
74
|
+
typeof projectRoot === "string" &&
|
|
75
|
+
projectRoot.length > 0) {
|
|
76
|
+
const paths = fileWritePaths(input.output.args, projectRoot);
|
|
77
|
+
if (paths !== null) {
|
|
78
|
+
const sessionId = sessionIdOf(input.input);
|
|
79
|
+
return {
|
|
80
|
+
event: "file-write",
|
|
81
|
+
projectRoot,
|
|
82
|
+
paths,
|
|
83
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (input.event === "PreToolUse" &&
|
|
88
|
+
isRecord(input.input) &&
|
|
89
|
+
typeof input.input.tool === "string" &&
|
|
90
|
+
input.input.tool.toLowerCase() === "bash" &&
|
|
25
91
|
isRecord(input.output) &&
|
|
26
92
|
isRecord(input.output.args) &&
|
|
27
93
|
typeof input.output.args.command === "string" &&
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const LOCKFILES = [
|
|
4
|
+
{ path: "bun.lock", packageManager: "bun" },
|
|
5
|
+
{ path: "bun.lockb", packageManager: "bun" },
|
|
6
|
+
{ path: "npm-shrinkwrap.json", packageManager: "npm" },
|
|
7
|
+
{ path: "package-lock.json", packageManager: "npm" },
|
|
8
|
+
{ path: "pnpm-lock.yaml", packageManager: "pnpm" },
|
|
9
|
+
{ path: "yarn.lock", packageManager: "yarn" }
|
|
10
|
+
];
|
|
11
|
+
const SETUP_COMMANDS = {
|
|
12
|
+
npm: ["ci"],
|
|
13
|
+
pnpm: ["install", "--frozen-lockfile"],
|
|
14
|
+
yarn: ["install", "--immutable"],
|
|
15
|
+
bun: ["install", "--frozen-lockfile"]
|
|
16
|
+
};
|
|
17
|
+
function hasErrorCode(error, code) {
|
|
18
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
19
|
+
}
|
|
20
|
+
async function regularFileExists(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const status = await lstat(filePath);
|
|
23
|
+
return status.isFile() && !status.isSymbolicLink();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detects project lockfiles and proposes safe, deterministic dependency installation
|
|
34
|
+
* commands for freshly cloned worktrees. Returns an empty array if detection is
|
|
35
|
+
* ambiguous or unsupported.
|
|
36
|
+
*/
|
|
37
|
+
export async function detectWorktreeSetup(root) {
|
|
38
|
+
const detected = await Promise.all(LOCKFILES.map(async (def) => (await regularFileExists(join(root, def.path))) ? def.packageManager : null));
|
|
39
|
+
const managers = [...new Set(detected.filter((m) => m !== null))];
|
|
40
|
+
if (managers.length !== 1) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const manager = managers[0];
|
|
44
|
+
const args = SETUP_COMMANDS[manager];
|
|
45
|
+
return [{ command: manager, args: [...args] }];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns a warning message if local environment files (.env) are detected
|
|
49
|
+
* without a .worktreeinclude file to carry them into new worktrees.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkWorktreeEnvWarning(root) {
|
|
52
|
+
const hasWorktreeInclude = await regularFileExists(join(root, ".worktreeinclude"));
|
|
53
|
+
if (hasWorktreeInclude) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const envCandidates = [".env", ".env.local"];
|
|
57
|
+
for (const candidate of envCandidates) {
|
|
58
|
+
if (await regularFileExists(join(root, candidate))) {
|
|
59
|
+
return "Local environment files (.env) detected without .worktreeinclude; consider creating .worktreeinclude to copy them to new worktrees.";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
@@ -78,7 +78,7 @@ async function detectVerificationCommands(root) {
|
|
|
78
78
|
blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
-
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
|
|
81
|
+
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
|
|
82
82
|
// Absent reviewRoles means external review is disabled; an empty selection
|
|
83
83
|
// must therefore omit the field rather than write an empty array.
|
|
84
84
|
const reviewRoles = reviewTargets.length > 0
|
|
@@ -88,6 +88,9 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
88
88
|
existing.verification.commands.length > 0
|
|
89
89
|
? existing.verification
|
|
90
90
|
: { commands: [...detectedCommands] };
|
|
91
|
+
const effectiveWorktree = worktree !== undefined
|
|
92
|
+
? (worktree === null ? undefined : structuredClone(worktree))
|
|
93
|
+
: (existing?.worktree === undefined ? undefined : structuredClone(existing.worktree));
|
|
91
94
|
return {
|
|
92
95
|
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
93
96
|
profiles: [...profiles],
|
|
@@ -103,12 +106,10 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
|
|
|
103
106
|
pathMappings: existing?.pathMappings ?? [],
|
|
104
107
|
securityExceptions: existing?.securityExceptions ?? [],
|
|
105
108
|
...(reviewRoles === undefined ? {} : { reviewRoles: [...reviewRoles] }),
|
|
106
|
-
...(
|
|
107
|
-
? {}
|
|
108
|
-
: { worktree: structuredClone(existing.worktree) })
|
|
109
|
+
...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
|
|
109
110
|
};
|
|
110
111
|
}
|
|
111
|
-
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false) {
|
|
112
|
+
async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
|
|
112
113
|
const current = await readCurrentFile(root, CONFIG_PATH);
|
|
113
114
|
const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
|
|
114
115
|
if (current !== null && owned === undefined) {
|
|
@@ -144,7 +145,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
144
145
|
existingConfig.verification.commands.length === 0
|
|
145
146
|
? await detectVerificationCommands(root)
|
|
146
147
|
: { commands: [], blockers: [] };
|
|
147
|
-
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
|
|
148
|
+
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
|
|
148
149
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
149
150
|
return {
|
|
150
151
|
operation: {
|
|
@@ -493,7 +494,7 @@ export async function createInstallPlan(options) {
|
|
|
493
494
|
: [];
|
|
494
495
|
const operations = [];
|
|
495
496
|
const artifacts = [];
|
|
496
|
-
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled);
|
|
497
|
+
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
|
|
497
498
|
operations.push(config.operation);
|
|
498
499
|
artifacts.push(config.record);
|
|
499
500
|
for (const artifact of contribution.artifacts) {
|
|
@@ -123,7 +123,10 @@ export async function createUpdatePlan(options) {
|
|
|
123
123
|
existingConfig: {
|
|
124
124
|
value: configPreview.migrated,
|
|
125
125
|
sourceHash: configPreview.sourceHash
|
|
126
|
-
}
|
|
126
|
+
},
|
|
127
|
+
...(options.worktree === undefined
|
|
128
|
+
? {}
|
|
129
|
+
: { worktree: options.worktree })
|
|
127
130
|
});
|
|
128
131
|
return {
|
|
129
132
|
targetVersion,
|
|
@@ -56,7 +56,7 @@ ensureWorktree) {
|
|
|
56
56
|
action: "block",
|
|
57
57
|
status: "FAIL",
|
|
58
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))}.`
|
|
59
|
+
remedy: `worktree.mode is auto, so this session now has its own worktree at ${worktree}. Enter it (Claude Code: EnterWorktree with path ${worktree}; otherwise use the worktree path as every command's working directory) and redo this edit there, at ${join(worktree, relative(mainRoot, target))}.`
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
catch (error) {
|
|
@@ -68,7 +68,7 @@ ensureWorktree) {
|
|
|
68
68
|
action: "block",
|
|
69
69
|
status: "FAIL",
|
|
70
70
|
code: "WORKTREE_REQUIRED",
|
|
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.`
|
|
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; otherwise use it as every command's working directory), and edit there.`
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -34,6 +34,30 @@ export async function probeReviewTarget(target, options) {
|
|
|
34
34
|
? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
|
|
35
35
|
: options.cwd;
|
|
36
36
|
try {
|
|
37
|
+
// agy's sandbox only answers inside a project-shaped directory: an empty
|
|
38
|
+
// temp dir hangs or denies file access, which this probe then misreads as
|
|
39
|
+
// unauthenticated. Cloning first matches the review attempt, which already
|
|
40
|
+
// runs inside a disposable clone; a failed clone falls back to the plain
|
|
41
|
+
// temp dir, which is exactly today's behavior.
|
|
42
|
+
let invocationCwd = directory;
|
|
43
|
+
if (deep && target === "agy") {
|
|
44
|
+
const clone = join(directory, "repository");
|
|
45
|
+
const cloned = await runVerificationCommand({
|
|
46
|
+
id: "review-probe-clone",
|
|
47
|
+
command: "git",
|
|
48
|
+
args: ["clone", "--no-hardlinks", "--quiet", "--", options.cwd, clone],
|
|
49
|
+
cwd: directory,
|
|
50
|
+
required: true,
|
|
51
|
+
evidence: { kind: "exit-code" },
|
|
52
|
+
timeoutMs: Math.min(probeTimeoutMs(options.timeoutMs), 30_000)
|
|
53
|
+
}, {
|
|
54
|
+
cwd: directory,
|
|
55
|
+
...(options.runner === undefined ? {} : { runner: options.runner })
|
|
56
|
+
});
|
|
57
|
+
if (cloned.status === "PASS") {
|
|
58
|
+
invocationCwd = clone;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
37
61
|
const invocation = buildProbeInvocation({
|
|
38
62
|
target,
|
|
39
63
|
prompt: PROBE_PROMPT,
|
|
@@ -48,12 +72,12 @@ export async function probeReviewTarget(target, options) {
|
|
|
48
72
|
id: `review-probe-${target}`,
|
|
49
73
|
command: invocation.command,
|
|
50
74
|
args: deep ? [...invocation.args] : ["--version"],
|
|
51
|
-
cwd:
|
|
75
|
+
cwd: invocationCwd,
|
|
52
76
|
required: true,
|
|
53
77
|
evidence: { kind: "exit-code" },
|
|
54
78
|
timeoutMs: probeTimeoutMs(options.timeoutMs)
|
|
55
79
|
}, {
|
|
56
|
-
cwd:
|
|
80
|
+
cwd: invocationCwd,
|
|
57
81
|
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
58
82
|
...(deep
|
|
59
83
|
? {
|
|
@@ -149,17 +149,18 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
149
149
|
|
|
150
150
|
- `mode: "auto"` routes every editing conversation through
|
|
151
151
|
`agent-ops worktree add <name> --session <id>`, run from the main checkout.
|
|
152
|
-
On Claude Code a direct Edit or Write of the main checkout outside
|
|
152
|
+
On Claude Code and opencode 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
155
|
- `agent-ops task create` run from the main checkout with a known session
|
|
156
156
|
creates that session's worktree first (or reuses it) and records the task
|
|
157
157
|
there, so the work starts in the worktree. Its output names the path to
|
|
158
158
|
enter and the `--base` to verify against.
|
|
159
|
-
- As a fallback on Claude Code, the first denied edit creates that worktree:
|
|
159
|
+
- As a fallback on Claude Code and opencode, the first denied edit creates that worktree:
|
|
160
160
|
`.worktrees/session-<first eight characters of the session id>`, setup
|
|
161
161
|
included, bound to the session. The denial names the path to enter with
|
|
162
|
-
EnterWorktree
|
|
162
|
+
EnterWorktree on Claude Code, or to use as every command's working directory
|
|
163
|
+
otherwise; a later denial for the same session reuses it. If creation
|
|
163
164
|
fails, the denial says why and falls back to the manual `worktree add`.
|
|
164
165
|
Run `agent-ops update` once so the PreToolUse hook gets the 600-second
|
|
165
166
|
timeout setup needs.
|
|
@@ -132,16 +132,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
- `mode: "auto"` 讓每個修改中的對話都先在主 checkout 執行
|
|
135
|
-
`agent-ops worktree add <name> --session <id>`。Claude Code 直接以 Edit 或
|
|
135
|
+
`agent-ops worktree add <name> --session <id>`。Claude Code 與 opencode 直接以 Edit 或
|
|
136
136
|
Write 修改主 checkout 中 `.worktrees/` 以外的檔案會被拒絕(需先執行一次
|
|
137
137
|
`agent-ops update`,讓 PreToolUse hook 比對檔案工具)。未設定或 `"off"` 則維持
|
|
138
138
|
目前的單一 checkout。
|
|
139
139
|
- 在主 checkout、已知 session 的情況下執行 `agent-ops task create`,會先建立(或重用)
|
|
140
140
|
該 session 的 worktree,並把 task 記錄在那裡,讓工作一開始就在 worktree 內。輸出會列出
|
|
141
141
|
要進入的路徑與 verify 用的 `--base`。
|
|
142
|
-
- 作為 Claude Code 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
142
|
+
- 作為 Claude Code 與 opencode 上的後備,第一次被拒絕的編輯會自動建立該 worktree:
|
|
143
143
|
`.worktrees/session-<session id 前八個字元>`,包含 setup,並綁定該 session。
|
|
144
|
-
|
|
144
|
+
拒絕訊息會列出要進入的路徑(Claude Code 以 EnterWorktree 進入,其他 harness
|
|
145
|
+
則作為每條命令的工作目錄);同一 session 之後被拒絕時會重用它。
|
|
145
146
|
建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
|
|
146
147
|
`agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
|
|
147
148
|
- `add` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
|