@kylecheng3146/agent-ops 0.3.4 → 0.3.6
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/hook-process.js +10 -1
- package/dist/runtime/src/hooks/completion-gate.js +21 -1
- package/dist/runtime/src/parallel/finish.js +38 -13
- package/dist/runtime/src/parallel/service.js +51 -6
- package/dist/runtime/src/review/execute.js +125 -9
- package/dist/runtime/src/review/extract.js +40 -0
- package/dist/runtime/src/review/invocation.js +3 -0
- package/dist/runtime/src/review/usage.js +19 -0
- package/docs/en/guides/configuration.md +33 -5
- package/docs/en/spec/delegation.md +1 -1
- package/docs/zh-TW/guides/configuration.md +26 -5
- package/docs/zh-TW/spec/delegation.md +1 -1
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
|
10
10
|
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
11
11
|
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
12
12
|
import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
|
|
13
|
-
import { ensureSessionWorktree } from "../../../runtime/src/parallel/service.js";
|
|
13
|
+
import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
|
|
14
14
|
import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
|
|
15
15
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
16
16
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
@@ -354,6 +354,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
354
354
|
: async (sessionId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId })).path);
|
|
355
355
|
}
|
|
356
356
|
: undefined;
|
|
357
|
+
if (hookEvent === "SessionStart" && config.worktree?.mode === "auto" &&
|
|
358
|
+
dependencies.worktree !== undefined && typeof parsedInput === "object" && parsedInput !== null) {
|
|
359
|
+
const fields = parsedInput;
|
|
360
|
+
const sessionId = harnessId === "agy" ? fields.conversationId : fields.session_id;
|
|
361
|
+
if (typeof sessionId === "string") {
|
|
362
|
+
// Advisory: a session that records nothing falls back to HEAD at add time.
|
|
363
|
+
await recordSessionOrigin(dependencies.worktree, { cwd: root, sessionId }).catch(() => undefined);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
357
366
|
const output = await runHookCommand({
|
|
358
367
|
harness: harness,
|
|
359
368
|
event: hookEvent,
|
|
@@ -6,7 +6,7 @@ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../secur
|
|
|
6
6
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
7
7
|
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
8
8
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
9
|
-
import { readWorktreeRecord } from "../parallel/service.js";
|
|
9
|
+
import { NOTES_REF, noteSessionLine, readWorktreeRecord } from "../parallel/service.js";
|
|
10
10
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
11
11
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
12
12
|
function gateDirectory(root) {
|
|
@@ -248,12 +248,32 @@ export class CompletionGateService {
|
|
|
248
248
|
event.event === "command-batch" ? event.commands : [];
|
|
249
249
|
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
250
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether `worktree finish` merged this session's reviewed work: a note
|
|
253
|
+
* naming the session on a commit HEAD contains, and nothing uncommitted on
|
|
254
|
+
* top. finish moves the task into that note, so the checkout has none left.
|
|
255
|
+
*/
|
|
256
|
+
async #finishedByWorktree(sessionId) {
|
|
257
|
+
const runner = this.#options.gitRunner;
|
|
258
|
+
if ((await collectChangeSurface(runner)).paths.length > 0)
|
|
259
|
+
return false;
|
|
260
|
+
// ponytail: newest 500 commits only; older merges need a one-time allow-stop
|
|
261
|
+
const log = await runner.run(["log", `--notes=${NOTES_REF}`, "--format=%N%x00", "-n", "500", "HEAD"]);
|
|
262
|
+
if (log.exitCode !== 0)
|
|
263
|
+
return false;
|
|
264
|
+
const line = noteSessionLine(sessionId);
|
|
265
|
+
return new TextDecoder().decode(log.stdout).split("\0").some((note) => note.split("\n").includes(line));
|
|
266
|
+
}
|
|
251
267
|
async #validateTask(sessionId) {
|
|
252
268
|
let stored;
|
|
253
269
|
try {
|
|
254
270
|
stored = await this.#options.taskService.status({ sessionId });
|
|
255
271
|
}
|
|
256
272
|
catch (error) {
|
|
273
|
+
if (error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED" &&
|
|
274
|
+
await this.#finishedByWorktree(sessionId)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
257
277
|
return error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED"
|
|
258
278
|
? gateResult("block", "FAIL", "COMPLETION_GATE_TASK_REQUIRED", `Attach this conversation to a formal task: agent-ops task create --title <title> --criterion <json> --criterion <json> (two to five criteria), then agent-ops task attach --task <task-id> --session ${shellQuote(sessionId)}.`)
|
|
259
279
|
: gateResult("block", "UNKNOWN", "COMPLETION_GATE_TASK_UNAVAILABLE", "Repair task state with agent-ops doctor before stopping.");
|
|
@@ -5,8 +5,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
5
5
|
import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
6
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
|
-
import { assertWorktreeName, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
|
-
export const NOTES_REF = "agent-ops";
|
|
8
|
+
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
10
9
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
11
10
|
const LOCK_POLL_MS = 2_000;
|
|
12
11
|
const LOCK_WAIT_MS = 30 * 60 * 1000;
|
|
@@ -180,6 +179,7 @@ function taskTree(tasks) {
|
|
|
180
179
|
function noteText(record, tasks) {
|
|
181
180
|
return [
|
|
182
181
|
`worktree: ${record.name} (${record.branch})`,
|
|
182
|
+
noteSessionLine(record.sessionId),
|
|
183
183
|
...tasks.flatMap(({ task, depth }) => {
|
|
184
184
|
const indent = " ".repeat(depth);
|
|
185
185
|
return [
|
|
@@ -191,8 +191,9 @@ function noteText(record, tasks) {
|
|
|
191
191
|
].join("\n");
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
* Brings a worktree's reviewed work into
|
|
195
|
-
*
|
|
194
|
+
* Brings a worktree's reviewed work into its target branch by fast-forward
|
|
195
|
+
* only, then retires the worktree. Run from the main checkout, which may be on
|
|
196
|
+
* another branch: then only the target's ref moves.
|
|
196
197
|
*/
|
|
197
198
|
export async function finishWorktree(deps, options) {
|
|
198
199
|
const name = assertWorktreeName(options.name);
|
|
@@ -207,12 +208,24 @@ export async function finishWorktree(deps, options) {
|
|
|
207
208
|
return await withFinishLock(deps, commonDir, async () => {
|
|
208
209
|
const mainRunner = runner(deps, mainRoot);
|
|
209
210
|
const worktreeRunner = runner(deps, record.path);
|
|
210
|
-
if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
|
|
211
|
-
throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
|
|
212
|
-
}
|
|
213
211
|
const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
// On the target, the merge rewrites the checkout's files, so its changes
|
|
213
|
+
// would mix in. Anywhere else only the target's ref moves and the checkout
|
|
214
|
+
// is never touched.
|
|
215
|
+
const onTarget = onBranch === record.targetBranch;
|
|
216
|
+
if (onTarget) {
|
|
217
|
+
if ((await collectChangeSurface(mainRunner)).paths.length > 0) {
|
|
218
|
+
throw finishError("WORKTREE_MAIN_DIRTY", "The main checkout has uncommitted changes; a fast-forward would mix them in.");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const listing = await deps.git(mainRoot, ["worktree", "list", "--porcelain"]);
|
|
223
|
+
const holder = listing.exitCode === 0
|
|
224
|
+
? parseWorktreeListPorcelain(listing.stdout).find(({ branch }) => branch === record.targetBranch)
|
|
225
|
+
: undefined;
|
|
226
|
+
if (holder !== undefined) {
|
|
227
|
+
throw finishError("WORKTREE_BRANCH_CHECKED_OUT", `${record.targetBranch} is checked out at ${holder.path}; moving it would desync that checkout. Switch it away or finish from there.`);
|
|
228
|
+
}
|
|
216
229
|
}
|
|
217
230
|
if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
|
|
218
231
|
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
@@ -276,8 +289,17 @@ export async function finishWorktree(deps, options) {
|
|
|
276
289
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
277
290
|
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
278
291
|
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
let mergedHead;
|
|
293
|
+
if (onTarget) {
|
|
294
|
+
await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
|
|
295
|
+
mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Compare-and-swap on the target's value read above: a target that moved
|
|
299
|
+
// since fails here instead of being overwritten.
|
|
300
|
+
mergedHead = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.branch}^{commit}`], "WORKTREE_LOG_FAILED", `Git could not read ${record.branch}.`);
|
|
301
|
+
await git(deps, mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
|
|
302
|
+
}
|
|
281
303
|
// The merge happened; everything below is cleanup, reported, never undone.
|
|
282
304
|
const warnings = [];
|
|
283
305
|
const attempt = async (label, step) => {
|
|
@@ -292,8 +314,11 @@ export async function finishWorktree(deps, options) {
|
|
|
292
314
|
if (gateEnabled) {
|
|
293
315
|
await attempt("gate", async () => {
|
|
294
316
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
295
|
-
await
|
|
296
|
-
await mainGate.rebase(before,
|
|
317
|
+
const after = await currentGateFingerprint(mainRoot, mainRunner);
|
|
318
|
+
await mainGate.rebase(before, after);
|
|
319
|
+
// rebase only moves baselines equal to `before`; a main that moved since
|
|
320
|
+
// this session began would leave it reading the merge as its own change.
|
|
321
|
+
await mainGate.seed(record.sessionId, after);
|
|
297
322
|
});
|
|
298
323
|
}
|
|
299
324
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { chmod, copyFile, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, sep } from "node:path";
|
|
3
3
|
import { calculateConfigHash } from "../config/hash.js";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
5
|
import { parseInstallManifest } from "../fs/manifest.js";
|
|
5
6
|
import { AgentOpsError } from "../fs/paths.js";
|
|
6
7
|
import { writeSessionMarker } from "../hooks/codex-loop.js";
|
|
7
|
-
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
9
|
/**
|
|
9
10
|
* One Git worktree per writing session, so parallel sessions stop sharing the
|
|
10
11
|
* change surface every fingerprint is taken over. Worktrees live inside the
|
|
@@ -14,6 +15,10 @@ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
|
14
15
|
export const WORKTREE_DIRECTORY = ".worktrees";
|
|
15
16
|
export const WORKTREE_BRANCH_PREFIX = "agent-ops/";
|
|
16
17
|
export const WORKTREE_RECORD_PATH = ".agent-ops/tasks/worktree.json";
|
|
18
|
+
/** The notes ref `worktree finish` records a merge in. */
|
|
19
|
+
export const NOTES_REF = "agent-ops";
|
|
20
|
+
/** The note line binding a merge to the session that finished it. */
|
|
21
|
+
export const noteSessionLine = (sessionId) => `session: ${sessionId}`;
|
|
17
22
|
const EXCLUDE_LINE = `/${WORKTREE_DIRECTORY}/`;
|
|
18
23
|
const NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
19
24
|
const SESSION = /^[^\0\r\n]{1,256}$/u;
|
|
@@ -221,9 +226,11 @@ export async function removeCheckout(deps, record, force) {
|
|
|
221
226
|
await git(deps, record.mainRoot, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
|
|
222
227
|
}
|
|
223
228
|
/**
|
|
224
|
-
* Creates `.worktrees/<name>` on `agent-ops/<name>` from `from`
|
|
225
|
-
*
|
|
226
|
-
*
|
|
229
|
+
* Creates `.worktrees/<name>` on `agent-ops/<name>` from `from` and makes it a
|
|
230
|
+
* working agent-ops checkout for `sessionId`. With neither `from` nor
|
|
231
|
+
* `targetBranch`, the branch recorded when the session began is both; without
|
|
232
|
+
* a record, HEAD and its branch. When the main checkout is detached and
|
|
233
|
+
* nothing is recorded, `targetBranch` names the branch `finish` merges back
|
|
227
234
|
* into. A branch already checked out in another worktree is rejected before
|
|
228
235
|
* Git runs, so the failure names the conflicting path.
|
|
229
236
|
*/
|
|
@@ -240,15 +247,24 @@ export async function addWorktree(deps, options) {
|
|
|
240
247
|
if (currentRoot !== mainRoot) {
|
|
241
248
|
throw worktreeError("WORKTREE_NESTED", `Run worktree commands from the main checkout (${mainRoot}), not from inside a worktree.`);
|
|
242
249
|
}
|
|
250
|
+
// Without an explicit target or base, the branch the main checkout was on
|
|
251
|
+
// when the session began, not the one it is on now: the user may have
|
|
252
|
+
// switched since.
|
|
253
|
+
const origin = options.targetBranch === undefined && options.from === undefined
|
|
254
|
+
? await readSessionOrigin(mainRoot, sessionId) : null;
|
|
255
|
+
if (origin !== null &&
|
|
256
|
+
(await deps.git(mainRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${origin}`])).exitCode !== 0) {
|
|
257
|
+
throw worktreeError("WORKTREE_ORIGIN_MISSING", `The session began on ${origin}, which no longer exists; pass --target-branch to agent-ops worktree add.`);
|
|
258
|
+
}
|
|
243
259
|
const headRef = await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
244
|
-
const targetBranch = options.targetBranch ?? headRef.stdout.trim();
|
|
260
|
+
const targetBranch = options.targetBranch ?? origin ?? headRef.stdout.trim();
|
|
245
261
|
if (targetBranch === "") {
|
|
246
262
|
throw worktreeError("WORKTREE_TARGET_REQUIRED", "The main checkout is detached; pass --target-branch <branch> naming the branch finish merges back into.");
|
|
247
263
|
}
|
|
248
264
|
if (!TARGET_BRANCH.test(targetBranch)) {
|
|
249
265
|
throw worktreeError("WORKTREE_TARGET_INVALID", `Invalid target branch ${targetBranch}; use 1-128 letters, digits, '.', '_', '/' or '-'.`);
|
|
250
266
|
}
|
|
251
|
-
const fromRef = options.from ?? "HEAD";
|
|
267
|
+
const fromRef = options.from ?? (origin === null ? "HEAD" : `refs/heads/${origin}`);
|
|
252
268
|
const base = await git(deps, mainRoot, ["rev-parse", "--verify", `${fromRef}^{commit}`], options.from === undefined ? "WORKTREE_NO_COMMIT" : "WORKTREE_BASE_INVALID", options.from === undefined
|
|
253
269
|
? "The main checkout has no commit to branch from."
|
|
254
270
|
: `Cannot resolve --from ${options.from} to a commit.`);
|
|
@@ -344,3 +360,32 @@ export async function ensureSessionWorktree(deps, options) {
|
|
|
344
360
|
return existing;
|
|
345
361
|
return (await addWorktree(deps, { cwd: mainRoot, name, sessionId: options.sessionId })).record;
|
|
346
362
|
}
|
|
363
|
+
function sessionOriginPath(mainRoot, sessionId) {
|
|
364
|
+
return join(mainRoot, ".agent-ops", "tasks", "session-origin", sha256(sessionId));
|
|
365
|
+
}
|
|
366
|
+
/** The branch recorded when `sessionId` began, or null. */
|
|
367
|
+
export async function readSessionOrigin(mainRoot, sessionId) {
|
|
368
|
+
const value = (await readPrivateFile(sessionOriginPath(mainRoot, sessionId), mainRoot).catch(() => null))?.trim();
|
|
369
|
+
return value !== undefined && TARGET_BRANCH.test(value) ? value : null;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Pins the branch the main checkout is on to `sessionId`, first write wins:
|
|
373
|
+
* SessionStart fires again on resume, compact and clear, by which time the
|
|
374
|
+
* user may have moved. Nothing is recorded from inside a worktree or on a
|
|
375
|
+
* detached HEAD, so a later add falls back to reading HEAD itself.
|
|
376
|
+
*/
|
|
377
|
+
export async function recordSessionOrigin(deps, options) {
|
|
378
|
+
if (!SESSION.test(options.sessionId))
|
|
379
|
+
return;
|
|
380
|
+
const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
|
|
381
|
+
if (currentRoot !== mainRoot)
|
|
382
|
+
return;
|
|
383
|
+
const branch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
384
|
+
if (!TARGET_BRANCH.test(branch))
|
|
385
|
+
return;
|
|
386
|
+
const path = sessionOriginPath(mainRoot, options.sessionId);
|
|
387
|
+
await withPrivateFileLock(path, mainRoot, async () => {
|
|
388
|
+
if (await readPrivateFile(path, mainRoot) === null)
|
|
389
|
+
await writePrivateFile(path, `${branch}\n`, mainRoot);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
6
6
|
import { redactSecrets } from "../security/redact.js";
|
|
7
|
-
import { extractReviewObject } from "./extract.js";
|
|
7
|
+
import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage, extractJsonObject, extractReviewObject } from "./extract.js";
|
|
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
11
|
import { detectHostRestriction } from "./host-sandbox.js";
|
|
12
12
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
13
13
|
import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
|
|
14
|
-
import { extractUsage } from "./usage.js";
|
|
14
|
+
import { extractUsage, sumUsage } from "./usage.js";
|
|
15
15
|
/**
|
|
16
16
|
* Full repository reviews need far more headroom than the lightweight auth
|
|
17
17
|
* probe. Five minutes was not enough: reviewing a real working tree, codex and
|
|
@@ -298,6 +298,43 @@ async function snapshotRepository(request, destination, options) {
|
|
|
298
298
|
}
|
|
299
299
|
return undefined;
|
|
300
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* What a lost turn is asked once, in its own conversation. The turn ended
|
|
303
|
+
* because a tool call was refused, and the reviewer already holds everything
|
|
304
|
+
* it inspected, so the cheapest recovery is to ask for the answer.
|
|
305
|
+
*/
|
|
306
|
+
/** A resumed turn only has to state an answer it already holds. */
|
|
307
|
+
const NUDGE_TIMEOUT_MS = 300_000;
|
|
308
|
+
const NUDGE_PROMPT = "Your previous turn ended without an answer because a tool call was refused. " +
|
|
309
|
+
"Do not call any tool. Using only what you have already inspected, reply now " +
|
|
310
|
+
"with the JSON object the schema requires and nothing else.";
|
|
311
|
+
/**
|
|
312
|
+
* Keeps what a lost turn left behind. The attempt directory is removed on the
|
|
313
|
+
* way out and its log is the only record of what the turn spent its tokens on,
|
|
314
|
+
* so without this a lost turn cannot be diagnosed. Best effort: it returns the
|
|
315
|
+
* repository-relative directory, or undefined, and never changes the outcome.
|
|
316
|
+
*/
|
|
317
|
+
async function keepLostTurn(repositoryRoot, sessionId, log, stdout, nudge) {
|
|
318
|
+
// Named in the diagnostic, so it keeps `/` on every platform; `join` below
|
|
319
|
+
// turns it into the native path.
|
|
320
|
+
const relative = `.agent-ops/reviews/lost-turns/${sessionId}`;
|
|
321
|
+
try {
|
|
322
|
+
const directory = join(repositoryRoot, relative);
|
|
323
|
+
await mkdir(directory, { recursive: true });
|
|
324
|
+
await writeFile(join(directory, "stdout.json"), stdout);
|
|
325
|
+
if (log !== undefined) {
|
|
326
|
+
await copyFile(log, join(directory, "agy.log"));
|
|
327
|
+
}
|
|
328
|
+
if (nudge !== undefined) {
|
|
329
|
+
await writeFile(join(directory, "nudge-stdout.json"), nudge.stdout);
|
|
330
|
+
await copyFile(nudge.log, join(directory, "agy-nudge.log")).catch(() => { });
|
|
331
|
+
}
|
|
332
|
+
return relative;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
301
338
|
/**
|
|
302
339
|
* One target's attempt at one prompt, in a throwaway home directory. Returns a
|
|
303
340
|
* validated report or the reason this target produced no usable verdict; the
|
|
@@ -521,10 +558,88 @@ async function attemptTarget(request, options) {
|
|
|
521
558
|
payload !== undefined
|
|
522
559
|
? ` (fields: ${Object.keys(payload).sort().join(", ")})`
|
|
523
560
|
: "";
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
561
|
+
const lostTurn = payload === undefined && answeredNothing(target, spawned.stdout);
|
|
562
|
+
const denied = lostTurn ? deniedToolsOf(target, spawned.stdout) : [];
|
|
563
|
+
const conversationId = lostTurn ? conversationIdOf(target, spawned.stdout) : undefined;
|
|
564
|
+
let nudged;
|
|
565
|
+
let recovered;
|
|
566
|
+
if (conversationId !== undefined && request.resumeLostTurn !== false &&
|
|
567
|
+
!budgetSpent(options)) {
|
|
568
|
+
const nudgeLog = join(attemptDirectory, "agy-nudge.log");
|
|
569
|
+
const nudge = buildTargetInvocation({
|
|
570
|
+
...invocationRequest,
|
|
571
|
+
prompt: NUDGE_PROMPT,
|
|
572
|
+
conversationId,
|
|
573
|
+
logFile: nudgeLog,
|
|
574
|
+
repositoryRoot: snapshotRoot
|
|
575
|
+
});
|
|
576
|
+
if (nudge !== undefined) {
|
|
577
|
+
options.onProgress?.(`${target}: turn ended without answering${denied.length > 0 ? ` (denied ${denied.join(", ")})` : ""} → resuming the conversation once`);
|
|
578
|
+
const nudgeWatch = watchForStall(nudgeLog, options.signal, stallIdleMs);
|
|
579
|
+
let second;
|
|
580
|
+
try {
|
|
581
|
+
second = await runVerificationCommand({
|
|
582
|
+
id: `review-${request.label}-nudge`,
|
|
583
|
+
command: nudge.command,
|
|
584
|
+
args: [...nudge.args],
|
|
585
|
+
cwd: executionDirectory,
|
|
586
|
+
required: true,
|
|
587
|
+
evidence: { kind: "exit-code" },
|
|
588
|
+
timeoutMs: stageTimeout(options, NUDGE_TIMEOUT_MS)
|
|
589
|
+
}, {
|
|
590
|
+
cwd: executionDirectory,
|
|
591
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
592
|
+
...(options.outputLimitBytes === undefined
|
|
593
|
+
? {}
|
|
594
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
595
|
+
stdin: nudge.stdin,
|
|
596
|
+
env: environment,
|
|
597
|
+
replaceEnv: true,
|
|
598
|
+
signal: nudgeWatch.signal,
|
|
599
|
+
onActivity: nudgeWatch.beat
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
nudgeWatch.stop();
|
|
604
|
+
}
|
|
605
|
+
usage = sumUsage(usage, extractUsage(target, second.stdout, second.stderr));
|
|
606
|
+
throwIfInterrupted(target, options, second.failureClass);
|
|
607
|
+
nudged = { log: nudgeLog, stdout: second.stdout };
|
|
608
|
+
if (!nudgeWatch.stalled() && second.failureClass === "none" &&
|
|
609
|
+
!second.stdoutTruncated) {
|
|
610
|
+
// A resumed agy turn states its answer in `response` and carries no
|
|
611
|
+
// `structured_output`; validateReviewReport stays the authority.
|
|
612
|
+
const finalMessage = extractFinalMessage(target, second.stdout);
|
|
613
|
+
const answer = extractReviewObject(target, second.stdout) ??
|
|
614
|
+
(finalMessage === undefined ? undefined : extractJsonObject(finalMessage));
|
|
615
|
+
const checked = answer === undefined
|
|
616
|
+
? undefined
|
|
617
|
+
: validateReviewReport(answer, request.expectedCriterionIds, request.changedFiles);
|
|
618
|
+
if (checked?.ok) {
|
|
619
|
+
recovered = checked.value;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const kept = lostTurn
|
|
625
|
+
? await keepLostTurn(request.repositoryRoot, sessionId, agyLog, spawned.stdout, nudged)
|
|
626
|
+
: undefined;
|
|
627
|
+
if (recovered !== undefined) {
|
|
628
|
+
options.onProgress?.(`${target}: the resumed conversation answered${kept === undefined ? "" : `; lost turn kept at ${kept}`}`);
|
|
629
|
+
return { kind: "verdict", report: recovered, sessionId, metrics: metrics() };
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
...skip(reason, lostTurn
|
|
633
|
+
? "the reviewer ended its turn without answering (empty response, no structured output" +
|
|
634
|
+
(denied.length > 0 ? `; denied ${denied.join(", ")}` : "") +
|
|
635
|
+
")" +
|
|
636
|
+
(kept === undefined ? "" : `; kept ${kept}`)
|
|
637
|
+
: errors.length > 0
|
|
638
|
+
? `${errors}${fields}`
|
|
639
|
+
: firstComplaint(spawned.stdout, spawned.stderr) ??
|
|
640
|
+
"the answer carried no review report"),
|
|
641
|
+
...(lostTurn ? { transient: true } : {})
|
|
642
|
+
};
|
|
528
643
|
}
|
|
529
644
|
return {
|
|
530
645
|
kind: "verdict",
|
|
@@ -539,7 +654,8 @@ async function attemptTarget(request, options) {
|
|
|
539
654
|
}
|
|
540
655
|
/**
|
|
541
656
|
* `attemptTarget`, retried once in a fresh session when the service dropped
|
|
542
|
-
* the call. Only once: a service that
|
|
657
|
+
* the call or agy ended its turn without answering. Only once: a service that
|
|
658
|
+
* is down stays down.
|
|
543
659
|
*/
|
|
544
660
|
async function attemptTargetWithRetry(request, options) {
|
|
545
661
|
const first = await attemptTarget(request, options);
|
|
@@ -547,7 +663,7 @@ async function attemptTargetWithRetry(request, options) {
|
|
|
547
663
|
return first;
|
|
548
664
|
}
|
|
549
665
|
options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
|
|
550
|
-
return attemptTarget(request, options);
|
|
666
|
+
return attemptTarget({ ...request, resumeLostTurn: false }, options);
|
|
551
667
|
}
|
|
552
668
|
/** Builds the `execute` callback with one necessary and one adversarial review. */
|
|
553
669
|
export function createReviewExecutor(options) {
|
|
@@ -50,6 +50,46 @@ export function extractFinalMessage(target, stdout) {
|
|
|
50
50
|
export function extractJsonObject(text) {
|
|
51
51
|
return parseObject(text.trim());
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* agy's plan mode can end its one `--print` turn without answering: a valid
|
|
55
|
+
* envelope with no `structured_output` and an empty `response`. That is a lost
|
|
56
|
+
* turn, not a verdict, so a fresh session is worth one retry. Anything the
|
|
57
|
+
* target did say, or any other target, is a real protocol failure instead.
|
|
58
|
+
*/
|
|
59
|
+
export function answeredNothing(target, stdout) {
|
|
60
|
+
if (target !== "agy") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const envelope = parseObject(stdout);
|
|
64
|
+
if (envelope === undefined || envelope.structured_output !== undefined) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const response = envelope.response;
|
|
68
|
+
return response === undefined ||
|
|
69
|
+
(typeof response === "string" && response.trim().length === 0);
|
|
70
|
+
}
|
|
71
|
+
/** The conversation a lost agy turn can be resumed in; agy alone reports one. */
|
|
72
|
+
export function conversationIdOf(target, stdout) {
|
|
73
|
+
const id = target === "agy" ? parseObject(stdout)?.conversation_id : undefined;
|
|
74
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The tools agy's print mode refused because nobody could confirm them. A turn
|
|
78
|
+
* that calls one ends there, which is what a lost turn is.
|
|
79
|
+
*/
|
|
80
|
+
export function deniedToolsOf(target, stdout) {
|
|
81
|
+
const denied = target === "agy" ? parseObject(stdout)?.denied_actions : undefined;
|
|
82
|
+
if (!Array.isArray(denied)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return denied.flatMap((action) => {
|
|
86
|
+
if (!isRecord(action)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const name = action.display_name ?? action.action;
|
|
90
|
+
return typeof name === "string" && name.length > 0 ? [name] : [];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
53
93
|
/**
|
|
54
94
|
* Review results use a strict native structured-output transport. Unlike the
|
|
55
95
|
* legacy probe parser above, this path neither recovers fenced JSON nor accepts
|
|
@@ -149,6 +149,9 @@ export function buildTargetInvocation(request) {
|
|
|
149
149
|
? []
|
|
150
150
|
: ["--add-dir", request.repositoryRoot]),
|
|
151
151
|
...logArgs(request.target, request.logFile),
|
|
152
|
+
...(request.target === "agy" && request.conversationId !== undefined
|
|
153
|
+
? ["--conversation", request.conversationId]
|
|
154
|
+
: []),
|
|
152
155
|
...isolationArgs(request.target),
|
|
153
156
|
...shared
|
|
154
157
|
],
|
|
@@ -69,3 +69,22 @@ export function extractUsage(target, stdout, stderr) {
|
|
|
69
69
|
...defined("costUsd", count(envelope?.total_cost_usd))
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
+
/** Two rounds of one attempt add up; a field only one round reported stays. */
|
|
73
|
+
export function sumUsage(first, second) {
|
|
74
|
+
if (first === undefined || second === undefined) {
|
|
75
|
+
return first ?? second;
|
|
76
|
+
}
|
|
77
|
+
const add = (key) => {
|
|
78
|
+
const a = first[key];
|
|
79
|
+
const b = second[key];
|
|
80
|
+
return a === undefined && b === undefined ? {} : { [key]: (a ?? 0) + (b ?? 0) };
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
...add("inputTokens"),
|
|
84
|
+
...add("outputTokens"),
|
|
85
|
+
...add("cacheReadTokens"),
|
|
86
|
+
...add("cacheWriteTokens"),
|
|
87
|
+
...add("totalTokens"),
|
|
88
|
+
...add("costUsd")
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -92,6 +92,18 @@ would consume the following flag instead. It deliberately does not pass
|
|
|
92
92
|
`--dangerously-skip-permissions`, which overrides the permission boundary, or
|
|
93
93
|
`--disable-slash-commands`, which disables plan-mode behavior.
|
|
94
94
|
|
|
95
|
+
Agy can end its one `--print` turn without answering: print mode has nobody to
|
|
96
|
+
confirm a tool call, so it refuses the call and the turn stops with an empty
|
|
97
|
+
`response` and no `structured_output`. The refused tools are listed in the
|
|
98
|
+
envelope's `denied_actions` and named in the diagnostic. agent-ops first resumes
|
|
99
|
+
that same conversation once (`--conversation`) and asks for the answer without
|
|
100
|
+
tools, then falls back to one fresh session. The lost turn's `agy.log` and
|
|
101
|
+
`stdout.json` are kept under `.agent-ops/reviews/lost-turns/<sessionId>/`
|
|
102
|
+
(local, git-ignored, never cleaned up automatically). If the denied tool is an
|
|
103
|
+
MCP tool from your own agy configuration, disabling that server for agy
|
|
104
|
+
(`agy mcp disable <name>`) removes the trigger; agent-ops does not change your
|
|
105
|
+
agy configuration.
|
|
106
|
+
|
|
95
107
|
Host network or loopback restrictions return `REVIEW_NOT_RUN` before any
|
|
96
108
|
reviewer is started. The managed instructions require a trusted outer host
|
|
97
109
|
runner to start the exact same command with both capabilities on its first
|
|
@@ -164,9 +176,15 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
164
176
|
fails, the denial says why and falls back to the manual `worktree add`.
|
|
165
177
|
Run `agent-ops update` once so the PreToolUse hook gets the 600-second
|
|
166
178
|
timeout setup needs.
|
|
167
|
-
- `add` creates `.worktrees/<name>` on branch `agent-ops/<name>`
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
- `add` creates `.worktrees/<name>` on branch `agent-ops/<name>` and excludes
|
|
180
|
+
`/.worktrees/` through `.git/info/exclude`, never `.gitignore`. Its base and
|
|
181
|
+
its merge target are the branch the main checkout was on when the session
|
|
182
|
+
began: SessionStart records that branch once per session (resume, compact and
|
|
183
|
+
clear never overwrite it; nothing is recorded on a detached HEAD or from
|
|
184
|
+
inside a worktree), so switching branches mid-session does not retarget the
|
|
185
|
+
work. Without a record it reads the main checkout's HEAD at add time, an
|
|
186
|
+
explicit `--target-branch` or `--from` outranks the record, and a recorded
|
|
187
|
+
branch that no longer exists is an error naming `--target-branch`. It copies the ignored files agent-ops installed and any
|
|
170
188
|
file matched by a root `.worktreeinclude` (gitignore syntax, for example
|
|
171
189
|
`.env` or `local.properties`); a file Git already checked out is never
|
|
172
190
|
overwritten. Trust is inherited only when the main checkout is trusted and
|
|
@@ -188,10 +206,20 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
188
206
|
each task keeps its own review record, so a parent and a subtask reviewed
|
|
189
207
|
on the same source do not overwrite each other.
|
|
190
208
|
- `agent-ops worktree finish <name>` merges by fast-forward only, one finish
|
|
191
|
-
at a time.
|
|
209
|
+
at a time. When the main checkout is on the target branch the merge updates
|
|
210
|
+
its files, so uncommitted changes there block it. When the main checkout is
|
|
211
|
+
on another branch or detached, only the target's ref moves: the checkout, its
|
|
212
|
+
files and its uncommitted changes are left alone. A target checked out in
|
|
213
|
+
another worktree is refused, naming that path. If the target moved, it rebases; a clean rebase whose own patch
|
|
192
214
|
is unchanged is re-verified before merging, and a conflict is reported with
|
|
193
215
|
the `refs/notes/agent-ops` intent of the work that landed first. The note
|
|
194
|
-
records every task of the worktree, subtasks indented under their parent
|
|
216
|
+
records every task of the worktree, subtasks indented under their parent,
|
|
217
|
+
and a `session: <id>` line naming the session that finished it. Finish
|
|
218
|
+
also resets that session's gate baseline to the merged source, so the
|
|
219
|
+
session can stop although the task now lives only in the note. As a
|
|
220
|
+
fallback, the gate lets an unattached session stop when the checkout is
|
|
221
|
+
clean and a note with its `session:` line sits within the newest 500
|
|
222
|
+
commits of `HEAD`; older merges need a one-time `allow-stop`.
|
|
195
223
|
- `worktree list`, `resume <name> --session <id>` and `remove <name>` manage
|
|
196
224
|
what is left; `remove --force` discards work and asks the user first.
|
|
197
225
|
`doctor` reports worktrees idle for more than seven days.
|
|
@@ -35,7 +35,7 @@ Concurrent delegated writers MUST NOT share one working tree.
|
|
|
35
35
|
Parallel editing conversations in one repository MUST each work in their own agent-ops worktree.
|
|
36
36
|
|
|
37
37
|
- Trigger: `worktree.mode` is `auto`, or a second conversation will edit the repository while another is still open.
|
|
38
|
-
- Action: Run `agent-ops worktree add <name> --session <id>` from the main checkout before the first edit, work only in the printed path, and merge with `agent-ops worktree finish <name>` after the task completes against the printed `--base`.
|
|
38
|
+
- Action: Run `agent-ops worktree add <name> --session <id>` from the main checkout before the first edit, work only in the printed path, and merge with `agent-ops worktree finish <name>` after the task completes against the printed `--base`. The merge lands on the branch the main checkout was on when the session began, wherever that checkout is now.
|
|
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.`
|
|
@@ -81,6 +81,15 @@ Agy 的 prompt 會直接作為 `--print` 的值;裸用 `-p` 會誤吞下一個
|
|
|
81
81
|
agent-ops 刻意不傳會繞過權限邊界的 `--dangerously-skip-permissions`,也不傳會
|
|
82
82
|
使 plan mode 失效的 `--disable-slash-commands`。
|
|
83
83
|
|
|
84
|
+
Agy 可能在唯一一輪 `--print` 中沒有回答就結束:print mode 沒有人可以確認工具呼叫,
|
|
85
|
+
會拒絕該次呼叫並停止,`response` 為空且沒有 `structured_output`。被拒的工具列在
|
|
86
|
+
信封的 `denied_actions`,並寫進診斷訊息。agent-ops 會先在同一個對話續問一次
|
|
87
|
+
(`--conversation`,要求不用工具直接回答),失敗才退回一個全新的 session。lost turn 的
|
|
88
|
+
`agy.log` 與 `stdout.json` 會保留在 `.agent-ops/reviews/lost-turns/<sessionId>/`
|
|
89
|
+
(僅本機、被 git 忽略、不會自動清理)。若被拒的是你自己 agy 設定的 MCP 工具,
|
|
90
|
+
對 agy 停用該 server(`agy mcp disable <name>`)即可移除觸發原因;agent-ops 不會
|
|
91
|
+
更動你的 agy 設定。
|
|
92
|
+
|
|
84
93
|
Host 缺少 network 或 loopback 權限時,會在啟動任何 reviewer 前回傳
|
|
85
94
|
`REVIEW_NOT_RUN`。Managed 規則要求可信任的外部 host runner 在第一次呼叫時
|
|
86
95
|
就用兩項權限啟動同一個完整指令;不要先在原 sandbox 執行再重試,因為那不算
|
|
@@ -145,9 +154,13 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
145
154
|
則作為每條命令的工作目錄);同一 session 之後被拒絕時會重用它。
|
|
146
155
|
建立失敗時,拒絕訊息會說明原因並退回手動 `worktree add`。需先執行一次
|
|
147
156
|
`agent-ops update`,讓 PreToolUse hook 取得 setup 所需的 600 秒逾時。
|
|
148
|
-
- `add`
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
- `add` 建立 `.worktrees/<name>` 與 branch `agent-ops/<name>`,並透過
|
|
158
|
+
`.git/info/exclude`(而非 `.gitignore`)排除 `/.worktrees/`。它的 base 與合併
|
|
159
|
+
目標是 session 開始時主 checkout 所在的 branch:SessionStart 會為每個 session
|
|
160
|
+
記錄該 branch 一次(resume、compact、clear 都不會覆寫;detached HEAD 或在
|
|
161
|
+
worktree 內觸發時不記錄),所以中途切換 branch 不會改變工作的目標。沒有記錄時
|
|
162
|
+
會在 add 當下讀取主 checkout 的 HEAD;明確的 `--target-branch` 或 `--from`
|
|
163
|
+
優先於記錄;記錄的 branch 已不存在時會報錯,並提示使用 `--target-branch`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
|
|
151
164
|
`.worktreeinclude`(gitignore 語法,例如 `.env` 或 `local.properties`)比對到
|
|
152
165
|
的檔案;Git 已 checkout 的檔案一律不覆寫。只有主 checkout 已 trusted 且
|
|
153
166
|
worktree 的 effective config 完全相同時才繼承 trust。
|
|
@@ -165,9 +178,17 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
|
|
|
165
178
|
維持附著在 task 樹最上層,且每個 task 各自保留 review 紀錄,所以在同一份原始碼上
|
|
166
179
|
review 的 parent 與 subtask 不會互相覆蓋。
|
|
167
180
|
- `agent-ops worktree finish <name>` 只以 fast-forward 合併,一次只執行一個
|
|
168
|
-
finish
|
|
181
|
+
finish。主 checkout 就在 target branch 上時,合併會更新它的檔案,所以那裡有未
|
|
182
|
+
commit 的變更會擋住合併;主 checkout 在其他 branch 或 detached 時,只移動
|
|
183
|
+
target 的 ref,checkout、檔案與未 commit 的變更都不會被動到。Target 若被其他
|
|
184
|
+
worktree checkout 則拒絕,並指出該路徑。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
|
|
169
185
|
重新驗證再合併;衝突則連同先合併那份工作在 `refs/notes/agent-ops` 的意圖一起
|
|
170
|
-
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent
|
|
186
|
+
回報。Note 會記錄 worktree 的每個 task,subtask 縮排在其 parent 之下,並附上
|
|
187
|
+
一行 `session: <id>`,標明完成這次 finish 的 session。Finish 也會把該 session
|
|
188
|
+
的 gate baseline 重設為合併後的原始碼,所以即使 task 現在只存在於 note 裡,
|
|
189
|
+
session 仍可停止。作為後備,gate 在 checkout 乾淨、且 `HEAD` 最近 500 個 commit
|
|
190
|
+
內有帶該 `session:` 行的 note 時,會讓未附著 task 的 session 停止;更舊的合併需要
|
|
191
|
+
一次性的 `allow-stop`。
|
|
171
192
|
- `worktree list`、`resume <name> --session <id>` 與 `remove <name>` 管理剩下的
|
|
172
193
|
worktree;`remove --force` 會丟棄工作,執行前會先詢問使用者。`doctor` 會回報
|
|
173
194
|
閒置超過七天的 worktree。
|
|
@@ -37,7 +37,7 @@ English source version: 2026-09-24. Revalidate: when the English specification c
|
|
|
37
37
|
同一個 repository 中並行修改檔案的對話 MUST 各自在自己的 agent-ops worktree 中工作。
|
|
38
38
|
|
|
39
39
|
- Trigger: `worktree.mode` 為 `auto`,或另一個對話仍開著時,第二個對話也要修改這個 repository。
|
|
40
|
-
- Action: 第一次修改前,在主 checkout 執行 `agent-ops worktree add <name> --session <id>`,只在印出的路徑中工作;task 以印出的 `--base` 完成後,用 `agent-ops worktree finish <name>`
|
|
40
|
+
- Action: 第一次修改前,在主 checkout 執行 `agent-ops worktree add <name> --session <id>`,只在印出的路徑中工作;task 以印出的 `--base` 完成後,用 `agent-ops worktree finish <name>` 合併。合併目標是 session 開始時主 checkout 所在的 branch,不論該 checkout 現在在哪裡。
|
|
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 全部作廢。`
|