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
@@ -1,6 +1,6 @@
1
1
  # HOLO-Codex npm Release Checklist
2
2
 
3
- Use this checklist for npm releases such as `v0.1.0`.
3
+ Use this checklist for npm releases such as `v0.1.2`. Replace `VERSION` with the package version being released.
4
4
 
5
5
  The public source release remains available at `https://github.com/tizerluo/HOLO-Codex`. The npm package is `holo-codex` and installs the stable `agent-loop` CLI. Compatibility identifiers remain unchanged: `agent-loop`, `.agent-loop/`, `autonomous-pr-loop`, and `plugins/autonomous-pr-loop/`.
6
6
 
@@ -12,7 +12,7 @@ Run from the release checkout:
12
12
  pnpm install --frozen-lockfile
13
13
  pnpm build:hooks
14
14
  pnpm lint
15
- pnpm test
15
+ pnpm exec vitest run --no-file-parallelism --maxWorkers=1
16
16
  npm pack --ignore-scripts --dry-run --json
17
17
  npm view holo-codex name version dist-tags --json || true
18
18
  ```
@@ -33,23 +33,52 @@ Expected result: no real tokens, no committed `.agent-loop/`, no raw hook payloa
33
33
  ## Tarball Smoke
34
34
 
35
35
  ```bash
36
- npm pack --ignore-scripts --json
36
+ pack_json="$(npm pack --ignore-scripts --json)"
37
+ tgz="$(node -e 'const fs = require("fs"); const pack = JSON.parse(fs.readFileSync(0, "utf8")); console.log(pack[0].filename)' <<< "$pack_json")"
38
+ tar -xOf "$tgz" package/plugins/autonomous-pr-loop/hooks/hooks.json
39
+ tar -xOf "$tgz" package/plugins/autonomous-pr-loop/hooks/hooks.json | node -e 'const fs = require("fs"); const hooks = JSON.parse(fs.readFileSync(0, "utf8")); const legacy = Object.keys(hooks).filter((key) => key !== "hooks"); if (!hooks.hooks || typeof hooks.hooks !== "object" || legacy.length) { console.error(JSON.stringify({ legacy }, null, 2)); process.exit(1); }'
37
40
  tmp="$(mktemp -d)"
38
41
  export CODEX_HOME="$tmp/codex-home"
39
42
  mkdir -p "$tmp/target-repo"
40
43
  git -C "$tmp/target-repo" init -b main
41
44
  git -C "$tmp/target-repo" remote add origin https://github.com/example/holo-codex-smoke.git
42
- npm install --prefix "$tmp/install" ./holo-codex-*.tgz
45
+ npm install --prefix "$tmp/install" "./$tgz"
43
46
  "$tmp/install/node_modules/.bin/agent-loop" --help
44
47
  "$tmp/install/node_modules/.bin/agent-loop" --repo "$tmp/target-repo" init --json
45
48
  "$tmp/install/node_modules/.bin/agent-loop" install-hooks --repo "$tmp/target-repo" --json
46
49
  "$tmp/install/node_modules/.bin/agent-loop" --repo "$tmp/target-repo" local doctor --json
47
50
  ```
48
51
 
49
- Do not publish if this smoke fails.
52
+ The extracted `hooks.json` must have a top-level `hooks` object and must not have legacy top-level hook event keys such as `PreToolUse`. Do not publish if this smoke fails.
50
53
 
51
54
  ## Publish
52
55
 
56
+ ### GitHub Actions CD
57
+
58
+ For `v0.1.2` and later, prefer the manual Release workflow:
59
+
60
+ 1. Configure npm Trusted Publishing for package `holo-codex`:
61
+ - Provider: GitHub Actions
62
+ - Repository: `tizerluo/HOLO-Codex`
63
+ - Workflow file: `release.yml`
64
+ - Allowed action: `npm publish`
65
+ - Environment: leave blank unless the workflow is later changed to use one.
66
+ 2. Run the `Release` workflow from `main` with:
67
+ - `version`: the exact `package.json` version
68
+ - `tag`: blank to use `v<version>`, or an explicit tag
69
+ - `dry_run`: `true` first
70
+ 3. Inspect the dry-run artifact and logs.
71
+ 4. Re-run with `dry_run: false` to publish with npm provenance and create the GitHub Release.
72
+
73
+ The workflow uses GitHub OIDC (`id-token: write`) instead of a long-lived npm token. It runs on Node 24 and installs npm `^11.5.1` in both validation and publish jobs, satisfying the npm Trusted Publishing floor of Node 22.14.0+ and npm 11.5.1+.
74
+ Dry runs may use an already-published version to test the workflow shape; real releases fail if the npm version or Git tag already exists.
75
+ Dry runs validate inputs, run the stable Vitest suite, pack the tarball, verify hook schema, smoke the packed tarball, and upload the release candidate. The workflow checks existing npm versions through retried registry HTTP status codes instead of parsing npm CLI error text. The publish job re-verifies the downloaded tarball integrity against `holo-pack.json` before `npm publish`.
76
+ Dry runs do not execute `npm publish`, so the first `dry_run: false` run is the first real Trusted Publishing/OIDC validation.
77
+ After a real publish, the workflow creates the Git tag and GitHub Release before the registry install smoke. That keeps the release recoverable even if npm registry propagation makes the smoke temporarily fail.
78
+ The manual fallback below is intentionally separate from Trusted Publishing. It may not create provenance unless npm supports it in the local environment and the maintainer explicitly chooses that path.
79
+
80
+ ### Manual fallback
81
+
53
82
  Confirm npm authentication without printing tokens:
54
83
 
55
84
  ```bash
@@ -126,11 +155,12 @@ Expected result: hooks and binding registry restore from the snapshot, non-agent
126
155
  After publish and post-publish smoke pass:
127
156
 
128
157
  ```bash
129
- git tag v0.1.0
130
- git push origin v0.1.0
131
- gh release create v0.1.0 \
158
+ VERSION=0.1.2
159
+ git tag "v$VERSION"
160
+ git push origin "v$VERSION"
161
+ gh release create "v$VERSION" \
132
162
  --repo tizerluo/HOLO-Codex \
133
- --title "HOLO-Codex v0.1.0" \
163
+ --title "HOLO-Codex v$VERSION" \
134
164
  --notes-file /path/to/release-notes.md
135
165
  ```
136
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holo-codex",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Human On Loop Codex control plane for observable, recoverable Codex workflow loops.",
5
5
  "license": "MIT",
6
6
  "author": "tizerluo",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "README.md",
30
30
  "README.zh-CN.md",
31
+ "CHANGELOG.md",
31
32
  "LICENSE",
32
33
  "SECURITY.md",
33
34
  "CONTRIBUTING.md",
@@ -59,7 +60,8 @@
59
60
  "scripts": {
60
61
  "agent-loop": "tsx plugins/autonomous-pr-loop/scripts/agent-loop.ts",
61
62
  "build:hooks": "esbuild plugins/autonomous-pr-loop/hooks/pre-tool-use.ts plugins/autonomous-pr-loop/hooks/post-tool-use.ts plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts plugins/autonomous-pr-loop/hooks/stop.ts plugins/autonomous-pr-loop/hooks/session-start.ts plugins/autonomous-pr-loop/hooks/pre-compact.ts plugins/autonomous-pr-loop/hooks/post-compact.ts plugins/autonomous-pr-loop/hooks/permission-request.ts --bundle --platform=node --format=esm --outdir=plugins/autonomous-pr-loop/hooks/dist",
62
- "prepack": "pnpm build:hooks",
63
+ "build:mcp": "esbuild plugins/autonomous-pr-loop/mcp-server/src/index.ts --bundle --platform=node --format=esm --outfile=plugins/autonomous-pr-loop/mcp-server/dist/index.js",
64
+ "prepack": "pnpm build:hooks && pnpm build:mcp",
63
65
  "test": "vitest run",
64
66
  "lint": "tsc --noEmit"
65
67
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autonomous-pr-loop",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Human On Loop Codex control plane for observable, recoverable Codex workflow loops.",
5
5
  "skills": "./skills/",
6
6
  "interface": {
@@ -2,11 +2,9 @@
2
2
  "mcpServers": {
3
3
  "autonomous-pr-loop": {
4
4
  "cwd": ".",
5
- "command": "pnpm",
5
+ "command": "node",
6
6
  "args": [
7
- "exec",
8
- "tsx",
9
- "./mcp-server/src/index.ts"
7
+ "./mcp-server/dist/index.js"
10
8
  ]
11
9
  }
12
10
  }
@@ -10,6 +10,7 @@ import { startDashboardServer } from "./dashboard-server.js";
10
10
  import { runDoctor } from "./doctor.js";
11
11
  import { AgentLoopError, isGateCode, toErrorPayload, type AgentLoopErrorCode } from "./errors.js";
12
12
  import { recoverBlockedRun } from "./gate-recovery.js";
13
+ import { commandsReferencingLegacyPrivateRepo, inspectAgentLoopBinary, inspectBundledHooksConfig, redactDiagnosticText, type AgentLoopBinaryInspection, type BundledHooksConfigInspection } from "./hook-diagnostics.js";
13
14
  import { agentLoopRouterHookEntries, collectHookCommands, isAgentLoopHookCommand, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
14
15
  import { hookRegistryPath, inspectHookRegistryLock, listHookBindings, removeHookBinding, upsertHookBinding } from "./hook-router.js";
15
16
  import { inspectLocalInstall, installLocalAgentLoop, listLocalInstallSnapshots, pruneLocalInstallSnapshots, rollbackLocalAgentLoop } from "./local-install.js";
@@ -141,6 +142,9 @@ export async function runAgentLoopCli(
141
142
  if (command === "approve-gate") {
142
143
  return approveGate(targetRepoRoot, filtered, json, localeOverride);
143
144
  }
145
+ if (command === "maintainer-override") {
146
+ return maintainerOverride(targetRepoRoot, filtered, json);
147
+ }
144
148
  if (command === "evidence") {
145
149
  return evidence(targetRepoRoot, filtered, json, localeOverride);
146
150
  }
@@ -188,6 +192,7 @@ function helpResult(json: boolean, usage = "agent-loop <command> [options]"): Cl
188
192
  "hooks",
189
193
  "local",
190
194
  "approve-gate",
195
+ "maintainer-override",
191
196
  "dashboard",
192
197
  "evidence",
193
198
  "delivery"
@@ -217,6 +222,7 @@ function commandHelpUsage(command: string): string | undefined {
217
222
  hooks: "agent-loop hooks install-router|bind|list|doctor|unbind [--session SESSION_ID] [--run RUN_ID] [--json]",
218
223
  local: "agent-loop local install|rollback|doctor|snapshots [--repo /path/to/repo] [--snapshot PATH] [--json]",
219
224
  "approve-gate": "agent-loop approve-gate <gate-id> --note \"...\" [--next-state STATE] [--json]",
225
+ "maintainer-override": "agent-loop maintainer-override approve --scope publish|merge --reason \"...\" [--ttl-minutes N] [--run RUN_ID] [--json]",
220
226
  dashboard: "agent-loop dashboard [--host 127.0.0.1] [--port 0] [--json]",
221
227
  evidence: "agent-loop evidence append --stage STAGE --summary \"...\" [--run RUN_ID] [--substage ID] [--actor ACTOR] [--status STATUS] [--source SOURCE] [--ref REF] [--artifact ID] [--json]",
222
228
  delivery: "agent-loop delivery bind|stage [options] [--json]"
@@ -239,7 +245,9 @@ const OPTIONS_WITH_VALUES = new Set([
239
245
  "--port",
240
246
  "--issue",
241
247
  "--ref",
248
+ "--reason",
242
249
  "--run",
250
+ "--scope",
243
251
  "--source",
244
252
  "--stage",
245
253
  "--status",
@@ -248,6 +256,7 @@ const OPTIONS_WITH_VALUES = new Set([
248
256
  "--substage",
249
257
  "--summary",
250
258
  "--title",
259
+ "--ttl-minutes",
251
260
  "--url",
252
261
  "--worker"
253
262
  ]);
@@ -714,6 +723,9 @@ function hooks(repoRoot: string, args: string[], json: boolean, localeOverride:
714
723
  report.routerInstalled ? "hook router installed" : "hook router missing",
715
724
  `active bindings: ${report.activeBindings}`,
716
725
  `legacy entries: ${report.legacyCommands.length}`,
726
+ `old private repo hook refs: ${report.legacyPrivateRepoCommands.length}`,
727
+ `bundled hooks config: ${report.bundledHooksConfig.valid ? "valid" : "invalid"}`,
728
+ `binary old private repo refs: ${report.agentLoopBinary.legacyPrivateRepoReferences.length}`,
717
729
  `hook capture: ${report.hookCapture.status} - ${report.hookCapture.reason}`
718
730
  ]);
719
731
  }
@@ -767,9 +779,12 @@ function local(repoRoot: string, args: string[], json: boolean): CliResult {
767
779
  "agent-loop local doctor",
768
780
  `binary: ${result.binary.path ?? "not found"}`,
769
781
  `binary points to expected package: ${result.binary.pointsToExpectedPackage ? "yes" : "no"}`,
782
+ `binary old private repo refs: ${result.binary.legacyPrivateRepoReferences.length}`,
783
+ `bundled hooks config: ${result.hooks.bundledHooksConfig.valid ? "valid" : "invalid"}`,
770
784
  `router installed: ${result.hooks.routerInstalled}`,
771
785
  `router points to expected dist: ${result.hooks.routerCommandsPointToExpectedDist ? "yes" : "no"}`,
772
786
  `legacy entries: ${result.hooks.legacyCommands.length}`,
787
+ `old private repo hook refs: ${result.hooks.legacyPrivateRepoCommands.length}`,
773
788
  `current repo bindings: ${result.bindings.currentRepoBindings}`,
774
789
  `stale/missing path bindings: ${result.bindings.staleOrMissingPathBindings}`,
775
790
  `temp path bindings: ${result.bindings.tempPathBindings}`,
@@ -913,6 +928,70 @@ function approveGate(repoRoot: string, args: string[], json: boolean, localeOver
913
928
  }
914
929
  }
915
930
 
931
+ function maintainerOverride(repoRoot: string, args: string[], json: boolean): CliResult {
932
+ const subcommand = args[1];
933
+ const scope = optionArg(args, "--scope");
934
+ const reason = optionArg(args, "--reason");
935
+ const ttlMinutes = maintainerOverrideTtlMinutes(args);
936
+ if (subcommand !== "approve") {
937
+ throw new AgentLoopError("unknown_command", "Usage: agent-loop maintainer-override approve --scope publish|merge --reason \"...\" [--ttl-minutes N] [--run RUN_ID]");
938
+ }
939
+ if (scope !== "publish" && scope !== "merge") {
940
+ throw new AgentLoopError("invalid_config", "maintainer-override approve requires --scope publish|merge.");
941
+ }
942
+ if (!reason || reason.trim().length === 0) {
943
+ throw new AgentLoopError("invalid_config", "maintainer-override approve requires --reason.");
944
+ }
945
+ const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
946
+ try {
947
+ const runId = optionArg(args, "--run") ?? storage.getCurrentRun()?.id;
948
+ if (!runId) {
949
+ throw new AgentLoopError("invalid_config", "maintainer-override approve requires an active run or --run.");
950
+ }
951
+ if (!storage.getRun(runId)) {
952
+ throw new AgentLoopError("invalid_config", `maintainer-override run ${runId} was not found.`);
953
+ }
954
+ const actor = process.env.USER ?? process.env.LOGNAME ?? "unknown";
955
+ const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
956
+ const details = {
957
+ scope,
958
+ reason,
959
+ actor,
960
+ source: "cli",
961
+ expiresAt,
962
+ ttlMinutes
963
+ };
964
+ const decision = storage.appendDecision({
965
+ runId,
966
+ kind: "maintainer_override_approved",
967
+ message: `Maintainer override approved for ${scope}.`,
968
+ details
969
+ });
970
+ const event = storage.appendEvent({
971
+ runId,
972
+ kind: "maintainer_override_approved",
973
+ message: `Maintainer override approved for ${scope}.`,
974
+ payload: details
975
+ });
976
+ return ok(json, { ok: true, decision, event, scope, expiresAt }, [
977
+ `maintainer override: ${scope}`,
978
+ `expires: ${expiresAt}`,
979
+ `reason: ${reason}`
980
+ ]);
981
+ } finally {
982
+ storage.close();
983
+ }
984
+ }
985
+
986
+ function maintainerOverrideTtlMinutes(args: string[]): number {
987
+ const value = optionArg(args, "--ttl-minutes") ?? "30";
988
+ const ttl = Number(value);
989
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 120) {
990
+ throw new AgentLoopError("invalid_config", "maintainer-override --ttl-minutes must be an integer from 1 to 120.");
991
+ }
992
+ return ttl;
993
+ }
994
+
916
995
  function gateState(details: unknown): string | undefined {
917
996
  if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
918
997
  const state = (details as { state?: unknown }).state;
@@ -1123,6 +1202,9 @@ function hookInstallReport(repoRoot: string, packageRoot: string): {
1123
1202
  routerInstalled: boolean;
1124
1203
  missingRouterEvents: string[];
1125
1204
  legacyCommands: string[];
1205
+ legacyPrivateRepoCommands: string[];
1206
+ bundledHooksConfig: BundledHooksConfigInspection;
1207
+ agentLoopBinary: AgentLoopBinaryInspection;
1126
1208
  activeBindings: number;
1127
1209
  currentRepoBindings: number;
1128
1210
  lock: ReturnType<typeof inspectHookRegistryLock>;
@@ -1145,7 +1227,10 @@ function hookInstallReport(repoRoot: string, packageRoot: string): {
1145
1227
  const missingRouterEvents = Object.entries(routerEntries)
1146
1228
  .filter(([, entries]) => !entries.some((entry) => hookCommands(entry).every((command) => commands.includes(command))))
1147
1229
  .map(([event]) => event);
1148
- const legacyCommands = commands.filter(isLegacyAgentLoopHookCommand);
1230
+ const legacyCommands = commands.filter(isLegacyAgentLoopHookCommand).map(redactDiagnosticText);
1231
+ const legacyPrivateRepoCommands = commandsReferencingLegacyPrivateRepo(commands);
1232
+ const bundledHooksConfig = inspectBundledHooksConfig(packageRoot);
1233
+ const agentLoopBinary = inspectAgentLoopBinary(packageRoot);
1149
1234
  let bindings: ReturnType<typeof listHookBindings>;
1150
1235
  let registryError: string | undefined;
1151
1236
  try {
@@ -1164,6 +1249,9 @@ function hookInstallReport(repoRoot: string, packageRoot: string): {
1164
1249
  routerInstalled: hooksJsonError === undefined && missingRouterEvents.length === 0,
1165
1250
  missingRouterEvents,
1166
1251
  legacyCommands,
1252
+ legacyPrivateRepoCommands,
1253
+ bundledHooksConfig,
1254
+ agentLoopBinary,
1167
1255
  activeBindings,
1168
1256
  currentRepoBindings,
1169
1257
  lock,
@@ -5,6 +5,7 @@ import { redactRemote, runCommand } from "./command.js";
5
5
  import { loadConfig, statePath } from "./config.js";
6
6
  import { AgentLoopError } from "./errors.js";
7
7
  import { inspectHookCapture } from "./hook-capture.js";
8
+ import { commandsReferencingLegacyPrivateRepo, inspectAgentLoopBinary, inspectBundledHooksConfig, redactDiagnosticText } from "./hook-diagnostics.js";
8
9
  import { agentLoopRouterHookCommand, collectHookCommands, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
9
10
  import { CODEX_HOOK_EVENTS, hookScriptName } from "./hook-events.js";
10
11
  import { hookRegistryPath, inspectHookRegistryLock, listHookBindings } from "./hook-router.js";
@@ -202,10 +203,17 @@ function checkHooksInstalled(repoRoot: string, pluginRoot: string): DoctorCheck
202
203
  }
203
204
  const commands = collectHookCommands(parsedHooks);
204
205
  const missing = CODEX_HOOK_EVENTS.filter((event) => !commands.includes(agentLoopRouterHookCommand(event, pluginRoot)));
205
- const legacyCommands = commands.filter(isLegacyAgentLoopHookCommand);
206
+ const legacyCommands = commands.filter(isLegacyAgentLoopHookCommand).map(redactDiagnosticText);
206
207
  const expectedDist = hookDistRoot(pluginRoot);
207
208
  const routerCommands = commands.filter((command) => command.includes("autonomous-pr-loop/hooks/dist/"));
208
- const unexpectedRouterCommands = routerCommands.filter((command) => !command.includes(expectedDist));
209
+ const unexpectedRouterCommands = routerCommands
210
+ .filter((command) => !command.includes(expectedDist))
211
+ .map(redactDiagnosticText);
212
+ const bundledHooksConfig = inspectBundledHooksConfig(pluginRoot);
213
+ const agentLoopBinary = inspectAgentLoopBinary(pluginRoot);
214
+ const legacyPrivateRepoCommands = commandsReferencingLegacyPrivateRepo(commands);
215
+ const legacyPrivateRepoDrift =
216
+ legacyPrivateRepoCommands.length > 0 || agentLoopBinary.legacyPrivateRepoReferences.length > 0;
209
217
  let bindings: ReturnType<typeof listHookBindings>;
210
218
  let registryError: string | undefined;
211
219
  try {
@@ -221,10 +229,12 @@ function checkHooksInstalled(repoRoot: string, pluginRoot: string): DoctorCheck
221
229
  const installed = missing.length === 0;
222
230
  const routerDistDrift = unexpectedRouterCommands.length > 0;
223
231
  const captureWarn = capture.status === "ambiguous" || capture.status === "unavailable";
224
- const status = !installed ? "warn" : routerDistDrift || registryError || lock.stale || legacyCommands.length > 0 || currentRepoBindings.length === 0 || captureWarn ? "warn" : "pass";
232
+ const status = !installed ? "warn" : routerDistDrift || !bundledHooksConfig.valid || legacyPrivateRepoDrift || registryError || lock.stale || legacyCommands.length > 0 || currentRepoBindings.length === 0 || captureWarn ? "warn" : "pass";
225
233
  const message = hookInstallMessage({
226
234
  installed,
227
235
  routerDistDrift,
236
+ bundledHooksConfigValid: bundledHooksConfig.valid,
237
+ legacyPrivateRepoDrift,
228
238
  registryError,
229
239
  lockStale: lock.stale,
230
240
  lockPath: lock.path,
@@ -247,6 +257,9 @@ function checkHooksInstalled(repoRoot: string, pluginRoot: string): DoctorCheck
247
257
  legacyCommands,
248
258
  routerCommandsPointToExpectedDist: routerCommands.length > 0 && unexpectedRouterCommands.length === 0,
249
259
  unexpectedRouterCommands,
260
+ bundledHooksConfig,
261
+ agentLoopBinary,
262
+ legacyPrivateRepoCommands,
250
263
  activeBindings: activeBindings.length,
251
264
  currentRepoBindings: currentRepoBindings.length,
252
265
  lock,
@@ -260,6 +273,8 @@ function checkHooksInstalled(repoRoot: string, pluginRoot: string): DoctorCheck
260
273
  function hookInstallMessage(input: {
261
274
  installed: boolean;
262
275
  routerDistDrift: boolean;
276
+ bundledHooksConfigValid: boolean;
277
+ legacyPrivateRepoDrift: boolean;
263
278
  registryError: string | undefined;
264
279
  lockStale: boolean;
265
280
  lockPath: string;
@@ -268,28 +283,37 @@ function hookInstallMessage(input: {
268
283
  installCommand: string;
269
284
  codexHome: string;
270
285
  }): string {
286
+ const suffix = input.legacyPrivateRepoDrift
287
+ ? " Also, hook commands still reference the old private repo."
288
+ : "";
289
+ if (!input.bundledHooksConfigValid) {
290
+ return `Bundled plugin hooks config is not valid for current Codex. Reinstall or refresh the HOLO-Codex plugin after fixing hooks/hooks.json.${suffix}`;
291
+ }
271
292
  if (!input.installed && input.routerDistDrift) {
272
- return `Codex hook router is not installed at the expected hook dist; existing router commands point outside the expected hook dist. Run \`${input.installCommand}\` to refresh router hooks and bind this repo.`;
293
+ return `Codex hook router is not installed at the expected hook dist; existing router commands point outside the expected hook dist. Run \`${input.installCommand}\` to refresh router hooks and bind this repo.${suffix}`;
273
294
  }
274
295
  if (!input.installed) {
275
- return `Codex hook router is not installed. Run \`${input.installCommand}\` to install router hooks and bind this repo.`;
296
+ return `Codex hook router is not installed. Run \`${input.installCommand}\` to install router hooks and bind this repo.${suffix}`;
276
297
  }
277
298
  if (input.routerDistDrift) {
278
- return `Codex hook router includes commands outside the expected hook dist. Run \`${input.installCommand}\` to refresh router hooks.`;
299
+ return `Codex hook router includes commands outside the expected hook dist. Run \`${input.installCommand}\` to refresh router hooks.${suffix}`;
279
300
  }
280
301
  if (input.registryError) {
281
- return `Codex hook binding registry is not valid. Fix ${hookRegistryPath(input.codexHome)}, then run \`${input.installCommand}\`.`;
302
+ return `Codex hook binding registry is not valid. Fix ${hookRegistryPath(input.codexHome)}, then run \`${input.installCommand}\`.${suffix}`;
282
303
  }
283
304
  if (input.lockStale) {
284
- return `Codex hook binding registry lock appears stale. Remove ${input.lockPath} or rerun after the stale writer exits.`;
305
+ return `Codex hook binding registry lock appears stale. Remove ${input.lockPath} or rerun after the stale writer exits.${suffix}`;
285
306
  }
286
307
  if (input.legacyCommands.length > 0) {
287
- return `Codex hook router is installed, but legacy per-repo agent-loop hooks remain. Run \`${input.installCommand}\` to migrate them.`;
308
+ return `Codex hook router is installed, but legacy per-repo agent-loop hooks remain. Run \`${input.installCommand}\` to migrate them.${suffix}`;
309
+ }
310
+ if (input.legacyPrivateRepoDrift) {
311
+ return `Codex hook setup still references the old private repo. Run \`${input.installCommand}\` from the canonical HOLO-Codex workspace and reinstall the global CLI if needed.`;
288
312
  }
289
313
  if (input.currentRepoBindings.length === 0) {
290
- return `Codex hook router is installed, but this repo is not bound. Run \`${input.installCommand}\`.`;
314
+ return `Codex hook router is installed, but this repo is not bound. Run \`${input.installCommand}\`.${suffix}`;
291
315
  }
292
- return "HOLO-Codex hook router is installed and this repo has an active binding.";
316
+ return `HOLO-Codex hook router is installed and this repo has an active binding.${suffix}`;
293
317
  }
294
318
 
295
319
  function installHooksCommand(repoRoot: string): string {
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { CODEX_HOOK_EVENTS } from "./hook-events.js";
5
+ import { redactSecrets } from "./redaction.js";
6
+
7
+ export const LEGACY_PRIVATE_REPO_MARKER = "codex-auto-PR-loop-plusin";
8
+ const BINARY_READ_LIMIT_BYTES = 128 * 1024;
9
+
10
+ export interface BundledHooksConfigInspection {
11
+ path: string;
12
+ valid: boolean;
13
+ legacyTopLevelEvents: string[];
14
+ error?: string;
15
+ }
16
+
17
+ export interface AgentLoopBinaryInspection {
18
+ path?: string;
19
+ realPath?: string;
20
+ expectedPackageRoot: string;
21
+ pointsToExpectedPackage: boolean;
22
+ referencesExpectedPackage: boolean;
23
+ legacyPrivateRepoReferences: string[];
24
+ readError?: string;
25
+ readTruncated?: boolean;
26
+ }
27
+
28
+ /** Inspect the plugin-bundled Codex hooks config without mutating plugin cache or user config. */
29
+ export function inspectBundledHooksConfig(packageRoot: string): BundledHooksConfigInspection {
30
+ const path = join(packageRoot, "plugins", "autonomous-pr-loop", "hooks", "hooks.json");
31
+ if (!existsSync(path)) {
32
+ return {
33
+ path,
34
+ valid: false,
35
+ legacyTopLevelEvents: [],
36
+ error: "missing bundled hooks config"
37
+ };
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
41
+ if (!isRecord(parsed)) {
42
+ return { path, valid: false, legacyTopLevelEvents: [], error: "expected JSON object" };
43
+ }
44
+ const legacyTopLevelEvents = CODEX_HOOK_EVENTS.filter((event) => event in parsed);
45
+ if (!isRecord(parsed.hooks)) {
46
+ return {
47
+ path,
48
+ valid: false,
49
+ legacyTopLevelEvents,
50
+ error: "expected top-level hooks object"
51
+ };
52
+ }
53
+ return {
54
+ path,
55
+ valid: legacyTopLevelEvents.length === 0,
56
+ legacyTopLevelEvents,
57
+ ...(legacyTopLevelEvents.length > 0 ? { error: "legacy top-level hook events are not valid bundled hook config" } : {})
58
+ };
59
+ } catch (error) {
60
+ return {
61
+ path,
62
+ valid: false,
63
+ legacyTopLevelEvents: [],
64
+ error: error instanceof Error ? error.message : String(error)
65
+ };
66
+ }
67
+ }
68
+
69
+ /** Inspect the first PATH agent-loop binary for source-install drift. */
70
+ export function inspectAgentLoopBinary(expectedPackageRoot: string): AgentLoopBinaryInspection {
71
+ const path = firstPathBinary("agent-loop");
72
+ const realPath = path && existsSync(path) ? realpathSync(path) : undefined;
73
+ let text = "";
74
+ let readError: string | undefined;
75
+ let readTruncated = false;
76
+ if (path && existsSync(path)) {
77
+ try {
78
+ const result = readTextPrefix(path, BINARY_READ_LIMIT_BYTES);
79
+ text = result.text;
80
+ readTruncated = result.truncated;
81
+ } catch (error) {
82
+ readError = error instanceof Error ? error.message : String(error);
83
+ }
84
+ }
85
+ const referencesExpectedPackage = text.includes(expectedPackageRoot);
86
+ const legacyPrivateRepoReferences = [
87
+ ...(path?.includes(LEGACY_PRIVATE_REPO_MARKER) ? [path] : []),
88
+ ...(realPath?.includes(LEGACY_PRIVATE_REPO_MARKER) ? [realPath] : []),
89
+ ...text.split(/\r?\n/).filter((line) => line.includes(LEGACY_PRIVATE_REPO_MARKER))
90
+ ].map(redactDiagnosticText);
91
+ return {
92
+ ...(path ? { path } : {}),
93
+ ...(realPath ? { realPath } : {}),
94
+ expectedPackageRoot,
95
+ pointsToExpectedPackage: (realPath ? realPath.startsWith(expectedPackageRoot) : false) || referencesExpectedPackage,
96
+ referencesExpectedPackage,
97
+ legacyPrivateRepoReferences,
98
+ ...(readError ? { readError } : {}),
99
+ ...(readTruncated ? { readTruncated } : {})
100
+ };
101
+ }
102
+
103
+ export function commandsReferencingLegacyPrivateRepo(commands: string[]): string[] {
104
+ return commands
105
+ .filter((command) => command.includes(LEGACY_PRIVATE_REPO_MARKER))
106
+ .map(redactDiagnosticText);
107
+ }
108
+
109
+ export function redactDiagnosticText(value: string): string {
110
+ return redactLegacyPrivateRepoPaths(redactSecrets(value));
111
+ }
112
+
113
+ function firstPathBinary(name: string): string | undefined {
114
+ try {
115
+ const output = execFileSync("sh", ["-lc", `command -v ${name} || true`], { encoding: "utf8" }).trim();
116
+ return output || undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ function redactLegacyPrivateRepoPaths(value: string): string {
127
+ const marker = escapeRegExp(LEGACY_PRIVATE_REPO_MARKER);
128
+ const unixPath = new RegExp(`(?:/[^\\s'"=]+)*/${marker}(?:/[^\\s'"]*)?`, "g");
129
+ const windowsPath = new RegExp(`(?:[A-Za-z]:\\\\[^\\s'"=]+\\\\)*${marker}(?:\\\\[^\\s'"]*)?`, "g");
130
+ return value
131
+ .replace(unixPath, "<legacy-private-repo-path>")
132
+ .replace(windowsPath, "<legacy-private-repo-path>");
133
+ }
134
+
135
+ function escapeRegExp(value: string): string {
136
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ }
138
+
139
+ function readTextPrefix(path: string, limitBytes: number): { text: string; truncated: boolean } {
140
+ const size = statSync(path).size;
141
+ const length = Math.min(size, limitBytes);
142
+ const buffer = Buffer.alloc(length);
143
+ const fd = openSync(path, "r");
144
+ try {
145
+ const bytesRead = readSync(fd, buffer, 0, length, 0);
146
+ return {
147
+ text: buffer.subarray(0, bytesRead).toString("utf8"),
148
+ truncated: size > bytesRead
149
+ };
150
+ } finally {
151
+ closeSync(fd);
152
+ }
153
+ }