@kylecheng3146/agent-ops 0.4.1 → 0.5.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/LICENSE +91 -21
- package/README.md +4 -1
- package/dist/packages/cli/src/bin.js +22 -2
- package/dist/packages/cli/src/commands/parallel.js +1 -0
- package/dist/packages/cli/src/commands/update.js +13 -1
- package/dist/packages/cli/src/hook-process.js +2 -2
- package/dist/packages/cli/src/public-plan.js +1 -0
- package/dist/runtime/src/adapters/claude/input.js +8 -2
- package/dist/runtime/src/hooks/completion-gate.js +81 -8
- package/dist/runtime/src/install/doctor.js +9 -2
- package/dist/runtime/src/install/ownership.js +7 -3
- package/dist/runtime/src/install/plan.js +38 -7
- package/dist/runtime/src/install/preauth.js +36 -4
- package/dist/runtime/src/parallel/enter.js +6 -4
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +16 -10
- package/dist/runtime/src/parallel/manage.js +8 -2
- package/dist/runtime/src/parallel/service.js +34 -8
- package/dist/runtime/src/review/slots.js +84 -0
- package/docs/en/guides/configuration.md +6 -3
- package/docs/en/spec/delegation.md +10 -0
- package/docs/en/spec/maintenance.md +10 -0
- package/docs/en/spec/troubleshooting.md +10 -0
- package/docs/zh-TW/guides/configuration.md +8 -5
- package/docs/zh-TW/spec/delegation.md +10 -0
- package/docs/zh-TW/spec/maintenance.md +10 -0
- package/docs/zh-TW/spec/troubleshooting.md +10 -0
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
Required Notice: Copyright (c) 2026 Kyle Cheng (https://github.com/kylecheng3146/agent-ops)
|
|
2
|
+
|
|
3
|
+
# PolyForm Shield License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncompete
|
|
34
|
+
|
|
35
|
+
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
|
36
|
+
|
|
37
|
+
## Competition
|
|
38
|
+
|
|
39
|
+
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
|
40
|
+
|
|
41
|
+
## New Products
|
|
42
|
+
|
|
43
|
+
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
|
44
|
+
|
|
45
|
+
## Discontinued Products
|
|
46
|
+
|
|
47
|
+
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
|
48
|
+
|
|
49
|
+
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
|
50
|
+
|
|
51
|
+
## Sales of Business
|
|
52
|
+
|
|
53
|
+
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
|
54
|
+
|
|
55
|
+
## Fair Use
|
|
56
|
+
|
|
57
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
58
|
+
|
|
59
|
+
## No Other Rights
|
|
60
|
+
|
|
61
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
62
|
+
|
|
63
|
+
## Patent Defense
|
|
64
|
+
|
|
65
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
66
|
+
|
|
67
|
+
## Violations
|
|
68
|
+
|
|
69
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
70
|
+
|
|
71
|
+
## No Liability
|
|
72
|
+
|
|
73
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
74
|
+
|
|
75
|
+
## Definitions
|
|
76
|
+
|
|
77
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
78
|
+
|
|
79
|
+
A **product** can be a good or service, or a combination of them.
|
|
80
|
+
|
|
81
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
82
|
+
|
|
83
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
|
84
|
+
|
|
85
|
+
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
|
86
|
+
|
|
87
|
+
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
88
|
+
|
|
89
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
90
|
+
|
|
91
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
CHANGED
|
@@ -412,4 +412,7 @@ Documentation:
|
|
|
412
412
|
|
|
413
413
|
## License
|
|
414
414
|
|
|
415
|
-
Copyright (c) 2026 Kyle Cheng. Released under the
|
|
415
|
+
Copyright (c) 2026 Kyle Cheng. Released under the
|
|
416
|
+
[PolyForm Shield License 1.0.0](LICENSE): free to use, change and share,
|
|
417
|
+
including inside a company, but not to build a product or service that competes
|
|
418
|
+
with agent-ops. Versions up to 0.4.1 were released under, and remain under, the MIT License.
|
|
@@ -34,6 +34,7 @@ import { runTaskCommand } from "./commands/task.js";
|
|
|
34
34
|
import { hasFreshVerification, runReviewCommand } from "./commands/review.js";
|
|
35
35
|
import { runBatchCommand } from "./commands/batch.js";
|
|
36
36
|
import { createSourceGuard } from "../../../runtime/src/review/batch-guard.js";
|
|
37
|
+
import { withReviewSlot } from "../../../runtime/src/review/slots.js";
|
|
37
38
|
import { memoizePreflight } from "../../../runtime/src/review/batch.js";
|
|
38
39
|
import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
|
|
39
40
|
import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
|
|
@@ -452,6 +453,25 @@ else {
|
|
|
452
453
|
const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
|
|
453
454
|
const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
|
|
454
455
|
const configuredReviewTargets = reviewRole?.targets ?? [];
|
|
456
|
+
// Every worktree of the repository shares this directory, so the
|
|
457
|
+
// slots bound reviews across writers, not just within one process.
|
|
458
|
+
const commonDirResult = await reviewGit.run([
|
|
459
|
+
"rev-parse",
|
|
460
|
+
"--path-format=absolute",
|
|
461
|
+
"--git-common-dir"
|
|
462
|
+
]);
|
|
463
|
+
const slotDir = commonDirResult.exitCode === 0
|
|
464
|
+
? new TextDecoder().decode(commonDirResult.stdout).trim()
|
|
465
|
+
: undefined;
|
|
466
|
+
const inSlot = (executor) => slotDir === undefined
|
|
467
|
+
? executor
|
|
468
|
+
: async (request) => await withReviewSlot({
|
|
469
|
+
dir: slotDir,
|
|
470
|
+
signal,
|
|
471
|
+
onWait: (line) => {
|
|
472
|
+
process.stderr.write(`${progressPrefix}${line}\n`);
|
|
473
|
+
}
|
|
474
|
+
}, async () => await executor(request));
|
|
455
475
|
return {
|
|
456
476
|
args: reviewArgs,
|
|
457
477
|
authorized: reviewArgs.yes,
|
|
@@ -470,7 +490,7 @@ else {
|
|
|
470
490
|
currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config),
|
|
471
491
|
config: reviewConfig,
|
|
472
492
|
evidenceStore: new FileEvidenceStore(root, root),
|
|
473
|
-
execute: createReviewExecutor({
|
|
493
|
+
execute: inSlot(createReviewExecutor({
|
|
474
494
|
targets: configuredReviewTargets,
|
|
475
495
|
cwd: root,
|
|
476
496
|
...(reviewRole?.model === undefined
|
|
@@ -495,7 +515,7 @@ else {
|
|
|
495
515
|
onProgress: (line) => {
|
|
496
516
|
process.stderr.write(`${progressPrefix}${line}\n`);
|
|
497
517
|
}
|
|
498
|
-
})
|
|
518
|
+
}))
|
|
499
519
|
};
|
|
500
520
|
};
|
|
501
521
|
if (args.command === "review") {
|
|
@@ -11,6 +11,7 @@ function listText(statuses, now) {
|
|
|
11
11
|
`${record.name}${idle.has(record.name) ? " (idle)" : ""}`,
|
|
12
12
|
` path: ${record.path}`,
|
|
13
13
|
` session: ${record.sessionId}`,
|
|
14
|
+
...(record.agentId === undefined ? [] : [` agent: ${record.agentId}`]),
|
|
14
15
|
` branch: ${record.branch}, ${ahead} commit(s) ahead of ${record.targetBranch}${dirty ? ", uncommitted changes" : ""}`,
|
|
15
16
|
` task: ${taskStatus}`,
|
|
16
17
|
` last activity: ${lastActivity}`
|
|
@@ -6,6 +6,9 @@ import { okEnvelope } from "../output.js";
|
|
|
6
6
|
import { formatOperationPlan } from "../plan-output.js";
|
|
7
7
|
import { toPublicUpdatePlan } from "../public-plan.js";
|
|
8
8
|
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
9
|
+
function repairedLines(plan) {
|
|
10
|
+
return plan.installation.repaired.map(({ path, reason }) => `repaired: ${path} (${reason.replace("-", " ")})`);
|
|
11
|
+
}
|
|
9
12
|
export function formatUpdatePlan(plan, trust) {
|
|
10
13
|
const detectedVerification = plan.installation.detectedVerification;
|
|
11
14
|
return formatOperationPlan({
|
|
@@ -21,6 +24,7 @@ export function formatUpdatePlan(plan, trust) {
|
|
|
21
24
|
: ""}`
|
|
22
25
|
]),
|
|
23
26
|
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
27
|
+
...repairedLines(plan),
|
|
24
28
|
`Schema migrations: ${plan.migrationSteps.length === 0
|
|
25
29
|
? "none"
|
|
26
30
|
: plan.migrationSteps
|
|
@@ -146,6 +150,14 @@ export async function runUpdateCommand(options) {
|
|
|
146
150
|
return okEnvelope("UPDATE_APPLIED", {
|
|
147
151
|
applied: true,
|
|
148
152
|
plan: toPublicUpdatePlan(plan, trust),
|
|
149
|
-
message: `Managed installation updated to ${plan.targetVersion}
|
|
153
|
+
message: `Managed installation updated to ${plan.targetVersion}.`,
|
|
154
|
+
...(plan.installation.repaired.length === 0
|
|
155
|
+
? {}
|
|
156
|
+
: {
|
|
157
|
+
text: [
|
|
158
|
+
`Managed installation updated to ${plan.targetVersion}.`,
|
|
159
|
+
...repairedLines(plan)
|
|
160
|
+
].join("\n")
|
|
161
|
+
})
|
|
150
162
|
});
|
|
151
163
|
}
|
|
@@ -353,7 +353,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
353
353
|
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
354
354
|
: await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
|
|
355
355
|
? undefined
|
|
356
|
-
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
356
|
+
: async (sessionId, agentId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId, ...(agentId === undefined ? {} : { agentId }) })).path, write.agentId);
|
|
357
357
|
}
|
|
358
358
|
: undefined;
|
|
359
359
|
const worktreeEnter = config.worktree?.mode === "auto"
|
|
@@ -361,7 +361,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
361
361
|
const mainRoot = await resolveMainRoot(gitRunner);
|
|
362
362
|
return mainRoot === null
|
|
363
363
|
? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
|
|
364
|
-
: await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId);
|
|
364
|
+
: await evaluateWorktreeEnter(mainRoot, enter.path, enter.sessionId, enter.agentId);
|
|
365
365
|
}
|
|
366
366
|
: undefined;
|
|
367
367
|
if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
|
|
@@ -70,6 +70,7 @@ export function toPublicInstallPlan(plan, trust) {
|
|
|
70
70
|
...toPublicOperations(plan.operations),
|
|
71
71
|
...toPublicPreauthorization(plan.preauthorization)
|
|
72
72
|
],
|
|
73
|
+
repaired: plan.repaired,
|
|
73
74
|
detectedVerification: plan.detectedVerification,
|
|
74
75
|
...(trust === undefined ? {} : { trust })
|
|
75
76
|
};
|
|
@@ -27,6 +27,10 @@ export function normalizeClaudeHookInput(input) {
|
|
|
27
27
|
const sessionId = typeof input.session_id === "string"
|
|
28
28
|
? input.session_id
|
|
29
29
|
: undefined;
|
|
30
|
+
// Present only inside a subagent, which shares the parent's session_id.
|
|
31
|
+
const agentId = typeof input.agent_id === "string" && /^[A-Za-z0-9_-]{1,128}$/u.test(input.agent_id)
|
|
32
|
+
? input.agent_id
|
|
33
|
+
: undefined;
|
|
30
34
|
if (input.hook_event_name === "SessionStart") {
|
|
31
35
|
return {
|
|
32
36
|
...normalizeHookEvent({ event: "session-start", projectRoot }),
|
|
@@ -59,7 +63,8 @@ export function normalizeClaudeHookInput(input) {
|
|
|
59
63
|
event: "file-write",
|
|
60
64
|
projectRoot,
|
|
61
65
|
paths: [resolve(projectRoot, target)],
|
|
62
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
66
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
67
|
+
...(agentId === undefined ? {} : { agentId })
|
|
63
68
|
};
|
|
64
69
|
}
|
|
65
70
|
}
|
|
@@ -76,7 +81,8 @@ export function normalizeClaudeHookInput(input) {
|
|
|
76
81
|
event: "enter-worktree",
|
|
77
82
|
projectRoot,
|
|
78
83
|
path: resolve(projectRoot, target),
|
|
79
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
84
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
85
|
+
...(agentId === undefined ? {} : { agentId })
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
}
|
|
@@ -47,9 +47,11 @@ function parseState(source, sessionId) {
|
|
|
47
47
|
throw new AgentOpsError("COMPLETION_GATE_STATE_INVALID", "Completion-gate state is invalid.");
|
|
48
48
|
}
|
|
49
49
|
const record = value;
|
|
50
|
-
const keys = Object.keys(record).filter((key) => key !== "root").sort().join(",");
|
|
50
|
+
const keys = Object.keys(record).filter((key) => key !== "root" && key !== "extraRoots").sort().join(",");
|
|
51
51
|
if (keys !== "baselineFingerprint,permitFingerprint,schemaVersion,sessionId" ||
|
|
52
52
|
("root" in record && !validRoot(record.root)) ||
|
|
53
|
+
("extraRoots" in record &&
|
|
54
|
+
(!Array.isArray(record.extraRoots) || record.extraRoots.length > 64 || !record.extraRoots.every(validRoot))) ||
|
|
53
55
|
record.schemaVersion !== 1 ||
|
|
54
56
|
record.sessionId !== sessionId ||
|
|
55
57
|
typeof record.baselineFingerprint !== "string" ||
|
|
@@ -253,12 +255,75 @@ export class CompletionGateService {
|
|
|
253
255
|
}
|
|
254
256
|
async grantPermit(sessionId) {
|
|
255
257
|
const fingerprint = await this.#fingerprint();
|
|
256
|
-
await this.#store.mutate(sessionId, (state) => {
|
|
258
|
+
const granted = await this.#store.mutate(sessionId, (state) => {
|
|
257
259
|
if (state === null) {
|
|
258
260
|
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session has no completion-gate baseline.");
|
|
259
261
|
}
|
|
260
262
|
return { ...state, permitFingerprint: fingerprint };
|
|
261
263
|
});
|
|
264
|
+
// One approved Stop covers every worktree the session works in.
|
|
265
|
+
if (this.#options.forRoot !== undefined) {
|
|
266
|
+
for (const root of this.#delegatedRoots(granted)) {
|
|
267
|
+
if (await isDirectory(root))
|
|
268
|
+
await (await this.#options.forRoot(root)).grantPermit(sessionId);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** The worktrees whose gates decide for this session, other than this checkout. */
|
|
273
|
+
#delegatedRoots(state) {
|
|
274
|
+
const roots = [...(state.root === undefined ? [] : [state.root]), ...(state.extraRoots ?? [])];
|
|
275
|
+
return [...new Set(roots)].filter((root) => root !== this.#options.root);
|
|
276
|
+
}
|
|
277
|
+
/** Adds a subagent's worktree to the roots this session's Stop must cover. */
|
|
278
|
+
async addRoot(sessionId, root) {
|
|
279
|
+
const fingerprint = await this.#fingerprint();
|
|
280
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
281
|
+
const base = current ?? {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
sessionId,
|
|
284
|
+
baselineFingerprint: fingerprint,
|
|
285
|
+
permitFingerprint: null
|
|
286
|
+
};
|
|
287
|
+
return { ...base, extraRoots: [...new Set([...(base.extraRoots ?? []), root])] };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Drops one worktree from the session, leaving its other roots in place. */
|
|
291
|
+
async removeRoot(sessionId, root) {
|
|
292
|
+
if (await this.#store.read(sessionId) === null)
|
|
293
|
+
return;
|
|
294
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
295
|
+
if (current === null)
|
|
296
|
+
throw new AgentOpsError("COMPLETION_GATE_NOT_INITIALIZED", "The session baseline disappeared.");
|
|
297
|
+
const { root: own, extraRoots, ...rest } = current;
|
|
298
|
+
const remaining = (extraRoots ?? []).filter((candidate) => candidate !== root);
|
|
299
|
+
return {
|
|
300
|
+
...rest,
|
|
301
|
+
...(own === undefined || own === root ? {} : { root: own }),
|
|
302
|
+
...(remaining.length === 0 ? {} : { extraRoots: remaining })
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* After `worktree finish` merged one worktree: moves the baseline past the
|
|
308
|
+
* merge and forgets that worktree, keeping the session's other roots.
|
|
309
|
+
*/
|
|
310
|
+
async afterFinish(sessionId, finishedRoot, baselineFingerprint) {
|
|
311
|
+
await this.#store.mutate(sessionId, (current) => {
|
|
312
|
+
const { root: own, extraRoots, ...rest } = current ?? {
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
sessionId,
|
|
315
|
+
baselineFingerprint,
|
|
316
|
+
permitFingerprint: null
|
|
317
|
+
};
|
|
318
|
+
const remaining = (extraRoots ?? []).filter((candidate) => candidate !== finishedRoot);
|
|
319
|
+
return {
|
|
320
|
+
...rest,
|
|
321
|
+
baselineFingerprint,
|
|
322
|
+
permitFingerprint: null,
|
|
323
|
+
...(own === undefined || own === finishedRoot ? {} : { root: own }),
|
|
324
|
+
...(remaining.length === 0 ? {} : { extraRoots: remaining })
|
|
325
|
+
};
|
|
326
|
+
});
|
|
262
327
|
}
|
|
263
328
|
#isPermitCommand(event) {
|
|
264
329
|
const commands = event.event === "command" ? [event] :
|
|
@@ -360,16 +425,24 @@ export class CompletionGateService {
|
|
|
360
425
|
: "The session baseline is unavailable: its SessionStart never reached this checkout. The next tool call records one; if this repeats, run agent-ops doctor.");
|
|
361
426
|
}
|
|
362
427
|
const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
|
|
363
|
-
|
|
428
|
+
const delegated = this.#delegatedRoots(state);
|
|
429
|
+
if (delegated.length > 0) {
|
|
364
430
|
// This checkout only has to stay untouched; the work, its task and its
|
|
365
|
-
// evidence live in the
|
|
431
|
+
// evidence live in the worktrees, and each of their gates decides.
|
|
366
432
|
if (changed) {
|
|
367
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${
|
|
433
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_MAIN_CHANGED", `This session works in ${delegated.join(", ")}, but this checkout changed too. Move those edits into a worktree, or ask the user for a one-time permit.`);
|
|
368
434
|
}
|
|
369
|
-
|
|
370
|
-
|
|
435
|
+
let last = null;
|
|
436
|
+
for (const root of delegated) {
|
|
437
|
+
if (!await isDirectory(root) || this.#options.forRoot === undefined) {
|
|
438
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_WORKTREE_MISSING", `The worktree ${root} is unavailable. Run agent-ops worktree list, then resume or remove it.`);
|
|
439
|
+
}
|
|
440
|
+
const result = await (await this.#options.forRoot(root)).handle(event);
|
|
441
|
+
if (result !== null && result.action === "block")
|
|
442
|
+
return result;
|
|
443
|
+
last = result;
|
|
371
444
|
}
|
|
372
|
-
return
|
|
445
|
+
return last;
|
|
373
446
|
}
|
|
374
447
|
if (changed) {
|
|
375
448
|
const failure = await this.#validateTask(sessionId);
|
|
@@ -5,7 +5,7 @@ import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
|
|
|
5
5
|
import { resolveContainedPath } from "../fs/paths.js";
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
7
|
import { detectHostRestriction } from "../review/host-sandbox.js";
|
|
8
|
-
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
|
+
import { assertExpectedManagedBlock, managedBlockBoundariesIntact, assertSupportedManifestOwnership } from "./ownership.js";
|
|
9
9
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
10
10
|
import { harnessDescriptor, managedRules } from "./harness.js";
|
|
11
11
|
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
@@ -214,6 +214,7 @@ async function checkMarkers(root, manifest) {
|
|
|
214
214
|
}
|
|
215
215
|
const failures = [];
|
|
216
216
|
const legacyPaths = [];
|
|
217
|
+
const driftedPaths = [];
|
|
217
218
|
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
218
219
|
for (const marker of manifest.markers) {
|
|
219
220
|
try {
|
|
@@ -229,12 +230,18 @@ async function checkMarkers(root, manifest) {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
catch {
|
|
232
|
-
|
|
233
|
+
// Intact markers with changed content are repaired by update; broken
|
|
234
|
+
// boundaries are not, since the owned span cannot be located.
|
|
235
|
+
const intact = await readContainedText(root, marker.path).then((source) => managedBlockBoundariesIntact(source, marker), () => false);
|
|
236
|
+
(intact ? driftedPaths : failures).push(marker.path);
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
if (failures.length > 0) {
|
|
236
240
|
return check("markers", "FAIL", `Managed block markers failed verification: ${failures.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`.");
|
|
237
241
|
}
|
|
242
|
+
if (driftedPaths.length > 0) {
|
|
243
|
+
return check("markers", "DEGRADED", `Managed block content changed after installation: ${driftedPaths.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`; it rewrites the content between the markers and leaves the rest of the file.");
|
|
244
|
+
}
|
|
238
245
|
if (legacyPaths.length > 0) {
|
|
239
246
|
return check("markers", "DEGRADED", `Legacy managed routing blocks need migration: ${legacyPaths.join(", ")}.`, "UPDATE_REQUIRED", "Run `agent-ops update`.");
|
|
240
247
|
}
|
|
@@ -221,12 +221,16 @@ function exactMarkerCount(source, marker) {
|
|
|
221
221
|
}
|
|
222
222
|
return count;
|
|
223
223
|
}
|
|
224
|
+
/** The start and end markers each appear exactly once, in order. */
|
|
225
|
+
export function managedBlockBoundariesIntact(source, marker) {
|
|
226
|
+
return (exactMarkerCount(source, marker.startMarker) === 1 &&
|
|
227
|
+
exactMarkerCount(source, marker.endMarker) === 1 &&
|
|
228
|
+
source.indexOf(marker.startMarker) < source.indexOf(marker.endMarker));
|
|
229
|
+
}
|
|
224
230
|
export function assertExpectedManagedBlock(source, marker, expected) {
|
|
225
231
|
const startIndex = source.indexOf(marker.startMarker);
|
|
226
232
|
const endIndex = source.indexOf(marker.endMarker);
|
|
227
|
-
if (
|
|
228
|
-
exactMarkerCount(source, marker.endMarker) !== 1 ||
|
|
229
|
-
startIndex >= endIndex) {
|
|
233
|
+
if (!managedBlockBoundariesIntact(source, marker)) {
|
|
230
234
|
throw new AgentOpsError("MANAGED_BLOCK_CHANGED", `Managed block boundaries changed after installation: ${marker.path}`);
|
|
231
235
|
}
|
|
232
236
|
const currentBlock = normalizeToLF(source.slice(startIndex, endIndex + marker.endMarker.length));
|
|
@@ -7,7 +7,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
|
7
7
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
8
8
|
import { validateConfig } from "../schema/validate.js";
|
|
9
9
|
import { planHarnessContributions, harnessDescriptor, selectHarnessHookSurface } from "./harness.js";
|
|
10
|
-
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
|
+
import { assertExpectedManagedBlock, managedBlockBoundariesIntact, assertSupportedManifestOwnership } from "./ownership.js";
|
|
11
11
|
import { codexHooksExplicitlyDisabled, loopLauncherArtifactId, loopSeeds, planLoopContribution, selectedLoopHarnesses } from "./codex-loop.js";
|
|
12
12
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
13
13
|
import { planHookRegistration, planHookRemoval } from "./hooks.js";
|
|
@@ -227,13 +227,13 @@ async function planArtifact(root, artifact, existingManifest) {
|
|
|
227
227
|
if (current !== null && owned === undefined) {
|
|
228
228
|
throw new AgentOpsError("UNMANAGED_INSTALL_PATH", `Refusing to replace an unmanaged install artifact: ${artifact.path}`);
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
owned.hash !== current.hash) {
|
|
233
|
-
throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
|
|
234
|
-
}
|
|
230
|
+
// Drifted managed artifacts are agent-ops owned: rewrite them and report it.
|
|
231
|
+
const drifted = current !== null && owned !== undefined && owned.hash !== current.hash;
|
|
235
232
|
const hash = sha256(artifact.content);
|
|
236
233
|
return {
|
|
234
|
+
...(drifted
|
|
235
|
+
? { repaired: { path: artifact.path, reason: "artifact-drift" } }
|
|
236
|
+
: {}),
|
|
237
237
|
operation: {
|
|
238
238
|
kind: "write",
|
|
239
239
|
path: artifact.path,
|
|
@@ -335,6 +335,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
335
335
|
}
|
|
336
336
|
const operations = [];
|
|
337
337
|
const records = [];
|
|
338
|
+
const repaired = [];
|
|
338
339
|
for (const { path, blocks: pathBlocks, removals: pathRemovals } of grouped.values()) {
|
|
339
340
|
const current = await readCurrentFile(root, path);
|
|
340
341
|
if (pathRemovals.length > 0 && current === null) {
|
|
@@ -350,6 +351,30 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
350
351
|
content = removeManagedBlock(content, marker.id, expected.markerStyle);
|
|
351
352
|
}
|
|
352
353
|
for (const block of pathBlocks) {
|
|
354
|
+
const expected = expectedMarkers.get(block.id);
|
|
355
|
+
if (current !== null && expected !== undefined) {
|
|
356
|
+
const { start, end } = managedBlockMarkers(block.id, block.version, block.markerStyle);
|
|
357
|
+
const marker = {
|
|
358
|
+
id: block.id,
|
|
359
|
+
path,
|
|
360
|
+
hash: sha256(content),
|
|
361
|
+
owner: "agent-ops",
|
|
362
|
+
startMarker: start,
|
|
363
|
+
endMarker: end
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
assertExpectedManagedBlock(content, marker, expected);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
// Content drift is repaired below; broken boundaries stay an error.
|
|
370
|
+
if (!managedBlockBoundariesIntact(content, marker)) {
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
if (!repaired.some((entry) => entry.path === path)) {
|
|
374
|
+
repaired.push({ path, reason: "block-drift" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
353
378
|
content = applyManagedBlock(content, block);
|
|
354
379
|
}
|
|
355
380
|
const hash = sha256(content);
|
|
@@ -380,7 +405,7 @@ async function planBlocks(root, blocks, removals = [], expectedMarkers = new Map
|
|
|
380
405
|
});
|
|
381
406
|
}
|
|
382
407
|
}
|
|
383
|
-
return { operations, records };
|
|
408
|
+
return { operations, records, repaired };
|
|
384
409
|
}
|
|
385
410
|
export async function createInstallPlan(options) {
|
|
386
411
|
if (options.toolkitVersion !== undefined &&
|
|
@@ -498,10 +523,14 @@ export async function createInstallPlan(options) {
|
|
|
498
523
|
const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
|
|
499
524
|
operations.push(config.operation);
|
|
500
525
|
artifacts.push(config.record);
|
|
526
|
+
const repaired = [];
|
|
501
527
|
for (const artifact of contribution.artifacts) {
|
|
502
528
|
const planned = await planArtifact(options.root, artifact, existing?.manifest ?? null);
|
|
503
529
|
operations.push(planned.operation);
|
|
504
530
|
artifacts.push(planned.record);
|
|
531
|
+
if (planned.repaired !== undefined) {
|
|
532
|
+
repaired.push(planned.repaired);
|
|
533
|
+
}
|
|
505
534
|
}
|
|
506
535
|
if (resolved.capabilities.includes("project-loop")) {
|
|
507
536
|
const seedHarnesses = loopHarnessesNeedingSeeds(options.harness, existing?.manifest ?? null);
|
|
@@ -513,6 +542,7 @@ export async function createInstallPlan(options) {
|
|
|
513
542
|
}
|
|
514
543
|
const plannedBlocks = await planBlocks(options.root, contribution.blocks, markersToRemove, expectedExistingMarkers);
|
|
515
544
|
operations.push(...plannedBlocks.operations);
|
|
545
|
+
repaired.push(...plannedBlocks.repaired);
|
|
516
546
|
const hooks = [];
|
|
517
547
|
if (options.hookRuntimePath !== undefined) {
|
|
518
548
|
for (const harness of options.harness) {
|
|
@@ -614,6 +644,7 @@ export async function createInstallPlan(options) {
|
|
|
614
644
|
config: config.config,
|
|
615
645
|
manifest,
|
|
616
646
|
operations,
|
|
647
|
+
repaired,
|
|
617
648
|
...(preauthorization.length === 0 ? {} : { preauthorization }),
|
|
618
649
|
detectedVerification: config.detectedVerification,
|
|
619
650
|
verificationBlockers: config.verificationBlockers
|
|
@@ -17,14 +17,26 @@ export const CLAUDE_PREAUTH_ALLOW = [
|
|
|
17
17
|
"Bash(agent-ops review *)",
|
|
18
18
|
"Bash(agent-ops batch *)",
|
|
19
19
|
"Bash(agent-ops worktree *)",
|
|
20
|
-
"Bash(agent-ops doctor *)"
|
|
20
|
+
"Bash(agent-ops doctor *)",
|
|
21
|
+
// The PreToolUse hook still blocks a path this session does not own.
|
|
22
|
+
"EnterWorktree",
|
|
23
|
+
// A worktree commit runs the project's pre-commit hook, usually this.
|
|
24
|
+
"Bash(npx lint-staged *)"
|
|
21
25
|
];
|
|
22
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* review and batch talk to the reviewer's API, which the sandbox withholds;
|
|
28
|
+
* worktree commit and lint-staged write the main .git, which a worktree's
|
|
29
|
+
* sandbox cannot.
|
|
30
|
+
*/
|
|
23
31
|
export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
24
32
|
"agent-ops review",
|
|
25
33
|
"agent-ops review *",
|
|
26
34
|
"agent-ops batch",
|
|
27
|
-
"agent-ops batch *"
|
|
35
|
+
"agent-ops batch *",
|
|
36
|
+
"agent-ops worktree commit",
|
|
37
|
+
"agent-ops worktree commit *",
|
|
38
|
+
"npx lint-staged",
|
|
39
|
+
"npx lint-staged *"
|
|
28
40
|
];
|
|
29
41
|
export const CODEX_RULES_PATH = "rules/agent-ops.rules";
|
|
30
42
|
export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
|
|
@@ -60,6 +72,26 @@ function rule(options) {
|
|
|
60
72
|
*/
|
|
61
73
|
export const CODEX_RULES_CONTENT = `${[
|
|
62
74
|
CODEX_RULES_MARKER,
|
|
75
|
+
...[
|
|
76
|
+
{
|
|
77
|
+
pattern: ["agent-ops", "worktree", "commit"],
|
|
78
|
+
justification: "agent-ops worktree commit runs git hooks, which write the main .git outside the sandbox.",
|
|
79
|
+
match: "agent-ops worktree commit -m message"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
pattern: ["npx", "lint-staged"],
|
|
83
|
+
justification: "lint-staged backs up through the main .git, which the sandbox cannot write from a worktree.",
|
|
84
|
+
match: "npx lint-staged --debug"
|
|
85
|
+
}
|
|
86
|
+
].map((entry) => [
|
|
87
|
+
"prefix_rule(",
|
|
88
|
+
` pattern = ${starlarkList(entry.pattern)},`,
|
|
89
|
+
' decision = "allow",',
|
|
90
|
+
` justification = ${JSON.stringify(entry.justification)},`,
|
|
91
|
+
` match = [${JSON.stringify(entry.match)}],`,
|
|
92
|
+
' not_match = ["agent-ops trust grant --scope project --yes"]',
|
|
93
|
+
")"
|
|
94
|
+
].join("\n")),
|
|
63
95
|
...[true, false].flatMap((withHost) => {
|
|
64
96
|
const host = withHost ? "AGENT_OPS_HOST=codex " : "";
|
|
65
97
|
const env = `env -u CODEX_SANDBOX_NETWORK_DISABLED ${host}agent-ops`;
|
|
@@ -240,7 +272,7 @@ async function codexChange(codexHome, desired) {
|
|
|
240
272
|
content: CODEX_RULES_CONTENT,
|
|
241
273
|
expectedHash: current?.hash ?? null
|
|
242
274
|
},
|
|
243
|
-
summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review
|
|
275
|
+
summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review, batch, doctor --check-auth, worktree commit and lint-staged.`,
|
|
244
276
|
ownedContent: true
|
|
245
277
|
};
|
|
246
278
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { canonicalPath } from "./guard.js";
|
|
4
|
-
import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
4
|
+
import { mayUseWorktree, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
5
5
|
/**
|
|
6
6
|
* Whether Claude's EnterWorktree may switch this session into `path`. Only the
|
|
7
7
|
* worktree agent-ops recorded for the session is answered for the host, so an
|
|
8
8
|
* allow never lets the session move its permission root to a directory it did
|
|
9
9
|
* not get from `worktree add`.
|
|
10
10
|
*/
|
|
11
|
-
export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
11
|
+
export async function evaluateWorktreeEnter(mainRoot, path, sessionId, agentId) {
|
|
12
12
|
if (sessionId === undefined) {
|
|
13
13
|
return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
|
|
14
14
|
}
|
|
@@ -16,7 +16,8 @@ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
|
16
16
|
const target = await canonicalPath(path);
|
|
17
17
|
const name = relative(directory, target).split(sep)[0] ?? "";
|
|
18
18
|
const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
|
|
19
|
-
|
|
19
|
+
const entered = inside ? await readWorktreeRecord(target) : null;
|
|
20
|
+
if (entered !== null && mayUseWorktree(entered, sessionId, agentId)) {
|
|
20
21
|
return {
|
|
21
22
|
action: "continue",
|
|
22
23
|
status: "PASS",
|
|
@@ -27,7 +28,8 @@ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
|
|
|
27
28
|
}
|
|
28
29
|
const owned = [];
|
|
29
30
|
for (const entry of await readdir(directory).catch(() => [])) {
|
|
30
|
-
|
|
31
|
+
const record = await readWorktreeRecord(join(directory, entry));
|
|
32
|
+
if (record !== null && mayUseWorktree(record, sessionId, agentId)) {
|
|
31
33
|
owned.push(join(directory, entry));
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -319,7 +319,8 @@ export async function finishWorktree(deps, options) {
|
|
|
319
319
|
await mainGate.rebase(before, after);
|
|
320
320
|
// rebase only moves baselines equal to `before`; a main that moved since
|
|
321
321
|
// this session began would leave it reading the merge as its own change.
|
|
322
|
-
|
|
322
|
+
// Only this worktree leaves the session; its other roots stay covered.
|
|
323
|
+
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
323
324
|
});
|
|
324
325
|
}
|
|
325
326
|
await attempt("trust", async () => await deps.trust.revoke(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, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
|
|
3
|
+
import { insideWorktreeDirectory, mayUseWorktree, 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"]);
|
|
@@ -42,8 +42,10 @@ export async function canonicalPath(path) {
|
|
|
42
42
|
* business.
|
|
43
43
|
*/
|
|
44
44
|
export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
|
|
45
|
-
/** Creates or reuses this
|
|
46
|
-
ensureWorktree
|
|
45
|
+
/** Creates or reuses this writer's worktree and returns its path. */
|
|
46
|
+
ensureWorktree,
|
|
47
|
+
/** The subagent making the write, when there is one: it has a worktree of its own. */
|
|
48
|
+
agentId) {
|
|
47
49
|
for (const path of paths) {
|
|
48
50
|
const target = await canonicalPath(path);
|
|
49
51
|
const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
|
|
@@ -51,17 +53,19 @@ ensureWorktree) {
|
|
|
51
53
|
// A worktree belongs to the session that made it: writing into another
|
|
52
54
|
// session's mixes two tasks' changes into one review and one merge.
|
|
53
55
|
const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
+
const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
|
|
57
|
+
if (record !== null && !mayUseWorktree(record, sessionId, agentId)) {
|
|
58
|
+
const owner = record.sessionId;
|
|
59
|
+
const sameSession = owner === sessionId;
|
|
56
60
|
let own = "";
|
|
57
61
|
if (ensureWorktree !== undefined) {
|
|
58
|
-
own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
|
|
62
|
+
own = await ensureWorktree(sessionId, agentId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead${agentId === undefined ? "" : ", with absolute paths under it"}.`, () => "");
|
|
59
63
|
}
|
|
60
64
|
return {
|
|
61
65
|
action: "block",
|
|
62
66
|
status: "FAIL",
|
|
63
|
-
code: "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
64
|
-
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
|
|
67
|
+
code: sameSession ? "WORKTREE_OWNED_BY_OTHER_AGENT" : "WORKTREE_OWNED_BY_OTHER_SESSION",
|
|
68
|
+
remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to ${sameSession ? `another writer of this session (${record.agentId ?? "its main thread"})` : `another session (${owner})`}; ` +
|
|
65
69
|
`this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
|
|
66
70
|
};
|
|
67
71
|
}
|
|
@@ -70,12 +74,14 @@ ensureWorktree) {
|
|
|
70
74
|
let failure = "";
|
|
71
75
|
if (sessionId !== undefined && ensureWorktree !== undefined) {
|
|
72
76
|
try {
|
|
73
|
-
const worktree = await ensureWorktree(sessionId);
|
|
77
|
+
const worktree = await ensureWorktree(sessionId, agentId);
|
|
74
78
|
return {
|
|
75
79
|
action: "block",
|
|
76
80
|
status: "FAIL",
|
|
77
81
|
code: "WORKTREE_CREATED",
|
|
78
|
-
remedy:
|
|
82
|
+
remedy: agentId === undefined
|
|
83
|
+
? `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))}.`
|
|
84
|
+
: `worktree.mode is auto, so this subagent now has its own worktree at ${worktree}. Redo this edit there with an absolute path, at ${join(worktree, relative(mainRoot, target))}, and use absolute paths under ${worktree} for every later edit.`
|
|
79
85
|
};
|
|
80
86
|
}
|
|
81
87
|
catch (error) {
|
|
@@ -149,7 +149,10 @@ export async function resumeWorktree(deps, options) {
|
|
|
149
149
|
await writeWorktreeRecord(record);
|
|
150
150
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
151
151
|
if (mainConfig.features.completionGate.enabled && previous.sessionId !== sessionId) {
|
|
152
|
-
|
|
152
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
153
|
+
await (previous.agentId === undefined
|
|
154
|
+
? mainGate.redirect(previous.sessionId, null)
|
|
155
|
+
: mainGate.removeRoot(previous.sessionId, previous.path));
|
|
153
156
|
}
|
|
154
157
|
await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
|
|
155
158
|
return record;
|
|
@@ -206,7 +209,10 @@ export async function removeWorktree(deps, options) {
|
|
|
206
209
|
const { record } = status;
|
|
207
210
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
208
211
|
if (mainConfig.features.completionGate.enabled) {
|
|
209
|
-
|
|
212
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
213
|
+
await (record.agentId === undefined
|
|
214
|
+
? mainGate.redirect(record.sessionId, null)
|
|
215
|
+
: mainGate.removeRoot(record.sessionId, record.path));
|
|
210
216
|
}
|
|
211
217
|
await deps.trust.revoke(record.path, await deps.loadConfig(record.path)).catch(() => undefined);
|
|
212
218
|
await removeCheckout(deps, record, true);
|
|
@@ -24,6 +24,10 @@ const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
|
24
24
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
25
25
|
const TARGET_BRANCH = /^[A-Za-z0-9._/-]{1,128}$/u;
|
|
26
26
|
const DEFAULT_SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
27
|
+
/** The session's own worktree is open to its main thread; a subagent's only to that subagent. */
|
|
28
|
+
export function mayUseWorktree(record, sessionId, agentId) {
|
|
29
|
+
return record.sessionId === sessionId && (agentId === undefined || record.agentId === agentId);
|
|
30
|
+
}
|
|
27
31
|
function worktreeError(code, message) {
|
|
28
32
|
return new AgentOpsError(code, message);
|
|
29
33
|
}
|
|
@@ -194,6 +198,7 @@ export async function readWorktreeRecord(root) {
|
|
|
194
198
|
try {
|
|
195
199
|
const value = JSON.parse(source);
|
|
196
200
|
return value.schemaVersion === 1 && typeof value.name === "string" &&
|
|
201
|
+
(value.agentId === undefined || typeof value.agentId === "string") &&
|
|
197
202
|
typeof value.branch === "string" && typeof value.path === "string" &&
|
|
198
203
|
typeof value.mainRoot === "string" && typeof value.targetBranch === "string" &&
|
|
199
204
|
typeof value.base === "string" && typeof value.sessionId === "string" &&
|
|
@@ -219,7 +224,12 @@ export async function bindSession(deps, record, mainConfig, baselineFingerprint)
|
|
|
219
224
|
return;
|
|
220
225
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
221
226
|
await (await deps.gate(record.path, worktreeConfig)).seed(record.sessionId, baselineFingerprint);
|
|
222
|
-
|
|
227
|
+
const mainGate = await deps.gate(record.mainRoot, mainConfig);
|
|
228
|
+
// A subagent's worktree joins the session's roots instead of replacing its own.
|
|
229
|
+
if (record.agentId === undefined)
|
|
230
|
+
await mainGate.redirect(record.sessionId, record.path);
|
|
231
|
+
else
|
|
232
|
+
await mainGate.addRoot(record.sessionId, record.path);
|
|
223
233
|
}
|
|
224
234
|
export async function removeCheckout(deps, record, force) {
|
|
225
235
|
await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
|
|
@@ -306,6 +316,7 @@ export async function addWorktree(deps, options) {
|
|
|
306
316
|
targetBranch,
|
|
307
317
|
base,
|
|
308
318
|
sessionId,
|
|
319
|
+
...(options.agentId === undefined ? {} : { agentId: options.agentId }),
|
|
309
320
|
createdAt: deps.now?.() ?? new Date().toISOString()
|
|
310
321
|
};
|
|
311
322
|
let trusted = false;
|
|
@@ -336,7 +347,9 @@ export async function addWorktree(deps, options) {
|
|
|
336
347
|
}
|
|
337
348
|
catch (error) {
|
|
338
349
|
if (mainConfig.features.completionGate.enabled) {
|
|
339
|
-
|
|
350
|
+
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
351
|
+
await (options.agentId === undefined ? mainGate.redirect(sessionId, null) : mainGate.removeRoot(sessionId, path))
|
|
352
|
+
.catch(() => undefined);
|
|
340
353
|
}
|
|
341
354
|
if (trusted)
|
|
342
355
|
await deps.trust.revoke(record.path, mainConfig).catch(() => undefined);
|
|
@@ -349,18 +362,31 @@ export function sessionWorktreeName(sessionId) {
|
|
|
349
362
|
const slug = sessionId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
350
363
|
return `session-${slug === "" ? "0" : slug}`;
|
|
351
364
|
}
|
|
365
|
+
/** The session's worktree name plus the first eight name-safe characters of the subagent id. */
|
|
366
|
+
export function agentWorktreeName(sessionId, agentId) {
|
|
367
|
+
const slug = agentId.toLowerCase().replace(/[^a-z0-9]/gu, "").slice(0, 8);
|
|
368
|
+
return `${sessionWorktreeName(sessionId)}-${slug === "" ? "0" : slug}`;
|
|
369
|
+
}
|
|
352
370
|
/**
|
|
353
|
-
* The worktree `sessionId` works in, created on
|
|
354
|
-
* to the same session is reused, so a
|
|
355
|
-
* still gets exactly one worktree.
|
|
371
|
+
* The worktree `sessionId` (or one of its subagents) works in, created on
|
|
372
|
+
* first use. One already bound to the same session and agent is reused, so a
|
|
373
|
+
* writer blocked twice before it moves still gets exactly one worktree.
|
|
356
374
|
*/
|
|
357
375
|
export async function ensureSessionWorktree(deps, options) {
|
|
358
376
|
const { mainRoot } = await resolveCheckouts(deps, options.cwd);
|
|
359
|
-
const
|
|
377
|
+
const { agentId } = options;
|
|
378
|
+
const name = agentId === undefined
|
|
379
|
+
? sessionWorktreeName(options.sessionId)
|
|
380
|
+
: agentWorktreeName(options.sessionId, agentId);
|
|
360
381
|
const existing = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
361
|
-
if (existing?.sessionId === options.sessionId)
|
|
382
|
+
if (existing?.sessionId === options.sessionId && existing.agentId === agentId)
|
|
362
383
|
return existing;
|
|
363
|
-
return (await addWorktree(deps, {
|
|
384
|
+
return (await addWorktree(deps, {
|
|
385
|
+
cwd: mainRoot,
|
|
386
|
+
name,
|
|
387
|
+
sessionId: options.sessionId,
|
|
388
|
+
...(agentId === undefined ? {} : { agentId })
|
|
389
|
+
})).record;
|
|
364
390
|
}
|
|
365
391
|
function sessionOriginPath(mainRoot, sessionId) {
|
|
366
392
|
return join(mainRoot, ".agent-ops", "tasks", "session-origin", sha256(sessionId));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { ReviewInterruptedError } from "./execute.js";
|
|
5
|
+
/** Reviews allowed at once across every worktree of a repository. */
|
|
6
|
+
export const REVIEW_SLOT_WIDTH = 2;
|
|
7
|
+
const POLL_MS = 1000;
|
|
8
|
+
// A review is two reviewer sessions of at most 15 minutes each plus probes.
|
|
9
|
+
const STALE_MS = 2 * 60 * 60 * 1000;
|
|
10
|
+
function alive(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return error.code === "EPERM";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// ponytail: same scheme as withFinishLock in parallel/finish.ts; unify if a third user appears.
|
|
20
|
+
async function stale(path, staleMs) {
|
|
21
|
+
try {
|
|
22
|
+
const owner = JSON.parse(await readFile(join(path, "owner.json"), "utf8"));
|
|
23
|
+
if (typeof owner.pid !== "number" || typeof owner.at !== "number")
|
|
24
|
+
return true;
|
|
25
|
+
return !alive(owner.pid) || Date.now() - owner.at > staleMs;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A holder between mkdir and writing its owner file looks like this too.
|
|
29
|
+
try {
|
|
30
|
+
return Date.now() - (await stat(path)).mtimeMs > 60_000;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runs `action` once one of `width` shared slots is free. Reviewer CLIs are
|
|
39
|
+
* metered and flaky under load, and writers in separate worktrees each start
|
|
40
|
+
* their own review, so this is what keeps the total bounded. A slot held by a
|
|
41
|
+
* dead process is reclaimed.
|
|
42
|
+
*/
|
|
43
|
+
export async function withReviewSlot(options, action) {
|
|
44
|
+
const width = options.width ?? REVIEW_SLOT_WIDTH;
|
|
45
|
+
if (!Number.isInteger(width) || width < 1) {
|
|
46
|
+
throw new AgentOpsError("REVIEW_SLOT_INVALID_WIDTH", "Review slot width must be a positive integer.");
|
|
47
|
+
}
|
|
48
|
+
const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
49
|
+
let waiting = false;
|
|
50
|
+
let held;
|
|
51
|
+
while (held === undefined) {
|
|
52
|
+
if (options.signal?.aborted === true)
|
|
53
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
54
|
+
for (let index = 0; index < width && held === undefined; index += 1) {
|
|
55
|
+
const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(path);
|
|
58
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
59
|
+
held = path;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "EEXIST")
|
|
63
|
+
throw error;
|
|
64
|
+
if (await stale(path, options.staleMs ?? STALE_MS)) {
|
|
65
|
+
await rm(path, { recursive: true, force: true });
|
|
66
|
+
index -= 1; // try the reclaimed slot again at once
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (held === undefined) {
|
|
71
|
+
if (!waiting) {
|
|
72
|
+
waiting = true;
|
|
73
|
+
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
74
|
+
}
|
|
75
|
+
await sleep(options.pollMs ?? POLL_MS);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await action();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await rm(held, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -236,12 +236,15 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
236
236
|
- With `worktree.mode` auto, `init` and `update` also pre-authorize what a
|
|
237
237
|
session runs itself, and the plan lists it before you confirm: in
|
|
238
238
|
`.claude/settings.local.json`, `permissions.allow` for `agent-ops task`,
|
|
239
|
-
`verify`, `review`, `worktree` and `doctor
|
|
240
|
-
|
|
239
|
+
`verify`, `review`, `worktree` and `doctor`, plus `EnterWorktree` (the hook
|
|
240
|
+
still blocks a path the session does not own) and `npx lint-staged`, and
|
|
241
|
+
`sandbox.excludedCommands` for `agent-ops review`, `batch`, `worktree commit`
|
|
242
|
+
and `npx lint-staged` (your other entries are kept, and the plan describes
|
|
241
243
|
the file instead of printing it); and, when the CLI can see Codex's home
|
|
242
244
|
(`CODEX_HOME`, else `~/.codex`), `rules/agent-ops.rules` with allow rules
|
|
243
245
|
for `agent-ops review` and `doctor --check-auth`, with and without
|
|
244
|
-
`AGENT_OPS_HOST
|
|
246
|
+
`AGENT_OPS_HOST=`, and for `agent-ops worktree commit` and `npx lint-staged`,
|
|
247
|
+
so a pre-commit hook can write the main `.git`. `trust`, `allow-stop`, `init`, `update` and `uninstall`
|
|
245
248
|
are never pre-authorized. Turning auto mode off, or `uninstall`, takes
|
|
246
249
|
exactly those entries back out; a rules file of that name agent-ops did not
|
|
247
250
|
write is left alone.
|
|
@@ -39,3 +39,13 @@ Parallel editing conversations in one repository MUST each work in their own age
|
|
|
39
39
|
- Evidence: `agent-ops worktree list` shows one worktree per editing session, and each finish reports a fast-forward merge.
|
|
40
40
|
- Positive: `Two conversations edit .worktrees/login and .worktrees/cart; each verifies, reviews and finishes on its own.`
|
|
41
41
|
- Negative: `Two conversations edit the main checkout at once, so each one's verification and review is voided by the other's writes.`
|
|
42
|
+
|
|
43
|
+
## DELEGATE-ISOLATION-003
|
|
44
|
+
|
|
45
|
+
Subagents that write files in one session MUST each work in their own agent-ops worktree, with their own task, verification and review.
|
|
46
|
+
|
|
47
|
+
- Trigger: `worktree.mode` is `auto` and a subagent writes a file. Hook calls made inside a subagent carry its `agent_id` under the parent's `session_id`; a payload without `agent_id` is treated as the session's main thread.
|
|
48
|
+
- Action: The first blocked write allocates a worktree owned by that session and agent; edit there using absolute paths, because the hook's `cwd` does not follow a `cd`. The subagent commits and runs `agent-ops verify` and `agent-ops review` in its worktree. At most two reviews run at once across all worktrees of the repository; the rest wait. The coordinator runs `agent-ops worktree finish <name>` for each worktree in turn, and Stop is allowed only when every worktree of the session has a completed task.
|
|
49
|
+
- Evidence: `agent-ops worktree list` shows an `agent:` line for each subagent worktree, writing into another writer's worktree is refused with `WORKTREE_OWNED_BY_OTHER_AGENT`, and each finish reports a fast-forward merge.
|
|
50
|
+
- Positive: `Two subagents implement independent tasks in .worktrees/session-ab12cd34-a9230363 and .worktrees/session-ab12cd34-b1111111; each reviews itself and the coordinator finishes them one after the other.`
|
|
51
|
+
- Negative: `A subagent edits the coordinator's worktree, or two subagents edit one worktree, so one review covers two tasks' changes.`
|
|
@@ -30,3 +30,13 @@ hand-edited in place.
|
|
|
30
30
|
- Evidence: The artifact hash and shim import tests pass; release documentation records the revalidation condition.
|
|
31
31
|
- Positive: `agent-ops update` rewrites a changed plugin after ownership checks pass.
|
|
32
32
|
- Negative: `Patch the opencode plugin manually and retain the old manifest hash.`
|
|
33
|
+
|
|
34
|
+
## MAINTAIN-DRIFT-001
|
|
35
|
+
|
|
36
|
+
`agent-ops update` MUST rewrite a managed artifact or managed block whose content drifted from the manifest, MUST report each rewrite, and MUST NOT rewrite anything it cannot prove it owns.
|
|
37
|
+
|
|
38
|
+
- Trigger: `agent-ops update` finds a managed whole-file artifact whose hash differs from the manifest, or a managed block whose markers are intact but whose content changed, for example after a Git operation restored an older tracked revision.
|
|
39
|
+
- Action: Rewrite the artifact, or only the content between the markers, and list it as `repaired: <path> (<reason>)`. Keep `.agent-ops/config.json`, `agent-ops uninstall`, paths absent from the manifest, and blocks with missing, duplicated or reordered markers as errors; the last needs a person to locate the owned span.
|
|
40
|
+
- Evidence: `InstallPlan.repaired` and the update output name each rewritten path; text outside the markers is byte-identical afterwards.
|
|
41
|
+
- Positive: `A tracked .agent-ops/GEMINI.md restored to an older version is rewritten by update, with "repaired: .agent-ops/GEMINI.md (artifact drift)".`
|
|
42
|
+
- Negative: `Rewrite a block whose end marker was deleted, or overwrite .agent-ops/config.json.`
|
|
@@ -19,3 +19,13 @@ The operator MUST preserve the failing evidence before applying a fix.
|
|
|
19
19
|
- Evidence: The test fails before the fix and passes after it.
|
|
20
20
|
- Positive: `RED parser test → GREEN parser test.`
|
|
21
21
|
- Negative: `Delete the failing test because it is inconvenient.`
|
|
22
|
+
|
|
23
|
+
## TROUBLESHOOT-DRIFT-001
|
|
24
|
+
|
|
25
|
+
An update that reports `repaired:` lines or a doctor check that reports drift MUST be read as managed content that no longer matched the manifest, not as a failed update.
|
|
26
|
+
|
|
27
|
+
- Trigger: `agent-ops doctor` reports `UPDATE_REQUIRED` for managed artifacts or DEGRADED managed blocks, or `agent-ops update` prints `repaired: <path> (<reason>)`.
|
|
28
|
+
- Action: Run `agent-ops update`. It rewrites a drifted managed artifact or the content between intact block markers. It still fails on `.agent-ops/config.json`, `agent-ops uninstall`, paths absent from the manifest, and blocks with missing, duplicated or reordered markers; fix those by hand, then update again. See MAINTAIN-DRIFT-001.
|
|
29
|
+
- Evidence: The update output names each repaired path and a second `agent-ops doctor` reports no drift.
|
|
30
|
+
- Positive: `A restored older GEMINI.md is rewritten and listed as "repaired: .agent-ops/GEMINI.md (artifact drift)".`
|
|
31
|
+
- Negative: `Delete the manifest to silence MANAGED_BLOCK_CHANGED on a block whose end marker is missing.`
|
|
@@ -204,11 +204,14 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
204
204
|
會把較舊的 matcher 回報為註冊漂移。
|
|
205
205
|
- 當 `worktree.mode` 為 auto,`init` 與 `update` 也會預先授權 session 自己會執行的
|
|
206
206
|
指令,並在你確認前先列在計畫裡:在 `.claude/settings.local.json` 寫入
|
|
207
|
-
`agent-ops task`、`verify`、`review`、`worktree`、`doctor
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
`agent-ops task`、`verify`、`review`、`worktree`、`doctor`,加上 `EnterWorktree`
|
|
208
|
+
(hook 仍會擋下 session 不擁有的路徑)與 `npx lint-staged` 的 `permissions.allow`,
|
|
209
|
+
以及 `agent-ops review`、`batch`、`worktree commit` 與 `npx lint-staged` 的
|
|
210
|
+
`sandbox.excludedCommands`(你原有的項目會保留,計畫只描述該檔案而不印出內容);
|
|
211
|
+
若 CLI 找得到 Codex 的 home(`CODEX_HOME`,否則 `~/.codex`),另寫入
|
|
212
|
+
`rules/agent-ops.rules`,內含 `agent-ops review` 與 `doctor --check-auth` 的 allow
|
|
213
|
+
規則(含與不含 `AGENT_OPS_HOST=`),以及 `agent-ops worktree commit` 與
|
|
214
|
+
`npx lint-staged` 的 allow 規則,讓 pre-commit hook 能寫入主 `.git`。`trust`、
|
|
212
215
|
`allow-stop`、`init`、`update`、`uninstall` 永遠不會被預先授權。關閉 auto 模式或
|
|
213
216
|
`uninstall` 會精確移除這些項目;同名但不是 agent-ops 寫的規則檔不會被動到。
|
|
214
217
|
- `review` 與 `doctor` 支援 `--output <file>`:JSON envelope 寫入該檔(權限 0600),
|
|
@@ -41,3 +41,13 @@ English source version: 2026-09-24. Revalidate: when the English specification c
|
|
|
41
41
|
- Evidence: `agent-ops worktree list` 顯示每個修改中的 session 各有一個 worktree,且每次 finish 都回報 fast-forward 合併。
|
|
42
42
|
- Positive: `兩個對話分別修改 .worktrees/login 與 .worktrees/cart;各自驗證、review 並 finish。`
|
|
43
43
|
- Negative: `兩個對話同時修改主 checkout,彼此的寫入讓對方的驗證與 review 全部作廢。`
|
|
44
|
+
|
|
45
|
+
## DELEGATE-ISOLATION-003
|
|
46
|
+
|
|
47
|
+
同一個 session 中會寫入檔案的 subagent MUST 各自在自己的 agent-ops worktree 中工作,並各自擁有 task、驗證與 review。
|
|
48
|
+
|
|
49
|
+
- Trigger: `worktree.mode` 為 `auto`,且 subagent 寫入檔案。subagent 內的 hook 呼叫會在父對話的 `session_id` 之下帶有自己的 `agent_id`;沒有 `agent_id` 的 payload 視為該 session 的主執行緒。
|
|
50
|
+
- Action: 第一次被擋下的寫入會配發一個屬於該 session 與該 agent 的 worktree;之後用絕對路徑在那裡編輯,因為 hook 的 `cwd` 不會跟著 `cd` 改變。subagent 在自己的 worktree 內 commit,並執行 `agent-ops verify` 與 `agent-ops review`。整個 repository 的所有 worktree 同時最多進行兩個 review,其餘排隊等待。協調者依序對每個 worktree 執行 `agent-ops worktree finish <name>`;只有該 session 的每個 worktree 都有已完成的 task,Stop 才會放行。
|
|
51
|
+
- Evidence: `agent-ops worktree list` 為每個 subagent worktree 顯示一行 `agent:`,寫入其他 writer 的 worktree 會以 `WORKTREE_OWNED_BY_OTHER_AGENT` 被拒絕,且每次 finish 都回報 fast-forward 合併。
|
|
52
|
+
- Positive: `兩個 subagent 在 .worktrees/session-ab12cd34-a9230363 與 .worktrees/session-ab12cd34-b1111111 各自實作獨立的 task;各自 review,協調者依序 finish。`
|
|
53
|
+
- Negative: `subagent 修改協調者的 worktree,或兩個 subagent 修改同一個 worktree,導致一次 review 涵蓋兩個 task 的變更。`
|
|
@@ -31,3 +31,13 @@ English source version: 2026-07-31. Revalidate: when the English specification c
|
|
|
31
31
|
- Evidence: artifact hash 與 shim import 測試通過;release 文件記錄重驗條件。
|
|
32
32
|
- Positive: `agent-ops update` 通過 ownership checks 後重寫已變更的 plugin。
|
|
33
33
|
- Negative: `手動修改 .opencode/plugins/agent-ops.js,卻保留舊的 manifest hash。`
|
|
34
|
+
|
|
35
|
+
## MAINTAIN-DRIFT-001
|
|
36
|
+
|
|
37
|
+
`agent-ops update` MUST 重寫內容已偏離 manifest 的 managed artifact 或 managed block,MUST 回報每一次重寫,且 MUST NOT 重寫無法證明歸其所有的內容。
|
|
38
|
+
|
|
39
|
+
- Trigger: `agent-ops update` 發現 managed 整檔 artifact 的 hash 與 manifest 不同,或 managed block 的標記完整但內容已變更,例如 Git 操作把已追蹤檔案還原成舊版。
|
|
40
|
+
- Action: 重寫該 artifact,或只重寫標記之間的內容,並以 `repaired: <path> (<reason>)` 列出。`.agent-ops/config.json`、`agent-ops uninstall`、不在 manifest 內的路徑,以及標記缺失、重複或順序顛倒的 block 仍視為錯誤;最後一種需要人來判斷哪一段歸 agent-ops。
|
|
41
|
+
- Evidence: `InstallPlan.repaired` 與 update 輸出列出每個被重寫的路徑;標記以外的文字前後逐位元組相同。
|
|
42
|
+
- Positive: `被還原成舊版的已追蹤 .agent-ops/GEMINI.md 由 update 重寫,並輸出 "repaired: .agent-ops/GEMINI.md (artifact drift)"。`
|
|
43
|
+
- Negative: `重寫結束標記已被刪除的 block,或覆寫 .agent-ops/config.json。`
|
|
@@ -21,3 +21,13 @@ English source version: 2026-07-23. Revalidate: when the English specification c
|
|
|
21
21
|
- Evidence: 測試在修正前失敗、修正後通過。
|
|
22
22
|
- Positive: `RED parser test → GREEN parser test。`
|
|
23
23
|
- Negative: `因為不方便就刪除失敗測試。`
|
|
24
|
+
|
|
25
|
+
## TROUBLESHOOT-DRIFT-001
|
|
26
|
+
|
|
27
|
+
update 輸出 `repaired:` 行,或 doctor 回報漂移時,MUST 視為 managed 內容與 manifest 不符,而不是 update 失敗。
|
|
28
|
+
|
|
29
|
+
- Trigger: `agent-ops doctor` 對 managed artifact 回報 `UPDATE_REQUIRED`,或對 managed block 回報 DEGRADED,或 `agent-ops update` 輸出 `repaired: <path> (<reason>)`。
|
|
30
|
+
- Action: 執行 `agent-ops update`。它會重寫已漂移的 managed artifact,或標記完整的 block 標記之間的內容。`.agent-ops/config.json`、`agent-ops uninstall`、不在 manifest 內的路徑,以及標記缺失、重複或順序顛倒的 block 仍會失敗;這些要手動修正後再 update。見 MAINTAIN-DRIFT-001。
|
|
31
|
+
- Evidence: update 輸出列出每個被修復的路徑,且再跑一次 `agent-ops doctor` 不再回報漂移。
|
|
32
|
+
- Positive: `被還原的舊版 GEMINI.md 被重寫,並列出 "repaired: .agent-ops/GEMINI.md (artifact drift)"。`
|
|
33
|
+
- Negative: `為了消除結束標記缺失的 MANAGED_BLOCK_CHANGED 而刪除 manifest。`
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kylecheng3146/agent-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"repository": "github:kylecheng3146/agent-ops",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=22.14.0"
|