| 130 | * at our gate, and only if no entry already references it. |
| 131 | */ |
| 132 | function mergeClaudeSettings(existing: string): string { |
| 133 | let parsed: Record<string, unknown>; |
| 134 | try { |
| 135 | parsed = JSON.parse(existing); |
| 136 | } catch { |
| 137 | fail( |
| 138 | `.claude/settings.json is not valid JSON; refusing to merge automatically. Fix or delete the file and re-run.` |
| 139 | ); |
| 140 | } |
| 141 | const hooks = (parsed.hooks ??= {}) as Record<string, unknown>; |
| 142 | const pre = (hooks.PreToolUse ??= []) as unknown[]; |
| 143 | const alreadyWired = pre.some((entry) => { |
| 144 | if (typeof entry !== "object" || entry === null) { |
| 145 | return false; |
| 146 | } |
| 147 | const candidate = entry as { hooks?: unknown[] }; |
| 148 | |
| 149 | return ( |
| 150 | Array.isArray(candidate.hooks) && |
| 151 | candidate.hooks.some((inner) => { |
| 152 | if (typeof inner !== "object" || inner === null) { |
| 153 | return false; |
| 154 | } |
| 155 | const command = (inner as { command?: unknown }).command; |
| 156 | |
| 157 | return ( |
| 158 | typeof command === "string" && command.includes("tools/spec-loop/hooks/gate") |
| 159 | ); |
| 160 | }) |
| 161 | ); |
| 162 | }); |
| 163 | |
| 164 | if (alreadyWired) { |
| 165 | return existing; |
| 166 | } |
| 167 | |
| 168 | pre.push({ |
| 169 | matcher: "Write|Edit|MultiEdit", |
| 170 | hooks: [ |
| 171 | { |
| 172 | type: "command", |
| 173 | command: "bun tools/spec-loop/hooks/gate.ts" |
| 174 | } |
| 175 | ] |
| 176 | }); |
| 177 | |
| 178 | return JSON.stringify(parsed, null, 2) + "\n"; |
| 179 | } |
| 180 | |
| 181 | const PLAN: IPlan[] = [ |
| 182 | { |