@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,2477 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/qgsd-solve.cjs
4
+ // Consistency solver orchestrator: sweeps Requirements->Formal->Tests->Code->Docs,
5
+ // computes a residual vector per layer transition, and auto-closes gaps.
6
+ //
7
+ // Layer transitions (8 forward + 3 reverse):
8
+ // R->F: Requirements without formal model coverage
9
+ // F->T: Formal invariants without test backing
10
+ // C->F: Code constants diverging from formal specs
11
+ // T->C: Failing unit tests
12
+ // F->C: Failing formal verification checks
13
+ // R->D: Requirements not documented in developer docs
14
+ // D->C: Stale structural claims in docs (dead file paths, missing CLI commands, absent dependencies)
15
+ // P->F: Acknowledged production debt entries diverging from formal model thresholds
16
+ // Reverse (discovery-only, human-gated):
17
+ // C->R: Source modules in bin/hooks/ with no requirement tracing
18
+ // T->R: Test files with no @req annotation or formal-test-sync mapping
19
+ // D->R: Doc capability claims without requirement backing
20
+ //
21
+ // Usage:
22
+ // node bin/qgsd-solve.cjs # full sync, up to 3 iterations
23
+ // node bin/qgsd-solve.cjs --report-only # single sweep, no mutations
24
+ // node bin/qgsd-solve.cjs --max-iterations=1
25
+ // node bin/qgsd-solve.cjs --json # machine-readable output
26
+ // node bin/qgsd-solve.cjs --verbose # pipe child stderr to parent stderr
27
+ // node bin/qgsd-solve.cjs --fast # skip F->C and T->C layers for sub-second iteration
28
+ //
29
+ // Requirements: QUICK-140
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { spawnSync } = require('child_process');
34
+
35
+ const TAG = '[qgsd-solve]';
36
+ let ROOT = process.cwd();
37
+ const SCRIPT_DIR = __dirname;
38
+ const DEFAULT_MAX_ITERATIONS = 3;
39
+
40
+ // ── CLI flags ────────────────────────────────────────────────────────────────
41
+
42
+ const args = process.argv.slice(2);
43
+ const reportOnly = args.includes('--report-only');
44
+ const jsonMode = args.includes('--json');
45
+ const verboseMode = args.includes('--verbose');
46
+ const fastMode = args.includes('--fast');
47
+
48
+ // Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
49
+ for (const arg of args) {
50
+ if (arg.startsWith('--project-root=')) {
51
+ ROOT = path.resolve(arg.slice('--project-root='.length));
52
+ }
53
+ }
54
+
55
+ let maxIterations = DEFAULT_MAX_ITERATIONS;
56
+ for (const arg of args) {
57
+ if (arg.startsWith('--max-iterations=')) {
58
+ const val = parseInt(arg.slice('--max-iterations='.length), 10);
59
+ if (!isNaN(val) && val >= 1 && val <= 10) {
60
+ maxIterations = val;
61
+ }
62
+ }
63
+ }
64
+
65
+ // ── Helper: spawnTool ────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Spawns a child process with error handling and optional stderr piping.
69
+ * Returns { ok: boolean, stdout: string, stderr: string }.
70
+ */
71
+ function spawnTool(script, args, opts = {}) {
72
+ const scriptPath = path.join(SCRIPT_DIR, path.basename(script));
73
+ // Auto-forward --project-root to child script
74
+ const childArgs = [...args];
75
+ if (!childArgs.some(a => a.startsWith('--project-root='))) {
76
+ childArgs.push('--project-root=' + ROOT);
77
+ }
78
+ const defaultStdio = verboseMode ? ['pipe', 'pipe', 'inherit'] : 'pipe';
79
+ const spawnOpts = {
80
+ encoding: 'utf8',
81
+ cwd: ROOT,
82
+ timeout: opts.timeout || 120000,
83
+ stdio: opts.stdio || defaultStdio,
84
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
85
+ };
86
+
87
+ try {
88
+ const result = spawnSync(process.execPath, [scriptPath, ...childArgs], spawnOpts);
89
+ if (result.error) {
90
+ return {
91
+ ok: false,
92
+ stdout: '',
93
+ stderr: result.error.message,
94
+ };
95
+ }
96
+ return {
97
+ ok: result.status === 0,
98
+ stdout: result.stdout || '',
99
+ stderr: result.stderr || '',
100
+ };
101
+ } catch (err) {
102
+ return {
103
+ ok: false,
104
+ stdout: '',
105
+ stderr: err.message,
106
+ };
107
+ }
108
+ }
109
+
110
+ // ── P->F layer imports ──────────────────────────────────────────────────────
111
+
112
+ const { sweepPtoF } = require('./sweepPtoF.cjs');
113
+ const { autoClosePtoF } = require('./autoClosePtoF.cjs');
114
+
115
+ // ── Doc discovery helpers ────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Simple wildcard matcher for patterns like "**\/*.md" and "README.md".
119
+ * Supports: ** (any path segment), * (any filename segment), literal match.
120
+ */
121
+ function matchWildcard(pattern, filePath) {
122
+ const normPath = filePath.replace(/\\/g, '/');
123
+ const normPattern = pattern.replace(/\\/g, '/');
124
+
125
+ if (!normPattern.includes('*')) {
126
+ return normPath === normPattern || normPath.endsWith('/' + normPattern);
127
+ }
128
+
129
+ let regex = normPattern
130
+ .replace(/\./g, '\\.')
131
+ .replace(/\*\*\//g, '(.+/)?')
132
+ .replace(/\*/g, '[^/]*');
133
+ regex = '^(' + regex + ')$';
134
+
135
+ return new RegExp(regex).test(normPath);
136
+ }
137
+
138
+ /**
139
+ * Recursively walk a directory, returning files up to maxDepth levels.
140
+ */
141
+ function walkDir(dir, maxDepth, currentDepth) {
142
+ if (currentDepth === undefined) currentDepth = 0;
143
+ if (maxDepth === undefined) maxDepth = 10;
144
+ if (currentDepth > maxDepth) return [];
145
+
146
+ const results = [];
147
+ let entries;
148
+ try {
149
+ entries = fs.readdirSync(dir, { withFileTypes: true });
150
+ } catch (e) {
151
+ return results;
152
+ }
153
+
154
+ for (const entry of entries) {
155
+ const fullPath = path.join(dir, entry.name);
156
+ if (entry.isDirectory()) {
157
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
158
+ const sub = walkDir(fullPath, maxDepth, currentDepth + 1);
159
+ for (const s of sub) results.push(s);
160
+ } else if (entry.isFile()) {
161
+ results.push(fullPath);
162
+ }
163
+ }
164
+ return results;
165
+ }
166
+
167
+ /**
168
+ * Detect uninitialized git submodules that overlap with doc paths.
169
+ * Returns array of { submodule, docKey } warnings.
170
+ */
171
+ function detectUninitializedSubmodules(docPaths) {
172
+ const gitmodulesPath = path.join(ROOT, '.gitmodules');
173
+ if (!fs.existsSync(gitmodulesPath)) return [];
174
+
175
+ const warnings = [];
176
+ try {
177
+ const content = fs.readFileSync(gitmodulesPath, 'utf8');
178
+ const submodules = [];
179
+ const re = /\[submodule\s+"([^"]+)"\][\s\S]*?path\s*=\s*(.+)/g;
180
+ let m;
181
+ while ((m = re.exec(content)) !== null) {
182
+ submodules.push({ name: m[1].trim(), path: m[2].trim() });
183
+ }
184
+
185
+ for (const sub of submodules) {
186
+ const subAbsPath = path.join(ROOT, sub.path);
187
+ const isInitialized = fs.existsSync(subAbsPath) &&
188
+ fs.readdirSync(subAbsPath).length > 0;
189
+
190
+ for (const [docKey, docPath] of Object.entries(docPaths)) {
191
+ const docNorm = docPath.replace(/\/$/, '');
192
+ if (sub.path === docNorm || sub.path.startsWith(docNorm + '/') || docNorm.startsWith(sub.path + '/')) {
193
+ if (!isInitialized) {
194
+ warnings.push({ submodule: sub.path, docKey, name: sub.name });
195
+ }
196
+ }
197
+ }
198
+ }
199
+ } catch (e) {
200
+ // .gitmodules parse error — fail-open
201
+ }
202
+ return warnings;
203
+ }
204
+
205
+ /**
206
+ * Discover documentation files based on:
207
+ * 1. .planning/polyrepo.json docs field (preferred — knows user vs developer vs examples)
208
+ * 2. .planning/config.json docs_paths (legacy)
209
+ * 3. Fallback patterns: README.md, docs/ (recursive .md)
210
+ * Returns array of { absPath, category } where category is 'user'|'developer'|'examples'|'unknown'.
211
+ */
212
+ function discoverDocFiles() {
213
+ let docPatterns = [
214
+ { pattern: 'README.md', category: 'user' },
215
+ { pattern: 'docs/**/*.md', category: 'unknown' },
216
+ ];
217
+ let markerDocs = null;
218
+
219
+ // Prefer polyrepo marker docs field
220
+ const markerPath = path.join(ROOT, '.planning', 'polyrepo.json');
221
+ try {
222
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
223
+ if (marker.docs && typeof marker.docs === 'object') {
224
+ markerDocs = marker.docs;
225
+ const patterns = [];
226
+ for (const [key, docPath] of Object.entries(marker.docs)) {
227
+ if (typeof docPath !== 'string') continue;
228
+ if (docPath.endsWith('/')) {
229
+ patterns.push({ pattern: docPath + '**/*.md', category: key });
230
+ } else {
231
+ patterns.push({ pattern: docPath, category: key });
232
+ }
233
+ }
234
+ if (patterns.length > 0) {
235
+ patterns.unshift({ pattern: 'README.md', category: 'user' });
236
+ // Sort: exact paths first, then deeper globs before shallower ones
237
+ patterns.sort((a, b) => {
238
+ const aGlob = a.pattern.includes('*') ? 1 : 0;
239
+ const bGlob = b.pattern.includes('*') ? 1 : 0;
240
+ if (aGlob !== bGlob) return aGlob - bGlob; // exact paths first
241
+ const aDepth = a.pattern.split('/').length;
242
+ const bDepth = b.pattern.split('/').length;
243
+ return bDepth - aDepth; // deeper globs first
244
+ });
245
+ docPatterns = patterns;
246
+ }
247
+
248
+ // Check for uninitialized submodules overlapping doc paths
249
+ const subWarnings = detectUninitializedSubmodules(marker.docs);
250
+ for (const w of subWarnings) {
251
+ console.error(
252
+ `[qgsd-solve] WARNING: docs.${w.docKey} overlaps submodule "${w.name}" ` +
253
+ `(${w.submodule}) which is not initialized. Run: git submodule update --init ${w.submodule}`
254
+ );
255
+ }
256
+ }
257
+ } catch (e) {
258
+ // No marker or malformed — fall through to config.json
259
+ }
260
+
261
+ // Fall back to config.json docs_paths if marker didn't provide patterns
262
+ if (!markerDocs) {
263
+ const configPath = path.join(ROOT, '.planning', 'config.json');
264
+ try {
265
+ const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
266
+ if (Array.isArray(configData.docs_paths) && configData.docs_paths.length > 0) {
267
+ docPatterns = configData.docs_paths.map(p => ({ pattern: p, category: 'unknown' }));
268
+ }
269
+ } catch (e) {
270
+ // Use defaults
271
+ }
272
+ }
273
+
274
+ const found = new Map();
275
+
276
+ for (const { pattern, category } of docPatterns) {
277
+ if (pattern.includes('*')) {
278
+ const parts = pattern.replace(/\\/g, '/').split('/');
279
+ let baseDir = ROOT;
280
+ for (const part of parts) {
281
+ if (part.includes('*')) break;
282
+ baseDir = path.join(baseDir, part);
283
+ }
284
+ if (!fs.existsSync(baseDir)) continue;
285
+
286
+ const allFiles = walkDir(baseDir, 10, 0);
287
+ for (const f of allFiles) {
288
+ const relative = path.relative(ROOT, f).replace(/\\/g, '/');
289
+ if (matchWildcard(pattern, relative)) {
290
+ if (!found.has(f)) found.set(f, category);
291
+ }
292
+ }
293
+ } else {
294
+ const fullPath = path.join(ROOT, pattern);
295
+ if (fs.existsSync(fullPath)) {
296
+ if (!found.has(fullPath)) found.set(fullPath, category);
297
+ }
298
+ }
299
+ }
300
+
301
+ return Array.from(found.entries()).map(([absPath, category]) => ({ absPath, category }));
302
+ }
303
+
304
+ // ── Keyword extraction ──────────────────────────────────────────────────────
305
+
306
+ const STOPWORDS = new Set([
307
+ 'the', 'this', 'that', 'with', 'from', 'into', 'each', 'when',
308
+ 'must', 'should', 'will', 'have', 'been', 'does', 'also', 'used',
309
+ 'using', 'only', 'such', 'both', 'than', 'some', 'more', 'most',
310
+ 'very', 'other', 'about', 'which', 'their', 'would', 'could',
311
+ 'there', 'where', 'these', 'those', 'after', 'before', 'being',
312
+ 'through', 'during', 'between', 'without', 'within', 'against',
313
+ 'under', 'above', 'below',
314
+ ]);
315
+
316
+ /**
317
+ * Extract keywords from text for fuzzy matching.
318
+ * Strips backtick-wrapped fragments, stopwords, and short tokens.
319
+ * Returns unique lowercase tokens.
320
+ */
321
+ function extractKeywords(text) {
322
+ let cleaned = text.replace(/`[^`]*`/g, ' ');
323
+ const tokens = cleaned.split(/[\s,;:.()\[\]{}<>!?"']+/);
324
+
325
+ const seen = new Set();
326
+ const result = [];
327
+
328
+ for (const raw of tokens) {
329
+ const token = raw.toLowerCase().replace(/[^a-z0-9-]/g, '');
330
+ if (token.length < 4) continue;
331
+ if (STOPWORDS.has(token)) continue;
332
+ if (seen.has(token)) continue;
333
+ seen.add(token);
334
+ result.push(token);
335
+ }
336
+
337
+ return result;
338
+ }
339
+
340
+ // ── Structural claims extraction ─────────────────────────────────────────────
341
+
342
+ /**
343
+ * Extract structural claims (file paths, CLI commands, dependencies) from doc content.
344
+ * Skips fenced code blocks, Example/Template headings, template variables,
345
+ * home directory paths, and code expressions.
346
+ * Returns array of { line, type, value, doc_file }.
347
+ */
348
+ function extractStructuralClaims(docContent, filePath) {
349
+ const lines = docContent.split('\n');
350
+ const claims = [];
351
+ let inFencedBlock = false;
352
+ let skipSection = false;
353
+
354
+ for (let i = 0; i < lines.length; i++) {
355
+ const line = lines[i];
356
+
357
+ // Track fenced code blocks
358
+ if (line.trimStart().startsWith('```')) {
359
+ inFencedBlock = !inFencedBlock;
360
+ continue;
361
+ }
362
+ if (inFencedBlock) continue;
363
+
364
+ // Track headings - skip Example/Template sections
365
+ const headingMatch = line.match(/^#{1,6}\s+(.+)/);
366
+ if (headingMatch) {
367
+ const headingText = headingMatch[1].toLowerCase();
368
+ skipSection = headingText.includes('example') || headingText.includes('template');
369
+ continue;
370
+ }
371
+ if (skipSection) continue;
372
+
373
+ // Find backtick-wrapped values
374
+ const backtickPattern = /`([^`]+)`/g;
375
+ let match;
376
+ while ((match = backtickPattern.exec(line)) !== null) {
377
+ const value = match[1].trim();
378
+ if (value.length < 4) continue;
379
+
380
+ // Filter: template variables
381
+ if (value.includes('{') || value.includes('}')) continue;
382
+
383
+ // Filter: home directory paths
384
+ if (value.startsWith('~/')) continue;
385
+
386
+ // Filter: code expressions (operators)
387
+ if (/[+=>]|&&|\|\|/.test(value)) continue;
388
+
389
+ // Classify the claim
390
+ let type = null;
391
+
392
+ // CLI command: starts with node, npx, npm
393
+ if (/^(node|npx|npm)\s+/.test(value)) {
394
+ type = 'cli_command';
395
+ }
396
+ // File path: contains / with extension, or starts with .
397
+ else if ((value.includes('/') && /\.\w+$/.test(value)) || (value.startsWith('.') && /\.\w+$/.test(value))) {
398
+ if (value.startsWith('/')) continue;
399
+ type = 'file_path';
400
+ }
401
+ // Dependency: npm-style package name (lowercase, optional @scope/)
402
+ else if (/^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value) && !value.includes('/')) {
403
+ type = 'dependency';
404
+ }
405
+
406
+ if (type) {
407
+ claims.push({
408
+ line: i + 1,
409
+ type: type,
410
+ value: value,
411
+ doc_file: filePath,
412
+ });
413
+ }
414
+ }
415
+ }
416
+
417
+ return claims;
418
+ }
419
+
420
+ // ── Preflight bootstrap ──────────────────────────────────────────────────────
421
+
422
+ /**
423
+ * Auto-creates .planning/formal/ subdirectories if missing on first run.
424
+ * Called at the top of main() before the iteration loop.
425
+ */
426
+ function preflight() {
427
+ const formalDir = path.join(ROOT, '.planning', 'formal');
428
+ const subdirs = ['tla', 'alloy', 'generated-stubs'];
429
+ let created = false;
430
+
431
+ if (!fs.existsSync(formalDir)) {
432
+ fs.mkdirSync(formalDir, { recursive: true });
433
+ created = true;
434
+ }
435
+
436
+ for (const sub of subdirs) {
437
+ const subPath = path.join(formalDir, sub);
438
+ if (!fs.existsSync(subPath)) {
439
+ fs.mkdirSync(subPath, { recursive: true });
440
+ created = true;
441
+ }
442
+ }
443
+
444
+ // Seed model-registry.json if missing
445
+ const registryPath = path.join(formalDir, 'model-registry.json');
446
+ if (!fs.existsSync(registryPath)) {
447
+ try {
448
+ fs.writeFileSync(registryPath, JSON.stringify({ models: [], search_dirs: [] }, null, 2) + '\n');
449
+ created = true;
450
+ } catch (e) {
451
+ // fail-open
452
+ }
453
+ }
454
+
455
+ if (created) {
456
+ process.stderr.write(TAG + ' Bootstrapped formal infrastructure\n');
457
+ }
458
+ }
459
+
460
+ // ── Layer transition sweeps ──────────────────────────────────────────────────
461
+
462
+ /**
463
+ * Triage requirements by formalizability.
464
+ * Scores each requirement into HIGH/MEDIUM/LOW/SKIP priority buckets.
465
+ * @param {Array} requirements - Array of requirement objects with text/description fields
466
+ * @returns {{ high: string[], medium: string[], low: string[], skip: string[] }}
467
+ */
468
+ function triageRequirements(requirements) {
469
+ const HIGH_KEYWORDS = ['shall', 'must', 'invariant', 'constraint'];
470
+ const MEDIUM_KEYWORDS = ['should', 'verify', 'ensure', 'validate', 'check'];
471
+ const LOW_KEYWORDS = ['may', 'could', 'consider', 'nice-to-have'];
472
+ const SKIP_KEYWORDS = ['deferred', 'out-of-scope', 'deprecated'];
473
+
474
+ const result = { high: [], medium: [], low: [], skip: [] };
475
+
476
+ for (const req of requirements) {
477
+ const id = req.id || req.requirement_id || '';
478
+ if (!id) continue;
479
+
480
+ const text = (req.text || req.description || '').toLowerCase();
481
+
482
+ // Check formalizability field override
483
+ if (req.formalizability === 'high') {
484
+ result.high.push(id);
485
+ continue;
486
+ }
487
+
488
+ // Priority order: SKIP > HIGH > MEDIUM > LOW
489
+ if (SKIP_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
490
+ result.skip.push(id);
491
+ } else if (HIGH_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
492
+ result.high.push(id);
493
+ } else if (MEDIUM_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
494
+ result.medium.push(id);
495
+ } else if (LOW_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
496
+ result.low.push(id);
497
+ } else {
498
+ result.low.push(id); // default to low if no keywords match
499
+ }
500
+ }
501
+
502
+ return result;
503
+ }
504
+
505
+ /**
506
+ * R->F: Requirements to Formal coverage.
507
+ * Returns { residual: N, detail: {...} }
508
+ */
509
+ function sweepRtoF() {
510
+ const result = spawnTool('bin/generate-traceability-matrix.cjs', [
511
+ '--json',
512
+ '--quiet',
513
+ ]);
514
+
515
+ if (!result.ok) {
516
+ return {
517
+ residual: -1,
518
+ detail: {
519
+ error: result.stderr || 'generate-traceability-matrix.cjs failed',
520
+ },
521
+ };
522
+ }
523
+
524
+ try {
525
+ const matrix = JSON.parse(result.stdout);
526
+ const coverage = matrix.coverage_summary || {};
527
+ const uncovered = coverage.uncovered_requirements || [];
528
+ const total = coverage.total_requirements || 0;
529
+ const covered = coverage.covered_requirements || 0;
530
+ const percentage = total > 0 ? ((covered / total) * 100).toFixed(1) : 0;
531
+
532
+ // Triage uncovered requirements by formalizability
533
+ // Load full requirements to get text for keyword matching
534
+ const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
535
+ let uncoveredReqs = [];
536
+ try {
537
+ const reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
538
+ let allReqs = [];
539
+ if (Array.isArray(reqData)) {
540
+ allReqs = reqData;
541
+ } else if (reqData.requirements && Array.isArray(reqData.requirements)) {
542
+ allReqs = reqData.requirements;
543
+ } else if (reqData.groups && Array.isArray(reqData.groups)) {
544
+ for (const group of reqData.groups) {
545
+ if (group.requirements && Array.isArray(group.requirements)) {
546
+ for (const r of group.requirements) allReqs.push(r);
547
+ }
548
+ }
549
+ }
550
+ const uncoveredSet = new Set(uncovered);
551
+ uncoveredReqs = allReqs.filter(r => uncoveredSet.has(r.id || r.requirement_id || ''));
552
+ } catch (e) {
553
+ // Can't load requirements — skip triage
554
+ }
555
+
556
+ const triage = triageRequirements(uncoveredReqs);
557
+ const highIds = triage.high;
558
+ const mediumIds = triage.medium;
559
+ const priority_batch = highIds.concat(mediumIds).slice(0, 15);
560
+
561
+ return {
562
+ residual: uncovered.length,
563
+ detail: {
564
+ uncovered_requirements: uncovered,
565
+ total: total,
566
+ covered: covered,
567
+ percentage: percentage,
568
+ triage: {
569
+ high: triage.high.length,
570
+ medium: triage.medium.length,
571
+ low: triage.low.length,
572
+ skip: triage.skip.length,
573
+ },
574
+ priority_batch: priority_batch,
575
+ },
576
+ };
577
+ } catch (err) {
578
+ return {
579
+ residual: -1,
580
+ detail: { error: 'Failed to parse traceability matrix: ' + err.message },
581
+ };
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Cache for formal-test-sync.cjs --json --report-only result.
587
+ */
588
+ let formalTestSyncCache = null;
589
+
590
+ /**
591
+ * Helper to load and cache formal-test-sync result.
592
+ */
593
+ function loadFormalTestSync() {
594
+ if (formalTestSyncCache) return formalTestSyncCache;
595
+
596
+ const result = spawnTool('bin/formal-test-sync.cjs', [
597
+ '--json',
598
+ '--report-only',
599
+ ]);
600
+
601
+ if (!result.ok) {
602
+ formalTestSyncCache = null;
603
+ return null;
604
+ }
605
+
606
+ try {
607
+ formalTestSyncCache = JSON.parse(result.stdout);
608
+ return formalTestSyncCache;
609
+ } catch (err) {
610
+ formalTestSyncCache = null;
611
+ return null;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * F->T: Formal to Tests coverage.
617
+ * Returns { residual: N, detail: {...} }
618
+ */
619
+ function sweepFtoT() {
620
+ const syncData = loadFormalTestSync();
621
+
622
+ if (!syncData) {
623
+ return {
624
+ residual: -1,
625
+ detail: { error: 'formal-test-sync.cjs failed' },
626
+ };
627
+ }
628
+
629
+ const gaps = syncData.coverage_gaps || {};
630
+ const stats = gaps.stats || {};
631
+ const gapCount = stats.gap_count || 0;
632
+ const gapsList = gaps.gaps || [];
633
+
634
+ return {
635
+ residual: gapCount,
636
+ detail: {
637
+ gap_count: gapCount,
638
+ formal_covered: stats.formal_covered || 0,
639
+ test_covered: stats.test_covered || 0,
640
+ gaps: gapsList.map((g) => g.requirement_id || g),
641
+ },
642
+ };
643
+ }
644
+
645
+ /**
646
+ * C->F: Code constants to Formal constants.
647
+ * Returns { residual: N, detail: {...} }
648
+ */
649
+ function sweepCtoF() {
650
+ const syncData = loadFormalTestSync();
651
+
652
+ if (!syncData) {
653
+ return {
654
+ residual: -1,
655
+ detail: { error: 'formal-test-sync.cjs failed' },
656
+ };
657
+ }
658
+
659
+ const validation = syncData.constants_validation || [];
660
+ const mismatches = validation.filter((entry) => {
661
+ return (
662
+ entry.match === false &&
663
+ entry.intentional_divergence !== true &&
664
+ entry.config_path !== null
665
+ );
666
+ });
667
+
668
+ return {
669
+ residual: mismatches.length,
670
+ detail: {
671
+ mismatches: mismatches.map((m) => ({
672
+ constant: m.constant,
673
+ source: m.source,
674
+ formal_value: m.formal_value,
675
+ config_value: m.config_value,
676
+ })),
677
+ },
678
+ };
679
+ }
680
+
681
+ /**
682
+ * T->C: Tests to Code.
683
+ * Returns { residual: N, detail: {...} }
684
+ */
685
+ function sweepTtoC() {
686
+ // Load configurable test runner settings
687
+ const configPath = path.join(ROOT, '.planning', 'config.json');
688
+ let tToCConfig = { runner: 'node-test', command: null, scope: 'all' };
689
+ try {
690
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
691
+ if (cfg.solve && cfg.solve.t_to_c) {
692
+ tToCConfig = { ...tToCConfig, ...cfg.solve.t_to_c };
693
+ }
694
+ } catch (e) { /* use defaults */ }
695
+
696
+ // Runner mode: none — skip entirely
697
+ if (tToCConfig.runner === 'none') {
698
+ return { residual: 0, detail: { skipped: true, reason: 'runner=none in config' } };
699
+ }
700
+
701
+ const spawnOpts = {
702
+ encoding: 'utf8',
703
+ cwd: ROOT,
704
+ timeout: 120000,
705
+ stdio: verboseMode ? ['pipe', 'pipe', 'inherit'] : 'pipe',
706
+ };
707
+
708
+ // Runner mode: jest
709
+ if (tToCConfig.runner === 'jest') {
710
+ const jestCmd = tToCConfig.command || 'npx';
711
+ const jestArgs = tToCConfig.command ? [] : ['jest', '--ci', '--json'];
712
+ let result;
713
+ try {
714
+ result = spawnSync(jestCmd, jestArgs, spawnOpts);
715
+ } catch (err) {
716
+ return {
717
+ residual: -1,
718
+ detail: { error: 'Failed to spawn jest: ' + err.message },
719
+ };
720
+ }
721
+
722
+ const output = (result.stdout || '') + (result.stderr || '');
723
+ // Try to parse Jest JSON output
724
+ try {
725
+ const jsonStart = output.indexOf('{');
726
+ if (jsonStart >= 0) {
727
+ const jestResult = JSON.parse(output.slice(jsonStart));
728
+ const failCount = jestResult.numFailedTests || 0;
729
+ const totalTests = jestResult.numTotalTests || 0;
730
+ return {
731
+ residual: failCount,
732
+ detail: {
733
+ total_tests: totalTests,
734
+ passed: totalTests - failCount,
735
+ failed: failCount,
736
+ skipped: 0,
737
+ todo: 0,
738
+ runner: 'jest',
739
+ },
740
+ };
741
+ }
742
+ } catch (e) { /* fall through to TAP parsing */ }
743
+
744
+ return {
745
+ residual: -1,
746
+ detail: { error: 'Failed to parse jest output', runner: 'jest' },
747
+ };
748
+ }
749
+
750
+ // Runner mode: node-test (default)
751
+ let result;
752
+ try {
753
+ result = spawnSync(process.execPath, ['--test'], spawnOpts);
754
+ } catch (err) {
755
+ return {
756
+ residual: -1,
757
+ detail: { error: 'Failed to spawn node --test: ' + err.message },
758
+ };
759
+ }
760
+
761
+ const output = (result.stdout || '') + (result.stderr || '');
762
+
763
+ // Parse TAP output for test summary.
764
+ // Support both # prefix (Node <= v24) and ℹ prefix (Node v25+)
765
+ let totalTests = 0;
766
+ let failCount = 0;
767
+ let skipCount = 0;
768
+ let todoCount = 0;
769
+
770
+ const testsMatch = output.match(/^[ℹ#]\s+tests\s+(\d+)/m);
771
+ if (testsMatch) totalTests = parseInt(testsMatch[1], 10);
772
+
773
+ const failMatch = output.match(/^[ℹ#]\s+fail\s+(\d+)/m);
774
+ if (failMatch) failCount = parseInt(failMatch[1], 10);
775
+
776
+ const skipMatch = output.match(/^[ℹ#]\s+skipped\s+(\d+)/m);
777
+ if (skipMatch) skipCount = parseInt(skipMatch[1], 10);
778
+
779
+ const todoMatch = output.match(/^[ℹ#]\s+todo\s+(\d+)/m);
780
+ if (todoMatch) todoCount = parseInt(todoMatch[1], 10);
781
+
782
+ // Fallback: count "not ok" lines if summary not found
783
+ if (failCount === 0 && totalTests === 0) {
784
+ const notOkMatches = output.match(/^not ok\s+\d+/gm) || [];
785
+ failCount = notOkMatches.length;
786
+ const okMatches = output.match(/^ok\s+\d+/gm) || [];
787
+ totalTests = notOkMatches.length + okMatches.length;
788
+ skipCount = 0;
789
+ todoCount = 0;
790
+ }
791
+
792
+ // Scope-based auto-detection: if scope is "generated-stubs-only", check if all failures
793
+ // are outside .planning/formal/generated-stubs/
794
+ if (tToCConfig.runner === 'node-test' && tToCConfig.scope === 'generated-stubs-only' && failCount > 0) {
795
+ const failLines = output.match(/^not ok\s+\d+\s+.*/gm) || [];
796
+ const stubFailures = failLines.filter(l => l.includes('generated-stubs'));
797
+ if (stubFailures.length === 0 && failLines.length > 0) {
798
+ return {
799
+ residual: 0,
800
+ detail: {
801
+ total_tests: totalTests,
802
+ passed: Math.max(0, totalTests - failCount - skipCount - todoCount),
803
+ failed: failCount,
804
+ skipped: skipCount,
805
+ todo: todoCount,
806
+ runner_mismatch: true,
807
+ warning: 'All ' + failLines.length + ' failures are outside generated-stubs scope — likely runner mismatch',
808
+ },
809
+ };
810
+ }
811
+ }
812
+
813
+ return {
814
+ residual: failCount + skipCount,
815
+ detail: {
816
+ total_tests: totalTests,
817
+ passed: Math.max(0, totalTests - failCount - skipCount - todoCount),
818
+ failed: failCount,
819
+ skipped: skipCount,
820
+ todo: todoCount,
821
+ },
822
+ };
823
+ }
824
+
825
+ /**
826
+ * F->C: Formal verification to Code.
827
+ * Returns { residual: N, detail: {...} }
828
+ */
829
+ function sweepFtoC() {
830
+ const verifyScript = path.join(SCRIPT_DIR, 'run-formal-verify.cjs');
831
+
832
+ if (!fs.existsSync(verifyScript)) {
833
+ return {
834
+ residual: 0,
835
+ detail: { skipped: true, reason: 'run-formal-verify.cjs not found' },
836
+ };
837
+ }
838
+
839
+ // Always run formal verification to get fresh data (all 88+ checks).
840
+ // Previously, report-only mode read stale check-results.ndjson which often
841
+ // contained only 4 CI-gated checks, hiding individual alloy/tla/prism failures.
842
+ // Now matches sweepTtoC behavior: always compute fresh diagnostic data.
843
+ // The --report-only flag prevents auto-close remediation (line ~1400), not data collection.
844
+ //
845
+ // stdio: discard stdout ('ignore') because run-formal-verify.cjs writes ~4MB of
846
+ // verbose progress output. We only need the NDJSON file it writes to disk.
847
+ // Without this, spawnSync's maxBuffer limit kills the child mid-run, resulting
848
+ // in a partial NDJSON with only 3-4 CI checks instead of the full 88+.
849
+ const result = spawnTool('bin/run-formal-verify.cjs', [], {
850
+ timeout: 300000,
851
+ stdio: ['pipe', 'ignore', 'pipe'],
852
+ });
853
+
854
+ // Non-zero exit is expected when checks fail — still parse check-results.ndjson.
855
+ // Only bail on spawn errors (result.stderr without any ndjson output).
856
+ if (!result.ok && result.stderr && !fs.existsSync(path.join(ROOT, '.planning', 'formal', 'check-results.ndjson'))) {
857
+ return {
858
+ residual: -1,
859
+ detail: { error: result.stderr.slice(0, 500) || 'run-formal-verify.cjs failed' },
860
+ };
861
+ }
862
+
863
+ // Parse .planning/formal/check-results.ndjson
864
+ const checkResultsPath = path.join(ROOT, '.planning', 'formal', 'check-results.ndjson');
865
+
866
+ if (!fs.existsSync(checkResultsPath)) {
867
+ return {
868
+ residual: 0,
869
+ detail: { note: 'No check-results.ndjson generated' },
870
+ };
871
+ }
872
+
873
+ try {
874
+ const lines = fs.readFileSync(checkResultsPath, 'utf8').split('\n');
875
+ let failedCount = 0;
876
+ let inconclusiveCount = 0;
877
+ let totalCount = 0;
878
+ const failures = [];
879
+ const inconclusiveChecks = [];
880
+
881
+ for (const line of lines) {
882
+ if (!line.trim()) continue;
883
+ totalCount++;
884
+ try {
885
+ const entry = JSON.parse(line);
886
+ if (entry.result === 'fail') {
887
+ failedCount++;
888
+ failures.push({
889
+ check_id: entry.check_id || entry.id || '?',
890
+ summary: entry.summary || '',
891
+ requirement_ids: entry.requirement_ids || [],
892
+ });
893
+ } else if (entry.result === 'inconclusive') {
894
+ inconclusiveCount++;
895
+ inconclusiveChecks.push({
896
+ check_id: entry.check_id || entry.id || '?',
897
+ summary: entry.summary || '',
898
+ });
899
+ }
900
+ } catch (e) {
901
+ // skip malformed lines
902
+ }
903
+ }
904
+
905
+ const existingDetail = {
906
+ total_checks: totalCount,
907
+ passed: Math.max(0, totalCount - failedCount - inconclusiveCount),
908
+ failed: failedCount,
909
+ inconclusive: inconclusiveCount,
910
+ failures: failures,
911
+ inconclusive_checks: inconclusiveChecks,
912
+ };
913
+
914
+ // Conformance trace self-healing: detect schema mismatch
915
+ const conformancePath = path.join(ROOT, '.planning', 'formal', 'trace', 'conformance-events.jsonl');
916
+ if (fs.existsSync(conformancePath)) {
917
+ try {
918
+ const events = fs.readFileSync(conformancePath, 'utf8').split('\n')
919
+ .filter(l => l.trim())
920
+ .map(l => { try { return JSON.parse(l); } catch(e) { return null; } })
921
+ .filter(Boolean);
922
+
923
+ const eventTypes = new Set(events.map(e => e.type || e.event));
924
+
925
+ // Try to load XState machine event types from spec/
926
+ const specDir = path.join(ROOT, '.planning', 'formal', 'spec');
927
+ let machineEventTypes = new Set();
928
+ if (fs.existsSync(specDir)) {
929
+ const specFiles = walkDir(specDir, 3, 0);
930
+ for (const f of specFiles) {
931
+ if (!f.endsWith('.json') && !f.endsWith('.js')) continue;
932
+ try {
933
+ const content = fs.readFileSync(f, 'utf8');
934
+ // Extract event types from "on": { "EVENT_NAME": ... } patterns
935
+ const onMatches = content.matchAll(/"on"\s*:\s*\{([^}]+)\}/g);
936
+ for (const m of onMatches) {
937
+ const keys = m[1].matchAll(/"([A-Z_]+)"/g);
938
+ for (const k of keys) machineEventTypes.add(k[1]);
939
+ }
940
+ } catch(e) { /* skip */ }
941
+ }
942
+ }
943
+
944
+ if (machineEventTypes.size > 0 && eventTypes.size > 0) {
945
+ const overlap = [...eventTypes].filter(t => machineEventTypes.has(t)).length;
946
+ const overlapPct = overlap / Math.max(eventTypes.size, 1);
947
+
948
+ if (overlapPct < 0.5) {
949
+ // Schema mismatch — reclassify
950
+ return {
951
+ residual: failedCount,
952
+ detail: {
953
+ ...existingDetail,
954
+ schema_mismatch: true,
955
+ schema_mismatch_detail: {
956
+ trace_event_types: eventTypes.size,
957
+ machine_event_types: machineEventTypes.size,
958
+ overlap: overlap,
959
+ overlap_pct: (overlapPct * 100).toFixed(1) + '%',
960
+ },
961
+ note: 'Conformance trace has <50% event type overlap with state machine — likely schema mismatch, not verification failure',
962
+ },
963
+ };
964
+ }
965
+ }
966
+ } catch (e) {
967
+ // Conformance trace check failed — fail-open, continue with normal result
968
+ }
969
+ }
970
+
971
+ return {
972
+ residual: failedCount,
973
+ detail: existingDetail,
974
+ };
975
+ } catch (err) {
976
+ return {
977
+ residual: -1,
978
+ detail: { error: 'Failed to parse check-results.ndjson: ' + err.message },
979
+ };
980
+ }
981
+ }
982
+
983
+ /**
984
+ * R->D: Requirements to Documentation.
985
+ * Detects requirements not mentioned in developer docs (by ID or keyword match).
986
+ * Returns { residual: N, detail: {...} }
987
+ */
988
+ function sweepRtoD() {
989
+ // Load requirements.json
990
+ const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
991
+ if (!fs.existsSync(reqPath)) {
992
+ return {
993
+ residual: 0,
994
+ detail: { skipped: true, reason: 'requirements.json not found' },
995
+ };
996
+ }
997
+
998
+ let reqData;
999
+ try {
1000
+ reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
1001
+ } catch (e) {
1002
+ return {
1003
+ residual: 0,
1004
+ detail: { skipped: true, reason: 'requirements.json parse error: ' + e.message },
1005
+ };
1006
+ }
1007
+
1008
+ // Discover doc files
1009
+ const allDiscovered = discoverDocFiles();
1010
+ // Only scan developer-category docs for R->D gap detection.
1011
+ // User docs (category='user') are human-controlled and must not drive auto-remediation.
1012
+ // Fall back to all docs only if no developer-category files exist (legacy setup).
1013
+ const developerDocs = allDiscovered.filter(f => f.category === 'developer');
1014
+ const docFiles = developerDocs.length > 0 ? developerDocs : allDiscovered;
1015
+ if (docFiles.length === 0) {
1016
+ return {
1017
+ residual: 0,
1018
+ detail: { skipped: true, reason: 'no doc files found' },
1019
+ };
1020
+ }
1021
+
1022
+ // Concatenate all doc content
1023
+ let allDocContent = '';
1024
+ for (const { absPath } of docFiles) {
1025
+ try {
1026
+ allDocContent += fs.readFileSync(absPath, 'utf8') + '\n';
1027
+ } catch (e) {
1028
+ // skip unreadable files
1029
+ }
1030
+ }
1031
+ const allDocContentLower = allDocContent.toLowerCase();
1032
+
1033
+ // Get requirements array - handle both flat array and envelope format
1034
+ let requirements = [];
1035
+ if (Array.isArray(reqData)) {
1036
+ requirements = reqData;
1037
+ } else if (reqData.requirements && Array.isArray(reqData.requirements)) {
1038
+ requirements = reqData.requirements;
1039
+ } else if (reqData.groups && Array.isArray(reqData.groups)) {
1040
+ // Envelope format with groups
1041
+ for (const group of reqData.groups) {
1042
+ if (group.requirements && Array.isArray(group.requirements)) {
1043
+ for (const r of group.requirements) requirements.push(r);
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ const undocumented = [];
1049
+ let documented = 0;
1050
+
1051
+ for (const req of requirements) {
1052
+ const id = req.id || req.requirement_id || '';
1053
+ const text = req.text || req.description || '';
1054
+ if (!id) continue;
1055
+
1056
+ // Primary: literal ID match (case-sensitive)
1057
+ if (allDocContent.includes(id)) {
1058
+ documented++;
1059
+ continue;
1060
+ }
1061
+
1062
+ // Secondary: keyword match (case-insensitive, 3+ keywords)
1063
+ const keywords = extractKeywords(text);
1064
+ if (keywords.length > 0) {
1065
+ let matchCount = 0;
1066
+ for (const kw of keywords) {
1067
+ if (allDocContentLower.includes(kw)) {
1068
+ matchCount++;
1069
+ }
1070
+ }
1071
+ if (matchCount >= 3) {
1072
+ documented++;
1073
+ continue;
1074
+ }
1075
+ }
1076
+
1077
+ undocumented.push(id);
1078
+ }
1079
+
1080
+ return {
1081
+ residual: undocumented.length,
1082
+ detail: {
1083
+ undocumented_requirements: undocumented,
1084
+ total_requirements: requirements.length,
1085
+ documented: documented,
1086
+ doc_files_scanned: docFiles.length,
1087
+ developer_docs_only: developerDocs.length > 0,
1088
+ },
1089
+ };
1090
+ }
1091
+
1092
+ /**
1093
+ * D->C: Documentation to Code.
1094
+ * Detects stale structural claims in docs (dead file paths, missing CLI commands, absent dependencies).
1095
+ * Returns { residual: N, detail: {...} }
1096
+ */
1097
+ function sweepDtoC() {
1098
+ const docFiles = discoverDocFiles();
1099
+ if (docFiles.length === 0) {
1100
+ return {
1101
+ residual: 0,
1102
+ detail: { skipped: true, reason: 'no doc files found' },
1103
+ };
1104
+ }
1105
+
1106
+ // Load package.json for dependency verification
1107
+ let pkgDeps = {};
1108
+ let pkgDevDeps = {};
1109
+ try {
1110
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
1111
+ pkgDeps = pkg.dependencies || {};
1112
+ pkgDevDeps = pkg.devDependencies || {};
1113
+ } catch (e) {
1114
+ // No package.json — skip dependency checks
1115
+ }
1116
+
1117
+ // Load acknowledged false positives
1118
+ const fpPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-false-positives.json');
1119
+ let acknowledgedFPs = new Set();
1120
+ try {
1121
+ const fpData = JSON.parse(fs.readFileSync(fpPath, 'utf8'));
1122
+ for (const entry of (fpData.entries || [])) {
1123
+ // Key by doc_file + value only (no line numbers — line numbers shift on edits and break suppression)
1124
+ acknowledgedFPs.add(entry.doc_file + ':' + entry.value);
1125
+ }
1126
+ } catch (e) { /* no ack file */ }
1127
+
1128
+ // Load pattern-based suppression rules
1129
+ let fpPatterns = [];
1130
+ try {
1131
+ const fpData = JSON.parse(fs.readFileSync(fpPath, 'utf8'));
1132
+ for (const entry of (fpData.patterns || [])) {
1133
+ if (entry.enabled === false) continue;
1134
+ try {
1135
+ fpPatterns.push({ type: entry.type, regex: new RegExp(entry.regex), reason: entry.reason });
1136
+ } catch (regexErr) {
1137
+ console.warn('Skipping malformed FP pattern:', entry.regex, regexErr.message);
1138
+ }
1139
+ }
1140
+ } catch (e) { /* no ack file or malformed */ }
1141
+
1142
+ // Severity weights: user-facing broken claims count more
1143
+ const CATEGORY_WEIGHT = { user: 2, examples: 1.5, developer: 1, unknown: 1 };
1144
+
1145
+ const brokenClaims = [];
1146
+ let totalClaimsChecked = 0;
1147
+ let suppressedFpCount = 0;
1148
+
1149
+ for (const { absPath, category } of docFiles) {
1150
+ let content;
1151
+ try {
1152
+ content = fs.readFileSync(absPath, 'utf8');
1153
+ } catch (e) {
1154
+ continue;
1155
+ }
1156
+
1157
+ const relativePath = path.relative(ROOT, absPath).replace(/\\/g, '/');
1158
+ const claims = extractStructuralClaims(content, relativePath);
1159
+
1160
+ for (const claim of claims) {
1161
+ totalClaimsChecked++;
1162
+
1163
+ let isBroken = false;
1164
+ let reason = '';
1165
+
1166
+ if (claim.type === 'file_path') {
1167
+ // Verify file exists
1168
+ const claimAbsPath = path.join(ROOT, claim.value);
1169
+ if (!fs.existsSync(claimAbsPath)) {
1170
+ isBroken = true;
1171
+ reason = 'file not found';
1172
+ }
1173
+ } else if (claim.type === 'cli_command') {
1174
+ // Extract script path from command (e.g., "node bin/foo.cjs" -> "bin/foo.cjs")
1175
+ const cmdParts = claim.value.split(/\s+/);
1176
+ if (cmdParts.length >= 2 && cmdParts[0] === 'node') {
1177
+ const scriptPath = cmdParts[1];
1178
+ if (!fs.existsSync(path.join(ROOT, scriptPath))) {
1179
+ isBroken = true;
1180
+ reason = 'script not found';
1181
+ }
1182
+ }
1183
+ } else if (claim.type === 'dependency') {
1184
+ // Verify in package.json
1185
+ if (!(claim.value in pkgDeps) && !(claim.value in pkgDevDeps)) {
1186
+ isBroken = true;
1187
+ reason = 'not in package.json';
1188
+ }
1189
+ }
1190
+
1191
+ if (isBroken) {
1192
+ // Filter acknowledged false positives
1193
+ if (acknowledgedFPs.has(claim.doc_file + ':' + claim.value)) {
1194
+ suppressedFpCount++;
1195
+ continue;
1196
+ }
1197
+
1198
+ // Filter by pattern-based suppression rules
1199
+ let patternSuppressed = false;
1200
+ for (const pat of fpPatterns) {
1201
+ if (pat.type === claim.type && pat.regex.test(claim.value)) {
1202
+ patternSuppressed = true;
1203
+ break;
1204
+ }
1205
+ }
1206
+ if (patternSuppressed) {
1207
+ suppressedFpCount++;
1208
+ continue;
1209
+ }
1210
+
1211
+ // Reduce weight for historical/archived docs
1212
+ let effectiveCategory = category;
1213
+ const docLower = claim.doc_file.toLowerCase();
1214
+ if (docLower.includes('changelog') || docLower.includes('history') ||
1215
+ docLower.includes('archived/') || docLower.includes('deprecated/')) {
1216
+ effectiveCategory = '_historical';
1217
+ }
1218
+
1219
+ brokenClaims.push({
1220
+ doc_file: claim.doc_file,
1221
+ line: claim.line,
1222
+ type: claim.type,
1223
+ value: claim.value,
1224
+ reason: reason,
1225
+ category: effectiveCategory === '_historical' ? category : category,
1226
+ weight: effectiveCategory === '_historical' ? 0.1 : (CATEGORY_WEIGHT[category] || 1),
1227
+ });
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ // Weighted residual: user-facing broken claims count more
1233
+ let weightedResidual = 0;
1234
+ const categoryBreakdown = {};
1235
+ for (const bc of brokenClaims) {
1236
+ const w = bc.weight !== undefined ? bc.weight : (CATEGORY_WEIGHT[bc.category] || 1);
1237
+ weightedResidual += w;
1238
+ categoryBreakdown[bc.category] = (categoryBreakdown[bc.category] || 0) + 1;
1239
+ }
1240
+
1241
+ return {
1242
+ residual: Math.ceil(weightedResidual),
1243
+ detail: {
1244
+ broken_claims: brokenClaims,
1245
+ total_claims_checked: totalClaimsChecked,
1246
+ doc_files_scanned: docFiles.length,
1247
+ raw_broken_count: brokenClaims.length,
1248
+ weighted_residual: weightedResidual,
1249
+ category_breakdown: categoryBreakdown,
1250
+ suppressed_fp_count: suppressedFpCount,
1251
+ },
1252
+ };
1253
+ }
1254
+
1255
+ // ── Reverse traceability sweeps ──────────────────────────────────────────────
1256
+
1257
+ const MAX_REVERSE_CANDIDATES = 200;
1258
+
1259
+ /**
1260
+ * C->R: Code to Requirements (reverse).
1261
+ * Scans bin/ and hooks/ for source modules not traced to any requirement.
1262
+ * Returns { residual: N, detail: { untraced_modules: [{file}], total_modules, traced } }
1263
+ */
1264
+ function sweepCtoR() {
1265
+ const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
1266
+ if (!fs.existsSync(reqPath)) {
1267
+ return { residual: 0, detail: { skipped: true, reason: 'requirements.json not found' } };
1268
+ }
1269
+
1270
+ let reqData;
1271
+ try {
1272
+ reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
1273
+ } catch (e) {
1274
+ return { residual: 0, detail: { skipped: true, reason: 'requirements.json parse error' } };
1275
+ }
1276
+
1277
+ // Flatten requirements
1278
+ let requirements = [];
1279
+ if (Array.isArray(reqData)) {
1280
+ requirements = reqData;
1281
+ } else if (reqData.requirements && Array.isArray(reqData.requirements)) {
1282
+ requirements = reqData.requirements;
1283
+ } else if (reqData.groups && Array.isArray(reqData.groups)) {
1284
+ for (const group of reqData.groups) {
1285
+ if (group.requirements && Array.isArray(group.requirements)) {
1286
+ for (const r of group.requirements) requirements.push(r);
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // Build searchable text from all requirements
1292
+ const allReqText = requirements.map(r => {
1293
+ const parts = [r.id || '', r.text || '', r.description || '', r.background || ''];
1294
+ if (r.provenance && r.provenance.source_file) parts.push(r.provenance.source_file);
1295
+ return parts.join(' ');
1296
+ }).join('\n');
1297
+
1298
+ // Scan bin/ and hooks/ for source modules
1299
+ const scanDirs = ['bin', 'hooks'];
1300
+ const sourceFiles = [];
1301
+
1302
+ for (const dir of scanDirs) {
1303
+ const absDir = path.join(ROOT, dir);
1304
+ if (!fs.existsSync(absDir)) continue;
1305
+
1306
+ let entries;
1307
+ try {
1308
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
1309
+ } catch (e) {
1310
+ continue;
1311
+ }
1312
+
1313
+ for (const entry of entries) {
1314
+ if (!entry.isFile()) continue;
1315
+ if (!entry.name.endsWith('.cjs') && !entry.name.endsWith('.js') && !entry.name.endsWith('.mjs')) continue;
1316
+ // Skip test files, dist copies, and generated files
1317
+ if (entry.name.includes('.test.')) continue;
1318
+ if (dir === 'hooks' && entry.name === 'dist') continue;
1319
+
1320
+ sourceFiles.push(path.join(dir, entry.name));
1321
+ }
1322
+ }
1323
+
1324
+ // Also scan hooks/dist/ as separate entry point files
1325
+ const distDir = path.join(ROOT, 'hooks', 'dist');
1326
+ if (fs.existsSync(distDir)) {
1327
+ try {
1328
+ const distEntries = fs.readdirSync(distDir, { withFileTypes: true });
1329
+ for (const entry of distEntries) {
1330
+ if (!entry.isFile()) continue;
1331
+ if (!entry.name.endsWith('.cjs') && !entry.name.endsWith('.js')) continue;
1332
+ // dist/ files are copies — skip, they trace through their source in hooks/
1333
+ }
1334
+ } catch (e) {
1335
+ // skip
1336
+ }
1337
+ }
1338
+
1339
+ const untraced = [];
1340
+ let traced = 0;
1341
+
1342
+ for (const file of sourceFiles) {
1343
+ const fileName = path.basename(file);
1344
+ const fileNoExt = fileName.replace(/\.(cjs|js|mjs)$/, '');
1345
+
1346
+ // Check if any requirement references this file
1347
+ if (allReqText.includes(file) || allReqText.includes(fileName) || allReqText.includes(fileNoExt)) {
1348
+ traced++;
1349
+ } else {
1350
+ untraced.push({ file });
1351
+ }
1352
+ }
1353
+
1354
+ return {
1355
+ residual: untraced.length,
1356
+ detail: {
1357
+ untraced_modules: untraced,
1358
+ total_modules: sourceFiles.length,
1359
+ traced: traced,
1360
+ },
1361
+ };
1362
+ }
1363
+
1364
+ /**
1365
+ * T->R: Tests to Requirements (reverse).
1366
+ * Scans test files for tests without @req annotation or formal-test-sync mapping.
1367
+ * Returns { residual: N, detail: { orphan_tests: [file_paths], total_tests, mapped } }
1368
+ */
1369
+ function sweepTtoR() {
1370
+ // Discover test files
1371
+ const testPatterns = [
1372
+ { dir: 'bin', suffix: '.test.cjs' },
1373
+ { dir: 'test', suffix: '.test.cjs' },
1374
+ { dir: 'test', suffix: '.test.js' },
1375
+ ];
1376
+
1377
+ const testFiles = [];
1378
+ for (const { dir, suffix } of testPatterns) {
1379
+ const absDir = path.join(ROOT, dir);
1380
+ if (!fs.existsSync(absDir)) continue;
1381
+
1382
+ let entries;
1383
+ try {
1384
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
1385
+ } catch (e) {
1386
+ continue;
1387
+ }
1388
+
1389
+ for (const entry of entries) {
1390
+ if (!entry.isFile()) continue;
1391
+ if (!entry.name.endsWith(suffix)) continue;
1392
+ testFiles.push(path.join(dir, entry.name));
1393
+ }
1394
+ }
1395
+
1396
+ if (testFiles.length === 0) {
1397
+ return { residual: 0, detail: { orphan_tests: [], total_tests: 0, mapped: 0 } };
1398
+ }
1399
+
1400
+ // Load formal-test-sync data for mapping info
1401
+ const syncData = loadFormalTestSync();
1402
+ const syncMappedFiles = new Set();
1403
+ if (syncData && syncData.coverage_gaps && syncData.coverage_gaps.gaps) {
1404
+ for (const gap of syncData.coverage_gaps.gaps) {
1405
+ if (gap.test_file) syncMappedFiles.add(gap.test_file);
1406
+ }
1407
+ }
1408
+ // Also check stub files from generated-stubs directory
1409
+ if (syncData && syncData.generated_stubs) {
1410
+ for (const stub of syncData.generated_stubs) {
1411
+ if (stub.source_test) syncMappedFiles.add(stub.source_test);
1412
+ }
1413
+ }
1414
+
1415
+ const orphans = [];
1416
+ let mapped = 0;
1417
+
1418
+ for (const testFile of testFiles) {
1419
+ const absPath = path.join(ROOT, testFile);
1420
+
1421
+ // Check for @req annotation in file content
1422
+ let hasReqAnnotation = false;
1423
+ try {
1424
+ const content = fs.readFileSync(absPath, 'utf8');
1425
+ // Match patterns like: @req REQ-01, @req ACT-02, // req: STOP-03
1426
+ hasReqAnnotation = /@req\s+[A-Z]+-\d+/i.test(content) ||
1427
+ /\/\/\s*req:\s*[A-Z]+-\d+/i.test(content);
1428
+ } catch (e) {
1429
+ // Can't read — treat as orphan
1430
+ }
1431
+
1432
+ // Check if formal-test-sync knows about this file
1433
+ const inSyncReport = syncMappedFiles.has(testFile) || syncMappedFiles.has(absPath);
1434
+
1435
+ if (hasReqAnnotation || inSyncReport) {
1436
+ mapped++;
1437
+ } else {
1438
+ orphans.push(testFile);
1439
+ }
1440
+ }
1441
+
1442
+ return {
1443
+ residual: orphans.length,
1444
+ detail: {
1445
+ orphan_tests: orphans,
1446
+ total_tests: testFiles.length,
1447
+ mapped: mapped,
1448
+ },
1449
+ };
1450
+ }
1451
+
1452
+ /**
1453
+ * D->R: Docs to Requirements (reverse).
1454
+ * Extracts capability claims from docs and checks if requirements back them.
1455
+ * Returns { residual: N, detail: { unbacked_claims: [{doc_file, line, claim_text}], total_claims, backed } }
1456
+ */
1457
+ function sweepDtoR() {
1458
+ const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
1459
+ if (!fs.existsSync(reqPath)) {
1460
+ return { residual: 0, detail: { skipped: true, reason: 'requirements.json not found' } };
1461
+ }
1462
+
1463
+ let reqData;
1464
+ try {
1465
+ reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
1466
+ } catch (e) {
1467
+ return { residual: 0, detail: { skipped: true, reason: 'requirements.json parse error' } };
1468
+ }
1469
+
1470
+ // Flatten requirements and extract keywords per requirement
1471
+ let requirements = [];
1472
+ if (Array.isArray(reqData)) {
1473
+ requirements = reqData;
1474
+ } else if (reqData.requirements && Array.isArray(reqData.requirements)) {
1475
+ requirements = reqData.requirements;
1476
+ } else if (reqData.groups && Array.isArray(reqData.groups)) {
1477
+ for (const group of reqData.groups) {
1478
+ if (group.requirements && Array.isArray(group.requirements)) {
1479
+ for (const r of group.requirements) requirements.push(r);
1480
+ }
1481
+ }
1482
+ }
1483
+
1484
+ const reqKeywordSets = requirements.map(r => {
1485
+ const text = (r.text || r.description || '') + ' ' + (r.background || '');
1486
+ return extractKeywords(text);
1487
+ });
1488
+
1489
+ // Discover doc files
1490
+ const docFiles = discoverDocFiles();
1491
+ if (docFiles.length === 0) {
1492
+ return { residual: 0, detail: { skipped: true, reason: 'no doc files found' } };
1493
+ }
1494
+
1495
+ // Action verbs that indicate capability claims
1496
+ const ACTION_VERBS = [
1497
+ 'supports', 'enables', 'provides', 'ensures', 'guarantees',
1498
+ 'validates', 'enforces', 'detects', 'prevents', 'handles',
1499
+ 'automates', 'generates', 'monitors', 'verifies', 'dispatches',
1500
+ ];
1501
+ const verbPattern = new RegExp('\\b(' + ACTION_VERBS.join('|') + ')\\b', 'i');
1502
+
1503
+ const unbacked = [];
1504
+ let totalClaims = 0;
1505
+ let backed = 0;
1506
+
1507
+ for (const { absPath } of docFiles) {
1508
+ let content;
1509
+ try {
1510
+ content = fs.readFileSync(absPath, 'utf8');
1511
+ } catch (e) {
1512
+ continue;
1513
+ }
1514
+
1515
+ const relativePath = path.relative(ROOT, absPath).replace(/\\/g, '/');
1516
+ const lines = content.split('\n');
1517
+ let inFencedBlock = false;
1518
+
1519
+ for (let i = 0; i < lines.length; i++) {
1520
+ const line = lines[i];
1521
+
1522
+ // Skip fenced code blocks
1523
+ if (line.trimStart().startsWith('```')) {
1524
+ inFencedBlock = !inFencedBlock;
1525
+ continue;
1526
+ }
1527
+ if (inFencedBlock) continue;
1528
+
1529
+ // Skip headings, empty lines, and list markers only
1530
+ if (line.match(/^#{1,6}\s/) || line.trim().length === 0) continue;
1531
+
1532
+ // Check for action verb
1533
+ if (!verbPattern.test(line)) continue;
1534
+
1535
+ totalClaims++;
1536
+
1537
+ // Extract keywords from this claim line
1538
+ const claimKeywords = extractKeywords(line);
1539
+ if (claimKeywords.length < 2) {
1540
+ // Too few keywords to match meaningfully
1541
+ backed++;
1542
+ continue;
1543
+ }
1544
+
1545
+ // Check if any requirement has 3+ keyword overlap
1546
+ let hasBacking = false;
1547
+ for (const reqKws of reqKeywordSets) {
1548
+ let overlap = 0;
1549
+ for (const kw of claimKeywords) {
1550
+ if (reqKws.includes(kw)) overlap++;
1551
+ }
1552
+ if (overlap >= 3) {
1553
+ hasBacking = true;
1554
+ break;
1555
+ }
1556
+ }
1557
+
1558
+ if (hasBacking) {
1559
+ backed++;
1560
+ } else {
1561
+ unbacked.push({
1562
+ doc_file: relativePath,
1563
+ line: i + 1,
1564
+ claim_text: line.trim().slice(0, 120),
1565
+ });
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ return {
1571
+ residual: unbacked.length,
1572
+ detail: {
1573
+ unbacked_claims: unbacked,
1574
+ total_claims: totalClaims,
1575
+ backed: backed,
1576
+ },
1577
+ };
1578
+ }
1579
+
1580
+ /**
1581
+ * Classify a reverse discovery candidate into category A/B/C.
1582
+ * Category A (likely requirements): strong requirement language or source modules/tests.
1583
+ * Category B (likely documentation): descriptive/documentation language only.
1584
+ * Category C (ambiguous): needs human review.
1585
+ * @param {object} candidate - Candidate with file_or_claim, evidence, type fields
1586
+ * @returns {{ category: string, reason: string, suggestion: string }}
1587
+ */
1588
+ function classifyCandidate(candidate) {
1589
+ const text = (candidate.file_or_claim || '').toLowerCase();
1590
+
1591
+ // Category A signals: strong requirement language
1592
+ // Use word-boundary regex to avoid false matches (e.g. "mustard" matching "must")
1593
+ // Consistent with triageRequirements() which also uses \b boundaries
1594
+ const reqSignals = ['must', 'shall', 'ensures', 'invariant', 'constraint', 'enforces', 'guarantees'];
1595
+ const hasReqLanguage = reqSignals.some(s => new RegExp('\\b' + s + '\\b', 'i').test(text));
1596
+
1597
+ // Category B signals: weak/descriptive language in doc claims
1598
+ const docSignals = ['supports', 'handles', 'provides', 'describes', 'documents', 'explains'];
1599
+ const hasDocLanguage = docSignals.some(s => new RegExp('\\b' + s + '\\b', 'i').test(text));
1600
+
1601
+ // Module and test types are more likely to be real requirements
1602
+ if (candidate.type === 'module' || candidate.type === 'test') {
1603
+ // Source modules and tests are usually genuine missing requirements
1604
+ return { category: 'A', reason: 'source ' + candidate.type + ' without requirement tracing', suggestion: 'approve' };
1605
+ }
1606
+
1607
+ if (candidate.type === 'claim') {
1608
+ if (hasReqLanguage) {
1609
+ return { category: 'A', reason: 'strong requirement language in doc claim', suggestion: 'approve' };
1610
+ }
1611
+ if (hasDocLanguage && !hasReqLanguage) {
1612
+ return { category: 'B', reason: 'descriptive/documentation language only', suggestion: 'acknowledge' };
1613
+ }
1614
+ return { category: 'C', reason: 'ambiguous — review needed', suggestion: 'review' };
1615
+ }
1616
+
1617
+ return { category: 'C', reason: 'unclassified candidate type', suggestion: 'review' };
1618
+ }
1619
+
1620
+ /**
1621
+ * Assemble and deduplicate reverse traceability candidates from all 3 scanners.
1622
+ * Merges C→R, T→R, D→R results, deduplicates, filters, and respects acknowledged-not-required.json.
1623
+ * Returns { candidates: [...], total_raw, deduped, filtered, acknowledged }
1624
+ */
1625
+ function assembleReverseCandidates(c_to_r, t_to_r, d_to_r) {
1626
+ const raw = [];
1627
+
1628
+ // Gather C→R candidates
1629
+ if (c_to_r.residual > 0 && c_to_r.detail.untraced_modules) {
1630
+ for (const mod of c_to_r.detail.untraced_modules) {
1631
+ raw.push({
1632
+ source_scanners: ['C→R'],
1633
+ evidence: mod.file,
1634
+ file_or_claim: mod.file,
1635
+ type: 'module',
1636
+ });
1637
+ }
1638
+ }
1639
+
1640
+ // Gather T→R candidates
1641
+ if (t_to_r.residual > 0 && t_to_r.detail.orphan_tests) {
1642
+ for (const testFile of t_to_r.detail.orphan_tests) {
1643
+ raw.push({
1644
+ source_scanners: ['T→R'],
1645
+ evidence: testFile,
1646
+ file_or_claim: testFile,
1647
+ type: 'test',
1648
+ });
1649
+ }
1650
+ }
1651
+
1652
+ // Gather D→R candidates
1653
+ if (d_to_r.residual > 0 && d_to_r.detail.unbacked_claims) {
1654
+ for (const claim of d_to_r.detail.unbacked_claims) {
1655
+ raw.push({
1656
+ source_scanners: ['D→R'],
1657
+ evidence: claim.doc_file + ':' + claim.line,
1658
+ file_or_claim: claim.claim_text,
1659
+ type: 'claim',
1660
+ });
1661
+ }
1662
+ }
1663
+
1664
+ const totalRaw = raw.length;
1665
+
1666
+ // Deduplicate: merge test files that correspond to source modules
1667
+ // e.g., test/foo.test.cjs and bin/foo.cjs → single candidate with both scanners
1668
+ const merged = [];
1669
+ const testToSource = new Map();
1670
+
1671
+ for (const candidate of raw) {
1672
+ if (candidate.type === 'test') {
1673
+ // Extract base name: test/foo.test.cjs → foo
1674
+ const baseName = path.basename(candidate.file_or_claim)
1675
+ .replace(/\.test\.(cjs|js|mjs)$/, '');
1676
+ testToSource.set(baseName, candidate);
1677
+ }
1678
+ }
1679
+
1680
+ const mergedTestBases = new Set();
1681
+
1682
+ for (const candidate of raw) {
1683
+ if (candidate.type === 'module') {
1684
+ const baseName = path.basename(candidate.file_or_claim)
1685
+ .replace(/\.(cjs|js|mjs)$/, '');
1686
+ const matchingTest = testToSource.get(baseName);
1687
+ if (matchingTest) {
1688
+ // Merge: combine scanners
1689
+ if (verboseMode) {
1690
+ process.stderr.write(TAG + ' Dedup: merged C→R ' + candidate.file_or_claim +
1691
+ ' + T→R ' + matchingTest.file_or_claim + '\n');
1692
+ }
1693
+ merged.push({
1694
+ source_scanners: ['C→R', 'T→R'],
1695
+ evidence: candidate.file_or_claim + ' + ' + matchingTest.file_or_claim,
1696
+ file_or_claim: candidate.file_or_claim,
1697
+ type: 'module',
1698
+ });
1699
+ mergedTestBases.add(baseName);
1700
+ } else {
1701
+ merged.push(candidate);
1702
+ }
1703
+ } else if (candidate.type === 'test') {
1704
+ const baseName = path.basename(candidate.file_or_claim)
1705
+ .replace(/\.test\.(cjs|js|mjs)$/, '');
1706
+ if (!mergedTestBases.has(baseName)) {
1707
+ merged.push(candidate);
1708
+ }
1709
+ } else {
1710
+ merged.push(candidate);
1711
+ }
1712
+ }
1713
+
1714
+ const deduped = totalRaw - merged.length;
1715
+
1716
+ // Filter out .planning/ files, generated stubs, node_modules
1717
+ let filtered = 0;
1718
+ const candidates = [];
1719
+ for (const c of merged) {
1720
+ if (c.file_or_claim.startsWith('.planning/') ||
1721
+ c.file_or_claim.includes('generated-stubs') ||
1722
+ c.file_or_claim.includes('node_modules')) {
1723
+ filtered++;
1724
+ continue;
1725
+ }
1726
+ candidates.push(c);
1727
+ }
1728
+
1729
+ // Load acknowledged-not-required.json and filter out previously rejected
1730
+ let acknowledged = 0;
1731
+ const ackPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-not-required.json');
1732
+ if (fs.existsSync(ackPath)) {
1733
+ try {
1734
+ const ackData = JSON.parse(fs.readFileSync(ackPath, 'utf8'));
1735
+ const ackSet = new Set((ackData.entries || []).map(e => e.file_or_claim));
1736
+ const afterAck = [];
1737
+ for (const c of candidates) {
1738
+ if (ackSet.has(c.file_or_claim)) {
1739
+ acknowledged++;
1740
+ } else {
1741
+ afterAck.push(c);
1742
+ }
1743
+ }
1744
+ candidates.length = 0;
1745
+ for (const c of afterAck) candidates.push(c);
1746
+ } catch (e) {
1747
+ // malformed ack file — fail-open
1748
+ }
1749
+ }
1750
+
1751
+ // Auto-categorize candidates into A/B/C
1752
+ for (const c of candidates) {
1753
+ const classification = classifyCandidate(c);
1754
+ c.category = classification.category;
1755
+ c.category_reason = classification.reason;
1756
+ c.suggestion = classification.suggestion;
1757
+ }
1758
+
1759
+ // Auto-acknowledge Category B candidates (documentation-only, no human review needed)
1760
+ const catBCandidates = candidates.filter(c => c.category === 'B');
1761
+ const autoAcknowledgedB = catBCandidates.length;
1762
+
1763
+ if (catBCandidates.length > 0 && !reportOnly) {
1764
+ // Write Category B to acknowledged-not-required.json
1765
+ const ackNrPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-not-required.json');
1766
+ let ackNrData = { entries: [] };
1767
+ try {
1768
+ ackNrData = JSON.parse(fs.readFileSync(ackNrPath, 'utf8'));
1769
+ if (!Array.isArray(ackNrData.entries)) ackNrData.entries = [];
1770
+ } catch (e) { /* create fresh */ }
1771
+
1772
+ const existingKeys = new Set(ackNrData.entries.map(e => e.file_or_claim));
1773
+ for (const c of catBCandidates) {
1774
+ if (!existingKeys.has(c.file_or_claim)) {
1775
+ ackNrData.entries.push({
1776
+ file_or_claim: c.file_or_claim,
1777
+ category: 'B',
1778
+ reason: c.category_reason,
1779
+ acknowledged_at: new Date().toISOString(),
1780
+ });
1781
+ }
1782
+ }
1783
+ try {
1784
+ fs.writeFileSync(ackNrPath, JSON.stringify(ackNrData, null, 2) + '\n', 'utf8');
1785
+ } catch (e) {
1786
+ process.stderr.write(TAG + ' WARNING: could not write acknowledged-not-required.json: ' + e.message + '\n');
1787
+ }
1788
+ }
1789
+
1790
+ // Remove Category B from candidates (never surface to humans)
1791
+ const afterCatB = candidates.filter(c => c.category !== 'B');
1792
+ candidates.length = 0;
1793
+ for (const c of afterCatB) candidates.push(c);
1794
+
1795
+ // Count by category for summary
1796
+ const categoryCounts = { A: 0, B: 0, C: 0 };
1797
+ for (const c of candidates) {
1798
+ categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
1799
+ }
1800
+
1801
+ // Apply max candidate cap (R3.6 improvement from copilot-1)
1802
+ if (candidates.length > MAX_REVERSE_CANDIDATES) {
1803
+ if (verboseMode) {
1804
+ process.stderr.write(TAG + ' Capping reverse candidates from ' +
1805
+ candidates.length + ' to ' + MAX_REVERSE_CANDIDATES + '\n');
1806
+ }
1807
+ candidates.length = MAX_REVERSE_CANDIDATES;
1808
+ }
1809
+
1810
+ return {
1811
+ candidates: candidates,
1812
+ total_raw: totalRaw,
1813
+ deduped: deduped,
1814
+ filtered: filtered,
1815
+ acknowledged: acknowledged,
1816
+ auto_acknowledged_b: autoAcknowledgedB,
1817
+ category_counts: categoryCounts,
1818
+ };
1819
+ }
1820
+
1821
+ // ── Residual computation ─────────────────────────────────────────────────────
1822
+
1823
+ /**
1824
+ * Computes residual vector for all layer transitions (8 forward + 3 reverse).
1825
+ * Returns residual object with forward layers + reverse discovery layers.
1826
+ */
1827
+ function computeResidual() {
1828
+ const r_to_f = sweepRtoF();
1829
+ const f_to_t = sweepFtoT();
1830
+ const c_to_f = sweepCtoF();
1831
+ const t_to_c = fastMode
1832
+ ? { residual: -1, detail: { skipped: true, reason: 'fast mode' } }
1833
+ : sweepTtoC();
1834
+ const f_to_c = fastMode
1835
+ ? { residual: -1, detail: { skipped: true, reason: 'fast mode' } }
1836
+ : sweepFtoC();
1837
+ const r_to_d = sweepRtoD();
1838
+ const d_to_c = sweepDtoC();
1839
+ const p_to_f = sweepPtoF({ root: ROOT });
1840
+
1841
+ // Reverse traceability discovery (do NOT add to automatable total)
1842
+ const c_to_r = sweepCtoR();
1843
+ const t_to_r = sweepTtoR();
1844
+ const d_to_r = sweepDtoR();
1845
+
1846
+ const total =
1847
+ (r_to_f.residual >= 0 ? r_to_f.residual : 0) +
1848
+ (f_to_t.residual >= 0 ? f_to_t.residual : 0) +
1849
+ (c_to_f.residual >= 0 ? c_to_f.residual : 0) +
1850
+ (t_to_c.residual >= 0 ? t_to_c.residual : 0) +
1851
+ (f_to_c.residual >= 0 ? f_to_c.residual : 0) +
1852
+ (r_to_d.residual >= 0 ? r_to_d.residual : 0) +
1853
+ (d_to_c.residual >= 0 ? d_to_c.residual : 0) +
1854
+ (p_to_f.residual >= 0 ? p_to_f.residual : 0);
1855
+
1856
+ const reverse_discovery_total =
1857
+ (c_to_r.residual >= 0 ? c_to_r.residual : 0) +
1858
+ (t_to_r.residual >= 0 ? t_to_r.residual : 0) +
1859
+ (d_to_r.residual >= 0 ? d_to_r.residual : 0);
1860
+
1861
+ // Assemble deduplicated reverse candidates
1862
+ const assembled_candidates = assembleReverseCandidates(c_to_r, t_to_r, d_to_r);
1863
+
1864
+ return {
1865
+ r_to_f,
1866
+ f_to_t,
1867
+ c_to_f,
1868
+ t_to_c,
1869
+ f_to_c,
1870
+ r_to_d,
1871
+ d_to_c,
1872
+ p_to_f,
1873
+ c_to_r,
1874
+ t_to_r,
1875
+ d_to_r,
1876
+ assembled_candidates,
1877
+ total,
1878
+ reverse_discovery_total,
1879
+ timestamp: new Date().toISOString(),
1880
+ };
1881
+ }
1882
+
1883
+ // ── Auto-close ───────────────────────────────────────────────────────────────
1884
+
1885
+ /**
1886
+ * Attempts to fix gaps found by the sweep.
1887
+ * Returns { actions_taken: [...], stubs_generated: N }
1888
+ */
1889
+ function autoClose(residual) {
1890
+ const actions = [];
1891
+
1892
+ // F->T gaps: generate test stubs
1893
+ if (residual.f_to_t.residual > 0) {
1894
+ const result = spawnTool('bin/formal-test-sync.cjs', []);
1895
+ if (result.ok) {
1896
+ actions.push(
1897
+ 'Generated test stubs for ' +
1898
+ residual.f_to_t.residual +
1899
+ ' uncovered invariants'
1900
+ );
1901
+ } else {
1902
+ actions.push(
1903
+ 'Could not auto-generate test stubs for ' +
1904
+ residual.f_to_t.residual +
1905
+ ' invariants (formal-test-sync.cjs failed)'
1906
+ );
1907
+ }
1908
+ }
1909
+
1910
+ // C->F mismatches: log but do not auto-fix
1911
+ if (residual.c_to_f.residual > 0) {
1912
+ actions.push(
1913
+ 'Cannot auto-fix ' +
1914
+ residual.c_to_f.residual +
1915
+ ' constant mismatch(es) — manual review required'
1916
+ );
1917
+ }
1918
+
1919
+ // T->C failures: log but do not auto-fix
1920
+ if (residual.t_to_c.residual > 0) {
1921
+ actions.push(
1922
+ residual.t_to_c.residual + ' test failure(s) — manual fix required'
1923
+ );
1924
+ }
1925
+
1926
+ // R->F gaps: log with triage info
1927
+ if (residual.r_to_f.residual > 0) {
1928
+ const triageDetail = residual.r_to_f.detail.triage;
1929
+ if (triageDetail) {
1930
+ actions.push(
1931
+ triageDetail.high + ' HIGH + ' + triageDetail.medium +
1932
+ ' MEDIUM priority requirements lack formal coverage'
1933
+ );
1934
+ } else {
1935
+ actions.push(
1936
+ residual.r_to_f.residual +
1937
+ ' requirement(s) lack formal model coverage — manual modeling required'
1938
+ );
1939
+ }
1940
+ }
1941
+
1942
+ // F->C failures: log but do not auto-fix
1943
+ if (residual.f_to_c.residual > 0) {
1944
+ actions.push(
1945
+ residual.f_to_c.residual +
1946
+ ' formal verification failure(s) — manual fix required'
1947
+ );
1948
+ }
1949
+
1950
+ // R->D gaps: log but do not auto-fix (manual review)
1951
+ if (residual.r_to_d.residual > 0) {
1952
+ actions.push(
1953
+ residual.r_to_d.residual +
1954
+ ' requirement(s) undocumented in developer docs — manual review required'
1955
+ );
1956
+ }
1957
+
1958
+ // D->C stale claims: log but do not auto-fix (manual review)
1959
+ if (residual.d_to_c.residual > 0) {
1960
+ actions.push(
1961
+ residual.d_to_c.residual +
1962
+ ' stale structural claim(s) in docs — manual review required'
1963
+ );
1964
+ }
1965
+
1966
+ // P->F divergence: dispatch parameter updates or flag investigations
1967
+ if (residual.p_to_f && residual.p_to_f.residual > 0) {
1968
+ const result = autoClosePtoF(residual.p_to_f, {
1969
+ spawnTool: spawnTool,
1970
+ });
1971
+ for (const action of result.actions_taken) {
1972
+ actions.push(action);
1973
+ }
1974
+ }
1975
+
1976
+ return {
1977
+ actions_taken: actions,
1978
+ stubs_generated: residual.f_to_t.residual > 0 ? 1 : 0,
1979
+ };
1980
+ }
1981
+
1982
+ // ── Health indicator ─────────────────────────────────────────────────────────
1983
+
1984
+ /**
1985
+ * Returns health string for a residual value.
1986
+ */
1987
+ function healthIndicator(residual) {
1988
+ if (residual === -1) return '? UNKNOWN';
1989
+ if (residual === 0) return 'OK GREEN';
1990
+ if (residual >= 1 && residual <= 3) return '!! YELLOW';
1991
+ return 'XX RED';
1992
+ }
1993
+
1994
+ // ── Report formatting ────────────────────────────────────────────────────────
1995
+
1996
+ /**
1997
+ * Formats human-readable report.
1998
+ */
1999
+ function formatReport(iterations, finalResidual, converged) {
2000
+ const lines = [];
2001
+
2002
+ lines.push('[qgsd-solve] Consistency Solver Report');
2003
+ lines.push('');
2004
+ lines.push(
2005
+ 'Iterations: ' +
2006
+ iterations.length +
2007
+ '/' +
2008
+ maxIterations +
2009
+ ' (converged: ' +
2010
+ (converged ? 'yes' : 'no') +
2011
+ ')'
2012
+ );
2013
+ lines.push('');
2014
+
2015
+ // Residual vector table
2016
+ lines.push('Layer Transition Residual Health');
2017
+ lines.push('─────────────────────────────────────────────');
2018
+
2019
+ const rows = [
2020
+ {
2021
+ label: 'R -> F (Req->Formal)',
2022
+ residual: finalResidual.r_to_f.residual,
2023
+ },
2024
+ {
2025
+ label: 'F -> T (Formal->Test)',
2026
+ residual: finalResidual.f_to_t.residual,
2027
+ },
2028
+ {
2029
+ label: 'C -> F (Code->Formal)',
2030
+ residual: finalResidual.c_to_f.residual,
2031
+ },
2032
+ {
2033
+ label: 'T -> C (Test->Code)',
2034
+ residual: finalResidual.t_to_c.residual,
2035
+ },
2036
+ {
2037
+ label: 'F -> C (Formal->Code)',
2038
+ residual: finalResidual.f_to_c.residual,
2039
+ },
2040
+ {
2041
+ label: 'R -> D (Req->Docs)',
2042
+ residual: finalResidual.r_to_d.residual,
2043
+ },
2044
+ {
2045
+ label: 'D -> C (Docs->Code)',
2046
+ residual: finalResidual.d_to_c.residual,
2047
+ },
2048
+ {
2049
+ label: 'P -> F (Prod->Formal)',
2050
+ residual: finalResidual.p_to_f ? finalResidual.p_to_f.residual : -1,
2051
+ },
2052
+ ];
2053
+
2054
+ for (const row of rows) {
2055
+ const res =
2056
+ row.residual >= 0 ? row.residual : '?';
2057
+ const health = healthIndicator(row.residual);
2058
+ const line = row.label.padEnd(28) + String(res).padStart(4) + ' ' + health;
2059
+ lines.push(line);
2060
+ }
2061
+
2062
+ lines.push('─────────────────────────────────────────────');
2063
+ lines.push('Total residual: ' + finalResidual.total);
2064
+
2065
+ // Reverse traceability discovery section
2066
+ if (finalResidual.c_to_r || finalResidual.t_to_r || finalResidual.d_to_r) {
2067
+ lines.push('');
2068
+ lines.push('Reverse Traceability Discovery (human-gated):');
2069
+ lines.push('─────────────────────────────────────────────');
2070
+
2071
+ const reverseRows = [
2072
+ { label: 'C -> R (Code->Req)', residual: finalResidual.c_to_r ? finalResidual.c_to_r.residual : -1 },
2073
+ { label: 'T -> R (Test->Req)', residual: finalResidual.t_to_r ? finalResidual.t_to_r.residual : -1 },
2074
+ { label: 'D -> R (Docs->Req)', residual: finalResidual.d_to_r ? finalResidual.d_to_r.residual : -1 },
2075
+ ];
2076
+
2077
+ for (const row of reverseRows) {
2078
+ const res = row.residual >= 0 ? row.residual : '?';
2079
+ const health = healthIndicator(row.residual);
2080
+ const line = row.label.padEnd(28) + String(res).padStart(4) + ' ' + health;
2081
+ lines.push(line);
2082
+ }
2083
+
2084
+ const rdTotal = finalResidual.reverse_discovery_total || 0;
2085
+ lines.push('─────────────────────────────────────────────');
2086
+ lines.push('Discovery total: ' + rdTotal);
2087
+
2088
+ if (finalResidual.assembled_candidates && finalResidual.assembled_candidates.candidates.length > 0) {
2089
+ const ac = finalResidual.assembled_candidates;
2090
+ lines.push('Candidates: ' + ac.candidates.length + ' (raw: ' + ac.total_raw +
2091
+ ', deduped: ' + ac.deduped + ', filtered: ' + ac.filtered +
2092
+ ', acknowledged: ' + ac.acknowledged + ')');
2093
+ if (ac.category_counts) {
2094
+ lines.push(' Category A (likely reqs): ' + (ac.category_counts.A || 0) +
2095
+ ', Category B (likely docs): ' + (ac.category_counts.B || 0) +
2096
+ ', Category C (ambiguous): ' + (ac.category_counts.C || 0));
2097
+ }
2098
+ }
2099
+ }
2100
+ lines.push('');
2101
+
2102
+ // Per-layer detail sections (only non-zero)
2103
+ if (finalResidual.r_to_f.residual > 0) {
2104
+ lines.push('## R -> F (Requirements -> Formal)');
2105
+ const detail = finalResidual.r_to_f.detail;
2106
+ if (detail.uncovered_requirements && detail.uncovered_requirements.length > 0) {
2107
+ lines.push('Uncovered requirements:');
2108
+ for (const req of detail.uncovered_requirements) {
2109
+ lines.push(' - ' + req);
2110
+ }
2111
+ }
2112
+ lines.push('');
2113
+ }
2114
+
2115
+ if (finalResidual.f_to_t.residual > 0) {
2116
+ lines.push('## F -> T (Formal -> Tests)');
2117
+ const detail = finalResidual.f_to_t.detail;
2118
+ lines.push('Gap count: ' + detail.gap_count);
2119
+ if (detail.gaps && detail.gaps.length > 0) {
2120
+ lines.push('Requirements with gaps:');
2121
+ for (const gap of detail.gaps.slice(0, 10)) {
2122
+ lines.push(' - ' + gap);
2123
+ }
2124
+ if (detail.gaps.length > 10) {
2125
+ lines.push(' ... and ' + (detail.gaps.length - 10) + ' more');
2126
+ }
2127
+ }
2128
+ lines.push('');
2129
+ }
2130
+
2131
+ if (finalResidual.c_to_f.residual > 0) {
2132
+ lines.push('## C -> F (Code Constants -> Formal)');
2133
+ const detail = finalResidual.c_to_f.detail;
2134
+ if (detail.mismatches && detail.mismatches.length > 0) {
2135
+ lines.push('Mismatches:');
2136
+ for (const m of detail.mismatches.slice(0, 5)) {
2137
+ lines.push(
2138
+ ' - ' +
2139
+ m.constant +
2140
+ ': formal=' +
2141
+ m.formal_value +
2142
+ ', config=' +
2143
+ m.config_value
2144
+ );
2145
+ }
2146
+ if (detail.mismatches.length > 5) {
2147
+ lines.push(' ... and ' + (detail.mismatches.length - 5) + ' more');
2148
+ }
2149
+ }
2150
+ lines.push('');
2151
+ }
2152
+
2153
+ if (finalResidual.t_to_c.residual > 0) {
2154
+ lines.push('## T -> C (Tests -> Code)');
2155
+ const detail = finalResidual.t_to_c.detail;
2156
+ const parts = [];
2157
+ if (detail.failed > 0) parts.push('\u2717 ' + detail.failed + ' failed');
2158
+ if (detail.skipped > 0) parts.push('\u2298 ' + detail.skipped + ' skipped');
2159
+ if (detail.todo > 0) parts.push('\u25F7 ' + detail.todo + ' todo');
2160
+ lines.push('Tests: ' + parts.join(', ') + ' (of ' + detail.total_tests + ' total)');
2161
+ lines.push('');
2162
+ }
2163
+
2164
+ if (finalResidual.f_to_c.residual > 0 || (finalResidual.f_to_c.detail && finalResidual.f_to_c.detail.inconclusive > 0)) {
2165
+ lines.push('## F -> C (Formal -> Code)');
2166
+ const detail = finalResidual.f_to_c.detail;
2167
+ const parts = [];
2168
+ if (detail.passed > 0) parts.push(detail.passed + ' pass');
2169
+ if (detail.failed > 0) parts.push(detail.failed + ' fail');
2170
+ if (detail.inconclusive > 0) parts.push(detail.inconclusive + ' inconclusive');
2171
+ lines.push('Checks: ' + parts.join(', ') + ' (of ' + detail.total_checks + ' total)');
2172
+ if (detail.failures && detail.failures.length > 0) {
2173
+ lines.push('');
2174
+ lines.push('Failures:');
2175
+ for (const fail of detail.failures) {
2176
+ const f = typeof fail === 'string' ? { check_id: fail, summary: '' } : fail;
2177
+ lines.push(' ✗ ' + f.check_id + (f.summary ? ' — ' + f.summary : ''));
2178
+ if (f.requirement_ids && f.requirement_ids.length > 0) {
2179
+ lines.push(' reqs: ' + f.requirement_ids.join(', '));
2180
+ }
2181
+ }
2182
+ }
2183
+ if (detail.inconclusive_checks && detail.inconclusive_checks.length > 0) {
2184
+ lines.push('');
2185
+ lines.push('Inconclusive:');
2186
+ for (const w of detail.inconclusive_checks) {
2187
+ lines.push(' ⚠ ' + w.check_id + (w.summary ? ' — ' + w.summary : ''));
2188
+ }
2189
+ }
2190
+ if (detail.stale) {
2191
+ lines.push('');
2192
+ lines.push('Note: results may be stale (from cached check-results.ndjson)');
2193
+ }
2194
+ lines.push('');
2195
+ }
2196
+
2197
+ if (finalResidual.r_to_d.residual > 0) {
2198
+ lines.push('## R -> D (Requirements -> Docs)');
2199
+ const detail = finalResidual.r_to_d.detail;
2200
+ if (detail.undocumented_requirements && detail.undocumented_requirements.length > 0) {
2201
+ lines.push('Undocumented requirements:');
2202
+ for (const req of detail.undocumented_requirements.slice(0, 20)) {
2203
+ lines.push(' - ' + req);
2204
+ }
2205
+ if (detail.undocumented_requirements.length > 20) {
2206
+ lines.push(' ... and ' + (detail.undocumented_requirements.length - 20) + ' more');
2207
+ }
2208
+ }
2209
+ lines.push('');
2210
+ }
2211
+
2212
+ if (finalResidual.d_to_c.residual > 0) {
2213
+ lines.push('## D -> C (Docs -> Code)');
2214
+ const detail = finalResidual.d_to_c.detail;
2215
+ if (detail.broken_claims && detail.broken_claims.length > 0) {
2216
+ lines.push('Broken structural claims:');
2217
+ for (const claim of detail.broken_claims.slice(0, 20)) {
2218
+ lines.push(' - ' + claim.doc_file + ':' + claim.line + ' [' + claim.type + '] `' + claim.value + '` — ' + claim.reason);
2219
+ }
2220
+ if (detail.broken_claims.length > 20) {
2221
+ lines.push(' ... and ' + (detail.broken_claims.length - 20) + ' more');
2222
+ }
2223
+ }
2224
+ lines.push('');
2225
+ }
2226
+
2227
+ if (finalResidual.p_to_f && finalResidual.p_to_f.residual > 0) {
2228
+ lines.push('## P -> F (Production -> Formal)');
2229
+ const detail = finalResidual.p_to_f.detail;
2230
+ if (detail.divergent_entries && detail.divergent_entries.length > 0) {
2231
+ lines.push('Divergent entries:');
2232
+ for (const ent of detail.divergent_entries.slice(0, 20)) {
2233
+ lines.push(' - ' + ent.id + ': ' + ent.formal_ref + ' (measured: ' + ent.measured + ', expected: ' + ent.expected + ')');
2234
+ }
2235
+ if (detail.divergent_entries.length > 20) {
2236
+ lines.push(' ... and ' + (detail.divergent_entries.length - 20) + ' more');
2237
+ }
2238
+ }
2239
+ if (detail.skipped_unlinked > 0) {
2240
+ lines.push('Skipped (waiting for formal link): ' + detail.skipped_unlinked);
2241
+ }
2242
+ lines.push('');
2243
+ }
2244
+
2245
+ // Reverse traceability detail
2246
+ if (finalResidual.c_to_r && finalResidual.c_to_r.residual > 0) {
2247
+ lines.push('## C -> R (Code -> Requirements) [reverse discovery]');
2248
+ const detail = finalResidual.c_to_r.detail;
2249
+ if (detail.untraced_modules && detail.untraced_modules.length > 0) {
2250
+ lines.push('Untraced modules (' + detail.untraced_modules.length + ' of ' + detail.total_modules + '):');
2251
+ for (const mod of detail.untraced_modules.slice(0, 20)) {
2252
+ lines.push(' - ' + mod.file);
2253
+ }
2254
+ if (detail.untraced_modules.length > 20) {
2255
+ lines.push(' ... and ' + (detail.untraced_modules.length - 20) + ' more');
2256
+ }
2257
+ }
2258
+ lines.push('');
2259
+ }
2260
+
2261
+ if (finalResidual.t_to_r && finalResidual.t_to_r.residual > 0) {
2262
+ lines.push('## T -> R (Tests -> Requirements) [reverse discovery]');
2263
+ const detail = finalResidual.t_to_r.detail;
2264
+ if (detail.orphan_tests && detail.orphan_tests.length > 0) {
2265
+ lines.push('Orphan tests (' + detail.orphan_tests.length + ' of ' + detail.total_tests + '):');
2266
+ for (const t of detail.orphan_tests.slice(0, 20)) {
2267
+ lines.push(' - ' + t);
2268
+ }
2269
+ if (detail.orphan_tests.length > 20) {
2270
+ lines.push(' ... and ' + (detail.orphan_tests.length - 20) + ' more');
2271
+ }
2272
+ }
2273
+ lines.push('');
2274
+ }
2275
+
2276
+ if (finalResidual.d_to_r && finalResidual.d_to_r.residual > 0) {
2277
+ lines.push('## D -> R (Docs -> Requirements) [reverse discovery]');
2278
+ const detail = finalResidual.d_to_r.detail;
2279
+ if (detail.unbacked_claims && detail.unbacked_claims.length > 0) {
2280
+ lines.push('Unbacked doc claims (' + detail.unbacked_claims.length + ' of ' + detail.total_claims + '):');
2281
+ for (const c of detail.unbacked_claims.slice(0, 20)) {
2282
+ lines.push(' - ' + c.doc_file + ':' + c.line + ' — ' + c.claim_text);
2283
+ }
2284
+ if (detail.unbacked_claims.length > 20) {
2285
+ lines.push(' ... and ' + (detail.unbacked_claims.length - 20) + ' more');
2286
+ }
2287
+ }
2288
+ lines.push('');
2289
+ }
2290
+
2291
+ return lines.join('\n');
2292
+ }
2293
+
2294
+ /**
2295
+ * Truncate detail arrays in a residual object to keep JSON output within pipe buffer limits.
2296
+ * Returns a shallow copy with truncated arrays and a `truncated` flag if applicable.
2297
+ */
2298
+ function truncateResidualDetail(residual) {
2299
+ const MAX_DETAIL_ITEMS = 30;
2300
+ const copy = {};
2301
+ for (const key of Object.keys(residual)) {
2302
+ const val = residual[key];
2303
+ if (val && typeof val === 'object' && val.detail && typeof val.detail === 'object') {
2304
+ const detailCopy = Object.assign({}, val.detail);
2305
+ // Truncate large arrays in detail
2306
+ for (const dk of Object.keys(detailCopy)) {
2307
+ if (Array.isArray(detailCopy[dk]) && detailCopy[dk].length > MAX_DETAIL_ITEMS) {
2308
+ const totalCount = detailCopy[dk].length;
2309
+ detailCopy[dk] = detailCopy[dk].slice(0, MAX_DETAIL_ITEMS);
2310
+ detailCopy[dk + '_truncated'] = true;
2311
+ detailCopy[dk + '_total'] = totalCount;
2312
+ }
2313
+ }
2314
+ copy[key] = { residual: val.residual, detail: detailCopy };
2315
+ } else {
2316
+ copy[key] = val;
2317
+ }
2318
+ }
2319
+ return copy;
2320
+ }
2321
+
2322
+ /**
2323
+ * Formats JSON output.
2324
+ */
2325
+ function formatJSON(iterations, finalResidual, converged) {
2326
+ const health = {};
2327
+ for (const key of ['r_to_f', 'f_to_t', 'c_to_f', 't_to_c', 'f_to_c', 'r_to_d', 'd_to_c', 'p_to_f', 'c_to_r', 't_to_r', 'd_to_r']) {
2328
+ const res = finalResidual[key] ? finalResidual[key].residual : -1;
2329
+ health[key] = healthIndicator(res).split(/\s+/)[1]; // Extract GREEN/YELLOW/RED/UNKNOWN
2330
+ }
2331
+
2332
+ const truncatedResidual = truncateResidualDetail(finalResidual);
2333
+
2334
+ return {
2335
+ solver_version: '1.2',
2336
+ generated_at: new Date().toISOString(),
2337
+ fast_mode: fastMode ? true : false,
2338
+ iteration_count: iterations.length,
2339
+ max_iterations: maxIterations,
2340
+ converged: converged,
2341
+ residual_vector: truncatedResidual,
2342
+ iterations: iterations.map((it) => ({
2343
+ iteration: it.iteration,
2344
+ residual: truncateResidualDetail(it.residual),
2345
+ actions: it.actions || [],
2346
+ })),
2347
+ health: health,
2348
+ };
2349
+ }
2350
+
2351
+ // ── Main ─────────────────────────────────────────────────────────────────────
2352
+
2353
+ function main() {
2354
+ // Step 0: Bootstrap formal infrastructure
2355
+ preflight();
2356
+
2357
+ const iterations = [];
2358
+ let converged = false;
2359
+ let prevTotal = null;
2360
+
2361
+ for (let i = 1; i <= maxIterations; i++) {
2362
+ process.stderr.write(TAG + ' Iteration ' + i + '/' + maxIterations + '\n');
2363
+
2364
+ // Clear formal-test-sync cache so computeResidual() sees fresh data after autoClose() mutations
2365
+ formalTestSyncCache = null;
2366
+
2367
+ const residual = computeResidual();
2368
+ const actions = [];
2369
+ iterations.push({ iteration: i, residual: residual, actions: actions });
2370
+
2371
+ // Check convergence: total residual unchanged from previous iteration
2372
+ if (prevTotal !== null && residual.total === prevTotal) {
2373
+ converged = true;
2374
+ process.stderr.write(
2375
+ TAG +
2376
+ ' Converged at iteration ' +
2377
+ i +
2378
+ ' (residual stable at ' +
2379
+ residual.total +
2380
+ ')\n'
2381
+ );
2382
+ break;
2383
+ }
2384
+
2385
+ // Check if already at zero
2386
+ if (residual.total === 0) {
2387
+ converged = true;
2388
+ process.stderr.write(TAG + ' All layers clean — residual is 0\n');
2389
+ break;
2390
+ }
2391
+
2392
+ // Auto-close if not report-only and not last iteration
2393
+ if (!reportOnly) {
2394
+ const closeResult = autoClose(residual);
2395
+ iterations[iterations.length - 1].actions = closeResult.actions_taken;
2396
+ } else {
2397
+ break; // report-only = single sweep, no loop
2398
+ }
2399
+
2400
+ prevTotal = residual.total;
2401
+ }
2402
+
2403
+ const finalResidual = iterations[iterations.length - 1].residual;
2404
+
2405
+ // Write solver state persistence
2406
+ const solveState = {
2407
+ last_run: new Date().toISOString(),
2408
+ converged: converged,
2409
+ iteration_count: iterations.length,
2410
+ final_residual_total: finalResidual.total,
2411
+ reverse_discovery_total: finalResidual.reverse_discovery_total || 0,
2412
+ known_issues: [],
2413
+ r_to_f_progress: {
2414
+ total: finalResidual.r_to_f.detail.total || 0,
2415
+ covered: finalResidual.r_to_f.detail.covered || 0,
2416
+ percentage: finalResidual.r_to_f.detail.percentage || 0,
2417
+ },
2418
+ };
2419
+ // Collect known issues from non-zero non-error layers
2420
+ for (const [key, val] of Object.entries(finalResidual)) {
2421
+ if (val && typeof val === 'object' && val.residual > 0) {
2422
+ solveState.known_issues.push({ layer: key, residual: val.residual });
2423
+ }
2424
+ }
2425
+ try {
2426
+ const stateDir = path.join(ROOT, '.planning', 'formal');
2427
+ fs.mkdirSync(stateDir, { recursive: true });
2428
+ fs.writeFileSync(
2429
+ path.join(stateDir, 'solve-state.json'),
2430
+ JSON.stringify(solveState, null, 2) + '\n'
2431
+ );
2432
+ } catch (e) {
2433
+ process.stderr.write(TAG + ' WARNING: could not write solve-state.json: ' + e.message + '\n');
2434
+ }
2435
+
2436
+ if (jsonMode) {
2437
+ process.stdout.write(
2438
+ JSON.stringify(formatJSON(iterations, finalResidual, converged), null, 2) +
2439
+ '\n'
2440
+ );
2441
+ } else {
2442
+ process.stdout.write(formatReport(iterations, finalResidual, converged));
2443
+ }
2444
+
2445
+ // Exit with non-zero if residual > 0 (signals gaps remain)
2446
+ process.exit(finalResidual.total > 0 ? 1 : 0);
2447
+ }
2448
+
2449
+ // ── Exports (for testing) ────────────────────────────────────────────────────
2450
+
2451
+ module.exports = {
2452
+ sweep: computeResidual,
2453
+ computeResidual,
2454
+ autoClose,
2455
+ formatReport,
2456
+ formatJSON,
2457
+ healthIndicator,
2458
+ preflight,
2459
+ triageRequirements,
2460
+ discoverDocFiles,
2461
+ extractKeywords,
2462
+ extractStructuralClaims,
2463
+ sweepRtoD,
2464
+ sweepDtoC,
2465
+ sweepTtoC,
2466
+ sweepCtoR,
2467
+ sweepTtoR,
2468
+ sweepDtoR,
2469
+ assembleReverseCandidates,
2470
+ classifyCandidate,
2471
+ };
2472
+
2473
+ // ── Entry point ──────────────────────────────────────────────────────────────
2474
+
2475
+ if (require.main === module) {
2476
+ main();
2477
+ }