ultra-builder-pro-cli 0.25.1 → 0.26.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 (241) hide show
  1. package/.ultra-template/contexts/TEMPLATE.md +87 -0
  2. package/.ultra-template/decisions/.gitkeep +1 -0
  3. package/.ultra-template/evidence/.gitkeep +1 -0
  4. package/.ultra-template/north-star.md +46 -0
  5. package/.ultra-template/research/.gitkeep +1 -0
  6. package/.ultra-template/specs/discovery.md +2 -2
  7. package/.ultra-template/tasks.json +3 -0
  8. package/.ultra-template/test-report.json +27 -0
  9. package/CHANGELOG.md +40 -0
  10. package/README.md +217 -579
  11. package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
  12. package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
  13. package/adapters/_shared/file-ops.cjs +76 -2
  14. package/adapters/_shared/host-profile.cjs +97 -0
  15. package/adapters/_shared/plugin-core.cjs +204 -0
  16. package/adapters/_shared/runtime-assets.cjs +31 -96
  17. package/adapters/claude.js +92 -291
  18. package/adapters/codex.js +309 -842
  19. package/adapters/grok.js +143 -1469
  20. package/adapters/kimi.js +124 -1053
  21. package/adapters/opencode.js +230 -742
  22. package/bin/delegate-worker.cjs +323 -0
  23. package/bin/delegate.cjs +456 -0
  24. package/bin/install.js +38 -11
  25. package/docs/ARCHITECTURE.md +122 -216
  26. package/docs/ARTIFACT-AUTHORITY.md +90 -268
  27. package/docs/DECISIONS.md +75 -87
  28. package/docs/PHILOSOPHY.md +177 -0
  29. package/docs/PLUGIN-ISOLATION-CONTRACT.md +62 -39
  30. package/docs/RUNTIME-COMPAT-MATRIX.md +99 -156
  31. package/docs/SKILL-AUTHORING.md +94 -73
  32. package/docs/WORKFLOW-LIFECYCLE.md +93 -189
  33. package/hooks/README.md +17 -37
  34. package/hooks/_common.py +113 -0
  35. package/hooks/adapters/codex.py +9 -16
  36. package/hooks/adapters/grok.py +5 -5
  37. package/hooks/adapters/kimi.py +8 -14
  38. package/hooks/block_dangerous_commands.py +95 -0
  39. package/hooks/compact_context.py +65 -0
  40. package/hooks/mid_workflow_recall.py +25 -0
  41. package/hooks/post_edit_guard.py +105 -0
  42. package/hooks/session_context.py +30 -0
  43. package/package.json +17 -52
  44. package/skills/ultra-change/SKILL.md +97 -50
  45. package/skills/ultra-change/references/change-contract.md +40 -1
  46. package/skills/ultra-delegate/SKILL.md +82 -0
  47. package/skills/ultra-delegate/references/delegation-contract.md +51 -0
  48. package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
  49. package/skills/ultra-deliver/SKILL.md +61 -42
  50. package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
  51. package/skills/ultra-dev/SKILL.md +96 -62
  52. package/skills/ultra-dev/references/debugging.md +22 -0
  53. package/skills/ultra-domain-modeling/SKILL.md +79 -0
  54. package/skills/ultra-grilling/SKILL.md +79 -0
  55. package/skills/ultra-init/SKILL.md +94 -37
  56. package/skills/ultra-init/references/brownfield-adoption.md +33 -83
  57. package/skills/ultra-init/scripts/init_project.cjs +84 -0
  58. package/skills/ultra-plan/SKILL.md +88 -68
  59. package/skills/ultra-research/SKILL.md +62 -48
  60. package/skills/ultra-research/references/00-problem-validation.md +5 -5
  61. package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
  62. package/skills/ultra-research/references/02-market-assessment.md +5 -4
  63. package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
  64. package/skills/ultra-research/references/04-product-strategy.md +3 -3
  65. package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
  66. package/skills/ultra-research/references/10-user-personas.md +3 -2
  67. package/skills/ultra-research/references/11-user-scenarios.md +3 -3
  68. package/skills/ultra-research/references/20-user-stories.md +4 -3
  69. package/skills/ultra-research/references/21-features-scope.md +3 -2
  70. package/skills/ultra-research/references/22-success-metrics.md +3 -3
  71. package/skills/ultra-research/references/30-architecture-context.md +3 -3
  72. package/skills/ultra-research/references/31-solution-strategy.md +3 -3
  73. package/skills/ultra-research/references/32-building-blocks.md +3 -3
  74. package/skills/ultra-research/references/40-deployment.md +3 -3
  75. package/skills/ultra-research/references/41-quality-risks.md +3 -3
  76. package/skills/ultra-research/references/99-synthesis.md +7 -7
  77. package/skills/ultra-review/SKILL.md +59 -32
  78. package/skills/ultra-review/references/code.md +17 -0
  79. package/skills/ultra-review/references/comments.md +12 -0
  80. package/skills/ultra-review/references/design.md +12 -0
  81. package/skills/ultra-review/references/errors.md +13 -0
  82. package/skills/ultra-review/references/spec.md +13 -0
  83. package/skills/ultra-review/references/tests.md +12 -0
  84. package/skills/ultra-review/references/worker-packet.md +40 -0
  85. package/skills/ultra-review/scripts/review_wait.py +26 -15
  86. package/skills/ultra-status/SKILL.md +61 -36
  87. package/skills/ultra-tdd/SKILL.md +81 -0
  88. package/skills/ultra-tdd/references/templates/README.md +20 -0
  89. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
  90. package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
  91. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
  92. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
  93. package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
  94. package/skills/ultra-tdd/references/test-execution.md +18 -0
  95. package/skills/ultra-test/SKILL.md +70 -30
  96. package/skills/ultra-test/references/export-syntax.md +18 -0
  97. package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
  98. package/skills/ultra-think/SKILL.md +49 -27
  99. package/skills/ultra-think/references/autonomy-boundary.md +48 -0
  100. package/skills/ultra-think/references/interaction-boundary.md +18 -33
  101. package/.ultra-template/docs/research/README.md +0 -55
  102. package/.ultra-template/reports/templates/delivery-report.json +0 -17
  103. package/.ultra-template/reports/templates/test-report.json +0 -18
  104. package/.ultra-template/tasks/tasks.json +0 -15
  105. package/.ultra-template/templates/task-context.md +0 -19
  106. package/adapters/_shared/codex-assets.cjs +0 -725
  107. package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
  108. package/adapters/_shared/interaction-contract.cjs +0 -129
  109. package/adapters/_shared/path-rewrite.cjs +0 -79
  110. package/adapters/_shared/runtime-native.cjs +0 -132
  111. package/adapters/_shared/settings-merge.cjs +0 -76
  112. package/agents/code-reviewer.md +0 -42
  113. package/agents/debugger.md +0 -40
  114. package/agents/review-code.md +0 -49
  115. package/agents/review-comments.md +0 -38
  116. package/agents/review-coordinator.md +0 -56
  117. package/agents/review-design.md +0 -40
  118. package/agents/review-errors.md +0 -43
  119. package/agents/review-spec.md +0 -39
  120. package/agents/review-tests.md +0 -40
  121. package/agents/tdd-runner.md +0 -38
  122. package/bin/orchestrator.js +0 -383
  123. package/commands/ultra-change.md +0 -10
  124. package/commands/ultra-deliver.md +0 -10
  125. package/commands/ultra-dev.md +0 -10
  126. package/commands/ultra-doctor.md +0 -10
  127. package/commands/ultra-init.md +0 -10
  128. package/commands/ultra-plan.md +0 -10
  129. package/commands/ultra-research.md +0 -10
  130. package/commands/ultra-review.md +0 -10
  131. package/commands/ultra-status.md +0 -10
  132. package/commands/ultra-test.md +0 -10
  133. package/commands/ultra-think.md +0 -10
  134. package/docs/AGENT-CONTEXT.md +0 -191
  135. package/docs/COMMIT-HASH-BACKFILL.md +0 -51
  136. package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
  137. package/docs/ROADMAP.md +0 -101
  138. package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
  139. package/hooks/active_task_context.py +0 -152
  140. package/hooks/context_envelope.py +0 -100
  141. package/hooks/health_check.py +0 -196
  142. package/hooks/pre_stop_check.py +0 -66
  143. package/hooks/runtime_paths.py +0 -396
  144. package/hooks/subagent_tracker.py +0 -88
  145. package/hooks/workflow_checkpoint.py +0 -76
  146. package/hooks/workflow_context.py +0 -48
  147. package/hooks/workflow_resume.py +0 -84
  148. package/mcp-server/hook-context.cjs +0 -38
  149. package/mcp-server/hook-event.cjs +0 -34
  150. package/mcp-server/lib/archive-journal.cjs +0 -1119
  151. package/mcp-server/lib/archive-mutation-worker.py +0 -365
  152. package/mcp-server/lib/artifact-registry.cjs +0 -1232
  153. package/mcp-server/lib/baseline-workflow.cjs +0 -1193
  154. package/mcp-server/lib/canonical-json.cjs +0 -19
  155. package/mcp-server/lib/change-authority.cjs +0 -26
  156. package/mcp-server/lib/change-packet.cjs +0 -790
  157. package/mcp-server/lib/change-workflow.cjs +0 -65
  158. package/mcp-server/lib/context-envelope.cjs +0 -581
  159. package/mcp-server/lib/context-paths.cjs +0 -142
  160. package/mcp-server/lib/decision-records.cjs +0 -423
  161. package/mcp-server/lib/delivery-transaction.cjs +0 -471
  162. package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
  163. package/mcp-server/lib/doctor.cjs +0 -627
  164. package/mcp-server/lib/git-bootstrap.cjs +0 -651
  165. package/mcp-server/lib/init-project.cjs +0 -1021
  166. package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
  167. package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
  168. package/mcp-server/lib/managed-file-write.cjs +0 -172
  169. package/mcp-server/lib/plan-checkpoint.cjs +0 -177
  170. package/mcp-server/lib/plan-publication-worker.cjs +0 -830
  171. package/mcp-server/lib/plan-store.cjs +0 -794
  172. package/mcp-server/lib/prd-parser.cjs +0 -83
  173. package/mcp-server/lib/pricing.cjs +0 -44
  174. package/mcp-server/lib/project-context.cjs +0 -178
  175. package/mcp-server/lib/projector.cjs +0 -386
  176. package/mcp-server/lib/provider-refs.cjs +0 -42
  177. package/mcp-server/lib/runtime-paths.cjs +0 -1714
  178. package/mcp-server/lib/runtime-state.cjs +0 -222
  179. package/mcp-server/lib/safe-project-file.cjs +0 -385
  180. package/mcp-server/lib/stage-checkpoints.cjs +0 -463
  181. package/mcp-server/lib/state-authority.cjs +0 -95
  182. package/mcp-server/lib/state-db.cjs +0 -1580
  183. package/mcp-server/lib/state-ops.cjs +0 -1428
  184. package/mcp-server/lib/task-contract.cjs +0 -404
  185. package/mcp-server/lib/task-expander.cjs +0 -105
  186. package/mcp-server/lib/task-ledger.cjs +0 -1964
  187. package/mcp-server/lib/telemetry.cjs +0 -85
  188. package/mcp-server/lib/topo.cjs +0 -131
  189. package/mcp-server/lib/ultra-facade.cjs +0 -2684
  190. package/mcp-server/lib/worker-packet.cjs +0 -358
  191. package/mcp-server/server.cjs +0 -437
  192. package/orchestrator/auto-merge.cjs +0 -128
  193. package/orchestrator/daemon.cjs +0 -264
  194. package/orchestrator/dispatch-rules.cjs +0 -78
  195. package/orchestrator/parallel-orchestrator.cjs +0 -903
  196. package/orchestrator/planner/plan-builder.cjs +0 -125
  197. package/orchestrator/recovery.cjs +0 -188
  198. package/orchestrator/session-close-journal-worker.cjs +0 -5
  199. package/orchestrator/session-close-journal.cjs +0 -1014
  200. package/orchestrator/session-runner.cjs +0 -1153
  201. package/orchestrator/worktree-manager.cjs +0 -282
  202. package/settings.json +0 -7
  203. package/skills/cc-collab/SKILL.md +0 -42
  204. package/skills/code-review-expert/SKILL.md +0 -43
  205. package/skills/code-review-expert/references/correctness-reliability.md +0 -30
  206. package/skills/code-review-expert/references/design-boundaries.md +0 -26
  207. package/skills/code-review-expert/references/removal-plan.md +0 -24
  208. package/skills/codex-collab/SKILL.md +0 -49
  209. package/skills/codex-collab/evals/evals.json +0 -35
  210. package/skills/integration-rules/SKILL.md +0 -32
  211. package/skills/security-rules/SKILL.md +0 -38
  212. package/skills/testing-rules/SKILL.md +0 -32
  213. package/skills/ultra-doctor/SKILL.md +0 -41
  214. package/skills/ultra-plan/references/semantic-preflight.md +0 -20
  215. package/skills/ultra-verify/SKILL.md +0 -46
  216. package/skills/ultra-verify/evals/evals.json +0 -46
  217. package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
  218. package/skills/ultra-verify/references/evidence-status.md +0 -21
  219. package/skills/ultra-verify/scripts/verify_wait.py +0 -94
  220. package/spec/cli-protocol.md +0 -102
  221. package/spec/mcp-tools.yaml +0 -312
  222. package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
  223. package/spec/schemas/change-delta.v1.schema.json +0 -153
  224. package/spec/schemas/command-manifest.schema.json +0 -29
  225. package/spec/schemas/context-file.v4.5.schema.json +0 -54
  226. package/spec/schemas/delivery-report.v1.schema.json +0 -66
  227. package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
  228. package/spec/schemas/mcp-tools.schema.json +0 -136
  229. package/spec/schemas/skill-manifest.schema.json +0 -24
  230. package/spec/schemas/state-db.sql +0 -641
  231. package/spec/schemas/task-ledger.v2.schema.json +0 -296
  232. package/spec/schemas/tasks.v4.5.schema.json +0 -96
  233. package/spec/schemas/test-report.v1.schema.json +0 -225
  234. package/ultra-tools/cli.cjs +0 -129
  235. package/ultra-tools/commands/db.cjs +0 -226
  236. package/ultra-tools/commands/legacy-memory.cjs +0 -159
  237. package/ultra-tools/commands/migrate.cjs +0 -1386
  238. package/ultra-tools/commands/session.cjs +0 -213
  239. package/ultra-tools/commands/status.cjs +0 -559
  240. package/ultra-tools/commands/system.cjs +0 -498
  241. package/ultra-tools/commands/task.cjs +0 -105
@@ -1,63 +1,40 @@
1
1
  'use strict';
2
2
 
3
- /** Build a native OpenCode Ultra Builder Pro plugin. */
4
-
5
3
  const fs = require('node:fs');
6
- const crypto = require('node:crypto');
7
4
  const os = require('node:os');
8
5
  const path = require('node:path');
9
- const { spawnSync } = require('node:child_process');
10
- const { isDeepStrictEqual } = require('node:util');
6
+ const crypto = require('node:crypto');
11
7
 
12
8
  const {
13
- copyTree,
9
+ copyHooks,
10
+ copySkills,
11
+ inspectPlugin,
12
+ markManaged,
13
+ removeManaged,
14
+ writePluginProvenance,
15
+ } = require('./_shared/plugin-core.cjs');
16
+ const {
17
+ captureAbsent,
14
18
  ensureDir,
15
19
  isManaged,
16
- markManaged,
17
- removeTree,
20
+ managedMetadata,
21
+ pruneCreatedEmpty,
22
+ removeTree: removePath,
18
23
  writeAtomic,
19
24
  } = require('./_shared/file-ops.cjs');
20
- const {
21
- applyNativeDoctor,
22
- buildMcpRuntime,
23
- mcpCommand,
24
- } = require('./_shared/codex-assets.cjs');
25
- const { adaptInteractionGuidance } = require('./_shared/interaction-contract.cjs');
26
- const provenance = require('./_shared/provenance.cjs');
27
- const {
28
- CORE_PUBLIC_SKILLS,
29
- skillsForRuntime,
30
- } = require('./_shared/runtime-assets.cjs');
31
- const {
32
- parse: parseFm,
33
- serialize: serializeFm,
34
- lowercaseKeys,
35
- } = require('./_shared/frontmatter.cjs');
36
- const {
37
- readJsonSafe,
38
- } = require('./_shared/settings-merge.cjs');
25
+ const { skillsForRuntime } = require('./_shared/runtime-assets.cjs');
39
26
 
40
- const LEGACY_SENTINEL_KEY = '_ubp_manifest';
41
- const MCP_SERVER_NAME = 'ultra-builder-pro';
42
- const SOURCE_TAG = 'ubp';
27
+ const PLUGIN_NAME = 'ultra-builder-pro';
43
28
  const BUNDLE_DIR = '.ultra-builder-pro';
44
- const MANAGED_TEXT_MARKER = '<!-- ultra-builder-pro:managed -->';
45
29
  const PLUGIN_MARKER = '// Managed by Ultra Builder Pro.';
46
- const PROVENANCE_FILE = 'provenance.json';
47
- const COMMAND_NAMES = CORE_PUBLIC_SKILLS;
48
-
49
- function discoverableSkillNames() {
50
- return skillsForRuntime('opencode')
51
- .filter((name) => !CORE_PUBLIC_SKILLS.includes(name));
52
- }
30
+ const TEXT_MARKER = '<!-- ultra-builder-pro:managed -->';
53
31
 
54
32
  function resolveTarget(ctx = {}) {
55
- if (ctx.configDir) return ctx.configDir;
33
+ if (ctx.configDir) return path.resolve(ctx.configDir);
56
34
  if (ctx.scope === 'global') {
57
35
  if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR;
58
36
  if (process.env.OPENCODE_CONFIG) return path.dirname(process.env.OPENCODE_CONFIG);
59
- const xdg = process.env.XDG_CONFIG_HOME;
60
- return path.join(xdg || path.join(ctx.homeDir || os.homedir(), '.config'), 'opencode');
37
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(ctx.homeDir || os.homedir(), '.config'), 'opencode');
61
38
  }
62
39
  return path.join(ctx.cwd || process.cwd(), '.opencode');
63
40
  }
@@ -66,464 +43,122 @@ function resolveRepoRoot(ctx = {}) {
66
43
  return ctx.repoRoot || path.resolve(__dirname, '..');
67
44
  }
68
45
 
69
- function shouldRunHostCli(ctx = {}) {
70
- if (typeof ctx.runHostCli === 'boolean') return ctx.runHostCli;
71
- return ctx.scope === 'global' && !ctx.configDir;
72
- }
73
-
74
- function runOpenCodeCli(args, ctx = {}) {
75
- const target = resolveTarget(ctx);
76
- const result = spawnSync(ctx.opencodeBin || 'opencode', args, {
77
- cwd: ctx.cwd || process.cwd(),
78
- encoding: 'utf8',
79
- stdio: ['ignore', 'pipe', 'pipe'],
80
- killSignal: 'SIGKILL',
81
- env: {
82
- ...process.env,
83
- OPENCODE_CONFIG_DIR: target,
84
- },
85
- timeout: ctx.hostCliTimeoutMs || 30000,
86
- });
87
- if (result.error) {
88
- if (result.error.code === 'ETIMEDOUT') {
89
- const error = new Error(`opencode ${args.join(' ')} timed out`);
90
- error.code = 'HOST_CLI_TIMEOUT';
91
- throw error;
92
- }
93
- throw result.error;
94
- }
95
- if (result.status !== 0) {
96
- const detail = (result.stderr || result.stdout || '').trim();
97
- throw new Error(`opencode ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
98
- }
99
- return result.stdout;
100
- }
101
-
102
- function openCodeMcpRow(output, serverName) {
103
- for (const rawLine of output.split(/\r?\n/)) {
104
- const tokens = rawLine.trim().split(/\s+/);
105
- if (tokens.some((token) => token === serverName)) return rawLine;
106
- }
107
- return '';
108
- }
109
-
110
- function inspectOpenCodeHost(ctx = {}) {
111
- const target = resolveTarget(ctx);
112
- const expectedLauncher = path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs');
113
- const expected = managedMcpEntry(target);
114
- const config = JSON.parse(runOpenCodeCli(['debug', 'config'], ctx));
115
- const entry = config?.mcp?.[MCP_SERVER_NAME];
116
- const configOk = isDeepStrictEqual(entry, expected);
117
- const mcpOutput = runOpenCodeCli(['mcp', 'list'], ctx)
118
- .replace(/\u001b\[[0-9;]*m/g, '');
119
- const mcpLine = openCodeMcpRow(mcpOutput, MCP_SERVER_NAME);
120
- const mcpOk = /\bconnected\b/i.test(mcpLine);
121
- return { config, configOk, mcpOutput, mcpOk };
122
- }
123
-
124
- function managedMcpEntry(target) {
125
- const command = mcpCommand(path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs'));
46
+ function buildHooksManifest() {
126
47
  return {
127
- type: 'local',
128
- enabled: true,
129
- command: [command.command, ...command.args],
130
- };
131
- }
132
-
133
- function hasManagedMcpIdentity(entry, target) {
134
- return Boolean(
135
- entry
136
- && typeof entry === 'object'
137
- && !Array.isArray(entry)
138
- && isDeepStrictEqual(entry.command, managedMcpEntry(target).command),
139
- );
140
- }
141
-
142
- function lowercaseFrontmatterTransform(buf, relPath) {
143
- if (!relPath.endsWith('.md')) return buf;
144
- const text = buf.toString('utf8');
145
- const { fm, body } = parseFm(text);
146
- if (!fm) return buf;
147
- return Buffer.from(serializeFm(lowercaseKeys(fm), body), 'utf8');
148
- }
149
-
150
- function openCodeTextTransform(input, assetName = '') {
151
- let text = adaptInteractionGuidance(input, 'opencode');
152
- text = text.replaceAll('$CLAUDE_PLUGIN_ROOT/skills', '~/.config/opencode/skills');
153
- text = text.replaceAll('~/.claude/skills', '~/.config/opencode/skills');
154
- text = text.replaceAll('~/.claude/hooks', '~/.config/opencode/.ultra-builder-pro/hooks');
155
- text = text.replaceAll('~/.claude', '~/.config/opencode');
156
- text = text.replaceAll('CLAUDE.md', 'AGENTS.md');
157
- text = text.replace(/@skills\/([a-z0-9-]+)\/SKILL\.md/g, '~/.config/opencode/skills/$1/SKILL.md');
158
-
159
- if (assetName === 'codex-collab') {
160
- text = text.replaceAll('the current host', 'OpenCode');
161
- text = text.replaceAll('current host', 'OpenCode');
162
- }
163
- if (assetName === 'ultra-verify') {
164
- text = text.replaceAll('the current host', 'OpenCode');
165
- text = text.replaceAll('current host', 'OpenCode');
166
- text = text.replaceAll('claude-analysis.md', 'opencode-analysis.md');
167
- text = text.replaceAll('Claude Code', 'OpenCode');
168
- text = text.replaceAll('Claude', 'OpenCode');
169
- }
170
- if (assetName === 'cc-collab') {
171
- text = text.replaceAll('the current host', 'OpenCode');
172
- text = text.replaceAll('current host', 'OpenCode');
173
- }
174
- if (assetName === 'ultra-review') {
175
- text = text.replace(
176
- /(?:the current host's native|the host-native) bounded-worker\s+mechanism/g,
177
- 'the OpenCode `task` tool using the installed bounded review agents',
178
- );
179
- }
180
- return text;
181
- }
182
-
183
- function openCodeCommandTransform(buf, relPath, commandName, workflowFile) {
184
- if (!relPath.endsWith('.md')) return buf;
185
- const { fm } = parseFm(buf.toString('utf8'));
186
- if (!fm) throw new Error(`missing frontmatter in OpenCode command: ${commandName}`);
187
- const nativeFm = {
188
- description: openCodeTextTransform(fm.description || `${commandName} workflow`, commandName)
189
- .replace(/\s+/g, ' ')
190
- .trim(),
191
- };
192
- const body = [
193
- `# Run ${commandName}`,
194
- '',
195
- `Read and follow \`${workflowFile}\` as the only workflow definition.`,
196
- 'This explicit command is the activation boundary; do not substitute a model-discovered',
197
- 'public skill or start another public Ultra workflow automatically.',
198
- '',
199
- 'Arguments: $ARGUMENTS',
200
- '',
201
- ].join('\n');
202
- return Buffer.from(serializeFm(nativeFm, body), 'utf8');
203
- }
204
-
205
- function openCodeSkillAssetTransform(buf, relPath, skillName) {
206
- if (!relPath.endsWith('.md')) return buf;
207
- const text = buf.toString('utf8');
208
- const { fm, body } = parseFm(text);
209
- if (!fm) return Buffer.from(openCodeTextTransform(text, skillName), 'utf8');
210
- const nativeFm = {
211
- name: String(fm.name || skillName),
212
- description: openCodeTextTransform(fm.description || `${skillName} workflow`, skillName)
213
- .replace(/\s+/g, ' ')
214
- .trim(),
215
- };
216
- return Buffer.from(serializeFm(nativeFm, openCodeTextTransform(body, skillName)), 'utf8');
217
- }
218
-
219
- function withManagedMarker(buf) {
220
- const text = buf.toString('utf8');
221
- const { fm, body } = parseFm(text);
222
- if (!fm) return Buffer.from(`${MANAGED_TEXT_MARKER}\n${text}`, 'utf8');
223
- const cleanBody = body.replace(`${MANAGED_TEXT_MARKER}\n`, '');
224
- return Buffer.from(serializeFm(fm, `${MANAGED_TEXT_MARKER}\n${cleanBody}`), 'utf8');
225
- }
226
-
227
- function sourceToolSet(value) {
228
- if (typeof value === 'string') {
229
- return new Set(value.split(',').map((name) => name.trim().toLowerCase()).filter(Boolean));
230
- }
231
- if (Array.isArray(value)) {
232
- return new Set(value.map((name) => String(name).trim().toLowerCase()).filter(Boolean));
233
- }
234
- if (value && typeof value === 'object') {
235
- return new Set(Object.entries(value)
236
- .filter(([, enabled]) => enabled)
237
- .map(([name]) => name.trim().toLowerCase()));
238
- }
239
- return new Set();
240
- }
241
-
242
- function openCodeAgentTransform(buf, relPath) {
243
- if (!relPath.endsWith('.md')) return buf;
244
- const text = buf.toString('utf8');
245
- const { fm, body } = parseFm(text);
246
- if (!fm) return buf;
247
-
248
- const tools = sourceToolSet(fm.tools);
249
- const allows = (name) => tools.has(name) ? 'allow' : 'deny';
250
- const skills = Array.isArray(fm.skills)
251
- ? fm.skills.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
252
- : [];
253
- const skillPermission = skills.length > 0
254
- ? Object.fromEntries([['*', 'deny'], ...skills.map((name) => [name, 'allow'])])
255
- : 'deny';
256
- const permission = {
257
- read: allows('read'),
258
- edit: tools.has('write') || tools.has('edit') ? 'allow' : 'deny',
259
- glob: allows('glob'),
260
- grep: allows('grep'),
261
- list: allows('glob'),
262
- bash: allows('bash'),
263
- task: 'deny',
264
- external_directory: 'deny',
265
- todowrite: 'deny',
266
- question: 'deny',
267
- webfetch: 'deny',
268
- websearch: 'deny',
269
- codesearch: 'deny',
270
- lsp: 'deny',
271
- doom_loop: 'deny',
272
- skill: skillPermission,
48
+ SessionStart: ['session_context.py', 'compact_context.py'],
49
+ PreToolUse: ['mid_workflow_recall.py', 'block_dangerous_commands.py'],
50
+ PostToolUse: ['post_edit_guard.py'],
51
+ PreCompact: ['compact_context.py'],
273
52
  };
274
- const nativeFm = {
275
- description: fm.description,
276
- mode: 'subagent',
277
- ...(Number.isInteger(fm.maxTurns) && fm.maxTurns > 0 ? { steps: fm.maxTurns } : {}),
278
- permission,
279
- };
280
- return Buffer.from(
281
- serializeFm(nativeFm, openCodeTextTransform(body, path.basename(relPath, '.md'))),
282
- 'utf8',
283
- );
284
- }
285
-
286
- function isManagedTextFile(file) {
287
- if (!fs.existsSync(file)) return false;
288
- return fs.readFileSync(file, 'utf8').includes(MANAGED_TEXT_MARKER);
289
- }
290
-
291
- function assertManagedTextTarget(file, kind) {
292
- if (fs.existsSync(file) && !isManagedTextFile(file)) {
293
- throw new Error(`refusing to replace unmanaged OpenCode ${kind}: ${file}`);
294
- }
295
- }
296
-
297
- function removeStaleManagedFiles(dir, expected) {
298
- if (!fs.existsSync(dir)) return [];
299
- const keep = new Set(expected);
300
- const removed = [];
301
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
302
- if (!entry.isFile() || keep.has(entry.name)) continue;
303
- const file = path.join(dir, entry.name);
304
- if (!isManagedTextFile(file)) continue;
305
- fs.unlinkSync(file);
306
- removed.push(entry.name);
307
- }
308
- return removed;
309
- }
310
-
311
- function removeEmptyDir(dir) {
312
- if (!fs.existsSync(dir)) return;
313
- if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
314
- }
315
-
316
- function copyCommands(repoRoot, target, publishedTarget = target) {
317
- const output = path.join(target, 'commands');
318
- ensureDir(output);
319
- removeStaleManagedFiles(output, COMMAND_NAMES.map((name) => `${name}.md`));
320
- const copied = [];
321
- for (const name of COMMAND_NAMES) {
322
- const file = `${name}.md`;
323
- const source = path.join(repoRoot, 'commands', file);
324
- if (!fs.existsSync(source)) throw new Error(`missing allowlisted OpenCode command: ${file}`);
325
- const workflowFile = path.join(publishedTarget, BUNDLE_DIR, 'workflows', name, 'SKILL.md');
326
- const transformed = withManagedMarker(
327
- openCodeCommandTransform(fs.readFileSync(source), file, name, workflowFile),
328
- );
329
- writeAtomic(path.join(output, file), transformed);
330
- copied.push(file);
331
- }
332
- return copied;
333
- }
334
-
335
- function copySkills(repoRoot, target) {
336
- const copied = [];
337
- const names = discoverableSkillNames();
338
- const skillsRoot = path.join(target, 'skills');
339
- ensureDir(skillsRoot);
340
- for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
341
- if (!entry.isDirectory() || names.includes(entry.name)) continue;
342
- const candidate = path.join(skillsRoot, entry.name);
343
- if (isManaged(candidate)) removeTree(candidate);
344
- }
345
- for (const name of names) {
346
- const source = path.join(repoRoot, 'skills', name);
347
- if (!fs.existsSync(path.join(source, 'SKILL.md'))) {
348
- throw new Error(`missing allowlisted OpenCode skill: ${name}`);
349
- }
350
- const destination = path.join(skillsRoot, name);
351
- if (fs.existsSync(destination)) removeTree(destination);
352
- const files = copyTree(source, destination, {
353
- transform: (buf, relPath) => openCodeSkillAssetTransform(buf, relPath, name),
354
- });
355
- markManaged(destination, { adapter: 'opencode', asset: 'skill', name });
356
- copied.push(...files.map((rel) => path.join(name, rel)));
357
- }
358
- return copied;
359
- }
360
-
361
- function copyPrivateWorkflows(repoRoot, bundleRoot) {
362
- const copied = [];
363
- const workflowsRoot = path.join(bundleRoot, 'workflows');
364
- for (const name of CORE_PUBLIC_SKILLS) {
365
- const source = path.join(repoRoot, 'skills', name);
366
- if (!fs.existsSync(path.join(source, 'SKILL.md'))) {
367
- throw new Error(`missing allowlisted OpenCode workflow: ${name}`);
368
- }
369
- const files = copyTree(source, path.join(workflowsRoot, name), {
370
- transform: (buf, relPath) => openCodeSkillAssetTransform(buf, relPath, name),
371
- });
372
- copied.push(...files.map((rel) => path.join('workflows', name, rel)));
373
- }
374
- return copied;
375
- }
376
-
377
- function copyAgents(repoRoot, target) {
378
- const sourceRoot = path.join(repoRoot, 'agents');
379
- const output = path.join(target, 'agents');
380
- const names = fs.readdirSync(sourceRoot).filter((name) => name.endsWith('.md')).sort();
381
- ensureDir(output);
382
- removeStaleManagedFiles(output, names);
383
- for (const name of names) {
384
- const source = fs.readFileSync(path.join(sourceRoot, name));
385
- writeAtomic(path.join(output, name), withManagedMarker(openCodeAgentTransform(source, name)));
386
- }
387
- return names;
388
- }
389
-
390
- function preflightAssets(repoRoot, target) {
391
- for (const name of COMMAND_NAMES) {
392
- assertManagedTextTarget(path.join(target, 'commands', `${name}.md`), 'command');
393
- }
394
- for (const name of discoverableSkillNames()) {
395
- const destination = path.join(target, 'skills', name);
396
- if (fs.existsSync(destination) && !isManaged(destination)) {
397
- throw new Error(`refusing to replace unmanaged OpenCode skill: ${destination}`);
398
- }
399
- }
400
- for (const name of fs.readdirSync(path.join(repoRoot, 'agents')).filter((entry) => entry.endsWith('.md'))) {
401
- assertManagedTextTarget(path.join(target, 'agents', name), 'agent');
402
- }
403
-
404
- const pluginFile = path.join(target, 'plugins', 'ultra-builder-pro.js');
405
- if (fs.existsSync(pluginFile) && !fs.readFileSync(pluginFile, 'utf8').startsWith(PLUGIN_MARKER)) {
406
- throw new Error(`refusing to replace unmanaged OpenCode plugin: ${pluginFile}`);
407
- }
408
- const bundleRoot = path.join(target, BUNDLE_DIR);
409
- if (fs.existsSync(bundleRoot) && !isManaged(bundleRoot)) {
410
- throw new Error(`refusing to replace unmanaged OpenCode runtime bundle: ${bundleRoot}`);
411
- }
412
53
  }
413
54
 
414
55
  function pluginSource() {
415
- return `// Managed by Ultra Builder Pro. Rebuild through the adapter; do not edit in place.
56
+ return `${PLUGIN_MARKER}
416
57
  import path from "node:path";
417
58
  import { execFileSync } from "node:child_process";
418
59
  import { fileURLToPath } from "node:url";
419
60
 
420
- const TEAM_TASK_LEDGER = ".ultra/tasks/tasks.json";
421
- const LIVE_TASK_PROJECTION = ".ultra/.runtime/projections/tasks.json";
422
- const NODE_BINARY = ${JSON.stringify(process.execPath)};
423
61
  const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
424
- const CONTEXT_CLI = path.resolve(
425
- PLUGIN_DIR, "..", ${JSON.stringify(BUNDLE_DIR)}, "runtime", "hook-context.cjs",
426
- );
62
+ const HOOK_ROOT = path.resolve(PLUGIN_DIR, "..", ${JSON.stringify(BUNDLE_DIR)}, "hooks");
427
63
 
428
- function readUltraContext(directory) {
64
+ function runHook(name, root, payload) {
429
65
  try {
430
- const raw = execFileSync(NODE_BINARY, [CONTEXT_CLI, "--discover", directory], {
431
- cwd: directory,
66
+ const stdout = execFileSync("python3", [path.join(HOOK_ROOT, name)], {
67
+ cwd: root,
68
+ input: JSON.stringify({ cwd: root, ...payload }),
432
69
  encoding: "utf8",
433
- stdio: ["ignore", "pipe", "pipe"],
434
- timeout: 5000,
435
- });
436
- const value = JSON.parse(raw);
437
- const root = value?.root;
438
- if (typeof root !== "string" || !root) return null;
439
- if (!value?.context || typeof value.text !== "string" || !value.text) {
440
- return { root, context: null, text: null };
441
- }
442
- return { root, context: value.context, text: value.text };
70
+ stdio: ["pipe", "pipe", "pipe"],
71
+ timeout: 10000,
72
+ }).trim();
73
+ return stdout ? JSON.parse(stdout) : {};
443
74
  } catch (error) {
444
- const detail = String(error?.stderr || error?.message || error);
445
- if (/RUNTIME_(?:STATE_CONFLICT|PATH_UNSAFE|ORPHAN_SIDECAR|ROOT_INVALID|AUTHORITY_MISMATCH)|both legacy .*runtime.*state\\.db/i.test(detail)) {
446
- throw new Error(detail.trim());
447
- }
448
- return null;
75
+ return { systemMessage: "Ultra hook failed open: " + String(error?.message || error) };
449
76
  }
450
77
  }
451
78
 
452
- function targetPaths(tool, args) {
453
- if (!args || typeof args !== "object") return [];
454
- const paths = [args.file_path, args.filePath, args.filepath, args.path]
455
- .filter((value) => typeof value === "string" && value.length > 0);
456
- const patch = args.patch ?? args.command;
457
- if (String(tool).toLowerCase() === "apply_patch" && typeof patch === "string") {
458
- const pattern = /^\\*\\*\\* (?:Add|Update|Delete) File: (.+?)\\s*$/gm;
459
- for (const match of patch.matchAll(pattern)) paths.push(match[1].trim());
460
- }
461
- return [...new Set(paths)];
462
- }
463
-
464
- function managedTaskRootForTarget(baseRoot, candidate) {
465
- const target = path.isAbsolute(candidate)
466
- ? path.resolve(candidate)
467
- : path.resolve(baseRoot, candidate);
468
- if (target === path.resolve(baseRoot, TEAM_TASK_LEDGER)
469
- || target === path.resolve(baseRoot, LIVE_TASK_PROJECTION)) return baseRoot;
470
- if (path.basename(target) !== "tasks.json") return null;
471
- const parent = path.dirname(target);
472
- if (path.basename(parent) === "tasks"
473
- && path.basename(path.dirname(parent)) === ".ultra") {
474
- return path.dirname(path.dirname(parent));
475
- }
476
- if (path.basename(parent) === "projections"
477
- && path.basename(path.dirname(parent)) === ".runtime"
478
- && path.basename(path.dirname(path.dirname(parent))) === ".ultra") {
479
- return path.dirname(path.dirname(path.dirname(parent)));
480
- }
481
- return null;
482
- }
483
-
484
- function protectsManagedTaskFile(baseRoot, candidate) {
485
- const targetRoot = managedTaskRootForTarget(baseRoot, candidate);
486
- if (!targetRoot) return false;
487
- return Boolean(readUltraContext(targetRoot));
79
+ function contextOf(output) {
80
+ return output?.hookSpecificOutput?.additionalContext || output?.systemMessage || "";
488
81
  }
489
82
 
490
83
  export const UltraBuilderProPlugin = async ({ directory, worktree }) => {
491
84
  const root = worktree || directory;
492
- let active = readUltraContext(root);
493
- const refresh = () => { active = readUltraContext(root); };
494
85
  return {
495
- event: async ({ event }) => {
496
- if (["session.created", "session.compacted", "session.idle"].includes(event?.type)) refresh();
497
- },
498
86
  "experimental.chat.system.transform": async (_input, output) => {
499
- refresh();
500
- if (active?.text) output.system.push(active.text);
87
+ const text = contextOf(runHook("session_context.py", root, { hook_event_name: "SessionStart" }));
88
+ if (text) output.system.push(text);
501
89
  },
502
90
  "experimental.session.compacting": async (_input, output) => {
503
- refresh();
504
- if (active?.text) output.context.push(active.text);
91
+ runHook("compact_context.py", root, { hook_event_name: "PreCompact" });
92
+ const text = contextOf(runHook("compact_context.py", root, {
93
+ hook_event_name: "SessionStart", source: "compact",
94
+ }));
95
+ if (text) output.context.push(text);
505
96
  },
506
97
  "tool.execute.before": async (input, output) => {
507
- refresh();
508
- const tool = String(input?.tool ?? "").toLowerCase();
509
- if (!["write", "edit", "apply_patch"].includes(tool)) return;
510
- if (targetPaths(tool, output?.args).some(
511
- (candidate) => protectsManagedTaskFile(root, candidate),
512
- )) {
513
- throw new Error(
514
- "Ultra Builder Pro refused a direct write to an MCP-managed task file. " +
515
- ".ultra/tasks/tasks.json is the MCP-published team checkpoint and " +
516
- ".ultra/.runtime/projections/tasks.json is the checkout-local DB view. " +
517
- "Use the Ultra MCP task or task.ledger tools."
518
- );
98
+ const tool = String(input?.tool || "").toLowerCase();
99
+ if (tool === "bash") {
100
+ const verdict = runHook("block_dangerous_commands.py", root, {
101
+ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: output?.args || {},
102
+ });
103
+ if (verdict?.decision === "block") throw new Error(verdict.reason);
104
+ }
105
+ if (["write", "edit", "grep", "apply_patch"].includes(tool)) {
106
+ runHook("mid_workflow_recall.py", root, {
107
+ hook_event_name: "PreToolUse", tool_name: input?.tool, tool_input: output?.args || {},
108
+ });
109
+ }
110
+ },
111
+ "tool.execute.after": async (input) => {
112
+ const tool = String(input?.tool || "").toLowerCase();
113
+ if (["write", "edit", "apply_patch"].includes(tool)) {
114
+ runHook("post_edit_guard.py", root, {
115
+ hook_event_name: "PostToolUse", tool_name: input?.tool, tool_input: input?.args || {},
116
+ });
519
117
  }
520
118
  },
521
- "tool.execute.after": async () => { refresh(); },
522
119
  };
523
120
  };
524
121
  `;
525
122
  }
526
123
 
124
+ function planRetiredCleanup(target) {
125
+ const files = [];
126
+ for (const dirName of ['commands', 'agents']) {
127
+ const dir = path.join(target, dirName);
128
+ if (!fs.existsSync(dir)) continue;
129
+ for (const file of fs.readdirSync(dir)) {
130
+ const candidate = path.join(dir, file);
131
+ if (!fs.statSync(candidate).isFile()) continue;
132
+ const text = fs.readFileSync(candidate, 'utf8');
133
+ if (text.includes(TEXT_MARKER)) files.push(candidate);
134
+ }
135
+ }
136
+ const configFile = path.join(target, 'opencode.json');
137
+ let config = null;
138
+ if (fs.existsSync(configFile)) {
139
+ const value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
140
+ if (value?.mcp?.[PLUGIN_NAME]) {
141
+ delete value.mcp[PLUGIN_NAME];
142
+ if (Object.keys(value.mcp).length === 0) delete value.mcp;
143
+ config = `${JSON.stringify(value, null, 2)}\n`;
144
+ }
145
+ }
146
+ return { config, configFile, files };
147
+ }
148
+
149
+ function cleanupRetired(target, plan = planRetiredCleanup(target)) {
150
+ for (const file of plan.files) {
151
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(TEXT_MARKER)) {
152
+ fs.unlinkSync(file);
153
+ }
154
+ }
155
+ for (const dirName of ['commands', 'agents']) {
156
+ const dir = path.join(target, dirName);
157
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
158
+ }
159
+ if (plan.config !== null) writeAtomic(plan.configFile, plan.config);
160
+ }
161
+
527
162
  function createInstallTransaction() {
528
163
  const changes = [];
529
164
  const backups = [];
@@ -561,10 +196,11 @@ function createInstallTransaction() {
561
196
  },
562
197
  removeFile(target) {
563
198
  if (!fs.existsSync(target)) return;
199
+ const link = fs.lstatSync(target).isSymbolicLink() ? fs.readlinkSync(target) : null;
564
200
  const stat = fs.statSync(target);
565
- const previous = { contents: fs.readFileSync(target), mode: stat.mode & 0o7777 };
201
+ const previous = { contents: fs.readFileSync(target), link, mode: stat.mode & 0o7777 };
566
202
  fs.unlinkSync(target);
567
- changes.push({ type: 'file', target, previous });
203
+ changes.push({ type: 'file', target, previous, removed: true });
568
204
  },
569
205
  removeTree(target) {
570
206
  if (!fs.existsSync(target)) return;
@@ -575,7 +211,7 @@ function createInstallTransaction() {
575
211
  },
576
212
  commit() {
577
213
  for (const backup of backups) {
578
- if (fs.existsSync(backup)) removeTree(backup);
214
+ if (fs.existsSync(backup)) removePath(backup);
579
215
  }
580
216
  changes.length = 0;
581
217
  backups.length = 0;
@@ -586,150 +222,69 @@ function createInstallTransaction() {
586
222
  try {
587
223
  if (change.type === 'file') {
588
224
  if (change.previous) {
589
- writeAtomic(change.target, change.previous.contents);
590
- fs.chmodSync(change.target, change.previous.mode);
225
+ if (change.removed && change.previous.link !== null) {
226
+ ensureDir(path.dirname(change.target));
227
+ fs.symlinkSync(change.previous.link, change.target);
228
+ } else {
229
+ writeAtomic(change.target, change.previous.contents);
230
+ fs.chmodSync(resolveTargetFile(change.target), change.previous.mode);
231
+ }
591
232
  } else if (fs.existsSync(change.target)) {
592
233
  fs.unlinkSync(change.target);
593
234
  }
594
235
  } else {
595
- if (!change.removed && fs.existsSync(change.target)) removeTree(change.target);
596
- if (change.backup && fs.existsSync(change.backup)) {
597
- fs.renameSync(change.backup, change.target);
598
- }
236
+ if (!change.removed && fs.existsSync(change.target)) removePath(change.target);
237
+ if (change.backup && fs.existsSync(change.backup)) fs.renameSync(change.backup, change.target);
599
238
  }
600
239
  } catch (error) {
601
240
  errors.push(error);
602
241
  }
603
242
  }
604
- if (errors.length > 0) {
605
- throw new AggregateError(errors, 'OpenCode install rollback failed');
606
- }
243
+ if (errors.length) throw new AggregateError(errors, 'OpenCode install rollback failed');
607
244
  },
608
245
  };
609
246
  }
610
247
 
611
- function buildStaging(repoRoot, staging, target) {
612
- const report = { target, copied: {}, config: { updated: false } };
613
- report.copied.commands = copyCommands(repoRoot, staging, target);
614
- report.copied.skills = copySkills(repoRoot, staging);
615
- report.copied.agents = copyAgents(repoRoot, staging);
616
- ensureDir(path.join(staging, 'plugins'));
617
- writeAtomic(path.join(staging, 'plugins', 'ultra-builder-pro.js'), pluginSource());
618
- report.copied.plugins = ['ultra-builder-pro.js'];
619
-
620
- const bundleRoot = path.join(staging, BUNDLE_DIR);
621
- ensureDir(bundleRoot);
622
- report.copied.workflows = copyPrivateWorkflows(repoRoot, bundleRoot);
623
- buildMcpRuntime(repoRoot, bundleRoot, { runtime: 'opencode' });
624
- markManaged(bundleRoot, { adapter: 'opencode', asset: 'runtime-bundle' });
625
- const configFile = path.join(target, 'opencode.json');
626
- const existing = readJsonSafe(configFile, { rescue: true });
627
- const mcp = { ...(existing.mcp || {}) };
628
- mcp[MCP_SERVER_NAME] = managedMcpEntry(target);
629
- const next = { ...existing, mcp };
630
- delete next[LEGACY_SENTINEL_KEY];
631
- writeAtomic(path.join(staging, 'opencode.json'), JSON.stringify(next, null, 2) + '\n');
632
- report.config.updated = true;
633
- const source = provenance.packageSource(repoRoot);
634
- const bundleAssets = provenance.assetRefsForTree('config', bundleRoot, {
635
- exclude: [PROVENANCE_FILE],
636
- }).map((asset) => ({ ...asset, path: path.join(BUNDLE_DIR, asset.path) }));
637
- const skillMarkers = discoverableSkillNames().map((name) => ({
638
- root: 'config', path: path.join('skills', name, '.ubp-managed'),
639
- }));
640
- const provenanceFile = path.join(bundleRoot, PROVENANCE_FILE);
641
- report.provenance = provenance.writeProvenance({
642
- file: provenanceFile,
643
- adapter: 'opencode',
644
- ...source,
645
- roots: { config: target },
646
- assetRoots: { config: staging },
647
- assets: [
648
- ...report.copied.commands.map((file) => ({ root: 'config', path: path.join('commands', file) })),
649
- ...report.copied.skills.map((file) => ({ root: 'config', path: path.join('skills', file) })),
650
- ...skillMarkers,
651
- ...report.copied.agents.map((file) => ({ root: 'config', path: path.join('agents', file) })),
652
- { root: 'config', path: path.join('plugins', 'ultra-builder-pro.js') },
653
- ...bundleAssets,
654
- ],
655
- contracts: {
656
- host_plugin: { root: 'config', path: path.join('plugins', 'ultra-builder-pro.js') },
657
- mcp_config: { root: 'config', path: 'opencode.json' },
658
- mcp_launcher: { root: 'config', path: path.join(BUNDLE_DIR, 'runtime', 'launch.cjs') },
659
- native_runtime: { root: 'config', path: path.join(BUNDLE_DIR, 'runtime', 'native-runtime.json') },
660
- context_envelope_helper: { root: 'config', path: path.join(BUNDLE_DIR, 'runtime', 'hook-context.cjs') },
661
- hook_event_helper: { root: 'config', path: path.join(BUNDLE_DIR, 'runtime', 'hook-event.cjs') },
662
- },
663
- });
664
- report.provenance.file = path.join(target, BUNDLE_DIR, PROVENANCE_FILE);
665
- return report;
248
+ function resolveTargetFile(file) {
249
+ return fs.lstatSync(file).isSymbolicLink() ? fs.realpathSync(file) : file;
666
250
  }
667
251
 
668
- function managedTextFiles(dir) {
669
- if (!fs.existsSync(dir)) return [];
670
- return fs.readdirSync(dir, { withFileTypes: true })
671
- .filter((entry) => entry.isFile() && isManagedTextFile(path.join(dir, entry.name)))
672
- .map((entry) => entry.name);
673
- }
252
+ function publishStaging({ staging, target, bundleRoot, skillRoot, pluginFile, skills, retiredCleanup }) {
253
+ const expected = new Set(skills);
254
+ const staleSkills = [];
255
+ if (fs.existsSync(skillRoot)) {
256
+ for (const entry of fs.readdirSync(skillRoot, { withFileTypes: true })) {
257
+ const candidate = path.join(skillRoot, entry.name);
258
+ if (entry.isDirectory() && !expected.has(entry.name) && isManaged(candidate)) staleSkills.push(candidate);
259
+ }
260
+ }
261
+ if (fs.existsSync(bundleRoot) && !isManaged(bundleRoot)) {
262
+ throw new Error(`refusing to replace unmanaged OpenCode bundle: ${bundleRoot}`);
263
+ }
264
+ for (const name of skills) {
265
+ const candidate = path.join(skillRoot, name);
266
+ if (fs.existsSync(candidate) && !isManaged(candidate)) {
267
+ throw new Error(`refusing to replace unmanaged OpenCode skill: ${candidate}`);
268
+ }
269
+ }
674
270
 
675
- function managedSkillDirs(root) {
676
- if (!fs.existsSync(root)) return [];
677
- return fs.readdirSync(root, { withFileTypes: true })
678
- .filter((entry) => entry.isDirectory() && isManaged(path.join(root, entry.name)))
679
- .map((entry) => entry.name);
680
- }
271
+ const stagedConfig = path.join(staging, 'opencode.json');
272
+ const stagedPlugin = path.join(staging, `${PLUGIN_NAME}.js`);
273
+ writeAtomic(stagedPlugin, pluginSource());
274
+ if (retiredCleanup.config !== null) writeAtomic(stagedConfig, retiredCleanup.config);
681
275
 
682
- function publishStaging(staging, target, report) {
683
276
  const transaction = createInstallTransaction();
684
- const commandNames = report.copied.commands;
685
- const agentNames = report.copied.agents;
686
- const skillNames = discoverableSkillNames();
687
277
  try {
688
- for (const file of commandNames) {
689
- transaction.replaceFile(
690
- path.join(staging, 'commands', file),
691
- path.join(target, 'commands', file),
692
- );
693
- }
694
- for (const stale of managedTextFiles(path.join(target, 'commands'))) {
695
- if (!commandNames.includes(stale)) {
696
- transaction.removeFile(path.join(target, 'commands', stale));
697
- }
698
- }
699
- for (const name of skillNames) {
700
- transaction.replaceTree(
701
- path.join(staging, 'skills', name),
702
- path.join(target, 'skills', name),
703
- );
704
- }
705
- for (const stale of managedSkillDirs(path.join(target, 'skills'))) {
706
- if (!skillNames.includes(stale)) {
707
- transaction.removeTree(path.join(target, 'skills', stale));
708
- }
278
+ transaction.replaceTree(path.join(staging, 'bundle'), bundleRoot);
279
+ for (const name of skills) {
280
+ transaction.replaceTree(path.join(staging, 'skills', name), path.join(skillRoot, name));
709
281
  }
710
- for (const file of agentNames) {
711
- transaction.replaceFile(
712
- path.join(staging, 'agents', file),
713
- path.join(target, 'agents', file),
714
- );
282
+ for (const candidate of staleSkills) transaction.removeTree(candidate);
283
+ transaction.replaceFile(stagedPlugin, pluginFile);
284
+ for (const file of retiredCleanup.files) transaction.removeFile(file);
285
+ if (retiredCleanup.config !== null) {
286
+ transaction.replaceFile(stagedConfig, retiredCleanup.configFile);
715
287
  }
716
- for (const stale of managedTextFiles(path.join(target, 'agents'))) {
717
- if (!agentNames.includes(stale)) {
718
- transaction.removeFile(path.join(target, 'agents', stale));
719
- }
720
- }
721
- transaction.replaceFile(
722
- path.join(staging, 'plugins', 'ultra-builder-pro.js'),
723
- path.join(target, 'plugins', 'ultra-builder-pro.js'),
724
- );
725
- transaction.replaceTree(
726
- path.join(staging, BUNDLE_DIR),
727
- path.join(target, BUNDLE_DIR),
728
- );
729
- transaction.replaceFile(
730
- path.join(staging, 'opencode.json'),
731
- path.join(target, 'opencode.json'),
732
- );
733
288
  transaction.commit();
734
289
  } catch (error) {
735
290
  try {
@@ -739,182 +294,115 @@ function publishStaging(staging, target, report) {
739
294
  }
740
295
  throw error;
741
296
  }
297
+
298
+ for (const dirName of ['commands', 'agents']) {
299
+ const directory = path.join(target, dirName);
300
+ if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) fs.rmdirSync(directory);
301
+ }
742
302
  }
743
303
 
744
304
  function install(ctx = {}) {
745
305
  const target = resolveTarget(ctx);
746
306
  const repoRoot = resolveRepoRoot(ctx);
307
+ const bundleRoot = path.join(target, BUNDLE_DIR);
308
+ const skillRoot = path.join(target, 'skills');
309
+ const pluginFile = path.join(target, 'plugins', `${PLUGIN_NAME}.js`);
310
+ const cleanupAbsent = managedMetadata(bundleRoot)?.cleanup_absent
311
+ || captureAbsent(target, ['.', 'skills', 'plugins']);
747
312
  ensureDir(target);
748
- preflightAssets(repoRoot, target);
749
- const staging = fs.mkdtempSync(path.join(path.dirname(target), '.ubp-opencode-staging-'));
313
+
314
+ if (fs.existsSync(pluginFile) && !fs.readFileSync(pluginFile, 'utf8').startsWith(PLUGIN_MARKER)) {
315
+ throw new Error(`refusing to replace unmanaged OpenCode plugin: ${pluginFile}`);
316
+ }
317
+ const retiredCleanup = planRetiredCleanup(target);
318
+ const staging = fs.mkdtempSync(path.join(target, `.${PLUGIN_NAME}-staging-`));
319
+ const stagedBundle = path.join(staging, 'bundle');
320
+ const stagedSkills = path.join(staging, 'skills');
321
+ ensureDir(stagedBundle);
322
+ ensureDir(stagedSkills);
750
323
  try {
751
- const report = buildStaging(repoRoot, staging, target);
752
- publishStaging(staging, target, report);
753
- return report;
754
- } finally {
755
- if (fs.existsSync(staging)) removeTree(staging);
324
+ const skills = copySkills({ runtime: 'opencode', repoRoot, skillRoot: stagedSkills });
325
+ for (const name of skills) markManaged(path.join(stagedSkills, name), { adapter: 'opencode', asset: 'skill', name });
326
+ copyHooks({
327
+ runtime: 'opencode', repoRoot, hookRoot: path.join(stagedBundle, 'hooks'),
328
+ hooksManifest: buildHooksManifest(),
329
+ });
330
+ markManaged(stagedBundle, { adapter: 'opencode', plugin: PLUGIN_NAME });
331
+ writePluginProvenance({
332
+ adapter: 'opencode', repoRoot, stagingRoot: stagedBundle, publishedRoot: bundleRoot,
333
+ contracts: { hooks_manifest: { root: 'plugin', path: 'hooks/hooks.json' } },
334
+ });
335
+ markManaged(stagedBundle, { cleanup_absent: cleanupAbsent });
336
+
337
+ publishStaging({
338
+ staging, target, bundleRoot, skillRoot, pluginFile, skills, retiredCleanup,
339
+ });
340
+ removePath(staging);
341
+ return {
342
+ target: bundleRoot,
343
+ pluginRoot: bundleRoot,
344
+ skillRoot,
345
+ hookRoot: path.join(bundleRoot, 'hooks'),
346
+ pluginFile,
347
+ copied: { skills },
348
+ };
349
+ } catch (error) {
350
+ if (fs.existsSync(staging)) removePath(staging);
351
+ pruneCreatedEmpty(target, cleanupAbsent);
352
+ throw error;
756
353
  }
757
354
  }
758
355
 
759
356
  function doctor(ctx = {}) {
760
357
  const target = resolveTarget(ctx);
761
- const repoRoot = resolveRepoRoot(ctx);
762
- const source = provenance.packageSource(repoRoot);
763
- const report = provenance.inspectProvenance({
764
- file: path.join(target, BUNDLE_DIR, PROVENANCE_FILE),
765
- expectedAdapter: 'opencode',
766
- expectedPackageVersion: source.packageInfo.version,
358
+ const bundleRoot = path.join(target, BUNDLE_DIR);
359
+ const pluginFile = path.join(target, 'plugins', `${PLUGIN_NAME}.js`);
360
+ const report = inspectPlugin({
361
+ adapter: 'opencode', repoRoot: resolveRepoRoot(ctx), pluginRoot: bundleRoot,
362
+ skillRoot: path.join(target, 'skills'), hookRoot: path.join(bundleRoot, 'hooks'),
363
+ manifestFile: pluginFile,
767
364
  });
768
- const configFile = path.join(target, 'opencode.json');
769
- const expectedLauncher = path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs');
770
- let registrationOk = false;
365
+ let configClean = true;
771
366
  try {
772
- const config = readJsonSafe(configFile);
773
- const entry = config.mcp && config.mcp[MCP_SERVER_NAME];
774
- const command = mcpCommand(expectedLauncher);
775
- registrationOk = entry?.type === 'local'
776
- && entry.enabled === true
777
- && Array.isArray(entry.command)
778
- && JSON.stringify(entry.command) === JSON.stringify([
779
- command.command,
780
- ...command.args,
781
- ]);
782
- } catch (error) {
783
- report.issues.push({ code: 'MCP_REGISTRATION_INVALID', message: error.message });
784
- }
785
- if (!registrationOk && !report.issues.some((entry) => entry.code === 'MCP_REGISTRATION_INVALID')) {
786
- report.issues.push({
787
- code: 'MCP_REGISTRATION_INVALID',
788
- path: configFile,
789
- expected_launcher: expectedLauncher,
790
- });
791
- }
792
- report.checks.registration = { status: registrationOk ? 'pass' : 'fail' };
793
- if (shouldRunHostCli(ctx)) {
794
- try {
795
- const host = inspectOpenCodeHost(ctx);
796
- report.checks.host_config = { status: host.configOk ? 'pass' : 'fail' };
797
- report.checks.host_mcp = { status: host.mcpOk ? 'pass' : 'fail' };
798
- if (!host.configOk) {
799
- report.issues.push({
800
- code: 'HOST_CONFIG_NOT_CONSUMED',
801
- path: configFile,
802
- });
803
- }
804
- if (!host.mcpOk) {
805
- report.issues.push({
806
- code: 'HOST_MCP_NOT_CONNECTED',
807
- server: MCP_SERVER_NAME,
808
- });
809
- }
810
- } catch (error) {
811
- report.checks.host_config = { status: 'fail' };
812
- report.checks.host_mcp = { status: 'fail' };
813
- report.issues.push({
814
- code: error.code === 'HOST_CLI_TIMEOUT'
815
- ? 'HOST_CLI_TIMEOUT'
816
- : 'HOST_CLI_INSPECTION_FAILED',
817
- message: error.message,
818
- });
367
+ const configFile = path.join(target, 'opencode.json');
368
+ if (fs.existsSync(configFile)) {
369
+ const value = JSON.parse(fs.readFileSync(configFile, 'utf8'));
370
+ configClean = !value?.mcp?.[PLUGIN_NAME];
819
371
  }
820
- }
821
- return applyNativeDoctor(report, path.join(target, BUNDLE_DIR, 'runtime'));
372
+ } catch { configClean = false; }
373
+ report.checks.no_mcp_registration = { status: configClean ? 'pass' : 'fail' };
374
+ if (!configClean) report.issues.push({ code: 'RETIRED_MCP_REGISTRATION_PRESENT' });
375
+ report.status = report.issues.length === 0 ? 'healthy' : 'degraded';
376
+ return report;
822
377
  }
823
378
 
824
379
  function uninstall(ctx = {}) {
825
380
  const target = resolveTarget(ctx);
826
- const report = {
827
- target,
828
- removed: {},
829
- config: { updated: false },
830
- issues: [],
381
+ const pluginRoot = path.join(target, BUNDLE_DIR);
382
+ const cleanupAbsent = managedMetadata(pluginRoot)?.cleanup_absent || [];
383
+ const skillRoot = path.join(target, 'skills');
384
+ const removedSkills = [];
385
+ for (const name of skillsForRuntime('opencode')) {
386
+ if (removeManaged(path.join(skillRoot, name), `OpenCode skill ${name}`)) removedSkills.push(name);
387
+ }
388
+ const pluginFile = path.join(target, 'plugins', `${PLUGIN_NAME}.js`);
389
+ if (fs.existsSync(pluginFile) && fs.readFileSync(pluginFile, 'utf8').startsWith(PLUGIN_MARKER)) fs.unlinkSync(pluginFile);
390
+ cleanupRetired(target);
391
+ const removedBundle = removeManaged(pluginRoot, 'OpenCode bundle');
392
+ const cleaned = pruneCreatedEmpty(target, cleanupAbsent);
393
+ return {
394
+ target: pluginRoot, pluginRoot,
395
+ removed: { bundle: removedBundle, skills: removedSkills },
396
+ cleaned,
831
397
  };
832
- const pluginFile = path.join(target, 'plugins', 'ultra-builder-pro.js');
833
- const bundleRoot = path.join(target, BUNDLE_DIR);
834
- const pluginOwned = fs.existsSync(pluginFile)
835
- && fs.readFileSync(pluginFile, 'utf8').startsWith(PLUGIN_MARKER);
836
- const bundleOwned = fs.existsSync(bundleRoot) && isManaged(bundleRoot);
837
- const configFile = path.join(target, 'opencode.json');
838
- if (fs.existsSync(configFile)) {
839
- const existing = readJsonSafe(configFile);
840
- const currentMcp = existing.mcp?.[MCP_SERVER_NAME];
841
- const expectedMcp = managedMcpEntry(target);
842
- const mcpOwned = hasManagedMcpIdentity(currentMcp, target);
843
- if (currentMcp !== undefined && !mcpOwned) {
844
- report.config.skipped = true;
845
- report.config.reason = 'ownership-conflict';
846
- report.issues.push({
847
- code: 'MCP_OWNERSHIP_CONFLICT',
848
- path: configFile,
849
- server: MCP_SERVER_NAME,
850
- expected: expectedMcp,
851
- actual: currentMcp,
852
- });
853
- return report;
854
- }
855
- if (mcpOwned || existing[LEGACY_SENTINEL_KEY]) {
856
- const mcp = { ...(existing.mcp || {}) };
857
- if (mcpOwned) delete mcp[MCP_SERVER_NAME];
858
- const next = { ...existing, mcp };
859
- delete next[LEGACY_SENTINEL_KEY];
860
- if (Object.keys(next.mcp || {}).length === 0) delete next.mcp;
861
- if (!isDeepStrictEqual(next, existing)) {
862
- writeAtomic(configFile, JSON.stringify(next, null, 2) + '\n');
863
- report.config.updated = true;
864
- }
865
- }
866
- }
867
- if (pluginOwned) {
868
- fs.unlinkSync(pluginFile);
869
- report.removed.plugin = true;
870
- }
871
-
872
- for (const sub of ['commands', 'agents']) {
873
- const dir = path.join(target, sub);
874
- const removed = [];
875
- if (fs.existsSync(dir)) {
876
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
877
- if (!entry.isFile()) continue;
878
- const file = path.join(dir, entry.name);
879
- if (!isManagedTextFile(file)) continue;
880
- fs.unlinkSync(file);
881
- removed.push(entry.name);
882
- }
883
- removeEmptyDir(dir);
884
- }
885
- if (removed.length > 0) report.removed[sub] = removed;
886
- }
887
-
888
- const skillsRoot = path.join(target, 'skills');
889
- const skills = [];
890
- if (fs.existsSync(skillsRoot)) {
891
- for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
892
- if (!entry.isDirectory()) continue;
893
- const dir = path.join(skillsRoot, entry.name);
894
- if (!isManaged(dir)) continue;
895
- removeTree(dir);
896
- skills.push(entry.name);
897
- }
898
- removeEmptyDir(skillsRoot);
899
- }
900
- if (skills.length > 0) report.removed.skills = skills;
901
-
902
- if (bundleOwned) {
903
- removeTree(bundleRoot);
904
- report.removed.runtime = true;
905
- }
906
- return report;
907
398
  }
908
399
 
909
400
  module.exports = {
910
401
  name: 'opencode',
911
- MCP_SERVER_NAME,
912
- SOURCE_TAG,
913
- BUNDLE_DIR,
914
- pluginSource,
915
- inspectOpenCodeHost,
402
+ PLUGIN_NAME,
403
+ buildHooksManifest,
916
404
  resolveTarget,
917
405
  install,
918
- uninstall,
919
406
  doctor,
407
+ uninstall,
920
408
  };