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.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,423 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isRecord, loadConfig, statePath } from "./config.js";
3
+ import { hookEventKind } from "./hook-events.js";
4
+ import { resolveHookRoute } from "./hook-router.js";
5
+ import { matchesProtectedPath } from "./policy.js";
6
+ import { redactSecrets } from "./redaction.js";
7
+ import { SqliteAgentLoopStorage } from "./storage.js";
8
+ import type { AgentLoopGateKind, AgentLoopStorage } from "./types.js";
9
+
10
+ export interface HookCommand {
11
+ file: string;
12
+ args: string[];
13
+ raw?: string;
14
+ }
15
+
16
+ export interface HookPolicyInput {
17
+ repoRoot: string;
18
+ command: HookCommand;
19
+ isWorker?: boolean;
20
+ protectedPaths?: string[];
21
+ storage?: AgentLoopStorage;
22
+ }
23
+
24
+ export interface HookPolicyDecision {
25
+ allow: boolean;
26
+ matchedPolicy: string;
27
+ gate?: AgentLoopGateKind;
28
+ blockedCommand: string;
29
+ nextAction: string;
30
+ reason: string;
31
+ }
32
+
33
+ /** Normalize a Codex PreToolUse hook payload into an argv-like command. */
34
+ export function commandFromHookPayload(payload: unknown): HookCommand | undefined {
35
+ if (!isRecord(payload)) {
36
+ return undefined;
37
+ }
38
+ const toolInput = isRecord(payload.tool_input) ? payload.tool_input : payload;
39
+ const file = stringValue(toolInput.file ?? toolInput.cmd ?? toolInput.executable);
40
+ const args = Array.isArray(toolInput.args) ? toolInput.args.filter((arg): arg is string => typeof arg === "string") : undefined;
41
+ if (file && args) {
42
+ return { file: basename(file), args, raw: [file, ...args].join(" ") };
43
+ }
44
+ const command = stringValue(toolInput.command ?? toolInput.cmd ?? toolInput.input);
45
+ if (!command) {
46
+ return undefined;
47
+ }
48
+ return tokenizeCommand(command);
49
+ }
50
+
51
+ /** Evaluate a hook command without spawning subprocesses. */
52
+ export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
53
+ const command = unwrapCommand(normalizeCommand(input.command));
54
+ const blockedCommand = renderCommand(command);
55
+ const destructive = destructivePolicy(command);
56
+ if (destructive) {
57
+ return deny(blockedCommand, destructive, "policy_violation", "Stop using the destructive command and continue through agent-loop.");
58
+ }
59
+ const worker = input.isWorker === true ||
60
+ process.env.AGENT_LOOP_WORKER_POLICY === "1" ||
61
+ command.raw?.includes("AGENT_LOOP_WORKER_POLICY=1") === true;
62
+ const workerPolicy = workerLifecyclePolicy(command);
63
+ if (worker && workerPolicy) {
64
+ return deny(blockedCommand, workerPolicy, "policy_violation", "Let the supervisor own commit, push, PR, and merge actions.");
65
+ }
66
+ const protectedPath = protectedPathPolicy(command, input.protectedPaths ?? []);
67
+ if (protectedPath) {
68
+ return deny(blockedCommand, protectedPath, "policy_violation", "Remove protected path changes from the command.");
69
+ }
70
+ const gate = gatedLifecyclePolicy(command, input.storage);
71
+ if (gate) {
72
+ return deny(blockedCommand, gate.policy, gate.gate, gate.nextAction);
73
+ }
74
+ if (!matchesHookAllowlist(command)) {
75
+ return deny(blockedCommand, "command_not_in_hook_allowlist", "policy_violation", "Use agent-loop MCP/CLI control surfaces or an allowlisted read/check command.");
76
+ }
77
+ return {
78
+ allow: true,
79
+ matchedPolicy: "allow",
80
+ blockedCommand,
81
+ nextAction: "Continue.",
82
+ reason: "No hook policy matched."
83
+ };
84
+ }
85
+
86
+ /** Run fail-safe PreToolUse policy and persist a ledger event when a command is blocked. */
87
+ export function evaluatePreToolUseHook(payload: unknown, repoRoot?: string): HookPolicyDecision {
88
+ const command = commandFromHookPayload(payload);
89
+ if (!command) {
90
+ return {
91
+ allow: true,
92
+ matchedPolicy: "allow_unparsed",
93
+ blockedCommand: "",
94
+ nextAction: "Continue.",
95
+ reason: "Hook payload did not contain a command."
96
+ };
97
+ }
98
+ const route = resolveHookRoute(payload, { legacyRepoRoot: repoRoot });
99
+ if (route.status === "no_match") {
100
+ return route.worktreeBinding ? routeSessionMismatchDecision(command, route.reason) : {
101
+ allow: true,
102
+ matchedPolicy: "hook_routing_no_match",
103
+ blockedCommand: renderCommand(command),
104
+ nextAction: "Continue.",
105
+ reason: route.reason
106
+ };
107
+ }
108
+ if (route.status === "ambiguous") {
109
+ return {
110
+ allow: false,
111
+ matchedPolicy: "hook_routing_ambiguous",
112
+ gate: "policy_violation",
113
+ blockedCommand: renderCommand(command),
114
+ nextAction: "Run `agent-loop hooks doctor` and bind this Codex session to exactly one agent-loop target.",
115
+ reason: route.reason
116
+ };
117
+ }
118
+ if (route.status === "route_error") {
119
+ return routeErrorDecision(command, route.reason);
120
+ }
121
+
122
+ let storage: SqliteAgentLoopStorage | undefined;
123
+ try {
124
+ const config = loadConfig(route.binding.repoRoot).config;
125
+ storage = new SqliteAgentLoopStorage(statePath(route.binding.repoRoot));
126
+ const decision = evaluateHookPolicy({ repoRoot: route.binding.repoRoot, command, storage, protectedPaths: config.protectedPaths });
127
+ recordHookDecision(storage, decision, route.binding.runId);
128
+ return decision;
129
+ } catch (error) {
130
+ const failSafe = evaluateHookPolicy({ repoRoot: route.binding.repoRoot, command });
131
+ if (!failSafe.allow) {
132
+ return {
133
+ ...failSafe,
134
+ matchedPolicy: `fail_safe:${failSafe.matchedPolicy}`,
135
+ reason: `Storage unavailable; denied dangerous command. ${error instanceof Error ? error.message : String(error)}`
136
+ };
137
+ }
138
+ return failSafe;
139
+ } finally {
140
+ storage?.close();
141
+ }
142
+ }
143
+
144
+ /** Convert a hook decision to Codex hook stdout JSON. */
145
+ export function toCodexHookResponse(decision: HookPolicyDecision): Record<string, unknown> {
146
+ if (decision.allow) {
147
+ return { continue: true };
148
+ }
149
+ return {
150
+ decision: "deny",
151
+ permissionDecision: "deny",
152
+ continue: false,
153
+ stopReason: decision.reason,
154
+ systemMessage: formatHookMessage(decision)
155
+ };
156
+ }
157
+
158
+ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecision, runId?: string): void {
159
+ const run = runId ? storage.listRuns(200).find((item) => item.id === runId) : storage.getCurrentRun();
160
+ const command = decision.blockedCommand;
161
+ storage.appendEvent({
162
+ ...(run ? { runId: run.id } : {}),
163
+ kind: hookEventKind("PreToolUse"),
164
+ message: decision.reason,
165
+ payload: {
166
+ allow: decision.allow,
167
+ matchedPolicy: decision.matchedPolicy,
168
+ ...(decision.gate ? { gate: decision.gate } : {}),
169
+ nextAction: decision.nextAction,
170
+ commandLength: command.length,
171
+ commandSha256: createHash("sha256").update(command).digest("hex"),
172
+ commandPreview: redactSecrets(command.slice(0, 500))
173
+ }
174
+ });
175
+ }
176
+
177
+ function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDecision {
178
+ const normalized = unwrapCommand(normalizeCommand(command));
179
+ const blockedCommand = renderCommand(normalized);
180
+ const destructive = destructivePolicy(normalized);
181
+ if (destructive || lifecycleCommand(normalized)) {
182
+ return deny(
183
+ blockedCommand,
184
+ `hook_routing_error${destructive ? `:${destructive}` : ""}`,
185
+ "policy_violation",
186
+ "Fix agent-loop hook routing with `agent-loop hooks doctor` before running lifecycle or destructive commands."
187
+ );
188
+ }
189
+ return {
190
+ allow: true,
191
+ matchedPolicy: "hook_routing_error_noop",
192
+ blockedCommand,
193
+ nextAction: "Continue.",
194
+ reason: `Hook routing unavailable; no-op for non-lifecycle command. ${reason}`
195
+ };
196
+ }
197
+
198
+ function routeSessionMismatchDecision(command: HookCommand, reason: string): HookPolicyDecision {
199
+ const normalized = unwrapCommand(normalizeCommand(command));
200
+ const blockedCommand = renderCommand(normalized);
201
+ const destructive = destructivePolicy(normalized);
202
+ if (destructive || lifecycleCommand(normalized)) {
203
+ return deny(
204
+ blockedCommand,
205
+ `hook_routing_session_mismatch${destructive ? `:${destructive}` : ""}`,
206
+ "policy_violation",
207
+ "Bind this Codex session explicitly with `agent-loop hooks bind --session ...` before running lifecycle or destructive commands."
208
+ );
209
+ }
210
+ return {
211
+ allow: true,
212
+ matchedPolicy: "hook_routing_no_match",
213
+ blockedCommand,
214
+ nextAction: "Continue.",
215
+ reason
216
+ };
217
+ }
218
+
219
+ function lifecycleCommand(command: HookCommand): boolean {
220
+ const args = stripGitGlobalOptions(command.args);
221
+ return command.file === "git" && ["commit", "push", "merge"].includes(args[0] ?? "") ||
222
+ command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "");
223
+ }
224
+
225
+ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
226
+ const args = stripGitGlobalOptions(command.args);
227
+ const lifecycleCommand = command.file === "git" && args[0] === "commit" ||
228
+ command.file === "git" && args[0] === "push" ||
229
+ command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge";
230
+ if (!lifecycleCommand) {
231
+ return undefined;
232
+ }
233
+ if (!storage) {
234
+ return {
235
+ policy: "storage_required_for_lifecycle",
236
+ gate: "policy_violation",
237
+ nextAction: "Run `pnpm agent-loop status` after restoring .agent-loop/state.sqlite."
238
+ };
239
+ }
240
+ const current = storage.getCurrentStatus();
241
+ const state = current.run?.currentState;
242
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR") {
243
+ return {
244
+ policy: "commit_push_state_gate",
245
+ gate: current.gate?.kind ?? "policy_violation",
246
+ nextAction: "Resume agent-loop until COMMIT_PUSH_PR owns publishing."
247
+ };
248
+ }
249
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage)) {
250
+ return {
251
+ policy: "commit_push_prerequisite_gate",
252
+ gate: "policy_violation",
253
+ nextAction: "Run SELF_CHECK and GitNexus detect_changes through agent-loop before publishing."
254
+ };
255
+ }
256
+ if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE") {
257
+ return {
258
+ policy: "merge_state_gate",
259
+ gate: current.gate?.kind ?? "merge_requires_confirmation",
260
+ nextAction: "Wait for READY_TO_MERGE/MERGE and explicit approval."
261
+ };
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ function destructivePolicy(command: HookCommand): string | undefined {
267
+ const args = stripGitGlobalOptions(command.args);
268
+ if (command.file === "git" && args[0] === "reset" && args.includes("--hard")) {
269
+ return "destructive_git_reset_hard";
270
+ }
271
+ if (command.file === "git" && args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
272
+ return "destructive_git_clean";
273
+ }
274
+ if (command.file === "git" && args[0] === "push" && args.some((arg) => ["-f", "--force", "--force-with-lease"].includes(arg))) {
275
+ return "destructive_git_force_push";
276
+ }
277
+ if (command.file === "gh" && command.args[0] === "repo" && command.args[1] === "delete") {
278
+ return "destructive_gh_repo_delete";
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ function workerLifecyclePolicy(command: HookCommand): string | undefined {
284
+ const args = stripGitGlobalOptions(command.args);
285
+ if (command.file === "git" && ["commit", "push", "merge"].includes(args[0] ?? "")) {
286
+ return "worker_git_lifecycle_forbidden";
287
+ }
288
+ if (command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "")) {
289
+ return "worker_gh_lifecycle_forbidden";
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ function protectedPathPolicy(command: HookCommand, protectedPaths: string[]): string | undefined {
295
+ const args = stripGitGlobalOptions(command.args);
296
+ if (command.file !== "git" || args[0] !== "add") {
297
+ return undefined;
298
+ }
299
+ const separator = args.indexOf("--");
300
+ const paths = separator >= 0 ? args.slice(separator + 1) : args.slice(1);
301
+ const hit = paths.find((path) => protectedPaths.some((pattern) => matchesProtectedPath(pattern, path)));
302
+ return hit ? `protected_path:${hit}` : undefined;
303
+ }
304
+
305
+ function matchesHookAllowlist(command: HookCommand): boolean {
306
+ const args = stripGitGlobalOptions(command.args);
307
+ if (command.file === "git") {
308
+ return args[0] === "status" ||
309
+ args[0] === "branch" && args[1] === "--show-current" ||
310
+ args[0] === "rev-parse" ||
311
+ args[0] === "diff" ||
312
+ args[0] === "add" && args[1] === "--" ||
313
+ args[0] === "commit" && args[1] === "-m" ||
314
+ args[0] === "push" && args[1] === "-u";
315
+ }
316
+ if (command.file === "gh") {
317
+ return command.args[0] === "auth" && command.args[1] === "status" ||
318
+ command.args[0] === "pr" && ["list", "view"].includes(command.args[1] ?? "") ||
319
+ command.args[0] === "api" && command.args[1] === "graphql";
320
+ }
321
+ if (command.file === "pnpm") {
322
+ return command.args[0] === "test" ||
323
+ command.args[0] === "lint" ||
324
+ command.args[0] === "agent-loop" && ["status", "doctor", "logs"].includes(command.args[1] ?? "");
325
+ }
326
+ if (command.file === "npx") {
327
+ return command.args[0] === "gitnexus" &&
328
+ ["--version", "status", "analyze", "detect_changes", "impact"].includes(command.args[1] ?? "");
329
+ }
330
+ if (command.file === "codex") {
331
+ return command.args[0] === "--version";
332
+ }
333
+ return false;
334
+ }
335
+
336
+ function deny(
337
+ blockedCommand: string,
338
+ matchedPolicy: string,
339
+ gate: AgentLoopGateKind,
340
+ nextAction: string
341
+ ): HookPolicyDecision {
342
+ return {
343
+ allow: false,
344
+ matchedPolicy,
345
+ gate,
346
+ blockedCommand,
347
+ nextAction,
348
+ reason: `${matchedPolicy} blocked ${blockedCommand}`
349
+ };
350
+ }
351
+
352
+ function formatHookMessage(decision: HookPolicyDecision): string {
353
+ return [
354
+ `blocked command: ${decision.blockedCommand}`,
355
+ `matched policy: ${decision.matchedPolicy}`,
356
+ decision.gate ? `gate: ${decision.gate}` : undefined,
357
+ `next action: ${decision.nextAction}`
358
+ ].filter(Boolean).join("\n");
359
+ }
360
+
361
+ function normalizeCommand(command: HookCommand): HookCommand {
362
+ return { ...command, file: basename(command.file) };
363
+ }
364
+
365
+ function unwrapCommand(command: HookCommand): HookCommand {
366
+ if (command.file === "env") {
367
+ const index = command.args.findIndex((arg) => !arg.includes("="));
368
+ if (index >= 0) {
369
+ return unwrapCommand({ file: command.args[index] ?? "", args: command.args.slice(index + 1), raw: renderCommand(command) });
370
+ }
371
+ }
372
+ if ((command.file === "sh" || command.file === "bash") && command.args[0] === "-c" && command.args[1]) {
373
+ return unwrapCommand(tokenizeCommand(command.args[1]));
374
+ }
375
+ return command;
376
+ }
377
+
378
+ function renderCommand(command: HookCommand): string {
379
+ return command.raw ?? [command.file, ...command.args].join(" ");
380
+ }
381
+
382
+ function tokenizeCommand(command: string): HookCommand {
383
+ const parts = command.match(/"[^"]*"|'[^']*'|\S+/g)?.map((part) => part.replace(/^["']|["']$/g, "")) ?? [];
384
+ const [file = "", ...args] = parts;
385
+ return { file: basename(file), args, raw: command };
386
+ }
387
+
388
+ function stripGitGlobalOptions(args: string[]): string[] {
389
+ const result = [...args];
390
+ while (result.length > 0) {
391
+ const first = result[0];
392
+ if (first === "-C" || first === "--git-dir" || first === "--work-tree" || first === "-c") {
393
+ result.splice(0, 2);
394
+ continue;
395
+ }
396
+ if (first === "--no-pager" || first === "--paginate") {
397
+ result.shift();
398
+ continue;
399
+ }
400
+ if (first?.startsWith("--git-dir=") || first?.startsWith("--work-tree=") || first?.startsWith("-c")) {
401
+ result.shift();
402
+ continue;
403
+ }
404
+ break;
405
+ }
406
+ return result;
407
+ }
408
+
409
+ function publishPrerequisitesSatisfied(storage: AgentLoopStorage): boolean {
410
+ const run = storage.getCurrentRun();
411
+ if (!run) {
412
+ return false;
413
+ }
414
+ return storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes");
415
+ }
416
+
417
+ function basename(value: string): string {
418
+ return value.replaceAll("\\", "/").split("/").at(-1) ?? value;
419
+ }
420
+
421
+ function stringValue(value: unknown): string | undefined {
422
+ return typeof value === "string" && value.length > 0 ? value : undefined;
423
+ }