@opengsd/gsd-core 1.16.0 → 1.17.0

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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-debug-session-manager.compact.md +6 -0
  7. package/agents/gsd-debug-session-manager.md +6 -0
  8. package/bin/gsd-mcp-server.js +3 -1
  9. package/bin/install.js +361 -94
  10. package/gsd-core/bin/gsd-tools.cjs +17 -2
  11. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  12. package/gsd-core/bin/lib/capability-registry.cjs +132 -80
  13. package/gsd-core/bin/lib/capability-validator.cjs +2 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  15. package/gsd-core/bin/lib/commands.cjs +28 -8
  16. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  17. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  18. package/gsd-core/bin/lib/graphify.cjs +3 -2
  19. package/gsd-core/bin/lib/init.cjs +32 -12
  20. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  21. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  22. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  23. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  24. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  25. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  26. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  27. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  28. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  29. package/gsd-core/bin/lib/phase.cjs +21 -0
  30. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  31. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
  32. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  33. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  34. package/gsd-core/bin/lib/smart-entry.cjs +92 -53
  35. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  36. package/gsd-core/bin/lib/state.cjs +454 -66
  37. package/gsd-core/bin/lib/update-context.cjs +18 -1
  38. package/gsd-core/bin/lib/verification.cjs +107 -17
  39. package/gsd-core/workflows/code-review.md +16 -1
  40. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  41. package/gsd-core/workflows/execute-phase.md +3 -1
  42. package/gsd-core/workflows/resume-project.md +14 -10
  43. package/hooks/dist/gsd-check-update.js +26 -20
  44. package/hooks/dist/gsd-context-monitor.js +14 -5
  45. package/hooks/dist/gsd-secret-read-guard.js +117 -1
  46. package/hooks/dist/gsd-statusline.js +321 -22
  47. package/hooks/gsd-check-update.js +26 -20
  48. package/hooks/gsd-context-monitor.js +14 -5
  49. package/hooks/gsd-secret-read-guard.js +117 -1
  50. package/hooks/gsd-statusline.js +321 -22
  51. package/package.json +1 -1
  52. package/scripts/gen-capability-registry.cjs +7 -1
  53. package/scripts/gen-loop-host-contract.cjs +4 -1
  54. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  55. package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
  56. package/scripts/mutation-matrix.cjs +3 -1
  57. package/scripts/release-tarball-smoke.cjs +14 -7
  58. package/vscode/package.json +1 -1
package/bin/install.js CHANGED
@@ -190,7 +190,7 @@ function isCodexHooksFeatureKey(key) {
190
190
  // GSD_CLAUDE_LEGACY_DENY_PERMISSIONS below.)
191
191
  //
192
192
  // Format: each string uses Claude Code's documented permission rule syntax \u2014
193
- // "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
193
+ // "Tool(pattern)" e.g. "Read(.planning/*)", "Edit(STATE.md)"
194
194
  // "Tool" (bare tool name, no pattern)
195
195
  //
196
196
  // Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
@@ -199,7 +199,6 @@ function isCodexHooksFeatureKey(key) {
199
199
  // instead of scattered literal 'claude' defaults/rosters (#2086).
200
200
  const DEFAULT_RUNTIME = 'claude';
201
201
  const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
202
- 'Bash(npx gsd-core *)',
203
202
  'Read(.planning/*)',
204
203
  'Edit(.planning/*)',
205
204
  'Read(STATE.md)',
@@ -232,9 +231,14 @@ const GSD_CLAUDE_LEGACY_DENY_PERMISSIONS = Object.freeze([
232
231
  // mergeClaudePermissions and uninstall cleanup retire those stale entries on
233
232
  // existing installs while the current GSD_CLAUDE_ALLOW_PERMISSIONS above
234
233
  // carries the working `Edit(...)` forms.
234
+ // #5054 — `Bash(npx gsd-core *)` is retired the same way. The project ships
235
+ // as `@opengsd/gsd-core`; the unscoped `gsd-core` npm name belongs to someone
236
+ // else, so the rule pre-authorized whatever that name's owner publishes next.
237
+ // GSD never runs the bare form (update.md uses `--package=@opengsd/gsd-core`).
235
238
  const GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
236
239
  'Write(.planning/*)',
237
240
  'Write(STATE.md)',
241
+ 'Bash(npx gsd-core *)',
238
242
  ]);
239
243
 
240
244
  /**
@@ -249,6 +253,7 @@ const GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS = Object.freeze([
249
253
  * `Write(...)` forms from before the fix) is removed from permissions.allow,
250
254
  * so existing installs end up with the working `Edit(...)` forms instead of
251
255
  * both the dead legacy entry and its replacement sitting side by side.
256
+ * The same filter retires the unscoped `Bash(npx gsd-core *)` rule (#5054).
252
257
  *
253
258
  * Migration (#4221): the retired GSD_CLAUDE_LEGACY_DENY_PERMISSIONS entries
254
259
  * are removed from permissions.deny (byte-equal only). Nothing is added to
@@ -408,6 +413,26 @@ const GSD_WINDSURF_HOOK_SCRIPTS = hooksSurface.GSD_WINDSURF_HOOK_SCRIPTS;
408
413
  // two guards require so their copies cannot drift.
409
414
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js', 'injection-patterns.js'];
410
415
 
416
+ /**
417
+ * Every file GSD stages under `hooks/lib/` (#5335). The install copies `hooks/dist/lib/` wholesale (and the
418
+ * allow-listed names from `hooks/lib/`), so a fixed list drifts the moment a helper is added: the new file is
419
+ * staged but never removed, which also keeps `hooks/lib/` non-empty and blocks its rmdir. The set is the
420
+ * allow-list plus the top-level files of the directories the installer copies from, so uninstall and the
421
+ * manifest cannot disagree with install. Only these names are ever removed; a user's own helper beside them
422
+ * is left alone.
423
+ */
424
+ function gsdHookLibFiles() {
425
+ const names = new Set(GSD_HOOK_LIB_FILES);
426
+ for (const parts of [['hooks', 'dist', 'lib'], ['hooks', 'lib']]) {
427
+ try {
428
+ for (const entry of fs.readdirSync(path.join(__dirname, '..', ...parts), { withFileTypes: true })) {
429
+ if (entry.isFile()) names.add(entry.name);
430
+ }
431
+ } catch { /* the directory is absent from this package layout */ }
432
+ }
433
+ return [...names];
434
+ }
435
+
411
436
  /**
412
437
  * Directory name GSD stages its shared hook bundle under, inside a runtime's
413
438
  * install root. Defaults to 'hooks' — the name every runtime used before #3023.
@@ -762,8 +787,11 @@ const {
762
787
  discoverInstallerMigrations,
763
788
  MANIFEST_SCHEMA_VERSION,
764
789
  readInstallManifest,
790
+ recordHostGeneration,
765
791
  runInstallerMigrations,
766
792
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
793
+ const { setJsoncValue, removeJsoncValue, hasJsoncFeatures, parseJsonc } = require(path.join(_gsdLibDir, 'jsonc-edit.cjs'));
794
+ const { isGsdMcpEntry, GSD_MCP_NO_PROMPTS_FLAG } = require(path.join(_gsdLibDir, 'opencode-mcp-entry.cjs'));
767
795
  const {
768
796
  assertInstallerMigrationsUnblocked,
769
797
  resolveInstallerMigrationPromptsForNonTty,
@@ -1256,6 +1284,17 @@ const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
1256
1284
  // writeKimiHooksToml / removeKimiHooksToml: kimi's native config.toml [[hooks]]
1257
1285
  // surface (#2095 EoS/kimi Upgrade 1) — separate from settings.json entirely.
1258
1286
  const writeKimiHooksToml = hooksSurface.writeKimiHooksToml;
1287
+
1288
+ // #4840: false when the runtime declares `context-warnings` unsupported in its
1289
+ // descriptor (`hostBehaviors.unsupportedFeatures`). gsd-context-monitor.js reads
1290
+ // the metrics bridge file only the statusline hook writes, and the statusline
1291
+ // is wired for Claude Code alone, so on any other host the hook fires on every
1292
+ // tool event, finds no file and exits silently — forever. Such a host gets no
1293
+ // monitor registration (stale ones are stripped), as Codex does since #2586.
1294
+ function runtimeSupportsContextWarnings(runtime) {
1295
+ const unsupported = hostBehaviorsFor(runtime).unsupportedFeatures;
1296
+ return !(Array.isArray(unsupported) && unsupported.includes('context-warnings'));
1297
+ }
1259
1298
  const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
1260
1299
  // processAttribution: pure Co-Authored-By content transform, relocated to the
1261
1300
  // conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
@@ -6959,6 +6998,82 @@ function writeCopilotHookConfig(targetDir) {
6959
6998
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
6960
6999
  */
6961
7000
 
7001
+ // #5335: ~/.gsd/defaults.json is machine-wide, so uninstalling one runtime must not strip a default another
7002
+ // still needs, nor one the user chose. A sidecar records which keys an install SET (not ones it found), the
7003
+ // value it set, and which non-Claude runtimes have installed since; uninstall reverts a key only when its
7004
+ // last such runtime is gone and the value is still the one the installer wrote.
7005
+ const DEFAULTS_PROVENANCE_NAME = 'defaults-provenance.json';
7006
+
7007
+ function readDefaultsProvenance(gsdDir) {
7008
+ try {
7009
+ const p = JSON.parse(fs.readFileSync(path.join(gsdDir, DEFAULTS_PROVENANCE_NAME), 'utf8'));
7010
+ if (p && typeof p === 'object' && p.keys && typeof p.keys === 'object') return p;
7011
+ } catch { /* absent or unreadable */ }
7012
+ return { version: 1, keys: {} };
7013
+ }
7014
+
7015
+ function recordDefaultsProvenance(gsdDir, runtime, defaults, setNow) {
7016
+ const prov = readDefaultsProvenance(gsdDir);
7017
+ let changed = false;
7018
+ for (const key of ['resolve_model_ids', 'runtime']) {
7019
+ let entry = prov.keys[key];
7020
+ if (setNow[key]) {
7021
+ entry = { setValue: defaults[key], runtimes: [] };
7022
+ prov.keys[key] = entry;
7023
+ changed = true;
7024
+ }
7025
+ if (entry && Array.isArray(entry.runtimes) && !entry.runtimes.includes(runtime)) {
7026
+ entry.runtimes.push(runtime);
7027
+ changed = true;
7028
+ }
7029
+ }
7030
+ if (changed) atomicWriteFileSync(path.join(gsdDir, DEFAULTS_PROVENANCE_NAME), JSON.stringify(prov, null, 2) + '\n', 'utf8');
7031
+ }
7032
+
7033
+ /** @returns {boolean} true when it removed or reverted something */
7034
+ function revertNonClaudeDefaults(runtime) {
7035
+ if (hostBehaviorsFor(runtime).nativeModelAliases || process.env.GSD_TEST_MODE) return false;
7036
+ const gsdDir = path.join(os.homedir(), '.gsd');
7037
+ const provPath = path.join(gsdDir, DEFAULTS_PROVENANCE_NAME);
7038
+ if (!fs.existsSync(provPath)) return false;
7039
+ let releaseLock = null;
7040
+ try {
7041
+ releaseLock = acquireInstallMigrationLock(gsdDir);
7042
+ const prov = readDefaultsProvenance(gsdDir);
7043
+ const defaultsPath = path.join(gsdDir, 'defaults.json');
7044
+ let defaults = null;
7045
+ try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* absent or corrupt */ }
7046
+ const reverted = [];
7047
+ for (const [key, entry] of Object.entries(prov.keys)) {
7048
+ if (!entry || !Array.isArray(entry.runtimes)) { delete prov.keys[key]; continue; }
7049
+ entry.runtimes = entry.runtimes.filter((r) => r !== runtime);
7050
+ if (entry.runtimes.length > 0) continue;
7051
+ if (defaults && typeof defaults === 'object' && defaults[key] === entry.setValue) {
7052
+ delete defaults[key];
7053
+ reverted.push(key);
7054
+ }
7055
+ delete prov.keys[key];
7056
+ }
7057
+ if (reverted.length > 0 && defaults) {
7058
+ if (Object.keys(defaults).length === 0) fs.rmSync(defaultsPath, { force: true });
7059
+ else atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
7060
+ console.log(` ${green}✓${reset} Reverted ${reverted.join(', ')} in ~/.gsd/defaults.json (set by the ${runtime} install)`);
7061
+ }
7062
+ if (Object.keys(prov.keys).length === 0) fs.rmSync(provPath, { force: true });
7063
+ else atomicWriteFileSync(provPath, JSON.stringify(prov, null, 2) + '\n', 'utf8');
7064
+ return reverted.length > 0;
7065
+ } catch (e) {
7066
+ console.log(` ${yellow}⚠${reset} Could not update ~/.gsd/defaults.json: ${e.message}`);
7067
+ return false;
7068
+ } finally {
7069
+ if (releaseLock) {
7070
+ try { releaseLock(); } catch { /* the stale-lock reaper clears it */ }
7071
+ }
7072
+ // ~/.gsd is GSD's own directory; drop it once the lock is released and nothing else lives in it.
7073
+ try { fs.rmdirSync(gsdDir); } catch { /* other GSD state lives here */ }
7074
+ }
7075
+ }
7076
+
6962
7077
  /**
6963
7078
  * #2834: Write ~/.gsd/defaults.json for non-Claude runtimes — sets
6964
7079
  * resolve_model_ids="omit" (so resolveModelInternal() returns '' instead of
@@ -7005,6 +7120,10 @@ function writeNonClaudeDefaults(runtime) {
7005
7120
  atomicWriteFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n', 'utf8');
7006
7121
  for (const message of applied) console.log(` ${green}✓${reset} ${message}`);
7007
7122
  }
7123
+ recordDefaultsProvenance(gsdDir, runtime, defaults, {
7124
+ resolve_model_ids: shouldDefaultToOmit,
7125
+ runtime: applied.some((m) => m.startsWith('Set runtime:')),
7126
+ });
7008
7127
  } catch (e) {
7009
7128
  console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`);
7010
7129
  } finally {
@@ -7212,7 +7331,11 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
7212
7331
  const newLines = [];
7213
7332
  let inAllowedTools = false;
7214
7333
  let inSkippedArray = false;
7334
+ let inAgentTools = false;
7335
+ let inDisallowedTools = false;
7215
7336
  const allowedTools = [];
7337
+ const agentTools = [];
7338
+ const agentDisallowed = [];
7216
7339
 
7217
7340
  for (const line of lines) {
7218
7341
  const trimmed = line.trim();
@@ -7228,11 +7351,31 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
7228
7351
  continue;
7229
7352
  }
7230
7353
 
7354
+ // Agents: collect the `tools:` allowlist and `disallowedTools:` denylist (block-list continuation
7355
+ // lines) so they can be rendered as `permission:` entries below (mirrors
7356
+ // src/runtime-artifact-conversion.cts's convertClaudeToOpencodeFrontmatter, #5338).
7357
+ if (isAgent && (inAgentTools || inDisallowedTools)) {
7358
+ if (trimmed.startsWith('- ')) {
7359
+ const tool = runtimeArtifactConversion._decodeToolScalar(trimmed.substring(2));
7360
+ if (tool !== null) (inAgentTools ? agentTools : agentDisallowed).push(tool);
7361
+ continue;
7362
+ }
7363
+ if (trimmed && !trimmed.startsWith('-')) {
7364
+ inAgentTools = false;
7365
+ inDisallowedTools = false;
7366
+ }
7367
+ }
7368
+
7231
7369
  // Detect inline tools: field (comma-separated string)
7232
7370
  if (trimmed.startsWith('tools:')) {
7233
7371
  if (isAgent) {
7234
- // Agents: strip tools entirely (not supported in OpenCode agent frontmatter)
7235
- inSkippedArray = true;
7372
+ const toolsValue = trimmed.substring(6).trim();
7373
+ // A comment-only value (`tools: # note`) is not inline content: read the block list instead.
7374
+ if (toolsValue && !toolsValue.startsWith('#')) {
7375
+ agentTools.push(...runtimeArtifactConversion._splitToolScalars(toolsValue).map(runtimeArtifactConversion._decodeToolScalar).filter((tool) => tool !== null));
7376
+ } else {
7377
+ inAgentTools = true;
7378
+ }
7236
7379
  continue;
7237
7380
  }
7238
7381
  const toolsValue = trimmed.substring(6).trim();
@@ -7244,8 +7387,18 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
7244
7387
  continue;
7245
7388
  }
7246
7389
 
7247
- // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools:
7248
- if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) {
7390
+ if (isAgent && trimmed.startsWith('disallowedTools:')) {
7391
+ const value = trimmed.substring(16).trim();
7392
+ if (value && !value.startsWith('#')) {
7393
+ agentDisallowed.push(...runtimeArtifactConversion._splitToolScalars(value).map(runtimeArtifactConversion._decodeToolScalar).filter((tool) => tool !== null));
7394
+ } else {
7395
+ inDisallowedTools = true;
7396
+ }
7397
+ continue;
7398
+ }
7399
+
7400
+ // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:
7401
+ if (isAgent && /^(skills|color|memory|maxTurns|permissionMode):/.test(trimmed)) {
7249
7402
  inSkippedArray = true;
7250
7403
  continue;
7251
7404
  }
@@ -7318,6 +7471,7 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
7318
7471
  if (modelOverride) {
7319
7472
  newLines.push(`model: ${modelOverride}`);
7320
7473
  }
7474
+ newLines.push(...runtimeArtifactConversion._buildOpencodeAgentPermissionBlock(agentTools, agentDisallowed));
7321
7475
  }
7322
7476
 
7323
7477
  // For commands: add tools object if we had allowed-tools or tools
@@ -8239,7 +8393,7 @@ function reclaimKimiHooksRoot(kimiHooksRoot) {
8239
8393
  const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
8240
8394
  if (fs.existsSync(kimiHooksLibDir)) {
8241
8395
  let removedKimiLibFiles = 0;
8242
- for (const file of GSD_HOOK_LIB_FILES) {
8396
+ for (const file of gsdHookLibFiles()) {
8243
8397
  try {
8244
8398
  fs.unlinkSync(path.join(kimiHooksLibDir, file));
8245
8399
  removedKimiLibFiles++;
@@ -8875,7 +9029,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8875
9029
  const hooksLibDir = path.join(hooksDir, 'lib');
8876
9030
  if (fs.existsSync(hooksLibDir)) {
8877
9031
  let removedLibFiles = 0;
8878
- for (const file of GSD_HOOK_LIB_FILES) {
9032
+ for (const file of gsdHookLibFiles()) {
8879
9033
  const filePath = path.join(hooksLibDir, file);
8880
9034
  try {
8881
9035
  fs.unlinkSync(filePath);
@@ -8911,6 +9065,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8911
9065
  console.log(` ${green}✓${reset} Removed GSD hooks/package.json (CommonJS marker)`);
8912
9066
  }
8913
9067
  } catch { /* best-effort */ }
9068
+ // Once GSD's hooks, helpers and marker are gone, an empty hooks dir is GSD's own leftover (#5335).
9069
+ // rmdir fails on a non-empty dir, so a user's own hook scripts keep it in place.
9070
+ try { fs.rmdirSync(hooksDir); } catch { /* not empty or absent */ }
8914
9071
  }
8915
9072
 
8916
9073
  // 4z. Remove the native plugin adapter (#1914, extended to Kilo by #2093).
@@ -8944,6 +9101,20 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
8944
9101
  // unlink above failed, pulling the marker out from under a still-present
8945
9102
  // CommonJS adapter would leave it unloadable. The exact content match
8946
9103
  // still leaves any user-authored package.json in place.
9104
+ // The terminal-client plugin directory (OpenCode 2.x statusline): GSD's file, then its now-empty directory.
9105
+ const _cliU = hostBehaviorsFor(runtime).nativeCliPlugin;
9106
+ if (_cliU) {
9107
+ const cliFile = path.join(targetDir, _cliU.dir, _cliU.file);
9108
+ if (fs.existsSync(cliFile)) {
9109
+ try {
9110
+ fs.unlinkSync(cliFile);
9111
+ removedCount++;
9112
+ removedFromPluginsDir = true;
9113
+ console.log(` ${green}✓${reset} Removed terminal-client plugin (${runtime})`);
9114
+ } catch (_) { /* best-effort */ }
9115
+ }
9116
+ try { fs.rmdirSync(path.join(targetDir, _cliU.dir)); } catch (_) { /* not empty or absent */ }
9117
+ }
8947
9118
  if (!fs.existsSync(pluginPath) && removeCommonJsMarker(pluginsDir)) {
8948
9119
  removedCount++;
8949
9120
  removedFromPluginsDir = true;
@@ -9171,40 +9342,56 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9171
9342
  }
9172
9343
  }
9173
9344
 
9174
- // 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
9345
+ // 6. For OpenCode, clean up permissions and the companion MCP entry from opencode.json or opencode.jsonc
9175
9346
  if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
9176
9347
  const configPath = resolveOpencodeConfigPath(targetDir);
9177
9348
  if (fs.existsSync(configPath)) {
9178
9349
  try {
9179
- const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
9180
- let modified = false;
9350
+ const originalText = fs.readFileSync(configPath, 'utf8');
9351
+ const config = parseJsonc(originalText);
9352
+ const edits = [];
9181
9353
 
9182
9354
  // Remove GSD permission entries
9183
- if (config.permission) {
9355
+ if (config.permission && typeof config.permission === 'object') {
9184
9356
  for (const permType of ['read', 'external_directory']) {
9185
- if (config.permission[permType]) {
9186
- const keys = Object.keys(config.permission[permType]);
9187
- for (const key of keys) {
9188
- if (key.includes('gsd-core')) {
9189
- delete config.permission[permType][key];
9190
- modified = true;
9191
- }
9192
- }
9193
- // Clean up empty objects
9194
- if (Object.keys(config.permission[permType]).length === 0) {
9195
- delete config.permission[permType];
9357
+ const section = config.permission[permType];
9358
+ if (!section || typeof section !== 'object') continue;
9359
+ for (const key of Object.keys(section)) {
9360
+ if (key.includes('gsd-core')) {
9361
+ delete section[key];
9362
+ edits.push({ path: ['permission', permType, key], remove: true });
9196
9363
  }
9197
9364
  }
9365
+ // Clean up empty objects
9366
+ if (Object.keys(section).length === 0) delete config.permission[permType];
9198
9367
  }
9199
- if (Object.keys(config.permission).length === 0) {
9200
- delete config.permission;
9368
+ if (Object.keys(config.permission).length === 0) delete config.permission;
9369
+ }
9370
+
9371
+ // #5333: remove the companion MCP entry, but only while it is still the entry GSD wrote — a
9372
+ // user-edited or user-defined `gsd` server stays. Both OpenCode generations' shapes: the 1.x
9373
+ // `mcp.gsd` and the 2.x native `mcp.servers.gsd`.
9374
+ let mcpRemoved = false;
9375
+ if (config.mcp && typeof config.mcp === 'object') {
9376
+ if (isGsdMcpEntry(config.mcp.gsd)) {
9377
+ delete config.mcp.gsd;
9378
+ edits.push({ path: ['mcp', 'gsd'], remove: true });
9379
+ mcpRemoved = true;
9201
9380
  }
9381
+ const servers = config.mcp.servers;
9382
+ if (servers && typeof servers === 'object' && isGsdMcpEntry(servers.gsd)) {
9383
+ delete servers.gsd;
9384
+ edits.push({ path: ['mcp', 'servers', 'gsd'], remove: true });
9385
+ if (Object.keys(servers).length === 0) delete config.mcp.servers;
9386
+ mcpRemoved = true;
9387
+ }
9388
+ if (Object.keys(config.mcp).length === 0) delete config.mcp;
9202
9389
  }
9203
9390
 
9204
- if (modified) {
9205
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
9391
+ if (edits.length > 0) {
9392
+ writeJsoncConfig(configPath, originalText, config, edits);
9206
9393
  removedCount++;
9207
- console.log(` ${green}✓${reset} Removed GSD permissions from ${path.basename(configPath)}`);
9394
+ console.log(` ${green}✓${reset} Removed GSD permissions${mcpRemoved ? ' and MCP server' : ''} from ${path.basename(configPath)}`);
9208
9395
  }
9209
9396
  } catch (e) {
9210
9397
  // Ignore JSON parse errors
@@ -9219,8 +9406,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9219
9406
  const configPath = resolveKiloConfigPath(targetDir);
9220
9407
  if (fs.existsSync(configPath)) {
9221
9408
  try {
9222
- const config = parseJsonc(fs.readFileSync(configPath, 'utf8'));
9223
- let modified = false;
9409
+ const originalText = fs.readFileSync(configPath, 'utf8');
9410
+ const config = parseJsonc(originalText);
9411
+ const edits = [];
9224
9412
 
9225
9413
  // Remove GSD permission entries
9226
9414
  if (config.permission) {
@@ -9230,7 +9418,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9230
9418
  for (const key of keys) {
9231
9419
  if (key.includes('gsd-core')) {
9232
9420
  delete config.permission[permType][key];
9233
- modified = true;
9421
+ edits.push({ path: ['permission', permType, key], remove: true });
9234
9422
  }
9235
9423
  }
9236
9424
  // Clean up empty objects
@@ -9244,8 +9432,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9244
9432
  }
9245
9433
  }
9246
9434
 
9247
- if (modified) {
9248
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
9435
+ if (edits.length > 0) {
9436
+ writeJsoncConfig(configPath, originalText, config, edits);
9249
9437
  removedCount++;
9250
9438
  console.log(` ${green}✓${reset} Removed GSD permissions from ${path.basename(configPath)}`);
9251
9439
  }
@@ -9287,6 +9475,67 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9287
9475
  console.log(` ${green}✓${reset} Removed ${MANIFEST_NAME}`);
9288
9476
  }
9289
9477
 
9478
+ // #5335: the installer's own bookkeeping goes with it — the applied-migrations state and the migration
9479
+ // journal. The journal's `-backups/` folders hold copies of files the USER had modified, so a folder that
9480
+ // still has content is kept; everything GSD generated for itself (run records, rollback snapshots, empty
9481
+ // folders) is removed.
9482
+ const installStatePath = path.join(targetDir, 'gsd-install-state.json');
9483
+ if (fs.existsSync(installStatePath)) {
9484
+ fs.rmSync(installStatePath, { force: true });
9485
+ removedCount++;
9486
+ console.log(` ${green}✓${reset} Removed gsd-install-state.json`);
9487
+ }
9488
+ const journalDir = path.join(targetDir, 'gsd-migration-journal');
9489
+ if (fs.existsSync(journalDir)) {
9490
+ let journalEntriesRemoved = 0;
9491
+ try {
9492
+ for (const entry of fs.readdirSync(journalDir, { withFileTypes: true })) {
9493
+ const full = path.join(journalDir, entry.name);
9494
+ if (entry.isSymbolicLink()) continue;
9495
+ if (entry.isFile() && entry.name.endsWith('.json')) {
9496
+ fs.rmSync(full, { force: true });
9497
+ journalEntriesRemoved++;
9498
+ } else if (entry.isDirectory() && entry.name.endsWith('-rollback')) {
9499
+ fs.rmSync(full, { recursive: true, force: true });
9500
+ journalEntriesRemoved++;
9501
+ } else if (entry.isDirectory() && entry.name.endsWith('-backups')) {
9502
+ try { fs.rmdirSync(full); journalEntriesRemoved++; } catch { /* holds the user's backed-up files: keep */ }
9503
+ }
9504
+ }
9505
+ fs.rmdirSync(journalDir);
9506
+ } catch { /* a non-empty journal dir (kept backups) stays */ }
9507
+ if (journalEntriesRemoved > 0) {
9508
+ removedCount++;
9509
+ console.log(` ${green}✓${reset} Removed gsd-migration-journal/ records`);
9510
+ }
9511
+ }
9512
+
9513
+ // #5335: the installer's user-artifact staging area is empty once the install it served is over. rmdir only
9514
+ // succeeds on an empty directory, so anything still staged there is left for its owner.
9515
+ try {
9516
+ fs.rmdirSync(path.join(targetDir, '.gsd-staging', 'user-artifacts'));
9517
+ fs.rmdirSync(path.join(targetDir, '.gsd-staging'));
9518
+ } catch { /* absent or not empty */ }
9519
+
9520
+ // #5335: OpenCode never reads settings.json, but the shared-settings install step creates it as `{}`.
9521
+ // An empty one carries nothing of the user's; a populated one is left alone.
9522
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
9523
+ const emptySettingsPath = path.join(targetDir, 'settings.json');
9524
+ try {
9525
+ const parsed = JSON.parse(fs.readFileSync(emptySettingsPath, 'utf8'));
9526
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
9527
+ fs.rmSync(emptySettingsPath, { force: true });
9528
+ removedCount++;
9529
+ console.log(` ${green}✓${reset} Removed empty settings.json`);
9530
+ }
9531
+ } catch { /* absent, unreadable or populated: leave it */ }
9532
+ }
9533
+
9534
+ // #5335: the machine-wide defaults the install wrote for this runtime, when nothing else needs them.
9535
+ if (revertNonClaudeDefaults(runtime)) {
9536
+ removedCount++;
9537
+ }
9538
+
9290
9539
  if (removedCount === 0) {
9291
9540
  console.log(` ${yellow}⚠${reset} No GSD files found to remove.`);
9292
9541
  }
@@ -9298,64 +9547,30 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
9298
9547
  }
9299
9548
 
9300
9549
  /**
9301
- * Parse JSONC (JSON with Comments) by stripping comments and trailing commas.
9302
- * OpenCode supports JSONC format via jsonc-parser, so users may have comments.
9303
- * This is a lightweight inline parser to avoid adding dependencies.
9304
- */
9305
- function parseJsonc(content) {
9306
- // Strip BOM if present
9307
- if (content.charCodeAt(0) === 0xFEFF) {
9308
- content = content.slice(1);
9309
- }
9310
-
9311
- // Remove single-line and block comments while preserving strings
9312
- let result = '';
9313
- let inString = false;
9314
- let i = 0;
9315
- while (i < content.length) {
9316
- const char = content[i];
9317
- const next = content[i + 1];
9318
-
9319
- if (inString) {
9320
- result += char;
9321
- // Handle escape sequences
9322
- if (char === '\\' && i + 1 < content.length) {
9323
- result += next;
9324
- i += 2;
9325
- continue;
9326
- }
9327
- if (char === '"') {
9328
- inString = false;
9329
- }
9330
- i++;
9331
- } else {
9332
- if (char === '"') {
9333
- inString = true;
9334
- result += char;
9335
- i++;
9336
- } else if (char === '/' && next === '/') {
9337
- // Skip single-line comment until end of line
9338
- while (i < content.length && content[i] !== '\n') {
9339
- i++;
9340
- }
9341
- } else if (char === '/' && next === '*') {
9342
- // Skip block comment
9343
- i += 2;
9344
- while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
9345
- i++;
9346
- }
9347
- i += 2; // Skip closing */
9348
- } else {
9349
- result += char;
9350
- i++;
9351
- }
9550
+ * Write back an OpenCode/Kilo config the installer changed (#5334).
9551
+ *
9552
+ * A file that carries JSONC syntax (a `.jsonc` name, comments or trailing commas) is edited as text, so
9553
+ * only the keys GSD owns change and the user's annotations and layout survive. Anything the text editor
9554
+ * cannot handle, and plain JSON, keeps the previous `JSON.stringify` rewrite.
9555
+ *
9556
+ * @param {string} configPath
9557
+ * @param {string|null} originalText current file text, or null when the file is new
9558
+ * @param {object} config the mutated parsed config (used for the fallback)
9559
+ * @param {Array<{path: string[], value?: unknown, remove?: boolean}>} edits
9560
+ */
9561
+ function writeJsoncConfig(configPath, originalText, config, edits) {
9562
+ if (typeof originalText === 'string' && (configPath.endsWith('.jsonc') || hasJsoncFeatures(originalText))) {
9563
+ let text = originalText;
9564
+ for (const edit of edits) {
9565
+ text = edit.remove ? removeJsoncValue(text, edit.path) : setJsoncValue(text, edit.path, edit.value);
9566
+ if (text === null) break;
9567
+ }
9568
+ if (text !== null) {
9569
+ fs.writeFileSync(configPath, text);
9570
+ return;
9352
9571
  }
9353
9572
  }
9354
-
9355
- // Remove trailing commas before } or ]
9356
- result = result.replace(/,(\s*[}\]])/g, '$1');
9357
-
9358
- return JSON.parse(result);
9573
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
9359
9574
  }
9360
9575
 
9361
9576
  /**
@@ -9377,9 +9592,11 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9377
9592
 
9378
9593
  // Read existing config or create empty object
9379
9594
  let config = {};
9595
+ let originalText = null;
9380
9596
  if (fs.existsSync(configPath)) {
9381
9597
  try {
9382
9598
  const content = fs.readFileSync(configPath, 'utf8');
9599
+ originalText = content;
9383
9600
  config = parseJsonc(content);
9384
9601
  } catch (e) {
9385
9602
  // Cannot parse - DO NOT overwrite user's config
@@ -9410,6 +9627,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9410
9627
  : `${opencodeConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
9411
9628
 
9412
9629
  let modified = false;
9630
+ const edits = [];
9413
9631
 
9414
9632
  // Configure read permission
9415
9633
  if (!config.permission.read || typeof config.permission.read !== 'object') {
@@ -9417,6 +9635,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9417
9635
  }
9418
9636
  if (config.permission.read[gsdPath] !== 'allow') {
9419
9637
  config.permission.read[gsdPath] = 'allow';
9638
+ edits.push({ path: ['permission', 'read', gsdPath], value: 'allow' });
9420
9639
  modified = true;
9421
9640
  }
9422
9641
 
@@ -9426,6 +9645,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9426
9645
  }
9427
9646
  if (config.permission.external_directory[gsdPath] !== 'allow') {
9428
9647
  config.permission.external_directory[gsdPath] = 'allow';
9648
+ edits.push({ path: ['permission', 'external_directory', gsdPath], value: 'allow' });
9429
9649
  modified = true;
9430
9650
  }
9431
9651
 
@@ -9442,9 +9662,10 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9442
9662
  if (config.mcp.gsd === undefined) {
9443
9663
  config.mcp.gsd = {
9444
9664
  type: 'local',
9445
- command: ['npx', '-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'],
9665
+ command: ['npx', '-y', '-p', PACKAGE_NAME, 'gsd-mcp-server', GSD_MCP_NO_PROMPTS_FLAG],
9446
9666
  enabled: true,
9447
9667
  };
9668
+ edits.push({ path: ['mcp', 'gsd'], value: config.mcp.gsd });
9448
9669
  modified = true;
9449
9670
  }
9450
9671
 
@@ -9453,7 +9674,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
9453
9674
  }
9454
9675
 
9455
9676
  // Write config back
9456
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
9677
+ writeJsoncConfig(configPath, originalText, config, edits);
9457
9678
  console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
9458
9679
  }
9459
9680
 
@@ -9476,9 +9697,11 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
9476
9697
 
9477
9698
  // Read existing config or create empty object
9478
9699
  let config = {};
9700
+ let originalText = null;
9479
9701
  if (fs.existsSync(configPath)) {
9480
9702
  try {
9481
9703
  const content = fs.readFileSync(configPath, 'utf8');
9704
+ originalText = content;
9482
9705
  config = parseJsonc(content);
9483
9706
  } catch (e) {
9484
9707
  // Cannot parse - DO NOT overwrite user's config
@@ -9503,6 +9726,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
9503
9726
  : `${kiloConfigDir.replace(/\\/g, '/')}/gsd-core/*`;
9504
9727
 
9505
9728
  let modified = false;
9729
+ const edits = [];
9506
9730
 
9507
9731
  // Configure read permission
9508
9732
  if (!config.permission.read || typeof config.permission.read !== 'object') {
@@ -9510,6 +9734,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
9510
9734
  }
9511
9735
  if (config.permission.read[gsdPath] !== 'allow') {
9512
9736
  config.permission.read[gsdPath] = 'allow';
9737
+ edits.push({ path: ['permission', 'read', gsdPath], value: 'allow' });
9513
9738
  modified = true;
9514
9739
  }
9515
9740
 
@@ -9519,6 +9744,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
9519
9744
  }
9520
9745
  if (config.permission.external_directory[gsdPath] !== 'allow') {
9521
9746
  config.permission.external_directory[gsdPath] = 'allow';
9747
+ edits.push({ path: ['permission', 'external_directory', gsdPath], value: 'allow' });
9522
9748
  modified = true;
9523
9749
  }
9524
9750
 
@@ -9527,7 +9753,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
9527
9753
  }
9528
9754
 
9529
9755
  // Write config back
9530
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
9756
+ writeJsoncConfig(configPath, originalText, config, edits);
9531
9757
  console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
9532
9758
  }
9533
9759
 
@@ -10012,8 +10238,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
10012
10238
  // to git-cmd.js (validate-commit classifier) and gsd-graphify-rebuild.sh.
10013
10239
  const hooksLibDir = path.join(hooksDir, 'lib');
10014
10240
  if (fs.existsSync(hooksLibDir)) {
10241
+ const hookLibNames = new Set(gsdHookLibFiles());
10015
10242
  for (const file of fs.readdirSync(hooksLibDir)) {
10016
- if (GSD_HOOK_LIB_FILES.includes(file)) {
10243
+ if (hookLibNames.has(file)) {
10017
10244
  manifest.files[sharedHooksDirName + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
10018
10245
  }
10019
10246
  }
@@ -10063,6 +10290,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
10063
10290
  manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
10064
10291
  }
10065
10292
  }
10293
+ // The OpenCode 2.x terminal-client plugin (statusline / update banner) is tracked beside it.
10294
+ const _cliM = hostBehaviorsFor(runtime).nativeCliPlugin;
10295
+ if (_cliM) {
10296
+ const cliInstallPath = path.join(configDir, _cliM.dir, _cliM.file);
10297
+ if (fs.existsSync(cliInstallPath)) {
10298
+ manifest.files[`${_cliM.dir}/${_cliM.file}`] = fileHash(cliInstallPath);
10299
+ }
10300
+ }
10066
10301
 
10067
10302
  fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
10068
10303
  return manifest;
@@ -13024,7 +13259,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13024
13259
  }
13025
13260
  const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
13026
13261
  const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
13027
- const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
13262
+ const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts, contextWarnings: runtimeSupportsContextWarnings(runtime) });
13028
13263
  configuredEntrypoints.push(...kimiHooksResult.configuredEntrypoints);
13029
13264
  if (kimiHooksResult.changed) {
13030
13265
  console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
@@ -13306,6 +13541,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13306
13541
  const updateCheckCommand = isGlobal
13307
13542
  ? buildHookCommand(targetDir, 'gsd-check-update.js', hookOpts)
13308
13543
  : localCmd('gsd-check-update.js');
13544
+ // #4840: no metrics writer on this host -> no monitor to register (see runtimeSupportsContextWarnings).
13545
+ const contextWarnings = runtimeSupportsContextWarnings(runtime);
13546
+ // The command stays built: the extended lifecycle events (SubagentStop, PreCompact, ...) still route to it.
13309
13547
  const contextMonitorCommand = isGlobal
13310
13548
  ? buildHookCommand(targetDir, 'gsd-context-monitor.js', hookOpts)
13311
13549
  : localCmd('gsd-context-monitor.js');
@@ -13350,6 +13588,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13350
13588
  hooksSurface: plan.hooksSurface,
13351
13589
  updateCheckCommand,
13352
13590
  contextMonitorCommand,
13591
+ contextWarnings,
13353
13592
  promptGuardCommand,
13354
13593
  readGuardCommand,
13355
13594
  readInjectionScannerCommand,
@@ -13358,6 +13597,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
13358
13597
  localCmd,
13359
13598
  localShellCmd,
13360
13599
  });
13600
+ if (!contextWarnings) {
13601
+ console.log(` ${dim}↳${reset} ${getRuntimeLabel(runtime)}: agent-facing context warnings are unsupported (no statusline writes the context metrics the monitor reads), so the context monitor hook is not registered`);
13602
+ }
13361
13603
 
13362
13604
  // Compute the update-banner hook command alongside the others so
13363
13605
  // installAllRuntimes can register it at finalize time when the user opts
@@ -13626,6 +13868,31 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
13626
13868
  // Configure OpenCode permissions
13627
13869
  if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
13628
13870
  configureOpencodePermissions(isGlobal, configDir);
13871
+ // ADR-5331: say which OpenCode generation this install will run under. Informational only —
13872
+ // a failed probe never affects the install.
13873
+ try {
13874
+ const ocv = require('../gsd-core/bin/lib/opencode-version.cjs');
13875
+ const gen = ocv.detectOpencodeGeneration();
13876
+ if (gen.generation !== null && typeof configDir === 'string' && configDir) {
13877
+ recordHostGeneration(configDir, { generation: gen.generation, version: gen.version });
13878
+ }
13879
+ if (gen.generation === 2) {
13880
+ console.log(` ${green}✓${reset} OpenCode ${gen.version || '2.x'} detected — GSD loads its OpenCode 2.x plugin entrypoint`);
13881
+ } else if (gen.generation === 1) {
13882
+ console.log(` ${green}✓${reset} OpenCode ${gen.version || '1.x'} detected — GSD loads its OpenCode 1.x plugin entrypoint`);
13883
+ }
13884
+ // ADR-5331 D6 / E8: OpenCode 2.x migrates its data directory one way. Say so before it happens, and say
13885
+ // when 1.x is installed over a data directory 2.x already migrated (1.x cannot open it).
13886
+ const dataDir = ocv.resolveOpencodeDataDir();
13887
+ const notice = ocv.describeDataDirNotice(gen.generation, ocv.classifyOpencodeDatabase(path.join(dataDir, 'opencode.db')), dataDir);
13888
+ if (notice) {
13889
+ console.log(notice.level === 'warn' ? ` ${yellow}⚠${reset} ${notice.text}` : ` ${dim}${notice.text}${reset}`);
13890
+ }
13891
+ // Phase 3 (#5339): OpenCode 1.x is bug-fix-only until the freeze date. The `opencode` runtime only —
13892
+ // this block does not run for Kilo.
13893
+ const deprecation = ocv.describeDeprecationNotice(gen.generation);
13894
+ if (deprecation) console.log(` ${yellow}⚠${reset} ${deprecation}`);
13895
+ } catch { /* detection is advisory */ }
13629
13896
  }
13630
13897
 
13631
13898
  // Configure Kilo permissions