@plainconceptsplatform/agent-harness 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +419 -0
- package/package.json +68 -0
- package/src/commands/join.js +244 -0
- package/src/commands/shared.js +27 -0
- package/src/commands/single.js +79 -0
- package/src/commands/update.js +109 -0
- package/src/commands/wizard.js +134 -0
- package/src/content/.agents/skills/browser-automation/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-guardrails-generic/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-guardrails-project/SKILL.md +8 -0
- package/src/content/.agents/skills/pc-make-architecture/SKILL.md +51 -0
- package/src/content/.agents/skills/pc-make-architecture/structure-template.md +38 -0
- package/src/content/.agents/skills/pc-make-design/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/SKILL.md +219 -0
- package/src/content/.agents/skills/pc-make-engineer/signal-mapping.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/template.md +81 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +29 -0
- package/src/content/.agents/skills/pc-make-guardrails/SKILL.md +74 -0
- package/src/content/.agents/skills/pc-make-guardrails/category-reference.md +68 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/SKILL.md +70 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/category-reference.md +98 -0
- package/src/content/.agents/skills/pc-make-user-model/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-ops-evidence/SKILL.md +127 -0
- package/src/content/.agents/skills/pc-ops-ship/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-plan-apply/SKILL.md +83 -0
- package/src/content/.agents/skills/pc-plan-apply/simple-mode.md +21 -0
- package/src/content/.agents/skills/pc-plan-archive/SKILL.md +63 -0
- package/src/content/.agents/skills/pc-plan-explore/SKILL.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/SKILL.md +94 -0
- package/src/content/.agents/skills/pc-plan-goal/branching.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/failure-policy.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/output-mode.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/output.md +68 -0
- package/src/content/.agents/skills/pc-plan-propose/SKILL.md +125 -0
- package/src/content/.agents/skills/pc-plan-propose/task-annotation.md +39 -0
- package/src/content/.agents/skills/pc-plan-quick/SKILL.md +62 -0
- package/src/content/.agents/skills/pc-plan-story/SKILL.md +146 -0
- package/src/content/.agents/skills/pc-repo-audit/SKILL.md +44 -0
- package/src/content/.agents/skills/pc-repo-help/SKILL.md +91 -0
- package/src/content/.agents/skills/pc-repo-initialize/SKILL.md +130 -0
- package/src/content/.agents/skills/pc-repo-onboard/SKILL.md +87 -0
- package/src/content/.agents/skills/pc-repo-verify/SKILL.md +34 -0
- package/src/content/.agents/skills/pc-userstory-az/SKILL.md +157 -0
- package/src/content/.agents/skills/pc-userstory-browser/SKILL.md +132 -0
- package/src/content/.agents/skills/pc-userstory-gh/SKILL.md +120 -0
- package/src/content/.agents/skills/pc-userstory-jira/SKILL.md +131 -0
- package/src/content/.opencode/_gitignore +7 -0
- package/src/content/.opencode/commands/init.md +5 -0
- package/src/content/.opencode/commands/make-architecture.md +5 -0
- package/src/content/.opencode/commands/make-design.md +5 -0
- package/src/content/.opencode/commands/make-engineer.md +5 -0
- package/src/content/.opencode/commands/make-evidence-scaffold.md +5 -0
- package/src/content/.opencode/commands/make-guardrails.md +5 -0
- package/src/content/.opencode/commands/make-user-model.md +5 -0
- package/src/content/.opencode/commands/ops-backlog.md +10 -0
- package/src/content/.opencode/commands/ops-evidence.md +9 -0
- package/src/content/.opencode/commands/ops-review.md +8 -0
- package/src/content/.opencode/commands/ops-ship.md +9 -0
- package/src/content/.opencode/commands/plan-apply.md +9 -0
- package/src/content/.opencode/commands/plan-archive.md +5 -0
- package/src/content/.opencode/commands/plan-explore.md +9 -0
- package/src/content/.opencode/commands/plan-goal.md +5 -0
- package/src/content/.opencode/commands/plan-propose.md +9 -0
- package/src/content/.opencode/commands/plan-quick.md +5 -0
- package/src/content/.opencode/commands/plan-story.md +9 -0
- package/src/content/.opencode/commands/repo-audit.md +5 -0
- package/src/content/.opencode/commands/repo-help.md +5 -0
- package/src/content/.opencode/commands/repo-initialize.md +5 -0
- package/src/content/.opencode/commands/repo-onboard.md +5 -0
- package/src/content/.opencode/commands/repo-verify.md +5 -0
- package/src/content/.opencode/package.json +10 -0
- package/src/content/.opencode/plugins/pc-subagent-monitor.js +139 -0
- package/src/content/.opencode/plugins/pc-subagent-tiers.js +179 -0
- package/src/content/.opencode/plugins/pc-system-reminders.js +96 -0
- package/src/content/.opencode/plugins/pc-system-reminders.test.js +35 -0
- package/src/content/.opencode/tui/pc-subagents.tsx +98 -0
- package/src/content/.opencode/tui.json +6 -0
- package/src/content/AGENTS.md +71 -0
- package/src/content/ARCHITECTURE.md +16 -0
- package/src/content/DESIGN.md +16 -0
- package/src/content/opencode.jsonc +31 -0
- package/src/content/openspec/changes/archive/.gitkeep +0 -0
- package/src/content/openspec/config.yaml +20 -0
- package/src/content/openspec/specs/.gitkeep +0 -0
- package/src/content/skills-lock.json +17 -0
- package/src/fragments/archive/az.md +95 -0
- package/src/fragments/archive/gh.md +94 -0
- package/src/fragments/archive/gl.md +94 -0
- package/src/fragments/archive/none.md +73 -0
- package/src/fragments/guardrails/codegraph.md +7 -0
- package/src/fragments/guardrails/humanizer.md +4 -0
- package/src/fragments/guardrails/memory.md +4 -0
- package/src/fragments/guardrails/rtk.md +3 -0
- package/src/fragments/guardrails/simple-english.md +4 -0
- package/src/fragments/ops-backlog/az.md +29 -0
- package/src/fragments/ops-backlog/gh.md +30 -0
- package/src/fragments/ops-backlog/jira.md +29 -0
- package/src/fragments/ops-evidence/az.md +41 -0
- package/src/fragments/ops-evidence/gh.md +53 -0
- package/src/fragments/ops-evidence/jira.md +38 -0
- package/src/fragments/ops-review/az.md +63 -0
- package/src/fragments/ops-review/gh.md +53 -0
- package/src/fragments/ops-review/gl.md +57 -0
- package/src/fragments/ops-ship/az.md +81 -0
- package/src/fragments/ops-ship/gh.md +69 -0
- package/src/fragments/ops-ship/gl.md +86 -0
- package/src/index.js +107 -0
- package/src/presets/agents-content.json +53 -0
- package/src/presets/browser.json +22 -0
- package/src/presets/clean.json +21 -0
- package/src/presets/models.json +68 -0
- package/src/presets/openspec.json +1 -0
- package/src/presets/optimization.json +37 -0
- package/src/presets/platforms.json +76 -0
- package/src/presets/quota.json +16 -0
- package/src/presets/source.json +23 -0
- package/src/steps/browser/index.js +91 -0
- package/src/steps/clean/index.js +120 -0
- package/src/steps/copy/agents.js +118 -0
- package/src/steps/copy/commands.js +91 -0
- package/src/steps/copy/fullstack-engineer.js +83 -0
- package/src/steps/copy/index.js +88 -0
- package/src/steps/copy/opencode-json.js +129 -0
- package/src/steps/copy/skills.js +196 -0
- package/src/steps/metadata/index.js +108 -0
- package/src/steps/models/format.js +88 -0
- package/src/steps/models/index.js +64 -0
- package/src/steps/models/write.js +34 -0
- package/src/steps/openspec/index.js +136 -0
- package/src/steps/optimization/codegraph.js +127 -0
- package/src/steps/optimization/humanizer.js +17 -0
- package/src/steps/optimization/index.js +163 -0
- package/src/steps/optimization/memory.js +88 -0
- package/src/steps/optimization/patch-guardrails.js +108 -0
- package/src/steps/optimization/quota.js +119 -0
- package/src/steps/optimization/simple-english.js +17 -0
- package/src/steps/optimization/skills-lock.js +30 -0
- package/src/steps/platform/index.js +109 -0
- package/src/steps/source/index.js +123 -0
- package/src/utils/copy.js +108 -0
- package/src/utils/exec-spinner.js +47 -0
- package/src/utils/exec.js +134 -0
- package/src/utils/legacy-check.js +30 -0
- package/src/utils/models-cache.js +58 -0
- package/src/utils/models-pricing.js +42 -0
- package/src/utils/paths.js +64 -0
- package/src/utils/process.js +3 -0
- package/src/utils/terminal.js +6 -0
- package/src/utils/update-manifest.js +49 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { info, success } from '../../utils/exec.js'
|
|
5
|
+
import { canUpdateManagedFile, readUpdateManifest, recordManagedFile, writeUpdateManifest } from '../../utils/update-manifest.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../content/.agents/skills')
|
|
9
|
+
const CONTENT_SKILLS_LOCK = path.resolve(__dirname, '../../content/skills-lock.json')
|
|
10
|
+
|
|
11
|
+
// Userstory skills parse backlog work items: selected by backlogPlatform only.
|
|
12
|
+
// Mixing the two axes here installs the wrong variant on mixed setups, because
|
|
13
|
+
// all variants rename to the same generic dir and the first copy wins.
|
|
14
|
+
const BACKLOG_PLATFORM_SKILLS = {
|
|
15
|
+
'pc-userstory-gh': 'github',
|
|
16
|
+
'pc-userstory-az': 'azure',
|
|
17
|
+
'pc-userstory-jira': 'jira',
|
|
18
|
+
'pc-userstory-browser': 'browser',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Platform-specific skills are renamed to their generic form on install.
|
|
22
|
+
// The -gh / -az / -jira / -gl suffix is only needed here to keep all variants in source.
|
|
23
|
+
// After install only one platform is present so no suffix is needed.
|
|
24
|
+
const SKILL_RENAME = {
|
|
25
|
+
'pc-userstory-gh': 'pc-userstory',
|
|
26
|
+
'pc-userstory-az': 'pc-userstory',
|
|
27
|
+
'pc-userstory-jira': 'pc-userstory',
|
|
28
|
+
'pc-userstory-browser': 'pc-userstory',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
|
|
32
|
+
if (skill in BACKLOG_PLATFORM_SKILLS) return BACKLOG_PLATFORM_SKILLS[skill] === backlogPlatform
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Shipped skills whose content is a PLACEHOLDER — a /make-* command
|
|
37
|
+
// overwrites them with project-specific content. During forceOverwrite,
|
|
38
|
+
// these must be preserved when they have already been generated (detected
|
|
39
|
+
// by the <!-- Last updated: marker). Without this check, the update would
|
|
40
|
+
// wipe project-specific guardrails, risk assessments, etc.
|
|
41
|
+
const GENERATABLE_SKILLS = new Set([
|
|
42
|
+
'pc-guardrails-project',
|
|
43
|
+
'pc-merge-risk-assess',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const MARKER_SKILLS = new Set([
|
|
47
|
+
'pc-guardrails-generic',
|
|
48
|
+
'pc-plan-archive',
|
|
49
|
+
'pc-ops-ship',
|
|
50
|
+
'pc-ops-evidence',
|
|
51
|
+
'pc-repo-initialize',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
async function isGeneratedSkill(dest) {
|
|
55
|
+
const skillMd = path.join(dest, 'SKILL.md')
|
|
56
|
+
if (!await fse.pathExists(skillMd)) return false
|
|
57
|
+
const content = await fse.readFile(skillMd, 'utf-8')
|
|
58
|
+
return content.includes('<!-- Last updated:')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function syncSkillFiles(src, dest, relativeRoot, cwd, manifest) {
|
|
62
|
+
for (const entry of await fse.readdir(src, { withFileTypes: true })) {
|
|
63
|
+
const sourcePath = path.join(src, entry.name)
|
|
64
|
+
const destinationPath = path.join(dest, entry.name)
|
|
65
|
+
const relativePath = path.join(relativeRoot, entry.name)
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
await syncSkillFiles(sourcePath, destinationPath, relativePath, cwd, manifest)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (!await fse.pathExists(destinationPath) || await canUpdateManagedFile(relativePath, cwd, manifest)) {
|
|
71
|
+
await fse.ensureDir(path.dirname(destinationPath))
|
|
72
|
+
await fse.copyFile(sourcePath, destinationPath)
|
|
73
|
+
await recordManagedFile(manifest, relativePath, sourcePath)
|
|
74
|
+
success(`Updated skill: ${relativePath}`)
|
|
75
|
+
} else {
|
|
76
|
+
info(`Preserving modified skill file: ${relativePath}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function recordSkillSourceFiles(src, relativeRoot, manifest) {
|
|
82
|
+
for (const entry of await fse.readdir(src, { withFileTypes: true })) {
|
|
83
|
+
const sourcePath = path.join(src, entry.name)
|
|
84
|
+
const relativePath = path.join(relativeRoot, entry.name)
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
await recordSkillSourceFiles(sourcePath, relativePath, manifest)
|
|
87
|
+
} else {
|
|
88
|
+
await recordManagedFile(manifest, relativePath, sourcePath)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function installObSkills(backlogPlatform = 'github', repoPlatform, { forceOverwrite = false, updateMode = false } = {}) {
|
|
94
|
+
const repo = repoPlatform ?? backlogPlatform
|
|
95
|
+
const destSkillsDir = path.join(process.cwd(), '.agents', 'skills')
|
|
96
|
+
await fse.ensureDir(destSkillsDir)
|
|
97
|
+
const manifest = await readUpdateManifest()
|
|
98
|
+
|
|
99
|
+
// Build the set of skill names we ship (source dirs, after rename).
|
|
100
|
+
// Only these may be removed during forceOverwrite — project-generated
|
|
101
|
+
// skills (pc-merge-risk-assess, custom loops, etc.) must survive.
|
|
102
|
+
const contentSkills = await fse.readdir(CONTENT_SKILLS_DIR)
|
|
103
|
+
const shippedNames = new Set()
|
|
104
|
+
for (const skill of contentSkills) {
|
|
105
|
+
const stat = await fse.stat(path.join(CONTENT_SKILLS_DIR, skill)).catch(() => null)
|
|
106
|
+
if (stat?.isDirectory()) shippedNames.add(SKILL_RENAME[skill] ?? skill)
|
|
107
|
+
}
|
|
108
|
+
// Also account for stale platform-variant names (e.g. pc-userstory-gh
|
|
109
|
+
// when the current platform is azure).
|
|
110
|
+
Object.keys(SKILL_RENAME).forEach(k => shippedNames.add(k))
|
|
111
|
+
|
|
112
|
+
if (forceOverwrite && !updateMode) {
|
|
113
|
+
for (const entry of await fse.readdir(destSkillsDir)) {
|
|
114
|
+
if (!entry.startsWith('pc-')) continue
|
|
115
|
+
if (!shippedNames.has(entry)) {
|
|
116
|
+
info(`Preserving project-generated skill: ${entry}`)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
// Generatable skills: preserve if already populated by /make-*
|
|
120
|
+
if (GENERATABLE_SKILLS.has(entry) && await isGeneratedSkill(path.join(destSkillsDir, entry))) {
|
|
121
|
+
info(`Preserving generated skill: ${entry}`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
await fse.remove(path.join(destSkillsDir, entry))
|
|
125
|
+
info(`Removing shipped skill: ${entry}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const skills = contentSkills
|
|
130
|
+
for (const skill of skills) {
|
|
131
|
+
const src = path.join(CONTENT_SKILLS_DIR, skill)
|
|
132
|
+
const destName = SKILL_RENAME[skill] ?? skill
|
|
133
|
+
const dest = path.join(destSkillsDir, destName)
|
|
134
|
+
const stat = await fse.stat(src)
|
|
135
|
+
if (!stat.isDirectory()) continue
|
|
136
|
+
if (!shouldInstallSkill(skill, backlogPlatform, repo)) {
|
|
137
|
+
if (forceOverwrite) {
|
|
138
|
+
const staleDest = path.join(destSkillsDir, skill)
|
|
139
|
+
if (skill !== destName && await fse.pathExists(staleDest)) {
|
|
140
|
+
await fse.remove(staleDest)
|
|
141
|
+
info(`Removing stale skill: ${skill}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
info(`Skipping skill: ${skill} (not needed for platforms: ${backlogPlatform}/${repo})`)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (updateMode) {
|
|
148
|
+
if (!await fse.pathExists(dest)) {
|
|
149
|
+
await syncSkillFiles(src, dest, path.join('.agents', 'skills', destName), process.cwd(), manifest)
|
|
150
|
+
} else if (MARKER_SKILLS.has(destName) || GENERATABLE_SKILLS.has(destName)) {
|
|
151
|
+
info(`Preserving project-owned skill: ${destName}`)
|
|
152
|
+
} else {
|
|
153
|
+
await syncSkillFiles(src, dest, path.join('.agents', 'skills', destName), process.cwd(), manifest)
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (await fse.pathExists(dest) && !forceOverwrite) {
|
|
158
|
+
info(`${destName} already exists, skipping`)
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
// Even with forceOverwrite, never overwrite an already-generated
|
|
162
|
+
// generatable skill — its content was produced by /make-guardrails or
|
|
163
|
+
// similar, not by the shipped placeholder.
|
|
164
|
+
if (forceOverwrite && GENERATABLE_SKILLS.has(destName) && await isGeneratedSkill(dest)) {
|
|
165
|
+
info(`Preserving generated skill: ${destName}`)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
await fse.copy(src, dest, { overwrite: true })
|
|
169
|
+
if (!MARKER_SKILLS.has(destName) && !GENERATABLE_SKILLS.has(destName)) {
|
|
170
|
+
await recordSkillSourceFiles(src, path.join('.agents', 'skills', destName), manifest)
|
|
171
|
+
}
|
|
172
|
+
success(`${forceOverwrite ? 'Updated' : 'Installed'} skill: ${destName}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await writeUpdateManifest(manifest)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function installSkills(backlogPlatform = 'github', repoPlatform, opts = {}) {
|
|
179
|
+
info('Installing built-in pc-skills...')
|
|
180
|
+
await installObSkills(backlogPlatform, repoPlatform, opts)
|
|
181
|
+
console.log()
|
|
182
|
+
|
|
183
|
+
if (await fse.pathExists(CONTENT_SKILLS_LOCK)) {
|
|
184
|
+
const destLock = path.join(process.cwd(), 'skills-lock.json')
|
|
185
|
+
if (await fse.pathExists(destLock) && (opts.updateMode || !opts.forceOverwrite)) {
|
|
186
|
+
info('skills-lock.json already exists, skipping')
|
|
187
|
+
} else {
|
|
188
|
+
await fse.copy(CONTENT_SKILLS_LOCK, destLock, { overwrite: true })
|
|
189
|
+
success(`${opts.forceOverwrite ? 'Updated' : 'Installed'} skills-lock.json`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// npx skills experimental_install is now called once at the end of the
|
|
194
|
+
// optimization step, after the user has selected which tools to enable.
|
|
195
|
+
// This avoids multiple npx skills add calls during onboarding.
|
|
196
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { header, success, warn } from '../../utils/exec.js'
|
|
6
|
+
import { configPath } from '../../utils/paths.js'
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url)
|
|
9
|
+
const { version: harnessVersion } = require('../../../package.json')
|
|
10
|
+
|
|
11
|
+
function clampConcurrency(n) {
|
|
12
|
+
const v = Number(n)
|
|
13
|
+
if (!Number.isFinite(v)) return 3
|
|
14
|
+
return Math.min(5, Math.max(1, Math.round(v)))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function detectOpencodeVersion() {
|
|
18
|
+
try {
|
|
19
|
+
const result = await execa('opencode', ['--version'], { reject: false })
|
|
20
|
+
if (result.exitCode !== 0) return null
|
|
21
|
+
const output = (result.stdout || result.stderr || '').trim()
|
|
22
|
+
return output || null
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeHarnessConfig(data) {
|
|
29
|
+
header('Step 10, Writing onboarding metadata')
|
|
30
|
+
|
|
31
|
+
const opencodeVersion = await detectOpencodeVersion()
|
|
32
|
+
const cwd = data.cwd ?? process.cwd()
|
|
33
|
+
const target = configPath(cwd)
|
|
34
|
+
|
|
35
|
+
// Read the existing file so load-bearing config (models, maxConcurrent)
|
|
36
|
+
// is preserved across metadata refreshes when not explicitly provided.
|
|
37
|
+
const existing = await fse.readJson(target).catch(() => null)
|
|
38
|
+
|
|
39
|
+
const selectedModels = Object.fromEntries(
|
|
40
|
+
Object.entries({
|
|
41
|
+
plan: data.planModel,
|
|
42
|
+
build: data.buildModel,
|
|
43
|
+
fast: data.fastModel,
|
|
44
|
+
}).filter(([, value]) => value)
|
|
45
|
+
)
|
|
46
|
+
const models = Object.keys(selectedModels).length > 0
|
|
47
|
+
? { ...existing?.models, ...selectedModels }
|
|
48
|
+
: existing?.models ?? null
|
|
49
|
+
const maxConcurrent = clampConcurrency(data.maxConcurrentAgents ?? existing?.agents?.maxConcurrent ?? 3)
|
|
50
|
+
|
|
51
|
+
const optionalTools = data.optionalTools ?? existing?.tools ?? {}
|
|
52
|
+
const { caveman: _caveman, ...existingTools } = existing?.tools ?? {}
|
|
53
|
+
const tools = {
|
|
54
|
+
rtk: !!(optionalTools.rtk?.optedIn ?? optionalTools.rtk),
|
|
55
|
+
quota: !!(optionalTools.quota?.optedIn ?? optionalTools.quota),
|
|
56
|
+
simpleEnglish: !!(
|
|
57
|
+
optionalTools.simpleEnglish?.optedIn ?? optionalTools.simpleEnglish ??
|
|
58
|
+
optionalTools.caveman?.optedIn ?? optionalTools.caveman
|
|
59
|
+
),
|
|
60
|
+
codegraph: !!(optionalTools.codegraph?.optedIn ?? optionalTools.codegraph),
|
|
61
|
+
memory: !!(optionalTools.memory?.optedIn ?? optionalTools.memory),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const payload = {
|
|
65
|
+
...existing,
|
|
66
|
+
version: 2,
|
|
67
|
+
generatedAt: new Date().toISOString(),
|
|
68
|
+
harnessVersion,
|
|
69
|
+
opencodeVersion,
|
|
70
|
+
|
|
71
|
+
platform: {
|
|
72
|
+
...existing?.platform,
|
|
73
|
+
backlog: data.backlogPlatform ?? 'none',
|
|
74
|
+
repo: data.repoPlatform ?? 'none',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
...(models ? { models } : {}),
|
|
78
|
+
|
|
79
|
+
agents: {
|
|
80
|
+
...existing?.agents,
|
|
81
|
+
maxConcurrent,
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
source: {
|
|
85
|
+
...existing?.source,
|
|
86
|
+
mode: data.sourceMode ?? 'current',
|
|
87
|
+
roots: data.sourceRoots ?? [],
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
tools: { ...existingTools, ...tools },
|
|
91
|
+
|
|
92
|
+
preexisting: {
|
|
93
|
+
...existing?.preexisting,
|
|
94
|
+
design: !!data.hasDesign,
|
|
95
|
+
architecture: !!data.hasArchitecture,
|
|
96
|
+
openspec: !!data.hasOpenspec,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await fse.ensureDir(path.dirname(target))
|
|
102
|
+
await fse.writeJson(target, payload, { spaces: 2 })
|
|
103
|
+
success('Wrote .opencode/harness.json')
|
|
104
|
+
if (!opencodeVersion) warn('Could not detect opencode version, saved as null')
|
|
105
|
+
} catch (err) {
|
|
106
|
+
warn(`Could not write onboarding metadata: ${err.message}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { search, select } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const MODELS_PRESET_PATH = path.resolve(__dirname, '../../presets/models.json');
|
|
8
|
+
const modelsPreset = await fse.readJson(MODELS_PRESET_PATH);
|
|
9
|
+
|
|
10
|
+
function costTier(input) {
|
|
11
|
+
if (input === undefined || input === null) return '';
|
|
12
|
+
const tier = modelsPreset.costTiers.find(t => t.max === undefined || input <= t.max);
|
|
13
|
+
return tier ? ` ${tier.label}` : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function costTierDisplay(cost, canonicalCost) {
|
|
17
|
+
return costTier(canonicalCost !== undefined ? canonicalCost : cost);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatPrice(price) {
|
|
21
|
+
if (price === undefined || price === null) return '?';
|
|
22
|
+
if (price === 0) return '$0 (subscription)';
|
|
23
|
+
return `$${price}/M`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildDisplayModels(rawModels) {
|
|
27
|
+
return rawModels.map(m => {
|
|
28
|
+
const priceStr = formatPrice(m.cost);
|
|
29
|
+
const canonicalNote = m.canonicalCost !== undefined
|
|
30
|
+
? ` · official price: ${formatPrice(m.canonicalCost)}/M`
|
|
31
|
+
: '';
|
|
32
|
+
const context = m.context ? `${m.context / 1000}k` : '?';
|
|
33
|
+
return {
|
|
34
|
+
...m,
|
|
35
|
+
label: `${m.name}${costTierDisplay(m.cost, m.canonicalCost)}, ${m.id}`,
|
|
36
|
+
description: `${priceStr}${canonicalNote} · context: ${context}`,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildModelChoices(input, models) {
|
|
42
|
+
const q = (input || '').toLowerCase();
|
|
43
|
+
const filtered = q
|
|
44
|
+
? models.filter(m =>
|
|
45
|
+
m.label.toLowerCase().includes(q) ||
|
|
46
|
+
m.id.toLowerCase().includes(q)
|
|
47
|
+
)
|
|
48
|
+
: models;
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
name: 'None',
|
|
53
|
+
value: null,
|
|
54
|
+
description: 'Leave this model unset',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: '← Change provider',
|
|
58
|
+
value: '__back__',
|
|
59
|
+
description: 'Go back to provider selection',
|
|
60
|
+
},
|
|
61
|
+
...filtered.slice(0, 50).map(m => ({
|
|
62
|
+
name: m.label,
|
|
63
|
+
value: m.id,
|
|
64
|
+
description: m.description,
|
|
65
|
+
})),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pickModel(message, models) {
|
|
70
|
+
return search({
|
|
71
|
+
message,
|
|
72
|
+
source: input => buildModelChoices(input, models),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function filterModelsByProvider(models, provider) {
|
|
77
|
+
if (!provider) return models
|
|
78
|
+
return models.filter(m => m.id.startsWith(`${provider}/`))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function pickProvider(message) {
|
|
82
|
+
return select({
|
|
83
|
+
message,
|
|
84
|
+
choices: modelsPreset.providers.map(p => ({ value: p.value, name: p.name })),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { modelsPreset };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { header, info, prompt, success, warn } from '../../utils/exec.js'
|
|
2
|
+
import { fetchModels } from '../../utils/models-cache.js'
|
|
3
|
+
import { buildDisplayModels, filterModelsByProvider, modelsPreset, pickModel, pickProvider } from './format.js'
|
|
4
|
+
import { writeModelsToConfigs } from './write.js'
|
|
5
|
+
|
|
6
|
+
async function pickRoleModel(role, allModels) {
|
|
7
|
+
const rolePreset = modelsPreset.roles[role]
|
|
8
|
+
const [first, ...rest] = rolePreset.info
|
|
9
|
+
prompt(first)
|
|
10
|
+
for (const line of rest) info(line)
|
|
11
|
+
console.log()
|
|
12
|
+
|
|
13
|
+
while (true) {
|
|
14
|
+
const provider = await pickProvider('Provider:')
|
|
15
|
+
const filtered = filterModelsByProvider(allModels, provider)
|
|
16
|
+
const available = filtered.length > 0 ? filtered : allModels
|
|
17
|
+
|
|
18
|
+
const suggestion = rolePreset.suggestions?.[provider]
|
|
19
|
+
if (suggestion) info(` Suggested: ${suggestion}`)
|
|
20
|
+
if (filtered.length === 0 && provider) warn(` No ${provider} models found: showing all`)
|
|
21
|
+
|
|
22
|
+
const model = await pickModel(rolePreset.prompt, available)
|
|
23
|
+
if (model !== '__back__') return model
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function chooseModels() {
|
|
28
|
+
header('Step 7, Choose models');
|
|
29
|
+
|
|
30
|
+
info('Fetching available models from models.dev...');
|
|
31
|
+
const { models: rawModels, source } = await fetchModels();
|
|
32
|
+
|
|
33
|
+
if (!rawModels) {
|
|
34
|
+
warn('Could not fetch models (offline and no cache). Skipping model selection.');
|
|
35
|
+
warn('Set models later in opencode.jsonc');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (source === 'stale-cache') {
|
|
40
|
+
warn('Network unavailable, using cached model list (may be outdated).');
|
|
41
|
+
} else if (source === 'cache') {
|
|
42
|
+
info('Using cached model list (refreshes weekly).');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const models = buildDisplayModels(rawModels);
|
|
46
|
+
success(`${models.length} models available`);
|
|
47
|
+
console.log();
|
|
48
|
+
info('Three models: main session, engineers, and light commands.');
|
|
49
|
+
info('Cost indicators: [$] cheap [$$] mid [$$$] expensive');
|
|
50
|
+
info('Pick provider first, then search by name. Change later in opencode.jsonc');
|
|
51
|
+
console.log();
|
|
52
|
+
|
|
53
|
+
const planModel = await pickRoleModel('plan', models); console.log();
|
|
54
|
+
const buildModel = await pickRoleModel('build', models); console.log();
|
|
55
|
+
const fastModel = await pickRoleModel('fast', models); console.log();
|
|
56
|
+
|
|
57
|
+
await writeModelsToConfigs({ planModel, buildModel, fastModel });
|
|
58
|
+
|
|
59
|
+
console.log();
|
|
60
|
+
warn('Make sure you have API access to the selected models.');
|
|
61
|
+
warn('Change them anytime in opencode.jsonc');
|
|
62
|
+
|
|
63
|
+
return { planModel, buildModel, fastModel };
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import { applyEdits, modify, parse } from 'jsonc-parser'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { success, warn } from '../../utils/exec.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writes the selected build model as the default model in opencode.jsonc.
|
|
8
|
+
* Per-tier models (plan/build/fast) are recorded in harness.json (models)
|
|
9
|
+
* by the metadata step, then consumed by the agent-variant generator and /plan-apply.
|
|
10
|
+
*
|
|
11
|
+
* opencode tolerates JSONC (comments, trailing commas) in its config, so this
|
|
12
|
+
* must not use readJson/writeJson: a strict parse crashes on valid configs and
|
|
13
|
+
* a strict rewrite deletes user comments. jsonc-parser edits only the `model`
|
|
14
|
+
* key and preserves the rest of the file byte-for-byte.
|
|
15
|
+
*/
|
|
16
|
+
export async function writeModelsToConfigs({ buildModel, cwd = process.cwd() }) {
|
|
17
|
+
const opencodeJsonPath = path.join(cwd, 'opencode.jsonc')
|
|
18
|
+
if (!await fse.pathExists(opencodeJsonPath)) return
|
|
19
|
+
|
|
20
|
+
const text = await fse.readFile(opencodeJsonPath, 'utf-8')
|
|
21
|
+
const errors = []
|
|
22
|
+
parse(text, errors)
|
|
23
|
+
if (errors.length > 0) {
|
|
24
|
+
warn('opencode.jsonc could not be parsed: leaving it untouched. Set "model" manually.')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `undefined` removes the key; a string value sets it.
|
|
29
|
+
const edits = modify(text, ['model'], buildModel || undefined, {
|
|
30
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
31
|
+
})
|
|
32
|
+
await fse.writeFile(opencodeJsonPath, applyEdits(text, edits), 'utf-8')
|
|
33
|
+
if (buildModel) success(`default model -> ${buildModel} (written to opencode.jsonc)`)
|
|
34
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execa } from "execa"
|
|
2
|
+
import fse from "fs-extra"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { commandExists, error, header, info, loading, success, warn } from "../../utils/exec.js"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async function cleanOpenspecFiles() {
|
|
8
|
+
const cwd = process.cwd()
|
|
9
|
+
const commandsDir = path.join(cwd, '.opencode', 'commands')
|
|
10
|
+
const opencodeSkillsDir = path.join(cwd, '.opencode', 'skills')
|
|
11
|
+
const agentsSkillsDir = path.join(cwd, '.agents', 'skills')
|
|
12
|
+
|
|
13
|
+
// Delete opsx-* command files generated by OpenSpec: we use /plan-propose and /plan-apply instead
|
|
14
|
+
if (await fse.pathExists(commandsDir)) {
|
|
15
|
+
for (const f of await fse.readdir(commandsDir)) {
|
|
16
|
+
if (f.startsWith('opsx-') && f.endsWith('.md')) {
|
|
17
|
+
await fse.remove(path.join(commandsDir, f))
|
|
18
|
+
info(`Removed generated command: ${f}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Move .opencode/skills/* to .agents/skills/: skills should be in the universal location
|
|
24
|
+
if (await fse.pathExists(opencodeSkillsDir)) {
|
|
25
|
+
await fse.ensureDir(agentsSkillsDir)
|
|
26
|
+
for (const dir of await fse.readdir(opencodeSkillsDir)) {
|
|
27
|
+
const src = path.join(opencodeSkillsDir, dir)
|
|
28
|
+
const dest = path.join(agentsSkillsDir, dir)
|
|
29
|
+
if (!(await fse.pathExists(dest))) {
|
|
30
|
+
await fse.move(src, dest)
|
|
31
|
+
info(`Moved skill: ${dir} → .agents/skills/`)
|
|
32
|
+
} else {
|
|
33
|
+
await fse.remove(src)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Remove .opencode/skills if now empty
|
|
37
|
+
const remaining = await fse.readdir(opencodeSkillsDir)
|
|
38
|
+
if (remaining.length === 0) await fse.remove(opencodeSkillsDir)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
success('OpenSpec generated files cleaned up')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const openspecSteps = {
|
|
45
|
+
check,
|
|
46
|
+
install,
|
|
47
|
+
init,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function initOpenspec() {
|
|
51
|
+
header("Step 6, Initializing OpenSpec")
|
|
52
|
+
|
|
53
|
+
let openSpec = await openspecSteps.check()
|
|
54
|
+
if (!openSpec.available) {
|
|
55
|
+
await openspecSteps.install()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
openSpec = await openspecSteps.check()
|
|
59
|
+
if (!openSpec.available) {
|
|
60
|
+
warn("OpenSpec has not been installed. It is required to create changes.")
|
|
61
|
+
return { ...openSpec, initialized: false }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const initResult = await openspecSteps.init()
|
|
65
|
+
if (!initResult.initialized) {
|
|
66
|
+
warn(
|
|
67
|
+
"OpenSpec has not been initialized. It is required to create changes.",
|
|
68
|
+
)
|
|
69
|
+
return { ...openSpec, initialized: false }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await cleanOpenspecFiles()
|
|
73
|
+
return { ...openSpec, initialized: true }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function install() {
|
|
77
|
+
info("Installing OpenSpec...")
|
|
78
|
+
try {
|
|
79
|
+
const result = await execa(
|
|
80
|
+
"npm",
|
|
81
|
+
["install", "@fission-ai/openspec", "--global"],
|
|
82
|
+
{
|
|
83
|
+
cwd: process.cwd(),
|
|
84
|
+
stdio: "pipe",
|
|
85
|
+
reject: false,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
if (result.exitCode !== 0) {
|
|
89
|
+
warn("OpenSpec install failed, check output above")
|
|
90
|
+
}
|
|
91
|
+
success("OpenSpec installed")
|
|
92
|
+
} catch (err) {
|
|
93
|
+
error(`Failed to run openspec install: ${err.message}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function check() {
|
|
98
|
+
loading("Checking OpenSpec...")
|
|
99
|
+
|
|
100
|
+
const available = await commandExists("openspec")
|
|
101
|
+
|
|
102
|
+
if (available) success("OpenSpec is available")
|
|
103
|
+
else warn("OpenSpec not found on PATH.")
|
|
104
|
+
|
|
105
|
+
return { available }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function init() {
|
|
109
|
+
loading("Initializing OpenSpec...")
|
|
110
|
+
try {
|
|
111
|
+
const result = await execa(
|
|
112
|
+
"npx",
|
|
113
|
+
["@fission-ai/openspec", "init", "--tools", "opencode", "--force"],
|
|
114
|
+
{
|
|
115
|
+
cwd: process.cwd(),
|
|
116
|
+
reject: false,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if (result.exitCode !== 0) {
|
|
121
|
+
const stderr = (result.stderr || "").trim()
|
|
122
|
+
const msg = stderr
|
|
123
|
+
? `init failed: ${stderr}`
|
|
124
|
+
: `init failed with exit code ${result.exitCode}`
|
|
125
|
+
throw new Error(msg)
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
error(`Failed to run openspec init: ${err.message}`)
|
|
129
|
+
return { initialized: false }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
success("OpenSpec initialized")
|
|
133
|
+
return { initialized: true }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|