holo-codex 0.1.1 → 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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - Added the manual GitHub Actions Release workflow for npm Trusted Publishing, dry-run tarball validation, and registry install smoke.
6
+ - Added audited maintainer override support for lifecycle gates so verified release and merge operations can proceed with recorded evidence.
7
+ - Relaxed the hook allowlist for maintainer-owned commands after verified evidence is present.
8
+ - Hardened Codex hook and MCP startup compatibility for current Codex configurations.
9
+ - Fixed dashboard Cleanup status consistency so sidebar substages and the main cleanup checklist use the same cleanup evidence.
10
+ - Added the `next_issue_selected` cleanup checklist item to keep cleanup progress complete and visible.
11
+ - Validated the release path with the existing GitHub Actions Release workflow, tarball checks, and dashboard smoke requirements.
12
+ - Kept #16, #17, #18, and #19 as follow-up improvements outside this release-prep PR.
13
+
3
14
  ## 0.1.1
4
15
 
5
16
  - Fixed the bundled Codex plugin hooks schema for newer Codex config parsers.
@@ -1,6 +1,6 @@
1
1
  # HOLO-Codex npm Release Checklist
2
2
 
3
- Use this checklist for npm releases such as `v0.1.1`. Replace `VERSION` with the package version being released.
3
+ Use this checklist for npm releases such as `v0.1.2`. Replace `VERSION` with the package version being released.
4
4
 
5
5
  The public source release remains available at `https://github.com/tizerluo/HOLO-Codex`. The npm package is `holo-codex` and installs the stable `agent-loop` CLI. Compatibility identifiers remain unchanged: `agent-loop`, `.agent-loop/`, `autonomous-pr-loop`, and `plugins/autonomous-pr-loop/`.
6
6
 
@@ -12,7 +12,7 @@ Run from the release checkout:
12
12
  pnpm install --frozen-lockfile
13
13
  pnpm build:hooks
14
14
  pnpm lint
15
- pnpm test
15
+ pnpm exec vitest run --no-file-parallelism --maxWorkers=1
16
16
  npm pack --ignore-scripts --dry-run --json
17
17
  npm view holo-codex name version dist-tags --json || true
18
18
  ```
@@ -53,6 +53,32 @@ The extracted `hooks.json` must have a top-level `hooks` object and must not hav
53
53
 
54
54
  ## Publish
55
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
+
56
82
  Confirm npm authentication without printing tokens:
57
83
 
58
84
  ```bash
@@ -129,7 +155,7 @@ Expected result: hooks and binding registry restore from the snapshot, non-agent
129
155
  After publish and post-publish smoke pass:
130
156
 
131
157
  ```bash
132
- VERSION=0.1.1
158
+ VERSION=0.1.2
133
159
  git tag "v$VERSION"
134
160
  git push origin "v$VERSION"
135
161
  gh release create "v$VERSION" \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holo-codex",
3
- "version": "0.1.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",
@@ -60,7 +60,8 @@
60
60
  "scripts": {
61
61
  "agent-loop": "tsx plugins/autonomous-pr-loop/scripts/agent-loop.ts",
62
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",
63
- "prepack": "pnpm build:hooks",
63
+ "build:mcp": "esbuild plugins/autonomous-pr-loop/mcp-server/src/index.ts --bundle --platform=node --format=esm --outfile=plugins/autonomous-pr-loop/mcp-server/dist/index.js",
64
+ "prepack": "pnpm build:hooks && pnpm build:mcp",
64
65
  "test": "vitest run",
65
66
  "lint": "tsc --noEmit"
66
67
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autonomous-pr-loop",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Human On Loop Codex control plane for observable, recoverable Codex workflow loops.",
5
5
  "skills": "./skills/",
6
6
  "interface": {
@@ -2,11 +2,9 @@
2
2
  "mcpServers": {
3
3
  "autonomous-pr-loop": {
4
4
  "cwd": ".",
5
- "command": "pnpm",
5
+ "command": "node",
6
6
  "args": [
7
- "exec",
8
- "tsx",
9
- "./mcp-server/src/index.ts"
7
+ "./mcp-server/dist/index.js"
10
8
  ]
11
9
  }
12
10
  }
@@ -142,6 +142,9 @@ export async function runAgentLoopCli(
142
142
  if (command === "approve-gate") {
143
143
  return approveGate(targetRepoRoot, filtered, json, localeOverride);
144
144
  }
145
+ if (command === "maintainer-override") {
146
+ return maintainerOverride(targetRepoRoot, filtered, json);
147
+ }
145
148
  if (command === "evidence") {
146
149
  return evidence(targetRepoRoot, filtered, json, localeOverride);
147
150
  }
@@ -189,6 +192,7 @@ function helpResult(json: boolean, usage = "agent-loop <command> [options]"): Cl
189
192
  "hooks",
190
193
  "local",
191
194
  "approve-gate",
195
+ "maintainer-override",
192
196
  "dashboard",
193
197
  "evidence",
194
198
  "delivery"
@@ -218,6 +222,7 @@ function commandHelpUsage(command: string): string | undefined {
218
222
  hooks: "agent-loop hooks install-router|bind|list|doctor|unbind [--session SESSION_ID] [--run RUN_ID] [--json]",
219
223
  local: "agent-loop local install|rollback|doctor|snapshots [--repo /path/to/repo] [--snapshot PATH] [--json]",
220
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]",
221
226
  dashboard: "agent-loop dashboard [--host 127.0.0.1] [--port 0] [--json]",
222
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]",
223
228
  delivery: "agent-loop delivery bind|stage [options] [--json]"
@@ -240,7 +245,9 @@ const OPTIONS_WITH_VALUES = new Set([
240
245
  "--port",
241
246
  "--issue",
242
247
  "--ref",
248
+ "--reason",
243
249
  "--run",
250
+ "--scope",
244
251
  "--source",
245
252
  "--stage",
246
253
  "--status",
@@ -249,6 +256,7 @@ const OPTIONS_WITH_VALUES = new Set([
249
256
  "--substage",
250
257
  "--summary",
251
258
  "--title",
259
+ "--ttl-minutes",
252
260
  "--url",
253
261
  "--worker"
254
262
  ]);
@@ -920,6 +928,70 @@ function approveGate(repoRoot: string, args: string[], json: boolean, localeOver
920
928
  }
921
929
  }
922
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
+
923
995
  function gateState(details: unknown): string | undefined {
924
996
  if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
925
997
  const state = (details as { state?: unknown }).state;
@@ -19,6 +19,7 @@ export interface HookPolicyInput {
19
19
  isWorker?: boolean;
20
20
  protectedPaths?: string[];
21
21
  storage?: AgentLoopStorage;
22
+ runId?: string;
22
23
  }
23
24
 
24
25
  export interface HookPolicyDecision {
@@ -28,8 +29,19 @@ export interface HookPolicyDecision {
28
29
  blockedCommand: string;
29
30
  nextAction: string;
30
31
  reason: string;
32
+ auditDetails?: Record<string, unknown>;
31
33
  }
32
34
 
35
+ type MaintainerOverrideScope = "publish" | "merge";
36
+
37
+ interface ActiveMaintainerOverride {
38
+ decisionId: string;
39
+ scope: MaintainerOverrideScope;
40
+ expiresAt: string;
41
+ }
42
+
43
+ const REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES = ["lint", "full_tests", "gitnexus_detect"] as const;
44
+
33
45
  /** Normalize a Codex PreToolUse hook payload into an argv-like command. */
34
46
  export function commandFromHookPayload(payload: unknown): HookCommand | undefined {
35
47
  if (!isRecord(payload)) {
@@ -50,7 +62,12 @@ export function commandFromHookPayload(payload: unknown): HookCommand | undefine
50
62
 
51
63
  /** Evaluate a hook command without spawning subprocesses. */
52
64
  export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
53
- const command = unwrapCommand(normalizeCommand(input.command));
65
+ const normalized = normalizeCommand(input.command);
66
+ const shellControl = shellControlPolicy(normalized);
67
+ if (shellControl) {
68
+ return deny(renderCommand(normalized), shellControl, "policy_violation", "Run one allowlisted command at a time without shell control operators.");
69
+ }
70
+ const command = unwrapCommand(normalized);
54
71
  const blockedCommand = renderCommand(command);
55
72
  const destructive = destructivePolicy(command);
56
73
  if (destructive) {
@@ -67,10 +84,25 @@ export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
67
84
  if (protectedPath) {
68
85
  return deny(blockedCommand, protectedPath, "policy_violation", "Remove protected path changes from the command.");
69
86
  }
70
- const gate = gatedLifecyclePolicy(command, input.storage);
87
+ const gate = gatedLifecyclePolicy(command, input.storage, input.runId);
71
88
  if (gate) {
72
89
  return deny(blockedCommand, gate.policy, gate.gate, gate.nextAction);
73
90
  }
91
+ const override = activeMaintainerOverride(input.storage, lifecycleOverrideScope(command), input.runId);
92
+ if (override && matchesHookAllowlist(command)) {
93
+ return {
94
+ allow: true,
95
+ matchedPolicy: `maintainer_override:${override.scope}`,
96
+ blockedCommand,
97
+ nextAction: "Continue.",
98
+ reason: `Maintainer override ${override.decisionId} allows ${blockedCommand} until ${override.expiresAt}.`,
99
+ auditDetails: {
100
+ overrideDecisionId: override.decisionId,
101
+ overrideScope: override.scope,
102
+ overrideExpiresAt: override.expiresAt
103
+ }
104
+ };
105
+ }
74
106
  if (!matchesHookAllowlist(command)) {
75
107
  return deny(blockedCommand, "command_not_in_hook_allowlist", "policy_violation", "Use agent-loop MCP/CLI control surfaces or an allowlisted read/check command.");
76
108
  }
@@ -123,7 +155,13 @@ export function evaluatePreToolUseHook(payload: unknown, repoRoot?: string): Hoo
123
155
  try {
124
156
  const config = loadConfig(route.binding.repoRoot).config;
125
157
  storage = new SqliteAgentLoopStorage(statePath(route.binding.repoRoot));
126
- const decision = evaluateHookPolicy({ repoRoot: route.binding.repoRoot, command, storage, protectedPaths: config.protectedPaths });
158
+ const decision = evaluateHookPolicy({
159
+ repoRoot: route.binding.repoRoot,
160
+ command,
161
+ storage,
162
+ ...(route.binding.runId ? { runId: route.binding.runId } : {}),
163
+ protectedPaths: config.protectedPaths
164
+ });
127
165
  recordHookDecision(storage, decision, route.binding.runId);
128
166
  return decision;
129
167
  } catch (error) {
@@ -147,10 +185,8 @@ export function toCodexHookResponse(decision: HookPolicyDecision): Record<string
147
185
  return { continue: true };
148
186
  }
149
187
  return {
150
- decision: "deny",
151
- permissionDecision: "deny",
152
- continue: false,
153
- stopReason: decision.reason,
188
+ decision: "block",
189
+ reason: decision.reason,
154
190
  systemMessage: formatHookMessage(decision)
155
191
  };
156
192
  }
@@ -166,6 +202,7 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
166
202
  allow: decision.allow,
167
203
  matchedPolicy: decision.matchedPolicy,
168
204
  ...(decision.gate ? { gate: decision.gate } : {}),
205
+ ...(decision.auditDetails ? { auditDetails: decision.auditDetails } : {}),
169
206
  nextAction: decision.nextAction,
170
207
  commandLength: command.length,
171
208
  commandSha256: createHash("sha256").update(command).digest("hex"),
@@ -175,9 +212,11 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
175
212
  }
176
213
 
177
214
  function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDecision {
178
- const normalized = unwrapCommand(normalizeCommand(command));
215
+ const baseCommand = normalizeCommand(command);
216
+ const shellControl = shellControlPolicy(baseCommand);
217
+ const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
179
218
  const blockedCommand = renderCommand(normalized);
180
- const destructive = destructivePolicy(normalized);
219
+ const destructive = shellControl ?? destructivePolicy(normalized);
181
220
  if (destructive || lifecycleCommand(normalized)) {
182
221
  return deny(
183
222
  blockedCommand,
@@ -196,9 +235,11 @@ function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDec
196
235
  }
197
236
 
198
237
  function routeSessionMismatchDecision(command: HookCommand, reason: string): HookPolicyDecision {
199
- const normalized = unwrapCommand(normalizeCommand(command));
238
+ const baseCommand = normalizeCommand(command);
239
+ const shellControl = shellControlPolicy(baseCommand);
240
+ const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
200
241
  const blockedCommand = renderCommand(normalized);
201
- const destructive = destructivePolicy(normalized);
242
+ const destructive = shellControl ?? destructivePolicy(normalized);
202
243
  if (destructive || lifecycleCommand(normalized)) {
203
244
  return deny(
204
245
  blockedCommand,
@@ -222,7 +263,7 @@ function lifecycleCommand(command: HookCommand): boolean {
222
263
  command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "");
223
264
  }
224
265
 
225
- function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
266
+ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage, runId?: string): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
226
267
  const args = stripGitGlobalOptions(command.args);
227
268
  const lifecycleCommand = command.file === "git" && args[0] === "commit" ||
228
269
  command.file === "git" && args[0] === "push" ||
@@ -238,22 +279,24 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
238
279
  };
239
280
  }
240
281
  const current = storage.getCurrentStatus();
241
- const state = current.run?.currentState;
242
- if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR") {
282
+ const run = runId ? storage.getRun(runId) : current.run;
283
+ const state = run?.currentState;
284
+ const override = activeMaintainerOverride(storage, lifecycleOverrideScope(command), runId);
285
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR" && !override) {
243
286
  return {
244
287
  policy: "commit_push_state_gate",
245
288
  gate: current.gate?.kind ?? "policy_violation",
246
289
  nextAction: "Resume agent-loop until COMMIT_PUSH_PR owns publishing."
247
290
  };
248
291
  }
249
- if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage)) {
292
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage, runId)) {
250
293
  return {
251
294
  policy: "commit_push_prerequisite_gate",
252
295
  gate: "policy_violation",
253
296
  nextAction: "Run SELF_CHECK and GitNexus detect_changes through agent-loop before publishing."
254
297
  };
255
298
  }
256
- if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE") {
299
+ if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE" && !override) {
257
300
  return {
258
301
  policy: "merge_state_gate",
259
302
  gate: current.gate?.kind ?? "merge_requires_confirmation",
@@ -263,6 +306,45 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
263
306
  return undefined;
264
307
  }
265
308
 
309
+ function lifecycleOverrideScope(command: HookCommand): MaintainerOverrideScope | undefined {
310
+ const args = stripGitGlobalOptions(command.args);
311
+ if (command.file === "git" && (args[0] === "commit" || args[0] === "push")) {
312
+ return "publish";
313
+ }
314
+ if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge") {
315
+ return "merge";
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ function activeMaintainerOverride(storage: AgentLoopStorage | undefined, scope: MaintainerOverrideScope | undefined, runId?: string): ActiveMaintainerOverride | undefined {
321
+ if (!storage || !scope) {
322
+ return undefined;
323
+ }
324
+ const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
325
+ if (!run) {
326
+ return undefined;
327
+ }
328
+ return storage.listDecisions(run.id)
329
+ .map((decision) => {
330
+ const details = objectDetails(decision.details);
331
+ const overrideScope = stringValue(details?.scope);
332
+ const expiresAt = stringValue(details?.expiresAt);
333
+ if (decision.kind !== "maintainer_override_approved" || !overrideScope || !expiresAt) {
334
+ return undefined;
335
+ }
336
+ if (overrideScope !== scope) {
337
+ return undefined;
338
+ }
339
+ const expiresAtMs = Date.parse(expiresAt);
340
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
341
+ return undefined;
342
+ }
343
+ return { decisionId: decision.id, scope, expiresAt };
344
+ })
345
+ .find((override): override is ActiveMaintainerOverride => override !== undefined);
346
+ }
347
+
266
348
  function destructivePolicy(command: HookCommand): string | undefined {
267
349
  const args = stripGitGlobalOptions(command.args);
268
350
  if (command.file === "git" && args[0] === "reset" && args.includes("--hard")) {
@@ -271,7 +353,11 @@ function destructivePolicy(command: HookCommand): string | undefined {
271
353
  if (command.file === "git" && args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
272
354
  return "destructive_git_clean";
273
355
  }
274
- if (command.file === "git" && args[0] === "push" && args.some((arg) => ["-f", "--force", "--force-with-lease"].includes(arg))) {
356
+ if (command.file === "git" && args[0] === "push" && args.some((arg) =>
357
+ ["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) ||
358
+ arg.startsWith("+") ||
359
+ /^:[^:]+/.test(arg)
360
+ )) {
275
361
  return "destructive_git_force_push";
276
362
  }
277
363
  if (command.file === "gh" && command.args[0] === "repo" && command.args[1] === "delete") {
@@ -304,24 +390,33 @@ function protectedPathPolicy(command: HookCommand, protectedPaths: string[]): st
304
390
 
305
391
  function matchesHookAllowlist(command: HookCommand): boolean {
306
392
  const args = stripGitGlobalOptions(command.args);
393
+ if (command.file === "rg" && matchesRipgrepAllowlist(command.args) || isApplyPatchCommand(command)) {
394
+ return true;
395
+ }
307
396
  if (command.file === "git") {
308
397
  return args[0] === "status" ||
309
398
  args[0] === "branch" && args[1] === "--show-current" ||
310
399
  args[0] === "rev-parse" ||
311
400
  args[0] === "diff" ||
401
+ ["log", "show"].includes(args[0] ?? "") ||
402
+ args[0] === "grep" && matchesGitGrepAllowlist(args.slice(1)) ||
403
+ args[0] === "switch" && args.length === 2 && typeof args[1] === "string" && !args[1].startsWith("-") ||
312
404
  args[0] === "add" && args[1] === "--" ||
313
405
  args[0] === "commit" && args[1] === "-m" ||
314
- args[0] === "push" && args[1] === "-u";
406
+ args[0] === "push" && matchesGitPushAllowlist(args.slice(1));
315
407
  }
316
408
  if (command.file === "gh") {
317
409
  return command.args[0] === "auth" && command.args[1] === "status" ||
318
- command.args[0] === "pr" && ["list", "view"].includes(command.args[1] ?? "") ||
410
+ command.args[0] === "pr" && ["list", "view", "checks"].includes(command.args[1] ?? "") ||
411
+ command.args[0] === "pr" && command.args[1] === "merge" && matchesGhPrMergeAllowlist(command.args.slice(2)) ||
319
412
  command.args[0] === "api" && command.args[1] === "graphql";
320
413
  }
321
414
  if (command.file === "pnpm") {
322
415
  return command.args[0] === "test" ||
323
416
  command.args[0] === "lint" ||
324
- command.args[0] === "agent-loop" && ["status", "doctor", "logs"].includes(command.args[1] ?? "");
417
+ command.args[0] === "build:hooks" ||
418
+ command.args[0] === "build:mcp" ||
419
+ command.args[0] === "agent-loop" && matchesAgentLoopAllowlist(command.args.slice(1));
325
420
  }
326
421
  if (command.file === "npx") {
327
422
  return command.args[0] === "gitnexus" &&
@@ -333,6 +428,87 @@ function matchesHookAllowlist(command: HookCommand): boolean {
333
428
  return false;
334
429
  }
335
430
 
431
+ function matchesRipgrepAllowlist(args: string[]): boolean {
432
+ return !args.some((arg) => arg === "--pre" || arg.startsWith("--pre="));
433
+ }
434
+
435
+ function matchesGitGrepAllowlist(args: string[]): boolean {
436
+ return !args.some((arg) =>
437
+ arg === "-O" ||
438
+ arg.startsWith("-O") ||
439
+ arg === "--open-files-in-pager" ||
440
+ arg.startsWith("--open-files-in-pager=")
441
+ );
442
+ }
443
+
444
+ function matchesGitPushAllowlist(args: string[]): boolean {
445
+ return args.length >= 3 &&
446
+ args[0] === "-u" &&
447
+ args.every((arg) => !["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) && !arg.startsWith("+") && !/^:[^:]+/.test(arg));
448
+ }
449
+
450
+ function matchesGhPrMergeAllowlist(args: string[]): boolean {
451
+ const allowedFlags = new Set(["--merge", "--squash", "--rebase", "--body", "--subject"]);
452
+ for (let index = 0; index < args.length; index += 1) {
453
+ const arg = args[index] ?? "";
454
+ if (["--admin", "--auto", "--delete-branch", "-d"].includes(arg)) {
455
+ return false;
456
+ }
457
+ if (arg.startsWith("--") && !allowedFlags.has(arg)) {
458
+ return false;
459
+ }
460
+ if ((arg === "--body" || arg === "--subject") && args[index + 1]) {
461
+ index += 1;
462
+ }
463
+ }
464
+ return args.some((arg) => ["--merge", "--squash", "--rebase"].includes(arg));
465
+ }
466
+
467
+ function isApplyPatchCommand(command: HookCommand): boolean {
468
+ return command.file === "apply_patch" || command.raw?.startsWith("*** Begin Patch") === true;
469
+ }
470
+
471
+ function matchesAgentLoopAllowlist(args: string[]): boolean {
472
+ if (["status", "doctor", "logs", "observe", "timeline", "workers", "stop"].includes(args[0] ?? "")) {
473
+ return true;
474
+ }
475
+ if (args[0] === "local") {
476
+ return args[1] === "doctor";
477
+ }
478
+ if (args[0] === "hooks") {
479
+ return ["doctor", "list"].includes(args[1] ?? "");
480
+ }
481
+ if (args[0] === "delivery") {
482
+ return ["bind", "stage"].includes(args[1] ?? "");
483
+ }
484
+ if (args[0] === "evidence") {
485
+ return args[1] === "append";
486
+ }
487
+ if (args[0] === "maintainer-override") {
488
+ return args[1] === "approve";
489
+ }
490
+ return false;
491
+ }
492
+
493
+ function shellControlPolicy(command: HookCommand): string | undefined {
494
+ if (isApplyPatchCommand(command)) {
495
+ return undefined;
496
+ }
497
+ if (command.raw && hasShellControlOperator(command.raw)) {
498
+ return "shell_control_operator_forbidden";
499
+ }
500
+ if (command.file === "env") {
501
+ const index = command.args.findIndex((arg) => !arg.includes("="));
502
+ if (index >= 0) {
503
+ return shellControlPolicy({ file: basename(command.args[index] ?? ""), args: command.args.slice(index + 1) });
504
+ }
505
+ }
506
+ if ((command.file === "sh" || command.file === "bash") && command.args[0] === "-c" && command.args[1] && hasShellControlOperator(command.args[1])) {
507
+ return "shell_control_operator_forbidden";
508
+ }
509
+ return undefined;
510
+ }
511
+
336
512
  function deny(
337
513
  blockedCommand: string,
338
514
  matchedPolicy: string,
@@ -385,6 +561,10 @@ function tokenizeCommand(command: string): HookCommand {
385
561
  return { file: basename(file), args, raw: command };
386
562
  }
387
563
 
564
+ function hasShellControlOperator(value: string): boolean {
565
+ return /&&|\|\||[;|<>\n\r]/.test(value);
566
+ }
567
+
388
568
  function stripGitGlobalOptions(args: string[]): string[] {
389
569
  const result = [...args];
390
570
  while (result.length > 0) {
@@ -406,12 +586,35 @@ function stripGitGlobalOptions(args: string[]): string[] {
406
586
  return result;
407
587
  }
408
588
 
409
- function publishPrerequisitesSatisfied(storage: AgentLoopStorage): boolean {
410
- const run = storage.getCurrentRun();
589
+ function publishPrerequisitesSatisfied(storage: AgentLoopStorage, runId?: string): boolean {
590
+ const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
411
591
  if (!run) {
412
592
  return false;
413
593
  }
414
- return storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes");
594
+ if (storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes")) {
595
+ return true;
596
+ }
597
+ return publishWorkflowEvidenceSatisfied(storage, run.id);
598
+ }
599
+
600
+ function publishWorkflowEvidenceSatisfied(storage: AgentLoopStorage, runId: string): boolean {
601
+ const completed = new Set<string>();
602
+ for (const event of storage.listEvents(200)) {
603
+ const payload = objectDetails(event.payload);
604
+ if (
605
+ event.runId !== runId ||
606
+ event.kind !== "workflow_stage_evidence" ||
607
+ stringValue(payload?.stageId) !== "verify" ||
608
+ stringValue(payload?.status) !== "done"
609
+ ) {
610
+ continue;
611
+ }
612
+ const substageId = stringValue(payload?.substageId);
613
+ if (substageId) {
614
+ completed.add(substageId);
615
+ }
616
+ }
617
+ return REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES.every((substageId) => completed.has(substageId));
415
618
  }
416
619
 
417
620
  function basename(value: string): string {
@@ -421,3 +624,7 @@ function basename(value: string): string {
421
624
  function stringValue(value: unknown): string | undefined {
422
625
  return typeof value === "string" && value.length > 0 ? value : undefined;
423
626
  }
627
+
628
+ function objectDetails(value: unknown): Record<string, unknown> | undefined {
629
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
630
+ }
@@ -602,6 +602,9 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
602
602
  }
603
603
 
604
604
  const run = this.getRun(runId);
605
+ if (!run) {
606
+ throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
607
+ }
605
608
  this.db
606
609
  .prepare(
607
610
  `insert into states (run_id, status, state, version, payload_json, created_at)
@@ -1562,7 +1565,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
1562
1565
  return row.user_version;
1563
1566
  }
1564
1567
 
1565
- private getRun(runId: string): AgentLoopRun {
1568
+ getRun(runId: string): AgentLoopRun | undefined {
1566
1569
  const row = this.db
1567
1570
  .prepare(
1568
1571
  `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
@@ -1570,10 +1573,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
1570
1573
  where id = ?`
1571
1574
  )
1572
1575
  .get(runId) as RunRow | undefined;
1573
- if (!row) {
1574
- throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
1575
- }
1576
- return fromRunRow(row);
1576
+ return row ? fromRunRow(row) : undefined;
1577
1577
  }
1578
1578
 
1579
1579
  private getActiveRun(): AgentLoopRun | undefined {