holo-codex 0.1.0 → 0.1.2

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 (27) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +141 -75
  3. package/README.zh-CN.md +141 -75
  4. package/docs/release-checklist.md +39 -9
  5. package/package.json +4 -2
  6. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +1 -1
  7. package/plugins/autonomous-pr-loop/.mcp.json +2 -4
  8. package/plugins/autonomous-pr-loop/core/cli.ts +89 -1
  9. package/plugins/autonomous-pr-loop/core/doctor.ts +35 -11
  10. package/plugins/autonomous-pr-loop/core/hook-diagnostics.ts +153 -0
  11. package/plugins/autonomous-pr-loop/core/hook-policy.ts +230 -23
  12. package/plugins/autonomous-pr-loop/core/local-install.ts +13 -19
  13. package/plugins/autonomous-pr-loop/core/storage.ts +5 -5
  14. package/plugins/autonomous-pr-loop/core/types.ts +2 -0
  15. package/plugins/autonomous-pr-loop/core/workflow-board.ts +78 -14
  16. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +4 -4
  17. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +4 -4
  18. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +4 -4
  19. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +4 -4
  20. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +191 -27
  21. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +4 -4
  22. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +4 -4
  23. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +4 -4
  24. package/plugins/autonomous-pr-loop/hooks/hooks.json +1 -104
  25. package/plugins/autonomous-pr-loop/mcp-server/dist/index.js +10551 -0
  26. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +6 -3
  27. package/plugins/autonomous-pr-loop/package.json +1 -1
@@ -19,6 +19,7 @@ export interface HookPolicyInput {
19
19
  isWorker?: boolean;
20
20
  protectedPaths?: string[];
21
21
  storage?: AgentLoopStorage;
22
+ runId?: string;
22
23
  }
23
24
 
24
25
  export interface HookPolicyDecision {
@@ -28,8 +29,19 @@ export interface HookPolicyDecision {
28
29
  blockedCommand: string;
29
30
  nextAction: string;
30
31
  reason: string;
32
+ auditDetails?: Record<string, unknown>;
31
33
  }
32
34
 
35
+ type MaintainerOverrideScope = "publish" | "merge";
36
+
37
+ interface ActiveMaintainerOverride {
38
+ decisionId: string;
39
+ scope: MaintainerOverrideScope;
40
+ expiresAt: string;
41
+ }
42
+
43
+ const REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES = ["lint", "full_tests", "gitnexus_detect"] as const;
44
+
33
45
  /** Normalize a Codex PreToolUse hook payload into an argv-like command. */
34
46
  export function commandFromHookPayload(payload: unknown): HookCommand | undefined {
35
47
  if (!isRecord(payload)) {
@@ -50,7 +62,12 @@ export function commandFromHookPayload(payload: unknown): HookCommand | undefine
50
62
 
51
63
  /** Evaluate a hook command without spawning subprocesses. */
52
64
  export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
53
- const command = unwrapCommand(normalizeCommand(input.command));
65
+ const normalized = normalizeCommand(input.command);
66
+ const shellControl = shellControlPolicy(normalized);
67
+ if (shellControl) {
68
+ return deny(renderCommand(normalized), shellControl, "policy_violation", "Run one allowlisted command at a time without shell control operators.");
69
+ }
70
+ const command = unwrapCommand(normalized);
54
71
  const blockedCommand = renderCommand(command);
55
72
  const destructive = destructivePolicy(command);
56
73
  if (destructive) {
@@ -67,10 +84,25 @@ export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
67
84
  if (protectedPath) {
68
85
  return deny(blockedCommand, protectedPath, "policy_violation", "Remove protected path changes from the command.");
69
86
  }
70
- const gate = gatedLifecyclePolicy(command, input.storage);
87
+ const gate = gatedLifecyclePolicy(command, input.storage, input.runId);
71
88
  if (gate) {
72
89
  return deny(blockedCommand, gate.policy, gate.gate, gate.nextAction);
73
90
  }
91
+ const override = activeMaintainerOverride(input.storage, lifecycleOverrideScope(command), input.runId);
92
+ if (override && matchesHookAllowlist(command)) {
93
+ return {
94
+ allow: true,
95
+ matchedPolicy: `maintainer_override:${override.scope}`,
96
+ blockedCommand,
97
+ nextAction: "Continue.",
98
+ reason: `Maintainer override ${override.decisionId} allows ${blockedCommand} until ${override.expiresAt}.`,
99
+ auditDetails: {
100
+ overrideDecisionId: override.decisionId,
101
+ overrideScope: override.scope,
102
+ overrideExpiresAt: override.expiresAt
103
+ }
104
+ };
105
+ }
74
106
  if (!matchesHookAllowlist(command)) {
75
107
  return deny(blockedCommand, "command_not_in_hook_allowlist", "policy_violation", "Use agent-loop MCP/CLI control surfaces or an allowlisted read/check command.");
76
108
  }
@@ -123,7 +155,13 @@ export function evaluatePreToolUseHook(payload: unknown, repoRoot?: string): Hoo
123
155
  try {
124
156
  const config = loadConfig(route.binding.repoRoot).config;
125
157
  storage = new SqliteAgentLoopStorage(statePath(route.binding.repoRoot));
126
- const decision = evaluateHookPolicy({ repoRoot: route.binding.repoRoot, command, storage, protectedPaths: config.protectedPaths });
158
+ const decision = evaluateHookPolicy({
159
+ repoRoot: route.binding.repoRoot,
160
+ command,
161
+ storage,
162
+ ...(route.binding.runId ? { runId: route.binding.runId } : {}),
163
+ protectedPaths: config.protectedPaths
164
+ });
127
165
  recordHookDecision(storage, decision, route.binding.runId);
128
166
  return decision;
129
167
  } catch (error) {
@@ -147,10 +185,8 @@ export function toCodexHookResponse(decision: HookPolicyDecision): Record<string
147
185
  return { continue: true };
148
186
  }
149
187
  return {
150
- decision: "deny",
151
- permissionDecision: "deny",
152
- continue: false,
153
- stopReason: decision.reason,
188
+ decision: "block",
189
+ reason: decision.reason,
154
190
  systemMessage: formatHookMessage(decision)
155
191
  };
156
192
  }
@@ -166,6 +202,7 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
166
202
  allow: decision.allow,
167
203
  matchedPolicy: decision.matchedPolicy,
168
204
  ...(decision.gate ? { gate: decision.gate } : {}),
205
+ ...(decision.auditDetails ? { auditDetails: decision.auditDetails } : {}),
169
206
  nextAction: decision.nextAction,
170
207
  commandLength: command.length,
171
208
  commandSha256: createHash("sha256").update(command).digest("hex"),
@@ -175,9 +212,11 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
175
212
  }
176
213
 
177
214
  function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDecision {
178
- const normalized = unwrapCommand(normalizeCommand(command));
215
+ const baseCommand = normalizeCommand(command);
216
+ const shellControl = shellControlPolicy(baseCommand);
217
+ const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
179
218
  const blockedCommand = renderCommand(normalized);
180
- const destructive = destructivePolicy(normalized);
219
+ const destructive = shellControl ?? destructivePolicy(normalized);
181
220
  if (destructive || lifecycleCommand(normalized)) {
182
221
  return deny(
183
222
  blockedCommand,
@@ -196,9 +235,11 @@ function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDec
196
235
  }
197
236
 
198
237
  function routeSessionMismatchDecision(command: HookCommand, reason: string): HookPolicyDecision {
199
- const normalized = unwrapCommand(normalizeCommand(command));
238
+ const baseCommand = normalizeCommand(command);
239
+ const shellControl = shellControlPolicy(baseCommand);
240
+ const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
200
241
  const blockedCommand = renderCommand(normalized);
201
- const destructive = destructivePolicy(normalized);
242
+ const destructive = shellControl ?? destructivePolicy(normalized);
202
243
  if (destructive || lifecycleCommand(normalized)) {
203
244
  return deny(
204
245
  blockedCommand,
@@ -222,7 +263,7 @@ function lifecycleCommand(command: HookCommand): boolean {
222
263
  command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "");
223
264
  }
224
265
 
225
- function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
266
+ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage, runId?: string): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
226
267
  const args = stripGitGlobalOptions(command.args);
227
268
  const lifecycleCommand = command.file === "git" && args[0] === "commit" ||
228
269
  command.file === "git" && args[0] === "push" ||
@@ -238,22 +279,24 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
238
279
  };
239
280
  }
240
281
  const current = storage.getCurrentStatus();
241
- const state = current.run?.currentState;
242
- if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR") {
282
+ const run = runId ? storage.getRun(runId) : current.run;
283
+ const state = run?.currentState;
284
+ const override = activeMaintainerOverride(storage, lifecycleOverrideScope(command), runId);
285
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR" && !override) {
243
286
  return {
244
287
  policy: "commit_push_state_gate",
245
288
  gate: current.gate?.kind ?? "policy_violation",
246
289
  nextAction: "Resume agent-loop until COMMIT_PUSH_PR owns publishing."
247
290
  };
248
291
  }
249
- if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage)) {
292
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage, runId)) {
250
293
  return {
251
294
  policy: "commit_push_prerequisite_gate",
252
295
  gate: "policy_violation",
253
296
  nextAction: "Run SELF_CHECK and GitNexus detect_changes through agent-loop before publishing."
254
297
  };
255
298
  }
256
- if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE") {
299
+ if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE" && !override) {
257
300
  return {
258
301
  policy: "merge_state_gate",
259
302
  gate: current.gate?.kind ?? "merge_requires_confirmation",
@@ -263,6 +306,45 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
263
306
  return undefined;
264
307
  }
265
308
 
309
+ function lifecycleOverrideScope(command: HookCommand): MaintainerOverrideScope | undefined {
310
+ const args = stripGitGlobalOptions(command.args);
311
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push")) {
312
+ return "publish";
313
+ }
314
+ if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge") {
315
+ return "merge";
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ function activeMaintainerOverride(storage: AgentLoopStorage | undefined, scope: MaintainerOverrideScope | undefined, runId?: string): ActiveMaintainerOverride | undefined {
321
+ if (!storage || !scope) {
322
+ return undefined;
323
+ }
324
+ const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
325
+ if (!run) {
326
+ return undefined;
327
+ }
328
+ return storage.listDecisions(run.id)
329
+ .map((decision) => {
330
+ const details = objectDetails(decision.details);
331
+ const overrideScope = stringValue(details?.scope);
332
+ const expiresAt = stringValue(details?.expiresAt);
333
+ if (decision.kind !== "maintainer_override_approved" || !overrideScope || !expiresAt) {
334
+ return undefined;
335
+ }
336
+ if (overrideScope !== scope) {
337
+ return undefined;
338
+ }
339
+ const expiresAtMs = Date.parse(expiresAt);
340
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
341
+ return undefined;
342
+ }
343
+ return { decisionId: decision.id, scope, expiresAt };
344
+ })
345
+ .find((override): override is ActiveMaintainerOverride => override !== undefined);
346
+ }
347
+
266
348
  function destructivePolicy(command: HookCommand): string | undefined {
267
349
  const args = stripGitGlobalOptions(command.args);
268
350
  if (command.file === "git" && args[0] === "reset" && args.includes("--hard")) {
@@ -271,7 +353,11 @@ function destructivePolicy(command: HookCommand): string | undefined {
271
353
  if (command.file === "git" && args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
272
354
  return "destructive_git_clean";
273
355
  }
274
- if (command.file === "git" && args[0] === "push" && args.some((arg) => ["-f", "--force", "--force-with-lease"].includes(arg))) {
356
+ if (command.file === "git" && args[0] === "push" && args.some((arg) =>
357
+ ["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) ||
358
+ arg.startsWith("+") ||
359
+ /^:[^:]+/.test(arg)
360
+ )) {
275
361
  return "destructive_git_force_push";
276
362
  }
277
363
  if (command.file === "gh" && command.args[0] === "repo" && command.args[1] === "delete") {
@@ -304,24 +390,33 @@ function protectedPathPolicy(command: HookCommand, protectedPaths: string[]): st
304
390
 
305
391
  function matchesHookAllowlist(command: HookCommand): boolean {
306
392
  const args = stripGitGlobalOptions(command.args);
393
+ if (command.file === "rg" && matchesRipgrepAllowlist(command.args) || isApplyPatchCommand(command)) {
394
+ return true;
395
+ }
307
396
  if (command.file === "git") {
308
397
  return args[0] === "status" ||
309
398
  args[0] === "branch" && args[1] === "--show-current" ||
310
399
  args[0] === "rev-parse" ||
311
400
  args[0] === "diff" ||
401
+ ["log", "show"].includes(args[0] ?? "") ||
402
+ args[0] === "grep" && matchesGitGrepAllowlist(args.slice(1)) ||
403
+ args[0] === "switch" && args.length === 2 && typeof args[1] === "string" && !args[1].startsWith("-") ||
312
404
  args[0] === "add" && args[1] === "--" ||
313
405
  args[0] === "commit" && args[1] === "-m" ||
314
- args[0] === "push" && args[1] === "-u";
406
+ args[0] === "push" && matchesGitPushAllowlist(args.slice(1));
315
407
  }
316
408
  if (command.file === "gh") {
317
409
  return command.args[0] === "auth" && command.args[1] === "status" ||
318
- command.args[0] === "pr" && ["list", "view"].includes(command.args[1] ?? "") ||
410
+ command.args[0] === "pr" && ["list", "view", "checks"].includes(command.args[1] ?? "") ||
411
+ command.args[0] === "pr" && command.args[1] === "merge" && matchesGhPrMergeAllowlist(command.args.slice(2)) ||
319
412
  command.args[0] === "api" && command.args[1] === "graphql";
320
413
  }
321
414
  if (command.file === "pnpm") {
322
415
  return command.args[0] === "test" ||
323
416
  command.args[0] === "lint" ||
324
- command.args[0] === "agent-loop" && ["status", "doctor", "logs"].includes(command.args[1] ?? "");
417
+ command.args[0] === "build:hooks" ||
418
+ command.args[0] === "build:mcp" ||
419
+ command.args[0] === "agent-loop" && matchesAgentLoopAllowlist(command.args.slice(1));
325
420
  }
326
421
  if (command.file === "npx") {
327
422
  return command.args[0] === "gitnexus" &&
@@ -333,6 +428,87 @@ function matchesHookAllowlist(command: HookCommand): boolean {
333
428
  return false;
334
429
  }
335
430
 
431
+ function matchesRipgrepAllowlist(args: string[]): boolean {
432
+ return !args.some((arg) => arg === "--pre" || arg.startsWith("--pre="));
433
+ }
434
+
435
+ function matchesGitGrepAllowlist(args: string[]): boolean {
436
+ return !args.some((arg) =>
437
+ arg === "-O" ||
438
+ arg.startsWith("-O") ||
439
+ arg === "--open-files-in-pager" ||
440
+ arg.startsWith("--open-files-in-pager=")
441
+ );
442
+ }
443
+
444
+ function matchesGitPushAllowlist(args: string[]): boolean {
445
+ return args.length >= 3 &&
446
+ args[0] === "-u" &&
447
+ args.every((arg) => !["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) && !arg.startsWith("+") && !/^:[^:]+/.test(arg));
448
+ }
449
+
450
+ function matchesGhPrMergeAllowlist(args: string[]): boolean {
451
+ const allowedFlags = new Set(["--merge", "--squash", "--rebase", "--body", "--subject"]);
452
+ for (let index = 0; index < args.length; index += 1) {
453
+ const arg = args[index] ?? "";
454
+ if (["--admin", "--auto", "--delete-branch", "-d"].includes(arg)) {
455
+ return false;
456
+ }
457
+ if (arg.startsWith("--") && !allowedFlags.has(arg)) {
458
+ return false;
459
+ }
460
+ if ((arg === "--body" || arg === "--subject") && args[index + 1]) {
461
+ index += 1;
462
+ }
463
+ }
464
+ return args.some((arg) => ["--merge", "--squash", "--rebase"].includes(arg));
465
+ }
466
+
467
+ function isApplyPatchCommand(command: HookCommand): boolean {
468
+ return command.file === "apply_patch" || command.raw?.startsWith("*** Begin Patch") === true;
469
+ }
470
+
471
+ function matchesAgentLoopAllowlist(args: string[]): boolean {
472
+ if (["status", "doctor", "logs", "observe", "timeline", "workers", "stop"].includes(args[0] ?? "")) {
473
+ return true;
474
+ }
475
+ if (args[0] === "local") {
476
+ return args[1] === "doctor";
477
+ }
478
+ if (args[0] === "hooks") {
479
+ return ["doctor", "list"].includes(args[1] ?? "");
480
+ }
481
+ if (args[0] === "delivery") {
482
+ return ["bind", "stage"].includes(args[1] ?? "");
483
+ }
484
+ if (args[0] === "evidence") {
485
+ return args[1] === "append";
486
+ }
487
+ if (args[0] === "maintainer-override") {
488
+ return args[1] === "approve";
489
+ }
490
+ return false;
491
+ }
492
+
493
+ function shellControlPolicy(command: HookCommand): string | undefined {
494
+ if (isApplyPatchCommand(command)) {
495
+ return undefined;
496
+ }
497
+ if (command.raw && hasShellControlOperator(command.raw)) {
498
+ return "shell_control_operator_forbidden";
499
+ }
500
+ if (command.file === "env") {
501
+ const index = command.args.findIndex((arg) => !arg.includes("="));
502
+ if (index >= 0) {
503
+ return shellControlPolicy({ file: basename(command.args[index] ?? ""), args: command.args.slice(index + 1) });
504
+ }
505
+ }
506
+ if ((command.file === "sh" || command.file === "bash") && command.args[0] === "-c" && command.args[1] && hasShellControlOperator(command.args[1])) {
507
+ return "shell_control_operator_forbidden";
508
+ }
509
+ return undefined;
510
+ }
511
+
336
512
  function deny(
337
513
  blockedCommand: string,
338
514
  matchedPolicy: string,
@@ -385,6 +561,10 @@ function tokenizeCommand(command: string): HookCommand {
385
561
  return { file: basename(file), args, raw: command };
386
562
  }
387
563
 
564
+ function hasShellControlOperator(value: string): boolean {
565
+ return /&&|\|\||[;|<>\n\r]/.test(value);
566
+ }
567
+
388
568
  function stripGitGlobalOptions(args: string[]): string[] {
389
569
  const result = [...args];
390
570
  while (result.length > 0) {
@@ -406,12 +586,35 @@ function stripGitGlobalOptions(args: string[]): string[] {
406
586
  return result;
407
587
  }
408
588
 
409
- function publishPrerequisitesSatisfied(storage: AgentLoopStorage): boolean {
410
- const run = storage.getCurrentRun();
589
+ function publishPrerequisitesSatisfied(storage: AgentLoopStorage, runId?: string): boolean {
590
+ const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
411
591
  if (!run) {
412
592
  return false;
413
593
  }
414
- return storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes");
594
+ if (storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes")) {
595
+ return true;
596
+ }
597
+ return publishWorkflowEvidenceSatisfied(storage, run.id);
598
+ }
599
+
600
+ function publishWorkflowEvidenceSatisfied(storage: AgentLoopStorage, runId: string): boolean {
601
+ const completed = new Set<string>();
602
+ for (const event of storage.listEvents(200)) {
603
+ const payload = objectDetails(event.payload);
604
+ if (
605
+ event.runId !== runId ||
606
+ event.kind !== "workflow_stage_evidence" ||
607
+ stringValue(payload?.stageId) !== "verify" ||
608
+ stringValue(payload?.status) !== "done"
609
+ ) {
610
+ continue;
611
+ }
612
+ const substageId = stringValue(payload?.substageId);
613
+ if (substageId) {
614
+ completed.add(substageId);
615
+ }
616
+ }
617
+ return REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES.every((substageId) => completed.has(substageId));
415
618
  }
416
619
 
417
620
  function basename(value: string): string {
@@ -421,3 +624,7 @@ function basename(value: string): string {
421
624
  function stringValue(value: unknown): string | undefined {
422
625
  return typeof value === "string" && value.length > 0 ? value : undefined;
423
626
  }
627
+
628
+ function objectDetails(value: unknown): Record<string, unknown> | undefined {
629
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
630
+ }
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { execFileSync } from "node:child_process";
3
2
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
3
  import { homedir } from "node:os";
5
4
  import { basename, dirname, join, resolve } from "node:path";
6
5
  import { runCommand } from "./command.js";
7
6
  import { isRecord } from "./config.js";
8
7
  import { AgentLoopError } from "./errors.js";
8
+ import { commandsReferencingLegacyPrivateRepo, inspectAgentLoopBinary, inspectBundledHooksConfig, redactDiagnosticText, type AgentLoopBinaryInspection, type BundledHooksConfigInspection } from "./hook-diagnostics.js";
9
9
  import { agentLoopRouterHookCommand, collectHookCommands, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
10
10
  import { CODEX_HOOK_EVENTS, hookScriptName } from "./hook-events.js";
11
11
  import { hookRegistryPath, inspectHookRegistryLock, listHookBindings } from "./hook-router.js";
@@ -90,13 +90,19 @@ export interface LocalDoctorReport {
90
90
  realPath?: string;
91
91
  expectedPackageRoot: string;
92
92
  pointsToExpectedPackage: boolean;
93
+ referencesExpectedPackage: boolean;
94
+ legacyPrivateRepoReferences: string[];
95
+ readError?: string;
96
+ readTruncated?: boolean;
93
97
  };
94
98
  hooks: {
95
99
  hooksPath: string;
96
100
  hooksJsonError?: string;
101
+ bundledHooksConfig: BundledHooksConfigInspection;
97
102
  routerInstalled: boolean;
98
103
  missingRouterEvents: string[];
99
104
  legacyCommands: string[];
105
+ legacyPrivateRepoCommands: string[];
100
106
  routerCommandsPointToExpectedDist: boolean;
101
107
  };
102
108
  bindings: {
@@ -353,8 +359,7 @@ export function inspectLocalInstall(options: LocalInstallOptions): LocalDoctorRe
353
359
  const repoRoot = canonicalPath(options.repoRoot);
354
360
  const codexHome = codexHomePath();
355
361
  const hooksPath = join(codexHome, "hooks.json");
356
- const binaryPath = firstPathBinary("agent-loop");
357
- const realBinaryPath = binaryPath && existsSync(binaryPath) ? canonicalPath(binaryPath) : undefined;
362
+ const binary = inspectAgentLoopBinary(packageRoot);
358
363
  const hooks = inspectHooks(hooksPath, packageRoot);
359
364
  const bindings = inspectBindings(codexHome, repoRoot);
360
365
  const selfLinkPollution = detectSelfLinkPollution(packageRoot);
@@ -363,12 +368,7 @@ export function inspectLocalInstall(options: LocalInstallOptions): LocalDoctorRe
363
368
  packageRoot,
364
369
  repoRoot,
365
370
  codexHome,
366
- binary: {
367
- ...(binaryPath ? { path: binaryPath } : {}),
368
- ...(realBinaryPath ? { realPath: realBinaryPath } : {}),
369
- expectedPackageRoot: packageRoot,
370
- pointsToExpectedPackage: realBinaryPath ? realBinaryPath.startsWith(packageRoot) : false
371
- },
371
+ binary,
372
372
  hooks,
373
373
  bindings,
374
374
  selfLinkPollution
@@ -599,12 +599,15 @@ function inspectHooks(hooksPath: string, packageRoot: string): LocalDoctorReport
599
599
  const missingRouterEvents = CODEX_HOOK_EVENTS.filter((event) => !commands.includes(agentLoopRouterHookCommand(event, packageRoot)));
600
600
  const expectedDist = hookDistRoot(packageRoot);
601
601
  const routerCommands = commands.filter((command) => command.includes("autonomous-pr-loop/hooks/dist/"));
602
+ const bundledHooksConfig = inspectBundledHooksConfig(packageRoot);
602
603
  return {
603
604
  hooksPath,
604
605
  ...(hooksJsonError ? { hooksJsonError } : {}),
606
+ bundledHooksConfig,
605
607
  routerInstalled: missingRouterEvents.length === 0,
606
608
  missingRouterEvents,
607
- legacyCommands: commands.filter(isLegacyAgentLoopHookCommand),
609
+ legacyCommands: commands.filter(isLegacyAgentLoopHookCommand).map(redactDiagnosticText),
610
+ legacyPrivateRepoCommands: commandsReferencingLegacyPrivateRepo(commands),
608
611
  routerCommandsPointToExpectedDist: routerCommands.length > 0 && routerCommands.every((command) => command.includes(expectedDist))
609
612
  };
610
613
  }
@@ -735,15 +738,6 @@ function gitStatus(cwd: string): string[] {
735
738
  return result.ok && result.stdout ? result.stdout.split(/\r?\n/).filter(Boolean) : [];
736
739
  }
737
740
 
738
- function firstPathBinary(name: string): string | undefined {
739
- try {
740
- const output = execFileSync("sh", ["-lc", `command -v ${name} || true`], { encoding: "utf8" }).trim();
741
- return output || undefined;
742
- } catch {
743
- return undefined;
744
- }
745
- }
746
-
747
741
  function localInstallBackupsDir(): string {
748
742
  return join(codexHomePath(), "agent-loop", "backups");
749
743
  }
@@ -602,6 +602,9 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
602
602
  }
603
603
 
604
604
  const run = this.getRun(runId);
605
+ if (!run) {
606
+ throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
607
+ }
605
608
  this.db
606
609
  .prepare(
607
610
  `insert into states (run_id, status, state, version, payload_json, created_at)
@@ -1562,7 +1565,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
1562
1565
  return row.user_version;
1563
1566
  }
1564
1567
 
1565
- private getRun(runId: string): AgentLoopRun {
1568
+ getRun(runId: string): AgentLoopRun | undefined {
1566
1569
  const row = this.db
1567
1570
  .prepare(
1568
1571
  `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
@@ -1570,10 +1573,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
1570
1573
  where id = ?`
1571
1574
  )
1572
1575
  .get(runId) as RunRow | undefined;
1573
- if (!row) {
1574
- throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
1575
- }
1576
- return fromRunRow(row);
1576
+ return row ? fromRunRow(row) : undefined;
1577
1577
  }
1578
1578
 
1579
1579
  private getActiveRun(): AgentLoopRun | undefined {
@@ -474,6 +474,8 @@ export interface AgentLoopStorage {
474
474
  listRunChecks(runId: string): AgentLoopRunCheck[];
475
475
  /** Return the latest run by update time, if any exists. */
476
476
  getCurrentRun(): AgentLoopRun | undefined;
477
+ /** Fetch a run by stable id. */
478
+ getRun(runId: string): AgentLoopRun | undefined;
477
479
  /** List persisted runs newest-first. */
478
480
  listRuns(limit?: number): AgentLoopRun[];
479
481
  /** Run a group of read queries against one consistent SQLite snapshot. */
@@ -771,13 +771,15 @@ function buildStage(input: {
771
771
  status,
772
772
  actorChips: actorChipsForStage(input.definition.id, status, input.profileRoleMapping, input.stageMetadata),
773
773
  evidenceCounts: counts,
774
- substages: input.definition.substages.map((substage, substageIndex) => ({
775
- ...substage,
776
- status: substageIndex === 0 && status === "active" ? "active" : status === "done" ? "done" : "pending",
777
- evidenceCounts: counts,
778
- latestEvidence: stageEvidence.slice(0, 3),
779
- requiredEvidence: []
780
- })),
774
+ substages: input.definition.id === "cleanup"
775
+ ? cleanupSubstages(input.definition, input.input, input.evidenceRefs, status)
776
+ : input.definition.substages.map((substage, substageIndex) => ({
777
+ ...substage,
778
+ status: substageIndex === 0 && status === "active" ? "active" : status === "done" ? "done" : "pending",
779
+ evidenceCounts: counts,
780
+ latestEvidence: stageEvidence.slice(0, 3),
781
+ requiredEvidence: []
782
+ })),
781
783
  latestAction: { label: status === "blocked" ? "Resolve blocker" : input.definition.nextAction, safeToRunFromDashboard: false, requiresConfirmation: false },
782
784
  blockers: [],
783
785
  nextAction: input.definition.nextAction
@@ -1343,14 +1345,36 @@ function satisfiedReviewEvidence(events: AgentLoopEvent[]): string | undefined {
1343
1345
  }
1344
1346
 
1345
1347
  function cleanupRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
1348
+ return cleanupSubstageRows(input);
1349
+ }
1350
+
1351
+ function cleanupSubstages(
1352
+ definition: (typeof WORKFLOW_STAGE_DEFINITIONS)[number],
1353
+ input: WorkflowBoardInput,
1354
+ refs: WorkflowEvidenceRef[],
1355
+ stageStatus: WorkflowStageStatus
1356
+ ): WorkflowBoardSubstage[] {
1357
+ const rows = cleanupSubstageRows(input);
1358
+ const firstIncompleteIndex = rows.findIndex((row) => row.status !== "passed" && row.status !== "skipped");
1359
+ return definition.substages.map((substage, index) => {
1360
+ const row = rows.find((item) => item.id === substage.id);
1361
+ const latestEvidence = cleanupEvidenceRefs(input.events, refs, substage.id);
1362
+ return {
1363
+ ...substage,
1364
+ status: row ? cleanupSubstageStatus(row, stageStatus, index === firstIncompleteIndex) : "pending",
1365
+ evidenceCounts: evidenceCounts(latestEvidence),
1366
+ latestEvidence,
1367
+ requiredEvidence: []
1368
+ };
1369
+ });
1370
+ }
1371
+
1372
+ function cleanupSubstageRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
1346
1373
  const evidence = cleanupEvidenceBySubstage(input.events);
1347
- return [
1348
- cleanupCheck("pr_merged", "PR merged", "GitHub", evidence, input.pr?.state === "MERGED", input.pr?.state ?? "no PR link"),
1349
- cleanupCheck("switched_main", "Switched to main", "Codex", evidence),
1350
- cleanupCheck("pulled_latest", "Pulled latest", "Codex", evidence),
1351
- cleanupCheck("gitnexus_reindexed", "GitNexus index rebuilt", "GitNexus", evidence),
1352
- cleanupCheck("worktree_clean", "Worktree clean", "Codex", evidence, input.run?.worktreeClean === true, String(input.run?.worktreeClean ?? "unknown"))
1353
- ];
1374
+ return cleanupDefinition().substages.map((substage) => {
1375
+ const fallback = cleanupFallback(input, substage.id);
1376
+ return cleanupCheck(substage.id, substage.label, cleanupOwner(substage.id), evidence, fallback.passed, fallback.evidence);
1377
+ });
1354
1378
  }
1355
1379
 
1356
1380
  function cleanupCheck(
@@ -1368,6 +1392,46 @@ function cleanupCheck(
1368
1392
  return { id, label, status: fallbackPassed ? "passed" : "pending", evidence: fallbackEvidence, owner };
1369
1393
  }
1370
1394
 
1395
+ function cleanupSubstageStatus(row: WorkflowCheckRow, stageStatus: WorkflowStageStatus, isFirstIncomplete: boolean): WorkflowStageStatus {
1396
+ if (row.status === "passed") return "done";
1397
+ if (row.status === "failed") return "failed";
1398
+ if (row.status === "blocked") return "blocked";
1399
+ if (row.status === "skipped") return "skipped";
1400
+ if (stageStatus === "active" && isFirstIncomplete) return "active";
1401
+ return "pending";
1402
+ }
1403
+
1404
+ function cleanupFallback(input: WorkflowBoardInput, substageId: string): { passed: boolean; evidence: string } {
1405
+ if (substageId === "pr_merged") {
1406
+ return { passed: input.pr?.state === "MERGED", evidence: input.pr?.state ?? "no PR link" };
1407
+ }
1408
+ if (substageId === "worktree_clean") {
1409
+ return { passed: input.run?.worktreeClean === true, evidence: String(input.run?.worktreeClean ?? "unknown") };
1410
+ }
1411
+ return { passed: false, evidence: "no appended evidence" };
1412
+ }
1413
+
1414
+ function cleanupOwner(substageId: string): string {
1415
+ if (substageId === "pr_merged") return "GitHub";
1416
+ if (substageId === "gitnexus_reindexed") return "GitNexus";
1417
+ return "Codex";
1418
+ }
1419
+
1420
+ function cleanupEvidenceRefs(events: AgentLoopEvent[], refs: WorkflowEvidenceRef[], substageId: string): WorkflowEvidenceRef[] {
1421
+ const eventIds = new Set(events
1422
+ .filter((event) => event.kind === WORKFLOW_EVIDENCE_KIND && payloadStage(event) === "cleanup" && payloadString(event, "substageId") === substageId)
1423
+ .map((event) => event.id));
1424
+ return refs.filter((ref) => eventIds.has(ref.id)).slice(0, 3);
1425
+ }
1426
+
1427
+ function cleanupDefinition(): (typeof WORKFLOW_STAGE_DEFINITIONS)[number] {
1428
+ const definition = STAGE_BY_ID.get("cleanup");
1429
+ if (!definition) {
1430
+ throw new AgentLoopError("invalid_config", "cleanup workflow stage definition is missing.");
1431
+ }
1432
+ return definition;
1433
+ }
1434
+
1371
1435
  function cleanupEvidenceBySubstage(events: AgentLoopEvent[]): Map<string, AgentLoopEvent> {
1372
1436
  const bySubstage = new Map<string, AgentLoopEvent>();
1373
1437
  for (const event of [...events].sort((left, right) => right.seq - left.seq)) {
@@ -923,6 +923,9 @@ var SqliteAgentLoopStorage = class {
923
923
  );
924
924
  }
925
925
  const run = this.getRun(runId);
926
+ if (!run) {
927
+ throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
928
+ }
926
929
  this.db.prepare(
927
930
  `insert into states (run_id, status, state, version, payload_json, created_at)
928
931
  values (?, ?, ?, ?, null, ?)`
@@ -1696,10 +1699,7 @@ var SqliteAgentLoopStorage = class {
1696
1699
  from runs
1697
1700
  where id = ?`
1698
1701
  ).get(runId);
1699
- if (!row) {
1700
- throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
1701
- }
1702
- return fromRunRow(row);
1702
+ return row ? fromRunRow(row) : void 0;
1703
1703
  }
1704
1704
  getActiveRun() {
1705
1705
  const row = this.db.prepare(