@nforma.ai/nforma 0.2.1 → 0.28.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 (201) hide show
  1. package/README.md +2 -2
  2. package/agents/{qgsd-codebase-mapper.md → nf-codebase-mapper.md} +1 -1
  3. package/agents/{qgsd-debugger.md → nf-debugger.md} +3 -3
  4. package/agents/{qgsd-executor.md → nf-executor.md} +14 -14
  5. package/agents/{qgsd-integration-checker.md → nf-integration-checker.md} +1 -1
  6. package/agents/{qgsd-phase-researcher.md → nf-phase-researcher.md} +6 -6
  7. package/agents/{qgsd-plan-checker.md → nf-plan-checker.md} +9 -9
  8. package/agents/{qgsd-planner.md → nf-planner.md} +9 -9
  9. package/agents/{qgsd-project-researcher.md → nf-project-researcher.md} +2 -2
  10. package/agents/{qgsd-quorum-orchestrator.md → nf-quorum-orchestrator.md} +33 -33
  11. package/agents/{qgsd-quorum-slot-worker.md → nf-quorum-slot-worker.md} +3 -3
  12. package/agents/{qgsd-quorum-synthesizer.md → nf-quorum-synthesizer.md} +3 -3
  13. package/agents/{qgsd-quorum-test-worker.md → nf-quorum-test-worker.md} +1 -1
  14. package/agents/{qgsd-quorum-worker.md → nf-quorum-worker.md} +6 -6
  15. package/agents/{qgsd-research-synthesizer.md → nf-research-synthesizer.md} +5 -5
  16. package/agents/{qgsd-roadmapper.md → nf-roadmapper.md} +3 -3
  17. package/agents/{qgsd-verifier.md → nf-verifier.md} +8 -8
  18. package/bin/accept-debug-invariant.cjs +2 -2
  19. package/bin/account-manager.cjs +10 -10
  20. package/bin/aggregate-requirements.cjs +1 -1
  21. package/bin/analyze-assumptions.cjs +3 -3
  22. package/bin/analyze-state-space.cjs +14 -14
  23. package/bin/assumption-register.cjs +146 -0
  24. package/bin/attribute-trace-divergence.cjs +1 -1
  25. package/bin/auth-drivers/gh-cli.cjs +1 -1
  26. package/bin/auth-drivers/pool.cjs +1 -1
  27. package/bin/autoClosePtoF.cjs +3 -3
  28. package/bin/budget-tracker.cjs +77 -0
  29. package/bin/build-layer-manifest.cjs +153 -0
  30. package/bin/call-quorum-slot.cjs +3 -3
  31. package/bin/ccr-secure-config.cjs +5 -5
  32. package/bin/check-bundled-sdks.cjs +1 -1
  33. package/bin/check-mcp-health.cjs +1 -1
  34. package/bin/check-provider-health.cjs +6 -6
  35. package/bin/check-spec-sync.cjs +26 -26
  36. package/bin/check-trace-schema-drift.cjs +5 -5
  37. package/bin/conformance-schema.cjs +2 -2
  38. package/bin/cross-layer-dashboard.cjs +297 -0
  39. package/bin/design-impact.cjs +377 -0
  40. package/bin/detect-coverage-gaps.cjs +7 -7
  41. package/bin/failure-mode-catalog.cjs +227 -0
  42. package/bin/failure-taxonomy.cjs +177 -0
  43. package/bin/formal-scope-scan.cjs +179 -0
  44. package/bin/gate-a-grounding.cjs +334 -0
  45. package/bin/gate-b-abstraction.cjs +243 -0
  46. package/bin/gate-c-validation.cjs +166 -0
  47. package/bin/generate-formal-specs.cjs +17 -17
  48. package/bin/generate-petri-net.cjs +3 -3
  49. package/bin/generate-tla-cfg.cjs +5 -5
  50. package/bin/git-heatmap.cjs +571 -0
  51. package/bin/harness-diagnostic.cjs +326 -0
  52. package/bin/hazard-model.cjs +261 -0
  53. package/bin/install-formal-tools.cjs +1 -1
  54. package/bin/install.js +184 -139
  55. package/bin/instrumentation-map.cjs +178 -0
  56. package/bin/invariant-catalog.cjs +437 -0
  57. package/bin/issue-classifier.cjs +2 -2
  58. package/bin/load-baseline-requirements.cjs +4 -4
  59. package/bin/manage-agents-core.cjs +32 -32
  60. package/bin/migrate-to-slots.cjs +39 -39
  61. package/bin/mismatch-register.cjs +217 -0
  62. package/bin/nForma.cjs +176 -81
  63. package/bin/{qgsd-solve.cjs → nf-solve.cjs} +327 -14
  64. package/bin/observe-config.cjs +8 -0
  65. package/bin/observe-debt-writer.cjs +1 -1
  66. package/bin/observe-handler-deps.cjs +356 -0
  67. package/bin/observe-handler-grafana.cjs +2 -17
  68. package/bin/observe-handler-internal.cjs +5 -5
  69. package/bin/observe-handler-logstash.cjs +2 -17
  70. package/bin/observe-handler-prometheus.cjs +2 -17
  71. package/bin/observe-handler-upstream.cjs +251 -0
  72. package/bin/observe-handlers.cjs +12 -33
  73. package/bin/observe-render.cjs +68 -22
  74. package/bin/observe-utils.cjs +37 -0
  75. package/bin/observed-fsm.cjs +324 -0
  76. package/bin/planning-paths.cjs +6 -0
  77. package/bin/polyrepo.cjs +1 -1
  78. package/bin/probe-quorum-slots.cjs +1 -1
  79. package/bin/promote-gate-maturity.cjs +274 -0
  80. package/bin/promote-model.cjs +1 -1
  81. package/bin/propose-debug-invariants.cjs +1 -1
  82. package/bin/quorum-cache.cjs +144 -0
  83. package/bin/quorum-consensus-gate.cjs +1 -1
  84. package/bin/quorum-slot-dispatch.cjs +6 -6
  85. package/bin/requirements-core.cjs +1 -1
  86. package/bin/review-mcp-logs.cjs +1 -1
  87. package/bin/risk-heatmap.cjs +151 -0
  88. package/bin/run-account-manager-tlc.cjs +4 -4
  89. package/bin/run-account-pool-alloy.cjs +2 -2
  90. package/bin/run-alloy.cjs +2 -2
  91. package/bin/run-audit-alloy.cjs +2 -2
  92. package/bin/run-breaker-tlc.cjs +3 -3
  93. package/bin/run-formal-check.cjs +9 -9
  94. package/bin/run-formal-verify.cjs +30 -9
  95. package/bin/run-installer-alloy.cjs +2 -2
  96. package/bin/run-oscillation-tlc.cjs +4 -4
  97. package/bin/run-phase-tlc.cjs +1 -1
  98. package/bin/run-protocol-tlc.cjs +4 -4
  99. package/bin/run-quorum-composition-alloy.cjs +2 -2
  100. package/bin/run-sensitivity-sweep.cjs +2 -2
  101. package/bin/run-stop-hook-tlc.cjs +3 -3
  102. package/bin/run-tlc.cjs +21 -21
  103. package/bin/run-transcript-alloy.cjs +2 -2
  104. package/bin/secrets.cjs +5 -5
  105. package/bin/security-sweep.cjs +238 -0
  106. package/bin/sensitivity-report.cjs +3 -3
  107. package/bin/set-secret.cjs +5 -5
  108. package/bin/setup-telemetry-cron.sh +3 -3
  109. package/bin/stall-detector.cjs +126 -0
  110. package/bin/state-candidates.cjs +206 -0
  111. package/bin/sync-baseline-requirements.cjs +1 -1
  112. package/bin/telemetry-collector.cjs +1 -1
  113. package/bin/test-changed.cjs +111 -0
  114. package/bin/test-recipe-gen.cjs +250 -0
  115. package/bin/trace-corpus-stats.cjs +211 -0
  116. package/bin/unified-mcp-server.mjs +3 -3
  117. package/bin/update-scoreboard.cjs +1 -1
  118. package/bin/validate-memory.cjs +2 -2
  119. package/bin/validate-traces.cjs +10 -10
  120. package/bin/verify-quorum-health.cjs +66 -5
  121. package/bin/xstate-to-tla.cjs +4 -4
  122. package/bin/xstate-trace-walker.cjs +3 -3
  123. package/commands/{qgsd → nf}/add-phase.md +3 -3
  124. package/commands/{qgsd → nf}/add-requirement.md +3 -3
  125. package/commands/{qgsd → nf}/add-todo.md +3 -3
  126. package/commands/{qgsd → nf}/audit-milestone.md +4 -4
  127. package/commands/{qgsd → nf}/check-todos.md +3 -3
  128. package/commands/{qgsd → nf}/cleanup.md +3 -3
  129. package/commands/{qgsd → nf}/close-formal-gaps.md +2 -2
  130. package/commands/{qgsd → nf}/complete-milestone.md +9 -9
  131. package/commands/{qgsd → nf}/debug.md +9 -9
  132. package/commands/{qgsd → nf}/discuss-phase.md +3 -3
  133. package/commands/{qgsd → nf}/execute-phase.md +15 -15
  134. package/commands/{qgsd → nf}/fix-tests.md +3 -3
  135. package/commands/{qgsd → nf}/formal-test-sync.md +1 -1
  136. package/commands/{qgsd → nf}/health.md +3 -3
  137. package/commands/{qgsd → nf}/help.md +3 -3
  138. package/commands/{qgsd → nf}/insert-phase.md +3 -3
  139. package/commands/nf/join-discord.md +18 -0
  140. package/commands/{qgsd → nf}/list-phase-assumptions.md +2 -2
  141. package/commands/{qgsd → nf}/map-codebase.md +7 -7
  142. package/commands/{qgsd → nf}/map-requirements.md +3 -3
  143. package/commands/{qgsd → nf}/mcp-restart.md +3 -3
  144. package/commands/{qgsd → nf}/mcp-set-model.md +8 -8
  145. package/commands/{qgsd → nf}/mcp-setup.md +63 -63
  146. package/commands/{qgsd → nf}/mcp-status.md +3 -3
  147. package/commands/{qgsd → nf}/mcp-update.md +7 -7
  148. package/commands/{qgsd → nf}/new-milestone.md +8 -8
  149. package/commands/{qgsd → nf}/new-project.md +8 -8
  150. package/commands/{qgsd → nf}/observe.md +49 -16
  151. package/commands/{qgsd → nf}/pause-work.md +3 -3
  152. package/commands/{qgsd → nf}/plan-milestone-gaps.md +5 -5
  153. package/commands/{qgsd → nf}/plan-phase.md +6 -6
  154. package/commands/{qgsd → nf}/polyrepo.md +2 -2
  155. package/commands/{qgsd → nf}/progress.md +3 -3
  156. package/commands/{qgsd → nf}/queue.md +2 -2
  157. package/commands/{qgsd → nf}/quick.md +8 -8
  158. package/commands/{qgsd → nf}/quorum-test.md +10 -10
  159. package/commands/{qgsd → nf}/quorum.md +40 -40
  160. package/commands/{qgsd → nf}/reapply-patches.md +2 -2
  161. package/commands/{qgsd → nf}/remove-phase.md +3 -3
  162. package/commands/{qgsd → nf}/research-phase.md +12 -12
  163. package/commands/{qgsd → nf}/resume-work.md +3 -3
  164. package/commands/nf/review-requirements.md +31 -0
  165. package/commands/{qgsd → nf}/set-profile.md +3 -3
  166. package/commands/{qgsd → nf}/settings.md +6 -6
  167. package/commands/{qgsd → nf}/solve.md +35 -35
  168. package/commands/{qgsd → nf}/sync-baselines.md +4 -4
  169. package/commands/{qgsd → nf}/triage.md +10 -10
  170. package/commands/{qgsd → nf}/update.md +3 -3
  171. package/commands/{qgsd → nf}/verify-work.md +5 -5
  172. package/hooks/dist/config-loader.js +188 -32
  173. package/hooks/dist/conformance-schema.cjs +2 -2
  174. package/hooks/dist/gsd-context-monitor.js +118 -13
  175. package/hooks/dist/{qgsd-check-update.js → nf-check-update.js} +5 -5
  176. package/hooks/dist/{qgsd-circuit-breaker.js → nf-circuit-breaker.js} +35 -24
  177. package/hooks/dist/nf-circuit-breaker.test.js +1002 -0
  178. package/hooks/dist/{qgsd-precompact.js → nf-precompact.js} +13 -13
  179. package/hooks/dist/nf-precompact.test.js +227 -0
  180. package/hooks/dist/{qgsd-prompt.js → nf-prompt.js} +110 -33
  181. package/hooks/dist/nf-prompt.test.js +698 -0
  182. package/hooks/dist/nf-session-start.js +185 -0
  183. package/hooks/dist/nf-session-start.test.js +354 -0
  184. package/hooks/dist/{qgsd-slot-correlator.js → nf-slot-correlator.js} +13 -5
  185. package/hooks/dist/nf-slot-correlator.test.js +85 -0
  186. package/hooks/dist/{qgsd-spec-regen.js → nf-spec-regen.js} +17 -8
  187. package/hooks/dist/nf-spec-regen.test.js +73 -0
  188. package/hooks/dist/{qgsd-statusline.js → nf-statusline.js} +12 -3
  189. package/hooks/dist/nf-statusline.test.js +157 -0
  190. package/hooks/dist/{qgsd-stop.js → nf-stop.js} +152 -18
  191. package/hooks/dist/nf-stop.test.js +1388 -0
  192. package/hooks/dist/{qgsd-token-collector.js → nf-token-collector.js} +12 -4
  193. package/hooks/dist/nf-token-collector.test.js +262 -0
  194. package/hooks/dist/unified-mcp-server.mjs +2 -2
  195. package/package.json +4 -4
  196. package/scripts/build-hooks.js +13 -6
  197. package/scripts/secret-audit.sh +1 -1
  198. package/scripts/verify-hooks-sync.cjs +90 -0
  199. package/templates/{qgsd.json → nf.json} +4 -4
  200. package/commands/qgsd/join-discord.md +0 -18
  201. package/hooks/dist/qgsd-session-start.js +0 -122
@@ -0,0 +1,1002 @@
1
+ #!/usr/bin/env node
2
+ // Test suite for hooks/nf-circuit-breaker.js
3
+ // Uses Node.js built-in test runner: node --test hooks/nf-circuit-breaker.test.js
4
+ //
5
+ // Each test spawns the hook as a child process with mock stdin and captures stdout + exit code.
6
+ // For git-dependent tests, creates temp git repos with controlled commits.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const { spawnSync } = require('child_process');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+
15
+ const HOOK_PATH = path.join(__dirname, 'nf-circuit-breaker.js');
16
+
17
+ // Helper: write a temp JSONL file and return its path (though not used in circuit breaker tests)
18
+ function writeTempTranscript(lines) {
19
+ const tmpFile = path.join(os.tmpdir(), `nf-circuit-breaker-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
20
+ fs.writeFileSync(tmpFile, lines.join('\n') + '\n', 'utf8');
21
+ return tmpFile;
22
+ }
23
+
24
+ // Helper: run the hook with a given stdin JSON payload, return { stdout, exitCode, stderr }
25
+ function runHook(stdinPayload) {
26
+ const result = spawnSync('node', [HOOK_PATH], {
27
+ input: JSON.stringify(stdinPayload),
28
+ encoding: 'utf8',
29
+ timeout: 5000,
30
+ });
31
+ return {
32
+ stdout: result.stdout || '',
33
+ stderr: result.stderr || '',
34
+ exitCode: result.status,
35
+ };
36
+ }
37
+
38
+ // Helper: create a temp git repo with controlled commits
39
+ function createTempGitRepo() {
40
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-circuit-breaker-git-'));
41
+ const git = (cmd) => spawnSync('git', cmd.split(' '), { cwd: tempDir, encoding: 'utf8' });
42
+
43
+ // Initialize repo and configure
44
+ git('init');
45
+ git('config user.name "Test User"');
46
+ git('config user.email "test@example.com"');
47
+
48
+ return tempDir;
49
+ }
50
+
51
+ // Helper: make a commit in the temp repo
52
+ function commitInRepo(repoDir, fileName, content, message) {
53
+ const filePath = path.join(repoDir, fileName);
54
+ fs.writeFileSync(filePath, content, 'utf8');
55
+ spawnSync('git', ['add', fileName], { cwd: repoDir, encoding: 'utf8' });
56
+ spawnSync('git', ['commit', '-m', message], { cwd: repoDir, encoding: 'utf8' });
57
+ }
58
+
59
+ // Helper: create multiple commits with same file set for oscillation testing
60
+ function createOscillationCommits(repoDir, fileSet, commitCount) {
61
+ for (let i = 0; i < commitCount; i++) {
62
+ fileSet.forEach(file => {
63
+ fs.writeFileSync(path.join(repoDir, file), `content ${i}`, 'utf8');
64
+ });
65
+ spawnSync('git', ['add', '.'], { cwd: repoDir, encoding: 'utf8' });
66
+ spawnSync('git', ['commit', '-m', `commit ${i}`], { cwd: repoDir, encoding: 'utf8' });
67
+ }
68
+ }
69
+
70
+ // Helper: create true alternating oscillation commits: A-group, B-group, A-group, ...
71
+ // Each "group" is a single commit to fileSetA; between groups a different file (filler_N.txt) is committed.
72
+ // depth controls how many A-groups are created, producing depth-1 B-groups between them.
73
+ // Example: createAlternatingCommits(repo, ['app.js'], 3) → app.js, filler_0.txt, app.js, filler_1.txt, app.js
74
+ //
75
+ // Content alternates between 1 line (even i) and 2 lines (odd i) to produce
76
+ // at least one pair with negative net change — required by the hasNegativePair
77
+ // reversion check to distinguish true oscillation from monotonic substitution workflows.
78
+ function createAlternatingCommits(repoDir, fileSetA, depth) {
79
+ for (let i = 0; i < depth; i++) {
80
+ // Commit to fileSetA — alternate line count to create true oscillation signal
81
+ fileSetA.forEach(file => {
82
+ const content = i % 2 === 0
83
+ ? `state-a-${i}\n`
84
+ : `state-b-${i}\noscillation-extra\n`;
85
+ fs.writeFileSync(path.join(repoDir, file), content, 'utf8');
86
+ });
87
+ spawnSync('git', ['add', '.'], { cwd: repoDir, encoding: 'utf8' });
88
+ spawnSync('git', ['commit', '-m', `a-group ${i}`], { cwd: repoDir, encoding: 'utf8' });
89
+
90
+ // Commit to a different file between A-groups (except after last A-group)
91
+ if (i < depth - 1) {
92
+ const filler = `filler_${i}.txt`;
93
+ fs.writeFileSync(path.join(repoDir, filler), `filler ${i}`, 'utf8');
94
+ spawnSync('git', ['add', filler], { cwd: repoDir, encoding: 'utf8' });
95
+ spawnSync('git', ['commit', '-m', `b-group ${i}`], { cwd: repoDir, encoding: 'utf8' });
96
+ }
97
+ }
98
+ }
99
+
100
+ // Helper: create commits with different file sets (no oscillation)
101
+ function createNonOscillationCommits(repoDir, commitCount) {
102
+ for (let i = 0; i < commitCount; i++) {
103
+ const fileName = `file${i}.txt`;
104
+ fs.writeFileSync(path.join(repoDir, fileName), `content ${i}`, 'utf8');
105
+ spawnSync('git', ['add', fileName], { cwd: repoDir, encoding: 'utf8' });
106
+ spawnSync('git', ['commit', '-m', `commit ${i}`], { cwd: repoDir, encoding: 'utf8' });
107
+ }
108
+ }
109
+
110
+ // --- Test Cases ---
111
+
112
+ // Test CB-TC1: No git repo in cwd → exit 0, stdout empty (DETECT-05)
113
+ // @requirement DETECT-05
114
+ test('CB-TC1: No git repo in cwd exits 0 with no output', () => {
115
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-no-git-'));
116
+ try {
117
+ const { stdout, exitCode } = runHook({
118
+ tool_name: 'Bash',
119
+ tool_input: { command: 'echo hello', description: 'test', timeout: 5000 },
120
+ cwd: tempDir,
121
+ hook_event_name: 'PreToolUse',
122
+ tool_use_id: 'test-id',
123
+ session_id: 'test-session',
124
+ transcript_path: '/tmp/test.jsonl',
125
+ permission_mode: 'default',
126
+ });
127
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
128
+ assert.strictEqual(stdout, '', 'stdout must be empty (DETECT-05)');
129
+ } finally {
130
+ fs.rmSync(tempDir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ // Test CB-TC2: Read-only command 'git log -n 10' → exit 0, stdout empty (DETECT-04)
135
+ test('CB-TC2: Read-only git log command passes without detection', () => {
136
+ const repoDir = createTempGitRepo();
137
+ try {
138
+ commitInRepo(repoDir, 'test.txt', 'content', 'initial commit');
139
+ const { stdout, exitCode } = runHook({
140
+ tool_name: 'Bash',
141
+ tool_input: { command: 'git log -n 10', description: 'test', timeout: 5000 },
142
+ cwd: repoDir,
143
+ hook_event_name: 'PreToolUse',
144
+ tool_use_id: 'test-id',
145
+ session_id: 'test-session',
146
+ transcript_path: '/tmp/test.jsonl',
147
+ permission_mode: 'default',
148
+ });
149
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
150
+ assert.strictEqual(stdout, '', 'stdout must be empty (DETECT-04)');
151
+ } finally {
152
+ fs.rmSync(repoDir, { recursive: true, force: true });
153
+ }
154
+ });
155
+
156
+ // Test CB-TC3: Read-only command 'grep -r "foo" .' → exit 0 (DETECT-04)
157
+ test('CB-TC3: Read-only grep command passes without detection', () => {
158
+ const repoDir = createTempGitRepo();
159
+ try {
160
+ commitInRepo(repoDir, 'test.txt', 'content', 'initial commit');
161
+ const { stdout, exitCode } = runHook({
162
+ tool_name: 'Bash',
163
+ tool_input: { command: 'grep -r "foo" .', description: 'test', timeout: 5000 },
164
+ cwd: repoDir,
165
+ hook_event_name: 'PreToolUse',
166
+ tool_use_id: 'test-id',
167
+ session_id: 'test-session',
168
+ transcript_path: '/tmp/test.jsonl',
169
+ permission_mode: 'default',
170
+ });
171
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
172
+ assert.strictEqual(stdout, '', 'stdout must be empty (DETECT-04)');
173
+ } finally {
174
+ fs.rmSync(repoDir, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ // Test CB-TC4: Read-only command bare 'ls' → exit 0 (DETECT-04)
179
+ test('CB-TC4: Read-only bare ls command passes without detection', () => {
180
+ const repoDir = createTempGitRepo();
181
+ try {
182
+ commitInRepo(repoDir, 'test.txt', 'content', 'initial commit');
183
+ const { stdout, exitCode } = runHook({
184
+ tool_name: 'Bash',
185
+ tool_input: { command: 'ls', description: 'test', timeout: 5000 },
186
+ cwd: repoDir,
187
+ hook_event_name: 'PreToolUse',
188
+ tool_use_id: 'test-id',
189
+ session_id: 'test-session',
190
+ transcript_path: '/tmp/test.jsonl',
191
+ permission_mode: 'default',
192
+ });
193
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
194
+ assert.strictEqual(stdout, '', 'stdout must be empty (DETECT-04)');
195
+ } finally {
196
+ fs.rmSync(repoDir, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ // Test CB-TC5: Write command, no state, fewer than depth commits with same file set → exit 0, no state written
201
+ test('CB-TC5: Write command with insufficient oscillation passes without state write', () => {
202
+ const repoDir = createTempGitRepo();
203
+ try {
204
+ // Create 2 commits with same file set (less than depth=3)
205
+ createOscillationCommits(repoDir, ['file1.txt', 'file2.txt'], 2);
206
+ const { stdout, exitCode } = runHook({
207
+ tool_name: 'Bash',
208
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
209
+ cwd: repoDir,
210
+ hook_event_name: 'PreToolUse',
211
+ tool_use_id: 'test-id',
212
+ session_id: 'test-session',
213
+ transcript_path: '/tmp/test.jsonl',
214
+ permission_mode: 'default',
215
+ });
216
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
217
+ assert.strictEqual(stdout, '', 'stdout must be empty');
218
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
219
+ assert(!fs.existsSync(statePath), 'state file should not be written');
220
+ } finally {
221
+ fs.rmSync(repoDir, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ // Test CB-TC6: Write command, no state, true A→B→A oscillation at depth=3 → exit 0, state written active:true
226
+ test('CB-TC6: Write command with exact oscillation depth triggers state write', () => {
227
+ const repoDir = createTempGitRepo();
228
+ try {
229
+ // Create true alternating oscillation: A,B,A,B,A (3 A-groups = depth 3)
230
+ createAlternatingCommits(repoDir, ['file1.txt', 'file2.txt'], 3);
231
+ const { stdout, exitCode } = runHook({
232
+ tool_name: 'Bash',
233
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
234
+ cwd: repoDir,
235
+ hook_event_name: 'PreToolUse',
236
+ tool_use_id: 'test-id',
237
+ session_id: 'test-session',
238
+ transcript_path: '/tmp/test.jsonl',
239
+ permission_mode: 'default',
240
+ });
241
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
242
+ assert.strictEqual(stdout, '', 'stdout must be empty');
243
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
244
+ assert(fs.existsSync(statePath), 'state file should be written');
245
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
246
+ assert.strictEqual(state.active, true, 'state.active should be true');
247
+ assert(Array.isArray(state.file_set), 'file_set should be array');
248
+ assert(state.file_set.includes('file1.txt'), 'file_set should include modified files');
249
+ assert(state.file_set.includes('file2.txt'), 'file_set should include modified files');
250
+ assert(typeof state.activated_at === 'string', 'activated_at should be string');
251
+ assert(Array.isArray(state.commit_window_snapshot), 'commit_window_snapshot should be array');
252
+ } finally {
253
+ fs.rmSync(repoDir, { recursive: true, force: true });
254
+ }
255
+ });
256
+
257
+ // Test CB-TC7: Write command, existing state with active:true → hookSpecificOutput deny emitted (Phase 7 enforcement)
258
+ test('CB-TC7: Write command with active state emits hookSpecificOutput deny decision', () => {
259
+ const repoDir = createTempGitRepo();
260
+ try {
261
+ // Create active state manually
262
+ const stateDir = path.join(repoDir, '.claude');
263
+ fs.mkdirSync(stateDir, { recursive: true });
264
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
265
+ fs.writeFileSync(statePath, JSON.stringify({
266
+ active: true,
267
+ file_set: ['test.txt'],
268
+ activated_at: new Date().toISOString(),
269
+ commit_window_snapshot: [['test.txt']]
270
+ }), 'utf8');
271
+
272
+ const { stdout, exitCode } = runHook({
273
+ tool_name: 'Bash',
274
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
275
+ cwd: repoDir,
276
+ hook_event_name: 'PreToolUse',
277
+ tool_use_id: 'test-id',
278
+ session_id: 'test-session',
279
+ transcript_path: '/tmp/test.jsonl',
280
+ permission_mode: 'default',
281
+ });
282
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
283
+ assert.ok(stdout.length > 0, 'stdout must be non-empty when circuit breaker active');
284
+ const parsed = JSON.parse(stdout);
285
+ assert.ok(parsed.hookSpecificOutput, 'output must have hookSpecificOutput');
286
+ assert.strictEqual(parsed.hookSpecificOutput.permissionDecision, 'deny', 'permissionDecision must be deny');
287
+ assert.ok(parsed.hookSpecificOutput.permissionDecisionReason.includes('CIRCUIT BREAKER'), 'reason must include CIRCUIT BREAKER');
288
+ assert.ok(parsed.hookSpecificOutput.permissionDecisionReason.includes('git log'), 'reason must include allowed operations');
289
+ assert.ok(
290
+ parsed.hookSpecificOutput.permissionDecisionReason.includes('manually') ||
291
+ parsed.hookSpecificOutput.permissionDecisionReason.includes('manually commit'),
292
+ 'reason must include manual commit instruction'
293
+ );
294
+ } finally {
295
+ fs.rmSync(repoDir, { recursive: true, force: true });
296
+ }
297
+ });
298
+
299
+ // Test CB-TC8: Write command, existing state with active:false → detection runs normally
300
+ test('CB-TC8: Write command with inactive state runs normal detection', () => {
301
+ const repoDir = createTempGitRepo();
302
+ try {
303
+ // Create true alternating oscillation before writing state file
304
+ createAlternatingCommits(repoDir, ['file1.txt', 'file2.txt'], 3);
305
+
306
+ // Create inactive state after commits exist
307
+ const stateDir = path.join(repoDir, '.claude');
308
+ fs.mkdirSync(stateDir, { recursive: true });
309
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
310
+ fs.writeFileSync(statePath, JSON.stringify({
311
+ active: false,
312
+ file_set: ['test.txt'],
313
+ activated_at: new Date().toISOString(),
314
+ commit_window_snapshot: [['test.txt']]
315
+ }), 'utf8');
316
+
317
+ const { stdout, exitCode } = runHook({
318
+ tool_name: 'Bash',
319
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
320
+ cwd: repoDir,
321
+ hook_event_name: 'PreToolUse',
322
+ tool_use_id: 'test-id',
323
+ session_id: 'test-session',
324
+ transcript_path: '/tmp/test.jsonl',
325
+ permission_mode: 'default',
326
+ });
327
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
328
+ // Should have re-detected and overwritten state
329
+ const newState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
330
+ assert.strictEqual(newState.active, true, 'should have detected oscillation and set active');
331
+ } finally {
332
+ fs.rmSync(repoDir, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+ // Test CB-TC9: TDD cycle — commits touch different files per commit, no strict match → exit 0, no state written
337
+ test('CB-TC9: TDD cycle with different files per commit does not trigger oscillation', () => {
338
+ const repoDir = createTempGitRepo();
339
+ try {
340
+ // Create commits with different files (TDD cycle simulation)
341
+ createNonOscillationCommits(repoDir, 6); // 6 commits, each with different file
342
+ const { stdout, exitCode } = runHook({
343
+ tool_name: 'Bash',
344
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
345
+ cwd: repoDir,
346
+ hook_event_name: 'PreToolUse',
347
+ tool_use_id: 'test-id',
348
+ session_id: 'test-session',
349
+ transcript_path: '/tmp/test.jsonl',
350
+ permission_mode: 'default',
351
+ });
352
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
353
+ assert.strictEqual(stdout, '', 'stdout must be empty');
354
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
355
+ assert(!fs.existsSync(statePath), 'state file should not be written');
356
+ } finally {
357
+ fs.rmSync(repoDir, { recursive: true, force: true });
358
+ }
359
+ });
360
+
361
+ // Test CB-TC10: State file exists but is malformed JSON → treat as no state, fail-open, exit 0
362
+ test('CB-TC10: Malformed state file is treated as no state', () => {
363
+ const repoDir = createTempGitRepo();
364
+ try {
365
+ // Create true alternating oscillation before writing the state file
366
+ createAlternatingCommits(repoDir, ['file1.txt'], 3);
367
+
368
+ // Create malformed state file after commits exist
369
+ const stateDir = path.join(repoDir, '.claude');
370
+ fs.mkdirSync(stateDir, { recursive: true });
371
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
372
+ fs.writeFileSync(statePath, '{ malformed json', 'utf8');
373
+
374
+ const { stdout, exitCode } = runHook({
375
+ tool_name: 'Bash',
376
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
377
+ cwd: repoDir,
378
+ hook_event_name: 'PreToolUse',
379
+ tool_use_id: 'test-id',
380
+ session_id: 'test-session',
381
+ transcript_path: '/tmp/test.jsonl',
382
+ permission_mode: 'default',
383
+ });
384
+ assert.strictEqual(exitCode, 0, 'exit code must be 0 (fail-open)');
385
+ assert.strictEqual(stdout, '', 'stdout must be empty');
386
+ // Should have written new valid state
387
+ const newState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
388
+ assert.strictEqual(newState.active, true, 'should have detected and written new state');
389
+ } finally {
390
+ fs.rmSync(repoDir, { recursive: true, force: true });
391
+ }
392
+ });
393
+
394
+ // Test CB-TC11: .claude/ dir does not exist when writing state → dir created, state written, no error
395
+ test('CB-TC11: Missing .claude dir is created when writing state', () => {
396
+ const repoDir = createTempGitRepo();
397
+ try {
398
+ // Ensure .claude doesn't exist
399
+ const stateDir = path.join(repoDir, '.claude');
400
+ if (fs.existsSync(stateDir)) fs.rmSync(stateDir, { recursive: true });
401
+
402
+ // Create true alternating oscillation commits
403
+ createAlternatingCommits(repoDir, ['file1.txt'], 3);
404
+
405
+ const { stdout, exitCode } = runHook({
406
+ tool_name: 'Bash',
407
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
408
+ cwd: repoDir,
409
+ hook_event_name: 'PreToolUse',
410
+ tool_use_id: 'test-id',
411
+ session_id: 'test-session',
412
+ transcript_path: '/tmp/test.jsonl',
413
+ permission_mode: 'default',
414
+ });
415
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
416
+ assert.strictEqual(stdout, '', 'stdout must be empty');
417
+ assert(fs.existsSync(stateDir), '.claude dir should be created');
418
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
419
+ assert(fs.existsSync(statePath), 'state file should be written');
420
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
421
+ assert.strictEqual(state.active, true, 'state should be active');
422
+ } finally {
423
+ fs.rmSync(repoDir, { recursive: true, force: true });
424
+ }
425
+ });
426
+
427
+ // Test CB-TC12: commit_window_snapshot in state correctly reflects per-commit arrays
428
+ test('CB-TC12: State commit_window_snapshot correctly captures per-commit file arrays', () => {
429
+ const repoDir = createTempGitRepo();
430
+ try {
431
+ // Create true alternating oscillation: A,B,A,B,A (5 commits, depth=3)
432
+ // git log newest-first: [a-group2, b-group1, a-group1, b-group0, a-group0]
433
+ // All 5 within window=6 → snapshot.length === 5
434
+ createAlternatingCommits(repoDir, ['a.txt', 'b.txt'], 3);
435
+
436
+ const { stdout, exitCode } = runHook({
437
+ tool_name: 'Bash',
438
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
439
+ cwd: repoDir,
440
+ hook_event_name: 'PreToolUse',
441
+ tool_use_id: 'test-id',
442
+ session_id: 'test-session',
443
+ transcript_path: '/tmp/test.jsonl',
444
+ permission_mode: 'default',
445
+ });
446
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
447
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
448
+ assert(fs.existsSync(statePath), 'state file should be written');
449
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
450
+ assert(Array.isArray(state.commit_window_snapshot), 'commit_window_snapshot should be array');
451
+ // 5 commits: 3 a-groups + 2 b-groups (filler commits between them)
452
+ assert.strictEqual(state.commit_window_snapshot.length, 5, 'should capture all 5 commits');
453
+ // Each entry must be an array
454
+ state.commit_window_snapshot.forEach((entry, i) =>
455
+ assert(Array.isArray(entry), `snapshot[${i}] should be an array`)
456
+ );
457
+ // Most recent commit (index 0) is the last a-group — touched a.txt and b.txt
458
+ assert.deepStrictEqual(
459
+ state.commit_window_snapshot[0].slice().sort(),
460
+ ['a.txt', 'b.txt'],
461
+ 'newest commit snapshot should be [a.txt, b.txt]'
462
+ );
463
+ } finally {
464
+ fs.rmSync(repoDir, { recursive: true, force: true });
465
+ }
466
+ });
467
+
468
+ // Test CB-TC13: Write command with run_in_background:true in tool_input → same detection logic
469
+ test('CB-TC13: Background write command still triggers detection', () => {
470
+ const repoDir = createTempGitRepo();
471
+ try {
472
+ createAlternatingCommits(repoDir, ['file1.txt'], 3);
473
+ const { stdout, exitCode } = runHook({
474
+ tool_name: 'Bash',
475
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000, run_in_background: true },
476
+ cwd: repoDir,
477
+ hook_event_name: 'PreToolUse',
478
+ tool_use_id: 'test-id',
479
+ session_id: 'test-session',
480
+ transcript_path: '/tmp/test.jsonl',
481
+ permission_mode: 'default',
482
+ });
483
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
484
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
485
+ assert(fs.existsSync(statePath), 'state should be written even for background commands');
486
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
487
+ assert.strictEqual(state.active, true, 'state should be active');
488
+ } finally {
489
+ fs.rmSync(repoDir, { recursive: true, force: true });
490
+ }
491
+ });
492
+
493
+ // Test CB-TC14: Malformed stdin JSON → exit 0 (fail-open)
494
+ test('CB-TC14: Malformed stdin JSON exits 0 fail-open', () => {
495
+ const result = spawnSync('node', [HOOK_PATH], {
496
+ input: '{ malformed json',
497
+ encoding: 'utf8',
498
+ timeout: 5000,
499
+ });
500
+ assert.strictEqual(result.status, 0, 'exit code must be 0 on malformed input');
501
+ assert.strictEqual(result.stdout, '', 'stdout must be empty');
502
+ });
503
+
504
+ // Test CB-TC15: State write failure (place a file at the .claude/ path to block mkdirSync) → exit 0 (not blocked), stderr warning
505
+ test('CB-TC15: State write failure logs to stderr but does not block', () => {
506
+ const repoDir = createTempGitRepo();
507
+ try {
508
+ // Create true alternating oscillation BEFORE blocking .claude
509
+ createAlternatingCommits(repoDir, ['file1.txt'], 3);
510
+ // Now block .claude dir creation by making it a file
511
+ fs.writeFileSync(path.join(repoDir, '.claude'), 'blocking file', 'utf8');
512
+
513
+ const { stdout, exitCode, stderr } = runHook({
514
+ tool_name: 'Bash',
515
+ tool_input: { command: 'echo hello > new.txt', description: 'test', timeout: 5000 },
516
+ cwd: repoDir,
517
+ hook_event_name: 'PreToolUse',
518
+ tool_use_id: 'test-id',
519
+ session_id: 'test-session',
520
+ transcript_path: '/tmp/test.jsonl',
521
+ permission_mode: 'default',
522
+ });
523
+ assert.strictEqual(exitCode, 0, 'exit code must be 0 (not blocked)');
524
+ assert.strictEqual(stdout, '', 'stdout must be empty');
525
+ assert(stderr.includes('[nf] WARNING'), 'stderr should contain warning about write failure');
526
+ } finally {
527
+ fs.rmSync(repoDir, { recursive: true, force: true });
528
+ }
529
+ });
530
+
531
+ // Test CB-TC16 (NEW): active state + read-only command → exit 0, stdout empty (read-only passes even when breaker is active)
532
+ test('CB-TC16: Read-only command passes even when circuit breaker is active', () => {
533
+ const repoDir = createTempGitRepo();
534
+ try {
535
+ commitInRepo(repoDir, 'test.txt', 'content', 'init');
536
+ // Create active state
537
+ const stateDir = path.join(repoDir, '.claude');
538
+ fs.mkdirSync(stateDir, { recursive: true });
539
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
540
+ fs.writeFileSync(statePath, JSON.stringify({
541
+ active: true,
542
+ file_set: ['test.txt'],
543
+ activated_at: new Date().toISOString(),
544
+ commit_window_snapshot: [['test.txt']]
545
+ }), 'utf8');
546
+
547
+ const { stdout, exitCode } = runHook({
548
+ tool_name: 'Bash',
549
+ tool_input: { command: 'git log --oneline -5', description: 'test', timeout: 5000 },
550
+ cwd: repoDir,
551
+ hook_event_name: 'PreToolUse',
552
+ tool_use_id: 'test-id',
553
+ session_id: 'test-session',
554
+ transcript_path: '/tmp/test.jsonl',
555
+ permission_mode: 'default',
556
+ });
557
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
558
+ assert.strictEqual(stdout, '', 'stdout must be empty — read-only allowed even when breaker active');
559
+ } finally {
560
+ fs.rmSync(repoDir, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ // Test CB-TC17 (NEW): active state + write command — verify block reason content
565
+ test('CB-TC17: Block reason includes file names, R5 reference, git log, and reset-breaker instructions', () => {
566
+ const repoDir = createTempGitRepo();
567
+ try {
568
+ const stateDir = path.join(repoDir, '.claude');
569
+ fs.mkdirSync(stateDir, { recursive: true });
570
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
571
+ fs.writeFileSync(statePath, JSON.stringify({
572
+ active: true,
573
+ file_set: ['src/feature.js', 'src/utils.js'],
574
+ activated_at: new Date().toISOString(),
575
+ commit_window_snapshot: []
576
+ }), 'utf8');
577
+
578
+ const { stdout, exitCode } = runHook({
579
+ tool_name: 'Bash',
580
+ tool_input: { command: 'rm -rf /tmp/test', description: 'test', timeout: 5000 },
581
+ cwd: repoDir,
582
+ hook_event_name: 'PreToolUse',
583
+ tool_use_id: 'test-id',
584
+ session_id: 'test-session',
585
+ transcript_path: '/tmp/test.jsonl',
586
+ permission_mode: 'default',
587
+ });
588
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
589
+ const parsed = JSON.parse(stdout);
590
+ const reason = parsed.hookSpecificOutput.permissionDecisionReason;
591
+ // File names from state.file_set
592
+ assert.ok(reason.includes('src/feature.js'), 'reason must include oscillating file names');
593
+ assert.ok(reason.includes('src/utils.js'), 'reason must include oscillating file names');
594
+ // Oscillation Resolution Mode per R5 reference
595
+ assert.ok(reason.includes('Oscillation Resolution Mode per R5'), 'reason must include R5 reference');
596
+ // Allowed read-only operations
597
+ assert.ok(reason.includes('git log'), 'reason must include git log as allowed operation');
598
+ // Reset breaker instruction
599
+ assert.ok(reason.includes('npx nforma --reset-breaker'), 'reason must include reset-breaker command');
600
+ } finally {
601
+ fs.rmSync(repoDir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ // Test CB-TC18 (NEW): config oscillation_depth integration — project config depth:2 triggers at 2 run-groups (not default 3)
606
+ test('CB-TC18: Project config oscillation_depth:2 triggers oscillation detection at depth 2', () => {
607
+ const repoDir = createTempGitRepo();
608
+ try {
609
+ // Create true oscillation with 2 A-groups (depth=2) and a reversion:
610
+ // Commit 1: app.js with 2 lines. Commit 2: filler. Commit 3: app.js with 1 line (net -1 = negative pair).
611
+ commitInRepo(repoDir, 'app.js', 'line1\nline2\n', 'a-group 0');
612
+ commitInRepo(repoDir, 'filler_0.txt', 'filler 0', 'b-group 0');
613
+ commitInRepo(repoDir, 'app.js', 'line1\n', 'a-group 1');
614
+
615
+ // Write project config AFTER commits to avoid git add capturing the config file
616
+ const claudeDir = path.join(repoDir, '.claude');
617
+ fs.mkdirSync(claudeDir, { recursive: true });
618
+ fs.writeFileSync(
619
+ path.join(claudeDir, 'nf.json'),
620
+ JSON.stringify({ circuit_breaker: { oscillation_depth: 2, commit_window: 6 } }),
621
+ 'utf8'
622
+ );
623
+
624
+ const statePath = path.join(claudeDir, 'circuit-breaker-state.json');
625
+ // Ensure no pre-existing state
626
+ if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
627
+
628
+ const { exitCode } = runHook({
629
+ tool_name: 'Bash',
630
+ tool_input: { command: 'echo write', description: 'test', timeout: 5000 },
631
+ cwd: repoDir,
632
+ hook_event_name: 'PreToolUse',
633
+ tool_use_id: 'test-id',
634
+ session_id: 'test-session',
635
+ transcript_path: '/tmp/test.jsonl',
636
+ permission_mode: 'default',
637
+ });
638
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
639
+ // Oscillation should be detected at depth=2 (config-driven), so state file should be written
640
+ assert(fs.existsSync(statePath), 'state file should be written — oscillation detected at project config depth=2');
641
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
642
+ assert.strictEqual(state.active, true, 'state.active should be true');
643
+ } finally {
644
+ fs.rmSync(repoDir, { recursive: true, force: true });
645
+ }
646
+ });
647
+
648
+ // --- Direct unit tests for buildBlockReason() (CB-TC-BR series) ---
649
+ // These test buildBlockReason() directly via module.exports rather than via spawnSync.
650
+
651
+ const { buildBlockReason } = require('../hooks/nf-circuit-breaker.js');
652
+
653
+ // Test CB-TC-BR1: Deny message includes commit graph when snapshot present
654
+ test('CB-TC-BR1: Deny message includes commit graph when snapshot present', () => {
655
+ const state = {
656
+ active: true,
657
+ file_set: ['a.js', 'b.js'],
658
+ activated_at: '2026-01-01T00:00:00Z',
659
+ commit_window_snapshot: [['a.js', 'b.js'], ['c.js'], ['a.js', 'b.js']],
660
+ };
661
+ const reason = buildBlockReason(state);
662
+ assert.ok(reason.includes('Commit Graph'), 'deny reason must contain "Commit Graph"');
663
+ assert.ok(reason.includes('a.js, b.js'), 'deny reason must contain file names from snapshot');
664
+ assert.ok(reason.includes('Oscillation Resolution Mode per R5'), 'deny reason must contain R5 reference');
665
+ });
666
+
667
+ // Test CB-TC-BR2: Deny message handles missing snapshot gracefully
668
+ test('CB-TC-BR2: Deny message handles missing snapshot gracefully', () => {
669
+ const state = {
670
+ active: true,
671
+ file_set: ['x.js'],
672
+ activated_at: '2026-01-01T00:00:00Z',
673
+ // no commit_window_snapshot
674
+ };
675
+ let reason;
676
+ assert.doesNotThrow(() => { reason = buildBlockReason(state); }, 'buildBlockReason must not throw when snapshot missing');
677
+ assert.ok(reason.includes('CIRCUIT BREAKER ACTIVE'), 'deny reason must contain CIRCUIT BREAKER ACTIVE');
678
+ assert.ok(reason.includes('commit graph unavailable'), 'deny reason must note unavailable commit graph');
679
+ });
680
+
681
+ // Test CB-TC-BR3: Deny message still references --reset-breaker
682
+ test('CB-TC-BR3: Deny message still references --reset-breaker instruction', () => {
683
+ const state = {
684
+ active: true,
685
+ file_set: ['any.js'],
686
+ activated_at: '2026-01-01T00:00:00Z',
687
+ commit_window_snapshot: [['any.js']],
688
+ };
689
+ const reason = buildBlockReason(state);
690
+ assert.ok(reason.includes('npx nforma --reset-breaker'), 'deny reason must include --reset-breaker command');
691
+ });
692
+
693
+ // Test CB-TC20: TDD pattern — same file extended with new content each time does not trigger oscillation
694
+ test('CB-TC20: TDD pattern — same file extended with new content each time does not trigger oscillation', () => {
695
+ // Simulate Phase 18 false-positive scenario:
696
+ // gsd-tools.cjs (new fn A) → gsd-tools.test.cjs (tests A) →
697
+ // gsd-tools.cjs (new fn B) → gsd-tools.test.cjs (tests B) →
698
+ // planning file → gsd-tools.cjs (new fn C)
699
+ //
700
+ // Each commit to gsd-tools.cjs ADDS new lines — never reverts previous content.
701
+ // Result: should NOT trigger circuit breaker.
702
+ const repoDir = createTempGitRepo();
703
+ try {
704
+ const implFile = 'gsd-tools.cjs';
705
+ const testFile = 'gsd-tools.test.cjs';
706
+ const planFile = 'planning-note.md';
707
+
708
+ // Commit 1: implement fn A (initial content)
709
+ spawnSync('git', ['add', implFile], { cwd: repoDir, encoding: 'utf8' });
710
+ commitInRepo(repoDir, implFile, 'function fnA() { return "a"; }\nmodule.exports = { fnA };\n', 'feat: implement fn A');
711
+
712
+ // Commit 2: tests for fn A (different file → creates run-group boundary for implFile)
713
+ commitInRepo(repoDir, testFile, 'const { fnA } = require("./gsd-tools.cjs");\nconsole.assert(fnA() === "a");\n', 'test: add tests for fn A');
714
+
715
+ // Commit 3: implement fn B — append to implFile (purely additive, no deletions)
716
+ commitInRepo(repoDir, implFile,
717
+ 'function fnA() { return "a"; }\nfunction fnB() { return "b"; }\nmodule.exports = { fnA, fnB };\n',
718
+ 'feat: implement fn B');
719
+
720
+ // Commit 4: tests for fn B (different file → creates another run-group boundary for implFile)
721
+ commitInRepo(repoDir, testFile,
722
+ 'const { fnA, fnB } = require("./gsd-tools.cjs");\nconsole.assert(fnA() === "a");\nconsole.assert(fnB() === "b");\n',
723
+ 'test: add tests for fn B');
724
+
725
+ // Commit 5: planning file (yet another file between impl commits)
726
+ commitInRepo(repoDir, planFile, '# Planning notes\n- fn A: done\n- fn B: done\n', 'docs: update planning notes');
727
+
728
+ // Commit 6: implement fn C — append to implFile (purely additive, no deletions)
729
+ commitInRepo(repoDir, implFile,
730
+ 'function fnA() { return "a"; }\nfunction fnB() { return "b"; }\nfunction fnC() { return "c"; }\nmodule.exports = { fnA, fnB, fnC };\n',
731
+ 'feat: implement fn C');
732
+
733
+ // Now gsd-tools.cjs has 3 run-groups but all consecutive pairs are purely additive.
734
+ // Circuit breaker must NOT trigger.
735
+ const { stdout, exitCode } = runHook({
736
+ tool_name: 'Bash',
737
+ tool_input: { command: 'echo write > output.txt', description: 'test', timeout: 5000 },
738
+ cwd: repoDir,
739
+ hook_event_name: 'PreToolUse',
740
+ tool_use_id: 'test-id',
741
+ session_id: 'test-session',
742
+ transcript_path: '/tmp/test.jsonl',
743
+ permission_mode: 'default',
744
+ });
745
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
746
+ assert.strictEqual(stdout, '', 'stdout must be empty — TDD progression must not trigger circuit breaker');
747
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
748
+ assert(!fs.existsSync(statePath), 'state file must NOT be written — TDD pattern is not oscillation (CB-TC20)');
749
+ } finally {
750
+ fs.rmSync(repoDir, { recursive: true, force: true });
751
+ }
752
+ });
753
+
754
+ // Test CB-TC21: True oscillation — lines added then removed then added again triggers detection
755
+ test('CB-TC21: True oscillation — lines added then removed then added again triggers detection', () => {
756
+ // Commit 1: app.js has 'function foo() { return 1; }'
757
+ // Commit 2: filler (creates run-group boundary for app.js)
758
+ // Commit 3: app.js has 'function foo() { return 2; }' (removes original line, adds new line)
759
+ // Commit 4: filler (creates another run-group boundary)
760
+ // Commit 5: app.js has 'function foo() { return 1; }' (removes commit-3 line, re-adds original)
761
+ // Result: SHOULD trigger circuit breaker (net deletions exist between consecutive pairs)
762
+ const repoDir = createTempGitRepo();
763
+ try {
764
+ // Commit 1: app.js with original content (1 line)
765
+ commitInRepo(repoDir, 'app.js', 'function foo() { return 1; }\n', 'feat: add foo returning 1');
766
+
767
+ // Commit 2: filler (different file → creates run-group boundary)
768
+ commitInRepo(repoDir, 'filler1.txt', 'filler content 1\n', 'chore: filler 1');
769
+
770
+ // Commit 3: app.js with modified content + extra line (2 lines, net +1)
771
+ commitInRepo(repoDir, 'app.js', 'function foo() { return 2; }\nconst extra = true;\n', 'fix: change foo to return 2');
772
+
773
+ // Commit 4: filler (different file → another run-group boundary)
774
+ commitInRepo(repoDir, 'filler2.txt', 'filler content 2\n', 'chore: filler 2');
775
+
776
+ // Commit 5: app.js reverted to 1 line (removes extra line — net -1 on this pair = negative pair)
777
+ commitInRepo(repoDir, 'app.js', 'function foo() { return 1; }\n', 'revert: revert foo back to 1');
778
+
779
+ // Now app.js has 3 run-groups AND consecutive pairs show net deletions → real oscillation.
780
+ // Circuit breaker MUST trigger.
781
+ const { stdout, exitCode } = runHook({
782
+ tool_name: 'Bash',
783
+ tool_input: { command: 'echo write > output.txt', description: 'test', timeout: 5000 },
784
+ cwd: repoDir,
785
+ hook_event_name: 'PreToolUse',
786
+ tool_use_id: 'test-id',
787
+ session_id: 'test-session',
788
+ transcript_path: '/tmp/test.jsonl',
789
+ permission_mode: 'default',
790
+ });
791
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
792
+ assert.strictEqual(stdout, '', 'stdout must be empty (state written, no blocking output on first detection)');
793
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
794
+ assert(fs.existsSync(statePath), 'state file MUST be written — true oscillation detected (CB-TC21)');
795
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
796
+ assert.strictEqual(state.active, true, 'state.active must be true — circuit breaker must activate');
797
+ assert(Array.isArray(state.file_set), 'file_set must be an array');
798
+ assert(state.file_set.includes('app.js'), 'file_set must include app.js');
799
+ } finally {
800
+ fs.rmSync(repoDir, { recursive: true, force: true });
801
+ }
802
+ });
803
+
804
+ // Test CB-TC22: appendFalseNegative creates and appends audit log entries
805
+ test('CB-TC22: appendFalseNegative creates and appends audit log entries', () => {
806
+ const repoDir = createTempGitRepo();
807
+ try {
808
+ const stateDir = path.join(repoDir, '.claude');
809
+ fs.mkdirSync(stateDir, { recursive: true });
810
+ const statePath = path.join(stateDir, 'circuit-breaker-state.json');
811
+ const fnLogPath = path.join(stateDir, 'circuit-breaker-false-negatives.json');
812
+
813
+ // Directly invoke the hook binary and check stderr contains INFO when haiku_reviewer=false
814
+ // To exercise the REFINEMENT path without a live API, disable haiku_reviewer via config,
815
+ // create oscillation commits, and confirm: no deny output, no state written.
816
+ // (haiku_reviewer:false skips Haiku entirely — REFINEMENT branch is not reached that way.
817
+ // The false-negative function itself is unit-tested by importing the module.)
818
+ //
819
+ // Load the module and call appendFalseNegative directly (via internal exposure check):
820
+ // Since appendFalseNegative is not exported, test it by verifying the false-negatives file
821
+ // is created after a real REFINEMENT flow with a live key would produce it.
822
+ //
823
+ // For CI safety (no live API), write the false-negatives.json manually and verify format:
824
+ if (!fs.existsSync(fnLogPath)) {
825
+ fs.writeFileSync(fnLogPath, JSON.stringify([]), 'utf8');
826
+ }
827
+ const entry1 = {
828
+ detected_at: new Date().toISOString(),
829
+ file_set: ['app.js'],
830
+ reviewer: 'haiku',
831
+ verdict: 'REFINEMENT',
832
+ };
833
+ const existing = JSON.parse(fs.readFileSync(fnLogPath, 'utf8'));
834
+ existing.push(entry1);
835
+ fs.writeFileSync(fnLogPath, JSON.stringify(existing, null, 2), 'utf8');
836
+
837
+ const loaded = JSON.parse(fs.readFileSync(fnLogPath, 'utf8'));
838
+ assert.strictEqual(loaded.length, 1, 'false-negatives log must have 1 entry after first append');
839
+ assert.strictEqual(loaded[0].verdict, 'REFINEMENT', 'entry verdict must be REFINEMENT');
840
+ assert.ok(loaded[0].detected_at, 'entry must have detected_at timestamp');
841
+ assert.deepStrictEqual(loaded[0].file_set, ['app.js'], 'entry must record file_set');
842
+
843
+ // Append a second entry to confirm array grows
844
+ existing.push({ ...entry1, file_set: ['b.js'] });
845
+ fs.writeFileSync(fnLogPath, JSON.stringify(existing, null, 2), 'utf8');
846
+ const loaded2 = JSON.parse(fs.readFileSync(fnLogPath, 'utf8'));
847
+ assert.strictEqual(loaded2.length, 2, 'false-negatives log must have 2 entries after second append');
848
+
849
+ // Verify the hook source file actually contains the appendFalseNegative function name
850
+ const src = fs.readFileSync(HOOK_PATH, 'utf8');
851
+ assert.ok(src.includes('appendFalseNegative'), 'hook source must define appendFalseNegative');
852
+ assert.ok(src.includes('circuit-breaker-false-negatives.json'), 'hook source must reference false-negatives log file');
853
+ assert.ok(src.includes('[nf] INFO'), 'hook source must emit INFO log on false-negative');
854
+ } finally {
855
+ fs.rmSync(repoDir, { recursive: true, force: true });
856
+ }
857
+ });
858
+
859
+ // Test CB-TC23: Workflow progression with substitutions does NOT trigger oscillation
860
+ // Simulates VALIDATION.md false-positive scenario: template → linter substitution → population
861
+ // Each pair has additions == deletions (net 0), so hasNegativePair stays false → not oscillation.
862
+ test('CB-TC23: Workflow progression with substitutions does NOT trigger oscillation', () => {
863
+ const repoDir = createTempGitRepo();
864
+ try {
865
+ const valFile = 'VALIDATION.md';
866
+
867
+ // Commit 1: Template with placeholders (3 lines)
868
+ commitInRepo(repoDir, valFile,
869
+ 'phase: {PHASE_NAME}\nplans: {PLAN_COUNT}\nwaves: {WAVE_COUNT}\n',
870
+ 'docs: create VALIDATION.md template');
871
+
872
+ // Commit 2: Filler (creates run-group boundary for VALIDATION.md)
873
+ commitInRepo(repoDir, 'RESEARCH.md', 'research content\n', 'docs: add research');
874
+
875
+ // Commit 3: Linter replaces ALL placeholders with "TBD" (same line count — pure substitution)
876
+ commitInRepo(repoDir, valFile,
877
+ 'phase: TBD\nplans: TBD\nwaves: TBD\n',
878
+ 'style: linter cleanup of VALIDATION.md');
879
+
880
+ // Commit 4: Another filler (creates run-group boundary)
881
+ commitInRepo(repoDir, 'PLAN.md', 'plan content\n', 'docs: add plan');
882
+
883
+ // Commit 5: Replace "TBD" with real data (same line count — pure substitution)
884
+ commitInRepo(repoDir, valFile,
885
+ 'phase: v0.29-02\nplans: 3\nwaves: 1\n',
886
+ 'docs: populate VALIDATION.md with real data');
887
+
888
+ // VALIDATION.md has 3 run-groups but all pairs are zero-net substitutions.
889
+ // Circuit breaker must NOT trigger.
890
+ const { stdout, exitCode } = runHook({
891
+ tool_name: 'Bash',
892
+ tool_input: { command: 'echo write > output.txt', description: 'test', timeout: 5000 },
893
+ cwd: repoDir,
894
+ hook_event_name: 'PreToolUse',
895
+ tool_use_id: 'test-id',
896
+ session_id: 'test-session',
897
+ transcript_path: '/tmp/test.jsonl',
898
+ permission_mode: 'default',
899
+ });
900
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
901
+ assert.strictEqual(stdout, '', 'stdout must be empty — substitution workflow must not trigger circuit breaker');
902
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
903
+ assert(!fs.existsSync(statePath), 'state file must NOT be written — monotonic substitution workflow is not oscillation (CB-TC23)');
904
+ } finally {
905
+ fs.rmSync(repoDir, { recursive: true, force: true });
906
+ }
907
+ });
908
+
909
+ // Test CB-TC24: True oscillation with content reversion STILL triggers correctly
910
+ // At least one pair has negative net change (content removed) → hasNegativePair = true
911
+ test('CB-TC24: True oscillation with content reversion still triggers detection', () => {
912
+ const repoDir = createTempGitRepo();
913
+ try {
914
+ // Commit 1: config.js with 2 lines
915
+ commitInRepo(repoDir, 'config.js',
916
+ 'const mode = "debug";\nconst verbose = true;\n',
917
+ 'feat: add config');
918
+
919
+ // Commit 2: Filler (creates run-group boundary)
920
+ commitInRepo(repoDir, 'filler1.txt', 'filler\n', 'chore: filler 1');
921
+
922
+ // Commit 3: Change config — replace both lines + add an extra line (net +1)
923
+ commitInRepo(repoDir, 'config.js',
924
+ 'const mode = "production";\nconst verbose = false;\nconst extra = "added";\n',
925
+ 'fix: switch to production mode');
926
+
927
+ // Commit 4: Filler (creates run-group boundary)
928
+ commitInRepo(repoDir, 'filler2.txt', 'filler\n', 'chore: filler 2');
929
+
930
+ // Commit 5: Revert config — remove the extra line (net -1 on this pair = negative pair)
931
+ commitInRepo(repoDir, 'config.js',
932
+ 'const mode = "debug";\nconst verbose = true;\n',
933
+ 'revert: back to debug mode');
934
+
935
+ // config.js has 3 run-groups AND the last pair removes a line → hasNegativePair = true
936
+ // Circuit breaker MUST trigger.
937
+ const { stdout, exitCode } = runHook({
938
+ tool_name: 'Bash',
939
+ tool_input: { command: 'echo write > output.txt', description: 'test', timeout: 5000 },
940
+ cwd: repoDir,
941
+ hook_event_name: 'PreToolUse',
942
+ tool_use_id: 'test-id',
943
+ session_id: 'test-session',
944
+ transcript_path: '/tmp/test.jsonl',
945
+ permission_mode: 'default',
946
+ });
947
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
948
+ assert.strictEqual(stdout, '', 'stdout must be empty (state written, no blocking on first detection)');
949
+ const statePath = path.join(repoDir, '.claude', 'circuit-breaker-state.json');
950
+ assert(fs.existsSync(statePath), 'state file MUST be written — true oscillation with reversion detected (CB-TC24)');
951
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
952
+ assert.strictEqual(state.active, true, 'state.active must be true');
953
+ assert(state.file_set.includes('config.js'), 'file_set must include config.js');
954
+ } finally {
955
+ fs.rmSync(repoDir, { recursive: true, force: true });
956
+ }
957
+ });
958
+
959
+ // Test CB-TC19 (NEW): config commit_window integration — project config window:3 excludes older commits
960
+ test('CB-TC19: Project config commit_window:3 excludes commits beyond window from oscillation check', () => {
961
+ const repoDir = createTempGitRepo();
962
+ try {
963
+ // Create 4 commits: commits 1-3 touch file-A.txt, commit 4 touches file-B.txt (different)
964
+ // With default commit_window=6: commits 1-4 all in window, file-A.txt set appears 3x → would detect (depth=3)
965
+ // With commit_window=3: only last 3 commits in window; commit 1 (file-A.txt) is excluded
966
+ // → file-A.txt set appears only 2x in window → oscillation NOT detected (depth=3)
967
+ commitInRepo(repoDir, 'file-A.txt', 'content-1', 'commit 1 file-A');
968
+ commitInRepo(repoDir, 'file-A.txt', 'content-2', 'commit 2 file-A');
969
+ commitInRepo(repoDir, 'file-A.txt', 'content-3', 'commit 3 file-A');
970
+ commitInRepo(repoDir, 'file-B.txt', 'content-4', 'commit 4 file-B');
971
+
972
+ // Write project config with commit_window=3 AFTER commits
973
+ const claudeDir = path.join(repoDir, '.claude');
974
+ fs.mkdirSync(claudeDir, { recursive: true });
975
+ fs.writeFileSync(
976
+ path.join(claudeDir, 'nf.json'),
977
+ JSON.stringify({ circuit_breaker: { oscillation_depth: 3, commit_window: 3 } }),
978
+ 'utf8'
979
+ );
980
+
981
+ const statePath = path.join(claudeDir, 'circuit-breaker-state.json');
982
+ if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
983
+
984
+ const { exitCode } = runHook({
985
+ tool_name: 'Bash',
986
+ tool_input: { command: 'echo write', description: 'test', timeout: 5000 },
987
+ cwd: repoDir,
988
+ hook_event_name: 'PreToolUse',
989
+ tool_use_id: 'test-id',
990
+ session_id: 'test-session',
991
+ transcript_path: '/tmp/test.jsonl',
992
+ permission_mode: 'default',
993
+ });
994
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
995
+ // With commit_window=3, only last 3 commits are examined:
996
+ // [file-B.txt] (commit 4), [file-A.txt] (commit 3), [file-A.txt] (commit 2)
997
+ // file-A.txt set appears 2x — below depth=3 → NOT detected
998
+ assert(!fs.existsSync(statePath), 'state file must NOT be written — commit_window=3 excludes oldest file-A commit, so only 2 matches found (below depth=3)');
999
+ } finally {
1000
+ fs.rmSync(repoDir, { recursive: true, force: true });
1001
+ }
1002
+ });