@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,397 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/generate-petri-net.cjs
4
+ // Generates a Graphviz DOT + SVG Petri Net for the QGSD quorum token-passing model.
5
+ // Requirements: PET-01, PET-02, PET-03
6
+ //
7
+ // Usage:
8
+ // node bin/generate-petri-net.cjs
9
+ //
10
+ // Output:
11
+ // .planning/formal/petri/quorum-petri-net.dot — Graphviz DOT source
12
+ // .planning/formal/petri/quorum-petri-net.svg — Rendered SVG (via @hpcc-js/wasm-graphviz)
13
+ //
14
+ // No system Graphviz install required — uses @hpcc-js/wasm-graphviz WASM build.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ // Quorum configuration
20
+ const QUORUM_SLOTS = ['gemini', 'opencode', 'copilot', 'codex', 'claude'];
21
+ const MIN_QUORUM_SIZE = Math.ceil(QUORUM_SLOTS.length / 2); // = 3
22
+
23
+ // Optional --min-quorum=N override (makes PET-03 deadlock check exercisable at runtime)
24
+ const minQuorumArg = process.argv.slice(2).find(a => a.startsWith('--min-quorum='));
25
+ const effectiveMinQuorum = minQuorumArg
26
+ ? parseInt(minQuorumArg.split('=')[1], 10)
27
+ : MIN_QUORUM_SIZE;
28
+
29
+ // PET-03: structural deadlock check (pure logic — before any rendering)
30
+ // A structural deadlock occurs when the quorum transition can NEVER fire because
31
+ // min_quorum_size > available_slots (more approvals needed than slots available)
32
+ if (effectiveMinQuorum > QUORUM_SLOTS.length) {
33
+ process.stderr.write(
34
+ '[generate-petri-net] WARNING: Structural deadlock detected.\n' +
35
+ '[generate-petri-net] min_quorum_size (' + effectiveMinQuorum + ') > ' +
36
+ 'available_slots (' + QUORUM_SLOTS.length + ').\n' +
37
+ '[generate-petri-net] Quorum transition can never fire.\n'
38
+ );
39
+ // Do NOT exit 1 — still emit the net for documentation purposes (per PET-03)
40
+ }
41
+
42
+ // buildDot: pure function — exported via _pure for unit testing
43
+ function buildDot(slots, minQuorum) {
44
+ return [
45
+ 'digraph quorum_petri_net {',
46
+ ' rankdir=LR;',
47
+ ' label="QGSD Quorum Petri Net (min_quorum=' + minQuorum + '/' + slots.length + ')";',
48
+ ' node [fontname="Helvetica"];',
49
+ '',
50
+ ' // Places (circles)',
51
+ ' node [shape=circle, fixedsize=true, width=1.2];',
52
+ ' idle [label="idle"];',
53
+ ' collecting [label="collecting\\nvotes"];',
54
+ ' deliberating [label="deliberating"];',
55
+ ' decided [label="decided"];',
56
+ '',
57
+ ' // Transitions (filled rectangles)',
58
+ ' node [shape=rect, height=0.3, width=1.5, style=filled, fillcolor=black, fontcolor=white];',
59
+ ' t_start [label="start quorum"];',
60
+ ' t_approve [label="approve\\n(>=' + minQuorum + '/' + slots.length + ')"];',
61
+ ' t_deliberate [label="deliberate"];',
62
+ ' t_force [label="force decide\\n(max rounds)"];',
63
+ '',
64
+ ' // Arcs (bipartite: place->transition or transition->place only)',
65
+ ' idle -> t_start;',
66
+ ' t_start -> collecting;',
67
+ ' collecting -> t_approve;',
68
+ ' collecting -> t_deliberate;',
69
+ ' t_approve -> decided;',
70
+ ' t_deliberate -> deliberating;',
71
+ ' deliberating -> t_approve;',
72
+ ' deliberating -> t_force;',
73
+ ' t_force -> decided;',
74
+ '}',
75
+ ].join('\n');
76
+ }
77
+
78
+ // ── Roadmap Petri Net (SIG-02) ───────────────────────────────────────────────
79
+
80
+ /**
81
+ * parseRoadmapPhases(roadmapContent) — parses ROADMAP.md to extract phases and dependencies.
82
+ * @param {string} roadmapContent - Raw ROADMAP.md content
83
+ * @returns {Array<{ number: string, name: string, dependsOn: string[], completed: boolean }>}
84
+ */
85
+ function parseRoadmapPhases(roadmapContent) {
86
+ const lines = roadmapContent.split('\n');
87
+ const phases = [];
88
+ let currentPhase = null;
89
+
90
+ const phaseHeaderRe = /^### Phase (v[\d.]+-\d+):\s*(.+)/;
91
+ const dependsOnRe = /^\*\*Depends on\*\*:\s*(.+)/;
92
+ const checkboxRe = /^- \[(x| )\].*(?:Phase )?(v[\d.]+-\d+)/;
93
+
94
+ const completedFromCheckboxes = new Set();
95
+
96
+ for (const line of lines) {
97
+ const headerMatch = phaseHeaderRe.exec(line);
98
+ if (headerMatch) {
99
+ if (currentPhase) phases.push(currentPhase);
100
+ currentPhase = {
101
+ number: headerMatch[1],
102
+ name: headerMatch[2].trim(),
103
+ dependsOn: [],
104
+ completed: false,
105
+ };
106
+ if (line.includes('completed')) currentPhase.completed = true;
107
+ continue;
108
+ }
109
+
110
+ if (currentPhase) {
111
+ const depsMatch = dependsOnRe.exec(line);
112
+ if (depsMatch) {
113
+ const depsStr = depsMatch[1];
114
+ const phaseRefs = depsStr.match(/v[\d.]+-\d+/g);
115
+ if (phaseRefs) {
116
+ currentPhase.dependsOn = phaseRefs;
117
+ }
118
+ continue;
119
+ }
120
+ }
121
+
122
+ const cbMatch = checkboxRe.exec(line);
123
+ if (cbMatch && cbMatch[1] === 'x') {
124
+ completedFromCheckboxes.add(cbMatch[2]);
125
+ }
126
+ }
127
+ if (currentPhase) phases.push(currentPhase);
128
+
129
+ // Mark completed from checkboxes and content patterns
130
+ for (const phase of phases) {
131
+ if (completedFromCheckboxes.has(phase.number)) {
132
+ phase.completed = true;
133
+ }
134
+ }
135
+
136
+ // Second pass: detect "(completed YYYY-MM-DD)" near phase headers
137
+ let currentPhaseIdx = -1;
138
+ for (const line of lines) {
139
+ const headerMatch = phaseHeaderRe.exec(line);
140
+ if (headerMatch) {
141
+ currentPhaseIdx = phases.findIndex(function(p) { return p.number === headerMatch[1]; });
142
+ continue;
143
+ }
144
+ if (currentPhaseIdx >= 0 && /completed\s+\d{4}-\d{2}-\d{2}/.test(line)) {
145
+ phases[currentPhaseIdx].completed = true;
146
+ }
147
+ }
148
+
149
+ return phases;
150
+ }
151
+
152
+ /**
153
+ * buildRoadmapDot(phases) — generates a Petri net DOT from parsed phases.
154
+ * @param {Array<{ number: string, name: string, dependsOn: string[], completed: boolean }>} phases
155
+ * @returns {string} DOT source
156
+ */
157
+ function buildRoadmapDot(phases) {
158
+ const lines = [
159
+ 'digraph roadmap_petri_net {',
160
+ ' rankdir=LR;',
161
+ ' label="QGSD Roadmap Petri Net (' + phases.length + ' phases)";',
162
+ ' node [fontname="Helvetica"];',
163
+ '',
164
+ ];
165
+
166
+ if (phases.length === 0) {
167
+ lines.push('}');
168
+ return lines.join('\n');
169
+ }
170
+
171
+ const phaseNumbers = new Set(phases.map(function(p) { return p.number; }));
172
+ const hasDependents = new Set();
173
+ for (const phase of phases) {
174
+ for (const dep of phase.dependsOn) {
175
+ if (phaseNumbers.has(dep)) hasDependents.add(dep);
176
+ }
177
+ }
178
+
179
+ lines.push(' // Places (circles)');
180
+ lines.push(' node [shape=circle, fixedsize=true, width=0.8];');
181
+
182
+ const sourcesExist = phases.some(function(p) { return p.dependsOn.length === 0; });
183
+ if (sourcesExist) {
184
+ lines.push(' p_start [label="start"];');
185
+ }
186
+
187
+ for (const phase of phases) {
188
+ for (const dep of phase.dependsOn) {
189
+ if (!phaseNumbers.has(dep)) continue;
190
+ const placeId = 'p_' + dep.replace(/[.-]/g, '_') + '__' + phase.number.replace(/[.-]/g, '_');
191
+ lines.push(' ' + placeId + ' [label=""];');
192
+ }
193
+ }
194
+
195
+ const sinksExist = phases.some(function(p) { return !hasDependents.has(p.number); });
196
+ if (sinksExist) {
197
+ lines.push(' p_done [label="done"];');
198
+ }
199
+
200
+ lines.push('');
201
+ lines.push(' // Transitions (rectangles) -- phases');
202
+ for (const phase of phases) {
203
+ const nodeId = 't_' + phase.number.replace(/[.-]/g, '_');
204
+ const label = phase.number + '\\n' + phase.name.substring(0, 30);
205
+ if (phase.completed) {
206
+ lines.push(' ' + nodeId + ' [shape=rect, height=0.5, width=2.0, style=filled, fillcolor="#4CAF50", fontcolor=white, label="' + label + '"];');
207
+ } else {
208
+ lines.push(' ' + nodeId + ' [shape=rect, height=0.5, width=2.0, style=filled, fillcolor=black, fontcolor=white, label="' + label + '"];');
209
+ }
210
+ }
211
+
212
+ lines.push('');
213
+ lines.push(' // Arcs');
214
+ for (const phase of phases) {
215
+ const nodeId = 't_' + phase.number.replace(/[.-]/g, '_');
216
+
217
+ if (phase.dependsOn.length === 0 && sourcesExist) {
218
+ lines.push(' p_start -> ' + nodeId + ';');
219
+ }
220
+
221
+ for (const dep of phase.dependsOn) {
222
+ if (!phaseNumbers.has(dep)) continue;
223
+ const placeId = 'p_' + dep.replace(/[.-]/g, '_') + '__' + phase.number.replace(/[.-]/g, '_');
224
+ lines.push(' ' + placeId + ' -> ' + nodeId + ';');
225
+ }
226
+
227
+ for (const downstream of phases) {
228
+ if (downstream.dependsOn.includes(phase.number)) {
229
+ const placeId = 'p_' + phase.number.replace(/[.-]/g, '_') + '__' + downstream.number.replace(/[.-]/g, '_');
230
+ lines.push(' ' + nodeId + ' -> ' + placeId + ';');
231
+ }
232
+ }
233
+
234
+ if (!hasDependents.has(phase.number) && sinksExist) {
235
+ lines.push(' ' + nodeId + ' -> p_done;');
236
+ }
237
+ }
238
+
239
+ lines.push('}');
240
+ return lines.join('\n');
241
+ }
242
+
243
+ /**
244
+ * computeCriticalPath(phases) — finds the longest path through the phase DAG.
245
+ * @param {Array<{ number: string, name: string, dependsOn: string[], completed: boolean }>} phases
246
+ * @returns {{ path: string[], length: number }}
247
+ */
248
+ function computeCriticalPath(phases) {
249
+ if (phases.length === 0) return { path: [], length: 0 };
250
+
251
+ const phaseMap = new Map();
252
+ for (const p of phases) phaseMap.set(p.number, p);
253
+
254
+ // Kahn's algorithm for topological sort + longest path DP
255
+ const inDegree = new Map();
256
+ const adj = new Map();
257
+
258
+ for (const p of phases) {
259
+ if (!inDegree.has(p.number)) inDegree.set(p.number, 0);
260
+ if (!adj.has(p.number)) adj.set(p.number, []);
261
+ }
262
+
263
+ for (const p of phases) {
264
+ for (const dep of p.dependsOn) {
265
+ if (!phaseMap.has(dep)) continue;
266
+ if (!adj.has(dep)) adj.set(dep, []);
267
+ adj.get(dep).push(p.number);
268
+ inDegree.set(p.number, (inDegree.get(p.number) || 0) + 1);
269
+ }
270
+ }
271
+
272
+ const queue = [];
273
+ for (const [node, deg] of inDegree) {
274
+ if (deg === 0) queue.push(node);
275
+ }
276
+
277
+ const dist = new Map();
278
+ const pred = new Map();
279
+ for (const p of phases) {
280
+ dist.set(p.number, 1);
281
+ pred.set(p.number, null);
282
+ }
283
+
284
+ while (queue.length > 0) {
285
+ const node = queue.shift();
286
+ for (const next of (adj.get(node) || [])) {
287
+ if (dist.get(node) + 1 > dist.get(next)) {
288
+ dist.set(next, dist.get(node) + 1);
289
+ pred.set(next, node);
290
+ }
291
+ inDegree.set(next, inDegree.get(next) - 1);
292
+ if (inDegree.get(next) === 0) queue.push(next);
293
+ }
294
+ }
295
+
296
+ let maxDist = 0;
297
+ let endNode = null;
298
+ for (const [node, d] of dist) {
299
+ if (d > maxDist) {
300
+ maxDist = d;
301
+ endNode = node;
302
+ }
303
+ }
304
+
305
+ const criticalPath = [];
306
+ let current = endNode;
307
+ while (current !== null) {
308
+ criticalPath.unshift(current);
309
+ current = pred.get(current);
310
+ }
311
+
312
+ return { path: criticalPath, length: criticalPath.length };
313
+ }
314
+
315
+ // Export pure functions for unit testing
316
+ module.exports._pure = { buildDot, buildRoadmapDot, computeCriticalPath, parseRoadmapPhases };
317
+
318
+ // Guard against running main logic when required as a module (test imports)
319
+ if (require.main === module) {
320
+ const isRoadmap = process.argv.includes('--roadmap');
321
+
322
+ if (isRoadmap) {
323
+ // ── Roadmap Petri Net mode ──────────────────────────────────────────────
324
+ const roadmapPath = path.join(process.cwd(), '.planning', 'ROADMAP.md');
325
+ if (!fs.existsSync(roadmapPath)) {
326
+ process.stderr.write('[generate-petri-net] ROADMAP.md not found at: ' + roadmapPath + '\n');
327
+ process.exit(1);
328
+ }
329
+
330
+ const content = fs.readFileSync(roadmapPath, 'utf8');
331
+ const phases = parseRoadmapPhases(content);
332
+ const dotContent = buildRoadmapDot(phases);
333
+ const criticalPath = computeCriticalPath(phases);
334
+
335
+ const outDir = path.join(process.cwd(), '.planning', 'formal', 'petri');
336
+ const dotPath = path.join(outDir, 'roadmap-petri-net.dot');
337
+ const svgPath = path.join(outDir, 'roadmap-petri-net.svg');
338
+
339
+ fs.mkdirSync(outDir, { recursive: true });
340
+ fs.writeFileSync(dotPath, dotContent);
341
+ process.stdout.write('[generate-petri-net] Roadmap DOT written to: ' + dotPath + '\n');
342
+ process.stdout.write('[generate-petri-net] Phases: ' + phases.length + '\n');
343
+ process.stdout.write('[generate-petri-net] Critical path (' + criticalPath.length + '): ' + criticalPath.path.join(' -> ') + '\n');
344
+
345
+ // Render SVG
346
+ (async () => {
347
+ let Graphviz;
348
+ try {
349
+ ({ Graphviz } = await import('@hpcc-js/wasm-graphviz'));
350
+ } catch (_e) {
351
+ process.stderr.write('[generate-petri-net] @hpcc-js/wasm-graphviz not installed -- SVG skipped.\n');
352
+ process.exit(0);
353
+ }
354
+ try {
355
+ const graphviz = await Graphviz.load();
356
+ const svg = graphviz.dot(dotContent);
357
+ fs.writeFileSync(svgPath, svg);
358
+ process.stdout.write('[generate-petri-net] Roadmap SVG written to: ' + svgPath + '\n');
359
+ } catch (renderErr) {
360
+ process.stderr.write('[generate-petri-net] SVG render failed: ' + renderErr.message + '\n');
361
+ process.exit(1);
362
+ }
363
+ })();
364
+ } else {
365
+ // ── Quorum Petri Net mode (existing) ────────────────────────────────────
366
+ const dotContent = buildDot(QUORUM_SLOTS, effectiveMinQuorum);
367
+ const outDir = path.join(process.cwd(), '.planning', 'formal', 'petri');
368
+ const dotPath = path.join(outDir, 'quorum-petri-net.dot');
369
+ const svgPath = path.join(outDir, 'quorum-petri-net.svg');
370
+
371
+ fs.mkdirSync(outDir, { recursive: true });
372
+ fs.writeFileSync(dotPath, dotContent);
373
+ process.stdout.write('[generate-petri-net] DOT written to: ' + dotPath + '\n');
374
+
375
+ (async () => {
376
+ let Graphviz;
377
+ try {
378
+ ({ Graphviz } = await import('@hpcc-js/wasm-graphviz'));
379
+ } catch (importErr) {
380
+ process.stderr.write(
381
+ '[generate-petri-net] @hpcc-js/wasm-graphviz not installed.\n' +
382
+ '[generate-petri-net] Run: npm install --save-dev @hpcc-js/wasm-graphviz\n'
383
+ );
384
+ process.exit(1);
385
+ }
386
+ try {
387
+ const graphviz = await Graphviz.load();
388
+ const svg = graphviz.dot(dotContent);
389
+ fs.writeFileSync(svgPath, svg);
390
+ process.stdout.write('[generate-petri-net] SVG written to: ' + svgPath + '\n');
391
+ } catch (renderErr) {
392
+ process.stderr.write('[generate-petri-net] SVG render failed: ' + renderErr.message + '\n');
393
+ process.exit(1);
394
+ }
395
+ })();
396
+ }
397
+ }
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/generate-phase-spec.cjs
4
+ // SPEC-04: Reads must_haves: truths: from *-PLAN.md YAML frontmatter and translates
5
+ // them into per-phase TLA+ scratch spec with INVARIANT/PROPERTY stubs.
6
+ //
7
+ // Data source: YAML frontmatter in *-PLAN.md files (NOT task-envelope.json — truths
8
+ // are empty in task-envelope.json at planning time; plan frontmatter is the correct source).
9
+ //
10
+ // Usage:
11
+ // node bin/generate-phase-spec.cjs .planning/phases/v0.21-04-spec-completeness/
12
+ // node bin/generate-phase-spec.cjs .planning/phases/v0.21-04-spec-completeness/v0.21-04-01-PLAN.md
13
+ // node bin/generate-phase-spec.cjs <phase-dir-or-plan-file> --dry-run
14
+ //
15
+ // Output: .planning/formal/tla/scratch/<phase>-<timestamp>.tla
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const ROOT = path.join(__dirname, '..');
21
+ const SCRATCH_DIR = path.join(ROOT, '.planning', 'formal', 'tla', 'scratch');
22
+
23
+ /**
24
+ * Parse YAML frontmatter from a PLAN.md file.
25
+ * Returns the frontmatter object (parsed manually — no yaml dependency).
26
+ * Frontmatter is between the first two '---' lines.
27
+ *
28
+ * Extracts:
29
+ * - phase: string
30
+ * - truths: string[] from must_haves.truths
31
+ */
32
+ function parsePlanFrontmatter(content) {
33
+ const lines = content.split('\n');
34
+ if (!lines[0] || lines[0].trim() !== '---') return { truths: [] };
35
+ const endIdx = lines.slice(1).findIndex(l => l.trim() === '---');
36
+ if (endIdx === -1) return { truths: [] };
37
+ const fmLines = lines.slice(1, endIdx + 1);
38
+
39
+ // Minimal YAML parser: extracts must_haves.truths array and phase field only.
40
+ // Looks for 'must_haves:' block, then 'truths:' key, then collects '- "..."' or "- '...'" entries.
41
+ const result = {};
42
+ let inMustHaves = false;
43
+ let inTruths = false;
44
+ const truths = [];
45
+
46
+ for (const line of fmLines) {
47
+ // Detect phase field at top-level
48
+ const phaseMatch = line.match(/^phase:\s*(.+)/);
49
+ if (phaseMatch) {
50
+ result.phase = phaseMatch[1].replace(/['"]/g, '').trim();
51
+ continue;
52
+ }
53
+
54
+ if (/^must_haves:/.test(line)) {
55
+ inMustHaves = true;
56
+ inTruths = false;
57
+ continue;
58
+ }
59
+
60
+ if (inMustHaves && /^\s{2}truths:/.test(line)) {
61
+ inTruths = true;
62
+ continue;
63
+ }
64
+
65
+ if (inMustHaves && inTruths) {
66
+ // Match list items: " - "text"" or " - 'text'" or " - text"
67
+ const itemMatch = line.match(/^\s{4}-\s+"?'?(.+?)'?"?\s*$/);
68
+ if (itemMatch) {
69
+ // Clean up: strip surrounding quotes if present
70
+ let truth = itemMatch[1].trim();
71
+ // Remove leading/trailing quotes that may remain
72
+ truth = truth.replace(/^["']|["']$/g, '');
73
+ if (truth.length > 0) truths.push(truth);
74
+ continue;
75
+ }
76
+ // Stop collecting truths when indentation drops below 4 spaces (non-list, non-empty)
77
+ if (!/^\s{4}/.test(line) && line.trim() !== '') {
78
+ inTruths = false;
79
+ }
80
+ }
81
+
82
+ // Detect top-level key change inside must_haves (drops indentation)
83
+ if (inMustHaves && !inTruths && !/^\s{2,}/.test(line) && line.trim() !== '') {
84
+ inMustHaves = false;
85
+ }
86
+ }
87
+
88
+ result.truths = truths;
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Collect all *-PLAN.md files from a directory or single file path.
94
+ * Returns sorted array of absolute paths.
95
+ */
96
+ function collectPlanFiles(inputPath) {
97
+ const resolved = path.resolve(inputPath);
98
+ const stat = fs.statSync(resolved);
99
+ if (stat.isFile()) return [resolved];
100
+ // Directory: find *-PLAN.md files
101
+ return fs.readdirSync(resolved)
102
+ .filter(f => f.endsWith('-PLAN.md'))
103
+ .sort()
104
+ .map(f => path.join(resolved, f));
105
+ }
106
+
107
+ /**
108
+ * Classify a truth string as safety (INVARIANT) or liveness (PROPERTY).
109
+ *
110
+ * Liveness keywords: eventually, deadline, async, progress, future, after, until, leads-to
111
+ * Safety keywords: everything else (threshold, depth, count, max, min, never, always, etc.)
112
+ *
113
+ * @param {string} truth
114
+ * @returns {'PROPERTY' | 'INVARIANT'}
115
+ */
116
+ function classifyTruth(truth) {
117
+ const lower = truth.toLowerCase();
118
+ const livenessKeywords = [
119
+ 'eventually',
120
+ 'deadline',
121
+ 'async',
122
+ 'progress',
123
+ 'future',
124
+ 'leads-to',
125
+ 'after',
126
+ 'until',
127
+ ];
128
+ const isLiveness = livenessKeywords.some(kw => lower.includes(kw));
129
+ return isLiveness ? 'PROPERTY' : 'INVARIANT';
130
+ }
131
+
132
+ /**
133
+ * Generate TLA+ module content from collected truths.
134
+ *
135
+ * @param {{ phase: string, truths: string[] }} options
136
+ * @returns {{ moduleName: string, spec: string, truthCount: number }}
137
+ */
138
+ function generatePhaseSpec({ phase, truths }) {
139
+ const allTruths = truths || [];
140
+
141
+ // TLA+ module name: sanitize phase string (e.g. v0.21-04 → Phasev0_21_04Spec)
142
+ const moduleName = 'Phase' + (phase || 'unknown').replace(/[^a-zA-Z0-9]/g, '_') + 'Spec';
143
+ const timestamp = new Date().toISOString();
144
+
145
+ let spec = `---- MODULE ${moduleName} ----
146
+ (* Source: must_haves: truths: from *-PLAN.md YAML frontmatter *)
147
+ (* Generated by bin/generate-phase-spec.cjs — SPEC-04 *)
148
+ (* Phase: ${phase || 'unknown'} *)
149
+ (* Generated: ${timestamp} *)
150
+ (* Purpose: Verify proposed state machine changes before quorum approval *)
151
+ (* PLACEHOLDER properties — developer fills in formal logic before TLC run *)
152
+
153
+ EXTENDS Naturals, FiniteSets, TLC
154
+
155
+ VARIABLES state, counter
156
+
157
+ TypeOK == state \\in {"INIT", "RUNNING", "DONE"}
158
+
159
+ Init == state = "INIT" /\\ counter = 0
160
+
161
+ Next ==
162
+ \\/ /\\ state = "INIT" /\\ state' = "RUNNING" /\\ counter' = counter + 1
163
+ \\/ /\\ state = "RUNNING" /\\ state' = "DONE" /\\ counter' = counter
164
+ \\/ /\\ state = "DONE" /\\ UNCHANGED <<state, counter>>
165
+
166
+ Spec == Init /\\ [][Next]_<<state, counter>>
167
+
168
+ \\* ── Requirements as Properties ──────────────────────────────────────────────
169
+
170
+ `;
171
+
172
+ if (allTruths.length === 0) {
173
+ spec += `\\* No must_haves: truths: found in *-PLAN.md frontmatter
174
+ \\* Add truths to see them translated to INVARIANT/PROPERTY stubs
175
+
176
+ `;
177
+ }
178
+
179
+ allTruths.forEach((truth, idx) => {
180
+ const kind = classifyTruth(truth);
181
+ const propName = `Req${String(idx + 1).padStart(2, '0')}`;
182
+ // Escape backslashes and truncate label for TLA+ comment
183
+ const label = truth.substring(0, 100).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
184
+ spec += `${kind} ${propName} ==
185
+ \\* "${label}${truth.length > 100 ? '...' : ''}"
186
+ TRUE \\* PLACEHOLDER: replace TRUE with formal TLA+ expression
187
+
188
+ `;
189
+ });
190
+
191
+ spec += `====`;
192
+ return { moduleName, spec, truthCount: allTruths.length };
193
+ }
194
+
195
+ // ── CLI entrypoint ────────────────────────────────────────────────────────────
196
+ if (require.main === module) {
197
+ const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
198
+ const flags = process.argv.slice(2).filter(a => a.startsWith('--'));
199
+ const isDryRun = flags.includes('--dry-run');
200
+
201
+ if (args.length === 0) {
202
+ process.stderr.write('[generate-phase-spec] Usage: node bin/generate-phase-spec.cjs <phase-dir-or-PLAN.md> [--dry-run]\n');
203
+ process.stderr.write('[generate-phase-spec] Reads must_haves: truths: from *-PLAN.md YAML frontmatter (NOT task-envelope.json)\n');
204
+ process.exit(1);
205
+ }
206
+
207
+ const inputPath = path.resolve(args[0]);
208
+ if (!fs.existsSync(inputPath)) {
209
+ process.stderr.write('[generate-phase-spec] Error: path not found: ' + inputPath + '\n');
210
+ process.exit(1);
211
+ }
212
+
213
+ const planFiles = collectPlanFiles(inputPath);
214
+ if (planFiles.length === 0) {
215
+ process.stderr.write('[generate-phase-spec] Error: no *-PLAN.md files found in ' + inputPath + '\n');
216
+ process.exit(1);
217
+ }
218
+
219
+ // Collect truths from all plan files
220
+ let phase = 'unknown';
221
+ const allTruths = [];
222
+ for (const planFile of planFiles) {
223
+ const content = fs.readFileSync(planFile, 'utf8');
224
+ const fm = parsePlanFrontmatter(content);
225
+ if (fm.phase) phase = fm.phase;
226
+ if (fm.truths && fm.truths.length > 0) allTruths.push(...fm.truths);
227
+ }
228
+
229
+ const { moduleName, spec, truthCount } = generatePhaseSpec({ phase, truths: allTruths });
230
+ const ts = Date.now();
231
+ const outName = phase.replace(/[^a-zA-Z0-9-]/g, '_') + '-' + ts + '.tla';
232
+ const outPath = path.join(SCRATCH_DIR, outName);
233
+
234
+ if (isDryRun) {
235
+ process.stdout.write('[generate-phase-spec] DRY-RUN: would write ' + outPath + '\n');
236
+ process.stdout.write('[generate-phase-spec] Plan files: ' + planFiles.join(', ') + '\n');
237
+ process.stdout.write('[generate-phase-spec] Truths collected: ' + truthCount + '\n');
238
+ process.stdout.write(spec + '\n');
239
+ } else {
240
+ fs.mkdirSync(SCRATCH_DIR, { recursive: true });
241
+ fs.writeFileSync(outPath, spec, 'utf8');
242
+ process.stdout.write(
243
+ '[generate-phase-spec] Written: ' + outPath +
244
+ ' (' + truthCount + ' truths from ' + planFiles.length + ' plan files → INVARIANT/PROPERTY stubs)\n'
245
+ );
246
+ }
247
+ }
248
+
249
+ module.exports = { generatePhaseSpec, classifyTruth, parsePlanFrontmatter };