@kylecheng3146/agent-ops 0.5.3 → 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 (124) hide show
  1. package/README.md +103 -1
  2. package/dist/packages/cli/src/args.js +183 -3
  3. package/dist/packages/cli/src/bin.js +76 -19
  4. package/dist/packages/cli/src/cli.js +53 -8
  5. package/dist/packages/cli/src/codex-loop-process.js +19 -2
  6. package/dist/packages/cli/src/commands/advance.js +42 -5
  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/review-show.js +1 -1
  10. package/dist/packages/cli/src/commands/review.js +100 -9
  11. package/dist/packages/cli/src/commands/run.js +115 -0
  12. package/dist/packages/cli/src/commands/task.js +61 -17
  13. package/dist/packages/cli/src/commands/update.js +8 -0
  14. package/dist/packages/cli/src/commands/verify.js +1 -0
  15. package/dist/packages/cli/src/context.js +64 -1
  16. package/dist/packages/cli/src/hook-entry.js +2 -1
  17. package/dist/packages/cli/src/hook-process.js +54 -3
  18. package/dist/packages/cli/src/loop-entry.js +2 -1
  19. package/dist/packages/cli/src/office-entry.js +230 -0
  20. package/dist/packages/cli/src/owned-run-step.js +65 -0
  21. package/dist/packages/cli/src/parallel-deps.js +18 -5
  22. package/dist/packages/cli/src/run-deps.js +321 -0
  23. package/dist/packages/cli/src/run-entry.js +952 -0
  24. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  25. package/dist/packages/cli/src/run-step-entry.js +58 -0
  26. package/dist/packages/cli/src/wizard.js +14 -2
  27. package/dist/runtime/src/adapters/agy/config.js +4 -3
  28. package/dist/runtime/src/adapters/agy/events.js +9 -0
  29. package/dist/runtime/src/adapters/claude/config.js +3 -3
  30. package/dist/runtime/src/adapters/claude/events.js +8 -0
  31. package/dist/runtime/src/adapters/codex/config.js +3 -3
  32. package/dist/runtime/src/adapters/codex/events.js +8 -0
  33. package/dist/runtime/src/adapters/opencode/config.js +5 -4
  34. package/dist/runtime/src/adapters/opencode/events.js +8 -0
  35. package/dist/runtime/src/config/explain.js +11 -0
  36. package/dist/runtime/src/config/hash.js +1 -1
  37. package/dist/runtime/src/config/merge.js +9 -1
  38. package/dist/runtime/src/contracts.js +2 -2
  39. package/dist/runtime/src/hooks/codex-loop.js +4 -0
  40. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  41. package/dist/runtime/src/install/doctor.js +2 -0
  42. package/dist/runtime/src/install/harness.js +44 -1
  43. package/dist/runtime/src/install/plan.js +16 -11
  44. package/dist/runtime/src/install/preauth.js +44 -18
  45. package/dist/runtime/src/install/profiles.js +8 -2
  46. package/dist/runtime/src/install/update.js +1 -0
  47. package/dist/runtime/src/office/collect.js +233 -0
  48. package/dist/runtime/src/office/page.js +873 -0
  49. package/dist/runtime/src/office/scene.js +168 -0
  50. package/dist/runtime/src/office/server.js +139 -0
  51. package/dist/runtime/src/office/sessions.js +115 -0
  52. package/dist/runtime/src/office/snapshot.js +107 -0
  53. package/dist/runtime/src/parallel/finish.js +85 -22
  54. package/dist/runtime/src/parallel/guard.js +12 -2
  55. package/dist/runtime/src/parallel/integrate.js +288 -10
  56. package/dist/runtime/src/parallel/manage.js +4 -1
  57. package/dist/runtime/src/parallel/receipt.js +71 -5
  58. package/dist/runtime/src/parallel/service.js +41 -1
  59. package/dist/runtime/src/review/attestation.js +36 -11
  60. package/dist/runtime/src/review/execute.js +24 -1
  61. package/dist/runtime/src/review/packet.js +35 -1
  62. package/dist/runtime/src/review/runner.js +12 -1
  63. package/dist/runtime/src/review/scope.js +18 -1
  64. package/dist/runtime/src/review/slots.js +35 -10
  65. package/dist/runtime/src/run/authorization.js +60 -0
  66. package/dist/runtime/src/run/budget.js +30 -0
  67. package/dist/runtime/src/run/controls.js +139 -0
  68. package/dist/runtime/src/run/convergence.js +177 -0
  69. package/dist/runtime/src/run/hosts/claude.js +180 -0
  70. package/dist/runtime/src/run/hosts/codex.js +279 -0
  71. package/dist/runtime/src/run/hosts/index.js +4 -0
  72. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  73. package/dist/runtime/src/run/hosts/types.js +1 -0
  74. package/dist/runtime/src/run/hosts/util.js +136 -0
  75. package/dist/runtime/src/run/integration.js +204 -0
  76. package/dist/runtime/src/run/macOS.js +189 -0
  77. package/dist/runtime/src/run/ownership.js +22 -0
  78. package/dist/runtime/src/run/phase.js +37 -0
  79. package/dist/runtime/src/run/planning.js +25 -0
  80. package/dist/runtime/src/run/policy-transition.js +1076 -0
  81. package/dist/runtime/src/run/policy.js +176 -0
  82. package/dist/runtime/src/run/ratchet.js +71 -0
  83. package/dist/runtime/src/run/scheduler.js +260 -0
  84. package/dist/runtime/src/run/service.js +433 -0
  85. package/dist/runtime/src/run/supervisor.js +635 -0
  86. package/dist/runtime/src/run/transport.js +159 -0
  87. package/dist/runtime/src/run/types.js +1 -0
  88. package/dist/runtime/src/run/usage.js +81 -0
  89. package/dist/runtime/src/run/verification.js +64 -0
  90. package/dist/runtime/src/run/worker-gate.js +48 -0
  91. package/dist/runtime/src/schema/acceptance.js +91 -0
  92. package/dist/runtime/src/schema/validate.js +80 -9
  93. package/dist/runtime/src/task/completion.js +14 -2
  94. package/dist/runtime/src/task/contract.js +17 -0
  95. package/dist/runtime/src/task/pin-finding.js +81 -0
  96. package/dist/runtime/src/task/service.js +316 -13
  97. package/dist/runtime/src/task/store.js +57 -7
  98. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  99. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  100. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  101. package/dist/runtime/src/verify/adapters/index.js +5 -0
  102. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  103. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  104. package/dist/runtime/src/verify/adapters/node.js +254 -0
  105. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  106. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  107. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  108. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  109. package/dist/runtime/src/verify/evidence.js +5 -2
  110. package/dist/runtime/src/verify/service.js +24 -8
  111. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  112. package/dist/runtime/src/verify/spawn.js +13 -2
  113. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  114. package/docs/en/spec/harness-adapters.md +113 -1
  115. package/docs/en/spec/loop-engineering.md +49 -0
  116. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  117. package/docs/zh-TW/spec/harness-adapters.md +93 -1
  118. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  119. package/package.json +1 -1
  120. package/schemas/config.schema.json +230 -19
  121. package/schemas/evidence.schema.json +129 -6
  122. package/schemas/task.schema.json +265 -6
  123. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  124. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -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
+ }