holo-codex 0.1.0
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/.agents/plugins/marketplace.json +20 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/README.zh-CN.md +215 -0
- package/SECURITY.md +39 -0
- package/assets/brand/README.md +35 -0
- package/assets/brand/holo-codex-icon.svg +28 -0
- package/assets/brand/holo-codex-lockup.svg +49 -0
- package/assets/brand/holo-codex-mark.svg +33 -0
- package/assets/brand/holo-codex-plugin-card.png +0 -0
- package/assets/brand/holo-codex-plugin-card.svg +81 -0
- package/assets/brand/holo-codex-readme-hero.png +0 -0
- package/assets/brand/holo-codex-readme-hero.svg +140 -0
- package/assets/brand/holo-codex-social-preview.png +0 -0
- package/assets/brand/holo-codex-social-preview.svg +130 -0
- package/assets/brand/holo-codex-wordmark-options.svg +52 -0
- package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
- package/docs/examples/generic-loop-repo-hygiene.md +168 -0
- package/docs/install.md +190 -0
- package/docs/local-release-readiness.md +206 -0
- package/docs/release-checklist.md +144 -0
- package/docs/self-bootstrap.md +150 -0
- package/docs/trust-and-safety.md +45 -0
- package/package.json +83 -0
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
- package/plugins/autonomous-pr-loop/.mcp.json +13 -0
- package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
- package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
- package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
- package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
- package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
- package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
- package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
- package/plugins/autonomous-pr-loop/core/command.ts +47 -0
- package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
- package/plugins/autonomous-pr-loop/core/config.ts +293 -0
- package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
- package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
- package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
- package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
- package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
- package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
- package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
- package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
- package/plugins/autonomous-pr-loop/core/git.ts +213 -0
- package/plugins/autonomous-pr-loop/core/github.ts +269 -0
- package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
- package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
- package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
- package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
- package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
- package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
- package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
- package/plugins/autonomous-pr-loop/core/index.ts +32 -0
- package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
- package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
- package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
- package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
- package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
- package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
- package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
- package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
- package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
- package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
- package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
- package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
- package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
- package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
- package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
- package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
- package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
- package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
- package/plugins/autonomous-pr-loop/core/types.ts +567 -0
- package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
- package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
- package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
- package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
- package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
- package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
- package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
- package/plugins/autonomous-pr-loop/package.json +9 -0
- package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
- package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
- package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
- package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
- package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
- package/plugins/autonomous-pr-loop/ui/index.html +26 -0
- package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
- package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
- package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
- package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
- package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
- package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
- package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
- package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
- package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
- package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
- package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
- package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
- package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
- package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { runCommand } from "./command.js";
|
|
7
|
+
import { isRecord } from "./config.js";
|
|
8
|
+
import { AgentLoopError } from "./errors.js";
|
|
9
|
+
import { agentLoopRouterHookCommand, collectHookCommands, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
|
|
10
|
+
import { CODEX_HOOK_EVENTS, hookScriptName } from "./hook-events.js";
|
|
11
|
+
import { hookRegistryPath, inspectHookRegistryLock, listHookBindings } from "./hook-router.js";
|
|
12
|
+
import { defaultPackageRoot, hookDistRoot, hookSourceRoot } from "./plugin-paths.js";
|
|
13
|
+
|
|
14
|
+
export interface LocalInstallOptions {
|
|
15
|
+
repoRoot: string;
|
|
16
|
+
packageRoot?: string;
|
|
17
|
+
allowDirty?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LocalInstallResult {
|
|
21
|
+
ok: true;
|
|
22
|
+
packageRoot: string;
|
|
23
|
+
repoRoot: string;
|
|
24
|
+
snapshotPath: string;
|
|
25
|
+
manifestChanges: string[];
|
|
26
|
+
install: {
|
|
27
|
+
buildHooks: CommandSummary;
|
|
28
|
+
globalInstall: CommandSummary;
|
|
29
|
+
installHooks: CommandSummary;
|
|
30
|
+
};
|
|
31
|
+
localDoctor: LocalDoctorReport;
|
|
32
|
+
repoDoctor: CommandSummary;
|
|
33
|
+
rollbackCommand: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LocalRollbackOptions {
|
|
37
|
+
snapshotPath: string;
|
|
38
|
+
packageRoot?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LocalRollbackResult {
|
|
42
|
+
ok: true;
|
|
43
|
+
snapshotPath: string;
|
|
44
|
+
restored: string[];
|
|
45
|
+
removed: string[];
|
|
46
|
+
preservedBrokenFiles: string[];
|
|
47
|
+
warnings: string[];
|
|
48
|
+
globalUninstall: CommandSummary;
|
|
49
|
+
localDoctor: LocalDoctorReport;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LocalSnapshotEntry {
|
|
53
|
+
path: string;
|
|
54
|
+
createdAt?: string;
|
|
55
|
+
repoRoot?: string;
|
|
56
|
+
packageRoot?: string;
|
|
57
|
+
invalid?: boolean;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocalSnapshotList {
|
|
62
|
+
ok: true;
|
|
63
|
+
backupsDir: string;
|
|
64
|
+
snapshots: LocalSnapshotEntry[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LocalSnapshotPruneOptions {
|
|
68
|
+
keep: number;
|
|
69
|
+
apply?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LocalSnapshotPruneResult {
|
|
73
|
+
ok: true;
|
|
74
|
+
backupsDir: string;
|
|
75
|
+
keep: number;
|
|
76
|
+
apply: boolean;
|
|
77
|
+
kept: LocalSnapshotEntry[];
|
|
78
|
+
candidates: LocalSnapshotEntry[];
|
|
79
|
+
deleted: string[];
|
|
80
|
+
warnings: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface LocalDoctorReport {
|
|
84
|
+
ok: true;
|
|
85
|
+
packageRoot: string;
|
|
86
|
+
repoRoot: string;
|
|
87
|
+
codexHome: string;
|
|
88
|
+
binary: {
|
|
89
|
+
path?: string;
|
|
90
|
+
realPath?: string;
|
|
91
|
+
expectedPackageRoot: string;
|
|
92
|
+
pointsToExpectedPackage: boolean;
|
|
93
|
+
};
|
|
94
|
+
hooks: {
|
|
95
|
+
hooksPath: string;
|
|
96
|
+
hooksJsonError?: string;
|
|
97
|
+
routerInstalled: boolean;
|
|
98
|
+
missingRouterEvents: string[];
|
|
99
|
+
legacyCommands: string[];
|
|
100
|
+
routerCommandsPointToExpectedDist: boolean;
|
|
101
|
+
};
|
|
102
|
+
bindings: {
|
|
103
|
+
registryPath: string;
|
|
104
|
+
activeBindings: number;
|
|
105
|
+
currentRepoBindings: number;
|
|
106
|
+
staleOrMissingPathBindings: number;
|
|
107
|
+
tempPathBindings: number;
|
|
108
|
+
lock: ReturnType<typeof inspectHookRegistryLock>;
|
|
109
|
+
registryError?: string;
|
|
110
|
+
};
|
|
111
|
+
selfLinkPollution: {
|
|
112
|
+
clean: boolean;
|
|
113
|
+
files: string[];
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface SnapshotManifest {
|
|
118
|
+
version: 1;
|
|
119
|
+
createdAt: string;
|
|
120
|
+
packageRoot: string;
|
|
121
|
+
repoRoot: string;
|
|
122
|
+
codexHome: string;
|
|
123
|
+
files: Array<{ name: string; originalPath: string; existed: boolean; backupPath?: string }>;
|
|
124
|
+
targetAgentLoop: {
|
|
125
|
+
path: string;
|
|
126
|
+
exists: boolean;
|
|
127
|
+
entries: Array<{ path: string; size: number }>;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CommandSummary {
|
|
132
|
+
ok: boolean;
|
|
133
|
+
command: string;
|
|
134
|
+
stdout?: string;
|
|
135
|
+
stderr?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type HookBindingRegistryJson = { version: 1; bindings: unknown[] };
|
|
139
|
+
type ManifestSnapshot = Map<string, { hash?: string; content?: Buffer }>;
|
|
140
|
+
const LEGACY_NPM_PACKAGE_NAME = "codex-auto-pr-loop-plugin";
|
|
141
|
+
const FALLBACK_NPM_PACKAGE_NAME = "holo-codex";
|
|
142
|
+
|
|
143
|
+
/** Install the local agent-loop CLI and hook router with a reversible snapshot. */
|
|
144
|
+
export function installLocalAgentLoop(options: LocalInstallOptions): LocalInstallResult {
|
|
145
|
+
const packageRoot = options.packageRoot ?? defaultPackageRoot();
|
|
146
|
+
const repoRoot = canonicalPath(options.repoRoot);
|
|
147
|
+
const dirty = gitStatus(packageRoot);
|
|
148
|
+
if (!options.allowDirty && dirty.length > 0) {
|
|
149
|
+
throw new AgentLoopError("invalid_config", `Plugin worktree is dirty. Commit/stash changes or rerun with --allow-dirty.\n${dirty.join("\n")}`, {
|
|
150
|
+
details: { dirty }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const beforeManifests = manifestSnapshots(packageRoot);
|
|
155
|
+
const snapshotPath = createLocalInstallSnapshot({ packageRoot, repoRoot });
|
|
156
|
+
const buildHooks = buildHooksForLocalInstall(packageRoot);
|
|
157
|
+
if (!buildHooks.ok) {
|
|
158
|
+
throw localInstallFailure(`Failed to build hooks before local install.\n${buildHooks.stderr ?? buildHooks.stdout ?? ""}`, snapshotPath);
|
|
159
|
+
}
|
|
160
|
+
const globalInstall = runCommandSummary("pnpm", ["add", "--global", packageRoot], homedir());
|
|
161
|
+
const afterGlobalHashes = manifestSnapshots(packageRoot);
|
|
162
|
+
const globalManifestChanges = changedManifestFiles(beforeManifests, afterGlobalHashes);
|
|
163
|
+
if (globalManifestChanges.length > 0) {
|
|
164
|
+
restoreManifestFiles(packageRoot, beforeManifests, globalManifestChanges);
|
|
165
|
+
throw localInstallFailure(`Global install changed repository manifests: ${globalManifestChanges.join(", ")}`, snapshotPath, globalManifestChanges);
|
|
166
|
+
}
|
|
167
|
+
if (!globalInstall.ok) {
|
|
168
|
+
throw localInstallFailure(`Failed to install global agent-loop CLI.\n${globalInstall.stderr ?? globalInstall.stdout ?? ""}`, snapshotPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const installHooks = runCommandSummary("pnpm", ["agent-loop", "install-hooks", "--repo", repoRoot, "--json"], packageRoot);
|
|
172
|
+
const afterHooksHashes = manifestSnapshots(packageRoot);
|
|
173
|
+
const manifestChanges = changedManifestFiles(beforeManifests, afterHooksHashes);
|
|
174
|
+
if (manifestChanges.length > 0) {
|
|
175
|
+
restoreManifestFiles(packageRoot, beforeManifests, manifestChanges);
|
|
176
|
+
throw localInstallFailure(`Local install changed repository manifests: ${manifestChanges.join(", ")}`, snapshotPath, manifestChanges);
|
|
177
|
+
}
|
|
178
|
+
if (!installHooks.ok) {
|
|
179
|
+
throw localInstallFailure(`Failed to install hook router.\n${installHooks.stderr ?? installHooks.stdout ?? ""}`, snapshotPath);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const localDoctor = inspectLocalInstall({ repoRoot, packageRoot });
|
|
183
|
+
const repoDoctor = runCommandSummary("pnpm", ["agent-loop", "--repo", repoRoot, "doctor", "--json"], packageRoot);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
packageRoot,
|
|
188
|
+
repoRoot,
|
|
189
|
+
snapshotPath,
|
|
190
|
+
manifestChanges,
|
|
191
|
+
install: { buildHooks, globalInstall, installHooks },
|
|
192
|
+
localDoctor,
|
|
193
|
+
repoDoctor,
|
|
194
|
+
rollbackCommand: `agent-loop local rollback --snapshot ${shellQuote(snapshotPath)}`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildHooksForLocalInstall(packageRoot: string): CommandSummary {
|
|
199
|
+
const distReady = CODEX_HOOK_EVENTS
|
|
200
|
+
.map((event) => join(hookDistRoot(packageRoot), hookScriptName(event)))
|
|
201
|
+
.every((script) => existsSync(script));
|
|
202
|
+
const sourceCheckout = existsSync(join(packageRoot, "pnpm-lock.yaml"));
|
|
203
|
+
if (distReady && !sourceCheckout) {
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
command: `pnpm build:hooks`,
|
|
207
|
+
stdout: "Skipped hook build because packaged hook dist is already present."
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!existsSync(hookSourceRoot(packageRoot)) && distReady) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
command: `pnpm build:hooks`,
|
|
214
|
+
stdout: "Skipped hook build because hook sources are unavailable and packaged hook dist is present."
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return runCommandSummary("pnpm", ["build:hooks"], packageRoot);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Restore hook/router state from a local-install snapshot and remove the global CLI link. */
|
|
221
|
+
export function rollbackLocalAgentLoop(options: LocalRollbackOptions): LocalRollbackResult {
|
|
222
|
+
const packageRoot = options.packageRoot ?? defaultPackageRoot();
|
|
223
|
+
const snapshotPath = resolve(options.snapshotPath);
|
|
224
|
+
const manifest = readSnapshotManifest(snapshotPath);
|
|
225
|
+
const restored: string[] = [];
|
|
226
|
+
const removed: string[] = [];
|
|
227
|
+
const preservedBrokenFiles: string[] = [];
|
|
228
|
+
const warnings: string[] = [];
|
|
229
|
+
|
|
230
|
+
for (const file of manifest.files) {
|
|
231
|
+
if (file.name === "hooks") {
|
|
232
|
+
const result = rollbackHooksFile(file, snapshotPath, manifest.packageRoot);
|
|
233
|
+
restored.push(...result.restored);
|
|
234
|
+
removed.push(...result.removed);
|
|
235
|
+
preservedBrokenFiles.push(...result.preservedBrokenFiles);
|
|
236
|
+
warnings.push(...result.warnings);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (file.name === "hook-bindings") {
|
|
240
|
+
const result = rollbackBindingRegistryFile(file, snapshotPath, manifest.repoRoot);
|
|
241
|
+
restored.push(...result.restored);
|
|
242
|
+
removed.push(...result.removed);
|
|
243
|
+
preservedBrokenFiles.push(...result.preservedBrokenFiles);
|
|
244
|
+
warnings.push(...result.warnings);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const packageName = localPackageName(packageRoot);
|
|
250
|
+
const globalUninstall = runCommandSummary("pnpm", ["remove", "--global", packageName], homedir());
|
|
251
|
+
if (!globalUninstall.ok) {
|
|
252
|
+
warnings.push("Global CLI uninstall did not complete; inspect `pnpm list --global --depth 0` manually.");
|
|
253
|
+
}
|
|
254
|
+
if (packageName !== LEGACY_NPM_PACKAGE_NAME) {
|
|
255
|
+
runCommandSummary("pnpm", ["remove", "--global", LEGACY_NPM_PACKAGE_NAME], homedir());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
snapshotPath,
|
|
261
|
+
restored,
|
|
262
|
+
removed,
|
|
263
|
+
preservedBrokenFiles,
|
|
264
|
+
warnings,
|
|
265
|
+
globalUninstall,
|
|
266
|
+
localDoctor: inspectLocalInstall({ repoRoot: manifest.repoRoot, packageRoot })
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function rollbackHooksFile(file: SnapshotManifest["files"][number], snapshotPath: string, packageRoot: string): { restored: string[]; removed: string[]; preservedBrokenFiles: string[]; warnings: string[] } {
|
|
271
|
+
const restored: string[] = [];
|
|
272
|
+
const removed: string[] = [];
|
|
273
|
+
const preservedBrokenFiles: string[] = [];
|
|
274
|
+
const warnings: string[] = [];
|
|
275
|
+
const managedCommands = new Set(CODEX_HOOK_EVENTS.map((event) => agentLoopRouterHookCommand(event, packageRoot)));
|
|
276
|
+
if (!existsSync(file.originalPath)) {
|
|
277
|
+
if (file.existed && file.backupPath) {
|
|
278
|
+
mkdirSync(dirname(file.originalPath), { recursive: true });
|
|
279
|
+
copyFileSync(join(snapshotPath, file.backupPath), file.originalPath);
|
|
280
|
+
restored.push(file.originalPath);
|
|
281
|
+
}
|
|
282
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let currentHooks: unknown;
|
|
286
|
+
try {
|
|
287
|
+
currentHooks = JSON.parse(readFileSync(file.originalPath, "utf8")) as unknown;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const preserved = preserveBrokenFile(file.originalPath);
|
|
290
|
+
preservedBrokenFiles.push(preserved);
|
|
291
|
+
warnings.push(`Current hooks.json is malformed; preserved ${preserved} and restored snapshot instead. ${error instanceof Error ? error.message : String(error)}`);
|
|
292
|
+
restoreOrRemoveSnapshotFile(file, snapshotPath, restored, removed);
|
|
293
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const stripped = stripManagedHookCommands(currentHooks, managedCommands);
|
|
297
|
+
if (stripped.removed === 0 && file.existed && file.backupPath) {
|
|
298
|
+
warnings.push("No matching agent-loop router hook command was found during rollback; hooks.json was left unchanged.");
|
|
299
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
300
|
+
}
|
|
301
|
+
mkdirSync(dirname(file.originalPath), { recursive: true });
|
|
302
|
+
writeFileSync(file.originalPath, `${JSON.stringify(stripped.value, null, 2)}\n`, { mode: 0o600 });
|
|
303
|
+
restored.push(file.originalPath);
|
|
304
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rollbackBindingRegistryFile(file: SnapshotManifest["files"][number], snapshotPath: string, repoRoot: string): { restored: string[]; removed: string[]; preservedBrokenFiles: string[]; warnings: string[] } {
|
|
308
|
+
const restored: string[] = [];
|
|
309
|
+
const removed: string[] = [];
|
|
310
|
+
const preservedBrokenFiles: string[] = [];
|
|
311
|
+
const warnings: string[] = [];
|
|
312
|
+
const snapshotRegistry = file.existed && file.backupPath
|
|
313
|
+
? readHookBindingRegistryJson(join(snapshotPath, file.backupPath))
|
|
314
|
+
: { version: 1 as const, bindings: [] };
|
|
315
|
+
const repoKey = canonicalPath(repoRoot);
|
|
316
|
+
|
|
317
|
+
if (!existsSync(file.originalPath)) {
|
|
318
|
+
const snapshotRepoBindings = snapshotRegistry.bindings.filter((binding) => bindingMatchesRepo(binding, repoKey));
|
|
319
|
+
if (snapshotRepoBindings.length > 0) {
|
|
320
|
+
writeHookBindingRegistryJson(file.originalPath, { version: 1, bindings: snapshotRepoBindings });
|
|
321
|
+
restored.push(file.originalPath);
|
|
322
|
+
}
|
|
323
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let currentRegistry: HookBindingRegistryJson;
|
|
327
|
+
try {
|
|
328
|
+
currentRegistry = readHookBindingRegistryJson(file.originalPath);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
const preserved = preserveBrokenFile(file.originalPath);
|
|
331
|
+
preservedBrokenFiles.push(preserved);
|
|
332
|
+
warnings.push(`Current hook binding registry is malformed; preserved ${preserved} and restored snapshot instead. ${error instanceof Error ? error.message : String(error)}`);
|
|
333
|
+
restoreOrRemoveSnapshotFile(file, snapshotPath, restored, removed);
|
|
334
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const keptCurrent = currentRegistry.bindings.filter((binding) => !bindingMatchesRepo(binding, repoKey));
|
|
338
|
+
const snapshotRepoBindings = snapshotRegistry.bindings.filter((binding) => bindingMatchesRepo(binding, repoKey));
|
|
339
|
+
const merged = dedupeBindingsById([...keptCurrent, ...snapshotRepoBindings]);
|
|
340
|
+
if (merged.length === 0 && !file.existed) {
|
|
341
|
+
rmSync(file.originalPath, { force: true });
|
|
342
|
+
removed.push(file.originalPath);
|
|
343
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
344
|
+
}
|
|
345
|
+
writeHookBindingRegistryJson(file.originalPath, { version: 1, bindings: merged });
|
|
346
|
+
restored.push(file.originalPath);
|
|
347
|
+
return { restored, removed, preservedBrokenFiles, warnings };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Inspect local install state without mutating hooks, bindings, or repo state. */
|
|
351
|
+
export function inspectLocalInstall(options: LocalInstallOptions): LocalDoctorReport {
|
|
352
|
+
const packageRoot = options.packageRoot ?? defaultPackageRoot();
|
|
353
|
+
const repoRoot = canonicalPath(options.repoRoot);
|
|
354
|
+
const codexHome = codexHomePath();
|
|
355
|
+
const hooksPath = join(codexHome, "hooks.json");
|
|
356
|
+
const binaryPath = firstPathBinary("agent-loop");
|
|
357
|
+
const realBinaryPath = binaryPath && existsSync(binaryPath) ? canonicalPath(binaryPath) : undefined;
|
|
358
|
+
const hooks = inspectHooks(hooksPath, packageRoot);
|
|
359
|
+
const bindings = inspectBindings(codexHome, repoRoot);
|
|
360
|
+
const selfLinkPollution = detectSelfLinkPollution(packageRoot);
|
|
361
|
+
return {
|
|
362
|
+
ok: true,
|
|
363
|
+
packageRoot,
|
|
364
|
+
repoRoot,
|
|
365
|
+
codexHome,
|
|
366
|
+
binary: {
|
|
367
|
+
...(binaryPath ? { path: binaryPath } : {}),
|
|
368
|
+
...(realBinaryPath ? { realPath: realBinaryPath } : {}),
|
|
369
|
+
expectedPackageRoot: packageRoot,
|
|
370
|
+
pointsToExpectedPackage: realBinaryPath ? realBinaryPath.startsWith(packageRoot) : false
|
|
371
|
+
},
|
|
372
|
+
hooks,
|
|
373
|
+
bindings,
|
|
374
|
+
selfLinkPollution
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** List local-install snapshots under CODEX_HOME. */
|
|
379
|
+
export function listLocalInstallSnapshots(): LocalSnapshotList {
|
|
380
|
+
const backupsDir = localInstallBackupsDir();
|
|
381
|
+
if (!existsSync(backupsDir)) {
|
|
382
|
+
return { ok: true, backupsDir, snapshots: [] };
|
|
383
|
+
}
|
|
384
|
+
const entries = readdirSafe(backupsDir)
|
|
385
|
+
.filter((entry) => entry.startsWith("local-install-"))
|
|
386
|
+
.map((entry) => join(backupsDir, entry))
|
|
387
|
+
.filter((path) => existsSync(join(path, "snapshot.json")))
|
|
388
|
+
.map((path) => {
|
|
389
|
+
try {
|
|
390
|
+
const manifest = readSnapshotManifest(path);
|
|
391
|
+
return { path, createdAt: manifest.createdAt, repoRoot: manifest.repoRoot, packageRoot: manifest.packageRoot };
|
|
392
|
+
} catch (error) {
|
|
393
|
+
return { path, invalid: true, error: error instanceof Error ? error.message : String(error) };
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
397
|
+
return { ok: true, backupsDir, snapshots: entries };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Preview or delete old local-install snapshots under CODEX_HOME. */
|
|
401
|
+
export function pruneLocalInstallSnapshots(options: LocalSnapshotPruneOptions): LocalSnapshotPruneResult {
|
|
402
|
+
if (!Number.isInteger(options.keep) || options.keep < 1) {
|
|
403
|
+
throw new AgentLoopError("invalid_config", "local snapshots prune requires --keep with a positive integer.");
|
|
404
|
+
}
|
|
405
|
+
const backupsDir = localInstallBackupsDir();
|
|
406
|
+
const warnings: string[] = [];
|
|
407
|
+
if (!existsSync(backupsDir)) {
|
|
408
|
+
return { ok: true, backupsDir, keep: options.keep, apply: options.apply === true, kept: [], candidates: [], deleted: [], warnings };
|
|
409
|
+
}
|
|
410
|
+
const snapshots = readdirSafe(backupsDir)
|
|
411
|
+
.filter((entry) => entry.startsWith("local-install-"))
|
|
412
|
+
.map((entry) => join(backupsDir, entry))
|
|
413
|
+
.filter((path) => existsSync(join(path, "snapshot.json")))
|
|
414
|
+
.flatMap((path): LocalSnapshotEntry[] => {
|
|
415
|
+
try {
|
|
416
|
+
const manifest = readSnapshotManifest(path);
|
|
417
|
+
if (Number.isNaN(Date.parse(manifest.createdAt))) {
|
|
418
|
+
warnings.push(`Skipping snapshot with invalid createdAt: ${path}`);
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
return [{ path, createdAt: manifest.createdAt, repoRoot: manifest.repoRoot, packageRoot: manifest.packageRoot }];
|
|
422
|
+
} catch (error) {
|
|
423
|
+
warnings.push(`Skipping malformed snapshot ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
428
|
+
const kept = snapshots.slice(0, options.keep);
|
|
429
|
+
const candidates = snapshots.slice(options.keep);
|
|
430
|
+
const deleted: string[] = [];
|
|
431
|
+
if (options.apply === true) {
|
|
432
|
+
for (const snapshot of candidates) {
|
|
433
|
+
try {
|
|
434
|
+
rmSync(snapshot.path, { recursive: true, force: true });
|
|
435
|
+
deleted.push(snapshot.path);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
warnings.push(`Failed to delete snapshot ${snapshot.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { ok: true, backupsDir, keep: options.keep, apply: options.apply === true, kept, candidates, deleted, warnings };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function createLocalInstallSnapshot(input: { packageRoot: string; repoRoot: string }): string {
|
|
445
|
+
const codexHome = codexHomePath();
|
|
446
|
+
const snapshotPath = join(localInstallBackupsDir(), `local-install-${timestamp()}-${randomUUID().slice(0, 8)}`);
|
|
447
|
+
mkdirSync(snapshotPath, { recursive: true });
|
|
448
|
+
const files = [
|
|
449
|
+
snapshotFile("hooks", join(codexHome, "hooks.json"), snapshotPath),
|
|
450
|
+
snapshotFile("hook-bindings", hookRegistryPath(codexHome), snapshotPath)
|
|
451
|
+
];
|
|
452
|
+
const manifest: SnapshotManifest = {
|
|
453
|
+
version: 1,
|
|
454
|
+
createdAt: new Date().toISOString(),
|
|
455
|
+
packageRoot: input.packageRoot,
|
|
456
|
+
repoRoot: input.repoRoot,
|
|
457
|
+
codexHome,
|
|
458
|
+
files,
|
|
459
|
+
targetAgentLoop: agentLoopMetadata(input.repoRoot)
|
|
460
|
+
};
|
|
461
|
+
writeFileSync(join(snapshotPath, "snapshot.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
462
|
+
return snapshotPath;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function snapshotFile(name: string, originalPath: string, snapshotPath: string): SnapshotManifest["files"][number] {
|
|
466
|
+
if (!existsSync(originalPath)) {
|
|
467
|
+
return { name, originalPath, existed: false };
|
|
468
|
+
}
|
|
469
|
+
const backupPath = `${name}-${basename(originalPath)}`;
|
|
470
|
+
copyFileSync(originalPath, join(snapshotPath, backupPath));
|
|
471
|
+
return { name, originalPath, existed: true, backupPath };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function agentLoopMetadata(repoRoot: string): SnapshotManifest["targetAgentLoop"] {
|
|
475
|
+
const path = join(repoRoot, ".agent-loop");
|
|
476
|
+
if (!existsSync(path)) {
|
|
477
|
+
return { path, exists: false, entries: [] };
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
path,
|
|
481
|
+
exists: true,
|
|
482
|
+
entries: readdirSafe(path).map((entry) => {
|
|
483
|
+
const entryPath = join(path, entry);
|
|
484
|
+
return { path: entry, size: statSync(entryPath).size };
|
|
485
|
+
})
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function readSnapshotManifest(snapshotPath: string): SnapshotManifest {
|
|
490
|
+
const parsed = JSON.parse(readFileSync(join(snapshotPath, "snapshot.json"), "utf8")) as SnapshotManifest;
|
|
491
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.files)) {
|
|
492
|
+
throw new Error(`Invalid local install snapshot: ${snapshotPath}`);
|
|
493
|
+
}
|
|
494
|
+
return parsed;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function restoreOrRemoveSnapshotFile(file: SnapshotManifest["files"][number], snapshotPath: string, restored: string[], removed: string[]): void {
|
|
498
|
+
if (file.existed && file.backupPath) {
|
|
499
|
+
mkdirSync(dirname(file.originalPath), { recursive: true });
|
|
500
|
+
copyFileSync(join(snapshotPath, file.backupPath), file.originalPath);
|
|
501
|
+
restored.push(file.originalPath);
|
|
502
|
+
} else {
|
|
503
|
+
rmSync(file.originalPath, { force: true });
|
|
504
|
+
removed.push(file.originalPath);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function preserveBrokenFile(path: string): string {
|
|
509
|
+
const preserved = `${path}.broken-${timestamp()}`;
|
|
510
|
+
try {
|
|
511
|
+
copyFileSync(path, preserved);
|
|
512
|
+
chmodSync(preserved, 0o600);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
throw new AgentLoopError("storage_error", `Failed to preserve malformed file before rollback: ${path}`, {
|
|
515
|
+
details: { sourcePath: path, preservePath: preserved, cause: error instanceof Error ? error.message : String(error) }
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return preserved;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function readHookBindingRegistryJson(path: string): HookBindingRegistryJson {
|
|
522
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
523
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.bindings)) {
|
|
524
|
+
throw new Error(`Invalid hook binding registry: expected { version: 1, bindings: [...] } in ${path}`);
|
|
525
|
+
}
|
|
526
|
+
return { version: 1, bindings: parsed.bindings };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function writeHookBindingRegistryJson(path: string, registry: HookBindingRegistryJson): void {
|
|
530
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
531
|
+
writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function bindingMatchesRepo(value: unknown, repoRoot: string): boolean {
|
|
535
|
+
if (!isRecord(value)) return false;
|
|
536
|
+
const bindingRepoRoot = typeof value.repoRoot === "string" ? canonicalPath(value.repoRoot) : undefined;
|
|
537
|
+
const bindingWorktreeRoot = typeof value.worktreeRoot === "string" ? canonicalPath(value.worktreeRoot) : undefined;
|
|
538
|
+
return bindingRepoRoot === repoRoot || bindingWorktreeRoot === repoRoot;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function dedupeBindingsById(bindings: unknown[]): unknown[] {
|
|
542
|
+
const seen = new Set<string>();
|
|
543
|
+
const output: unknown[] = [];
|
|
544
|
+
for (const binding of bindings) {
|
|
545
|
+
const id = isRecord(binding) && typeof binding.id === "string" ? binding.id : undefined;
|
|
546
|
+
if (id && seen.has(id)) continue;
|
|
547
|
+
if (id) seen.add(id);
|
|
548
|
+
output.push(binding);
|
|
549
|
+
}
|
|
550
|
+
return output;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function stripManagedHookCommands(value: unknown, managedCommands: Set<string>): { value: unknown; removed: number } {
|
|
554
|
+
const stripped = stripManagedHookCommandsInner(value, managedCommands);
|
|
555
|
+
return { value: stripped.drop ? {} : stripped.value, removed: stripped.removed };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function stripManagedHookCommandsInner(value: unknown, managedCommands: Set<string>): { value: unknown; removed: number; drop: boolean } {
|
|
559
|
+
if (Array.isArray(value)) {
|
|
560
|
+
let removed = 0;
|
|
561
|
+
const next: unknown[] = [];
|
|
562
|
+
for (const item of value) {
|
|
563
|
+
const stripped = stripManagedHookCommandsInner(item, managedCommands);
|
|
564
|
+
removed += stripped.removed;
|
|
565
|
+
if (!stripped.drop) next.push(stripped.value);
|
|
566
|
+
}
|
|
567
|
+
return { value: next, removed, drop: false };
|
|
568
|
+
}
|
|
569
|
+
if (!isRecord(value)) {
|
|
570
|
+
return { value, removed: 0, drop: false };
|
|
571
|
+
}
|
|
572
|
+
if (value.type === "command" && typeof value.command === "string" && managedCommands.has(value.command)) {
|
|
573
|
+
return { value: undefined, removed: 1, drop: true };
|
|
574
|
+
}
|
|
575
|
+
let removed = 0;
|
|
576
|
+
const next: Record<string, unknown> = {};
|
|
577
|
+
for (const [key, child] of Object.entries(value)) {
|
|
578
|
+
const stripped = stripManagedHookCommandsInner(child, managedCommands);
|
|
579
|
+
removed += stripped.removed;
|
|
580
|
+
if (!stripped.drop) next[key] = stripped.value;
|
|
581
|
+
}
|
|
582
|
+
if (Array.isArray(next.hooks) && next.hooks.length === 0 && Object.keys(next).every((key) => ["matcher", "hooks", "timeout", "statusMessage"].includes(key))) {
|
|
583
|
+
return { value: undefined, removed, drop: true };
|
|
584
|
+
}
|
|
585
|
+
return { value: next, removed, drop: false };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function inspectHooks(hooksPath: string, packageRoot: string): LocalDoctorReport["hooks"] {
|
|
589
|
+
let commands: string[] = [];
|
|
590
|
+
let hooksJsonError: string | undefined;
|
|
591
|
+
if (existsSync(hooksPath)) {
|
|
592
|
+
try {
|
|
593
|
+
commands = collectHookCommands(JSON.parse(readFileSync(hooksPath, "utf8")) as unknown);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
commands = [];
|
|
596
|
+
hooksJsonError = error instanceof Error ? error.message : String(error);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const missingRouterEvents = CODEX_HOOK_EVENTS.filter((event) => !commands.includes(agentLoopRouterHookCommand(event, packageRoot)));
|
|
600
|
+
const expectedDist = hookDistRoot(packageRoot);
|
|
601
|
+
const routerCommands = commands.filter((command) => command.includes("autonomous-pr-loop/hooks/dist/"));
|
|
602
|
+
return {
|
|
603
|
+
hooksPath,
|
|
604
|
+
...(hooksJsonError ? { hooksJsonError } : {}),
|
|
605
|
+
routerInstalled: missingRouterEvents.length === 0,
|
|
606
|
+
missingRouterEvents,
|
|
607
|
+
legacyCommands: commands.filter(isLegacyAgentLoopHookCommand),
|
|
608
|
+
routerCommandsPointToExpectedDist: routerCommands.length > 0 && routerCommands.every((command) => command.includes(expectedDist))
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function inspectBindings(codexHome: string, repoRoot: string): LocalDoctorReport["bindings"] {
|
|
613
|
+
try {
|
|
614
|
+
const bindings = listHookBindings(codexHome);
|
|
615
|
+
const active = bindings.filter((binding) => binding.status === "active");
|
|
616
|
+
const staleOrMissingPathBindings = active.filter((binding) => !existsSync(binding.repoRoot)).length;
|
|
617
|
+
const tempPathBindings = active.filter((binding) => binding.repoRoot.includes("/var/folders/") || binding.repoRoot.includes("/private/var/folders/")).length;
|
|
618
|
+
return {
|
|
619
|
+
registryPath: hookRegistryPath(codexHome),
|
|
620
|
+
activeBindings: active.length,
|
|
621
|
+
currentRepoBindings: active.filter((binding) => binding.repoRoot === repoRoot).length,
|
|
622
|
+
staleOrMissingPathBindings,
|
|
623
|
+
tempPathBindings,
|
|
624
|
+
lock: inspectHookRegistryLock(codexHome)
|
|
625
|
+
};
|
|
626
|
+
} catch (error) {
|
|
627
|
+
return {
|
|
628
|
+
registryPath: hookRegistryPath(codexHome),
|
|
629
|
+
activeBindings: 0,
|
|
630
|
+
currentRepoBindings: 0,
|
|
631
|
+
staleOrMissingPathBindings: 0,
|
|
632
|
+
tempPathBindings: 0,
|
|
633
|
+
lock: inspectHookRegistryLock(codexHome),
|
|
634
|
+
registryError: error instanceof Error ? error.message : String(error)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function detectSelfLinkPollution(packageRoot: string): LocalDoctorReport["selfLinkPollution"] {
|
|
640
|
+
const polluted = new Set<string>();
|
|
641
|
+
const packageNames = new Set([localPackageName(packageRoot), LEGACY_NPM_PACKAGE_NAME]);
|
|
642
|
+
const packageJsonPath = join(packageRoot, "package.json");
|
|
643
|
+
if (existsSync(packageJsonPath)) {
|
|
644
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
645
|
+
dependencies?: Record<string, string>;
|
|
646
|
+
devDependencies?: Record<string, string>;
|
|
647
|
+
optionalDependencies?: Record<string, string>;
|
|
648
|
+
};
|
|
649
|
+
for (const deps of [parsed.dependencies, parsed.devDependencies, parsed.optionalDependencies]) {
|
|
650
|
+
for (const packageName of packageNames) {
|
|
651
|
+
if (deps?.[packageName]?.startsWith("link:")) {
|
|
652
|
+
polluted.add("package.json");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
for (const file of ["pnpm-lock.yaml", "pnpm-workspace.yaml"]) {
|
|
658
|
+
const path = join(packageRoot, file);
|
|
659
|
+
if (!existsSync(path)) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const content = readFileSync(path, "utf8");
|
|
663
|
+
for (const packageName of packageNames) {
|
|
664
|
+
if (new RegExp(`${escapeRegExp(packageName)}:\\s*link:`).test(content)) {
|
|
665
|
+
polluted.add(file);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { clean: polluted.size === 0, files: [...polluted] };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function localPackageName(packageRoot: string): string {
|
|
673
|
+
try {
|
|
674
|
+
const parsed = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { name?: unknown };
|
|
675
|
+
return typeof parsed.name === "string" && parsed.name.trim() ? parsed.name : FALLBACK_NPM_PACKAGE_NAME;
|
|
676
|
+
} catch {
|
|
677
|
+
return FALLBACK_NPM_PACKAGE_NAME;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function escapeRegExp(value: string): string {
|
|
682
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function manifestSnapshots(packageRoot: string): ManifestSnapshot {
|
|
686
|
+
return new Map(["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"].map((file) => {
|
|
687
|
+
const path = join(packageRoot, file);
|
|
688
|
+
if (!existsSync(path)) {
|
|
689
|
+
return [file, {}];
|
|
690
|
+
}
|
|
691
|
+
const content = readFileSync(path);
|
|
692
|
+
return [file, { hash: sha256(content), content }];
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function changedManifestFiles(before: ManifestSnapshot, after: ManifestSnapshot): string[] {
|
|
697
|
+
return [...before.keys()].filter((file) => before.get(file)?.hash !== after.get(file)?.hash);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function restoreManifestFiles(packageRoot: string, before: ManifestSnapshot, changed: string[]): void {
|
|
701
|
+
for (const file of changed) {
|
|
702
|
+
const snapshot = before.get(file);
|
|
703
|
+
const path = join(packageRoot, file);
|
|
704
|
+
if (snapshot?.content) {
|
|
705
|
+
writeFileSync(path, snapshot.content);
|
|
706
|
+
} else {
|
|
707
|
+
rmSync(path, { force: true });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function runCommandSummary(file: string, args: string[], cwd: string): CommandSummary {
|
|
713
|
+
const result = runCommand(file, args, cwd);
|
|
714
|
+
return {
|
|
715
|
+
ok: result.ok,
|
|
716
|
+
command: [file, ...args.map(shellQuote)].join(" "),
|
|
717
|
+
...(result.stdout ? { stdout: result.stdout } : {}),
|
|
718
|
+
...(result.stderr ? { stderr: result.stderr } : {})
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function localInstallFailure(message: string, snapshotPath: string, manifestChanges: string[] = []): AgentLoopError {
|
|
723
|
+
const rollbackCommand = `agent-loop local rollback --snapshot ${shellQuote(snapshotPath)}`;
|
|
724
|
+
return new AgentLoopError("storage_error", `${message}\nSnapshot: ${snapshotPath}\nRollback: ${rollbackCommand}`, {
|
|
725
|
+
details: {
|
|
726
|
+
snapshotPath,
|
|
727
|
+
rollbackCommand,
|
|
728
|
+
...(manifestChanges.length > 0 ? { manifestChanges } : {})
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function gitStatus(cwd: string): string[] {
|
|
734
|
+
const result = runCommand("git", ["status", "--short"], cwd);
|
|
735
|
+
return result.ok && result.stdout ? result.stdout.split(/\r?\n/).filter(Boolean) : [];
|
|
736
|
+
}
|
|
737
|
+
|
|
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
|
+
function localInstallBackupsDir(): string {
|
|
748
|
+
return join(codexHomePath(), "agent-loop", "backups");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function codexHomePath(): string {
|
|
752
|
+
return process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function canonicalPath(path: string): string {
|
|
756
|
+
const resolved = resolve(path);
|
|
757
|
+
return existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function timestamp(): string {
|
|
761
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function sha256(value: Buffer | string): string {
|
|
765
|
+
return createHash("sha256").update(value).digest("hex");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function shellQuote(value: string): string {
|
|
769
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function readdirSafe(path: string): string[] {
|
|
773
|
+
try {
|
|
774
|
+
return existsSync(path) ? readdirSync(path) : [];
|
|
775
|
+
} catch {
|
|
776
|
+
return [];
|
|
777
|
+
}
|
|
778
|
+
}
|