@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,185 @@
1
+ #!/usr/bin/env node
2
+ // hooks/nf-session-start.js
3
+ // SessionStart hook — syncs nForma keychain secrets into ~/.claude.json
4
+ // on every session start so mcpServers env blocks always reflect current keychain state.
5
+ //
6
+ // Runs synchronously (hook expects process to exit) — uses async IIFE with catch.
7
+
8
+ 'use strict';
9
+
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const fs = require('fs');
13
+
14
+ const { loadConfig, shouldRunHook } = require('./config-loader');
15
+
16
+ // ─── Stdin accumulation (for hook input JSON containing cwd) ─────────────────
17
+ let _stdinRaw = '';
18
+ process.stdin.setEncoding('utf8');
19
+ process.stdin.on('data', c => _stdinRaw += c);
20
+
21
+ let _stdinReady;
22
+ const _stdinPromise = new Promise(resolve => { _stdinReady = resolve; });
23
+ process.stdin.on('end', () => _stdinReady());
24
+
25
+ // Locate secrets.cjs — try installed global path first, then local dev path.
26
+ //
27
+ // IMPORTANT: install.js copies bin/*.cjs to ~/.claude/nf-bin/ (not ~/.claude/nf/bin/).
28
+ // See bin/install.js line ~1679: binDest = path.join(targetDir, 'nf-bin')
29
+ // where targetDir = os.homedir() + '/.claude'.
30
+ function findSecrets() {
31
+ const candidates = [
32
+ path.join(os.homedir(), '.claude', 'nf-bin', 'secrets.cjs'), // installed path
33
+ path.join(__dirname, '..', 'bin', 'secrets.cjs'), // local dev path
34
+ ];
35
+ for (const p of candidates) {
36
+ try {
37
+ return require(p);
38
+ } catch (_) {}
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // ─── State reminder parser ──────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Parse STATE.md content for an in-progress phase and return a terse reminder.
47
+ * Returns null if no reminder is needed (complete, not started, or missing fields).
48
+ * @param {string} stateContent - Raw STATE.md content.
49
+ * @returns {string|null}
50
+ */
51
+ function parseStateForReminder(stateContent) {
52
+ if (!stateContent || typeof stateContent !== 'string') return null;
53
+
54
+ const phaseMatch = stateContent.match(/Phase:\s*(.+)/);
55
+ const statusMatch = stateContent.match(/Status:\s*(.+)/);
56
+ const planMatch = stateContent.match(/Plan:\s*(.+)/);
57
+ const lastMatch = stateContent.match(/Last activity:\s*(.+)/);
58
+
59
+ if (!phaseMatch) return null;
60
+ if (!statusMatch) return null;
61
+
62
+ const status = statusMatch[1].trim();
63
+ if (status === 'Complete' || status === 'Not started') return null;
64
+
65
+ const phase = phaseMatch[1].trim();
66
+ const plan = planMatch ? planMatch[1].trim() : 'unknown plan';
67
+ const lastActivity = lastMatch ? lastMatch[1].trim() : 'unknown';
68
+
69
+ return 'SESSION STATE REMINDER: Phase ' + phase + ' -- ' + plan + ' -- ' + status + ' (last: ' + lastActivity + ')';
70
+ }
71
+
72
+ (async () => {
73
+ // Resolve project cwd from hook input JSON
74
+ await _stdinPromise;
75
+ let _hookCwd = process.cwd();
76
+ try { _hookCwd = JSON.parse(_stdinRaw).cwd || process.cwd(); } catch (_) {}
77
+
78
+ // Profile guard — exit early if this hook is not active for the current profile
79
+ const config = loadConfig(_hookCwd);
80
+ const profile = config.hook_profile || 'standard';
81
+ if (!shouldRunHook('nf-session-start', profile)) {
82
+ process.exit(0);
83
+ }
84
+
85
+ const secrets = findSecrets();
86
+ if (!secrets) {
87
+ // silently skip — nForma may not be installed yet or keytar absent
88
+ process.exit(0);
89
+ }
90
+ try {
91
+ await secrets.syncToClaudeJson(secrets.SERVICE);
92
+ } catch (e) {
93
+ // Non-fatal — write to stderr for debug logs, but never block session start
94
+ process.stderr.write('[nf-session-start] sync error: ' + e.message + '\n');
95
+ }
96
+
97
+ // Populate CCR config from keytar (fail-silent — CCR may not be installed)
98
+ try {
99
+ const { execFileSync } = require('child_process');
100
+ const nodeFsRef = require('fs');
101
+ const ccrCandidates = [
102
+ path.join(os.homedir(), '.claude', 'nf-bin', 'ccr-secure-config.cjs'),
103
+ path.join(__dirname, '..', 'bin', 'ccr-secure-config.cjs'),
104
+ ];
105
+ let ccrConfigPath = null;
106
+ for (const p of ccrCandidates) {
107
+ if (nodeFsRef.existsSync(p)) { ccrConfigPath = p; break; }
108
+ }
109
+ if (ccrConfigPath) {
110
+ execFileSync(process.execPath, [ccrConfigPath], { stdio: 'pipe', timeout: 10000 });
111
+ }
112
+ } catch (e) {
113
+ process.stderr.write('[nf-session-start] CCR config error: ' + e.message + '\n');
114
+ }
115
+
116
+ // Collect all additionalContext pieces — write once at the end
117
+ const _contextPieces = [];
118
+
119
+ // Telemetry surfacing — inject top unsurfaced issue as additionalContext
120
+ // Guard: only active when running inside the nForma dev repo itself
121
+ try {
122
+ const pkgPath = path.join(_hookCwd, 'package.json');
123
+ const isNfRepo = fs.existsSync(pkgPath) &&
124
+ JSON.parse(fs.readFileSync(pkgPath, 'utf8')).name === 'nforma';
125
+ const fixesPath = path.join(_hookCwd, '.planning', 'telemetry', 'pending-fixes.json');
126
+ if (isNfRepo && fs.existsSync(fixesPath)) {
127
+ const fixes = JSON.parse(fs.readFileSync(fixesPath, 'utf8'));
128
+ const issue = (fixes.issues || []).find(i => !i.surfaced && i.priority >= 50);
129
+ if (issue) {
130
+ issue.surfaced = true;
131
+ issue.surfacedAt = new Date().toISOString();
132
+ fs.writeFileSync(fixesPath, JSON.stringify(fixes, null, 2), 'utf8');
133
+ _contextPieces.push('Telemetry alert [priority=' + issue.priority + ']: ' + issue.description + '\nSuggested fix: ' + issue.action);
134
+ }
135
+ }
136
+ } catch (_) {}
137
+
138
+ // Session state reminder — inject brief context when work is in progress
139
+ try {
140
+ const statePath = path.join(_hookCwd, '.planning', 'STATE.md');
141
+ if (fs.existsSync(statePath)) {
142
+ const stateContent = fs.readFileSync(statePath, 'utf8');
143
+ const reminder = parseStateForReminder(stateContent);
144
+ if (reminder) {
145
+ _contextPieces.push(reminder);
146
+ }
147
+ }
148
+ } catch (_) {}
149
+
150
+ // Write combined additionalContext output (once)
151
+ if (_contextPieces.length > 0) {
152
+ process.stdout.write(JSON.stringify({
153
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: _contextPieces.join('\n\n') }
154
+ }));
155
+ }
156
+
157
+ // Memory staleness check — warn about outdated MEMORY.md entries
158
+ try {
159
+ const validateMemoryCandidates = [
160
+ path.join(os.homedir(), '.claude', 'nf-bin', 'validate-memory.cjs'),
161
+ path.join(__dirname, '..', 'bin', 'validate-memory.cjs'),
162
+ ];
163
+ let validateMemoryMod = null;
164
+ for (const p of validateMemoryCandidates) {
165
+ try { validateMemoryMod = require(p); break; } catch (_) {}
166
+ }
167
+ if (validateMemoryMod) {
168
+ const { findings } = validateMemoryMod.validateMemory({ cwd: _hookCwd, quiet: true });
169
+ if (findings.length > 0) {
170
+ const summary = findings
171
+ .map(f => '[memory-check] ' + f.message)
172
+ .join('\n');
173
+ process.stderr.write(summary + '\n');
174
+ }
175
+ }
176
+ } catch (_) {}
177
+
178
+ process.exit(0);
179
+ })();
180
+
181
+ // Export for unit testing
182
+ if (typeof module !== 'undefined') {
183
+ module.exports = module.exports || {};
184
+ module.exports.parseStateForReminder = parseStateForReminder;
185
+ }
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // Test suite for hooks/nf-session-start.js
3
+ // Uses Node.js built-in test runner: node --test hooks/nf-session-start.test.js
4
+ //
5
+ // All tests spawn the hook as a child process with a mock stdin JSON payload.
6
+ // The hook has no exports — only subprocess integration tests are possible.
7
+ // Timeout is 8000ms to account for async operations (bootstrap secrets sync).
8
+
9
+ 'use strict';
10
+
11
+ const { test } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const { spawnSync } = require('child_process');
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const HOOK_PATH = path.join(__dirname, 'nf-session-start.js');
19
+
20
+ // ─── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function makeTmpDir() {
23
+ const dir = path.join(
24
+ os.tmpdir(),
25
+ 'nf-ss-' + Date.now() + '-' + Math.random().toString(36).slice(2)
26
+ );
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ function runHook(stdinPayload, opts = {}) {
32
+ const input = stdinPayload === null ? '' : JSON.stringify(stdinPayload);
33
+ const result = spawnSync('node', [HOOK_PATH], {
34
+ input,
35
+ encoding: 'utf8',
36
+ timeout: 8000,
37
+ ...opts,
38
+ });
39
+ return {
40
+ stdout: result.stdout || '',
41
+ stderr: result.stderr || '',
42
+ exitCode: result.status,
43
+ parsed: (() => { try { return JSON.parse(result.stdout); } catch { return null; } })(),
44
+ };
45
+ }
46
+
47
+ // Write a minimal package.json to the given dir.
48
+ function writePackageJson(dir, name) {
49
+ fs.writeFileSync(
50
+ path.join(dir, 'package.json'),
51
+ JSON.stringify({ name }, null, 2),
52
+ 'utf8'
53
+ );
54
+ }
55
+
56
+ // Write pending-fixes.json under .planning/telemetry/ in the given dir.
57
+ function writePendingFixes(dir, issues) {
58
+ const telemetryDir = path.join(dir, '.planning', 'telemetry');
59
+ fs.mkdirSync(telemetryDir, { recursive: true });
60
+ fs.writeFileSync(
61
+ path.join(telemetryDir, 'pending-fixes.json'),
62
+ JSON.stringify({ issues }, null, 2),
63
+ 'utf8'
64
+ );
65
+ }
66
+
67
+ // Read pending-fixes.json back from disk.
68
+ function readPendingFixes(dir) {
69
+ const fixesPath = path.join(dir, '.planning', 'telemetry', 'pending-fixes.json');
70
+ return JSON.parse(fs.readFileSync(fixesPath, 'utf8'));
71
+ }
72
+
73
+ // ─── Subprocess integration tests ───────────────────────────────────────────
74
+
75
+ test('valid empty JSON stdin → exits 0 (secrets not found, silently skips)', () => {
76
+ // Pass a cwd that is not a nForma repo so telemetry branch also skips.
77
+ const tmpDir = makeTmpDir();
78
+ const { exitCode, stderr } = runHook({ cwd: tmpDir });
79
+ assert.equal(exitCode, 0, 'hook must exit 0 even when secrets module is absent');
80
+ // There should be no hard error in stderr (sync errors are expected-silent here)
81
+ // We allow stderr to contain debug notes but must not crash.
82
+ assert.ok(
83
+ !stderr.includes('TypeError') && !stderr.includes('ReferenceError'),
84
+ 'should not throw a JS error on stderr: ' + stderr
85
+ );
86
+ });
87
+
88
+ test('invalid JSON stdin → exits 0 (fail-open)', () => {
89
+ const result = spawnSync('node', [HOOK_PATH], {
90
+ input: 'not valid json {{{',
91
+ encoding: 'utf8',
92
+ timeout: 8000,
93
+ });
94
+ assert.equal(result.status, 0, 'hook must exit 0 on JSON parse failure');
95
+ });
96
+
97
+ test('empty stdin → exits 0 (fail-open)', () => {
98
+ const result = spawnSync('node', [HOOK_PATH], {
99
+ input: '',
100
+ encoding: 'utf8',
101
+ timeout: 8000,
102
+ });
103
+ assert.equal(result.status, 0, 'hook must exit 0 when stdin is empty');
104
+ });
105
+
106
+ test('telemetry: unsurfaced issue with priority >= 50 → outputs additionalContext and marks issue surfaced', () => {
107
+ const tmpDir = makeTmpDir();
108
+ writePackageJson(tmpDir, 'nforma');
109
+ writePendingFixes(tmpDir, [
110
+ {
111
+ id: 'fix-001',
112
+ description: 'Quorum scoreboard drift detected in last 3 sessions',
113
+ action: 'Run node bin/update-scoreboard.cjs --repair',
114
+ priority: 80,
115
+ surfaced: false,
116
+ },
117
+ ]);
118
+
119
+ const { exitCode, parsed } = runHook({ cwd: tmpDir });
120
+
121
+ assert.equal(exitCode, 0, 'hook must exit 0');
122
+ assert.ok(parsed !== null, 'stdout should be valid JSON');
123
+ assert.ok(parsed.hookSpecificOutput, 'output must have hookSpecificOutput');
124
+ assert.equal(
125
+ parsed.hookSpecificOutput.hookEventName,
126
+ 'SessionStart',
127
+ 'hookEventName must be SessionStart'
128
+ );
129
+
130
+ const ctx = parsed.hookSpecificOutput.additionalContext;
131
+ assert.ok(typeof ctx === 'string' && ctx.length > 0, 'additionalContext must be a non-empty string');
132
+ assert.ok(
133
+ ctx.includes('Quorum scoreboard drift detected'),
134
+ 'additionalContext must include the issue description'
135
+ );
136
+ assert.ok(
137
+ ctx.includes('priority=80'),
138
+ 'additionalContext must include the priority'
139
+ );
140
+ assert.ok(
141
+ ctx.includes('node bin/update-scoreboard.cjs --repair'),
142
+ 'additionalContext must include the suggested action'
143
+ );
144
+
145
+ // Verify the file was updated on disk to mark the issue surfaced.
146
+ const updated = readPendingFixes(tmpDir);
147
+ const issue = updated.issues[0];
148
+ assert.equal(issue.surfaced, true, 'issue.surfaced must be set to true after surfacing');
149
+ assert.ok(
150
+ typeof issue.surfacedAt === 'string' && issue.surfacedAt.length > 0,
151
+ 'issue.surfacedAt must be an ISO timestamp string'
152
+ );
153
+ });
154
+
155
+ test('telemetry: issue already surfaced (surfaced=true) → no additionalContext output', () => {
156
+ const tmpDir = makeTmpDir();
157
+ writePackageJson(tmpDir, 'nforma');
158
+ writePendingFixes(tmpDir, [
159
+ {
160
+ id: 'fix-002',
161
+ description: 'Old issue already surfaced',
162
+ action: 'Nothing to do',
163
+ priority: 90,
164
+ surfaced: true,
165
+ surfacedAt: '2026-02-01T00:00:00.000Z',
166
+ },
167
+ ]);
168
+
169
+ const { exitCode, stdout } = runHook({ cwd: tmpDir });
170
+
171
+ assert.equal(exitCode, 0, 'hook must exit 0');
172
+ // When no telemetry issue is surfaced the hook writes nothing to stdout.
173
+ assert.equal(stdout.trim(), '', 'stdout must be empty when issue is already surfaced');
174
+ });
175
+
176
+ test('telemetry: priority below 50 → no additionalContext output', () => {
177
+ const tmpDir = makeTmpDir();
178
+ writePackageJson(tmpDir, 'nforma');
179
+ writePendingFixes(tmpDir, [
180
+ {
181
+ id: 'fix-003',
182
+ description: 'Low priority noise item',
183
+ action: 'Ignore for now',
184
+ priority: 30,
185
+ surfaced: false,
186
+ },
187
+ ]);
188
+
189
+ const { exitCode, stdout } = runHook({ cwd: tmpDir });
190
+
191
+ assert.equal(exitCode, 0, 'hook must exit 0');
192
+ assert.equal(stdout.trim(), '', 'stdout must be empty when issue priority is below 50');
193
+ });
194
+
195
+ test('telemetry: priority exactly 50 → outputs additionalContext (boundary value)', () => {
196
+ const tmpDir = makeTmpDir();
197
+ writePackageJson(tmpDir, 'nforma');
198
+ writePendingFixes(tmpDir, [
199
+ {
200
+ id: 'fix-004',
201
+ description: 'Boundary priority issue at exactly 50',
202
+ action: 'Check threshold logic',
203
+ priority: 50,
204
+ surfaced: false,
205
+ },
206
+ ]);
207
+
208
+ const { exitCode, parsed } = runHook({ cwd: tmpDir });
209
+
210
+ assert.equal(exitCode, 0, 'hook must exit 0');
211
+ assert.ok(parsed !== null, 'stdout should be valid JSON at boundary priority=50');
212
+ const ctx = parsed.hookSpecificOutput.additionalContext;
213
+ assert.ok(
214
+ ctx.includes('Boundary priority issue at exactly 50'),
215
+ 'additionalContext must include the boundary issue description'
216
+ );
217
+ });
218
+
219
+ test('non-nForma repo (package.json name != "nforma") → no telemetry output', () => {
220
+ const tmpDir = makeTmpDir();
221
+ writePackageJson(tmpDir, 'some-other-project');
222
+ writePendingFixes(tmpDir, [
223
+ {
224
+ id: 'fix-005',
225
+ description: 'Should never be surfaced in non-nForma repo',
226
+ action: 'N/A',
227
+ priority: 99,
228
+ surfaced: false,
229
+ },
230
+ ]);
231
+
232
+ const { exitCode, stdout } = runHook({ cwd: tmpDir });
233
+
234
+ assert.equal(exitCode, 0, 'hook must exit 0');
235
+ assert.equal(stdout.trim(), '', 'stdout must be empty for non-nForma repo');
236
+
237
+ // Verify the file was NOT modified (issue.surfaced remains false).
238
+ const unchanged = readPendingFixes(tmpDir);
239
+ assert.equal(
240
+ unchanged.issues[0].surfaced,
241
+ false,
242
+ 'issue.surfaced must remain false when repo is not nForma'
243
+ );
244
+ });
245
+
246
+ test('missing .planning/telemetry/pending-fixes.json → exits 0 silently', () => {
247
+ const tmpDir = makeTmpDir();
248
+ writePackageJson(tmpDir, 'nforma');
249
+ // Do NOT write pending-fixes.json — directory does not even exist.
250
+
251
+ const { exitCode, stdout, stderr } = runHook({ cwd: tmpDir });
252
+
253
+ assert.equal(exitCode, 0, 'hook must exit 0 when pending-fixes.json is absent');
254
+ assert.equal(stdout.trim(), '', 'stdout must be empty when pending-fixes.json is absent');
255
+ assert.ok(
256
+ !stderr.includes('TypeError') && !stderr.includes('ReferenceError'),
257
+ 'no JS error on stderr when telemetry file is absent: ' + stderr
258
+ );
259
+ });
260
+
261
+ test('telemetry: multiple issues, only first unsurfaced high-priority one is surfaced', () => {
262
+ const tmpDir = makeTmpDir();
263
+ writePackageJson(tmpDir, 'nforma');
264
+ writePendingFixes(tmpDir, [
265
+ {
266
+ id: 'fix-low',
267
+ description: 'Low priority skipped item',
268
+ action: 'Skip me',
269
+ priority: 20,
270
+ surfaced: false,
271
+ },
272
+ {
273
+ id: 'fix-surfaced',
274
+ description: 'Already surfaced item',
275
+ action: 'Already done',
276
+ priority: 95,
277
+ surfaced: true,
278
+ surfacedAt: '2026-02-01T00:00:00.000Z',
279
+ },
280
+ {
281
+ id: 'fix-high',
282
+ description: 'High priority unsurfaced item that should be picked',
283
+ action: 'Fix the high priority thing',
284
+ priority: 75,
285
+ surfaced: false,
286
+ },
287
+ {
288
+ id: 'fix-second-high',
289
+ description: 'Second high priority item that should NOT be picked this session',
290
+ action: 'Fix the second thing',
291
+ priority: 70,
292
+ surfaced: false,
293
+ },
294
+ ]);
295
+
296
+ const { exitCode, parsed } = runHook({ cwd: tmpDir });
297
+
298
+ assert.equal(exitCode, 0, 'hook must exit 0');
299
+ assert.ok(parsed !== null, 'stdout should be valid JSON');
300
+ const ctx = parsed.hookSpecificOutput.additionalContext;
301
+ assert.ok(
302
+ ctx.includes('High priority unsurfaced item that should be picked'),
303
+ 'additionalContext must include the first eligible unsurfaced issue'
304
+ );
305
+ assert.ok(
306
+ !ctx.includes('Second high priority item that should NOT be picked'),
307
+ 'additionalContext must NOT include the second unsurfaced issue'
308
+ );
309
+
310
+ // Verify only fix-high was marked surfaced on disk.
311
+ const updated = readPendingFixes(tmpDir);
312
+ const fixHigh = updated.issues.find(i => i.id === 'fix-high');
313
+ const fixSecond = updated.issues.find(i => i.id === 'fix-second-high');
314
+ assert.equal(fixHigh.surfaced, true, 'fix-high must be marked surfaced');
315
+ assert.equal(fixSecond.surfaced, false, 'fix-second-high must remain unsurfaced');
316
+ });
317
+
318
+ test('cwd field absent in stdin JSON → exits 0 (defaults to process.cwd, no crash)', () => {
319
+ // Pass an object with no cwd field. The hook should default to process.cwd()
320
+ // and not crash regardless of whether that directory has a nForma package.json.
321
+ const { exitCode } = runHook({});
322
+ assert.equal(exitCode, 0, 'hook must exit 0 when cwd is absent from stdin');
323
+ });
324
+
325
+ test('stdout is either empty or valid JSON (never partial/corrupt output)', () => {
326
+ const tmpDir = makeTmpDir();
327
+ // Repo with a surfaceable issue to exercise the stdout write path.
328
+ writePackageJson(tmpDir, 'nforma');
329
+ writePendingFixes(tmpDir, [
330
+ {
331
+ id: 'fix-json-integrity',
332
+ description: 'Test JSON output integrity',
333
+ action: 'Verify output is parseable',
334
+ priority: 60,
335
+ surfaced: false,
336
+ },
337
+ ]);
338
+
339
+ const { stdout, exitCode } = runHook({ cwd: tmpDir });
340
+
341
+ assert.equal(exitCode, 0);
342
+ if (stdout.trim().length > 0) {
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(stdout);
346
+ } catch (e) {
347
+ assert.fail('stdout is non-empty but not valid JSON: ' + stdout);
348
+ }
349
+ assert.ok(
350
+ parsed && typeof parsed === 'object',
351
+ 'parsed stdout must be an object'
352
+ );
353
+ }
354
+ });
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // hooks/qgsd-slot-correlator.js
3
- // SubagentStart hook — writes a correlation placeholder file for qgsd-quorum-slot-worker subagents.
2
+ // hooks/nf-slot-correlator.js
3
+ // SubagentStart hook — writes a correlation placeholder file for nf-quorum-slot-worker subagents.
4
4
  //
5
5
  // At SubagentStart time, the prompt/slot name is not available in the hook payload.
6
6
  // This hook writes a stub correlation file { agent_id, ts, slot: null } so the
@@ -8,7 +8,7 @@
8
8
  // The slot is resolved from last_assistant_message preamble by the token collector.
9
9
  //
10
10
  // Guards:
11
- // - Only processes agent_type === 'qgsd-quorum-slot-worker' (exits 0 otherwise)
11
+ // - Only processes agent_type === 'nf-quorum-slot-worker' (exits 0 otherwise)
12
12
  // - If agent_id is absent: exits 0 gracefully
13
13
  // - Fail-open: any unhandled error exits 0
14
14
 
@@ -16,6 +16,7 @@
16
16
 
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
+ const { loadConfig, shouldRunHook } = require('./config-loader');
19
20
 
20
21
  function main() {
21
22
  let raw = '';
@@ -25,8 +26,15 @@ function main() {
25
26
  try {
26
27
  const input = JSON.parse(raw);
27
28
 
28
- // Guard: only process qgsd-quorum-slot-worker subagents
29
- if (input.agent_type !== 'qgsd-quorum-slot-worker') {
29
+ // Profile guard exit early if this hook is not active for the current profile
30
+ const config = loadConfig();
31
+ const profile = config.hook_profile || 'standard';
32
+ if (!shouldRunHook('nf-slot-correlator', profile)) {
33
+ process.exit(0);
34
+ }
35
+
36
+ // Guard: only process nf-quorum-slot-worker subagents
37
+ if (input.agent_type !== 'nf-quorum-slot-worker') {
30
38
  process.exit(0);
31
39
  }
32
40
 
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // Test suite for hooks/nf-slot-correlator.js
3
+ // Uses Node.js built-in test runner: node --test hooks/nf-slot-correlator.test.js
4
+
5
+ 'use strict';
6
+
7
+ const { test } = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const { spawnSync } = require('child_process');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const HOOK_PATH = path.join(__dirname, 'nf-slot-correlator.js');
15
+
16
+ // Helper: create isolated tmpdir per test
17
+ function makeTmpDir() {
18
+ return path.join(os.tmpdir(), 'nf-sc-' + Date.now() + '-' + Math.random().toString(36).slice(2));
19
+ }
20
+
21
+ // Helper: run the hook with a given stdin JSON payload using tmpDir as cwd
22
+ function runHook(stdinPayload, tmpDir) {
23
+ fs.mkdirSync(tmpDir, { recursive: true });
24
+ const result = spawnSync('node', [HOOK_PATH], {
25
+ input: JSON.stringify(stdinPayload),
26
+ cwd: tmpDir,
27
+ encoding: 'utf8',
28
+ timeout: 5000,
29
+ });
30
+ return {
31
+ stdout: result.stdout || '',
32
+ stderr: result.stderr || '',
33
+ exitCode: result.status,
34
+ };
35
+ }
36
+
37
+ test('writes correlation placeholder for nf-quorum-slot-worker', () => {
38
+ const tmpDir = makeTmpDir();
39
+
40
+ const payload = {
41
+ agent_type: 'nf-quorum-slot-worker',
42
+ agent_id: 'agent42',
43
+ };
44
+
45
+ const { exitCode } = runHook(payload, tmpDir);
46
+ assert.equal(exitCode, 0);
47
+
48
+ const corrFile = path.join(tmpDir, '.planning', 'quorum', 'correlations', 'quorum-slot-corr-agent42.json');
49
+ assert.equal(fs.existsSync(corrFile), true, 'Correlation file should exist');
50
+
51
+ const data = JSON.parse(fs.readFileSync(corrFile, 'utf8'));
52
+ assert.equal(data.agent_id, 'agent42');
53
+ assert.equal(data.slot, null, 'slot should be null in SubagentStart (prompt not available)');
54
+ assert.ok(data.ts, 'ts field should be present');
55
+ });
56
+
57
+ test('non-nf agent type: exits 0, no file written', () => {
58
+ const tmpDir = makeTmpDir();
59
+
60
+ const payload = {
61
+ agent_type: 'other',
62
+ agent_id: 'agent99',
63
+ };
64
+
65
+ const { exitCode } = runHook(payload, tmpDir);
66
+ assert.equal(exitCode, 0);
67
+
68
+ const planningDir = path.join(tmpDir, '.planning');
69
+ // No .planning dir should be created at all
70
+ const anyFile = fs.existsSync(planningDir);
71
+ assert.equal(anyFile, false, 'No files should be written for non-nf agents');
72
+ });
73
+
74
+ test('missing agent_id: exits 0 gracefully', () => {
75
+ const tmpDir = makeTmpDir();
76
+
77
+ const payload = {
78
+ agent_type: 'nf-quorum-slot-worker',
79
+ agent_id: null,
80
+ };
81
+
82
+ const { exitCode } = runHook(payload, tmpDir);
83
+ assert.equal(exitCode, 0);
84
+ // No crash — exits gracefully when agent_id is null
85
+ });