@kylecheng3146/agent-ops 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +49 -5
- package/dist/packages/cli/src/bin.js +17 -35
- package/dist/packages/cli/src/cli.js +27 -2
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/init.js +1 -0
- package/dist/packages/cli/src/commands/parallel.js +16 -3
- package/dist/packages/cli/src/commands/uninstall.js +3 -2
- package/dist/packages/cli/src/commands/update.js +1 -0
- package/dist/packages/cli/src/context.js +28 -3
- package/dist/packages/cli/src/hook-process.js +14 -3
- package/dist/packages/cli/src/public-plan.js +32 -2
- package/dist/runtime/src/adapters/claude/config.js +1 -1
- package/dist/runtime/src/adapters/claude/input.js +17 -0
- package/dist/runtime/src/adapters/claude/output.js +9 -0
- package/dist/runtime/src/hooks/codex-loop.js +67 -7
- package/dist/runtime/src/hooks/completion-gate.js +27 -2
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/apply.js +2 -0
- package/dist/runtime/src/install/doctor.js +12 -1
- package/dist/runtime/src/install/harness.js +16 -5
- package/dist/runtime/src/install/plan.js +9 -0
- package/dist/runtime/src/install/preauth.js +265 -0
- package/dist/runtime/src/install/probes.js +32 -0
- package/dist/runtime/src/install/uninstall.js +29 -11
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/parallel/enter.js +43 -0
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +20 -1
- package/dist/runtime/src/parallel/manage.js +38 -0
- package/dist/runtime/src/parallel/service.js +3 -1
- package/dist/runtime/src/review/execute.js +15 -7
- package/dist/runtime/src/review/host-sandbox.js +45 -1
- package/dist/runtime/src/review/probe.js +44 -12
- package/dist/runtime/src/review/render.js +9 -0
- package/docs/en/guides/configuration.md +61 -4
- package/docs/zh-TW/guides/configuration.md +49 -1
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
|
6
6
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
7
7
|
import { FileTransaction } from "../fs/transaction.js";
|
|
8
8
|
import { planHookRemoval } from "./hooks.js";
|
|
9
|
+
import { applyPreauth, planPreauth } from "./preauth.js";
|
|
9
10
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
11
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
11
12
|
import { isHarnessId } from "./harness.js";
|
|
@@ -207,7 +208,7 @@ async function planArtifactRemoval(root, artifact) {
|
|
|
207
208
|
expectedHash: current.hash
|
|
208
209
|
};
|
|
209
210
|
}
|
|
210
|
-
async function createFullUninstallPlan(root) {
|
|
211
|
+
async function createFullUninstallPlan(root, options = {}) {
|
|
211
212
|
const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
|
|
212
213
|
if (currentManifest === null) {
|
|
213
214
|
return {
|
|
@@ -262,11 +263,19 @@ async function createFullUninstallPlan(root) {
|
|
|
262
263
|
path: MANIFEST_PATH,
|
|
263
264
|
expectedHash: currentManifest.hash
|
|
264
265
|
});
|
|
266
|
+
const preauthorization = await planPreauth({
|
|
267
|
+
root,
|
|
268
|
+
scope: manifest.scope,
|
|
269
|
+
harness: manifest.harness,
|
|
270
|
+
desired: false,
|
|
271
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
|
|
272
|
+
});
|
|
265
273
|
return {
|
|
266
274
|
installed: true,
|
|
267
275
|
manifest,
|
|
268
276
|
manifestHash: currentManifest.hash,
|
|
269
|
-
operations
|
|
277
|
+
operations,
|
|
278
|
+
...(preauthorization.length === 0 ? {} : { preauthorization })
|
|
270
279
|
};
|
|
271
280
|
}
|
|
272
281
|
async function planLoopMarkerUpdate(root, marker, expected, remaining) {
|
|
@@ -300,7 +309,7 @@ async function planLoopMarkerUpdate(root, marker, expected, remaining) {
|
|
|
300
309
|
}
|
|
301
310
|
};
|
|
302
311
|
}
|
|
303
|
-
async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses) {
|
|
312
|
+
async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, planOptions = {}) {
|
|
304
313
|
if (selectedHarnesses.length === 0 ||
|
|
305
314
|
selectedHarnesses.some((id) => !isHarnessId(id)) ||
|
|
306
315
|
new Set(selectedHarnesses).size !== selectedHarnesses.length ||
|
|
@@ -310,7 +319,7 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
|
|
|
310
319
|
const selected = selectedSet(selectedHarnesses);
|
|
311
320
|
const remaining = manifest.harness.filter((id) => !selected.has(id));
|
|
312
321
|
if (remaining.length === 0) {
|
|
313
|
-
return await createFullUninstallPlan(root);
|
|
322
|
+
return await createFullUninstallPlan(root, planOptions);
|
|
314
323
|
}
|
|
315
324
|
const operations = [];
|
|
316
325
|
const removedArtifacts = manifest.artifacts.filter((artifact) => shouldRemove(artifactOwners(manifest, artifact), selected));
|
|
@@ -398,26 +407,34 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
|
|
|
398
407
|
content: formatInstallManifest(resultingManifest),
|
|
399
408
|
expectedHash: currentManifest.hash
|
|
400
409
|
});
|
|
410
|
+
const preauthorization = await planPreauth({
|
|
411
|
+
root,
|
|
412
|
+
scope: manifest.scope,
|
|
413
|
+
harness: selectedHarnesses,
|
|
414
|
+
desired: false,
|
|
415
|
+
...(planOptions.codexHome === undefined ? {} : { codexHome: planOptions.codexHome })
|
|
416
|
+
});
|
|
401
417
|
return {
|
|
402
418
|
installed: true,
|
|
403
419
|
manifest,
|
|
404
420
|
manifestHash: currentManifest.hash,
|
|
405
421
|
operations,
|
|
406
422
|
selectedHarnesses,
|
|
407
|
-
resultingManifest
|
|
423
|
+
resultingManifest,
|
|
424
|
+
...(preauthorization.length === 0 ? {} : { preauthorization })
|
|
408
425
|
};
|
|
409
426
|
}
|
|
410
|
-
export async function createUninstallPlan(root, selectedHarnesses) {
|
|
427
|
+
export async function createUninstallPlan(root, selectedHarnesses, options = {}) {
|
|
411
428
|
if (selectedHarnesses === undefined) {
|
|
412
|
-
return await createFullUninstallPlan(root);
|
|
429
|
+
return await createFullUninstallPlan(root, options);
|
|
413
430
|
}
|
|
414
431
|
const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
|
|
415
432
|
if (currentManifest === null) {
|
|
416
|
-
return await createFullUninstallPlan(root);
|
|
433
|
+
return await createFullUninstallPlan(root, options);
|
|
417
434
|
}
|
|
418
435
|
const manifest = parseInstallManifest(currentManifest.content);
|
|
419
436
|
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
420
|
-
return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses);
|
|
437
|
+
return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, options);
|
|
421
438
|
}
|
|
422
439
|
function allowedPaths(plan) {
|
|
423
440
|
if (plan.manifest === null) {
|
|
@@ -559,9 +576,9 @@ async function validateUninstalled(root, manifest, resultingManifest) {
|
|
|
559
576
|
throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", "Installation manifest still exists.");
|
|
560
577
|
}
|
|
561
578
|
}
|
|
562
|
-
export async function applyUninstallPlan(root, plan) {
|
|
579
|
+
export async function applyUninstallPlan(root, plan, options = {}) {
|
|
563
580
|
assertUninstallPlan(plan);
|
|
564
|
-
const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses);
|
|
581
|
+
const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses, options);
|
|
565
582
|
if (JSON.stringify(currentPlan) !== JSON.stringify(plan)) {
|
|
566
583
|
throw new AgentOpsError("INVALID_UNINSTALL_PLAN", "Uninstall plan no longer matches the current managed installation.");
|
|
567
584
|
}
|
|
@@ -571,4 +588,5 @@ export async function applyUninstallPlan(root, plan) {
|
|
|
571
588
|
}
|
|
572
589
|
const transaction = new FileTransaction(root);
|
|
573
590
|
await transaction.apply({ operations: plan.operations }, async () => await validateUninstalled(root, manifest, plan.resultingManifest));
|
|
591
|
+
await applyPreauth(plan.preauthorization ?? []);
|
|
574
592
|
}
|
|
@@ -124,6 +124,7 @@ export async function createUpdatePlan(options) {
|
|
|
124
124
|
value: configPreview.migrated,
|
|
125
125
|
sourceHash: configPreview.sourceHash
|
|
126
126
|
},
|
|
127
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
|
|
127
128
|
...(options.worktree === undefined
|
|
128
129
|
? {}
|
|
129
130
|
: { worktree: options.worktree })
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { canonicalPath } from "./guard.js";
|
|
4
|
+
import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether Claude's EnterWorktree may switch this session into `path`. Only the
|
|
7
|
+
* worktree agent-ops recorded for the session is answered for the host, so an
|
|
8
|
+
* allow never lets the session move its permission root to a directory it did
|
|
9
|
+
* not get from `worktree add`.
|
|
10
|
+
*/
|
|
11
|
+
export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
12
|
+
if (sessionId === undefined) {
|
|
13
|
+
return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
|
|
14
|
+
}
|
|
15
|
+
const directory = join(mainRoot, WORKTREE_DIRECTORY);
|
|
16
|
+
const target = await canonicalPath(path);
|
|
17
|
+
const name = relative(directory, target).split(sep)[0] ?? "";
|
|
18
|
+
const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
|
|
19
|
+
if (inside && (await readWorktreeRecord(target))?.sessionId === sessionId) {
|
|
20
|
+
return {
|
|
21
|
+
action: "continue",
|
|
22
|
+
status: "PASS",
|
|
23
|
+
code: "WORKTREE_ENTER_ALLOWED",
|
|
24
|
+
remedy: `${target} is the worktree agent-ops recorded for this session.`,
|
|
25
|
+
decision: "allow"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const owned = [];
|
|
29
|
+
for (const entry of await readdir(directory).catch(() => [])) {
|
|
30
|
+
if ((await readWorktreeRecord(join(directory, entry)))?.sessionId === sessionId) {
|
|
31
|
+
owned.push(join(directory, entry));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
action: "block",
|
|
36
|
+
status: "FAIL",
|
|
37
|
+
code: "WORKTREE_ENTER_DENIED",
|
|
38
|
+
remedy: `EnterWorktree may enter only a worktree this session owns. ` +
|
|
39
|
+
(owned.length > 0
|
|
40
|
+
? `Yours: ${owned.join(", ")}.`
|
|
41
|
+
: `None yet: run agent-ops worktree add <name> --session ${sessionId} from ${mainRoot}.`)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -251,7 +251,8 @@ export async function finishWorktree(deps, options) {
|
|
|
251
251
|
await deps.tasks(record.path, base).complete(task.task.id, {});
|
|
252
252
|
}
|
|
253
253
|
catch (error) {
|
|
254
|
-
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)}`
|
|
254
|
+
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)} ` +
|
|
255
|
+
"A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
const worktreeGate = await deps.gate(record.path, worktreeConfig);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
|
-
import { insideWorktreeDirectory } from "./service.js";
|
|
3
|
+
import { insideWorktreeDirectory, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
4
4
|
/** The main checkout of the repository `runner` runs in, or null. */
|
|
5
5
|
export async function resolveMainRoot(runner) {
|
|
6
6
|
const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
@@ -47,6 +47,25 @@ ensureWorktree) {
|
|
|
47
47
|
for (const path of paths) {
|
|
48
48
|
const target = await canonicalPath(path);
|
|
49
49
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
50
|
+
if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
|
|
51
|
+
// A worktree belongs to the session that made it: writing into another
|
|
52
|
+
// session's mixes two tasks' changes into one review and one merge.
|
|
53
|
+
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
54
|
+
const owner = (await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name)))?.sessionId;
|
|
55
|
+
if (owner !== undefined && owner !== sessionId) {
|
|
56
|
+
let own = "";
|
|
57
|
+
if (ensureWorktree !== undefined) {
|
|
58
|
+
own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
action: "block",
|
|
62
|
+
status: "FAIL",
|
|
63
|
+
code: "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
64
|
+
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
|
|
65
|
+
`this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
50
69
|
if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
|
|
51
70
|
let failure = "";
|
|
52
71
|
if (sessionId !== undefined && ensureWorktree !== undefined) {
|
|
@@ -154,6 +154,44 @@ export async function resumeWorktree(deps, options) {
|
|
|
154
154
|
await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
|
|
155
155
|
return record;
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Commits everything in the calling session's own worktree. Git runs from
|
|
159
|
+
* here rather than the shell, where a host cannot prove a command stays inside
|
|
160
|
+
* the worktree; that is also why it refuses anything that is not the caller's.
|
|
161
|
+
*
|
|
162
|
+
* ponytail: stages all changes, so an untracked file Git does not ignore goes
|
|
163
|
+
* in too; narrow it with a path list if that ever commits something unwanted.
|
|
164
|
+
*/
|
|
165
|
+
export async function commitWorktree(deps, options) {
|
|
166
|
+
const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
|
|
167
|
+
if (currentRoot === mainRoot) {
|
|
168
|
+
throw manageError("WORKTREE_COMMIT_MAIN", `worktree commit works only inside an agent-ops worktree; this is the main checkout (${mainRoot}).`);
|
|
169
|
+
}
|
|
170
|
+
const record = await readWorktreeRecord(currentRoot);
|
|
171
|
+
if (record === null) {
|
|
172
|
+
throw manageError("WORKTREE_COMMIT_UNRECORDED", `${currentRoot} is not an agent-ops worktree; see agent-ops worktree list.`);
|
|
173
|
+
}
|
|
174
|
+
if (options.sessionId === undefined) {
|
|
175
|
+
throw manageError("WORKTREE_COMMIT_SESSION_REQUIRED", "Cannot tell which session is committing; pass --session <id> or set AGENT_OPS_SESSION_ID.");
|
|
176
|
+
}
|
|
177
|
+
if (record.sessionId !== options.sessionId) {
|
|
178
|
+
throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
|
|
179
|
+
}
|
|
180
|
+
const step = async (args, code, message) => {
|
|
181
|
+
const result = await deps.git(record.path, args);
|
|
182
|
+
if (result.exitCode !== 0) {
|
|
183
|
+
const detail = result.stderr.trim().split("\n")[0];
|
|
184
|
+
throw manageError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
return result.stdout.trim();
|
|
187
|
+
};
|
|
188
|
+
await step(["add", "-A"], "WORKTREE_COMMIT_FAILED", "Git could not stage the worktree.");
|
|
189
|
+
if ((await deps.git(record.path, ["diff", "--cached", "--quiet"])).exitCode === 0) {
|
|
190
|
+
throw manageError("WORKTREE_COMMIT_NOTHING", `${record.path} has no changes to commit.`);
|
|
191
|
+
}
|
|
192
|
+
await step(["commit", "-m", options.message], "WORKTREE_COMMIT_FAILED", "Git refused the commit.");
|
|
193
|
+
return { record, commit: await step(["rev-parse", "HEAD"], "WORKTREE_COMMIT_FAILED", "Git could not read the new commit.") };
|
|
194
|
+
}
|
|
157
195
|
/**
|
|
158
196
|
* Deletes a worktree and its branch. Work that exists nowhere else — an
|
|
159
197
|
* uncommitted change or a commit the target lacks — needs `force`.
|
|
@@ -291,7 +291,9 @@ export async function addWorktree(deps, options) {
|
|
|
291
291
|
const mainTrust = await deps.trust.status(mainRoot, mainConfig);
|
|
292
292
|
const setupSteps = mainConfig.worktree?.setup ?? [];
|
|
293
293
|
if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
|
|
294
|
-
throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands
|
|
294
|
+
throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands and the main checkout is not trusted: trust is bound to the config hash, " +
|
|
295
|
+
"so a hand edit of .agent-ops/config.json (adding worktree.setup, say) leaves it untrusted. " +
|
|
296
|
+
"Trust is the user's to grant, once: run agent-ops update in the main checkout, which lists the commands it will trust and asks.");
|
|
295
297
|
}
|
|
296
298
|
await ensureExcluded(deps, mainRoot, commonDir);
|
|
297
299
|
await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
|
|
@@ -8,7 +8,7 @@ import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage,
|
|
|
8
8
|
import { buildTargetInvocation } from "./invocation.js";
|
|
9
9
|
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
10
10
|
import { detectHostTarget, planReviewTargets } from "./roles.js";
|
|
11
|
-
import { detectHostRestriction } from "./host-sandbox.js";
|
|
11
|
+
import { detectHostRestriction, isLoginFailure, isNetworkFailure } from "./host-sandbox.js";
|
|
12
12
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
13
13
|
import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
|
|
14
14
|
import { extractUsage, sumUsage } from "./usage.js";
|
|
@@ -143,10 +143,14 @@ function preflightUnavailableReason(result) {
|
|
|
143
143
|
return "timeout";
|
|
144
144
|
case "unauthenticated":
|
|
145
145
|
return "login-required";
|
|
146
|
+
case "network-unreachable":
|
|
147
|
+
return "network-unreachable";
|
|
146
148
|
case "ineligible":
|
|
147
149
|
return "capability-unavailable";
|
|
148
150
|
case "capability-unavailable":
|
|
149
151
|
return "capability-unavailable";
|
|
152
|
+
case "probe-failed":
|
|
153
|
+
return "probe-failed";
|
|
150
154
|
}
|
|
151
155
|
}
|
|
152
156
|
/**
|
|
@@ -155,7 +159,7 @@ function preflightUnavailableReason(result) {
|
|
|
155
159
|
* suppressed under `--json`: a machine consumer would otherwise be left with
|
|
156
160
|
* the bare authentication guess this exists to qualify. Never evidence.
|
|
157
161
|
*/
|
|
158
|
-
function firstComplaint(...streams) {
|
|
162
|
+
export function firstComplaint(...streams) {
|
|
159
163
|
for (const stream of streams) {
|
|
160
164
|
const line = redactSecrets(stream)
|
|
161
165
|
.split(/\r?\n/u)
|
|
@@ -171,9 +175,10 @@ function rejectedCallReason(output) {
|
|
|
171
175
|
if (/\b(?:quota|rate limit|usage limit|too many requests)\b/iu.test(output)) {
|
|
172
176
|
return "quota-exhausted";
|
|
173
177
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
if (isNetworkFailure(output)) {
|
|
179
|
+
return "network-unreachable";
|
|
180
|
+
}
|
|
181
|
+
if (isLoginFailure(output)) {
|
|
177
182
|
return "login-required";
|
|
178
183
|
}
|
|
179
184
|
return "capability-unavailable";
|
|
@@ -735,15 +740,18 @@ export function createReviewExecutor(options) {
|
|
|
735
740
|
attempts: []
|
|
736
741
|
};
|
|
737
742
|
}
|
|
738
|
-
const
|
|
743
|
+
const answer = await options.preflightTarget(target, {
|
|
739
744
|
timeoutMs: Math.max(1, remainingMs())
|
|
740
745
|
});
|
|
746
|
+
const result = typeof answer === "string" ? answer : answer.result;
|
|
741
747
|
if (result === "ok") {
|
|
742
748
|
preflight.push({ target, status: "PASS" });
|
|
743
749
|
continue;
|
|
744
750
|
}
|
|
745
751
|
const reason = preflightUnavailableReason(result);
|
|
746
|
-
const
|
|
752
|
+
const complaint = typeof answer === "string" ? undefined : answer.diagnostic;
|
|
753
|
+
const diagnostic = `target preflight returned ${result}` +
|
|
754
|
+
(complaint === undefined ? "" : `: ${complaint}`);
|
|
747
755
|
preflight.push({ target, status: "NOT_RUN", reason, diagnostic });
|
|
748
756
|
report(`${target}: preflight ${reason} (${diagnostic})`);
|
|
749
757
|
return {
|
|
@@ -1,7 +1,51 @@
|
|
|
1
|
-
import { createServer } from "node:net";
|
|
1
|
+
import { connect, createServer } from "node:net";
|
|
2
2
|
/** Targets that need a loopback listener of their own to answer at all. */
|
|
3
3
|
export const BIND_DEPENDENT_TARGETS = ["agy"];
|
|
4
4
|
const BIND_PROBE_TIMEOUT_MS = 2_000;
|
|
5
|
+
/**
|
|
6
|
+
* A reviewer that cannot reach its API host says so in its own words. Reading
|
|
7
|
+
* that as "not logged in" sent users to re-login an authenticated CLI when the
|
|
8
|
+
* host sandbox was what withheld the network.
|
|
9
|
+
*/
|
|
10
|
+
const NETWORK_FAILURE = /\b(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b|getaddrinfo|fetch failed|network is unreachable|could not resolve host|no such host|dial tcp/iu;
|
|
11
|
+
export function isNetworkFailure(output) {
|
|
12
|
+
return NETWORK_FAILURE.test(output);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Only output that says so is a login problem. A CLI that dies for any other
|
|
16
|
+
* reason (a sandbox denying its state directory, a crash) used to be reported
|
|
17
|
+
* as "not logged in" too, which sent users to re-login a working install.
|
|
18
|
+
*/
|
|
19
|
+
const LOGIN_FAILURE = /\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b|\b(?:invalid|expired)\s+(?:api key|token|credential)|\b401\b/iu;
|
|
20
|
+
export function isLoginFailure(output) {
|
|
21
|
+
return LOGIN_FAILURE.test(output);
|
|
22
|
+
}
|
|
23
|
+
/** The host each reviewer talks to, so a reachability check tests its real path. */
|
|
24
|
+
export const TARGET_API_HOST = {
|
|
25
|
+
agy: "cloudcode-pa.googleapis.com",
|
|
26
|
+
codex: "api.openai.com",
|
|
27
|
+
claude: "api.anthropic.com"
|
|
28
|
+
};
|
|
29
|
+
export const REACH_TIMEOUT_MS = 2_000;
|
|
30
|
+
/**
|
|
31
|
+
* DNS plus a TCP connect to `host:443`, nothing sent. Diagnostic only: it
|
|
32
|
+
* refines a failed probe's reason and never gates a review, so a proxy-only
|
|
33
|
+
* network that fails it still gets the review attempted.
|
|
34
|
+
*/
|
|
35
|
+
export async function probeHostReachable(host, port = 443) {
|
|
36
|
+
return await new Promise((resolve) => {
|
|
37
|
+
const socket = connect({ host, port });
|
|
38
|
+
const finish = (value) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
socket.destroy();
|
|
41
|
+
resolve(value);
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => finish(false), REACH_TIMEOUT_MS);
|
|
44
|
+
timer.unref();
|
|
45
|
+
socket.once("connect", () => finish(true));
|
|
46
|
+
socket.once("error", () => finish(false));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
5
49
|
/**
|
|
6
50
|
* Opens and immediately closes a loopback listener on an ephemeral port.
|
|
7
51
|
* Cheap enough to run before every review, and it exercises exactly the
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { extractFinalMessage } from "./extract.js";
|
|
6
|
-
import { isolatedReviewEnvironment } from "./execute.js";
|
|
6
|
+
import { firstComplaint, isolatedReviewEnvironment } from "./execute.js";
|
|
7
|
+
import { isLoginFailure, isNetworkFailure, probeHostReachable, TARGET_API_HOST } from "./host-sandbox.js";
|
|
7
8
|
import { buildProbeInvocation } from "./invocation.js";
|
|
9
|
+
/** A log's last non-empty line: where a CLI that logs to a file puts its error. */
|
|
10
|
+
async function lastLogLine(path) {
|
|
11
|
+
const text = await readFile(path, "utf8").catch(() => "");
|
|
12
|
+
return text.split(/\r?\n/u).filter((line) => line.trim().length > 0).at(-1) ?? "";
|
|
13
|
+
}
|
|
8
14
|
const PROBE_PROMPT = "Reply with the single word OK and nothing else.";
|
|
9
15
|
/**
|
|
10
16
|
* Matches the review timeout rather than being "quick": codex at high
|
|
@@ -29,6 +35,9 @@ export function probeTimeoutMs(requested) {
|
|
|
29
35
|
* is not evidence at all.
|
|
30
36
|
*/
|
|
31
37
|
export async function probeReviewTarget(target, options) {
|
|
38
|
+
return (await probeReviewTargetDetailed(target, options)).result;
|
|
39
|
+
}
|
|
40
|
+
export async function probeReviewTargetDetailed(target, options) {
|
|
32
41
|
const deep = options.deep === true;
|
|
33
42
|
const directory = deep
|
|
34
43
|
? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
|
|
@@ -66,7 +75,7 @@ export async function probeReviewTarget(target, options) {
|
|
|
66
75
|
: {})
|
|
67
76
|
});
|
|
68
77
|
if (invocation === undefined) {
|
|
69
|
-
return "ineligible";
|
|
78
|
+
return { result: "ineligible" };
|
|
70
79
|
}
|
|
71
80
|
const spawned = await runVerificationCommand({
|
|
72
81
|
id: `review-probe-${target}`,
|
|
@@ -88,21 +97,44 @@ export async function probeReviewTarget(target, options) {
|
|
|
88
97
|
: {})
|
|
89
98
|
});
|
|
90
99
|
if (spawned.failureClass === "missing-executable") {
|
|
91
|
-
return "missing-executable";
|
|
100
|
+
return { result: "missing-executable" };
|
|
92
101
|
}
|
|
93
102
|
if (spawned.timedOut) {
|
|
94
|
-
return "timeout";
|
|
103
|
+
return { result: "timeout" };
|
|
95
104
|
}
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const output = `${spawned.stderr}\n${spawned.stdout}`;
|
|
106
|
+
// The target's own words, so a failure is never reduced to a guess. agy
|
|
107
|
+
// writes its errors to the log file the probe hands it, not to stderr.
|
|
108
|
+
const complaint = async () => firstComplaint(spawned.stderr, spawned.stdout) ??
|
|
109
|
+
(deep && target === "agy"
|
|
110
|
+
? firstComplaint(await lastLogLine(join(directory, "agy.log")))
|
|
111
|
+
: undefined);
|
|
112
|
+
if (/(?:operation not permitted|permission denied|bind(?:ing)?[^\n]*(?:failed|denied))/iu.test(output)) {
|
|
113
|
+
return { result: "capability-unavailable", diagnostic: await complaint() };
|
|
98
114
|
}
|
|
99
115
|
if (!deep) {
|
|
100
|
-
return spawned.status === "PASS"
|
|
116
|
+
return spawned.status === "PASS"
|
|
117
|
+
? { result: "ok" }
|
|
118
|
+
: { result: "probe-failed", diagnostic: await complaint() };
|
|
119
|
+
}
|
|
120
|
+
if (spawned.status === "PASS" &&
|
|
121
|
+
extractFinalMessage(target, spawned.stdout) !== undefined) {
|
|
122
|
+
return { result: "ok" };
|
|
123
|
+
}
|
|
124
|
+
// An unreachable API host outranks whatever the CLI printed: a sandbox that
|
|
125
|
+
// withholds the network makes an authenticated CLI say "not logged in".
|
|
126
|
+
if (isNetworkFailure(output)) {
|
|
127
|
+
return { result: "network-unreachable", diagnostic: await complaint() };
|
|
128
|
+
}
|
|
129
|
+
if (!await (options.reachable ?? probeHostReachable)(TARGET_API_HOST[target])) {
|
|
130
|
+
return { result: "network-unreachable", diagnostic: await complaint() };
|
|
101
131
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
const diagnostic = await complaint();
|
|
133
|
+
// Login is what the output says it is; anything else is a probe that could
|
|
134
|
+
// not run, and the diagnostic is all the evidence there is.
|
|
135
|
+
return isLoginFailure(`${output}\n${diagnostic ?? ""}`)
|
|
136
|
+
? { result: "unauthenticated", diagnostic }
|
|
137
|
+
: { result: "probe-failed", diagnostic };
|
|
106
138
|
}
|
|
107
139
|
finally {
|
|
108
140
|
if (deep) {
|
|
@@ -83,6 +83,15 @@ export function renderReviewResult(result) {
|
|
|
83
83
|
result.attempts?.some((attempt) => attempt.reason === "login-required")) {
|
|
84
84
|
lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
|
|
85
85
|
}
|
|
86
|
+
if (result.reason === "network-unreachable" ||
|
|
87
|
+
result.attempts?.some((attempt) => attempt.reason === "network-unreachable")) {
|
|
88
|
+
lines.push("The reviewer could not reach the network. This is not a login " +
|
|
89
|
+
"problem: run agent-ops outside the sandbox, or grant it escalated execution.");
|
|
90
|
+
}
|
|
91
|
+
if (result.reason === "probe-failed") {
|
|
92
|
+
lines.push("The reviewer CLI failed its probe for the reason quoted above. This " +
|
|
93
|
+
"is not a login problem: fix that cause instead of logging in again.");
|
|
94
|
+
}
|
|
86
95
|
// Evidence is pinned to the source it was produced from, so any edit to a
|
|
87
96
|
// changed file after the verifier ran — a doc rewritten by a later step
|
|
88
97
|
// counts — voids it. Without the next command the caller reads
|
|
@@ -130,10 +130,21 @@ never rewritten.
|
|
|
130
130
|
targets are permitted, `--yes` decides whether to spend money now.
|
|
131
131
|
|
|
132
132
|
Reviewer failures are classified conservatively from their output: recognizable
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
network errors (`ENOTFOUND`, `fetch failed`, `getaddrinfo` and similar) become
|
|
134
|
+
`network-unreachable`, authentication messages become `login-required`, quota
|
|
135
|
+
messages become `quota-exhausted`, and other non-zero exits become
|
|
136
|
+
`capability-unavailable`. A sandbox that withholds the network therefore is not
|
|
137
|
+
reported as a logged-out CLI: `network-unreachable` tells you to run outside the
|
|
138
|
+
sandbox instead of running `<target> login`. When a `--check-auth` probe or the
|
|
139
|
+
review preflight finds a target unauthenticated, a 2-second TCP connection to
|
|
140
|
+
that target's API host on port 443 (`cloudcode-pa.googleapis.com` for agy,
|
|
141
|
+
`api.openai.com` for codex, `api.anthropic.com` for claude) decides between the
|
|
142
|
+
two; it only refines the reported reason and never blocks a review. The review suggests
|
|
143
|
+
`doctor --check-auth` only when an authentication failure was detected. A probe
|
|
144
|
+
that fails without network or login wording is `probe-failed`, never
|
|
145
|
+
`login-required`: the preflight record and `doctor` quote the target's own first
|
|
146
|
+
line (for agy, the last line of its log file when stderr is empty) so the real
|
|
147
|
+
cause is visible instead of guessed. Confirm target authentication with:
|
|
137
148
|
|
|
138
149
|
```bash
|
|
139
150
|
agent-ops doctor # presence only: no tokens, no network
|
|
@@ -196,6 +207,52 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
196
207
|
- The session's completion gate follows it: Claude Code enters the worktree
|
|
197
208
|
with EnterWorktree, and a host that cannot move a session (agy) is judged by
|
|
198
209
|
the worktree's gate through a redirect the main checkout records.
|
|
210
|
+
- A command knows its session from `--session`, then `AGENT_OPS_SESSION_ID`,
|
|
211
|
+
then `CODEX_THREAD_ID`, then the id a SessionStart recorded for the
|
|
212
|
+
checkout. Claude Code's SessionStart appends `AGENT_OPS_SESSION_ID` to its
|
|
213
|
+
`CLAUDE_ENV_FILE`, so every later Bash command carries it. When a different
|
|
214
|
+
session started in the same checkout within fifteen minutes, the recorded id
|
|
215
|
+
is contested: `task create`, `attach` and `status` fail with
|
|
216
|
+
`SESSION_ID_AMBIGUOUS` instead of acting as the wrong session.
|
|
217
|
+
- A worktree belongs to the session that created it: a file write into
|
|
218
|
+
another session's worktree is denied (`WORKTREE_OWNED_BY_OTHER_SESSION`),
|
|
219
|
+
naming the session's own worktree. A session with no baseline (its
|
|
220
|
+
SessionStart never arrived) gets one at its first tool call, never at Stop.
|
|
221
|
+
- Inside a worktree the project config is the main checkout's, so a branch
|
|
222
|
+
cannot change the gate, verifiers or trust that judge it before merging; a
|
|
223
|
+
worktree of a repository whose main checkout has no config uses its own.
|
|
224
|
+
- `agent-ops worktree commit --message <text>` stages and commits every change
|
|
225
|
+
in the current worktree. Git runs inside agent-ops, not the shell, so a host
|
|
226
|
+
that cannot prove a shell command stays inside the worktree still lets the
|
|
227
|
+
session commit; it refuses the main checkout, a worktree another session
|
|
228
|
+
owns, a caller it cannot identify and an empty change. It stages everything
|
|
229
|
+
Git does not ignore, so keep stray files out of the worktree.
|
|
230
|
+
- In auto mode the Claude PreToolUse hook also handles `EnterWorktree`: a
|
|
231
|
+
switch into exactly a worktree recorded for the calling session is allowed
|
|
232
|
+
without Claude Code's own confirmation, and any other path is denied
|
|
233
|
+
(`WORKTREE_ENTER_DENIED`, naming the session's worktrees). Run `agent-ops
|
|
234
|
+
update` once so the hook's matcher includes `EnterWorktree`; `doctor`
|
|
235
|
+
reports an older matcher as registration drift.
|
|
236
|
+
- With `worktree.mode` auto, `init` and `update` also pre-authorize what a
|
|
237
|
+
session runs itself, and the plan lists it before you confirm: in
|
|
238
|
+
`.claude/settings.local.json`, `permissions.allow` for `agent-ops task`,
|
|
239
|
+
`verify`, `review`, `worktree` and `doctor` and `sandbox.excludedCommands`
|
|
240
|
+
for `agent-ops review` (your other entries are kept, and the plan describes
|
|
241
|
+
the file instead of printing it); and, when the CLI can see Codex's home
|
|
242
|
+
(`CODEX_HOME`, else `~/.codex`), `rules/agent-ops.rules` with allow rules
|
|
243
|
+
for `agent-ops review` and `doctor --check-auth`, with and without
|
|
244
|
+
`AGENT_OPS_HOST=`. `trust`, `allow-stop`, `init`, `update` and `uninstall`
|
|
245
|
+
are never pre-authorized. Turning auto mode off, or `uninstall`, takes
|
|
246
|
+
exactly those entries back out; a rules file of that name agent-ops did not
|
|
247
|
+
write is left alone.
|
|
248
|
+
- `review` and `doctor` take `--output <file>`: the JSON envelope is written
|
|
249
|
+
there (mode 0600) and one summary line is printed. Use it instead of a shell
|
|
250
|
+
redirect, which Codex wraps in `zsh -lc` and can only allow by matching the
|
|
251
|
+
whole command string.
|
|
252
|
+
- Trust is bound to the config hash, so a hand edit of `.agent-ops/config.json`
|
|
253
|
+
(adding `worktree.setup`, say) leaves the repository untrusted and
|
|
254
|
+
`WORKTREE_SETUP_UNTRUSTED` stops the next worktree. The one grant is the
|
|
255
|
+
user's: `agent-ops update` lists the commands it will trust and asks once.
|
|
199
256
|
- In a worktree, verify and review each task but do not run `task complete`
|
|
200
257
|
(Claude Code refuses it inside an isolated worktree session). `agent-ops
|
|
201
258
|
worktree finish <name>` completes every non-archived task in the worktree,
|
|
@@ -111,9 +111,19 @@ task ID 必須明確指定,且永遠使用該 task 的原始 criteria;不能
|
|
|
111
111
|
每次執行 review 仍需 `--yes`:init 的勾選決定「允許哪些目標」,
|
|
112
112
|
`--yes` 決定「現在是否要花錢」。
|
|
113
113
|
|
|
114
|
-
Reviewer
|
|
114
|
+
Reviewer 失敗會依輸出採保守分類:網路錯誤(`ENOTFOUND`、`fetch failed`、
|
|
115
|
+
`getaddrinfo` 等)為 `network-unreachable`,可辨識的認證訊息為 `login-required`,
|
|
115
116
|
quota 訊息為 `quota-exhausted`,其他非零退出則為 `capability-unavailable`。
|
|
117
|
+
因此沙盒擋住網路時不會被誤報成 CLI 未登入:`network-unreachable` 會提示改在
|
|
118
|
+
沙盒外執行,而不是執行 `<target> login`。當 `--check-auth` 探測或 review
|
|
119
|
+
preflight 判定目標未認證時,會再對該目標的 API 主機(agy 為
|
|
120
|
+
`cloudcode-pa.googleapis.com`、codex 為 `api.openai.com`、claude 為
|
|
121
|
+
`api.anthropic.com`)的 443 埠做 2 秒 TCP 連線測試,據此在兩種原因間擇一;
|
|
122
|
+
它只修正回報的原因,不會阻擋 review。
|
|
116
123
|
只有偵測到認證失敗時,review 才會建議執行 `doctor --check-auth`。
|
|
124
|
+
探測失敗但輸出沒有網路或登入字樣時為 `probe-failed`,不會被當成
|
|
125
|
+
`login-required`:preflight 紀錄與 `doctor` 會引述目標自己的第一行輸出
|
|
126
|
+
(agy 在 stderr 為空時取其 log 檔最後一行),讓真正原因可見而非猜測。
|
|
117
127
|
可用以下指令確認目標的認證狀態:
|
|
118
128
|
|
|
119
129
|
```bash
|
|
@@ -170,6 +180,44 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
170
180
|
- Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
|
|
171
181
|
worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
|
|
172
182
|
由 worktree 的 gate 判定。
|
|
183
|
+
- 指令依序以 `--session`、`AGENT_OPS_SESSION_ID`、`CODEX_THREAD_ID`、SessionStart
|
|
184
|
+
為該 checkout 記錄的 id 得知自己的 session。Claude Code 的 SessionStart 會把
|
|
185
|
+
`AGENT_OPS_SESSION_ID` 寫入 `CLAUDE_ENV_FILE`,之後每個 Bash 指令都帶有它。
|
|
186
|
+
十五分鐘內若有另一個 session 在同一 checkout 啟動,記錄的 id 視為有爭議:
|
|
187
|
+
`task create`、`attach`、`status` 會以 `SESSION_ID_AMBIGUOUS` 失敗,而不是以
|
|
188
|
+
錯誤的 session 行動。
|
|
189
|
+
- Worktree 屬於建立它的 session:寫入別的 session 的 worktree 會被拒絕
|
|
190
|
+
(`WORKTREE_OWNED_BY_OTHER_SESSION`),並指出該 session 自己的 worktree。沒有
|
|
191
|
+
baseline 的 session(SessionStart 沒送達)會在第一次呼叫工具時補建,絕不會在
|
|
192
|
+
Stop 時補建。
|
|
193
|
+
- 在 worktree 內,專案 config 一律取自主 checkout,因此分支無法在合併前改變判定
|
|
194
|
+
它自己的 gate、verifier 或 trust;主 checkout 沒有 config 時才使用 worktree 自己的。
|
|
195
|
+
- `agent-ops worktree commit --message <text>` 會暫存並 commit 目前 worktree 的
|
|
196
|
+
所有變更。Git 由 agent-ops 自己執行而非經過 shell,因此即使 host 無法證明某條
|
|
197
|
+
shell 指令停留在 worktree 內,session 仍能 commit;它會拒絕主 checkout、別的
|
|
198
|
+
session 擁有的 worktree、無法辨識的呼叫者,以及沒有變更的情況。它會暫存所有
|
|
199
|
+
Git 未忽略的檔案,所以請不要把雜檔留在 worktree 裡。
|
|
200
|
+
- 在 auto 模式下,Claude 的 PreToolUse hook 也處理 `EnterWorktree`:切入恰好是
|
|
201
|
+
為呼叫 session 記錄的 worktree 時,不必經過 Claude Code 自己的確認即放行;其他
|
|
202
|
+
任何路徑一律拒絕(`WORKTREE_ENTER_DENIED`,並列出該 session 的 worktree)。請
|
|
203
|
+
執行一次 `agent-ops update`,讓 hook 的 matcher 包含 `EnterWorktree`;`doctor`
|
|
204
|
+
會把較舊的 matcher 回報為註冊漂移。
|
|
205
|
+
- 當 `worktree.mode` 為 auto,`init` 與 `update` 也會預先授權 session 自己會執行的
|
|
206
|
+
指令,並在你確認前先列在計畫裡:在 `.claude/settings.local.json` 寫入
|
|
207
|
+
`agent-ops task`、`verify`、`review`、`worktree`、`doctor` 的 `permissions.allow`,
|
|
208
|
+
以及 `agent-ops review` 的 `sandbox.excludedCommands`(你原有的項目會保留,計畫只
|
|
209
|
+
描述該檔案而不印出內容);若 CLI 找得到 Codex 的 home(`CODEX_HOME`,否則
|
|
210
|
+
`~/.codex`),另寫入 `rules/agent-ops.rules`,內含 `agent-ops review` 與
|
|
211
|
+
`doctor --check-auth` 的 allow 規則(含與不含 `AGENT_OPS_HOST=`)。`trust`、
|
|
212
|
+
`allow-stop`、`init`、`update`、`uninstall` 永遠不會被預先授權。關閉 auto 模式或
|
|
213
|
+
`uninstall` 會精確移除這些項目;同名但不是 agent-ops 寫的規則檔不會被動到。
|
|
214
|
+
- `review` 與 `doctor` 支援 `--output <file>`:JSON envelope 寫入該檔(權限 0600),
|
|
215
|
+
並只印出一行摘要。請用它取代 shell 重導向;Codex 會把重導向包成 `zsh -lc`,只能
|
|
216
|
+
以整串指令比對來放行。
|
|
217
|
+
- Trust 綁定 config hash,因此手動編輯 `.agent-ops/config.json`(例如加入
|
|
218
|
+
`worktree.setup`)會讓 repository 變成未受信任,`WORKTREE_SETUP_UNTRUSTED` 會擋下
|
|
219
|
+
下一個 worktree。唯一的授權點屬於使用者:`agent-ops update` 會列出它將信任的指令並
|
|
220
|
+
詢問一次。
|
|
173
221
|
- 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
|
|
174
222
|
(Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
|
|
175
223
|
<name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask
|