@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.
- package/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- 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
|
+
}
|