@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,167 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * probe-quorum-slots.cjs — parallel reachability probe for quorum slots
6
+ *
7
+ * Usage:
8
+ * node probe-quorum-slots.cjs --slots slot1,slot2,slot3 [--timeout 8000] [--cwd <dir>]
9
+ *
10
+ * Spawns each slot's CLI with a minimal "OK" prompt and a short timeout.
11
+ * All slots are probed in parallel (Promise.all).
12
+ *
13
+ * Output (stdout): JSON array of { slot, healthy, latencyMs, error }
14
+ * healthy: true → CLI responded within timeout (any exit code)
15
+ * healthy: false → CLI timed out or failed to spawn
16
+ *
17
+ * Only probes type=subprocess slots. HTTP slots are skipped (marked healthy=true by default).
18
+ * Exit code: always 0 — caller interprets the JSON to decide what to skip.
19
+ */
20
+
21
+ const { spawn } = require('child_process');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ // ─── Find providers.json (mirrors call-quorum-slot.cjs logic) ────────────────
27
+ function findProviders() {
28
+ const searchPaths = [
29
+ path.join(__dirname, 'providers.json'),
30
+ path.join(os.homedir(), '.claude', 'qgsd-bin', 'providers.json'),
31
+ ];
32
+ try {
33
+ const claudeJson = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
34
+ const u1args = claudeJson?.mcpServers?.['unified-1']?.args ?? [];
35
+ const serverScript = u1args.find(a => typeof a === 'string' && a.endsWith('unified-mcp-server.mjs'));
36
+ if (serverScript) searchPaths.unshift(path.join(path.dirname(serverScript), 'providers.json'));
37
+ } catch (_) {}
38
+ for (const p of searchPaths) {
39
+ try { if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')).providers; } catch (_) {}
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // ─── Kill entire process group (mirrors call-quorum-slot.cjs killGroup) ──────
45
+ function makeKillGroup(child) {
46
+ return () => {
47
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (_) { try { child.kill('SIGTERM'); } catch (_) {} }
48
+ setTimeout(() => {
49
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { try { child.kill('SIGKILL'); } catch (_) {} }
50
+ try { child.stdout.destroy(); } catch (_) {}
51
+ try { child.stderr.destroy(); } catch (_) {}
52
+ }, 1000);
53
+ };
54
+ }
55
+
56
+ // ─── Probe a single subprocess slot ──────────────────────────────────────────
57
+ function probeSlot(provider, timeoutMs, spawnCwd) {
58
+ return new Promise((resolve) => {
59
+ const start = Date.now();
60
+
61
+ // Replace {prompt} with the minimal probe string
62
+ const args = provider.args_template.map(a => (a === '{prompt}' ? 'OK' : a));
63
+ const env = { ...process.env, ...(provider.env ?? {}) };
64
+
65
+ let child;
66
+ try {
67
+ child = spawn(provider.cli, args, {
68
+ env,
69
+ cwd: spawnCwd,
70
+ stdio: ['pipe', 'pipe', 'pipe'],
71
+ detached: true,
72
+ });
73
+ } catch (err) {
74
+ resolve({ slot: provider.name, healthy: false, latencyMs: Date.now() - start, error: `spawn: ${err.message}` });
75
+ return;
76
+ }
77
+
78
+ child.stdin.end(); // non-interactive
79
+
80
+ let stdout = '';
81
+ let stderr = '';
82
+ let timedOut = false;
83
+
84
+ const killGroup = makeKillGroup(child);
85
+
86
+ const timer = setTimeout(() => {
87
+ timedOut = true;
88
+ killGroup();
89
+ }, timeoutMs);
90
+
91
+ child.stdout.on('data', d => { stdout += d.toString().slice(0, 1024); });
92
+ child.stderr.on('data', d => { stderr += d.toString().slice(0, 512); });
93
+
94
+ child.on('close', (code) => {
95
+ clearTimeout(timer);
96
+ const latencyMs = Date.now() - start;
97
+ if (timedOut) {
98
+ resolve({ slot: provider.name, healthy: false, latencyMs, error: `TIMEOUT after ${timeoutMs}ms` });
99
+ } else {
100
+ // Any response (even exit code ≠ 0) counts as "reachable" — the CLI started and ran.
101
+ // Only a timeout means the slot is truly unreachable.
102
+ resolve({ slot: provider.name, healthy: true, latencyMs, error: code !== 0 ? `exit ${code}` : null });
103
+ }
104
+ });
105
+
106
+ child.on('error', (err) => {
107
+ clearTimeout(timer);
108
+ resolve({ slot: provider.name, healthy: false, latencyMs: Date.now() - start, error: `spawn: ${err.message}` });
109
+ });
110
+ });
111
+ }
112
+
113
+ // ─── Main ─────────────────────────────────────────────────────────────────────
114
+ async function main() {
115
+ const argv = process.argv.slice(2);
116
+ const getArg = (f) => { const i = argv.indexOf(f); return i !== -1 && argv[i + 1] ? argv[i + 1] : null; };
117
+
118
+ const slotsArg = getArg('--slots');
119
+ const timeoutMs = Math.max(1000, parseInt(getArg('--timeout') || '8000', 10));
120
+ const spawnCwd = getArg('--cwd') ?? process.cwd();
121
+
122
+ if (!slotsArg) {
123
+ process.stderr.write('Usage: node probe-quorum-slots.cjs --slots slot1,slot2 [--timeout 8000] [--cwd <dir>]\n');
124
+ process.exit(1);
125
+ }
126
+
127
+ const providers = findProviders();
128
+ if (!providers) {
129
+ process.stderr.write('[probe-quorum-slots] Could not find providers.json — skipping probe\n');
130
+ // Fail-open: emit empty array so caller treats all slots as healthy
131
+ process.stdout.write('[]\n');
132
+ process.exit(0);
133
+ }
134
+
135
+ if (providers.length === 0) {
136
+ process.stderr.write('[probe-quorum-slots] No providers configured in providers.json — skipping probe\n');
137
+ process.stdout.write('[]\n');
138
+ process.exit(0);
139
+ }
140
+
141
+ const slotNames = slotsArg.split(',').map(s => s.trim()).filter(Boolean);
142
+
143
+ const results = await Promise.all(
144
+ slotNames.map(name => {
145
+ const provider = providers.find(p => p.name === name);
146
+ if (!provider) {
147
+ // Unknown slot — treat as healthy (fail-open)
148
+ return Promise.resolve({ slot: name, healthy: true, latencyMs: 0, error: 'unknown slot — skipped' });
149
+ }
150
+ if (provider.type !== 'subprocess') {
151
+ // HTTP slots are not probed — treat as healthy
152
+ return Promise.resolve({ slot: name, healthy: true, latencyMs: 0, error: null });
153
+ }
154
+ return probeSlot(provider, timeoutMs, spawnCwd);
155
+ })
156
+ );
157
+
158
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
159
+ process.exit(0);
160
+ }
161
+
162
+ main().catch(err => {
163
+ process.stderr.write(`[probe-quorum-slots] Fatal: ${err.message}\n`);
164
+ // Fail-open on unexpected errors
165
+ process.stdout.write('[]\n');
166
+ process.exit(0);
167
+ });
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/promote-model.cjs
4
+ // Atomic promotion: merges PROPERTY definitions from proposed-changes.tla into
5
+ // a canonical target spec. Both the spec and model-registry.json are written
6
+ // atomically using tmp+rename to prevent partial writes.
7
+ //
8
+ // Usage:
9
+ // node bin/promote-model.cjs <proposed-changes.tla> <target-spec.tla> [--source-id <id>]
10
+ //
11
+ // Exit codes:
12
+ // 0 — success (merged N properties, registry updated)
13
+ // 1 — error (file not found, duplicate property names, etc.)
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+
20
+ // ── Find .planning/formal/ root from target path ───────────────────────────────────────
21
+ // Walks up from a given path until finding .planning/formal/, then
22
+ // returns the grandparent of that directory as the project root.
23
+ // Falls back to ROOT (the QGSD project root) if no .planning/formal/ ancestor is found.
24
+ function findProjectRoot(startPath) {
25
+ let current = path.dirname(startPath);
26
+ while (true) {
27
+ if (path.basename(current) === 'formal' && path.basename(path.dirname(current)) === '.planning') {
28
+ return path.dirname(path.dirname(current));
29
+ }
30
+ const parent = path.dirname(current);
31
+ if (parent === current) break; // filesystem root
32
+ current = parent;
33
+ }
34
+ return ROOT; // fallback
35
+ }
36
+
37
+ // ── Parse CLI arguments ───────────────────────────────────────────────────────
38
+ const args = process.argv.slice(2);
39
+
40
+ let proposedPath = null;
41
+ let targetPath = null;
42
+ let sourceId = null;
43
+
44
+ for (let i = 0; i < args.length; i++) {
45
+ if (args[i] === '--source-id' && i + 1 < args.length) {
46
+ sourceId = args[++i];
47
+ } else if (!proposedPath) {
48
+ proposedPath = args[i];
49
+ } else if (!targetPath) {
50
+ targetPath = args[i];
51
+ }
52
+ }
53
+
54
+ if (!proposedPath || !targetPath) {
55
+ process.stderr.write('Usage: node bin/promote-model.cjs <proposed-changes.tla> <target-spec.tla> [--source-id <id>]\n');
56
+ process.exit(1);
57
+ }
58
+
59
+ // Resolve paths relative to cwd (or absolute)
60
+ const resolvedProposed = path.resolve(proposedPath);
61
+ const resolvedTarget = path.resolve(targetPath);
62
+
63
+ // ── Read proposed file ─────────────────────────────────────────────────────
64
+ if (!fs.existsSync(resolvedProposed)) {
65
+ process.stderr.write('Error: proposed file not found: ' + resolvedProposed + '\n');
66
+ process.exit(1);
67
+ }
68
+ const proposedContent = fs.readFileSync(resolvedProposed, 'utf8');
69
+
70
+ // ── Read target file ───────────────────────────────────────────────────────
71
+ if (!fs.existsSync(resolvedTarget)) {
72
+ process.stderr.write('Error: target file not found: ' + resolvedTarget + '\n');
73
+ process.exit(1);
74
+ }
75
+ const targetContent = fs.readFileSync(resolvedTarget, 'utf8');
76
+
77
+ // ── Extract PROPERTY names using canonical regex ───────────────────────────
78
+ // Pattern: /^PROPERTY\s+(\w+)/gm — sufficient for all TLA+ PROPERTY declarations.
79
+ // Each PROPERTY keyword appears at line start with its name on the same line.
80
+ const PROPERTY_RE = /^PROPERTY\s+(\w+)/gm;
81
+
82
+ function extractPropertyNames(content) {
83
+ return [...content.matchAll(PROPERTY_RE)].map(m => m[1]);
84
+ }
85
+
86
+ const proposedNames = extractPropertyNames(proposedContent);
87
+ const targetNames = extractPropertyNames(targetContent);
88
+
89
+ if (proposedNames.length === 0) {
90
+ process.stderr.write('Warning: no PROPERTY definitions found in proposed file: ' + resolvedProposed + '\n');
91
+ // Not an error — allow no-op promotions
92
+ }
93
+
94
+ // ── Duplicate detection ────────────────────────────────────────────────────
95
+ const targetNameSet = new Set(targetNames);
96
+ const duplicates = proposedNames.filter(name => targetNameSet.has(name));
97
+
98
+ if (duplicates.length > 0) {
99
+ process.stderr.write('Error: duplicate PROPERTY names: ' + duplicates.join(', ') + '\n');
100
+ process.stderr.write('These names already exist in target spec: ' + resolvedTarget + '\n');
101
+ process.exit(1);
102
+ }
103
+
104
+ // ── Extract PROPERTY blocks from proposed content ─────────────────────────
105
+ // We extract all lines that are part of PROPERTY blocks.
106
+ // A PROPERTY block starts at a line matching /^PROPERTY\s+\w+/ and continues
107
+ // until the next PROPERTY, INVARIANT, ===, or EOF.
108
+ function extractPropertyBlocks(content) {
109
+ const lines = content.split('\n');
110
+ const blocks = [];
111
+ let inBlock = false;
112
+ let currentBlock = [];
113
+
114
+ for (const line of lines) {
115
+ if (/^PROPERTY\s+\w+/.test(line)) {
116
+ if (inBlock && currentBlock.length > 0) {
117
+ // Trim trailing empty lines from previous block
118
+ while (currentBlock.length > 0 && currentBlock[currentBlock.length - 1].trim() === '') {
119
+ currentBlock.pop();
120
+ }
121
+ blocks.push(currentBlock.join('\n'));
122
+ currentBlock = [];
123
+ }
124
+ inBlock = true;
125
+ currentBlock.push(line);
126
+ } else if (inBlock) {
127
+ // Stop at module-level delimiters
128
+ if (/^====/.test(line) || /^INVARIANT\s+/.test(line)) {
129
+ if (currentBlock.length > 0) {
130
+ while (currentBlock.length > 0 && currentBlock[currentBlock.length - 1].trim() === '') {
131
+ currentBlock.pop();
132
+ }
133
+ blocks.push(currentBlock.join('\n'));
134
+ currentBlock = [];
135
+ }
136
+ inBlock = false;
137
+ } else {
138
+ currentBlock.push(line);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Flush last block
144
+ if (inBlock && currentBlock.length > 0) {
145
+ while (currentBlock.length > 0 && currentBlock[currentBlock.length - 1].trim() === '') {
146
+ currentBlock.pop();
147
+ }
148
+ if (currentBlock.length > 0) {
149
+ blocks.push(currentBlock.join('\n'));
150
+ }
151
+ }
152
+
153
+ return blocks;
154
+ }
155
+
156
+ const propertyBlocks = extractPropertyBlocks(proposedContent);
157
+
158
+ // ── Merge into target ──────────────────────────────────────────────────────
159
+ // Insert before the closing ==== if present, otherwise append.
160
+ let mergedContent;
161
+ const endMarkerIndex = targetContent.lastIndexOf('\n====');
162
+
163
+ if (endMarkerIndex !== -1) {
164
+ // Insert before the ==== end marker
165
+ const before = targetContent.slice(0, endMarkerIndex);
166
+ const after = targetContent.slice(endMarkerIndex);
167
+ mergedContent = before + '\n\n' + propertyBlocks.join('\n\n') + '\n' + after;
168
+ } else {
169
+ // Append at end
170
+ mergedContent = targetContent.trimEnd() + '\n\n' + propertyBlocks.join('\n\n') + '\n';
171
+ }
172
+
173
+ // ── Atomic write of target spec ────────────────────────────────────────────
174
+ const tmpSpec = resolvedTarget + '.tmp.' + Date.now() + '.' + Math.random().toString(36).slice(2);
175
+ fs.writeFileSync(tmpSpec, mergedContent, 'utf8');
176
+ fs.renameSync(tmpSpec, resolvedTarget);
177
+
178
+ // ── Update model-registry.json ─────────────────────────────────────────────
179
+ // Find registry relative to the target file's .planning/formal/ ancestor
180
+ const projectRoot = findProjectRoot(resolvedTarget);
181
+ const registryPath = path.join(projectRoot, '.planning', 'formal', 'model-registry.json');
182
+ let newVersion = null;
183
+
184
+ if (!fs.existsSync(registryPath)) {
185
+ process.stderr.write('[promote-model] Warning: .planning/formal/model-registry.json not found — skipping registry update\n');
186
+ } else {
187
+ let registry;
188
+ try {
189
+ registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
190
+ } catch (err) {
191
+ process.stderr.write('[promote-model] Warning: cannot parse registry — skipping update: ' + err.message + '\n');
192
+ registry = null;
193
+ }
194
+
195
+ if (registry) {
196
+ if (!registry.models) registry.models = {};
197
+ const key = path.relative(projectRoot, resolvedTarget).replace(/\\/g, '/');
198
+ const now = new Date().toISOString();
199
+ const existing = registry.models[key] || {};
200
+ newVersion = (existing.version || 0) + 1;
201
+
202
+ registry.models[key] = {
203
+ version: newVersion,
204
+ last_updated: now,
205
+ update_source: 'plan-promote',
206
+ source_id: sourceId || null,
207
+ session_id: null,
208
+ description: existing.description || ''
209
+ };
210
+ registry.last_sync = now;
211
+
212
+ // Atomic write of registry
213
+ const tmpReg = registryPath + '.tmp.' + Date.now() + '.' + Math.random().toString(36).slice(2);
214
+ fs.writeFileSync(tmpReg, JSON.stringify(registry, null, 2), 'utf8');
215
+ fs.renameSync(tmpReg, registryPath);
216
+ }
217
+ }
218
+
219
+ // ── Report success ─────────────────────────────────────────────────────────
220
+ const versionStr = newVersion !== null ? '. Registry version: ' + newVersion : '';
221
+ process.stdout.write(
222
+ '[promote-model] Merged ' + proposedNames.length + ' propert' +
223
+ (proposedNames.length === 1 ? 'y' : 'ies') +
224
+ ' into ' + path.relative(process.cwd(), resolvedTarget) + versionStr + '\n'
225
+ );
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // bin/propose-debug-invariants.cjs
4
+ // LOOP-04 (v0.21-03): Mine debug session artifacts for TLA+ PROPERTY candidates.
5
+ // Usage:
6
+ // node bin/propose-debug-invariants.cjs # Interactive: accept/reject each
7
+ // node bin/propose-debug-invariants.cjs --non-interactive # CI: print all, exit 0
8
+
9
+ const { spawnSync } = require('child_process');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ const NON_INTERACTIVE = process.argv.includes('--non-interactive');
14
+ const DEBUG_ARTIFACT_PATH = path.join(process.cwd(), '.planning', 'quick', 'quorum-debug-latest.md');
15
+ const ACCEPT_SCRIPT = path.join(__dirname, 'accept-debug-invariant.cjs');
16
+ const DEFAULT_SPEC = path.join(process.cwd(), '.planning', 'formal', 'tla', 'QGSDQuorum.tla');
17
+
18
+ function sanitizeTlaName(str) {
19
+ return str.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 40).replace(/^_+|_+$/g, '') || 'Unknown';
20
+ }
21
+
22
+ function mineTransitions(lines) {
23
+ const candidates = [];
24
+ const seen = new Set();
25
+ const re = /(\w+)\s*[-\u2192]>\s*(\w+)/g;
26
+ for (let i = 0; i < lines.length; i++) {
27
+ let m;
28
+ re.lastIndex = 0;
29
+ while ((m = re.exec(lines[i])) !== null) {
30
+ const from = m[1], to = m[2];
31
+ const key = from + '_' + to;
32
+ if (seen.has(key)) continue;
33
+ seen.add(key);
34
+ const name = 'TransitionHint_' + from + '_to_' + to;
35
+ const body = '[][phase = "' + from + '" => phase\' \\in {"' + from + '", "' + to + '"}]_vars';
36
+ candidates.push({
37
+ name, body,
38
+ source: 'line ' + (i + 1) + ' "' + lines[i].trim().slice(0, 60) + '"',
39
+ formatted: 'PROPERTY ' + name + ' == ' + body,
40
+ });
41
+ }
42
+ }
43
+ return candidates;
44
+ }
45
+
46
+ function mineRootCauses(lines) {
47
+ const candidates = [];
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const m = lines[i].match(/root_cause:\s*(.+)/i);
50
+ if (!m) continue;
51
+ const text = m[1].trim();
52
+ const sanitized = sanitizeTlaName(text);
53
+ const name = 'RootCauseHint_' + sanitized;
54
+ const body = 'TRUE \\* Review: ' + text.slice(0, 80);
55
+ candidates.push({
56
+ name, body,
57
+ source: 'line ' + (i + 1) + ' "' + lines[i].trim().slice(0, 60) + '"',
58
+ formatted: 'INVARIANT ' + name + ' == ' + body,
59
+ });
60
+ }
61
+ return candidates;
62
+ }
63
+
64
+ function mineInvariantCandidates(lines) {
65
+ const candidates = [];
66
+ let n = 1;
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const m = lines[i].match(/[Ii]nvariant(?:\s+candidate)?:\s*(.+)/);
69
+ if (!m) continue;
70
+ const text = m[1].trim();
71
+ const name = 'InvariantCandidate_' + n++;
72
+ const body = 'TRUE \\* TODO: Formalize: ' + text.slice(0, 80);
73
+ candidates.push({
74
+ name, body,
75
+ source: 'line ' + (i + 1) + ' "' + lines[i].trim().slice(0, 60) + '"',
76
+ formatted: 'PROPERTY ' + name + ' == ' + body,
77
+ });
78
+ }
79
+ return candidates;
80
+ }
81
+
82
+ if (!fs.existsSync(DEBUG_ARTIFACT_PATH)) {
83
+ process.stdout.write('No debug artifact found at ' + path.relative(process.cwd(), DEBUG_ARTIFACT_PATH) + ' — run a debug session first.\n');
84
+ process.exit(0);
85
+ }
86
+
87
+ let artifactText;
88
+ try {
89
+ artifactText = fs.readFileSync(DEBUG_ARTIFACT_PATH, 'utf8');
90
+ } catch (e) {
91
+ process.stdout.write('Warning: failed to read debug artifact: ' + e.message + '\n');
92
+ process.exit(0);
93
+ }
94
+
95
+ const lines = artifactText.split('\n');
96
+
97
+ const candidates = [
98
+ ...mineTransitions(lines),
99
+ ...mineRootCauses(lines),
100
+ ...mineInvariantCandidates(lines),
101
+ ];
102
+
103
+ if (candidates.length === 0) {
104
+ process.stdout.write('No invariant candidates detected in the debug artifact.\n');
105
+ process.exit(0);
106
+ }
107
+
108
+ if (NON_INTERACTIVE) {
109
+ process.stdout.write('=== propose-debug-invariants: ' + candidates.length + ' candidate(s) from quorum-debug-latest.md ===\n\n');
110
+ for (let i = 0; i < candidates.length; i++) {
111
+ const c = candidates[i];
112
+ process.stdout.write('[' + (i + 1) + '] ' + c.formatted + '\n');
113
+ process.stdout.write(' Source: ' + c.source + '\n\n');
114
+ }
115
+ process.stdout.write('Run without --non-interactive to accept/reject each individually.\n');
116
+ process.exit(0);
117
+ }
118
+
119
+ const readline = require('readline');
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
+
122
+ let accepted = 0, rejected = 0, skipped = 0;
123
+ let idx = 0;
124
+
125
+ function askNext() {
126
+ if (idx >= candidates.length) {
127
+ rl.close();
128
+ process.stdout.write('\nSummary: ' + accepted + ' accepted, ' + rejected + ' rejected, ' + skipped + ' skipped.\n');
129
+ process.exit(0);
130
+ return;
131
+ }
132
+
133
+ const c = candidates[idx];
134
+ process.stdout.write('\n[' + (idx + 1) + '/' + candidates.length + '] ' + c.formatted + '\n');
135
+ process.stdout.write(' Source: ' + c.source + '\n');
136
+ rl.question('Accept [a], Reject [r], or Skip [s]? ', (answer) => {
137
+ const choice = answer.trim().toLowerCase();
138
+ if (choice === 'a') {
139
+ const sessionId = 'debug-sess-' + Math.floor(Date.now() / 1000) + '-' + Math.random().toString(16).slice(2, 10);
140
+ const acceptResult = spawnSync(process.execPath, [
141
+ ACCEPT_SCRIPT, DEFAULT_SPEC,
142
+ '--property-name', c.name,
143
+ '--property-body', c.body,
144
+ '--session-id', sessionId,
145
+ ], { encoding: 'utf8', timeout: 15000 });
146
+
147
+ if (acceptResult.status === 0 && !acceptResult.error) {
148
+ process.stdout.write('Accepted: ' + c.name + ' -> written to spec.\n');
149
+ accepted++;
150
+ } else {
151
+ process.stdout.write('Warning: failed to write invariant: ' + (acceptResult.stderr || String(acceptResult.error || '')) + '\n');
152
+ }
153
+ } else if (choice === 'r') {
154
+ process.stdout.write('Rejected.\n');
155
+ rejected++;
156
+ } else {
157
+ process.stdout.write('Skipped.\n');
158
+ skipped++;
159
+ }
160
+ idx++;
161
+ askNext();
162
+ });
163
+ }
164
+
165
+ askNext();