@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,934 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * quorum-slot-dispatch.cjs — prompt construction + output parsing wrapper
6
+ *
7
+ * Usage:
8
+ * node quorum-slot-dispatch.cjs \
9
+ * --slot <name> \
10
+ * --mode <A|B> \
11
+ * --round <n> \
12
+ * --question <text> \
13
+ * [--artifact-path <path>] \
14
+ * [--review-context <string>] \
15
+ * [--prior-positions-file <path>] \
16
+ * [--traces-file <path>] \
17
+ * [--request-improvements] \
18
+ * [--timeout <ms>] \
19
+ * [--cwd <dir>]
20
+ *
21
+ * Builds the Mode A or Mode B prompt from deterministic JS templates matching
22
+ * agents/qgsd-quorum-slot-worker.md Step 2, pipes it to call-quorum-slot.cjs via
23
+ * child_process.spawn, parses the output, and emits a structured YAML result block.
24
+ *
25
+ * Exported pure functions (testable without subprocess):
26
+ * buildModeAPrompt, buildModeBPrompt, parseVerdict, parseReasoning,
27
+ * parseCitations, parseImprovements, emitResultBlock, stripQuotes
28
+ */
29
+
30
+ const { spawn } = require('child_process');
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ // ─── Arg parsing (mirrors call-quorum-slot.cjs pattern) ───────────────────────
35
+ const argv = process.argv.slice(2);
36
+ const getArg = (f) => {
37
+ const i = argv.indexOf(f);
38
+ return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : null;
39
+ };
40
+ const hasFlag = (f) => argv.includes(f);
41
+
42
+ // ─── Requirements loading and matching functions ──────────────────────────────
43
+
44
+ /**
45
+ * Cache for loaded requirements, keyed by projectRoot to avoid re-reading disk.
46
+ * @type {Map<string, Array>}
47
+ */
48
+ const requirementsCache = new Map();
49
+
50
+ /**
51
+ * loadRequirements — reads `.planning/formal/requirements.json` from projectRoot.
52
+ * Fail-open: returns [] if file missing, malformed, or any error occurs.
53
+ * Caches result keyed by projectRoot to avoid re-reading disk.
54
+ *
55
+ * @param {string} projectRoot
56
+ * @returns {Array} — array of requirement objects, or [] if load failed
57
+ */
58
+ function loadRequirements(projectRoot) {
59
+ if (requirementsCache.has(projectRoot)) {
60
+ return requirementsCache.get(projectRoot);
61
+ }
62
+
63
+ try {
64
+ const filePath = path.join(projectRoot, '.planning', 'formal', 'requirements.json');
65
+ const content = fs.readFileSync(filePath, 'utf8');
66
+ const data = JSON.parse(content);
67
+ const reqs = Array.isArray(data.requirements) ? data.requirements : [];
68
+ requirementsCache.set(projectRoot, reqs);
69
+ return reqs;
70
+ } catch (e) {
71
+ // Fail-open: return empty array on any error (missing file, malformed JSON, etc.)
72
+ requirementsCache.set(projectRoot, []);
73
+ return [];
74
+ }
75
+ }
76
+
77
+ /**
78
+ * List of English stopwords to filter out during keyword extraction.
79
+ * @type {Set<string>}
80
+ */
81
+ const STOPWORDS = new Set([
82
+ 'the', 'a', 'is', 'it', 'to', 'of', 'and', 'or', 'in', 'for', 'this', 'that',
83
+ 'with', 'from', 'be', 'are', 'was', 'has', 'have', 'do', 'does', 'not', 'but',
84
+ 'an', 'on', 'at', 'by', 'we', 'should', 'would', 'could', 'will', 'can',
85
+ 'what', 'how', 'why', 'when', 'which', 'these', 'those', 'as', 'if', 'then',
86
+ 'there', 'their', 'they', 'them', 'its', 'so', 'some', 'any', 'all', 'each',
87
+ 'every', 'both', 'other', 'very', 'just', 'only', 'more', 'most', 'less', 'too'
88
+ ]);
89
+
90
+ /**
91
+ * Map artifact path segments to category groups for stronger matching.
92
+ * @type {Map<string, string>}
93
+ */
94
+ const PATH_CATEGORY_MAP = new Map([
95
+ ['hook', 'Hooks & Enforcement'],
96
+ ['quorum', 'Quorum & Dispatch'],
97
+ ['dispatch', 'Quorum & Dispatch'],
98
+ ['install', 'Installer & CLI'],
99
+ ['mcp', 'MCP & Agents'],
100
+ ['agent', 'MCP & Agents'],
101
+ ['slot', 'MCP & Agents'],
102
+ ['formal', 'Formal Verification'],
103
+ ['alloy', 'Formal Verification'],
104
+ ['tla', 'Formal Verification'],
105
+ ['prism', 'Formal Verification'],
106
+ ['config', 'Configuration'],
107
+ ['plan', 'Planning & Tracking'],
108
+ ['state', 'Planning & Tracking'],
109
+ ['test', 'Testing & Quality'],
110
+ ['observe', 'Observability & Diagnostics'],
111
+ ['telemetry', 'Observability & Diagnostics'],
112
+ ['scoreboard', 'Observability & Diagnostics']
113
+ ]);
114
+
115
+ /**
116
+ * matchRequirementsByKeywords — filters requirements based on keywords from question and artifact path.
117
+ * Returns max 20 matching requirements, sorted by relevance score (descending).
118
+ *
119
+ * @param {Array} requirements — full requirements array
120
+ * @param {string} question — the question text
121
+ * @param {string|null} artifactPath — optional artifact path (e.g. "hooks/qgsd-stop.js")
122
+ * @returns {Array} — filtered requirements (max 20), sorted by score descending
123
+ */
124
+ function matchRequirementsByKeywords(requirements, question, artifactPath) {
125
+ if (!requirements || requirements.length === 0) {
126
+ return [];
127
+ }
128
+
129
+ // Extract keywords from question
130
+ const questionKeywords = extractKeywords(question);
131
+
132
+ // Extract keywords from artifact path
133
+ const pathKeywords = artifactPath ? extractPathKeywords(artifactPath) : new Set();
134
+ const pathCategories = artifactPath ? extractPathCategories(artifactPath) : [];
135
+
136
+ // Score each requirement
137
+ const scored = requirements.map(req => {
138
+ let score = 0;
139
+
140
+ // Match on id prefix (e.g. "DISP" from "DISP-01")
141
+ const idPrefix = req.id ? req.id.split('-')[0].toLowerCase() : '';
142
+ if (idPrefix && (questionKeywords.has(idPrefix) || pathKeywords.has(idPrefix))) {
143
+ score += 2;
144
+ }
145
+
146
+ // Match on category_raw
147
+ if (req.category_raw) {
148
+ const catRaw = req.category_raw.toLowerCase();
149
+ for (const kw of questionKeywords) {
150
+ if (catRaw.includes(kw)) score += 1;
151
+ }
152
+ }
153
+
154
+ // Match on category (group)
155
+ if (req.category) {
156
+ const cat = req.category.toLowerCase();
157
+ for (const kw of questionKeywords) {
158
+ if (cat.includes(kw)) score += 1;
159
+ }
160
+ // Boost if category matches path-derived categories
161
+ for (const pathCat of pathCategories) {
162
+ if (cat === pathCat.toLowerCase()) {
163
+ score += 3; // Strong signal from artifact path
164
+ }
165
+ }
166
+ }
167
+
168
+ // Match on text
169
+ if (req.text) {
170
+ const text = req.text.toLowerCase();
171
+ for (const kw of questionKeywords) {
172
+ if (text.includes(kw)) score += 1;
173
+ }
174
+ for (const kw of pathKeywords) {
175
+ if (text.includes(kw)) score += 1;
176
+ }
177
+ }
178
+
179
+ return { req, score };
180
+ });
181
+
182
+ // Sort by score descending, filter out zero-score entries, cap at 20
183
+ return scored
184
+ .filter(({ score }) => score > 0)
185
+ .sort((a, b) => b.score - a.score)
186
+ .slice(0, 20)
187
+ .map(({ req }) => req);
188
+ }
189
+
190
+ /**
191
+ * extractKeywords — splits text into tokens and filters out stopwords.
192
+ * Splits on spaces, slashes, hyphens, dots, underscores.
193
+ *
194
+ * @param {string} text
195
+ * @returns {Set<string>} — set of meaningful lowercase tokens
196
+ */
197
+ function extractKeywords(text) {
198
+ if (!text) return new Set();
199
+
200
+ // Split on common delimiters: space, /, -, ., _
201
+ const tokens = text.toLowerCase()
202
+ .split(/[\s\/\-\._]+/)
203
+ .filter(t => t.length > 0 && !STOPWORDS.has(t));
204
+
205
+ return new Set(tokens);
206
+ }
207
+
208
+ /**
209
+ * extractPathKeywords — extracts tokens from artifact path (just filename parts).
210
+ *
211
+ * @param {string} artifactPath
212
+ * @returns {Set<string>} — meaningful tokens from path
213
+ */
214
+ function extractPathKeywords(artifactPath) {
215
+ if (!artifactPath) return new Set();
216
+
217
+ // Extract just the filename part
218
+ const filename = path.basename(artifactPath);
219
+ const tokens = filename.toLowerCase()
220
+ .split(/[\s\/\-\._]+/)
221
+ .filter(t => t.length > 0 && !STOPWORDS.has(t));
222
+
223
+ return new Set(tokens);
224
+ }
225
+
226
+ /**
227
+ * extractPathCategories — maps artifact path segments to category groups.
228
+ * Checks each segment of the path against PATH_CATEGORY_MAP.
229
+ *
230
+ * @param {string} artifactPath
231
+ * @returns {Array<string>} — matching category groups
232
+ */
233
+ function extractPathCategories(artifactPath) {
234
+ if (!artifactPath) return [];
235
+
236
+ const categories = new Set();
237
+ const segments = artifactPath.toLowerCase().split(/[\s\/\-\._]+/);
238
+
239
+ for (const segment of segments) {
240
+ if (PATH_CATEGORY_MAP.has(segment)) {
241
+ categories.add(PATH_CATEGORY_MAP.get(segment));
242
+ }
243
+ }
244
+
245
+ return Array.from(categories);
246
+ }
247
+
248
+ /**
249
+ * formatRequirementsSection — formats an array of requirements into a text block.
250
+ * Returns null if the array is empty (no section should be injected).
251
+ *
252
+ * @param {Array} requirements — array of requirement objects
253
+ * @returns {string|null} — formatted section or null
254
+ */
255
+ function formatRequirementsSection(requirements) {
256
+ if (!requirements || requirements.length === 0) {
257
+ return null;
258
+ }
259
+
260
+ const lines = [];
261
+ lines.push('=== APPLICABLE REQUIREMENTS ===');
262
+ lines.push('The following project requirements are relevant to this review.');
263
+ lines.push('Consider whether the proposed change satisfies or violates these:');
264
+ lines.push('');
265
+
266
+ for (const req of requirements) {
267
+ const category = req.category || 'Unknown';
268
+ lines.push(`- [${req.id}] ${req.text} (${category})`);
269
+ }
270
+
271
+ lines.push('');
272
+ lines.push('================================');
273
+
274
+ return lines.join('\n');
275
+ }
276
+
277
+ // ─── Pure prompt-construction functions ──────────────────────────────────────
278
+
279
+ /**
280
+ * buildModeAPrompt — constructs the Mode A question prompt.
281
+ *
282
+ * Matches the EXACT template from agents/qgsd-quorum-slot-worker.md Step 2 Mode A.
283
+ *
284
+ * @param {object} opts
285
+ * @param {number} opts.round
286
+ * @param {string} opts.repoDir
287
+ * @param {string} opts.question
288
+ * @param {string} [opts.artifactPath]
289
+ * @param {string} [opts.artifactContent] - pre-read content (avoids model read failures)
290
+ * @param {string} [opts.reviewContext]
291
+ * @param {string} [opts.priorPositions] - Round 2+ cross-pollination
292
+ * @param {boolean}[opts.requestImprovements]
293
+ * @param {Array} [opts.requirements] - array of requirement objects to inject
294
+ * @returns {string}
295
+ */
296
+ function buildModeAPrompt({ round, repoDir, question, artifactPath, artifactContent, reviewContext, priorPositions, requestImprovements, requirements }) {
297
+ const lines = [];
298
+
299
+ // Header
300
+ lines.push(`QGSD Quorum — Round ${round}`);
301
+ lines.push('');
302
+
303
+ // Repository + question
304
+ lines.push(`Repository: ${repoDir}`);
305
+ lines.push('');
306
+ lines.push(`Question: ${question}`);
307
+
308
+ // Artifact section (conditional)
309
+ if (artifactPath) {
310
+ lines.push('');
311
+ lines.push('=== Artifact ===');
312
+ lines.push(`Path: ${artifactPath}`);
313
+ if (artifactContent) {
314
+ lines.push('Content:');
315
+ lines.push(artifactContent);
316
+ } else {
317
+ lines.push('(Read this file to obtain its full content before evaluating.)');
318
+ }
319
+ lines.push('================');
320
+ }
321
+
322
+ // Requirements section (conditional — injected right after question/artifact, before review context)
323
+ if (requirements && requirements.length > 0) {
324
+ const reqSection = formatRequirementsSection(requirements);
325
+ if (reqSection) {
326
+ lines.push('');
327
+ lines.push(reqSection);
328
+ }
329
+ }
330
+
331
+ // Review context (conditional — first occurrence)
332
+ if (reviewContext) {
333
+ lines.push('');
334
+ lines.push(`\u26a0 REVIEW CONTEXT: ${reviewContext}`);
335
+ }
336
+
337
+ if (round >= 2 && priorPositions) {
338
+ // ── Round 2+ path ─────────────────────────────────────────────────────
339
+ lines.push('');
340
+ lines.push('The following positions are from other AI models in this quorum — not human experts.');
341
+ lines.push('Evaluate them as peer AI opinions.');
342
+ lines.push('');
343
+ lines.push('Prior positions:');
344
+ lines.push(priorPositions);
345
+
346
+ // Review context reminder (Round 2+ only, when reviewContext present)
347
+ if (reviewContext) {
348
+ lines.push('');
349
+ lines.push(`\u26a0 REVIEW CONTEXT REMINDER: ${reviewContext}`);
350
+ lines.push('(If any prior position applied evaluation criteria inconsistent with the above — e.g.');
351
+ lines.push('rejected a plan because code was absent, or approved test results without checking');
352
+ lines.push('assertions — reconsider your position in light of the correct evaluation criteria.)');
353
+ }
354
+
355
+ lines.push('');
356
+ if (artifactContent) {
357
+ lines.push('Before revising your position, re-review the artifact content provided above and');
358
+ lines.push('use your tools to check any other relevant files if needed.');
359
+ } else {
360
+ lines.push('Before revising your position, use your tools to re-check relevant files. At minimum');
361
+ lines.push('re-read CLAUDE.md and .planning/STATE.md if they exist, and re-read the artifact file if');
362
+ lines.push('one was provided.');
363
+ }
364
+ lines.push('');
365
+ lines.push('Given the above, do you maintain your answer or revise it? State your updated position');
366
+ lines.push('clearly (2\u20134 sentences).');
367
+
368
+ // Improvements block (Round 2+, when requestImprovements)
369
+ if (requestImprovements) {
370
+ lines.push('If you APPROVE and have specific, actionable improvements, append:');
371
+ lines.push('');
372
+ lines.push('Improvements:');
373
+ lines.push('- suggestion: [concise change \u2014 one sentence]');
374
+ lines.push(' rationale: [why this strengthens the plan]');
375
+ lines.push('');
376
+ lines.push('Omit this section entirely if you have no improvements, or if you BLOCK.');
377
+ }
378
+
379
+ lines.push('');
380
+ lines.push('If your re-check references specific files, line numbers, or code snippets, record');
381
+ lines.push('them in a citations: field in your response (optional).');
382
+
383
+ } else {
384
+ // ── Round 1 path ──────────────────────────────────────────────────────
385
+ lines.push('');
386
+ if (artifactContent) {
387
+ lines.push('The artifact content is provided above. Use your available tools to read any other');
388
+ lines.push('relevant files from the Repository directory if needed. Your answer must be grounded');
389
+ lines.push('in the artifact content and what you actually find in the repo.');
390
+ } else {
391
+ lines.push('IMPORTANT: Before answering, use your available tools to read files from the');
392
+ lines.push('Repository directory above. At minimum read: CLAUDE.md (if it exists),');
393
+ lines.push('.planning/STATE.md (if it exists), and the artifact file at the path shown in the');
394
+ lines.push('Artifact section above (if present). Then read any other files directly relevant to');
395
+ lines.push('the question. Your answer must be grounded in what you actually find in the repo.');
396
+ }
397
+ lines.push('');
398
+ lines.push('You are one AI model in a multi-model quorum. Your peer reviewers are other AI language');
399
+ lines.push('models \u2014 not human experts. Give your honest answer with reasoning. Be concise (3\u20136');
400
+ lines.push('sentences). Do not defer to peer models.');
401
+
402
+ // Improvements block (Round 1, when requestImprovements)
403
+ if (requestImprovements) {
404
+ lines.push('If you APPROVE and have specific, actionable improvements, append:');
405
+ lines.push('');
406
+ lines.push('Improvements:');
407
+ lines.push('- suggestion: [concise change \u2014 one sentence]');
408
+ lines.push(' rationale: [why this strengthens the plan]');
409
+ lines.push('');
410
+ lines.push('Omit this section entirely if you have no improvements, or if you BLOCK.');
411
+ }
412
+
413
+ lines.push('');
414
+ lines.push('If your answer references specific files, line numbers, or code snippets from the');
415
+ lines.push('repository, record them in a citations: field in your response (optional \u2014 only');
416
+ lines.push('include if you actually cite code).');
417
+ }
418
+
419
+ return lines.join('\n');
420
+ }
421
+
422
+ /**
423
+ * buildModeBPrompt — constructs the Mode B execution review prompt.
424
+ *
425
+ * Matches the EXACT template from agents/qgsd-quorum-slot-worker.md Step 2 Mode B.
426
+ *
427
+ * @param {object} opts
428
+ * @param {number} opts.round
429
+ * @param {string} opts.repoDir
430
+ * @param {string} opts.question
431
+ * @param {string} opts.traces - execution trace output (required for Mode B)
432
+ * @param {string} [opts.artifactPath]
433
+ * @param {string} [opts.artifactContent] - pre-read content (avoids model read failures)
434
+ * @param {string} [opts.reviewContext]
435
+ * @param {string} [opts.priorPositions] - Round 2+
436
+ * @param {Array} [opts.requirements] - array of requirement objects to inject
437
+ * @returns {string}
438
+ */
439
+ function buildModeBPrompt({ round, repoDir, question, traces, artifactPath, artifactContent, reviewContext, priorPositions, requirements }) {
440
+ const lines = [];
441
+
442
+ // Header
443
+ lines.push(`QGSD Quorum — Execution Review (Round ${round})`);
444
+ lines.push('');
445
+
446
+ // Repository + question
447
+ lines.push(`Repository: ${repoDir}`);
448
+ lines.push('');
449
+ lines.push(`QUESTION: ${question}`);
450
+
451
+ // Artifact section (conditional)
452
+ if (artifactPath) {
453
+ lines.push('');
454
+ lines.push('=== Artifact ===');
455
+ lines.push(`Path: ${artifactPath}`);
456
+ if (artifactContent) {
457
+ lines.push('Content:');
458
+ lines.push(artifactContent);
459
+ } else {
460
+ lines.push('(Read this file to obtain its full content before evaluating.)');
461
+ }
462
+ lines.push('================');
463
+ }
464
+
465
+ // Requirements section (conditional — injected right after question/artifact, before review context)
466
+ if (requirements && requirements.length > 0) {
467
+ const reqSection = formatRequirementsSection(requirements);
468
+ if (reqSection) {
469
+ lines.push('');
470
+ lines.push(reqSection);
471
+ }
472
+ }
473
+
474
+ // Review context (conditional — first occurrence)
475
+ if (reviewContext) {
476
+ lines.push('');
477
+ lines.push(`\u26a0 REVIEW CONTEXT: ${reviewContext}`);
478
+ }
479
+
480
+ // Execution traces (always present in Mode B)
481
+ lines.push('');
482
+ lines.push('=== EXECUTION TRACES ===');
483
+ lines.push(traces || '');
484
+
485
+ // Prior positions (Round 2+)
486
+ if (round >= 2 && priorPositions) {
487
+ lines.push('');
488
+ lines.push('Prior positions:');
489
+ lines.push(priorPositions);
490
+
491
+ // Review context reminder (Round 2+ only, when reviewContext present)
492
+ if (reviewContext) {
493
+ lines.push('');
494
+ lines.push(`\u26a0 REVIEW CONTEXT REMINDER: ${reviewContext}`);
495
+ lines.push('(If any prior position applied incorrect evaluation criteria, reconsider in light of the above.)');
496
+ }
497
+ }
498
+
499
+ lines.push('');
500
+ if (artifactContent) {
501
+ lines.push('The artifact content is provided above. Use your tools to read any other relevant files');
502
+ lines.push('from the Repository directory if needed.');
503
+ } else {
504
+ lines.push('Before giving your verdict, use your tools to read files from the Repository directory');
505
+ lines.push('above. At minimum read: CLAUDE.md (if it exists), .planning/STATE.md (if it exists), and');
506
+ lines.push('the artifact file at the path shown above (if present).');
507
+ }
508
+ lines.push('');
509
+ lines.push('Note: prior positions are opinions from other AI models \u2014 not human specialists.');
510
+ lines.push('');
511
+ lines.push('Review the execution traces above. Give:');
512
+ lines.push('');
513
+ lines.push('verdict: APPROVE | REJECT | FLAG');
514
+ lines.push('reasoning: [2\u20134 sentences grounded in the actual trace output \u2014 not assumptions]');
515
+ lines.push('');
516
+ lines.push('APPROVE if output clearly shows the question is satisfied.');
517
+ lines.push('REJECT if output shows it is NOT satisfied.');
518
+ lines.push('FLAG if output is ambiguous or requires human judgment.');
519
+ lines.push('If your verdict references specific lines from the execution traces or files, record');
520
+ lines.push('them in a citations: field (optional \u2014 only when you directly cite output lines or');
521
+ lines.push('file content).');
522
+
523
+ return lines.join('\n');
524
+ }
525
+
526
+ // ─── Output parsing functions ─────────────────────────────────────────────────
527
+
528
+ /**
529
+ * parseVerdict — extracts verdict from raw CLI output.
530
+ *
531
+ * Mode A: first 500 chars of rawOutput (free-form position summary)
532
+ * Mode B (default): extract APPROVE|REJECT|FLAG from "verdict:" line; default FLAG
533
+ *
534
+ * @param {string} rawOutput
535
+ * @param {string} [mode] 'A' or 'B' (default B)
536
+ * @returns {string}
537
+ */
538
+ function parseVerdict(rawOutput, mode) {
539
+ if (mode === 'A') {
540
+ return (rawOutput || '').slice(0, 500);
541
+ }
542
+ // Mode B: extract APPROVE|REJECT|FLAG
543
+ const match = (rawOutput || '').match(/verdict:\s*(APPROVE|REJECT|FLAG)/i);
544
+ return match ? match[1].toUpperCase() : 'FLAG';
545
+ }
546
+
547
+ /**
548
+ * parseReasoning — extracts reasoning from "reasoning: ..." line.
549
+ *
550
+ * @param {string} rawOutput
551
+ * @returns {string|null}
552
+ */
553
+ function parseReasoning(rawOutput) {
554
+ if (!rawOutput) return null;
555
+ const match = rawOutput.match(/^reasoning:\s*(.+)$/m);
556
+ if (match) return match[1].trim();
557
+ // Fallback: first 400 chars
558
+ return null;
559
+ }
560
+
561
+ /**
562
+ * parseCitations — extracts citations block from "citations: |" section.
563
+ *
564
+ * Handles both space-indented and tab-indented YAML block scalar content.
565
+ *
566
+ * @param {string} rawOutput
567
+ * @returns {string|null}
568
+ */
569
+ function parseCitations(rawOutput) {
570
+ if (!rawOutput) return null;
571
+
572
+ const lines = rawOutput.split('\n');
573
+ let inCitations = false;
574
+ const citationLines = [];
575
+
576
+ for (let i = 0; i < lines.length; i++) {
577
+ const line = lines[i];
578
+ const trimmed = line.trim();
579
+
580
+ if (!inCitations) {
581
+ // Detect "citations: |" or "citations:" line
582
+ if (/^citations:\s*\|?\s*$/.test(trimmed)) {
583
+ inCitations = true;
584
+ continue;
585
+ }
586
+ } else {
587
+ // Indented continuation (space or tab)
588
+ if (line.startsWith(' ') || line.startsWith('\t')) {
589
+ citationLines.push(trimmed);
590
+ } else if (trimmed === '') {
591
+ // blank line inside block — keep
592
+ citationLines.push('');
593
+ } else {
594
+ // Non-indented non-empty line — end of block
595
+ break;
596
+ }
597
+ }
598
+ }
599
+
600
+ if (citationLines.length === 0) return null;
601
+
602
+ // Remove trailing empty lines
603
+ while (citationLines.length > 0 && citationLines[citationLines.length - 1] === '') {
604
+ citationLines.pop();
605
+ }
606
+
607
+ return citationLines.length > 0 ? citationLines.join('\n') : null;
608
+ }
609
+
610
+ /**
611
+ * stripQuotes — strips surrounding single or double quotes from a string.
612
+ * @param {string} s
613
+ * @returns {string}
614
+ */
615
+ function stripQuotes(s) {
616
+ if (!s) return s;
617
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
618
+ return s.slice(1, -1);
619
+ }
620
+ return s;
621
+ }
622
+
623
+ /**
624
+ * parseImprovements — scans rawOutput for "Improvements:" section, parses list entries.
625
+ *
626
+ * Migrated from bin/gsd-quorum-slot-worker-improvements.test.cjs (canonical location).
627
+ * Never throws — improvements are additive, not required.
628
+ *
629
+ * @param {string} rawOutput
630
+ * @returns {Array<{suggestion: string, rationale: string}>}
631
+ */
632
+ function parseImprovements(rawOutput) {
633
+ if (!rawOutput || typeof rawOutput !== 'string') return [];
634
+
635
+ const lines = rawOutput.split('\n');
636
+ let inSection = false;
637
+ const results = [];
638
+ let currentEntry = null;
639
+
640
+ for (let i = 0; i < lines.length; i++) {
641
+ const line = lines[i];
642
+
643
+ // Detect section start
644
+ if (!inSection && line.trimStart().startsWith('Improvements:')) {
645
+ inSection = true;
646
+ continue;
647
+ }
648
+
649
+ if (!inSection) continue;
650
+
651
+ // Detect section end: non-indented non-empty line that isn't a list item
652
+ const trimmed = line.trim();
653
+ if (trimmed === '') continue; // blank lines: skip, stay in section
654
+
655
+ // Check if this is a new top-level key (non-indented, non-list-item) — section ends
656
+ if (!line.startsWith(' ') && !line.startsWith('\t') && !trimmed.startsWith('-') && trimmed !== '') {
657
+ // End of improvements section
658
+ if (currentEntry && currentEntry.suggestion && currentEntry.rationale) {
659
+ results.push({ suggestion: currentEntry.suggestion, rationale: currentEntry.rationale });
660
+ }
661
+ currentEntry = null;
662
+ inSection = false;
663
+ continue;
664
+ }
665
+
666
+ // Match `- suggestion:` line — starts a new entry
667
+ const suggestionMatch = trimmed.match(/^-\s+suggestion:\s*(.*)$/);
668
+ if (suggestionMatch) {
669
+ // Save previous entry if complete
670
+ if (currentEntry && currentEntry.suggestion && currentEntry.rationale) {
671
+ results.push({ suggestion: currentEntry.suggestion, rationale: currentEntry.rationale });
672
+ }
673
+ const val = stripQuotes(suggestionMatch[1].trim());
674
+ currentEntry = { suggestion: val, rationale: null };
675
+ continue;
676
+ }
677
+
678
+ // Match `rationale:` line (indented continuation)
679
+ const rationaleMatch = trimmed.match(/^rationale:\s*(.*)$/);
680
+ if (rationaleMatch && currentEntry) {
681
+ const val = stripQuotes(rationaleMatch[1].trim());
682
+ currentEntry.rationale = val;
683
+ continue;
684
+ }
685
+ }
686
+
687
+ // Flush last entry
688
+ if (currentEntry && currentEntry.suggestion && currentEntry.rationale) {
689
+ results.push({ suggestion: currentEntry.suggestion, rationale: currentEntry.rationale });
690
+ }
691
+
692
+ return results;
693
+ }
694
+
695
+ /**
696
+ * emitResultBlock — produces the YAML-formatted result block matching the agent spec Step 4.
697
+ *
698
+ * Returns a string (does NOT write to stdout — main() handles that).
699
+ *
700
+ * @param {object} opts
701
+ * @param {string} opts.slot
702
+ * @param {number} opts.round
703
+ * @param {string} opts.verdict
704
+ * @param {string} opts.reasoning
705
+ * @param {string} [opts.citations]
706
+ * @param {Array} [opts.improvements]
707
+ * @param {string} [opts.rawOutput]
708
+ * @param {boolean}[opts.isUnavail]
709
+ * @param {string} [opts.unavailMessage]
710
+ * @returns {string}
711
+ */
712
+ function emitResultBlock({ slot, round, verdict, reasoning, citations, improvements, rawOutput, isUnavail, unavailMessage }) {
713
+ const lines = [];
714
+
715
+ lines.push(`slot: ${slot}`);
716
+ lines.push(`round: ${round}`);
717
+ lines.push(`verdict: ${verdict}`);
718
+
719
+ if (reasoning) {
720
+ lines.push(`reasoning: ${reasoning}`);
721
+ } else {
722
+ lines.push('reasoning:');
723
+ }
724
+
725
+ if (citations) {
726
+ lines.push('citations: |');
727
+ const citLines = citations.split('\n');
728
+ for (const cl of citLines) {
729
+ lines.push(` ${cl}`);
730
+ }
731
+ }
732
+
733
+ if (improvements && improvements.length > 0) {
734
+ lines.push('improvements:');
735
+ for (const imp of improvements) {
736
+ lines.push(` - suggestion: "${imp.suggestion}"`);
737
+ lines.push(` rationale: "${imp.rationale}"`);
738
+ }
739
+ }
740
+
741
+ if (isUnavail && unavailMessage) {
742
+ lines.push('unavail_message: |');
743
+ const msgLines = unavailMessage.slice(0, 500).split('\n');
744
+ for (const ml of msgLines) {
745
+ lines.push(` ${ml}`);
746
+ }
747
+ }
748
+
749
+ lines.push('raw: |');
750
+ const rawTruncated = (rawOutput || '').slice(0, 5000);
751
+ const rawLines = rawTruncated.split('\n');
752
+ for (const rl of rawLines) {
753
+ lines.push(` ${rl}`);
754
+ }
755
+
756
+ return lines.join('\n') + '\n';
757
+ }
758
+
759
+ // ─── Main (CLI entry point) ───────────────────────────────────────────────────
760
+
761
+ async function main() {
762
+ const slot = getArg('--slot');
763
+ const mode = getArg('--mode') || 'A';
764
+ const roundArg = getArg('--round');
765
+ const question = getArg('--question') || '';
766
+ const artifactPath = getArg('--artifact-path') || null;
767
+ const reviewContext = getArg('--review-context') || null;
768
+ const priorPositionsFile = getArg('--prior-positions-file') || null;
769
+ const tracesFile = getArg('--traces-file') || null;
770
+ const requestImprovements = hasFlag('--request-improvements');
771
+ const timeoutArg = getArg('--timeout');
772
+ const cwd = getArg('--cwd') || process.cwd();
773
+
774
+ if (!slot) {
775
+ process.stderr.write('[quorum-slot-dispatch] --slot is required\n');
776
+ process.exit(1);
777
+ }
778
+ if (!roundArg) {
779
+ process.stderr.write('[quorum-slot-dispatch] --round is required\n');
780
+ process.exit(1);
781
+ }
782
+
783
+ const round = parseInt(roundArg, 10);
784
+ const timeout = timeoutArg ? parseInt(timeoutArg, 10) : 30000;
785
+
786
+ // Read optional temp files
787
+ let priorPositions = null;
788
+ if (priorPositionsFile) {
789
+ try {
790
+ priorPositions = fs.readFileSync(priorPositionsFile, 'utf8');
791
+ } catch (e) {
792
+ process.stderr.write(`[quorum-slot-dispatch] Could not read prior-positions-file: ${e.message}\n`);
793
+ }
794
+ }
795
+
796
+ let traces = null;
797
+ if (tracesFile) {
798
+ try {
799
+ traces = fs.readFileSync(tracesFile, 'utf8');
800
+ } catch (e) {
801
+ process.stderr.write(`[quorum-slot-dispatch] Could not read traces-file: ${e.message}\n`);
802
+ }
803
+ }
804
+
805
+ // Build prompt
806
+ const repoDir = cwd;
807
+
808
+ // Pre-read artifact file content to inline in prompt (prevents read failures in models)
809
+ const ARTIFACT_MAX_BYTES = 50 * 1024; // 50KB cap
810
+ let artifactContent = null;
811
+ if (artifactPath) {
812
+ try {
813
+ const resolvedPath = path.isAbsolute(artifactPath) ? artifactPath : path.join(cwd, artifactPath);
814
+ const stat = fs.statSync(resolvedPath);
815
+ if (stat.size <= ARTIFACT_MAX_BYTES) {
816
+ artifactContent = fs.readFileSync(resolvedPath, 'utf8');
817
+ } else {
818
+ process.stderr.write(`[quorum-slot-dispatch] artifact too large (${stat.size} bytes > ${ARTIFACT_MAX_BYTES}), models must read it themselves\n`);
819
+ }
820
+ } catch (e) {
821
+ process.stderr.write(`[quorum-slot-dispatch] could not pre-read artifact: ${e.message}\n`);
822
+ }
823
+ }
824
+
825
+ // Load and match requirements (fail-open: if loading fails, requirements will be empty)
826
+ const allRequirements = loadRequirements(repoDir);
827
+ const matchedRequirements = matchRequirementsByKeywords(allRequirements, question, artifactPath);
828
+
829
+ let prompt;
830
+ if (mode === 'B') {
831
+ prompt = buildModeBPrompt({ round, repoDir, question, artifactPath, artifactContent, reviewContext, priorPositions, traces: traces || '', requirements: matchedRequirements });
832
+ } else {
833
+ prompt = buildModeAPrompt({ round, repoDir, question, artifactPath, artifactContent, reviewContext, priorPositions, requestImprovements, requirements: matchedRequirements });
834
+ }
835
+
836
+ // Locate call-quorum-slot.cjs relative to this script
837
+ const cqsPath = path.join(__dirname, 'call-quorum-slot.cjs');
838
+
839
+ // Spawn call-quorum-slot.cjs as child process with stdin pipe
840
+ const rawOutput = await new Promise((resolve) => {
841
+ let child;
842
+ try {
843
+ child = spawn(process.execPath, [cqsPath, '--slot', slot, '--timeout', String(timeout), '--cwd', cwd], {
844
+ stdio: ['pipe', 'pipe', 'pipe']
845
+ });
846
+ } catch (err) {
847
+ resolve({ exitCode: 1, output: `[spawn error: ${err.message}]` });
848
+ return;
849
+ }
850
+
851
+ // Write prompt to child stdin and close
852
+ child.stdin.write(prompt, 'utf8');
853
+ child.stdin.end();
854
+
855
+ let stdout = '';
856
+ let stderr = '';
857
+ const MAX_BUF = 10 * 1024 * 1024;
858
+
859
+ child.stdout.on('data', d => {
860
+ if (stdout.length < MAX_BUF) stdout += d.toString().slice(0, MAX_BUF - stdout.length);
861
+ });
862
+ child.stderr.on('data', d => {
863
+ stderr += d.toString().slice(0, 4096);
864
+ });
865
+
866
+ child.on('close', (code) => {
867
+ resolve({ exitCode: code, output: stdout || stderr || '(no output)' });
868
+ });
869
+
870
+ child.on('error', (err) => {
871
+ resolve({ exitCode: 1, output: `[spawn error: ${err.message}]` });
872
+ });
873
+ });
874
+
875
+ const { exitCode, output } = rawOutput;
876
+ const isUnavail = exitCode !== 0 || output.includes('TIMEOUT');
877
+
878
+ let result;
879
+ if (isUnavail) {
880
+ result = emitResultBlock({
881
+ slot,
882
+ round,
883
+ verdict: 'UNAVAIL',
884
+ reasoning: 'Bash call failed or timed out.',
885
+ rawOutput: output,
886
+ isUnavail: true,
887
+ unavailMessage: output.slice(0, 500)
888
+ });
889
+ } else {
890
+ const verdict = parseVerdict(output, mode);
891
+ const reasoning = parseReasoning(output) || output.slice(0, 400);
892
+ const citations = parseCitations(output);
893
+ const improvements = requestImprovements ? parseImprovements(output) : [];
894
+
895
+ result = emitResultBlock({
896
+ slot,
897
+ round,
898
+ verdict,
899
+ reasoning,
900
+ citations,
901
+ improvements: improvements.length > 0 ? improvements : undefined,
902
+ rawOutput: output
903
+ });
904
+ }
905
+
906
+ process.stdout.write(result);
907
+ if (!result.endsWith('\n')) process.stdout.write('\n');
908
+ process.exit(0);
909
+ }
910
+
911
+ // ─── Module exports ───────────────────────────────────────────────────────────
912
+ if (typeof module !== 'undefined') {
913
+ module.exports = {
914
+ buildModeAPrompt,
915
+ buildModeBPrompt,
916
+ parseVerdict,
917
+ parseReasoning,
918
+ parseCitations,
919
+ parseImprovements,
920
+ emitResultBlock,
921
+ stripQuotes,
922
+ loadRequirements,
923
+ matchRequirementsByKeywords,
924
+ formatRequirementsSection,
925
+ };
926
+ }
927
+
928
+ // ─── Entry point guard ────────────────────────────────────────────────────────
929
+ if (require.main === module) {
930
+ main().catch(err => {
931
+ process.stderr.write(`[quorum-slot-dispatch] Fatal: ${err.message}\n`);
932
+ process.exit(1);
933
+ });
934
+ }