@nforma.ai/nforma 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
package/bin/install.js ADDED
@@ -0,0 +1,2424 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+
9
+ // Colors
10
+ const cyan = '\x1b[36m';
11
+ const salmon = '\x1b[38;5;209m';
12
+ const green = '\x1b[32m';
13
+ const yellow = '\x1b[33m';
14
+ const dim = '\x1b[2m';
15
+ const reset = '\x1b[0m';
16
+
17
+ // Get version from package.json
18
+ const pkg = require('../package.json');
19
+
20
+ // Parse args
21
+ const args = process.argv.slice(2);
22
+ const hasGlobal = args.includes('--global') || args.includes('-g');
23
+ const hasLocal = args.includes('--local') || args.includes('-l');
24
+ const hasOpencode = args.includes('--opencode');
25
+ const hasClaude = args.includes('--claude');
26
+ const hasGemini = args.includes('--gemini');
27
+ const hasBoth = args.includes('--both'); // Legacy flag, keeps working
28
+ const hasAll = args.includes('--all');
29
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
30
+ const hasRedetectMcps = args.includes('--redetect-mcps');
31
+ const hasResetBreaker = args.includes('--reset-breaker');
32
+ const hasDisableBreaker = args.includes('--disable-breaker');
33
+ const hasEnableBreaker = args.includes('--enable-breaker');
34
+ const hasMigrateSlots = args.includes('--migrate-slots');
35
+ const hasFormal = args.includes('--formal');
36
+
37
+ // Runtime selection - can be set by flags or interactive prompt
38
+ let selectedRuntimes = [];
39
+ if (hasAll) {
40
+ selectedRuntimes = ['claude', 'opencode', 'gemini'];
41
+ } else if (hasBoth) {
42
+ selectedRuntimes = ['claude', 'opencode'];
43
+ } else {
44
+ if (hasOpencode) selectedRuntimes.push('opencode');
45
+ if (hasClaude) selectedRuntimes.push('claude');
46
+ if (hasGemini) selectedRuntimes.push('gemini');
47
+ }
48
+
49
+ // Helper to get directory name for a runtime (used for local/project installs)
50
+ function getDirName(runtime) {
51
+ if (runtime === 'opencode') return '.opencode';
52
+ if (runtime === 'gemini') return '.gemini';
53
+ return '.claude';
54
+ }
55
+
56
+ /**
57
+ * Get the config directory path relative to home directory for a runtime
58
+ * Used for templating hooks that use path.join(homeDir, '<configDir>', ...)
59
+ * @param {string} runtime - 'claude', 'opencode', or 'gemini'
60
+ * @param {boolean} isGlobal - Whether this is a global install
61
+ */
62
+ function getConfigDirFromHome(runtime, isGlobal) {
63
+ if (!isGlobal) {
64
+ // Local installs use the same dir name pattern
65
+ return `'${getDirName(runtime)}'`;
66
+ }
67
+ // Global installs - OpenCode uses XDG path structure
68
+ if (runtime === 'opencode') {
69
+ // OpenCode: ~/.config/opencode -> '.config', 'opencode'
70
+ // Return as comma-separated for path.join() replacement
71
+ return "'.config', 'opencode'";
72
+ }
73
+ if (runtime === 'gemini') return "'.gemini'";
74
+ return "'.claude'";
75
+ }
76
+
77
+ /**
78
+ * Get the global config directory for OpenCode
79
+ * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
80
+ * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
81
+ */
82
+ function getOpencodeGlobalDir() {
83
+ // 1. Explicit OPENCODE_CONFIG_DIR env var
84
+ if (process.env.OPENCODE_CONFIG_DIR) {
85
+ return expandTilde(process.env.OPENCODE_CONFIG_DIR);
86
+ }
87
+
88
+ // 2. OPENCODE_CONFIG env var (use its directory)
89
+ if (process.env.OPENCODE_CONFIG) {
90
+ return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
91
+ }
92
+
93
+ // 3. XDG_CONFIG_HOME/opencode
94
+ if (process.env.XDG_CONFIG_HOME) {
95
+ return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
96
+ }
97
+
98
+ // 4. Default: ~/.config/opencode (XDG default)
99
+ return path.join(os.homedir(), '.config', 'opencode');
100
+ }
101
+
102
+ /**
103
+ * Get the global config directory for a runtime
104
+ * @param {string} runtime - 'claude', 'opencode', or 'gemini'
105
+ * @param {string|null} explicitDir - Explicit directory from --config-dir flag
106
+ */
107
+ function getGlobalDir(runtime, explicitDir = null) {
108
+ if (runtime === 'opencode') {
109
+ // For OpenCode, --config-dir overrides env vars
110
+ if (explicitDir) {
111
+ return expandTilde(explicitDir);
112
+ }
113
+ return getOpencodeGlobalDir();
114
+ }
115
+
116
+ if (runtime === 'gemini') {
117
+ // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
118
+ if (explicitDir) {
119
+ return expandTilde(explicitDir);
120
+ }
121
+ if (process.env.GEMINI_CONFIG_DIR) {
122
+ return expandTilde(process.env.GEMINI_CONFIG_DIR);
123
+ }
124
+ return path.join(os.homedir(), '.gemini');
125
+ }
126
+
127
+ // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
128
+ if (explicitDir) {
129
+ return expandTilde(explicitDir);
130
+ }
131
+ if (process.env.CLAUDE_CONFIG_DIR) {
132
+ return expandTilde(process.env.CLAUDE_CONFIG_DIR);
133
+ }
134
+ return path.join(os.homedir(), '.claude');
135
+ }
136
+
137
+ const banner = '\n' +
138
+ salmon + ' ███╗ ██╗' + cyan + '███████╗ ██████╗ ██████╗ ███╗ ███╗ █████╗\n' +
139
+ salmon + ' ████╗ ██║' + cyan + '██╔════╝██╔═══██╗██╔══██╗████╗ ████║██╔══██╗\n' +
140
+ salmon + ' ██╔██╗ ██║' + cyan + '█████╗ ██║ ██║██████╔╝██╔████╔██║███████║\n' +
141
+ salmon + ' ██║╚██╗██║' + cyan + '██╔══╝ ██║ ██║██╔══██╗██║╚██╔╝██║██╔══██║\n' +
142
+ salmon + ' ██║ ╚████║' + cyan + '██║ ╚██████╔╝██║ ██║██║ ╚═╝ ██║██║ ██║\n' +
143
+ salmon + ' ╚═╝ ╚═══╝' + cyan + '╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝' + reset + '\n' +
144
+ '\n' +
145
+ ' nForma — Quorum Gets Shit Done ' + dim + 'v' + pkg.version + reset + '\n' +
146
+ ' Built on GSD-CC by TÂCHES.\n' +
147
+ ' Full automation through quorum of coding agents. By Jonathan Borduas.\n' +
148
+ '\n' +
149
+ cyan + ' The task of leadership is to create an alignment of strengths\n' +
150
+ ' so strong that it makes the system\u2019s weaknesses irrelevant.\n' +
151
+ dim + ' \u2014 Peter Drucker' + reset + '\n';
152
+
153
+ // nForma: MCP auto-detection — keyword map for quorum model server matching
154
+ const QGSD_KEYWORD_MAP = {
155
+ codex: { keywords: ['codex'], defaultPrefix: 'mcp__codex-cli-1__' },
156
+ gemini: { keywords: ['gemini'], defaultPrefix: 'mcp__gemini-cli-1__' },
157
+ opencode: { keywords: ['opencode'], defaultPrefix: 'mcp__opencode-1__' },
158
+ copilot: { keywords: ['copilot'], defaultPrefix: 'mcp__copilot-1__' },
159
+ };
160
+
161
+ // Reads ~/.claude.json to find MCP server names, keyword-matches to identify quorum candidates,
162
+ // and returns a required_models object with derived tool prefixes.
163
+ // Falls back to hardcoded defaults if ~/.claude.json is missing, malformed, or has no matching servers.
164
+ function buildRequiredModelsFromMcp() {
165
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
166
+ let mcpServers = {};
167
+
168
+ try {
169
+ if (fs.existsSync(claudeJsonPath)) {
170
+ const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
171
+ mcpServers = d.mcpServers || {};
172
+ }
173
+ } catch (e) {
174
+ console.warn(` ${yellow}⚠${reset} Could not read ~/.claude.json: ${e.message}`);
175
+ }
176
+
177
+ const requiredModels = {};
178
+ let anyDetected = false;
179
+
180
+ for (const [modelKey, { keywords, defaultPrefix }] of Object.entries(QGSD_KEYWORD_MAP)) {
181
+ const matched = Object.keys(mcpServers).find(serverName =>
182
+ keywords.some(kw => serverName.toLowerCase().includes(kw))
183
+ );
184
+ if (matched) {
185
+ requiredModels[modelKey] = { tool_prefix: `mcp__${matched}__`, required: true };
186
+ anyDetected = true;
187
+ console.log(` ${green}✓${reset} Detected ${modelKey} MCP server: ${matched} → prefix: mcp__${matched}__`);
188
+ } else {
189
+ requiredModels[modelKey] = { tool_prefix: defaultPrefix, required: true };
190
+ console.warn(` ${yellow}⚠${reset} No ${modelKey} MCP server found in ~/.claude.json; using default prefix: ${defaultPrefix}`);
191
+ }
192
+ }
193
+
194
+ if (!anyDetected) {
195
+ console.warn(` ${yellow}⚠${reset} No quorum MCP servers detected — using hardcoded defaults. Edit ~/.claude/qgsd.json to configure.`);
196
+ }
197
+
198
+ return requiredModels;
199
+ }
200
+
201
+ // Returns all mcpServer slot names from ~/.claude.json as the default quorum composition.
202
+ // Used for COMP-04: fresh install writes quorum_active; reinstall backfills if absent/empty.
203
+ function buildActiveSlots() {
204
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
205
+ try {
206
+ if (fs.existsSync(claudeJsonPath)) {
207
+ const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
208
+ return Object.keys(d.mcpServers || {});
209
+ }
210
+ } catch (e) {
211
+ console.warn(` ${yellow}⚠${reset} Could not read ~/.claude.json for quorum_active: ${e.message}`);
212
+ }
213
+ return [];
214
+ }
215
+
216
+ // Generates quorum_instructions text from detected required_models.
217
+ // Uses detected tool_prefix values so behavioral instructions (UserPromptSubmit injection)
218
+ // name the same tools as the structural enforcement (Stop hook), preventing mismatch
219
+ // when server names differ from defaults (e.g. renamed MCP servers).
220
+ function buildQuorumInstructions(requiredModels) {
221
+ function toolName(key, prefix) {
222
+ if (key === 'codex') return prefix + 'review';
223
+ if (key === 'gemini') return prefix + 'gemini';
224
+ if (key === 'opencode') return prefix + 'opencode';
225
+ return prefix + key;
226
+ }
227
+ const required = Object.entries(requiredModels).filter(([, def]) => def.required);
228
+ const steps = required.map(([key, def], i) =>
229
+ ` ${i + 1}. Call ${toolName(key, def.tool_prefix)} with the full plan content`
230
+ ).join('\n');
231
+ return (
232
+ 'QUORUM REQUIRED (structural enforcement — Stop hook will verify)\n\n' +
233
+ 'Before presenting any planning output to the user, you MUST:\n' +
234
+ steps + '\n' +
235
+ ` ${required.length + 1}. Present all model responses, resolve any concerns, then deliver your final output\n` +
236
+ ` ${required.length + 2}. Include the token <!-- GSD_DECISION --> somewhere in your FINAL output (not in intermediate messages or status updates — only when you are delivering the completed plan, research, verification report, or filtered question list to the user)\n\n` +
237
+ 'Fail-open: if a model is UNAVAILABLE (quota/error), note it and proceed with available models.\n' +
238
+ 'The Stop hook reads the transcript — skipping quorum will block your response.'
239
+ );
240
+ }
241
+
242
+ // INST-05: Validate MCP availability and warn (yellow) per missing model.
243
+ // Runs on every install/reinstall — not just first-time.
244
+ // Does NOT abort installation (fail-open philosophy).
245
+ function warnMissingMcpServers() {
246
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
247
+ let mcpServers = {};
248
+ try {
249
+ if (fs.existsSync(claudeJsonPath)) {
250
+ const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
251
+ mcpServers = d.mcpServers || {};
252
+ }
253
+ } catch (e) {
254
+ // If we can't read, skip silently — detection already warned in buildRequiredModelsFromMcp
255
+ return;
256
+ }
257
+
258
+ for (const [modelKey, { keywords }] of Object.entries(QGSD_KEYWORD_MAP)) {
259
+ const found = Object.keys(mcpServers).some(serverName =>
260
+ keywords.some(kw => serverName.toLowerCase().includes(kw))
261
+ );
262
+ if (!found) {
263
+ console.warn(
264
+ ` ${yellow}⚠${reset} No ${modelKey} MCP server found in ~/.claude.json — ` +
265
+ `quorum enforcement for ${modelKey} will be inactive until configured`
266
+ );
267
+ }
268
+ }
269
+ }
270
+
271
+ // INST-01: Detects whether any claude-mcp-server quorum agents are configured.
272
+ // Used in finishInstall() to nudge new users to run /qgsd:mcp-setup.
273
+ // Fail-open: returns false on read errors (never blocks install).
274
+ function hasClaudeMcpAgents() {
275
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
276
+ try {
277
+ if (!fs.existsSync(claudeJsonPath)) return false;
278
+ const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
279
+ const mcpServers = d.mcpServers || {};
280
+ return Object.entries(mcpServers).some(([name, cfg]) => {
281
+ // Match slot pattern: claude-1 through claude-N
282
+ if (/^claude-\d+$/.test(name)) return true;
283
+ // Fallback: detect claude-mcp-server in args path (handles new installs before migration)
284
+ if ((cfg.args || []).some(a => String(a).includes('claude-mcp-server'))) return true;
285
+ return false;
286
+ });
287
+ } catch (e) {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ // Parse --config-dir argument
293
+ function parseConfigDirArg() {
294
+ const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
295
+ if (configDirIndex !== -1) {
296
+ const nextArg = args[configDirIndex + 1];
297
+ // Error if --config-dir is provided without a value or next arg is another flag
298
+ if (!nextArg || nextArg.startsWith('-')) {
299
+ console.error(` ${yellow}--config-dir requires a path argument${reset}`);
300
+ process.exit(1);
301
+ }
302
+ return nextArg;
303
+ }
304
+ // Also handle --config-dir=value format
305
+ const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
306
+ if (configDirArg) {
307
+ const value = configDirArg.split('=')[1];
308
+ if (!value) {
309
+ console.error(` ${yellow}--config-dir requires a non-empty path${reset}`);
310
+ process.exit(1);
311
+ }
312
+ return value;
313
+ }
314
+ return null;
315
+ }
316
+ const explicitConfigDir = parseConfigDirArg();
317
+ const hasHelp = args.includes('--help') || args.includes('-h');
318
+ const forceStatusline = args.includes('--force-statusline');
319
+
320
+ console.log(banner);
321
+
322
+ // Show help if requested
323
+ if (hasHelp) {
324
+ console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--formal${reset} Install formal verification tools (TLA+, Alloy, PRISM)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --claude --global --config-dir ~/.claude-bc\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Claude Code globally${reset}\n npx get-shit-done-cc --claude --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR environment variables.\n`);
325
+ process.exit(0);
326
+ }
327
+
328
+ /**
329
+ * Expand ~ to home directory (shell doesn't expand in env vars passed to node)
330
+ */
331
+ function expandTilde(filePath) {
332
+ if (filePath && filePath.startsWith('~/')) {
333
+ return path.join(os.homedir(), filePath.slice(2));
334
+ }
335
+ return filePath;
336
+ }
337
+
338
+ /**
339
+ * Build a hook command path using forward slashes for cross-platform compatibility.
340
+ * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
341
+ */
342
+ function buildHookCommand(configDir, hookName) {
343
+ // Use forward slashes for Node.js compatibility on all platforms
344
+ const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
345
+ return `node "${hooksPath}"`;
346
+ }
347
+
348
+ /**
349
+ * Read and parse settings.json, returning empty object if it doesn't exist
350
+ */
351
+ function readSettings(settingsPath) {
352
+ if (fs.existsSync(settingsPath)) {
353
+ try {
354
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
355
+ } catch (e) {
356
+ return {};
357
+ }
358
+ }
359
+ return {};
360
+ }
361
+
362
+ /**
363
+ * Write settings.json with proper formatting
364
+ */
365
+ function writeSettings(settingsPath, settings) {
366
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
367
+ }
368
+
369
+ // Cache for attribution settings (populated once per runtime during install)
370
+ const attributionCache = new Map();
371
+
372
+ /**
373
+ * Get commit attribution setting for a runtime
374
+ * @param {string} runtime - 'claude', 'opencode', or 'gemini'
375
+ * @returns {null|undefined|string} null = remove, undefined = keep default, string = custom
376
+ */
377
+ function getCommitAttribution(runtime) {
378
+ // Return cached value if available
379
+ if (attributionCache.has(runtime)) {
380
+ return attributionCache.get(runtime);
381
+ }
382
+
383
+ let result;
384
+
385
+ if (runtime === 'opencode') {
386
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
387
+ result = config.disable_ai_attribution === true ? null : undefined;
388
+ } else if (runtime === 'gemini') {
389
+ // Gemini: check gemini settings.json for attribution config
390
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
391
+ if (!settings.attribution || settings.attribution.commit === undefined) {
392
+ result = undefined;
393
+ } else if (settings.attribution.commit === '') {
394
+ result = null;
395
+ } else {
396
+ result = settings.attribution.commit;
397
+ }
398
+ } else {
399
+ // Claude Code
400
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
401
+ if (!settings.attribution || settings.attribution.commit === undefined) {
402
+ result = undefined;
403
+ } else if (settings.attribution.commit === '') {
404
+ result = null;
405
+ } else {
406
+ result = settings.attribution.commit;
407
+ }
408
+ }
409
+
410
+ // Cache and return
411
+ attributionCache.set(runtime, result);
412
+ return result;
413
+ }
414
+
415
+ /**
416
+ * Process Co-Authored-By lines based on attribution setting
417
+ * @param {string} content - File content to process
418
+ * @param {null|undefined|string} attribution - null=remove, undefined=keep, string=replace
419
+ * @returns {string} Processed content
420
+ */
421
+ function processAttribution(content, attribution) {
422
+ if (attribution === null) {
423
+ // Remove Co-Authored-By lines and the preceding blank line
424
+ return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, '');
425
+ }
426
+ if (attribution === undefined) {
427
+ return content;
428
+ }
429
+ // Replace with custom attribution (escape $ to prevent backreference injection)
430
+ const safeAttribution = attribution.replace(/\$/g, '$$$$');
431
+ return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`);
432
+ }
433
+
434
+ /**
435
+ * Convert Claude Code frontmatter to opencode format
436
+ * - Converts 'allowed-tools:' array to 'permission:' object
437
+ * @param {string} content - Markdown file content with YAML frontmatter
438
+ * @returns {string} - Content with converted frontmatter
439
+ */
440
+ // Color name to hex mapping for opencode compatibility
441
+ const colorNameToHex = {
442
+ cyan: '#00FFFF',
443
+ red: '#FF0000',
444
+ green: '#00FF00',
445
+ blue: '#0000FF',
446
+ yellow: '#FFFF00',
447
+ magenta: '#FF00FF',
448
+ orange: '#FFA500',
449
+ purple: '#800080',
450
+ pink: '#FFC0CB',
451
+ white: '#FFFFFF',
452
+ black: '#000000',
453
+ gray: '#808080',
454
+ grey: '#808080',
455
+ };
456
+
457
+ // Tool name mapping from Claude Code to OpenCode
458
+ // OpenCode uses lowercase tool names; special mappings for renamed tools
459
+ const claudeToOpencodeTools = {
460
+ AskUserQuestion: 'question',
461
+ SlashCommand: 'skill',
462
+ TodoWrite: 'todowrite',
463
+ WebFetch: 'webfetch',
464
+ WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
465
+ };
466
+
467
+ // Tool name mapping from Claude Code to Gemini CLI
468
+ // Gemini CLI uses snake_case built-in tool names
469
+ const claudeToGeminiTools = {
470
+ Read: 'read_file',
471
+ Write: 'write_file',
472
+ Edit: 'replace',
473
+ Bash: 'run_shell_command',
474
+ Glob: 'glob',
475
+ Grep: 'search_file_content',
476
+ WebSearch: 'google_web_search',
477
+ WebFetch: 'web_fetch',
478
+ TodoWrite: 'write_todos',
479
+ AskUserQuestion: 'ask_user',
480
+ };
481
+
482
+ /**
483
+ * Convert a Claude Code tool name to OpenCode format
484
+ * - Applies special mappings (AskUserQuestion -> question, etc.)
485
+ * - Converts to lowercase (except MCP tools which keep their format)
486
+ */
487
+ function convertToolName(claudeTool) {
488
+ // Check for special mapping first
489
+ if (claudeToOpencodeTools[claudeTool]) {
490
+ return claudeToOpencodeTools[claudeTool];
491
+ }
492
+ // MCP tools (mcp__*) keep their format
493
+ if (claudeTool.startsWith('mcp__')) {
494
+ return claudeTool;
495
+ }
496
+ // Default: convert to lowercase
497
+ return claudeTool.toLowerCase();
498
+ }
499
+
500
+ /**
501
+ * Convert a Claude Code tool name to Gemini CLI format
502
+ * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
503
+ * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
504
+ * - Filters out Task — agents are auto-registered as tools in Gemini
505
+ * @returns {string|null} Gemini tool name, or null if tool should be excluded
506
+ */
507
+ function convertGeminiToolName(claudeTool) {
508
+ // MCP tools: exclude — auto-discovered from mcpServers config at runtime
509
+ if (claudeTool.startsWith('mcp__')) {
510
+ return null;
511
+ }
512
+ // Task: exclude — agents are auto-registered as callable tools
513
+ if (claudeTool === 'Task') {
514
+ return null;
515
+ }
516
+ // Check for explicit mapping
517
+ if (claudeToGeminiTools[claudeTool]) {
518
+ return claudeToGeminiTools[claudeTool];
519
+ }
520
+ // Default: lowercase
521
+ return claudeTool.toLowerCase();
522
+ }
523
+
524
+ /**
525
+ * Strip HTML <sub> tags for Gemini CLI output
526
+ * Terminals don't support subscript — Gemini renders these as raw HTML.
527
+ * Converts <sub>text</sub> to italic *(text)* for readable terminal output.
528
+ */
529
+ function stripSubTags(content) {
530
+ return content.replace(/<sub>(.*?)<\/sub>/g, '*($1)*');
531
+ }
532
+
533
+ /**
534
+ * Convert Claude Code agent frontmatter to Gemini CLI format
535
+ * Gemini agents use .md files with YAML frontmatter, same as Claude,
536
+ * but with different field names and formats:
537
+ * - tools: must be a YAML array (not comma-separated string)
538
+ * - tool names: must use Gemini built-in names (read_file, not Read)
539
+ * - color: must be removed (causes validation error)
540
+ * - mcp__* tools: must be excluded (auto-discovered at runtime)
541
+ */
542
+ function convertClaudeToGeminiAgent(content) {
543
+ if (!content.startsWith('---')) return content;
544
+
545
+ const endIndex = content.indexOf('---', 3);
546
+ if (endIndex === -1) return content;
547
+
548
+ const frontmatter = content.substring(3, endIndex).trim();
549
+ const body = content.substring(endIndex + 3);
550
+
551
+ const lines = frontmatter.split('\n');
552
+ const newLines = [];
553
+ let inAllowedTools = false;
554
+ const tools = [];
555
+
556
+ for (const line of lines) {
557
+ const trimmed = line.trim();
558
+
559
+ // Convert allowed-tools YAML array to tools list
560
+ if (trimmed.startsWith('allowed-tools:')) {
561
+ inAllowedTools = true;
562
+ continue;
563
+ }
564
+
565
+ // Handle inline tools: field (comma-separated string)
566
+ if (trimmed.startsWith('tools:')) {
567
+ const toolsValue = trimmed.substring(6).trim();
568
+ if (toolsValue) {
569
+ const parsed = toolsValue.split(',').map(t => t.trim()).filter(t => t);
570
+ for (const t of parsed) {
571
+ const mapped = convertGeminiToolName(t);
572
+ if (mapped) tools.push(mapped);
573
+ }
574
+ } else {
575
+ // tools: with no value means YAML array follows
576
+ inAllowedTools = true;
577
+ }
578
+ continue;
579
+ }
580
+
581
+ // Strip color field (not supported by Gemini CLI, causes validation error)
582
+ if (trimmed.startsWith('color:')) continue;
583
+
584
+ // Collect allowed-tools/tools array items
585
+ if (inAllowedTools) {
586
+ if (trimmed.startsWith('- ')) {
587
+ const mapped = convertGeminiToolName(trimmed.substring(2).trim());
588
+ if (mapped) tools.push(mapped);
589
+ continue;
590
+ } else if (trimmed && !trimmed.startsWith('-')) {
591
+ inAllowedTools = false;
592
+ }
593
+ }
594
+
595
+ if (!inAllowedTools) {
596
+ newLines.push(line);
597
+ }
598
+ }
599
+
600
+ // Add tools as YAML array (Gemini requires array format)
601
+ if (tools.length > 0) {
602
+ newLines.push('tools:');
603
+ for (const tool of tools) {
604
+ newLines.push(` - ${tool}`);
605
+ }
606
+ }
607
+
608
+ const newFrontmatter = newLines.join('\n').trim();
609
+
610
+ // Escape ${VAR} patterns in agent body for Gemini CLI compatibility.
611
+ // Gemini's templateString() treats all ${word} patterns as template variables
612
+ // and throws "Template validation failed: Missing required input parameters"
613
+ // when they can't be resolved. GSD agents use ${PHASE}, ${PLAN}, etc. as
614
+ // shell variables in bash code blocks — convert to $VAR (no braces) which
615
+ // is equivalent bash and invisible to Gemini's /\$\{(\w+)\}/g regex.
616
+ const escapedBody = body.replace(/\$\{(\w+)\}/g, '$$$1');
617
+
618
+ return `---\n${newFrontmatter}\n---${stripSubTags(escapedBody)}`;
619
+ }
620
+
621
+ function convertClaudeToOpencodeFrontmatter(content) {
622
+ // Replace tool name references in content (applies to all files)
623
+ let convertedContent = content;
624
+ convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
625
+ convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
626
+ convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
627
+ // Replace /qgsd:command with /qgsd-command for opencode (flat command structure)
628
+ convertedContent = convertedContent.replace(/\/qgsd:/g, '/qgsd-');
629
+ // Replace ~/.claude with ~/.config/opencode (OpenCode's correct config location)
630
+ convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
631
+ // Replace general-purpose subagent type with OpenCode's equivalent "general"
632
+ convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
633
+
634
+ // Check if content has frontmatter
635
+ if (!convertedContent.startsWith('---')) {
636
+ return convertedContent;
637
+ }
638
+
639
+ // Find the end of frontmatter
640
+ const endIndex = convertedContent.indexOf('---', 3);
641
+ if (endIndex === -1) {
642
+ return convertedContent;
643
+ }
644
+
645
+ const frontmatter = convertedContent.substring(3, endIndex).trim();
646
+ const body = convertedContent.substring(endIndex + 3);
647
+
648
+ // Parse frontmatter line by line (simple YAML parsing)
649
+ const lines = frontmatter.split('\n');
650
+ const newLines = [];
651
+ let inAllowedTools = false;
652
+ const allowedTools = [];
653
+
654
+ for (const line of lines) {
655
+ const trimmed = line.trim();
656
+
657
+ // Detect start of allowed-tools array
658
+ if (trimmed.startsWith('allowed-tools:')) {
659
+ inAllowedTools = true;
660
+ continue;
661
+ }
662
+
663
+ // Detect inline tools: field (comma-separated string)
664
+ if (trimmed.startsWith('tools:')) {
665
+ const toolsValue = trimmed.substring(6).trim();
666
+ if (toolsValue) {
667
+ // Parse comma-separated tools
668
+ const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
669
+ allowedTools.push(...tools);
670
+ }
671
+ continue;
672
+ }
673
+
674
+ // Remove name: field - opencode uses filename for command name
675
+ if (trimmed.startsWith('name:')) {
676
+ continue;
677
+ }
678
+
679
+ // Convert color names to hex for opencode
680
+ if (trimmed.startsWith('color:')) {
681
+ const colorValue = trimmed.substring(6).trim().toLowerCase();
682
+ const hexColor = colorNameToHex[colorValue];
683
+ if (hexColor) {
684
+ newLines.push(`color: "${hexColor}"`);
685
+ } else if (colorValue.startsWith('#')) {
686
+ // Validate hex color format (#RGB or #RRGGBB)
687
+ if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
688
+ // Already hex and valid, keep as is
689
+ newLines.push(line);
690
+ }
691
+ // Skip invalid hex colors
692
+ }
693
+ // Skip unknown color names
694
+ continue;
695
+ }
696
+
697
+ // Collect allowed-tools items
698
+ if (inAllowedTools) {
699
+ if (trimmed.startsWith('- ')) {
700
+ allowedTools.push(trimmed.substring(2).trim());
701
+ continue;
702
+ } else if (trimmed && !trimmed.startsWith('-')) {
703
+ // End of array, new field started
704
+ inAllowedTools = false;
705
+ }
706
+ }
707
+
708
+ // Keep other fields (including name: which opencode ignores)
709
+ if (!inAllowedTools) {
710
+ newLines.push(line);
711
+ }
712
+ }
713
+
714
+ // Add tools object if we had allowed-tools or tools
715
+ if (allowedTools.length > 0) {
716
+ newLines.push('tools:');
717
+ for (const tool of allowedTools) {
718
+ newLines.push(` ${convertToolName(tool)}: true`);
719
+ }
720
+ }
721
+
722
+ // Rebuild frontmatter (body already has tool names converted)
723
+ const newFrontmatter = newLines.join('\n').trim();
724
+ return `---\n${newFrontmatter}\n---${body}`;
725
+ }
726
+
727
+ /**
728
+ * Convert Claude Code markdown command to Gemini TOML format
729
+ * @param {string} content - Markdown file content with YAML frontmatter
730
+ * @returns {string} - TOML content
731
+ */
732
+ function convertClaudeToGeminiToml(content) {
733
+ // Check if content has frontmatter
734
+ if (!content.startsWith('---')) {
735
+ return `prompt = ${JSON.stringify(content)}\n`;
736
+ }
737
+
738
+ const endIndex = content.indexOf('---', 3);
739
+ if (endIndex === -1) {
740
+ return `prompt = ${JSON.stringify(content)}\n`;
741
+ }
742
+
743
+ const frontmatter = content.substring(3, endIndex).trim();
744
+ const body = content.substring(endIndex + 3).trim();
745
+
746
+ // Extract description from frontmatter
747
+ let description = '';
748
+ const lines = frontmatter.split('\n');
749
+ for (const line of lines) {
750
+ const trimmed = line.trim();
751
+ if (trimmed.startsWith('description:')) {
752
+ description = trimmed.substring(12).trim();
753
+ break;
754
+ }
755
+ }
756
+
757
+ // Construct TOML
758
+ let toml = '';
759
+ if (description) {
760
+ toml += `description = ${JSON.stringify(description)}\n`;
761
+ }
762
+
763
+ toml += `prompt = ${JSON.stringify(body)}\n`;
764
+
765
+ return toml;
766
+ }
767
+
768
+ /**
769
+ * Copy commands to a flat structure for OpenCode
770
+ * OpenCode expects: command/qgsd-help.md (invoked as /qgsd-help)
771
+ * Source structure: commands/qgsd/help.md
772
+ *
773
+ * @param {string} srcDir - Source directory (e.g., commands/qgsd/)
774
+ * @param {string} destDir - Destination directory (e.g., command/)
775
+ * @param {string} prefix - Prefix for filenames (e.g., 'qgsd')
776
+ * @param {string} pathPrefix - Path prefix for file references
777
+ * @param {string} runtime - Target runtime ('claude' or 'opencode')
778
+ */
779
+ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
780
+ if (!fs.existsSync(srcDir)) {
781
+ return;
782
+ }
783
+
784
+ // Remove old qgsd-*.md files before copying new ones
785
+ if (fs.existsSync(destDir)) {
786
+ for (const file of fs.readdirSync(destDir)) {
787
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
788
+ fs.unlinkSync(path.join(destDir, file));
789
+ }
790
+ }
791
+ } else {
792
+ fs.mkdirSync(destDir, { recursive: true });
793
+ }
794
+
795
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
796
+
797
+ for (const entry of entries) {
798
+ const srcPath = path.join(srcDir, entry.name);
799
+
800
+ if (entry.isDirectory()) {
801
+ // Recurse into subdirectories, adding to prefix
802
+ // e.g., commands/qgsd/debug/start.md -> command/qgsd-debug-start.md
803
+ copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
804
+ } else if (entry.name.endsWith('.md')) {
805
+ // Flatten: help.md -> qgsd-help.md
806
+ const baseName = entry.name.replace('.md', '');
807
+ const destName = `${prefix}-${baseName}.md`;
808
+ const destPath = path.join(destDir, destName);
809
+
810
+ let content = fs.readFileSync(srcPath, 'utf8');
811
+ const globalClaudeRegex = /~\/\.claude\//g;
812
+ const localClaudeRegex = /\.\/\.claude\//g;
813
+ const opencodeDirRegex = /~\/\.opencode\//g;
814
+ content = content.replace(globalClaudeRegex, pathPrefix);
815
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
816
+ content = content.replace(opencodeDirRegex, pathPrefix);
817
+ content = processAttribution(content, getCommitAttribution(runtime));
818
+ content = convertClaudeToOpencodeFrontmatter(content);
819
+
820
+ fs.writeFileSync(destPath, content);
821
+ }
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Recursively copy directory, replacing paths in .md files
827
+ * Deletes existing destDir first to remove orphaned files from previous versions
828
+ * @param {string} srcDir - Source directory
829
+ * @param {string} destDir - Destination directory
830
+ * @param {string} pathPrefix - Path prefix for file references
831
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
832
+ */
833
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) {
834
+ const isOpencode = runtime === 'opencode';
835
+ const dirName = getDirName(runtime);
836
+
837
+ // Clean install: remove existing destination to prevent orphaned files
838
+ if (fs.existsSync(destDir)) {
839
+ fs.rmSync(destDir, { recursive: true });
840
+ }
841
+ fs.mkdirSync(destDir, { recursive: true });
842
+
843
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
844
+
845
+ for (const entry of entries) {
846
+ const srcPath = path.join(srcDir, entry.name);
847
+ const destPath = path.join(destDir, entry.name);
848
+
849
+ if (entry.isDirectory()) {
850
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime);
851
+ } else if (entry.name.endsWith('.md')) {
852
+ // Replace ~/.claude/ and ./.claude/ with runtime-appropriate paths
853
+ let content = fs.readFileSync(srcPath, 'utf8');
854
+ const globalClaudeRegex = /~\/\.claude\//g;
855
+ const localClaudeRegex = /\.\/\.claude\//g;
856
+ content = content.replace(globalClaudeRegex, pathPrefix);
857
+ content = content.replace(localClaudeRegex, `./${dirName}/`);
858
+ content = processAttribution(content, getCommitAttribution(runtime));
859
+
860
+ // Convert frontmatter for opencode compatibility
861
+ if (isOpencode) {
862
+ content = convertClaudeToOpencodeFrontmatter(content);
863
+ fs.writeFileSync(destPath, content);
864
+ } else if (runtime === 'gemini') {
865
+ // Convert to TOML for Gemini (strip <sub> tags — terminals can't render subscript)
866
+ content = stripSubTags(content);
867
+ const tomlContent = convertClaudeToGeminiToml(content);
868
+ // Replace extension with .toml
869
+ const tomlPath = destPath.replace(/\.md$/, '.toml');
870
+ fs.writeFileSync(tomlPath, tomlContent);
871
+ } else {
872
+ fs.writeFileSync(destPath, content);
873
+ }
874
+ } else {
875
+ fs.copyFileSync(srcPath, destPath);
876
+ }
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Clean up orphaned files from previous GSD versions
882
+ */
883
+ function cleanupOrphanedFiles(configDir) {
884
+ const orphanedFiles = [
885
+ 'hooks/gsd-notify.sh', // Removed in v1.6.x
886
+ 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
887
+ 'hooks/gsd-statusline.js', // Renamed to qgsd-statusline.js in v0.2
888
+ 'hooks/gsd-check-update.js', // Renamed to qgsd-check-update.js in v0.2
889
+ ];
890
+
891
+ for (const relPath of orphanedFiles) {
892
+ const fullPath = path.join(configDir, relPath);
893
+ if (fs.existsSync(fullPath)) {
894
+ fs.unlinkSync(fullPath);
895
+ console.log(` ${green}✓${reset} Removed orphaned ${relPath}`);
896
+ }
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Clean up orphaned hook registrations from settings.json
902
+ */
903
+ function cleanupOrphanedHooks(settings) {
904
+ const orphanedHookPatterns = [
905
+ 'gsd-notify.sh', // Removed in v1.6.x
906
+ 'hooks/statusline.js', // Renamed to gsd-statusline.js in v1.9.0
907
+ 'gsd-intel-index.js', // Removed in v1.9.2
908
+ 'gsd-intel-session.js', // Removed in v1.9.2
909
+ 'gsd-intel-prune.js', // Removed in v1.9.2
910
+ 'hooks/gsd-check-update.js', // Renamed to qgsd-check-update.js in v0.2
911
+ 'hooks/gsd-statusline.js', // Renamed to qgsd-statusline.js in v0.2
912
+ ];
913
+
914
+ let cleanedHooks = false;
915
+
916
+ // Check all hook event types (Stop, SessionStart, etc.)
917
+ if (settings.hooks) {
918
+ for (const eventType of Object.keys(settings.hooks)) {
919
+ const hookEntries = settings.hooks[eventType];
920
+ if (Array.isArray(hookEntries)) {
921
+ // Filter out entries that contain orphaned hooks
922
+ const filtered = hookEntries.filter(entry => {
923
+ if (entry.hooks && Array.isArray(entry.hooks)) {
924
+ // Check if any hook in this entry matches orphaned patterns
925
+ const hasOrphaned = entry.hooks.some(h =>
926
+ h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
927
+ );
928
+ if (hasOrphaned) {
929
+ cleanedHooks = true;
930
+ return false; // Remove this entry
931
+ }
932
+ }
933
+ return true; // Keep this entry
934
+ });
935
+ settings.hooks[eventType] = filtered;
936
+ }
937
+ }
938
+ }
939
+
940
+ if (cleanedHooks) {
941
+ console.log(` ${green}✓${reset} Removed orphaned hook registrations`);
942
+ }
943
+
944
+ // Fix #330 + qgsd migration: update statusLine if it points to old statusline path
945
+ if (settings.statusLine && settings.statusLine.command) {
946
+ const cmd = settings.statusLine.command;
947
+ if ((cmd.includes('statusline.js') || cmd.includes('gsd-statusline.js')) &&
948
+ !cmd.includes('qgsd-statusline.js')) {
949
+ settings.statusLine.command = cmd
950
+ .replace(/\bgsd-statusline\.js\b/, 'qgsd-statusline.js')
951
+ .replace(/\bstatusline\.js\b/, 'qgsd-statusline.js');
952
+ console.log(` ${green}✓${reset} Updated statusline path → qgsd-statusline.js`);
953
+ }
954
+ }
955
+
956
+ return settings;
957
+ }
958
+
959
+ /**
960
+ * Uninstall GSD from the specified directory for a specific runtime
961
+ * Removes only GSD-specific files/directories, preserves user content
962
+ * @param {boolean} isGlobal - Whether to uninstall from global or local
963
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
964
+ */
965
+ function uninstall(isGlobal, runtime = 'claude') {
966
+ const isOpencode = runtime === 'opencode';
967
+ const dirName = getDirName(runtime);
968
+
969
+ // Get the target directory based on runtime and install type
970
+ const targetDir = isGlobal
971
+ ? getGlobalDir(runtime, explicitConfigDir)
972
+ : path.join(process.cwd(), dirName);
973
+
974
+ const locationLabel = isGlobal
975
+ ? targetDir.replace(os.homedir(), '~')
976
+ : targetDir.replace(process.cwd(), '.');
977
+
978
+ let runtimeLabel = 'Claude Code';
979
+ if (runtime === 'opencode') runtimeLabel = 'OpenCode';
980
+ if (runtime === 'gemini') runtimeLabel = 'Gemini';
981
+
982
+ console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
983
+
984
+ // Check if target directory exists
985
+ if (!fs.existsSync(targetDir)) {
986
+ console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
987
+ console.log(` Nothing to uninstall.\n`);
988
+ return;
989
+ }
990
+
991
+ let removedCount = 0;
992
+
993
+ // 1. Remove GSD commands directory
994
+ if (isOpencode) {
995
+ // OpenCode: remove command/qgsd-*.md files
996
+ const commandDir = path.join(targetDir, 'command');
997
+ if (fs.existsSync(commandDir)) {
998
+ const files = fs.readdirSync(commandDir);
999
+ for (const file of files) {
1000
+ if (file.startsWith('qgsd-') && file.endsWith('.md')) {
1001
+ fs.unlinkSync(path.join(commandDir, file));
1002
+ removedCount++;
1003
+ }
1004
+ }
1005
+ console.log(` ${green}✓${reset} Removed GSD commands from command/`);
1006
+ }
1007
+ } else {
1008
+ // Claude Code & Gemini: remove commands/qgsd/ directory
1009
+ const gsdCommandsDir = path.join(targetDir, 'commands', 'qgsd');
1010
+ if (fs.existsSync(gsdCommandsDir)) {
1011
+ fs.rmSync(gsdCommandsDir, { recursive: true });
1012
+ removedCount++;
1013
+ console.log(` ${green}✓${reset} Removed commands/qgsd/`);
1014
+ }
1015
+ }
1016
+
1017
+ // 2. Remove qgsd directory
1018
+ const gsdDir = path.join(targetDir, 'qgsd');
1019
+ if (fs.existsSync(gsdDir)) {
1020
+ fs.rmSync(gsdDir, { recursive: true });
1021
+ removedCount++;
1022
+ console.log(` ${green}✓${reset} Removed qgsd/`);
1023
+ }
1024
+
1025
+ // 2b. Migration: warn about old get-shit-done/ and commands/gsd/ paths from pre-v0.2 nForma installs
1026
+ // REN-03: intentional legacy path reference for migration detection
1027
+ const oldGsdDir = path.join(targetDir, 'get-shit-done');
1028
+ const oldCommandsGsdDir = path.join(targetDir, 'commands', 'gsd');
1029
+ if (fs.existsSync(oldGsdDir) || fs.existsSync(oldCommandsGsdDir)) {
1030
+ console.log(`\n ${yellow}⚠ Migration notice:${reset} Old nForma paths detected from a pre-v0.2 install:`);
1031
+ if (fs.existsSync(oldGsdDir)) console.log(` ${yellow}•${reset} ${oldGsdDir}`);
1032
+ if (fs.existsSync(oldCommandsGsdDir)) console.log(` ${yellow}•${reset} ${oldCommandsGsdDir}`);
1033
+ console.log(` ${yellow} If you don't have upstream GSD installed, these can be safely removed:${reset}`);
1034
+ if (fs.existsSync(oldGsdDir)) console.log(` rm -rf ${oldGsdDir}`);
1035
+ if (fs.existsSync(oldCommandsGsdDir)) console.log(` rm -rf ${oldCommandsGsdDir}`);
1036
+ console.log();
1037
+ }
1038
+
1039
+ // 3. Remove GSD agents (qgsd-*.md files only)
1040
+ const agentsDir = path.join(targetDir, 'agents');
1041
+ if (fs.existsSync(agentsDir)) {
1042
+ const files = fs.readdirSync(agentsDir);
1043
+ let agentCount = 0;
1044
+ for (const file of files) {
1045
+ if (file.startsWith('qgsd-') && file.endsWith('.md')) {
1046
+ fs.unlinkSync(path.join(agentsDir, file));
1047
+ agentCount++;
1048
+ }
1049
+ }
1050
+ if (agentCount > 0) {
1051
+ removedCount++;
1052
+ console.log(` ${green}✓${reset} Removed ${agentCount} nForma agents`);
1053
+ }
1054
+
1055
+ // Migration: warn about old gsd-*.md agents from pre-v0.2 nForma installs
1056
+ const oldAgents = fs.readdirSync(agentsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
1057
+ if (oldAgents.length > 0) {
1058
+ console.log(`\n ${yellow}⚠ Migration notice:${reset} Old nForma agents (gsd-*.md) detected from a pre-v0.2 install:`);
1059
+ oldAgents.forEach(f => console.log(` ${yellow}•${reset} ${path.join(agentsDir, f)}`));
1060
+ console.log(` ${yellow} If you don't use upstream GSD, these can be safely removed:${reset}`);
1061
+ console.log(` for f in ~/.claude/agents/gsd-*.md; do rm "$f"; done\n`);
1062
+ }
1063
+ }
1064
+
1065
+ // 4. Remove GSD hooks
1066
+ const hooksDir = path.join(targetDir, 'hooks');
1067
+ if (fs.existsSync(hooksDir)) {
1068
+ const gsdHooks = ['qgsd-statusline.js', 'qgsd-check-update.js', 'gsd-check-update.sh'];
1069
+ let hookCount = 0;
1070
+ for (const hook of gsdHooks) {
1071
+ const hookPath = path.join(hooksDir, hook);
1072
+ if (fs.existsSync(hookPath)) {
1073
+ fs.unlinkSync(hookPath);
1074
+ hookCount++;
1075
+ }
1076
+ }
1077
+ if (hookCount > 0) {
1078
+ removedCount++;
1079
+ console.log(` ${green}✓${reset} Removed ${hookCount} GSD hooks`);
1080
+ }
1081
+ }
1082
+
1083
+ // 5. Remove GSD package.json (CommonJS mode marker)
1084
+ const pkgJsonPath = path.join(targetDir, 'package.json');
1085
+ if (fs.existsSync(pkgJsonPath)) {
1086
+ try {
1087
+ const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
1088
+ // Only remove if it's our minimal CommonJS marker
1089
+ if (content === '{"type":"commonjs"}') {
1090
+ fs.unlinkSync(pkgJsonPath);
1091
+ removedCount++;
1092
+ console.log(` ${green}✓${reset} Removed GSD package.json`);
1093
+ }
1094
+ } catch (e) {
1095
+ // Ignore read errors
1096
+ }
1097
+ }
1098
+
1099
+ // 6. Clean up settings.json (remove GSD hooks and statusline)
1100
+ const settingsPath = path.join(targetDir, 'settings.json');
1101
+ if (fs.existsSync(settingsPath)) {
1102
+ let settings = readSettings(settingsPath);
1103
+ let settingsModified = false;
1104
+
1105
+ // Remove GSD statusline if it references our hook
1106
+ if (settings.statusLine && settings.statusLine.command &&
1107
+ settings.statusLine.command.includes('qgsd-statusline')) {
1108
+ delete settings.statusLine;
1109
+ settingsModified = true;
1110
+ console.log(` ${green}✓${reset} Removed GSD statusline from settings`);
1111
+ }
1112
+
1113
+ // Remove GSD hooks from SessionStart
1114
+ if (settings.hooks && settings.hooks.SessionStart) {
1115
+ const before = settings.hooks.SessionStart.length;
1116
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
1117
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1118
+ // Filter out GSD hooks
1119
+ const hasGsdHook = entry.hooks.some(h =>
1120
+ h.command && (
1121
+ h.command.includes('qgsd-check-update') ||
1122
+ h.command.includes('qgsd-statusline') ||
1123
+ h.command.includes('qgsd-session-start')
1124
+ )
1125
+ );
1126
+ return !hasGsdHook;
1127
+ }
1128
+ return true;
1129
+ });
1130
+ if (settings.hooks.SessionStart.length < before) {
1131
+ settingsModified = true;
1132
+ console.log(` ${green}✓${reset} Removed GSD hooks from settings`);
1133
+ }
1134
+ // Clean up empty array
1135
+ if (settings.hooks.SessionStart.length === 0) {
1136
+ delete settings.hooks.SessionStart;
1137
+ }
1138
+ }
1139
+
1140
+ if (settings.hooks && settings.hooks.UserPromptSubmit) {
1141
+ const before = settings.hooks.UserPromptSubmit.length;
1142
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(entry =>
1143
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-prompt')))
1144
+ );
1145
+ if (settings.hooks.UserPromptSubmit.length < before) {
1146
+ settingsModified = true;
1147
+ console.log(` ${green}✓${reset} Removed nForma quorum injection hook`);
1148
+ }
1149
+ if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit;
1150
+ }
1151
+ if (settings.hooks && settings.hooks.Stop) {
1152
+ const before = settings.hooks.Stop.length;
1153
+ settings.hooks.Stop = settings.hooks.Stop.filter(entry =>
1154
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-stop')))
1155
+ );
1156
+ if (settings.hooks.Stop.length < before) {
1157
+ settingsModified = true;
1158
+ console.log(` ${green}✓${reset} Removed nForma quorum gate hook`);
1159
+ }
1160
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
1161
+ }
1162
+ if (settings.hooks && settings.hooks.PreToolUse) {
1163
+ const before = settings.hooks.PreToolUse.length;
1164
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry =>
1165
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-circuit-breaker')))
1166
+ );
1167
+ if (settings.hooks.PreToolUse.length < before) {
1168
+ settingsModified = true;
1169
+ console.log(` ${green}✓${reset} Removed nForma circuit breaker hook`);
1170
+ }
1171
+ if (settings.hooks.PreToolUse.length === 0) delete settings.hooks.PreToolUse;
1172
+ }
1173
+ if (settings.hooks && settings.hooks.PostToolUse) {
1174
+ const before = settings.hooks.PostToolUse.length;
1175
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry =>
1176
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')))
1177
+ );
1178
+ if (settings.hooks.PostToolUse.length < before) {
1179
+ settingsModified = true;
1180
+ console.log(` ${green}✓${reset} Removed nForma context monitor hook`);
1181
+ }
1182
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
1183
+ }
1184
+ // Remove qgsd-spec-regen hook (uninstall path)
1185
+ if (settings.hooks && settings.hooks.PostToolUse) {
1186
+ const before = settings.hooks.PostToolUse.length;
1187
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry =>
1188
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-spec-regen')))
1189
+ );
1190
+ if (settings.hooks.PostToolUse.length < before) {
1191
+ settingsModified = true;
1192
+ console.log(` ${green}✓${reset} Removed nForma spec-regen hook`);
1193
+ }
1194
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
1195
+ }
1196
+ if (settings.hooks && settings.hooks.PreCompact) {
1197
+ const before = settings.hooks.PreCompact.length;
1198
+ settings.hooks.PreCompact = settings.hooks.PreCompact.filter(entry =>
1199
+ !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-precompact')))
1200
+ );
1201
+ if (settings.hooks.PreCompact.length < before) {
1202
+ settingsModified = true;
1203
+ console.log(` ${green}✓${reset} Removed nForma PreCompact hook`);
1204
+ }
1205
+ if (settings.hooks.PreCompact.length === 0) delete settings.hooks.PreCompact;
1206
+ }
1207
+
1208
+ // Clean up empty hooks object
1209
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
1210
+ delete settings.hooks;
1211
+ }
1212
+
1213
+ if (settingsModified) {
1214
+ writeSettings(settingsPath, settings);
1215
+ removedCount++;
1216
+ }
1217
+ }
1218
+
1219
+ // 6. For OpenCode, clean up permissions from opencode.json
1220
+ if (isOpencode) {
1221
+ // For local uninstalls, clean up ./.opencode/opencode.json
1222
+ // For global uninstalls, clean up ~/.config/opencode/opencode.json
1223
+ const opencodeConfigDir = isGlobal
1224
+ ? getOpencodeGlobalDir()
1225
+ : path.join(process.cwd(), '.opencode');
1226
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1227
+ if (fs.existsSync(configPath)) {
1228
+ try {
1229
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1230
+ let modified = false;
1231
+
1232
+ // Remove GSD permission entries
1233
+ if (config.permission) {
1234
+ for (const permType of ['read', 'external_directory']) {
1235
+ if (config.permission[permType]) {
1236
+ const keys = Object.keys(config.permission[permType]);
1237
+ for (const key of keys) {
1238
+ if (key.includes('qgsd')) {
1239
+ delete config.permission[permType][key];
1240
+ modified = true;
1241
+ }
1242
+ }
1243
+ // Clean up empty objects
1244
+ if (Object.keys(config.permission[permType]).length === 0) {
1245
+ delete config.permission[permType];
1246
+ }
1247
+ }
1248
+ }
1249
+ if (Object.keys(config.permission).length === 0) {
1250
+ delete config.permission;
1251
+ }
1252
+ }
1253
+
1254
+ if (modified) {
1255
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1256
+ removedCount++;
1257
+ console.log(` ${green}✓${reset} Removed GSD permissions from opencode.json`);
1258
+ }
1259
+ } catch (e) {
1260
+ // Ignore JSON parse errors
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ if (removedCount === 0) {
1266
+ console.log(` ${yellow}⚠${reset} No GSD files found to remove.`);
1267
+ }
1268
+
1269
+ console.log(`
1270
+ ${green}Done!${reset} GSD has been uninstalled from ${runtimeLabel}.
1271
+ Your other files and settings have been preserved.
1272
+ `);
1273
+ }
1274
+
1275
+ /**
1276
+ * Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
1277
+ * OpenCode supports JSONC format via jsonc-parser, so users may have comments.
1278
+ * This is a lightweight inline parser to avoid adding dependencies.
1279
+ */
1280
+ function parseJsonc(content) {
1281
+ // Strip BOM if present
1282
+ if (content.charCodeAt(0) === 0xFEFF) {
1283
+ content = content.slice(1);
1284
+ }
1285
+
1286
+ // Remove single-line and block comments while preserving strings
1287
+ let result = '';
1288
+ let inString = false;
1289
+ let i = 0;
1290
+ while (i < content.length) {
1291
+ const char = content[i];
1292
+ const next = content[i + 1];
1293
+
1294
+ if (inString) {
1295
+ result += char;
1296
+ // Handle escape sequences
1297
+ if (char === '\\' && i + 1 < content.length) {
1298
+ result += next;
1299
+ i += 2;
1300
+ continue;
1301
+ }
1302
+ if (char === '"') {
1303
+ inString = false;
1304
+ }
1305
+ i++;
1306
+ } else {
1307
+ if (char === '"') {
1308
+ inString = true;
1309
+ result += char;
1310
+ i++;
1311
+ } else if (char === '/' && next === '/') {
1312
+ // Skip single-line comment until end of line
1313
+ while (i < content.length && content[i] !== '\n') {
1314
+ i++;
1315
+ }
1316
+ } else if (char === '/' && next === '*') {
1317
+ // Skip block comment
1318
+ i += 2;
1319
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
1320
+ i++;
1321
+ }
1322
+ i += 2; // Skip closing */
1323
+ } else {
1324
+ result += char;
1325
+ i++;
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ // Remove trailing commas before } or ]
1331
+ result = result.replace(/,(\s*[}\]])/g, '$1');
1332
+
1333
+ return JSON.parse(result);
1334
+ }
1335
+
1336
+ /**
1337
+ * Configure OpenCode permissions to allow reading GSD reference docs
1338
+ * This prevents permission prompts when GSD accesses the qgsd directory
1339
+ * @param {boolean} isGlobal - Whether this is a global or local install
1340
+ */
1341
+ function configureOpencodePermissions(isGlobal = true) {
1342
+ // For local installs, use ./.opencode/opencode.json
1343
+ // For global installs, use ~/.config/opencode/opencode.json
1344
+ const opencodeConfigDir = isGlobal
1345
+ ? getOpencodeGlobalDir()
1346
+ : path.join(process.cwd(), '.opencode');
1347
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1348
+
1349
+ // Ensure config directory exists
1350
+ fs.mkdirSync(opencodeConfigDir, { recursive: true });
1351
+
1352
+ // Read existing config or create empty object
1353
+ let config = {};
1354
+ if (fs.existsSync(configPath)) {
1355
+ try {
1356
+ const content = fs.readFileSync(configPath, 'utf8');
1357
+ config = parseJsonc(content);
1358
+ } catch (e) {
1359
+ // Cannot parse - DO NOT overwrite user's config
1360
+ console.log(` ${yellow}⚠${reset} Could not parse opencode.json - skipping permission config`);
1361
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
1362
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
1363
+ return;
1364
+ }
1365
+ }
1366
+
1367
+ // Ensure permission structure exists
1368
+ if (!config.permission) {
1369
+ config.permission = {};
1370
+ }
1371
+
1372
+ // Build the GSD path using the actual config directory
1373
+ // Use ~ shorthand if it's in the default location, otherwise use full path
1374
+ const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1375
+ const gsdPath = opencodeConfigDir === defaultConfigDir
1376
+ ? '~/.config/opencode/qgsd/*'
1377
+ : `${opencodeConfigDir.replace(/\\/g, '/')}/qgsd/*`;
1378
+
1379
+ let modified = false;
1380
+
1381
+ // Configure read permission
1382
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
1383
+ config.permission.read = {};
1384
+ }
1385
+ if (config.permission.read[gsdPath] !== 'allow') {
1386
+ config.permission.read[gsdPath] = 'allow';
1387
+ modified = true;
1388
+ }
1389
+
1390
+ // Configure external_directory permission (the safety guard for paths outside project)
1391
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1392
+ config.permission.external_directory = {};
1393
+ }
1394
+ if (config.permission.external_directory[gsdPath] !== 'allow') {
1395
+ config.permission.external_directory[gsdPath] = 'allow';
1396
+ modified = true;
1397
+ }
1398
+
1399
+ if (!modified) {
1400
+ return; // Already configured
1401
+ }
1402
+
1403
+ // Write config back
1404
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1405
+ console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
1406
+ }
1407
+
1408
+ /**
1409
+ * Verify a directory exists and contains files
1410
+ */
1411
+ function verifyInstalled(dirPath, description) {
1412
+ if (!fs.existsSync(dirPath)) {
1413
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory not created`);
1414
+ return false;
1415
+ }
1416
+ try {
1417
+ const entries = fs.readdirSync(dirPath);
1418
+ if (entries.length === 0) {
1419
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: directory is empty`);
1420
+ return false;
1421
+ }
1422
+ } catch (e) {
1423
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: ${e.message}`);
1424
+ return false;
1425
+ }
1426
+ return true;
1427
+ }
1428
+
1429
+ /**
1430
+ * Verify a file exists
1431
+ */
1432
+ function verifyFileInstalled(filePath, description) {
1433
+ if (!fs.existsSync(filePath)) {
1434
+ console.error(` ${yellow}✗${reset} Failed to install ${description}: file not created`);
1435
+ return false;
1436
+ }
1437
+ return true;
1438
+ }
1439
+
1440
+ /**
1441
+ * Install to the specified directory for a specific runtime
1442
+ * @param {boolean} isGlobal - Whether to install globally or locally
1443
+ * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini')
1444
+ */
1445
+
1446
+ // ──────────────────────────────────────────────────────
1447
+ // Local Patch Persistence
1448
+ // ──────────────────────────────────────────────────────
1449
+
1450
+ const PATCHES_DIR_NAME = 'gsd-local-patches';
1451
+ const MANIFEST_NAME = 'qgsd-file-manifest.json';
1452
+
1453
+ /**
1454
+ * Compute SHA256 hash of file contents
1455
+ */
1456
+ function fileHash(filePath) {
1457
+ const content = fs.readFileSync(filePath);
1458
+ return crypto.createHash('sha256').update(content).digest('hex');
1459
+ }
1460
+
1461
+ /**
1462
+ * Recursively collect all files in dir with their hashes
1463
+ */
1464
+ function generateManifest(dir, baseDir) {
1465
+ if (!baseDir) baseDir = dir;
1466
+ const manifest = {};
1467
+ if (!fs.existsSync(dir)) return manifest;
1468
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1469
+ for (const entry of entries) {
1470
+ const fullPath = path.join(dir, entry.name);
1471
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1472
+ if (entry.isDirectory()) {
1473
+ Object.assign(manifest, generateManifest(fullPath, baseDir));
1474
+ } else {
1475
+ manifest[relPath] = fileHash(fullPath);
1476
+ }
1477
+ }
1478
+ return manifest;
1479
+ }
1480
+
1481
+ /**
1482
+ * Write file manifest after installation for future modification detection
1483
+ */
1484
+ function writeManifest(configDir) {
1485
+ const gsdDir = path.join(configDir, 'qgsd');
1486
+ const commandsDir = path.join(configDir, 'commands', 'qgsd');
1487
+ const agentsDir = path.join(configDir, 'agents');
1488
+ const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
1489
+
1490
+ const gsdHashes = generateManifest(gsdDir);
1491
+ for (const [rel, hash] of Object.entries(gsdHashes)) {
1492
+ manifest.files['qgsd/' + rel] = hash;
1493
+ }
1494
+ if (fs.existsSync(commandsDir)) {
1495
+ const cmdHashes = generateManifest(commandsDir);
1496
+ for (const [rel, hash] of Object.entries(cmdHashes)) {
1497
+ manifest.files['commands/qgsd/' + rel] = hash;
1498
+ }
1499
+ }
1500
+ if (fs.existsSync(agentsDir)) {
1501
+ for (const file of fs.readdirSync(agentsDir)) {
1502
+ if (file.startsWith('qgsd-') && file.endsWith('.md')) {
1503
+ manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
1509
+ return manifest;
1510
+ }
1511
+
1512
+ /**
1513
+ * Detect user-modified GSD files by comparing against install manifest.
1514
+ * Backs up modified files to gsd-local-patches/ for reapply after update.
1515
+ */
1516
+ function saveLocalPatches(configDir) {
1517
+ const manifestPath = path.join(configDir, MANIFEST_NAME);
1518
+ if (!fs.existsSync(manifestPath)) return [];
1519
+
1520
+ let manifest;
1521
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
1522
+
1523
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1524
+ const modified = [];
1525
+
1526
+ for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1527
+ const fullPath = path.join(configDir, relPath);
1528
+ if (!fs.existsSync(fullPath)) continue;
1529
+ const currentHash = fileHash(fullPath);
1530
+ if (currentHash !== originalHash) {
1531
+ const backupPath = path.join(patchesDir, relPath);
1532
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1533
+ fs.copyFileSync(fullPath, backupPath);
1534
+ modified.push(relPath);
1535
+ }
1536
+ }
1537
+
1538
+ if (modified.length > 0) {
1539
+ const meta = {
1540
+ backed_up_at: new Date().toISOString(),
1541
+ from_version: manifest.version,
1542
+ files: modified
1543
+ };
1544
+ fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1545
+ console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/');
1546
+ for (const f of modified) {
1547
+ console.log(' ' + dim + f + reset);
1548
+ }
1549
+ }
1550
+ return modified;
1551
+ }
1552
+
1553
+ /**
1554
+ * After install, report backed-up patches for user to reapply.
1555
+ */
1556
+ function reportLocalPatches(configDir) {
1557
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1558
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
1559
+ if (!fs.existsSync(metaPath)) return [];
1560
+
1561
+ let meta;
1562
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1563
+
1564
+ if (meta.files && meta.files.length > 0) {
1565
+ console.log('');
1566
+ console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
1567
+ for (const f of meta.files) {
1568
+ console.log(' ' + cyan + f + reset);
1569
+ }
1570
+ console.log('');
1571
+ console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset);
1572
+ console.log(' Run ' + cyan + '/qgsd:reapply-patches' + reset + ' to merge them into the new version.');
1573
+ console.log(' Or manually compare and merge the files.');
1574
+ console.log('');
1575
+ }
1576
+ return meta.files || [];
1577
+ }
1578
+
1579
+ function install(isGlobal, runtime = 'claude') {
1580
+ const isOpencode = runtime === 'opencode';
1581
+ const isGemini = runtime === 'gemini';
1582
+ const dirName = getDirName(runtime);
1583
+ const src = path.join(__dirname, '..');
1584
+
1585
+ // Get the target directory based on runtime and install type
1586
+ const targetDir = isGlobal
1587
+ ? getGlobalDir(runtime, explicitConfigDir)
1588
+ : path.join(process.cwd(), dirName);
1589
+
1590
+ const locationLabel = isGlobal
1591
+ ? targetDir.replace(os.homedir(), '~')
1592
+ : targetDir.replace(process.cwd(), '.');
1593
+
1594
+ // Path prefix for file references in markdown content
1595
+ // For global installs: use full path
1596
+ // For local installs: use relative
1597
+ const pathPrefix = isGlobal
1598
+ ? `${targetDir.replace(/\\/g, '/')}/`
1599
+ : `./${dirName}/`;
1600
+
1601
+ let runtimeLabel = 'Claude Code';
1602
+ if (isOpencode) runtimeLabel = 'OpenCode';
1603
+ if (isGemini) runtimeLabel = 'Gemini';
1604
+
1605
+ console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
1606
+
1607
+ // Track installation failures
1608
+ const failures = [];
1609
+
1610
+ // Save any locally modified GSD files before they get wiped
1611
+ saveLocalPatches(targetDir);
1612
+
1613
+ // Clean up orphaned files from previous versions
1614
+ cleanupOrphanedFiles(targetDir);
1615
+
1616
+ // OpenCode uses 'command/' (singular) with flat structure
1617
+ // Claude Code & Gemini use 'commands/' (plural) with nested structure
1618
+ if (isOpencode) {
1619
+ // OpenCode: flat structure in command/ directory
1620
+ const commandDir = path.join(targetDir, 'command');
1621
+ fs.mkdirSync(commandDir, { recursive: true });
1622
+
1623
+ // Copy commands/qgsd/*.md as command/qgsd-*.md (flatten structure)
1624
+ const gsdSrc = path.join(src, 'commands', 'qgsd');
1625
+ copyFlattenedCommands(gsdSrc, commandDir, 'qgsd', pathPrefix, runtime);
1626
+ if (verifyInstalled(commandDir, 'command/qgsd-*')) {
1627
+ const count = fs.readdirSync(commandDir).filter(f => f.startsWith('qgsd-')).length;
1628
+ console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
1629
+ } else {
1630
+ failures.push('command/qgsd-*');
1631
+ }
1632
+ } else {
1633
+ // Claude Code & Gemini: nested structure in commands/ directory
1634
+ const commandsDir = path.join(targetDir, 'commands');
1635
+ fs.mkdirSync(commandsDir, { recursive: true });
1636
+
1637
+ const gsdSrc = path.join(src, 'commands', 'qgsd');
1638
+ const gsdDest = path.join(commandsDir, 'qgsd');
1639
+ copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime);
1640
+ if (verifyInstalled(gsdDest, 'commands/qgsd')) {
1641
+ console.log(` ${green}✓${reset} Installed commands/qgsd`);
1642
+ } else {
1643
+ failures.push('commands/qgsd');
1644
+ }
1645
+ }
1646
+
1647
+ // Copy qgsd skill with path replacement
1648
+ const skillSrc = path.join(src, 'qgsd-core');
1649
+ const skillDest = path.join(targetDir, 'qgsd');
1650
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
1651
+ if (verifyInstalled(skillDest, 'qgsd')) {
1652
+ console.log(` ${green}✓${reset} Installed qgsd`);
1653
+ } else {
1654
+ failures.push('qgsd');
1655
+ }
1656
+
1657
+ // Copy agents to agents directory
1658
+ const agentsSrc = path.join(src, 'agents');
1659
+ if (fs.existsSync(agentsSrc)) {
1660
+ const agentsDest = path.join(targetDir, 'agents');
1661
+ fs.mkdirSync(agentsDest, { recursive: true });
1662
+
1663
+ // Remove old qgsd-*.md files before copying new ones
1664
+ if (fs.existsSync(agentsDest)) {
1665
+ for (const file of fs.readdirSync(agentsDest)) {
1666
+ if (file.startsWith('qgsd-') && file.endsWith('.md')) {
1667
+ fs.unlinkSync(path.join(agentsDest, file));
1668
+ }
1669
+ }
1670
+ }
1671
+
1672
+ // Copy new agents
1673
+ const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
1674
+ for (const entry of agentEntries) {
1675
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1676
+ let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1677
+ // Always replace ~/.claude/ as it is the source of truth in the repo
1678
+ const dirRegex = /~\/\.claude\//g;
1679
+ content = content.replace(dirRegex, pathPrefix);
1680
+ content = processAttribution(content, getCommitAttribution(runtime));
1681
+ // Convert frontmatter for runtime compatibility
1682
+ if (isOpencode) {
1683
+ content = convertClaudeToOpencodeFrontmatter(content);
1684
+ } else if (isGemini) {
1685
+ content = convertClaudeToGeminiAgent(content);
1686
+ }
1687
+ fs.writeFileSync(path.join(agentsDest, entry.name), content);
1688
+ }
1689
+ }
1690
+ if (verifyInstalled(agentsDest, 'agents')) {
1691
+ console.log(` ${green}✓${reset} Installed agents`);
1692
+ } else {
1693
+ failures.push('agents');
1694
+ }
1695
+ }
1696
+
1697
+ // Copy CHANGELOG.md
1698
+ const changelogSrc = path.join(src, 'CHANGELOG.md');
1699
+ const changelogDest = path.join(targetDir, 'qgsd', 'CHANGELOG.md');
1700
+ if (fs.existsSync(changelogSrc)) {
1701
+ fs.copyFileSync(changelogSrc, changelogDest);
1702
+ if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
1703
+ console.log(` ${green}✓${reset} Installed CHANGELOG.md`);
1704
+ } else {
1705
+ failures.push('CHANGELOG.md');
1706
+ }
1707
+ }
1708
+
1709
+ // Write VERSION file
1710
+ const versionDest = path.join(targetDir, 'qgsd', 'VERSION');
1711
+ fs.writeFileSync(versionDest, pkg.version);
1712
+ if (verifyFileInstalled(versionDest, 'VERSION')) {
1713
+ console.log(` ${green}✓${reset} Wrote VERSION (${pkg.version})`);
1714
+ } else {
1715
+ failures.push('VERSION');
1716
+ }
1717
+
1718
+ // Write package.json to force CommonJS mode for GSD scripts
1719
+ // Prevents "require is not defined" errors when project has "type": "module"
1720
+ // Node.js walks up looking for package.json - this stops inheritance from project
1721
+ const pkgJsonDest = path.join(targetDir, 'package.json');
1722
+ fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
1723
+ console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
1724
+
1725
+ // Copy hooks from dist/ (bundled with dependencies)
1726
+ // Template paths for the target runtime (replaces '.claude' with correct config dir)
1727
+ const hooksSrc = path.join(src, 'hooks', 'dist');
1728
+ if (fs.existsSync(hooksSrc)) {
1729
+ const hooksDest = path.join(targetDir, 'hooks');
1730
+ fs.mkdirSync(hooksDest, { recursive: true });
1731
+ const hookEntries = fs.readdirSync(hooksSrc);
1732
+ const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
1733
+ for (const entry of hookEntries) {
1734
+ const srcFile = path.join(hooksSrc, entry);
1735
+ if (fs.statSync(srcFile).isFile()) {
1736
+ const destFile = path.join(hooksDest, entry);
1737
+ // Template .js files to replace '.claude' with runtime-specific config dir
1738
+ if (entry.endsWith('.js')) {
1739
+ let content = fs.readFileSync(srcFile, 'utf8');
1740
+ content = content.replace(/'\.claude'/g, configDirReplacement);
1741
+ fs.writeFileSync(destFile, content);
1742
+ } else {
1743
+ fs.copyFileSync(srcFile, destFile);
1744
+ }
1745
+ }
1746
+ }
1747
+ if (verifyInstalled(hooksDest, 'hooks')) {
1748
+ console.log(` ${green}✓${reset} Installed hooks (bundled)`);
1749
+ } else {
1750
+ failures.push('hooks');
1751
+ }
1752
+ }
1753
+
1754
+ // Copy bin/*.cjs scripts to qgsd-bin/ (used by commands with absolute paths)
1755
+ const binSrc = path.join(src, 'bin');
1756
+ if (fs.existsSync(binSrc)) {
1757
+ const binDest = path.join(targetDir, 'qgsd-bin');
1758
+ fs.mkdirSync(binDest, { recursive: true });
1759
+ const binEntries = fs.readdirSync(binSrc);
1760
+ for (const entry of binEntries) {
1761
+ if (entry.endsWith('.cjs') || entry === 'providers.json') {
1762
+ fs.copyFileSync(path.join(binSrc, entry), path.join(binDest, entry));
1763
+ }
1764
+ }
1765
+ console.log(` ${green}✓${reset} Installed qgsd-bin scripts`);
1766
+ }
1767
+
1768
+ if (failures.length > 0) {
1769
+ console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
1770
+ process.exit(1);
1771
+ }
1772
+
1773
+ // Configure statusline and hooks in settings.json
1774
+ // Gemini shares same hook system as Claude Code for now
1775
+ const settingsPath = path.join(targetDir, 'settings.json');
1776
+ const settings = cleanupOrphanedHooks(readSettings(settingsPath));
1777
+ const statuslineCommand = isGlobal
1778
+ ? buildHookCommand(targetDir, 'qgsd-statusline.js')
1779
+ : 'node ' + dirName + '/hooks/qgsd-statusline.js';
1780
+ const updateCheckCommand = isGlobal
1781
+ ? buildHookCommand(targetDir, 'qgsd-check-update.js')
1782
+ : 'node ' + dirName + '/hooks/qgsd-check-update.js';
1783
+
1784
+ // Enable experimental agents for Gemini CLI (required for custom sub-agents)
1785
+ if (isGemini) {
1786
+ if (!settings.experimental) {
1787
+ settings.experimental = {};
1788
+ }
1789
+ if (!settings.experimental.enableAgents) {
1790
+ settings.experimental.enableAgents = true;
1791
+ console.log(` ${green}✓${reset} Enabled experimental agents`);
1792
+ }
1793
+ }
1794
+
1795
+ // Configure SessionStart hook for update checking (skip for opencode)
1796
+ if (!isOpencode) {
1797
+ if (!settings.hooks) {
1798
+ settings.hooks = {};
1799
+ }
1800
+ if (!settings.hooks.SessionStart) {
1801
+ settings.hooks.SessionStart = [];
1802
+ }
1803
+
1804
+ const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
1805
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-check-update'))
1806
+ );
1807
+
1808
+ if (!hasGsdUpdateHook) {
1809
+ settings.hooks.SessionStart.push({
1810
+ hooks: [
1811
+ {
1812
+ type: 'command',
1813
+ command: updateCheckCommand
1814
+ }
1815
+ ]
1816
+ });
1817
+ console.log(` ${green}✓${reset} Configured update check hook`);
1818
+ }
1819
+
1820
+ // Register nForma session-start secret sync hook
1821
+ const hasGsdSessionStartHook = settings.hooks.SessionStart.some(entry =>
1822
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-session-start'))
1823
+ );
1824
+ if (!hasGsdSessionStartHook) {
1825
+ settings.hooks.SessionStart.push({
1826
+ hooks: [
1827
+ {
1828
+ type: 'command',
1829
+ command: buildHookCommand(targetDir, 'qgsd-session-start.js')
1830
+ }
1831
+ ]
1832
+ });
1833
+ console.log(` ${green}✓${reset} Configured nForma secret sync hook (SessionStart)`);
1834
+ }
1835
+
1836
+ // INST-05: Warn (yellow) if quorum MCP servers are absent — runs every install
1837
+ warnMissingMcpServers();
1838
+
1839
+ // Register nForma UserPromptSubmit hook (quorum injection)
1840
+ // MUST be in settings.json — plugin hooks.json silently discards UserPromptSubmit output (GitHub #10225)
1841
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
1842
+ const hasQgsdPromptHook = settings.hooks.UserPromptSubmit.some(entry =>
1843
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-prompt'))
1844
+ );
1845
+ if (!hasQgsdPromptHook) {
1846
+ settings.hooks.UserPromptSubmit.push({
1847
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-prompt.js') }]
1848
+ });
1849
+ console.log(` ${green}✓${reset} Configured nForma quorum injection hook (UserPromptSubmit)`);
1850
+ }
1851
+
1852
+ // Register nForma Stop hook (quorum gate — verifies quorum evidence before Claude delivers planning output)
1853
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
1854
+ const hasQgsdStopHook = settings.hooks.Stop.some(entry =>
1855
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-stop'))
1856
+ );
1857
+ if (!hasQgsdStopHook) {
1858
+ settings.hooks.Stop.push({
1859
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-stop.js'), timeout: 30 }]
1860
+ });
1861
+ console.log(` ${green}✓${reset} Configured nForma quorum gate hook (Stop)`);
1862
+ }
1863
+
1864
+ // INST-08: Register nForma circuit breaker hook (PreToolUse — Claude Code only)
1865
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
1866
+ const hasCircuitBreakerHook = settings.hooks.PreToolUse.some(entry =>
1867
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-circuit-breaker'))
1868
+ );
1869
+ if (!hasCircuitBreakerHook) {
1870
+ settings.hooks.PreToolUse.push({
1871
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-circuit-breaker.js'), timeout: 10 }]
1872
+ });
1873
+ console.log(` ${green}✓${reset} Configured nForma circuit breaker hook (PreToolUse)`);
1874
+ }
1875
+
1876
+ // Register nForma context monitor hook (PostToolUse — context window warnings)
1877
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
1878
+ const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry =>
1879
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
1880
+ );
1881
+ if (!hasContextMonitorHook) {
1882
+ settings.hooks.PostToolUse.push({
1883
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'gsd-context-monitor.js') }]
1884
+ });
1885
+ console.log(` ${green}✓${reset} Configured nForma context monitor hook (PostToolUse)`);
1886
+ }
1887
+
1888
+ // Register nForma spec-regen hook (PostToolUse — auto-regenerate specs on machine file write)
1889
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
1890
+ const hasSpecRegenHook = settings.hooks.PostToolUse.some(entry =>
1891
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-spec-regen'))
1892
+ );
1893
+ if (!hasSpecRegenHook) {
1894
+ settings.hooks.PostToolUse.push({
1895
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-spec-regen.js') }]
1896
+ });
1897
+ console.log(` ${green}✓${reset} Configured nForma spec-regen hook (PostToolUse)`);
1898
+ }
1899
+
1900
+ // Register nForma PreCompact hook (phase state injection at compaction time)
1901
+ if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
1902
+ const hasPreCompactHook = settings.hooks.PreCompact.some(entry =>
1903
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-precompact'))
1904
+ );
1905
+ if (!hasPreCompactHook) {
1906
+ settings.hooks.PreCompact.push({
1907
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-precompact.js') }]
1908
+ });
1909
+ console.log(` ${green}✓${reset} Configured nForma PreCompact hook (phase state injection)`);
1910
+ }
1911
+
1912
+ // Register nForma token collector hook (SubagentStop — logs per-slot token usage)
1913
+ if (!settings.hooks.SubagentStop) settings.hooks.SubagentStop = [];
1914
+ const hasTokenCollectorHook = settings.hooks.SubagentStop.some(entry =>
1915
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-token-collector'))
1916
+ );
1917
+ if (!hasTokenCollectorHook) {
1918
+ settings.hooks.SubagentStop.push({
1919
+ matcher: 'qgsd-quorum-slot-worker',
1920
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-token-collector.js'), async: true }]
1921
+ });
1922
+ console.log(` ${green}✓${reset} Configured nForma token collector hook (SubagentStop)`);
1923
+ }
1924
+
1925
+ // Register nForma slot correlator hook (SubagentStart — writes agent_id correlation file)
1926
+ if (!settings.hooks.SubagentStart) settings.hooks.SubagentStart = [];
1927
+ const hasSlotCorrelatorHook = settings.hooks.SubagentStart.some(entry =>
1928
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('qgsd-slot-correlator'))
1929
+ );
1930
+ if (!hasSlotCorrelatorHook) {
1931
+ settings.hooks.SubagentStart.push({
1932
+ matcher: 'qgsd-quorum-slot-worker',
1933
+ hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'qgsd-slot-correlator.js'), async: true }]
1934
+ });
1935
+ console.log(` ${green}✓${reset} Configured nForma slot correlator hook (SubagentStart)`);
1936
+ }
1937
+
1938
+ // Write nForma config — skip if exists unless --redetect-mcps flag set
1939
+ const qgsdConfigPath = path.join(targetDir, 'qgsd.json');
1940
+
1941
+ // --redetect-mcps: delete existing config so fresh detection runs below
1942
+ if (hasRedetectMcps && fs.existsSync(qgsdConfigPath)) {
1943
+ fs.unlinkSync(qgsdConfigPath);
1944
+ console.log(` ${cyan}◆${reset} Re-detecting MCP prefixes (--redetect-mcps)...`);
1945
+ }
1946
+
1947
+ if (!fs.existsSync(qgsdConfigPath)) {
1948
+ // Build config with auto-detected MCP prefixes
1949
+ const detectedModels = buildRequiredModelsFromMcp();
1950
+ const qgsdConfig = {
1951
+ quorum_commands: [
1952
+ 'plan-phase', 'new-project', 'new-milestone',
1953
+ 'discuss-phase', 'verify-work', 'research-phase',
1954
+ ],
1955
+ fail_mode: 'open',
1956
+ required_models: detectedModels,
1957
+ quorum_active: buildActiveSlots(), // COMP-04: populated from all discovered slots
1958
+ // Generated from detected prefixes — behavioral instructions match structural enforcement
1959
+ quorum_instructions: buildQuorumInstructions(detectedModels),
1960
+ // INST-09: Must match DEFAULT_CONFIG.circuit_breaker in hooks/config-loader.js
1961
+ circuit_breaker: {
1962
+ oscillation_depth: 3,
1963
+ commit_window: 6,
1964
+ },
1965
+ };
1966
+
1967
+ fs.writeFileSync(qgsdConfigPath, JSON.stringify(qgsdConfig, null, 2) + '\n', 'utf8');
1968
+ console.log(` ${green}✓${reset} Wrote nForma config with detected MCP prefixes (~/.claude/qgsd.json)`);
1969
+ console.log(` ${green}✓${reset} Wrote quorum_active (${qgsdConfig.quorum_active.length} slots) to qgsd.json`);
1970
+ } else {
1971
+ // INST-06: print active config summary on reinstall
1972
+ try {
1973
+ const existingConfig = JSON.parse(fs.readFileSync(qgsdConfigPath, 'utf8'));
1974
+ const models = existingConfig.required_models || {};
1975
+ const summary = Object.entries(models)
1976
+ .map(([key, def]) => `${key} → ${def.tool_prefix || '(unset)'}`)
1977
+ .join(', ');
1978
+ console.log(` ${dim}↳ ~/.claude/qgsd.json exists — active config: ${summary}${reset}`);
1979
+ console.log(` ${dim} (run with --redetect-mcps to refresh MCP prefix detection)${reset}`);
1980
+
1981
+ // INST-10: Add missing circuit_breaker block or missing sub-keys without touching existing user values
1982
+ if (!existingConfig.circuit_breaker) {
1983
+ existingConfig.circuit_breaker = { oscillation_depth: 3, commit_window: 6 };
1984
+ fs.writeFileSync(qgsdConfigPath, JSON.stringify(existingConfig, null, 2) + '\n', 'utf8');
1985
+ console.log(` ${green}✓${reset} Added circuit_breaker config block to qgsd.json`);
1986
+ } else {
1987
+ // Backfill individual missing sub-keys without touching values the user has set
1988
+ let subKeyAdded = false;
1989
+ if (existingConfig.circuit_breaker.oscillation_depth === undefined) {
1990
+ existingConfig.circuit_breaker.oscillation_depth = 3;
1991
+ subKeyAdded = true;
1992
+ }
1993
+ if (existingConfig.circuit_breaker.commit_window === undefined) {
1994
+ existingConfig.circuit_breaker.commit_window = 6;
1995
+ subKeyAdded = true;
1996
+ }
1997
+ if (subKeyAdded) {
1998
+ fs.writeFileSync(qgsdConfigPath, JSON.stringify(existingConfig, null, 2) + '\n', 'utf8');
1999
+ console.log(` ${green}✓${reset} Added missing circuit_breaker sub-keys to qgsd.json`);
2000
+ }
2001
+ }
2002
+
2003
+ // COMP-04: Backfill quorum_active if absent or empty (same pattern as circuit_breaker backfill)
2004
+ if (!existingConfig.quorum_active || existingConfig.quorum_active.length === 0) {
2005
+ const discoveredSlots = buildActiveSlots();
2006
+ if (discoveredSlots.length > 0) {
2007
+ existingConfig.quorum_active = discoveredSlots;
2008
+ fs.writeFileSync(qgsdConfigPath, JSON.stringify(existingConfig, null, 2) + '\n', 'utf8');
2009
+ console.log(` ${green}✓${reset} Backfilled quorum_active (${discoveredSlots.length} slots) in qgsd.json`);
2010
+ }
2011
+ }
2012
+
2013
+ // MULTI-03: Incremental quorum_active update — append new slots discovered since last install
2014
+ // Only runs if quorum_active is already set (non-empty); new slots are appended, existing preserved
2015
+ if (existingConfig.quorum_active && existingConfig.quorum_active.length > 0) {
2016
+ const { addSlotToQuorumActive } = require('./migrate-to-slots.cjs');
2017
+ const allCurrentSlots = buildActiveSlots();
2018
+ const newSlots = allCurrentSlots.filter(s => !existingConfig.quorum_active.includes(s));
2019
+ for (const newSlot of newSlots) {
2020
+ const result = addSlotToQuorumActive(newSlot, qgsdConfigPath);
2021
+ if (result.added) {
2022
+ console.log(` ${green}✓${reset} Added new slot to quorum_active: ${newSlot}`);
2023
+ existingConfig.quorum_active.push(newSlot); // keep in-memory copy consistent
2024
+ }
2025
+ }
2026
+ }
2027
+ // If quorum_active is already set and non-empty: do NOT overwrite (user config preserved)
2028
+ } catch {
2029
+ console.log(` ${dim}↳ ~/.claude/qgsd.json already exists — skipping (user config preserved)${reset}`);
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ // Write file manifest for future modification detection
2035
+ writeManifest(targetDir);
2036
+ console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
2037
+
2038
+ // Report any backed-up local patches
2039
+ reportLocalPatches(targetDir);
2040
+
2041
+ return { settingsPath, settings, statuslineCommand, runtime };
2042
+ }
2043
+
2044
+ /**
2045
+ * Apply statusline config, then print completion message
2046
+ */
2047
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
2048
+ const isOpencode = runtime === 'opencode';
2049
+
2050
+ if (shouldInstallStatusline && !isOpencode) {
2051
+ settings.statusLine = {
2052
+ type: 'command',
2053
+ command: statuslineCommand
2054
+ };
2055
+ console.log(` ${green}✓${reset} Configured statusline`);
2056
+ }
2057
+
2058
+ // Always write settings
2059
+ writeSettings(settingsPath, settings);
2060
+
2061
+ // Configure OpenCode permissions
2062
+ if (isOpencode) {
2063
+ configureOpencodePermissions(isGlobal);
2064
+ }
2065
+
2066
+ let program = 'Claude Code';
2067
+ if (runtime === 'opencode') program = 'OpenCode';
2068
+ if (runtime === 'gemini') program = 'Gemini';
2069
+
2070
+ const command = isOpencode ? '/qgsd-help' : '/qgsd:help';
2071
+
2072
+ let nudge = '';
2073
+ if (runtime === 'claude' && !hasClaudeMcpAgents()) {
2074
+ nudge = `\n ${yellow}⚠${reset} No quorum agents configured.\n Run ${cyan}/qgsd:mcp-setup${reset} in Claude Code to set up your agents.\n`;
2075
+ }
2076
+
2077
+ console.log(`
2078
+ ${green}Done!${reset} Launch ${program} and run ${cyan}${command}${reset}.
2079
+ ${nudge}
2080
+ ${cyan}Join the community:${reset} https://discord.gg/5JJgD5svVS
2081
+ `);
2082
+
2083
+ // Best-effort formal tools — always runs after success banner, never blocks main install
2084
+ if (!hasUninstall && !hasFormal) {
2085
+ const { spawnSync: _formalSpawn } = require('child_process');
2086
+ const formalScript = path.join(__dirname, 'install-formal-tools.cjs');
2087
+ if (fs.existsSync(formalScript)) {
2088
+ console.log(' Formal verification tools:');
2089
+ _formalSpawn(process.execPath, [formalScript], { stdio: 'inherit' });
2090
+ // exit code ignored — best-effort
2091
+ }
2092
+ }
2093
+ }
2094
+
2095
+ /**
2096
+ * Handle statusline configuration with optional prompt
2097
+ */
2098
+ function handleStatusline(settings, isInteractive, callback) {
2099
+ const hasExisting = settings.statusLine != null;
2100
+
2101
+ if (!hasExisting) {
2102
+ callback(true);
2103
+ return;
2104
+ }
2105
+
2106
+ if (forceStatusline) {
2107
+ callback(true);
2108
+ return;
2109
+ }
2110
+
2111
+ if (!isInteractive) {
2112
+ console.log(` ${yellow}⚠${reset} Skipping statusline (already configured)`);
2113
+ console.log(` Use ${cyan}--force-statusline${reset} to replace\n`);
2114
+ callback(false);
2115
+ return;
2116
+ }
2117
+
2118
+ const existingCmd = settings.statusLine.command || settings.statusLine.url || '(custom)';
2119
+
2120
+ const rl = readline.createInterface({
2121
+ input: process.stdin,
2122
+ output: process.stdout
2123
+ });
2124
+
2125
+ console.log(`
2126
+ ${yellow}⚠${reset} Existing statusline detected\n
2127
+ Your current statusline:
2128
+ ${dim}command: ${existingCmd}${reset}
2129
+
2130
+ nForma includes a statusline showing:
2131
+ • Model name
2132
+ • Current task (from todo list)
2133
+ • Context window usage (color-coded)
2134
+
2135
+ ${cyan}1${reset}) Keep existing
2136
+ ${cyan}2${reset}) Replace with nForma statusline
2137
+ `);
2138
+
2139
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2140
+ rl.close();
2141
+ const choice = answer.trim() || '1';
2142
+ callback(choice === '2');
2143
+ });
2144
+ }
2145
+
2146
+ /**
2147
+ * Prompt for runtime selection
2148
+ */
2149
+ function promptRuntime(callback) {
2150
+ const rl = readline.createInterface({
2151
+ input: process.stdin,
2152
+ output: process.stdout
2153
+ });
2154
+
2155
+ let answered = false;
2156
+
2157
+ rl.on('close', () => {
2158
+ if (!answered) {
2159
+ answered = true;
2160
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
2161
+ process.exit(0);
2162
+ }
2163
+ });
2164
+
2165
+ console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
2166
+ ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - open source, free models
2167
+ ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
2168
+ ${cyan}4${reset}) All
2169
+ `);
2170
+
2171
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2172
+ answered = true;
2173
+ rl.close();
2174
+ const choice = answer.trim() || '1';
2175
+ if (choice === '4') {
2176
+ callback(['claude', 'opencode', 'gemini']);
2177
+ } else if (choice === '3') {
2178
+ callback(['gemini']);
2179
+ } else if (choice === '2') {
2180
+ callback(['opencode']);
2181
+ } else {
2182
+ callback(['claude']);
2183
+ }
2184
+ });
2185
+ }
2186
+
2187
+ /**
2188
+ * Prompt for install location
2189
+ */
2190
+ function promptLocation(runtimes) {
2191
+ if (!process.stdin.isTTY) {
2192
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to global install${reset}\n`);
2193
+ installAllRuntimes(runtimes, true, false);
2194
+ return;
2195
+ }
2196
+
2197
+ const rl = readline.createInterface({
2198
+ input: process.stdin,
2199
+ output: process.stdout
2200
+ });
2201
+
2202
+ let answered = false;
2203
+
2204
+ rl.on('close', () => {
2205
+ if (!answered) {
2206
+ answered = true;
2207
+ console.log(`\n ${yellow}Installation cancelled${reset}\n`);
2208
+ process.exit(0);
2209
+ }
2210
+ });
2211
+
2212
+ const pathExamples = runtimes.map(r => {
2213
+ const globalPath = getGlobalDir(r, explicitConfigDir);
2214
+ return globalPath.replace(os.homedir(), '~');
2215
+ }).join(', ');
2216
+
2217
+ const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
2218
+
2219
+ console.log(` ${yellow}Where would you like to install?${reset}\n\n ${cyan}1${reset}) Global ${dim}(${pathExamples})${reset} - available in all projects
2220
+ ${cyan}2${reset}) Local ${dim}(${localExamples})${reset} - this project only
2221
+ `);
2222
+
2223
+ rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
2224
+ answered = true;
2225
+ rl.close();
2226
+ const choice = answer.trim() || '1';
2227
+ const isGlobal = choice !== '2';
2228
+ installAllRuntimes(runtimes, isGlobal, true);
2229
+ });
2230
+ }
2231
+
2232
+ /**
2233
+ * Install GSD for all selected runtimes
2234
+ */
2235
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
2236
+ const results = [];
2237
+
2238
+ for (const runtime of runtimes) {
2239
+ const result = install(isGlobal, runtime);
2240
+ results.push(result);
2241
+ }
2242
+
2243
+ // Handle statusline for Claude & Gemini (OpenCode uses themes)
2244
+ const claudeResult = results.find(r => r.runtime === 'claude');
2245
+ const geminiResult = results.find(r => r.runtime === 'gemini');
2246
+
2247
+ // Logic: if both are present, ask once if interactive? Or ask for each?
2248
+ // Simpler: Ask once and apply to both if applicable.
2249
+
2250
+ if (claudeResult || geminiResult) {
2251
+ // Use whichever settings exist to check for existing statusline
2252
+ const primaryResult = claudeResult || geminiResult;
2253
+
2254
+ handleStatusline(primaryResult.settings, isInteractive, (shouldInstallStatusline) => {
2255
+ if (claudeResult) {
2256
+ finishInstall(claudeResult.settingsPath, claudeResult.settings, claudeResult.statuslineCommand, shouldInstallStatusline, 'claude', isGlobal);
2257
+ }
2258
+ if (geminiResult) {
2259
+ finishInstall(geminiResult.settingsPath, geminiResult.settings, geminiResult.statuslineCommand, shouldInstallStatusline, 'gemini', isGlobal);
2260
+ }
2261
+
2262
+ const opencodeResult = results.find(r => r.runtime === 'opencode');
2263
+ if (opencodeResult) {
2264
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode', isGlobal);
2265
+ }
2266
+ });
2267
+ } else {
2268
+ // Only OpenCode
2269
+ const opencodeResult = results[0];
2270
+ finishInstall(opencodeResult.settingsPath, opencodeResult.settings, opencodeResult.statuslineCommand, false, 'opencode', isGlobal);
2271
+ }
2272
+ }
2273
+
2274
+ // RECV-01: --reset-breaker clears project-relative circuit breaker state and exits before any install logic
2275
+ if (hasResetBreaker) {
2276
+ const { spawnSync } = require('child_process');
2277
+ const gitResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2278
+ cwd: process.cwd(),
2279
+ encoding: 'utf8',
2280
+ timeout: 5000,
2281
+ });
2282
+ const projectRoot = (gitResult.status === 0 && !gitResult.error)
2283
+ ? gitResult.stdout.trim()
2284
+ : process.cwd();
2285
+ const stateFile = path.join(projectRoot, '.claude', 'circuit-breaker-state.json');
2286
+ if (fs.existsSync(stateFile)) {
2287
+ fs.rmSync(stateFile);
2288
+ console.log(` ${green}✓${reset} Circuit breaker state cleared. Claude can resume Bash execution.`);
2289
+ console.log(` Removed: ${stateFile.replace(os.homedir(), '~')}`);
2290
+ } else {
2291
+ console.log(` ${dim}No active circuit breaker state found at ${stateFile.replace(os.homedir(), '~')}${reset}`);
2292
+ }
2293
+ // Stamp manualResetAt on all active oscillation-log.json entries
2294
+ const oscLogFile = path.join(projectRoot, '.planning', 'oscillation-log.json');
2295
+ if (fs.existsSync(oscLogFile)) {
2296
+ try {
2297
+ const oscLog = JSON.parse(fs.readFileSync(oscLogFile, 'utf8'));
2298
+ const now = new Date().toISOString();
2299
+ let touched = 0;
2300
+ for (const key of Object.keys(oscLog)) {
2301
+ if (!oscLog[key].resolvedAt) {
2302
+ oscLog[key].manualResetAt = now;
2303
+ touched++;
2304
+ }
2305
+ }
2306
+ fs.writeFileSync(oscLogFile, JSON.stringify(oscLog, null, 2), 'utf8');
2307
+ if (touched > 0) {
2308
+ console.log(` ${green}✓${reset} Stamped manualResetAt on ${touched} active oscillation log entr${touched === 1 ? 'y' : 'ies'}.`);
2309
+ }
2310
+ } catch (e) {
2311
+ console.log(` ${dim}Could not update oscillation log: ${e.message}${reset}`);
2312
+ }
2313
+ }
2314
+ process.exit(0);
2315
+ }
2316
+
2317
+ // DISABLE-01: --disable-breaker pauses circuit breaker enforcement without resetting detection
2318
+ if (hasDisableBreaker) {
2319
+ const { spawnSync } = require('child_process');
2320
+ const gitResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2321
+ cwd: process.cwd(),
2322
+ encoding: 'utf8',
2323
+ timeout: 5000,
2324
+ });
2325
+ const projectRoot = (gitResult.status === 0 && !gitResult.error)
2326
+ ? gitResult.stdout.trim()
2327
+ : process.cwd();
2328
+ const stateFile = path.join(projectRoot, '.claude', 'circuit-breaker-state.json');
2329
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
2330
+ const existing = fs.existsSync(stateFile)
2331
+ ? JSON.parse(fs.readFileSync(stateFile, 'utf8'))
2332
+ : {};
2333
+ fs.writeFileSync(stateFile, JSON.stringify({ ...existing, disabled: true, active: false }, null, 2), 'utf8');
2334
+ console.log(` ${yellow}⊘${reset} Circuit breaker ${yellow}disabled${reset}. Detection and enforcement paused.`);
2335
+ console.log(` Run ${cyan}npx qgsd --enable-breaker${reset} to resume.`);
2336
+ process.exit(0);
2337
+ }
2338
+
2339
+ // DISABLE-02: --enable-breaker resumes circuit breaker enforcement
2340
+ if (hasEnableBreaker) {
2341
+ const { spawnSync } = require('child_process');
2342
+ const gitResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2343
+ cwd: process.cwd(),
2344
+ encoding: 'utf8',
2345
+ timeout: 5000,
2346
+ });
2347
+ const projectRoot = (gitResult.status === 0 && !gitResult.error)
2348
+ ? gitResult.stdout.trim()
2349
+ : process.cwd();
2350
+ const stateFile = path.join(projectRoot, '.claude', 'circuit-breaker-state.json');
2351
+ if (fs.existsSync(stateFile)) {
2352
+ const existing = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
2353
+ fs.writeFileSync(stateFile, JSON.stringify({ ...existing, disabled: false, active: false }, null, 2), 'utf8');
2354
+ }
2355
+ // If no state file, nothing to do — circuit breaker is already effectively enabled
2356
+ console.log(` ${green}✓${reset} Circuit breaker ${green}enabled${reset}. Oscillation detection resumed.`);
2357
+ process.exit(0);
2358
+ }
2359
+
2360
+ // SLOT-02: --migrate-slots renames mcpServers keys and patches qgsd.json tool_prefix values
2361
+ if (hasMigrateSlots) {
2362
+ const { migrateClaudeJson, migrateQgsdJson } = require('./migrate-to-slots.cjs');
2363
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
2364
+ const qgsdJsonPath = path.join(os.homedir(), '.claude', 'qgsd.json');
2365
+ const dryRun = args.includes('--dry-run');
2366
+ const r1 = migrateClaudeJson(claudeJsonPath, dryRun);
2367
+ const r2 = migrateQgsdJson(qgsdJsonPath, dryRun);
2368
+ if (r1.changed === 0 && r2.changed === 0) {
2369
+ console.log(` ${green}✓${reset} Already migrated — no changes needed`);
2370
+ } else {
2371
+ if (r1.changed > 0) {
2372
+ console.log(` ${green}✓${reset} Migrated ${r1.changed} mcpServers entries:`);
2373
+ r1.renamed.forEach(r => console.log(` ${r.from} → ${r.to}`));
2374
+ }
2375
+ if (r2.changed > 0) {
2376
+ console.log(` ${green}✓${reset} Patched ${r2.changed} qgsd.json tool_prefix values`);
2377
+ }
2378
+ }
2379
+ process.exit(0);
2380
+ }
2381
+
2382
+ if (hasFormal) {
2383
+ const { spawnSync } = require('child_process');
2384
+ const formalScript = path.join(__dirname, 'install-formal-tools.cjs');
2385
+ const result = spawnSync(process.execPath, [formalScript], { stdio: 'inherit' });
2386
+ process.exit(result.status ?? 0);
2387
+ }
2388
+
2389
+ // Main logic
2390
+ if (hasGlobal && hasLocal) {
2391
+ console.error(` ${yellow}Cannot specify both --global and --local${reset}`);
2392
+ process.exit(1);
2393
+ } else if (explicitConfigDir && hasLocal) {
2394
+ console.error(` ${yellow}Cannot use --config-dir with --local${reset}`);
2395
+ process.exit(1);
2396
+ } else if (hasUninstall) {
2397
+ if (!hasGlobal && !hasLocal) {
2398
+ console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
2399
+ process.exit(1);
2400
+ }
2401
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
2402
+ for (const runtime of runtimes) {
2403
+ uninstall(hasGlobal, runtime);
2404
+ }
2405
+ } else if (selectedRuntimes.length > 0) {
2406
+ if (!hasGlobal && !hasLocal) {
2407
+ promptLocation(selectedRuntimes);
2408
+ } else {
2409
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
2410
+ }
2411
+ } else if (hasGlobal || hasLocal) {
2412
+ // Default to Claude if no runtime specified but location is
2413
+ installAllRuntimes(['claude'], hasGlobal, false);
2414
+ } else {
2415
+ // Interactive
2416
+ if (!process.stdin.isTTY) {
2417
+ console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
2418
+ installAllRuntimes(['claude'], true, false);
2419
+ } else {
2420
+ promptRuntime((runtimes) => {
2421
+ promptLocation(runtimes);
2422
+ });
2423
+ }
2424
+ }