opencode-onboard 0.3.1 → 0.4.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 (79) hide show
  1. package/README.md +266 -214
  2. package/content/.agents/agents/basic-engineer.md +30 -0
  3. package/content/.agents/agents/devops-manager.md +38 -29
  4. package/content/.agents/session-log.json +41 -0
  5. package/content/.agents/skills/ob-default/SKILL.md +21 -0
  6. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
  7. package/content/.agents/skills/ob-global/SKILL.md +49 -0
  8. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
  9. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
  10. package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
  11. package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
  12. package/content/.opencode/commands/opsx-apply.md +50 -33
  13. package/content/.opencode/opencode.json +3 -3
  14. package/content/.opencode/plugins/session-log.js +1 -1
  15. package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
  16. package/content/AGENTS.md +95 -141
  17. package/content/skills-lock.json +4 -0
  18. package/package.json +6 -1
  19. package/src/index.js +112 -191
  20. package/src/presets/browser.json +18 -0
  21. package/src/presets/clean.json +21 -0
  22. package/src/presets/models.json +33 -0
  23. package/src/presets/optimization.json +22 -0
  24. package/src/presets/platforms.json +29 -2
  25. package/src/presets/quota.json +14 -0
  26. package/src/presets/source.json +17 -0
  27. package/src/steps/browser/browser.test.js +81 -0
  28. package/src/steps/{install-browser.js → browser/index.js} +12 -15
  29. package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
  30. package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
  31. package/src/steps/{patch-agents-md.js → copy/agents.js} +41 -20
  32. package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
  33. package/src/steps/copy/index.js +33 -0
  34. package/src/steps/copy/skills.js +55 -0
  35. package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
  36. package/src/steps/metadata/metadata.test.js +96 -0
  37. package/src/steps/models/format.js +60 -0
  38. package/src/steps/models/format.test.js +74 -0
  39. package/src/steps/models/index.js +52 -0
  40. package/src/steps/models/write.js +54 -0
  41. package/src/steps/models/write.test.js +119 -0
  42. package/src/steps/{init-openspec.js → openspec/ensemble.js} +27 -61
  43. package/src/steps/openspec/ensemble.test.js +79 -0
  44. package/src/steps/openspec/index.js +32 -0
  45. package/src/steps/optimization/caveman-guidance.js +11 -0
  46. package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
  47. package/src/steps/optimization/global.js +64 -0
  48. package/src/steps/optimization/index.js +101 -0
  49. package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
  50. package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
  51. package/src/steps/platform/index.js +81 -0
  52. package/src/steps/platform/platform.test.js +129 -0
  53. package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
  54. package/src/steps/source/source.test.js +89 -0
  55. package/src/utils/__tests__/copy.test.js +12 -5
  56. package/src/utils/copy.js +4 -24
  57. package/src/utils/exec-spinner.js +47 -0
  58. package/src/utils/exec.js +120 -162
  59. package/src/utils/models-cache.js +25 -68
  60. package/src/utils/models-pricing.js +42 -0
  61. package/src/utils/models-pricing.test.js +94 -0
  62. package/content/.agents/agents/back-engineer.md +0 -87
  63. package/content/.agents/agents/front-engineer.md +0 -86
  64. package/content/.agents/agents/infra-engineer.md +0 -85
  65. package/content/.agents/agents/quality-engineer.md +0 -86
  66. package/content/.agents/agents/security-auditor.md +0 -86
  67. package/src/steps/__tests__/check-env.test.js +0 -70
  68. package/src/steps/__tests__/check-platform.test.js +0 -104
  69. package/src/steps/__tests__/check-rtk.test.js +0 -38
  70. package/src/steps/__tests__/choose-platform.test.js +0 -38
  71. package/src/steps/check-env.js +0 -26
  72. package/src/steps/check-platform.js +0 -80
  73. package/src/steps/check-rtk.js +0 -38
  74. package/src/steps/choose-models.js +0 -163
  75. package/src/steps/choose-platform.js +0 -22
  76. package/src/steps/choose-skills-provider.js +0 -79
  77. package/src/steps/copy-content.js +0 -89
  78. package/src/steps/enable-caveman-guidance.js +0 -93
  79. package/src/steps/token-optimization.js +0 -59
@@ -0,0 +1,52 @@
1
+ import path from 'path'
2
+ import { header, info, success, warn } from '../../utils/exec.js'
3
+ import { fetchModels } from '../../utils/models-cache.js'
4
+ import { buildDisplayModels, modelsPreset, pickModel } from './format.js'
5
+ import { writeModelsToConfigs } from './write.js'
6
+
7
+ export async function chooseModels() {
8
+ header('Step 7, Choose models');
9
+
10
+ info('Fetching available models from models.dev...');
11
+ const { models: rawModels, source } = await fetchModels();
12
+
13
+ if (!rawModels) {
14
+ warn('Could not fetch models (offline and no cache). Skipping model selection.');
15
+ warn('Set models later in .agents/agents/<name>.md and .opencode/opencode.json');
16
+ return;
17
+ }
18
+
19
+ if (source === 'stale-cache') {
20
+ warn('Network unavailable, using cached model list (may be outdated).');
21
+ } else if (source === 'cache') {
22
+ info('Using cached model list (refreshes weekly).');
23
+ }
24
+
25
+ const models = buildDisplayModels(rawModels);
26
+ success(`${models.length} models available`);
27
+ console.log();
28
+ info('Cost indicators: [$] cheap [$$] mid [$$$] expensive');
29
+ info('Type to search. Change selections later in .agents/agents/ and .opencode/opencode.json');
30
+ console.log();
31
+
32
+ for (const line of modelsPreset.roles.plan.info) info(line);
33
+ const planModel = await pickModel(modelsPreset.roles.plan.prompt, models);
34
+ console.log();
35
+
36
+ for (const line of modelsPreset.roles.build.info) info(line);
37
+ const buildModel = await pickModel(modelsPreset.roles.build.prompt, models);
38
+ console.log();
39
+
40
+ for (const line of modelsPreset.roles.fast.info) info(line);
41
+ const fastModel = await pickModel(modelsPreset.roles.fast.prompt, models);
42
+ console.log();
43
+
44
+ const agentsDir = path.join(process.cwd(), '.agents', 'agents');
45
+ await writeModelsToConfigs({ planModel, buildModel, fastModel, agentsDir, preset: modelsPreset });
46
+
47
+ console.log();
48
+ warn('Make sure you have API access to the selected models.');
49
+ warn('Change them anytime in .agents/agents/<name>.md and .opencode/opencode.json');
50
+
51
+ return { planModel, buildModel, fastModel };
52
+ }
@@ -0,0 +1,54 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { success } from '../../utils/exec.js'
4
+
5
+ export async function writeModelToAgent(agentFile, modelId) {
6
+ const content = await fse.readFile(agentFile, 'utf-8');
7
+ const updated = content.replace(
8
+ /^(---\n[\s\S]*?)\n---/m,
9
+ `$1\nmodel: ${modelId}\n---`
10
+ );
11
+ await fse.writeFile(agentFile, updated, 'utf-8');
12
+ }
13
+
14
+ export async function writeModelsToConfigs({ planModel, buildModel, fastModel, agentsDir, preset }) {
15
+ for (const name of preset.roles.build.agents) {
16
+ const file = path.join(agentsDir, `${name}.md`);
17
+ if (await fse.pathExists(file)) {
18
+ await writeModelToAgent(file, buildModel);
19
+ success(`${name} → ${buildModel}`);
20
+ }
21
+ }
22
+
23
+ for (const name of preset.roles.fast.agents) {
24
+ const file = path.join(agentsDir, `${name}.md`);
25
+ if (await fse.pathExists(file)) {
26
+ await writeModelToAgent(file, fastModel);
27
+ success(`${name} → ${fastModel}`);
28
+ }
29
+ }
30
+
31
+ const opencodeJsonPath = path.join(process.cwd(), '.opencode', 'opencode.json');
32
+ if (await fse.pathExists(opencodeJsonPath)) {
33
+ const config = await fse.readJson(opencodeJsonPath);
34
+ config.model = buildModel;
35
+ await fse.writeJson(opencodeJsonPath, config, { spaces: 2 });
36
+ success(`default model -> ${buildModel} (written to .opencode/opencode.json)`);
37
+ }
38
+
39
+ const ensembleJsonPath = path.join(process.cwd(), '.opencode', 'ensemble.json');
40
+ if (await fse.pathExists(ensembleJsonPath)) {
41
+ const ensemble = await fse.readJson(ensembleJsonPath);
42
+ delete ensemble.defaultModel;
43
+ ensemble.modelsByAgent = {
44
+ ...ensemble.modelsByAgent,
45
+ plan: planModel,
46
+ build: buildModel,
47
+ explore: fastModel,
48
+ };
49
+ await fse.writeJson(ensembleJsonPath, ensemble, { spaces: 2 });
50
+ success(`plan model -> ${planModel} (written to .opencode/ensemble.json)`);
51
+ success(`build model -> ${buildModel} (written to .opencode/ensemble.json)`);
52
+ success(`fast model -> ${fastModel} (written to .opencode/ensemble.json)`);
53
+ }
54
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ success: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('fs-extra', () => ({
11
+ default: {
12
+ readFile: vi.fn(),
13
+ writeFile: vi.fn(),
14
+ writeJson: vi.fn(),
15
+ pathExists: vi.fn(),
16
+ },
17
+ }))
18
+
19
+ import fse from 'fs-extra'
20
+ import { success } from '../../utils/exec.js'
21
+ import { writeModelToAgent, writeModelsToConfigs } from './write.js'
22
+
23
+ describe('writeModelToAgent()', () => {
24
+ let tmpDir
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-write-test-'))
28
+ })
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(tmpDir, { recursive: true, force: true })
32
+ })
33
+
34
+ it('adds model field to agent file frontmatter', async () => {
35
+ const filePath = path.join(tmpDir, 'test-agent.md')
36
+ const original = `---
37
+ name: Test Agent
38
+ description: A test agent
39
+ ---
40
+
41
+ # Test Agent`
42
+ fs.writeFileSync(filePath, original, 'utf-8')
43
+
44
+ await writeModelToAgent(filePath, 'anthropic/claude-3-sonnet')
45
+
46
+ const updated = fs.readFileSync(filePath, 'utf-8')
47
+ expect(updated).toContain('model: anthropic/claude-3-sonnet')
48
+ })
49
+
50
+ it('preserves existing frontmatter fields', async () => {
51
+ const filePath = path.join(tmpDir, 'test-agent.md')
52
+ const original = `---
53
+ name: Test Agent
54
+ description: A test agent
55
+ custom_field: custom_value
56
+ ---
57
+
58
+ # Test Agent`
59
+ fs.writeFileSync(filePath, original, 'utf-8')
60
+
61
+ await writeModelToAgent(filePath, 'test/model')
62
+
63
+ const updated = fs.readFileSync(filePath, 'utf-8')
64
+ expect(updated).toContain('name: Test Agent')
65
+ expect(updated).toContain('description: A test agent')
66
+ expect(updated).toContain('custom_field: custom_value')
67
+ expect(updated).toContain('model: test/model')
68
+ })
69
+ })
70
+
71
+ describe('writeModelsToConfigs()', () => {
72
+ let tmpDir, agentsDir, opencodeJsonPath, ensembleJsonPath
73
+
74
+ beforeEach(() => {
75
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'models-config-test-'))
76
+ agentsDir = path.join(tmpDir, '.agents', 'agents')
77
+ fs.mkdirSync(agentsDir, { recursive: true })
78
+ opencodeJsonPath = path.join(tmpDir, '.opencode', 'opencode.json')
79
+ ensembleJsonPath = path.join(tmpDir, '.opencode', 'ensemble.json')
80
+ fs.mkdirSync(path.dirname(opencodeJsonPath), { recursive: true })
81
+ })
82
+
83
+ afterEach(() => {
84
+ fs.rmSync(tmpDir, { recursive: true, force: true })
85
+ })
86
+
87
+ it('writes build model to agent files', async () => {
88
+ fs.writeFileSync(path.join(agentsDir, 'back-engineer.md'), '---\nname: Back\n---', 'utf-8')
89
+ fs.writeFileSync(path.join(agentsDir, 'front-engineer.md'), '---\nname: Front\n---', 'utf-8')
90
+
91
+ await writeModelsToConfigs({
92
+ planModel: 'plan-model',
93
+ buildModel: 'build-model',
94
+ fastModel: 'fast-model',
95
+ agentsDir,
96
+ preset: {
97
+ roles: {
98
+ build: { agents: ['back-engineer'] },
99
+ fast: { agents: ['front-engineer'] },
100
+ },
101
+ },
102
+ })
103
+
104
+ expect(success).toHaveBeenCalledWith('back-engineer → build-model')
105
+ expect(success).toHaveBeenCalledWith('front-engineer → fast-model')
106
+ })
107
+
108
+ it('reports success when writing configs', async () => {
109
+ await writeModelsToConfigs({
110
+ planModel: 'plan-model',
111
+ buildModel: 'build-model',
112
+ fastModel: 'fast-model',
113
+ agentsDir: '/nonexistent',
114
+ preset: { roles: { build: { agents: [] }, fast: { agents: [] } } },
115
+ })
116
+
117
+ expect(success).toHaveBeenCalled()
118
+ })
119
+ })
@@ -1,14 +1,12 @@
1
- import { execa } from 'execa'
2
- import path from 'node:path'
3
1
  import fse from 'fs-extra'
4
- import { error, header, success, warn } from '../utils/exec.js'
2
+ import path from 'node:path'
5
3
 
6
- const APPLY_TARGETS = [
4
+ export const APPLY_TARGETS = [
7
5
  path.join('.opencode', 'commands', 'opsx-apply.md'),
8
6
  path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'),
9
7
  ]
10
8
 
11
- const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
9
+ export const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
12
10
 
13
11
  NEVER implement tasks directly. Always delegate to specialists via ensemble.
14
12
  Do NOT touch any source files before the team is running, not even a single edit.
@@ -51,11 +49,11 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
51
49
 
52
50
  The spawn prompt must contain exactly:
53
51
  1. Their name and role on this team
54
- 2. Which tasks are theirs, list the task IDs and content from the board
52
+ 2. Which tasks are theirs, include the LITERAL task IDs (e.g. "task-abc123") AND the task content for each. Copy them verbatim from the IDs returned by team_tasks_add. Do NOT paraphrase or omit IDs.
55
53
  3. Key context they need (summarized from context files, do NOT tell them to read files themselves)
56
54
  4. The 6 OpenCode tools they have available (these are OpenCode tools, NOT shell commands, call them directly as tools, never via bash):
57
55
  team_claim, team_tasks_complete, team_tasks_list, team_tasks_add, team_message, team_broadcast
58
- 5. How to proceed: call team_claim tool with the task_id to claim a task before starting it, call team_tasks_complete tool after finishing it, repeat until all their tasks are done, then call team_message tool to notify lead with results or blockers
56
+ 5. How to proceed: for EACH task ID listed above, call team_claim tool with that exact task_id before starting it, call team_tasks_complete tool with that task_id after finishing it, then move to the next task. When all tasks are done or blocked, call team_message to notify lead with results or blockers.
59
57
  6. Which skills to load: list the skill names and paths they MUST read before implementing. Example: "Before starting, read \`.agents/skills/next-best-practices/SKILL.md\` and follow its rules for all Next.js code."
60
58
 
61
59
  Keep spawn prompts under 600 tokens. Do not describe team internals or how ensemble works.
@@ -71,12 +69,13 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
71
69
  (wait for result)
72
70
  \`\`\`
73
71
 
74
- Then immediately send each spawned agent a start message to kick them off:
72
+ Then immediately send each spawned agent a start message that repeats their task IDs:
75
73
  \`\`\`
76
- team_message to:"back" text:"Start now. Read all skills listed in your prompt first, confirm loaded skills, then claim your first task with team_claim."
77
- team_message to:"front" text:"Start now. Read all skills listed in your prompt first, confirm loaded skills, then claim your first task with team_claim."
78
- team_message to:"infra" text:"Start now. Read all skills listed in your prompt first, confirm loaded skills, then claim your first task with team_claim."
74
+ team_message to:"back" text:"Start now. Load skills first. Your tasks: [task-<id1>] <task1 text>, [task-<id2>] <task2 text>. Call team_claim task_id:<id> for each before starting it."
75
+ team_message to:"front" text:"Start now. Load skills first. Your tasks: [task-<id3>] <task3 text>. Call team_claim task_id:<id> before starting it."
76
+ team_message to:"infra" text:"Start now. Load skills first. Your tasks: [task-<id4>] <task4 text>. Call team_claim task_id:<id> before starting it."
79
77
  \`\`\`
78
+ Replace placeholders with REAL task IDs and content. Never send a generic "claim your first task" message without the actual IDs.
80
79
 
81
80
  **Step 6e.** After sending start messages, tell the user what is running, then STOP and wait.
82
81
  Do NOT call team_results, team_status, or team_broadcast in a loop.
@@ -104,7 +103,7 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
104
103
  8. **Mark tasks complete in openspec**
105
104
 
106
105
  Update tasks.md: \`- [ ]\` -> \`- [x]\` for each completed task.
107
- Run \`rtk openspec status --change "<name>" --json\` to confirm.
106
+ Run \`openspec status --change "<name>" --json\` to confirm.
108
107
 
109
108
  9. **Show status, then cleanup**
110
109
 
@@ -124,7 +123,9 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
124
123
  - NEVER call team_spawn before team_tasks_add, tasks must exist before agents are spawned
125
124
  - NEVER poll team_results or team_status in a loop, wait for teammates to message you
126
125
  - NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
127
- - ALWAYS pass the task IDs returned by team_tasks_add to each agent's spawn prompt
126
+ - ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt, copy the exact IDs, never paraphrase
127
+ - ALWAYS repeat the same literal task IDs in the team_message start trigger, never send a generic "claim your first task" without the actual IDs
128
+ - NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
128
129
  - NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
129
130
  - ALWAYS add every task to the board with team_tasks_add before spawning
130
131
  - ALWAYS spawn agents sequentially (wait for each team_spawn result before the next), then send start messages to all of them together
@@ -133,64 +134,29 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
133
134
  - Mark tasks complete in openspec AFTER specialists finish, not before
134
135
  - Pause on errors, blockers, or unclear requirements. Do not guess
135
136
  - Use contextFiles from CLI output, do not assume specific file paths
136
- - Use \`rtk\` wrapper for ALL CLI commands. Never run openspec, git, gh, or az directly
137
+ - Follow CLI rules from \`@ob-global\` when present
137
138
  - If model quota/rate-limit is exhausted, tell lead immediately via team_message and stop claiming new tasks until respawned
138
139
  `
139
140
 
140
141
  const STEP_6_START = /^6\.\s+\*\*Implement\b/im
141
142
  const FLUID_SECTION = /^\*\*Fluid Workflow Integration\*\*/im
142
143
 
143
- async function patchApplyFile(filePath) {
144
- if (!await fse.pathExists(filePath)) return { ok: false, reason: 'missing-file' }
144
+ export async function patchApplyFile(filePath) {
145
+ if (!await fse.pathExists(filePath)) return { ok: false, reason: 'missing-file' };
145
146
 
146
- const original = await fse.readFile(filePath, 'utf-8')
147
- const startMatch = original.match(STEP_6_START)
148
- if (!startMatch || startMatch.index === undefined) return { ok: false, reason: 'missing-step-6' }
147
+ const original = await fse.readFile(filePath, 'utf-8');
148
+ const startMatch = original.match(STEP_6_START);
149
+ if (!startMatch || startMatch.index === undefined) return { ok: false, reason: 'missing-step-6' };
149
150
 
150
- const before = original.slice(0, startMatch.index).replace(/\s*$/, '')
151
- const fromStep6 = original.slice(startMatch.index)
152
- const fluidMatch = fromStep6.match(FLUID_SECTION)
151
+ const before = original.slice(0, startMatch.index).replace(/\s*$/, '');
152
+ const fromStep6 = original.slice(startMatch.index);
153
+ const fluidMatch = fromStep6.match(FLUID_SECTION);
153
154
 
154
155
  const after = fluidMatch && fluidMatch.index !== undefined
155
156
  ? `\n\n${fromStep6.slice(fluidMatch.index).replace(/^\s*/, '')}`
156
- : ''
157
-
158
- const patched = `${before}\n\n${ENSEMBLE_SECTION}${after}`
159
- await fse.writeFile(filePath, patched, 'utf-8')
160
- return { ok: true }
161
- }
157
+ : '';
162
158
 
163
- export async function initOpenspec() {
164
- header('Step 7, Initializing OpenSpec')
165
-
166
- try {
167
- const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
168
- cwd: process.cwd(),
169
- stdio: 'pipe',
170
- reject: false,
171
- })
172
-
173
- if (result.exitCode === 0) {
174
- success('OpenSpec initialized')
175
- } else {
176
- warn('OpenSpec init exited with non-zero code, check output above')
177
- }
178
- } catch (err) {
179
- error(`Failed to run openspec init: ${err.message}`)
180
- }
181
-
182
- // Keep openspec defaults for selection/status/context steps, replace only implementation + guardrails.
183
- for (const rel of APPLY_TARGETS) {
184
- const abs = path.join(process.cwd(), rel)
185
- try {
186
- const res = await patchApplyFile(abs)
187
- if (res.ok) {
188
- success(`Patched ensemble implementation section in ${rel}`)
189
- } else {
190
- warn(`Could not patch ${rel} (${res.reason})`)
191
- }
192
- } catch (err) {
193
- warn(`Could not patch ${rel}: ${err.message}`)
194
- }
195
- }
159
+ const patched = `${before}\n\n${ENSEMBLE_SECTION}${after}`;
160
+ await fse.writeFile(filePath, patched, 'utf-8');
161
+ return { ok: true };
196
162
  }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+ import { patchApplyFile, APPLY_TARGETS } from './ensemble.js'
6
+
7
+ describe('patchApplyFile()', () => {
8
+ let tmpDir
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-'))
12
+ })
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true })
16
+ })
17
+
18
+ it('returns ok:false when file does not exist', async () => {
19
+ const result = await patchApplyFile(path.join(tmpDir, 'missing.md'))
20
+
21
+ expect(result).toEqual({ ok: false, reason: 'missing-file' })
22
+ })
23
+
24
+ it('returns ok:false when Step 6 is not found', async () => {
25
+ const filePath = path.join(tmpDir, 'opsx-apply.md')
26
+ fs.writeFileSync(filePath, 'Some other content without Step 6', 'utf-8')
27
+
28
+ const result = await patchApplyFile(filePath)
29
+
30
+ expect(result).toEqual({ ok: false, reason: 'missing-step-6' })
31
+ })
32
+
33
+ it('patches file with ENSEMBLE_SECTION when Step 6 found', async () => {
34
+ const filePath = path.join(tmpDir, 'opsx-apply.md')
35
+ const original = `Some header
36
+
37
+ 6. **Implement**
38
+ Old implementation here.
39
+
40
+ **Fluid Workflow Integration**
41
+ Fluid section content.
42
+ `
43
+ fs.writeFileSync(filePath, original, 'utf-8')
44
+
45
+ const result = await patchApplyFile(filePath)
46
+
47
+ expect(result.ok).toBe(true)
48
+ const patched = fs.readFileSync(filePath, 'utf-8')
49
+ expect(patched).toContain('**Implement via ensemble team**')
50
+ expect(patched).toContain('6. **Implement via ensemble team**')
51
+ expect(patched).toContain('**Fluid Workflow Integration**')
52
+ })
53
+
54
+ it('removes original Step 6 content before patching', async () => {
55
+ const filePath = path.join(tmpDir, 'SKILL.md')
56
+ const original = `Steps:
57
+
58
+ 6. **Implement**
59
+ Do things directly.
60
+
61
+ 7. **Quality check**
62
+ `
63
+ fs.writeFileSync(filePath, original, 'utf-8')
64
+
65
+ await patchApplyFile(filePath)
66
+
67
+ const patched = fs.readFileSync(filePath, 'utf-8')
68
+ expect(patched).not.toContain('Do things directly.')
69
+ expect(patched).toContain('NEVER implement tasks directly')
70
+ })
71
+ })
72
+
73
+ describe('APPLY_TARGETS', () => {
74
+ it('contains expected OpenSpec apply file paths', () => {
75
+ expect(APPLY_TARGETS).toHaveLength(2)
76
+ expect(APPLY_TARGETS).toContain(path.join('.opencode', 'commands', 'opsx-apply.md'))
77
+ expect(APPLY_TARGETS).toContain(path.join('.opencode', 'skills', 'openspec-apply-change', 'SKILL.md'))
78
+ })
79
+ })
@@ -0,0 +1,32 @@
1
+ import { execa } from 'execa'
2
+ import path from 'node:path'
3
+ import { error, header, success, warn } from '../../utils/exec.js'
4
+ import { APPLY_TARGETS, patchApplyFile } from './ensemble.js'
5
+
6
+ export async function initOpenspec() {
7
+ header('Step 6, Initializing OpenSpec');
8
+
9
+ try {
10
+ const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
11
+ cwd: process.cwd(),
12
+ stdio: 'pipe',
13
+ reject: false,
14
+ });
15
+
16
+ if (result.exitCode === 0) success('OpenSpec initialized');
17
+ else warn('OpenSpec init exited with non-zero code, check output above');
18
+ } catch (err) {
19
+ error(`Failed to run openspec init: ${err.message}`);
20
+ }
21
+
22
+ for (const rel of APPLY_TARGETS) {
23
+ const abs = path.join(process.cwd(), rel);
24
+ try {
25
+ const res = await patchApplyFile(abs);
26
+ if (res.ok) success(`Patched ensemble implementation section in ${rel}`);
27
+ else warn(`Could not patch ${rel} (${res.reason})`);
28
+ } catch (err) {
29
+ warn(`Could not patch ${rel}: ${err.message}`);
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,11 @@
1
+ import { info } from '../../utils/exec.js'
2
+
3
+ export async function enableCavemanGuidance(cavemanResult) {
4
+ if (!cavemanResult?.installed) {
5
+ info('Caveman guidance skipped (caveman not installed)')
6
+ return { enabled: false }
7
+ }
8
+
9
+ info('Caveman guidance is configured only via @ob-global skill')
10
+ return { enabled: true, patchedFiles: 0 }
11
+ }
@@ -1,13 +1,5 @@
1
1
  import { execa } from 'execa'
2
- import fse from 'fs-extra'
3
- import path from 'node:path'
4
- import { header, success, warn, error, loading, info } from '../utils/exec.js'
5
-
6
- const SKILLS_LOCK_CANDIDATES = [
7
- 'skills-lock.json',
8
- '.skills-lock.json',
9
- '.skills/skills-lock.json',
10
- ]
2
+ import { header, success, warn, error, loading, info } from '../../utils/exec.js'
11
3
 
12
4
  export async function installCaveman(options = {}) {
13
5
  if (!options.skipHeader) header('Installing caveman')
@@ -23,19 +15,13 @@ export async function installCaveman(options = {}) {
23
15
  })
24
16
 
25
17
  if (result.exitCode === 0) {
26
- if (options.skillsProvider !== 'npx-skills') {
27
- for (const rel of SKILLS_LOCK_CANDIDATES) {
28
- const abs = path.join(process.cwd(), rel)
29
- if (await fse.pathExists(abs)) await fse.remove(abs)
30
- }
31
- }
32
18
  success('caveman installed')
33
19
  return { optedIn: true, installed: true }
34
- } else {
35
- if (result.stderr?.trim()) warn(result.stderr.trim().split('\n').slice(-3).join('\n'))
36
- warn('caveman install exited with non-zero code')
37
- return { optedIn: true, installed: false }
38
20
  }
21
+
22
+ if (result.stderr?.trim()) warn(result.stderr.trim().split('\n').slice(-3).join('\n'))
23
+ warn('caveman install exited with non-zero code')
24
+ return { optedIn: true, installed: false }
39
25
  } catch (err) {
40
26
  error(`Failed to install caveman: ${err.message}`)
41
27
  return { optedIn: true, installed: false }
@@ -0,0 +1,64 @@
1
+ import fse from 'fs-extra'
2
+ import path from 'node:path'
3
+ import { info, success, warn } from '../../utils/exec.js'
4
+
5
+ const SOURCE_START = '<!-- OB-SOURCE-ROOTS-START -->'
6
+ const SOURCE_END = '<!-- OB-SOURCE-ROOTS-END -->'
7
+ const RTK_START = '<!-- OB-RTK-START -->'
8
+ const RTK_END = '<!-- OB-RTK-END -->'
9
+ const CAVEMAN_START = '<!-- OB-CAVEMAN-START -->'
10
+ const CAVEMAN_END = '<!-- OB-CAVEMAN-END -->'
11
+
12
+ function relRoot(cwd, abs) {
13
+ const rel = path.relative(cwd, abs).replace(/\\/g, '/')
14
+ return rel || '.'
15
+ }
16
+
17
+ function buildSourceRootsSection(sourceRoots, cwd) {
18
+ const roots = Array.isArray(sourceRoots) && sourceRoots.length > 0 ? sourceRoots : [cwd]
19
+ const bullets = roots.map(r => `- \`${relRoot(cwd, r)}\``).join('\n')
20
+ const multiRepo = roots.length > 1
21
+ ? '\nEach root is an independent git repository. For branch/commit/push workflows, run git operations per repository. There is no single shared git history across all roots.\n'
22
+ : ''
23
+
24
+ return `Read and analyze code ONLY from these roots:\n\n${bullets}\n${multiRepo}`
25
+ }
26
+
27
+ function buildRtkSection(rtkEnabled) {
28
+ if (!rtkEnabled) return 'RTK was not selected during onboarding. Do not assume `rtk` exists.'
29
+ return `## RTK, MANDATORY\n\nUse \`rtk\` for ALL CLI commands. Never run commands directly.\n\n- \`rtk git\` NOT \`git\`\n- \`rtk gh\` NOT \`gh\`\n- \`rtk az\` NOT \`az\`\n- \`rtk openspec\` NOT \`openspec\`\n\nIf \`rtk\` is not available, report blocker and stop CLI execution.`
30
+ }
31
+
32
+ function buildCavemanSection(cavemanEnabled) {
33
+ if (!cavemanEnabled) return 'Caveman was not selected during onboarding. Use normal concise style.'
34
+ return `## Caveman\n\ncaveman mode. Active now. Every response. No revert unless user asks \"stop caveman\" or \"normal mode\".`
35
+ }
36
+
37
+ function replaceBetween(content, start, end, replacement) {
38
+ if (!content.includes(start) || !content.includes(end)) return content
39
+ const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
40
+ return content.replace(pattern, `${start}\n${replacement.trim()}\n${end}`)
41
+ }
42
+
43
+ export async function configureObGlobal(ctx = {}, tokenOpt = {}) {
44
+ const cwd = process.cwd()
45
+ const skillPath = path.join(cwd, '.agents', 'skills', 'ob-global', 'SKILL.md')
46
+
47
+ if (!await fse.pathExists(skillPath)) {
48
+ warn('ob-global skill not found, skipping dynamic configuration')
49
+ return { configured: false }
50
+ }
51
+
52
+ const sourceRootsSection = buildSourceRootsSection(ctx.sourceRoots, cwd)
53
+ const rtkSection = buildRtkSection(!!tokenOpt?.rtk?.optedIn)
54
+ const cavemanSection = buildCavemanSection(!!tokenOpt?.caveman?.optedIn)
55
+
56
+ let content = await fse.readFile(skillPath, 'utf-8')
57
+ content = replaceBetween(content, SOURCE_START, SOURCE_END, sourceRootsSection)
58
+ content = replaceBetween(content, RTK_START, RTK_END, rtkSection)
59
+ content = replaceBetween(content, CAVEMAN_START, CAVEMAN_END, cavemanSection)
60
+ await fse.writeFile(skillPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
61
+ info('Configured ob-global from onboarding selections')
62
+ success('ob-global skill updated')
63
+ return { configured: true, path: skillPath }
64
+ }