@kylecheng3146/agent-ops 0.7.3 → 0.8.3

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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/packages/cli/src/args.js +72 -1
  3. package/dist/packages/cli/src/bin.js +69 -10
  4. package/dist/packages/cli/src/cli.js +11 -3
  5. package/dist/packages/cli/src/codex-loop-process.js +6 -0
  6. package/dist/packages/cli/src/commands/batch.js +3 -1
  7. package/dist/packages/cli/src/commands/hook.js +4 -1
  8. package/dist/packages/cli/src/commands/review.js +9 -3
  9. package/dist/packages/cli/src/commands/task.js +44 -2
  10. package/dist/packages/cli/src/commands/update.js +18 -0
  11. package/dist/packages/cli/src/commands/verify.js +3 -1
  12. package/dist/packages/cli/src/debug-intake.js +245 -0
  13. package/dist/packages/cli/src/hook-process.js +155 -34
  14. package/dist/packages/cli/src/office-entry.js +34 -6
  15. package/dist/packages/cli/src/run-deps.js +8 -0
  16. package/dist/runtime/src/adapters/agy/config.js +3 -2
  17. package/dist/runtime/src/adapters/claude/config.js +7 -3
  18. package/dist/runtime/src/adapters/codex/config.js +10 -3
  19. package/dist/runtime/src/adapters/codex/events.js +2 -1
  20. package/dist/runtime/src/adapters/codex/input.js +20 -2
  21. package/dist/runtime/src/adapters/codex/output.js +4 -1
  22. package/dist/runtime/src/config/merge.js +1 -1
  23. package/dist/runtime/src/hooks/completion-gate.js +15 -0
  24. package/dist/runtime/src/install/harness.js +2 -0
  25. package/dist/runtime/src/install/plan.js +3 -3
  26. package/dist/runtime/src/install/profiles.js +2 -0
  27. package/dist/runtime/src/install/update.js +10 -2
  28. package/dist/runtime/src/office/activity.js +105 -0
  29. package/dist/runtime/src/office/art.js +1000 -0
  30. package/dist/runtime/src/office/collect.js +137 -10
  31. package/dist/runtime/src/office/layout.js +203 -0
  32. package/dist/runtime/src/office/page.js +883 -644
  33. package/dist/runtime/src/office/scene.js +38 -33
  34. package/dist/runtime/src/office/sessions.js +18 -1
  35. package/dist/runtime/src/office/snapshot.js +74 -1
  36. package/dist/runtime/src/parallel/finish.js +4 -2
  37. package/dist/runtime/src/parallel/manage.js +19 -2
  38. package/dist/runtime/src/review/attestation.js +4 -1
  39. package/dist/runtime/src/review/batch.js +10 -0
  40. package/dist/runtime/src/review/execute.js +10 -1
  41. package/dist/runtime/src/review/slots.js +59 -44
  42. package/dist/runtime/src/run/hosts/codex.js +1 -1
  43. package/dist/runtime/src/run/transport.js +3 -0
  44. package/dist/runtime/src/schema/validate.js +8 -2
  45. package/dist/runtime/src/security/permissions.js +3 -1
  46. package/dist/runtime/src/task/completion.js +20 -1
  47. package/dist/runtime/src/task/contract.js +1 -1
  48. package/dist/runtime/src/task/debug-intake.js +12 -0
  49. package/dist/runtime/src/task/debug-state.js +130 -0
  50. package/dist/runtime/src/task/debug-user-event.js +93 -0
  51. package/dist/runtime/src/task/debug.js +586 -0
  52. package/dist/runtime/src/task/service.js +46 -0
  53. package/dist/runtime/src/task/store.js +8 -1
  54. package/dist/runtime/src/verify/acceptance-replay.js +9 -4
  55. package/dist/runtime/src/verify/service.js +21 -2
  56. package/docs/en/guides/configuration.md +7 -0
  57. package/docs/en/guides/task-debug.md +111 -0
  58. package/docs/en/spec/harness-adapters.md +31 -4
  59. package/docs/zh-TW/guides/configuration.md +6 -0
  60. package/docs/zh-TW/guides/task-debug.md +66 -0
  61. package/docs/zh-TW/spec/harness-adapters.md +23 -4
  62. package/package.json +1 -1
  63. package/schemas/config.schema.json +30 -2
  64. package/schemas/manifest.schema.json +1 -0
  65. package/schemas/task.schema.json +62 -2
@@ -0,0 +1,245 @@
1
+ import { join } from "node:path";
2
+ import { TaskService } from "../../../runtime/src/task/service.js";
3
+ import { FileTaskStore } from "../../../runtime/src/task/store.js";
4
+ import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
5
+ import { evaluateGuardrail } from "../../../runtime/src/guardrails/evaluate.js";
6
+ import { isDebugIssue } from "../../../runtime/src/task/debug-intake.js";
7
+ import { ensureSessionWorktree, readWorktreeRecord, sessionWorktreeName } from "../../../runtime/src/parallel/service.js";
8
+ import { listWorktrees, resumeWorktree } from "../../../runtime/src/parallel/manage.js";
9
+ import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
10
+ import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
11
+ import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
12
+ import { loadEffectiveConfig } from "./context.js";
13
+ import { gitRunner, worktreeDependencies } from "./parallel-deps.js";
14
+ const foregroundClockSupported = (host) => host !== "opencode";
15
+ function nativeSessionId(input) {
16
+ const fromInput = [input.session_id, input.sessionId, input.conversationId, input.thread_id, input.threadId]
17
+ .find((value) => typeof value === "string" && value.length > 0);
18
+ return fromInput ?? [process.env.AGENT_OPS_SESSION_ID, process.env.CODEX_THREAD_ID]
19
+ .find((value) => value !== undefined && value.length > 0);
20
+ }
21
+ /** Start only an already attached DEBUG issue; native hooks never create work. */
22
+ export async function startDebugSession(root, sessionId, host) {
23
+ if (!foregroundClockSupported(host) || sessionId.length === 0)
24
+ return;
25
+ const config = (await loadEffectiveConfig(root, "project")).config;
26
+ const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId)));
27
+ const contextRoot = saved?.mainRoot === root && saved.sessionId === sessionId ? saved.path : root;
28
+ const contextConfig = contextRoot === root ? config : (await loadEffectiveConfig(contextRoot, "project")).config;
29
+ const tasks = new TaskService(new FileTaskStore(join(contextRoot, ".agent-ops/tasks/state.json"), contextRoot), { completion: { root: contextRoot, gitRunner: gitRunner(contextRoot), loadConfig: async () => contextConfig } });
30
+ const attached = await tasks.status({ sessionId }).catch(() => null);
31
+ if (attached?.status === "active" && attached.task.debug !== undefined)
32
+ await tasks.debugService().foregroundStart(attached.task.id, sessionId);
33
+ }
34
+ export async function routeDebugPrompt(root, input, host = "claude") {
35
+ if (typeof input !== "object" || input === null || Array.isArray(input))
36
+ return null;
37
+ const fields = input;
38
+ if (typeof fields.prompt !== "string")
39
+ return null;
40
+ const continuation = /^(?:繼續|继续|continue)[。.!!]?$/iu.test(fields.prompt.trim());
41
+ const debugIssue = isDebugIssue(fields.prompt);
42
+ const config = (await loadEffectiveConfig(root, "project")).config;
43
+ if (config.features.debug?.enabled !== true)
44
+ return null;
45
+ if (evaluateGuardrail({ kind: "content", content: fields.prompt, scope: root }).action === "block")
46
+ return null;
47
+ const session = nativeSessionId(fields);
48
+ if (session === undefined) {
49
+ if (!debugIssue && !continuation)
50
+ return null;
51
+ return "DEBUG intake unsupported: this hook has no reliable native session identity. Use task create --debug --goal with the original issue in the native conversation.";
52
+ }
53
+ const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(session)));
54
+ const contextRoot = saved?.mainRoot === root && saved.sessionId === session ? saved.path : root;
55
+ const contextConfig = contextRoot === root ? config : (await loadEffectiveConfig(contextRoot, "project")).config;
56
+ const original = new TaskService(new FileTaskStore(join(contextRoot, ".agent-ops/tasks/state.json"), contextRoot), { completion: { root: contextRoot, gitRunner: gitRunner(contextRoot), loadConfig: async () => contextConfig } });
57
+ const attached = await original.status({ sessionId: session }).catch(() => null);
58
+ if (attached?.status === "active" && attached.task.debug !== undefined) {
59
+ await original.debugService().syncNativeControl(attached.task.id, session);
60
+ const control = (await original.debugService().root(attached.task.id)).debug.control;
61
+ if (control?.paused === true)
62
+ return `DEBUG original issue ${attached.task.id} is paused by the human. Preserve its task, evidence and remaining budget; stop after saving progress. Only a native human continue event resumes it.`;
63
+ if (continuation) {
64
+ let message = `DEBUG continue preserved original issue ${attached.task.id}: native user event did not extend time; other limits remain authoritative.`;
65
+ try {
66
+ await original.debugService().extend(attached.task.id, session, "latest");
67
+ message = `DEBUG original issue ${attached.task.id}: native human time grant applied once; other limits remain authoritative.`;
68
+ }
69
+ catch (error) {
70
+ message = `DEBUG continue preserved original issue ${attached.task.id}: ${error instanceof Error ? error.message : "Native user approval unavailable"}`;
71
+ }
72
+ if (foregroundClockSupported(host)) {
73
+ try {
74
+ await original.debugService().foregroundStart(attached.task.id, session);
75
+ }
76
+ catch (error) {
77
+ message += ` Foreground clock unavailable: ${error instanceof Error ? error.message : "host lifecycle is unsupported"}`;
78
+ }
79
+ }
80
+ else
81
+ message += " Foreground clock unsupported on this host; preserve the issue and use a host with a trustworthy end-of-turn signal.";
82
+ if (control?.mode === "diagnose")
83
+ message += " Diagnosis-only scope remains active; continue discriminating evidence without product repairs until a proven diagnosis or runtime blocker. Only a native human resume repair event restores the full loop.";
84
+ return message;
85
+ }
86
+ if (foregroundClockSupported(host)) {
87
+ try {
88
+ await original.debugService().foregroundStart(attached.task.id, session);
89
+ }
90
+ catch (error) {
91
+ return `DEBUG original issue ${attached.task.id}: foreground clock unavailable: ${error instanceof Error ? error.message : "host lifecycle is unsupported"}`;
92
+ }
93
+ }
94
+ if (control?.mode === "diagnose")
95
+ return `DEBUG original issue ${attached.task.id}: diagnosis only. Continue reproduction and hypothesis evidence until a supported diagnosis or a runtime blocker. Do not repair product behavior. A failure to reproduce is not a stop condition; a human resume repair event restores the full loop.`;
96
+ if (!debugIssue)
97
+ return null;
98
+ return `Continue original DEBUG issue ${attached.task.id}; retain its existing budget, evidence and hypotheses.`;
99
+ }
100
+ if (!debugIssue && !continuation)
101
+ return null;
102
+ if (continuation) {
103
+ if (attached?.status !== "active" || attached.task.debug === undefined)
104
+ return null;
105
+ try {
106
+ await original.debugService().extend(attached.task.id, session, "latest");
107
+ return `DEBUG original issue ${attached.task.id}: native human time grant applied once; other limits remain authoritative.`;
108
+ }
109
+ catch (error) {
110
+ return `DEBUG continue preserved original issue ${attached.task.id}: ${error instanceof Error ? error.message : "Native user approval unavailable"}`;
111
+ }
112
+ }
113
+ if (attached?.status === "active")
114
+ return attached.task.debug === undefined ? null : `Continue original DEBUG issue ${attached.task.id}; retain its existing budget, evidence and hypotheses.`;
115
+ // Another session's worktree may already hold this issue; reusing it keeps
116
+ // its consumed budget and rounds instead of starting a fresh task.
117
+ const holders = await listWorktrees(worktreeDependencies(), root).catch(() => []);
118
+ for (const { record: held } of holders) {
119
+ if (held.sessionId === session)
120
+ continue;
121
+ const heldConfig = (await loadEffectiveConfig(held.path, "project").catch(() => null))?.config;
122
+ if (heldConfig === undefined)
123
+ continue;
124
+ const issue = await new TaskService(new FileTaskStore(join(held.path, ".agent-ops/tasks/state.json"), held.path), { completion: { root: held.path, gitRunner: gitRunner(held.path), loadConfig: async () => heldConfig } }).activeDebugRoot(fields.prompt).catch(() => null);
125
+ if (issue === null)
126
+ continue;
127
+ try {
128
+ await resumeWorktree(worktreeDependencies(), { cwd: root, name: held.name, sessionId: session });
129
+ }
130
+ catch (error) {
131
+ return `DEBUG original issue ${issue.task.id} is still active in ${held.path}; a new task would reset its budget. ${error instanceof Error ? error.message : "Resume needs confirmation."} After the user confirms, run agent-ops worktree resume ${held.name} --session ${session}.`;
132
+ }
133
+ return `Continue original DEBUG issue ${issue.task.id} in ${held.path}; its budget, rounds and evidence are preserved.`;
134
+ }
135
+ await original.assertDebugBaseline();
136
+ const record = config.worktree?.mode === "auto" && await readWorktreeRecord(root) === null ?
137
+ await ensureSessionWorktree(worktreeDependencies(), { cwd: root, sessionId: session }) : null;
138
+ const path = record?.path ?? root;
139
+ const effective = (await loadEffectiveConfig(path, "project")).config;
140
+ const tasks = new TaskService(new FileTaskStore(join(path, ".agent-ops/tasks/state.json"), path), { completion: { root: path, gitRunner: gitRunner(path), loadConfig: async () => effective } });
141
+ const existing = await tasks.status({ sessionId: session }).catch(() => null);
142
+ if (existing?.status === "active")
143
+ return existing.task.debug === undefined ? null : `Continue original DEBUG issue ${existing.task.id}.`;
144
+ const held = await tasks.activeDebugRoot(fields.prompt);
145
+ if (held !== null) {
146
+ await tasks.attach(session, held.task.id);
147
+ let message = `Continue original DEBUG issue ${held.task.id}; its budget, rounds and evidence are preserved.`;
148
+ if (foregroundClockSupported(host)) {
149
+ try {
150
+ await tasks.debugService().foregroundStart(held.task.id, session);
151
+ }
152
+ catch (error) {
153
+ message += ` Foreground clock unavailable: ${error instanceof Error ? error.message : "host lifecycle is unsupported"}`;
154
+ }
155
+ }
156
+ return message;
157
+ }
158
+ const ids = effective.verification.commands.filter(c => c.required).map(c => c.id);
159
+ const baseline = (await gitRunner(path).run(["rev-parse", "--verify", "HEAD"])).stdout.toString().trim();
160
+ const criteria = ["issue-regression", "related-checks"].map(id => ({ id, description: id === "issue-regression" ? "Reproduce and fix the original reported issue with relevant regression evidence." : "Pass necessary related checks and two fresh independent reviews.", verifierIds: ids,
161
+ acceptance: { mode: "review-only", baselineCommit: baseline, bindings: [], reviewOnlyReason: "Provisional investigation intake only. Identify and revise to executable original-issue regression before debug success; this criterion cannot prove reproduction." } }));
162
+ const created = await tasks.create({ title: fields.prompt.split("\n")[0].slice(0, 160), goal: fields.prompt, intent: "Investigate and fix the original issue through authoritative task debug evidence.", debug: { facts: ["User-reported symptom is unverified."], unknowns: ["Baseline reproduction", "Root cause", "Relevant regression and related checks"] }, criteria, sessionId: session, policyConfigHash: calculateConfigHash(effective) });
163
+ let clockNote = "";
164
+ if (foregroundClockSupported(host)) {
165
+ try {
166
+ await tasks.debugService().foregroundStart(created.task.id, session);
167
+ }
168
+ catch (error) {
169
+ return `DEBUG issue ${created.task.id} created in ${path}, but its foreground clock is unavailable: ${error instanceof Error ? error.message : "host lifecycle is unsupported"}. Preserve the issue and do not claim timed completion.`;
170
+ }
171
+ }
172
+ else
173
+ clockNote = " Native foreground timing is unsupported on this host; controlled operations remain evidence-timed and do not claim a complete active-time budget.";
174
+ return `DEBUG issue ${created.task.id} created in ${path}. Preserve the original goal. Investigate, record falsifiable hypotheses, revise provisional criteria to relevant behavioral regression, repair, verify, obtain two fresh independent reviews, then finish. A clean HEAD baseline is fixed automatically; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Defaults: 60 active minutes / 5 material repair candidates. No user-authored debug plan is required.${clockNote}`;
175
+ }
176
+ export async function interruptDebugSession(root, sessionId, nativeInput) {
177
+ const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId)));
178
+ const path = saved?.mainRoot === root && saved.sessionId === sessionId ? saved.path : root;
179
+ const tasks = new TaskService(new FileTaskStore(join(path, ".agent-ops/tasks/state.json"), path), { completion: { root: path, gitRunner: gitRunner(path), loadConfig: async () => (await loadEffectiveConfig(path, "project")).config } });
180
+ const attached = await tasks.status({ sessionId }).catch(error => {
181
+ if (error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED")
182
+ return null;
183
+ throw error;
184
+ });
185
+ if (attached?.status === "active" && attached.task.debug !== undefined)
186
+ await tasks.debugService().interrupt(attached.task.id, sessionId, nativeInput);
187
+ }
188
+ /** Close only the task attached to a native host's verified session. */
189
+ export async function closeDebugSession(root, sessionId, host = "claude") {
190
+ if (!foregroundClockSupported(host))
191
+ return;
192
+ if (sessionId.length === 0)
193
+ return;
194
+ const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId)));
195
+ const contextRoot = saved?.mainRoot === root && saved.sessionId === sessionId ? saved.path : root;
196
+ const tasks = new TaskService(new FileTaskStore(join(contextRoot, ".agent-ops/tasks/state.json"), contextRoot), { completion: { root: contextRoot, gitRunner: gitRunner(contextRoot), loadConfig: async () => (await loadEffectiveConfig(contextRoot, "project")).config } });
197
+ const attached = await tasks.status({ sessionId }).catch(() => null);
198
+ if (attached?.status === "active" && attached.task.debug !== undefined)
199
+ await tasks.debugService().foregroundStop(attached.task.id, sessionId);
200
+ }
201
+ /**
202
+ * Without the completion gate, an active DEBUG issue still cannot end a turn
203
+ * unless a current runtime stop checkpoint exists. Sessions without one pass.
204
+ */
205
+ export function debugStopGate(root, rootConfig) {
206
+ return { handle: async (event) => {
207
+ if (event.event !== "stop" || event.sessionId === undefined || event.sessionId.length === 0 ||
208
+ event.terminationReason !== "model_stop" || event.fullyIdle !== true)
209
+ return null;
210
+ const sessionId = event.sessionId;
211
+ const evaluate = async (attempt) => {
212
+ const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId))).catch(() => null);
213
+ const contextRoot = saved?.mainRoot === root && saved.sessionId === sessionId ? saved.path : root;
214
+ const config = contextRoot === root && attempt === 0 ? rootConfig : (await loadEffectiveConfig(contextRoot, "project")).config;
215
+ const runner = gitRunner(contextRoot);
216
+ const tasks = new TaskService(new FileTaskStore(join(contextRoot, ".agent-ops/tasks/state.json"), contextRoot), { completion: { root: contextRoot, gitRunner: runner, loadConfig: async () => config } });
217
+ const attached = await tasks.status({ sessionId }).catch(error => {
218
+ if (error instanceof AgentOpsError && error.code === "TASK_SESSION_UNATTACHED")
219
+ return null;
220
+ throw error;
221
+ });
222
+ if (attached?.status !== "active" || attached.task.debug === undefined)
223
+ return null;
224
+ await tasks.debugService().prepareStop(attached.task.id, sessionId);
225
+ const surface = await collectChangeSurface(runner);
226
+ const fingerprint = await calculateSourceFingerprint(contextRoot, { mode: "worktree", changedFiles: surface.paths }, runner);
227
+ return await tasks.debugService().validCheckpoint(attached.task.id, sessionId, fingerprint)
228
+ ? { action: "continue", status: "UNKNOWN", code: "COMPLETION_GATE_TASK_DEBUG_STOPPED", remedy: "Original issue remains active with a runtime-bound non-success checkpoint." }
229
+ : { action: "block", status: "FAIL", code: "COMPLETION_GATE_TASK_DEBUG_ACTIVE", remedy: "Continue the original DEBUG investigation, repair, verification and review loop. Failure to reproduce and an arbitrary checkpoint do not permit stopping. Preserve the task and budgets; record a current runtime stop checkpoint only for a proven blocker or exhausted limits." };
230
+ };
231
+ // Reload the task/config once for a transient gate fault; never rewrite bad evidence.
232
+ for (let attempt = 0; attempt < 2; attempt++) {
233
+ try {
234
+ return await evaluate(attempt);
235
+ }
236
+ catch (error) {
237
+ if (attempt === 0)
238
+ continue;
239
+ const code = error instanceof AgentOpsError ? error.code : "IO_ERROR";
240
+ return { action: "continue", status: "UNKNOWN", code: "TASK_DEBUG_GATE_UNAVAILABLE", remedy: `DEBUG remains incomplete: gate ${code} failed after one targeted reload. Preserve the original task, evidence and budgets; run agent-ops doctor and restore readable task/config evidence before resuming. No completion is attested.` };
241
+ }
242
+ }
243
+ return null;
244
+ } };
245
+ }
@@ -14,12 +14,14 @@ import { evaluateWorktreeEnter } from "../../../runtime/src/parallel/enter.js";
14
14
  import { evaluateWorktreeWrite, resolveMainRoot } from "../../../runtime/src/parallel/guard.js";
15
15
  import { ensureSessionWorktree, recordSessionOrigin } from "../../../runtime/src/parallel/service.js";
16
16
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
17
+ import { closeDebugSession, debugStopGate, interruptDebugSession, routeDebugPrompt, startDebugSession } from "./debug-intake.js";
17
18
  import { TaskService } from "../../../runtime/src/task/service.js";
18
19
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
19
20
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
20
21
  import { readWorktreeRecord } from "../../../runtime/src/parallel/service.js";
21
22
  import { FileRunRepository } from "../../../runtime/src/run/service.js";
22
23
  import { validateWorkerStopHandoff } from "../../../runtime/src/run/worker-gate.js";
24
+ import { evaluateGuardrail } from "../../../runtime/src/guardrails/evaluate.js";
23
25
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
24
26
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
25
27
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -53,13 +55,19 @@ function parseInput(source) {
53
55
  }
54
56
  function withInjectedSession(harness, input) {
55
57
  const sessionId = process.env.AGENT_OPS_SESSION_ID;
56
- return harness === "agy" &&
57
- sessionId !== undefined &&
58
- typeof input === "object" &&
59
- input !== null &&
60
- !Array.isArray(input)
61
- ? { ...input, conversationId: sessionId }
62
- : input;
58
+ const codexSession = sessionId ?? process.env.CODEX_THREAD_ID;
59
+ if ((harness !== "agy" && harness !== "codex") ||
60
+ typeof input !== "object" || input === null || Array.isArray(input))
61
+ return input;
62
+ const fields = input;
63
+ if (harness === "agy") {
64
+ return (typeof fields.conversationId === "string" && fields.conversationId.length > 0) || sessionId === undefined
65
+ ? input
66
+ : { ...fields, conversationId: sessionId };
67
+ }
68
+ return (typeof fields.session_id === "string" && fields.session_id.length > 0) || codexSession === undefined
69
+ ? input
70
+ : { ...fields, session_id: codexSession };
63
71
  }
64
72
  function writeHookOutput(io, output) {
65
73
  if (output.stdout.length > 0) {
@@ -225,7 +233,7 @@ function completionGateFor(root, config, gitRunner, harness) {
225
233
  config,
226
234
  gitRunner,
227
235
  ...(harness === undefined ? {} : { harness }),
228
- taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root)),
236
+ taskService: new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root), { completion: { root, gitRunner, loadConfig: async () => config } }),
229
237
  evidenceStore: new FileEvidenceStore(root, root),
230
238
  forRoot: async (worktree) => {
231
239
  const outcome = await loadProjectHookConfig(worktree);
@@ -246,7 +254,7 @@ function completionGateFor(root, config, gitRunner, harness) {
246
254
  */
247
255
  async function closeOfficeSession(io, dependencies) {
248
256
  try {
249
- if (process.env.AGENT_OPS_DISABLE === "1" || dependencies.office === undefined)
257
+ if (process.env.AGENT_OPS_DISABLE === "1")
250
258
  return 0;
251
259
  const input = parseInput(await readStdin(io.stdin));
252
260
  if (typeof input !== "object" || input === null || Array.isArray(input))
@@ -254,26 +262,95 @@ async function closeOfficeSession(io, dependencies) {
254
262
  const fields = input;
255
263
  const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
256
264
  const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
257
- if (outcome.kind === "invalid" || outcome.config.features.office?.enabled !== true)
265
+ if (outcome.kind === "invalid")
258
266
  return 0;
259
- await dependencies.office({
260
- root, harness: "claude", event: "SessionEnd", input, validated: true,
261
- ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
262
- });
267
+ if (dependencies.office !== undefined && outcome.config.features.office?.enabled === true)
268
+ await dependencies.office({
269
+ root, harness: "claude", event: "SessionEnd", input, validated: true,
270
+ ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
271
+ });
272
+ if (typeof fields.session_id === "string")
273
+ await closeDebugSession(root, fields.session_id, "claude").catch(() => undefined);
263
274
  }
264
275
  catch {
265
276
  // Office never blocks a session, least of all one that is ending.
266
277
  }
267
278
  return 0;
268
279
  }
280
+ /**
281
+ * A prompt submitted outside the project loop: Office records the turn's work
282
+ * starting, and command policy refuses a literal credential the way the loop
283
+ * does. Every runtime failure lets the prompt through.
284
+ */
285
+ async function promptSubmitted(harness, io, dependencies) {
286
+ try {
287
+ if (process.env.AGENT_OPS_DISABLE === "1")
288
+ return 0;
289
+ const input = parseInput(await readStdin(io.stdin));
290
+ if (typeof input !== "object" || input === null || Array.isArray(input))
291
+ return 0;
292
+ const fields = input;
293
+ const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
294
+ const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
295
+ if (outcome.kind === "invalid")
296
+ return 0;
297
+ const config = outcome.config;
298
+ if (dependencies.office !== undefined && config.features.office?.enabled === true) {
299
+ await dependencies.office({
300
+ root, harness, event: "UserPromptSubmit", input, validated: true,
301
+ ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
302
+ }).catch(() => undefined);
303
+ }
304
+ if (config.profiles.length === 0)
305
+ return 0;
306
+ // ponytail: the loop's 64 KiB prompt bound; a larger prompt goes unchecked.
307
+ const prompt = fields.prompt;
308
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 64 * 1024 || prompt.includes("\0"))
309
+ return 0;
310
+ if (evaluateGuardrail({ kind: "content", content: prompt, scope: root }).action !== "block") {
311
+ const context = await routeDebugPrompt(root, input, harness).catch(error => `DEBUG intake unavailable: ${error instanceof Error ? error.message : "preserve current state"}`);
312
+ if (context !== null)
313
+ io.writeStdout(harness === "claude" ? JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } }) : context + "\n");
314
+ return 0;
315
+ }
316
+ if (!config.profiles.includes("guardrails") && !config.profiles.includes("loop") && !config.profiles.includes("run"))
317
+ return 0;
318
+ const reason = "agent-ops blocked a suspected secret.";
319
+ if (harness === "claude") {
320
+ io.writeStdout(JSON.stringify({ decision: "block", reason }));
321
+ return 0;
322
+ }
323
+ io.writeStderr(reason + "\n");
324
+ return 2;
325
+ }
326
+ catch {
327
+ return 0;
328
+ }
329
+ }
269
330
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
270
331
  const [harness, event] = argv;
332
+ if (harness === "codex" && event === "Interrupt") {
333
+ try {
334
+ const raw = await readStdin(io.stdin), input = parseInput(raw);
335
+ if (typeof input !== "object" || input === null || Array.isArray(input))
336
+ return 0;
337
+ const fields = input;
338
+ if (typeof fields.session_id !== "string" || fields.session_id.length === 0)
339
+ return 0;
340
+ const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
341
+ await interruptDebugSession(root, fields.session_id, raw);
342
+ }
343
+ catch {
344
+ io.writeStdout(JSON.stringify({ systemMessage: "agent-ops: DEBUG interrupt progress could not be sealed; preserve the task and run agent-ops doctor before resuming." }));
345
+ }
346
+ return 0;
347
+ }
271
348
  if (harness === "claude" && event === "SessionEnd")
272
349
  return await closeOfficeSession(io, dependencies);
273
- // Both hosts whose Stop hook can actually refuse a stop. codex never fires
274
- // Stop under `codex exec` and rejects `permissionDecision:ask`, so its
275
- // escape hatch could not be user-approved; opencode can only deny a tool
276
- // call, never a stop.
350
+ if ((harness === "claude" || harness === "codex") && event === "UserPromptSubmit")
351
+ return await promptSubmitted(harness, io, dependencies);
352
+ // The generic completion gate requires an approval escape hatch. Codex
353
+ // only enforces DEBUG through runtime checkpoints and native human control.
277
354
  const completionGateInstalled = (harness === "agy" || harness === "claude") &&
278
355
  event === "Stop" &&
279
356
  argv.includes("--completion-gate");
@@ -322,8 +399,19 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
322
399
  root = inputRoot;
323
400
  }
324
401
  }
325
- const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
402
+ let configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
403
+ const nativeStop = normalizeHookInput(harnessId, parsedInput);
404
+ if (harnessId === "codex" && hookEvent === "Stop" && configOutcome.kind === "invalid")
405
+ configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
326
406
  if (configOutcome.kind === "invalid") {
407
+ if (harnessId === "codex" && nativeStop?.event === "stop" && nativeStop.sessionId !== undefined) {
408
+ writeHookOutput(io, harnessDescriptor(harnessId).runtime.formatOutput("Stop", {
409
+ action: "continue", status: "UNKNOWN", code: "TASK_DEBUG_GATE_UNAVAILABLE",
410
+ remedy: "DEBUG remains incomplete: configuration failed after one targeted reload. Preserve the task, evidence and budgets; fix .agent-ops/config.json and run agent-ops doctor. No completion is attested."
411
+ }));
412
+ await closeDebugSession(root, nativeStop.sessionId, harnessId).catch(() => undefined);
413
+ return 0;
414
+ }
327
415
  if (completionGateInstalled) {
328
416
  writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
329
417
  action: "block",
@@ -346,6 +434,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
346
434
  return 0;
347
435
  }
348
436
  const config = configOutcome.config;
437
+ if (harnessId === "codex" && hookEvent === "Stop" && nativeStop !== null && config.features.debug?.enabled === true) {
438
+ const result = await debugStopGate(root, config).handle(nativeStop);
439
+ if (result !== null) {
440
+ writeHookOutput(io, harnessDescriptor(harnessId).runtime.formatOutput("Stop", result));
441
+ if (result.action === "continue" && nativeStop.sessionId !== undefined)
442
+ await closeDebugSession(root, nativeStop.sessionId, harnessId).catch(() => undefined);
443
+ return 0;
444
+ }
445
+ }
349
446
  if (completionGateInstalled && !config.features.completionGate.enabled) {
350
447
  writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
351
448
  action: "block",
@@ -359,8 +456,10 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
359
456
  ? await repositoryTrust(root, config, cliVersion)
360
457
  : await dependencies.trust(root, config, cliVersion);
361
458
  const trusted = trustStatus === "TRUSTED";
459
+ const normalized = normalizeHookInput(harnessId, parsedInput);
460
+ if (normalized?.event === "session-start" && normalized.sessionId !== undefined)
461
+ await startDebugSession(root, normalized.sessionId, harnessId).catch(() => undefined);
362
462
  if (dependencies.office !== undefined && config.features.office?.enabled === true) {
363
- const normalized = normalizeHookInput(harnessId, parsedInput);
364
463
  if (normalized !== null) {
365
464
  const phase = officePhaseHint(normalized);
366
465
  void dependencies.office({
@@ -452,21 +551,43 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
452
551
  await recordSessionOrigin(dependencies.worktree, { cwd: root, sessionId }).catch(() => undefined);
453
552
  }
454
553
  }
455
- const output = await runHookCommand({
456
- harness: harness,
457
- event: hookEvent,
458
- stdin: rawInput,
459
- config,
460
- trusted,
461
- ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
462
- ...(worktreeEnter === undefined ? {} : { worktreeEnter }),
463
- ...(dependencies.advisory === undefined
464
- ? {}
465
- : { advisory: dependencies.advisory }),
466
- ...(stopVerification === undefined ? {} : { stopVerification }),
467
- ...(completionGate === undefined ? {} : { completionGate })
468
- });
554
+ let hookResult;
555
+ // Without the gate, DEBUG still refuses a Stop that has no valid stop
556
+ // checkpoint. Its own failure stays advisory, so it never blocks other work.
557
+ const debugStop = completionGate === undefined && hookEvent === "Stop" && normalized !== null &&
558
+ (harnessId === "agy" || harnessId === "claude" || harnessId === "codex") && config.features.debug?.enabled === true
559
+ ? await debugStopGate(root, config).handle(normalized)
560
+ : null;
561
+ if (debugStop !== null)
562
+ hookResult = debugStop;
563
+ const output = debugStop !== null
564
+ ? harnessDescriptor(harnessId).runtime.formatOutput("Stop", debugStop)
565
+ : await runHookCommand({
566
+ harness: harness,
567
+ event: hookEvent,
568
+ stdin: rawInput,
569
+ input: parsedInput,
570
+ config,
571
+ trusted,
572
+ onResult: result => { hookResult = result; },
573
+ ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
574
+ ...(worktreeEnter === undefined ? {} : { worktreeEnter }),
575
+ ...(dependencies.advisory === undefined
576
+ ? {}
577
+ : { advisory: dependencies.advisory }),
578
+ ...(stopVerification === undefined ? {} : { stopVerification }),
579
+ ...(completionGate === undefined ? {} : { completionGate })
580
+ });
469
581
  writeHookOutput(io, output);
582
+ // A Stop can be advisory or gated, but the foreground clock only ends on
583
+ // the same trusted idle model_stop that the native decision accepted.
584
+ if (normalized?.event === "stop" &&
585
+ normalized.sessionId !== undefined &&
586
+ normalized.terminationReason === "model_stop" &&
587
+ normalized.fullyIdle === true &&
588
+ hookResult?.action === "continue") {
589
+ await closeDebugSession(root, normalized.sessionId, harnessId).catch(() => undefined);
590
+ }
470
591
  }
471
592
  catch {
472
593
  if (completionGateInstalled) {
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
7
7
  import { collectOfficeBuilding } from "../../../runtime/src/office/collect.js";
8
8
  import { claimOffice, createOfficeServer, ensureOffice, legacyOfficeHome, officeRecordPath, readLiveOffice, officeUrl, releaseOffice, userOfficeHome } from "../../../runtime/src/office/server.js";
9
9
  import { registerOfficeRepo } from "../../../runtime/src/office/repos.js";
10
- import { recordOfficeSession } from "../../../runtime/src/office/sessions.js";
10
+ import { recordOfficeSession, recordOfficeSessionPhase } from "../../../runtime/src/office/sessions.js";
11
11
  import { resolveCheckouts } from "../../../runtime/src/parallel/service.js";
12
12
  import { createLaunchdDescriptor, LaunchdController } from "../../../runtime/src/run/macOS.js";
13
13
  import { ensurePrivateDirectory, readPrivateFile } from "../../../runtime/src/security/permissions.js";
@@ -47,8 +47,11 @@ function sessionIdentity(options) {
47
47
  const nested = recordValue(options.input, "input");
48
48
  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);
49
49
  }
50
- function officeEvent(event) {
51
- return event === "SessionStart" ? "start" : event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
50
+ /** agy's SessionStart is its native PreInvocation, sent as each invocation's work begins. */
51
+ export function officeEvent(harness, event) {
52
+ if (event === "SessionStart")
53
+ return harness === "agy" ? "activity" : "start";
54
+ return event === "Stop" ? "stop" : event === "SessionEnd" ? "end" : "activity";
52
55
  }
53
56
  /** Phase hints come only from normalized native event kinds and exact agent-ops argv. */
54
57
  export function officePhaseHint(event) {
@@ -64,13 +67,14 @@ export function officePhaseHint(event) {
64
67
  if (basename(command.command) !== "agent-ops")
65
68
  continue;
66
69
  const action = command.args[0];
67
- const next = action === "verify"
70
+ // batch and advance report their later steps themselves; this is their first.
71
+ const next = action === "verify" || action === "batch"
68
72
  ? "verifying"
69
73
  : action === "review"
70
74
  ? "reviewing"
71
75
  : action === "worktree" && command.args[1] === "finish"
72
76
  ? "integrating"
73
- : action === "task" && command.args[1] === "complete"
77
+ : action === "task" && (command.args[1] === "complete" || command.args[1] === "advance")
74
78
  ? "integrating"
75
79
  : action === "task"
76
80
  ? "planning"
@@ -117,7 +121,7 @@ export async function observeOfficeSession(options) {
117
121
  harness: options.harness,
118
122
  projectRoot: mainRoot,
119
123
  commonDir,
120
- event: officeEvent(options.event),
124
+ event: officeEvent(options.harness, options.event),
121
125
  ...(options.agentId === undefined ? {} : { agentId: options.agentId }),
122
126
  ...(process.env.AGENT_OPS_RUN_ID === undefined ? {} : { runId: process.env.AGENT_OPS_RUN_ID }),
123
127
  ...(process.env.AGENT_OPS_WORKER_ID === undefined ? {} : { workerId: process.env.AGENT_OPS_WORKER_ID }),
@@ -136,6 +140,30 @@ export async function observeOfficeSession(options) {
136
140
  // Office is display-only; a missing Git checkout, lock or launcher never blocks a host hook.
137
141
  }
138
142
  }
143
+ /**
144
+ * Moves these sessions' people as a batch or advance reaches each step.
145
+ * Display only: a disabled Office, an unknown session or a failed write records nothing.
146
+ */
147
+ export async function officePhaseReporter(cwd, sessionIds) {
148
+ const none = async () => { };
149
+ try {
150
+ if (sessionIds.length === 0 || !(await officeEnabled(cwd)))
151
+ return none;
152
+ const { commonDir } = await checkouts(cwd);
153
+ // One write at a time, in order, so the latest step is the one that stays.
154
+ let chain = Promise.resolve();
155
+ return async (phase) => {
156
+ chain = chain.then(async () => {
157
+ for (const sessionId of sessionIds)
158
+ await recordOfficeSessionPhase(commonDir, sessionId, phase).catch(() => { });
159
+ });
160
+ await chain;
161
+ };
162
+ }
163
+ catch {
164
+ return none;
165
+ }
166
+ }
139
167
  async function checkouts(cwd) {
140
168
  return await resolveCheckouts(worktreeDependencies(), cwd);
141
169
  }
@@ -13,6 +13,7 @@ import { FileRunRepository, RunService } from "../../../runtime/src/run/service.
13
13
  import { readPrivateFile, writePrivateFile } from "../../../runtime/src/security/permissions.js";
14
14
  import { collectChangeSurface } from "../../../runtime/src/verify/change-surface.js";
15
15
  import { worktreeDependencies, gitRunner, trustStore } from "./parallel-deps.js";
16
+ import { officePhaseReporter } from "./office-entry.js";
16
17
  import { NativeRunTransport } from "../../../runtime/src/run/transport.js";
17
18
  import { ClaudeGoalHost } from "../../../runtime/src/run/hosts/claude.js";
18
19
  import { CodexGoalHost } from "../../../runtime/src/run/hosts/codex.js";
@@ -145,6 +146,13 @@ export async function runPhaseObserver(cwd, runId, workerId) {
145
146
  const repository = new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir);
146
147
  return async (phase, progress) => await recordRunPhase(repository, runId, workerId, phase, progress);
147
148
  }
149
+ /** A run worker's advance reports to its run; an ordinary session's moves its own person in Office. */
150
+ export async function advancePhaseObserver(cwd, sessionId, env = process.env) {
151
+ if (env.AGENT_OPS_RUN_ID !== undefined && env.AGENT_OPS_WORKER_ID !== undefined)
152
+ return await runPhaseObserver(cwd, env.AGENT_OPS_RUN_ID, env.AGENT_OPS_WORKER_ID);
153
+ const office = await officePhaseReporter(cwd, sessionId === undefined ? [] : [sessionId]);
154
+ return async (phase) => await office(phase);
155
+ }
148
156
  export const runEntry = fileURLToPath(new URL("./run-entry.js", import.meta.url));
149
157
  export function runDescriptor(state) {
150
158
  return createLaunchdDescriptor({ runId: state.runId, workerId: "supervisor", privateDirectory: join(state.commonDir, "agent-ops", "runs", state.runId),