opencode-onboard 0.3.3 → 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.
- package/README.md +266 -214
- package/content/.agents/agents/basic-engineer.md +30 -0
- package/content/.agents/agents/devops-manager.md +38 -29
- package/content/.agents/session-log.json +41 -0
- package/content/.agents/skills/ob-default/SKILL.md +21 -0
- package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
- package/content/.agents/skills/ob-global/SKILL.md +49 -0
- package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
- package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
- package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
- package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
- package/content/.opencode/commands/opsx-apply.md +50 -33
- package/content/.opencode/plugins/session-log.js +1 -1
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
- package/content/AGENTS.md +94 -144
- package/content/skills-lock.json +4 -0
- package/package.json +6 -1
- package/src/index.js +13 -47
- package/src/presets/browser.json +18 -0
- package/src/presets/clean.json +21 -0
- package/src/presets/models.json +33 -0
- package/src/presets/optimization.json +22 -0
- package/src/presets/platforms.json +29 -2
- package/src/presets/quota.json +14 -0
- package/src/presets/source.json +17 -0
- package/src/steps/browser/browser.test.js +81 -0
- package/src/steps/{install-browser.js → browser/index.js} +12 -15
- package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
- package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
- package/src/steps/copy/agents.js +106 -0
- package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
- package/src/steps/copy/index.js +33 -0
- package/src/steps/copy/skills.js +55 -0
- package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
- package/src/steps/metadata/metadata.test.js +96 -0
- package/src/steps/models/format.js +60 -0
- package/src/steps/models/format.test.js +74 -0
- package/src/steps/models/index.js +52 -0
- package/src/steps/models/write.js +54 -0
- package/src/steps/models/write.test.js +119 -0
- package/src/steps/{init-openspec.js → openspec/ensemble.js} +20 -57
- package/src/steps/openspec/ensemble.test.js +79 -0
- package/src/steps/openspec/index.js +32 -0
- package/src/steps/optimization/caveman-guidance.js +11 -0
- package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
- package/src/steps/optimization/global.js +64 -0
- package/src/steps/optimization/index.js +101 -0
- package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
- package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
- package/src/steps/platform/index.js +81 -0
- package/src/steps/platform/platform.test.js +129 -0
- package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
- package/src/steps/source/source.test.js +89 -0
- package/src/utils/__tests__/copy.test.js +12 -5
- package/src/utils/copy.js +4 -24
- package/src/utils/exec-spinner.js +47 -0
- package/src/utils/exec.js +120 -162
- package/src/utils/models-cache.js +25 -68
- package/src/utils/models-pricing.js +42 -0
- package/src/utils/models-pricing.test.js +94 -0
- package/content/.agents/agents/back-engineer.md +0 -87
- package/content/.agents/agents/front-engineer.md +0 -86
- package/content/.agents/agents/infra-engineer.md +0 -85
- package/content/.agents/agents/quality-engineer.md +0 -86
- package/content/.agents/agents/security-auditor.md +0 -86
- package/src/steps/__tests__/check-env.test.js +0 -70
- package/src/steps/__tests__/check-platform.test.js +0 -104
- package/src/steps/__tests__/check-rtk.test.js +0 -38
- package/src/steps/__tests__/choose-platform.test.js +0 -38
- package/src/steps/check-env.js +0 -26
- package/src/steps/check-platform.js +0 -80
- package/src/steps/check-rtk.js +0 -38
- package/src/steps/choose-models.js +0 -165
- package/src/steps/choose-platform.js +0 -22
- package/src/steps/choose-skills-provider.js +0 -79
- package/src/steps/copy-content.js +0 -89
- package/src/steps/enable-caveman-guidance.js +0 -78
- package/src/steps/patch-agents-md.js +0 -153
- package/src/steps/token-optimization.js +0 -59
|
@@ -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
|
|
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,7 +49,7 @@ 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
|
|
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
|
|
@@ -105,7 +103,7 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
105
103
|
8. **Mark tasks complete in openspec**
|
|
106
104
|
|
|
107
105
|
Update tasks.md: \`- [ ]\` -> \`- [x]\` for each completed task.
|
|
108
|
-
Run \`
|
|
106
|
+
Run \`openspec status --change "<name>" --json\` to confirm.
|
|
109
107
|
|
|
110
108
|
9. **Show status, then cleanup**
|
|
111
109
|
|
|
@@ -125,8 +123,8 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
125
123
|
- NEVER call team_spawn before team_tasks_add, tasks must exist before agents are spawned
|
|
126
124
|
- NEVER poll team_results or team_status in a loop, wait for teammates to message you
|
|
127
125
|
- NEVER call team_claim or team_tasks_complete as lead, only agents call these tools
|
|
128
|
-
- ALWAYS pass the LITERAL task IDs returned by team_tasks_add into each agent's spawn prompt
|
|
129
|
-
- ALWAYS repeat the same literal task IDs in the team_message start trigger
|
|
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
|
|
130
128
|
- NEVER send a start message that omits task IDs; if a task ID is missing from the start message, the agent cannot claim
|
|
131
129
|
- NEVER edit files between team_spawn and team_merge, team_merge blocks on overlapping local changes
|
|
132
130
|
- ALWAYS add every task to the board with team_tasks_add before spawning
|
|
@@ -136,64 +134,29 @@ const ENSEMBLE_SECTION = `6. **Implement via ensemble team**
|
|
|
136
134
|
- Mark tasks complete in openspec AFTER specialists finish, not before
|
|
137
135
|
- Pause on errors, blockers, or unclear requirements. Do not guess
|
|
138
136
|
- Use contextFiles from CLI output, do not assume specific file paths
|
|
139
|
-
-
|
|
137
|
+
- Follow CLI rules from \`@ob-global\` when present
|
|
140
138
|
- If model quota/rate-limit is exhausted, tell lead immediately via team_message and stop claiming new tasks until respawned
|
|
141
139
|
`
|
|
142
140
|
|
|
143
141
|
const STEP_6_START = /^6\.\s+\*\*Implement\b/im
|
|
144
142
|
const FLUID_SECTION = /^\*\*Fluid Workflow Integration\*\*/im
|
|
145
143
|
|
|
146
|
-
async function patchApplyFile(filePath) {
|
|
147
|
-
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' };
|
|
148
146
|
|
|
149
|
-
const original = await fse.readFile(filePath, 'utf-8')
|
|
150
|
-
const startMatch = original.match(STEP_6_START)
|
|
151
|
-
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' };
|
|
152
150
|
|
|
153
|
-
const before = original.slice(0, startMatch.index).replace(/\s*$/, '')
|
|
154
|
-
const fromStep6 = original.slice(startMatch.index)
|
|
155
|
-
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);
|
|
156
154
|
|
|
157
155
|
const after = fluidMatch && fluidMatch.index !== undefined
|
|
158
156
|
? `\n\n${fromStep6.slice(fluidMatch.index).replace(/^\s*/, '')}`
|
|
159
|
-
: ''
|
|
160
|
-
|
|
161
|
-
const patched = `${before}\n\n${ENSEMBLE_SECTION}${after}`
|
|
162
|
-
await fse.writeFile(filePath, patched, 'utf-8')
|
|
163
|
-
return { ok: true }
|
|
164
|
-
}
|
|
157
|
+
: '';
|
|
165
158
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
|
|
171
|
-
cwd: process.cwd(),
|
|
172
|
-
stdio: 'pipe',
|
|
173
|
-
reject: false,
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
if (result.exitCode === 0) {
|
|
177
|
-
success('OpenSpec initialized')
|
|
178
|
-
} else {
|
|
179
|
-
warn('OpenSpec init exited with non-zero code, check output above')
|
|
180
|
-
}
|
|
181
|
-
} catch (err) {
|
|
182
|
-
error(`Failed to run openspec init: ${err.message}`)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Keep openspec defaults for selection/status/context steps, replace only implementation + guardrails.
|
|
186
|
-
for (const rel of APPLY_TARGETS) {
|
|
187
|
-
const abs = path.join(process.cwd(), rel)
|
|
188
|
-
try {
|
|
189
|
-
const res = await patchApplyFile(abs)
|
|
190
|
-
if (res.ok) {
|
|
191
|
-
success(`Patched ensemble implementation section in ${rel}`)
|
|
192
|
-
} else {
|
|
193
|
-
warn(`Could not patch ${rel} (${res.reason})`)
|
|
194
|
-
}
|
|
195
|
-
} catch (err) {
|
|
196
|
-
warn(`Could not patch ${rel}: ${err.message}`)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
159
|
+
const patched = `${before}\n\n${ENSEMBLE_SECTION}${after}`;
|
|
160
|
+
await fse.writeFile(filePath, patched, 'utf-8');
|
|
161
|
+
return { ok: true };
|
|
199
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { checkbox, confirm } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { code, commandExists, header, info, loading, success, warn } from '../../utils/exec.js'
|
|
6
|
+
import { installQuota } from './quota.js'
|
|
7
|
+
import { installCaveman } from './caveman.js'
|
|
8
|
+
import { enableCavemanGuidance } from './caveman-guidance.js'
|
|
9
|
+
import { configureObGlobal } from './global.js'
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const OPTIMIZATION_PRESET_PATH = path.resolve(__dirname, '../../presets/optimization.json')
|
|
13
|
+
const optimizationPreset = await fse.readJson(OPTIMIZATION_PRESET_PATH)
|
|
14
|
+
|
|
15
|
+
export async function checkRtk(options = {}) {
|
|
16
|
+
if (!options.skipHeader) header('Checking rtk')
|
|
17
|
+
|
|
18
|
+
let shouldCheck = true
|
|
19
|
+
if (!options.skipPrompt) {
|
|
20
|
+
info('Recommended: install and verify rtk for safer agent CLI command execution.')
|
|
21
|
+
shouldCheck = await confirm({
|
|
22
|
+
message: 'Check rtk now?',
|
|
23
|
+
default: true,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!shouldCheck) {
|
|
28
|
+
warn('Skipped rtk check (you can install it later)')
|
|
29
|
+
return { optedIn: false, checked: false, available: false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
loading('checking rtk...')
|
|
33
|
+
|
|
34
|
+
const available = await commandExists('rtk')
|
|
35
|
+
|
|
36
|
+
if (available) {
|
|
37
|
+
success('rtk is available')
|
|
38
|
+
return { optedIn: true, checked: true, available: true }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
warn('rtk not found on PATH.')
|
|
42
|
+
console.log()
|
|
43
|
+
info('rtk is required for agents to run CLI commands safely.')
|
|
44
|
+
info('Install it from: https://github.com/rtk-ai/rtk#pre-built-binaries')
|
|
45
|
+
console.log()
|
|
46
|
+
info('After installing, verify with:')
|
|
47
|
+
code(['rtk --version'])
|
|
48
|
+
return { optedIn: true, checked: true, available: false }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function tokenOptimizationStep(options = {}) {
|
|
52
|
+
header('Step 8, Token optimization tools')
|
|
53
|
+
|
|
54
|
+
const defaultSelected = optimizationPreset.choices
|
|
55
|
+
.filter(choice => choice.checked)
|
|
56
|
+
.map(choice => choice.value)
|
|
57
|
+
let selected = defaultSelected
|
|
58
|
+
|
|
59
|
+
if (!options.skipPrompt && process.stdin.isTTY) {
|
|
60
|
+
info(optimizationPreset.info)
|
|
61
|
+
const timeoutMs = optimizationPreset.timeoutMs
|
|
62
|
+
const choice = await Promise.race([
|
|
63
|
+
checkbox({
|
|
64
|
+
message: optimizationPreset.message,
|
|
65
|
+
choices: optimizationPreset.choices,
|
|
66
|
+
}),
|
|
67
|
+
new Promise(resolve => setTimeout(() => resolve(defaultSelected), timeoutMs)),
|
|
68
|
+
])
|
|
69
|
+
selected = Array.isArray(choice) ? choice : defaultSelected
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
loading('applying token optimization selections...')
|
|
73
|
+
|
|
74
|
+
const has = value => selected.includes(value)
|
|
75
|
+
|
|
76
|
+
const rtk = has('rtk')
|
|
77
|
+
? await checkRtk({ skipHeader: true, skipPrompt: true })
|
|
78
|
+
: { optedIn: false, checked: false, available: false }
|
|
79
|
+
|
|
80
|
+
const quota = has('quota')
|
|
81
|
+
? await installQuota({ skipHeader: true, skipPrompt: true })
|
|
82
|
+
: { optedIn: false, installed: false }
|
|
83
|
+
|
|
84
|
+
const caveman = has('caveman')
|
|
85
|
+
? await installCaveman({
|
|
86
|
+
skipHeader: true,
|
|
87
|
+
skipPrompt: true,
|
|
88
|
+
})
|
|
89
|
+
: { optedIn: false, installed: false }
|
|
90
|
+
|
|
91
|
+
const cavemanGuidance = has('caveman')
|
|
92
|
+
? await enableCavemanGuidance(caveman)
|
|
93
|
+
: { enabled: false }
|
|
94
|
+
|
|
95
|
+
const obGlobal = await configureObGlobal(options.ctx || {}, { rtk, quota, caveman, cavemanGuidance })
|
|
96
|
+
|
|
97
|
+
if (selected.length === 0) warn('No token optimization tools selected')
|
|
98
|
+
else success('Token optimization step completed')
|
|
99
|
+
|
|
100
|
+
return { rtk, quota, caveman, cavemanGuidance, obGlobal }
|
|
101
|
+
}
|
package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js}
RENAMED
|
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
|
|
3
3
|
vi.mock('@inquirer/prompts', () => ({
|
|
4
4
|
checkbox: vi.fn(),
|
|
5
|
+
confirm: vi.fn(),
|
|
5
6
|
}))
|
|
6
7
|
|
|
7
8
|
vi.mock('../../utils/exec.js', () => ({
|
|
9
|
+
code: vi.fn(),
|
|
10
|
+
commandExists: vi.fn(),
|
|
8
11
|
header: vi.fn(),
|
|
9
12
|
info: vi.fn(),
|
|
10
13
|
loading: vi.fn(),
|
|
@@ -12,29 +15,18 @@ vi.mock('../../utils/exec.js', () => ({
|
|
|
12
15
|
warn: vi.fn(),
|
|
13
16
|
}))
|
|
14
17
|
|
|
15
|
-
vi.mock('
|
|
16
|
-
|
|
17
|
-
}))
|
|
18
|
-
|
|
19
|
-
vi.mock('../install-quota.js', () => ({
|
|
20
|
-
installQuota: vi.fn(),
|
|
21
|
-
}))
|
|
22
|
-
|
|
23
|
-
vi.mock('../install-caveman.js', () => ({
|
|
24
|
-
installCaveman: vi.fn(),
|
|
25
|
-
}))
|
|
26
|
-
|
|
27
|
-
vi.mock('../enable-caveman-guidance.js', () => ({
|
|
28
|
-
enableCavemanGuidance: vi.fn(),
|
|
29
|
-
}))
|
|
18
|
+
vi.mock('./quota.js', () => ({ installQuota: vi.fn() }))
|
|
19
|
+
vi.mock('./caveman.js', () => ({ installCaveman: vi.fn() }))
|
|
20
|
+
vi.mock('./caveman-guidance.js', () => ({ enableCavemanGuidance: vi.fn() }))
|
|
21
|
+
vi.mock('./global.js', () => ({ configureObGlobal: vi.fn() }))
|
|
30
22
|
|
|
31
23
|
import { checkbox } from '@inquirer/prompts'
|
|
32
|
-
import { warn } from '../../utils/exec.js'
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import { tokenOptimizationStep } from '
|
|
24
|
+
import { commandExists, warn } from '../../utils/exec.js'
|
|
25
|
+
import { installQuota } from './quota.js'
|
|
26
|
+
import { installCaveman } from './caveman.js'
|
|
27
|
+
import { enableCavemanGuidance } from './caveman-guidance.js'
|
|
28
|
+
import { configureObGlobal } from './global.js'
|
|
29
|
+
import { tokenOptimizationStep } from './index.js'
|
|
38
30
|
|
|
39
31
|
describe('tokenOptimizationStep()', () => {
|
|
40
32
|
beforeEach(() => {
|
|
@@ -43,17 +35,19 @@ describe('tokenOptimizationStep()', () => {
|
|
|
43
35
|
|
|
44
36
|
it('runs all optimizations by default selection', async () => {
|
|
45
37
|
checkbox.mockResolvedValue(['rtk', 'quota', 'caveman'])
|
|
46
|
-
|
|
38
|
+
commandExists.mockResolvedValue(true)
|
|
47
39
|
installQuota.mockResolvedValue({ optedIn: true, installed: true })
|
|
48
40
|
installCaveman.mockResolvedValue({ optedIn: true, installed: true })
|
|
49
41
|
enableCavemanGuidance.mockResolvedValue({ enabled: true })
|
|
42
|
+
configureObGlobal.mockResolvedValue({ configured: true })
|
|
50
43
|
|
|
51
44
|
const result = await tokenOptimizationStep()
|
|
52
45
|
|
|
53
|
-
expect(
|
|
46
|
+
expect(commandExists).toHaveBeenCalledWith('rtk')
|
|
54
47
|
expect(installQuota).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
|
|
55
48
|
expect(installCaveman).toHaveBeenCalledWith({ skipHeader: true, skipPrompt: true })
|
|
56
49
|
expect(enableCavemanGuidance).toHaveBeenCalledWith({ optedIn: true, installed: true })
|
|
50
|
+
expect(configureObGlobal).toHaveBeenCalled()
|
|
57
51
|
expect(result.rtk.available).toBe(true)
|
|
58
52
|
expect(result.quota.installed).toBe(true)
|
|
59
53
|
expect(result.caveman.installed).toBe(true)
|
|
@@ -65,10 +59,11 @@ describe('tokenOptimizationStep()', () => {
|
|
|
65
59
|
|
|
66
60
|
const result = await tokenOptimizationStep()
|
|
67
61
|
|
|
68
|
-
expect(
|
|
62
|
+
expect(commandExists).not.toHaveBeenCalled()
|
|
69
63
|
expect(installQuota).not.toHaveBeenCalled()
|
|
70
64
|
expect(installCaveman).not.toHaveBeenCalled()
|
|
71
65
|
expect(enableCavemanGuidance).not.toHaveBeenCalled()
|
|
66
|
+
expect(configureObGlobal).toHaveBeenCalled()
|
|
72
67
|
expect(warn).toHaveBeenCalledWith('No token optimization tools selected')
|
|
73
68
|
expect(result.rtk.optedIn).toBe(false)
|
|
74
69
|
expect(result.quota.optedIn).toBe(false)
|