@nforma.ai/nforma 0.2.1

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 (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env node
2
+ // hooks/qgsd-circuit-breaker.js
3
+ // PreToolUse hook — oscillation detection, state persistence, and notification for circuit breaker.
4
+ //
5
+ // Reads JSON from stdin (Claude Code PreToolUse event payload), checks for oscillation
6
+ // in git history when Bash commands are executed, and persists breaker state across
7
+ // invocations. Non-blocking: all tool calls are allowed through; oscillation is reported
8
+ // as a priority warning via the hook output so Claude sees it without being hard-blocked.
9
+ //
10
+ // Config-driven defaults via loadConfig(gitRoot): oscillation_depth and commit_window
11
+ // State file: .claude/circuit-breaker-state.json (gitignored)
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const { spawnSync } = require('child_process');
19
+ const { loadConfig } = require('./config-loader');
20
+ const { schema_version } = require('./conformance-schema.cjs');
21
+
22
+ // Read-only command regex: git log/diff/diff-tree/status/show/blame, grep, cat, ls, head, tail, find
23
+ const READ_ONLY_REGEX = /^\s*(git\s+(log|diff|diff-tree|status|show|blame)|grep|cat\s|ls(\s|$)|head|tail|find)\s*/;
24
+
25
+ // Returns git root directory or null if not a git repo
26
+ function getGitRoot(cwd) {
27
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
28
+ cwd,
29
+ encoding: 'utf8',
30
+ timeout: 5000,
31
+ });
32
+ if (result.status !== 0 || result.error) return null;
33
+ return result.stdout.trim() || null;
34
+ }
35
+
36
+ // Reads existing state file, returns object or null
37
+ function readState(statePath) {
38
+ if (!fs.existsSync(statePath)) return null;
39
+ try {
40
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
41
+ } catch {
42
+ return null; // Malformed or error
43
+ }
44
+ }
45
+
46
+ // Returns true if command is read-only (skip detection on read-only commands)
47
+ function isReadOnly(command) {
48
+ return READ_ONLY_REGEX.test(command);
49
+ }
50
+
51
+ // Gets last N commit hashes via git log
52
+ function getCommitHashes(gitRoot, window) {
53
+ const result = spawnSync('git', ['log', `--format=%H`, `-${window}`], {
54
+ cwd: gitRoot,
55
+ encoding: 'utf8',
56
+ timeout: 5000,
57
+ });
58
+ if (result.status !== 0 || result.error) return [];
59
+ return result.stdout.trim().split('\n').filter(h => h.length > 0);
60
+ }
61
+
62
+ // Gets file sets for each commit hash using diff-tree.
63
+ // --root ensures root commits (no parent) also report their files.
64
+ function getCommitFileSets(gitRoot, hashes) {
65
+ const sets = [];
66
+ for (const hash of hashes) {
67
+ const result = spawnSync(
68
+ 'git',
69
+ ['diff-tree', '--no-commit-id', '-r', '--name-only', '--root', hash],
70
+ { cwd: gitRoot, encoding: 'utf8', timeout: 5000 }
71
+ );
72
+ if (result.status !== 0 || result.error) {
73
+ sets.push([]);
74
+ } else {
75
+ const files = result.stdout.trim().split('\n').filter(f => f.length > 0);
76
+ sets.push(files);
77
+ }
78
+ }
79
+ return sets;
80
+ }
81
+
82
+ // Gets the unified diff between two commits for a specific set of files.
83
+ // Returns the raw diff string, or empty string on error (fail-open).
84
+ // olderHash is the earlier commit, newerHash is the later commit (forward in time).
85
+ function getCommitDiff(gitRoot, olderHash, newerHash, files) {
86
+ const result = spawnSync(
87
+ 'git',
88
+ ['diff', olderHash, newerHash, '--', ...files],
89
+ { cwd: gitRoot, encoding: 'utf8', timeout: 5000 }
90
+ );
91
+ if (result.status !== 0 || result.error) return '';
92
+ return result.stdout || '';
93
+ }
94
+
95
+ // Second-pass reversion check: given the hashes (newest-first) belonging to
96
+ // run-groups for an oscillating file set key, and the files in that set,
97
+ // determines whether the pattern is true oscillation or TDD progression.
98
+ //
99
+ // Algorithm: sum net change (additions - deletions) across all consecutive pairs.
100
+ // - Positive total net change → file grew overall → TDD progression (not oscillation).
101
+ // - Zero or negative total net change → file didn't grow → true oscillation.
102
+ //
103
+ // This correctly handles TDD patterns where a line like `module.exports` is modified
104
+ // (1 deletion, 1 addition per commit) alongside net-new lines — the net change remains
105
+ // positive because new functions are added each time.
106
+ //
107
+ // For true oscillation (same content toggled back and forth), each pair is symmetric
108
+ // (same number added as removed) so the total net change is zero.
109
+ //
110
+ // hashes: all commit hashes (newest-first) in the oscillating run-groups
111
+ // files: file paths in the oscillating set
112
+ // gitRoot: git repository root
113
+ //
114
+ // Returns true if real oscillation (net change <= 0), false if TDD progression (net change > 0).
115
+ // Returns true also if ALL pairs errored out (git unavailable → fall back to original behavior).
116
+ function hasReversionInHashes(gitRoot, hashes, files) {
117
+ // hashes are newest-first; consecutive pairs: (hashes[i], hashes[i-1]) where
118
+ // hashes[i] is older (higher index = earlier in time), hashes[i-1] is newer.
119
+ // We diff older → newer: git diff <hashes[i]> <hashes[i-1]>
120
+ let totalNetChange = 0;
121
+ let errorsOnly = true;
122
+
123
+ for (let i = hashes.length - 1; i >= 1; i--) {
124
+ const olderHash = hashes[i];
125
+ const newerHash = hashes[i - 1];
126
+ const diff = getCommitDiff(gitRoot, olderHash, newerHash, files);
127
+
128
+ if (diff === '') {
129
+ // git error — skip this pair (fail-open for individual pair)
130
+ continue;
131
+ }
132
+
133
+ errorsOnly = false;
134
+
135
+ // Parse diff: count additions and deletions (excluding file header lines)
136
+ const lines = diff.split('\n');
137
+ let additions = 0;
138
+ let deletions = 0;
139
+ for (const line of lines) {
140
+ if (line.startsWith('---') || line.startsWith('+++')) continue;
141
+ if (line.startsWith('+')) additions++;
142
+ else if (line.startsWith('-')) deletions++;
143
+ }
144
+
145
+ totalNetChange += (additions - deletions);
146
+ }
147
+
148
+ // If all pairs errored out → fall back to original behavior (treat as oscillation)
149
+ if (errorsOnly) return true;
150
+
151
+ // Positive net change → file grew overall → TDD progression, not oscillation
152
+ // Zero or negative net change → file didn't grow → real oscillation
153
+ return totalNetChange <= 0;
154
+ }
155
+
156
+ // Detects true oscillation: returns { detected: bool, fileSet: string[] }
157
+ //
158
+ // Algorithm: collapse consecutive identical file sets into run-groups first,
159
+ // then count how many times each file set's group appears in the collapsed
160
+ // sequence. This correctly handles patterns like A A A B B A A B B B A A
161
+ // (3 A-groups, 2 B-groups → oscillation at depth 3) while ignoring simple
162
+ // iterative refinement like A A A (1 A-group → not oscillation).
163
+ //
164
+ // Second-pass reversion check: when a file set reaches >= depth run-groups,
165
+ // diff consecutive pairs to confirm content was actually reverted (net deletions).
166
+ // If all pairs are purely additive (TDD progression), do NOT flag as oscillation.
167
+ //
168
+ // hashes: commit hashes array (newest-first, same order as fileSets)
169
+ // gitRoot: git repository root (used for diff-based reversion check)
170
+ function detectOscillation(fileSets, depth, hashes, gitRoot) {
171
+ // Step 1: collapse consecutive identical file sets into runs, tracking indices
172
+ const runs = [];
173
+ for (let i = 0; i < fileSets.length; i++) {
174
+ const files = fileSets[i];
175
+ const key = files.slice().sort().join('\0');
176
+ if (runs.length === 0 || runs[runs.length - 1].key !== key) {
177
+ runs.push({ key, files, indices: [i] });
178
+ } else {
179
+ runs[runs.length - 1].indices.push(i);
180
+ }
181
+ }
182
+
183
+ // Step 2: count run-group occurrences per file set key, tracking which runs belong to each key
184
+ const keyRuns = new Map(); // key → array of run objects
185
+ for (const run of runs) {
186
+ if (!keyRuns.has(run.key)) keyRuns.set(run.key, []);
187
+ keyRuns.get(run.key).push(run);
188
+ }
189
+
190
+ // Step 3: any file set with >= depth run-groups is a candidate for oscillation
191
+ for (const [key, keyRunList] of keyRuns) {
192
+ if (keyRunList.length >= depth) {
193
+ const files = key.split('\0').filter(f => f.length > 0);
194
+
195
+ // Second-pass reversion check (if hashes and gitRoot provided)
196
+ if (hashes && gitRoot && hashes.length > 0) {
197
+ // Collect all hashes from the oscillating run-groups (newest-first order preserved)
198
+ const oscillatingHashes = [];
199
+ for (const run of keyRunList) {
200
+ for (const idx of run.indices) {
201
+ if (idx < hashes.length) oscillatingHashes.push(hashes[idx]);
202
+ }
203
+ }
204
+ // Sort by index position (newest-first as they appear in hashes array)
205
+ // The indices are already ordered since we iterate runs in order
206
+ const isRealOscillation = hasReversionInHashes(gitRoot, oscillatingHashes, files);
207
+ if (!isRealOscillation) {
208
+ // All additive → TDD progression, not a real loop
209
+ return { detected: false, fileSet: [] };
210
+ }
211
+ }
212
+
213
+ return { detected: true, fileSet: files };
214
+ }
215
+ }
216
+ return { detected: false, fileSet: [] };
217
+ }
218
+
219
+ // Consults Claude Haiku to verify whether detected oscillation is genuine
220
+ // (a real bug loop) or iterative refinement (the same files improved repeatedly).
221
+ // Returns 'GENUINE', 'REFINEMENT', or null if the API is unavailable.
222
+ async function consultHaiku(gitRoot, fileSet, fileSets, model) {
223
+ const apiKey = process.env.ANTHROPIC_API_KEY;
224
+ if (!apiKey) return null;
225
+
226
+ const logResult = spawnSync('git', ['log', '--oneline', '-10'], {
227
+ cwd: gitRoot, encoding: 'utf8', timeout: 5000,
228
+ });
229
+ const gitLog = logResult.stdout || '(unavailable)';
230
+
231
+ // Collect short diffs for the oscillating commits
232
+ const hashResult = spawnSync('git', ['log', '--format=%H', '-10'], {
233
+ cwd: gitRoot, encoding: 'utf8', timeout: 5000,
234
+ });
235
+ const hashes = (hashResult.stdout || '').trim().split('\n').filter(Boolean);
236
+ const diffs = [];
237
+ for (const hash of hashes.slice(0, 8)) {
238
+ const d = spawnSync('git', ['diff-tree', '-p', '--no-commit-id', '-r', hash], {
239
+ cwd: gitRoot, encoding: 'utf8', timeout: 5000,
240
+ });
241
+ if (d.stdout) diffs.push(`--- ${hash.slice(0, 7)} ---\n${d.stdout.slice(0, 800)}`);
242
+ }
243
+
244
+ const prompt =
245
+ `You are a circuit breaker analyzer for a coding agent. A potential oscillation pattern was detected.\n\n` +
246
+ `Oscillating file set: ${fileSet.join(', ')}\n\n` +
247
+ `Recent git log:\n${gitLog}\n\n` +
248
+ `Recent diffs (truncated):\n${diffs.join('\n\n').slice(0, 3000)}\n\n` +
249
+ `Question: Is this GENUINE oscillation (the same bug being introduced and fixed repeatedly, agent stuck in a loop) ` +
250
+ `or REFINEMENT (developer/agent iteratively improving the same files toward a clear goal, e.g. adjusting a banner message, tuning output)?\n\n` +
251
+ `Reply with exactly one word: GENUINE or REFINEMENT`;
252
+
253
+ const https = require('https');
254
+ const body = JSON.stringify({
255
+ model,
256
+ max_tokens: 10,
257
+ messages: [{ role: 'user', content: prompt }],
258
+ });
259
+
260
+ return new Promise((resolve) => {
261
+ const req = https.request({
262
+ hostname: 'api.anthropic.com',
263
+ path: '/v1/messages',
264
+ method: 'POST',
265
+ headers: {
266
+ 'Content-Type': 'application/json',
267
+ 'x-api-key': apiKey,
268
+ 'anthropic-version': '2023-06-01',
269
+ 'Content-Length': Buffer.byteLength(body),
270
+ },
271
+ timeout: 12000,
272
+ }, (res) => {
273
+ let data = '';
274
+ res.on('data', chunk => { data += chunk; });
275
+ res.on('end', () => {
276
+ try {
277
+ const parsed = JSON.parse(data);
278
+ const text = ((parsed.content || [])[0] || {}).text || '';
279
+ const verdict = text.trim().toUpperCase();
280
+ resolve(verdict.startsWith('REFINEMENT') ? 'REFINEMENT' : 'GENUINE');
281
+ } catch { resolve(null); }
282
+ });
283
+ });
284
+ req.on('error', () => resolve(null));
285
+ req.on('timeout', () => { req.destroy(); resolve(null); });
286
+ req.write(body);
287
+ req.end();
288
+ });
289
+ }
290
+
291
+ // Writes state file
292
+ function writeState(statePath, fileSet, snapshot) {
293
+ try {
294
+ const stateDir = path.dirname(statePath);
295
+ fs.mkdirSync(stateDir, { recursive: true });
296
+ const state = {
297
+ active: true,
298
+ file_set: fileSet,
299
+ activated_at: new Date().toISOString(),
300
+ commit_window_snapshot: snapshot
301
+ };
302
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
303
+ } catch (e) {
304
+ process.stderr.write(`[qgsd] WARNING: Could not write circuit breaker state: ${e.message}\n`);
305
+ // Fail-open: do not block execution
306
+ }
307
+ }
308
+
309
+ // Appends a false-negative entry to .claude/circuit-breaker-false-negatives.json
310
+ // for audit trail when Haiku classifies detected oscillation as REFINEMENT.
311
+ // Fail-open: any error is logged to stderr but does not block the tool call.
312
+ function appendFalseNegative(statePath, fileSet) {
313
+ try {
314
+ const fnLogPath = statePath.replace('circuit-breaker-state.json', 'circuit-breaker-false-negatives.json');
315
+ let existing = [];
316
+ if (fs.existsSync(fnLogPath)) {
317
+ try {
318
+ existing = JSON.parse(fs.readFileSync(fnLogPath, 'utf8'));
319
+ if (!Array.isArray(existing)) existing = [];
320
+ } catch {
321
+ existing = [];
322
+ }
323
+ }
324
+ existing.push({
325
+ detected_at: new Date().toISOString(),
326
+ file_set: fileSet,
327
+ reviewer: 'haiku',
328
+ verdict: 'REFINEMENT',
329
+ });
330
+ fs.writeFileSync(fnLogPath, JSON.stringify(existing, null, 2), 'utf8');
331
+ } catch (e) {
332
+ process.stderr.write(`[qgsd] WARNING: Could not write false-negative log: ${e.message}\n`);
333
+ // Fail-open: do not block execution
334
+ }
335
+ }
336
+
337
+ // Returns path to oscillation log file for the given git root
338
+ function getOscillationLogPath(gitRoot) {
339
+ return path.join(gitRoot, '.planning', 'oscillation-log.json');
340
+ }
341
+
342
+ // Reads oscillation log, returns {} on missing or parse error
343
+ function readOscillationLog(logPath) {
344
+ if (!fs.existsSync(logPath)) return {};
345
+ try { return JSON.parse(fs.readFileSync(logPath, 'utf8')); }
346
+ catch { return {}; }
347
+ }
348
+
349
+ // Writes oscillation log, fails open with stderr warning
350
+ function writeOscillationLog(logPath, log) {
351
+ try {
352
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
353
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2), 'utf8');
354
+ } catch (e) {
355
+ process.stderr.write(`[qgsd] WARNING: Could not write oscillation log: ${e.message}\n`);
356
+ }
357
+ }
358
+
359
+ // SHA-1 of sorted file list, 12 hex chars
360
+ function makeFileSetHash(files) {
361
+ return crypto.createHash('sha1')
362
+ .update(files.slice().sort().join('\0'))
363
+ .digest('hex').slice(0, 12);
364
+ }
365
+
366
+ // SHA-1 of run-group sequence (same collapse as detectOscillation step 1), 12 hex chars
367
+ function makePatternHash(fileSets) {
368
+ const runKeys = [];
369
+ for (const files of fileSets) {
370
+ const key = files.slice().sort().join('\0');
371
+ if (runKeys.length === 0 || runKeys[runKeys.length - 1] !== key) {
372
+ runKeys.push(key);
373
+ }
374
+ }
375
+ return crypto.createHash('sha1')
376
+ .update(runKeys.join('|'))
377
+ .digest('hex').slice(0, 12);
378
+ }
379
+
380
+ // Appends a structured conformance event to .planning/conformance-events.jsonl.
381
+ // Uses appendFileSync (atomic for writes < POSIX PIPE_BUF = 4096 bytes).
382
+ // Always wrapped in try/catch — hooks are fail-open; never crashes on logging failure.
383
+ // NEVER writes to stdout — stdout is the Claude Code hook decision channel.
384
+ function appendConformanceEvent(event) {
385
+ try {
386
+ const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
387
+ const logPath = pp.resolve(process.cwd(), 'conformance-events');
388
+ fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
389
+ } catch (err) {
390
+ process.stderr.write('[qgsd] conformance log write failed: ' + err.message + '\n');
391
+ }
392
+ }
393
+
394
+ // Builds the deny reason block for when the circuit breaker is active.
395
+ // Returns a message explaining the block and how to resolve it.
396
+ function buildBlockReason(state) {
397
+ const fileList = (state.file_set || []).join(', ') || '(unknown)';
398
+ const snapshot = state.commit_window_snapshot;
399
+ const lines = [
400
+ 'CIRCUIT BREAKER ACTIVE',
401
+ '',
402
+ 'Oscillating file set: ' + fileList,
403
+ '',
404
+ ];
405
+ if (Array.isArray(snapshot) && snapshot.length > 0) {
406
+ lines.push('Commit Graph (most recent first):');
407
+ lines.push('| # | Files Changed |');
408
+ lines.push('|---|---------------|');
409
+ snapshot.forEach((files, index) => {
410
+ const fileStr = Array.isArray(files) && files.length > 0 ? files.join(', ') : '(empty)';
411
+ lines.push(`| ${index + 1} | ${fileStr} |`);
412
+ });
413
+ lines.push('');
414
+ } else {
415
+ lines.push('(commit graph unavailable)');
416
+ lines.push('');
417
+ }
418
+ lines.push(
419
+ 'Invoke Oscillation Resolution Mode per R5 in CLAUDE.md — see qgsd-core/workflows/oscillation-resolution-mode.md for the full procedure.',
420
+ '',
421
+ 'Read-only operations are still allowed (e.g. git log --oneline to review the commit history).',
422
+ 'You must manually commit a root-cause fix before write operations are unblocked.',
423
+ '',
424
+ "After committing the fix, run 'npx qgsd --reset-breaker' to clear the circuit breaker state.",
425
+ );
426
+ return lines.join('\n');
427
+ }
428
+
429
+ // Builds the priority warning notice for the allow decision
430
+ // Returns a message Claude will see in the hook output (non-blocking notification)
431
+ function buildWarningNotice(state) {
432
+ const fileList = (state.file_set || []).join(', ') || '(unknown)';
433
+ const snapshot = state.commit_window_snapshot;
434
+ const lines = [
435
+ 'OSCILLATION DETECTED — PRIORITY NOTICE',
436
+ '',
437
+ 'Oscillating file set: ' + fileList,
438
+ '',
439
+ 'Fix the oscillation in the listed files before continuing.',
440
+ 'Run git log to see the pattern. Do NOT make more commits to these files until the root cause is resolved.',
441
+ '',
442
+ ];
443
+
444
+ if (Array.isArray(snapshot) && snapshot.length > 0) {
445
+ lines.push('Commit Graph (most recent first):');
446
+ lines.push('| # | Files Changed |');
447
+ lines.push('|---|---------------|');
448
+ snapshot.forEach((files, index) => {
449
+ const fileStr = Array.isArray(files) && files.length > 0 ? files.join(', ') : '(empty)';
450
+ lines.push(`| ${index + 1} | ${fileStr} |`);
451
+ });
452
+ lines.push('');
453
+ }
454
+
455
+ lines.push(
456
+ 'Invoke Oscillation Resolution Mode per R5 in CLAUDE.md — see qgsd-core/workflows/oscillation-resolution-mode.md for the full procedure.',
457
+ '',
458
+ 'After committing the fix, run \'npx qgsd --reset-breaker\' to clear the circuit breaker state.',
459
+ 'To temporarily disable the circuit breaker for deliberate iterative work, run \'npx qgsd --disable-breaker\'.',
460
+ 'Re-enable with \'npx qgsd --enable-breaker\' when done.'
461
+ );
462
+
463
+ return lines.join('\n');
464
+ }
465
+
466
+ function main() {
467
+ let raw = '';
468
+ process.stdin.setEncoding('utf8');
469
+ process.stdin.on('data', chunk => { raw += chunk; });
470
+ process.stdin.on('end', async () => {
471
+ try {
472
+ const input = JSON.parse(raw);
473
+ const cwd = input.cwd || process.cwd();
474
+
475
+ const hookEvent = input.hook_event_name || input.hookEventName || 'PreToolUse';
476
+ const toolName = input.tool_name || input.toolName || '';
477
+
478
+ // Get git root — shared by both handlers
479
+ const gitRoot = getGitRoot(cwd);
480
+ if (!gitRoot) {
481
+ process.exit(0); // DETECT-05: not a git repo
482
+ }
483
+
484
+ const config = loadConfig(gitRoot);
485
+ const logPath = getOscillationLogPath(gitRoot);
486
+
487
+ // ── PostToolUse: Haiku convergence check ─────────────────────────────
488
+ if (hookEvent === 'PostToolUse' && toolName === 'Bash') {
489
+ const log = readOscillationLog(logPath);
490
+ const activeKeys = Object.keys(log).filter(k => !log[k].resolvedAt);
491
+ if (activeKeys.length === 0) process.exit(0);
492
+
493
+ const toolOutput = (input.tool_response &&
494
+ (input.tool_response.output || input.tool_response.stdout)) || '';
495
+ const lastCommitResult = spawnSync('git', ['log', '--oneline', '-1'], {
496
+ cwd: gitRoot, encoding: 'utf8', timeout: 5000,
497
+ });
498
+ const lastCommit = (lastCommitResult.stdout || '').trim();
499
+
500
+ const apiKey = process.env.ANTHROPIC_API_KEY;
501
+ if (!apiKey) process.exit(0);
502
+
503
+ const activeEntry = log[activeKeys[0]];
504
+ const haikuPrompt =
505
+ `You are a circuit breaker monitor. An oscillation was detected on files: ${activeEntry.files.join(', ')}.\n\n` +
506
+ `A Bash command just completed. Output (truncated):\n${toolOutput.slice(0, 2000)}\n\n` +
507
+ `Last git commit: ${lastCommit}\n\n` +
508
+ `Does this output indicate the oscillation has been resolved (e.g. tests passing, fix committed)?\n` +
509
+ `Reply with exactly one word: YES or NO`;
510
+
511
+ const requestBody = JSON.stringify({
512
+ model: config.circuit_breaker.haiku_model,
513
+ max_tokens: 10,
514
+ messages: [{ role: 'user', content: haikuPrompt }],
515
+ });
516
+
517
+ const nodeScript = `
518
+ const https = require('https');
519
+ const body = process.env.HAIKU_BODY;
520
+ const apiKey = process.env.ANTHROPIC_API_KEY;
521
+ const req = https.request({
522
+ hostname: 'api.anthropic.com',
523
+ path: '/v1/messages',
524
+ method: 'POST',
525
+ headers: {
526
+ 'Content-Type': 'application/json',
527
+ 'x-api-key': apiKey,
528
+ 'anthropic-version': '2023-06-01',
529
+ 'Content-Length': Buffer.byteLength(body),
530
+ },
531
+ timeout: 12000,
532
+ }, (res) => {
533
+ let d = '';
534
+ res.on('data', c => { d += c; });
535
+ res.on('end', () => {
536
+ try {
537
+ const p = JSON.parse(d);
538
+ process.stdout.write(((p.content||[])[0]||{}).text||'NO');
539
+ } catch { process.stdout.write('NO'); }
540
+ });
541
+ });
542
+ req.on('error', () => process.stdout.write('NO'));
543
+ req.on('timeout', () => { req.destroy(); process.stdout.write('NO'); });
544
+ req.write(body);
545
+ req.end();
546
+ `;
547
+
548
+ try {
549
+ const spawnResult = spawnSync('node', ['-e', nodeScript], {
550
+ env: { ...process.env, HAIKU_BODY: requestBody },
551
+ encoding: 'utf8',
552
+ timeout: 15000,
553
+ });
554
+ const verdict = (spawnResult.stdout || '').trim().toUpperCase();
555
+
556
+ if (verdict.startsWith('YES')) {
557
+ const resolvedHashResult = spawnSync('git', ['log', '--format=%H', '-1'], {
558
+ cwd: gitRoot, encoding: 'utf8', timeout: 5000,
559
+ });
560
+ const resolvedCommit = (resolvedHashResult.stdout || '').trim() || null;
561
+ const now = new Date().toISOString();
562
+ for (const k of activeKeys) {
563
+ log[k].resolvedAt = now;
564
+ log[k].resolvedByCommit = resolvedCommit;
565
+ log[k].haikuRationale = `Haiku YES on Bash output; last commit: ${lastCommit}`;
566
+ }
567
+ writeOscillationLog(logPath, log);
568
+ // Clear state file so PreToolUse stops warning
569
+ const statePath = path.join(gitRoot, '.claude', 'circuit-breaker-state.json');
570
+ try { if (fs.existsSync(statePath)) fs.rmSync(statePath); } catch {}
571
+ process.stderr.write(`[qgsd] INFO: Oscillation resolved by Haiku — circuit breaker cleared.\n`);
572
+ }
573
+ } catch (e) {
574
+ process.stderr.write(`[qgsd] WARNING: PostToolUse Haiku check failed: ${e.message}\n`);
575
+ }
576
+ process.exit(0);
577
+ }
578
+
579
+ // ── PreToolUse: oscillation detection + notification ─────────────────
580
+ const command = (input.tool_input && input.tool_input.command) || '';
581
+
582
+ // Check existing state
583
+ const statePath = path.join(gitRoot, '.claude', 'circuit-breaker-state.json');
584
+ const state = readState(statePath);
585
+
586
+ // DISABLE-01: If circuit breaker is disabled, skip all detection and notification
587
+ if (state && state.disabled) {
588
+ process.exit(0);
589
+ }
590
+
591
+ // DETECT-04: Skip detection for read-only commands (BEFORE active state check)
592
+ if (isReadOnly(command)) {
593
+ process.exit(0);
594
+ }
595
+
596
+ if (state && state.active) {
597
+ // Check if already resolved in log
598
+ const fileSetHash = makeFileSetHash(state.file_set || []);
599
+ const logKey = `${fileSetHash}:legacy`;
600
+ const log = readOscillationLog(logPath);
601
+ if (log[logKey] && log[logKey].resolvedAt) {
602
+ process.exit(0); // Already resolved
603
+ }
604
+ // Breaker already active — emit deny decision
605
+ process.stdout.write(JSON.stringify({
606
+ hookSpecificOutput: {
607
+ hookEventName: 'PreToolUse',
608
+ permissionDecision: 'deny',
609
+ permissionDecisionReason: buildBlockReason(state),
610
+ }
611
+ }));
612
+ process.exit(0);
613
+ }
614
+
615
+ const hashes = getCommitHashes(gitRoot, config.circuit_breaker.commit_window);
616
+ const fileSets = getCommitFileSets(gitRoot, hashes);
617
+
618
+ // Detect oscillation
619
+ const result = detectOscillation(fileSets, config.circuit_breaker.oscillation_depth, hashes, gitRoot);
620
+ if (!result.detected) {
621
+ process.exit(0);
622
+ }
623
+
624
+ // HAIKU-01: Consult Haiku to verify before notifying (if enabled)
625
+ if (config.circuit_breaker.haiku_reviewer) {
626
+ const verdict = await consultHaiku(gitRoot, result.fileSet, fileSets, config.circuit_breaker.haiku_model);
627
+ if (verdict === 'REFINEMENT') {
628
+ // Haiku confirmed this is iterative refinement, not a bug loop — do not notify.
629
+ // Log false-negative for auditability (stderr + persistent file).
630
+ process.stderr.write(`[qgsd] INFO: circuit breaker false-negative — Haiku classified oscillation as REFINEMENT (files: ${result.fileSet.join(', ')}). Allowing tool call to proceed.\n`);
631
+ appendFalseNegative(statePath, result.fileSet);
632
+ process.exit(0);
633
+ }
634
+ // verdict === 'GENUINE' or null (API unavailable) → trust the algorithm and notify
635
+ }
636
+
637
+ // Log-based suppression: if this exact oscillation was already resolved, skip
638
+ const fileSetHash = makeFileSetHash(result.fileSet);
639
+ const patternHash = makePatternHash(fileSets);
640
+ const logKey = `${fileSetHash}:${patternHash}`;
641
+ const oscLog = readOscillationLog(logPath);
642
+ if (oscLog[logKey] && oscLog[logKey].resolvedAt) {
643
+ // Already resolved — suppress warning entirely
644
+ process.exit(0);
645
+ }
646
+ // Upsert log entry
647
+ oscLog[logKey] = {
648
+ files: result.fileSet.slice().sort(),
649
+ pattern: fileSets.map(s => s.slice().sort().join(',')).join(' | '),
650
+ firstSeen: (oscLog[logKey] && oscLog[logKey].firstSeen) ? oscLog[logKey].firstSeen : new Date().toISOString(),
651
+ lastSeen: new Date().toISOString(),
652
+ resolvedAt: null,
653
+ resolvedByCommit: null,
654
+ haikuRationale: null,
655
+ manualResetAt: (oscLog[logKey] && oscLog[logKey].manualResetAt) ? oscLog[logKey].manualResetAt : null,
656
+ };
657
+ writeOscillationLog(logPath, oscLog);
658
+
659
+ // Write state so qgsd-prompt.js picks it up on next user message
660
+ writeState(statePath, result.fileSet, fileSets);
661
+
662
+ appendConformanceEvent({
663
+ ts: new Date().toISOString(),
664
+ phase: 'IDLE',
665
+ action: 'circuit_break',
666
+ slots_available: 0,
667
+ vote_result: null,
668
+ outcome: 'BLOCK',
669
+ schema_version,
670
+ });
671
+
672
+ // State written — exit silently on first detection (warning emitted on next call via active state path)
673
+ process.exit(0);
674
+ } catch {
675
+ process.exit(0); // Fail-open on any error
676
+ }
677
+ });
678
+ }
679
+
680
+ if (require.main === module) main();
681
+
682
+ module.exports = { buildWarningNotice, buildBlockReason };