ultra-builder-pro-cli 0.25.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.ultra-template/changes/abandoned/.gitkeep +0 -0
  2. package/.ultra-template/contexts/TEMPLATE.md +94 -0
  3. package/.ultra-template/decisions/.gitkeep +1 -0
  4. package/.ultra-template/evidence/.gitkeep +1 -0
  5. package/.ultra-template/north-star.md +46 -0
  6. package/.ultra-template/research/.gitkeep +1 -0
  7. package/.ultra-template/specs/discovery.md +2 -2
  8. package/.ultra-template/tasks.json +3 -0
  9. package/.ultra-template/test-report.json +27 -0
  10. package/CHANGELOG.md +107 -1
  11. package/README.md +217 -579
  12. package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
  13. package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
  14. package/adapters/_shared/file-ops.cjs +76 -2
  15. package/adapters/_shared/host-profile.cjs +97 -0
  16. package/adapters/_shared/plugin-core.cjs +204 -0
  17. package/adapters/_shared/runtime-assets.cjs +31 -96
  18. package/adapters/claude.js +92 -291
  19. package/adapters/codex.js +309 -842
  20. package/adapters/grok.js +143 -1469
  21. package/adapters/kimi.js +124 -1053
  22. package/adapters/opencode.js +230 -742
  23. package/bin/delegate-worker.cjs +328 -0
  24. package/bin/delegate.cjs +463 -0
  25. package/bin/install.js +38 -11
  26. package/docs/ARCHITECTURE.md +125 -216
  27. package/docs/ARTIFACT-AUTHORITY.md +90 -268
  28. package/docs/DECISIONS.md +77 -87
  29. package/docs/PHILOSOPHY.md +208 -0
  30. package/docs/PLUGIN-ISOLATION-CONTRACT.md +65 -39
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +140 -155
  32. package/docs/SKILL-AUTHORING.md +94 -73
  33. package/docs/WORKFLOW-LIFECYCLE.md +97 -189
  34. package/hooks/README.md +19 -37
  35. package/hooks/_common.py +113 -0
  36. package/hooks/adapters/codex.py +9 -16
  37. package/hooks/adapters/grok.py +5 -5
  38. package/hooks/adapters/kimi.py +8 -14
  39. package/hooks/block_dangerous_commands.py +201 -0
  40. package/hooks/compact_context.py +65 -0
  41. package/hooks/mid_workflow_recall.py +25 -0
  42. package/hooks/post_edit_guard.py +105 -0
  43. package/hooks/session_context.py +30 -0
  44. package/package.json +17 -52
  45. package/skills/ultra-change/SKILL.md +115 -50
  46. package/skills/ultra-change/references/change-contract.md +68 -3
  47. package/skills/ultra-delegate/SKILL.md +89 -0
  48. package/skills/ultra-delegate/references/delegation-contract.md +59 -0
  49. package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
  50. package/skills/ultra-deliver/SKILL.md +98 -42
  51. package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
  52. package/skills/ultra-dev/SKILL.md +115 -62
  53. package/skills/ultra-dev/references/debugging.md +22 -0
  54. package/skills/ultra-domain-modeling/SKILL.md +79 -0
  55. package/skills/ultra-grilling/SKILL.md +118 -0
  56. package/skills/ultra-grilling/references/reframing.md +72 -0
  57. package/skills/ultra-init/SKILL.md +102 -37
  58. package/skills/ultra-init/references/brownfield-adoption.md +33 -83
  59. package/skills/ultra-init/scripts/init_project.cjs +84 -0
  60. package/skills/ultra-plan/SKILL.md +101 -68
  61. package/skills/ultra-research/SKILL.md +68 -48
  62. package/skills/ultra-research/references/00-problem-validation.md +5 -5
  63. package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
  64. package/skills/ultra-research/references/02-market-assessment.md +5 -4
  65. package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
  66. package/skills/ultra-research/references/04-product-strategy.md +3 -3
  67. package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
  68. package/skills/ultra-research/references/10-user-personas.md +3 -2
  69. package/skills/ultra-research/references/11-user-scenarios.md +3 -3
  70. package/skills/ultra-research/references/20-user-stories.md +4 -3
  71. package/skills/ultra-research/references/21-features-scope.md +3 -2
  72. package/skills/ultra-research/references/22-success-metrics.md +3 -3
  73. package/skills/ultra-research/references/30-architecture-context.md +3 -3
  74. package/skills/ultra-research/references/31-solution-strategy.md +3 -3
  75. package/skills/ultra-research/references/32-building-blocks.md +3 -3
  76. package/skills/ultra-research/references/40-deployment.md +3 -3
  77. package/skills/ultra-research/references/41-quality-risks.md +3 -3
  78. package/skills/ultra-research/references/99-synthesis.md +7 -7
  79. package/skills/ultra-review/SKILL.md +84 -32
  80. package/skills/ultra-review/references/code.md +17 -0
  81. package/skills/ultra-review/references/comments.md +12 -0
  82. package/skills/ultra-review/references/design.md +12 -0
  83. package/skills/ultra-review/references/errors.md +13 -0
  84. package/skills/ultra-review/references/spec.md +13 -0
  85. package/skills/ultra-review/references/tests.md +12 -0
  86. package/skills/ultra-review/references/worker-packet.md +40 -0
  87. package/skills/ultra-review/scripts/review_wait.py +26 -15
  88. package/skills/ultra-status/SKILL.md +61 -36
  89. package/skills/ultra-tdd/SKILL.md +81 -0
  90. package/skills/ultra-tdd/references/templates/README.md +20 -0
  91. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
  92. package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
  93. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
  94. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
  95. package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
  96. package/skills/ultra-tdd/references/test-execution.md +18 -0
  97. package/skills/ultra-test/SKILL.md +75 -30
  98. package/skills/ultra-test/references/export-syntax.md +18 -0
  99. package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
  100. package/skills/ultra-think/SKILL.md +49 -27
  101. package/skills/ultra-think/references/autonomy-boundary.md +48 -0
  102. package/skills/ultra-think/references/interaction-boundary.md +18 -33
  103. package/.ultra-template/docs/research/README.md +0 -55
  104. package/.ultra-template/reports/templates/delivery-report.json +0 -17
  105. package/.ultra-template/reports/templates/test-report.json +0 -18
  106. package/.ultra-template/tasks/tasks.json +0 -15
  107. package/.ultra-template/templates/task-context.md +0 -19
  108. package/adapters/_shared/codex-assets.cjs +0 -725
  109. package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
  110. package/adapters/_shared/interaction-contract.cjs +0 -129
  111. package/adapters/_shared/path-rewrite.cjs +0 -79
  112. package/adapters/_shared/runtime-native.cjs +0 -132
  113. package/adapters/_shared/settings-merge.cjs +0 -76
  114. package/agents/code-reviewer.md +0 -42
  115. package/agents/debugger.md +0 -40
  116. package/agents/review-code.md +0 -49
  117. package/agents/review-comments.md +0 -38
  118. package/agents/review-coordinator.md +0 -56
  119. package/agents/review-design.md +0 -40
  120. package/agents/review-errors.md +0 -43
  121. package/agents/review-spec.md +0 -39
  122. package/agents/review-tests.md +0 -40
  123. package/agents/tdd-runner.md +0 -38
  124. package/bin/orchestrator.js +0 -383
  125. package/commands/ultra-change.md +0 -10
  126. package/commands/ultra-deliver.md +0 -10
  127. package/commands/ultra-dev.md +0 -10
  128. package/commands/ultra-doctor.md +0 -10
  129. package/commands/ultra-init.md +0 -10
  130. package/commands/ultra-plan.md +0 -10
  131. package/commands/ultra-research.md +0 -10
  132. package/commands/ultra-review.md +0 -10
  133. package/commands/ultra-status.md +0 -10
  134. package/commands/ultra-test.md +0 -10
  135. package/commands/ultra-think.md +0 -10
  136. package/docs/AGENT-CONTEXT.md +0 -191
  137. package/docs/COMMIT-HASH-BACKFILL.md +0 -51
  138. package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
  139. package/docs/ROADMAP.md +0 -101
  140. package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
  141. package/hooks/active_task_context.py +0 -152
  142. package/hooks/context_envelope.py +0 -100
  143. package/hooks/health_check.py +0 -196
  144. package/hooks/pre_stop_check.py +0 -66
  145. package/hooks/runtime_paths.py +0 -396
  146. package/hooks/subagent_tracker.py +0 -88
  147. package/hooks/workflow_checkpoint.py +0 -76
  148. package/hooks/workflow_context.py +0 -48
  149. package/hooks/workflow_resume.py +0 -84
  150. package/mcp-server/hook-context.cjs +0 -38
  151. package/mcp-server/hook-event.cjs +0 -34
  152. package/mcp-server/lib/archive-journal.cjs +0 -1119
  153. package/mcp-server/lib/archive-mutation-worker.py +0 -365
  154. package/mcp-server/lib/artifact-registry.cjs +0 -1232
  155. package/mcp-server/lib/baseline-workflow.cjs +0 -1193
  156. package/mcp-server/lib/canonical-json.cjs +0 -19
  157. package/mcp-server/lib/change-authority.cjs +0 -26
  158. package/mcp-server/lib/change-packet.cjs +0 -790
  159. package/mcp-server/lib/change-workflow.cjs +0 -65
  160. package/mcp-server/lib/context-envelope.cjs +0 -581
  161. package/mcp-server/lib/context-paths.cjs +0 -142
  162. package/mcp-server/lib/decision-records.cjs +0 -423
  163. package/mcp-server/lib/delivery-transaction.cjs +0 -471
  164. package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
  165. package/mcp-server/lib/doctor.cjs +0 -627
  166. package/mcp-server/lib/git-bootstrap.cjs +0 -651
  167. package/mcp-server/lib/init-project.cjs +0 -1021
  168. package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
  169. package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
  170. package/mcp-server/lib/managed-file-write.cjs +0 -172
  171. package/mcp-server/lib/plan-checkpoint.cjs +0 -177
  172. package/mcp-server/lib/plan-publication-worker.cjs +0 -830
  173. package/mcp-server/lib/plan-store.cjs +0 -794
  174. package/mcp-server/lib/prd-parser.cjs +0 -83
  175. package/mcp-server/lib/pricing.cjs +0 -44
  176. package/mcp-server/lib/project-context.cjs +0 -178
  177. package/mcp-server/lib/projector.cjs +0 -386
  178. package/mcp-server/lib/provider-refs.cjs +0 -42
  179. package/mcp-server/lib/runtime-paths.cjs +0 -1714
  180. package/mcp-server/lib/runtime-state.cjs +0 -222
  181. package/mcp-server/lib/safe-project-file.cjs +0 -385
  182. package/mcp-server/lib/stage-checkpoints.cjs +0 -463
  183. package/mcp-server/lib/state-authority.cjs +0 -95
  184. package/mcp-server/lib/state-db.cjs +0 -1580
  185. package/mcp-server/lib/state-ops.cjs +0 -1428
  186. package/mcp-server/lib/task-contract.cjs +0 -404
  187. package/mcp-server/lib/task-expander.cjs +0 -105
  188. package/mcp-server/lib/task-ledger.cjs +0 -1964
  189. package/mcp-server/lib/telemetry.cjs +0 -85
  190. package/mcp-server/lib/topo.cjs +0 -131
  191. package/mcp-server/lib/ultra-facade.cjs +0 -2684
  192. package/mcp-server/lib/worker-packet.cjs +0 -358
  193. package/mcp-server/server.cjs +0 -437
  194. package/orchestrator/auto-merge.cjs +0 -128
  195. package/orchestrator/daemon.cjs +0 -264
  196. package/orchestrator/dispatch-rules.cjs +0 -78
  197. package/orchestrator/parallel-orchestrator.cjs +0 -903
  198. package/orchestrator/planner/plan-builder.cjs +0 -125
  199. package/orchestrator/recovery.cjs +0 -188
  200. package/orchestrator/session-close-journal-worker.cjs +0 -5
  201. package/orchestrator/session-close-journal.cjs +0 -1014
  202. package/orchestrator/session-runner.cjs +0 -1153
  203. package/orchestrator/worktree-manager.cjs +0 -282
  204. package/settings.json +0 -7
  205. package/skills/cc-collab/SKILL.md +0 -42
  206. package/skills/code-review-expert/SKILL.md +0 -43
  207. package/skills/code-review-expert/references/correctness-reliability.md +0 -30
  208. package/skills/code-review-expert/references/design-boundaries.md +0 -26
  209. package/skills/code-review-expert/references/removal-plan.md +0 -24
  210. package/skills/codex-collab/SKILL.md +0 -49
  211. package/skills/codex-collab/evals/evals.json +0 -35
  212. package/skills/integration-rules/SKILL.md +0 -32
  213. package/skills/security-rules/SKILL.md +0 -38
  214. package/skills/testing-rules/SKILL.md +0 -32
  215. package/skills/ultra-doctor/SKILL.md +0 -41
  216. package/skills/ultra-plan/references/semantic-preflight.md +0 -20
  217. package/skills/ultra-verify/SKILL.md +0 -46
  218. package/skills/ultra-verify/evals/evals.json +0 -46
  219. package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
  220. package/skills/ultra-verify/references/evidence-status.md +0 -21
  221. package/skills/ultra-verify/scripts/verify_wait.py +0 -94
  222. package/spec/cli-protocol.md +0 -102
  223. package/spec/mcp-tools.yaml +0 -312
  224. package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
  225. package/spec/schemas/change-delta.v1.schema.json +0 -153
  226. package/spec/schemas/command-manifest.schema.json +0 -29
  227. package/spec/schemas/context-file.v4.5.schema.json +0 -54
  228. package/spec/schemas/delivery-report.v1.schema.json +0 -66
  229. package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
  230. package/spec/schemas/mcp-tools.schema.json +0 -136
  231. package/spec/schemas/skill-manifest.schema.json +0 -24
  232. package/spec/schemas/state-db.sql +0 -641
  233. package/spec/schemas/task-ledger.v2.schema.json +0 -296
  234. package/spec/schemas/tasks.v4.5.schema.json +0 -96
  235. package/spec/schemas/test-report.v1.schema.json +0 -225
  236. package/ultra-tools/cli.cjs +0 -129
  237. package/ultra-tools/commands/db.cjs +0 -226
  238. package/ultra-tools/commands/legacy-memory.cjs +0 -159
  239. package/ultra-tools/commands/migrate.cjs +0 -1386
  240. package/ultra-tools/commands/session.cjs +0 -213
  241. package/ultra-tools/commands/status.cjs +0 -559
  242. package/ultra-tools/commands/system.cjs +0 -498
  243. 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
  };