@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,541 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * call-quorum-slot.cjs — bash-callable quorum slot dispatcher
6
+ *
7
+ * Usage:
8
+ * echo "<prompt>" | node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>]
9
+ * node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>] <<'EOF'
10
+ * <multi-line prompt>
11
+ * EOF
12
+ *
13
+ * --cwd <dir> Set the working directory for spawned CLI processes (defaults to process.cwd()).
14
+ * Pass the project repo path so CLIs auto-detect the correct git context.
15
+ *
16
+ * Reads providers.json, dispatches to the slot's CLI (subprocess) or HTTP provider,
17
+ * prints the response text to stdout.
18
+ *
19
+ * Used by qgsd-quorum-orchestrator (sub-agent) which cannot access MCP tools.
20
+ *
21
+ * Exit codes: 0 = success, 1 = error (message on stderr)
22
+ */
23
+
24
+ const { spawn } = require('child_process');
25
+ const https = require('https');
26
+ const http = require('http');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+
31
+ // ─── Utilities ──────────────────────────────────────────────────────────────
32
+ function sleep(ms) {
33
+ return new Promise(resolve => setTimeout(resolve, ms));
34
+ }
35
+
36
+ // ─── Token sentinel for CLI slots (OBSV-04) ───────────────────────────────────
37
+ function appendTokenSentinel(slotName) {
38
+ try {
39
+ const record = JSON.stringify({
40
+ ts: new Date().toISOString(),
41
+ session_id: null,
42
+ agent_id: null,
43
+ slot: slotName,
44
+ input_tokens: null,
45
+ output_tokens: null,
46
+ cache_creation_input_tokens: null,
47
+ cache_read_input_tokens: null,
48
+ });
49
+ const pp = require('./planning-paths.cjs');
50
+ const logPath = pp.resolve(findProjectRoot(), 'token-usage');
51
+ fs.appendFileSync(logPath, record + '\n', 'utf8');
52
+ } catch (_) {} // observational — never fails
53
+ }
54
+
55
+ // ─── Telemetry logging for quorum slot dispatch (OBS-01) ─────────────────────
56
+ function recordTelemetry(slotName, round, verdict, latencyMs, provider, providerStatus, retryCount, errorType) {
57
+ try {
58
+ const sessionId = process.env.CLAUDE_SESSION_ID || 'session-' + Date.now();
59
+ const record = JSON.stringify({
60
+ ts: new Date().toISOString(),
61
+ session_id: sessionId,
62
+ round: parseInt(round, 10) || 0,
63
+ slot: slotName,
64
+ verdict: verdict,
65
+ latency_ms: latencyMs,
66
+ provider: provider,
67
+ provider_status: providerStatus,
68
+ retry_count: retryCount,
69
+ error_type: errorType,
70
+ });
71
+ const pp = require('./planning-paths.cjs');
72
+ const logPath = pp.resolve(findProjectRoot(), 'quorum-rounds', { sessionId });
73
+ fs.appendFileSync(logPath, record + '\n', 'utf8');
74
+ } catch (_) {
75
+ // Fail-open: telemetry errors never block or crash the dispatch
76
+ // Log to stderr for observability, but do not rethrow
77
+ process.stderr.write('[call-quorum-slot] telemetry error (non-fatal): recordTelemetry failed\n');
78
+ }
79
+ }
80
+
81
+ // ─── Failure log ───────────────────────────────────────────────────────────────
82
+ function findProjectRoot() {
83
+ let dir = __dirname;
84
+ for (let i = 0; i < 8; i++) {
85
+ if (fs.existsSync(path.join(dir, '.planning'))) return dir;
86
+ const parent = path.dirname(dir);
87
+ if (parent === dir) break;
88
+ dir = parent;
89
+ }
90
+ return process.cwd();
91
+ }
92
+
93
+ function writeFailureLog(slotName, errorMsg, stderrText) {
94
+ try {
95
+ const pp = require('./planning-paths.cjs');
96
+ const logPath = pp.resolve(findProjectRoot(), 'quorum-failures');
97
+
98
+ // Classify error type
99
+ let error_type;
100
+ if (/usage:|unknown flag|unknown option|invalid flag|unrecognized/i.test(errorMsg)) {
101
+ error_type = 'CLI_SYNTAX';
102
+ } else if (/TIMEOUT/i.test(errorMsg)) {
103
+ error_type = 'TIMEOUT';
104
+ } else if (/401|403|unauthorized|forbidden/i.test(errorMsg)) {
105
+ error_type = 'AUTH';
106
+ } else {
107
+ error_type = 'UNKNOWN';
108
+ }
109
+
110
+ // Extract pattern: first 200 chars of stderrText or errorMsg, strip ANSI codes
111
+ const rawPattern = (stderrText && stderrText.length > 0) ? stderrText : errorMsg;
112
+ const pattern = rawPattern.replace(/\x1b\[[0-9;]*m/g, '').slice(0, 200);
113
+
114
+ // Read existing log
115
+ let records = [];
116
+ if (fs.existsSync(logPath)) {
117
+ try {
118
+ records = JSON.parse(fs.readFileSync(logPath, 'utf8'));
119
+ if (!Array.isArray(records)) records = [];
120
+ } catch (_) { records = []; }
121
+ }
122
+
123
+ // Update or insert record
124
+ const existing = records.find(r => r.slot === slotName && r.error_type === error_type);
125
+ if (existing) {
126
+ existing.count++;
127
+ existing.last_seen = new Date().toISOString();
128
+ } else {
129
+ records.push({ slot: slotName, error_type, pattern, count: 1, last_seen: new Date().toISOString() });
130
+ }
131
+
132
+ fs.writeFileSync(logPath, JSON.stringify(records, null, 2), 'utf8');
133
+ } catch (_) { /* failure logging must never interrupt the primary flow */ }
134
+ }
135
+
136
+ // ─── Retry with exponential backoff (FAIL-01) ───────────────────────────────
137
+ function isRetryable(error) {
138
+ const msg = (error && error.message) ? error.message : String(error);
139
+
140
+ // Non-retryable errors: fail immediately
141
+ if (/spawn error/i.test(msg)) {
142
+ return false;
143
+ }
144
+ if (/usage:|unknown flag|unknown option|invalid flag|unrecognized/i.test(msg)) {
145
+ return false;
146
+ }
147
+
148
+ // Retryable errors: TIMEOUT and network errors
149
+ if (/TIMEOUT/i.test(msg)) {
150
+ return true;
151
+ }
152
+ if (/ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg)) {
153
+ return true;
154
+ }
155
+
156
+ // Fail-open: unknown errors are retryable
157
+ return true;
158
+ }
159
+
160
+ async function retryWithBackoff(fn, slotName, maxRetries = 2, delays = [1000, 3000]) {
161
+ const MAX_RETRIES = maxRetries;
162
+ let retryAttempts = 0;
163
+
164
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
165
+ try {
166
+ const result = await fn();
167
+ return { result, retryCount: retryAttempts };
168
+ } catch (err) {
169
+ const isLastAttempt = attempt >= MAX_RETRIES;
170
+ const isNonRetryable = !isRetryable(err);
171
+
172
+ // Fail immediately if non-retryable or no more retries
173
+ if (isNonRetryable || isLastAttempt) {
174
+ throw err;
175
+ }
176
+
177
+ // Log retry attempt and sleep before next attempt
178
+ const delayMs = delays[attempt] ?? 3000; // default to 3s if delay not specified
179
+ retryAttempts++;
180
+ process.stderr.write(`[call-quorum-slot] retry ${attempt + 1}/${MAX_RETRIES} for slot ${slotName} after ${delayMs}ms\n`);
181
+ await sleep(delayMs);
182
+ }
183
+ }
184
+ }
185
+
186
+ // ─── Args ──────────────────────────────────────────────────────────────────────
187
+ const argv = process.argv.slice(2);
188
+ const getArg = (f) => { const i = argv.indexOf(f); return i !== -1 && argv[i + 1] ? argv[i + 1] : null; };
189
+
190
+ const slot = getArg('--slot');
191
+ const _timeoutArg = getArg('--timeout');
192
+ // Treat 0 / negative / NaN as "not set" — fall through to provider.quorum_timeout_ms.
193
+ // Zero can be passed when the quorum orchestrator LLM fails to compute SLOT_TIMEOUTS,
194
+ // causing the process to be killed in ~1 ms and logged as "TIMEOUT after 0ms".
195
+ let timeoutMs = _timeoutArg !== null ? parseInt(_timeoutArg, 10) : null;
196
+ if (timeoutMs !== null && (isNaN(timeoutMs) || timeoutMs <= 0)) timeoutMs = null;
197
+ const roundNum = getArg('--round');
198
+ const spawnCwd = getArg('--cwd') ?? process.cwd();
199
+
200
+ if (!slot) {
201
+ process.stderr.write('Usage: echo "<prompt>" | node call-quorum-slot.cjs --slot <name> [--timeout <ms>] [--cwd <dir>]\n');
202
+ process.exit(1);
203
+ }
204
+
205
+ // ─── Find providers.json ───────────────────────────────────────────────────────
206
+ function findProviders() {
207
+ const searchPaths = [
208
+ path.join(__dirname, 'providers.json'), // same dir (qgsd-bin)
209
+ path.join(os.homedir(), '.claude', 'qgsd-bin', 'providers.json'), // installed fallback
210
+ ];
211
+
212
+ // Also derive path from unified-1 MCP server config in ~/.claude.json
213
+ try {
214
+ const claudeJson = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
215
+ const u1args = claudeJson?.mcpServers?.['unified-1']?.args ?? [];
216
+ const serverScript = u1args.find(a => typeof a === 'string' && a.endsWith('unified-mcp-server.mjs'));
217
+ if (serverScript) {
218
+ searchPaths.unshift(path.join(path.dirname(serverScript), 'providers.json'));
219
+ }
220
+ } catch (_) { /* no claude.json — fine */ }
221
+
222
+ for (const p of searchPaths) {
223
+ try {
224
+ if (fs.existsSync(p)) {
225
+ return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
226
+ }
227
+ } catch (_) { /* try next */ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ // ─── Read stdin ────────────────────────────────────────────────────────────────
233
+ function readStdin() {
234
+ return new Promise((resolve) => {
235
+ let data = '';
236
+ process.stdin.setEncoding('utf8');
237
+ process.stdin.on('data', chunk => { data += chunk; });
238
+ process.stdin.on('end', () => resolve(data.trim()));
239
+ // If stdin is a TTY (no pipe), resolve immediately with empty string
240
+ if (process.stdin.isTTY) resolve('');
241
+ });
242
+ }
243
+
244
+ // ─── Subprocess dispatch ───────────────────────────────────────────────────────
245
+ function runSubprocess(provider, prompt, timeoutMs) {
246
+ const args = provider.args_template.map(a => (a === '{prompt}' ? prompt : a));
247
+ const env = { ...process.env, ...(provider.env ?? {}) };
248
+
249
+ return new Promise((resolve, reject) => {
250
+ let child;
251
+ try {
252
+ // detached: true creates a new process group — required to kill all descendants
253
+ // (ccr → Claude Code → node, opencode → LLM subprocess, etc.)
254
+ child = spawn(provider.cli, args, { env, cwd: spawnCwd, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
255
+ } catch (err) {
256
+ reject(new Error(`[spawn error: ${err.message}]`));
257
+ return;
258
+ }
259
+
260
+ child.stdin.end(); // non-interactive
261
+
262
+ let stdout = '';
263
+ let stderr = '';
264
+ let timedOut = false;
265
+ const MAX_BUF = 10 * 1024 * 1024;
266
+
267
+ // Kill entire process group, then destroy streams to force 'close' even if
268
+ // grandchildren keep the pipes open (the common case with ccr/opencode).
269
+ const killGroup = () => {
270
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (_) { try { child.kill('SIGTERM'); } catch (_) {} }
271
+ setTimeout(() => {
272
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { try { child.kill('SIGKILL'); } catch (_) {} }
273
+ try { child.stdout.destroy(); } catch (_) {}
274
+ try { child.stderr.destroy(); } catch (_) {}
275
+ }, 2000);
276
+ };
277
+
278
+ const timer = setTimeout(() => {
279
+ timedOut = true;
280
+ killGroup();
281
+ }, timeoutMs);
282
+
283
+ child.stdout.on('data', d => {
284
+ if (stdout.length < MAX_BUF) stdout += d.toString().slice(0, MAX_BUF - stdout.length);
285
+ });
286
+ child.stderr.on('data', d => { stderr += d.toString().slice(0, 4096); });
287
+
288
+ child.on('close', (code) => {
289
+ clearTimeout(timer);
290
+ if (timedOut) {
291
+ reject(new Error(`TIMEOUT after ${timeoutMs}ms`));
292
+ return;
293
+ }
294
+ const output = stdout || stderr || '(no output)';
295
+ resolve(code !== 0 ? `${output}\n[exit code ${code}]` : output);
296
+ });
297
+
298
+ child.on('error', (err) => {
299
+ clearTimeout(timer);
300
+ reject(new Error(`[spawn error: ${err.message}]`));
301
+ });
302
+ });
303
+ }
304
+
305
+ // ─── OAuth account rotation ────────────────────────────────────────────────────
306
+ function matchesRotationPattern(text, patterns) {
307
+ const lower = (text ?? '').toLowerCase();
308
+ return patterns.some(p => lower.includes(p.toLowerCase()));
309
+ }
310
+
311
+ function spawnRotateCmd(cmdArray) {
312
+ return new Promise((resolve) => {
313
+ const [bin, ...args] = cmdArray;
314
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
315
+ child.on('close', (code) => {
316
+ if (code !== 0) process.stderr.write(`[oauth-rotation] ${bin} exited ${code}\n`);
317
+ resolve(); // non-fatal — always attempt retry
318
+ });
319
+ child.on('error', (err) => {
320
+ process.stderr.write(`[oauth-rotation] ${bin} error: ${err.message}\n`);
321
+ resolve(); // non-fatal
322
+ });
323
+ });
324
+ }
325
+
326
+ async function runSubprocessWithRotation(provider, prompt, timeoutMs) {
327
+ const rot = provider.oauth_rotation;
328
+ const max = rot.max_retries ?? 3;
329
+ const patterns = rot.retry_on_patterns ?? ['quota', 'resource_exhausted', 'unauthorized', '401', '403'];
330
+ let lastErr = null;
331
+ let totalRetryCount = 0;
332
+
333
+ for (let attempt = 0; attempt <= max; attempt++) {
334
+ if (attempt > 0) {
335
+ process.stderr.write(`[oauth-rotation] attempt ${attempt}/${max} — rotating OAuth account\n`);
336
+ await spawnRotateCmd(rot.rotate_cmd);
337
+ }
338
+ try {
339
+ // Wrap inner call with retry-with-backoff (each oauth attempt gets retry protection)
340
+ const retryResult = await retryWithBackoff(() => runSubprocess(provider, prompt, timeoutMs), provider.name);
341
+ const out = retryResult.result;
342
+ totalRetryCount = attempt + retryResult.retryCount;
343
+ if (matchesRotationPattern(out, patterns) && attempt < max) {
344
+ lastErr = new Error('quota/auth pattern in output');
345
+ continue;
346
+ }
347
+ return { result: out, retryCount: totalRetryCount };
348
+ } catch (err) {
349
+ if (matchesRotationPattern(err.message, patterns) && attempt < max) {
350
+ lastErr = err;
351
+ continue;
352
+ }
353
+ throw err;
354
+ }
355
+ }
356
+ throw lastErr ?? new Error('[oauth-rotation] all attempts exhausted');
357
+ }
358
+
359
+ // ─── Read per-slot env from ~/.claude.json (for HTTP PROVIDER_SLOT pattern) ───
360
+ function loadSlotEnv(slotName) {
361
+ try {
362
+ const claudeJson = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
363
+ return claudeJson?.mcpServers?.[slotName]?.env ?? {};
364
+ } catch (_) { return {}; }
365
+ }
366
+
367
+ // ─── HTTP dispatch ─────────────────────────────────────────────────────────────
368
+ function runHttp(provider, prompt, timeoutMs) {
369
+ // HTTP slots use PROVIDER_SLOT mode: API keys live in ~/.claude.json server env,
370
+ // not in process.env. Load them from there, falling back to process.env.
371
+ const slotEnv = loadSlotEnv(provider.name);
372
+ const apiKey = slotEnv['ANTHROPIC_API_KEY']
373
+ ?? process.env[provider.apiKeyEnv]
374
+ ?? process.env['ANTHROPIC_API_KEY']
375
+ ?? '';
376
+ const baseUrl = slotEnv['ANTHROPIC_BASE_URL'] ?? provider.baseUrl;
377
+ const model = slotEnv['CLAUDE_DEFAULT_MODEL'] ?? provider.model;
378
+
379
+ const body = JSON.stringify({
380
+ model: model,
381
+ messages: [{ role: 'user', content: prompt }],
382
+ stream: false,
383
+ });
384
+
385
+ const url = new URL(`${baseUrl}/chat/completions`);
386
+ const isHttps = url.protocol === 'https:';
387
+ const transport = isHttps ? https : http;
388
+ const options = {
389
+ hostname: url.hostname,
390
+ port: url.port || (isHttps ? 443 : 80),
391
+ path: url.pathname + url.search,
392
+ method: 'POST',
393
+ headers: {
394
+ 'Content-Type': 'application/json',
395
+ 'Authorization': `Bearer ${apiKey}`,
396
+ 'Content-Length': Buffer.byteLength(body),
397
+ },
398
+ };
399
+
400
+ return new Promise((resolve, reject) => {
401
+ let timedOut = false;
402
+
403
+ const req = transport.request(options, (res) => {
404
+ let data = '';
405
+ res.on('data', chunk => { data += chunk; });
406
+ res.on('end', () => {
407
+ if (timedOut) return;
408
+ clearTimeout(timer);
409
+ try {
410
+ const parsed = JSON.parse(data);
411
+ const content = parsed?.choices?.[0]?.message?.content;
412
+ if (content) {
413
+ resolve(content);
414
+ } else {
415
+ reject(new Error(`[HTTP error: unexpected response] ${data.slice(0, 500)}`));
416
+ }
417
+ } catch (e) {
418
+ reject(new Error(`[HTTP error: JSON parse failed] ${data.slice(0, 500)}`));
419
+ }
420
+ });
421
+ });
422
+
423
+ const timer = setTimeout(() => {
424
+ timedOut = true;
425
+ req.destroy();
426
+ reject(new Error(`TIMEOUT after ${timeoutMs}ms`));
427
+ }, timeoutMs);
428
+
429
+ req.on('error', (err) => {
430
+ clearTimeout(timer);
431
+ if (!timedOut) reject(new Error(`[HTTP request error: ${err.message}]`));
432
+ });
433
+
434
+ req.write(body);
435
+ req.end();
436
+ });
437
+ }
438
+
439
+ // ─── Main ──────────────────────────────────────────────────────────────────────
440
+ async function main() {
441
+ const providers = findProviders();
442
+ if (!providers) {
443
+ process.stderr.write('[call-quorum-slot] Could not find providers.json\n');
444
+ process.exit(1);
445
+ }
446
+
447
+ if (providers.length === 0) {
448
+ process.stderr.write('[call-quorum-slot] No providers configured in providers.json — cannot dispatch slot\n');
449
+ process.exit(1);
450
+ }
451
+
452
+ const provider = providers.find(p => p.name === slot);
453
+ if (!provider) {
454
+ const names = providers.map(p => p.name).join(', ');
455
+ process.stderr.write(`[call-quorum-slot] Unknown slot: "${slot}". Available: ${names}\n`);
456
+ process.exit(1);
457
+ }
458
+
459
+ const prompt = await readStdin();
460
+ if (!prompt) {
461
+ process.stderr.write('[call-quorum-slot] No prompt received on stdin\n');
462
+ process.exit(1);
463
+ }
464
+
465
+ // timeoutMs is null when --timeout not passed → fall through to provider.quorum_timeout_ms.
466
+ // provider.timeout_ms (300s) is intentionally last — it's the full session timeout, not quorum.
467
+ // When both are set, take the minimum so provider.quorum_timeout_ms always acts as a hard cap.
468
+ const providerCap = provider.quorum_timeout_ms ?? null;
469
+ const effectiveTimeout = (timeoutMs !== null && providerCap !== null)
470
+ ? Math.min(timeoutMs, providerCap)
471
+ : (timeoutMs ?? providerCap ?? provider.timeout_ms ?? 30000);
472
+
473
+ const startMs = Date.now();
474
+
475
+ try {
476
+ let result;
477
+ let retryCount = 0;
478
+
479
+ if (provider.type === 'subprocess') {
480
+ if (provider.oauth_rotation?.enabled) {
481
+ const retryResult = await runSubprocessWithRotation(provider, prompt, effectiveTimeout);
482
+ result = retryResult.result;
483
+ retryCount = retryResult.retryCount;
484
+ } else {
485
+ const retryResult = await retryWithBackoff(() => runSubprocess(provider, prompt, effectiveTimeout), slot);
486
+ result = retryResult.result;
487
+ retryCount = retryResult.retryCount;
488
+ }
489
+ } else if (provider.type === 'http') {
490
+ const retryResult = await retryWithBackoff(() => runHttp(provider, prompt, effectiveTimeout), slot);
491
+ result = retryResult.result;
492
+ retryCount = retryResult.retryCount;
493
+ } else {
494
+ process.stderr.write(`[call-quorum-slot] Unknown provider type: ${provider.type}\n`);
495
+ writeFailureLog(slot, `Unknown provider type: ${provider.type}`, '');
496
+ appendTokenSentinel(slot);
497
+ const latencyMs = Date.now() - startMs;
498
+ recordTelemetry(slot, roundNum, 'FLAG', latencyMs, provider.provider || provider.name, 'unavailable', 0, 'UNKNOWN_TYPE');
499
+ process.exit(1);
500
+ }
501
+
502
+ // Extract verdict from result using regex
503
+ const verdict = (/APPROVE|BLOCK|FLAG/.exec(result) || [])[0] || 'UNKNOWN';
504
+ const latencyMs = Date.now() - startMs;
505
+ const providerName = provider.provider || provider.name;
506
+
507
+ recordTelemetry(slot, roundNum, verdict, latencyMs, providerName, 'available', retryCount, null);
508
+
509
+ process.stdout.write(result);
510
+ if (!result.endsWith('\n')) process.stdout.write('\n');
511
+ appendTokenSentinel(slot);
512
+ process.exit(0);
513
+ } catch (err) {
514
+ const latencyMs = Date.now() - startMs;
515
+ const providerName = provider.provider || provider.name;
516
+
517
+ // Classify error type
518
+ let errorType = 'UNKNOWN';
519
+ if (/TIMEOUT/i.test(err.message)) {
520
+ errorType = 'TIMEOUT';
521
+ } else if (/401|403|unauthorized|forbidden/i.test(err.message)) {
522
+ errorType = 'AUTH';
523
+ } else if (/spawn error/i.test(err.message)) {
524
+ errorType = 'SPAWN_ERROR';
525
+ } else if (/usage:|unknown flag/i.test(err.message)) {
526
+ errorType = 'CLI_SYNTAX';
527
+ }
528
+
529
+ recordTelemetry(slot, roundNum, 'FLAG', latencyMs, providerName, 'unavailable', 0, errorType);
530
+
531
+ process.stderr.write(`[call-quorum-slot] ${err.message}\n`);
532
+ writeFailureLog(slot, err.message, '');
533
+ appendTokenSentinel(slot);
534
+ process.exit(1);
535
+ }
536
+ }
537
+
538
+ main().catch(err => {
539
+ process.stderr.write(`[call-quorum-slot] Fatal: ${err.message}\n`);
540
+ process.exit(1);
541
+ });
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // bin/ccr-secure-config.cjs
3
+ // Reads the 3 CCR provider API keys from keytar (qgsd service) and writes them
4
+ // into ~/.claude-code-router/config.json with chmod 600.
5
+ // Designed to be called at session start and on-demand. Fail-silent when keytar
6
+ // is unavailable or keys are not yet stored.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+ const { execFileSync } = require('child_process');
14
+
15
+ const CONFIG_PATH = path.join(os.homedir(), '.claude-code-router', 'config.json');
16
+
17
+ // Locate secrets.cjs — try installed global path first, then local dev path.
18
+ function findSecrets() {
19
+ const candidates = [
20
+ path.join(os.homedir(), '.claude', 'qgsd-bin', 'secrets.cjs'), // installed path
21
+ path.join(__dirname, 'secrets.cjs'), // local dev path
22
+ ];
23
+ for (const p of candidates) {
24
+ try {
25
+ if (fs.existsSync(p)) {
26
+ return require(p);
27
+ }
28
+ } catch (_) {}
29
+ }
30
+ return null;
31
+ }
32
+
33
+ async function main() {
34
+ const secrets = findSecrets();
35
+ if (!secrets) {
36
+ process.stderr.write('[ccr-secure-config] secrets.cjs not found — skipping CCR config population\n');
37
+ process.exit(0);
38
+ }
39
+
40
+ let akashKey, togetherKey, fireworksKey;
41
+ try {
42
+ akashKey = await secrets.get('qgsd', 'AKASHML_API_KEY');
43
+ togetherKey = await secrets.get('qgsd', 'TOGETHER_API_KEY');
44
+ fireworksKey = await secrets.get('qgsd', 'FIREWORKS_API_KEY');
45
+ } catch (e) {
46
+ process.stderr.write('[ccr-secure-config] keytar unavailable: ' + e.message + '\n');
47
+ process.exit(0);
48
+ }
49
+
50
+ if (!akashKey && !togetherKey && !fireworksKey) {
51
+ process.stderr.write('[ccr-secure-config] No CCR provider keys found in keytar — run manage-agents (option 9) to set them\n');
52
+ process.exit(0);
53
+ }
54
+
55
+ let config;
56
+ try {
57
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
58
+ config = JSON.parse(raw);
59
+ } catch (e) {
60
+ process.stderr.write('[ccr-secure-config] Could not read ' + CONFIG_PATH + ': ' + e.message + '\n');
61
+ process.exit(1);
62
+ }
63
+
64
+ if (!Array.isArray(config.providers)) {
65
+ process.stderr.write('[ccr-secure-config] config.json has no providers array\n');
66
+ process.exit(1);
67
+ }
68
+
69
+ const providerKeyMap = {
70
+ akashml: akashKey,
71
+ together: togetherKey,
72
+ fireworks: fireworksKey,
73
+ };
74
+
75
+ let patched = 0;
76
+ for (const provider of config.providers) {
77
+ if (!provider.name) continue;
78
+ const keyName = provider.name.toLowerCase();
79
+ if (keyName in providerKeyMap && providerKeyMap[keyName]) {
80
+ provider.api_key = providerKeyMap[keyName];
81
+ patched++;
82
+ }
83
+ }
84
+
85
+ // Write config back with restrictive permissions
86
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
87
+
88
+ // Enforce permissions on existing file (writeFileSync mode only applies to new files on some systems)
89
+ try {
90
+ execFileSync('chmod', ['600', CONFIG_PATH]);
91
+ } catch (_) {}
92
+
93
+ console.log('[ccr-secure-config] Populated ' + patched + ' provider key(s)');
94
+ }
95
+
96
+ main().catch((e) => {
97
+ process.stderr.write('[ccr-secure-config] Unexpected error: ' + e.message + '\n');
98
+ process.exit(1);
99
+ });