makdoong2-team 1.3.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/agents/makdoong2-analyzer.md +135 -0
  4. package/agents/makdoong2-engineer.md +165 -0
  5. package/agents/makdoong2-planner.md +267 -0
  6. package/agents/makdoong2-publisher.md +481 -0
  7. package/agents/makdoong2-team-leader.md +249 -0
  8. package/agents/makdoong2-verifier.md +353 -0
  9. package/assets/makdoong2-team.default.json +46 -0
  10. package/assets/makdoong2-team.schema.json +357 -0
  11. package/bin/cli.js +404 -0
  12. package/dist/agent-stage-config.d.ts +15 -0
  13. package/dist/agent-stage-config.js +119 -0
  14. package/dist/config.d.ts +79 -0
  15. package/dist/config.js +96 -0
  16. package/dist/logger.d.ts +14 -0
  17. package/dist/logger.js +131 -0
  18. package/dist/mcp-secret-injector.d.ts +56 -0
  19. package/dist/mcp-secret-injector.js +89 -0
  20. package/dist/model-chain-cli.d.ts +1 -0
  21. package/dist/model-chain-cli.js +21 -0
  22. package/dist/model-fallback-policy.d.ts +69 -0
  23. package/dist/model-fallback-policy.js +211 -0
  24. package/dist/opencode-plugin.d.ts +8 -0
  25. package/dist/opencode-plugin.js +2457 -0
  26. package/dist/poll-sub-session.d.ts +139 -0
  27. package/dist/poll-sub-session.js +494 -0
  28. package/dist/redact-secrets.d.ts +3 -0
  29. package/dist/redact-secrets.js +68 -0
  30. package/dist/session-index.d.ts +11 -0
  31. package/dist/session-index.js +71 -0
  32. package/dist/skill-mcp-registry.d.ts +59 -0
  33. package/dist/skill-mcp-registry.js +178 -0
  34. package/dist/stall-escalation.d.ts +1 -0
  35. package/dist/stall-escalation.js +22 -0
  36. package/dist/tmux-monitor.d.ts +193 -0
  37. package/dist/tmux-monitor.js +694 -0
  38. package/dist/verdict-hash.d.ts +1 -0
  39. package/dist/verdict-hash.js +62 -0
  40. package/gates/stage-analysis-verify.sh +84 -0
  41. package/gates/stage2-requirements-verify.sh +13 -0
  42. package/gates/stage3-scope-verify.sh +45 -0
  43. package/gates/stage4-dev-post-verify.sh +64 -0
  44. package/gates/stage4-dev-verify.sh +36 -0
  45. package/gates/stage5-coverage-verify.sh +36 -0
  46. package/gates/stage5-test-verify.sh +24 -0
  47. package/gates/stage6-commit-verify.sh +41 -0
  48. package/gates/stage6-post-commit-verify.sh +131 -0
  49. package/gates/stage7-post-pr-verify.sh +53 -0
  50. package/gates/stage7-pr-verify.sh +48 -0
  51. package/gates/stage8-post-review-verify.sh +84 -0
  52. package/gates/stage8-review-verify.sh +45 -0
  53. package/gates/verify.sh +44 -0
  54. package/opencode.json.example +40 -0
  55. package/package.json +84 -0
  56. package/postinstall.mjs +56 -0
  57. package/references/commit-convention.md +130 -0
  58. package/references/jira-issue-templates.md +203 -0
  59. package/references/pr-template.md +381 -0
  60. package/scripts/config.sh +46 -0
  61. package/scripts/coverage-record.sh +67 -0
  62. package/scripts/gate-policy-test.sh +152 -0
  63. package/scripts/install-lib.mjs +1029 -0
  64. package/scripts/lint-agent-prompts.sh +74 -0
  65. package/scripts/log-event.sh +44 -0
  66. package/scripts/model-policy.mjs +183 -0
  67. package/scripts/publish-if-changed.sh +207 -0
  68. package/scripts/release.sh +276 -0
  69. package/scripts/rollback-commits.sh +35 -0
  70. package/scripts/smoke-test.mjs +194 -0
  71. package/scripts/state.sh +192 -0
  72. package/scripts/test-postinstall.mjs +141 -0
  73. package/scripts/with-fallback.sh +56 -0
  74. package/scripts/wt-sync-ignored.sh +193 -0
  75. package/skills/_lib/load-secret.sh +149 -0
  76. package/skills/bamboo-ci/SKILL.md +81 -0
  77. package/skills/bamboo-ci/run-bamboo.sh +23 -0
  78. package/skills/bitbucket-research/SKILL.md +87 -0
  79. package/skills/bitbucket-research/run-repos.sh +23 -0
  80. package/skills/confluence-research/SKILL.md +75 -0
  81. package/skills/confluence-research/run-docs.sh +23 -0
  82. package/skills/github-oss-research/SKILL.md +59 -0
  83. package/skills/jira-research/SKILL.md +75 -0
  84. package/skills/jira-research/run-works.sh +23 -0
  85. package/src/hooks/guard-bash.sh +67 -0
  86. package/src/hooks/session-start.sh +96 -0
  87. package/src/hooks/sync-state.sh +47 -0
  88. package/stages/01-jira.md +43 -0
  89. package/stages/01-planning.md +229 -0
  90. package/stages/02-requirements.md +298 -0
  91. package/stages/03-scope.md +81 -0
  92. package/stages/04-analysis.md +281 -0
  93. package/stages/05-worktree-dev.md +124 -0
  94. package/stages/06-test.md +161 -0
  95. package/stages/07-commit.md +229 -0
  96. package/stages/08-pr.md +177 -0
  97. package/stages/09-review-comments.md +277 -0
@@ -0,0 +1,1029 @@
1
+ // scripts/install-lib.mjs — Reusable install logic for CLI and postinstall.
2
+ //
3
+ // Extracted from bin/cli.js doInstall() to enable both:
4
+ // 1) Manual install via `makdoong2-team install` (CLI)
5
+ // 2) Automatic install via `npm install -g` (postinstall.mjs)
6
+ //
7
+ // Single source of truth for deployment logic.
8
+
9
+ import {
10
+ cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, rmSync,
11
+ symlinkSync, lstatSync, renameSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join, dirname, resolve } from "node:path";
15
+
16
+ // opencode parses its config as JSONC — comments and trailing commas are legal
17
+ // there. Using strict JSON.parse here silently skipped the whole opencode.json
18
+ // patch (plugin ref, tools, permissions) for anyone whose config had a trailing
19
+ // comma, so `install` reported success while deploying nothing. These two
20
+ // scanners strip JSONC syntax outside of string literals before parsing.
21
+ function stripJsoncComments(text) {
22
+ let out = "";
23
+ let inString = false, escaped = false, inLine = false, inBlock = false;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const c = text[i], n = text[i + 1];
26
+ if (inLine) {
27
+ if (c === "\n") { inLine = false; out += c; }
28
+ continue;
29
+ }
30
+ if (inBlock) {
31
+ if (c === "*" && n === "/") { inBlock = false; i++; }
32
+ continue;
33
+ }
34
+ if (inString) {
35
+ out += c;
36
+ if (escaped) escaped = false;
37
+ else if (c === "\\") escaped = true;
38
+ else if (c === '"') inString = false;
39
+ continue;
40
+ }
41
+ if (c === '"') { inString = true; out += c; continue; }
42
+ if (c === "/" && n === "/") { inLine = true; i++; continue; }
43
+ if (c === "/" && n === "*") { inBlock = true; i++; continue; }
44
+ out += c;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function stripTrailingCommas(text) {
50
+ let out = "";
51
+ let inString = false, escaped = false;
52
+ for (let i = 0; i < text.length; i++) {
53
+ const c = text[i];
54
+ if (inString) {
55
+ out += c;
56
+ if (escaped) escaped = false;
57
+ else if (c === "\\") escaped = true;
58
+ else if (c === '"') inString = false;
59
+ continue;
60
+ }
61
+ if (c === '"') { inString = true; out += c; continue; }
62
+ if (c === ",") {
63
+ let j = i + 1;
64
+ while (j < text.length && /\s/.test(text[j])) j++;
65
+ if (text[j] === "}" || text[j] === "]") continue;
66
+ }
67
+ out += c;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function parseJsonc(text) {
73
+ return JSON.parse(stripTrailingCommas(stripJsoncComments(text)));
74
+ }
75
+
76
+ // Constants (extracted from bin/cli.js)
77
+ // Legacy plugin refs — kept for cleanup only. Two historical shapes exist:
78
+ // 1. Relative-path ref written when the plugin lived under
79
+ // ~/.config/opencode/plugins/makdoong2-team/.
80
+ // 2. Absolute-path ref pointing at src/opencode-plugin.ts inside the npm
81
+ // module (pre-dist-build era: opencode had to load raw TypeScript).
82
+ // Both are stripped from opencode.json on install and replaced with the npm
83
+ // package name (see currentPluginRef()) so the entry stays portable across
84
+ // nvm versions and reinstalls.
85
+ const LEGACY_RELATIVE_PLUGIN_REF = "./plugins/makdoong2-team/src/opencode-plugin.ts";
86
+ const LEGACY_ABSOLUTE_TS_SUFFIX = "/src/opencode-plugin.ts";
87
+ const LEGACY_SCOPED_NAME = "@local/makdoong2-team";
88
+ const PLUGIN_PACKAGE_NAME = "makdoong2-team";
89
+ const TOOLS = ["verify_stage", "dispatch_stage", "dispatch_verifier", "auto_advance_stage", "get_fallback_model"];
90
+ const TOOL_SEARCH_PLUGIN_PREFIX = "opencode-tool-search";
91
+ // UTIL_SCRIPTS is no longer deployed (scripts live inside the npm module).
92
+ // Retained for cleanup: older installs copied these into ~/.config/opencode/scripts/
93
+ // and we need the list to remove them without touching unrelated user scripts.
94
+ const UTIL_SCRIPTS = ["state.sh", "rollback-commits.sh", "wt-sync-ignored.sh", "log-event.sh", "config.sh", "model-policy.mjs"];
95
+ const RESEARCH_SKILLS = ["jira-research", "confluence-research", "bitbucket-research", "github-oss-research", "bamboo-ci"];
96
+
97
+ // Stale config-dir artifacts left behind by pre-refactor installs. Removed
98
+ // (with backup) at the start of install() so a re-run cleanly migrates users
99
+ // to the npm-module-based layout.
100
+ const STALE_PATHS = [
101
+ "plugins/makdoong2-team",
102
+ "gates",
103
+ "stages",
104
+ "references",
105
+ "bin/with-fallback.sh",
106
+ "makdoong2-team.schema.json",
107
+ ];
108
+
109
+ /**
110
+ * Plugin entry as written to opencode.json. Uses the npm package name so
111
+ * opencode's loader resolves it via Node's standard module resolution
112
+ * (package.json main/exports → dist/opencode-plugin.js). Matches the format
113
+ * used by opencode-claude-auth, opencode-tool-search, oh-my-openagent.
114
+ *
115
+ * pkgRoot is accepted for signature stability with older callers but is not
116
+ * consulted — the ref is portable across install locations.
117
+ * @param {string} _pkgRoot - Kept for backward-compatible signature.
118
+ * @returns {string}
119
+ */
120
+ function pluginRef(_pkgRoot) {
121
+ return PLUGIN_PACKAGE_NAME;
122
+ }
123
+
124
+ /**
125
+ * True when the entry is a legacy plugin ref that must be stripped from
126
+ * opencode.json before the current package-name ref is inserted. Recognises:
127
+ * 1. Old relative path from the pre-npm-module install layout.
128
+ * 2. Pre-dist absolute path pointing at src/opencode-plugin.ts.
129
+ * 3. Old @local-scoped npm name used while the package lived on the internal
130
+ * Artifactory, before it was published to the public npm registry.
131
+ * 4. Version-pinned form of the current name ("makdoong2-team@1.2.0"), which
132
+ * would freeze the plugin on one version across upgrades.
133
+ * Cases 3 and 4 accept both the bare name and the "name@version" suffix form.
134
+ * @param {unknown} entry - Element from opencode.json plugin[]
135
+ * @returns {boolean}
136
+ */
137
+ function isLegacyPluginEntry(entry) {
138
+ if (typeof entry !== "string") return false;
139
+ if (entry === LEGACY_RELATIVE_PLUGIN_REF) return true;
140
+ if (entry.endsWith(LEGACY_ABSOLUTE_TS_SUFFIX)) return true;
141
+ if (entry === LEGACY_SCOPED_NAME) return true;
142
+ if (entry.startsWith(`${LEGACY_SCOPED_NAME}@`)) return true;
143
+ if (entry.startsWith(`${PLUGIN_PACKAGE_NAME}@`)) return true;
144
+ return false;
145
+ }
146
+
147
+ // Legacy per-skill secrets.env directories. Install backs up and removes
148
+ // these files on sight — credentials now live only in makdoong2-team.json
149
+ // .secrets.*, and the shipping runtime never reads secrets.env. No value
150
+ // migration: users on the new install (re)enter tokens directly in the JSON.
151
+ const LEGACY_SECRET_SKILL_DIRS = [
152
+ "bitbucket-research",
153
+ "jira-research",
154
+ "confluence-research",
155
+ "bamboo-ci",
156
+ ];
157
+
158
+ // Legacy glob previously written into opencode.json permission.read so the
159
+ // UI would not prompt when a skill sourced its own secrets.env. Retained here
160
+ // so cleanup can strip stale entries.
161
+ const LEGACY_SKILL_SECRETS_GLOB = "~/.config/opencode/skills/*/secrets.env";
162
+
163
+ // Stale external_directory glob written by pre-0.13.5 installs. The single *
164
+ // doesn't match multi-segment paths, and even ** cannot traverse dot-directories
165
+ // (.nvm, .config) in opencode's glob engine. Retained here only for cleanup.
166
+ // Replaced by absolute-path entries computed from pkgRoot and configDir at
167
+ // install time (see computeExternalDirPaths).
168
+ const LEGACY_PKG_EXTERNAL_DIR_GLOB = "*/@local/makdoong2-team/**";
169
+
170
+ // Names of the four secret keys the schema recognises. Used to seed the
171
+ // scaffolding when an existing makdoong2-team.json is missing a `secrets` key.
172
+ const SECRET_KEYS = [
173
+ "BITBUCKET_API_TOKEN",
174
+ "JIRA_API_TOKEN",
175
+ "CONFLUENCE_API_TOKEN",
176
+ "BAMBOO_TOKEN",
177
+ ];
178
+
179
+ // Default logging config values seeded when keys are missing from an existing
180
+ // makdoong2-team.json. Kept in sync with assets/makdoong2-team.default.json.
181
+ const DEFAULT_LOGGING_LEVEL = "error";
182
+ const DEFAULT_LOGGING_MODE = "stdin";
183
+ const DEFAULT_LOGGING_PATH = null;
184
+
185
+ /**
186
+ * Resolve the opencode config directory.
187
+ * @param {string|undefined} flag - Optional explicit path from CLI --config flag
188
+ * @returns {string} Absolute path to config directory
189
+ */
190
+ export function resolveConfigDir(flag) {
191
+ if (flag) return resolve(flag);
192
+
193
+ const base = (process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim())
194
+ ? process.env.XDG_CONFIG_HOME
195
+ : join(homedir(), ".config");
196
+ return join(base, "opencode");
197
+ }
198
+
199
+ /**
200
+ * Deploy makdoong2-team to opencode config directory.
201
+ *
202
+ * Most runtime assets (plugin source, gates, stages, references, utility
203
+ * scripts, schema, with-fallback wrapper) now ship inside the npm module and
204
+ * are loaded from there directly — deploying them into ~/.config/opencode/
205
+ * created duplicate copies that drifted from the module and confused users
206
+ * about the source of truth. Only assets that opencode expects to find under
207
+ * its config dir by convention (agents, skills) are still copied.
208
+ *
209
+ * Phases:
210
+ * 0) Cleanup stale config-dir artifacts from pre-refactor installs (backup + rm)
211
+ * 1) Agents (opencode convention: ~/.config/opencode/agents/)
212
+ * 2) Skills (makdoong2-team entry + research skills)
213
+ * 3) Remove legacy per-skill secrets.env files (moved to makdoong2-team.json)
214
+ * 4) Config file (seed from default if absent, respect --force)
215
+ * 4b) Ensure .secrets scaffolding present in makdoong2-team.json
216
+ * 5) Patch opencode.json (add plugin ref + tools; strip legacy secrets glob)
217
+ *
218
+ * @param {Object} opts
219
+ * @param {string} opts.configDir - Absolute path to config dir (default: resolveConfigDir(undefined))
220
+ * @param {string} opts.pkgRoot - Absolute path to package root
221
+ * @param {boolean} [opts.force=false] - Overwrite makdoong2-team.json if exists
222
+ * @param {"idempotent"|"always"|"skip"} [opts.patchOpencode="idempotent"] - opencode.json patch strategy
223
+ * @param {Object} [opts.logger=console] - Console-like logger
224
+ * @returns {{deployed: string[], backedUp: string[], skipped: string[], warnings: string[]}}
225
+ */
226
+ export function install(opts) {
227
+ const {
228
+ configDir,
229
+ pkgRoot,
230
+ force = false,
231
+ patchOpencode = "idempotent",
232
+ logger = console,
233
+ } = opts;
234
+
235
+ const result = {
236
+ deployed: [],
237
+ backedUp: [],
238
+ skipped: [],
239
+ warnings: [],
240
+ };
241
+
242
+ const ok = (m) => { logger.log(` ✓ ${m}`); result.deployed.push(m); };
243
+ const info = (m) => logger.log(m);
244
+ const warn = (m) => { logger.warn(` ! ${m}`); result.warnings.push(m); };
245
+
246
+ function timestamp() {
247
+ return new Date().toISOString().replace(/[:.]/g, "-");
248
+ }
249
+
250
+ function backup(target) {
251
+ if (!existsSync(target)) return;
252
+ const bak = `${target}.bak.${timestamp()}`;
253
+ cpSync(target, bak, { recursive: true });
254
+ info(` backup: ${target} → ${bak}`);
255
+ result.backedUp.push(bak);
256
+ }
257
+
258
+ function copyInto(srcAbs, destAbs, { exec = false } = {}) {
259
+ mkdirSync(dirname(destAbs), { recursive: true });
260
+ cpSync(srcAbs, destAbs, { recursive: true });
261
+ if (exec) chmodSync(destAbs, 0o755);
262
+ }
263
+
264
+ function chmodShAll(dir) {
265
+ if (!existsSync(dir)) return;
266
+ for (const name of readdirSync(dir)) {
267
+ if (name.endsWith(".sh")) chmodSync(join(dir, name), 0o755);
268
+ }
269
+ }
270
+
271
+ function pruneStaleAgents(srcDir, destDir, { info }) {
272
+ if (!existsSync(destDir)) return;
273
+ const fresh = new Set(readdirSync(srcDir).filter(n => n.endsWith(".md")));
274
+ for (const name of readdirSync(destDir)) {
275
+ if (!name.endsWith(".md")) continue;
276
+ if (!name.startsWith("makdoong2-")) continue;
277
+ if (!fresh.has(name)) {
278
+ rmSync(join(destDir, name));
279
+ info(` pruned stale agent: ${name}`);
280
+ }
281
+ }
282
+ }
283
+
284
+ info(`[makdoong2-team] installing from ${pkgRoot} → ${configDir}`);
285
+
286
+ // 0) Cleanup stale config-dir artifacts from pre-refactor installs.
287
+ // Older versions of install() copied plugin source, gates, stages, references,
288
+ // utility scripts, the with-fallback wrapper and the config schema into
289
+ // ~/.config/opencode/. They now live inside the npm module, so stale copies
290
+ // in the config dir would shadow the current versions or drift out of sync.
291
+ // Backup each existing entry then remove it so a subsequent install run leaves
292
+ // the config dir with only agents/skills/config/opencode.json.
293
+ info("Cleaning up stale files from previous install...");
294
+ for (const rel of STALE_PATHS) {
295
+ const target = join(configDir, rel);
296
+ if (!existsSync(target)) continue;
297
+ backup(target);
298
+ rmSync(target, { recursive: true, force: true });
299
+ info(` removed stale: ${target}`);
300
+ }
301
+ // `scripts/` needs special handling: it may contain non-makdoong2 scripts
302
+ // that other tools installed into the shared config dir. Only remove the
303
+ // known UTIL_SCRIPTS entries; leave everything else alone.
304
+ const scriptsDir = join(configDir, "scripts");
305
+ if (existsSync(scriptsDir)) {
306
+ for (const name of UTIL_SCRIPTS) {
307
+ const s = join(scriptsDir, name);
308
+ if (!existsSync(s)) continue;
309
+ backup(s);
310
+ rmSync(s, { force: true });
311
+ info(` removed stale: ${s}`);
312
+ }
313
+ }
314
+
315
+ // Create directory structure — only agents/ and research skills/ are deployed.
316
+ // All other runtime assets (gates, scripts, stages, references) live in the
317
+ // npm module and are referenced via resolvePaths() in src/config.ts.
318
+ const skillDirs = RESEARCH_SKILLS.map((s) => `skills/${s}`);
319
+ for (const d of ["agents", ...skillDirs]) {
320
+ mkdirSync(join(configDir, d), { recursive: true });
321
+ }
322
+
323
+ // 1) Agents
324
+ // Prune stale agent definitions before deploy so removed agents do not linger.
325
+ // `cpSync({recursive:true})` only overwrites — it never deletes, which leaves
326
+ // pre-refactor agents like makdoong2-jira behind and confuses the team-leader.
327
+ pruneStaleAgents(join(pkgRoot, "agents"), join(configDir, "agents"), { info });
328
+ copyInto(join(pkgRoot, "agents"), join(configDir, "agents"));
329
+ ok("agent definitions");
330
+
331
+ // 2) Research skills only (no makdoong2-team skill — workflow is agent-only)
332
+ // Copy the shared _lib helpers (sourced by run-*.sh) first so downstream
333
+ // skill dirs can reference ../_lib/load-secret.sh.
334
+ const libSrc = join(pkgRoot, "skills", "_lib");
335
+ if (existsSync(libSrc)) {
336
+ const libDst = join(configDir, "skills", "_lib");
337
+ mkdirSync(libDst, { recursive: true });
338
+ for (const name of readdirSync(libSrc)) {
339
+ cpSync(join(libSrc, name), join(libDst, name));
340
+ if (name.endsWith(".sh")) chmodSync(join(libDst, name), 0o755);
341
+ }
342
+ }
343
+ // Research skills — MCP-backed investigation tools, NOT the workflow orchestrator.
344
+ for (const skill of RESEARCH_SKILLS) {
345
+ const src = join(pkgRoot, "skills", skill);
346
+ if (!existsSync(src)) continue;
347
+ const dst = join(configDir, "skills", skill);
348
+ mkdirSync(dst, { recursive: true });
349
+ for (const name of readdirSync(src)) {
350
+ cpSync(join(src, name), join(dst, name));
351
+ if (name.endsWith(".sh")) chmodSync(join(dst, name), 0o755);
352
+ }
353
+ }
354
+ ok("research skills (jira/confluence/bitbucket/github-oss/bamboo)");
355
+
356
+ // 3) Config file — seed from default ONLY if absent (never clobber user edits unless --force)
357
+ const cfgPath = join(configDir, "makdoong2-team.json");
358
+ if (!existsSync(cfgPath) || force) {
359
+ if (existsSync(cfgPath)) backup(cfgPath);
360
+ cpSync(join(pkgRoot, "assets/makdoong2-team.default.json"), cfgPath);
361
+ ok(`config → ${cfgPath}`);
362
+ } else {
363
+ info(` ! config exists, left as-is: ${cfgPath} (use --force to reset)`);
364
+ result.skipped.push(cfgPath);
365
+ }
366
+
367
+ // 3b) Ensure .secrets scaffolding exists in makdoong2-team.json so users
368
+ // upgrading from a pre-secrets version see the expected shape and can
369
+ // fill in tokens without hunting for the schema. Idempotent additive merge.
370
+ ensureSecretsScaffolding(cfgPath, { ok, info });
371
+
372
+ // 3b') Same additive merge for .logging so users upgrading from a
373
+ // pre-mode/path version see level+mode+path shape without manual editing.
374
+ ensureLoggingScaffolding(cfgPath, { ok, info });
375
+
376
+ // 3c) Back up and remove legacy per-skill secrets.env from previous installs.
377
+ // No value migration — users on the new layout enter tokens directly in
378
+ // makdoong2-team.json .secrets.*.
379
+ cleanupLegacySecretsEnv(configDir, { info, backup });
380
+
381
+ // 5) Patch opencode.json
382
+ if (patchOpencode !== "skip") {
383
+ patchOpencodeJson(configDir, pkgRoot, patchOpencode, { ok, info, warn, backup });
384
+ }
385
+
386
+ // 6) Seed opencode plugin cache directory.
387
+ // The package now lives on the public registry.npmjs.org, so opencode's
388
+ // internal npm client *can* fetch it — but that fetch resolves independently
389
+ // of the globally installed module and can leave the session running a
390
+ // different version than `npm ls -g` reports. We pre-populate
391
+ // ~/.cache/opencode/packages/makdoong2-team@latest/ with a symlink to the
392
+ // already-installed npm module so opencode skips the fetch and always loads
393
+ // exactly the version that was just installed (also keeps installs offline-safe).
394
+ seedOpencodeCache(pkgRoot, { ok, info, warn });
395
+
396
+ return result;
397
+ }
398
+
399
+ /**
400
+ * Patch opencode.json — add plugin ref + tools (seed from example if absent)
401
+ * @param {string} configDir - Config directory
402
+ * @param {string} pkgRoot - Package root
403
+ * @param {"idempotent"|"always"} strategy - Patch strategy
404
+ * @param {Object} helpers - Helper functions {ok, info, warn, backup}
405
+ */
406
+ function patchOpencodeJson(configDir, pkgRoot, strategy, { ok, info, warn, backup }) {
407
+ const ocPath = join(configDir, "opencode.json");
408
+ const currentPluginRef = pluginRef(pkgRoot);
409
+ let oc;
410
+
411
+ const requiredExtDirPaths = computeExternalDirPaths(pkgRoot, configDir);
412
+
413
+ if (existsSync(ocPath)) {
414
+ try {
415
+ oc = parseJsonc(readFileSync(ocPath, "utf8"));
416
+ } catch (err) {
417
+ throw new Error(
418
+ `opencode.json could not be parsed (even allowing JSONC comments and trailing commas): ${ocPath}\n` +
419
+ ` ${err.message}\n` +
420
+ ` Fix the syntax and re-run the install — continuing would silently leave the plugin unregistered.`
421
+ );
422
+ }
423
+
424
+ // Idempotent check: plugin ref + all tools enabled + alwaysLoad includes our
425
+ // tools + no legacy plugin ref remnants + no stale globs + all required
426
+ // absolute-path external_directory entries present.
427
+ if (strategy === "idempotent") {
428
+ const hasPlugin = Array.isArray(oc.plugin) && oc.plugin.includes(currentPluginRef);
429
+ const hasLegacy = Array.isArray(oc.plugin) && oc.plugin.some(isLegacyPluginEntry);
430
+ const hasAllTools = oc.tools && typeof oc.tools === "object" && TOOLS.every((t) => oc.tools[t] === true);
431
+ const hasAlwaysLoad = TOOLS.every((t) => alwaysLoadIncludes(oc.plugin, t));
432
+ const hasStaleReadGlob = oc.permission?.read && Object.prototype.hasOwnProperty.call(oc.permission.read, LEGACY_SKILL_SECRETS_GLOB);
433
+ const hasStaleExtDirGlob = Object.prototype.hasOwnProperty.call(oc.permission?.external_directory ?? {}, LEGACY_PKG_EXTERNAL_DIR_GLOB);
434
+ const hasAllExtDirPaths = requiredExtDirPaths.every(
435
+ (p) => oc.permission?.external_directory?.[p] === "allow"
436
+ );
437
+ if (hasPlugin && !hasLegacy && hasAllTools && hasAlwaysLoad && !hasStaleReadGlob && !hasStaleExtDirGlob && hasAllExtDirPaths) {
438
+ info(` ! opencode.json already configured (idempotent skip)`);
439
+ return;
440
+ }
441
+ }
442
+
443
+ backup(ocPath);
444
+ } else if (existsSync(join(pkgRoot, "opencode.json.example"))) {
445
+ oc = JSON.parse(readFileSync(join(pkgRoot, "opencode.json.example"), "utf8"));
446
+ } else {
447
+ oc = {};
448
+ }
449
+
450
+ oc.plugin = Array.isArray(oc.plugin) ? oc.plugin : [];
451
+ // Strip every legacy ref shape written by pre-refactor installs:
452
+ // - old relative path pointing at ~/.config/opencode/plugins/makdoong2-team/
453
+ // - old absolute path pointing at <npm-root>/src/opencode-plugin.ts
454
+ // Leaving either behind either fails to resolve (relative) or pins the entry
455
+ // to a specific nvm/node install (absolute), breaking portability. Replaced
456
+ // with the npm package name inserted below.
457
+ oc.plugin = oc.plugin.filter((entry) => !isLegacyPluginEntry(entry));
458
+ if (!oc.plugin.includes(currentPluginRef)) oc.plugin.push(currentPluginRef);
459
+ // Without this, opencode-tool-search hides our tools behind BM25 search, and
460
+ // the leader's `tool_search("dispatch_stage")` returns no match — pushing it
461
+ // to fall back on call_omo_agent loops that hang in stage 4.
462
+ ensureToolsInAlwaysLoad(oc.plugin, TOOLS);
463
+ oc.tools = oc.tools && typeof oc.tools === "object" ? oc.tools : {};
464
+ for (const t of TOOLS) oc.tools[t] = true;
465
+ // Strip the legacy secrets.env read permission — credentials no longer
466
+ // live in per-skill secrets.env, so opencode does not need file-read
467
+ // access to them. Idempotent (no-op when absent).
468
+ removeLegacyReadPermission(oc);
469
+ ensureExternalDirPaths(oc, requiredExtDirPaths);
470
+ writeFileSync(ocPath, JSON.stringify(oc, null, 2) + "\n");
471
+ ok(`opencode.json (plugin + ${TOOLS.length} tools)`);
472
+ }
473
+
474
+ /**
475
+ * Strip the pre-secrets.env-migration read-permission glob from opencode.json.
476
+ * The glob (${LEGACY_SKILL_SECRETS_GLOB}) was needed while credentials lived
477
+ * in per-skill secrets.env; now that they live only in makdoong2-team.json,
478
+ * the glob is redundant clutter that suggests stale files still matter.
479
+ * Idempotent — no-op when absent.
480
+ * @param {Object} oc - opencode.json config object (mutated in place)
481
+ */
482
+ function removeLegacyReadPermission(oc) {
483
+ const read = oc?.permission?.read;
484
+ if (!read || typeof read !== "object") return;
485
+ if (Object.prototype.hasOwnProperty.call(read, LEGACY_SKILL_SECRETS_GLOB)) {
486
+ delete read[LEGACY_SKILL_SECRETS_GLOB];
487
+ }
488
+ if (Object.keys(read).length === 0) delete oc.permission.read;
489
+ if (oc.permission && Object.keys(oc.permission).length === 0) delete oc.permission;
490
+ }
491
+
492
+ /**
493
+ * Compute the external_directory allow paths to write into opencode.json.
494
+ * Returns absolute-path entries (with trailing /**) for:
495
+ * - pkgRoot: the npm package tree (stage specs, gate scripts, references)
496
+ * - configDir: the opencode config directory (agents, skills)
497
+ *
498
+ * Absolute-path prefixes are used instead of glob patterns because
499
+ * path.matchesGlob's "**" cannot traverse dot-directories (.nvm, .config),
500
+ * making patterns like "**\/makdoong2-team\/**" ineffective in practice.
501
+ *
502
+ * @param {string} pkgRoot - npm module root (e.g. /root/.nvm/.../node_modules/makdoong2-team)
503
+ * @param {string} configDir - opencode config dir (e.g. /root/.config/opencode)
504
+ * @returns {string[]}
505
+ */
506
+ export function computeExternalDirPaths(pkgRoot, configDir) {
507
+ return [
508
+ pkgRoot.replace(/\/+$/, "") + "/**",
509
+ configDir.replace(/\/+$/, "") + "/**",
510
+ ];
511
+ }
512
+
513
+ /**
514
+ * Idempotently apply required external_directory allow entries to opencode.json.
515
+ * Strips the legacy glob pattern (pre-0.13.5) and adds the absolute-path entries
516
+ * computed by computeExternalDirPaths. Mutates oc in place.
517
+ * @param {Object} oc - opencode.json config object
518
+ * @param {string[]} paths - Absolute-path allow entries from computeExternalDirPaths
519
+ */
520
+ function ensureExternalDirPaths(oc, paths) {
521
+ oc.permission = oc.permission && typeof oc.permission === "object" ? oc.permission : {};
522
+ oc.permission.external_directory =
523
+ oc.permission.external_directory && typeof oc.permission.external_directory === "object"
524
+ ? oc.permission.external_directory
525
+ : {};
526
+ if (Object.prototype.hasOwnProperty.call(oc.permission.external_directory, LEGACY_PKG_EXTERNAL_DIR_GLOB)) {
527
+ delete oc.permission.external_directory[LEGACY_PKG_EXTERNAL_DIR_GLOB];
528
+ }
529
+ for (const p of paths) {
530
+ oc.permission.external_directory[p] = "allow";
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Back up and remove legacy per-skill secrets.env files under configDir.
536
+ * Credentials now live only in makdoong2-team.json .secrets.*; no value
537
+ * migration is performed. Users on the new layout re-enter their tokens
538
+ * directly in the JSON config. Idempotent — no-op when files are absent.
539
+ * @param {string} configDir
540
+ * @param {Object} helpers
541
+ */
542
+ function cleanupLegacySecretsEnv(configDir, { info, backup }) {
543
+ let removed = 0;
544
+ for (const skill of LEGACY_SECRET_SKILL_DIRS) {
545
+ const target = join(configDir, "skills", skill, "secrets.env");
546
+ if (!existsSync(target)) continue;
547
+ backup(target);
548
+ rmSync(target, { force: true });
549
+ info(` removed legacy secret file: skills/${skill}/secrets.env`);
550
+ removed++;
551
+ }
552
+ if (removed > 0) {
553
+ info(` ! ${removed} legacy secrets.env file(s) backed up + removed — set tokens in makdoong2-team.json .secrets.*`);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Ensure `.secrets` object exists in makdoong2-team.json with the four
559
+ * canonical token keys (empty string default). Idempotent additive merge —
560
+ * preserves any user-authored token values, only fills missing keys.
561
+ * Skips silently if the file is absent or unparseable.
562
+ * @param {string} cfgPath
563
+ * @param {Object} helpers
564
+ */
565
+ function ensureSecretsScaffolding(cfgPath, { ok, info }) {
566
+ if (!existsSync(cfgPath)) return;
567
+ let cfg;
568
+ try {
569
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
570
+ } catch {
571
+ info(` ! makdoong2-team.json not valid JSON — skipping secrets scaffolding`);
572
+ return;
573
+ }
574
+ const before = cfg.secrets;
575
+ if (!before || typeof before !== "object") cfg.secrets = {};
576
+ let added = 0;
577
+ for (const key of SECRET_KEYS) {
578
+ if (!Object.prototype.hasOwnProperty.call(cfg.secrets, key)) {
579
+ cfg.secrets[key] = "";
580
+ added++;
581
+ }
582
+ }
583
+ if (added === 0 && before && typeof before === "object") return;
584
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
585
+ ok(`secrets scaffolding ensured (${added} key(s) seeded)`);
586
+ }
587
+
588
+ function ensureLoggingScaffolding(cfgPath, { ok, info }) {
589
+ if (!existsSync(cfgPath)) return;
590
+ let cfg;
591
+ try {
592
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
593
+ } catch {
594
+ info(` ! makdoong2-team.json not valid JSON — skipping logging scaffolding`);
595
+ return;
596
+ }
597
+ const before = cfg.logging;
598
+ const hadLoggingBlock = before && typeof before === "object";
599
+ if (!hadLoggingBlock) cfg.logging = {};
600
+ let added = 0;
601
+ if (!Object.prototype.hasOwnProperty.call(cfg.logging, "level")) {
602
+ cfg.logging.level = DEFAULT_LOGGING_LEVEL;
603
+ added++;
604
+ }
605
+ if (!Object.prototype.hasOwnProperty.call(cfg.logging, "mode")) {
606
+ cfg.logging.mode = DEFAULT_LOGGING_MODE;
607
+ added++;
608
+ }
609
+ if (!Object.prototype.hasOwnProperty.call(cfg.logging, "path")) {
610
+ cfg.logging.path = DEFAULT_LOGGING_PATH;
611
+ added++;
612
+ }
613
+ if (added === 0 && hadLoggingBlock) return;
614
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
615
+ ok(`logging scaffolding ensured (${added} key(s) seeded; defaults: level="${DEFAULT_LOGGING_LEVEL}", mode="${DEFAULT_LOGGING_MODE}")`);
616
+ }
617
+
618
+ function findToolSearchEntry(pluginArr) {
619
+ if (!Array.isArray(pluginArr)) return null;
620
+ for (const entry of pluginArr) {
621
+ if (Array.isArray(entry) && typeof entry[0] === "string" && entry[0].startsWith(TOOL_SEARCH_PLUGIN_PREFIX)) {
622
+ return entry;
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ function alwaysLoadIncludes(pluginArr, toolName) {
629
+ const entry = findToolSearchEntry(pluginArr);
630
+ if (!entry) return true; // tool-search not configured → all tools always visible
631
+ const opts = entry[1];
632
+ if (!opts || typeof opts !== "object") return true;
633
+ const list = opts.alwaysLoad;
634
+ if (!Array.isArray(list)) return true;
635
+ return list.includes(toolName);
636
+ }
637
+
638
+ function ensureToolsInAlwaysLoad(pluginArr, toolNames) {
639
+ const entry = findToolSearchEntry(pluginArr);
640
+ if (!entry) return; // tool-search not configured; nothing to patch
641
+ if (!entry[1] || typeof entry[1] !== "object") entry[1] = {};
642
+ if (!Array.isArray(entry[1].alwaysLoad)) entry[1].alwaysLoad = [];
643
+ for (const t of toolNames) {
644
+ if (!entry[1].alwaysLoad.includes(t)) entry[1].alwaysLoad.push(t);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Resolve the cache root opencode uses to stage downloaded plugin packages.
650
+ * Mirrors opencode's own logic: $XDG_CACHE_HOME/opencode/packages, falling
651
+ * back to $HOME/.cache/opencode/packages.
652
+ * @returns {string}
653
+ */
654
+ export function opencodeCacheRoot() {
655
+ const xdg = process.env.XDG_CACHE_HOME;
656
+ const base = xdg && xdg.trim() ? xdg : join(homedir(), ".cache");
657
+ return join(base, "opencode", "packages");
658
+ }
659
+
660
+ /**
661
+ * Remove makdoong tools from opencode-tool-search alwaysLoad list.
662
+ * No-op when opencode-tool-search is not configured (user doesn't use it).
663
+ * @param {Array} pluginArr - opencode.json plugin array (mutated in place)
664
+ * @param {string[]} toolNames - Tool IDs to remove
665
+ * @returns {boolean} true if any entry was removed
666
+ */
667
+ function removeToolsFromAlwaysLoad(pluginArr, toolNames) {
668
+ const entry = findToolSearchEntry(pluginArr);
669
+ if (!entry) return false; // opencode-tool-search not present — nothing to do
670
+ const opts = entry[1];
671
+ if (!opts || typeof opts !== "object" || !Array.isArray(opts.alwaysLoad)) return false;
672
+ const before = opts.alwaysLoad.length;
673
+ opts.alwaysLoad = opts.alwaysLoad.filter((t) => !toolNames.includes(t));
674
+ return opts.alwaysLoad.length < before;
675
+ }
676
+
677
+ /**
678
+ * Remove a directory only when it is empty (or does not exist). Silent on error.
679
+ * @param {string} dir
680
+ */
681
+ function tryCleanEmptyDir(dir) {
682
+ try {
683
+ if (!existsSync(dir)) return;
684
+ if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
685
+ } catch { /* ignore */ }
686
+ }
687
+
688
+ /**
689
+ * Strip makdoong2-team entries from opencode.json:
690
+ * - plugin array entry (current package name + all legacy shapes)
691
+ * - tools section keys (verify_stage, dispatch_stage, …)
692
+ * - alwaysLoad entries from opencode-tool-search config (only when present)
693
+ * - pkgRoot/** entry from permission.external_directory
694
+ * - legacy glob from permission.external_directory
695
+ *
696
+ * configDir/** is intentionally left alone — it is a broad permission that
697
+ * may legitimately be used by other tools.
698
+ *
699
+ * @param {string} configDir
700
+ * @param {string} pkgRoot
701
+ * @param {Object} helpers - {ok, skip, info, warn}
702
+ * @returns {boolean} true if the file was modified
703
+ */
704
+ function unpatchOpencodeJson(configDir, pkgRoot, { ok, skip, warn }) {
705
+ const ocPath = join(configDir, "opencode.json");
706
+ if (!existsSync(ocPath)) {
707
+ skip("opencode.json not found — nothing to patch");
708
+ return false;
709
+ }
710
+
711
+ let oc;
712
+ try {
713
+ oc = parseJsonc(readFileSync(ocPath, "utf8"));
714
+ } catch (err) {
715
+ warn(`opencode.json could not be parsed — leaving untouched: ${ocPath} (${err.message})`);
716
+ return false;
717
+ }
718
+
719
+ let changed = false;
720
+
721
+ // — Plugin array —
722
+ if (Array.isArray(oc.plugin)) {
723
+ const before = oc.plugin.length;
724
+ oc.plugin = oc.plugin.filter((entry) => {
725
+ if (typeof entry === "string") {
726
+ return entry !== PLUGIN_PACKAGE_NAME && !isLegacyPluginEntry(entry);
727
+ }
728
+ return true; // keep array entries (tool-search config tuples, other plugins)
729
+ });
730
+ if (oc.plugin.length < before) changed = true;
731
+ }
732
+
733
+ // — alwaysLoad (only when opencode-tool-search is configured) —
734
+ if (Array.isArray(oc.plugin) && removeToolsFromAlwaysLoad(oc.plugin, TOOLS)) changed = true;
735
+
736
+ // — tools section —
737
+ if (oc.tools && typeof oc.tools === "object") {
738
+ for (const t of TOOLS) {
739
+ if (Object.prototype.hasOwnProperty.call(oc.tools, t)) {
740
+ delete oc.tools[t];
741
+ changed = true;
742
+ }
743
+ }
744
+ if (Object.keys(oc.tools).length === 0) delete oc.tools;
745
+ }
746
+
747
+ // — external_directory: pkgRoot/** entries and legacy glob —
748
+ // We match by package-name suffix rather than exact pkgRoot prefix because the
749
+ // uninstall may run from a different pkgRoot (e.g. dev checkout) than the one
750
+ // that originally wrote the entry (npm global install path). Any key that ends
751
+ // with "makdoong2-team/**" is safe to remove — that suffix also matches the
752
+ // legacy "@local/makdoong2-team/**" entries written by internal-registry installs.
753
+ const extDir = oc.permission?.external_directory;
754
+ if (extDir && typeof extDir === "object") {
755
+ const pkgSuffix = `${PLUGIN_PACKAGE_NAME}/**`; // "makdoong2-team/**"
756
+ const pkgEntry = pkgRoot.replace(/\/+$/, "") + "/**";
757
+ for (const key of Object.keys(extDir)) {
758
+ const isLegacyGlob = key === LEGACY_PKG_EXTERNAL_DIR_GLOB;
759
+ const isCurrentPkg = key === pkgEntry;
760
+ const isNpmPattern = key.endsWith(pkgSuffix);
761
+ if (isLegacyGlob || isCurrentPkg || isNpmPattern) {
762
+ delete extDir[key];
763
+ changed = true;
764
+ }
765
+ }
766
+ if (Object.keys(extDir).length === 0) {
767
+ delete oc.permission.external_directory;
768
+ }
769
+ if (oc.permission && Object.keys(oc.permission).length === 0) {
770
+ delete oc.permission;
771
+ }
772
+ }
773
+
774
+ if (!changed) {
775
+ skip("opencode.json: no makdoong2-team entries found");
776
+ return false;
777
+ }
778
+
779
+ writeFileSync(ocPath, JSON.stringify(oc, null, 2) + "\n");
780
+ ok(`opencode.json patched (plugin ref + ${TOOLS.length} tools removed)`);
781
+ return true;
782
+ }
783
+
784
+ /**
785
+ * Remove the opencode plugin cache symlinks created by seedOpencodeCache.
786
+ * Only removes entries that are confirmed symlinks; leaves real content alone.
787
+ *
788
+ * @param {string} pkgRoot
789
+ * @param {Object} helpers - {ok, skip, warn}
790
+ */
791
+ function removeOpencodeCache(pkgRoot, { ok, skip, warn }) {
792
+ let pkg;
793
+ try {
794
+ pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
795
+ } catch (err) {
796
+ warn(`cache removal skipped — cannot read package.json: ${err.message}`);
797
+ return;
798
+ }
799
+ const { name, version } = pkg;
800
+ if (!name || !version) {
801
+ warn("cache removal skipped — package.json missing name/version");
802
+ return;
803
+ }
804
+
805
+ const cacheRoot = opencodeCacheRoot();
806
+ const tags = [`${name}@latest`, `${name}@${version}`];
807
+ let removedCount = 0;
808
+
809
+ for (const tag of tags) {
810
+ const cacheDir = join(cacheRoot, tag);
811
+ if (!existsSync(cacheDir)) continue;
812
+ const modulesDir = join(cacheDir, "node_modules", name);
813
+ if (!existsSync(modulesDir)) continue;
814
+ try {
815
+ if (lstatSync(modulesDir).isSymbolicLink()) {
816
+ rmSync(modulesDir, { force: true });
817
+ // Prune now-empty ancestor dirs. slice(0, -1) is empty for the current
818
+ // unscoped name (scopeDir === node_modules); it still resolves the extra
819
+ // scope level for caches left behind by the old @local-scoped installs.
820
+ const scopeDir = join(cacheDir, "node_modules", ...name.split("/").slice(0, -1));
821
+ tryCleanEmptyDir(scopeDir);
822
+ tryCleanEmptyDir(join(cacheDir, "node_modules"));
823
+ tryCleanEmptyDir(cacheDir);
824
+ removedCount++;
825
+ } else {
826
+ warn(`cache entry at ${modulesDir} is not a symlink — leaving in place`);
827
+ }
828
+ } catch (err) {
829
+ warn(`cache removal failed for ${tag}: ${err.message}`);
830
+ }
831
+ }
832
+
833
+ if (removedCount > 0) ok(`opencode plugin cache symlinks removed (${tags.join(", ")})`);
834
+ else skip("no opencode plugin cache symlinks to remove");
835
+ }
836
+
837
+ /**
838
+ * Revert all config-dir and opencode.json changes made by install().
839
+ *
840
+ * What is removed (idempotent — absent targets are silently skipped):
841
+ * 1. Agent definitions: agents/makdoong2-*.md
842
+ * 2. Research skill directories: skills/jira-research, confluence-research, bitbucket-research,
843
+ * and skills/_lib/ (only when it contains solely our files)
844
+ * 3. opencode.json: plugin ref, tools keys, alwaysLoad entries (when
845
+ * opencode-tool-search is configured), pkgRoot/** external_directory entry
846
+ * 4. opencode plugin cache symlinks seeded by seedOpencodeCache()
847
+ *
848
+ * What is intentionally left in place:
849
+ * - makdoong2-team.json (user credentials / config)
850
+ * - configDir/** from external_directory (broad permission; may be used by others)
851
+ * - The npm package itself (use npm uninstall -g makdoong2-team for that)
852
+ *
853
+ * @param {Object} opts
854
+ * @param {string} opts.configDir - Absolute path to config dir
855
+ * @param {string} opts.pkgRoot - Absolute path to package root (npm module)
856
+ * @param {Object} [opts.logger=console]
857
+ * @returns {{removed: string[], skipped: string[], warnings: string[]}}
858
+ */
859
+ export function uninstall(opts) {
860
+ const {
861
+ configDir,
862
+ pkgRoot,
863
+ logger = console,
864
+ } = opts;
865
+
866
+ const result = { removed: [], skipped: [], warnings: [] };
867
+
868
+ const ok = (m) => { logger.log(` ✓ ${m}`); result.removed.push(m); };
869
+ const skip = (m) => { logger.log(` - ${m}`); result.skipped.push(m); };
870
+ const info = (m) => logger.log(m);
871
+ const warn = (m) => { logger.warn(` ! ${m}`); result.warnings.push(m); };
872
+
873
+ info(`[makdoong2-team] uninstall — ${configDir}`);
874
+
875
+ // 1) Agent definitions
876
+ info("Removing agent definitions...");
877
+ const agentsDir = join(configDir, "agents");
878
+ if (existsSync(agentsDir)) {
879
+ let count = 0;
880
+ for (const name of readdirSync(agentsDir)) {
881
+ if (!name.startsWith("makdoong2-") || !name.endsWith(".md")) continue;
882
+ rmSync(join(agentsDir, name), { force: true });
883
+ count++;
884
+ }
885
+ if (count > 0) ok(`${count} agent definition(s) removed from agents/`);
886
+ else skip("agents/: no makdoong2-*.md files found");
887
+ } else {
888
+ skip("agents/: directory not found");
889
+ }
890
+
891
+ // 2) Research skill directories
892
+ info("Removing research skills...");
893
+ let skillsRemoved = 0;
894
+ for (const skill of RESEARCH_SKILLS) {
895
+ const skillDir = join(configDir, "skills", skill);
896
+ if (!existsSync(skillDir)) continue;
897
+ rmSync(skillDir, { recursive: true, force: true });
898
+ skillsRemoved++;
899
+ }
900
+ // _lib — remove only when its contents are exclusively ours
901
+ const libDir = join(configDir, "skills", "_lib");
902
+ if (existsSync(libDir)) {
903
+ const libFiles = readdirSync(libDir);
904
+ const ourLibFiles = new Set(["load-secret.sh"]);
905
+ if (libFiles.every((f) => ourLibFiles.has(f))) {
906
+ rmSync(libDir, { recursive: true, force: true });
907
+ skillsRemoved++;
908
+ } else {
909
+ warn("skills/_lib/ contains unrecognised files — leaving in place");
910
+ }
911
+ }
912
+ if (skillsRemoved > 0) ok(`${skillsRemoved} research skill director(y/ies) removed`);
913
+ else skip("skills/: no makdoong2-team skill directories found");
914
+
915
+ // 3) opencode.json
916
+ info("Patching opencode.json...");
917
+ unpatchOpencodeJson(configDir, pkgRoot, { ok, skip, warn });
918
+
919
+ // 4) opencode plugin cache symlinks
920
+ info("Removing opencode cache entries...");
921
+ removeOpencodeCache(pkgRoot, { ok, skip, warn });
922
+
923
+ info("");
924
+ info("[makdoong2-team] uninstall complete — restart opencode to apply changes.");
925
+ info(` makdoong2-team.json preserved at: ${join(configDir, "makdoong2-team.json")}`);
926
+ info(" (Delete it manually if you no longer need the credentials stored there.)");
927
+
928
+ return result;
929
+ }
930
+
931
+ /**
932
+ * Pre-populate opencode's plugin cache with a symlink pointing at the
933
+ * already-installed npm module for this package. Idempotent.
934
+ *
935
+ * Rationale: pins the session to the globally installed module instead of
936
+ * whatever opencode's built-in npm client resolves from registry.npmjs.org on
937
+ * its own. Without this step an upgrade can appear to succeed (`npm ls -g`
938
+ * shows the new version) while opencode keeps loading a stale cached copy.
939
+ *
940
+ * @param {string} pkgRoot - Absolute path to this npm module's root (source of the symlink target)
941
+ * @param {Object} helpers - {ok, info, warn} logger callbacks
942
+ */
943
+ export function readCachedVersion(modulesDir) {
944
+ try {
945
+ return JSON.parse(readFileSync(join(modulesDir, "package.json"), "utf8")).version ?? null;
946
+ } catch {
947
+ return null;
948
+ }
949
+ }
950
+
951
+ function seedOpencodeCache(pkgRoot, { ok, info, warn }) {
952
+ let pkg;
953
+ try {
954
+ pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
955
+ } catch (err) {
956
+ warn(`opencode cache seed skipped — cannot read package.json: ${err.message}`);
957
+ return;
958
+ }
959
+ const name = pkg.name;
960
+ const version = pkg.version;
961
+ if (!name || !version) {
962
+ warn("opencode cache seed skipped — package.json missing name/version");
963
+ return;
964
+ }
965
+
966
+ // opencode caches under `<name>@latest` (the tag it resolves) as well as
967
+ // `<name>@<explicit-version>` for pinned refs. Seed both so opencode.json
968
+ // entries in either shape ("makdoong2-team" or "makdoong2-team@0.2.1")
969
+ // both hit the cache.
970
+ const cacheRoot = opencodeCacheRoot();
971
+ const tags = [`${name}@latest`, `${name}@${version}`];
972
+ for (const tag of tags) {
973
+ const cacheDir = join(cacheRoot, tag);
974
+ const modulesDir = join(cacheDir, "node_modules", name);
975
+ const parentDir = join(cacheDir, "node_modules", ...name.split("/").slice(0, -1));
976
+
977
+ try {
978
+ mkdirSync(parentDir, { recursive: true });
979
+ } catch (err) {
980
+ warn(`opencode cache seed failed to mkdir ${parentDir}: ${err.message}`);
981
+ continue;
982
+ }
983
+
984
+ // Remove existing symlink or empty dir before re-linking. A real directory
985
+ // is an opencode-downloaded copy; unconditionally skipping it (the previous
986
+ // behaviour) pinned `<name>@latest` to whatever version opencode fetched
987
+ // first and made every later install a no-op — the plugin then kept loading
988
+ // stale code while `npm ls -g` reported the new version. Replace it when the
989
+ // version differs, and only skip when it already matches.
990
+ if (existsSync(modulesDir)) {
991
+ const st = lstatSync(modulesDir);
992
+ if (st.isSymbolicLink()) {
993
+ rmSync(modulesDir, { force: true });
994
+ } else if (st.isDirectory() && readdirSync(modulesDir).length === 0) {
995
+ rmSync(modulesDir, { recursive: true, force: true });
996
+ } else {
997
+ const cachedVersion = readCachedVersion(modulesDir);
998
+ if (cachedVersion === version) {
999
+ info(` ! opencode cache already at v${version} for ${tag} — skipping`);
1000
+ continue;
1001
+ }
1002
+ const stale = `${modulesDir}.stale-${cachedVersion ?? "unknown"}`;
1003
+ rmSync(stale, { recursive: true, force: true });
1004
+ try {
1005
+ renameSync(modulesDir, stale);
1006
+ } catch (err) {
1007
+ warn(`opencode cache seed could not set aside stale ${cachedVersion ?? "unknown"} copy: ${err.message}`);
1008
+ continue;
1009
+ }
1010
+ ok(`opencode cache ${tag}: replaced stale v${cachedVersion ?? "unknown"} → v${version} (old copy kept at ${stale})`);
1011
+ }
1012
+ }
1013
+
1014
+ try {
1015
+ symlinkSync(pkgRoot, modulesDir, "dir");
1016
+ } catch (err) {
1017
+ warn(`opencode cache symlink failed: ${err.message}`);
1018
+ continue;
1019
+ }
1020
+
1021
+ // Write the minimal package.json opencode expects so its post-fetch
1022
+ // sanity check ("open .../node_modules/package.json") succeeds.
1023
+ const stubPath = join(cacheDir, "package.json");
1024
+ if (!existsSync(stubPath)) {
1025
+ writeFileSync(stubPath, JSON.stringify({ dependencies: { [name]: version } }, null, 2) + "\n");
1026
+ }
1027
+ }
1028
+ ok(`opencode plugin cache seeded (${tags.join(", ")})`);
1029
+ }