@kylecheng3146/agent-ops 0.3.7 → 0.4.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.
Files changed (46) hide show
  1. package/README.md +35 -0
  2. package/dist/packages/cli/src/args.js +93 -9
  3. package/dist/packages/cli/src/bin.js +151 -104
  4. package/dist/packages/cli/src/cli.js +54 -5
  5. package/dist/packages/cli/src/commands/batch.js +106 -0
  6. package/dist/packages/cli/src/commands/hook.js +4 -1
  7. package/dist/packages/cli/src/commands/init.js +1 -0
  8. package/dist/packages/cli/src/commands/parallel.js +16 -3
  9. package/dist/packages/cli/src/commands/review.js +22 -0
  10. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  11. package/dist/packages/cli/src/commands/update.js +1 -0
  12. package/dist/packages/cli/src/context.js +28 -3
  13. package/dist/packages/cli/src/hook-process.js +14 -3
  14. package/dist/packages/cli/src/public-plan.js +32 -2
  15. package/dist/runtime/src/adapters/claude/config.js +1 -1
  16. package/dist/runtime/src/adapters/claude/input.js +17 -0
  17. package/dist/runtime/src/adapters/claude/output.js +9 -0
  18. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  19. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  20. package/dist/runtime/src/hooks/dispatch.js +5 -0
  21. package/dist/runtime/src/install/apply.js +2 -0
  22. package/dist/runtime/src/install/doctor.js +8 -1
  23. package/dist/runtime/src/install/harness.js +17 -6
  24. package/dist/runtime/src/install/plan.js +9 -0
  25. package/dist/runtime/src/install/preauth.js +276 -0
  26. package/dist/runtime/src/install/probes.js +32 -0
  27. package/dist/runtime/src/install/uninstall.js +29 -11
  28. package/dist/runtime/src/install/update.js +1 -0
  29. package/dist/runtime/src/parallel/enter.js +43 -0
  30. package/dist/runtime/src/parallel/finish.js +2 -1
  31. package/dist/runtime/src/parallel/guard.js +20 -1
  32. package/dist/runtime/src/parallel/manage.js +38 -0
  33. package/dist/runtime/src/parallel/service.js +3 -1
  34. package/dist/runtime/src/review/batch-guard.js +11 -0
  35. package/dist/runtime/src/review/batch-select.js +37 -0
  36. package/dist/runtime/src/review/batch.js +202 -0
  37. package/dist/runtime/src/review/execute.js +10 -7
  38. package/dist/runtime/src/review/host-sandbox.js +9 -0
  39. package/dist/runtime/src/review/invocation.js +15 -2
  40. package/dist/runtime/src/review/probe.js +51 -15
  41. package/dist/runtime/src/review/render.js +4 -0
  42. package/docs/en/guides/configuration.md +51 -1
  43. package/docs/en/spec/review.md +70 -0
  44. package/docs/zh-TW/guides/configuration.md +41 -0
  45. package/docs/zh-TW/spec/review.md +71 -1
  46. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { lstat, realpath } from "node:fs/promises";
3
+ import { appendFile, lstat, realpath } from "node:fs/promises";
4
4
  import { promisify } from "node:util";
5
5
  import { applyManagedBlock } from "../fs/managed-block.js";
6
+ import { AgentOpsError } from "../fs/paths.js";
6
7
  import { evaluateGuardrail } from "../guardrails/evaluate.js";
7
8
  import { appendLocalLog } from "../logging/local-log.js";
8
9
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
@@ -394,18 +395,29 @@ function parseSessionMarker(source) {
394
395
  }
395
396
  const session = source.match(/^Session: (\S+)$/mu)?.[1];
396
397
  const task = source.match(/^Task: (\S+)$/mu)?.[1];
398
+ const contested = /^Contested: yes$/mu.test(source);
397
399
  return {
398
400
  ...(session === undefined ? {} : { sessionId: session }),
399
- ...(task === undefined ? {} : { taskId: task })
401
+ ...(task === undefined ? {} : { taskId: task }),
402
+ ...(contested ? { contested } : {})
400
403
  };
401
404
  }
405
+ /**
406
+ * A different session starting inside this window means two are open in one
407
+ * checkout.
408
+ *
409
+ * ponytail: a session idle for longer than this is not noticed; hosts that
410
+ * inject their own id (Claude Code, Codex) never depend on the marker at all.
411
+ */
412
+ const CONTEST_WINDOW_MS = 15 * 60 * 1000;
402
413
  /**
403
414
  * The bridge the CLI cannot build for itself: a hook is told the session id,
404
415
  * a command run inside that session is not. Recording it here lets
405
416
  * `agent-ops task attach` default to the session actually running.
406
417
  *
407
- * ponytail: last writer wins. Two concurrent sessions in one checkout already
408
- * violate the one-writer rule; pass `--session` explicitly if you need that.
418
+ * The last writer wins, but a different session replacing a recent one marks
419
+ * the checkout contested, so a command that has to guess its session refuses
420
+ * instead of acting as the wrong one (`resolveCommandSessionId`).
409
421
  */
410
422
  export async function writeSessionMarker(options) {
411
423
  const path = loopPath(options.root, options.harness, "loop-state.md");
@@ -414,12 +426,22 @@ export async function writeSessionMarker(options) {
414
426
  const baseline = source ?? "# Loop state\n";
415
427
  const previous = parseSessionMarker(source);
416
428
  const taskId = options.taskId ?? previous.taskId;
429
+ const replaced = previous.sessionId !== undefined &&
430
+ previous.sessionId !== options.sessionId;
431
+ const age = replaced
432
+ ? (options.now?.() ?? Date.now()) -
433
+ await lstat(path).then((status) => status.mtimeMs, () => 0)
434
+ : 0;
435
+ const contested = replaced
436
+ ? age < CONTEST_WINDOW_MS
437
+ : previous.contested === true;
417
438
  await writePrivateFile(path, applyManagedBlock(baseline, {
418
439
  id: LOOP_SESSION_ID,
419
440
  version: 1,
420
441
  content: [
421
442
  `Session: ${options.sessionId}`,
422
- ...(taskId === undefined ? [] : [`Task: ${taskId}`])
443
+ ...(taskId === undefined ? [] : [`Task: ${taskId}`]),
444
+ ...(contested ? ["Contested: yes"] : [])
423
445
  ].join("\n")
424
446
  }), options.root);
425
447
  });
@@ -430,6 +452,9 @@ export async function writeSessionMarker(options) {
430
452
  * written marker wins, because a checkout may carry both.
431
453
  */
432
454
  export async function readRecordedSessionId(root) {
455
+ return (await readRecordedSession(root))?.sessionId;
456
+ }
457
+ async function readRecordedSession(root) {
433
458
  const candidates = [];
434
459
  for (const harness of ["claude", "codex"]) {
435
460
  const path = loopPath(root, harness, "loop-state.md");
@@ -439,13 +464,40 @@ export async function readRecordedSessionId(root) {
439
464
  continue;
440
465
  }
441
466
  const status = await lstat(path);
442
- candidates.push({ sessionId: marker.sessionId, at: status.mtimeMs });
467
+ candidates.push({
468
+ sessionId: marker.sessionId,
469
+ contested: marker.contested === true,
470
+ at: status.mtimeMs
471
+ });
443
472
  }
444
473
  catch {
445
474
  continue;
446
475
  }
447
476
  }
448
- return candidates.sort((left, right) => right.at - left.at)[0]?.sessionId;
477
+ return candidates.sort((left, right) => right.at - left.at)[0];
478
+ }
479
+ /** The id the host injected into this command's environment, if any. */
480
+ export function sessionIdFromEnvironment(env = process.env) {
481
+ return [env.AGENT_OPS_SESSION_ID, env.CODEX_THREAD_ID]
482
+ .find((value) => value !== undefined && value !== "");
483
+ }
484
+ /**
485
+ * Who is running this command: the injected id, else the one a SessionStart
486
+ * recorded for this checkout. A recorded id another session has since
487
+ * contested is a guess, and acting as the wrong session is worse than
488
+ * stopping.
489
+ */
490
+ export async function resolveCommandSessionId(root, env = process.env) {
491
+ const injected = sessionIdFromEnvironment(env);
492
+ if (injected !== undefined) {
493
+ return injected;
494
+ }
495
+ const recorded = await readRecordedSession(root);
496
+ if (recorded?.contested === true) {
497
+ throw new AgentOpsError("SESSION_ID_AMBIGUOUS", "Another session started in this checkout recently, so its recorded session id may not be yours. " +
498
+ "Pass --session <id>, or set AGENT_OPS_SESSION_ID.");
499
+ }
500
+ return recorded?.sessionId;
449
501
  }
450
502
  /**
451
503
  * The task attached to this session, never merely the most recent one: a
@@ -650,6 +702,14 @@ export async function runProjectLoop(options) {
650
702
  sessionId,
651
703
  ...(restore.taskId === undefined ? {} : { taskId: restore.taskId })
652
704
  }).catch(() => undefined);
705
+ // Claude Code sources this file before every Bash command, so each of
706
+ // this session's commands knows who it is. `sessionId` matched
707
+ // SESSION_ID_PATTERN, which is why it is safe to write unquoted.
708
+ const envFile = (options.env ?? process.env).CLAUDE_ENV_FILE;
709
+ if (options.harness === "claude" && envFile !== undefined && envFile !== "") {
710
+ await appendFile(envFile, `export AGENT_OPS_SESSION_ID=${sessionId}\n`)
711
+ .catch(() => undefined);
712
+ }
653
713
  }
654
714
  try {
655
715
  const [goal, telemetryEntries] = await Promise.all([
@@ -234,6 +234,23 @@ export class CompletionGateService {
234
234
  ? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
235
235
  : undefined);
236
236
  }
237
+ /** Best effort: no baseline only means Stop keeps blocking, as before. */
238
+ async #backfill(sessionId) {
239
+ try {
240
+ if (await this.#store.read(sessionId) !== null)
241
+ return;
242
+ const baselineFingerprint = await this.#fingerprint();
243
+ await this.#store.mutate(sessionId, (current) => current ?? {
244
+ schemaVersion: 1,
245
+ sessionId,
246
+ baselineFingerprint,
247
+ permitFingerprint: null
248
+ });
249
+ }
250
+ catch {
251
+ return;
252
+ }
253
+ }
237
254
  async grantPermit(sessionId) {
238
255
  const fingerprint = await this.#fingerprint();
239
256
  await this.#store.mutate(sessionId, (state) => {
@@ -320,8 +337,14 @@ export class CompletionGateService {
320
337
  if (event.event === "session-start") {
321
338
  return await this.initialize(sessionId);
322
339
  }
323
- if (event.event !== "stop")
340
+ if (event.event !== "stop") {
341
+ // A session whose SessionStart never reached this checkout has no
342
+ // baseline, and the first tool call is the earliest safe moment to take
343
+ // one. Stop never is: a baseline taken there would swallow the change.
344
+ if (event.event !== "unsupported")
345
+ await this.#backfill(sessionId);
324
346
  return null;
347
+ }
325
348
  if (event.terminationReason === undefined ||
326
349
  (event.terminationReason === "model_stop" && event.fullyIdle === undefined)) {
327
350
  return gateResult("block", "UNKNOWN", "COMPLETION_GATE_STOP_INPUT_INVALID", "Restore the Stop termination reason and fullyIdle metadata before stopping.");
@@ -332,7 +355,9 @@ export class CompletionGateService {
332
355
  const fingerprint = await this.#fingerprint();
333
356
  const state = await this.#store.read(sessionId);
334
357
  if (state === null) {
335
- return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
358
+ return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", this.#options.harness === "agy"
359
+ ? "The session baseline is unavailable; continue once so PreInvocation can initialize it."
360
+ : "The session baseline is unavailable: its SessionStart never reached this checkout. The next tool call records one; if this repeats, run agent-ops doctor.");
336
361
  }
337
362
  const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
338
363
  if (state.root !== undefined && state.root !== this.#options.root) {
@@ -57,6 +57,11 @@ export async function dispatchHookEvent(event, options) {
57
57
  ? continueWith("PASS", "HOOK_NOOP")
58
58
  : await options.worktreeGuard(event);
59
59
  }
60
+ if (event.event === "enter-worktree") {
61
+ return options.worktreeEnter === undefined
62
+ ? continueWith("PASS", "HOOK_NOOP")
63
+ : await options.worktreeEnter(event);
64
+ }
60
65
  if (event.event === "session-start" &&
61
66
  options.capabilities.includes("lifecycle-summary") &&
62
67
  options.advisory !== undefined) {
@@ -4,6 +4,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
4
4
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
5
5
  import { FileTransaction } from "../fs/transaction.js";
6
6
  import { validateConfig } from "../schema/validate.js";
7
+ import { applyPreauth } from "./preauth.js";
7
8
  const CONFIG_PATH = ".agent-ops/config.json";
8
9
  const MANIFEST_PATH = ".agent-ops/manifest.json";
9
10
  async function readInstalledFile(root, path) {
@@ -67,4 +68,5 @@ export async function applyInstallPlan(root, plan) {
67
68
  assertManifestOperation(plan);
68
69
  const transaction = new FileTransaction(root);
69
70
  await transaction.apply({ operations: plan.operations }, async () => await validateAppliedPlan(root, plan));
71
+ await applyPreauth(plan.preauthorization ?? []);
70
72
  }
@@ -460,7 +460,9 @@ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []
460
460
  const failures = [];
461
461
  const degraded = [];
462
462
  for (const target of targets) {
463
- const result = await probe(target, checkAuth);
463
+ const answer = await probe(target, checkAuth);
464
+ const result = typeof answer === "string" ? answer : answer.result;
465
+ const diagnostic = typeof answer === "string" ? undefined : answer.diagnostic;
464
466
  if (result === "missing-executable") {
465
467
  failures.push(check("review-targets", "FAIL", `${target} not found.`, undefined, `Install ${target}, or remove "${target}" from reviewRoles[].targets.`));
466
468
  continue;
@@ -483,6 +485,11 @@ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []
483
485
  failures.push(check("review-targets", "FAIL", `${target} could not complete its read-only capability probe.`, undefined, "Run agent-ops review on a host with network and loopback permission, then retry."));
484
486
  continue;
485
487
  }
488
+ if (result === "probe-failed") {
489
+ failures.push(check("review-targets", "FAIL", `${target} failed its probe${diagnostic === undefined ? "" : `: ${diagnostic}`}. ` +
490
+ "This is not evidence of a missing login.", undefined, "Fix what the message reports, or run agent-ops review --json for the full preflight record, then re-run: agent-ops doctor --check-auth"));
491
+ continue;
492
+ }
486
493
  if (checkAuth && result !== "ok") {
487
494
  failures.push(check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the call.`, undefined, `Run: ${target} login`));
488
495
  }
@@ -48,7 +48,8 @@ function jsonHookRegistered(control, source, capabilities) {
48
48
  control.isManagedHandler === undefined) {
49
49
  return false;
50
50
  }
51
- const events = Object.keys(control.buildHooks(capabilities, "probe").hooks);
51
+ const expected = control.buildHooks(capabilities, "probe").hooks;
52
+ const events = Object.keys(expected);
52
53
  if (events.length === 0) {
53
54
  return true;
54
55
  }
@@ -57,12 +58,22 @@ function jsonHookRegistered(control, source, capabilities) {
57
58
  return false;
58
59
  }
59
60
  const hooks = parsed.hooks;
61
+ const isManaged = control.isManagedHandler;
62
+ const ownsHandler = (group, matcher) => isRecord(group) &&
63
+ (matcher === undefined || group.matcher === matcher) &&
64
+ Array.isArray(group.hooks) &&
65
+ group.hooks.some(isManaged);
60
66
  return events.every((event) => {
61
67
  const groups = hooks[event];
68
+ // A matcher is part of the registration: one written before a tool was
69
+ // added to it still holds a managed handler, which never fires for that tool.
70
+ const matchers = [expected[event]]
71
+ .flat()
72
+ .filter(isRecord)
73
+ .flatMap((group) => typeof group.matcher === "string" ? [group.matcher] : []);
62
74
  return (Array.isArray(groups) &&
63
- groups.some((group) => isRecord(group) &&
64
- Array.isArray(group.hooks) &&
65
- group.hooks.some(control.isManagedHandler)));
75
+ groups.some((group) => ownsHandler(group)) &&
76
+ matchers.every((matcher) => groups.some((group) => ownsHandler(group, matcher))));
66
77
  });
67
78
  }
68
79
  function runtimeFailureResult(capability, registrations, remedy) {
@@ -308,10 +319,10 @@ export function managedRules(descriptor, context) {
308
319
  ""
309
320
  ];
310
321
  if (context.capabilities.includes("rules")) {
311
- lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
322
+ lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
312
323
  }
313
324
  if (context.capabilities.includes("task")) {
314
- lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
325
+ lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
315
326
  }
316
327
  if (context.capabilities.includes("lifecycle-summary")) {
317
328
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -11,6 +11,7 @@ import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./
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";
14
+ import { planPreauth } from "./preauth.js";
14
15
  import { resolveCapabilities, resolveProfiles } from "./profiles.js";
15
16
  const CONFIG_PATH = ".agent-ops/config.json";
16
17
  const MANIFEST_PATH = ".agent-ops/manifest.json";
@@ -598,6 +599,13 @@ export async function createInstallPlan(options) {
598
599
  content: formatInstallManifest(manifest),
599
600
  expectedHash: existing?.hash ?? null
600
601
  });
602
+ const preauthorization = await planPreauth({
603
+ root: options.root,
604
+ scope: options.scope,
605
+ harness: options.harness,
606
+ desired: config.config.worktree?.mode === "auto",
607
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
608
+ });
601
609
  return {
602
610
  scope: options.scope,
603
611
  harness: options.harness,
@@ -606,6 +614,7 @@ export async function createInstallPlan(options) {
606
614
  config: config.config,
607
615
  manifest,
608
616
  operations,
617
+ ...(preauthorization.length === 0 ? {} : { preauthorization }),
609
618
  detectedVerification: config.detectedVerification,
610
619
  verificationBlockers: config.verificationBlockers
611
620
  };
@@ -0,0 +1,276 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { sha256 } from "../fs/hash.js";
5
+ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
6
+ import { FileTransaction } from "../fs/transaction.js";
7
+ /**
8
+ * What a session may do without asking, decided once, when the user confirms
9
+ * init or update: the commands a worktree session runs itself. `trust`,
10
+ * `allow-stop`, `init`, `update` and `uninstall` stay out, so the approvals
11
+ * that are the user's alone remain the user's.
12
+ */
13
+ export const CLAUDE_LOCAL_SETTINGS_PATH = ".claude/settings.local.json";
14
+ export const CLAUDE_PREAUTH_ALLOW = [
15
+ "Bash(agent-ops task *)",
16
+ "Bash(agent-ops verify *)",
17
+ "Bash(agent-ops review *)",
18
+ "Bash(agent-ops batch *)",
19
+ "Bash(agent-ops worktree *)",
20
+ "Bash(agent-ops doctor *)"
21
+ ];
22
+ /** review and batch talk to the reviewer's API, which the sandbox withholds. */
23
+ export const CLAUDE_PREAUTH_UNSANDBOXED = [
24
+ "agent-ops review",
25
+ "agent-ops review *",
26
+ "agent-ops batch",
27
+ "agent-ops batch *"
28
+ ];
29
+ export const CODEX_RULES_PATH = "rules/agent-ops.rules";
30
+ export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
31
+ const HOSTS = ["AGENT_OPS_HOST=codex", "AGENT_OPS_HOST=claude", "AGENT_OPS_HOST=agy"];
32
+ const ENV_PREFIX = ["env", "-u", "CODEX_SANDBOX_NETWORK_DISABLED"];
33
+ function starlarkList(values) {
34
+ return `[${values.map((value) => typeof value === "string"
35
+ ? JSON.stringify(value)
36
+ : `[${value.map((item) => JSON.stringify(item)).join(", ")}]`).join(", ")}]`;
37
+ }
38
+ function rule(options) {
39
+ const pattern = [
40
+ ...ENV_PREFIX,
41
+ ...(options.withHost ? [HOSTS] : []),
42
+ "agent-ops",
43
+ ...options.command
44
+ ];
45
+ return [
46
+ "prefix_rule(",
47
+ ` pattern = ${starlarkList(pattern)},`,
48
+ ' decision = "allow",',
49
+ ` justification = ${JSON.stringify(options.justification)},`,
50
+ ` match = [${JSON.stringify(options.match)}],`,
51
+ ` not_match = [${JSON.stringify(options.notMatch)}]`,
52
+ ")"
53
+ ].join("\n");
54
+ }
55
+ /**
56
+ * Prefix rules for the exact shapes `.agent-ops/AGENTS.md` tells Codex to run,
57
+ * with and without AGENT_OPS_HOST, for review, batch and the auth probe.
58
+ * Matching is by argv prefix, so a shell redirect would defeat it: use
59
+ * `--output`.
60
+ */
61
+ export const CODEX_RULES_CONTENT = `${[
62
+ CODEX_RULES_MARKER,
63
+ ...[true, false].flatMap((withHost) => {
64
+ const host = withHost ? "AGENT_OPS_HOST=codex " : "";
65
+ const env = `env -u CODEX_SANDBOX_NETWORK_DISABLED ${host}agent-ops`;
66
+ return [
67
+ rule({
68
+ withHost,
69
+ command: ["review"],
70
+ justification: "agent-ops review needs network and loopback outside the sandbox.",
71
+ match: `${env} review --task task-x --yes --output review.json`,
72
+ notMatch: `${env} trust grant --scope project --yes`
73
+ }),
74
+ rule({
75
+ withHost,
76
+ command: ["batch"],
77
+ justification: "agent-ops batch runs reviews, which need network and loopback outside the sandbox.",
78
+ match: `${env} batch --parent task-x --yes --output batch.json`,
79
+ notMatch: `${env} trust grant --scope project --yes`
80
+ }),
81
+ rule({
82
+ withHost,
83
+ command: ["doctor", "--check-auth"],
84
+ justification: "agent-ops doctor --check-auth probes review targets, which needs the network outside the sandbox.",
85
+ match: `${env} doctor --check-auth --json --output doctor.json`,
86
+ notMatch: `${env} doctor --scope user`
87
+ })
88
+ ];
89
+ })
90
+ ].join("\n")}\n`;
91
+ export function codexHomeDirectory(env = process.env) {
92
+ return env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
93
+ }
94
+ async function readCurrent(root, path) {
95
+ const resolved = await resolveContainedPath(root, path);
96
+ try {
97
+ if (!(await lstat(resolved)).isFile()) {
98
+ throw new AgentOpsError("PREAUTH_TARGET_INVALID", `Pre-authorization target must be a regular file: ${path}`);
99
+ }
100
+ const content = await readFile(resolved, "utf8");
101
+ return { content, hash: sha256(content) };
102
+ }
103
+ catch (error) {
104
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
105
+ return null;
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+ function invalidSettings(reason, cause) {
111
+ return new AgentOpsError("PREAUTH_SETTINGS_INVALID", `${CLAUDE_LOCAL_SETTINGS_PATH} ${reason}; fix it or remove it, then run agent-ops update.`, cause === undefined ? undefined : { cause });
112
+ }
113
+ function stringList(value, where) {
114
+ if (value === undefined) {
115
+ return [];
116
+ }
117
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
118
+ throw invalidSettings(`has a ${where} that is not a list of strings`);
119
+ }
120
+ return [...value];
121
+ }
122
+ /**
123
+ * The managed entries merged into, or stripped from, the user's own local
124
+ * settings. Nothing else in the file is touched. `null` content means the file
125
+ * held nothing but these entries and should go; `undefined` means no change.
126
+ */
127
+ export function planClaudeLocalSettings(source, desired) {
128
+ let settings = {};
129
+ if (source !== null && source.trim() !== "") {
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(source);
133
+ }
134
+ catch (error) {
135
+ throw invalidSettings("is not valid JSON", error);
136
+ }
137
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
138
+ throw invalidSettings("is not a JSON object");
139
+ }
140
+ settings = parsed;
141
+ }
142
+ const permissions = (settings.permissions ?? {});
143
+ const sandbox = (settings.sandbox ?? {});
144
+ if (typeof permissions !== "object" || Array.isArray(permissions) ||
145
+ typeof sandbox !== "object" || Array.isArray(sandbox)) {
146
+ throw invalidSettings("has a permissions or sandbox entry that is not an object");
147
+ }
148
+ const allow = stringList(permissions.allow, "permissions.allow");
149
+ const excluded = stringList(sandbox.excludedCommands, "sandbox.excludedCommands");
150
+ const merge = (current, managed) => desired
151
+ ? [...current, ...managed.filter((entry) => !current.includes(entry))]
152
+ : current.filter((entry) => !managed.includes(entry));
153
+ const nextAllow = merge(allow, CLAUDE_PREAUTH_ALLOW);
154
+ const nextExcluded = merge(excluded, CLAUDE_PREAUTH_UNSANDBOXED);
155
+ if (nextAllow.length === allow.length && nextExcluded.length === excluded.length &&
156
+ nextAllow.every((entry, index) => entry === allow[index]) &&
157
+ nextExcluded.every((entry, index) => entry === excluded[index])) {
158
+ return undefined;
159
+ }
160
+ const { allow: _allow, ...otherPermissions } = permissions;
161
+ const { excludedCommands: _excluded, ...otherSandbox } = sandbox;
162
+ const nextPermissions = {
163
+ ...otherPermissions,
164
+ ...(nextAllow.length === 0 ? {} : { allow: nextAllow })
165
+ };
166
+ const nextSandbox = {
167
+ ...otherSandbox,
168
+ ...(nextExcluded.length === 0 ? {} : { excludedCommands: nextExcluded })
169
+ };
170
+ const { permissions: _p, sandbox: _s, ...rest } = settings;
171
+ const next = {
172
+ ...rest,
173
+ ...(Object.keys(nextPermissions).length === 0 ? {} : { permissions: nextPermissions }),
174
+ ...(Object.keys(nextSandbox).length === 0 ? {} : { sandbox: nextSandbox })
175
+ };
176
+ return {
177
+ content: Object.keys(next).length === 0 ? null : `${JSON.stringify(next, null, 2)}\n`
178
+ };
179
+ }
180
+ async function claudeChange(root, desired) {
181
+ const current = await readCurrent(root, CLAUDE_LOCAL_SETTINGS_PATH);
182
+ const planned = planClaudeLocalSettings(current?.content ?? null, desired);
183
+ if (planned === undefined) {
184
+ return undefined;
185
+ }
186
+ const summary = desired
187
+ ? `Adds to ${CLAUDE_LOCAL_SETTINGS_PATH} (existing entries kept): permissions.allow ` +
188
+ `${JSON.stringify(CLAUDE_PREAUTH_ALLOW)}; sandbox.excludedCommands ${JSON.stringify(CLAUDE_PREAUTH_UNSANDBOXED)}.`
189
+ : `Removes the agent-ops entries from ${CLAUDE_LOCAL_SETTINGS_PATH}, keeping everything else.`;
190
+ if (planned.content === null) {
191
+ return current === null
192
+ ? undefined
193
+ : {
194
+ root,
195
+ operation: { kind: "remove", path: CLAUDE_LOCAL_SETTINGS_PATH, expectedHash: current.hash },
196
+ summary,
197
+ ownedContent: false
198
+ };
199
+ }
200
+ return {
201
+ root,
202
+ operation: {
203
+ kind: "write",
204
+ path: CLAUDE_LOCAL_SETTINGS_PATH,
205
+ content: planned.content,
206
+ expectedHash: current?.hash ?? null
207
+ },
208
+ summary,
209
+ ownedContent: false
210
+ };
211
+ }
212
+ async function codexChange(codexHome, desired) {
213
+ const isDirectory = await lstat(codexHome).then((status) => status.isDirectory(), () => false);
214
+ if (!isDirectory) {
215
+ return undefined;
216
+ }
217
+ const current = await readCurrent(codexHome, CODEX_RULES_PATH);
218
+ // A rules file of the same name that agent-ops did not write is not ours to
219
+ // overwrite or delete.
220
+ if (current !== null && !current.content.startsWith(CODEX_RULES_MARKER)) {
221
+ return undefined;
222
+ }
223
+ if (!desired) {
224
+ return current === null
225
+ ? undefined
226
+ : {
227
+ root: codexHome,
228
+ operation: { kind: "remove", path: CODEX_RULES_PATH, expectedHash: current.hash },
229
+ summary: `Removes ${join(codexHome, CODEX_RULES_PATH)}, the rules agent-ops wrote.`,
230
+ ownedContent: true
231
+ };
232
+ }
233
+ return current?.content === CODEX_RULES_CONTENT
234
+ ? undefined
235
+ : {
236
+ root: codexHome,
237
+ operation: {
238
+ kind: "write",
239
+ path: CODEX_RULES_PATH,
240
+ content: CODEX_RULES_CONTENT,
241
+ expectedHash: current?.hash ?? null
242
+ },
243
+ summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review and doctor --check-auth.`,
244
+ ownedContent: true
245
+ };
246
+ }
247
+ /**
248
+ * What init, update and uninstall change beyond the manifest: the local Claude
249
+ * settings entries and the Codex rules file. Project scope only; `desired`
250
+ * false plans the removal of what agent-ops wrote.
251
+ */
252
+ export async function planPreauth(options) {
253
+ if (options.scope !== "project") {
254
+ return [];
255
+ }
256
+ const changes = [];
257
+ if (options.harness.includes("claude")) {
258
+ changes.push(await claudeChange(options.root, options.desired));
259
+ }
260
+ // Codex's home is outside the project, so it is only touched when the caller
261
+ // names it (the CLI does): planning alone must never reach into a real home.
262
+ if (options.harness.includes("codex") && options.codexHome !== undefined) {
263
+ changes.push(await codexChange(options.codexHome, options.desired));
264
+ }
265
+ return changes.filter((change) => change !== undefined);
266
+ }
267
+ export async function applyPreauth(changes) {
268
+ for (const change of changes) {
269
+ try {
270
+ await new FileTransaction(change.root).apply({ operations: [change.operation] });
271
+ }
272
+ catch (error) {
273
+ throw new AgentOpsError("PREAUTH_APPLY_FAILED", `The rest was applied, but ${join(change.root, change.operation.path)} was not written; fix that file and run agent-ops update.`, { cause: error });
274
+ }
275
+ }
276
+ }
@@ -118,3 +118,35 @@ export function repositoryTrustStatus(trust) {
118
118
  ? "FAIL"
119
119
  : "UNKNOWN";
120
120
  }
121
+ /**
122
+ * Trust is bound to the config hash, so a hand edit of the config is what
123
+ * usually costs it. The fix is always the user's: `update` lists the commands
124
+ * it will trust and asks once, which is why nothing here tells an agent to
125
+ * grant it.
126
+ */
127
+ const TRUST_REMEDY = "The user grants it, once: run `agent-ops update`, which lists the commands it will trust and asks (or `agent-ops trust grant`).";
128
+ export function repositoryTrustProbe(trust, verificationConfigured) {
129
+ const status = repositoryTrustStatus(trust);
130
+ if (trust === "STALE") {
131
+ return {
132
+ status,
133
+ message: "Repository trust binding is stale: the config hash changed since trust was granted, as a hand edit of .agent-ops/config.json does.",
134
+ code: "TRUST_REQUIRED",
135
+ remediation: TRUST_REMEDY
136
+ };
137
+ }
138
+ if (trust === "UNTRUSTED") {
139
+ return {
140
+ status,
141
+ message: verificationConfigured
142
+ ? "Repository is not trusted; Stop verification will not run."
143
+ : "Repository is not trusted.",
144
+ ...(verificationConfigured
145
+ ? { code: "TRUST_REQUIRED", remediation: TRUST_REMEDY }
146
+ : {
147
+ remediation: "No action needed; trust is only required once verification.commands is set."
148
+ })
149
+ };
150
+ }
151
+ return { status };
152
+ }