@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,297 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * cross-layer-dashboard.cjs — Cross-Layer Alignment Dashboard.
6
+ *
7
+ * Aggregates L1 coverage, Gate A, Gate B, and Gate C scores into a single
8
+ * terminal view. Re-runs gate scripts by default for freshness.
9
+ *
10
+ * Requirements: INTG-04
11
+ *
12
+ * Usage:
13
+ * node bin/cross-layer-dashboard.cjs # re-run gates, display terminal dashboard
14
+ * node bin/cross-layer-dashboard.cjs --cached # read existing gate JSON files (fast)
15
+ * node bin/cross-layer-dashboard.cjs --json # output aggregated JSON
16
+ * node bin/cross-layer-dashboard.cjs --cached --json
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { spawnSync } = require('child_process');
22
+
23
+ const ROOT = process.env.PROJECT_ROOT
24
+ || (process.argv.find(a => a.startsWith('--project-root=')) || '').replace('--project-root=', '')
25
+ || path.join(__dirname, '..');
26
+ const SCRIPT_DIR = __dirname;
27
+ const FORMAL = path.join(ROOT, '.planning', 'formal');
28
+ const EVIDENCE_DIR = path.join(FORMAL, 'evidence');
29
+ const GATES_DIR = path.join(FORMAL, 'gates');
30
+
31
+ const args = process.argv.slice(2);
32
+ const JSON_FLAG = args.includes('--json');
33
+ const CACHED_FLAG = args.includes('--cached');
34
+
35
+ // ── Health indicator logic ──────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Returns [PASS], [WARN], or [FAIL] based on score vs target.
39
+ * PASS: score >= target
40
+ * WARN: score >= target * 0.8
41
+ * FAIL: score < target * 0.8
42
+ */
43
+ function healthIndicator(score, target) {
44
+ if (score == null || target == null) return '[N/A]';
45
+ if (score >= target) return '[PASS]';
46
+ if (score >= target * 0.8) return '[WARN]';
47
+ return '[FAIL]';
48
+ }
49
+
50
+ // ── spawnTool (adapted from nf-solve.cjs) ───────────────────────────────────
51
+
52
+ function spawnTool(script, spawnArgs) {
53
+ const scriptPath = path.join(SCRIPT_DIR, path.basename(script));
54
+ const childArgs = [...spawnArgs];
55
+ if (!childArgs.some(a => a.startsWith('--project-root='))) {
56
+ childArgs.push('--project-root=' + ROOT);
57
+ }
58
+ try {
59
+ const result = spawnSync(process.execPath, [scriptPath, ...childArgs], {
60
+ encoding: 'utf8',
61
+ cwd: ROOT,
62
+ timeout: 60000,
63
+ stdio: 'pipe',
64
+ maxBuffer: 10 * 1024 * 1024,
65
+ });
66
+ if (result.error) {
67
+ return { ok: false, stdout: '', stderr: result.error.message };
68
+ }
69
+ // Gate scripts exit 1 when target not met but still produce valid JSON
70
+ return {
71
+ ok: result.status === 0 || result.status === 1,
72
+ stdout: result.stdout || '',
73
+ stderr: result.stderr || '',
74
+ exitCode: result.status,
75
+ };
76
+ } catch (err) {
77
+ return { ok: false, stdout: '', stderr: err.message };
78
+ }
79
+ }
80
+
81
+ // ── Data collection ─────────────────────────────────────────────────────────
82
+
83
+ function readJsonFile(filePath) {
84
+ try {
85
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
86
+ } catch (err) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function collectGateData(script, cachedFile, jsonFlag) {
92
+ if (CACHED_FLAG) {
93
+ const cached = readJsonFile(path.join(GATES_DIR, cachedFile));
94
+ if (cached) return cached;
95
+ process.stderr.write(`[dashboard] WARN: cached file ${cachedFile} not found, gate unavailable\n`);
96
+ return null;
97
+ }
98
+ const result = spawnTool(script, ['--json']);
99
+ if (!result.ok) {
100
+ process.stderr.write(`[dashboard] WARN: ${script} failed: ${result.stderr.slice(0, 200)}\n`);
101
+ return null;
102
+ }
103
+ try {
104
+ return JSON.parse(result.stdout);
105
+ } catch (err) {
106
+ process.stderr.write(`[dashboard] WARN: ${script} produced invalid JSON\n`);
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function collectL1Coverage() {
112
+ const imap = readJsonFile(path.join(EVIDENCE_DIR, 'instrumentation-map.json'));
113
+ if (!imap || !imap.coverage) return null;
114
+ return imap.coverage.coverage_pct;
115
+ }
116
+
117
+ function collectMaturityData() {
118
+ if (CACHED_FLAG) {
119
+ // No cached file for maturity; skip in cached mode
120
+ return null;
121
+ }
122
+ const result = spawnTool('promote-gate-maturity.cjs', ['--check', '--json']);
123
+ if (!result.ok) return null;
124
+ try {
125
+ return JSON.parse(result.stdout);
126
+ } catch { return null; }
127
+ }
128
+
129
+ function collectAll() {
130
+ const gateA = collectGateData('gate-a-grounding.cjs', 'gate-a-grounding.json');
131
+ const gateB = collectGateData('gate-b-abstraction.cjs', 'gate-b-abstraction.json');
132
+ const gateC = collectGateData('gate-c-validation.cjs', 'gate-c-validation.json');
133
+ const l1Pct = collectL1Coverage();
134
+ const maturity = collectMaturityData();
135
+
136
+ return { gateA, gateB, gateC, l1Pct, maturity };
137
+ }
138
+
139
+ // ── Build aggregated result ─────────────────────────────────────────────────
140
+
141
+ function buildResult(data) {
142
+ const { gateA, gateB, gateC, l1Pct, maturity } = data;
143
+
144
+ const result = {
145
+ generated: new Date().toISOString(),
146
+ l1_coverage_pct: l1Pct != null ? l1Pct : null,
147
+ gate_a: gateA ? {
148
+ score: gateA.grounding_score,
149
+ target: gateA.target,
150
+ target_met: gateA.target_met,
151
+ explained: gateA.explained,
152
+ total: gateA.total,
153
+ } : null,
154
+ gate_b: gateB ? {
155
+ score: gateB.gate_b_score,
156
+ target: gateB.target,
157
+ target_met: gateB.target_met,
158
+ grounded_entries: gateB.grounded_entries,
159
+ total_entries: gateB.total_entries,
160
+ } : null,
161
+ gate_c: gateC ? {
162
+ score: gateC.gate_c_score,
163
+ target: gateC.target,
164
+ target_met: gateC.target_met,
165
+ validated_entries: gateC.validated_entries,
166
+ total_entries: gateC.total_entries,
167
+ } : null,
168
+ maturity: maturity ? {
169
+ total: maturity.total,
170
+ by_level: maturity.by_level,
171
+ } : null,
172
+ };
173
+
174
+ // Determine overall health
175
+ const allMet = [
176
+ result.gate_a?.target_met,
177
+ result.gate_b?.target_met,
178
+ result.gate_c?.target_met,
179
+ ];
180
+ result.all_targets_met = allMet.every(v => v === true);
181
+
182
+ return result;
183
+ }
184
+
185
+ // ── Terminal rendering ──────────────────────────────────────────────────────
186
+
187
+ function pct(value, decimals = 1) {
188
+ if (value == null) return 'N/A';
189
+ return (value * 100).toFixed(decimals) + '%';
190
+ }
191
+
192
+ function renderTerminal(result) {
193
+ const lines = [];
194
+ const W = 64;
195
+ const hr = '─'.repeat(W);
196
+ const dhr = '═'.repeat(W);
197
+
198
+ lines.push('╔' + dhr + '╗');
199
+ lines.push('║' + ' Cross-Layer Alignment Dashboard'.padEnd(W) + '║');
200
+ lines.push('╚' + dhr + '╝');
201
+ lines.push('');
202
+
203
+ // L1 Coverage
204
+ const l1Target = 50; // percentage
205
+ const l1Val = result.l1_coverage_pct;
206
+ const l1Health = l1Val != null
207
+ ? healthIndicator(l1Val / 100, l1Target / 100)
208
+ : '[N/A]';
209
+ lines.push('┌' + hr + '┐');
210
+ lines.push('│' + ' Layer Health'.padEnd(W) + '│');
211
+ lines.push('├' + hr + '┤');
212
+ lines.push('│' + ` L1 Coverage: ${l1Val != null ? l1Val.toFixed(1) + '%' : 'N/A'}`.padEnd(W - 8) + l1Health.padStart(8) + '│');
213
+
214
+ // Gate A
215
+ const gaScore = result.gate_a?.score;
216
+ const gaTarget = result.gate_a?.target ?? 0.8;
217
+ const gaHealth = gaScore != null ? healthIndicator(gaScore, gaTarget) : '[N/A]';
218
+ const gaDetail = result.gate_a
219
+ ? `${result.gate_a.explained}/${result.gate_a.total} traces explained`
220
+ : '';
221
+ lines.push('│' + ` Gate A: ${pct(gaScore)}`.padEnd(W - 8) + gaHealth.padStart(8) + '│');
222
+ if (gaDetail) {
223
+ lines.push('│' + ` ${gaDetail}`.padEnd(W) + '│');
224
+ }
225
+
226
+ // Gate B
227
+ const gbScore = result.gate_b?.score;
228
+ const gbTarget = result.gate_b?.target ?? 1.0;
229
+ const gbHealth = gbScore != null ? healthIndicator(gbScore, gbTarget) : '[N/A]';
230
+ const gbDetail = result.gate_b
231
+ ? `${result.gate_b.grounded_entries}/${result.gate_b.total_entries} entries grounded`
232
+ : '';
233
+ lines.push('│' + ` Gate B: ${pct(gbScore)}`.padEnd(W - 8) + gbHealth.padStart(8) + '│');
234
+ if (gbDetail) {
235
+ lines.push('│' + ` ${gbDetail}`.padEnd(W) + '│');
236
+ }
237
+
238
+ // Gate C
239
+ const gcScore = result.gate_c?.score;
240
+ const gcTarget = result.gate_c?.target ?? 0.8;
241
+ const gcHealth = gcScore != null ? healthIndicator(gcScore, gcTarget) : '[N/A]';
242
+ const gcDetail = result.gate_c
243
+ ? `${result.gate_c.validated_entries}/${result.gate_c.total_entries} entries validated`
244
+ : '';
245
+ lines.push('│' + ` Gate C: ${pct(gcScore)}`.padEnd(W - 8) + gcHealth.padStart(8) + '│');
246
+ if (gcDetail) {
247
+ lines.push('│' + ` ${gcDetail}`.padEnd(W) + '│');
248
+ }
249
+
250
+ lines.push('└' + hr + '┘');
251
+
252
+ // Maturity distribution
253
+ if (result.maturity) {
254
+ lines.push('');
255
+ lines.push('┌' + hr + '┐');
256
+ lines.push('│' + ' Gate Maturity Distribution'.padEnd(W) + '│');
257
+ lines.push('├' + hr + '┤');
258
+ const by = result.maturity.by_level || {};
259
+ lines.push('│' + ` ADVISORY: ${by.ADVISORY ?? 0}`.padEnd(W) + '│');
260
+ lines.push('│' + ` SOFT_GATE: ${by.SOFT_GATE ?? 0}`.padEnd(W) + '│');
261
+ lines.push('│' + ` HARD_GATE: ${by.HARD_GATE ?? 0}`.padEnd(W) + '│');
262
+ lines.push('│' + ` Total: ${result.maturity.total ?? 0}`.padEnd(W) + '│');
263
+ lines.push('└' + hr + '┘');
264
+ }
265
+
266
+ // Composite summary
267
+ lines.push('');
268
+ const status = result.all_targets_met ? 'ALL TARGETS MET' : 'TARGETS NOT MET';
269
+ const statusIndicator = result.all_targets_met ? '[PASS]' : '[FAIL]';
270
+ lines.push(` Composite: ${status} ${statusIndicator}`);
271
+ lines.push('');
272
+
273
+ return lines.join('\n');
274
+ }
275
+
276
+ // ── Main ────────────────────────────────────────────────────────────────────
277
+
278
+ function main() {
279
+ const data = collectAll();
280
+ const result = buildResult(data);
281
+
282
+ if (JSON_FLAG) {
283
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
284
+ } else {
285
+ process.stdout.write(renderTerminal(result));
286
+ }
287
+
288
+ // Exit 0 if all gates met, 1 otherwise
289
+ process.exit(result.all_targets_met ? 0 : 1);
290
+ }
291
+
292
+ // Export for testing
293
+ module.exports = { healthIndicator, buildResult, renderTerminal };
294
+
295
+ if (require.main === module) {
296
+ main();
297
+ }
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/design-impact.cjs
4
+ // Three-layer git diff impact analysis — traces changes through L1 (instrumentation),
5
+ // L2 (state transitions), and L3 (hazards) of the formal verification architecture.
6
+ //
7
+ // Usage:
8
+ // node bin/design-impact.cjs # analyze HEAD~1..HEAD
9
+ // node bin/design-impact.cjs --diff=HEAD~3..HEAD # specific commit range
10
+ // node bin/design-impact.cjs --stdin # read diff from stdin
11
+ // node bin/design-impact.cjs --json # JSON output
12
+ // node bin/design-impact.cjs --project-root=/path # cross-repo usage
13
+ //
14
+ // Exit codes:
15
+ // 0 — success
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Resolve project root
23
+ // ---------------------------------------------------------------------------
24
+ const ROOT = (() => {
25
+ const args = process.argv.slice(2);
26
+ const rootArg = args.find(a => a.startsWith('--project-root='));
27
+ if (rootArg) return rootArg.split('=').slice(1).join('=');
28
+ if (process.env.PROJECT_ROOT) return process.env.PROJECT_ROOT;
29
+ return path.resolve(__dirname, '..');
30
+ })();
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // CLI flags
34
+ // ---------------------------------------------------------------------------
35
+ const ARGS = process.argv.slice(2);
36
+ const JSON_OUTPUT = ARGS.includes('--json');
37
+ const STDIN_MODE = ARGS.includes('--stdin');
38
+ const DIFF_ARG = ARGS.find(a => a.startsWith('--diff='));
39
+ const DIFF_REF = DIFF_ARG ? DIFF_ARG.split('=').slice(1).join('=') : 'HEAD~1..HEAD';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Diff parsing
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Parse a unified diff to extract changed file names.
47
+ * Only looks at --- / +++ lines and 'diff --git' headers.
48
+ * Returns deduplicated array of file paths (relative to repo root).
49
+ */
50
+ function parseGitDiff(diffText) {
51
+ const files = new Set();
52
+ const lines = diffText.split('\n');
53
+ for (const line of lines) {
54
+ // Handle 'diff --git a/foo b/foo' header
55
+ const gitMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
56
+ if (gitMatch) {
57
+ files.add(gitMatch[2]);
58
+ continue;
59
+ }
60
+ // Skip binary file markers
61
+ if (line.startsWith('Binary files ')) continue;
62
+ // Parse +++ b/path (new file path)
63
+ if (line.startsWith('+++ b/')) {
64
+ files.add(line.slice(6));
65
+ continue;
66
+ }
67
+ }
68
+ return [...files];
69
+ }
70
+
71
+ /**
72
+ * Parse diff hunk headers to extract changed line ranges per file.
73
+ * Returns Map<filePath, Array<{start, count}>> for the new-side (+) ranges.
74
+ */
75
+ function parseDiffLineRanges(diffText) {
76
+ const ranges = new Map();
77
+ let currentFile = null;
78
+ const lines = diffText.split('\n');
79
+ for (const line of lines) {
80
+ // Track current file from +++ header
81
+ if (line.startsWith('+++ b/')) {
82
+ currentFile = line.slice(6);
83
+ if (!ranges.has(currentFile)) ranges.set(currentFile, []);
84
+ continue;
85
+ }
86
+ if (line.startsWith('+++ ')) {
87
+ // /dev/null or similar — skip
88
+ currentFile = null;
89
+ continue;
90
+ }
91
+ // Parse @@ hunk header
92
+ if (currentFile && line.startsWith('@@')) {
93
+ const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
94
+ if (match) {
95
+ const start = parseInt(match[1], 10);
96
+ const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
97
+ ranges.get(currentFile).push({ start, count });
98
+ }
99
+ }
100
+ }
101
+ return ranges;
102
+ }
103
+
104
+ /**
105
+ * Check if a line number falls within any of the changed ranges for a file.
106
+ */
107
+ function isLineInRanges(lineNum, ranges) {
108
+ if (!ranges || ranges.length === 0) return false;
109
+ for (const r of ranges) {
110
+ if (lineNum >= r.start && lineNum < r.start + r.count) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Layer analysis
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * L1: Instrumentation impact — find emission points in changed files.
121
+ */
122
+ function analyzeL1(changedFiles, lineRanges, instrumentationMap) {
123
+ const points = instrumentationMap.emission_points || [];
124
+ const affected = [];
125
+ for (const ep of points) {
126
+ if (!changedFiles.includes(ep.file)) continue;
127
+ const fileRanges = lineRanges.get(ep.file);
128
+ const impactType = isLineInRanges(ep.line_number, fileRanges) ? 'direct' : 'file_level';
129
+ affected.push({
130
+ file: ep.file,
131
+ line_number: ep.line_number,
132
+ action: ep.action,
133
+ xstate_event: ep.xstate_event,
134
+ impact_type: impactType,
135
+ });
136
+ }
137
+ return {
138
+ affected_emission_points: affected.length,
139
+ direct_hits: affected.filter(a => a.impact_type === 'direct').length,
140
+ details: affected,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * L2: State transition impact — find transitions for affected xstate events.
146
+ */
147
+ function analyzeL2(l1Impact, observedFsm) {
148
+ const affectedEvents = new Set();
149
+ for (const ep of l1Impact.details) {
150
+ if (ep.xstate_event) affectedEvents.add(ep.xstate_event);
151
+ }
152
+ const transitions = [];
153
+ const observed = observedFsm.observed_transitions || {};
154
+ for (const [state, events] of Object.entries(observed)) {
155
+ for (const [event, info] of Object.entries(events)) {
156
+ if (affectedEvents.has(event)) {
157
+ transitions.push({
158
+ state,
159
+ event,
160
+ to_state: info.to_state,
161
+ count: info.count,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ return {
167
+ affected_transitions: transitions.length,
168
+ affected_events: [...affectedEvents],
169
+ details: transitions,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * L3: Hazard impact — find hazards and failure modes for affected state-event pairs.
175
+ */
176
+ function analyzeL3(l2Impact, hazardModel, failureModeCatalog) {
177
+ // Build set of affected (state, event) pairs from L2
178
+ const affectedPairs = new Set();
179
+ for (const t of l2Impact.details) {
180
+ affectedPairs.add(`${t.state}|${t.event}`);
181
+ }
182
+
183
+ const hazards = (hazardModel.hazards || []).filter(h =>
184
+ affectedPairs.has(`${h.state}|${h.event}`)
185
+ );
186
+
187
+ const failureModes = (failureModeCatalog.failure_modes || []).filter(fm =>
188
+ affectedPairs.has(`${fm.state}|${fm.event}`)
189
+ );
190
+
191
+ const maxRpn = hazards.length > 0 ? Math.max(...hazards.map(h => h.rpn)) : 0;
192
+
193
+ return {
194
+ affected_hazards: hazards.length,
195
+ max_rpn: maxRpn,
196
+ affected_failure_modes: failureModes.length,
197
+ details: hazards.map(h => ({
198
+ hazard_id: h.id,
199
+ state: h.state,
200
+ event: h.event,
201
+ rpn: h.rpn,
202
+ severity: h.severity,
203
+ })),
204
+ failure_mode_details: failureModes.map(fm => ({
205
+ id: fm.id,
206
+ state: fm.state,
207
+ event: fm.event,
208
+ failure_mode: fm.failure_mode,
209
+ severity_class: fm.severity_class,
210
+ })),
211
+ };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Main analysis entry point
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Run complete three-layer impact analysis.
220
+ * @param {Object} opts
221
+ * @param {string[]} opts.changedFiles - list of changed file paths
222
+ * @param {Map<string, Array<{start:number, count:number}>>} opts.lineRanges - per-file line ranges
223
+ * @param {Object} opts.instrumentationMap - parsed instrumentation-map.json
224
+ * @param {Object} opts.observedFsm - parsed observed-fsm.json
225
+ * @param {Object} opts.hazardModel - parsed hazard-model.json
226
+ * @param {Object} opts.failureModeCatalog - parsed failure-mode-catalog.json
227
+ */
228
+ function analyzeImpact(opts) {
229
+ const { changedFiles, lineRanges, instrumentationMap, observedFsm, hazardModel, failureModeCatalog } = opts;
230
+
231
+ const l1 = analyzeL1(changedFiles, lineRanges, instrumentationMap);
232
+ const l2 = l1.affected_emission_points > 0
233
+ ? analyzeL2(l1, observedFsm)
234
+ : { affected_transitions: 0, affected_events: [], details: [] };
235
+ const l3 = l2.affected_transitions > 0
236
+ ? analyzeL3(l2, hazardModel, failureModeCatalog)
237
+ : { affected_hazards: 0, max_rpn: 0, affected_failure_modes: 0, details: [], failure_mode_details: [] };
238
+
239
+ let summary;
240
+ if (l1.affected_emission_points === 0) {
241
+ summary = 'No instrumented files affected by this change';
242
+ } else {
243
+ summary = `${l1.affected_emission_points} emission point(s) affected (${l1.direct_hits} direct), ` +
244
+ `${l2.affected_transitions} state transition(s), ` +
245
+ `${l3.affected_hazards} hazard(s) (max RPN: ${l3.max_rpn})`;
246
+ }
247
+
248
+ return {
249
+ schema_version: '1',
250
+ generated: new Date().toISOString(),
251
+ changed_files: changedFiles.length,
252
+ l1_impact: l1,
253
+ l2_impact: l2,
254
+ l3_impact: l3,
255
+ summary,
256
+ };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // File loaders
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function loadJSON(relPath) {
264
+ const fullPath = path.join(ROOT, relPath);
265
+ try {
266
+ return JSON.parse(fs.readFileSync(fullPath, 'utf8'));
267
+ } catch (e) {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Human-readable output
274
+ // ---------------------------------------------------------------------------
275
+
276
+ function printReport(report) {
277
+ console.log('=== Design Impact Report ===\n');
278
+ console.log(`Changed files: ${report.changed_files}`);
279
+ console.log(`Summary: ${report.summary}\n`);
280
+
281
+ if (report.l1_impact.affected_emission_points === 0) {
282
+ console.log('No instrumented files affected — L2/L3 analysis skipped.\n');
283
+ return;
284
+ }
285
+
286
+ // L1
287
+ console.log('--- L1: Instrumentation Impact ---');
288
+ console.log(` Affected emission points: ${report.l1_impact.affected_emission_points} (${report.l1_impact.direct_hits} direct)`);
289
+ for (const ep of report.l1_impact.details) {
290
+ console.log(` [${ep.impact_type}] ${ep.file}:${ep.line_number} — ${ep.action} (${ep.xstate_event || 'no event'})`);
291
+ }
292
+ console.log('');
293
+
294
+ // L2
295
+ console.log('--- L2: State Transition Impact ---');
296
+ console.log(` Affected transitions: ${report.l2_impact.affected_transitions}`);
297
+ console.log(` Affected events: ${report.l2_impact.affected_events.join(', ')}`);
298
+ for (const t of report.l2_impact.details) {
299
+ console.log(` ${t.state} --[${t.event}]--> ${t.to_state} (${t.count} observed)`);
300
+ }
301
+ console.log('');
302
+
303
+ // L3
304
+ console.log('--- L3: Hazard Impact ---');
305
+ console.log(` Affected hazards: ${report.l3_impact.affected_hazards} (max RPN: ${report.l3_impact.max_rpn})`);
306
+ console.log(` Affected failure modes: ${report.l3_impact.affected_failure_modes}`);
307
+ for (const h of report.l3_impact.details) {
308
+ console.log(` ${h.hazard_id}: ${h.state} --[${h.event}]--> RPN=${h.rpn} severity=${h.severity}`);
309
+ }
310
+ console.log('');
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // CLI main
315
+ // ---------------------------------------------------------------------------
316
+
317
+ async function main() {
318
+ let diffText;
319
+
320
+ if (STDIN_MODE) {
321
+ // Read diff from stdin
322
+ diffText = fs.readFileSync(0, 'utf8');
323
+ } else {
324
+ // Run git diff
325
+ const [ref1, ref2] = DIFF_REF.includes('..')
326
+ ? DIFF_REF.split('..')
327
+ : [DIFF_REF + '~1', DIFF_REF];
328
+
329
+ const diffResult = spawnSync('git', ['diff', '--unified=0', ref1, ref2], {
330
+ cwd: ROOT,
331
+ encoding: 'utf8',
332
+ maxBuffer: 10 * 1024 * 1024,
333
+ });
334
+
335
+ if (diffResult.error) {
336
+ console.error(`Error running git diff: ${diffResult.error.message}`);
337
+ process.exit(1);
338
+ }
339
+
340
+ diffText = diffResult.stdout || '';
341
+ }
342
+
343
+ const changedFiles = parseGitDiff(diffText);
344
+ const lineRanges = parseDiffLineRanges(diffText);
345
+
346
+ // Load formal verification artifacts
347
+ const instrumentationMap = loadJSON('.planning/formal/evidence/instrumentation-map.json') || { emission_points: [] };
348
+ const observedFsm = loadJSON('.planning/formal/semantics/observed-fsm.json') || { observed_transitions: {} };
349
+ const hazardModel = loadJSON('.planning/formal/reasoning/hazard-model.json') || { hazards: [] };
350
+ const failureModeCatalog = loadJSON('.planning/formal/reasoning/failure-mode-catalog.json') || { failure_modes: [] };
351
+
352
+ const report = analyzeImpact({
353
+ changedFiles,
354
+ lineRanges,
355
+ instrumentationMap,
356
+ observedFsm,
357
+ hazardModel,
358
+ failureModeCatalog,
359
+ });
360
+
361
+ if (JSON_OUTPUT) {
362
+ console.log(JSON.stringify(report, null, 2));
363
+ } else {
364
+ printReport(report);
365
+ }
366
+ }
367
+
368
+ // Export for testing
369
+ module.exports = { analyzeImpact, parseGitDiff, parseDiffLineRanges, analyzeL1, analyzeL2, analyzeL3, isLineInRanges };
370
+
371
+ // Run CLI if invoked directly
372
+ if (require.main === module) {
373
+ main().catch(err => {
374
+ console.error(err);
375
+ process.exit(1);
376
+ });
377
+ }