@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,472 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Domain-specific pattern words for keyword extraction
7
+ const DOMAIN_PATTERNS = [
8
+ 'NDJSON', 'TLA', 'TLA+', 'Alloy', 'PRISM', 'UPPAAL', 'xstate',
9
+ 'frontmatter', 'quorum', 'circuit-breaker', 'hook', 'MCP', 'scoreboard',
10
+ 'liveness', 'fairness', 'CTL', 'LTL'
11
+ ];
12
+
13
+ // Stopwords for filtering
14
+ const STOPWORDS = new Set([
15
+ 'the', 'and', 'is', 'a', 'for', 'to', 'in', 'of', 'with', 'that',
16
+ 'on', 'from', 'all', 'be', 'have', 'has', 'are', 'as', 'at', 'by',
17
+ 'or', 'it', 'this', 'an', 'was', 'were', 'been', 'can', 'will'
18
+ ]);
19
+
20
+ /**
21
+ * Simple YAML frontmatter parser (supports basic key: value format).
22
+ * Does not use external dependencies.
23
+ */
24
+ function parseSimpleYaml(yamlStr) {
25
+ const result = {};
26
+ const lines = yamlStr.split('\n');
27
+
28
+ for (const line of lines) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith('#')) continue;
31
+
32
+ const colonIdx = trimmed.indexOf(':');
33
+ if (colonIdx === -1) {
34
+ // Line without colon is invalid YAML syntax
35
+ throw new Error(`Invalid YAML: ${trimmed}`);
36
+ }
37
+
38
+ const key = trimmed.substring(0, colonIdx).trim();
39
+ const value = trimmed.substring(colonIdx + 1).trim();
40
+
41
+ // Skip empty keys
42
+ if (!key) {
43
+ throw new Error(`Invalid YAML: empty key on line "${trimmed}"`);
44
+ }
45
+
46
+ // Parse value type
47
+ let parsedValue;
48
+ if (value === '' || value === 'null') {
49
+ parsedValue = null;
50
+ } else if (value === 'true') {
51
+ parsedValue = true;
52
+ } else if (value === 'false') {
53
+ parsedValue = false;
54
+ } else if (/^\d+$/.test(value)) {
55
+ parsedValue = parseInt(value, 10);
56
+ } else if (/^\d+\.\d+$/.test(value)) {
57
+ parsedValue = parseFloat(value);
58
+ } else if (value.startsWith('[') && value.endsWith(']')) {
59
+ // Simple array parsing
60
+ try {
61
+ parsedValue = JSON.parse(value);
62
+ } catch (e) {
63
+ parsedValue = value;
64
+ }
65
+ } else {
66
+ // String value (remove quotes if present)
67
+ parsedValue = value.replace(/^["']|["']$/g, '');
68
+ }
69
+
70
+ result[key] = parsedValue;
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Parse YAML frontmatter from a markdown file content string.
78
+ * Returns an object with parsed fields or empty object if no frontmatter.
79
+ */
80
+ function parseVerificationFrontmatter(content) {
81
+ const lines = content.split('\n');
82
+ if (lines.length < 3 || lines[0] !== '---') {
83
+ return {};
84
+ }
85
+
86
+ let endMarker = -1;
87
+ for (let i = 1; i < lines.length; i++) {
88
+ if (lines[i] === '---') {
89
+ endMarker = i;
90
+ break;
91
+ }
92
+ }
93
+
94
+ if (endMarker === -1) {
95
+ return {};
96
+ }
97
+
98
+ const yamlStr = lines.slice(1, endMarker).join('\n');
99
+ try {
100
+ const parsed = parseSimpleYaml(yamlStr);
101
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
102
+ } catch (e) {
103
+ // Return empty object for malformed YAML (do not throw)
104
+ return {};
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Extract requirement IDs from content using [A-Z]+-\d+ pattern.
110
+ * Returns array of unique uppercase IDs.
111
+ */
112
+ function extractRequirementIds(content) {
113
+ const reqPattern = /[A-Z]+-\d+/g;
114
+ const matches = content.match(reqPattern) || [];
115
+ return [...new Set(matches)].sort();
116
+ }
117
+
118
+ /**
119
+ * Extract keywords from directory name, phase goal text, and Observable Truths.
120
+ * Returns array of keywords capped at 12.
121
+ */
122
+ function extractKeywords(dirName, phaseGoal, truthsText) {
123
+ const keywords = new Set();
124
+
125
+ // From directory name: split on hyphens, filter version prefix
126
+ const parts = dirName.split('-');
127
+ for (let i = 0; i < parts.length; i++) {
128
+ const part = parts[i];
129
+ // Skip version prefix (v0.XX, v0.XX-NN)
130
+ if (/^v\d+/.test(part)) {
131
+ continue;
132
+ }
133
+ // Skip plan numbers
134
+ if (/^\d+$/.test(part)) {
135
+ continue;
136
+ }
137
+ const word = part.toLowerCase();
138
+ if (word && !STOPWORDS.has(word)) {
139
+ keywords.add(word);
140
+ }
141
+ }
142
+
143
+ // From phase goal text: extract nouns, filter stopwords
144
+ if (phaseGoal) {
145
+ const goalWords = phaseGoal
146
+ .toLowerCase()
147
+ .replace(/[^\w\s]/g, ' ')
148
+ .split(/\s+/)
149
+ .filter(w => w && w.length > 2 && !STOPWORDS.has(w));
150
+
151
+ // Keep distinctive words (top 5-8)
152
+ const wordFreq = {};
153
+ goalWords.forEach(w => {
154
+ wordFreq[w] = (wordFreq[w] || 0) + 1;
155
+ });
156
+ const sorted = Object.entries(wordFreq)
157
+ .sort((a, b) => b[1] - a[1])
158
+ .slice(0, 8)
159
+ .map(([w]) => w);
160
+ sorted.forEach(w => keywords.add(w));
161
+ }
162
+
163
+ // From Observable Truths text: look for domain-specific patterns
164
+ if (truthsText) {
165
+ const lowerTruths = truthsText.toLowerCase();
166
+ DOMAIN_PATTERNS.forEach(pattern => {
167
+ if (lowerTruths.includes(pattern.toLowerCase())) {
168
+ keywords.add(pattern.toLowerCase());
169
+ }
170
+ });
171
+
172
+ // Also extract distinctive words from truths text
173
+ const truthWords = truthsText
174
+ .toLowerCase()
175
+ .replace(/[^\w\s]/g, ' ')
176
+ .split(/\s+/)
177
+ .filter(w => w && w.length > 2 && !STOPWORDS.has(w));
178
+ truthWords.forEach(w => keywords.add(w));
179
+ }
180
+
181
+ // Deduplicate and cap at 12
182
+ const result = Array.from(keywords).slice(0, 12);
183
+ return result;
184
+ }
185
+
186
+ /**
187
+ * Extract Observable Truths text from VERIFICATION.md content.
188
+ * Looks for a markdown table with "Observable Truth" or similar header.
189
+ */
190
+ function extractObservableTruths(content) {
191
+ const lines = content.split('\n');
192
+ let truthsText = '';
193
+ let inTable = false;
194
+
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i];
197
+
198
+ // Look for table header with "Observable" or "Truth"
199
+ if (/observable|truth/i.test(line) && /\|/.test(line)) {
200
+ inTable = true;
201
+ continue;
202
+ }
203
+
204
+ // Collect table rows
205
+ if (inTable && /^\|/.test(line)) {
206
+ truthsText += ' ' + line;
207
+ } else if (inTable && !/^\|/.test(line)) {
208
+ break;
209
+ }
210
+ }
211
+
212
+ return truthsText;
213
+ }
214
+
215
+ /**
216
+ * Extract phase name from content (H1 heading).
217
+ * Pattern: # Phase vX.YY-NN: {Name}
218
+ */
219
+ function extractPhaseName(content) {
220
+ const match = content.match(/^#\s+Phase\s+([^:]+):\s*(.+)$/m);
221
+ if (match) {
222
+ return match[2].trim();
223
+ }
224
+ return '';
225
+ }
226
+
227
+ /**
228
+ * Build the complete phase index from all VERIFICATION.md files.
229
+ * Returns { version, generated_at, phases }
230
+ */
231
+ function buildPhaseIndex() {
232
+ const phasesDir = '.planning/phases';
233
+ const skipped = [];
234
+ const phases = [];
235
+
236
+ if (!fs.existsSync(phasesDir)) {
237
+ console.error(`Error: ${phasesDir} not found`);
238
+ process.exit(1);
239
+ }
240
+
241
+ const entries = fs.readdirSync(phasesDir);
242
+
243
+ for (const entry of entries) {
244
+ const dirPath = path.join(phasesDir, entry);
245
+ const stat = fs.statSync(dirPath);
246
+ if (!stat.isDirectory()) continue;
247
+
248
+ // Look for primary VERIFICATION.md (without plan suffix)
249
+ let verificationPath = null;
250
+ const files = fs.readdirSync(dirPath);
251
+
252
+ // First try to find primary VERIFICATION.md (matches v0.XX-NN-VERIFICATION.md pattern)
253
+ for (const file of files) {
254
+ if (file.endsWith('-VERIFICATION.md')) {
255
+ // Check if this is primary (no extra plan number suffix)
256
+ // e.g., v0.14-02-VERIFICATION.md is primary, v0.14-02-03-VERIFICATION.md is not
257
+ const baseName = file.replace(/-VERIFICATION\.md$/, '');
258
+ const parts = baseName.split('-');
259
+ // Primary format: v0.XX-NN (version and phase number only)
260
+ if (parts.length === 2 && /^v\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
261
+ verificationPath = path.join(dirPath, file);
262
+ break;
263
+ }
264
+ }
265
+ }
266
+
267
+ // Fallback: use first VERIFICATION.md found
268
+ if (!verificationPath) {
269
+ const verFile = files.find(f => f.endsWith('-VERIFICATION.md'));
270
+ if (verFile) {
271
+ verificationPath = path.join(dirPath, verFile);
272
+ }
273
+ }
274
+
275
+ if (!verificationPath) {
276
+ continue;
277
+ }
278
+
279
+ try {
280
+ const content = fs.readFileSync(verificationPath, 'utf-8');
281
+ const frontmatter = parseVerificationFrontmatter(content);
282
+
283
+ // Skip if frontmatter is empty (malformed file)
284
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
285
+ const relPath = path.relative(process.cwd(), verificationPath);
286
+ console.error(`WARN: Skipping ${relPath}: no valid YAML frontmatter found`);
287
+ skipped.push(verificationPath);
288
+ continue;
289
+ }
290
+
291
+ const phaseId = frontmatter.phase || entry;
292
+ const status = frontmatter.status || 'unknown';
293
+ const phaseName = extractPhaseName(content) || entry;
294
+
295
+ // Extract requirement IDs (newer format)
296
+ const requirementIds = extractRequirementIds(content);
297
+
298
+ // Extract keywords (especially for older phases)
299
+ const truthsText = extractObservableTruths(content);
300
+ const keywords = extractKeywords(entry, phaseName, truthsText);
301
+
302
+ const phaseEntry = {
303
+ phase_id: phaseId,
304
+ phase_name: phaseName,
305
+ status: status,
306
+ requirement_ids: requirementIds,
307
+ keywords: keywords,
308
+ verification_path: path.relative(process.cwd(), verificationPath)
309
+ };
310
+
311
+ phases.push(phaseEntry);
312
+ } catch (e) {
313
+ const relPath = path.relative(process.cwd(), verificationPath);
314
+ console.error(`WARN: Skipping ${relPath}: ${e.message}`);
315
+ skipped.push(verificationPath);
316
+ }
317
+ }
318
+
319
+ // Sort by phase_id for consistent ordering
320
+ phases.sort((a, b) => a.phase_id.localeCompare(b.phase_id));
321
+
322
+ const index = {
323
+ version: '1.0',
324
+ generated_at: new Date().toISOString(),
325
+ phases: phases
326
+ };
327
+
328
+ // Write compact JSON (one entry per line for readability)
329
+ const outputPath = '.planning/formal/phase-index.json';
330
+ const jsonLines = [
331
+ '{',
332
+ ` "version": "${index.version}",`,
333
+ ` "generated_at": "${index.generated_at}",`,
334
+ ' "phases": ['
335
+ ];
336
+
337
+ for (let i = 0; i < phases.length; i++) {
338
+ const entry = JSON.stringify(phases[i]);
339
+ const comma = i < phases.length - 1 ? ',' : '';
340
+ jsonLines.push(` ${entry}${comma}`);
341
+ }
342
+
343
+ jsonLines.push(' ]');
344
+ jsonLines.push('}');
345
+
346
+ fs.writeFileSync(outputPath, jsonLines.join('\n'));
347
+
348
+ // Print summary
349
+ const skippedMsg = skipped.length > 0 ? ` (${skipped.length} skipped — see warnings above)` : '';
350
+ console.log(`Phase index: ${phases.length} phases indexed${skippedMsg}, written to ${outputPath}`);
351
+
352
+ return index;
353
+ }
354
+
355
+ /**
356
+ * Append or update a single phase entry in the existing phase-index.json.
357
+ * Idempotent: removes old entry with same phase_id before appending.
358
+ */
359
+ function appendPhaseEntry(phaseDir, verificationPath) {
360
+ // Read or create base index
361
+ const indexPath = '.planning/formal/phase-index.json';
362
+ let index = {
363
+ version: '1.0',
364
+ generated_at: new Date().toISOString(),
365
+ phases: []
366
+ };
367
+
368
+ if (fs.existsSync(indexPath)) {
369
+ try {
370
+ const content = fs.readFileSync(indexPath, 'utf-8');
371
+ index = JSON.parse(content);
372
+ } catch (e) {
373
+ // Start fresh if parse fails
374
+ console.error(`WARN: Could not parse ${indexPath}, starting fresh`);
375
+ }
376
+ }
377
+
378
+ // Ensure phases array exists
379
+ if (!Array.isArray(index.phases)) {
380
+ index.phases = [];
381
+ }
382
+
383
+ // Parse the verification file
384
+ try {
385
+ if (!fs.existsSync(verificationPath)) {
386
+ console.error(`WARN: Verification file not found: ${verificationPath}`);
387
+ return;
388
+ }
389
+
390
+ const content = fs.readFileSync(verificationPath, 'utf-8');
391
+ const frontmatter = parseVerificationFrontmatter(content);
392
+
393
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
394
+ console.error(`WARN: No valid frontmatter in ${verificationPath}`);
395
+ return;
396
+ }
397
+
398
+ const phaseId = frontmatter.phase || path.basename(phaseDir);
399
+ const status = frontmatter.status || 'unknown';
400
+ const phaseName = extractPhaseName(content) || path.basename(phaseDir);
401
+ const requirementIds = extractRequirementIds(content);
402
+ const truthsText = extractObservableTruths(content);
403
+ const keywords = extractKeywords(path.basename(phaseDir), phaseName, truthsText);
404
+
405
+ // Remove any existing entry with same phase_id (idempotent upsert)
406
+ index.phases = index.phases.filter(p => p.phase_id !== phaseId);
407
+
408
+ // Add new entry
409
+ const phaseEntry = {
410
+ phase_id: phaseId,
411
+ phase_name: phaseName,
412
+ status: status,
413
+ requirement_ids: requirementIds,
414
+ keywords: keywords,
415
+ verification_path: path.relative(process.cwd(), verificationPath)
416
+ };
417
+
418
+ index.phases.push(phaseEntry);
419
+
420
+ // Re-sort by phase_id
421
+ index.phases.sort((a, b) => a.phase_id.localeCompare(b.phase_id));
422
+
423
+ // Update timestamp
424
+ index.generated_at = new Date().toISOString();
425
+
426
+ // Write back
427
+ const jsonLines = [
428
+ '{',
429
+ ` "version": "${index.version}",`,
430
+ ` "generated_at": "${index.generated_at}",`,
431
+ ' "phases": ['
432
+ ];
433
+
434
+ for (let i = 0; i < index.phases.length; i++) {
435
+ const entry = JSON.stringify(index.phases[i]);
436
+ const comma = i < index.phases.length - 1 ? ',' : '';
437
+ jsonLines.push(` ${entry}${comma}`);
438
+ }
439
+
440
+ jsonLines.push(' ]');
441
+ jsonLines.push('}');
442
+
443
+ fs.writeFileSync(indexPath, jsonLines.join('\n'));
444
+ } catch (e) {
445
+ console.error(`WARN: Could not append phase entry: ${e.message}`);
446
+ }
447
+ }
448
+
449
+ // Export functions
450
+ module.exports = {
451
+ buildPhaseIndex,
452
+ appendPhaseEntry,
453
+ parseVerificationFrontmatter,
454
+ extractKeywords,
455
+ extractRequirementIds,
456
+ extractObservableTruths,
457
+ extractPhaseName,
458
+ _pure: {
459
+ buildPhaseIndex,
460
+ appendPhaseEntry,
461
+ parseVerificationFrontmatter,
462
+ extractKeywords,
463
+ extractRequirementIds,
464
+ extractObservableTruths,
465
+ extractPhaseName
466
+ }
467
+ };
468
+
469
+ // CLI mode
470
+ if (require.main === module) {
471
+ buildPhaseIndex();
472
+ }