@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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
- // hooks/qgsd-spec-regen.js
3
- // PostToolUse hook: when Claude writes to qgsd-workflow.machine.ts,
2
+ // hooks/nf-spec-regen.js
3
+ // PostToolUse hook: when Claude writes to nf-workflow.machine.ts,
4
4
  // automatically trigger generate-formal-specs.cjs to regenerate TLA+/Alloy specs.
5
5
  //
6
6
  // LOOP-02 (v0.21-03): Self-Calibrating Feedback Loops
@@ -16,6 +16,7 @@
16
16
  const { spawnSync } = require('child_process');
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
+ const { loadConfig, shouldRunHook } = require('./config-loader');
19
20
 
20
21
  let raw = '';
21
22
  process.stdin.setEncoding('utf8');
@@ -23,11 +24,19 @@ process.stdin.on('data', (chunk) => { raw += chunk; });
23
24
  process.stdin.on('end', () => {
24
25
  try {
25
26
  const input = JSON.parse(raw);
27
+
28
+ // Profile guard — exit early if this hook is not active for the current profile
29
+ const config = loadConfig(input.cwd || process.cwd());
30
+ const profile = config.hook_profile || 'standard';
31
+ if (!shouldRunHook('nf-spec-regen', profile)) {
32
+ process.exit(0);
33
+ }
34
+
26
35
  const toolName = input.tool_name || '';
27
36
  const filePath = (input.tool_input && input.tool_input.file_path) || '';
28
37
 
29
- // Only act on Write calls to qgsd-workflow.machine.ts
30
- if (toolName !== 'Write' || !filePath.includes('qgsd-workflow.machine.ts')) {
38
+ // Only act on Write calls to nf-workflow.machine.ts
39
+ if (toolName !== 'Write' || !filePath.includes('nf-workflow.machine.ts')) {
31
40
  process.exit(0); // No-op — not a machine file write
32
41
  }
33
42
 
@@ -50,16 +59,16 @@ process.stdin.on('end', () => {
50
59
  (result.error ? String(result.error) : '');
51
60
  }
52
61
 
53
- // Also regenerate QGSDQuorum_xstate.tla (xstate-to-tla.cjs)
62
+ // Also regenerate NFQuorum_xstate.tla (xstate-to-tla.cjs)
54
63
  const xstateScript = path.join(cwd, 'bin', 'xstate-to-tla.cjs');
55
- const machineFile = path.join(cwd, 'src', 'machines', 'qgsd-workflow.machine.ts');
56
- const guardsConfig = path.join(cwd, '.planning', 'formal', 'tla', 'guards', 'qgsd-workflow.json');
64
+ const machineFile = path.join(cwd, 'src', 'machines', 'nf-workflow.machine.ts');
65
+ const guardsConfig = path.join(cwd, '.planning', 'formal', 'tla', 'guards', 'nf-workflow.json');
57
66
 
58
67
  if (fs.existsSync(xstateScript) && fs.existsSync(guardsConfig)) {
59
68
  const xstateResult = spawnSync(process.execPath, [
60
69
  xstateScript, machineFile,
61
70
  '--config=' + guardsConfig,
62
- '--module=QGSDQuorum'
71
+ '--module=NFQuorum'
63
72
  ], {
64
73
  encoding: 'utf8',
65
74
  cwd: cwd,
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ // Wave 0 RED stubs for LOOP-02: hooks/nf-spec-regen.js
8
+ // These tests define the contract. They will fail until Plan 03 implements the hook.
9
+
10
+ function runHook(inputPayload) {
11
+ const hookPath = path.join(__dirname, 'nf-spec-regen.js');
12
+ const result = spawnSync(process.execPath, [hookPath], {
13
+ input: JSON.stringify(inputPayload),
14
+ encoding: 'utf8',
15
+ timeout: 15000,
16
+ });
17
+ return result;
18
+ }
19
+
20
+ test('LOOP-02: nf-spec-regen.js exits 0 and is a no-op for non-Write tool events', () => {
21
+ const result = runHook({
22
+ tool_name: 'Read',
23
+ tool_input: { file_path: '/some/file.ts' },
24
+ tool_response: {},
25
+ cwd: process.cwd(),
26
+ context_window: {}
27
+ });
28
+ // RED: script does not exist yet
29
+ assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for non-Write tools. Not yet implemented.');
30
+ });
31
+
32
+ test('LOOP-02: nf-spec-regen.js exits 0 and is a no-op for Write to non-machine file', () => {
33
+ const result = runHook({
34
+ tool_name: 'Write',
35
+ tool_input: { file_path: '/some/other-file.ts' },
36
+ tool_response: {},
37
+ cwd: process.cwd(),
38
+ context_window: {}
39
+ });
40
+ assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for Write to non-machine file. Not yet implemented.');
41
+ // Should produce no output or empty additionalContext for non-matching files
42
+ });
43
+
44
+ test('LOOP-02: nf-spec-regen.js exits 0 for Write to nf-workflow.machine.ts and returns additionalContext', () => {
45
+ const result = runHook({
46
+ tool_name: 'Write',
47
+ tool_input: { file_path: '/Users/jonathanborduas/code/QGSD/src/machines/nf-workflow.machine.ts' },
48
+ tool_response: {},
49
+ cwd: '/Users/jonathanborduas/code/QGSD',
50
+ context_window: {}
51
+ });
52
+ assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for machine file write. Not yet implemented.');
53
+ // Output must be valid JSON with additionalContext field
54
+ if (result.stdout && result.stdout.trim()) {
55
+ let parsed;
56
+ assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); },
57
+ 'LOOP-02: stdout must be valid JSON when hook fires. Not yet implemented.');
58
+ assert.ok(
59
+ parsed && parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext,
60
+ 'LOOP-02: output must contain hookSpecificOutput.additionalContext. Not yet implemented.'
61
+ );
62
+ }
63
+ });
64
+
65
+ test('LOOP-02: nf-spec-regen.js exits 0 (fail-open) on malformed stdin JSON', () => {
66
+ const hookPath = path.join(__dirname, 'nf-spec-regen.js');
67
+ const result = spawnSync(process.execPath, [hookPath], {
68
+ input: 'not-valid-json',
69
+ encoding: 'utf8',
70
+ timeout: 10000,
71
+ });
72
+ assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 on malformed JSON (fail-open). Not yet implemented.');
73
+ });
@@ -5,6 +5,7 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
+ const { loadConfig, shouldRunHook } = require('./config-loader');
8
9
 
9
10
  // Read JSON from stdin
10
11
  let input = '';
@@ -13,6 +14,14 @@ process.stdin.on('data', chunk => input += chunk);
13
14
  process.stdin.on('end', () => {
14
15
  try {
15
16
  const data = JSON.parse(input);
17
+
18
+ // Profile guard — exit early if this hook is not active for the current profile
19
+ const config = loadConfig(data.workspace?.current_dir || process.cwd());
20
+ const profile = config.hook_profile || 'standard';
21
+ if (!shouldRunHook('nf-statusline', profile)) {
22
+ process.exit(0);
23
+ }
24
+
16
25
  const model = data.model?.display_name || 'Claude';
17
26
  const dir = data.workspace?.current_dir || process.cwd();
18
27
  const session = data.session_id || '';
@@ -66,14 +75,14 @@ process.stdin.on('end', () => {
66
75
  }
67
76
  }
68
77
 
69
- // QGSD update available?
78
+ // nForma update available?
70
79
  let gsdUpdate = '';
71
- const cacheFile = path.join(homeDir, '.claude', 'cache', 'qgsd-update-check.json');
80
+ const cacheFile = path.join(homeDir, '.claude', 'cache', 'nf-update-check.json');
72
81
  if (fs.existsSync(cacheFile)) {
73
82
  try {
74
83
  const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
75
84
  if (cache.update_available) {
76
- gsdUpdate = '\x1b[33m⬆ /qgsd:update\x1b[0m │ ';
85
+ gsdUpdate = '\x1b[33m⬆ /nf:update\x1b[0m │ ';
77
86
  }
78
87
  } catch (e) {}
79
88
  }
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // Test suite for hooks/nf-statusline.js
3
+ // Uses Node.js built-in test runner: node --test hooks/nf-statusline.test.js
4
+ //
5
+ // Each test spawns the hook as a child process with mock stdin (JSON payload).
6
+ // Captures stdout + exit code. The hook reads JSON from stdin and writes
7
+ // formatted statusline text to stdout.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { spawnSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const HOOK_PATH = path.join(__dirname, 'nf-statusline.js');
17
+
18
+ // Helper: run the hook with a given stdin JSON payload and optional extra env vars
19
+ function runHook(stdinPayload, extraEnv) {
20
+ const input = typeof stdinPayload === 'string'
21
+ ? stdinPayload
22
+ : JSON.stringify(stdinPayload);
23
+
24
+ const result = spawnSync('node', [HOOK_PATH], {
25
+ input,
26
+ encoding: 'utf8',
27
+ timeout: 5000,
28
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
29
+ });
30
+ return {
31
+ stdout: result.stdout || '',
32
+ stderr: result.stderr || '',
33
+ exitCode: result.status,
34
+ };
35
+ }
36
+
37
+ // Helper: create a temp directory structure, write a file inside it, return tempDir
38
+ function makeTempDir(suffix) {
39
+ const dir = path.join(os.tmpdir(), `nf-sl-test-${Date.now()}-${suffix}`);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ return dir;
42
+ }
43
+
44
+ // --- Test Cases ---
45
+
46
+ // TC1: Minimal payload — stdout contains model name and directory basename
47
+ test('TC1: minimal payload includes model name and directory name', () => {
48
+ const { stdout, exitCode } = runHook({
49
+ model: { display_name: 'TestModel' },
50
+ workspace: { current_dir: '/tmp/myproject' },
51
+ });
52
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
53
+ assert.ok(stdout.includes('TestModel'), 'stdout must include model name "TestModel"');
54
+ assert.ok(stdout.includes('myproject'), 'stdout must include directory basename "myproject"');
55
+ });
56
+
57
+ // TC2: Context at 100% remaining (0% used) → green bar, 0%
58
+ // rawUsed = 100 - 100 = 0; scaled = round(0 / 80 * 100) = 0; filled = 0 → all empty blocks
59
+ test('TC2: context at 100% remaining shows all-empty bar at 0%', () => {
60
+ const { stdout, exitCode } = runHook({
61
+ model: { display_name: 'M' },
62
+ context_window: { remaining_percentage: 100 },
63
+ });
64
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
65
+ assert.ok(stdout.includes('░░░░░░░░░░'), 'stdout must include all-empty bar (0% used)');
66
+ assert.ok(stdout.includes('0%'), 'stdout must include 0%');
67
+ });
68
+
69
+ // TC3: Context at 20% remaining (80% used → scaled 100%) → full bar, 100%
70
+ // rawUsed = 80; scaled = round(80/80 * 100) = 100; filled = 10 → full blocks
71
+ // At 100% scaled, the hook uses the skull emoji and blink+red ANSI code
72
+ test('TC3: context at 20% remaining shows full bar at 100% (skull zone)', () => {
73
+ const { stdout, exitCode } = runHook({
74
+ model: { display_name: 'M' },
75
+ context_window: { remaining_percentage: 20 },
76
+ });
77
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
78
+ assert.ok(stdout.includes('100%'), 'stdout must include 100%');
79
+ assert.ok(stdout.includes('██████████'), 'stdout must include full bar (10 filled segments)');
80
+ });
81
+
82
+ // TC4: Context at 51% remaining (49% used → scaled 61%) → green zone (scaled < 63)
83
+ // rawUsed = 49; scaled = round(49/80 * 100) = round(61.25) = 61; 61 < 63 → green
84
+ test('TC4: context at 51% remaining shows 61% in green (below 63% yellow threshold)', () => {
85
+ const { stdout, exitCode } = runHook({
86
+ model: { display_name: 'M' },
87
+ context_window: { remaining_percentage: 51 },
88
+ });
89
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
90
+ assert.ok(stdout.includes('61%'), 'stdout must include 61%');
91
+ assert.ok(stdout.includes('\x1b[32m'), 'stdout must include green ANSI code \\x1b[32m');
92
+ });
93
+
94
+ // TC5: Context at 36% remaining (64% used → scaled 80%) → yellow zone (63 <= scaled < 81)
95
+ // rawUsed = 64; scaled = round(64/80 * 100) = round(80) = 80; 63 <= 80 < 81 → yellow
96
+ test('TC5: context at 36% remaining shows 80% in yellow (63–80% yellow zone)', () => {
97
+ const { stdout, exitCode } = runHook({
98
+ model: { display_name: 'M' },
99
+ context_window: { remaining_percentage: 36 },
100
+ });
101
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
102
+ assert.ok(stdout.includes('80%'), 'stdout must include 80%');
103
+ assert.ok(stdout.includes('\x1b[33m'), 'stdout must include yellow ANSI code \\x1b[33m');
104
+ });
105
+
106
+ // TC6: Malformed JSON input → exits 0, stdout is empty (silent fail)
107
+ test('TC6: malformed JSON input exits 0 with empty stdout (silent fail)', () => {
108
+ const { stdout, exitCode } = runHook('this is not valid json');
109
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
110
+ assert.strictEqual(stdout, '', 'stdout must be empty on malformed JSON input');
111
+ });
112
+
113
+ // TC7: Update available — output includes '/nf:update'
114
+ test('TC7: update available banner shows /nf:update in output', () => {
115
+ const tempHome = makeTempDir('tc7');
116
+ const cacheDir = path.join(tempHome, '.claude', 'cache');
117
+ fs.mkdirSync(cacheDir, { recursive: true });
118
+ const cacheFile = path.join(cacheDir, 'nf-update-check.json');
119
+ fs.writeFileSync(cacheFile, JSON.stringify({ update_available: true, latest: '1.0.1' }), 'utf8');
120
+
121
+ try {
122
+ const { stdout, exitCode } = runHook(
123
+ { model: { display_name: 'M' } },
124
+ { HOME: tempHome }
125
+ );
126
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
127
+ assert.ok(stdout.includes('/nf:update'), 'stdout must include /nf:update when update is available');
128
+ } finally {
129
+ fs.rmSync(tempHome, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ // TC8: Task in progress — output includes the task's activeForm text
134
+ test('TC8: in-progress task is shown in statusline output', () => {
135
+ const tempHome = makeTempDir('tc8');
136
+ const todosDir = path.join(tempHome, '.claude', 'todos');
137
+ fs.mkdirSync(todosDir, { recursive: true });
138
+
139
+ const sessionId = 'sess123';
140
+ const todosFile = path.join(todosDir, `${sessionId}-agent-0.json`);
141
+ fs.writeFileSync(
142
+ todosFile,
143
+ JSON.stringify([{ status: 'in_progress', activeForm: 'Fix the thing' }]),
144
+ 'utf8'
145
+ );
146
+
147
+ try {
148
+ const { stdout, exitCode } = runHook(
149
+ { model: { display_name: 'M' }, session_id: sessionId },
150
+ { HOME: tempHome }
151
+ );
152
+ assert.strictEqual(exitCode, 0, 'exit code must be 0');
153
+ assert.ok(stdout.includes('Fix the thing'), 'stdout must include the in-progress task activeForm text');
154
+ } finally {
155
+ fs.rmSync(tempHome, { recursive: true, force: true });
156
+ }
157
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // hooks/qgsd-stop.js
2
+ // hooks/nf-stop.js
3
3
  // Stop hook — quorum verification gate for GSD planning commands.
4
4
  //
5
5
  // Reads JSON from stdin (Claude Code Stop event payload), applies guards in
@@ -7,9 +7,9 @@
7
7
  // evidence. Blocks with decision:block if a planning command was issued but
8
8
  // quorum tool calls are missing. Fails open on all errors.
9
9
  //
10
- // Config: ~/.claude/qgsd.json (two-layer merge via shared config-loader)
10
+ // Config: ~/.claude/nf.json (two-layer merge via shared config-loader)
11
11
  // Unavailability: reads ~/.claude.json mcpServers to detect which models are installed
12
- // (QGSD_CLAUDE_JSON env var overrides the path — for testing only)
12
+ // (NF_CLAUDE_JSON env var overrides the path — for testing only)
13
13
 
14
14
  'use strict';
15
15
 
@@ -17,9 +17,15 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const os = require('os');
19
19
 
20
- const { loadConfig, DEFAULT_CONFIG, slotToToolCall } = require('./config-loader');
20
+ const { loadConfig, DEFAULT_CONFIG, slotToToolCall, shouldRunHook } = require('./config-loader');
21
21
  const { schema_version } = require('./conformance-schema.cjs');
22
22
 
23
+ // Cache module — fail-open: if require fails, all cache logic is skipped
24
+ let cacheModule = null;
25
+ try {
26
+ cacheModule = require(path.join(__dirname, '..', 'bin', 'quorum-cache.cjs'));
27
+ } catch (_) { /* fail-open: cache unavailable */ }
28
+
23
29
  // Appends a structured conformance event to .planning/conformance-events.jsonl.
24
30
  // Uses appendFileSync (atomic for writes < POSIX PIPE_BUF = 4096 bytes).
25
31
  // Always wrapped in try/catch — hooks are fail-open; never crashes on logging failure.
@@ -30,14 +36,14 @@ function appendConformanceEvent(event) {
30
36
  const logPath = pp.resolve(process.cwd(), 'conformance-events');
31
37
  fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
32
38
  } catch (err) {
33
- process.stderr.write('[qgsd] conformance log write failed: ' + err.message + '\n');
39
+ process.stderr.write('[nf] conformance log write failed: ' + err.message + '\n');
34
40
  }
35
41
  }
36
42
 
37
- // Builds the regex that matches /gsd:<quorum-command> or /qgsd:<quorum-command> in any text.
43
+ // Builds the regex that matches /nf:<cmd>, /gsd:<cmd>, or /qgsd:<cmd> in any text.
38
44
  function buildCommandPattern(quorumCommands) {
39
45
  const escaped = quorumCommands.map(c => c.replace(/-/g, '\\-'));
40
- return new RegExp('\\/q?gsd:(' + escaped.join('|') + ')');
46
+ return new RegExp('\\/(nf|q?gsd):(' + escaped.join('|') + ')');
41
47
  }
42
48
 
43
49
  // Returns true if a parsed JSONL user entry is a human text message.
@@ -119,9 +125,9 @@ function hasQuorumCommand(currentTurnLines, cmdPattern) {
119
125
  return false;
120
126
  }
121
127
 
122
- // Extracts the matched /gsd:<command> or /qgsd:<command> text from the first matching user line.
128
+ // Extracts the matched /nf:<cmd>, /gsd:<cmd>, or /qgsd:<cmd> text from the first matching user line.
123
129
  // Uses XML-tag-first strategy: prefers the <command-name> tag for accurate command identification.
124
- // Falls back to first 300 chars of message text, then to '/qgsd:plan-phase' as ultimate fallback.
130
+ // Falls back to first 300 chars of message text, then to '/nf:plan-phase' as ultimate fallback.
125
131
  function extractCommand(currentTurnLines, cmdPattern) {
126
132
  for (const line of currentTurnLines) {
127
133
  try {
@@ -148,12 +154,12 @@ function extractCommand(currentTurnLines, cmdPattern) {
148
154
  // Skip malformed lines
149
155
  }
150
156
  }
151
- return '/qgsd:plan-phase';
157
+ return '/nf:plan-phase';
152
158
  }
153
159
 
154
- // Returns true if any assistant turn used Task(subagent_type=qgsd-quorum-slot-worker).
160
+ // Returns true if any assistant turn used Task(subagent_type=nf-quorum-slot-worker).
155
161
  // Slot-workers are the inline dispatch mechanism — one per active slot per round.
156
- // Replaced qgsd-quorum-orchestrator (deprecated quick-103) as full quorum evidence.
162
+ // Replaced nf-quorum-orchestrator (deprecated quick-103) as full quorum evidence.
157
163
  function wasSlotWorkerUsed(currentTurnLines) {
158
164
  for (const line of currentTurnLines) {
159
165
  try {
@@ -165,7 +171,7 @@ function wasSlotWorkerUsed(currentTurnLines) {
165
171
  if (block.type !== 'tool_use' || block.name !== 'Task') continue;
166
172
  const input = block.input || {};
167
173
  const subagentType = input.subagent_type || input.subagentType || '';
168
- if (subagentType === 'qgsd-quorum-slot-worker') return true;
174
+ if (subagentType === 'nf-quorum-slot-worker') return true;
169
175
  }
170
176
  } catch { /* skip */ }
171
177
  }
@@ -281,7 +287,7 @@ function buildAgentPool(config) {
281
287
  // Returns an array of derived tool prefixes (e.g. ['mcp__codex-cli-1__', 'mcp__gemini-cli-1__']).
282
288
  // Returns null if the file is missing or malformed — callers treat null as "unknown" (conservative).
283
289
  //
284
- // TESTING ONLY: set QGSD_CLAUDE_JSON env var to override the file path.
290
+ // TESTING ONLY: set NF_CLAUDE_JSON env var to override the file path.
285
291
  // In production, always reads ~/.claude.json.
286
292
  //
287
293
  // KNOWN LIMITATION: Only reads ~/.claude.json (user-scoped MCPs). Project-scoped MCPs
@@ -289,14 +295,14 @@ function buildAgentPool(config) {
289
295
  // project level, it will be classified as unavailable and skipped (fail-open).
290
296
  // In practice, quorum models (Codex, Gemini, OpenCode) are global tools.
291
297
  function getAvailableMcpPrefixes() {
292
- const claudeJsonPath = process.env.QGSD_CLAUDE_JSON || path.join(os.homedir(), '.claude.json');
298
+ const claudeJsonPath = process.env.NF_CLAUDE_JSON || path.join(os.homedir(), '.claude.json');
293
299
  if (!fs.existsSync(claudeJsonPath)) return null;
294
300
  try {
295
301
  const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
296
302
  const servers = d.mcpServers || {};
297
303
  return Object.keys(servers).map(name => 'mcp__' + name + '__');
298
304
  } catch (e) {
299
- process.stderr.write('[qgsd] WARNING: Could not parse ~/.claude.json: ' + e.message + '\n');
305
+ process.stderr.write('[nf] WARNING: Could not parse ~/.claude.json: ' + e.message + '\n');
300
306
  return null;
301
307
  }
302
308
  }
@@ -313,6 +319,35 @@ const ARTIFACT_PATTERNS = [
313
319
  /PROJECT\.md/, // PROJECT.md (new-project early commit)
314
320
  ];
315
321
 
322
+ // Counts the number of distinct quorum dispatch rounds in the current turn.
323
+ // A dispatch round is one assistant message that contains at least one Task tool_use
324
+ // block targeting nf-quorum-slot-worker. Multiple parallel dispatches within one
325
+ // assistant message count as a single round (they are dispatched simultaneously).
326
+ // Returns Math.max(1, rounds) — at least round 1 even if no explicit dispatch detected.
327
+ function countDeliberationRounds(currentTurnLines) {
328
+ let rounds = 0;
329
+ for (const line of currentTurnLines) {
330
+ try {
331
+ const entry = JSON.parse(line);
332
+ if (entry.type !== 'assistant') continue;
333
+ const content = entry.message && entry.message.content;
334
+ if (!Array.isArray(content)) continue;
335
+ let hasSlotWorker = false;
336
+ for (const block of content) {
337
+ if (block.type === 'tool_use' && block.name === 'Task') {
338
+ const inputStr = JSON.stringify(block.input || {});
339
+ if (inputStr.includes('nf-quorum-slot-worker')) {
340
+ hasSlotWorker = true;
341
+ break; // One per assistant message — no need to check more blocks
342
+ }
343
+ }
344
+ }
345
+ if (hasSlotWorker) rounds++;
346
+ } catch { /* skip malformed lines */ }
347
+ }
348
+ return Math.max(1, rounds);
349
+ }
350
+
316
351
  // Returns true if the current turn contains a Bash tool_use block that BOTH:
317
352
  // (a) invokes gsd-tools.cjs commit, AND
318
353
  // (b) references a planning artifact file path (not codebase/*.md).
@@ -401,6 +436,54 @@ function deriveMissingToolName(modelKey, modelDef) {
401
436
  return prefix + modelKey;
402
437
  }
403
438
 
439
+ // Returns true if any user entry in currentTurnLines contains the NF_CACHE_HIT marker.
440
+ // This marker is injected by nf-prompt.js when a valid cache hit is found.
441
+ function hasCacheHitMarker(currentTurnLines) {
442
+ for (const line of currentTurnLines) {
443
+ try {
444
+ const entry = JSON.parse(line);
445
+ if (entry.type !== 'user') continue;
446
+ const content = entry.message?.content;
447
+ let text = '';
448
+ if (typeof content === 'string') {
449
+ text = content;
450
+ } else if (Array.isArray(content)) {
451
+ text = content
452
+ .filter(c => c?.type === 'text')
453
+ .map(c => c.text || '')
454
+ .join('');
455
+ }
456
+ if (text.includes('<!-- NF_CACHE_HIT -->')) return true;
457
+ } catch { /* skip malformed lines */ }
458
+ }
459
+ return false;
460
+ }
461
+
462
+ // Extracts the cache key from NF_CACHE_KEY marker in currentTurnLines.
463
+ // Returns the hex string or null if not found.
464
+ function extractCacheKey(currentTurnLines) {
465
+ const keyPattern = /<!-- NF_CACHE_KEY:([a-f0-9]+) -->/;
466
+ for (const line of currentTurnLines) {
467
+ try {
468
+ const entry = JSON.parse(line);
469
+ if (entry.type !== 'user') continue;
470
+ const content = entry.message?.content;
471
+ let text = '';
472
+ if (typeof content === 'string') {
473
+ text = content;
474
+ } else if (Array.isArray(content)) {
475
+ text = content
476
+ .filter(c => c?.type === 'text')
477
+ .map(c => c.text || '')
478
+ .join('');
479
+ }
480
+ const m = text.match(keyPattern);
481
+ if (m) return m[1];
482
+ } catch { /* skip malformed lines */ }
483
+ }
484
+ return null;
485
+ }
486
+
404
487
  function main() {
405
488
  let raw = '';
406
489
  process.stdin.setEncoding('utf8');
@@ -426,6 +509,12 @@ function main() {
426
509
 
427
510
  const config = loadConfig();
428
511
 
512
+ // Profile guard — exit early if this hook is not active for the current profile
513
+ const profile = config.hook_profile || 'standard';
514
+ if (!shouldRunHook('nf-stop', profile)) {
515
+ process.exit(0);
516
+ }
517
+
429
518
  // Read and split transcript JSONL; skip empty lines
430
519
  const lines = fs.readFileSync(input.transcript_path, 'utf8')
431
520
  .split('\n')
@@ -435,7 +524,10 @@ function main() {
435
524
  const currentTurnLines = getCurrentTurnLines(lines);
436
525
 
437
526
  // Build command pattern once; reuse for detection and extraction
438
- const cmdPattern = buildCommandPattern(config.quorum_commands);
527
+ // Strict mode: match ANY /nf: or /gsd: or /qgsd: command, not just quorum_commands list.
528
+ const cmdPattern = profile === 'strict'
529
+ ? /\/(nf|q?gsd):[\w][\w-]*/
530
+ : buildCommandPattern(config.quorum_commands);
439
531
 
440
532
  // GUARD 4: Only enforce quorum if a planning command is in current turn (STOP-06)
441
533
  if (!hasQuorumCommand(currentTurnLines, cmdPattern)) {
@@ -469,6 +561,24 @@ function main() {
469
561
  process.exit(0); // Solo mode: Claude's vote is the quorum — no block
470
562
  }
471
563
 
564
+ // GUARD 7: Cache hit bypass — nf-prompt.js validated the cache entry and injected
565
+ // the NF_CACHE_HIT marker. Trust it: the entry was completed, within TTL, and
566
+ // matches git HEAD + quorum_active composition (EventualConsensus preserved).
567
+ if (hasCacheHitMarker(currentTurnLines)) {
568
+ appendConformanceEvent({
569
+ ts: new Date().toISOString(),
570
+ phase: 'DECIDING',
571
+ action: 'quorum_complete',
572
+ cache_hit: true,
573
+ pass_at_k: 0,
574
+ slots_available: 0,
575
+ vote_result: 0,
576
+ outcome: 'APPROVE',
577
+ schema_version,
578
+ });
579
+ process.exit(0); // Cache hit: quorum approved via cached result
580
+ }
581
+
472
582
  // Build agent pool from config
473
583
  const agentPool = buildAgentPool(config);
474
584
 
@@ -481,7 +591,7 @@ function main() {
481
591
  }
482
592
 
483
593
  // Ceiling: require maxSize successful (non-error) responses from the full pool.
484
- // Named maxSize for consistency with qgsd-prompt.js and the config schema.
594
+ // Named maxSize for consistency with nf-prompt.js and the config schema.
485
595
  // --n N override: N total participants means N-1 external models required.
486
596
  const maxSize = quorumSizeOverride !== null && quorumSizeOverride > 1
487
597
  ? quorumSizeOverride - 1 // --n N means N-1 external models required
@@ -532,10 +642,34 @@ function main() {
532
642
  process.exit(0);
533
643
  }
534
644
 
645
+ // Cache backfill: update pending cache entry with vote result after successful quorum
646
+ if (cacheModule) {
647
+ try {
648
+ const cKey = extractCacheKey(currentTurnLines);
649
+ if (cKey) {
650
+ const cacheDir = path.join(process.cwd(), '.planning', '.quorum-cache');
651
+ const pendingEntry = cacheModule.readCache(cKey, cacheDir);
652
+ // readCache returns null for pending entries (no completed field) — read raw instead
653
+ const cacheFilePath = path.join(cacheDir, `${cKey}.json`);
654
+ if (fs.existsSync(cacheFilePath)) {
655
+ const rawEntry = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'));
656
+ rawEntry.vote_result = successCount;
657
+ rawEntry.outcome = 'APPROVE';
658
+ rawEntry.completed = new Date().toISOString();
659
+ cacheModule.writeCache(cKey, rawEntry, cacheDir);
660
+ }
661
+ }
662
+ } catch (backfillErr) {
663
+ // Fail-open: backfill failure never blocks quorum approval
664
+ process.stderr.write('[nf] cache backfill failed (fail-open): ' + (backfillErr.message || backfillErr) + '\n');
665
+ }
666
+ }
667
+
535
668
  appendConformanceEvent({
536
669
  ts: new Date().toISOString(),
537
670
  phase: 'DECIDING',
538
671
  action: 'quorum_complete',
672
+ pass_at_k: countDeliberationRounds(currentTurnLines),
539
673
  slots_available: agentPool.length,
540
674
  vote_result: successCount,
541
675
  outcome: 'APPROVE',