@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
@@ -1,3 +1,5 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { taskContractHash } from "../../../../runtime/src/task/contract.js";
1
3
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
4
  import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/render.js";
3
5
  import { errorEnvelope, okEnvelope } from "../output.js";
@@ -12,20 +14,21 @@ function parseCriterion(source) {
12
14
  catch {
13
15
  throw new AgentOpsError("TASK_CRITERION_INVALID", "Each --criterion value must be a JSON criterion object.");
14
16
  }
15
- if (!isRecord(value) ||
16
- Object.keys(value).sort().join(",") !==
17
- "description,id,verifierIds" ||
18
- typeof value.id !== "string" ||
19
- typeof value.description !== "string" ||
20
- !Array.isArray(value.verifierIds) ||
21
- value.verifierIds.some((id) => typeof id !== "string")) {
22
- throw new AgentOpsError("TASK_CRITERION_INVALID", "Each criterion requires only id, description, and verifierIds.");
17
+ if (!isRecord(value))
18
+ throw new AgentOpsError("TASK_CRITERION_INVALID", "Criterion must be an object.");
19
+ // TaskService normalizes the creation baseline, then uses the shared strict validator.
20
+ return value;
21
+ }
22
+ async function jsonFile(path) {
23
+ const status = await lstat(path);
24
+ if (!status.isFile() || status.isSymbolicLink() || status.size > 65536)
25
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Payload must be a regular JSON file at most 64 KiB.");
26
+ try {
27
+ return JSON.parse(await readFile(path, "utf8"));
28
+ }
29
+ catch {
30
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Invalid JSON payload.");
23
31
  }
24
- return {
25
- id: value.id,
26
- description: value.description,
27
- verifierIds: [...value.verifierIds]
28
- };
29
32
  }
30
33
  function parseEvidence(values) {
31
34
  const evidence = {};
@@ -57,7 +60,7 @@ function taskAction(args) {
57
60
  "complete",
58
61
  "create",
59
62
  "export",
60
- "status"
63
+ "status", "revise", "replan", "pin-finding"
61
64
  ].includes(args.action)) {
62
65
  throw new AgentOpsError("TASK_ACTION_INVALID", "A supported task action is required.");
63
66
  }
@@ -68,7 +71,8 @@ function taskEnvelope(action, code, message, record) {
68
71
  action,
69
72
  message,
70
73
  record,
71
- text: renderTaskMarkdown(record)
74
+ contractHash: taskContractHash(record.task),
75
+ text: renderTaskMarkdown(record) + "\nContract: " + taskContractHash(record.task)
72
76
  });
73
77
  }
74
78
  function renderTaskList(records) {
@@ -93,7 +97,10 @@ export async function runTaskCommand(options) {
93
97
  const record = await (worktree?.service ?? options.service).create({
94
98
  title: options.args.title,
95
99
  ...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
96
- criteria: (options.args.criteria ?? []).map(parseCriterion),
100
+ criteria: [...(options.args.criteria ?? []).map(parseCriterion),
101
+ ...await Promise.all((options.args.criterionFiles ?? []).map(async (path) => await jsonFile(path)))]
102
+ .map(c => c.acceptance === undefined || options.args.baseline === undefined ? c :
103
+ { ...c, acceptance: { ...c.acceptance, baselineCommit: c.acceptance.baselineCommit ?? options.args.baseline } }),
97
104
  ...(options.policyConfigHash === undefined
98
105
  ? {}
99
106
  : { policyConfigHash: options.policyConfigHash }),
@@ -127,6 +134,35 @@ export async function runTaskCommand(options) {
127
134
  }
128
135
  };
129
136
  }
137
+ if (action === "revise") {
138
+ const payload = await jsonFile(options.args.criteriaFile);
139
+ if (!Array.isArray(payload))
140
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Criteria file must contain an array.");
141
+ const record = await options.service.revise(requireTaskId(options.args), {
142
+ expectedContractHash: options.args.expectedContract, criteria: payload,
143
+ reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
144
+ });
145
+ return taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record);
146
+ }
147
+ if (action === "replan") {
148
+ const payload = await jsonFile(options.args.planFile);
149
+ if (!Array.isArray(payload))
150
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Plan file must contain split task objects.");
151
+ const records = await options.service.replan(requireTaskId(options.args), {
152
+ expectedTreeContractHash: options.args.expectedTreeContract, reason: options.args.reason,
153
+ tasks: payload
154
+ });
155
+ return okEnvelope("TASK_REPLANNED", { action, message: "Task tree split atomically; reverify and review.", records, text: renderTaskList(records) });
156
+ }
157
+ if (action === "pin-finding") {
158
+ const criterion = parseCriterion(JSON.stringify(await jsonFile(options.args.criterionFiles[0])));
159
+ const pinned = await options.service.pinFinding(requireTaskId(options.args), options.args.expectedContract, options.args.findingReference, criterion, options.args.reason);
160
+ if (pinned.record === null)
161
+ return { code: "NEEDS_REPLAN", status: "error",
162
+ data: { action, message: "Pin requires atomic replan to retain two to five criteria.", pendingCriterion: pinned.pendingCriterion, text: "NEEDS_REPLAN: include the pending criterion in task replan; no task was changed." },
163
+ errors: [{ code: "NEEDS_REPLAN", message: "Replan the full current tree and pending pin." }] };
164
+ return taskEnvelope(action, "TASK_FINDING_PINNED", "Pinned saved finding; reverify and review the new contract.", pinned.record);
165
+ }
130
166
  if (action === "status") {
131
167
  if (options.args.taskId === undefined && sessionId === undefined) {
132
168
  const parentTaskId = options.args.parentTaskId;
@@ -143,7 +179,15 @@ export async function runTaskCommand(options) {
143
179
  const record = await options.service.status(options.args.taskId === undefined
144
180
  ? { sessionId }
145
181
  : { taskId: options.args.taskId });
146
- return taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
182
+ const coverage = await options.service.coverage(record.task.id);
183
+ const proven = coverage.rows.filter(row => row.status === "proven").length;
184
+ const mechanical = coverage.rows.filter(row => row.mode === "behavioral" || row.mode === "invariant").length;
185
+ const reviewOnly = coverage.rows.filter(row => row.mode === "review-only").length;
186
+ const legacy = coverage.rows.filter(row => row.mode === "legacy").length;
187
+ const envelope = taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
188
+ return { ...envelope, data: { ...envelope.data, coverage, treeContractHash: await options.service.treeContract(record.task.id),
189
+ text: envelope.data.text + "\nAcceptance: " + proven + "/" + mechanical + " mechanical proven; " + reviewOnly + " review-only; " + legacy + " legacy; originally mechanical " + coverage.originalMechanical +
190
+ "; reviewed " + coverage.reviewed + "; undischarged " + coverage.rows.filter(row => row.status !== "proven" && !coverage.reviewed).length } };
147
191
  }
148
192
  if (action === "attach") {
149
193
  if (sessionId === undefined) {
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { loadConfigFile } from "../../../../runtime/src/config/load.js";
3
4
  import { detectWorktreeSetup } from "../../../../runtime/src/install/parallel-setup.js";
4
5
  import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
5
6
  import { okEnvelope } from "../output.js";
@@ -15,6 +16,7 @@ export function formatUpdatePlan(plan, trust) {
15
16
  title: "Update plan",
16
17
  metadata: [
17
18
  `Target version: ${plan.targetVersion}`,
19
+ `Office (Preview): ${plan.installation.config.features.office?.enabled === true ? "enabled" : "disabled"}`,
18
20
  `Harness: ${plan.installation.harness.join(", ")}`,
19
21
  ...(plan.installation.config.worktree === undefined
20
22
  ? []
@@ -95,9 +97,15 @@ export async function runUpdateCommand(options) {
95
97
  };
96
98
  }
97
99
  }
100
+ let officeEnabled = options.args.office === undefined ? undefined : options.args.office === "on";
101
+ if (officeEnabled === undefined && options.promptOffice !== undefined && options.isTTY && !options.args.yes) {
102
+ const { config } = await loadConfigFile(join(options.root, ".agent-ops", "config.json"));
103
+ officeEnabled = await options.promptOffice("Enable Office (Preview)?", config.features.office?.enabled === true);
104
+ }
98
105
  const plan = await createUpdatePlan({
99
106
  root: options.root,
100
107
  adapters: options.adapters,
108
+ ...(officeEnabled === undefined ? {} : { officeEnabled }),
101
109
  ...(options.args.harness === undefined
102
110
  ? {}
103
111
  : { harness: options.args.harness }),
@@ -7,6 +7,7 @@ function safe(value) {
7
7
  }
8
8
  function publicReport(report) {
9
9
  return {
10
+ ...(report.acceptance === undefined ? {} : { acceptance: report.acceptance }),
10
11
  taskId: report.taskId,
11
12
  status: report.status,
12
13
  surface: {
@@ -7,13 +7,16 @@ import { sha256 } from "../../../runtime/src/fs/hash.js";
7
7
  import { loadConfigFile } from "../../../runtime/src/config/load.js";
8
8
  import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
9
9
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
10
- import { localStatePaths } from "../../../runtime/src/security/permissions.js";
10
+ import { localStatePaths, readPrivateFile } from "../../../runtime/src/security/permissions.js";
11
+ import { FileRunRepository } from "../../../runtime/src/run/service.js";
12
+ import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
11
13
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
12
14
  export const DEFAULT_CONFIG = {
13
15
  schemaVersion: 3,
14
16
  profiles: [],
15
17
  verification: { commands: [] },
16
18
  features: {
19
+ office: { enabled: false },
17
20
  stopVerification: {
18
21
  enabled: false
19
22
  },
@@ -72,6 +75,14 @@ async function loadOptionalConfig(path) {
72
75
  }
73
76
  }
74
77
  export async function loadEffectiveConfig(root, scope, projectOverride) {
78
+ if (scope === "project" && projectOverride === undefined) {
79
+ const run = await runPolicyContext(root);
80
+ if (run !== null)
81
+ return mergeConfigLayers([defaultConfigLayer(), {
82
+ source: "project", sourcePath: join(run.state.commonDir, "agent-ops/runs", run.state.runId, "policies", run.state.policyBinding.artifactDigest + ".json"),
83
+ config: run.policy.config
84
+ }]);
85
+ }
75
86
  const home = process.env.AGENT_OPS_HOME ?? homedir();
76
87
  const userPath = join(home, ".agent-ops", "config.json");
77
88
  const projectPath = join(scope === "user" ? root : projectConfigRoot(root), ".agent-ops", "config.json");
@@ -184,6 +195,14 @@ export async function repositoryTrust(root, config, cliVersion) {
184
195
  const home = process.env.AGENT_OPS_HOME ?? homedir();
185
196
  const state = localStatePaths(home);
186
197
  try {
198
+ const run = await runPolicyContext(root);
199
+ if (run !== null) {
200
+ if (run.state.status !== "active" || run.state.disableRestart || run.state.awaitingResume ||
201
+ Date.parse(run.policy.expiresAt) <= Date.now() || calculateConfigHash(config) !== run.state.policyBinding.configHash)
202
+ return "STALE";
203
+ const store = new FileTrustStore(state.trustStore, state.anchorDirectory);
204
+ return (await store.status(run.policy.baseTrustBinding)).status;
205
+ }
187
206
  const binding = await repositoryTrustBinding(root, config, cliVersion);
188
207
  return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
189
208
  }
@@ -191,6 +210,50 @@ export async function repositoryTrust(root, config, cliVersion) {
191
210
  return "UNTRUSTED";
192
211
  }
193
212
  }
213
+ /** A run-private capability is usable only by its registered checkout or proof group. */
214
+ export async function runPolicyContext(root) {
215
+ let common;
216
+ try {
217
+ common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ const source = await readPrivateFile(join(root, ".agent-ops/tasks/worktree.json"), root);
223
+ const record = source === null ? null : JSON.parse(source);
224
+ const runId = process.env.AGENT_OPS_RUN_ID ?? record?.runId;
225
+ if (runId === undefined)
226
+ return null;
227
+ const repository = new FileRunRepository(join(common, "agent-ops/runs"), common);
228
+ const saved = await repository.read(runId);
229
+ if (saved === null || saved.policyBinding === undefined)
230
+ return null;
231
+ const here = realpathSync(root);
232
+ const worker = saved.workers.find(w => w.worktree === here);
233
+ const proofProcess = worker?.proofProcess ?? saved.proofProcess;
234
+ let proof = false;
235
+ if (proofProcess != null && String(proofProcess.processId) === process.env.AGENT_OPS_RUN_PROOF_PID) {
236
+ try {
237
+ proof = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === String(proofProcess.processId) &&
238
+ execFileSync("ps", ["-o", "lstart=", "-p", String(proofProcess.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === proofProcess.processIdentity;
239
+ }
240
+ catch {
241
+ proof = false;
242
+ }
243
+ }
244
+ if ((!proof && (worker === undefined || record?.runId !== runId || record.workerId !== worker.workerId ||
245
+ record.ownerSessionId !== worker.ownerSessionId || record.coordinatorId !== saved.coordinatorId || record.workerGeneration !== worker.generation)) ||
246
+ (worker === undefined && !(proof && saved.root === here)) ||
247
+ (process.env.AGENT_OPS_WORKER_ID !== undefined && (process.env.AGENT_OPS_WORKER_ID !== (worker?.workerId ?? saved.coordinatorId) ||
248
+ process.env.AGENT_OPS_WORKER_GENERATION !== String((worker ?? saved.workers.find(w => w.workerId === saved.coordinatorId))?.generation))))
249
+ throw new AgentOpsError("RUN_WORKER_STALE", "Run policy requires the current registered worker generation or proof process.");
250
+ if (saved.status !== "active" || saved.disableRestart || saved.awaitingResume)
251
+ throw new AgentOpsError("RUN_POLICY_DISABLED", "Run execution policy is inactive; explicit resume is required.");
252
+ const policy = await readRunPolicy(saved, await runRuntimeHash());
253
+ if (policy === null)
254
+ return null;
255
+ return { repository, state: saved, policy, worker };
256
+ }
194
257
  export async function repositoryTrustBinding(root, config, cliVersion) {
195
258
  return await calculateTrustBinding({
196
259
  repositoryPath: root,
@@ -4,8 +4,9 @@
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  import { runHookProcess } from "./hook-process.js";
6
6
  import { worktreeDependencies } from "./parallel-deps.js";
7
+ import { observeOfficeSession } from "./office-entry.js";
7
8
  process.exitCode = await runHookProcess(process.argv.slice(2), {
8
9
  stdin: process.stdin,
9
10
  writeStdout: (value) => process.stdout.write(value),
10
11
  writeStderr: (value) => process.stderr.write(value)
11
- }, CLI_VERSION, { worktree: worktreeDependencies() });
12
+ }, CLI_VERSION, { worktree: worktreeDependencies(), office: observeOfficeSession });
@@ -9,6 +9,7 @@ import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
9
9
  import { redactSecrets } from "../../../runtime/src/security/redact.js";
10
10
  import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
11
11
  import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
12
+ import { officePhaseHint } from "./office-entry.js";
12
13
  import { evaluateWorktreeEnter } from "../../../runtime/src/parallel/enter.js";
13
14
  import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
14
15
  import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
@@ -16,6 +17,9 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
16
17
  import { TaskService } from "../../../runtime/src/task/service.js";
17
18
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
18
19
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
20
+ import { readWorktreeRecord } from "../../../runtime/src/parallel/service.js";
21
+ import { FileRunRepository } from "../../../runtime/src/run/service.js";
22
+ import { validateWorkerStopHandoff } from "../../../runtime/src/run/worker-gate.js";
19
23
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
20
24
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
21
25
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -281,7 +285,11 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
281
285
  parsedInput !== null &&
282
286
  typeof parsedInput.projectRoot === "string"
283
287
  ? parsedInput.projectRoot
284
- : undefined;
288
+ : typeof parsedInput === "object" &&
289
+ parsedInput !== null &&
290
+ typeof parsedInput.cwd === "string"
291
+ ? parsedInput.cwd
292
+ : undefined;
285
293
  if (inputRoot !== undefined) {
286
294
  root = inputRoot;
287
295
  }
@@ -323,6 +331,22 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
323
331
  ? await repositoryTrust(root, config, cliVersion)
324
332
  : await dependencies.trust(root, config, cliVersion);
325
333
  const trusted = trustStatus === "TRUSTED";
334
+ if (dependencies.office !== undefined && config.features.office?.enabled === true) {
335
+ const normalized = normalizeHookInput(harnessId, parsedInput);
336
+ if (normalized !== null) {
337
+ const phase = officePhaseHint(normalized);
338
+ void dependencies.office({
339
+ root,
340
+ harness: harnessId,
341
+ event: hookEvent,
342
+ input: parsedInput,
343
+ validated: true,
344
+ ...(normalized.sessionId === undefined ? {} : { sessionId: normalized.sessionId }),
345
+ ...(normalized.agentId === undefined ? {} : { agentId: normalized.agentId }),
346
+ ...(phase === undefined ? {} : { phase })
347
+ }).catch(() => undefined);
348
+ }
349
+ }
326
350
  const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
327
351
  const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
328
352
  const stopVerification = shouldBuildStopVerification(harnessId, event, config, parsedInput)
@@ -343,7 +367,34 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
343
367
  const completionGate = (harnessId === "agy" || harnessId === "claude") &&
344
368
  config.features.completionGate.enabled
345
369
  ? dependencies.completionGate ?? {
346
- handle: async (normalized) => await completionGateFor(root, config, gitRunner, harnessId).handle(normalized)
370
+ handle: async (normalized) => {
371
+ if (normalized.event === "stop" && normalized.terminationReason === "model_stop" && normalized.fullyIdle === true && normalized.sessionId !== undefined) {
372
+ const record = await readWorktreeRecord(root);
373
+ if (record?.runId !== undefined && record.workerId !== undefined && record.workerGeneration !== undefined) {
374
+ const common = await gitRunner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
375
+ if (common.exitCode === 0) {
376
+ const commonDir = Buffer.from(common.stdout).toString("utf8").trim();
377
+ const repository = new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir);
378
+ const state = await repository.read(record.runId);
379
+ const worker = state?.workers.find(worker => worker.workerId === record.workerId);
380
+ if (worker !== undefined && worker.workerId !== state.coordinatorId && worker.stopIntent !== null) {
381
+ const head = await gitRunner.run(["rev-parse", "HEAD"]);
382
+ const tasks = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
383
+ if (head.exitCode === 0 && Buffer.from(head.stdout).toString("utf8").trim() === worker.stopIntent.sourceCommit &&
384
+ await tasks.treeContract(worker.taskId) === worker.stopIntent.contractDigest) {
385
+ const handoff = await validateWorkerStopHandoff(repository, { root, sessionId: normalized.sessionId,
386
+ ...(normalized.agentId === undefined ? {} : { agentId: normalized.agentId }), runId: record.runId,
387
+ workerId: record.workerId, generation: record.workerGeneration,
388
+ deliveryDigest: worker.stopIntent.deliveryDigest ?? undefined, contractDigest: worker.stopIntent.contractDigest ?? undefined });
389
+ if (handoff !== null)
390
+ return handoff;
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+ return await completionGateFor(root, config, gitRunner, harnessId).handle(normalized);
397
+ }
347
398
  }
348
399
  : undefined;
349
400
  const worktreeGuard = config.worktree?.mode === "auto"
@@ -353,7 +404,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
353
404
  ? { action: "continue", status: "UNKNOWN", code: "WORKTREE_GUARD_UNAVAILABLE" }
354
405
  : await evaluateWorktreeWrite(mainRoot, write.paths, write.sessionId, dependencies.worktree === undefined
355
406
  ? undefined
356
- : async (sessionId, agentId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId, ...(agentId === undefined ? {} : { agentId }) })).path, write.agentId);
407
+ : async (sessionId, agentId) => (await ensureSessionWorktree(dependencies.worktree, { cwd: mainRoot, sessionId, ...(agentId === undefined ? {} : { agentId }) })).path, write.agentId, config.profiles.includes("run"));
357
408
  }
358
409
  : undefined;
359
410
  const worktreeEnter = config.worktree?.mode === "auto"
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // Entry point used only by the generated project-local loop launchers.
3
3
  import { runLoopProcess } from "./codex-loop-process.js";
4
+ import { observeOfficeSession } from "./office-entry.js";
4
5
  process.exitCode = await runLoopProcess(process.argv.slice(2), {
5
6
  stdin: process.stdin,
6
7
  writeStdout: (value) => process.stdout.write(value),
7
8
  writeStderr: (value) => process.stderr.write(value)
8
- });
9
+ }, { office: observeOfficeSession });
@@ -0,0 +1,230 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { basename, join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { collectOfficeInput } from "../../../runtime/src/office/collect.js";
6
+ import { claimOffice, createOfficeServer, ensureOffice, readLiveOffice, officeUrl, releaseOffice } from "../../../runtime/src/office/server.js";
7
+ import { buildOfficeSnapshot } from "../../../runtime/src/office/snapshot.js";
8
+ import { recordOfficeSession } from "../../../runtime/src/office/sessions.js";
9
+ import { resolveCheckouts } from "../../../runtime/src/parallel/service.js";
10
+ import { createLaunchdDescriptor, LaunchdController } from "../../../runtime/src/run/macOS.js";
11
+ import { ensurePrivateDirectory } from "../../../runtime/src/security/permissions.js";
12
+ import { worktreeDependencies } from "./parallel-deps.js";
13
+ import { normalizeShellHookEvent } from "../../../runtime/src/hooks/shell.js";
14
+ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
15
+ import { loadEffectiveConfig } from "./context.js";
16
+ const entry = fileURLToPath(import.meta.url);
17
+ /** Missing or invalid config keeps this Preview disabled. */
18
+ export async function officeEnabled(cwd) {
19
+ if (process.env.AGENT_OPS_DISABLE === "1")
20
+ return false;
21
+ try {
22
+ return (await loadEffectiveConfig(cwd, "project")).config.features.office?.enabled === true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function requireOffice(cwd) {
29
+ if (!(await officeEnabled(cwd))) {
30
+ throw new AgentOpsError("OFFICE_PREVIEW_DISABLED", "Office (Preview) is disabled. Enable it with agent-ops update --office on.");
31
+ }
32
+ }
33
+ function recordValue(input, key) {
34
+ if (typeof input !== "object" || input === null || Array.isArray(input))
35
+ return undefined;
36
+ return input[key];
37
+ }
38
+ function observationInput(input) {
39
+ return typeof input === "object" && input !== null && !Array.isArray(input);
40
+ }
41
+ function firstText(...values) {
42
+ return values.find((value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\0\r\n]/u.test(value));
43
+ }
44
+ function sessionIdentity(options) {
45
+ const nested = recordValue(options.input, "input");
46
+ return firstText(options.sessionId, recordValue(options.input, "session_id"), recordValue(options.input, "sessionId"), recordValue(options.input, "conversationId"), recordValue(options.input, "thread_id"), recordValue(options.input, "threadId"), recordValue(nested, "sessionID"), recordValue(nested, "session_id"), process.env.AGENT_OPS_SESSION_ID, process.env.CODEX_THREAD_ID);
47
+ }
48
+ function officeEvent(event) {
49
+ return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : "activity";
50
+ }
51
+ /** Phase hints come only from normalized native event kinds and exact agent-ops argv. */
52
+ export function officePhaseHint(event) {
53
+ if (event.event === "file-write" || event.event === "content")
54
+ return "implementing";
55
+ const commands = event.event === "command"
56
+ ? [event]
57
+ : event.event === "command-batch"
58
+ ? event.commands
59
+ : [];
60
+ let phase;
61
+ for (const command of commands) {
62
+ if (basename(command.command) !== "agent-ops")
63
+ continue;
64
+ const action = command.args[0];
65
+ const next = action === "verify"
66
+ ? "verifying"
67
+ : action === "review"
68
+ ? "reviewing"
69
+ : action === "worktree" && command.args[1] === "finish"
70
+ ? "integrating"
71
+ : action === "task" && command.args[1] === "complete"
72
+ ? "integrating"
73
+ : action === "task"
74
+ ? "planning"
75
+ : undefined;
76
+ if (next === "integrating" || next === "reviewing" || next === "verifying")
77
+ phase = next;
78
+ else if (phase === undefined && next !== undefined)
79
+ phase = next;
80
+ }
81
+ return phase;
82
+ }
83
+ /** Loop adapters expose Bash and native edit events with the same safe shape. */
84
+ export function officeLoopPhaseHint(input, root) {
85
+ if (!observationInput(input))
86
+ return undefined;
87
+ const tool = input.tool_name;
88
+ if (tool === "Edit" || tool === "Write" || tool === "NotebookEdit" || tool === "FileWrite")
89
+ return "implementing";
90
+ if (tool !== "Bash")
91
+ return undefined;
92
+ const toolInput = recordValue(input, "tool_input");
93
+ const command = recordValue(toolInput, "command");
94
+ return typeof command === "string" ? officePhaseHint(normalizeShellHookEvent(command, root)) : undefined;
95
+ }
96
+ /** Records hook presence and starts Office at the production process boundary. */
97
+ export async function observeOfficeSession(options) {
98
+ if (process.env.AGENT_OPS_DISABLE === "1")
99
+ return;
100
+ if (options.validated !== true)
101
+ return;
102
+ // Host hooks with malformed or absent input are fail-open and must not
103
+ // create a server from an unrelated process cwd.
104
+ if (!observationInput(options.input))
105
+ return;
106
+ const identity = sessionIdentity(options);
107
+ if (identity === undefined)
108
+ return;
109
+ try {
110
+ if (!(await officeEnabled(options.root)))
111
+ return;
112
+ const { mainRoot, commonDir } = await checkouts(options.root);
113
+ if (identity !== undefined) {
114
+ await recordOfficeSession({
115
+ sessionId: identity,
116
+ harness: options.harness,
117
+ projectRoot: mainRoot,
118
+ commonDir,
119
+ event: officeEvent(options.event),
120
+ ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
121
+ ...(process.env.AGENT_OPS_RUN_ID === undefined ? {} : { runId: process.env.AGENT_OPS_RUN_ID }),
122
+ ...(process.env.AGENT_OPS_WORKER_ID === undefined ? {} : { workerId: process.env.AGENT_OPS_WORKER_ID }),
123
+ ...(process.env.AGENT_OPS_TASK_ID === undefined ? {} : { taskId: process.env.AGENT_OPS_TASK_ID }),
124
+ ...(options.phase === undefined ? {} : { phase: options.phase }),
125
+ host: options.harness
126
+ });
127
+ }
128
+ // A resumed session may outlive the ten-minute idle shutdown. Reusing the
129
+ // same ensure path revives that repository server without creating a tab
130
+ // unless this call actually wins a new-server start claim.
131
+ if (options.event !== "Stop")
132
+ await ensureBackgroundOffice(options.root, undefined, openOfficeBrowser);
133
+ }
134
+ catch {
135
+ // Office is display-only; a missing Git checkout, lock or launcher never blocks a host hook.
136
+ }
137
+ }
138
+ async function checkouts(cwd) {
139
+ return await resolveCheckouts(worktreeDependencies(), cwd);
140
+ }
141
+ /** Serve the office until no run has been active for the idle window. */
142
+ export async function serveOffice(cwd, onUrl = () => { }, openBrowser) {
143
+ await requireOffice(cwd);
144
+ const { mainRoot, commonDir } = await checkouts(cwd);
145
+ // ponytail: one shared 1s snapshot so several polling tabs cost one git scan
146
+ let cached = null;
147
+ const snapshot = () => {
148
+ if (cached === null || Date.now() - cached.at > 1000)
149
+ cached = { at: Date.now(), value: collectOfficeInput(mainRoot, commonDir).then(buildOfficeSnapshot) };
150
+ return cached.value;
151
+ };
152
+ let finish = () => { };
153
+ const idle = new Promise((resolve) => { finish = resolve; });
154
+ const office = createOfficeServer({ snapshot, onIdle: () => finish() });
155
+ const record = await claimOffice(commonDir, office);
156
+ if (record === null) {
157
+ const live = await readLiveOffice(commonDir);
158
+ if (live !== null)
159
+ onUrl(officeUrl(live));
160
+ return;
161
+ }
162
+ onUrl(officeUrl(record));
163
+ if (openBrowser !== undefined)
164
+ void openBrowser(officeUrl(record)).catch(() => undefined);
165
+ const timer = setInterval(() => {
166
+ void officeEnabled(cwd).then(enabled => enabled ? office.tick() : finish());
167
+ }, 15_000);
168
+ await idle;
169
+ clearInterval(timer);
170
+ await releaseOffice(commonDir, record);
171
+ await office.close();
172
+ }
173
+ /** Reuse the live server or start one under launchd; null where launchd is unavailable. */
174
+ export async function ensureBackgroundOffice(cwd, launchd = new LaunchdController(), openBrowser) {
175
+ await requireOffice(cwd);
176
+ const { mainRoot, commonDir } = await checkouts(cwd);
177
+ const live = await readLiveOffice(commonDir);
178
+ if (live !== null)
179
+ return officeUrl(live);
180
+ let started = false;
181
+ const args = [entry, mainRoot];
182
+ if (!launchd.supported) {
183
+ const url = await ensureOffice(commonDir, async () => {
184
+ started = true;
185
+ await new Promise((resolve, reject) => {
186
+ const child = spawn(process.execPath, args, { cwd: mainRoot, detached: true, stdio: "ignore", windowsHide: true });
187
+ child.once("error", reject);
188
+ child.once("spawn", () => { child.unref(); resolve(); });
189
+ });
190
+ });
191
+ if (started && openBrowser !== undefined)
192
+ void openBrowser(url).catch(() => undefined);
193
+ return url;
194
+ }
195
+ const privateDirectory = join(commonDir, "agent-ops", "office");
196
+ const descriptor = createLaunchdDescriptor({
197
+ runId: "office-" + createHash("sha256").update(commonDir).digest("hex").slice(0, 12), workerId: "server",
198
+ privateDirectory, command: process.execPath, args,
199
+ cwd: mainRoot, pathEnvironment: process.env.PATH
200
+ });
201
+ const url = await ensureOffice(commonDir, async () => {
202
+ started = true;
203
+ // The descriptor write anchors on this directory, so it must exist first.
204
+ await ensurePrivateDirectory(privateDirectory, commonDir);
205
+ await launchd.writeDescriptor(descriptor);
206
+ // A server that exited idle stays loaded in launchd; reload it with the current descriptor.
207
+ await launchd.bootout(descriptor).catch(() => { });
208
+ await launchd.bootstrap(descriptor);
209
+ });
210
+ if (started && openBrowser !== undefined)
211
+ void openBrowser(url).catch(() => undefined);
212
+ return url;
213
+ }
214
+ export function openOfficeBrowser(url) {
215
+ const command = process.platform === "darwin"
216
+ ? "open"
217
+ : process.platform === "win32"
218
+ ? "rundll32.exe"
219
+ : "xdg-open";
220
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
221
+ return new Promise((resolve, reject) => {
222
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
223
+ child.once("error", reject);
224
+ child.once("spawn", () => { child.unref(); resolve(); });
225
+ });
226
+ }
227
+ if (process.argv[1] === entry) {
228
+ await serveOffice(process.argv[2] ?? process.cwd(), () => { }, process.argv[3] === "--open" ? openOfficeBrowser : undefined);
229
+ process.exit(0);
230
+ }