@kylecheng3146/agent-ops 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +55 -1
  2. package/dist/packages/cli/src/args.js +19 -2
  3. package/dist/packages/cli/src/bin.js +14 -4
  4. package/dist/packages/cli/src/cli.js +20 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +19 -2
  6. package/dist/packages/cli/src/commands/advance.js +19 -2
  7. package/dist/packages/cli/src/commands/init.js +2 -0
  8. package/dist/packages/cli/src/commands/office.js +20 -0
  9. package/dist/packages/cli/src/commands/run.js +6 -2
  10. package/dist/packages/cli/src/commands/update.js +8 -0
  11. package/dist/packages/cli/src/context.js +1 -0
  12. package/dist/packages/cli/src/hook-entry.js +2 -1
  13. package/dist/packages/cli/src/hook-process.js +23 -2
  14. package/dist/packages/cli/src/loop-entry.js +2 -1
  15. package/dist/packages/cli/src/office-entry.js +230 -0
  16. package/dist/packages/cli/src/run-deps.js +9 -2
  17. package/dist/packages/cli/src/run-entry.js +9 -0
  18. package/dist/packages/cli/src/wizard.js +14 -2
  19. package/dist/runtime/src/adapters/agy/config.js +4 -3
  20. package/dist/runtime/src/adapters/agy/events.js +9 -0
  21. package/dist/runtime/src/adapters/claude/config.js +3 -3
  22. package/dist/runtime/src/adapters/claude/events.js +8 -0
  23. package/dist/runtime/src/adapters/codex/config.js +3 -3
  24. package/dist/runtime/src/adapters/codex/events.js +8 -0
  25. package/dist/runtime/src/adapters/opencode/config.js +5 -4
  26. package/dist/runtime/src/adapters/opencode/events.js +8 -0
  27. package/dist/runtime/src/config/explain.js +5 -0
  28. package/dist/runtime/src/hooks/codex-loop.js +4 -0
  29. package/dist/runtime/src/install/harness.js +44 -1
  30. package/dist/runtime/src/install/plan.js +16 -11
  31. package/dist/runtime/src/install/preauth.js +44 -18
  32. package/dist/runtime/src/install/profiles.js +8 -2
  33. package/dist/runtime/src/install/update.js +1 -0
  34. package/dist/runtime/src/office/collect.js +233 -0
  35. package/dist/runtime/src/office/page.js +873 -0
  36. package/dist/runtime/src/office/scene.js +168 -0
  37. package/dist/runtime/src/office/server.js +139 -0
  38. package/dist/runtime/src/office/sessions.js +115 -0
  39. package/dist/runtime/src/office/snapshot.js +107 -0
  40. package/dist/runtime/src/parallel/guard.js +7 -1
  41. package/dist/runtime/src/review/slots.js +1 -1
  42. package/dist/runtime/src/run/phase.js +37 -0
  43. package/dist/runtime/src/run/service.js +8 -0
  44. package/dist/runtime/src/schema/validate.js +13 -2
  45. package/docs/en/spec/harness-adapters.md +113 -1
  46. package/docs/zh-TW/spec/harness-adapters.md +93 -1
  47. package/package.json +1 -1
  48. package/schemas/config.schema.json +10 -1
@@ -305,6 +305,49 @@ function subagentInstruction(descriptor) {
305
305
  return "Delegate it with this harness's subagent or task tool when it has one; otherwise run the same bounded steps sequentially in this session.";
306
306
  }
307
307
  }
308
+ /**
309
+ * The `run` profile's hand-off of feature-sized work to `agent-ops run`.
310
+ * agy keeps the subtask flow: `run` launches only Claude Code or Codex.
311
+ * AGENTS.md is shared by Codex and opencode, so it carries the Codex form.
312
+ */
313
+ function autoRunInstruction(descriptor, context) {
314
+ const file = descriptor.control.instructionFile;
315
+ if (!context.capabilities.includes("auto-run") || file === "GEMINI.md") {
316
+ return [];
317
+ }
318
+ const host = file === "CLAUDE.md" ? "claude" : "codex";
319
+ const start = `agent-ops run --goal-file .agent-ops/state/run-goal.md --host ${host} --wait`;
320
+ return [
321
+ "With the `run` profile, a change that needs more than five acceptance",
322
+ "criteria goes to `agent-ops run` instead of being split here; five or",
323
+ "fewer stay in this session. From the main checkout, before any `task",
324
+ "create` (its worktree makes `run` refuse with `WORKTREE_NESTED`), write",
325
+ "the user's prompt verbatim, then your proposed acceptance criteria marked",
326
+ "as proposals, to `.agent-ops/state/run-goal.md` (gitignored), and start",
327
+ ...(host === "claude"
328
+ ? [`\`${start}\``, "as a background shell command."]
329
+ : [
330
+ "it as a background shell command through the trusted outer host",
331
+ "runner, like review: the outer request uses",
332
+ "`sandbox_permissions: \"require_escalated\"`, and the shell form is",
333
+ `\`env -u CODEX_SANDBOX_NETWORK_DISABLED ${start}\`.`
334
+ ]),
335
+ "When it returns awaiting input, relay the question to the user, run",
336
+ "`agent-ops run respond <id> --question-id <q> --answer <text>`, and poll",
337
+ "`agent-ops run status <id>` until it finishes or asks again; on",
338
+ "completion report the result. If the start is refused with",
339
+ "`RUN_TARGET_REQUIRED`, `RUN_BACKGROUND_UNSUPPORTED`, `RUN_TARGET_DIRTY` or",
340
+ "`WORKTREE_NESTED`, say so in one line naming the code and fall back to",
341
+ "the subtask flow (`task create --parent`, then batch or advance).",
342
+ "`RUN_REPO_UNTRUSTED` means stop and ask the user; never work",
343
+ "around trust. If the run ends without completing (blocked, budget",
344
+ "exhausted, stopped, failing review), report the `agent-ops run status",
345
+ "<id>` state and code, the last `agent-ops run logs <id>` events, and the",
346
+ "exact `agent-ops run resume <id>` and `agent-ops run stop <id>` commands,",
347
+ "then stop: never resume automatically or take over the run's work.",
348
+ ""
349
+ ];
350
+ }
308
351
  export function managedRules(descriptor, context) {
309
352
  const lines = [
310
353
  "# Loop Engineering",
@@ -322,7 +365,7 @@ export function managedRules(descriptor, context) {
322
365
  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.", "");
323
366
  }
324
367
  if (context.capabilities.includes("task")) {
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 and verification; final review covers all", "tasks together when their worktrees are integrated;", "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.", "For subagents in separate worktrees, use `task advance` instead.", "", "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.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
368
+ 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 and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", ...autoRunInstruction(descriptor, context), "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.", "For subagents in separate worktrees, use `task advance` instead.", "", "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.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
326
369
  }
327
370
  if (context.capabilities.includes("lifecycle-summary")) {
328
371
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -79,7 +79,7 @@ async function detectVerificationCommands(root) {
79
79
  blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
80
80
  };
81
81
  }
82
- function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree) {
82
+ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false, worktree, officeEnabled) {
83
83
  // Absent reviewRoles means external review is disabled; an empty selection
84
84
  // must therefore omit the field rather than write an empty array.
85
85
  const reviewRoles = reviewTargets.length > 0
@@ -96,13 +96,14 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
96
96
  schemaVersion: CONFIG_SCHEMA_VERSION,
97
97
  profiles: [...profiles],
98
98
  verification,
99
- features: existing?.features ?? {
100
- stopVerification: {
101
- enabled: false
102
- },
103
- completionGate: {
104
- enabled: completionGateEnabled
105
- }
99
+ features: {
100
+ ...(existing?.features ?? {
101
+ stopVerification: { enabled: false },
102
+ completionGate: { enabled: completionGateEnabled }
103
+ }),
104
+ ...(officeEnabled === undefined
105
+ ? (existing === undefined ? { office: { enabled: false } } : {})
106
+ : { office: { enabled: officeEnabled } })
106
107
  },
107
108
  pathMappings: existing?.pathMappings ?? [],
108
109
  securityExceptions: existing?.securityExceptions ?? [],
@@ -110,7 +111,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
110
111
  ...(effectiveWorktree === undefined ? {} : { worktree: effectiveWorktree })
111
112
  };
112
113
  }
113
- async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree) {
114
+ async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = [], completionGateEnabled = false, worktree, officeEnabled) {
114
115
  const current = await readCurrentFile(root, CONFIG_PATH);
115
116
  const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
116
117
  if (current !== null && owned === undefined) {
@@ -146,7 +147,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
146
147
  existingConfig.verification.commands.length === 0
147
148
  ? await detectVerificationCommands(root)
148
149
  : { commands: [], blockers: [] };
149
- const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree);
150
+ const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled, worktree, officeEnabled);
150
151
  const content = `${JSON.stringify(config, null, 2)}\n`;
151
152
  return {
152
153
  operation: {
@@ -437,6 +438,10 @@ export async function createInstallPlan(options) {
437
438
  await assertCodexLoopConfiguration(options.root, options.harness, resolved.capabilities);
438
439
  const existing = await readExistingManifest(options.root);
439
440
  assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
441
+ const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree, options.officeEnabled);
442
+ resolved.capabilities = resolved.capabilities.filter(capability => capability !== "office-presence");
443
+ if (config.config.features.office?.enabled === true)
444
+ resolved.capabilities.push("office-presence");
440
445
  const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
441
446
  const explicitHookTargets = new Map();
442
447
  for (const target of options.hookTargets ?? []) {
@@ -520,7 +525,6 @@ export async function createInstallPlan(options) {
520
525
  : [];
521
526
  const operations = [];
522
527
  const artifacts = [];
523
- const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree);
524
528
  operations.push(config.operation);
525
529
  artifacts.push(config.record);
526
530
  const repaired = [];
@@ -634,6 +638,7 @@ export async function createInstallPlan(options) {
634
638
  scope: options.scope,
635
639
  harness: options.harness,
636
640
  desired: config.config.worktree?.mode === "auto",
641
+ run: resolved.profiles.includes("run"),
637
642
  ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
638
643
  });
639
644
  return {
@@ -40,6 +40,9 @@ export const CLAUDE_PREAUTH_UNSANDBOXED = [
40
40
  "npx lint-staged",
41
41
  "npx lint-staged *"
42
42
  ];
43
+ /** Added with the `run` profile: `agent-ops run` starts a launchd supervisor. */
44
+ export const CLAUDE_PREAUTH_RUN_ALLOW = ["Bash(agent-ops run *)"];
45
+ export const CLAUDE_PREAUTH_RUN_UNSANDBOXED = ["agent-ops run", "agent-ops run *"];
43
46
  export const CODEX_RULES_PATH = "rules/agent-ops.rules";
44
47
  export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
45
48
  const HOSTS = ["AGENT_OPS_HOST=codex", "AGENT_OPS_HOST=claude", "AGENT_OPS_HOST=agy"];
@@ -129,6 +132,23 @@ export const CODEX_RULES_CONTENT = `${[
129
132
  ];
130
133
  })
131
134
  ].join("\n")}\n`;
135
+ /**
136
+ * The `run` profile's rule: one argv prefix covers bare `agent-ops run` and
137
+ * every `agent-ops run *` shape the rules tell Codex to start escalated.
138
+ */
139
+ export const CODEX_RUN_RULE = rule({
140
+ withHost: false,
141
+ command: ["run"],
142
+ justification: "agent-ops run starts a launchd supervisor and reviews, which need network and loopback outside the sandbox.",
143
+ match: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops run --goal-file .agent-ops/state/run-goal.md --host codex --wait",
144
+ notMatch: "env -u CODEX_SANDBOX_NETWORK_DISABLED agent-ops trust grant --scope project --yes"
145
+ });
146
+ /** The rules file for worktree auto mode and the `run` profile, or null for neither. */
147
+ export function codexRulesContent(worktree, run) {
148
+ if (!run)
149
+ return worktree ? CODEX_RULES_CONTENT : null;
150
+ return `${worktree ? CODEX_RULES_CONTENT : `${CODEX_RULES_MARKER}\n`}${CODEX_RUN_RULE}\n`;
151
+ }
132
152
  export function codexHomeDirectory(env = process.env) {
133
153
  return env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
134
154
  }
@@ -165,7 +185,7 @@ function stringList(value, where) {
165
185
  * settings. Nothing else in the file is touched. `null` content means the file
166
186
  * held nothing but these entries and should go; `undefined` means no change.
167
187
  */
168
- export function planClaudeLocalSettings(source, desired) {
188
+ export function planClaudeLocalSettings(source, desired, run = false) {
169
189
  let settings = {};
170
190
  if (source !== null && source.trim() !== "") {
171
191
  let parsed;
@@ -188,11 +208,14 @@ export function planClaudeLocalSettings(source, desired) {
188
208
  }
189
209
  const allow = stringList(permissions.allow, "permissions.allow");
190
210
  const excluded = stringList(sandbox.excludedCommands, "sandbox.excludedCommands");
191
- const merge = (current, managed) => desired
192
- ? [...current, ...managed.filter((entry) => !current.includes(entry))]
193
- : current.filter((entry) => !managed.includes(entry));
194
- const nextAllow = merge(allow, CLAUDE_PREAUTH_ALLOW);
195
- const nextExcluded = merge(excluded, CLAUDE_PREAUTH_UNSANDBOXED);
211
+ // Run entries leave with the profile, so an update without it strips them.
212
+ const merge = (current, managed, runManaged) => {
213
+ const wanted = [...(desired ? managed : []), ...(run ? runManaged : [])];
214
+ const kept = current.filter((entry) => wanted.includes(entry) || ![...managed, ...runManaged].includes(entry));
215
+ return [...kept, ...wanted.filter((entry) => !kept.includes(entry))];
216
+ };
217
+ const nextAllow = merge(allow, CLAUDE_PREAUTH_ALLOW, CLAUDE_PREAUTH_RUN_ALLOW);
218
+ const nextExcluded = merge(excluded, CLAUDE_PREAUTH_UNSANDBOXED, CLAUDE_PREAUTH_RUN_UNSANDBOXED);
196
219
  if (nextAllow.length === allow.length && nextExcluded.length === excluded.length &&
197
220
  nextAllow.every((entry, index) => entry === allow[index]) &&
198
221
  nextExcluded.every((entry, index) => entry === excluded[index])) {
@@ -218,15 +241,16 @@ export function planClaudeLocalSettings(source, desired) {
218
241
  content: Object.keys(next).length === 0 ? null : `${JSON.stringify(next, null, 2)}\n`
219
242
  };
220
243
  }
221
- async function claudeChange(root, desired) {
244
+ async function claudeChange(root, desired, run) {
222
245
  const current = await readCurrent(root, CLAUDE_LOCAL_SETTINGS_PATH);
223
- const planned = planClaudeLocalSettings(current?.content ?? null, desired);
246
+ const planned = planClaudeLocalSettings(current?.content ?? null, desired, run);
224
247
  if (planned === undefined) {
225
248
  return undefined;
226
249
  }
227
- const summary = desired
250
+ const summary = desired || run
228
251
  ? `Adds to ${CLAUDE_LOCAL_SETTINGS_PATH} (existing entries kept): permissions.allow ` +
229
- `${JSON.stringify(CLAUDE_PREAUTH_ALLOW)}; sandbox.excludedCommands ${JSON.stringify(CLAUDE_PREAUTH_UNSANDBOXED)}.`
252
+ `${JSON.stringify([...(desired ? CLAUDE_PREAUTH_ALLOW : []), ...(run ? CLAUDE_PREAUTH_RUN_ALLOW : [])])}; sandbox.excludedCommands ` +
253
+ `${JSON.stringify([...(desired ? CLAUDE_PREAUTH_UNSANDBOXED : []), ...(run ? CLAUDE_PREAUTH_RUN_UNSANDBOXED : [])])}.`
230
254
  : `Removes the agent-ops entries from ${CLAUDE_LOCAL_SETTINGS_PATH}, keeping everything else.`;
231
255
  if (planned.content === null) {
232
256
  return current === null
@@ -250,7 +274,8 @@ async function claudeChange(root, desired) {
250
274
  ownedContent: false
251
275
  };
252
276
  }
253
- async function codexChange(codexHome, desired) {
277
+ async function codexChange(codexHome, desired, run) {
278
+ const content = codexRulesContent(desired, run);
254
279
  const isDirectory = await lstat(codexHome).then((status) => status.isDirectory(), () => false);
255
280
  if (!isDirectory) {
256
281
  return undefined;
@@ -261,7 +286,7 @@ async function codexChange(codexHome, desired) {
261
286
  if (current !== null && !current.content.startsWith(CODEX_RULES_MARKER)) {
262
287
  return undefined;
263
288
  }
264
- if (!desired) {
289
+ if (content === null) {
265
290
  return current === null
266
291
  ? undefined
267
292
  : {
@@ -271,37 +296,38 @@ async function codexChange(codexHome, desired) {
271
296
  ownedContent: true
272
297
  };
273
298
  }
274
- return current?.content === CODEX_RULES_CONTENT
299
+ return current?.content === content
275
300
  ? undefined
276
301
  : {
277
302
  root: codexHome,
278
303
  operation: {
279
304
  kind: "write",
280
305
  path: CODEX_RULES_PATH,
281
- content: CODEX_RULES_CONTENT,
306
+ content,
282
307
  expectedHash: current?.hash ?? null
283
308
  },
284
- summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review, batch, doctor --check-auth, worktree commit and lint-staged.`,
309
+ summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for ${desired ? `agent-ops review, batch, ${run ? "run, " : ""}doctor --check-auth, worktree commit and lint-staged` : "agent-ops run"}.`,
285
310
  ownedContent: true
286
311
  };
287
312
  }
288
313
  /**
289
314
  * What init, update and uninstall change beyond the manifest: the local Claude
290
315
  * settings entries and the Codex rules file. Project scope only; `desired`
291
- * false plans the removal of what agent-ops wrote.
316
+ * (worktree auto mode) and `run` false plan the removal of what agent-ops wrote.
292
317
  */
293
318
  export async function planPreauth(options) {
319
+ const run = options.run === true;
294
320
  if (options.scope !== "project") {
295
321
  return [];
296
322
  }
297
323
  const changes = [];
298
324
  if (options.harness.includes("claude")) {
299
- changes.push(await claudeChange(options.root, options.desired));
325
+ changes.push(await claudeChange(options.root, options.desired, run));
300
326
  }
301
327
  // Codex's home is outside the project, so it is only touched when the caller
302
328
  // names it (the CLI does): planning alone must never reach into a real home.
303
329
  if (options.harness.includes("codex") && options.codexHome !== undefined) {
304
- changes.push(await codexChange(options.codexHome, options.desired));
330
+ changes.push(await codexChange(options.codexHome, options.desired, run));
305
331
  }
306
332
  return changes.filter((change) => change !== undefined);
307
333
  }
@@ -1,16 +1,20 @@
1
1
  import { AgentOpsError } from "../fs/paths.js";
2
- const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop"];
2
+ const PROFILE_ORDER = ["core", "advisory", "guardrails", "loop", "run"];
3
3
  export const PROFILE_CAPABILITIES = {
4
4
  core: ["rules", "task", "verify", "review"],
5
5
  advisory: ["lifecycle-summary", "local-log"],
6
6
  guardrails: ["command-policy"],
7
- loop: ["project-loop"]
7
+ loop: ["project-loop"],
8
+ run: ["auto-run"]
8
9
  };
9
10
  export function resolveProfiles(inputProfiles) {
10
11
  if (inputProfiles.length === 0) {
11
12
  throw new AgentOpsError("PROFILE_REQUIRED", "At least one installation profile is required.");
12
13
  }
13
14
  const selectedProfiles = new Set(inputProfiles);
15
+ if (selectedProfiles.has("run")) {
16
+ selectedProfiles.add("loop");
17
+ }
14
18
  if (selectedProfiles.has("guardrails") ||
15
19
  selectedProfiles.has("loop")) {
16
20
  selectedProfiles.add("core");
@@ -30,6 +34,8 @@ export function resolveProfiles(inputProfiles) {
30
34
  }
31
35
  export function resolveCapabilities(config) {
32
36
  const resolved = resolveProfiles(config.profiles);
37
+ if (config.features.office?.enabled === true)
38
+ resolved.capabilities.push("office-presence");
33
39
  if (config.features.stopVerification.enabled) {
34
40
  resolved.capabilities.push("optional-stop-verify");
35
41
  }
@@ -114,6 +114,7 @@ export async function createUpdatePlan(options) {
114
114
  adapters: options.adapters,
115
115
  toolkitVersion: options.toolkitVersion ?? targetVersion,
116
116
  allowHarnessChange: true,
117
+ ...(options.officeEnabled === undefined ? {} : { officeEnabled: options.officeEnabled }),
117
118
  ...(options.hookRuntimePath === undefined
118
119
  ? {}
119
120
  : { hookRuntimePath: options.hookRuntimePath }),
@@ -0,0 +1,233 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { readWorktreeRecord, parseWorktreeListPorcelain } from "../parallel/service.js";
6
+ import { FileRunRepository } from "../run/service.js";
7
+ import { criteriaProgress } from "../run/phase.js";
8
+ import { parseTaskStateSource } from "../task/store.js";
9
+ import { findReviewAttestation } from "../review/attestation.js";
10
+ import { FileEvidenceStore } from "../verify/evidence.js";
11
+ import { readPrivateFile } from "../security/permissions.js";
12
+ import { OFFICE_SESSION_RETENTION_MS, readOfficeSessions } from "./sessions.js";
13
+ const run = promisify(execFile);
14
+ export const officeGit = async (cwd, args) => {
15
+ try {
16
+ return { exitCode: 0, stdout: (await run("git", [...args], { cwd, maxBuffer: 8 * 1024 * 1024 })).stdout };
17
+ }
18
+ catch {
19
+ return { exitCode: 1, stdout: "" };
20
+ }
21
+ };
22
+ const MAX_PATHS = 200;
23
+ const OFFICE_PHASES = new Set(["planning", "implementing", "verifying", "reviewing", "integrating", "unknown"]);
24
+ function officePhase(value) {
25
+ return value === undefined ? undefined : OFFICE_PHASES.has(value) ? value : "unknown";
26
+ }
27
+ function evidenceValue(value, taskId, criterionId) {
28
+ if (typeof value !== "object" || value === null || Array.isArray(value))
29
+ return null;
30
+ const evidence = value;
31
+ return evidence.taskId === taskId && evidence.criterionId === criterionId &&
32
+ (evidence.status === "PASS" || evidence.status === "FAIL" || evidence.status === "UNKNOWN")
33
+ ? value : null;
34
+ }
35
+ async function taskView(root, record) {
36
+ const store = new FileEvidenceStore(root, root);
37
+ const loaded = [];
38
+ for (const criterion of record.task.criteria) {
39
+ for (const reference of record.evidence[criterion.id] ?? []) {
40
+ if (reference.startsWith("review:"))
41
+ continue;
42
+ const item = await store.load(reference).catch(() => null);
43
+ const parsed = evidenceValue(item, record.task.id, criterion.id);
44
+ if (parsed !== null)
45
+ loaded.push({ reference, evidence: parsed });
46
+ }
47
+ }
48
+ // A task keeps its evidence history. The desk shows the newest observation
49
+ // for each verifier/acceptance phase so an old failure cannot poison a
50
+ // later PASS, and a stale review cannot remain current after a new verify.
51
+ const latest = new Map();
52
+ for (const item of loaded) {
53
+ const phase = item.evidence.acceptance?.phase ?? "command";
54
+ const key = `${item.evidence.criterionId}\0${item.evidence.commandId}\0${phase}`;
55
+ const previous = latest.get(key);
56
+ const currentAt = Date.parse(item.evidence.finishedAt);
57
+ const previousAt = previous === undefined ? Number.NEGATIVE_INFINITY : Date.parse(previous.evidence.finishedAt);
58
+ if (previous === undefined || currentAt >= previousAt)
59
+ latest.set(key, item);
60
+ }
61
+ const evidence = [...latest.values()].map(item => item.evidence);
62
+ let passed = 0;
63
+ for (const criterion of record.task.criteria) {
64
+ const rows = evidence.filter(item => item.criterionId === criterion.id);
65
+ const result = criteriaProgress([criterion], {
66
+ results: rows.map(item => ({ status: item.status })),
67
+ acceptance: rows.flatMap(item => item.acceptance?.checks.map(check => ({ criterionId: criterion.id, status: check.status })) ?? [])
68
+ });
69
+ passed += result.passed;
70
+ }
71
+ const total = record.task.criteria.length;
72
+ const verify = evidence.some(item => item.status === "FAIL" || item.status === "UNKNOWN")
73
+ ? "FAIL"
74
+ : total > 0 && passed === total
75
+ ? "PASS"
76
+ : null;
77
+ const newest = evidence.reduce((current, item) => current === null || Date.parse(item.finishedAt) >= Date.parse(current.finishedAt) ? item : current, null);
78
+ const review = newest !== null &&
79
+ await findReviewAttestation(root, newest.sourceFingerprint, record.task.id) !== null ? "PASS" : null;
80
+ const progress = { verify, review, passed, total };
81
+ const phase = record.status === "complete"
82
+ ? "integrating"
83
+ : review === "PASS"
84
+ ? "reviewing"
85
+ : verify !== null
86
+ ? "verifying"
87
+ : evidence.length > 0
88
+ ? "implementing"
89
+ : "planning";
90
+ return { taskId: record.task.id, status: record.status, phase, progress, completedAt: record.completedAt };
91
+ }
92
+ async function taskState(root) {
93
+ const source = await readPrivateFile(join(root, ".agent-ops", "tasks", "state.json"), root).catch(() => null);
94
+ if (source === null)
95
+ return null;
96
+ try {
97
+ return parseTaskStateSource(source);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ async function attachedTaskView(root, sessionId, fallbackTaskId) {
104
+ const state = await taskState(root);
105
+ if (state === null)
106
+ return null;
107
+ const taskId = state.sessions.find(session => session.sessionId === sessionId)?.taskId ?? fallbackTaskId;
108
+ if (taskId === undefined)
109
+ return null;
110
+ const record = state.tasks.find(task => task.task.id === taskId);
111
+ return record === undefined ? null : await taskView(root, record);
112
+ }
113
+ /** Changed paths and line counts against the worktree's base, committed or not. */
114
+ export async function worktreeDiff(git, path, base) {
115
+ // The record is a file inside the checkout; only a commit id may reach git's argument list.
116
+ const numstat = /^[0-9a-f]{40,64}$/u.test(base) ? await git(path, ["diff", "--numstat", "-z", base, "--"]) : { exitCode: 1, stdout: "" };
117
+ const untracked = await git(path, ["ls-files", "--others", "--exclude-standard", "-z"]);
118
+ let insertions = 0, deletions = 0;
119
+ const paths = [];
120
+ // -z numstat: "added\tdeleted\tpath\0", renames as "added\tdeleted\t\0from\0to\0".
121
+ const fields = numstat.stdout.split("\0");
122
+ for (let i = 0; i < fields.length; i++) {
123
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/u.exec(fields[i]);
124
+ if (match === null)
125
+ continue;
126
+ insertions += Number(match[1]) || 0;
127
+ deletions += Number(match[2]) || 0;
128
+ paths.push(match[3] === "" ? (i += 2, fields[i]) : match[3]);
129
+ }
130
+ paths.push(...untracked.stdout.split("\0").filter(p => p !== ""));
131
+ let recent = null, newest = -1;
132
+ for (const p of paths.slice(0, MAX_PATHS)) {
133
+ const mtime = await stat(join(path, p)).then(s => s.mtimeMs, () => -1);
134
+ if (mtime > newest) {
135
+ newest = mtime;
136
+ recent = p;
137
+ }
138
+ }
139
+ return { files: paths.length, insertions, deletions, paths: paths.slice(0, MAX_PATHS), recent: recent ?? paths[0] ?? null };
140
+ }
141
+ /** Each run is read on its own: one unreadable run must not hide the building. */
142
+ export async function readRuns(commonDir) {
143
+ const directory = join(commonDir, "agent-ops", "runs");
144
+ const repository = new FileRunRepository(directory, commonDir);
145
+ const names = await readdir(directory).catch(() => []);
146
+ const runs = [];
147
+ for (const name of names.filter(n => n.endsWith(".json"))) {
148
+ const state = await repository.read(basename(name, ".json")).catch(() => null);
149
+ if (state !== null)
150
+ runs.push(state);
151
+ }
152
+ return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
153
+ }
154
+ async function readSessions(mainRoot, commonDir, now, trustedRoots = [mainRoot]) {
155
+ const sessions = await readOfficeSessions(commonDir, now).catch(() => []);
156
+ const views = await Promise.all(sessions.map(async (session) => {
157
+ let task = null;
158
+ for (const root of trustedRoots) {
159
+ task = await attachedTaskView(root, session.sessionId, root === mainRoot ? session.taskId : undefined);
160
+ if (task !== null)
161
+ break;
162
+ }
163
+ const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
164
+ const phase = task === null
165
+ ? observedPhase
166
+ : task.status === "complete"
167
+ ? task.phase
168
+ : observedPhase ?? task.phase;
169
+ return task === null
170
+ ? session
171
+ : { ...session, taskId: task.taskId, phase, taskStatus: task.status, progress: task.progress,
172
+ completedAt: task.completedAt, status: task.status === "complete" ? "idle" : session.status };
173
+ }));
174
+ return views.filter(session => session.completedAt === undefined || session.completedAt === null ||
175
+ now - Date.parse(session.completedAt) <= OFFICE_SESSION_RETENTION_MS);
176
+ }
177
+ function alive(pid) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ return true;
181
+ }
182
+ catch (error) {
183
+ return error.code === "EPERM";
184
+ }
185
+ }
186
+ /** Review slots held by a live process (see review/slots.ts). */
187
+ export async function readReviewSlots(commonDir) {
188
+ const slots = [];
189
+ for (const name of (await readdir(commonDir).catch(() => [])).sort()) {
190
+ const match = /^agent-ops-review-slot-(\d+)\.lock$/u.exec(name);
191
+ if (match === null)
192
+ continue;
193
+ try {
194
+ const owner = JSON.parse(await readFile(join(commonDir, name, "owner.json"), "utf8"));
195
+ if (typeof owner.pid !== "number" || typeof owner.at !== "number" || !alive(owner.pid))
196
+ continue;
197
+ slots.push({ slot: Number(match[1]), since: new Date(owner.at).toISOString(),
198
+ taskId: typeof owner.taskId === "string" ? owner.taskId : null, root: typeof owner.root === "string" ? owner.root : null });
199
+ }
200
+ catch { /* a slot being claimed or released */ }
201
+ }
202
+ return slots;
203
+ }
204
+ export async function collectOfficeInput(mainRoot, commonDir, git = officeGit, now = Date.now()) {
205
+ const worktrees = [];
206
+ const discovered = [];
207
+ for (const entry of parseWorktreeListPorcelain((await git(mainRoot, ["worktree", "list", "--porcelain"])).stdout)) {
208
+ const path = await realpath(resolve(entry.path)).catch(() => resolve(entry.path));
209
+ const record = await readWorktreeRecord(path).catch(() => null);
210
+ if (record === null)
211
+ continue;
212
+ discovered.push({ path, record });
213
+ }
214
+ const sessions = await readSessions(mainRoot, commonDir, now, [mainRoot, ...discovered.map(item => item.path)]);
215
+ for (const { path, record } of discovered) {
216
+ const session = sessions.find(item => item.sessionId === record.sessionId);
217
+ const task = await attachedTaskView(path, record.sessionId, session?.taskId);
218
+ const base = { name: record.name, path, branch: record.branch, sessionId: record.sessionId,
219
+ ...(record.runId === undefined ? {} : { runId: record.runId }), diff: await worktreeDiff(git, path, record.base) };
220
+ const observedPhase = session?.phase === undefined ? undefined : officePhase(session.phase) ?? "unknown";
221
+ const phase = task === null
222
+ ? observedPhase
223
+ : task.status === "complete"
224
+ ? task.phase
225
+ : observedPhase ?? task.phase;
226
+ worktrees.push(task === null
227
+ ? session === undefined ? base : { ...base, ...(officePhase(session.phase) === undefined ? {} : { phase: officePhase(session.phase) }), status: session.status,
228
+ ...(session.taskId === undefined ? {} : { taskId: session.taskId }), host: session.host ?? session.harness }
229
+ : { ...base, phase, status: task.status, taskId: task.taskId, progress: task.progress,
230
+ ...(task.completedAt === null ? {} : { completedAt: task.completedAt }), host: session?.host ?? session?.harness });
231
+ }
232
+ return { runs: await readRuns(commonDir), worktrees, sessions, reviews: await readReviewSlots(commonDir), now };
233
+ }