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.
- 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/opencode.json +3 -3
- package/content/.opencode/plugins/session-log.js +1 -1
- package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
- package/content/AGENTS.md +95 -141
- package/content/skills-lock.json +4 -0
- package/package.json +6 -1
- package/src/index.js +112 -191
- 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/{patch-agents-md.js → copy/agents.js} +41 -20
- 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} +27 -61
- 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 -163
- 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 -93
- 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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
77
|
-
team_message to:"front" text:"Start now.
|
|
78
|
-
team_message to:"infra" text:"Start now.
|
|
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 \`
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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
|
+
}
|