@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,553 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/qgsd-stop.js
|
|
3
|
+
// Stop hook — quorum verification gate for GSD planning commands.
|
|
4
|
+
//
|
|
5
|
+
// Reads JSON from stdin (Claude Code Stop event payload), applies guards in
|
|
6
|
+
// strict order, then scans the current-turn transcript window for quorum
|
|
7
|
+
// evidence. Blocks with decision:block if a planning command was issued but
|
|
8
|
+
// quorum tool calls are missing. Fails open on all errors.
|
|
9
|
+
//
|
|
10
|
+
// Config: ~/.claude/qgsd.json (two-layer merge via shared config-loader)
|
|
11
|
+
// Unavailability: reads ~/.claude.json mcpServers to detect which models are installed
|
|
12
|
+
// (QGSD_CLAUDE_JSON env var overrides the path — for testing only)
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
const { loadConfig, DEFAULT_CONFIG, slotToToolCall } = require('./config-loader');
|
|
21
|
+
const { schema_version } = require('./conformance-schema.cjs');
|
|
22
|
+
|
|
23
|
+
// Appends a structured conformance event to .planning/conformance-events.jsonl.
|
|
24
|
+
// Uses appendFileSync (atomic for writes < POSIX PIPE_BUF = 4096 bytes).
|
|
25
|
+
// Always wrapped in try/catch — hooks are fail-open; never crashes on logging failure.
|
|
26
|
+
// NEVER writes to stdout — stdout is the Claude Code hook decision channel.
|
|
27
|
+
function appendConformanceEvent(event) {
|
|
28
|
+
try {
|
|
29
|
+
const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
|
|
30
|
+
const logPath = pp.resolve(process.cwd(), 'conformance-events');
|
|
31
|
+
fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
process.stderr.write('[qgsd] conformance log write failed: ' + err.message + '\n');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Builds the regex that matches /gsd:<quorum-command> or /qgsd:<quorum-command> in any text.
|
|
38
|
+
function buildCommandPattern(quorumCommands) {
|
|
39
|
+
const escaped = quorumCommands.map(c => c.replace(/-/g, '\\-'));
|
|
40
|
+
return new RegExp('\\/q?gsd:(' + escaped.join('|') + ')');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Returns true if a parsed JSONL user entry is a human text message.
|
|
44
|
+
// Excludes tool_result-only messages (intermediate turn messages from
|
|
45
|
+
// multi-step tool call sequences) which also use type:"user" in the JSONL.
|
|
46
|
+
function isHumanMessage(entry) {
|
|
47
|
+
const content = entry.message?.content;
|
|
48
|
+
if (typeof content === 'string') return content.length > 0;
|
|
49
|
+
if (Array.isArray(content)) return content.some(c => c?.type === 'text');
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Scans backward from end to find last HUMAN user message boundary.
|
|
54
|
+
// Skips tool_result user messages — those are intermediate turn messages,
|
|
55
|
+
// not human turn boundaries. Returns all lines from the boundary forward.
|
|
56
|
+
// If no human message is found, returns all lines (conservative — treats
|
|
57
|
+
// whole transcript as current turn to avoid false passes).
|
|
58
|
+
function getCurrentTurnLines(lines) {
|
|
59
|
+
let lastUserIdx = -1;
|
|
60
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
61
|
+
try {
|
|
62
|
+
const entry = JSON.parse(lines[i]);
|
|
63
|
+
if (entry.type === 'user' && isHumanMessage(entry)) {
|
|
64
|
+
lastUserIdx = i;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip malformed lines
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return lastUserIdx >= 0 ? lines.slice(lastUserIdx) : lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extracts the value of the <command-name> XML tag injected by Claude Code for real slash command
|
|
75
|
+
// invocations. Returns the trimmed tag content or null if the tag is absent.
|
|
76
|
+
// This tag is ONLY present for actual invocations — never in @file-expanded workflow content.
|
|
77
|
+
function extractCommandTag(entry) {
|
|
78
|
+
let text = '';
|
|
79
|
+
const content = entry.message?.content;
|
|
80
|
+
if (typeof content === 'string') {
|
|
81
|
+
text = content;
|
|
82
|
+
} else if (Array.isArray(content)) {
|
|
83
|
+
const first = content.find(c => c?.type === 'text');
|
|
84
|
+
text = first ? (first.text || '') : '';
|
|
85
|
+
}
|
|
86
|
+
const m = text.match(/<command-name>([\s\S]*?)<\/command-name>/);
|
|
87
|
+
return m ? m[1].trim() : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Returns true if any user entry in currentTurnLines contains a GSD quorum command.
|
|
91
|
+
// Uses XML-tag-first strategy: if a <command-name> tag is present, only that tag is tested
|
|
92
|
+
// against cmdPattern (never the full body). Falls back to first 300 chars of message text
|
|
93
|
+
// when no tag is found, to avoid matching @file-expanded workflow content.
|
|
94
|
+
function hasQuorumCommand(currentTurnLines, cmdPattern) {
|
|
95
|
+
for (const line of currentTurnLines) {
|
|
96
|
+
try {
|
|
97
|
+
const entry = JSON.parse(line);
|
|
98
|
+
if (entry.type !== 'user') continue;
|
|
99
|
+
const tag = extractCommandTag(entry);
|
|
100
|
+
if (tag !== null) {
|
|
101
|
+
// Tag present: test only the tag value — do NOT fall through to body scan
|
|
102
|
+
if (cmdPattern.test(tag)) return true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// No tag: fall back to first 300 chars of message text (avoids @file-expanded content)
|
|
106
|
+
const content = entry.message?.content;
|
|
107
|
+
let textContent = '';
|
|
108
|
+
if (typeof content === 'string') {
|
|
109
|
+
textContent = content;
|
|
110
|
+
} else if (Array.isArray(content)) {
|
|
111
|
+
const first = content.find(c => c?.type === 'text');
|
|
112
|
+
textContent = first ? (first.text || '') : '';
|
|
113
|
+
}
|
|
114
|
+
if (cmdPattern.test(textContent.slice(0, 300))) return true;
|
|
115
|
+
} catch {
|
|
116
|
+
// Skip malformed lines
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extracts the matched /gsd:<command> or /qgsd:<command> text from the first matching user line.
|
|
123
|
+
// Uses XML-tag-first strategy: prefers the <command-name> tag for accurate command identification.
|
|
124
|
+
// Falls back to first 300 chars of message text, then to '/qgsd:plan-phase' as ultimate fallback.
|
|
125
|
+
function extractCommand(currentTurnLines, cmdPattern) {
|
|
126
|
+
for (const line of currentTurnLines) {
|
|
127
|
+
try {
|
|
128
|
+
const entry = JSON.parse(line);
|
|
129
|
+
if (entry.type !== 'user') continue;
|
|
130
|
+
const tag = extractCommandTag(entry);
|
|
131
|
+
if (tag !== null) {
|
|
132
|
+
const tagMatch = cmdPattern.exec(tag);
|
|
133
|
+
if (tagMatch) return tagMatch[0];
|
|
134
|
+
continue; // Tag present but wrong command — do not fall through
|
|
135
|
+
}
|
|
136
|
+
// No tag: fall back to first 300 chars of message text
|
|
137
|
+
const content = entry.message?.content;
|
|
138
|
+
let textContent = '';
|
|
139
|
+
if (typeof content === 'string') {
|
|
140
|
+
textContent = content;
|
|
141
|
+
} else if (Array.isArray(content)) {
|
|
142
|
+
const first = content.find(c => c?.type === 'text');
|
|
143
|
+
textContent = first ? (first.text || '') : '';
|
|
144
|
+
}
|
|
145
|
+
const textMatch = cmdPattern.exec(textContent.slice(0, 300));
|
|
146
|
+
if (textMatch) return textMatch[0];
|
|
147
|
+
} catch {
|
|
148
|
+
// Skip malformed lines
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return '/qgsd:plan-phase';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Returns true if any assistant turn used Task(subagent_type=qgsd-quorum-slot-worker).
|
|
155
|
+
// Slot-workers are the inline dispatch mechanism — one per active slot per round.
|
|
156
|
+
// Replaced qgsd-quorum-orchestrator (deprecated quick-103) as full quorum evidence.
|
|
157
|
+
function wasSlotWorkerUsed(currentTurnLines) {
|
|
158
|
+
for (const line of currentTurnLines) {
|
|
159
|
+
try {
|
|
160
|
+
const entry = JSON.parse(line);
|
|
161
|
+
if (entry.type !== 'assistant') continue;
|
|
162
|
+
const content = entry.message && entry.message.content;
|
|
163
|
+
if (!Array.isArray(content)) continue;
|
|
164
|
+
for (const block of content) {
|
|
165
|
+
if (block.type !== 'tool_use' || block.name !== 'Task') continue;
|
|
166
|
+
const input = block.input || {};
|
|
167
|
+
const subagentType = input.subagent_type || input.subagentType || '';
|
|
168
|
+
if (subagentType === 'qgsd-quorum-slot-worker') return true;
|
|
169
|
+
}
|
|
170
|
+
} catch { /* skip */ }
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Returns true if any assistant turn made a tool_use call whose name starts with prefix.
|
|
176
|
+
function wasSlotCalled(currentTurnLines, prefix) {
|
|
177
|
+
for (const line of currentTurnLines) {
|
|
178
|
+
try {
|
|
179
|
+
const entry = JSON.parse(line);
|
|
180
|
+
if (entry.type !== 'assistant') continue;
|
|
181
|
+
const content = entry.message && entry.message.content;
|
|
182
|
+
if (!Array.isArray(content)) continue;
|
|
183
|
+
for (const block of content) {
|
|
184
|
+
if (block.type === 'tool_use' && block.name && block.name.startsWith(prefix)) return true;
|
|
185
|
+
}
|
|
186
|
+
} catch { /* skip */ }
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Returns true if the slot was called AND its tool_result was NOT an error.
|
|
192
|
+
// Specifically:
|
|
193
|
+
// 1. Scans for assistant entries with tool_use blocks whose name starts with prefix. Records IDs.
|
|
194
|
+
// 2. Scans for user entries whose content contains a tool_result matching one of those IDs.
|
|
195
|
+
// 3. If the tool_result content contains "type":"tool_error" or is_error:true, returns false.
|
|
196
|
+
// 4. If a matching non-error tool_result exists, returns true.
|
|
197
|
+
// 5. If no tool_use found for this prefix, returns false.
|
|
198
|
+
// Errors and quota responses do NOT count toward the ceiling.
|
|
199
|
+
function wasSlotCalledSuccessfully(currentTurnLines, prefix) {
|
|
200
|
+
// Step 1: collect tool_use IDs for this prefix
|
|
201
|
+
const toolUseIds = new Set();
|
|
202
|
+
for (const line of currentTurnLines) {
|
|
203
|
+
try {
|
|
204
|
+
const entry = JSON.parse(line);
|
|
205
|
+
if (entry.type !== 'assistant') continue;
|
|
206
|
+
const content = entry.message && entry.message.content;
|
|
207
|
+
if (!Array.isArray(content)) continue;
|
|
208
|
+
for (const block of content) {
|
|
209
|
+
if (block.type === 'tool_use' && block.name && block.name.startsWith(prefix)) {
|
|
210
|
+
if (block.id) toolUseIds.add(block.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch { /* skip */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (toolUseIds.size === 0) return false; // No tool_use found for this prefix
|
|
217
|
+
|
|
218
|
+
// Step 2: find matching tool_result entries and check for errors
|
|
219
|
+
for (const line of currentTurnLines) {
|
|
220
|
+
try {
|
|
221
|
+
const entry = JSON.parse(line);
|
|
222
|
+
if (entry.type !== 'user') continue;
|
|
223
|
+
const content = entry.message && entry.message.content;
|
|
224
|
+
if (!Array.isArray(content)) continue;
|
|
225
|
+
for (const block of content) {
|
|
226
|
+
if (block.type !== 'tool_result') continue;
|
|
227
|
+
if (!toolUseIds.has(block.tool_use_id)) continue;
|
|
228
|
+
// Matching tool_result found — check for error
|
|
229
|
+
const contentStr = JSON.stringify(block.content || '');
|
|
230
|
+
const isError = block.is_error === true || contentStr.includes('"type":"tool_error"');
|
|
231
|
+
if (isError) return false; // Error/quota response — does not count
|
|
232
|
+
return true; // Non-error response — counts toward ceiling
|
|
233
|
+
}
|
|
234
|
+
} catch { /* skip */ }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// tool_use found but no tool_result yet — treat as not successfully called
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Builds the ordered agent pool from config.
|
|
242
|
+
// If quorum_active is set, derives pool from it (preferred path).
|
|
243
|
+
// Falls back to required_models for backward compat.
|
|
244
|
+
// Returns array of { slot, prefix, authType, callTool }.
|
|
245
|
+
function buildAgentPool(config) {
|
|
246
|
+
const agentConfig = config.agent_config || {};
|
|
247
|
+
const quorumActive = config.quorum_active || [];
|
|
248
|
+
|
|
249
|
+
let entries;
|
|
250
|
+
if (quorumActive.length > 0) {
|
|
251
|
+
entries = quorumActive.map(slot => ({
|
|
252
|
+
slot,
|
|
253
|
+
prefix: 'mcp__' + slot + '__',
|
|
254
|
+
authType: (agentConfig[slot] && agentConfig[slot].auth_type) || 'api',
|
|
255
|
+
callTool: slotToToolCall(slot),
|
|
256
|
+
}));
|
|
257
|
+
} else {
|
|
258
|
+
// Backward compat: derive from required_models
|
|
259
|
+
// Use deriveMissingToolName to get canonical tool names (e.g. copilot → __ask not __copilot)
|
|
260
|
+
entries = Object.entries(config.required_models || {}).map(([key, def]) => ({
|
|
261
|
+
slot: key,
|
|
262
|
+
prefix: def.tool_prefix,
|
|
263
|
+
authType: 'api',
|
|
264
|
+
callTool: deriveMissingToolName(key, def),
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Sort sub before api when preferSub is enabled
|
|
269
|
+
if (config.quorum && config.quorum.preferSub) {
|
|
270
|
+
entries.sort((a, b) => {
|
|
271
|
+
if (a.authType === 'sub' && b.authType !== 'sub') return -1;
|
|
272
|
+
if (a.authType !== 'sub' && b.authType === 'sub') return 1;
|
|
273
|
+
return 0;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return entries;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reads ~/.claude.json to determine which MCP servers are registered.
|
|
281
|
+
// Returns an array of derived tool prefixes (e.g. ['mcp__codex-cli-1__', 'mcp__gemini-cli-1__']).
|
|
282
|
+
// Returns null if the file is missing or malformed — callers treat null as "unknown" (conservative).
|
|
283
|
+
//
|
|
284
|
+
// TESTING ONLY: set QGSD_CLAUDE_JSON env var to override the file path.
|
|
285
|
+
// In production, always reads ~/.claude.json.
|
|
286
|
+
//
|
|
287
|
+
// KNOWN LIMITATION: Only reads ~/.claude.json (user-scoped MCPs). Project-scoped MCPs
|
|
288
|
+
// configured in .mcp.json are not checked. If a required model is only configured at
|
|
289
|
+
// project level, it will be classified as unavailable and skipped (fail-open).
|
|
290
|
+
// In practice, quorum models (Codex, Gemini, OpenCode) are global tools.
|
|
291
|
+
function getAvailableMcpPrefixes() {
|
|
292
|
+
const claudeJsonPath = process.env.QGSD_CLAUDE_JSON || path.join(os.homedir(), '.claude.json');
|
|
293
|
+
if (!fs.existsSync(claudeJsonPath)) return null;
|
|
294
|
+
try {
|
|
295
|
+
const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
296
|
+
const servers = d.mcpServers || {};
|
|
297
|
+
return Object.keys(servers).map(name => 'mcp__' + name + '__');
|
|
298
|
+
} catch (e) {
|
|
299
|
+
process.stderr.write('[qgsd] WARNING: Could not parse ~/.claude.json: ' + e.message + '\n');
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Planning artifact file path patterns — matches only planning artifacts, not codebase docs.
|
|
305
|
+
// Each pattern is specific enough to avoid false positives from ls/cat/grep Bash calls.
|
|
306
|
+
const ARTIFACT_PATTERNS = [
|
|
307
|
+
/-PLAN\.md/, // e.g. 04-01-PLAN.md
|
|
308
|
+
/-RESEARCH\.md/, // e.g. 04-RESEARCH.md
|
|
309
|
+
/-CONTEXT\.md/, // e.g. 04-CONTEXT.md (discuss-phase output)
|
|
310
|
+
/-UAT\.md/, // e.g. 04-UAT.md (verify-work UAT output)
|
|
311
|
+
/ROADMAP\.md/, // ROADMAP.md (new-project, new-milestone)
|
|
312
|
+
/REQUIREMENTS\.md/, // REQUIREMENTS.md (new-project)
|
|
313
|
+
/PROJECT\.md/, // PROJECT.md (new-project early commit)
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
// Returns true if the current turn contains a Bash tool_use block that BOTH:
|
|
317
|
+
// (a) invokes gsd-tools.cjs commit, AND
|
|
318
|
+
// (b) references a planning artifact file path (not codebase/*.md).
|
|
319
|
+
// Requiring both conditions prevents false positives from ls/cat/grep commands.
|
|
320
|
+
function hasArtifactCommit(currentTurnLines) {
|
|
321
|
+
for (const line of currentTurnLines) {
|
|
322
|
+
try {
|
|
323
|
+
const entry = JSON.parse(line);
|
|
324
|
+
if (entry.type !== 'assistant') continue;
|
|
325
|
+
const content = entry.message && entry.message.content;
|
|
326
|
+
if (!Array.isArray(content)) continue;
|
|
327
|
+
for (const block of content) {
|
|
328
|
+
if (block.type !== 'tool_use') continue;
|
|
329
|
+
if (block.name !== 'Bash') continue;
|
|
330
|
+
const cmdStr = JSON.stringify(block.input || '');
|
|
331
|
+
// Both conditions must hold in the same Bash block
|
|
332
|
+
if (!cmdStr.includes('gsd-tools.cjs commit')) continue;
|
|
333
|
+
if (ARTIFACT_PATTERNS.some(p => p.test(cmdStr))) return true;
|
|
334
|
+
}
|
|
335
|
+
} catch { /* skip */ }
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The exact token Claude must include in its final output to mark a decision turn.
|
|
341
|
+
// Used by hasDecisionMarker (Stop hook) and injected into Claude's context (Prompt hook).
|
|
342
|
+
const DECISION_MARKER = '<!-- GSD_DECISION -->';
|
|
343
|
+
|
|
344
|
+
// Returns true if the last assistant text block in currentTurnLines contains DECISION_MARKER.
|
|
345
|
+
// Walks lines in reverse to find the most recent assistant entry with a text content block.
|
|
346
|
+
function hasDecisionMarker(currentTurnLines) {
|
|
347
|
+
for (let i = currentTurnLines.length - 1; i >= 0; i--) {
|
|
348
|
+
try {
|
|
349
|
+
const entry = JSON.parse(currentTurnLines[i]);
|
|
350
|
+
if (entry.type !== 'assistant') continue;
|
|
351
|
+
const content = entry.message && entry.message.content;
|
|
352
|
+
if (!Array.isArray(content)) continue;
|
|
353
|
+
for (const block of content) {
|
|
354
|
+
if (block.type !== 'text') continue;
|
|
355
|
+
if (block.text && block.text.includes(DECISION_MARKER)) return true;
|
|
356
|
+
}
|
|
357
|
+
// Found the last assistant entry — no text block with marker → stop scanning
|
|
358
|
+
break;
|
|
359
|
+
} catch { /* skip */ }
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Parses --n N flag from a text string.
|
|
365
|
+
// Returns N (integer >= 1) if found, or null if absent/invalid.
|
|
366
|
+
function parseQuorumSizeFlag(text) {
|
|
367
|
+
const m = text.match(/--n\s+(\d+)/);
|
|
368
|
+
if (!m) return null;
|
|
369
|
+
const n = parseInt(m[1], 10);
|
|
370
|
+
return (Number.isInteger(n) && n >= 1) ? n : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Extracts the user-typed prompt text from the current turn lines.
|
|
374
|
+
// Checks <command-name> tag first; falls back to first 300 chars of message text.
|
|
375
|
+
function extractPromptText(currentTurnLines) {
|
|
376
|
+
for (const line of currentTurnLines) {
|
|
377
|
+
try {
|
|
378
|
+
const entry = JSON.parse(line);
|
|
379
|
+
if (entry.type !== 'user') continue;
|
|
380
|
+
const tag = extractCommandTag(entry);
|
|
381
|
+
if (tag !== null) return tag;
|
|
382
|
+
const content = entry.message?.content;
|
|
383
|
+
if (typeof content === 'string') return content.slice(0, 300);
|
|
384
|
+
if (Array.isArray(content)) {
|
|
385
|
+
const first = content.find(c => c?.type === 'text');
|
|
386
|
+
return first ? (first.text || '').slice(0, 300) : '';
|
|
387
|
+
}
|
|
388
|
+
} catch { /* skip */ }
|
|
389
|
+
}
|
|
390
|
+
return '';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Derives the canonical tool name for the block reason message.
|
|
394
|
+
// Uses known model keys first; falls back to prefix + key for unknown models.
|
|
395
|
+
function deriveMissingToolName(modelKey, modelDef) {
|
|
396
|
+
const prefix = modelDef.tool_prefix;
|
|
397
|
+
if (modelKey === 'codex') return prefix + 'review';
|
|
398
|
+
if (modelKey === 'gemini') return prefix + 'gemini';
|
|
399
|
+
if (modelKey === 'opencode') return prefix + 'opencode';
|
|
400
|
+
if (modelKey === 'copilot') return prefix + 'ask';
|
|
401
|
+
return prefix + modelKey;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function main() {
|
|
405
|
+
let raw = '';
|
|
406
|
+
process.stdin.setEncoding('utf8');
|
|
407
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
408
|
+
process.stdin.on('end', () => {
|
|
409
|
+
try {
|
|
410
|
+
const input = JSON.parse(raw);
|
|
411
|
+
|
|
412
|
+
// GUARD 1: Infinite loop prevention — MUST be first (STOP-02)
|
|
413
|
+
if (input.stop_hook_active) {
|
|
414
|
+
process.exit(0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// GUARD 2: Subagent exclusion (STOP-03)
|
|
418
|
+
if (input.hook_event_name === 'SubagentStop') {
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// GUARD 3: Transcript must exist (fail-open)
|
|
423
|
+
if (!input.transcript_path || !fs.existsSync(input.transcript_path)) {
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const config = loadConfig();
|
|
428
|
+
|
|
429
|
+
// Read and split transcript JSONL; skip empty lines
|
|
430
|
+
const lines = fs.readFileSync(input.transcript_path, 'utf8')
|
|
431
|
+
.split('\n')
|
|
432
|
+
.filter(l => l.trim().length > 0);
|
|
433
|
+
|
|
434
|
+
// Scope to current turn: lines since last user message (STOP-04)
|
|
435
|
+
const currentTurnLines = getCurrentTurnLines(lines);
|
|
436
|
+
|
|
437
|
+
// Build command pattern once; reuse for detection and extraction
|
|
438
|
+
const cmdPattern = buildCommandPattern(config.quorum_commands);
|
|
439
|
+
|
|
440
|
+
// GUARD 4: Only enforce quorum if a planning command is in current turn (STOP-06)
|
|
441
|
+
if (!hasQuorumCommand(currentTurnLines, cmdPattern)) {
|
|
442
|
+
process.exit(0);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// GUARD 5: Only enforce quorum on project decision turns (SCOPE-01, SCOPE-02, SCOPE-03)
|
|
446
|
+
// A turn is a decision turn if it contains a planning artifact commit OR a decision marker.
|
|
447
|
+
// GSD-internal operation turns (routing, agent spawning, questioning) have neither.
|
|
448
|
+
const isDecisionTurn = hasArtifactCommit(currentTurnLines) || hasDecisionMarker(currentTurnLines);
|
|
449
|
+
if (!isDecisionTurn) {
|
|
450
|
+
process.exit(0); // GSD-internal operation — not a project decision turn
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Extract --n N flag from current-turn user prompt (if present)
|
|
454
|
+
const promptText = extractPromptText(currentTurnLines);
|
|
455
|
+
const quorumSizeOverride = parseQuorumSizeFlag(promptText);
|
|
456
|
+
const soloMode = quorumSizeOverride === 1;
|
|
457
|
+
|
|
458
|
+
// GUARD 6: Solo mode (--n 1) — Claude-only quorum, no external models required
|
|
459
|
+
if (soloMode) {
|
|
460
|
+
appendConformanceEvent({
|
|
461
|
+
ts: new Date().toISOString(),
|
|
462
|
+
phase: 'DECIDING',
|
|
463
|
+
action: 'quorum_complete',
|
|
464
|
+
slots_available: 0,
|
|
465
|
+
vote_result: 1,
|
|
466
|
+
outcome: 'APPROVE',
|
|
467
|
+
schema_version,
|
|
468
|
+
});
|
|
469
|
+
process.exit(0); // Solo mode: Claude's vote is the quorum — no block
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Build agent pool from config
|
|
473
|
+
const agentPool = buildAgentPool(config);
|
|
474
|
+
|
|
475
|
+
// Get available MCP prefixes from ~/.claude.json
|
|
476
|
+
const availablePrefixes = getAvailableMcpPrefixes();
|
|
477
|
+
|
|
478
|
+
// Check if slot-workers handled quorum (inline dispatch counts as full quorum evidence)
|
|
479
|
+
if (wasSlotWorkerUsed(currentTurnLines)) {
|
|
480
|
+
process.exit(0);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Ceiling: require maxSize successful (non-error) responses from the full pool.
|
|
484
|
+
// Named maxSize for consistency with qgsd-prompt.js and the config schema.
|
|
485
|
+
// --n N override: N total participants means N-1 external models required.
|
|
486
|
+
const maxSize = quorumSizeOverride !== null && quorumSizeOverride > 1
|
|
487
|
+
? quorumSizeOverride - 1 // --n N means N-1 external models required
|
|
488
|
+
: (config.quorum && Number.isInteger(config.quorum.maxSize) && config.quorum.maxSize >= 1)
|
|
489
|
+
? config.quorum.maxSize
|
|
490
|
+
: 2;
|
|
491
|
+
|
|
492
|
+
// Iterate the full agentPool (already sorted sub-first when preferSub is set).
|
|
493
|
+
// Count successful (non-error) responses. Stop once ceiling is satisfied.
|
|
494
|
+
// missingAgents is populated for failure reporting — only read in the block path below.
|
|
495
|
+
let successCount = 0;
|
|
496
|
+
const missingAgents = []; // only read when successCount < maxSize (block path)
|
|
497
|
+
for (const agent of agentPool) {
|
|
498
|
+
// If availablePrefixes is null (unknown), treat as available (conservative enforcement)
|
|
499
|
+
const isAvailable = availablePrefixes === null || availablePrefixes.includes(agent.prefix);
|
|
500
|
+
if (!isAvailable) continue; // Model not installed — skip
|
|
501
|
+
|
|
502
|
+
if (wasSlotCalledSuccessfully(currentTurnLines, agent.prefix)) {
|
|
503
|
+
successCount++;
|
|
504
|
+
if (successCount >= maxSize) break; // ceiling satisfied — stop counting
|
|
505
|
+
} else {
|
|
506
|
+
// Track failures for error reporting (only used in the block path below)
|
|
507
|
+
let toolName;
|
|
508
|
+
if (agent.callTool) {
|
|
509
|
+
toolName = agent.callTool;
|
|
510
|
+
} else {
|
|
511
|
+
toolName = deriveMissingToolName(agent.slot, { tool_prefix: agent.prefix });
|
|
512
|
+
}
|
|
513
|
+
missingAgents.push(toolName);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (successCount < maxSize) {
|
|
518
|
+
// Only read missingAgents here — never in the success path
|
|
519
|
+
appendConformanceEvent({
|
|
520
|
+
ts: new Date().toISOString(),
|
|
521
|
+
phase: 'DECIDING',
|
|
522
|
+
action: 'quorum_block',
|
|
523
|
+
slots_available: agentPool.length,
|
|
524
|
+
vote_result: successCount,
|
|
525
|
+
outcome: 'BLOCK',
|
|
526
|
+
schema_version,
|
|
527
|
+
});
|
|
528
|
+
process.stdout.write(JSON.stringify({
|
|
529
|
+
decision: 'block',
|
|
530
|
+
reason: 'QUORUM REQUIRED: Missing tool calls for: ' + missingAgents.join(', ') + '. Run the required quorum agent(s) before completing this planning command.'
|
|
531
|
+
}));
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
appendConformanceEvent({
|
|
536
|
+
ts: new Date().toISOString(),
|
|
537
|
+
phase: 'DECIDING',
|
|
538
|
+
action: 'quorum_complete',
|
|
539
|
+
slots_available: agentPool.length,
|
|
540
|
+
vote_result: successCount,
|
|
541
|
+
outcome: 'APPROVE',
|
|
542
|
+
schema_version,
|
|
543
|
+
});
|
|
544
|
+
process.exit(0);
|
|
545
|
+
|
|
546
|
+
} catch {
|
|
547
|
+
// Fail-open: never crash the user's session on any unexpected error
|
|
548
|
+
process.exit(0);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
main();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/qgsd-token-collector.js
|
|
3
|
+
// SubagentStop hook — reads agent_transcript_path, sums message.usage fields,
|
|
4
|
+
// appends a token record to .planning/token-usage.jsonl.
|
|
5
|
+
//
|
|
6
|
+
// Guards:
|
|
7
|
+
// - Only processes agent_type === 'qgsd-quorum-slot-worker' (exits 0 otherwise)
|
|
8
|
+
// - If transcript path is absent or missing: writes null-token record and exits 0 (fail-open)
|
|
9
|
+
// - isSidechain === true entries are excluded from token sum
|
|
10
|
+
// - isApiErrorMessage === true entries are excluded from token sum
|
|
11
|
+
// - Never writes to stdout (stdout is the Claude Code hook decision channel)
|
|
12
|
+
// - Fail-open: any unhandled error exits 0 without crashing the user's session
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
// Resolve slot name from correlation file or last_assistant_message preamble.
|
|
20
|
+
// Order:
|
|
21
|
+
// 1. Read correlation file at .planning/quorum-slot-corr-<agent_id>.json
|
|
22
|
+
// - If slot is set → return it (and delete file)
|
|
23
|
+
// - If slot is null → delete file, fall through to next
|
|
24
|
+
// 2. Parse "slot: <name>" from first matching line in last_assistant_message
|
|
25
|
+
// 3. Fallback: return agent_id or 'unknown'
|
|
26
|
+
function resolveSlot(input) {
|
|
27
|
+
if (input.agent_id) {
|
|
28
|
+
const pp = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
|
|
29
|
+
const corrPath = pp.resolveWithFallback(process.cwd(), 'quorum-correlation', { agentId: input.agent_id });
|
|
30
|
+
if (fs.existsSync(corrPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const data = JSON.parse(fs.readFileSync(corrPath, 'utf8'));
|
|
33
|
+
try { fs.unlinkSync(corrPath); } catch (_) {}
|
|
34
|
+
if (data.slot) return data.slot;
|
|
35
|
+
// slot is null → fall through to last_assistant_message
|
|
36
|
+
} catch (_) {
|
|
37
|
+
// Malformed correlation file — fall through
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse from last_assistant_message preamble
|
|
43
|
+
if (input.last_assistant_message) {
|
|
44
|
+
const m = input.last_assistant_message.match(/^slot:\s*(\S+)/m);
|
|
45
|
+
if (m) return m[1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Final fallback
|
|
49
|
+
return input.agent_id || 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Append a token record to .planning/token-usage.jsonl.
|
|
53
|
+
// Never throws — any error is silently swallowed (observational).
|
|
54
|
+
function appendRecord(input, inputTokens, outputTokens, cacheCreate, cacheRead) {
|
|
55
|
+
try {
|
|
56
|
+
const slot = resolveSlot(input);
|
|
57
|
+
const record = JSON.stringify({
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
session_id: input.session_id || null,
|
|
60
|
+
agent_id: input.agent_id || null,
|
|
61
|
+
slot,
|
|
62
|
+
input_tokens: inputTokens,
|
|
63
|
+
output_tokens: outputTokens,
|
|
64
|
+
cache_creation_input_tokens: cacheCreate,
|
|
65
|
+
cache_read_input_tokens: cacheRead,
|
|
66
|
+
});
|
|
67
|
+
const pp2 = require(path.join(__dirname, '..', 'bin', 'planning-paths.cjs'));
|
|
68
|
+
const logPath = pp2.resolve(process.cwd(), 'token-usage');
|
|
69
|
+
fs.appendFileSync(logPath, record + '\n', 'utf8');
|
|
70
|
+
} catch (_) {} // observational — never fails
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main() {
|
|
74
|
+
let raw = '';
|
|
75
|
+
process.stdin.setEncoding('utf8');
|
|
76
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
77
|
+
process.stdin.on('end', () => {
|
|
78
|
+
try {
|
|
79
|
+
const input = JSON.parse(raw);
|
|
80
|
+
|
|
81
|
+
// Guard: only process qgsd-quorum-slot-worker subagents
|
|
82
|
+
if (input.agent_type !== 'qgsd-quorum-slot-worker') {
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Guard: absent or missing transcript → null sentinel record
|
|
87
|
+
if (!input.agent_transcript_path || !fs.existsSync(input.agent_transcript_path)) {
|
|
88
|
+
appendRecord(input, null, null, null, null);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Read and parse transcript JSONL
|
|
93
|
+
const rawTranscript = fs.readFileSync(input.agent_transcript_path, 'utf8');
|
|
94
|
+
const lines = rawTranscript.split('\n').filter(l => l.trim().length > 0);
|
|
95
|
+
|
|
96
|
+
let inputSum = 0;
|
|
97
|
+
let outputSum = 0;
|
|
98
|
+
let cacheCreateSum = 0;
|
|
99
|
+
let cacheReadSum = 0;
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
try {
|
|
103
|
+
const entry = JSON.parse(line);
|
|
104
|
+
// Only sum assistant entries
|
|
105
|
+
if (entry.type !== 'assistant') continue;
|
|
106
|
+
// Exclude sidechain entries
|
|
107
|
+
if (entry.isSidechain === true) continue;
|
|
108
|
+
// Exclude API error entries
|
|
109
|
+
if (entry.isApiErrorMessage === true) continue;
|
|
110
|
+
// Require usage data
|
|
111
|
+
const usage = entry.message && entry.message.usage;
|
|
112
|
+
if (!usage) continue;
|
|
113
|
+
|
|
114
|
+
inputSum += (usage.input_tokens || 0);
|
|
115
|
+
outputSum += (usage.output_tokens || 0);
|
|
116
|
+
cacheCreateSum += (usage.cache_creation_input_tokens || 0);
|
|
117
|
+
cacheReadSum += (usage.cache_read_input_tokens || 0);
|
|
118
|
+
} catch (_) {
|
|
119
|
+
// Skip malformed lines
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
appendRecord(input, inputSum, outputSum, cacheCreateSum, cacheReadSum);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
|
|
126
|
+
} catch (_) {
|
|
127
|
+
// Fail-open: never crash the session on any unexpected error
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main();
|