@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,466 @@
1
+ #!/usr/bin/env node
2
+ // bin/aggregate-requirements.cjs
3
+ // Requirements aggregation pipeline: parse .planning/REQUIREMENTS.md into .planning/formal/requirements.json
4
+ // Provides: parseRequirements, parseTraceability, validateEnvelope, aggregateRequirements
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+
12
+ // Parse requirement bullets from REQUIREMENTS.md
13
+ // Returns array of { id, text, category, completed } sorted by id
14
+ function parseRequirements(content) {
15
+ const requirements = [];
16
+ let currentCategory = null;
17
+
18
+ const lines = content.split('\n');
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+
23
+ // Match category headers: ### Category — PREFIX, ### Category (PREFIX), ### Category — Multi Word, ### Category
24
+ const headerMatch = line.match(/^###\s+(.+)$/);
25
+ if (headerMatch) {
26
+ let cat = headerMatch[1].trim();
27
+ cat = cat.replace(/\s+[—–-]{1,2}\s+.+$/, ''); // "Foo — Bar" → "Foo"
28
+ cat = cat.replace(/\s*\([^)]+\)\s*$/, ''); // "Foo (BAR)" → "Foo"
29
+ currentCategory = cat.trim() || null;
30
+ continue;
31
+ }
32
+
33
+ // Match requirement bullets: - [ ] **ID**: text or - [x] **ID**: text
34
+ const reqMatch = line.match(/^-\s+\[([ x])\]\s+\*\*([A-Z]+-\d+)\*\*:\s*(.+)$/);
35
+ if (reqMatch) {
36
+ const [, completed_mark, id, text] = reqMatch;
37
+ requirements.push({
38
+ id,
39
+ text: text.trim(),
40
+ category: currentCategory || 'Uncategorized',
41
+ completed: completed_mark === 'x'
42
+ });
43
+ }
44
+ }
45
+
46
+ // Sort by id (lexicographic) for determinism
47
+ return requirements.sort((a, b) => a.id.localeCompare(b.id));
48
+ }
49
+
50
+ // Parse the Traceability table from REQUIREMENTS.md
51
+ // Returns map: { [reqId]: { phase, status } }
52
+ function parseTraceability(content) {
53
+ const traceability = {};
54
+
55
+ // Match table rows: | REQ-ID | vX.XX-NN | Status |
56
+ const tableRowRegex = /^\|\s*([A-Z]+-\d+)\s*\|\s*(v[\d.]+-\d+)\s*\|\s*(\w+)\s*\|/gm;
57
+ let match;
58
+
59
+ while ((match = tableRowRegex.exec(content)) !== null) {
60
+ const [, reqId, phase, status] = match;
61
+ traceability[reqId] = {
62
+ phase,
63
+ status: status === 'Complete' ? 'Complete' : 'Pending'
64
+ };
65
+ }
66
+
67
+ return traceability;
68
+ }
69
+
70
+ // Extract milestone from document title
71
+ // Matches: # Requirements: QGSD vX.XX ... or similar
72
+ function extractMilestone(content) {
73
+ const match = content.match(/#\s+Requirements:.*?(v[\d.]+)/);
74
+ return match ? match[1] : 'unknown';
75
+ }
76
+
77
+ // Discover archived milestone REQUIREMENTS.md files
78
+ // Returns paths sorted by milestone version (oldest first)
79
+ function discoverArchiveFiles(archiveDir) {
80
+ if (!fs.existsSync(archiveDir)) return [];
81
+
82
+ const entries = fs.readdirSync(archiveDir)
83
+ .filter(function(f) { return /^v[\d.]+-REQUIREMENTS\.md$/.test(f); });
84
+
85
+ // Sort by version: extract numeric parts and compare
86
+ entries.sort(function(a, b) {
87
+ const va = a.match(/^v([\d.]+)-/);
88
+ const vb = b.match(/^v([\d.]+)-/);
89
+ if (!va || !vb) return a.localeCompare(b);
90
+ const partsA = va[1].split('.').map(Number);
91
+ const partsB = vb[1].split('.').map(Number);
92
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
93
+ const diff = (partsA[i] || 0) - (partsB[i] || 0);
94
+ if (diff !== 0) return diff;
95
+ }
96
+ return 0;
97
+ });
98
+
99
+ return entries.map(function(f) { return path.join(archiveDir, f); });
100
+ }
101
+
102
+ // Inline schema validation: check all required fields and types
103
+ function validateEnvelope(obj) {
104
+ const errors = [];
105
+
106
+ if (!obj || typeof obj !== 'object') {
107
+ errors.push('Envelope must be an object');
108
+ return { valid: false, errors };
109
+ }
110
+
111
+ // Check schema_version
112
+ if (typeof obj.schema_version !== 'string' || obj.schema_version !== '1') {
113
+ errors.push('schema_version must be string "1"');
114
+ }
115
+
116
+ // Check source
117
+ if (typeof obj.source !== 'string') {
118
+ errors.push('source must be a string');
119
+ }
120
+
121
+ // Check aggregated_at (ISO 8601)
122
+ if (typeof obj.aggregated_at !== 'string') {
123
+ errors.push('aggregated_at must be a string (ISO 8601)');
124
+ } else if (!isValidISO8601(obj.aggregated_at)) {
125
+ errors.push('aggregated_at is not valid ISO 8601');
126
+ }
127
+
128
+ // Check frozen_at (string or null)
129
+ if (obj.frozen_at !== null && typeof obj.frozen_at !== 'string') {
130
+ errors.push('frozen_at must be null or a string (ISO 8601)');
131
+ } else if (obj.frozen_at && !isValidISO8601(obj.frozen_at)) {
132
+ errors.push('frozen_at is not valid ISO 8601');
133
+ }
134
+
135
+ // Check content_hash
136
+ if (!obj.content_hash || typeof obj.content_hash !== 'string') {
137
+ errors.push('content_hash must be a string');
138
+ } else if (!/^sha256:[a-f0-9]{64}$/.test(obj.content_hash)) {
139
+ errors.push('content_hash must match pattern sha256:[64-hex-chars]');
140
+ }
141
+
142
+ // Check requirements array
143
+ if (!Array.isArray(obj.requirements)) {
144
+ errors.push('requirements must be an array');
145
+ return { valid: false, errors };
146
+ }
147
+
148
+ // Validate each requirement
149
+ obj.requirements.forEach((req, idx) => {
150
+ if (!req.id || typeof req.id !== 'string') {
151
+ errors.push('requirements[' + idx + '].id must be a string');
152
+ } else if (!/^[A-Z]+-\d+$/.test(req.id)) {
153
+ errors.push('requirements[' + idx + '].id does not match pattern');
154
+ }
155
+
156
+ if (!req.text || typeof req.text !== 'string') {
157
+ errors.push('requirements[' + idx + '].text must be a non-empty string');
158
+ }
159
+
160
+ if (!req.category || typeof req.category !== 'string') {
161
+ errors.push('requirements[' + idx + '].category must be a non-empty string');
162
+ }
163
+
164
+ if (!req.phase || typeof req.phase !== 'string') {
165
+ errors.push('requirements[' + idx + '].phase must be a string');
166
+ } else if (req.phase !== 'unknown' && !/^v[\d.]+-\d+$/.test(req.phase)) {
167
+ errors.push('requirements[' + idx + '].phase does not match pattern');
168
+ }
169
+
170
+ if (!['Pending', 'Complete'].includes(req.status)) {
171
+ errors.push('requirements[' + idx + '].status must be Pending or Complete');
172
+ }
173
+
174
+ if (req.background !== undefined && typeof req.background !== 'string') {
175
+ errors.push('requirements[' + idx + '].background must be a string if present');
176
+ }
177
+
178
+ if (req.category_raw !== undefined && typeof req.category_raw !== 'string') {
179
+ errors.push('requirements[' + idx + '].category_raw must be a string if present');
180
+ }
181
+
182
+ if (!req.provenance || typeof req.provenance !== 'object') {
183
+ errors.push('requirements[' + idx + '].provenance must be an object');
184
+ } else {
185
+ if (typeof req.provenance.source_file !== 'string') {
186
+ errors.push('requirements[' + idx + '].provenance.source_file must be a string');
187
+ }
188
+ if (typeof req.provenance.milestone !== 'string') {
189
+ errors.push('requirements[' + idx + '].provenance.milestone must be a string');
190
+ }
191
+ }
192
+ });
193
+
194
+ return {
195
+ valid: errors.length === 0,
196
+ errors
197
+ };
198
+ }
199
+
200
+ // Validate ISO 8601 datetime string
201
+ function isValidISO8601(dateString) {
202
+ const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
203
+ if (!iso8601Regex.test(dateString)) {
204
+ return false;
205
+ }
206
+ return !isNaN(Date.parse(dateString));
207
+ }
208
+
209
+ // Compute SHA-256 hash of content
210
+ function computeContentHash(obj) {
211
+ const jsonStr = JSON.stringify(obj, null, 2);
212
+ return 'sha256:' + crypto.createHash('sha256').update(jsonStr).digest('hex');
213
+ }
214
+
215
+ // Process a single requirements file into a Map (mutates reqMap in place)
216
+ function mergeFileIntoMap(reqMap, filePath) {
217
+ const content = fs.readFileSync(filePath, 'utf8');
218
+ const requirements = parseRequirements(content);
219
+ const traceability = parseTraceability(content);
220
+ const milestone = extractMilestone(content);
221
+
222
+ requirements.forEach(function(req) {
223
+ // Use traceability table when available; fall back to checkbox state [x] = Complete
224
+ const trace = traceability[req.id] || { phase: 'unknown', status: req.completed ? 'Complete' : 'Pending' };
225
+ reqMap.set(req.id, {
226
+ id: req.id,
227
+ text: req.text,
228
+ category: req.category,
229
+ phase: trace.phase,
230
+ status: trace.status,
231
+ provenance: {
232
+ source_file: filePath,
233
+ milestone: milestone
234
+ }
235
+ });
236
+ });
237
+ }
238
+
239
+ // Main aggregation pipeline
240
+ function aggregateRequirements(options) {
241
+ options = options || {};
242
+ const requirementsPath = options.requirementsPath || '.planning/REQUIREMENTS.md';
243
+ const outputPath = options.outputPath || '.planning/formal/requirements.json';
244
+ const deterministic = options.deterministic || false;
245
+ const skipArchive = options.skipArchive || false;
246
+ const archiveDir = options.archiveDir || '.planning/milestones';
247
+
248
+ // Check if output path exists and is frozen; also capture existing formal_models enrichment
249
+ // formal_models is enrichment data (not milestone-sourced) and must survive re-aggregation
250
+ const existingFormalModels = {};
251
+ if (fs.existsSync(outputPath)) {
252
+ const existing = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
253
+ if (existing.frozen_at !== null) {
254
+ throw new Error('Envelope is frozen -- use amendment workflow (ENV-04)');
255
+ }
256
+ // Capture formal_models keyed by requirement id for merge-back after aggregation
257
+ if (Array.isArray(existing.requirements)) {
258
+ existing.requirements.forEach(function(req) {
259
+ if (req.formal_models !== undefined) {
260
+ existingFormalModels[req.id] = req.formal_models;
261
+ }
262
+ });
263
+ }
264
+ }
265
+
266
+ // Build merged requirement map: archive oldest→newest, then current (last write wins)
267
+ const reqMap = new Map();
268
+
269
+ if (!skipArchive) {
270
+ var archiveFiles = discoverArchiveFiles(archiveDir);
271
+ archiveFiles.forEach(function(archivePath) {
272
+ mergeFileIntoMap(reqMap, archivePath);
273
+ });
274
+ }
275
+
276
+ // Current REQUIREMENTS.md is optional when archives provide requirements
277
+ if (fs.existsSync(requirementsPath)) {
278
+ mergeFileIntoMap(reqMap, requirementsPath);
279
+ } else if (reqMap.size === 0) {
280
+ throw new Error('REQUIREMENTS.md not found at ' + requirementsPath + ' and no archive files found');
281
+ }
282
+
283
+ // Convert Map to sorted array
284
+ const merged = Array.from(reqMap.values());
285
+ merged.sort(function(a, b) { return a.id.localeCompare(b.id); });
286
+
287
+ // Merge back formal_models enrichment data (preserved from previous output)
288
+ // formal_models is not milestone-sourced so the aggregator must not discard it
289
+ merged.forEach(function(req) {
290
+ if (existingFormalModels[req.id] !== undefined) {
291
+ req.formal_models = existingFormalModels[req.id];
292
+ }
293
+ });
294
+
295
+ // Apply category group consolidation from .planning/formal/category-groups.json
296
+ const groupsPath = path.join(path.dirname(outputPath), 'category-groups.json');
297
+ let categoryGroups = null;
298
+ const unmappedCategories = [];
299
+ if (fs.existsSync(groupsPath)) {
300
+ try {
301
+ categoryGroups = JSON.parse(fs.readFileSync(groupsPath, 'utf8'));
302
+ } catch (_) { /* ignore parse errors, skip grouping */ }
303
+ }
304
+ if (categoryGroups) {
305
+ const seen = new Set();
306
+ merged.forEach(function(req) {
307
+ const group = categoryGroups[req.category];
308
+ if (group) {
309
+ req.category_raw = req.category;
310
+ req.category = group;
311
+ } else if (req.category !== 'Uncategorized') {
312
+ if (!seen.has(req.category)) {
313
+ unmappedCategories.push(req.category);
314
+ seen.add(req.category);
315
+ }
316
+ }
317
+ });
318
+ }
319
+
320
+ // Compute content hash from the requirements array (before envelope wrapping)
321
+ const contentHash = computeContentHash(merged);
322
+
323
+ // Get current timestamp or use fixed timestamp for deterministic mode
324
+ const now = deterministic
325
+ ? '2026-03-01T20:32:24.000Z'
326
+ : new Date().toISOString();
327
+
328
+ // Build envelope with keys in alphabetical order for determinism
329
+ const envelope = {
330
+ aggregated_at: now,
331
+ content_hash: contentHash,
332
+ frozen_at: null,
333
+ requirements: merged,
334
+ schema_version: '1',
335
+ source: requirementsPath
336
+ };
337
+
338
+ // Validate envelope
339
+ const validation = validateEnvelope(envelope);
340
+ if (!validation.valid) {
341
+ throw new Error('Envelope validation failed: ' + validation.errors.join('; '));
342
+ }
343
+
344
+ // Write atomically (temp + rename)
345
+ const dir = path.dirname(outputPath);
346
+ if (!fs.existsSync(dir)) {
347
+ fs.mkdirSync(dir, { recursive: true });
348
+ }
349
+
350
+ const tmpPath = outputPath + '.' + Date.now() + '.tmp';
351
+ try {
352
+ fs.writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), 'utf8');
353
+ fs.renameSync(tmpPath, outputPath);
354
+ } catch (e) {
355
+ try {
356
+ fs.unlinkSync(tmpPath);
357
+ } catch (_ignored) {}
358
+ throw e;
359
+ }
360
+
361
+ return {
362
+ valid: true,
363
+ requirementCount: merged.length,
364
+ outputPath: outputPath,
365
+ unmappedCategories: unmappedCategories
366
+ };
367
+ }
368
+
369
+ // CLI entrypoint
370
+ if (require.main === module) {
371
+ try {
372
+ const args = process.argv.slice(2);
373
+ const opts = {};
374
+ let dryRun = false;
375
+ let deterministic = false;
376
+ let skipArchive = false;
377
+
378
+ // Parse arguments
379
+ for (let i = 0; i < args.length; i++) {
380
+ if (args[i] === '--dry-run') {
381
+ dryRun = true;
382
+ } else if (args[i] === '--deterministic') {
383
+ deterministic = true;
384
+ } else if (args[i] === '--skip-archive') {
385
+ skipArchive = true;
386
+ } else if (args[i].indexOf('--requirements=') === 0) {
387
+ opts.requirementsPath = args[i].split('=')[1];
388
+ } else if (args[i].indexOf('--output=') === 0) {
389
+ opts.outputPath = args[i].split('=')[1];
390
+ }
391
+ }
392
+
393
+ opts.deterministic = deterministic;
394
+ opts.skipArchive = skipArchive;
395
+
396
+ if (dryRun) {
397
+ // Dry run: output to stdout without writing file
398
+ const requirementsPath = opts.requirementsPath || '.planning/REQUIREMENTS.md';
399
+ const archiveDir = opts.archiveDir || '.planning/milestones';
400
+
401
+ const reqMap = new Map();
402
+
403
+ if (!skipArchive) {
404
+ var archiveFiles = discoverArchiveFiles(archiveDir);
405
+ archiveFiles.forEach(function(archivePath) {
406
+ mergeFileIntoMap(reqMap, archivePath);
407
+ });
408
+ }
409
+
410
+ if (fs.existsSync(requirementsPath)) {
411
+ mergeFileIntoMap(reqMap, requirementsPath);
412
+ } else if (reqMap.size === 0) {
413
+ throw new Error('REQUIREMENTS.md not found at ' + requirementsPath + ' and no archive files found');
414
+ }
415
+
416
+ const merged = Array.from(reqMap.values());
417
+ merged.sort(function(a, b) { return a.id.localeCompare(b.id); });
418
+
419
+ // Apply category group consolidation (same as write path)
420
+ const groupsPath = path.join('.planning', 'formal', 'category-groups.json');
421
+ if (fs.existsSync(groupsPath)) {
422
+ try {
423
+ const categoryGroups = JSON.parse(fs.readFileSync(groupsPath, 'utf8'));
424
+ merged.forEach(function(req) {
425
+ const group = categoryGroups[req.category];
426
+ if (group) {
427
+ req.category_raw = req.category;
428
+ req.category = group;
429
+ }
430
+ });
431
+ } catch (_) { /* ignore */ }
432
+ }
433
+
434
+ const contentHash = computeContentHash(merged);
435
+ const now = deterministic
436
+ ? '2026-03-01T20:32:24.000Z'
437
+ : new Date().toISOString();
438
+
439
+ const envelope = {
440
+ aggregated_at: now,
441
+ content_hash: contentHash,
442
+ frozen_at: null,
443
+ requirements: merged,
444
+ schema_version: '1',
445
+ source: requirementsPath
446
+ };
447
+
448
+ console.log(JSON.stringify(envelope, null, 2));
449
+ } else {
450
+ const result = aggregateRequirements(opts);
451
+ console.log('Aggregated ' + result.requirementCount + ' requirements to ' + result.outputPath);
452
+ }
453
+ } catch (e) {
454
+ console.error('Error: ' + e.message);
455
+ process.exit(1);
456
+ }
457
+ }
458
+
459
+ // Exports
460
+ module.exports = {
461
+ parseRequirements: parseRequirements,
462
+ parseTraceability: parseTraceability,
463
+ validateEnvelope: validateEnvelope,
464
+ aggregateRequirements: aggregateRequirements,
465
+ discoverArchiveFiles: discoverArchiveFiles
466
+ };