@nforma.ai/nforma 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/run-formal-verify.cjs
4
+ // Master runner: executes ALL formal verification tools and generates ALL formal artifacts.
5
+ //
6
+ // Coverage:
7
+ // Generate (2) — xstate-to-tla.cjs (XState → TLA+, cfg)
8
+ // generate-formal-specs.cjs (XState → Alloy, PRISM)
9
+ // Petri net (2) — generate-petri-net.cjs + render account-manager DOT → SVG
10
+ // TLA+ (10) — MCsafety, MCliveness, MCoscillation, MCconvergence,
11
+ // MCbreaker, MCdeliberation, MCprefilter, MCaccount-manager, MCMCPEnv,
12
+ // MCStopHook
13
+ // Alloy (8) — quorum-votes, scoreboard-recompute, availability-parsing,
14
+ // transcript-scan, install-scope, taxonomy-safety, account-pool-structure,
15
+ // quorum-composition
16
+ // PRISM (3) — quorum, oauth-rotation, mcp-availability
17
+ // CI enforce (4) — check-trace-redaction.cjs, check-trace-schema-drift.cjs, check-liveness-fairness.cjs,
18
+ // validate-traces.cjs
19
+ // UPPAAL (1) — run-uppaal.cjs (quorum-races.xml, empirical timing bounds)
20
+ // Triage (1) — generate-triage-bundle.cjs (diff-report.md + suspects.md)
21
+ // Traceability (3) — generate-traceability-matrix.cjs (requirements <-> properties matrix)
22
+ // check-coverage-guard.cjs (coverage regression guard vs baseline)
23
+ // analyze-state-space.cjs (state-space risk classification per TLA+ model)
24
+ // Registry (N) — custom check commands from model-registry.json
25
+ // ─────────────────────────────────────────────────────────────
26
+ // Total: 34+ steps (dynamic — registry can add more)
27
+ //
28
+ // Usage:
29
+ // node bin/run-formal-verify.cjs # all 28 steps
30
+ // node bin/run-formal-verify.cjs --concurrent # run tool groups in parallel (old behavior)
31
+ // QGSD_FORMAL_CONCURRENT=1 node bin/run-formal-verify.cjs # same via env var
32
+ // node bin/run-formal-verify.cjs --only=generate # source extraction only (2 steps)
33
+ // node bin/run-formal-verify.cjs --only=tla # TLA+ only (10 steps)
34
+ // node bin/run-formal-verify.cjs --only=alloy # Alloy only (8 steps)
35
+ // node bin/run-formal-verify.cjs --only=prism # PRISM only (3 steps)
36
+ // node bin/run-formal-verify.cjs --only=petri # Petri only (2 steps)
37
+ // node bin/run-formal-verify.cjs --only=ci # CI enforcement only (4 steps)
38
+ // node bin/run-formal-verify.cjs --only=uppaal # UPPAAL only (1 step)
39
+ //
40
+ // Behaviour:
41
+ // - Runs steps sequentially; streams child output to stdout/stderr.
42
+ // - Continues on failure; collects pass/fail for every step.
43
+ // - Prints a summary table at the end.
44
+ // - Exits 0 only when every step passes.
45
+ //
46
+ // Prerequisites: see individual runner scripts in bin/.
47
+
48
+ const { spawnSync } = require('child_process');
49
+ const fs = require('fs');
50
+ const path = require('path');
51
+
52
+ const TAG = '[run-formal-verify]';
53
+ const HR = '═'.repeat(64);
54
+ const SEP = '─'.repeat(64);
55
+
56
+ let ROOT = process.cwd();
57
+
58
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
59
+ for (const arg of process.argv.slice(2)) {
60
+ if (arg.startsWith('--project-root=')) {
61
+ ROOT = path.resolve(arg.slice('--project-root='.length));
62
+ }
63
+ }
64
+
65
+ // ── Runner picker maps ─────────────────────────────────────────────────────────
66
+ // Maps known QGSD model names to their specialized runners. Unknown models
67
+ // fall back to generic runners (run-tlc.cjs, run-alloy.cjs, run-prism.cjs).
68
+
69
+ const TLA_RUNNER_MAP = {
70
+ 'MCoscillation': { script: 'run-oscillation-tlc.cjs', args: (c) => [c] },
71
+ 'MCconvergence': { script: 'run-oscillation-tlc.cjs', args: (c) => [c] },
72
+ 'MCbreaker': { script: 'run-breaker-tlc.cjs', args: (c) => [c] },
73
+ 'MCdeliberation': { script: 'run-protocol-tlc.cjs', args: (c) => [c] },
74
+ 'MCprefilter': { script: 'run-protocol-tlc.cjs', args: (c) => [c] },
75
+ 'MCaccount-manager': { script: 'run-account-manager-tlc.cjs', args: () => [] },
76
+ 'MCStopHook': { script: 'run-stop-hook-tlc.cjs', args: (c) => [c] },
77
+ };
78
+ function pickTLARunner(configName) {
79
+ return TLA_RUNNER_MAP[configName] || { script: 'run-tlc.cjs', args: (c) => [c] };
80
+ }
81
+
82
+ const ALLOY_RUNNER_MAP = {
83
+ 'quorum-votes': { script: 'run-alloy.cjs', args: [] },
84
+ 'scoreboard-recompute': { script: 'run-audit-alloy.cjs', args: ['--spec=scoreboard-recompute'] },
85
+ 'availability-parsing': { script: 'run-audit-alloy.cjs', args: ['--spec=availability-parsing'] },
86
+ 'transcript-scan': { script: 'run-transcript-alloy.cjs', args: ['--spec=transcript-scan'] },
87
+ 'install-scope': { script: 'run-installer-alloy.cjs', args: ['--spec=install-scope'] },
88
+ 'taxonomy-safety': { script: 'run-installer-alloy.cjs', args: ['--spec=taxonomy-safety'] },
89
+ 'account-pool-structure': { script: 'run-account-pool-alloy.cjs', args: [] },
90
+ 'quorum-composition': { script: 'run-quorum-composition-alloy.cjs', args: [] },
91
+ };
92
+ function pickAlloyRunner(specName) {
93
+ return (ALLOY_RUNNER_MAP[specName] || { script: 'run-alloy.cjs', args: ['--spec=' + specName] }).script;
94
+ }
95
+ function pickAlloyArgs(specName) {
96
+ return (ALLOY_RUNNER_MAP[specName] || { script: 'run-alloy.cjs', args: ['--spec=' + specName] }).args;
97
+ }
98
+
99
+ const PRISM_RUNNER_MAP = {
100
+ 'quorum': { script: 'run-prism.cjs', args: [] },
101
+ 'oauth-rotation': { script: 'run-oauth-rotation-prism.cjs', args: [] },
102
+ 'mcp-availability': { script: 'run-prism.cjs', args: ['--model=mcp-availability'] },
103
+ };
104
+ function pickPrismRunner(modelName) {
105
+ return (PRISM_RUNNER_MAP[modelName] || { script: 'run-prism.cjs', args: ['--model=' + modelName] }).script;
106
+ }
107
+ function pickPrismArgs(modelName) {
108
+ return (PRISM_RUNNER_MAP[modelName] || { script: 'run-prism.cjs', args: ['--model=' + modelName] }).args;
109
+ }
110
+
111
+ // ── Dynamic model discovery ───────────────────────────────────────────────────
112
+ // Scans ROOT/.planning/formal/{tla,alloy,prism,petri,uppaal}/ and builds step entries.
113
+ // Also reads ROOT/.planning/formal/model-registry.json for:
114
+ // - search_dirs: additional directories to scan for formal model files
115
+ // - models[].check.command: custom shell commands producing type:shell steps
116
+ function discoverModels(root) {
117
+ const discovered = [];
118
+ const formalDir = path.join(root, '.planning', 'formal');
119
+
120
+ // TLA+: scan for *.cfg files in .planning/formal/tla/
121
+ const tlaDir = path.join(formalDir, 'tla');
122
+ if (fs.existsSync(tlaDir)) {
123
+ const cfgFiles = fs.readdirSync(tlaDir).filter(f => f.endsWith('.cfg'));
124
+ for (const cfg of cfgFiles) {
125
+ const configName = cfg.replace('.cfg', '');
126
+ const runner = pickTLARunner(configName);
127
+ discovered.push({
128
+ tool: 'tla',
129
+ id: 'tla:' + configName.toLowerCase(),
130
+ label: 'TLA+ — ' + configName,
131
+ type: 'node',
132
+ script: runner.script,
133
+ args: runner.args(configName),
134
+ });
135
+ }
136
+ }
137
+
138
+ // Alloy: scan for *.als files in .planning/formal/alloy/ (exclude subdirectories, JARs)
139
+ const alloyDir = path.join(formalDir, 'alloy');
140
+ if (fs.existsSync(alloyDir)) {
141
+ const alsFiles = fs.readdirSync(alloyDir).filter(f => f.endsWith('.als'));
142
+ for (const als of alsFiles) {
143
+ const specName = als.replace('.als', '');
144
+ discovered.push({
145
+ tool: 'alloy',
146
+ id: 'alloy:' + specName,
147
+ label: 'Alloy ' + specName,
148
+ type: 'node',
149
+ script: pickAlloyRunner(specName),
150
+ args: pickAlloyArgs(specName),
151
+ });
152
+ }
153
+ }
154
+
155
+ // PRISM: scan for *.pm files in .planning/formal/prism/
156
+ const prismDir = path.join(formalDir, 'prism');
157
+ if (fs.existsSync(prismDir)) {
158
+ const pmFiles = fs.readdirSync(prismDir).filter(f => f.endsWith('.pm'));
159
+ for (const pm of pmFiles) {
160
+ const modelName = pm.replace('.pm', '');
161
+ discovered.push({
162
+ tool: 'prism',
163
+ id: 'prism:' + modelName,
164
+ label: 'PRISM ' + modelName,
165
+ type: 'node',
166
+ script: pickPrismRunner(modelName),
167
+ args: pickPrismArgs(modelName),
168
+ });
169
+ }
170
+ }
171
+
172
+ // Petri: scan for *.dot files in .planning/formal/petri/
173
+ const petriDir = path.join(formalDir, 'petri');
174
+ if (fs.existsSync(petriDir)) {
175
+ const dotFiles = fs.readdirSync(petriDir).filter(f => f.endsWith('.dot'));
176
+ for (const dot of dotFiles) {
177
+ const name = dot.replace('.dot', '');
178
+ discovered.push({
179
+ tool: 'petri',
180
+ id: 'petri:' + name,
181
+ label: 'Petri ' + name + ' — render DOT -> SVG',
182
+ type: 'wasm-dot',
183
+ dot: dot,
184
+ svg: dot.replace('.dot', '.svg'),
185
+ });
186
+ }
187
+ }
188
+
189
+ // UPPAAL: scan for *.xml files in .planning/formal/uppaal/
190
+ const uppaalDir = path.join(formalDir, 'uppaal');
191
+ if (fs.existsSync(uppaalDir)) {
192
+ const xmlFiles = fs.readdirSync(uppaalDir).filter(f => f.endsWith('.xml'));
193
+ for (const xml of xmlFiles) {
194
+ discovered.push({
195
+ tool: 'uppaal',
196
+ id: 'uppaal:' + xml.replace('.xml', ''),
197
+ label: 'UPPAAL ' + xml.replace('.xml', ''),
198
+ type: 'node',
199
+ script: 'run-uppaal.cjs',
200
+ args: [],
201
+ nonCritical: true,
202
+ });
203
+ }
204
+ }
205
+
206
+ // ── Registry-driven discovery ────────────────────────────────────────────
207
+ // Read model-registry.json for search_dirs and check.command entries.
208
+ // Fail-open: if missing or malformed, log warning and continue.
209
+ let registry = null;
210
+ const registryPath = path.join(root, '.planning', 'formal', 'model-registry.json');
211
+ try {
212
+ const raw = fs.readFileSync(registryPath, 'utf8');
213
+ registry = JSON.parse(raw);
214
+ } catch (err) {
215
+ process.stderr.write(TAG + ' Warning: could not read model-registry.json: ' + err.message + '\n');
216
+ }
217
+
218
+ if (registry && Array.isArray(registry.search_dirs)) {
219
+ for (const dir of registry.search_dirs) {
220
+ const resolvedDir = path.resolve(root, dir);
221
+ if (!fs.existsSync(resolvedDir)) continue;
222
+
223
+ const files = fs.readdirSync(resolvedDir);
224
+
225
+ // TLA+: *.cfg
226
+ for (const f of files.filter(f => f.endsWith('.cfg'))) {
227
+ const configName = f.replace('.cfg', '');
228
+ const runner = pickTLARunner(configName);
229
+ const stepId = ('tla:' + path.join(dir, configName).replace(/\\/g, '/')).toLowerCase();
230
+ discovered.push({
231
+ tool: 'tla',
232
+ id: stepId,
233
+ label: 'TLA+ — ' + dir + '/' + configName,
234
+ type: 'node',
235
+ script: runner.script,
236
+ args: runner.args(configName),
237
+ });
238
+ }
239
+
240
+ // Alloy: *.als
241
+ for (const f of files.filter(f => f.endsWith('.als'))) {
242
+ const specName = f.replace('.als', '');
243
+ const stepId = ('alloy:' + path.join(dir, specName).replace(/\\/g, '/')).toLowerCase();
244
+ discovered.push({
245
+ tool: 'alloy',
246
+ id: stepId,
247
+ label: 'Alloy ' + dir + '/' + specName,
248
+ type: 'node',
249
+ script: pickAlloyRunner(specName),
250
+ args: pickAlloyArgs(specName),
251
+ });
252
+ }
253
+
254
+ // PRISM: *.pm
255
+ for (const f of files.filter(f => f.endsWith('.pm'))) {
256
+ const modelName = f.replace('.pm', '');
257
+ const stepId = ('prism:' + path.join(dir, modelName).replace(/\\/g, '/')).toLowerCase();
258
+ discovered.push({
259
+ tool: 'prism',
260
+ id: stepId,
261
+ label: 'PRISM ' + dir + '/' + modelName,
262
+ type: 'node',
263
+ script: pickPrismRunner(modelName),
264
+ args: pickPrismArgs(modelName),
265
+ });
266
+ }
267
+
268
+ // Petri: *.dot
269
+ for (const f of files.filter(f => f.endsWith('.dot'))) {
270
+ const name = f.replace('.dot', '');
271
+ const stepId = ('petri:' + path.join(dir, name).replace(/\\/g, '/')).toLowerCase();
272
+ discovered.push({
273
+ tool: 'petri',
274
+ id: stepId,
275
+ label: 'Petri ' + dir + '/' + name + ' — render DOT -> SVG',
276
+ type: 'wasm-dot',
277
+ dot: f,
278
+ svg: f.replace('.dot', '.svg'),
279
+ });
280
+ }
281
+
282
+ // UPPAAL: *.xml
283
+ for (const f of files.filter(f => f.endsWith('.xml'))) {
284
+ const stepId = ('uppaal:' + path.join(dir, f.replace('.xml', '')).replace(/\\/g, '/')).toLowerCase();
285
+ discovered.push({
286
+ tool: 'uppaal',
287
+ id: stepId,
288
+ label: 'UPPAAL ' + dir + '/' + f.replace('.xml', ''),
289
+ type: 'node',
290
+ script: 'run-uppaal.cjs',
291
+ args: [],
292
+ nonCritical: true,
293
+ });
294
+ }
295
+ }
296
+ }
297
+
298
+ // Registry check.command entries → type:shell steps
299
+ if (registry && registry.models && typeof registry.models === 'object') {
300
+ for (const [modelPath, entry] of Object.entries(registry.models)) {
301
+ if (entry && entry.check && typeof entry.check.command === 'string') {
302
+ discovered.push({
303
+ tool: 'registry',
304
+ id: 'registry:' + modelPath,
305
+ label: 'Registry check — ' + modelPath,
306
+ type: 'shell',
307
+ command: entry.check.command,
308
+ config: entry.check.config || null,
309
+ cwd: root,
310
+ nonCritical: entry.check.nonCritical || false,
311
+ });
312
+ }
313
+ }
314
+ }
315
+
316
+ return discovered;
317
+ }
318
+
319
+ // ── Step registry ─────────────────────────────────────────────────────────────
320
+ //
321
+ // type: 'node' — run node bin/<script> <args...>
322
+ // type: 'wasm-dot' — render .planning/formal/petri/<dot> → .planning/formal/petri/<svg>
323
+ // via @hpcc-js/wasm-graphviz (async)
324
+ //
325
+ // STATIC_STEPS: always run (generate, CI enforcement, triage, traceability).
326
+ // Dynamic steps are discovered from ROOT/.planning/formal/{tla,alloy,prism,petri,uppaal}/.
327
+ const STATIC_STEPS = [
328
+ // ─ Source extraction — must run first so generated specs are fresh ──────────
329
+ {
330
+ tool: 'generate', id: 'generate:tla-from-xstate',
331
+ label: 'Generate TLA+ spec (QGSDQuorum_xstate.tla) + TLC model config from XState machine (xstate-to-tla)',
332
+ type: 'node', script: 'xstate-to-tla.cjs',
333
+ args: ['src/machines/qgsd-workflow.machine.ts', '--module=QGSDQuorum', '--config=.planning/formal/tla/guards/qgsd-workflow.json'],
334
+ },
335
+ {
336
+ tool: 'generate', id: 'generate:alloy-prism-specs',
337
+ label: 'Generate Alloy + PRISM models from XState machine (generate-formal-specs)',
338
+ type: 'node', script: 'generate-formal-specs.cjs', args: [],
339
+ },
340
+
341
+ // ─ Petri net generator (produces DOT files — discovery handles rendering) ──
342
+ {
343
+ tool: 'petri', id: 'petri:quorum',
344
+ label: 'Petri quorum — generate DOT + render SVG',
345
+ type: 'node', script: 'generate-petri-net.cjs', args: [],
346
+ },
347
+
348
+ // ─ CI enforcement — redaction + schema drift ──────────────────────────────
349
+ {
350
+ tool: 'ci', id: 'ci:trace-redaction',
351
+ label: 'Trace redaction enforcement (check-trace-redaction.cjs)',
352
+ type: 'node', script: 'check-trace-redaction.cjs', args: [],
353
+ },
354
+ {
355
+ tool: 'ci', id: 'ci:trace-schema-drift',
356
+ label: 'Trace schema drift guard (check-trace-schema-drift.cjs)',
357
+ type: 'node', script: 'check-trace-schema-drift.cjs', args: [],
358
+ },
359
+ {
360
+ tool: 'ci', id: 'ci:liveness-fairness-lint',
361
+ label: 'Liveness-fairness lint — detect liveness properties without fairness declarations (LIVE-01, LIVE-02)',
362
+ type: 'node', script: 'check-liveness-fairness.cjs', args: [],
363
+ },
364
+ {
365
+ tool: 'ci', id: 'ci:conformance-traces',
366
+ label: 'Conformance trace validation — XState machine replay with evidence confidence (EVID-01, EVID-02)',
367
+ type: 'node', script: 'validate-traces.cjs', args: [],
368
+ },
369
+
370
+ // ─ Triage bundle ─────────────────────────────────────────────────────────
371
+ {
372
+ tool: 'ci', id: 'ci:triage-bundle',
373
+ label: 'Generate triage bundle — diff-report.md + suspects.md (generate-triage-bundle)',
374
+ type: 'node', script: 'generate-triage-bundle.cjs', args: [],
375
+ nonCritical: true,
376
+ },
377
+
378
+ // ─ Traceability matrix ─────────────────────────────────────────────────────
379
+ {
380
+ tool: 'traceability', id: 'traceability:matrix',
381
+ label: 'Generate traceability matrix (requirements <-> formal properties)',
382
+ type: 'node', script: 'generate-traceability-matrix.cjs', args: ['--quiet'],
383
+ nonCritical: true,
384
+ },
385
+ {
386
+ tool: 'traceability', id: 'traceability:coverage-guard',
387
+ label: 'Check formal coverage regression against baseline',
388
+ type: 'node', script: 'check-coverage-guard.cjs', args: ['--quiet'],
389
+ nonCritical: true,
390
+ },
391
+ {
392
+ tool: 'traceability', id: 'traceability:state-space',
393
+ label: 'State-space analysis (risk classification per TLA+ model)',
394
+ type: 'node', script: 'analyze-state-space.cjs', args: [],
395
+ nonCritical: true,
396
+ },
397
+ ];
398
+
399
+ // Discover dynamic model steps from ROOT/.planning/formal/
400
+ const dynamicSteps = discoverModels(ROOT);
401
+
402
+ // Deduplicate: if a dynamic step has the same id as a static step, skip it
403
+ const staticIds = new Set(STATIC_STEPS.map(s => s.id));
404
+ const uniqueDynamicSteps = dynamicSteps.filter(s => !staticIds.has(s.id));
405
+
406
+ const STEPS = [...STATIC_STEPS, ...uniqueDynamicSteps];
407
+
408
+ process.stdout.write(TAG + ' Static steps: ' + STATIC_STEPS.length + '\n');
409
+ process.stdout.write(TAG + ' Discovered models: ' + uniqueDynamicSteps.length + '\n');
410
+
411
+ // ── CLI filter ────────────────────────────────────────────────────────────────
412
+ const argv = process.argv.slice(2);
413
+ const onlyArg = argv.find(a => a.startsWith('--only='));
414
+ const only = onlyArg ? onlyArg.split('=')[1] : null;
415
+ const concurrent = argv.includes('--concurrent') || process.env.QGSD_FORMAL_CONCURRENT === '1';
416
+
417
+ const steps = only
418
+ ? STEPS.filter(s => s.tool === only || s.id === only)
419
+ : STEPS;
420
+
421
+ if (only && steps.length === 0) {
422
+ process.stderr.write(
423
+ TAG + ' Unknown --only value: ' + only + '\n' +
424
+ TAG + ' Valid values: tla, alloy, prism, petri, generate, ci, uppaal, registry, or a step id\n'
425
+ );
426
+ process.exit(1);
427
+ }
428
+
429
+ // ── Result tracker ────────────────────────────────────────────────────────────
430
+ const results = []; // { id, label, passed, note }
431
+
432
+ function record(id, label, passed, note, nonCritical) {
433
+ results.push({ id, label, passed, note: note || '', nonCritical: !!nonCritical });
434
+ }
435
+
436
+ // ── Step execution ────────────────────────────────────────────────────────────
437
+ function runNodeStep(step) {
438
+ const scriptPath = path.join(__dirname, step.script);
439
+ if (!fs.existsSync(scriptPath)) {
440
+ process.stderr.write(TAG + ' Script not found: ' + scriptPath + '\n');
441
+ return false;
442
+ }
443
+ // Auto-forward --project-root to child scripts
444
+ const childArgs = [...step.args];
445
+ if (!childArgs.some(a => a.startsWith('--project-root='))) {
446
+ childArgs.push('--project-root=' + ROOT);
447
+ }
448
+ const result = spawnSync(process.execPath, [scriptPath, ...childArgs], {
449
+ stdio: 'inherit',
450
+ encoding: 'utf8',
451
+ cwd: ROOT,
452
+ env: { ...process.env, CHECK_RESULTS_ROOT: ROOT },
453
+ });
454
+ if (result.error) {
455
+ process.stderr.write(TAG + ' Launch error: ' + result.error.message + '\n');
456
+ return false;
457
+ }
458
+ return result.status === 0;
459
+ }
460
+
461
+ function runShellStep(step) {
462
+ // NOTE: command.split(/\s+/) is a known limitation — quoted arguments
463
+ // with spaces (e.g., 'echo "hello world"') will be split incorrectly.
464
+ // Future enhancement: accept command as an array format for complex args.
465
+ const parts = step.command.split(/\s+/);
466
+ const cmd = parts[0];
467
+ const args = parts.slice(1);
468
+ // Substitute {{config}} placeholder if config is set
469
+ const resolvedArgs = step.config
470
+ ? args.map(a => a.replace('{{config}}', step.config))
471
+ : args;
472
+ const result = spawnSync(cmd, resolvedArgs, {
473
+ stdio: 'inherit',
474
+ encoding: 'utf8',
475
+ cwd: step.cwd || ROOT,
476
+ env: { ...process.env, CHECK_RESULTS_ROOT: ROOT },
477
+ });
478
+ if (result.error) {
479
+ process.stderr.write(TAG + ' Shell launch error: ' + result.error.message + '\n');
480
+ return false;
481
+ }
482
+ return result.status === 0;
483
+ }
484
+
485
+ async function runWasmDotStep(step) {
486
+ const petriDir = path.join(ROOT, '.planning', 'formal', 'petri');
487
+ const dotPath = path.join(petriDir, step.dot);
488
+ const svgPath = path.join(petriDir, step.svg);
489
+
490
+ if (!fs.existsSync(dotPath)) {
491
+ process.stderr.write(TAG + ' DOT source not found: ' + dotPath + '\n');
492
+ return false;
493
+ }
494
+
495
+ const dotContent = fs.readFileSync(dotPath, 'utf8');
496
+
497
+ let Graphviz;
498
+ try {
499
+ ({ Graphviz } = await import('@hpcc-js/wasm-graphviz'));
500
+ } catch (_) {
501
+ process.stderr.write(
502
+ TAG + ' @hpcc-js/wasm-graphviz not installed.\n' +
503
+ TAG + ' Run: npm install --save-dev @hpcc-js/wasm-graphviz\n'
504
+ );
505
+ return false;
506
+ }
507
+
508
+ try {
509
+ const graphviz = await Graphviz.load();
510
+ const svg = graphviz.dot(dotContent);
511
+ fs.writeFileSync(svgPath, svg);
512
+ process.stdout.write(TAG + ' SVG written: ' + svgPath + '\n');
513
+ return true;
514
+ } catch (err) {
515
+ process.stderr.write(TAG + ' SVG render failed: ' + err.message + '\n');
516
+ return false;
517
+ }
518
+ }
519
+
520
+ // ── Group runner — executes steps sequentially within a group ─────────────────
521
+ async function runGroup(groupSteps) {
522
+ for (const step of groupSteps) {
523
+ process.stdout.write(TAG + ' ' + SEP + '\n');
524
+ process.stdout.write(TAG + ' [' + step.id + '] ' + step.label + '\n');
525
+ process.stdout.write(TAG + ' ' + SEP + '\n');
526
+
527
+ let passed = false;
528
+ if (step.type === 'node') {
529
+ passed = runNodeStep(step);
530
+ } else if (step.type === 'wasm-dot') {
531
+ passed = await runWasmDotStep(step);
532
+ } else if (step.type === 'shell') {
533
+ passed = runShellStep(step);
534
+ }
535
+
536
+ const mark = passed ? '✓' : '✗';
537
+ process.stdout.write('\n' + TAG + ' ' + mark + ' ' + step.id + '\n\n');
538
+ record(step.id, step.label, passed, undefined, step.nonCritical);
539
+ }
540
+ }
541
+
542
+ // ── runOnce() — executes the full pipeline once ───────────────────────────────
543
+ // Returns the count of failed steps (0 = all passed).
544
+ // Does NOT call process.exit() — the caller decides (non-watch exits, watch loops).
545
+ async function runOnce() {
546
+ // Reset results array so watch-mode re-runs start clean
547
+ results.length = 0;
548
+ // Truncate NDJSON file — fresh run (UNIF-02)
549
+ const ndjsonPath = path.join(ROOT, '.planning', 'formal', 'check-results.ndjson');
550
+ fs.writeFileSync(ndjsonPath, '', 'utf8');
551
+
552
+ process.stdout.write(TAG + ' ' + HR + '\n');
553
+ process.stdout.write(TAG + ' QGSD Formal Verification Suite\n');
554
+ if (only) {
555
+ process.stdout.write(TAG + ' Filter: --only=' + only + '\n');
556
+ }
557
+ process.stdout.write(TAG + ' Steps: ' + steps.length + '\n');
558
+ process.stdout.write(TAG + ' ' + HR + '\n\n');
559
+
560
+ const startMs = Date.now();
561
+
562
+ // ── Phase 1: Generate (sequential prerequisite) ────────────────────────────
563
+ const generateSteps = steps.filter(s => s.tool === 'generate');
564
+ const toolSteps = steps.filter(s => s.tool !== 'generate' && s.tool !== 'traceability');
565
+ const postSteps = steps.filter(s => s.tool === 'traceability');
566
+
567
+ if (generateSteps.length > 0) {
568
+ process.stdout.write(TAG + ' Phase 1: Running generate steps sequentially...\n\n');
569
+ await runGroup(generateSteps);
570
+ }
571
+
572
+ // ── Phase 2: Tool groups (sequential by default, --concurrent for parallel) ─
573
+ if (toolSteps.length > 0) {
574
+ const toolGroupNames = [...new Set(toolSteps.map(s => s.tool))];
575
+ if (concurrent) {
576
+ process.stdout.write(TAG + ' Phase 2: Running tool groups concurrently: ' + toolGroupNames.join(', ') + '\n\n');
577
+ await Promise.all(
578
+ toolGroupNames.map(tool => runGroup(toolSteps.filter(s => s.tool === tool)))
579
+ );
580
+ } else {
581
+ process.stdout.write(TAG + ' Phase 2: Running tool groups sequentially: ' + toolGroupNames.join(', ') + '\n\n');
582
+ for (const tool of toolGroupNames) {
583
+ await runGroup(toolSteps.filter(s => s.tool === tool));
584
+ }
585
+ }
586
+ }
587
+
588
+ // ── Phase 3: Post-processing (needs fully populated check-results.ndjson) ──
589
+ if (postSteps.length > 0) {
590
+ process.stdout.write(TAG + ' Phase 3: Post-processing (traceability matrix)...\n\n');
591
+ await runGroup(postSteps);
592
+ }
593
+
594
+ // ── Summary ─────────────────────────────────────────────────────────────────
595
+ const passed = results.filter(r => r.passed).length;
596
+ const failed = results.filter(r => !r.passed && !r.nonCritical).length;
597
+
598
+ process.stdout.write(TAG + ' ' + HR + '\n');
599
+ process.stdout.write(TAG + ' SUMMARY — ' + passed + '/' + results.length + ' passed\n');
600
+ process.stdout.write(TAG + ' ' + HR + '\n');
601
+
602
+ for (const r of results) {
603
+ const mark = r.passed ? '✓' : '✗';
604
+ const extra = r.note ? ' (' + r.note + ')' : '';
605
+ process.stdout.write(TAG + ' ' + mark + ' ' + r.id.padEnd(30) + r.label + extra + '\n');
606
+ }
607
+
608
+ process.stdout.write(TAG + ' ' + HR + '\n');
609
+
610
+ const elapsedMs = Date.now() - startMs;
611
+ const elapsedSec = (elapsedMs / 1000).toFixed(1);
612
+ process.stdout.write(TAG + ' Wall-clock: ' + elapsedSec + 's (' + elapsedMs + 'ms)\n');
613
+ process.stdout.write(TAG + ' ' + HR + '\n');
614
+
615
+ // NDJSON-based summary (UNIF-03)
616
+ try {
617
+ const ndjsonLines = fs.readFileSync(ndjsonPath, 'utf8')
618
+ .split('\n').filter(l => l.trim().length > 0);
619
+ const checkResults = ndjsonLines.map(l => JSON.parse(l));
620
+ const ndjsonFailed = checkResults.filter(r => r.result === 'fail').length;
621
+ const ndjsonPassed = checkResults.filter(r => r.result === 'pass').length;
622
+ const ndjsonOther = checkResults.length - ndjsonFailed - ndjsonPassed;
623
+ process.stdout.write(
624
+ TAG + ' check-results.ndjson: ' + ndjsonPassed + ' pass, ' +
625
+ ndjsonFailed + ' fail' +
626
+ (ndjsonOther > 0 ? ', ' + ndjsonOther + ' warn/inconclusive' : '') + '\n'
627
+ );
628
+ } catch (err) {
629
+ process.stderr.write(TAG + ' Warning: could not read check-results.ndjson: ' + err.message + '\n');
630
+ }
631
+
632
+ return failed;
633
+ }
634
+
635
+ // ── Entry point ───────────────────────────────────────────────────────────────
636
+ const watchArg = argv.includes('--watch');
637
+
638
+ if (watchArg) {
639
+ // ── Watch mode ─────────────────────────────────────────────────────────────
640
+ // machineDir uses process.cwd() so tests can point the watcher at a tmpDir
641
+ // by spawning with a custom cwd. __dirname-relative paths would always point
642
+ // to the real repo's src/machines/ regardless of spawn cwd, breaking isolation.
643
+ const machineDir = path.join(process.cwd(), 'src', 'machines');
644
+ const machineName = 'qgsd-workflow.machine.ts';
645
+ let debounceTimer = null;
646
+ let running = false; // concurrent-run guard
647
+ let watcher = null;
648
+
649
+ process.stdout.write(TAG + ' ' + HR + '\n');
650
+ process.stdout.write(TAG + ' Watch mode enabled\n');
651
+ process.stdout.write(TAG + ' Watching: ' + path.join(machineDir, machineName) + '\n');
652
+ process.stdout.write(TAG + ' Press Ctrl+C to stop.\n');
653
+ process.stdout.write(TAG + ' Tip: use --only=generate for faster feedback, --concurrent for parallel tool groups.\n');
654
+ process.stdout.write(TAG + ' ' + HR + '\n\n');
655
+
656
+ // Existence check — fail fast if invoked from wrong directory
657
+ if (!fs.existsSync(machineDir)) {
658
+ process.stderr.write(TAG + ' Error: machine directory not found: ' + machineDir + '\n');
659
+ process.stderr.write(TAG + ' Run --watch from the project root (where src/machines/ exists).\n');
660
+ process.exit(1);
661
+ }
662
+
663
+ // Initial run
664
+ runOnce().catch(err => process.stderr.write(TAG + ' Error: ' + err.message + '\n'));
665
+
666
+ // Watch parent directory — NOT the file directly.
667
+ // On macOS, watching a file dies after first rename (editor atomic-write pattern).
668
+ watcher = fs.watch(machineDir, (eventType, filename) => {
669
+ if (filename === machineName) {
670
+ if (debounceTimer) clearTimeout(debounceTimer);
671
+ debounceTimer = setTimeout(() => {
672
+ if (running) return;
673
+ running = true;
674
+ process.stdout.write('\n' + TAG + ' ' + HR + '\n');
675
+ process.stdout.write(TAG + ' Change detected — re-running verification\n');
676
+ process.stdout.write(TAG + ' ' + new Date().toISOString() + '\n');
677
+ process.stdout.write(TAG + ' ' + HR + '\n\n');
678
+ runOnce()
679
+ .catch(err => process.stderr.write(TAG + ' Error: ' + err.message + '\n'))
680
+ .finally(() => { running = false; });
681
+ }, 300);
682
+ }
683
+ });
684
+
685
+ process.on('SIGINT', () => {
686
+ if (debounceTimer) clearTimeout(debounceTimer);
687
+ if (watcher) watcher.close();
688
+ process.stdout.write('\n' + TAG + ' Exiting watch mode.\n');
689
+ process.exit(0);
690
+ });
691
+
692
+ } else {
693
+ // ── Non-watch mode: original behavior ──────────────────────────────────────
694
+ runOnce().then(failed => {
695
+ if (failed > 0) {
696
+ process.stderr.write(TAG + ' ' + failed + ' step(s) failed.\n');
697
+ process.exit(1);
698
+ }
699
+ process.exit(0);
700
+ });
701
+ }