@kylecheng3146/agent-ops 0.8.0 → 0.8.4

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 (52) hide show
  1. package/dist/packages/cli/src/args.js +56 -1
  2. package/dist/packages/cli/src/bin.js +29 -2
  3. package/dist/packages/cli/src/cli.js +9 -3
  4. package/dist/packages/cli/src/codex-loop-process.js +6 -0
  5. package/dist/packages/cli/src/commands/hook.js +4 -1
  6. package/dist/packages/cli/src/commands/review.js +9 -3
  7. package/dist/packages/cli/src/commands/task.js +44 -2
  8. package/dist/packages/cli/src/debug-intake.js +245 -0
  9. package/dist/packages/cli/src/hook-process.js +110 -37
  10. package/dist/runtime/src/adapters/agy/config.js +3 -2
  11. package/dist/runtime/src/adapters/claude/config.js +4 -3
  12. package/dist/runtime/src/adapters/codex/config.js +7 -3
  13. package/dist/runtime/src/adapters/codex/events.js +2 -1
  14. package/dist/runtime/src/adapters/codex/input.js +20 -2
  15. package/dist/runtime/src/adapters/codex/output.js +4 -1
  16. package/dist/runtime/src/config/merge.js +1 -1
  17. package/dist/runtime/src/hooks/completion-gate.js +15 -0
  18. package/dist/runtime/src/install/harness.js +2 -0
  19. package/dist/runtime/src/install/plan.js +4 -4
  20. package/dist/runtime/src/install/profiles.js +2 -0
  21. package/dist/runtime/src/office/art.js +2 -1
  22. package/dist/runtime/src/office/layout.js +6 -3
  23. package/dist/runtime/src/office/page.js +133 -9
  24. package/dist/runtime/src/office/scene.js +13 -4
  25. package/dist/runtime/src/office/snapshot.js +12 -0
  26. package/dist/runtime/src/parallel/manage.js +19 -2
  27. package/dist/runtime/src/review/attestation.js +4 -1
  28. package/dist/runtime/src/review/execute.js +1 -1
  29. package/dist/runtime/src/review/slots.js +59 -44
  30. package/dist/runtime/src/run/hosts/codex.js +1 -1
  31. package/dist/runtime/src/run/transport.js +3 -0
  32. package/dist/runtime/src/schema/validate.js +8 -2
  33. package/dist/runtime/src/security/permissions.js +3 -1
  34. package/dist/runtime/src/task/completion.js +20 -1
  35. package/dist/runtime/src/task/contract.js +1 -1
  36. package/dist/runtime/src/task/debug-intake.js +12 -0
  37. package/dist/runtime/src/task/debug-state.js +129 -0
  38. package/dist/runtime/src/task/debug-user-event.js +93 -0
  39. package/dist/runtime/src/task/debug.js +586 -0
  40. package/dist/runtime/src/task/service.js +46 -0
  41. package/dist/runtime/src/task/store.js +8 -1
  42. package/dist/runtime/src/verify/acceptance-replay.js +9 -4
  43. package/dist/runtime/src/verify/change-surface.js +4 -1
  44. package/dist/runtime/src/verify/service.js +21 -2
  45. package/docs/en/guides/task-debug.md +111 -0
  46. package/docs/en/spec/harness-adapters.md +15 -2
  47. package/docs/zh-TW/guides/task-debug.md +66 -0
  48. package/docs/zh-TW/spec/harness-adapters.md +11 -2
  49. package/package.json +1 -1
  50. package/schemas/config.schema.json +30 -2
  51. package/schemas/manifest.schema.json +1 -0
  52. package/schemas/task.schema.json +62 -2
@@ -14,6 +14,7 @@ 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";
@@ -21,7 +22,6 @@ 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";
23
24
  import { evaluateGuardrail } from "../../../runtime/src/guardrails/evaluate.js";
24
- import { resolveCapabilities } from "../../../runtime/src/install/profiles.js";
25
25
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
26
26
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
27
27
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -55,13 +55,19 @@ function parseInput(source) {
55
55
  }
56
56
  function withInjectedSession(harness, input) {
57
57
  const sessionId = process.env.AGENT_OPS_SESSION_ID;
58
- return harness === "agy" &&
59
- sessionId !== undefined &&
60
- typeof input === "object" &&
61
- input !== null &&
62
- !Array.isArray(input)
63
- ? { ...input, conversationId: sessionId }
64
- : 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 };
65
71
  }
66
72
  function writeHookOutput(io, output) {
67
73
  if (output.stdout.length > 0) {
@@ -227,7 +233,7 @@ function completionGateFor(root, config, gitRunner, harness) {
227
233
  config,
228
234
  gitRunner,
229
235
  ...(harness === undefined ? {} : { harness }),
230
- 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 } }),
231
237
  evidenceStore: new FileEvidenceStore(root, root),
232
238
  forRoot: async (worktree) => {
233
239
  const outcome = await loadProjectHookConfig(worktree);
@@ -248,7 +254,7 @@ function completionGateFor(root, config, gitRunner, harness) {
248
254
  */
249
255
  async function closeOfficeSession(io, dependencies) {
250
256
  try {
251
- if (process.env.AGENT_OPS_DISABLE === "1" || dependencies.office === undefined)
257
+ if (process.env.AGENT_OPS_DISABLE === "1")
252
258
  return 0;
253
259
  const input = parseInput(await readStdin(io.stdin));
254
260
  if (typeof input !== "object" || input === null || Array.isArray(input))
@@ -256,12 +262,15 @@ async function closeOfficeSession(io, dependencies) {
256
262
  const fields = input;
257
263
  const root = dependencies.root ?? (typeof fields.cwd === "string" ? fields.cwd : process.cwd());
258
264
  const outcome = await hookConfigOutcome(root, dependencies.loadConfig);
259
- if (outcome.kind === "invalid" || outcome.config.features.office?.enabled !== true)
265
+ if (outcome.kind === "invalid")
260
266
  return 0;
261
- await dependencies.office({
262
- root, harness: "claude", event: "SessionEnd", input, validated: true,
263
- ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
264
- });
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);
265
274
  }
266
275
  catch {
267
276
  // Office never blocks a session, least of all one that is ending.
@@ -292,13 +301,19 @@ async function promptSubmitted(harness, io, dependencies) {
292
301
  ...(typeof fields.session_id === "string" ? { sessionId: fields.session_id } : {})
293
302
  }).catch(() => undefined);
294
303
  }
295
- if (config.profiles.length === 0 || !resolveCapabilities(config).capabilities.includes("command-policy"))
304
+ if (config.profiles.length === 0)
296
305
  return 0;
297
306
  // ponytail: the loop's 64 KiB prompt bound; a larger prompt goes unchecked.
298
307
  const prompt = fields.prompt;
299
308
  if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 64 * 1024 || prompt.includes("\0"))
300
309
  return 0;
301
- if (evaluateGuardrail({ kind: "content", content: prompt, scope: root }).action !== "block")
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"))
302
317
  return 0;
303
318
  const reason = "agent-ops blocked a suspected secret.";
304
319
  if (harness === "claude") {
@@ -314,14 +329,28 @@ async function promptSubmitted(harness, io, dependencies) {
314
329
  }
315
330
  export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
316
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
+ }
317
348
  if (harness === "claude" && event === "SessionEnd")
318
349
  return await closeOfficeSession(io, dependencies);
319
350
  if ((harness === "claude" || harness === "codex") && event === "UserPromptSubmit")
320
351
  return await promptSubmitted(harness, io, dependencies);
321
- // Both hosts whose Stop hook can actually refuse a stop. codex never fires
322
- // Stop under `codex exec` and rejects `permissionDecision:ask`, so its
323
- // escape hatch could not be user-approved; opencode can only deny a tool
324
- // call, never a stop.
352
+ // The generic completion gate requires an approval escape hatch. Codex
353
+ // only enforces DEBUG through runtime checkpoints and native human control.
325
354
  const completionGateInstalled = (harness === "agy" || harness === "claude") &&
326
355
  event === "Stop" &&
327
356
  argv.includes("--completion-gate");
@@ -370,8 +399,19 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
370
399
  root = inputRoot;
371
400
  }
372
401
  }
373
- 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);
374
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
+ }
375
415
  if (completionGateInstalled) {
376
416
  writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
377
417
  action: "block",
@@ -394,6 +434,15 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
394
434
  return 0;
395
435
  }
396
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
+ }
397
446
  if (completionGateInstalled && !config.features.completionGate.enabled) {
398
447
  writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
399
448
  action: "block",
@@ -407,8 +456,10 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
407
456
  ? await repositoryTrust(root, config, cliVersion)
408
457
  : await dependencies.trust(root, config, cliVersion);
409
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);
410
462
  if (dependencies.office !== undefined && config.features.office?.enabled === true) {
411
- const normalized = normalizeHookInput(harnessId, parsedInput);
412
463
  if (normalized !== null) {
413
464
  const phase = officePhaseHint(normalized);
414
465
  void dependencies.office({
@@ -500,21 +551,43 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
500
551
  await recordSessionOrigin(dependencies.worktree, { cwd: root, sessionId }).catch(() => undefined);
501
552
  }
502
553
  }
503
- const output = await runHookCommand({
504
- harness: harness,
505
- event: hookEvent,
506
- stdin: rawInput,
507
- config,
508
- trusted,
509
- ...(worktreeGuard === undefined ? {} : { worktreeGuard }),
510
- ...(worktreeEnter === undefined ? {} : { worktreeEnter }),
511
- ...(dependencies.advisory === undefined
512
- ? {}
513
- : { advisory: dependencies.advisory }),
514
- ...(stopVerification === undefined ? {} : { stopVerification }),
515
- ...(completionGate === undefined ? {} : { completionGate })
516
- });
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
+ });
517
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
+ }
518
591
  }
519
592
  catch {
520
593
  if (completionGateInstalled) {
@@ -54,7 +54,7 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
54
54
  throw new AgentOpsError("AGY_HOOK_PATH_INVALID", "agy hook runtime path is invalid.");
55
55
  }
56
56
  const hooks = {};
57
- if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
57
+ if ((capabilities.includes("lifecycle-summary") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) || capabilities.includes("project-loop")) {
58
58
  hooks.PreInvocation = [handler(runtimePath, "SessionStart", platform)];
59
59
  }
60
60
  if ((capabilities.includes("command-policy") || capabilities.includes("office-presence")) || capabilities.includes("project-loop")) {
@@ -62,7 +62,8 @@ export function buildAgyHookSettings(capabilities, runtimePath, platform = proce
62
62
  }
63
63
  if (capabilities.includes("office-presence") ||
64
64
  capabilities.includes("optional-stop-verify") ||
65
- capabilities.includes("completion-gate")) {
65
+ capabilities.includes("completion-gate") ||
66
+ capabilities.includes("task-debug")) {
66
67
  hooks.Stop = [handler(runtimePath, "Stop", platform, capabilities.includes("completion-gate"))];
67
68
  }
68
69
  return { hooks };
@@ -115,9 +115,10 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
115
115
  }
116
116
  // The loop has its own prompt hook. Outside it, a prompt is both the
117
117
  // turn's start for Office and content for command policy.
118
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
118
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
119
119
  hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
120
- hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
120
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
121
+ hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
121
122
  }
122
123
  }
123
124
  // The gate supersedes report-only Stop verification: one managed Stop
@@ -125,7 +126,7 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
125
126
  if (capabilities.includes("completion-gate")) {
126
127
  hooks.Stop = [matcherGroup("Stop", runtimePath, true)];
127
128
  }
128
- else if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
129
+ else if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
129
130
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
130
131
  }
131
132
  // Stop only ends a turn. SessionEnd is the one signal that the conversation
@@ -84,14 +84,18 @@ export function buildCodexHookConfig(capabilities, runtimePath) {
84
84
  }
85
85
  // The loop has its own prompt hook. Outside it, a prompt is both the
86
86
  // turn's start for Office and content for command policy.
87
- if (capabilities.includes("command-policy") || capabilities.includes("office-presence")) {
87
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence") || capabilities.includes("task-debug")) {
88
88
  hooks.UserPromptSubmit = [matcherGroup("UserPromptSubmit", runtimePath)];
89
- hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
89
+ if (capabilities.includes("command-policy") || capabilities.includes("office-presence"))
90
+ hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
90
91
  }
91
92
  }
92
- if (capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
93
+ if (capabilities.includes("task-debug") || capabilities.includes("optional-stop-verify") || capabilities.includes("office-presence")) {
93
94
  hooks.Stop = [matcherGroup("Stop", runtimePath)];
94
95
  }
96
+ if (capabilities.includes("task-debug")) {
97
+ hooks.Interrupt = [{ hooks: [{ ...commandHook("Interrupt", runtimePath), timeout: 3 }] }];
98
+ }
95
99
  return {
96
100
  description: "agent-ops lifecycle hooks.",
97
101
  hooks
@@ -8,7 +8,8 @@ export const CODEX_SUPPORTED_EVENTS = [
8
8
  "PostCompact",
9
9
  "SubagentStart",
10
10
  "SubagentStop",
11
- "Stop"
11
+ "Stop",
12
+ "Interrupt"
12
13
  ];
13
14
  export function codexMatcherSupport(event) {
14
15
  return event === "PreToolUse" ? "tool-name" : "unsupported";
@@ -15,17 +15,35 @@ export function normalizeCodexHookInput(input) {
15
15
  return normalizeHookEvent(input);
16
16
  }
17
17
  const projectRoot = input.cwd;
18
+ const sessionId = typeof input.session_id === "string" && input.session_id.length > 0
19
+ ? input.session_id
20
+ : undefined;
18
21
  if (input.hook_event_name === "SessionStart") {
19
- return normalizeHookEvent({
22
+ const start = normalizeHookEvent({
20
23
  event: "session-start",
21
24
  projectRoot
22
25
  });
26
+ return {
27
+ event: "session-start",
28
+ projectRoot: start.projectRoot,
29
+ ...(sessionId === undefined ? {} : { sessionId })
30
+ };
23
31
  }
24
32
  if (input.hook_event_name === "Stop") {
25
- return normalizeHookEvent({
33
+ const stop = normalizeHookEvent({
26
34
  event: "stop",
27
35
  projectRoot
28
36
  });
37
+ return {
38
+ event: "stop",
39
+ projectRoot: stop.projectRoot,
40
+ ...(sessionId === undefined ? {} : { sessionId }),
41
+ terminationReason: typeof input.termination_reason === "string"
42
+ ? input.termination_reason
43
+ : "model_stop",
44
+ // stop_hook_active means a prior continuation, not a non-idle Stop.
45
+ fullyIdle: true
46
+ };
29
47
  }
30
48
  if (input.hook_event_name === "PreToolUse" &&
31
49
  input.tool_name === "Bash") {
@@ -1,6 +1,9 @@
1
1
  export const CODEX_NON_ZERO_EXIT_BEHAVIOR = "UNKNOWN";
2
2
  export const CODEX_PRE_TOOL_BLOCKING = "UNKNOWN";
3
3
  export function codexHookOutput(event, result) {
4
+ if (event === "Stop" && result.action === "block") {
5
+ return { exitCode: 0, stdout: JSON.stringify({ decision: "block", reason: `agent-ops: ${result.code}. ${result.remedy ?? "Continue the unfinished task."}` }) };
6
+ }
4
7
  if (event === "Stop" && result.evidence !== undefined) {
5
8
  return {
6
9
  exitCode: 0,
@@ -28,7 +31,7 @@ export function codexHookOutput(event, result) {
28
31
  continue: result.action !== "block",
29
32
  ...(result.action === "block"
30
33
  ? { stopReason: result.code }
31
- : { systemMessage: `agent-ops: ${result.code}` })
34
+ : { systemMessage: `agent-ops: ${result.code}${result.remedy === undefined ? "" : ". " + result.remedy}` })
32
35
  })
33
36
  };
34
37
  }
@@ -114,7 +114,7 @@ export function mergeConfigLayers(inputLayers) {
114
114
  let worktree;
115
115
  for (const layer of layers) {
116
116
  schemaVersion = effective(layer.config.schemaVersion, layer);
117
- features = effective(layer.config.features, layer);
117
+ features = effective({ ...layer.config.features, ...(layer.config.features.debug === undefined && features?.value.debug !== undefined ? { debug: features.value.debug } : {}) }, layer);
118
118
  // Whole-block override: a workflow choice, not a guardrail, so a project
119
119
  // may turn a user-level `auto` off.
120
120
  if (layer.config.worktree !== undefined) {
@@ -260,6 +260,9 @@ export class CompletionGateService {
260
260
  }
261
261
  }
262
262
  async grantPermit(sessionId) {
263
+ const attached = await this.#options.taskService.status({ sessionId }).catch(() => null);
264
+ if (attached?.task.debug !== undefined && attached.status === "active")
265
+ throw new AgentOpsError("TASK_DEBUG_CHECKPOINT_REQUIRED", "Active debug issues require a runtime stop checkpoint; allow-stop does not grant budget or success.");
263
266
  const fingerprint = await this.#fingerprint();
264
267
  const granted = await this.#store.mutate(sessionId, (state) => {
265
268
  if (state === null) {
@@ -485,6 +488,18 @@ export class CompletionGateService {
485
488
  }
486
489
  return last;
487
490
  }
491
+ const debugTask = await this.#options.taskService.status({ sessionId }).catch(() => null);
492
+ if (debugTask?.task.debug !== undefined) {
493
+ if (debugTask.status === "active") {
494
+ await this.#options.taskService.debugService().prepareStop(debugTask.task.id, sessionId);
495
+ const valid = await this.#options.taskService.debugService().validCheckpoint(debugTask.task.id, sessionId, fingerprint);
496
+ return valid ? gateResult("continue", "UNKNOWN", "COMPLETION_GATE_TASK_DEBUG_STOPPED", "Original issue remains active with a runtime-bound non-success checkpoint.") :
497
+ gateResult("block", "FAIL", "COMPLETION_GATE_TASK_DEBUG_ACTIVE", "Continue the investigation/evidence loop or record a current runtime stop checkpoint, even when Git has no net diff.");
498
+ }
499
+ const failure = await this.#validateTask(sessionId);
500
+ if (failure !== null)
501
+ return failure;
502
+ }
488
503
  if (changed) {
489
504
  const failure = await this.#validateTask(sessionId);
490
505
  if (failure !== null)
@@ -361,6 +361,8 @@ export function managedRules(descriptor, context) {
361
361
  : [`Toolkit version: ${context.toolkitVersion}`]),
362
362
  ""
363
363
  ];
364
+ if (context.capabilities.includes("task-debug"))
365
+ lines.push("Task DEBUG routing is enabled for investigation/bugfix issues. Native prompt hooks create the issue task when a session identity is available; otherwise use agent-ops task create --debug --goal <verbatim issue> with two to five issue-specific criteria. Explain/read-only, feature and refactor requests use the existing normal task flow.", "For DEBUG: understand the issue, investigate with task debug --debug-action investigate --command-id <trusted verifier>, record falsifiable hypotheses with --debug-action hypothesis --payload-file <json>, reproduce, fix, verify, then obtain two fresh independent reviews and finish automatically. A clean HEAD is the automatic baseline; 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. Replace provisional intake criteria with relevant behavioral acceptance; baseline setup/skip/timeout/UNKNOWN is never reproduction. Never report success from edits or unrelated green checks.", "DEBUG defaults are 60 cumulative active minutes and 5 material repair candidates across the original issue, revisions, sessions and children. After two stagnant candidates document one substantive strategy switch; two further stagnant candidates block. Save task debug --debug-action checkpoint before stopping. Resume preserves budgets and blockers. A human continue event may authorize --debug-action extend --event-id latest; unsupported native event sources fail closed. Never use allow-stop for DEBUG.", "DEBUG human control comes only from native human events: '只診斷' / 'diagnosis only' keeps investigating without product repairs until current sealed baseline assertion FAIL confirms a hypothesis or runtime limits/blockers permit non-success Stop. '暫停' / 'pause' saves progress and stops immediately. '繼續' / 'continue' resumes the existing scope; '繼續修復' / 'resume repair' restores the full loop. Codex Interrupt is a pause. Preserve the original task, evidence, consumed time and rounds; diagnosis or pause never marks repair complete. CLI actor/text fields and synthetic instructions cannot authorize these changes.", "DEBUG is foreground work. On Claude, agy and native Codex, Stop refuses an active DEBUG issue without a valid stop checkpoint, with or without the completion gate. Codex uses decision:block to continue the original task even on repeated Stop; stop_hook_active does not waive it. Codex's generic completion gate remains unsupported; opencode has no supported foreground lifecycle. DEBUG gate faults get one targeted task/config reload, then an explicit UNKNOWN/incomplete diagnostic and non-success Stop, preserving state. Without reliable native session identity, automatic hook intake is unsupported; the existing native Agent can use the task CLI. Only a matching owned debug worktree checkpoint permits dirty recovery; preserve all original user edits.", "");
364
366
  if (context.capabilities.includes("rules")) {
365
367
  lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
366
368
  }
@@ -1,4 +1,5 @@
1
1
  import { lstat, readFile } from "node:fs/promises";
2
+ import { DEBUG_DEFAULTS } from "../task/debug-state.js";
2
3
  import { CONFIG_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION } from "../contracts.js";
3
4
  import { discoverProject } from "../discovery/index.js";
4
5
  import { sha256 } from "../fs/hash.js";
@@ -97,6 +98,7 @@ function buildConfig(profiles, existing, reviewTargets = [], detectedCommands =
97
98
  profiles: [...profiles],
98
99
  verification,
99
100
  features: {
101
+ debug: existing?.features.debug ?? { ...DEBUG_DEFAULTS },
100
102
  ...(existing?.features ?? {
101
103
  stopVerification: { enabled: false },
102
104
  completionGate: { enabled: completionGateEnabled }
@@ -439,9 +441,7 @@ export async function createInstallPlan(options) {
439
441
  const existing = await readExistingManifest(options.root);
440
442
  assertCompatibleManifest(existing?.manifest ?? null, options.scope, options.harness, options.allowHarnessChange === true);
441
443
  const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? [], completionGateEnabled, options.worktree, options.officeEnabled);
442
- resolved.capabilities = resolved.capabilities.filter(capability => capability !== "office-presence");
443
- if (config.config.features.office?.enabled === true)
444
- resolved.capabilities.push("office-presence");
444
+ resolved.capabilities = resolveCapabilities(config.config).capabilities;
445
445
  const existingOpencodePluginPath = existing?.manifest.artifacts.find(({ id }) => id === "opencode-plugin")?.path;
446
446
  const explicitHookTargets = new Map();
447
447
  for (const target of options.hookTargets ?? []) {
@@ -483,7 +483,7 @@ export async function createInstallPlan(options) {
483
483
  });
484
484
  const contribution = {
485
485
  artifacts: [
486
- ...(options.scope === "project" ? [{ id: "runtime-ignore", path: ".agent-ops/.gitignore", content: "/tasks/\n/reviews/\n" }] : []),
486
+ ...(options.scope === "project" ? [{ id: "runtime-ignore", path: ".agent-ops/.gitignore", content: "/tasks/\n/reviews/\n/state/\n" }] : []),
487
487
  ...baseContribution.artifacts,
488
488
  ...loopContribution.artifacts
489
489
  ],
@@ -34,6 +34,8 @@ export function resolveProfiles(inputProfiles) {
34
34
  }
35
35
  export function resolveCapabilities(config) {
36
36
  const resolved = resolveProfiles(config.profiles);
37
+ if (config.features.debug?.enabled === true)
38
+ resolved.capabilities.push("task-debug");
37
39
  if (config.features.office?.enabled === true)
38
40
  resolved.capabilities.push("office-presence");
39
41
  if (config.features.stopVerification.enabled) {
@@ -293,7 +293,8 @@ export function avatarColors(identity, role) {
293
293
  let hash = 0;
294
294
  for (let i = 0; i < identity.length; i++)
295
295
  hash = (Math.imul(hash, 31) + identity.charCodeAt(i)) >>> 0;
296
- const hair = hairs[hash % 3], skin = skins[Math.floor(hash / 3) % 3], shirt = clothes[role] ?? clothes.visitor;
296
+ // The viewer always wears the lightest skin; everyone else follows their identity.
297
+ const hair = hairs[hash % 3], skin = skins[role === "viewer" ? 0 : Math.floor(hash / 3) % 3], shirt = clothes[role] ?? clothes.visitor;
297
298
  return { O: "#292633", H: hair[0], I: hair[1], J: hair[2], S: skin[0], T: skin[1], U: skin[2],
298
299
  C: shirt[0], D: shirt[1], E: shirt[2], P: "#404958", Q: "#626e7f", B: "#2e3443", K: "#292633", R: "#e39a86" };
299
300
  }
@@ -116,10 +116,13 @@ export function officeLayout() {
116
116
  board: { x: 118, y: 42 },
117
117
  staff: {
118
118
  stations: { qa: rooms[2].slots[0], desk: rooms[3].slots[0], armchair: rooms[3].slots[1], integrator: rooms[4].slots[0] },
119
- // Two sit on the sofa, in front of its back; two stand chatting at the cafe table.
120
- rest: [seat(83, 212, "sit", 262, { x: 100, y: 268 }), seat(101, 212, "sit", 262, { x: 118, y: 268 }),
121
- seat(88, 241, "right0", 291, { x: 106, y: 290 }), seat(148, 241, "left0", 291, { x: 166, y: 290 })]
119
+ // Where idle staff spend time: the sofa, both sides of the cafe table, then the cooler,
120
+ // vending machine, fridge and counter, seen from behind.
121
+ spots: [seat(83, 212, "sit", 262, { x: 100, y: 268 }), seat(101, 212, "sit", 262, { x: 118, y: 268 }),
122
+ seat(88, 241, "right0", 291, { x: 106, y: 290 }), seat(148, 241, "left0", 291, { x: 166, y: 290 }),
123
+ stand(137, 183, "up0"), stand(113, 181, "up0"), stand(87, 181, "up0"), stand(49, 181, "up0")]
122
124
  },
125
+ look: { board: stand(134, 64, "up0"), status: stand(414, 100, "up0") },
123
126
  hotspots: [
124
127
  { kind: "task", room: "planning", rects: [area("board", 118, 42)] },
125
128
  // The monitor sits at (30, 1) inside a cubicle, one pixel further in once outlined.