@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.
@@ -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 the branch the main checkout is on,
195
- * by fast-forward only, then retires the worktree. Run from the main checkout.
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
- if (onBranch !== record.targetBranch) {
215
- throw finishError("WORKTREE_TARGET_MOVED", `The main checkout is on ${onBranch || "a detached HEAD"}; switch it back to ${record.targetBranch} to finish.`);
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
- await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
280
- const mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
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 mainGate.redirect(record.sessionId, null);
296
- await mainGate.rebase(before, await currentGateFingerprint(mainRoot, mainRunner));
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` (default HEAD)
225
- * and makes it a working agent-ops checkout for `sessionId`. When the main
226
- * checkout is detached, `targetBranch` names the branch `finish` merges back
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
- return skip(reason, errors.length > 0
525
- ? `${errors}${fields}`
526
- : firstComplaint(spawned.stdout, spawned.stderr) ??
527
- "the answer carried no review report");
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 is down stays down.
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>` from the main
168
- checkout's HEAD and excludes `/.worktrees/` through `.git/info/exclude`,
169
- never `.gitignore`. It copies the ignored files agent-ops installed and any
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. If the target moved, it rebases; a clean rebase whose own patch
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` 從主 checkout 的 HEAD 建立 `.worktrees/<name>` 與 branch
149
- `agent-ops/<name>`,並透過 `.git/info/exclude`(而非 `.gitignore`)排除
150
- `/.worktrees/`。它會複製 agent-ops 安裝的 ignored 檔案,以及根目錄
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。Target 若已前進會先 rebase;沒有衝突且自身 patch 不變的 rebase 會先
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 全部作廢。`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",