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.
- package/CHANGELOG.md +21 -0
- package/README.md +141 -75
- package/README.zh-CN.md +141 -75
- package/docs/release-checklist.md +39 -9
- package/package.json +4 -2
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +1 -1
- package/plugins/autonomous-pr-loop/.mcp.json +2 -4
- package/plugins/autonomous-pr-loop/core/cli.ts +89 -1
- package/plugins/autonomous-pr-loop/core/doctor.ts +35 -11
- package/plugins/autonomous-pr-loop/core/hook-diagnostics.ts +153 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +230 -23
- package/plugins/autonomous-pr-loop/core/local-install.ts +13 -19
- package/plugins/autonomous-pr-loop/core/storage.ts +5 -5
- package/plugins/autonomous-pr-loop/core/types.ts +2 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +78 -14
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +191 -27
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/hooks.json +1 -104
- package/plugins/autonomous-pr-loop/mcp-server/dist/index.js +10551 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +6 -3
- 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.
|
|
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
|
|
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"
|
|
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
|
-
|
|
130
|
-
git
|
|
131
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
},
|
|
@@ -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
|
|
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
|
|
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
|
+
}
|