@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
package/bin/nForma.cjs
ADDED
|
@@ -0,0 +1,2726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// ─── Circuit breaker CLI (non-interactive, exits before TUI loads) ───────────
|
|
10
|
+
const cliArgs = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
function getBreakerProjectRoot() {
|
|
13
|
+
const git = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
14
|
+
cwd: process.cwd(), encoding: 'utf8', timeout: 5000,
|
|
15
|
+
});
|
|
16
|
+
return (git.status === 0 && !git.error) ? git.stdout.trim() : process.cwd();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getBreakerStateFile() {
|
|
20
|
+
return path.join(getBreakerProjectRoot(), '.claude', 'circuit-breaker-state.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (cliArgs.includes('--disable-breaker')) {
|
|
24
|
+
const stateFile = getBreakerStateFile();
|
|
25
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
26
|
+
const existing = fs.existsSync(stateFile)
|
|
27
|
+
? JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
28
|
+
: {};
|
|
29
|
+
fs.writeFileSync(stateFile, JSON.stringify({ ...existing, disabled: true, active: false }, null, 2), 'utf8');
|
|
30
|
+
console.log(' \u2298 Circuit breaker disabled. Detection and enforcement paused.');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (cliArgs.includes('--enable-breaker')) {
|
|
35
|
+
const stateFile = getBreakerStateFile();
|
|
36
|
+
if (fs.existsSync(stateFile)) {
|
|
37
|
+
const existing = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
38
|
+
fs.writeFileSync(stateFile, JSON.stringify({ ...existing, disabled: false, active: false }, null, 2), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
console.log(' \u2713 Circuit breaker enabled. Oscillation detection resumed.');
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (cliArgs.includes('--reset-breaker')) {
|
|
45
|
+
const stateFile = getBreakerStateFile();
|
|
46
|
+
if (fs.existsSync(stateFile)) {
|
|
47
|
+
fs.rmSync(stateFile);
|
|
48
|
+
console.log(' \u2713 Circuit breaker state cleared. Claude can resume Bash execution.');
|
|
49
|
+
} else {
|
|
50
|
+
console.log(' No active circuit breaker state found.');
|
|
51
|
+
}
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── TUI (interactive mode — no CLI flags matched) ───────────────────────────
|
|
56
|
+
const blessed = require('blessed');
|
|
57
|
+
let XTerm;
|
|
58
|
+
let _xtermError = null;
|
|
59
|
+
try {
|
|
60
|
+
XTerm = require('blessed-xterm');
|
|
61
|
+
} catch (e) {
|
|
62
|
+
_xtermError = e.message;
|
|
63
|
+
// Auto-rebuild node-pty native addon when missing or compiled for wrong Node ABI
|
|
64
|
+
const needsRebuild = (e.code === 'MODULE_NOT_FOUND' && e.message.includes('pty.node'))
|
|
65
|
+
|| e.code === 'ERR_DLOPEN_FAILED';
|
|
66
|
+
if (needsRebuild) {
|
|
67
|
+
try {
|
|
68
|
+
const { spawnSync } = require('child_process');
|
|
69
|
+
const projRoot = path.join(__dirname, '..');
|
|
70
|
+
// npm rebuild exits 1 even on success — ignore exit code, check file after
|
|
71
|
+
spawnSync('npm', ['rebuild', 'node-pty'], { cwd: projRoot, stdio: 'ignore', timeout: 60000 });
|
|
72
|
+
// Clear all cached blessed-xterm/node-pty modules before retry
|
|
73
|
+
Object.keys(require.cache).forEach(k => {
|
|
74
|
+
if (k.includes('blessed-xterm') || k.includes('node-pty')) delete require.cache[k];
|
|
75
|
+
});
|
|
76
|
+
XTerm = require('blessed-xterm');
|
|
77
|
+
_xtermError = null;
|
|
78
|
+
} catch (rebuildErr) {
|
|
79
|
+
_xtermError = `blessed-xterm native rebuild failed: ${rebuildErr.message}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Reuse logic layer from manage-agents-core.cjs ───────────────────────────
|
|
85
|
+
const core = require('./manage-agents-core.cjs');
|
|
86
|
+
const pure = core._pure;
|
|
87
|
+
|
|
88
|
+
const { readClaudeJson, writeClaudeJson, getGlobalMcpServers } = core;
|
|
89
|
+
const {
|
|
90
|
+
buildDashboardLines, probeAllSlots,
|
|
91
|
+
maskKey, deriveKeytarAccount,
|
|
92
|
+
readQgsdJson, writeQgsdJson,
|
|
93
|
+
buildExportData, validateImportSchema, buildBackupPath,
|
|
94
|
+
buildTimeoutChoices, applyTimeoutUpdate,
|
|
95
|
+
buildPolicyChoices,
|
|
96
|
+
validateTimeout, validateUpdatePolicy,
|
|
97
|
+
runAutoUpdateCheck,
|
|
98
|
+
probeAndPersistKey,
|
|
99
|
+
} = pure;
|
|
100
|
+
|
|
101
|
+
const { updateAgents, getUpdateStatuses } = require('./update-agents.cjs');
|
|
102
|
+
const reqCore = require('./requirements-core.cjs');
|
|
103
|
+
|
|
104
|
+
// ─── File paths ───────────────────────────────────────────────────────────────
|
|
105
|
+
const CLAUDE_JSON_PATH = path.join(os.homedir(), '.claude.json');
|
|
106
|
+
const PROVIDERS_JSON = path.join(__dirname, 'providers.json');
|
|
107
|
+
const PROVIDERS_JSON_TMP = PROVIDERS_JSON + '.tmp';
|
|
108
|
+
|
|
109
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
110
|
+
const PROVIDER_KEY_NAMES = [
|
|
111
|
+
{ key: 'AKASHML_API_KEY', label: 'AkashML API Key' },
|
|
112
|
+
{ key: 'TOGETHER_API_KEY', label: 'Together.xyz API Key' },
|
|
113
|
+
{ key: 'FIREWORKS_API_KEY', label: 'Fireworks API Key' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const PROVIDER_PRESETS = [
|
|
117
|
+
{ label: 'AkashML (api.akashml.com/v1)', value: 'https://api.akashml.com/v1' },
|
|
118
|
+
{ label: 'Together.xyz (api.together.xyz/v1)', value: 'https://api.together.xyz/v1' },
|
|
119
|
+
{ label: 'Fireworks.ai (api.fireworks.ai/inference/v1)', value: 'https://api.fireworks.ai/inference/v1' },
|
|
120
|
+
{ label: 'Custom URL…', value: '__custom__' },
|
|
121
|
+
{ label: 'None (subprocess only)', value: '' },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const MODULES = [
|
|
125
|
+
{
|
|
126
|
+
name: 'Agents',
|
|
127
|
+
icon: '⚡',
|
|
128
|
+
art: ['▄█▄', '█ █', '█▀█'],
|
|
129
|
+
key: 'f1',
|
|
130
|
+
items: [
|
|
131
|
+
{ label: ' List Agents', action: 'list' },
|
|
132
|
+
{ label: ' Add Agent', action: 'add' },
|
|
133
|
+
{ label: ' Clone Slot', action: 'clone' },
|
|
134
|
+
{ label: ' Edit Agent', action: 'edit' },
|
|
135
|
+
{ label: ' Remove Agent', action: 'remove' },
|
|
136
|
+
{ label: ' Reorder Agents', action: 'reorder' },
|
|
137
|
+
{ label: ' Check Agent Health', action: 'health-single' },
|
|
138
|
+
{ label: ' Login / Auth', action: 'login' },
|
|
139
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
140
|
+
{ label: ' Provider Keys', action: 'provider-keys' },
|
|
141
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
142
|
+
{ label: ' Batch Rotate Keys', action: 'batch-rotate' },
|
|
143
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
144
|
+
{ label: ' Live Health', action: 'health' },
|
|
145
|
+
{ label: ' Scoreboard', action: 'scoreboard' },
|
|
146
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
147
|
+
{ label: ' Update Agents', action: 'update-agents' },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'Reqs',
|
|
152
|
+
icon: '◆',
|
|
153
|
+
art: ['█▀█', '██▀', '█ █'],
|
|
154
|
+
key: 'f2',
|
|
155
|
+
items: [
|
|
156
|
+
{ label: ' Browse Reqs', action: 'req-browse' },
|
|
157
|
+
{ label: ' Coverage', action: 'req-coverage' },
|
|
158
|
+
{ label: ' Traceability', action: 'req-traceability' },
|
|
159
|
+
{ label: ' Aggregate', action: 'req-aggregate' },
|
|
160
|
+
{ label: ' Coverage Gaps', action: 'req-gaps' },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'Config',
|
|
165
|
+
icon: '⚙',
|
|
166
|
+
art: ['▄▀▀', '█ ', '▀▄▄'],
|
|
167
|
+
key: 'f3',
|
|
168
|
+
items: [
|
|
169
|
+
{ label: ' Settings', action: 'settings' },
|
|
170
|
+
{ label: ' Tune Timeouts', action: 'tune-timeouts' },
|
|
171
|
+
{ label: ' Set Update Policy', action: 'update-policy' },
|
|
172
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
173
|
+
{ label: ' Export Roster', action: 'export' },
|
|
174
|
+
{ label: ' Import Roster', action: 'import' },
|
|
175
|
+
{ label: ' ─────────────────', action: 'sep' },
|
|
176
|
+
{ label: ' Exit', action: 'exit' },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'Sessions',
|
|
181
|
+
icon: '\u25b6',
|
|
182
|
+
art: ['\u2584\u2580\u2584', '\u2588\u2580\u2580', '\u2580\u2584\u2584'],
|
|
183
|
+
key: 'f4',
|
|
184
|
+
items: [
|
|
185
|
+
{ label: ' New Session', action: 'session-new' },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// Backward compat: flat array of all items across modules (tests + exports rely on this)
|
|
191
|
+
const MENU_ITEMS = MODULES.flatMap(m => m.items);
|
|
192
|
+
|
|
193
|
+
// ─── Event log ──────────────────────────────────────────────────────────────
|
|
194
|
+
const _logEntries = []; // { ts, level, msg }
|
|
195
|
+
const LOG_MAX = 200;
|
|
196
|
+
|
|
197
|
+
function logEvent(level, msg) {
|
|
198
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
199
|
+
_logEntries.push({ ts, level, msg });
|
|
200
|
+
if (_logEntries.length > LOG_MAX) _logEntries.shift();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (_xtermError) logEvent('warn', `blessed-xterm unavailable: ${_xtermError}`);
|
|
204
|
+
|
|
205
|
+
// ─── Session state ───────────────────────────────────────────────────────────
|
|
206
|
+
const sessions = []; // { id, name, cwd, term (XTerm widget), alive }
|
|
207
|
+
let activeSessionIdx = -1; // -1 = no terminal shown
|
|
208
|
+
let sessionIdCounter = 0;
|
|
209
|
+
|
|
210
|
+
// ─── Module switching (activity bar) ──────────────────────────────────────────
|
|
211
|
+
let activeModuleIdx = 0;
|
|
212
|
+
|
|
213
|
+
function switchModule(idx) {
|
|
214
|
+
// Hide active terminal when leaving Sessions module
|
|
215
|
+
if (activeModuleIdx === 3 && activeSessionIdx >= 0 && activeSessionIdx < sessions.length) {
|
|
216
|
+
sessions[activeSessionIdx].term.hide();
|
|
217
|
+
contentBox.show();
|
|
218
|
+
}
|
|
219
|
+
activeModuleIdx = idx;
|
|
220
|
+
const mod = MODULES[idx];
|
|
221
|
+
|
|
222
|
+
// Update activity bar icons — 3-line pixel art per module
|
|
223
|
+
const blocks = MODULES.map((m, i) => {
|
|
224
|
+
const active = (i === idx);
|
|
225
|
+
const C = active ? '{#4a9090-fg}' : '{#666666-fg}';
|
|
226
|
+
const E = '{/}';
|
|
227
|
+
const fk = m.key.toUpperCase();
|
|
228
|
+
const lines = [
|
|
229
|
+
'',
|
|
230
|
+
...m.art.map(row => ` ${C}${row}${E}`),
|
|
231
|
+
` ${C}${fk}${E}`,
|
|
232
|
+
];
|
|
233
|
+
if (i < MODULES.length - 1) lines.push(` {#444444-fg}─────{/}`);
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
});
|
|
236
|
+
activityBar.setContent(blocks.join('\n'));
|
|
237
|
+
|
|
238
|
+
// Swap menu items
|
|
239
|
+
menuList.clearItems();
|
|
240
|
+
menuList.setItems(mod.items.map(m => m.label));
|
|
241
|
+
menuList.setLabel(` {#888888-fg}${mod.name}{/} `);
|
|
242
|
+
menuList.select(0);
|
|
243
|
+
menuList.focus();
|
|
244
|
+
screen.render();
|
|
245
|
+
|
|
246
|
+
// If switching TO Sessions with an active session, reconnect terminal
|
|
247
|
+
if (idx === 3 && activeSessionIdx >= 0 && activeSessionIdx < sessions.length) {
|
|
248
|
+
connectSession(activeSessionIdx);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Auto-show first item's content (skip separators, use view-only for interactive actions)
|
|
253
|
+
const first = mod.items[0];
|
|
254
|
+
if (first && first.action !== 'sep') {
|
|
255
|
+
const viewAction = first.action === 'settings' ? 'settings-view' : first.action;
|
|
256
|
+
dispatch(viewAction);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Session lifecycle ───────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
function refreshSessionMenu() {
|
|
263
|
+
const mod = MODULES[3]; // Sessions
|
|
264
|
+
const items = [{ label: ' New Session', action: 'session-new' }];
|
|
265
|
+
if (sessions.length > 0) {
|
|
266
|
+
items.push({ label: ' \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', action: 'sep' });
|
|
267
|
+
sessions.forEach((s, i) => {
|
|
268
|
+
const status = s.alive ? '{green-fg}\u25cf{/}' : '{red-fg}\u25cb{/}';
|
|
269
|
+
const active = (i === activeSessionIdx) ? '{#4a9090-fg}\u25b8{/} ' : ' ';
|
|
270
|
+
items.push({
|
|
271
|
+
label: `${active}${status} [${s.id}] ${s.name}`,
|
|
272
|
+
action: `session-connect-${i}`,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
items.push({ label: ' \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', action: 'sep' });
|
|
276
|
+
items.push({ label: ' Kill Session', action: 'session-kill' });
|
|
277
|
+
}
|
|
278
|
+
mod.items = items;
|
|
279
|
+
// If Sessions module is active, refresh the visible menu
|
|
280
|
+
if (activeModuleIdx === 3) {
|
|
281
|
+
menuList.clearItems();
|
|
282
|
+
menuList.setItems(mod.items.map(m => m.label));
|
|
283
|
+
screen.render();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createSession(name, cwd) {
|
|
288
|
+
if (!XTerm) {
|
|
289
|
+
toast('Sessions require blessed-xterm (native rebuild needed). Run: npm rebuild', true);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const id = ++sessionIdCounter;
|
|
293
|
+
const term = new XTerm({
|
|
294
|
+
screen,
|
|
295
|
+
parent: screen,
|
|
296
|
+
shell: 'claude',
|
|
297
|
+
args: [],
|
|
298
|
+
cwd: cwd || process.cwd(),
|
|
299
|
+
cursorType: 'block',
|
|
300
|
+
scrollback: 1000,
|
|
301
|
+
top: 3, left: 35, right: 0, bottom: 2,
|
|
302
|
+
border: { type: 'line' },
|
|
303
|
+
style: {
|
|
304
|
+
bg: S.mid,
|
|
305
|
+
border: { fg: S.bdr },
|
|
306
|
+
focus: { border: { fg: '#4a9090' } },
|
|
307
|
+
},
|
|
308
|
+
label: ` {#4a9090-fg}${name}{/} `,
|
|
309
|
+
tags: true,
|
|
310
|
+
ignoreKeys: ['f1', 'f2', 'f3', 'f4', 'C-\\\\'],
|
|
311
|
+
});
|
|
312
|
+
term.hide();
|
|
313
|
+
|
|
314
|
+
const session = { id, name, cwd: cwd || process.cwd(), term, alive: true };
|
|
315
|
+
sessions.push(session);
|
|
316
|
+
|
|
317
|
+
term.on('exit', () => {
|
|
318
|
+
session.alive = false;
|
|
319
|
+
refreshSessionMenu();
|
|
320
|
+
if (activeSessionIdx === sessions.indexOf(session)) {
|
|
321
|
+
toast(`Session "${name}" exited`);
|
|
322
|
+
}
|
|
323
|
+
screen.render();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
refreshSessionMenu();
|
|
327
|
+
connectSession(sessions.length - 1);
|
|
328
|
+
return session;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function connectSession(idx) {
|
|
332
|
+
if (idx < 0 || idx >= sessions.length) return;
|
|
333
|
+
// Hide contentBox
|
|
334
|
+
contentBox.hide();
|
|
335
|
+
// Hide previous active terminal
|
|
336
|
+
if (activeSessionIdx >= 0 && activeSessionIdx < sessions.length) {
|
|
337
|
+
sessions[activeSessionIdx].term.hide();
|
|
338
|
+
}
|
|
339
|
+
// Show and focus new terminal
|
|
340
|
+
activeSessionIdx = idx;
|
|
341
|
+
sessions[idx].term.show();
|
|
342
|
+
sessions[idx].term.focus();
|
|
343
|
+
screen.render();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function disconnectSession() {
|
|
347
|
+
if (activeSessionIdx >= 0 && activeSessionIdx < sessions.length) {
|
|
348
|
+
sessions[activeSessionIdx].term.hide();
|
|
349
|
+
}
|
|
350
|
+
activeSessionIdx = -1;
|
|
351
|
+
contentBox.show();
|
|
352
|
+
menuList.focus();
|
|
353
|
+
screen.render();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function killSession(idx) {
|
|
357
|
+
if (idx < 0 || idx >= sessions.length) return;
|
|
358
|
+
const session = sessions[idx];
|
|
359
|
+
try { session.term.terminate(); } catch (_) {}
|
|
360
|
+
screen.remove(session.term);
|
|
361
|
+
sessions.splice(idx, 1);
|
|
362
|
+
// Adjust activeSessionIdx
|
|
363
|
+
if (activeSessionIdx === idx) {
|
|
364
|
+
disconnectSession();
|
|
365
|
+
} else if (activeSessionIdx > idx) {
|
|
366
|
+
activeSessionIdx--;
|
|
367
|
+
}
|
|
368
|
+
refreshSessionMenu();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function newSessionFlow() {
|
|
372
|
+
const name = await promptInput({ title: 'New Session', prompt: 'Session name:' });
|
|
373
|
+
if (!name) return;
|
|
374
|
+
const cwd = await promptInput({ title: 'New Session', prompt: 'Working directory:', default: process.cwd() });
|
|
375
|
+
createSession(name, cwd || process.cwd());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function killSessionFlow() {
|
|
379
|
+
if (sessions.length === 0) { toast('No sessions to kill'); return; }
|
|
380
|
+
const items = sessions.map((s, i) => ({
|
|
381
|
+
label: `[${s.id}] ${s.name} (${s.alive ? 'alive' : 'dead'})`,
|
|
382
|
+
value: i,
|
|
383
|
+
}));
|
|
384
|
+
const choice = await promptList({ title: 'Kill Session', items });
|
|
385
|
+
killSession(choice.value);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── Providers.json helpers ───────────────────────────────────────────────────
|
|
389
|
+
function readProvidersJson() {
|
|
390
|
+
if (!fs.existsSync(PROVIDERS_JSON)) return { providers: [] };
|
|
391
|
+
return JSON.parse(fs.readFileSync(PROVIDERS_JSON, 'utf8'));
|
|
392
|
+
}
|
|
393
|
+
function writeProvidersJson(data) {
|
|
394
|
+
fs.writeFileSync(PROVIDERS_JSON_TMP, JSON.stringify(data, null, 2), 'utf8');
|
|
395
|
+
fs.renameSync(PROVIDERS_JSON_TMP, PROVIDERS_JSON);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Update policy helper ────────────────────────────────────────────────────
|
|
399
|
+
function writeUpdatePolicy(slotName, policy) {
|
|
400
|
+
const qgsd = readQgsdJson();
|
|
401
|
+
if (!qgsd.agent_config) qgsd.agent_config = {};
|
|
402
|
+
if (!qgsd.agent_config[slotName]) qgsd.agent_config[slotName] = {};
|
|
403
|
+
qgsd.agent_config[slotName].update_policy = policy;
|
|
404
|
+
writeQgsdJson(qgsd);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── Secrets loader (cached — keychain prompted once per process) ─────────────
|
|
408
|
+
let _secretsCache = undefined;
|
|
409
|
+
function loadSecrets() {
|
|
410
|
+
if (_secretsCache !== undefined) return _secretsCache;
|
|
411
|
+
const candidates = [
|
|
412
|
+
path.join(os.homedir(), '.claude', 'qgsd-bin', 'secrets.cjs'),
|
|
413
|
+
path.join(__dirname, 'secrets.cjs'),
|
|
414
|
+
];
|
|
415
|
+
for (const p of candidates) {
|
|
416
|
+
try { if (fs.existsSync(p)) { _secretsCache = require(p); return _secretsCache; } } catch (_) {}
|
|
417
|
+
}
|
|
418
|
+
_secretsCache = null;
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Data helpers ─────────────────────────────────────────────────────────────
|
|
423
|
+
function pad(str, len) { return String(str || '').slice(0, len).padEnd(len); }
|
|
424
|
+
|
|
425
|
+
function deriveProviderName(url) {
|
|
426
|
+
if (!url) return 'subprocess';
|
|
427
|
+
if (url.includes('akashml.com')) return 'AkashML';
|
|
428
|
+
if (url.includes('together.xyz')) return 'Together.xyz';
|
|
429
|
+
if (url.includes('fireworks.ai')) return 'Fireworks';
|
|
430
|
+
if (url.includes('openai.com')) return 'OpenAI';
|
|
431
|
+
if (url.includes('google')) return 'Google';
|
|
432
|
+
try { return new URL(url).hostname.replace(/^api\./, ''); } catch (_) { return url.slice(0, 14); }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* detectModel(meta) — read the live model from the CLI's own config file.
|
|
437
|
+
* Used for subscription-based CLI providers (codex, opencode) that store
|
|
438
|
+
* the active model on disk rather than in an env var.
|
|
439
|
+
* Returns null on any error so callers can fall back gracefully.
|
|
440
|
+
*/
|
|
441
|
+
function detectModel(meta) {
|
|
442
|
+
if (!meta.model_detect) return null;
|
|
443
|
+
try {
|
|
444
|
+
const filePath = meta.model_detect.file.replace(/^~/, os.homedir());
|
|
445
|
+
if (!fs.existsSync(filePath)) return null;
|
|
446
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
447
|
+
const m = content.match(new RegExp(meta.model_detect.pattern, 'm'));
|
|
448
|
+
return m ? m[1] : null;
|
|
449
|
+
} catch (_) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function agentRows() {
|
|
455
|
+
const data = readClaudeJson();
|
|
456
|
+
const servers = getGlobalMcpServers(data);
|
|
457
|
+
const secrets = loadSecrets();
|
|
458
|
+
|
|
459
|
+
// Cross-reference providers.json for display_type and quorum_timeout_ms
|
|
460
|
+
let providerMeta = {};
|
|
461
|
+
try {
|
|
462
|
+
const pdata = readProvidersJson();
|
|
463
|
+
for (const p of (pdata.providers || [])) providerMeta[p.name] = p;
|
|
464
|
+
} catch (_) {}
|
|
465
|
+
|
|
466
|
+
let i = 1;
|
|
467
|
+
return Object.entries(servers).filter(([name]) =>
|
|
468
|
+
!name.startsWith('unified-')
|
|
469
|
+
).map(([name, cfg]) => {
|
|
470
|
+
const env = cfg.env || {};
|
|
471
|
+
const baseUrl = env.ANTHROPIC_BASE_URL || '';
|
|
472
|
+
const provSlot = env.PROVIDER_SLOT || name;
|
|
473
|
+
const meta = providerMeta[provSlot] || providerMeta[name] || {};
|
|
474
|
+
// MCP-server providers expose model via env; CLI providers via model_detect file or static meta.model
|
|
475
|
+
const model = env.CLAUDE_DEFAULT_MODEL || env.ANTHROPIC_MODEL
|
|
476
|
+
|| detectModel(meta) || meta.model || '—';
|
|
477
|
+
// MCP providers set CLAUDE_MCP_TIMEOUT_MS; CLI providers declare quorum_timeout_ms in providers.json
|
|
478
|
+
const timeout = env.CLAUDE_MCP_TIMEOUT_MS
|
|
479
|
+
? `${env.CLAUDE_MCP_TIMEOUT_MS}ms`
|
|
480
|
+
: meta.quorum_timeout_ms ? `${meta.quorum_timeout_ms}ms` : '—';
|
|
481
|
+
const displayType = meta.display_type || (cfg.command === 'node' ? 'claude-mcp-server' : cfg.command || '?');
|
|
482
|
+
const providerName = deriveProviderName(baseUrl);
|
|
483
|
+
const description = meta.description || '';
|
|
484
|
+
|
|
485
|
+
// Key status: check env + local index (no keychain prompt)
|
|
486
|
+
const account = 'ANTHROPIC_API_KEY_' + name.toUpperCase().replace(/-/g, '_');
|
|
487
|
+
const hasKey = !!env.ANTHROPIC_API_KEY || (secrets ? secrets.hasKey(account) : false);
|
|
488
|
+
|
|
489
|
+
// OAuth rotation pool info — delegated to the auth driver (no inline CLI-specific logic).
|
|
490
|
+
// extractAccountName() is called even with an empty pool so single-account providers
|
|
491
|
+
// (e.g. gemini with oauth_creds.json but no pool directory yet) still show their identity.
|
|
492
|
+
let poolInfo = null;
|
|
493
|
+
if (meta.oauth_rotation?.enabled && meta.auth?.type) {
|
|
494
|
+
try {
|
|
495
|
+
const { loadDriver } = require('./auth-drivers/index.cjs');
|
|
496
|
+
const driver = loadDriver(meta.auth.type);
|
|
497
|
+
const accounts = driver.list(meta);
|
|
498
|
+
const activeEntry = accounts.find(a => a.active);
|
|
499
|
+
const activeName = activeEntry?.name ?? driver.extractAccountName(meta);
|
|
500
|
+
if (accounts.length || activeName) {
|
|
501
|
+
poolInfo = { size: accounts.length, active: activeName };
|
|
502
|
+
}
|
|
503
|
+
} catch (_) {}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Providers without oauth_rotation (e.g. opencode) may declare identity_detect
|
|
507
|
+
// in providers.json — the simple driver reads it via extractAccountName().
|
|
508
|
+
if (!poolInfo && meta.auth?.type && meta.identity_detect) {
|
|
509
|
+
try {
|
|
510
|
+
const { loadDriver } = require('./auth-drivers/index.cjs');
|
|
511
|
+
const identity = loadDriver(meta.auth.type).extractAccountName(meta);
|
|
512
|
+
if (identity) poolInfo = { size: 0, active: identity };
|
|
513
|
+
} catch (_) {}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Last failure from quorum-failures.json (best-effort read)
|
|
517
|
+
let lastFailure = null;
|
|
518
|
+
try {
|
|
519
|
+
let failPath;
|
|
520
|
+
try {
|
|
521
|
+
const pp = require('./planning-paths.cjs');
|
|
522
|
+
failPath = pp.resolveWithFallback(process.cwd(), 'quorum-failures');
|
|
523
|
+
} catch (_) {
|
|
524
|
+
failPath = path.join(os.homedir(), '.claude', 'qgsd', 'quorum-failures.json');
|
|
525
|
+
}
|
|
526
|
+
if (fs.existsSync(failPath)) {
|
|
527
|
+
const failures = JSON.parse(fs.readFileSync(failPath, 'utf8'));
|
|
528
|
+
const entry = failures[provSlot] || failures[name];
|
|
529
|
+
if (entry) lastFailure = { count: entry.count || 1, type: entry.error_type || 'UNKNOWN' };
|
|
530
|
+
}
|
|
531
|
+
} catch (_) {}
|
|
532
|
+
|
|
533
|
+
return { n: String(i++), name, model, timeout, env, cfg,
|
|
534
|
+
baseUrl, providerName, displayType, hasKey, description,
|
|
535
|
+
poolInfo, lastFailure };
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ─── Header info (version, profile, quorum n) ────────────────────────────────
|
|
540
|
+
function buildHeaderInfo() {
|
|
541
|
+
let version = '?';
|
|
542
|
+
try {
|
|
543
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
544
|
+
version = pkg.version || '?';
|
|
545
|
+
} catch (_) {}
|
|
546
|
+
|
|
547
|
+
let profile = '—';
|
|
548
|
+
try {
|
|
549
|
+
const cfgPath = path.join(process.cwd(), '.planning', 'config.json');
|
|
550
|
+
if (fs.existsSync(cfgPath))
|
|
551
|
+
profile = JSON.parse(fs.readFileSync(cfgPath, 'utf8')).model_profile || '—';
|
|
552
|
+
} catch (_) {}
|
|
553
|
+
|
|
554
|
+
let quorumN = '—';
|
|
555
|
+
let failMode = '—';
|
|
556
|
+
try {
|
|
557
|
+
const qgsd = readQgsdJson();
|
|
558
|
+
const defN = qgsd.quorum?.maxSize;
|
|
559
|
+
const byProf = qgsd.quorum?.maxSizeByProfile || {};
|
|
560
|
+
const effN = byProf[profile] ?? defN;
|
|
561
|
+
if (effN != null) quorumN = String(effN) + (byProf[profile] != null ? '*' : '');
|
|
562
|
+
if (qgsd.fail_mode) failMode = qgsd.fail_mode;
|
|
563
|
+
} catch (_) {}
|
|
564
|
+
|
|
565
|
+
// Key agent tiers — from qgsd-core/references/model-profiles.md
|
|
566
|
+
const TIERS = {
|
|
567
|
+
planner: { quality: 'opus', balanced: 'opus', budget: 'sonnet' },
|
|
568
|
+
executor: { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
|
|
569
|
+
researcher: { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
|
|
570
|
+
verifier: { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
|
|
571
|
+
};
|
|
572
|
+
const p = TIERS.planner[profile] ? profile : 'balanced';
|
|
573
|
+
const models = {
|
|
574
|
+
planner: TIERS.planner[p],
|
|
575
|
+
executor: TIERS.executor[p],
|
|
576
|
+
researcher: TIERS.researcher[p],
|
|
577
|
+
verifier: TIERS.verifier[p],
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
return { version, profile, quorumN, failMode, models };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── Screen setup ─────────────────────────────────────────────────────────────
|
|
584
|
+
const screen = blessed.screen({ smartCSR: true, title: 'nForma' });
|
|
585
|
+
|
|
586
|
+
// ─── Surface palette ─────────────────────────────────────────────────────────
|
|
587
|
+
// Layered surfaces for depth (dark → light = recessed → raised):
|
|
588
|
+
// base #1a1a1a — screen fill, activity bar
|
|
589
|
+
// mid #1e1e1e — main panels (menu, content)
|
|
590
|
+
// top #222222 — header, status bar (raised chrome)
|
|
591
|
+
// bdr #3a3a3a — borders
|
|
592
|
+
// sel #1e3a3a — selection highlight (teal tint)
|
|
593
|
+
const S = { base: '#1a1a1a', mid: '#1e1e1e', top: '#222222', bdr: '#3a3a3a', sel: '#1e3a3a' };
|
|
594
|
+
|
|
595
|
+
const header = blessed.box({
|
|
596
|
+
top: 0, left: 0, width: '100%', height: 3,
|
|
597
|
+
tags: true,
|
|
598
|
+
border: { type: 'line' },
|
|
599
|
+
style: { bg: S.top, border: { fg: S.bdr } },
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
function renderHeader() {
|
|
603
|
+
const logo = ` {#e07850-fg}{bold}nForma AI{/bold}{/} {#777777-fg}· agent manager{/}`;
|
|
604
|
+
const keys = `{#4a9090-fg}[F1]{/} A {#4a9090-fg}[F2]{/} R {#4a9090-fg}[F3]{/} C {#4a9090-fg}[F4]{/} S {#4a9090-fg}[Tab]{/} cycle {#4a9090-fg}[C-\\]{/} menu {#4a9090-fg}[q]{/} quit `;
|
|
605
|
+
const w = screen.width || 120;
|
|
606
|
+
const gap = Math.max(2, w - 25 - 74);
|
|
607
|
+
header.setContent(logo + ' '.repeat(gap) + keys);
|
|
608
|
+
screen.render();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const activityBar = blessed.box({
|
|
612
|
+
top: 3, left: 0, width: 9, bottom: 2,
|
|
613
|
+
tags: true,
|
|
614
|
+
border: { type: 'line' },
|
|
615
|
+
style: { bg: S.base, fg: '#888888', border: { fg: S.bdr } },
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const menuList = blessed.list({
|
|
619
|
+
top: 3, left: 9, width: 26, bottom: 2,
|
|
620
|
+
label: ' {#666666-fg}Agents{/} ', tags: true,
|
|
621
|
+
border: { type: 'line' },
|
|
622
|
+
style: {
|
|
623
|
+
bg: S.mid,
|
|
624
|
+
border: { fg: S.bdr },
|
|
625
|
+
selected: { bg: S.sel, fg: '#cccccc', bold: true },
|
|
626
|
+
item: { fg: '#999999' },
|
|
627
|
+
},
|
|
628
|
+
keys: true, vi: true, mouse: true, tags: true,
|
|
629
|
+
items: MODULES[0].items.map(m => m.label),
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const contentBox = blessed.box({
|
|
633
|
+
top: 3, left: 35, right: 0, bottom: 2,
|
|
634
|
+
label: ' {#666666-fg}Content{/} ', tags: true,
|
|
635
|
+
border: { type: 'line' },
|
|
636
|
+
scrollable: true, alwaysScroll: true, mouse: true,
|
|
637
|
+
scrollbar: { ch: ' ', style: { bg: '#444444' } },
|
|
638
|
+
style: { bg: S.mid, fg: '#aaaaaa', border: { fg: S.bdr } },
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const statusBar = blessed.box({
|
|
642
|
+
bottom: 0, left: 0, width: '100%', height: 3,
|
|
643
|
+
tags: true,
|
|
644
|
+
border: { type: 'line' },
|
|
645
|
+
style: { fg: '#999999', bg: S.top, border: { fg: S.bdr } },
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
function refreshStatusBar(extra) {
|
|
649
|
+
try {
|
|
650
|
+
const { requirements } = reqCore.readRequirementsJson();
|
|
651
|
+
const registry = reqCore.readModelRegistry();
|
|
652
|
+
const checkResults = reqCore.readCheckResults();
|
|
653
|
+
const cov = reqCore.computeCoverage(requirements, registry, checkResults);
|
|
654
|
+
const complete = cov.byStatus.Complete || 0;
|
|
655
|
+
const pending = cov.byStatus.Pending || 0;
|
|
656
|
+
const pct = cov.total ? Math.round(complete / cov.total * 100) : 0;
|
|
657
|
+
const fmPct = cov.total ? Math.round(cov.withFormalModels / cov.total * 100) : 0;
|
|
658
|
+
|
|
659
|
+
const D = '{#777777-fg}', V = '{#aaaaaa-fg}', A = '{#4a9090-fg}', G = '{green-fg}', Y = '{yellow-fg}', E = '{/}';
|
|
660
|
+
let line = ` ${D}Reqs${E} ${A}${cov.total}${E}` +
|
|
661
|
+
` ${G}${complete}${E}${D}✓${E}` +
|
|
662
|
+
` ${Y}${pending}${E}${D}…${E}` +
|
|
663
|
+
` ${D}(${V}${pct}%${E}${D})${E}` +
|
|
664
|
+
` ${D}Formal${E} ${V}${fmPct}%${E}`;
|
|
665
|
+
|
|
666
|
+
if (extra) line += ` ${extra}`;
|
|
667
|
+
statusBar.setContent(line);
|
|
668
|
+
} catch (_) {
|
|
669
|
+
statusBar.setContent(extra ? ` ${extra}` : '');
|
|
670
|
+
}
|
|
671
|
+
screen.render();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ─── Settings content (rendered in contentBox when Settings action selected) ──
|
|
675
|
+
function buildSettingsPaneContent() {
|
|
676
|
+
const cfg = readProjectConfig();
|
|
677
|
+
const qgsd = readQgsdJson();
|
|
678
|
+
const profile = cfg.model_profile || 'balanced';
|
|
679
|
+
const ov = cfg.model_overrides || {};
|
|
680
|
+
const defN = qgsd.quorum?.maxSize ?? 3;
|
|
681
|
+
const byProf = qgsd.quorum?.maxSizeByProfile || {};
|
|
682
|
+
const effN = byProf[profile] ?? defN;
|
|
683
|
+
const nStr = String(effN) + (byProf[profile] != null ? '*' : '');
|
|
684
|
+
const failStr = qgsd.fail_mode || '—';
|
|
685
|
+
|
|
686
|
+
const D = '{#777777-fg}', V = '{#aaaaaa-fg}', A = '{#4a9090-fg}', Z = '{/}';
|
|
687
|
+
const mTag = k => ov[k] ? `${A}${ov[k]}${Z}{#888888-fg}*${Z}` : `${V}${AGENT_TIERS[k]?.[profile] || '—'}${Z}`;
|
|
688
|
+
|
|
689
|
+
const agents = ['qgsd-planner', 'qgsd-executor', 'qgsd-phase-researcher', 'qgsd-verifier', 'qgsd-codebase-mapper'];
|
|
690
|
+
const labels = ['Planner', 'Executor', 'Researcher', 'Verifier', 'Mapper'];
|
|
691
|
+
|
|
692
|
+
const lines = [
|
|
693
|
+
`{bold}Project Settings{/bold}`,
|
|
694
|
+
`${'─'.repeat(40)}`,
|
|
695
|
+
``,
|
|
696
|
+
` ${D}Profile ${Z} ${V}${profile}${Z}`,
|
|
697
|
+
` ${D}Quorum n ${Z} ${V}${nStr}${Z}`,
|
|
698
|
+
` ${D}Fail mode ${Z} ${V}${failStr}${Z}`,
|
|
699
|
+
``,
|
|
700
|
+
`{bold}Model Tiers{/bold} {#888888-fg}(${profile} profile)${Z}`,
|
|
701
|
+
`${'─'.repeat(40)}`,
|
|
702
|
+
``,
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
for (let i = 0; i < agents.length; i++) {
|
|
706
|
+
lines.push(` ${D}${pad(labels[i], 12)}${Z} ${mTag(agents[i])}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
lines.push('');
|
|
710
|
+
lines.push(`{#888888-fg} * = override active${Z}`);
|
|
711
|
+
|
|
712
|
+
return lines.join('\n');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function refreshSettingsPane() {
|
|
716
|
+
// Settings now render in contentBox when Config module's Settings action is triggered
|
|
717
|
+
// Only refresh if we're currently showing settings in the content area
|
|
718
|
+
if (activeModuleIdx === 2 && _showingSettings) {
|
|
719
|
+
setContent('Settings', buildSettingsPaneContent());
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
let _showingSettings = false;
|
|
723
|
+
|
|
724
|
+
screen.append(header);
|
|
725
|
+
screen.append(activityBar);
|
|
726
|
+
screen.append(menuList);
|
|
727
|
+
screen.append(contentBox);
|
|
728
|
+
screen.append(statusBar);
|
|
729
|
+
|
|
730
|
+
// ─── Content helpers ─────────────────────────────────────────────────────────
|
|
731
|
+
function setContent(label, text) {
|
|
732
|
+
contentBox.setLabel(` {#666666-fg}${label}{/} `);
|
|
733
|
+
contentBox.setContent(text);
|
|
734
|
+
contentBox.scrollTo(0);
|
|
735
|
+
screen.render();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ─── Event log viewer ───────────────────────────────────────────────────────
|
|
739
|
+
function showEventLog() {
|
|
740
|
+
if (!_logEntries.length) { setContent('Event Log', '{#777777-fg}No events logged.{/}'); return; }
|
|
741
|
+
const levelColor = { warn: '{yellow-fg}', error: '{red-fg}', info: '{#4a9090-fg}' };
|
|
742
|
+
const lines = _logEntries.map(e => {
|
|
743
|
+
const c = levelColor[e.level] || '{#aaaaaa-fg}';
|
|
744
|
+
return `{#555555-fg}${e.ts}{/} ${c}${e.level.toUpperCase().padEnd(5)}{/} ${e.msg}`;
|
|
745
|
+
});
|
|
746
|
+
setContent('Event Log', lines.join('\n'));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ─── Promisified overlay helpers ─────────────────────────────────────────────
|
|
750
|
+
function promptInput(opts) {
|
|
751
|
+
return new Promise((resolve, reject) => {
|
|
752
|
+
const box = blessed.box({
|
|
753
|
+
top: 'center', left: 'center', width: 64, height: 10,
|
|
754
|
+
label: ` {#888888-fg}${opts.title}{/} `, tags: true,
|
|
755
|
+
border: { type: 'line' },
|
|
756
|
+
style: { bg: '#222222', border: { fg: '#444444' } },
|
|
757
|
+
shadow: true,
|
|
758
|
+
});
|
|
759
|
+
blessed.text({ parent: box, top: 1, left: 2, content: opts.prompt || '', tags: true,
|
|
760
|
+
style: { fg: '#aaaaaa', bg: '#222222' } });
|
|
761
|
+
const input = blessed.textbox({
|
|
762
|
+
parent: box, top: 3, left: 2, right: 2, height: 1,
|
|
763
|
+
inputOnFocus: true, censor: !!opts.isPassword,
|
|
764
|
+
style: { fg: '#cccccc', bg: '#2e2e2e' },
|
|
765
|
+
});
|
|
766
|
+
if (opts.default) input.setValue(opts.default);
|
|
767
|
+
blessed.text({ parent: box, top: 6, left: 2,
|
|
768
|
+
content: '{#777777-fg}[Enter]{/} confirm [Esc] cancel', tags: true,
|
|
769
|
+
style: { bg: '#222222' } });
|
|
770
|
+
screen.append(box);
|
|
771
|
+
input.focus();
|
|
772
|
+
screen.render();
|
|
773
|
+
input.once('submit', (val) => {
|
|
774
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
775
|
+
resolve(val.trim());
|
|
776
|
+
});
|
|
777
|
+
input.key(['escape'], () => {
|
|
778
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
779
|
+
reject(new Error('cancelled'));
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function promptList(opts) {
|
|
785
|
+
return new Promise((resolve, reject) => {
|
|
786
|
+
const height = Math.min((opts.items || []).length + 4, 20);
|
|
787
|
+
const box = blessed.list({
|
|
788
|
+
top: 'center', left: 'center', width: 52, height,
|
|
789
|
+
label: ` {#888888-fg}${opts.title}{/} `, tags: true,
|
|
790
|
+
border: { type: 'line' },
|
|
791
|
+
style: {
|
|
792
|
+
bg: '#222222',
|
|
793
|
+
border: { fg: '#444444' },
|
|
794
|
+
selected: { bg: '#1e3a3a', fg: '#cccccc', bold: true },
|
|
795
|
+
item: { fg: '#888888' },
|
|
796
|
+
},
|
|
797
|
+
keys: true, vi: true, mouse: true,
|
|
798
|
+
items: (opts.items || []).map(i => ' ' + i.label),
|
|
799
|
+
shadow: true,
|
|
800
|
+
});
|
|
801
|
+
screen.append(box);
|
|
802
|
+
box.focus();
|
|
803
|
+
screen.render();
|
|
804
|
+
box.on('select', (_, idx) => {
|
|
805
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
806
|
+
resolve(opts.items[idx]);
|
|
807
|
+
});
|
|
808
|
+
box.key(['escape', 'q'], () => {
|
|
809
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
810
|
+
reject(new Error('cancelled'));
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function promptCheckbox(opts) {
|
|
816
|
+
return new Promise((resolve, reject) => {
|
|
817
|
+
const items = opts.items || [];
|
|
818
|
+
const selected = new Set();
|
|
819
|
+
const height = Math.min(items.length + 6, 24);
|
|
820
|
+
|
|
821
|
+
function makeItemLine(item, i) {
|
|
822
|
+
return ` ${selected.has(i) ? '{green-fg}[✓]{/}' : '[ ]'} ${item.label}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const box = blessed.list({
|
|
826
|
+
top: 'center', left: 'center', width: 58, height,
|
|
827
|
+
label: ` {#888888-fg}${opts.title}{/} `, tags: true,
|
|
828
|
+
border: { type: 'line' },
|
|
829
|
+
style: {
|
|
830
|
+
bg: '#222222',
|
|
831
|
+
border: { fg: '#444444' },
|
|
832
|
+
selected: { bg: '#1e3a3a', fg: '#cccccc' },
|
|
833
|
+
item: { fg: '#888888' },
|
|
834
|
+
},
|
|
835
|
+
keys: true, vi: true, mouse: true, tags: true,
|
|
836
|
+
items: items.map((item, i) => makeItemLine(item, i)),
|
|
837
|
+
shadow: true,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
box.key(['space'], () => {
|
|
841
|
+
const idx = box.selected;
|
|
842
|
+
if (selected.has(idx)) selected.delete(idx); else selected.add(idx);
|
|
843
|
+
box.setItems(items.map((item, i) => makeItemLine(item, i)));
|
|
844
|
+
box.select(idx);
|
|
845
|
+
screen.render();
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
box.key(['enter'], () => {
|
|
849
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
850
|
+
resolve(items.filter((_, i) => selected.has(i)).map(item => item.value));
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
box.key(['escape', 'q'], () => {
|
|
854
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
855
|
+
reject(new Error('cancelled'));
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
screen.append(box);
|
|
859
|
+
box.focus();
|
|
860
|
+
screen.render();
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ─── External terminal launcher ───────────────────────────────────────────────
|
|
865
|
+
// Writes a temp shell script and opens it in a new Terminal.app window via osascript.
|
|
866
|
+
// Returns true if the terminal was opened successfully.
|
|
867
|
+
function spawnExternalTerminal(loginCmd) {
|
|
868
|
+
const tmpScript = path.join(os.tmpdir(), 'qgsd-auth-' + Date.now() + '.sh');
|
|
869
|
+
try {
|
|
870
|
+
fs.writeFileSync(tmpScript, [
|
|
871
|
+
'#!/bin/sh',
|
|
872
|
+
loginCmd.map(a => `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(' '),
|
|
873
|
+
'echo ""',
|
|
874
|
+
'echo "✓ Sign-in complete. You may close this window."',
|
|
875
|
+
'rm -f \'' + tmpScript + '\'',
|
|
876
|
+
].join('\n'), { mode: 0o755 });
|
|
877
|
+
const r = spawnSync('osascript', ['-e',
|
|
878
|
+
`tell application "Terminal"\n do script "bash '${tmpScript}'"\n activate\nend tell`
|
|
879
|
+
], { stdio: 'ignore' });
|
|
880
|
+
return r.status === 0;
|
|
881
|
+
} catch (_) { return false; }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Login launcher dialog (stays in TUI — opens Terminal for interactive OAuth) ─
|
|
885
|
+
// Opens a new terminal window running loginCmd, then either:
|
|
886
|
+
// • auto-resolves when credentialFile mtime changes (file-based providers), or
|
|
887
|
+
// • waits for manual [Enter] confirmation (keychain-based providers like gh).
|
|
888
|
+
// Resolves to 'changed' | 'manual' | throws on cancel.
|
|
889
|
+
function promptLoginExternal(title, loginCmd, credentialFile = null) {
|
|
890
|
+
const beforeMtime = credentialFile && fs.existsSync(credentialFile)
|
|
891
|
+
? fs.statSync(credentialFile).mtimeMs : 0;
|
|
892
|
+
|
|
893
|
+
const opened = spawnExternalTerminal(loginCmd);
|
|
894
|
+
|
|
895
|
+
return new Promise((resolve, reject) => {
|
|
896
|
+
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
897
|
+
let spinIdx = 0;
|
|
898
|
+
const hint = credentialFile
|
|
899
|
+
? '{gray-fg}Auto-completing when sign-in detected · [Enter] done · [Esc] cancel{/}'
|
|
900
|
+
: '{gray-fg}[Enter] when sign-in is complete · [Esc] cancel{/}';
|
|
901
|
+
const openedLine = opened
|
|
902
|
+
? '{green-fg}✓ Terminal window opened{/}'
|
|
903
|
+
: '{yellow-fg}⚠ Could not open Terminal — run manually:{/}\n ' + loginCmd.join(' ');
|
|
904
|
+
|
|
905
|
+
const box = blessed.box({
|
|
906
|
+
top: 'center', left: 'center', width: 70, height: 12,
|
|
907
|
+
label: ` {#888888-fg}${title}{/} `, tags: true,
|
|
908
|
+
border: { type: 'line' },
|
|
909
|
+
style: { bg: '#222222', border: { fg: '#444444' } },
|
|
910
|
+
shadow: true,
|
|
911
|
+
keys: true, mouse: true,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
function render() {
|
|
915
|
+
box.setContent(
|
|
916
|
+
`\n ${openedLine}\n\n` +
|
|
917
|
+
` {#4a9090-fg}${SPIN[spinIdx % SPIN.length]}{/} Waiting for sign-in to complete…\n\n` +
|
|
918
|
+
` ${hint}`
|
|
919
|
+
);
|
|
920
|
+
screen.render();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
screen.append(box);
|
|
924
|
+
box.focus();
|
|
925
|
+
render();
|
|
926
|
+
|
|
927
|
+
let timer = null;
|
|
928
|
+
|
|
929
|
+
function cleanup() {
|
|
930
|
+
if (timer) clearInterval(timer);
|
|
931
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (credentialFile) {
|
|
935
|
+
timer = setInterval(() => {
|
|
936
|
+
spinIdx++;
|
|
937
|
+
try {
|
|
938
|
+
const mtime = fs.existsSync(credentialFile) ? fs.statSync(credentialFile).mtimeMs : 0;
|
|
939
|
+
if (mtime > beforeMtime) { cleanup(); resolve('changed'); return; }
|
|
940
|
+
} catch (_) {}
|
|
941
|
+
render();
|
|
942
|
+
}, 500);
|
|
943
|
+
} else {
|
|
944
|
+
timer = setInterval(() => { spinIdx++; render(); }, 500);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
box.key(['enter'], () => { cleanup(); resolve('manual'); });
|
|
948
|
+
box.key(['escape', 'q'], () => { cleanup(); reject(new Error('cancelled')); });
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ─── Toast notification ───────────────────────────────────────────────────────
|
|
953
|
+
function toast(msg, isError = false) {
|
|
954
|
+
logEvent(isError ? 'error' : 'info', msg.replace(/\{[^}]*\}/g, ''));
|
|
955
|
+
const box = blessed.message({
|
|
956
|
+
top: 'center', left: 'center', width: 54, height: 5,
|
|
957
|
+
border: { type: 'line' },
|
|
958
|
+
style: { border: { fg: isError ? 'red' : 'green' } },
|
|
959
|
+
shadow: true,
|
|
960
|
+
});
|
|
961
|
+
screen.append(box);
|
|
962
|
+
box.display(`{${isError ? 'red' : 'green'}-fg}${msg}{/}`, 2, () => {
|
|
963
|
+
screen.remove(box); menuList.focus(); screen.render();
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ─── List Agents ─────────────────────────────────────────────────────────────
|
|
968
|
+
function renderList() {
|
|
969
|
+
try {
|
|
970
|
+
const rows = agentRows();
|
|
971
|
+
const W = { n: 3, name: 16, provider: 13, model: 38, key: 8, timeout: 9 };
|
|
972
|
+
const hdr = `{bold}${pad('#', W.n)} ${pad('Slot', W.name)} ${pad('Provider', W.provider)} ${pad('Model', W.model)} ${pad('Key', W.key)} Timeout{/bold}`;
|
|
973
|
+
const sep = '─'.repeat(W.n + 2 + W.name + 2 + W.provider + 2 + W.model + 2 + W.key + 2 + W.timeout);
|
|
974
|
+
|
|
975
|
+
const lines = [hdr, sep];
|
|
976
|
+
for (const r of rows) {
|
|
977
|
+
// Key badge — pad to 8 visual chars after tag close
|
|
978
|
+
// No BASE_URL = subscription/CLI auth, no API key needed
|
|
979
|
+
const isSubAuth = !r.baseUrl;
|
|
980
|
+
const keyBadge = r.hasKey
|
|
981
|
+
? '{green-fg}✓ set{/} ' // 5 visible + 3 spaces = 8
|
|
982
|
+
: isSubAuth
|
|
983
|
+
? '{#4a9090-fg}sub{/} ' // 3 visible + 5 spaces = 8
|
|
984
|
+
: '{red-fg}✗ unset{/} '; // 7 visible + 1 space = 8
|
|
985
|
+
|
|
986
|
+
// Line 1: main info
|
|
987
|
+
lines.push(
|
|
988
|
+
`${pad(r.n, W.n)} {#4a9090-fg}${pad(r.name, W.name)}{/} ` +
|
|
989
|
+
`${pad(r.providerName, W.provider)} ` +
|
|
990
|
+
`${pad(r.model.slice(0, W.model), W.model)} ` +
|
|
991
|
+
`${keyBadge}` +
|
|
992
|
+
`${r.timeout}`
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
// Line 2: secondary details
|
|
996
|
+
const details = [];
|
|
997
|
+
if (r.displayType) details.push(r.displayType);
|
|
998
|
+
if (r.baseUrl) details.push(r.baseUrl);
|
|
999
|
+
if (r.poolInfo) {
|
|
1000
|
+
const acct = r.poolInfo.active ?? '?';
|
|
1001
|
+
const poolSize = r.poolInfo.size > 1 ? ` (${r.poolInfo.size} accts)` : '';
|
|
1002
|
+
details.push(`{cyan-fg}◉ ${acct}{/}${poolSize}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (r.lastFailure) details.push('{red-fg}⚠ ' + r.lastFailure.count + 'x ' + r.lastFailure.type + '{/}');
|
|
1005
|
+
lines.push('{gray-fg} ' + details.join(' ') + '{/}');
|
|
1006
|
+
lines.push('');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
setContent(`Agents (${rows.length})`, lines.join('\n'));
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
setContent('Agents', `{red-fg}Error: ${err.message}{/}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ─── Add Agent ────────────────────────────────────────────────────────────────
|
|
1016
|
+
async function addAgentFlow() {
|
|
1017
|
+
const data = readClaudeJson();
|
|
1018
|
+
const servers = getGlobalMcpServers(data);
|
|
1019
|
+
const existing = Object.keys(servers);
|
|
1020
|
+
|
|
1021
|
+
const slotName = await promptInput({ title: 'Add Agent — Slot name', prompt: 'Slot name (e.g. claude-7):' });
|
|
1022
|
+
if (!slotName) { toast('Slot name is required', true); return; }
|
|
1023
|
+
if (/\s/.test(slotName)) { toast('No spaces allowed in slot name', true); return; }
|
|
1024
|
+
if (existing.includes(slotName)) { toast(`"${slotName}" already exists`, true); return; }
|
|
1025
|
+
|
|
1026
|
+
const typeChoice = await promptList({ title: 'Add Agent — Type', items: [
|
|
1027
|
+
{ label: 'API Agent (claude-mcp-server — AkashML, Together, Fireworks…)', value: 'api' },
|
|
1028
|
+
{ label: 'CLI Agent (subprocess — codex, gemini, opencode, copilot…)', value: 'cli' },
|
|
1029
|
+
] });
|
|
1030
|
+
|
|
1031
|
+
if (typeChoice.value === 'cli') {
|
|
1032
|
+
// CLI / subprocess agent — command + providers.json metadata
|
|
1033
|
+
const command = await promptInput({ title: 'Add Agent — CLI command', prompt: 'CLI command (e.g. codex, gemini, gh):' });
|
|
1034
|
+
if (!command) { toast('Command is required', true); return; }
|
|
1035
|
+
const mainTool = await promptInput({ title: 'Add Agent — Main tool', prompt: 'Main tool name (e.g. github_copilot_chat):' });
|
|
1036
|
+
const model = await promptInput({ title: 'Add Agent — Model', prompt: 'Model name (optional):' });
|
|
1037
|
+
const timeout = await promptInput({ title: 'Add Agent — Timeout', prompt: 'quorum_timeout_ms:', default: '30000' });
|
|
1038
|
+
|
|
1039
|
+
data.mcpServers = { ...servers, [slotName]: { type: 'stdio', command, args: [] } };
|
|
1040
|
+
writeClaudeJson(data);
|
|
1041
|
+
|
|
1042
|
+
// Write providers.json metadata
|
|
1043
|
+
const pdata = readProvidersJson();
|
|
1044
|
+
if (!pdata.providers) pdata.providers = [];
|
|
1045
|
+
const entry = { name: slotName, type: 'subprocess', display_type: `${command}-cli` };
|
|
1046
|
+
if (mainTool) entry.mainTool = mainTool;
|
|
1047
|
+
if (model) entry.model = model;
|
|
1048
|
+
if (timeout) entry.quorum_timeout_ms = parseInt(timeout, 10);
|
|
1049
|
+
pdata.providers.push(entry);
|
|
1050
|
+
writeProvidersJson(pdata);
|
|
1051
|
+
|
|
1052
|
+
toast(`✓ Added CLI agent "${slotName}"`);
|
|
1053
|
+
renderList();
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// API / MCP server agent
|
|
1058
|
+
const preset = await promptList({ title: 'Add Agent — Provider', items: PROVIDER_PRESETS });
|
|
1059
|
+
let baseUrl = preset.value;
|
|
1060
|
+
if (baseUrl === '__custom__') {
|
|
1061
|
+
baseUrl = await promptInput({ title: 'Add Agent — Base URL', prompt: 'ANTHROPIC_BASE_URL:' });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const model = await promptInput({ title: 'Add Agent — Model', prompt: 'CLAUDE_DEFAULT_MODEL:', default: 'claude-sonnet-4-6' });
|
|
1065
|
+
const apiKey = await promptInput({ title: 'Add Agent — API Key', prompt: 'ANTHROPIC_API_KEY:', isPassword: true });
|
|
1066
|
+
const timeout = await promptInput({ title: 'Add Agent — Timeout', prompt: 'CLAUDE_MCP_TIMEOUT_MS:', default: '30000' });
|
|
1067
|
+
|
|
1068
|
+
const env = {};
|
|
1069
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
|
|
1070
|
+
if (model) env.CLAUDE_DEFAULT_MODEL = model;
|
|
1071
|
+
if (timeout) env.CLAUDE_MCP_TIMEOUT_MS = timeout;
|
|
1072
|
+
env.PROVIDER_SLOT = slotName;
|
|
1073
|
+
|
|
1074
|
+
const secrets = loadSecrets();
|
|
1075
|
+
if (apiKey && secrets) {
|
|
1076
|
+
await secrets.set('qgsd', deriveKeytarAccount(slotName), apiKey);
|
|
1077
|
+
} else if (apiKey) {
|
|
1078
|
+
env.ANTHROPIC_API_KEY = apiKey;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
data.mcpServers = { ...servers, [slotName]: { type: 'stdio', command: 'node', args: [], env } };
|
|
1082
|
+
writeClaudeJson(data);
|
|
1083
|
+
toast(`✓ Added "${slotName}"`);
|
|
1084
|
+
renderList();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ─── Clone Slot ───────────────────────────────────────────────────────────────
|
|
1088
|
+
async function cloneSlotFlow() {
|
|
1089
|
+
const data = readClaudeJson();
|
|
1090
|
+
const servers = getGlobalMcpServers(data);
|
|
1091
|
+
const slots = Object.keys(servers);
|
|
1092
|
+
if (!slots.length) { toast('No agents to clone', true); return; }
|
|
1093
|
+
|
|
1094
|
+
const source = await promptList({ title: 'Clone Slot — Pick source',
|
|
1095
|
+
items: slots.map(s => ({ label: `${pad(s, 14)} ${(servers[s].env || {}).CLAUDE_DEFAULT_MODEL || '—'}`, value: s })) });
|
|
1096
|
+
const newName = await promptInput({ title: 'Clone Slot — New name', prompt: `New slot name (cloning ${source.value}):` });
|
|
1097
|
+
|
|
1098
|
+
if (!newName || /\s/.test(newName)) { toast('Invalid slot name', true); return; }
|
|
1099
|
+
if (slots.includes(newName)) { toast(`"${newName}" already exists`, true); return; }
|
|
1100
|
+
|
|
1101
|
+
const cloned = JSON.parse(JSON.stringify(servers[source.value]));
|
|
1102
|
+
if (cloned.env) cloned.env.PROVIDER_SLOT = newName;
|
|
1103
|
+
|
|
1104
|
+
data.mcpServers = { ...servers, [newName]: cloned };
|
|
1105
|
+
writeClaudeJson(data);
|
|
1106
|
+
|
|
1107
|
+
// Copy qgsd.json agent_config metadata from source to cloned slot
|
|
1108
|
+
try {
|
|
1109
|
+
const qgsd = readQgsdJson();
|
|
1110
|
+
const sourceConfig = (qgsd.agent_config || {})[source.value];
|
|
1111
|
+
if (sourceConfig) {
|
|
1112
|
+
if (!qgsd.agent_config) qgsd.agent_config = {};
|
|
1113
|
+
qgsd.agent_config[newName] = JSON.parse(JSON.stringify(sourceConfig));
|
|
1114
|
+
// Clear key_status from clone (needs fresh probe)
|
|
1115
|
+
if (qgsd.agent_config[newName].key_status) {
|
|
1116
|
+
delete qgsd.agent_config[newName].key_status;
|
|
1117
|
+
}
|
|
1118
|
+
writeQgsdJson(qgsd);
|
|
1119
|
+
}
|
|
1120
|
+
} catch (_) { /* qgsd.json might not exist yet -- non-fatal */ }
|
|
1121
|
+
|
|
1122
|
+
toast(`✓ Cloned "${source.value}" → "${newName}"`);
|
|
1123
|
+
renderList();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ─── Edit Agent ───────────────────────────────────────────────────────────────
|
|
1127
|
+
async function editAgentFlow() {
|
|
1128
|
+
const data = readClaudeJson();
|
|
1129
|
+
const servers = getGlobalMcpServers(data);
|
|
1130
|
+
const slots = Object.keys(servers);
|
|
1131
|
+
if (!slots.length) { toast('No agents to edit', true); return; }
|
|
1132
|
+
|
|
1133
|
+
// Pre-load providers.json for slot-picker labels (shows real model, not 'node')
|
|
1134
|
+
let provData = {};
|
|
1135
|
+
try {
|
|
1136
|
+
const pd = readProvidersJson();
|
|
1137
|
+
for (const p of (pd.providers || [])) provData[p.name] = p;
|
|
1138
|
+
} catch (_) {}
|
|
1139
|
+
|
|
1140
|
+
while (true) { // slot loop: ESC → main menu
|
|
1141
|
+
let target;
|
|
1142
|
+
try {
|
|
1143
|
+
target = await promptList({ title: 'Edit Agent — Pick slot',
|
|
1144
|
+
items: slots.map(s => {
|
|
1145
|
+
const env = (servers[s].env || {});
|
|
1146
|
+
const model = (env.CLAUDE_DEFAULT_MODEL || provData[s]?.model || servers[s].command || '?').slice(0, 30);
|
|
1147
|
+
return { label: `${pad(s, 14)} ${model}`, value: s };
|
|
1148
|
+
}) });
|
|
1149
|
+
} catch (_) { return; } // ESC → main menu
|
|
1150
|
+
|
|
1151
|
+
const slotName = target.value;
|
|
1152
|
+
const cfg = servers[slotName];
|
|
1153
|
+
const env = cfg.env || {};
|
|
1154
|
+
|
|
1155
|
+
// Always load providers.json meta — it's the source of truth for CLI agents
|
|
1156
|
+
let pMeta = {};
|
|
1157
|
+
try {
|
|
1158
|
+
const pd = readProvidersJson();
|
|
1159
|
+
pMeta = (pd.providers || []).find(p => p.name === slotName) || {};
|
|
1160
|
+
} catch (_) {}
|
|
1161
|
+
|
|
1162
|
+
// CLI agents are declared as type:subprocess in providers.json.
|
|
1163
|
+
// cfg.command may be 'node' (unified-server) even for CLI slots.
|
|
1164
|
+
const isCli = pMeta.type === 'subprocess' || cfg.command !== 'node';
|
|
1165
|
+
|
|
1166
|
+
while (true) { // field loop: ESC → slot picker
|
|
1167
|
+
const fieldItems = isCli ? [
|
|
1168
|
+
{ label: `CLI Path ${pMeta.cli || cfg.command || '(not set)'}`, value: 'cliPath' },
|
|
1169
|
+
{ label: `Main Tool ${pMeta.mainTool || '(not set)'}`, value: 'mainTool' },
|
|
1170
|
+
{ label: `Model ${pMeta.model || '(not set)'}`, value: 'cliModel' },
|
|
1171
|
+
{ label: `Timeout ${pMeta.quorum_timeout_ms || '(not set)'}`, value: 'cliTimeout'},
|
|
1172
|
+
] : [
|
|
1173
|
+
{ label: `Model ${env.CLAUDE_DEFAULT_MODEL || '(not set)'}`, value: 'model' },
|
|
1174
|
+
{ label: `API Key ${env.ANTHROPIC_API_KEY ? '(set)' : '(not set)'}`, value: 'apiKey' },
|
|
1175
|
+
{ label: `Base URL ${env.ANTHROPIC_BASE_URL || '(not set)'}`, value: 'baseUrl' },
|
|
1176
|
+
{ label: `Timeout ${env.CLAUDE_MCP_TIMEOUT_MS || '(not set)'}`, value: 'timeout' },
|
|
1177
|
+
{ label: `Provider Slot ${env.PROVIDER_SLOT || slotName}`, value: 'provSlot' },
|
|
1178
|
+
];
|
|
1179
|
+
|
|
1180
|
+
let field;
|
|
1181
|
+
try {
|
|
1182
|
+
field = await promptList({ title: `Edit "${slotName}" — Field`, items: fieldItems });
|
|
1183
|
+
} catch (_) { break; } // ESC → back to slot picker
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
if (field.value === 'model') {
|
|
1187
|
+
const val = await promptInput({ title: `Edit "${slotName}" — Model`,
|
|
1188
|
+
prompt: 'CLAUDE_DEFAULT_MODEL:', default: env.CLAUDE_DEFAULT_MODEL || '' });
|
|
1189
|
+
if (val) env.CLAUDE_DEFAULT_MODEL = val; else delete env.CLAUDE_DEFAULT_MODEL;
|
|
1190
|
+
|
|
1191
|
+
} else if (field.value === 'apiKey') {
|
|
1192
|
+
const val = await promptInput({ title: `Edit "${slotName}" — API Key`,
|
|
1193
|
+
prompt: 'ANTHROPIC_API_KEY (blank = remove):', isPassword: true });
|
|
1194
|
+
const secrets = loadSecrets();
|
|
1195
|
+
const account = deriveKeytarAccount(slotName);
|
|
1196
|
+
if (val && secrets) { await secrets.set('qgsd', account, val); delete env.ANTHROPIC_API_KEY; }
|
|
1197
|
+
else if (val) { env.ANTHROPIC_API_KEY = val; }
|
|
1198
|
+
else if (secrets) { await secrets.delete('qgsd', account); delete env.ANTHROPIC_API_KEY; }
|
|
1199
|
+
else { delete env.ANTHROPIC_API_KEY; }
|
|
1200
|
+
|
|
1201
|
+
} else if (field.value === 'baseUrl') {
|
|
1202
|
+
let preset;
|
|
1203
|
+
try { preset = await promptList({ title: `Edit "${slotName}" — Base URL`, items: PROVIDER_PRESETS }); }
|
|
1204
|
+
catch (_) { continue; } // ESC at preset picker → re-show field picker
|
|
1205
|
+
let baseUrl = preset.value;
|
|
1206
|
+
if (baseUrl === '__custom__') {
|
|
1207
|
+
baseUrl = await promptInput({ title: `Edit "${slotName}" — Base URL`, prompt: 'ANTHROPIC_BASE_URL:' });
|
|
1208
|
+
}
|
|
1209
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl; else delete env.ANTHROPIC_BASE_URL;
|
|
1210
|
+
|
|
1211
|
+
} else if (field.value === 'timeout') {
|
|
1212
|
+
const val = await promptInput({ title: `Edit "${slotName}" — Timeout`,
|
|
1213
|
+
prompt: 'CLAUDE_MCP_TIMEOUT_MS:', default: env.CLAUDE_MCP_TIMEOUT_MS || '30000' });
|
|
1214
|
+
if (val) env.CLAUDE_MCP_TIMEOUT_MS = val; else delete env.CLAUDE_MCP_TIMEOUT_MS;
|
|
1215
|
+
|
|
1216
|
+
} else if (field.value === 'provSlot') {
|
|
1217
|
+
const val = await promptInput({ title: `Edit "${slotName}" — Provider Slot`,
|
|
1218
|
+
prompt: 'PROVIDER_SLOT:', default: env.PROVIDER_SLOT || slotName });
|
|
1219
|
+
env.PROVIDER_SLOT = val || slotName;
|
|
1220
|
+
|
|
1221
|
+
} else if (['cliPath', 'mainTool', 'cliModel', 'cliTimeout'].includes(field.value)) {
|
|
1222
|
+
const prompts = {
|
|
1223
|
+
cliPath: { label: 'CLI path', key: 'cli', cur: pMeta.cli || cfg.command, transform: v => v },
|
|
1224
|
+
mainTool: { label: 'Main tool', key: 'mainTool', cur: pMeta.mainTool, transform: v => v },
|
|
1225
|
+
cliModel: { label: 'Model', key: 'model', cur: pMeta.model, transform: v => v },
|
|
1226
|
+
cliTimeout: { label: 'quorum_timeout_ms', key: 'quorum_timeout_ms', cur: pMeta.quorum_timeout_ms, transform: v => parseInt(v, 10) },
|
|
1227
|
+
};
|
|
1228
|
+
const { label, key, cur, transform } = prompts[field.value];
|
|
1229
|
+
const val = await promptInput({ title: `Edit "${slotName}" — ${label}`, prompt: `${label}:`, default: String(cur || '') });
|
|
1230
|
+
const pd = readProvidersJson();
|
|
1231
|
+
let entry = (pd.providers || []).find(p => p.name === slotName);
|
|
1232
|
+
if (!entry) { entry = { name: slotName, type: 'subprocess' }; pd.providers = [...(pd.providers || []), entry]; }
|
|
1233
|
+
if (val) entry[key] = transform(val); else delete entry[key];
|
|
1234
|
+
writeProvidersJson(pd);
|
|
1235
|
+
toast(`✓ Saved "${slotName}"`);
|
|
1236
|
+
renderList();
|
|
1237
|
+
continue; // re-show field picker after CLI save
|
|
1238
|
+
}
|
|
1239
|
+
} catch (_) { continue; } // ESC during value input → re-show field picker
|
|
1240
|
+
|
|
1241
|
+
cfg.env = env;
|
|
1242
|
+
data.mcpServers[slotName] = cfg;
|
|
1243
|
+
writeClaudeJson(data);
|
|
1244
|
+
toast(`✓ Saved "${slotName}"`);
|
|
1245
|
+
renderList();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ─── Remove Agent ─────────────────────────────────────────────────────────────
|
|
1251
|
+
async function removeAgentFlow() {
|
|
1252
|
+
const data = readClaudeJson();
|
|
1253
|
+
const servers = getGlobalMcpServers(data);
|
|
1254
|
+
const slots = Object.keys(servers);
|
|
1255
|
+
if (!slots.length) { toast('No agents to remove', true); return; }
|
|
1256
|
+
|
|
1257
|
+
const target = await promptList({ title: 'Remove Agent',
|
|
1258
|
+
items: slots.map(s => {
|
|
1259
|
+
const model = ((servers[s].env || {}).CLAUDE_DEFAULT_MODEL || servers[s].command || '?').slice(0, 30);
|
|
1260
|
+
return { label: `${pad(s, 14)} ${model}`, value: s };
|
|
1261
|
+
}) });
|
|
1262
|
+
|
|
1263
|
+
const confirm = await promptList({ title: `Remove "${target.value}"?`,
|
|
1264
|
+
items: [
|
|
1265
|
+
{ label: 'Cancel (keep it)', value: 'cancel' },
|
|
1266
|
+
{ label: `⚠ Yes, delete "${target.value}"`, value: 'confirm' },
|
|
1267
|
+
] });
|
|
1268
|
+
|
|
1269
|
+
if (confirm.value !== 'confirm') { toast('Cancelled'); return; }
|
|
1270
|
+
|
|
1271
|
+
data.mcpServers = Object.fromEntries(Object.entries(servers).filter(([k]) => k !== target.value));
|
|
1272
|
+
writeClaudeJson(data);
|
|
1273
|
+
toast(`✓ Removed "${target.value}"`);
|
|
1274
|
+
renderList();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ─── Reorder Agents ───────────────────────────────────────────────────────────
|
|
1278
|
+
async function reorderFlow() {
|
|
1279
|
+
const data = readClaudeJson();
|
|
1280
|
+
const servers = getGlobalMcpServers(data);
|
|
1281
|
+
const slots = Object.keys(servers);
|
|
1282
|
+
if (!slots.length) { toast('No agents to reorder', true); return; }
|
|
1283
|
+
|
|
1284
|
+
const target = await promptList({ title: 'Reorder — Pick slot to move',
|
|
1285
|
+
items: slots.map((s, i) => ({ label: `${String(i + 1).padStart(2)}. ${s}`, value: s })) });
|
|
1286
|
+
|
|
1287
|
+
const slotName = target.value;
|
|
1288
|
+
const currentIdx = slots.indexOf(slotName);
|
|
1289
|
+
const posStr = await promptInput({ title: `Move "${slotName}"`,
|
|
1290
|
+
prompt: `Move to position (1–${slots.length}):`, default: String(currentIdx + 1) });
|
|
1291
|
+
const newPos = parseInt(posStr, 10);
|
|
1292
|
+
|
|
1293
|
+
if (isNaN(newPos) || newPos < 1 || newPos > slots.length) {
|
|
1294
|
+
toast(`Position must be 1–${slots.length}`, true); return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const entries = Object.entries(servers);
|
|
1298
|
+
const [entry] = entries.splice(currentIdx, 1);
|
|
1299
|
+
entries.splice(newPos - 1, 0, entry);
|
|
1300
|
+
data.mcpServers = Object.fromEntries(entries);
|
|
1301
|
+
writeClaudeJson(data);
|
|
1302
|
+
toast(`✓ Moved "${slotName}" to position ${newPos}`);
|
|
1303
|
+
renderList();
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// ─── Check Agent Health (single slot) ────────────────────────────────────────
|
|
1307
|
+
async function checkHealthSingle() {
|
|
1308
|
+
const data = readClaudeJson();
|
|
1309
|
+
const servers = getGlobalMcpServers(data);
|
|
1310
|
+
const slots = Object.keys(servers);
|
|
1311
|
+
if (!slots.length) { toast('No agents configured', true); return; }
|
|
1312
|
+
|
|
1313
|
+
const target = await promptList({ title: 'Check Health — Pick slot',
|
|
1314
|
+
items: slots.map(s => ({ label: pad(s, 14) + ' ' + ((servers[s].env || {}).CLAUDE_DEFAULT_MODEL || '—'), value: s })) });
|
|
1315
|
+
|
|
1316
|
+
const slotName = target.value;
|
|
1317
|
+
const env = (servers[slotName].env || {});
|
|
1318
|
+
setContent('Agent Health', `{gray-fg}Probing ${slotName}…{/}`);
|
|
1319
|
+
|
|
1320
|
+
if (!env.ANTHROPIC_BASE_URL) {
|
|
1321
|
+
setContent('Agent Health', `{yellow-fg}${slotName} is a subprocess provider — no HTTP endpoint to probe.{/}`);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const secrets = loadSecrets();
|
|
1326
|
+
const lines = [`{bold}${slotName}{/bold}`, '─'.repeat(50)];
|
|
1327
|
+
|
|
1328
|
+
const hMap = await probeAllSlots(servers, [slotName], secrets);
|
|
1329
|
+
const p = hMap[slotName] || {};
|
|
1330
|
+
const status = p.healthy
|
|
1331
|
+
? `{green-fg}✓ UP (${p.latencyMs}ms){/}`
|
|
1332
|
+
: p.healthy === null
|
|
1333
|
+
? `{gray-fg}— subprocess (no HTTP endpoint){/}`
|
|
1334
|
+
: `{red-fg}✗ DOWN [${p.error || 'timeout'}]{/}`;
|
|
1335
|
+
lines.push(` Status: ${status}`);
|
|
1336
|
+
|
|
1337
|
+
lines.push(` URL: ${env.ANTHROPIC_BASE_URL}`);
|
|
1338
|
+
lines.push(` Model: ${env.CLAUDE_DEFAULT_MODEL || '—'}`);
|
|
1339
|
+
setContent('Agent Health', lines.join('\n'));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ─── Auth flow (driver-orchestrated — no CLI-specific logic in the TUI) ───────
|
|
1343
|
+
|
|
1344
|
+
async function authFlow(slotName, meta) {
|
|
1345
|
+
const { loadDriver } = require('./auth-drivers/index.cjs');
|
|
1346
|
+
const authCfg = meta.auth;
|
|
1347
|
+
const loginCmd = authCfg?.login ?? [meta.cli, 'auth', 'login'];
|
|
1348
|
+
|
|
1349
|
+
let driver;
|
|
1350
|
+
try { driver = loadDriver(authCfg?.type); }
|
|
1351
|
+
catch (err) { toast(err.message, true); return; }
|
|
1352
|
+
|
|
1353
|
+
const addLabel = '+ Add account' + (loginCmd.length ? ' (' + loginCmd.join(' ') + ')' : '');
|
|
1354
|
+
|
|
1355
|
+
// Loop so: after add/switch the list refreshes in place; Esc exits back to slot picker.
|
|
1356
|
+
while (true) {
|
|
1357
|
+
let accounts;
|
|
1358
|
+
try { accounts = driver.list(meta); }
|
|
1359
|
+
catch (err) { toast('Could not list accounts: ' + err.message, true); return; }
|
|
1360
|
+
|
|
1361
|
+
const items = accounts.map(a => ({
|
|
1362
|
+
label: pad(a.name, 36) + (a.active ? ' {green-fg}(active){/}' : ''), value: a.name, isActive: a.active,
|
|
1363
|
+
}));
|
|
1364
|
+
items.push({ label: addLabel, value: '__add__', isActive: false });
|
|
1365
|
+
|
|
1366
|
+
let picked;
|
|
1367
|
+
try { picked = await promptList({ title: 'Auth — ' + slotName, items }); }
|
|
1368
|
+
catch (_) { return; } // Esc → caller (slot-picker loop) re-shows slot list
|
|
1369
|
+
|
|
1370
|
+
// ── Add account ─────────────────────────────────────────────────────────
|
|
1371
|
+
if (picked.value === '__add__') {
|
|
1372
|
+
const credFile = driver.addCredentialFile(meta);
|
|
1373
|
+
|
|
1374
|
+
// Pool providers (Gemini, Codex): if the credential file already exists,
|
|
1375
|
+
// the CLI opens in interactive mode instead of triggering fresh OAuth.
|
|
1376
|
+
// Back it up first so the CLI sees no active session.
|
|
1377
|
+
let backupCredFile = null;
|
|
1378
|
+
if (credFile && fs.existsSync(credFile) && typeof driver.add === 'function') {
|
|
1379
|
+
// Auto-save current account to pool before displacing it (best-effort).
|
|
1380
|
+
const curName = typeof driver.extractAccountName === 'function'
|
|
1381
|
+
? driver.extractAccountName(meta) : null;
|
|
1382
|
+
if (curName) {
|
|
1383
|
+
const alreadyInPool = driver.list(meta).some(a => a.name === curName);
|
|
1384
|
+
if (!alreadyInPool) {
|
|
1385
|
+
try { await driver.add(meta, curName); } catch (_) {}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// Move active creds aside so CLI triggers fresh OAuth.
|
|
1389
|
+
backupCredFile = credFile + '.auth-in-progress';
|
|
1390
|
+
try { fs.renameSync(credFile, backupCredFile); }
|
|
1391
|
+
catch (_) { backupCredFile = null; }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
try { await promptLoginExternal('Add Account — ' + slotName, loginCmd, credFile); }
|
|
1395
|
+
catch (_) {
|
|
1396
|
+
// Cancelled — restore the backed-up credentials so the active account still works.
|
|
1397
|
+
if (backupCredFile && fs.existsSync(backupCredFile)) {
|
|
1398
|
+
try { fs.renameSync(backupCredFile, credFile); } catch (_) {}
|
|
1399
|
+
}
|
|
1400
|
+
continue; // Esc from spinner → back to account list
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Login succeeded — old creds are already in the pool; discard backup.
|
|
1404
|
+
if (backupCredFile && fs.existsSync(backupCredFile)) {
|
|
1405
|
+
try { fs.unlinkSync(backupCredFile); } catch (_) {}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Drivers that manage accounts internally (gh-cli) need no capture step
|
|
1409
|
+
if (typeof driver.add === 'function' && credFile !== null) {
|
|
1410
|
+
let name = driver.extractAccountName(meta);
|
|
1411
|
+
if (!name) {
|
|
1412
|
+
try { name = await promptInput({ title: 'Add Account — ' + slotName, prompt: 'Account name / alias:' }); }
|
|
1413
|
+
catch (_) { continue; } // Esc from name prompt → back to account list
|
|
1414
|
+
if (!name) { toast('Name required', true); continue; }
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
await driver.add(meta, name);
|
|
1418
|
+
toast('Account "' + name + '" added to pool');
|
|
1419
|
+
renderList();
|
|
1420
|
+
} catch (err) {
|
|
1421
|
+
toast('Add failed: ' + err.message, true);
|
|
1422
|
+
}
|
|
1423
|
+
} else {
|
|
1424
|
+
toast('Signed in — account will appear on next refresh');
|
|
1425
|
+
}
|
|
1426
|
+
continue; // refresh account list
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ── Switch account ───────────────────────────────────────────────────────
|
|
1430
|
+
if (picked.isActive) { toast(picked.value + ' is already active'); continue; }
|
|
1431
|
+
|
|
1432
|
+
try {
|
|
1433
|
+
driver.switch(meta, picked.value);
|
|
1434
|
+
toast('Switched ' + slotName + ' → ' + picked.value);
|
|
1435
|
+
renderList();
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
toast('Switch failed: ' + err.message, true);
|
|
1438
|
+
}
|
|
1439
|
+
continue; // refresh account list to show updated active state
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// ─── Login / Auth ─────────────────────────────────────────────────────────────
|
|
1444
|
+
async function loginAgentFlow() {
|
|
1445
|
+
const { loadDriver } = require('./auth-drivers/index.cjs');
|
|
1446
|
+
|
|
1447
|
+
// Loop so: Esc from account list re-shows slot picker; Esc from slot picker exits.
|
|
1448
|
+
while (true) {
|
|
1449
|
+
const pdata = readProvidersJson();
|
|
1450
|
+
const metaByName = Object.fromEntries((pdata.providers || []).map(p => [p.name, p]));
|
|
1451
|
+
|
|
1452
|
+
const rows = agentRows();
|
|
1453
|
+
const items = rows.map(r => {
|
|
1454
|
+
const meta = metaByName[r.name] || {};
|
|
1455
|
+
const authCfg = meta.auth;
|
|
1456
|
+
let driverAvailable = false;
|
|
1457
|
+
try { if (authCfg?.type) { loadDriver(authCfg.type); driverAvailable = true; } } catch (_) {}
|
|
1458
|
+
const hasPool = !!meta.oauth_rotation?.enabled;
|
|
1459
|
+
const label = pad(r.name, 16) + ' ' +
|
|
1460
|
+
(authCfg ? authCfg.type : '{gray-fg}(no auth){/}') +
|
|
1461
|
+
(hasPool ? ' · pool' : '') +
|
|
1462
|
+
(!driverAvailable && authCfg ? ' {red-fg}(no driver){/}' : '');
|
|
1463
|
+
return { label, value: r.name, meta, driverAvailable };
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
let picked;
|
|
1467
|
+
try { picked = await promptList({ title: 'Login / Auth — Pick slot', items }); }
|
|
1468
|
+
catch (_) { return; } // Esc → back to main menu
|
|
1469
|
+
|
|
1470
|
+
if (!picked.meta.auth) { toast('No auth configured for this slot', true); continue; }
|
|
1471
|
+
if (!picked.driverAvailable) { toast('No driver for type "' + picked.meta.auth.type + '"', true); continue; }
|
|
1472
|
+
|
|
1473
|
+
await authFlow(picked.value, picked.meta);
|
|
1474
|
+
// authFlow returned (user pressed Esc from account list) → loop back to slot picker
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ─── Provider Keys ────────────────────────────────────────────────────────────
|
|
1479
|
+
async function renderProviderKeys() {
|
|
1480
|
+
const secrets = loadSecrets();
|
|
1481
|
+
if (!secrets) { setContent('Provider Keys', '{red-fg}secrets.cjs not found — QGSD not installed.{/}'); return; }
|
|
1482
|
+
const lines = ['{bold}Provider Keys (keytar){/bold}', '─'.repeat(40)];
|
|
1483
|
+
for (const { key, label } of PROVIDER_KEY_NAMES) {
|
|
1484
|
+
// hasKey() reads local JSON index — no keychain prompt
|
|
1485
|
+
const display = secrets.hasKey(key) ? '{green-fg}✓ set{/}' : '{gray-fg}(not set){/}';
|
|
1486
|
+
lines.push(` ${pad(label, 22)} ${display}`);
|
|
1487
|
+
}
|
|
1488
|
+
lines.push('', '{gray-fg}Use Provider Keys → Set to update a key.{/}');
|
|
1489
|
+
setContent('Provider Keys', lines.join('\n'));
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async function providerKeysFlow() {
|
|
1493
|
+
setContent('Provider Keys', '{gray-fg}Select an action…{/}');
|
|
1494
|
+
const secrets = loadSecrets();
|
|
1495
|
+
if (!secrets) { toast('secrets.cjs not found — QGSD not installed', true); return; }
|
|
1496
|
+
|
|
1497
|
+
while (true) { // action loop: ESC → main menu
|
|
1498
|
+
let action;
|
|
1499
|
+
try {
|
|
1500
|
+
action = await promptList({ title: 'Provider Keys',
|
|
1501
|
+
items: [
|
|
1502
|
+
{ label: 'View stored keys', value: 'view' },
|
|
1503
|
+
{ label: 'Set / update a key', value: 'set' },
|
|
1504
|
+
{ label: 'Remove a key', value: 'remove' },
|
|
1505
|
+
] });
|
|
1506
|
+
} catch (_) { return; } // ESC → main menu
|
|
1507
|
+
|
|
1508
|
+
if (action.value === 'view') { await renderProviderKeys(); continue; }
|
|
1509
|
+
|
|
1510
|
+
while (true) { // key loop: ESC → action picker
|
|
1511
|
+
let picked;
|
|
1512
|
+
try {
|
|
1513
|
+
picked = await promptList({
|
|
1514
|
+
title: action.value === 'set' ? 'Set which key?' : 'Remove which key?',
|
|
1515
|
+
items: PROVIDER_KEY_NAMES.map(k => ({ label: k.label, value: k.key })),
|
|
1516
|
+
});
|
|
1517
|
+
} catch (_) { break; } // ESC → back to action picker
|
|
1518
|
+
|
|
1519
|
+
try {
|
|
1520
|
+
if (action.value === 'remove') {
|
|
1521
|
+
await secrets.delete('qgsd', picked.value);
|
|
1522
|
+
toast(`Removed ${picked.label}`);
|
|
1523
|
+
await renderProviderKeys();
|
|
1524
|
+
continue; // re-show key picker (remove more)
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
const val = await promptInput({ title: `Set ${picked.label}`, prompt: `Value for ${picked.value}:`, isPassword: true });
|
|
1528
|
+
if (!val) { toast('Empty value — key not stored', true); continue; }
|
|
1529
|
+
await secrets.set('qgsd', picked.value, val);
|
|
1530
|
+
toast(`${picked.label} saved to keychain`);
|
|
1531
|
+
await renderProviderKeys();
|
|
1532
|
+
} catch (_) { continue; } // ESC during value input → re-show key picker
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ─── Post-Rotation Validation (CRED-01: fire-and-forget, non-blocking) ───────
|
|
1538
|
+
/**
|
|
1539
|
+
* Fire-and-forget post-rotation validation.
|
|
1540
|
+
* Probes each rotated slot and persists key_status to qgsd.json.
|
|
1541
|
+
* Does NOT block the caller -- called with .catch(() => {}).
|
|
1542
|
+
* Uses sequential for...of to avoid keychain concurrency (same pattern as rotation loop).
|
|
1543
|
+
* Reuses probeAndPersistKey from manage-agents-core.cjs (DRY -- do not duplicate probe/classify/write logic).
|
|
1544
|
+
*/
|
|
1545
|
+
async function validateRotatedKeys(rotatedSlots) {
|
|
1546
|
+
const data = readClaudeJson();
|
|
1547
|
+
const servers = getGlobalMcpServers(data);
|
|
1548
|
+
const secretsLib = loadSecrets();
|
|
1549
|
+
for (const slotName of rotatedSlots) {
|
|
1550
|
+
const cfg = servers[slotName] || {};
|
|
1551
|
+
const env = cfg.env || {};
|
|
1552
|
+
if (!env.ANTHROPIC_BASE_URL) continue;
|
|
1553
|
+
let apiKey = env.ANTHROPIC_API_KEY || '';
|
|
1554
|
+
// If keytar is available, try to read the key from secure storage
|
|
1555
|
+
// Follow the SAME pattern as probeAllSlots (manage-agents-core.cjs line 620-627)
|
|
1556
|
+
if (secretsLib) {
|
|
1557
|
+
try {
|
|
1558
|
+
const account = deriveKeytarAccount(slotName);
|
|
1559
|
+
const k = await secretsLib.get('qgsd', account);
|
|
1560
|
+
if (k) apiKey = k;
|
|
1561
|
+
} catch (_) {}
|
|
1562
|
+
}
|
|
1563
|
+
await probeAndPersistKey(slotName, env.ANTHROPIC_BASE_URL, apiKey);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// ─── Batch Rotate Keys ────────────────────────────────────────────────────────
|
|
1568
|
+
async function batchRotateFlow() {
|
|
1569
|
+
const data = readClaudeJson();
|
|
1570
|
+
const servers = getGlobalMcpServers(data);
|
|
1571
|
+
const slots = Object.keys(servers);
|
|
1572
|
+
if (!slots.length) { toast('No agents configured', true); return; }
|
|
1573
|
+
|
|
1574
|
+
const secrets = loadSecrets();
|
|
1575
|
+
setContent('Batch Rotate Keys', '{gray-fg}Select slots to rotate (one at a time, pick Done when finished)…{/}');
|
|
1576
|
+
|
|
1577
|
+
const remaining = [...slots];
|
|
1578
|
+
const rotated = [];
|
|
1579
|
+
|
|
1580
|
+
while (remaining.length) {
|
|
1581
|
+
const items = [
|
|
1582
|
+
...remaining.map(s => {
|
|
1583
|
+
const account = deriveKeytarAccount(s);
|
|
1584
|
+
const hasKey = (secrets && secrets.hasKey(account)) || !!(servers[s].env || {}).ANTHROPIC_API_KEY;
|
|
1585
|
+
return { label: `${pad(s, 14)} ${hasKey ? '[key set]' : '[no key]'}`, value: s };
|
|
1586
|
+
}),
|
|
1587
|
+
{ label: '─── Done', value: '__done__' },
|
|
1588
|
+
];
|
|
1589
|
+
|
|
1590
|
+
let picked;
|
|
1591
|
+
try { picked = await promptList({ title: 'Batch Rotate — Pick slot', items }); }
|
|
1592
|
+
catch (_) { break; } // ESC → exit loop, show summary
|
|
1593
|
+
if (picked.value === '__done__') break;
|
|
1594
|
+
|
|
1595
|
+
let newKey;
|
|
1596
|
+
try {
|
|
1597
|
+
newKey = await promptInput({
|
|
1598
|
+
title: `Rotate key for "${picked.value}"`,
|
|
1599
|
+
prompt: `New API key for ${picked.value}:`,
|
|
1600
|
+
isPassword: true,
|
|
1601
|
+
});
|
|
1602
|
+
} catch (_) { continue; } // ESC → skip slot, re-show picker
|
|
1603
|
+
if (!newKey) { toast('Empty value — skipped', true); continue; }
|
|
1604
|
+
|
|
1605
|
+
const account = deriveKeytarAccount(picked.value);
|
|
1606
|
+
if (secrets) {
|
|
1607
|
+
await secrets.set('qgsd', account, newKey);
|
|
1608
|
+
} else {
|
|
1609
|
+
if (!servers[picked.value].env) servers[picked.value].env = {};
|
|
1610
|
+
servers[picked.value].env.ANTHROPIC_API_KEY = newKey;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
rotated.push(picked.value);
|
|
1614
|
+
remaining.splice(remaining.indexOf(picked.value), 1);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (rotated.length) {
|
|
1618
|
+
if (!secrets) writeClaudeJson(data);
|
|
1619
|
+
toast(`✓ ${rotated.length} slot(s) rotated`);
|
|
1620
|
+
// Fire-and-forget validation (CRED-01: does not block quorum dispatch)
|
|
1621
|
+
validateRotatedKeys(rotated).catch(() => {});
|
|
1622
|
+
} else {
|
|
1623
|
+
toast('No keys rotated');
|
|
1624
|
+
}
|
|
1625
|
+
renderList();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// ─── Live Health ──────────────────────────────────────────────────────────────
|
|
1629
|
+
let healthInterval = null;
|
|
1630
|
+
async function renderHealth() {
|
|
1631
|
+
setContent('Live Health', '{gray-fg}Probing slots…{/}');
|
|
1632
|
+
try {
|
|
1633
|
+
const data = readClaudeJson();
|
|
1634
|
+
const servers = getGlobalMcpServers(data);
|
|
1635
|
+
const slots = Object.keys(servers);
|
|
1636
|
+
const healthMap = await probeAllSlots(servers, slots, loadSecrets());
|
|
1637
|
+
const lines = buildDashboardLines(slots, servers, healthMap, Date.now());
|
|
1638
|
+
setContent('Live Health', lines.join('\n'));
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
setContent('Live Health', `{red-fg}Error: ${err.message}{/}`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// ─── Scoreboard ──────────────────────────────────────────────────────────────
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Build formatted scoreboard lines from parsed quorum-scoreboard.json data.
|
|
1648
|
+
* Pure function — returns blessed-tagged string[].
|
|
1649
|
+
* @param {object} data Parsed quorum-scoreboard.json
|
|
1650
|
+
* @param {object} [opts]
|
|
1651
|
+
* @param {string} [opts.orchestrator='claude'] Model key of the orchestrator (excluded from voter ranking)
|
|
1652
|
+
*/
|
|
1653
|
+
function buildScoreboardLines(data, opts) {
|
|
1654
|
+
if (!data || !data.models) return ['{gray-fg}No scoreboard data found.{/}'];
|
|
1655
|
+
|
|
1656
|
+
const orchestrator = (opts && opts.orchestrator) || 'claude';
|
|
1657
|
+
const roster = (opts && opts.roster) || null; // Set<string> of current slot names, or null = show all
|
|
1658
|
+
|
|
1659
|
+
// Build provider info lookup: slot name → { cli, model }
|
|
1660
|
+
const providerMap = new Map();
|
|
1661
|
+
if (opts && opts.providers) {
|
|
1662
|
+
for (const p of opts.providers) {
|
|
1663
|
+
const cli = (p.cli || '').split('/').pop() || '\u2014';
|
|
1664
|
+
const model = (p.model || '').split('/').pop() || '\u2014';
|
|
1665
|
+
providerMap.set(p.name, { cli, model });
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Guard for empty providers
|
|
1670
|
+
if (opts && opts.providers && opts.providers.length === 0) {
|
|
1671
|
+
const lines = [];
|
|
1672
|
+
lines.push('{bold} Quorum Scoreboard{/bold}');
|
|
1673
|
+
lines.push(' {gray-fg}No agents configured in providers.json.{/}');
|
|
1674
|
+
lines.push(' {gray-fg}Run /qgsd:mcp-setup to add agents.{/}');
|
|
1675
|
+
lines.push('');
|
|
1676
|
+
return lines;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
const W = { slot: 10, cli: 8, model: 16, score: 5, inv: 4, norm: 6, tp: 3, tn: 3, fp: 3, fn: 3, impr: 4 };
|
|
1680
|
+
const SEP_W = 85;
|
|
1681
|
+
const lines = [];
|
|
1682
|
+
|
|
1683
|
+
lines.push('{bold} Quorum Scoreboard{/bold}');
|
|
1684
|
+
lines.push(' ' + '\u2500'.repeat(SEP_W));
|
|
1685
|
+
lines.push('');
|
|
1686
|
+
|
|
1687
|
+
// Header
|
|
1688
|
+
const hdr =
|
|
1689
|
+
' ' +
|
|
1690
|
+
pad('Slot', W.slot) + ' ' +
|
|
1691
|
+
pad('CLI', W.cli) + ' ' +
|
|
1692
|
+
pad('Model', W.model) + ' ' +
|
|
1693
|
+
'Score'.padStart(W.score) + ' ' +
|
|
1694
|
+
'Inv'.padStart(W.inv) + ' ' +
|
|
1695
|
+
'Norm'.padStart(W.norm) + ' ' +
|
|
1696
|
+
'TP'.padStart(W.tp) + ' ' +
|
|
1697
|
+
'TN'.padStart(W.tn) + ' ' +
|
|
1698
|
+
'FP'.padStart(W.fp) + ' ' +
|
|
1699
|
+
'FN'.padStart(W.fn) + ' ' +
|
|
1700
|
+
'Impr'.padStart(W.impr);
|
|
1701
|
+
lines.push('{bold}' + hdr + '{/bold}');
|
|
1702
|
+
lines.push(' ' + '\u2500'.repeat(SEP_W));
|
|
1703
|
+
|
|
1704
|
+
// Format a single row
|
|
1705
|
+
function fmtRow(e) {
|
|
1706
|
+
const normStr = e.norm.toFixed(2);
|
|
1707
|
+
const normColor = e.norm >= 1.2 ? 'green' : e.norm >= 1.0 ? '#4a9090' : e.norm >= 0 ? 'yellow' : 'red';
|
|
1708
|
+
return (
|
|
1709
|
+
' ' +
|
|
1710
|
+
pad(e.name, W.slot) + ' ' +
|
|
1711
|
+
pad(e.cli, W.cli) + ' ' +
|
|
1712
|
+
pad(e.model, W.model) + ' ' +
|
|
1713
|
+
String(e.score).padStart(W.score) + ' ' +
|
|
1714
|
+
String(e.inv).padStart(W.inv) + ' ' +
|
|
1715
|
+
`{${normColor}-fg}${normStr.padStart(W.norm)}{/}` + ' ' +
|
|
1716
|
+
String(e.tp).padStart(W.tp) + ' ' +
|
|
1717
|
+
String(e.tn).padStart(W.tn) + ' ' +
|
|
1718
|
+
(e.fp > 0 ? `{red-fg}${String(e.fp).padStart(W.fp)}{/}` : String(e.fp).padStart(W.fp)) + ' ' +
|
|
1719
|
+
(e.fn > 0 ? `{yellow-fg}${String(e.fn).padStart(W.fn)}{/}` : String(e.fn).padStart(W.fn)) + ' ' +
|
|
1720
|
+
(e.impr > 0 ? `{green-fg}${String(e.impr).padStart(W.impr)}{/}` : String(e.impr).padStart(W.impr))
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function toEntry(name, cli, model, m) {
|
|
1725
|
+
const s = m || {};
|
|
1726
|
+
const inv = s.invocations || 0;
|
|
1727
|
+
return {
|
|
1728
|
+
name, cli, model,
|
|
1729
|
+
score: s.score || 0, inv,
|
|
1730
|
+
norm: inv > 0 ? (s.score || 0) / inv : 0,
|
|
1731
|
+
tp: s.tp || 0, tn: s.tn || 0, fp: s.fp || 0, fn: s.fn || 0,
|
|
1732
|
+
impr: s.impr || 0,
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// When providers are available, do exact composite-key lookups so scores
|
|
1737
|
+
// match the specific slot+model combination currently configured.
|
|
1738
|
+
// Without providers, fall back to slot-name aggregation (legacy mode).
|
|
1739
|
+
const entries = [];
|
|
1740
|
+
const dormant = [];
|
|
1741
|
+
|
|
1742
|
+
if (opts && opts.providers) {
|
|
1743
|
+
const slots = data.slots || {};
|
|
1744
|
+
const ZERO = { score: 0, invocations: 0, tp: 0, tn: 0, fp: 0, fn: 0, impr: 0 };
|
|
1745
|
+
|
|
1746
|
+
for (const p of opts.providers) {
|
|
1747
|
+
if (p.name === orchestrator) continue;
|
|
1748
|
+
const cli = (p.cli || '').split('/').pop() || '\u2014';
|
|
1749
|
+
const modelKey = p.model || '';
|
|
1750
|
+
const shortMdl = modelKey.split('/').pop() || '\u2014';
|
|
1751
|
+
|
|
1752
|
+
// Exact slot match for the current model
|
|
1753
|
+
const compositeKey = p.name + ':' + modelKey;
|
|
1754
|
+
const slotStats = slots[compositeKey] || ZERO;
|
|
1755
|
+
|
|
1756
|
+
// For primary (-1) slots, add legacy model-family data.
|
|
1757
|
+
// Rounds used model-family keys (e.g. "copilot") before switching to
|
|
1758
|
+
// composite keys (e.g. "copilot-1:gpt-4.1"), so the two are non-overlapping.
|
|
1759
|
+
const familyName = p.name.replace(/-\d+$/, '');
|
|
1760
|
+
const modelStats = (p.name.endsWith('-1') && familyName !== orchestrator)
|
|
1761
|
+
? (data.models[familyName] || ZERO)
|
|
1762
|
+
: ZERO;
|
|
1763
|
+
|
|
1764
|
+
const merged = {
|
|
1765
|
+
score: (slotStats.score || 0) + (modelStats.score || 0),
|
|
1766
|
+
invocations: (slotStats.invocations || 0) + (modelStats.invocations || 0),
|
|
1767
|
+
tp: (slotStats.tp || 0) + (modelStats.tp || 0),
|
|
1768
|
+
tn: (slotStats.tn || 0) + (modelStats.tn || 0),
|
|
1769
|
+
fp: (slotStats.fp || 0) + (modelStats.fp || 0),
|
|
1770
|
+
fn: (slotStats.fn || 0) + (modelStats.fn || 0),
|
|
1771
|
+
impr: (slotStats.impr || 0) + (modelStats.impr || 0),
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
const e = toEntry(p.name, cli, shortMdl, merged);
|
|
1775
|
+
if (e.inv > 0) { entries.push(e); } else { dormant.push(p.name); }
|
|
1776
|
+
}
|
|
1777
|
+
} else {
|
|
1778
|
+
// Legacy fallback: aggregate by slot name (no exact model matching)
|
|
1779
|
+
const agentMap = new Map();
|
|
1780
|
+
function mergeInto(name, stats) {
|
|
1781
|
+
const prev = agentMap.get(name) || { score: 0, invocations: 0, tp: 0, tn: 0, fp: 0, fn: 0, impr: 0 };
|
|
1782
|
+
prev.score += stats.score || 0;
|
|
1783
|
+
prev.invocations += stats.invocations || 0;
|
|
1784
|
+
prev.tp += stats.tp || 0;
|
|
1785
|
+
prev.tn += stats.tn || 0;
|
|
1786
|
+
prev.fp += stats.fp || 0;
|
|
1787
|
+
prev.fn += stats.fn || 0;
|
|
1788
|
+
prev.impr += stats.impr || 0;
|
|
1789
|
+
agentMap.set(name, prev);
|
|
1790
|
+
}
|
|
1791
|
+
for (const [name, m] of Object.entries(data.models)) {
|
|
1792
|
+
if (name === orchestrator) continue;
|
|
1793
|
+
if (roster && !roster.has(name)) continue;
|
|
1794
|
+
mergeInto(name, m);
|
|
1795
|
+
}
|
|
1796
|
+
if (data.slots) {
|
|
1797
|
+
for (const s of Object.values(data.slots)) {
|
|
1798
|
+
const slotName = s.slot || '?';
|
|
1799
|
+
if (roster && !roster.has(slotName)) continue;
|
|
1800
|
+
mergeInto(slotName, s);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
for (const [name, m] of agentMap) {
|
|
1804
|
+
const e = toEntry(name, '\u2014', '\u2014', m);
|
|
1805
|
+
if (e.inv > 0) { entries.push(e); } else { dormant.push(name); }
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
entries.sort((a, b) => b.norm - a.norm || b.score - a.score);
|
|
1810
|
+
|
|
1811
|
+
for (const e of entries) {
|
|
1812
|
+
lines.push(fmtRow(e));
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (dormant.length > 0) {
|
|
1816
|
+
lines.push('');
|
|
1817
|
+
lines.push(` {gray-fg}Dormant: ${dormant.join(', ')}{/}`);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Delivery stats
|
|
1821
|
+
if (data.delivery_stats && data.delivery_stats.total_rounds > 0) {
|
|
1822
|
+
const ds = data.delivery_stats;
|
|
1823
|
+
lines.push('');
|
|
1824
|
+
lines.push(' ' + '\u2500'.repeat(SEP_W));
|
|
1825
|
+
lines.push(` Total rounds: {bold}${ds.total_rounds}{/bold} Target votes: ${ds.target_vote_count || 3}`);
|
|
1826
|
+
|
|
1827
|
+
const outcomes = Object.entries(ds.achieved_by_outcome || {})
|
|
1828
|
+
.sort(([a], [b]) => b.localeCompare(a)); // descending vote count
|
|
1829
|
+
for (const [key, val] of outcomes) {
|
|
1830
|
+
const label = key.replace('_', ' ');
|
|
1831
|
+
lines.push(` ${label}: ${val.count} (${val.pct}%)`);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
lines.push('');
|
|
1836
|
+
lines.push(' {gray-fg}Norm = score \u00F7 invocations | [q/Esc] back{/}');
|
|
1837
|
+
|
|
1838
|
+
return lines;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function renderScoreboard() {
|
|
1842
|
+
try {
|
|
1843
|
+
let sbPath;
|
|
1844
|
+
try {
|
|
1845
|
+
const pp = require('./planning-paths.cjs');
|
|
1846
|
+
sbPath = pp.resolveWithFallback(process.cwd(), 'quorum-scoreboard');
|
|
1847
|
+
} catch (_) {
|
|
1848
|
+
sbPath = path.resolve(process.cwd(), path.join('.planning', 'quorum-scoreboard.json'));
|
|
1849
|
+
}
|
|
1850
|
+
if (!fs.existsSync(sbPath)) {
|
|
1851
|
+
setContent('Scoreboard', '{gray-fg}No scoreboard found{/}');
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
const data = JSON.parse(fs.readFileSync(sbPath, 'utf8'));
|
|
1855
|
+
const pdata = readProvidersJson();
|
|
1856
|
+
const providersList = pdata.providers || [];
|
|
1857
|
+
const roster = new Set(providersList.map(p => p.name));
|
|
1858
|
+
const lines = buildScoreboardLines(data, { roster, providers: providersList });
|
|
1859
|
+
setContent('Scoreboard', lines.join('\n'));
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
setContent('Scoreboard', `{red-fg}Error: ${err.message}{/}`);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// ─── Update Agents ────────────────────────────────────────────────────────────
|
|
1866
|
+
async function updateAgentsFlow() {
|
|
1867
|
+
setContent('Update Agents', '{gray-fg}Checking update status…{/}');
|
|
1868
|
+
|
|
1869
|
+
let statuses;
|
|
1870
|
+
try {
|
|
1871
|
+
statuses = await getUpdateStatuses();
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
setContent('Update Agents', `{red-fg}Error: ${err.message}{/}`);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// Build CLI metadata map for running updates
|
|
1878
|
+
const CLI_META = {
|
|
1879
|
+
codex: { installType: 'npm-global', pkg: '@openai/codex' },
|
|
1880
|
+
gemini: { installType: 'npm-global', pkg: '@google/gemini-cli' },
|
|
1881
|
+
opencode: { installType: 'npm-global', pkg: 'opencode' },
|
|
1882
|
+
copilot: { installType: 'gh-extension', ext: 'github/gh-copilot' },
|
|
1883
|
+
ccr: { installType: 'npm-global', pkg: 'claude-code-router' },
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const lines = ['{bold}Update Status{/bold}', '─'.repeat(50)];
|
|
1887
|
+
const outdated = [];
|
|
1888
|
+
for (const [name, info] of statuses) {
|
|
1889
|
+
const badge = info.status === 'up-to-date'
|
|
1890
|
+
? '{green-fg}✓ up to date{/}'
|
|
1891
|
+
: info.status === 'update-available'
|
|
1892
|
+
? `{yellow-fg}↑ ${info.current || '?'} → ${info.latest || '?'}{/}`
|
|
1893
|
+
: '{gray-fg}— unknown{/}';
|
|
1894
|
+
lines.push(` ${pad(name, 14)} ${badge}`);
|
|
1895
|
+
if (info.status === 'update-available' && CLI_META[name]) {
|
|
1896
|
+
outdated.push({ name, meta: CLI_META[name], latest: info.latest });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (!statuses.size) lines.push(' {gray-fg}No managed agents detected.{/}');
|
|
1900
|
+
|
|
1901
|
+
if (outdated.length === 0) {
|
|
1902
|
+
lines.push('', '{green-fg}All agents are up to date.{/}');
|
|
1903
|
+
setContent('Update Agents', lines.join('\n'));
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
lines.push('');
|
|
1908
|
+
setContent('Update Agents', lines.join('\n'));
|
|
1909
|
+
|
|
1910
|
+
// Choice loop: ESC at top level → main menu; ESC at checkbox → back to choice
|
|
1911
|
+
let toUpdate;
|
|
1912
|
+
while (true) {
|
|
1913
|
+
let choice;
|
|
1914
|
+
try {
|
|
1915
|
+
choice = await promptList({
|
|
1916
|
+
title: 'Apply Updates',
|
|
1917
|
+
items: [
|
|
1918
|
+
{ label: `Update all (${outdated.length})`, value: 'all' },
|
|
1919
|
+
{ label: 'Select individual', value: 'select' },
|
|
1920
|
+
{ label: 'Skip', value: 'skip' },
|
|
1921
|
+
],
|
|
1922
|
+
});
|
|
1923
|
+
} catch (_) { return; } // ESC → main menu
|
|
1924
|
+
|
|
1925
|
+
if (choice.value === 'skip') { toast('Skipped.'); return; }
|
|
1926
|
+
|
|
1927
|
+
if (choice.value === 'all') { toUpdate = outdated; break; }
|
|
1928
|
+
|
|
1929
|
+
// 'select' — checkbox; ESC → re-show choice list
|
|
1930
|
+
try {
|
|
1931
|
+
const picked = await promptCheckbox({
|
|
1932
|
+
title: 'Select agents to update',
|
|
1933
|
+
items: outdated.map(r => ({
|
|
1934
|
+
label: `${r.name} (→ ${r.latest || '?'})`,
|
|
1935
|
+
value: r.name,
|
|
1936
|
+
})),
|
|
1937
|
+
});
|
|
1938
|
+
toUpdate = outdated.filter(r => picked.includes(r.name));
|
|
1939
|
+
} catch (_) { continue; } // ESC → re-show choice list
|
|
1940
|
+
if (!toUpdate.length) { toast('Nothing selected.'); continue; }
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Run updates — capture output, display in contentBox
|
|
1945
|
+
const results = [];
|
|
1946
|
+
for (const { name, meta } of toUpdate) {
|
|
1947
|
+
setContent('Update Agents', `{gray-fg}Updating ${name}…{/}`);
|
|
1948
|
+
let cmd, args;
|
|
1949
|
+
if (meta.installType === 'npm-global') {
|
|
1950
|
+
cmd = 'npm'; args = ['install', '-g', `${meta.pkg}@latest`];
|
|
1951
|
+
} else {
|
|
1952
|
+
cmd = 'gh'; args = ['extension', 'upgrade', 'copilot'];
|
|
1953
|
+
}
|
|
1954
|
+
const res = spawnSync(cmd, args, {
|
|
1955
|
+
encoding: 'utf8', timeout: 60000,
|
|
1956
|
+
});
|
|
1957
|
+
const ok = res.status === 0;
|
|
1958
|
+
results.push(ok
|
|
1959
|
+
? `{green-fg}✓ ${name} updated{/}`
|
|
1960
|
+
: `{red-fg}✗ ${name} failed (exit ${res.status}){/}\n ${(res.stderr || '').slice(0, 200)}`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// Refresh badge after updates
|
|
1965
|
+
try {
|
|
1966
|
+
const fresh = await getUpdateStatuses();
|
|
1967
|
+
const remaining = [...fresh.values()].filter(s => s.status === 'update-available').length;
|
|
1968
|
+
applyUpdateBadge(remaining);
|
|
1969
|
+
} catch (_) {}
|
|
1970
|
+
|
|
1971
|
+
setContent('Update Agents', ['{bold}Update Results{/bold}', '─'.repeat(50), ...results].join('\n'));
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// ─── Settings helpers ─────────────────────────────────────────────────────────
|
|
1975
|
+
const AGENT_TIERS = {
|
|
1976
|
+
'qgsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' },
|
|
1977
|
+
'qgsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
|
|
1978
|
+
'qgsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
|
|
1979
|
+
'qgsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
|
|
1980
|
+
'qgsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' },
|
|
1981
|
+
};
|
|
1982
|
+
const AGENT_LABELS = {
|
|
1983
|
+
'qgsd-planner': 'Planner',
|
|
1984
|
+
'qgsd-executor': 'Executor',
|
|
1985
|
+
'qgsd-phase-researcher': 'Researcher',
|
|
1986
|
+
'qgsd-verifier': 'Verifier',
|
|
1987
|
+
'qgsd-codebase-mapper': 'Mapper',
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
function readProjectConfig() {
|
|
1991
|
+
try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), '.planning', 'config.json'), 'utf8')); }
|
|
1992
|
+
catch (_) { return {}; }
|
|
1993
|
+
}
|
|
1994
|
+
function writeProjectConfig(cfg) {
|
|
1995
|
+
const p = path.join(process.cwd(), '.planning', 'config.json');
|
|
1996
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1997
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// ─── Settings ─────────────────────────────────────────────────────────────────
|
|
2001
|
+
async function settingsFlow() {
|
|
2002
|
+
while (true) {
|
|
2003
|
+
const cfg = readProjectConfig();
|
|
2004
|
+
const qgsd = readQgsdJson();
|
|
2005
|
+
const profile = cfg.model_profile || 'balanced';
|
|
2006
|
+
const defN = qgsd.quorum?.maxSize ?? 3;
|
|
2007
|
+
const byProf = qgsd.quorum?.maxSizeByProfile || {};
|
|
2008
|
+
const effN = byProf[profile] ?? defN;
|
|
2009
|
+
const nStr = String(effN) + (byProf[profile] != null ? '*' : '');
|
|
2010
|
+
const ovCount = Object.keys(cfg.model_overrides || {}).length;
|
|
2011
|
+
|
|
2012
|
+
let picked;
|
|
2013
|
+
try {
|
|
2014
|
+
picked = await promptList({ title: 'Settings', items: [
|
|
2015
|
+
{ label: ` Profile ${profile}`, value: 'profile' },
|
|
2016
|
+
{ label: ` Quorum n ${nStr} →`, value: 'n' },
|
|
2017
|
+
{ label: ` Fail mode ${qgsd.fail_mode || '—'}`,value: 'fail' },
|
|
2018
|
+
{ label: ` Model overrides ${ovCount ? `${ovCount} active` : 'none'} →`, value: 'overrides' },
|
|
2019
|
+
]});
|
|
2020
|
+
} catch (_) { return; }
|
|
2021
|
+
|
|
2022
|
+
if (picked.value === 'profile') {
|
|
2023
|
+
let choice;
|
|
2024
|
+
try {
|
|
2025
|
+
choice = await promptList({ title: 'Settings — Profile', items: [
|
|
2026
|
+
{ label: 'quality Opus for all decision-making', value: 'quality' },
|
|
2027
|
+
{ label: 'balanced Opus planner · Sonnet workers (default)', value: 'balanced' },
|
|
2028
|
+
{ label: 'budget Sonnet workers · Haiku checkers', value: 'budget' },
|
|
2029
|
+
]});
|
|
2030
|
+
} catch (_) { continue; }
|
|
2031
|
+
const c = readProjectConfig();
|
|
2032
|
+
c.model_profile = choice.value;
|
|
2033
|
+
writeProjectConfig(c);
|
|
2034
|
+
refreshSettingsPane();
|
|
2035
|
+
toast(`✓ Profile → ${choice.value}`);
|
|
2036
|
+
|
|
2037
|
+
} else if (picked.value === 'n') {
|
|
2038
|
+
await quorumNFlow();
|
|
2039
|
+
refreshSettingsPane();
|
|
2040
|
+
|
|
2041
|
+
} else if (picked.value === 'fail') {
|
|
2042
|
+
let choice;
|
|
2043
|
+
try {
|
|
2044
|
+
choice = await promptList({ title: 'Settings — Fail Mode', items: [
|
|
2045
|
+
{ label: 'lenient Continue with partial quorum (default)', value: 'lenient' },
|
|
2046
|
+
{ label: 'strict Block if any required slot fails', value: 'strict' },
|
|
2047
|
+
]});
|
|
2048
|
+
} catch (_) { continue; }
|
|
2049
|
+
const qg = readQgsdJson();
|
|
2050
|
+
qg.fail_mode = choice.value;
|
|
2051
|
+
writeQgsdJson(qg);
|
|
2052
|
+
refreshSettingsPane();
|
|
2053
|
+
toast(`✓ Fail mode → ${choice.value}`);
|
|
2054
|
+
|
|
2055
|
+
} else if (picked.value === 'overrides') {
|
|
2056
|
+
await modelOverridesFlow();
|
|
2057
|
+
refreshSettingsPane();
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
async function modelOverridesFlow() {
|
|
2063
|
+
while (true) {
|
|
2064
|
+
const cfg = readProjectConfig();
|
|
2065
|
+
const profile = cfg.model_profile || 'balanced';
|
|
2066
|
+
const overrides = cfg.model_overrides || {};
|
|
2067
|
+
const items = Object.keys(AGENT_TIERS).map(key => {
|
|
2068
|
+
const def = AGENT_TIERS[key][profile];
|
|
2069
|
+
const ov = overrides[key];
|
|
2070
|
+
const tag = ov
|
|
2071
|
+
? `{#4a9090-fg}${ov}{/} {#3d3d3d-fg}(override){/}`
|
|
2072
|
+
: `${def} {#3d3d3d-fg}(${profile} default){/}`;
|
|
2073
|
+
return { label: ` ${pad(AGENT_LABELS[key], 14)} ${tag}`, value: key };
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
let picked;
|
|
2077
|
+
try { picked = await promptList({ title: 'Model Overrides', items }); }
|
|
2078
|
+
catch (_) { return; }
|
|
2079
|
+
|
|
2080
|
+
const cfg2 = readProjectConfig();
|
|
2081
|
+
const prof2 = cfg2.model_profile || 'balanced';
|
|
2082
|
+
const defTier = AGENT_TIERS[picked.value][prof2];
|
|
2083
|
+
let choice;
|
|
2084
|
+
try {
|
|
2085
|
+
choice = await promptList({ title: `Override — ${AGENT_LABELS[picked.value]}`, items: [
|
|
2086
|
+
{ label: `opus ${defTier === 'opus' ? '← profile default' : ''}`, value: 'opus' },
|
|
2087
|
+
{ label: `sonnet ${defTier === 'sonnet' ? '← profile default' : ''}`, value: 'sonnet' },
|
|
2088
|
+
{ label: `haiku ${defTier === 'haiku' ? '← profile default' : ''}`, value: 'haiku' },
|
|
2089
|
+
{ label: `reset use profile default (${defTier})`, value: '__reset__' },
|
|
2090
|
+
]});
|
|
2091
|
+
} catch (_) { continue; }
|
|
2092
|
+
|
|
2093
|
+
const c = readProjectConfig();
|
|
2094
|
+
if (!c.model_overrides) c.model_overrides = {};
|
|
2095
|
+
if (choice.value === '__reset__') {
|
|
2096
|
+
delete c.model_overrides[picked.value];
|
|
2097
|
+
if (!Object.keys(c.model_overrides).length) delete c.model_overrides;
|
|
2098
|
+
} else {
|
|
2099
|
+
c.model_overrides[picked.value] = choice.value;
|
|
2100
|
+
}
|
|
2101
|
+
writeProjectConfig(c);
|
|
2102
|
+
refreshSettingsPane();
|
|
2103
|
+
const msg = choice.value === '__reset__'
|
|
2104
|
+
? `${AGENT_LABELS[picked.value]} reset to profile default (${defTier})`
|
|
2105
|
+
: `${AGENT_LABELS[picked.value]} → ${choice.value}`;
|
|
2106
|
+
toast(`✓ ${msg}`);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
async function quorumNFlow() {
|
|
2111
|
+
while (true) {
|
|
2112
|
+
const qgsd = readQgsdJson();
|
|
2113
|
+
const defN = qgsd.quorum?.maxSize ?? 3;
|
|
2114
|
+
const byProf = qgsd.quorum?.maxSizeByProfile || {};
|
|
2115
|
+
const fmt = p => byProf[p] != null ? String(byProf[p]) : `${defN} (default)`;
|
|
2116
|
+
|
|
2117
|
+
let picked;
|
|
2118
|
+
try {
|
|
2119
|
+
picked = await promptList({ title: 'Quorum n', items: [
|
|
2120
|
+
{ label: ` Default n=${defN} (used when no per-profile override)`, value: 'default' },
|
|
2121
|
+
{ label: ` Quality n=${fmt('quality')}`, value: 'quality' },
|
|
2122
|
+
{ label: ` Balanced n=${fmt('balanced')}`, value: 'balanced' },
|
|
2123
|
+
{ label: ` Budget n=${fmt('budget')}`, value: 'budget' },
|
|
2124
|
+
]});
|
|
2125
|
+
} catch (_) { return; }
|
|
2126
|
+
|
|
2127
|
+
const current = picked.value === 'default' ? defN : (byProf[picked.value] ?? defN);
|
|
2128
|
+
let val;
|
|
2129
|
+
try {
|
|
2130
|
+
val = await promptInput({
|
|
2131
|
+
title: `Quorum n — ${picked.value}`,
|
|
2132
|
+
prompt: `n (0 = remove per-profile override, blank = keep current):`,
|
|
2133
|
+
default: String(current),
|
|
2134
|
+
});
|
|
2135
|
+
} catch (_) { continue; }
|
|
2136
|
+
|
|
2137
|
+
if (!val || val.trim() === '' || val.trim() === String(current)) continue;
|
|
2138
|
+
const n = parseInt(val.trim(), 10);
|
|
2139
|
+
if (isNaN(n) || n < 0) { toast('Invalid — enter a positive number or 0 to remove', true); continue; }
|
|
2140
|
+
|
|
2141
|
+
if (!qgsd.quorum) qgsd.quorum = {};
|
|
2142
|
+
if (picked.value === 'default') {
|
|
2143
|
+
qgsd.quorum.maxSize = n;
|
|
2144
|
+
} else {
|
|
2145
|
+
if (!qgsd.quorum.maxSizeByProfile) qgsd.quorum.maxSizeByProfile = {};
|
|
2146
|
+
if (n === 0) {
|
|
2147
|
+
delete qgsd.quorum.maxSizeByProfile[picked.value];
|
|
2148
|
+
if (!Object.keys(qgsd.quorum.maxSizeByProfile).length) delete qgsd.quorum.maxSizeByProfile;
|
|
2149
|
+
} else {
|
|
2150
|
+
qgsd.quorum.maxSizeByProfile[picked.value] = n;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
writeQgsdJson(qgsd);
|
|
2154
|
+
renderHeader();
|
|
2155
|
+
refreshSettingsPane();
|
|
2156
|
+
toast(`✓ Quorum n (${picked.value}) → ${n === 0 ? 'reset to default' : n}`);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// ─── Tune Timeouts ────────────────────────────────────────────────────────────
|
|
2161
|
+
async function tuneTimeoutsFlow() {
|
|
2162
|
+
const data = readClaudeJson();
|
|
2163
|
+
const servers = getGlobalMcpServers(data);
|
|
2164
|
+
const slots = Object.keys(servers);
|
|
2165
|
+
if (!slots.length) { toast('No slots configured', true); return; }
|
|
2166
|
+
|
|
2167
|
+
let providersData;
|
|
2168
|
+
try { providersData = readProvidersJson(); } catch { providersData = { providers: [] }; }
|
|
2169
|
+
|
|
2170
|
+
const rows = buildTimeoutChoices(slots, servers, providersData);
|
|
2171
|
+
let changed = false;
|
|
2172
|
+
|
|
2173
|
+
for (const { slotName, providerSlot, currentMs } of rows) {
|
|
2174
|
+
let val;
|
|
2175
|
+
try {
|
|
2176
|
+
val = await promptInput({
|
|
2177
|
+
title: `Tune Timeout — ${slotName}`,
|
|
2178
|
+
prompt: `Current: ${currentMs != null ? currentMs + ' ms' : '—'} New timeout (ms, blank = keep):`,
|
|
2179
|
+
default: currentMs != null ? String(currentMs) : '',
|
|
2180
|
+
});
|
|
2181
|
+
} catch (_) { continue; } // ESC → skip slot, move to next
|
|
2182
|
+
const trimmed = (val || '').trim();
|
|
2183
|
+
if (trimmed) {
|
|
2184
|
+
const result = validateTimeout(trimmed);
|
|
2185
|
+
if (!result.valid) {
|
|
2186
|
+
toast(result.error, true);
|
|
2187
|
+
continue;
|
|
2188
|
+
}
|
|
2189
|
+
if (result.ms !== null && result.ms !== currentMs) {
|
|
2190
|
+
const updated = applyTimeoutUpdate(providersData, providerSlot, result.ms);
|
|
2191
|
+
Object.assign(providersData, updated);
|
|
2192
|
+
changed = true;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (changed) {
|
|
2198
|
+
writeProvidersJson(providersData);
|
|
2199
|
+
toast('Timeouts saved — restart Claude Code to apply');
|
|
2200
|
+
} else {
|
|
2201
|
+
toast('No changes made');
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// ─── Set Update Policy ────────────────────────────────────────────────────────
|
|
2206
|
+
async function updatePolicyFlow() {
|
|
2207
|
+
const data = readClaudeJson();
|
|
2208
|
+
const servers = getGlobalMcpServers(data);
|
|
2209
|
+
const slots = Object.keys(servers);
|
|
2210
|
+
if (!slots.length) { toast('No slots configured', true); return; }
|
|
2211
|
+
|
|
2212
|
+
const qgsd = readQgsdJson();
|
|
2213
|
+
const agentConfig = qgsd.agent_config || {};
|
|
2214
|
+
|
|
2215
|
+
const target = await promptList({ title: 'Update Policy — Pick slot',
|
|
2216
|
+
items: slots.map(s => ({
|
|
2217
|
+
label: `${pad(s, 14)} policy: ${(agentConfig[s] || {}).update_policy || '—'}`,
|
|
2218
|
+
value: s,
|
|
2219
|
+
})) });
|
|
2220
|
+
|
|
2221
|
+
const currentPolicy = (agentConfig[target.value] || {}).update_policy || null;
|
|
2222
|
+
const policy = await promptList({ title: `Update Policy — ${target.value}`,
|
|
2223
|
+
items: buildPolicyChoices(currentPolicy).map(c => ({ label: c.name, value: c.value })) });
|
|
2224
|
+
|
|
2225
|
+
writeUpdatePolicy(target.value, policy.value);
|
|
2226
|
+
toast(`✓ ${target.value}: update_policy = ${policy.value}`);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// ─── Export Roster ────────────────────────────────────────────────────────────
|
|
2230
|
+
async function exportFlow() {
|
|
2231
|
+
const filePath = await promptInput({ title: 'Export Roster',
|
|
2232
|
+
prompt: 'Export file path (e.g. ~/Desktop/roster-backup.json):' });
|
|
2233
|
+
if (!filePath) { toast('Path is required', true); return; }
|
|
2234
|
+
|
|
2235
|
+
const resolved = filePath.replace(/^~/, os.homedir());
|
|
2236
|
+
const rawData = readClaudeJson();
|
|
2237
|
+
const exported = buildExportData(rawData);
|
|
2238
|
+
fs.writeFileSync(resolved, JSON.stringify(exported, null, 2), 'utf8');
|
|
2239
|
+
setContent('Export Roster', `{green-fg}✓ Exported to:{/}\n ${resolved}\n\n{gray-fg}All API key values have been replaced with __redacted__{/}`);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// ─── Import Roster ────────────────────────────────────────────────────────────
|
|
2243
|
+
async function importFlow() {
|
|
2244
|
+
const filePath = await promptInput({ title: 'Import Roster', prompt: 'Import file path:' });
|
|
2245
|
+
if (!filePath) { toast('Path is required', true); return; }
|
|
2246
|
+
|
|
2247
|
+
const resolved = filePath.replace(/^~/, os.homedir());
|
|
2248
|
+
let parsed;
|
|
2249
|
+
try {
|
|
2250
|
+
parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
toast(`Error reading file: ${err.message}`, true); return;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
const errors = validateImportSchema(parsed);
|
|
2256
|
+
if (errors.length) {
|
|
2257
|
+
setContent('Import Roster', `{red-fg}Validation failed:{/}\n` + errors.map(e => ` - ${e}`).join('\n'));
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
2262
|
+
const backupPath = buildBackupPath(CLAUDE_JSON_PATH, ts);
|
|
2263
|
+
try { fs.copyFileSync(CLAUDE_JSON_PATH, backupPath); } catch (err) {
|
|
2264
|
+
toast(`Backup failed — import aborted: ${err.message}`, true); return;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// Re-prompt __redacted__ keys
|
|
2268
|
+
const secrets = loadSecrets();
|
|
2269
|
+
for (const [slotName, cfg] of Object.entries(parsed.mcpServers || {})) {
|
|
2270
|
+
if (!cfg.env) continue;
|
|
2271
|
+
for (const [envKey, envVal] of Object.entries(cfg.env)) {
|
|
2272
|
+
if (envVal === '__redacted__') {
|
|
2273
|
+
let val;
|
|
2274
|
+
try {
|
|
2275
|
+
val = await promptInput({ title: `Import — ${slotName}`,
|
|
2276
|
+
prompt: `API key for ${slotName} / ${envKey} (blank = skip):`, isPassword: true });
|
|
2277
|
+
} catch (_) { delete cfg.env[envKey]; continue; } // ESC → skip key, move to next
|
|
2278
|
+
if (val) {
|
|
2279
|
+
if (secrets) { await secrets.set('qgsd', deriveKeytarAccount(slotName), val); delete cfg.env[envKey]; }
|
|
2280
|
+
else { cfg.env[envKey] = val; }
|
|
2281
|
+
} else {
|
|
2282
|
+
delete cfg.env[envKey];
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
writeClaudeJson(parsed);
|
|
2289
|
+
const slotCount = Object.keys(parsed.mcpServers || {}).length;
|
|
2290
|
+
setContent('Import Roster', `{green-fg}✓ Import complete: ${slotCount} server(s) applied.{/}\n Backup at: ${backupPath}`);
|
|
2291
|
+
renderList();
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// ─── Action dispatcher ────────────────────────────────────────────────────────
|
|
2295
|
+
async function dispatch(action) {
|
|
2296
|
+
if (healthInterval) { clearInterval(healthInterval); healthInterval = null; }
|
|
2297
|
+
if (action === 'sep') return;
|
|
2298
|
+
if (action === 'exit') { screen.destroy(); process.exit(0); }
|
|
2299
|
+
|
|
2300
|
+
try {
|
|
2301
|
+
if (action === 'list') renderList();
|
|
2302
|
+
else if (action === 'add') await addAgentFlow();
|
|
2303
|
+
else if (action === 'clone') await cloneSlotFlow();
|
|
2304
|
+
else if (action === 'edit') await editAgentFlow();
|
|
2305
|
+
else if (action === 'remove') await removeAgentFlow();
|
|
2306
|
+
else if (action === 'reorder') await reorderFlow();
|
|
2307
|
+
else if (action === 'health-single') await checkHealthSingle();
|
|
2308
|
+
else if (action === 'login') await loginAgentFlow();
|
|
2309
|
+
else if (action === 'provider-keys') await providerKeysFlow();
|
|
2310
|
+
else if (action === 'batch-rotate') await batchRotateFlow();
|
|
2311
|
+
else if (action === 'health') {
|
|
2312
|
+
await renderHealth();
|
|
2313
|
+
healthInterval = setInterval(renderHealth, 10_000);
|
|
2314
|
+
}
|
|
2315
|
+
else if (action === 'scoreboard') renderScoreboard();
|
|
2316
|
+
else if (action === 'update-agents') await updateAgentsFlow();
|
|
2317
|
+
else if (action === 'settings-view') {
|
|
2318
|
+
_showingSettings = true;
|
|
2319
|
+
setContent('Settings', buildSettingsPaneContent());
|
|
2320
|
+
}
|
|
2321
|
+
else if (action === 'settings') {
|
|
2322
|
+
_showingSettings = true;
|
|
2323
|
+
setContent('Settings', buildSettingsPaneContent());
|
|
2324
|
+
await settingsFlow();
|
|
2325
|
+
_showingSettings = false;
|
|
2326
|
+
}
|
|
2327
|
+
else if (action === 'tune-timeouts') await tuneTimeoutsFlow();
|
|
2328
|
+
else if (action === 'update-policy') await updatePolicyFlow();
|
|
2329
|
+
else if (action === 'export') await exportFlow();
|
|
2330
|
+
else if (action === 'import') await importFlow();
|
|
2331
|
+
else if (action === 'req-browse') await reqBrowseFlow();
|
|
2332
|
+
else if (action === 'req-coverage') renderReqCoverage();
|
|
2333
|
+
else if (action === 'req-traceability') await reqTraceabilityFlow();
|
|
2334
|
+
else if (action === 'req-aggregate') await reqAggregateFlow();
|
|
2335
|
+
else if (action === 'req-gaps') reqCoverageGapsFlow();
|
|
2336
|
+
else if (action === 'session-new') await newSessionFlow();
|
|
2337
|
+
else if (action === 'session-kill') await killSessionFlow();
|
|
2338
|
+
else if (action.startsWith('session-connect-')) {
|
|
2339
|
+
connectSession(parseInt(action.replace('session-connect-', ''), 10));
|
|
2340
|
+
}
|
|
2341
|
+
} catch (err) {
|
|
2342
|
+
if (err.message !== 'cancelled') toast(err.message, true);
|
|
2343
|
+
menuList.focus();
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// ─── Requirements: Coverage ──────────────────────────────────────────────────
|
|
2348
|
+
function renderReqCoverage() {
|
|
2349
|
+
try {
|
|
2350
|
+
const { envelope, requirements } = reqCore.readRequirementsJson();
|
|
2351
|
+
const registry = reqCore.readModelRegistry();
|
|
2352
|
+
const checkResults = reqCore.readCheckResults();
|
|
2353
|
+
const cov = reqCore.computeCoverage(requirements, registry, checkResults);
|
|
2354
|
+
|
|
2355
|
+
const lines = [];
|
|
2356
|
+
lines.push('{bold}Requirements Coverage{/bold}');
|
|
2357
|
+
lines.push('─'.repeat(60));
|
|
2358
|
+
lines.push('');
|
|
2359
|
+
|
|
2360
|
+
// Totals
|
|
2361
|
+
const completePct = cov.total ? ((cov.byStatus.Complete || 0) / cov.total * 100).toFixed(1) : '0.0';
|
|
2362
|
+
const pendingPct = cov.total ? ((cov.byStatus.Pending || 0) / cov.total * 100).toFixed(1) : '0.0';
|
|
2363
|
+
lines.push(` Total: ${cov.total}`);
|
|
2364
|
+
lines.push(` {green-fg}Complete:{/} ${cov.byStatus.Complete || 0} (${completePct}%)`);
|
|
2365
|
+
lines.push(` {yellow-fg}Pending:{/} ${cov.byStatus.Pending || 0} (${pendingPct}%)`);
|
|
2366
|
+
for (const [status, count] of Object.entries(cov.byStatus).sort()) {
|
|
2367
|
+
if (status !== 'Complete' && status !== 'Pending') {
|
|
2368
|
+
lines.push(` ${status}: ${count}`);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
lines.push('');
|
|
2372
|
+
|
|
2373
|
+
// Category group breakdown with "Includes" column
|
|
2374
|
+
lines.push('{bold}By Category{/bold}');
|
|
2375
|
+
const cats = Object.entries(cov.byCategory).sort((a, b) => b[1].total - a[1].total);
|
|
2376
|
+
|
|
2377
|
+
// Build "Includes" column: collect category_raw values per group
|
|
2378
|
+
const groupRaws = {};
|
|
2379
|
+
for (const r of requirements) {
|
|
2380
|
+
const grp = r.category || 'Uncategorized';
|
|
2381
|
+
const raw = r.category_raw || grp;
|
|
2382
|
+
if (groupRaws[grp] === undefined) groupRaws[grp] = new Set();
|
|
2383
|
+
if (raw !== grp) groupRaws[grp].add(raw);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
const catW = Math.max(...cats.map(([c]) => c.length), 12);
|
|
2387
|
+
lines.push(` ${pad('Group', catW)} Reqs Complete Includes`);
|
|
2388
|
+
lines.push(' ' + '─'.repeat(catW + 50));
|
|
2389
|
+
for (const [cat, info] of cats) {
|
|
2390
|
+
const pct = info.total ? (info.complete / info.total * 100).toFixed(0) : '0';
|
|
2391
|
+
const raws = groupRaws[cat];
|
|
2392
|
+
const includes = raws && raws.size > 0 ? [...raws].sort().slice(0, 5).join(', ') + (raws.size > 5 ? '...' : '') : '';
|
|
2393
|
+
lines.push(` ${pad(cat, catW)} ${pad(String(info.total), 4)} ${pad(info.complete + ' (' + pct + '%)', 8)} {#666666-fg}${includes}{/}`);
|
|
2394
|
+
}
|
|
2395
|
+
lines.push('');
|
|
2396
|
+
|
|
2397
|
+
// Formal model coverage
|
|
2398
|
+
const fmPct = cov.total ? (cov.withFormalModels / cov.total * 100).toFixed(1) : '0.0';
|
|
2399
|
+
lines.push('{bold}Formal Verification{/bold}');
|
|
2400
|
+
lines.push(` Models: ${cov.totalModels} formal models registered`);
|
|
2401
|
+
lines.push(` Coverage: ${cov.withFormalModels}/${cov.total} requirements have formal models (${fmPct}%)`);
|
|
2402
|
+
lines.push('');
|
|
2403
|
+
|
|
2404
|
+
// Check results
|
|
2405
|
+
const crPct = cov.total ? (cov.withCheckResults / cov.total * 100).toFixed(1) : '0.0';
|
|
2406
|
+
lines.push('{bold}Check Results{/bold}');
|
|
2407
|
+
lines.push(` Linked: ${cov.withCheckResults}/${cov.total} requirements have check results (${crPct}%)`);
|
|
2408
|
+
for (const [res, count] of Object.entries(cov.checksByResult).sort()) {
|
|
2409
|
+
const color = res === 'pass' ? 'green' : res === 'fail' ? 'red' : 'yellow';
|
|
2410
|
+
lines.push(` {${color}-fg}${pad(res, 14)}{/} ${count}`);
|
|
2411
|
+
}
|
|
2412
|
+
lines.push('');
|
|
2413
|
+
|
|
2414
|
+
// Envelope
|
|
2415
|
+
if (envelope) {
|
|
2416
|
+
lines.push('{bold}Envelope{/bold}');
|
|
2417
|
+
if (envelope.aggregated_at) lines.push(` Aggregated: ${envelope.aggregated_at}`);
|
|
2418
|
+
if (envelope.frozen_at) lines.push(` Frozen: ${envelope.frozen_at}`);
|
|
2419
|
+
if (envelope.content_hash) lines.push(` Hash: ${envelope.content_hash.slice(0, 20)}…`);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
setContent('Coverage', lines.join('\n'));
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
setContent('Coverage', `{red-fg}Error: ${err.message}{/}`);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// ─── Requirements: Browse ────────────────────────────────────────────────────
|
|
2429
|
+
async function reqBrowseFlow() {
|
|
2430
|
+
const { requirements } = reqCore.readRequirementsJson();
|
|
2431
|
+
if (!requirements.length) { setContent('Browse Reqs', 'No requirements found.'); return; }
|
|
2432
|
+
renderReqList(requirements, {});
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function renderReqList(reqs, filters) {
|
|
2436
|
+
const lines = [];
|
|
2437
|
+
const filterDesc = [];
|
|
2438
|
+
if (filters.category) filterDesc.push(`category=${filters.category}`);
|
|
2439
|
+
if (filters.status) filterDesc.push(`status=${filters.status}`);
|
|
2440
|
+
if (filters.search) filterDesc.push(`search="${filters.search}"`);
|
|
2441
|
+
const subtitle = filterDesc.length ? ` (${filterDesc.join(', ')})` : '';
|
|
2442
|
+
|
|
2443
|
+
// Dynamic text width: fill remaining space in contentBox
|
|
2444
|
+
const innerW = (screen.width || 120) - 35 - 2; // contentBox: left=35, borders=2
|
|
2445
|
+
|
|
2446
|
+
lines.push(`{bold}Requirements (${reqs.length})${subtitle}{/bold}`);
|
|
2447
|
+
lines.push('─'.repeat(Math.max(70, innerW)));
|
|
2448
|
+
// indent + ID + gap + Status + gap + Category + gap
|
|
2449
|
+
const fixed = 2 + 12 + 2 + 3 + 2 + 16 + 2;
|
|
2450
|
+
const W = { id: 12, status: 3, cat: 16, text: Math.max(20, innerW - fixed - 1) };
|
|
2451
|
+
lines.push(` ${pad('ID', W.id)} ${pad('St', W.status)} ${pad('Category', W.cat)} Text`);
|
|
2452
|
+
lines.push(' ' + '─'.repeat(W.id + 2 + W.status + 2 + W.cat + 2 + W.text));
|
|
2453
|
+
|
|
2454
|
+
for (const r of reqs) {
|
|
2455
|
+
const icon = r.status === 'Complete' ? '{green-fg}✓{/}' : '{yellow-fg}○{/}';
|
|
2456
|
+
lines.push(
|
|
2457
|
+
` {#4a9090-fg}${pad(r.id, W.id)}{/} ${icon}${' '.repeat(W.status - 1)} ${pad(r.category || 'Uncategorized', W.cat)} ${pad(r.text, W.text)}`
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
setContent(`Browse Reqs (${reqs.length})`, lines.join('\n'));
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// ─── Requirements: Traceability ──────────────────────────────────────────────
|
|
2465
|
+
async function reqTraceabilityFlow() {
|
|
2466
|
+
const { requirements } = reqCore.readRequirementsJson();
|
|
2467
|
+
if (!requirements.length) { setContent('Traceability', 'No requirements found.'); return; }
|
|
2468
|
+
|
|
2469
|
+
const items = requirements.map(r => ({
|
|
2470
|
+
label: `${pad(r.id, 12)} ${r.status === 'Complete' ? '✓' : '○'} ${(r.text || '').slice(0, 40)}`,
|
|
2471
|
+
value: r.id,
|
|
2472
|
+
}));
|
|
2473
|
+
|
|
2474
|
+
const choice = await promptList({ title: 'Traceability — Pick Requirement', items });
|
|
2475
|
+
const reqId = choice.value;
|
|
2476
|
+
|
|
2477
|
+
const registry = reqCore.readModelRegistry();
|
|
2478
|
+
const checkResults = reqCore.readCheckResults();
|
|
2479
|
+
const trace = reqCore.buildTraceability(reqId, requirements, registry, checkResults);
|
|
2480
|
+
|
|
2481
|
+
if (!trace) { setContent('Traceability', `{red-fg}Requirement ${reqId} not found{/}`); return; }
|
|
2482
|
+
|
|
2483
|
+
const lines = [];
|
|
2484
|
+
const r = trace.requirement;
|
|
2485
|
+
|
|
2486
|
+
lines.push(`{bold}${r.id}{/bold} ${r.status === 'Complete' ? '{green-fg}Complete{/}' : '{yellow-fg}' + (r.status || 'Unknown') + '{/}'}`);
|
|
2487
|
+
lines.push('─'.repeat(60));
|
|
2488
|
+
lines.push('');
|
|
2489
|
+
if (r.category) lines.push(` Category: ${r.category}`);
|
|
2490
|
+
if (r.phase) lines.push(` Phase: ${r.phase}`);
|
|
2491
|
+
lines.push('');
|
|
2492
|
+
lines.push(` {bold}Text:{/bold}`);
|
|
2493
|
+
lines.push(` ${r.text || '—'}`);
|
|
2494
|
+
if (r.background) {
|
|
2495
|
+
lines.push('');
|
|
2496
|
+
lines.push(` {bold}Background:{/bold}`);
|
|
2497
|
+
lines.push(` {gray-fg}${r.background}{/}`);
|
|
2498
|
+
}
|
|
2499
|
+
if (r.provenance) {
|
|
2500
|
+
lines.push('');
|
|
2501
|
+
lines.push(` {bold}Source:{/bold}`);
|
|
2502
|
+
if (r.provenance.source_file) lines.push(` File: ${r.provenance.source_file}`);
|
|
2503
|
+
if (r.provenance.milestone) lines.push(` Milestone: ${r.provenance.milestone}`);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// Formal Models
|
|
2507
|
+
lines.push('');
|
|
2508
|
+
lines.push(`{bold}Formal Models (${trace.formalModels.length}){/bold}`);
|
|
2509
|
+
if (trace.formalModels.length) {
|
|
2510
|
+
for (const fm of trace.formalModels) {
|
|
2511
|
+
lines.push(` {cyan-fg}${fm.path}{/}`);
|
|
2512
|
+
if (fm.description) lines.push(` ${fm.description}`);
|
|
2513
|
+
if (fm.version != null) lines.push(` {gray-fg}v${fm.version}{/}`);
|
|
2514
|
+
}
|
|
2515
|
+
} else {
|
|
2516
|
+
lines.push(' {gray-fg}No formal models linked{/}');
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Check Results
|
|
2520
|
+
lines.push('');
|
|
2521
|
+
lines.push(`{bold}Check Results (${trace.checkResults.length}){/bold}`);
|
|
2522
|
+
if (trace.checkResults.length) {
|
|
2523
|
+
for (const cr of trace.checkResults) {
|
|
2524
|
+
const color = cr.result === 'pass' ? 'green' : cr.result === 'fail' ? 'red' : 'yellow';
|
|
2525
|
+
const runtime = cr.runtime_ms != null ? ` (${cr.runtime_ms}ms)` : '';
|
|
2526
|
+
lines.push(` {${color}-fg}${pad(cr.result, 14)}{/} ${cr.check_id || '—'}${runtime}`);
|
|
2527
|
+
if (cr.summary) lines.push(` {gray-fg}${cr.summary}{/}`);
|
|
2528
|
+
}
|
|
2529
|
+
} else {
|
|
2530
|
+
lines.push(' {gray-fg}No check results{/}');
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// Unmapped check_ids
|
|
2534
|
+
if (trace.unmappedCheckIds.length) {
|
|
2535
|
+
lines.push('');
|
|
2536
|
+
lines.push(`{bold}Awaiting Results (${trace.unmappedCheckIds.length}){/bold}`);
|
|
2537
|
+
for (const cid of trace.unmappedCheckIds) {
|
|
2538
|
+
lines.push(` {gray-fg}${cid}{/}`);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
setContent(`Trace: ${reqId}`, lines.join('\n'));
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// ─── Requirements: Aggregate ─────────────────────────────────────────────────
|
|
2546
|
+
async function reqAggregateFlow() {
|
|
2547
|
+
try {
|
|
2548
|
+
const { aggregateRequirements } = require('./aggregate-requirements.cjs');
|
|
2549
|
+
const result = aggregateRequirements();
|
|
2550
|
+
const count = result && result.count != null ? result.count : '?';
|
|
2551
|
+
const output = result && result.outputPath ? result.outputPath : '.planning/formal/requirements.json';
|
|
2552
|
+
toast(`Aggregated ${count} requirements → ${output}`);
|
|
2553
|
+
} catch (err) {
|
|
2554
|
+
const hint = err.message && err.message.includes('frozen')
|
|
2555
|
+
? '\n{gray-fg}Hint: the envelope may be frozen. Delete frozen_at to re-aggregate.{/}'
|
|
2556
|
+
: '';
|
|
2557
|
+
setContent('Aggregate', `{red-fg}Error: ${err.message}{/}${hint}`);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// ─── Requirements: Coverage Gaps -------------------------------------------------
|
|
2562
|
+
function reqCoverageGapsFlow() {
|
|
2563
|
+
try {
|
|
2564
|
+
const { detectCoverageGaps } = require('./detect-coverage-gaps.cjs');
|
|
2565
|
+
const lines = [];
|
|
2566
|
+
lines.push('{bold}TLC Coverage Gap Analysis{/bold}');
|
|
2567
|
+
lines.push('─'.repeat(60));
|
|
2568
|
+
lines.push('');
|
|
2569
|
+
|
|
2570
|
+
// Run for all known specs
|
|
2571
|
+
const specs = ['QGSDQuorum', 'QGSDStopHook', 'QGSDCircuitBreaker'];
|
|
2572
|
+
let totalGaps = 0;
|
|
2573
|
+
|
|
2574
|
+
for (const specName of specs) {
|
|
2575
|
+
const result = detectCoverageGaps({ specName });
|
|
2576
|
+
lines.push(`{bold}${specName}{/bold}`);
|
|
2577
|
+
|
|
2578
|
+
if (result.status === 'full-coverage') {
|
|
2579
|
+
lines.push(' {green-fg}Full coverage{/} — all TLC-reachable states observed in traces');
|
|
2580
|
+
} else if (result.status === 'gaps-found') {
|
|
2581
|
+
totalGaps += result.gaps.length;
|
|
2582
|
+
lines.push(` {yellow-fg}${result.gaps.length} gap(s){/} — states reachable by TLC but not observed:`);
|
|
2583
|
+
for (const gap of result.gaps) {
|
|
2584
|
+
lines.push(` {red-fg}${gap}{/}`);
|
|
2585
|
+
}
|
|
2586
|
+
if (result.outputPath) {
|
|
2587
|
+
lines.push(` Report: ${result.outputPath}`);
|
|
2588
|
+
}
|
|
2589
|
+
} else if (result.status === 'no-traces') {
|
|
2590
|
+
lines.push(' {gray-fg}No conformance traces found{/}');
|
|
2591
|
+
} else if (result.status === 'unknown-spec') {
|
|
2592
|
+
lines.push(` {gray-fg}${result.reason}{/}`);
|
|
2593
|
+
}
|
|
2594
|
+
lines.push('');
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
lines.push('─'.repeat(60));
|
|
2598
|
+
if (totalGaps > 0) {
|
|
2599
|
+
lines.push(`{yellow-fg}Total gaps: ${totalGaps} state(s) need test coverage{/}`);
|
|
2600
|
+
} else {
|
|
2601
|
+
lines.push('{green-fg}No coverage gaps detected across all specs{/}');
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
setContent('Coverage Gaps', lines.join('\n'));
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
setContent('Coverage Gaps', `{red-fg}Error: ${err.message}{/}`);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// ─── Key bindings ─────────────────────────────────────────────────────────────
|
|
2611
|
+
screen.key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
2612
|
+
screen.key(['C-l'], () => showEventLog());
|
|
2613
|
+
screen.key(['f1'], () => switchModule(0));
|
|
2614
|
+
screen.key(['f2'], () => switchModule(1));
|
|
2615
|
+
screen.key(['f3'], () => switchModule(2));
|
|
2616
|
+
screen.key(['f4'], () => switchModule(3));
|
|
2617
|
+
screen.key(['C-\\'], () => {
|
|
2618
|
+
if (activeModuleIdx === 3 && activeSessionIdx >= 0) {
|
|
2619
|
+
menuList.focus();
|
|
2620
|
+
}
|
|
2621
|
+
});
|
|
2622
|
+
screen.key(['tab'], () => switchModule((activeModuleIdx + 1) % MODULES.length));
|
|
2623
|
+
screen.key(['S-tab'], () => switchModule((activeModuleIdx - 1 + MODULES.length) % MODULES.length));
|
|
2624
|
+
screen.key(['r'], () => {
|
|
2625
|
+
const item = MODULES[activeModuleIdx].items[menuList.selected];
|
|
2626
|
+
if (item) dispatch(item.action);
|
|
2627
|
+
});
|
|
2628
|
+
screen.key(['u'], () => dispatch('update-agents'));
|
|
2629
|
+
screen.key(['/'], async () => {
|
|
2630
|
+
// Filter shortcut: only active in Reqs module when browsing
|
|
2631
|
+
if (activeModuleIdx !== 1) return;
|
|
2632
|
+
const { requirements } = reqCore.readRequirementsJson();
|
|
2633
|
+
if (!requirements.length) return;
|
|
2634
|
+
const categories = reqCore.getUniqueCategories(requirements);
|
|
2635
|
+
const statuses = [...new Set(requirements.map(r => r.status || 'Unknown'))].sort();
|
|
2636
|
+
try {
|
|
2637
|
+
const filterChoice = await promptList({ title: 'Filter', items: [
|
|
2638
|
+
{ label: 'All', value: 'all' },
|
|
2639
|
+
{ label: 'By Category', value: 'category' },
|
|
2640
|
+
{ label: 'By Status', value: 'status' },
|
|
2641
|
+
{ label: 'Search', value: 'search' },
|
|
2642
|
+
] });
|
|
2643
|
+
let filters = {};
|
|
2644
|
+
if (filterChoice.value === 'category') {
|
|
2645
|
+
const catChoice = await promptList({ title: 'Category', items: categories.map(c => ({ label: c, value: c })) });
|
|
2646
|
+
filters.category = catChoice.value;
|
|
2647
|
+
} else if (filterChoice.value === 'status') {
|
|
2648
|
+
const statusChoice = await promptList({ title: 'Status', items: statuses.map(s => ({ label: s, value: s })) });
|
|
2649
|
+
filters.status = statusChoice.value;
|
|
2650
|
+
} else if (filterChoice.value === 'search') {
|
|
2651
|
+
const term = await promptInput({ title: 'Search', prompt: 'Search text (id or description):' });
|
|
2652
|
+
if (term) filters.search = term;
|
|
2653
|
+
}
|
|
2654
|
+
const filtered = reqCore.filterRequirements(requirements, filters);
|
|
2655
|
+
renderReqList(filtered, filters);
|
|
2656
|
+
} catch (_) { /* cancelled */ }
|
|
2657
|
+
menuList.focus();
|
|
2658
|
+
});
|
|
2659
|
+
menuList.on('select', (_, idx) => {
|
|
2660
|
+
const item = MODULES[activeModuleIdx].items[idx];
|
|
2661
|
+
if (item) dispatch(item.action);
|
|
2662
|
+
});
|
|
2663
|
+
|
|
2664
|
+
// ─── Background update notice ─────────────────────────────────────────────────
|
|
2665
|
+
const UPDATE_AGENTS_IDX = MODULES[0].items.findIndex(m => m.action === 'update-agents');
|
|
2666
|
+
|
|
2667
|
+
function applyUpdateBadge(outdatedCount) {
|
|
2668
|
+
// Status bar: show stats + optional update notice
|
|
2669
|
+
if (outdatedCount > 0) {
|
|
2670
|
+
const n = outdatedCount;
|
|
2671
|
+
refreshStatusBar(`{#888800-fg}⚑ ${n} update${n > 1 ? 's' : ''} available — press [u]{/}`);
|
|
2672
|
+
} else {
|
|
2673
|
+
refreshStatusBar();
|
|
2674
|
+
}
|
|
2675
|
+
// Menu item badge (only when Agents module is active)
|
|
2676
|
+
if (activeModuleIdx === 0 && UPDATE_AGENTS_IDX >= 0) {
|
|
2677
|
+
const base = ' Update Agents';
|
|
2678
|
+
menuList.setItem(UPDATE_AGENTS_IDX, outdatedCount > 0
|
|
2679
|
+
? `${base} {yellow-fg}(${outdatedCount}↑){/}`
|
|
2680
|
+
: base
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
screen.render();
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// Fire on startup — non-blocking, never throws
|
|
2687
|
+
// skipGh=true: avoids spawning `gh` which reads macOS keychain for GitHub auth
|
|
2688
|
+
(async () => {
|
|
2689
|
+
try {
|
|
2690
|
+
const statuses = await getUpdateStatuses({ skipGh: true });
|
|
2691
|
+
const outdated = [...statuses.values()].filter(s => s.status === 'update-available').length;
|
|
2692
|
+
applyUpdateBadge(outdated);
|
|
2693
|
+
} catch (_) {}
|
|
2694
|
+
// PLCY-03: auto-update policy check for slots configured as 'auto'
|
|
2695
|
+
runAutoUpdateCheck().catch(() => {});
|
|
2696
|
+
})();
|
|
2697
|
+
|
|
2698
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
2699
|
+
if (require.main === module) {
|
|
2700
|
+
renderHeader();
|
|
2701
|
+
switchModule(0);
|
|
2702
|
+
const warns = _logEntries.filter(e => e.level === 'warn' || e.level === 'error');
|
|
2703
|
+
if (warns.length) {
|
|
2704
|
+
refreshStatusBar(`{yellow-fg}⚠ ${warns.length} warning${warns.length > 1 ? 's' : ''} — [C-l] event log{/}`);
|
|
2705
|
+
} else {
|
|
2706
|
+
refreshStatusBar();
|
|
2707
|
+
}
|
|
2708
|
+
renderList();
|
|
2709
|
+
screen.render();
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// ─── Exports (pure functions for testing) ────────────────────────────────────
|
|
2713
|
+
module.exports._pure = {
|
|
2714
|
+
pad,
|
|
2715
|
+
readProvidersJson,
|
|
2716
|
+
writeProvidersJson,
|
|
2717
|
+
writeUpdatePolicy,
|
|
2718
|
+
agentRows,
|
|
2719
|
+
buildScoreboardLines,
|
|
2720
|
+
PROVIDER_KEY_NAMES,
|
|
2721
|
+
PROVIDER_PRESETS,
|
|
2722
|
+
MENU_ITEMS,
|
|
2723
|
+
MODULES,
|
|
2724
|
+
logEvent,
|
|
2725
|
+
_logEntries,
|
|
2726
|
+
};
|