@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,653 @@
1
+ #!/usr/bin/env node
2
+ // hooks/qgsd-prompt.js
3
+ // UserPromptSubmit hook — three responsibilities:
4
+ //
5
+ // 1. CIRCUIT BREAKER RECOVERY: If the circuit breaker is active, inject the
6
+ // oscillation-resolution-mode workflow into Claude's context so resolution
7
+ // starts automatically on the next user message.
8
+ //
9
+ // 2. PENDING TASK INJECTION: If a pending-task file exists, atomically claim it
10
+ // and inject it as a queued command (survives /clear). Session-scoped files
11
+ // take priority over the generic file to prevent cross-session delivery.
12
+ //
13
+ // 3. QUORUM INJECTION: If the prompt is a GSD planning command, inject quorum
14
+ // instructions so Claude runs multi-model review before presenting output.
15
+ //
16
+ // Output mechanism: hookSpecificOutput.additionalContext (NOT systemMessage)
17
+ // systemMessage only shows a UI warning; additionalContext goes into Claude's context.
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const { spawnSync } = require('child_process');
25
+ const { loadConfig, slotToToolCall } = require('./config-loader');
26
+ const { schema_version } = require('./conformance-schema.cjs');
27
+
28
+ const DEFAULT_QUORUM_INSTRUCTIONS_FALLBACK = `QUORUM REQUIRED (structural enforcement — Stop hook will verify)
29
+
30
+ Run the full R3 quorum protocol inline (dispatch_pattern from commands/qgsd/quorum.md):
31
+
32
+ 1. State Claude's own position (vote) first — APPROVE or BLOCK with 1-2 sentence rationale
33
+ 2. Run provider pre-flight: node ~/.claude/qgsd-bin/check-provider-health.cjs --json
34
+ 3. Build $DISPATCH_LIST first (quorum.md Adaptive Fan-Out: read risk_level → compute FAN_OUT_COUNT → first FAN_OUT_COUNT-1 slots). Then dispatch $DISPATCH_LIST as sibling qgsd-quorum-slot-worker Tasks in one message turn — do NOT dispatch slots outside $DISPATCH_LIST:
35
+ Task(subagent_type="qgsd-quorum-slot-worker", prompt="slot: <slot>\\nround: 1\\n...")
36
+ 4. Synthesize results inline. Deliberate up to 10 rounds per R3.3 if no consensus.
37
+ 5. Update scoreboard: node ~/.claude/qgsd-bin/update-scoreboard.cjs merge-wave ...
38
+ 6. [HEAL-01] After each deliberation round's merge-wave, check early escalation:
39
+ node ~/.claude/qgsd-bin/quorum-consensus-gate.cjs --min-quorum=2 --remaining-rounds=R
40
+ (R = maxDeliberation - currentRound). Exit code 1 = stop deliberating, proceed to decision (early escalation — P(consensus|remaining) below threshold).
41
+ 7. Include the token <!-- GSD_DECISION --> in your FINAL output (only when delivering
42
+ the completed plan, research, verification report, or filtered question list)
43
+
44
+ Fail-open: if a model is UNAVAILABLE (quota/error), note it and proceed with available models.
45
+ Failover rule: if a slot returns an error or quota exceeded, skip it and continue with remaining active slots.
46
+ The Stop hook reads the transcript — skipping quorum will block your response.`;
47
+
48
+ // Appends a structured conformance event to .planning/conformance-events.jsonl.
49
+ // Uses appendFileSync (atomic for writes < POSIX PIPE_BUF = 4096 bytes).
50
+ // Always wrapped in try/catch — hooks are fail-open; never crashes on logging failure.
51
+ // NEVER writes to stdout — stdout is the Claude Code hook decision channel.
52
+ function appendConformanceEvent(event) {
53
+ try {
54
+ const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
55
+ const logPath = pp.resolve(process.cwd(), 'conformance-events');
56
+ fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
57
+ } catch (err) {
58
+ process.stderr.write('[qgsd] conformance log write failed: ' + err.message + '\n');
59
+ }
60
+ }
61
+
62
+ // Locate the oscillation-resolution-mode workflow.
63
+ // Tries global install path first (~/.claude/qgsd/), then local (.claude/qgsd/).
64
+ function findResolutionWorkflow(cwd) {
65
+ const candidates = [
66
+ path.join(os.homedir(), '.claude', 'qgsd', 'workflows', 'oscillation-resolution-mode.md'),
67
+ path.join(cwd, '.claude', 'qgsd', 'workflows', 'oscillation-resolution-mode.md'),
68
+ ];
69
+ for (const p of candidates) {
70
+ if (fs.existsSync(p)) return fs.readFileSync(p, 'utf8');
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // Atomically claim and read a pending-task file, if one exists.
76
+ // Checks session-scoped file first (.claude/pending-task-<sessionId>.txt) to prevent
77
+ // cross-session delivery, then falls back to the generic .claude/pending-task.txt.
78
+ // Uses fs.renameSync for atomic claiming — POSIX guarantees only one process wins.
79
+ // Returns the task string, or null if no pending task exists.
80
+ function consumePendingTask(cwd, sessionId) {
81
+ const claudeDir = path.join(cwd, '.claude');
82
+ if (!fs.existsSync(claudeDir)) return null;
83
+
84
+ const candidates = [];
85
+ if (sessionId) candidates.push(path.join(claudeDir, `pending-task-${sessionId}.txt`));
86
+ candidates.push(path.join(claudeDir, 'pending-task.txt'));
87
+
88
+ for (const pendingFile of candidates) {
89
+ if (!fs.existsSync(pendingFile)) continue;
90
+
91
+ const claimedFile = pendingFile + '.claimed';
92
+ try {
93
+ fs.renameSync(pendingFile, claimedFile); // atomic claim — only one session wins
94
+ } catch {
95
+ continue; // another session claimed it first, or file vanished — skip
96
+ }
97
+
98
+ try {
99
+ const task = fs.readFileSync(claimedFile, 'utf8').trim();
100
+ fs.unlinkSync(claimedFile);
101
+ if (task) return task;
102
+ } catch {
103
+ try { fs.unlinkSync(claimedFile); } catch {} // best-effort cleanup
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Parses --n N flag from a prompt string.
110
+ // Returns N (integer >= 1) if found, or null if absent/invalid.
111
+ function parseQuorumSizeFlag(prompt) {
112
+ const m = prompt.match(/--n\s+(\d+)/);
113
+ if (!m) return null;
114
+ const n = parseInt(m[1], 10);
115
+ return (Number.isInteger(n) && n >= 1) ? n : null;
116
+ }
117
+
118
+ // Maps task envelope risk_level to a fan-out count (total participants including Claude).
119
+ // Proportional to maxSize (n) using ceil(tier/3 * n):
120
+ // low/routine (tier 1) → ceil(n/3) — e.g. n=3→1, n=6→2
121
+ // medium (tier 2) → ceil(2n/3) — e.g. n=3→2, n=6→4
122
+ // high (tier 3) → n — full pool
123
+ // absent/invalid → n — fail-open: conservative
124
+ // Result is always in [1..maxSize].
125
+ function mapRiskLevelToCount(riskLevel, maxSize) {
126
+ const n = maxSize;
127
+ if (riskLevel === 'low' || riskLevel === 'routine') return Math.max(1, Math.ceil(n / 3));
128
+ if (riskLevel === 'medium') return Math.max(1, Math.ceil(2 * n / 3));
129
+ // 'high', undefined, null, invalid string → fail-open to maxSize
130
+ return n;
131
+ }
132
+
133
+ // Returns slot names that have timed out within the last ttlMinutes.
134
+ // Reads quorum-failures.json written by call-quorum-slot.cjs on every failure.
135
+ function getRecentlyTimedOutSlots(cwd, ttlMinutes = 30) {
136
+ try {
137
+ const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
138
+ const logPath = pp.resolveWithFallback(cwd, 'quorum-failures');
139
+ if (!fs.existsSync(logPath)) return [];
140
+ const records = JSON.parse(fs.readFileSync(logPath, 'utf8'));
141
+ if (!Array.isArray(records)) return [];
142
+ const cutoff = Date.now() - ttlMinutes * 60 * 1000;
143
+ return records
144
+ .filter(r => r.error_type === 'TIMEOUT' && new Date(r.last_seen).getTime() > cutoff)
145
+ .map(r => r.slot);
146
+ } catch (_) { return []; }
147
+ }
148
+
149
+ // Locate providers.json from multiple search paths (borrowed from call-quorum-slot.cjs).
150
+ function findProviders() {
151
+ const searchPaths = [
152
+ path.join(__dirname, '..', 'bin', 'providers.json'),
153
+ path.join(os.homedir(), '.claude', 'qgsd-bin', 'providers.json'),
154
+ ];
155
+ for (const p of searchPaths) {
156
+ try {
157
+ if (fs.existsSync(p)) {
158
+ return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
159
+ }
160
+ } catch (_) { /* try next */ }
161
+ }
162
+ return null;
163
+ }
164
+
165
+ // Triggers a fresh health probe before reading the cache.
166
+ // Runs check-provider-health.cjs with a 3s timeout via spawnSync.
167
+ // Fail-open: if spawn fails or times out, dispatch continues with stale/missing cache.
168
+ function triggerHealthProbe() {
169
+ try {
170
+ const searchPaths = [
171
+ path.join(__dirname, '..', 'bin', 'check-provider-health.cjs'),
172
+ path.join(os.homedir(), '.claude', 'qgsd-bin', 'check-provider-health.cjs'),
173
+ ];
174
+ let checkPath = null;
175
+ for (const p of searchPaths) {
176
+ if (fs.existsSync(p)) { checkPath = p; break; }
177
+ }
178
+ if (!checkPath) return; // no probe script found — fail-open
179
+ spawnSync('node', [checkPath, '--json'], { timeout: 3000, stdio: 'ignore' });
180
+ } catch (_) { /* fail-open: probe failure does not block dispatch */ }
181
+ }
182
+
183
+ // Filters slots by availability window from scoreboard.
184
+ // Reads .planning/quorum-scoreboard.json availability section.
185
+ // Slots whose available_at_iso is in the future are excluded (cooling down).
186
+ // Fail-open: if scoreboard missing, malformed, or any error, returns all slots unchanged.
187
+ function getAvailableSlots(slots, cwd) {
188
+ try {
189
+ const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
190
+ const sbPath = pp.resolveWithFallback(cwd, 'quorum-scoreboard');
191
+ if (!fs.existsSync(sbPath)) return slots;
192
+ const scoreboard = JSON.parse(fs.readFileSync(sbPath, 'utf8'));
193
+ if (!scoreboard || !scoreboard.availability) return slots;
194
+ const now = Date.now();
195
+ return slots.filter(s => {
196
+ const avail = scoreboard.availability[s.slot];
197
+ if (!avail || !avail.available_at_iso) return true;
198
+ try {
199
+ const ts = new Date(avail.available_at_iso).getTime();
200
+ if (isNaN(ts)) return true; // malformed date: fail-open
201
+ if (ts > now) {
202
+ console.error(`[qgsd-dispatch] AVAILABILITY EXCLUDE: ${s.slot} -- available_at_iso=${avail.available_at_iso} is in the future (now=${new Date().toISOString()})`);
203
+ return false;
204
+ }
205
+ return true;
206
+ } catch (_) { return true; }
207
+ });
208
+ } catch (_) { return slots; } // fail-open: any error → return all slots
209
+ }
210
+
211
+ // Sorts slots by flakiness (primary) then success rate (secondary) from scoreboard slot stats.
212
+ // Reads .planning/quorum-scoreboard.json slots section.
213
+ // Scoreboard keys are composite: "slotName:modelId" (e.g., "claude-1:deepseek-ai/DeepSeek-V3.2").
214
+ // Extract slot name: const slotName = key.split(':')[0];
215
+ // Example: const allModelsForSlot = Object.entries(scoreboard.slots)
216
+ // .filter(([k]) => k.startsWith(slotName + ':'))
217
+ // .map(([_, v]) => v);
218
+ // Then sum their tp/fn values.
219
+ // Fail-open: if scoreboard missing or any error, returns slots in original order.
220
+ function sortBySuccessRate(slots, cwd) {
221
+ try {
222
+ const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
223
+ const sbPath = pp.resolveWithFallback(cwd, 'quorum-scoreboard');
224
+ if (!fs.existsSync(sbPath)) return [...slots];
225
+ const scoreboard = JSON.parse(fs.readFileSync(sbPath, 'utf8'));
226
+ if (!scoreboard || !scoreboard.slots) return [...slots];
227
+
228
+ // Read flakiness score for a slot — primary sort key
229
+ const getFlakiness = (slotName) => {
230
+ // Look up flakiness_score from scoreboard slots entries for this slot name
231
+ const entries = Object.entries(scoreboard.slots)
232
+ .filter(([k]) => k === slotName || k.startsWith(slotName + ':'));
233
+ if (entries.length === 0) return 0; // fail-open: unknown = reliable
234
+ // Use the max flakiness across any model for this slot
235
+ return Math.max(...entries.map(([_, v]) => v.flakiness_score ?? 0));
236
+ };
237
+
238
+ // Aggregate tp/fn across all model entries for a given slot name — secondary sort key
239
+ const getRate = (slotName) => {
240
+ const entries = Object.entries(scoreboard.slots)
241
+ .filter(([k]) => k.split(':')[0] === slotName)
242
+ .map(([_, v]) => v);
243
+ if (entries.length === 0) return 0.5; // default for unknown
244
+ const totalTp = entries.reduce((sum, e) => sum + (e.tp || 0), 0);
245
+ const totalFn = entries.reduce((sum, e) => sum + (e.fn || 0), 0);
246
+ const rate = (totalTp + totalFn) === 0 ? 0.5 : totalTp / (totalTp + totalFn);
247
+ return rate;
248
+ };
249
+
250
+ // Sort by flakiness ascending (lower = more reliable = first), then success rate descending
251
+ const sorted = [...slots].sort((a, b) => {
252
+ const flakDiff = getFlakiness(a.slot) - getFlakiness(b.slot);
253
+ if (flakDiff !== 0) return flakDiff;
254
+ return getRate(b.slot) - getRate(a.slot);
255
+ });
256
+
257
+ console.error('[qgsd-dispatch] DISPATCH ORDER (flakiness,rate): [' +
258
+ sorted.map(s => `${s.slot}(f=${getFlakiness(s.slot).toFixed(2)},r=${getRate(s.slot).toFixed(3)})`).join(', ') + ']');
259
+ return sorted;
260
+ } catch (_) { return [...slots]; } // fail-open: any error → return original order
261
+ }
262
+
263
+ // Returns slot names whose backing provider is DOWN (probed unhealthy).
264
+ // Reads ~/.claude/qgsd-provider-cache.json and matches providers from providers.json.
265
+ // When a provider's endpoint is DOWN, ALL slots backed by that provider are skipped.
266
+ function getDownProviderSlots() {
267
+ try {
268
+ const cachePath = path.join(os.homedir(), '.claude', 'qgsd-provider-cache.json');
269
+ if (!fs.existsSync(cachePath)) return [];
270
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
271
+ if (!cache || !cache.entries) return [];
272
+
273
+ const providers = findProviders();
274
+ if (!providers) return [];
275
+
276
+ // Extract hostnames from DOWN cache entries.
277
+ // Cache keys are baseUrls like "https://api.akashml.com/v1".
278
+ // Extract hostname and strip common domain suffixes to get a match key (e.g., "akashml").
279
+ const downHostnames = [];
280
+ const now = Date.now();
281
+ for (const [baseUrl, entry] of Object.entries(cache.entries)) {
282
+ // Match TTL used by check-provider-health.cjs: 180s healthy, 300s unhealthy
283
+ const ttl = entry.healthy ? 180000 : 300000;
284
+ if (now - entry.cachedAt >= ttl) continue; // expired cache entry — ignore
285
+ if (entry.healthy) continue; // provider is UP — not skipping
286
+
287
+ try {
288
+ // Extract hostname and normalize to match provider field values in providers.json
289
+ const hostname = new URL(baseUrl).hostname; // e.g., "api.akashml.com"
290
+ let normalized = hostname.replace(/^api\./, ''); // remove "api." prefix
291
+ normalized = normalized.replace(/\.(com|ai|xyz|io)$/, ''); // remove TLD
292
+ downHostnames.push(normalized);
293
+ } catch (_) { /* malformed URL — skip this entry */ }
294
+ }
295
+
296
+ if (downHostnames.length === 0) return [];
297
+
298
+ // Find all slots backed by DOWN providers.
299
+ // Match provider field against each down hostname.
300
+ const skipSlots = [];
301
+ for (const provider of providers) {
302
+ if (!provider.provider) continue;
303
+ for (const downHostname of downHostnames) {
304
+ // Match: provider field contains or is contained by downHostname (e.g., "akashml" == "akashml")
305
+ if (provider.provider.includes(downHostname) || downHostname.includes(provider.provider)) {
306
+ skipSlots.push(provider.name);
307
+ break;
308
+ }
309
+ }
310
+ }
311
+ return skipSlots;
312
+ } catch (_) { return []; } // fail-open: any error → proceed with no provider skips
313
+ }
314
+
315
+ // Check if the circuit breaker is active (and not disabled) for a given git root.
316
+ function isBreakerActive(cwd) {
317
+ const gitResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
318
+ cwd, encoding: 'utf8', timeout: 5000,
319
+ });
320
+ if (gitResult.status !== 0 || gitResult.error) return false;
321
+ const gitRoot = gitResult.stdout.trim();
322
+ const statePath = path.join(gitRoot, '.claude', 'circuit-breaker-state.json');
323
+ if (!fs.existsSync(statePath)) return false;
324
+ try {
325
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
326
+ return state.active === true && state.disabled !== true;
327
+ } catch { return false; }
328
+ }
329
+
330
+ let raw = '';
331
+ process.stdin.setEncoding('utf8');
332
+ process.stdin.on('data', chunk => raw += chunk);
333
+ process.stdin.on('end', () => {
334
+ try {
335
+ const input = JSON.parse(raw);
336
+ const prompt = (input.prompt || '').trim();
337
+ const cwd = input.cwd || process.cwd();
338
+ const sessionId = input.session_id || null;
339
+
340
+ // ── Priority 1: Circuit breaker active → inject resolution workflow ──────
341
+ if (isBreakerActive(cwd)) {
342
+ const workflow = findResolutionWorkflow(cwd);
343
+ const context = workflow
344
+ ? `CIRCUIT BREAKER ACTIVE — OSCILLATION RESOLUTION MODE\n\nYou MUST follow this procedure immediately before doing anything else:\n\n${workflow}`
345
+ : `CIRCUIT BREAKER ACTIVE — OSCILLATION RESOLUTION MODE\n\nOscillation has been detected in recent commits. Tool calls are NOT blocked — you can still read and write files — but you MUST resolve the oscillation before making further commits.\nFollow the oscillation resolution procedure in R5 of CLAUDE.md:\n1. Run: git log --oneline --name-only -6 to identify the oscillating file set.\n2. Run quorum diagnosis with structural coupling framing.\n3. Present unified solution to user for approval.\n4. Do NOT commit until user approves AND runs: npx qgsd --reset-breaker`;
346
+ process.stdout.write(JSON.stringify({
347
+ hookSpecificOutput: {
348
+ hookEventName: 'UserPromptSubmit',
349
+ additionalContext: context,
350
+ }
351
+ }));
352
+ process.exit(0);
353
+ }
354
+
355
+ // ── Priority 2: Pending task → inject queued command ─────────────────────
356
+ const pendingTask = consumePendingTask(cwd, sessionId);
357
+ if (pendingTask) {
358
+ process.stdout.write(JSON.stringify({
359
+ hookSpecificOutput: {
360
+ hookEventName: 'UserPromptSubmit',
361
+ additionalContext: `PENDING QUEUED TASK — Execute this immediately before anything else:\n\n${pendingTask}\n\n(This task was queued via /qgsd:queue before the previous /clear.)`,
362
+ }
363
+ }));
364
+ process.exit(0);
365
+ }
366
+
367
+ // ── Priority 3: Planning command → inject quorum instructions ────────────
368
+ const config = loadConfig(cwd);
369
+ const commands = config.quorum_commands;
370
+
371
+ // Parse --n N override from the raw prompt
372
+ const quorumSizeOverride = parseQuorumSizeFlag(prompt);
373
+
374
+ // Dynamic fallback step generation from quorum_active (COMP-02)
375
+ const activeSlots = (config.quorum_active && config.quorum_active.length > 0)
376
+ ? config.quorum_active
377
+ : null; // null = use hardcoded fallback list
378
+
379
+ let instructions;
380
+
381
+ // Solo mode: --n 1 means Claude-only quorum — bypass all external slot dispatches
382
+ if (quorumSizeOverride === 1) {
383
+ instructions = `<!-- QGSD_SOLO_MODE -->\nSOLO MODE ACTIVE (--n 1): Self-quorum only. Skip ALL external slot-worker Task dispatches. Claude's vote is the quorum. Write <!-- GSD_DECISION --> in your final output. The Stop hook is informed.\n\n`;
384
+ } else if (config.quorum_instructions) {
385
+ // Explicit quorum_instructions in config — use as-is
386
+ instructions = config.quorum_instructions;
387
+ } else if (activeSlots) {
388
+ // Build ordered slot list, sub agents first when preferSub is set
389
+ const agentCfg = config.agent_config || {};
390
+ const preferSub = !(config.quorum && config.quorum.preferSub === false);
391
+ // Resolve maxSize ceiling: per-profile override > global default > hardcoded 3
392
+ // --n N is a separate cap applied on top (see below).
393
+ const profileKey = (() => {
394
+ try {
395
+ const cfgPath = path.join(process.cwd(), '.planning', 'config.json');
396
+ const pcfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
397
+ return pcfg.model_profile || null;
398
+ } catch (_) { return null; }
399
+ })();
400
+ const perProfileN = profileKey && config.quorum?.maxSizeByProfile?.[profileKey];
401
+ const globalN = (config.quorum && Number.isInteger(config.quorum.maxSize) && config.quorum.maxSize >= 1)
402
+ ? config.quorum.maxSize : 3;
403
+ const maxSize = Number.isInteger(perProfileN) && perProfileN >= 1 ? perProfileN : globalN;
404
+
405
+ // Read risk_level from hook input context (passed by orchestrator via additionalContext or context_yaml)
406
+ // Context YAML format: "risk_level: routine\n..." in input.context or input.context_yaml
407
+ const contextYaml = (input.context || input.context_yaml || '').toString();
408
+ const riskLevelMatch = contextYaml.match(/^risk_level:\s*(\S+)/m);
409
+ const riskLevelFromContext = riskLevelMatch ? riskLevelMatch[1].trim() : null;
410
+
411
+ // Compute fan-out count. Priority chain:
412
+ // 1. risk_level from context YAML → adaptive fan-out count
413
+ // 2. config.quorum.maxSize (default ceiling)
414
+ // 3. --n N user flag: treated as a MAXIMUM cap, not a mandatory value.
415
+ // min(risk-driven count, N) — so --n 3 with risk_level=low (→2) uses 2, not 3.
416
+ // 4. available pool size (hard cap, applied via slice)
417
+ const riskDrivenCount = mapRiskLevelToCount(riskLevelFromContext, maxSize);
418
+ const fanOutCount = quorumSizeOverride !== null
419
+ ? Math.min(riskDrivenCount, quorumSizeOverride)
420
+ : riskDrivenCount;
421
+
422
+ let orderedSlots = activeSlots.map(slot => ({
423
+ slot,
424
+ authType: (agentCfg[slot] && agentCfg[slot].auth_type) || 'api',
425
+ }));
426
+
427
+ // Guard: empty roster — no external agents configured at all
428
+ if (orderedSlots.length === 0) {
429
+ // Fail-open to solo mode: Claude is the only quorum participant
430
+ console.error('[qgsd-dispatch] WARNING: no external agents in roster — falling back to solo quorum');
431
+ instructions = `<!-- QGSD_SOLO_MODE -->\nSOLO MODE ACTIVE (empty roster): No external agents configured in providers.json or quorum_active. Claude's vote is the quorum. Write <!-- GSD_DECISION --> in your final output. The Stop hook is informed.\n\nTo add agents, run /qgsd:mcp-setup or edit ~/.claude/qgsd.json quorum_active.\n`;
432
+ } else {
433
+ if (preferSub) {
434
+ orderedSlots.sort((a, b) => {
435
+ if (a.authType === 'sub' && b.authType !== 'sub') return -1;
436
+ if (a.authType !== 'sub' && b.authType === 'sub') return 1;
437
+ return 0;
438
+ });
439
+ }
440
+
441
+ // externalSlotCap = fanOutCount - 1 (Claude accounts for the +1 in total participants)
442
+ const externalSlotCap = fanOutCount - 1;
443
+ let cappedSlots = orderedSlots.slice(0, externalSlotCap);
444
+
445
+ // DISP-01: Trigger fresh health probe before reading cache
446
+ triggerHealthProbe();
447
+
448
+ // Filter out slots backed by DOWN providers before building dynamic steps.
449
+ const recentTimeouts = getRecentlyTimedOutSlots(cwd);
450
+ const providerSkips = getDownProviderSlots();
451
+ const allSkipSlots = [...new Set([...recentTimeouts, ...providerSkips])];
452
+ const skipSet = new Set(allSkipSlots);
453
+ cappedSlots = cappedSlots.filter(s => !skipSet.has(s.slot));
454
+
455
+ // DISP-02: Filter by availability window (exclude cooling-down slots)
456
+ const beforeAvail = cappedSlots.map(s => s.slot);
457
+ cappedSlots = getAvailableSlots(cappedSlots, cwd);
458
+ const availabilitySkips = beforeAvail.filter(s => !cappedSlots.some(c => c.slot === s));
459
+
460
+ // DISP-03: Sort by descending success rate
461
+ cappedSlots = sortBySuccessRate(cappedSlots, cwd);
462
+
463
+ // SC-4: Graceful fallback — ensure at least one slot in dispatch list
464
+ if (cappedSlots.length === 0 && orderedSlots.length > 0) {
465
+ const relaxedSlots = orderedSlots.filter(s => !skipSet.has(s.slot));
466
+ if (relaxedSlots.length > 0) {
467
+ cappedSlots = [relaxedSlots[0]];
468
+ } else {
469
+ cappedSlots = [orderedSlots[0]]; // last resort: any slot at all
470
+ }
471
+ console.error(`[qgsd-dispatch] FALLBACK: all slots filtered, restored ${cappedSlots[0].slot}`);
472
+ }
473
+
474
+ // Generate step list, with optional section headers when preferSub is on
475
+ let stepLines = [];
476
+ let stepNum = 1;
477
+ const hasMixed = preferSub && cappedSlots.some(s => s.authType === 'sub') && cappedSlots.some(s => s.authType !== 'sub');
478
+ let inApiSection = false;
479
+ for (const { slot, authType } of cappedSlots) {
480
+ if (hasMixed && authType !== 'sub' && !inApiSection) {
481
+ stepLines.push(' [API agents — overflow if sub count insufficient]');
482
+ inApiSection = true;
483
+ }
484
+ stepLines.push(` ${stepNum}. Task(subagent_type="qgsd-quorum-slot-worker", prompt="slot: ${slot}\\nround: 1\\ntimeout_ms: 60000\\nrepo_dir: <cwd>\\nmode: A\\nquestion: <question>")`);
485
+ stepNum++;
486
+ }
487
+ const dynamicSteps = stepLines.join('\n');
488
+ const afterSteps = cappedSlots.length + 1;
489
+
490
+ // Compute T1 unused sub-CLI slots: sub agents cut by the fan-out cap.
491
+ // These are the preferred replacement tier when a dispatched slot returns UNAVAIL.
492
+ // They must be tried before any T2 ccr/api slots (claude-1..6).
493
+ const cappedSlotNames = new Set(cappedSlots.map(s => s.slot));
494
+ const t1Unused = orderedSlots
495
+ .filter(s => s.authType === 'sub' && !cappedSlotNames.has(s.slot))
496
+ .map(s => s.slot);
497
+ const t2Slots = orderedSlots
498
+ .filter(s => s.authType !== 'sub' && !cappedSlotNames.has(s.slot))
499
+ .map(s => s.slot);
500
+
501
+ // Build a structured dispatch sequence (enumerated steps, not prose rules).
502
+ // Structured sequences are less ambiguous than conditional prose for LLM execution.
503
+ let failoverRule;
504
+ if (t1Unused.length > 0) {
505
+ failoverRule =
506
+ `SLOT DISPATCH SEQUENCE (FALLBACK-01) — execute in order, skip UNAVAIL:\n` +
507
+ ` Step 1 PRIMARY: [${cappedSlots.map(s => s.slot).join(', ')}]\n` +
508
+ ` Step 2 T1 sub-CLI: [${t1Unused.join(', ')}] ← try these BEFORE any T2 slot\n` +
509
+ ` Step 3 T2 ccr: [${t2Slots.length > 0 ? t2Slots.join(', ') : 'none'}]\n` +
510
+ `UNAVAIL slots do not count toward the ${maxSize} required quorum votes.`;
511
+ } else {
512
+ failoverRule =
513
+ `Failover rule: if a slot-worker returns UNAVAIL or error, skip it — ` +
514
+ `errors do not count toward the ${maxSize} required.`;
515
+ }
516
+
517
+ // Emit a conformance event when FALLBACK-01 is active so audit tooling can detect
518
+ // cases where T2 was used without T1 being exhausted first.
519
+ if (t1Unused.length > 0) {
520
+ try {
521
+ const pp = require(require('path').join(__dirname, '..', 'bin', 'planning-paths.cjs'));
522
+ const logPath = pp.resolve(cwd, 'conformance-events');
523
+ require('fs').appendFileSync(logPath, JSON.stringify({
524
+ type: 'quorum_fallback_t1_required',
525
+ t1Slots: t1Unused,
526
+ t2Slots,
527
+ primarySlots: cappedSlots.map(s => s.slot),
528
+ fanOutCount,
529
+ ts: new Date().toISOString(),
530
+ }) + '\n', 'utf8');
531
+ } catch (_) { /* non-fatal — conformance logging must not block quorum */ }
532
+ }
533
+
534
+ // Always emit --n N so Stop hook's parseQuorumSizeFlag reads the correct ceiling.
535
+ // When user passed --n N explicitly: show OVERRIDE note.
536
+ // Build the quorum size note emitted into Claude's context.
537
+ // --n N is a maximum cap; the actual fanOutCount may be lower due to risk_level.
538
+ let minNote;
539
+ if (quorumSizeOverride !== null) {
540
+ const capNote = fanOutCount < quorumSizeOverride
541
+ ? `--n ${quorumSizeOverride} (max) → ${fanOutCount} via risk_level=${riskLevelFromContext}`
542
+ : `--n ${quorumSizeOverride}`;
543
+ minNote = ` (${capNote}: Claude + ${externalSlotCap} external slot${externalSlotCap !== 1 ? 's' : ''})`;
544
+ } else if (riskLevelFromContext && fanOutCount < maxSize) {
545
+ minNote = ` (--n ${fanOutCount} — envelope risk_level: ${riskLevelFromContext} → ${externalSlotCap} external slot${externalSlotCap !== 1 ? 's' : ''})`;
546
+ } else {
547
+ minNote = ` (--n ${fanOutCount})`;
548
+ }
549
+
550
+ let skipNote = '';
551
+ if (recentTimeouts.length > 0) {
552
+ skipNote += `SKIP (TIMEOUT < 30min ago): [${recentTimeouts.join(', ')}] — do NOT dispatch these slots.\n`;
553
+ }
554
+ if (providerSkips.length > 0) {
555
+ skipNote += `SKIP (PROVIDER DOWN): [${providerSkips.join(', ')}] — entire provider unreachable, skip all backed slots.\n`;
556
+ }
557
+ if (availabilitySkips.length > 0) {
558
+ skipNote += `SKIP (COOLING DOWN): [${availabilitySkips.join(', ')}] — available_at in future, skipping.\n`;
559
+ }
560
+
561
+ instructions = `QUORUM REQUIRED${minNote} (structural enforcement — Stop hook will verify)\n\n` +
562
+ `Run the full R3 quorum protocol inline (dispatch_pattern from commands/qgsd/quorum.md):\n` +
563
+ `Dispatch ALL active slots as parallel sibling qgsd-quorum-slot-worker Tasks in ONE message turn.\n` +
564
+ `NEVER call mcp__*__* tools directly — use Task(subagent_type="qgsd-quorum-slot-worker") ONLY:\n` +
565
+ (hasMixed ? ' [Subscription agents — preferred, flat-fee]\n' : '') +
566
+ dynamicSteps + '\n\n' +
567
+ skipNote +
568
+ failoverRule + '\n\n' +
569
+ `After quorum:\n` +
570
+ ` ${afterSteps}. Synthesize results inline. Deliberate up to 10 rounds per R3.3 if no consensus.\n` +
571
+ ` ${afterSteps + 1}. Update scoreboard: node ~/.claude/qgsd-bin/update-scoreboard.cjs merge-wave ...\n` +
572
+ ` ${afterSteps + 2}. [HEAL-01] After EACH deliberation round's merge-wave, check early escalation:\n` +
573
+ ` node ~/.claude/qgsd-bin/quorum-consensus-gate.cjs --min-quorum=2 --remaining-rounds=R\n` +
574
+ ` where R = (maxDeliberation - currentRound). For example, on round 2 of 7 max: --remaining-rounds=5.\n` +
575
+ ` If exit code 1 (shouldEscalate=true, P(consensus|remaining) below 10% threshold), stop deliberating and proceed to decision immediately.\n` +
576
+ ` This prevents wasting rounds when consensus is mathematically unlikely.\n` +
577
+ ` ${afterSteps + 3}. Include the token <!-- GSD_DECISION --> in your FINAL output\n\n` +
578
+ `Fail-open: if a model is UNAVAILABLE (quota/error), note it and proceed with available models.\n` +
579
+ `The Stop hook reads the transcript — skipping quorum will block your response.`;
580
+ }
581
+ } else {
582
+ // Neither quorum_instructions nor quorum_active configured — use hardcoded fallback
583
+ instructions = DEFAULT_QUORUM_INSTRUCTIONS_FALLBACK;
584
+ }
585
+
586
+ // Append model override block if any preferences are set.
587
+ // Skip when activeSlots is configured: Task-based dispatch uses call-quorum-slot.cjs which
588
+ // reads the model from providers.json — injecting mcp__*__* tool names here would
589
+ // re-introduce the direct-MCP escape hatch that the activeSlots branch eliminates.
590
+ const prefs = config.model_preferences || {};
591
+ const overrideEntries = Object.entries(prefs).filter(([, m]) => m && typeof m === 'string');
592
+ if (overrideEntries.length > 0 && !activeSlots) {
593
+ // Agent key → primary quorum tool call mapping
594
+ const AGENT_TOOL_MAP = {
595
+ 'codex-cli-1': 'mcp__codex-cli-1__review',
596
+ 'gemini-cli-1': 'mcp__gemini-cli-1__gemini',
597
+ 'opencode-1': 'mcp__opencode-1__opencode',
598
+ 'copilot-1': 'mcp__copilot-1__ask',
599
+ 'claude-1': 'mcp__claude-1__claude',
600
+ 'claude-2': 'mcp__claude-2__claude',
601
+ 'claude-3': 'mcp__claude-3__claude',
602
+ 'claude-4': 'mcp__claude-4__claude',
603
+ 'claude-5': 'mcp__claude-5__claude',
604
+ 'claude-6': 'mcp__claude-6__claude',
605
+ };
606
+ const lines = overrideEntries.map(([agent, model]) => {
607
+ const tool = AGENT_TOOL_MAP[agent] || ('mcp__' + agent);
608
+ return ' - When calling ' + tool + ', include model="' + model + '" in the tool input';
609
+ }).join('\n');
610
+ instructions += '\n\nModel overrides (from qgsd.json model_preferences):\n' +
611
+ 'The following agents have preferred models configured. Pass the specified model parameter:\n' +
612
+ lines;
613
+ }
614
+
615
+ // Anchored allowlist — requires /gsd: or /qgsd: prefix and word boundary after command name.
616
+ const cmdPattern = new RegExp('^\\s*\\/q?gsd:(' + commands.join('|') + ')(\\s|$)');
617
+ if (!cmdPattern.test(prompt)) {
618
+ process.exit(0); // Silent pass — UPS-05
619
+ }
620
+
621
+ appendConformanceEvent({
622
+ ts: new Date().toISOString(),
623
+ phase: 'IDLE',
624
+ action: 'quorum_start',
625
+ slots_available: 0,
626
+ vote_result: null,
627
+ outcome: null,
628
+ schema_version,
629
+ });
630
+
631
+ process.stdout.write(JSON.stringify({
632
+ hookSpecificOutput: {
633
+ hookEventName: 'UserPromptSubmit',
634
+ additionalContext: instructions,
635
+ }
636
+ }));
637
+ process.exit(0);
638
+
639
+ } catch (e) {
640
+ process.exit(0); // Fail-open on any error
641
+ }
642
+ });
643
+
644
+ // Export helpers for unit testing (tree-shaken at runtime — no cost)
645
+ // The file is a script and exits via process.exit() before reaching this line in normal operation.
646
+ // When require()d by tests, the stdin handler is registered but never fires, so module.exports is set.
647
+ if (typeof module !== 'undefined') {
648
+ module.exports = module.exports || {};
649
+ module.exports.mapRiskLevelToCount = mapRiskLevelToCount;
650
+ module.exports.parseQuorumSizeFlag = parseQuorumSizeFlag;
651
+ module.exports.getAvailableSlots = getAvailableSlots;
652
+ module.exports.sortBySuccessRate = sortBySuccessRate;
653
+ }