@plainconceptsplatform/agent-harness 2.1.0 → 2.2.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/package.json +1 -1
- package/src/commands/migrate.js +310 -0
- package/src/index.js +13 -4
- package/src/steps/copy/agents.js +119 -118
- package/src/steps/copy/commands.js +95 -91
- package/src/steps/copy/skills.js +2 -2
- package/src/steps/optimization/patch-guardrails.js +109 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plainconceptsplatform/agent-harness",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Installs the Plain Concepts Platform Harness into any codebase, and keeps it up to date. Wires OpenCode, OpenSpec, codegraph, and agentmemory into a multi-agent workflow that runs on native parallel subagents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execa } from 'execa'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { GENERATABLE_SKILLS, SKILL_RENAME } from '../steps/copy/skills.js'
|
|
7
|
+
import { header, info, success, warn } from '../utils/exec.js'
|
|
8
|
+
import { CONFIG_FILE, MANIFEST_FILE, OPENCODE_DIR, USER_CONFIG_FILE } from '../utils/paths.js'
|
|
9
|
+
import { exit } from '../utils/process.js'
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../content/.agents/skills')
|
|
13
|
+
|
|
14
|
+
// State files, old name to new. The run-state file is deleted rather than moved:
|
|
15
|
+
// it is live wave state owned by the monitor plugin and is rebuilt on demand.
|
|
16
|
+
const STATE_MOVES = [
|
|
17
|
+
['opencode-onboard.json', CONFIG_FILE],
|
|
18
|
+
['opencode-onboard.user.json', USER_CONFIG_FILE],
|
|
19
|
+
['opencode-onboard-managed.json', MANIFEST_FILE],
|
|
20
|
+
]
|
|
21
|
+
const STATE_DELETES = ['.ob-run.json']
|
|
22
|
+
|
|
23
|
+
const TEXT_EXTENSIONS = new Set(['.md', '.mdx', '.json', '.jsonc', '.js', '.ts', '.tsx', '.yaml', '.yml', '.txt'])
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage'])
|
|
25
|
+
|
|
26
|
+
// Archived OpenSpec changes are an audit trail: those proposals really did
|
|
27
|
+
// reference ob-* skills when they were written. Rewriting them would make the
|
|
28
|
+
// record say something that was never true, so the archive is left verbatim.
|
|
29
|
+
const ARCHIVE_SEGMENTS = ['openspec', 'changes', 'archive']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The skills this package ships, expressed under the old `ob-` prefix.
|
|
33
|
+
*
|
|
34
|
+
* A shipped skill is safe to delete because `update` reinstalls it as `pc-*`
|
|
35
|
+
* with current content, and that is strictly better than renaming a v1 copy we
|
|
36
|
+
* would then have to convince the manifest to overwrite. Anything NOT in this
|
|
37
|
+
* set is project-owned and must be renamed with its content intact.
|
|
38
|
+
*/
|
|
39
|
+
async function shippedLegacyNames() {
|
|
40
|
+
const entries = await fse.readdir(CONTENT_SKILLS_DIR, { withFileTypes: true }).catch(() => [])
|
|
41
|
+
const names = new Set()
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isDirectory()) continue
|
|
44
|
+
// Platform variants install under a generic name, so both spellings ship.
|
|
45
|
+
for (const name of [entry.name, SKILL_RENAME[entry.name]].filter(Boolean)) {
|
|
46
|
+
if (name.startsWith('pc-')) names.add(`ob-${name.slice(3)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return names
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Generated skills carry project content even though the placeholder ships. */
|
|
53
|
+
function isGeneratable(legacyName) {
|
|
54
|
+
return GENERATABLE_SKILLS.has(`pc-${legacyName.slice(3)}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isArchived(filePath) {
|
|
58
|
+
// Split on both separators: path.join yields backslashes on Windows, and a
|
|
59
|
+
// separator-blind check here would let the archive be rewritten there only.
|
|
60
|
+
const parts = filePath.split(/[\\/]/)
|
|
61
|
+
const at = parts.indexOf(ARCHIVE_SEGMENTS[0])
|
|
62
|
+
if (at === -1) return false
|
|
63
|
+
return ARCHIVE_SEGMENTS.every((segment, i) => parts[at + i] === segment)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function collectTextFiles(dir, acc = []) {
|
|
67
|
+
const entries = await fse.readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
70
|
+
const full = path.join(dir, entry.name)
|
|
71
|
+
if (isArchived(full)) continue
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await collectTextFiles(full, acc)
|
|
74
|
+
} else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || entry.name === '.gitignore') {
|
|
75
|
+
acc.push(full)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return acc
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite the identifiers v2 renamed.
|
|
83
|
+
*
|
|
84
|
+
* `\b` matters: a bare `ob-` also appears inside words such as `{blob-url}` in
|
|
85
|
+
* the shipped GitHub ship fragment, and rewriting that silently corrupts the
|
|
86
|
+
* command it belongs to.
|
|
87
|
+
*/
|
|
88
|
+
function rewriteIdentifiers(text) {
|
|
89
|
+
return text
|
|
90
|
+
.replace(/\bob-/g, 'pc-')
|
|
91
|
+
.replace(/\bOB-/g, 'PC-')
|
|
92
|
+
.replace(/opencode-onboard-managed\.json/g, MANIFEST_FILE)
|
|
93
|
+
.replace(/opencode-onboard\.user\.json/g, USER_CONFIG_FILE)
|
|
94
|
+
.replace(/opencode-onboard\.json/g, CONFIG_FILE)
|
|
95
|
+
.replace(/@plainconceptsplatform\/opencode-onboard/g, '@plainconceptsplatform/agent-harness')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Base engineer templates and fullstack are subagents in v2. */
|
|
99
|
+
function demoteToSubagent(text) {
|
|
100
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
101
|
+
if (!fm) return text
|
|
102
|
+
if (!/^mode:\s*primary/m.test(fm[1])) return text
|
|
103
|
+
const patched = fm[1].replace(/^mode:.*$/m, 'mode: subagent')
|
|
104
|
+
return `---\n${patched}\n---${text.slice(fm[0].length)}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function isDirty(cwd) {
|
|
108
|
+
const result = await execa('git', ['status', '--porcelain'], { cwd, reject: false })
|
|
109
|
+
if (result.exitCode !== 0) return false
|
|
110
|
+
return result.stdout.trim().length > 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function planMigration(cwd = process.cwd()) {
|
|
114
|
+
const opencodeDir = path.join(cwd, OPENCODE_DIR)
|
|
115
|
+
const skillsDir = path.join(cwd, '.agents', 'skills')
|
|
116
|
+
|
|
117
|
+
const moves = []
|
|
118
|
+
for (const [from, to] of STATE_MOVES) {
|
|
119
|
+
if (await fse.pathExists(path.join(opencodeDir, from))) moves.push([from, to])
|
|
120
|
+
}
|
|
121
|
+
const deletes = []
|
|
122
|
+
for (const name of STATE_DELETES) {
|
|
123
|
+
if (await fse.pathExists(path.join(opencodeDir, name))) deletes.push(name)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const shipped = await shippedLegacyNames()
|
|
127
|
+
const entries = (await fse.readdir(skillsDir).catch(() => [])).filter(e => e.startsWith('ob-'))
|
|
128
|
+
|
|
129
|
+
const skillsToRename = []
|
|
130
|
+
const skillsToDrop = []
|
|
131
|
+
for (const name of entries) {
|
|
132
|
+
const stat = await fse.stat(path.join(skillsDir, name)).catch(() => null)
|
|
133
|
+
if (!stat?.isDirectory()) continue
|
|
134
|
+
// Generated content wins over "it ships": the placeholder shipped, the
|
|
135
|
+
// 150-line project guardrails that replaced it did not.
|
|
136
|
+
if (isGeneratable(name) || !shipped.has(name)) skillsToRename.push([name, `pc-${name.slice(3)}`])
|
|
137
|
+
else skillsToDrop.push(name)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const agentsDir = path.join(cwd, OPENCODE_DIR, 'agents')
|
|
141
|
+
const agentFiles = await fse.readdir(agentsDir).catch(() => [])
|
|
142
|
+
const staleVariants = agentFiles.filter(f => /^[\w-]+-engineer\.(build|fast|plan)\.md$/.test(f))
|
|
143
|
+
|
|
144
|
+
// Plugins and the TUI panel are shipped code, and update installs the pc-*
|
|
145
|
+
// copies alongside rather than over the ob-* ones. Left in place, both
|
|
146
|
+
// generations load: two tier plugins writing the same variant files and two
|
|
147
|
+
// monitors writing the same run state.
|
|
148
|
+
const stalePlugins = []
|
|
149
|
+
for (const dir of ['plugins', 'tui']) {
|
|
150
|
+
const full = path.join(cwd, OPENCODE_DIR, dir)
|
|
151
|
+
for (const file of await fse.readdir(full).catch(() => [])) {
|
|
152
|
+
if (file.startsWith('ob-')) stalePlugins.push(path.join(dir, file))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { moves, deletes, skillsToRename, skillsToDrop, staleVariants, stalePlugins }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function runMigrate({ cwd = process.cwd(), force = false, dryRun = false } = {}) {
|
|
160
|
+
header('Migrating from opencode-onboard v1')
|
|
161
|
+
|
|
162
|
+
const opencodeDir = path.join(cwd, OPENCODE_DIR)
|
|
163
|
+
if (await fse.pathExists(path.join(opencodeDir, CONFIG_FILE))) {
|
|
164
|
+
success(`${OPENCODE_DIR}/${CONFIG_FILE} already exists: this project is already on v2.`)
|
|
165
|
+
return { migrated: false, alreadyV2: true }
|
|
166
|
+
}
|
|
167
|
+
if (!await fse.pathExists(path.join(opencodeDir, 'opencode-onboard.json'))) {
|
|
168
|
+
warn(`No ${OPENCODE_DIR}/opencode-onboard.json found: nothing to migrate.`)
|
|
169
|
+
return { migrated: false }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The migration rewrites files in place across the repo. A clean tree means
|
|
173
|
+
// `git checkout .` is always a complete undo.
|
|
174
|
+
if (!force && !dryRun && await isDirty(cwd)) {
|
|
175
|
+
warn('Working tree has uncommitted changes.')
|
|
176
|
+
warn('Commit or stash them first so this migration can be reverted with `git checkout .`, or pass --force.')
|
|
177
|
+
return { migrated: false, dirty: true }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const plan = await planMigration(cwd)
|
|
181
|
+
|
|
182
|
+
for (const [from, to] of plan.moves) info(`${from} -> ${to}`)
|
|
183
|
+
for (const name of plan.deletes) info(`${name} -> removed (rebuilt on demand)`)
|
|
184
|
+
for (const [from, to] of plan.skillsToRename) info(`${from}/ -> ${to}/ (content kept)`)
|
|
185
|
+
if (plan.skillsToDrop.length > 0) {
|
|
186
|
+
info(`${plan.skillsToDrop.length} shipped skill(s) removed, reinstalled as pc-* by update`)
|
|
187
|
+
}
|
|
188
|
+
if (plan.staleVariants.length > 0) {
|
|
189
|
+
info(`${plan.staleVariants.length} tier variant(s) removed, regenerated at startup`)
|
|
190
|
+
}
|
|
191
|
+
if (plan.stalePlugins.length > 0) {
|
|
192
|
+
info(`${plan.stalePlugins.length} ob- plugin/tui file(s) removed, replaced by pc-* on update`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (dryRun) {
|
|
196
|
+
console.log()
|
|
197
|
+
info('Dry run: nothing written.')
|
|
198
|
+
return { migrated: false, dryRun: true, plan }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 1. State files. The manifest keeps its hashes; only its keys are renamed,
|
|
202
|
+
// so update still recognizes untouched files and refreshes them.
|
|
203
|
+
for (const [from, to] of plan.moves) {
|
|
204
|
+
const fromPath = path.join(opencodeDir, from)
|
|
205
|
+
const toPath = path.join(opencodeDir, to)
|
|
206
|
+
if (to === MANIFEST_FILE) {
|
|
207
|
+
const manifest = await fse.readJson(fromPath).catch(() => null)
|
|
208
|
+
if (manifest?.files && typeof manifest.files === 'object') {
|
|
209
|
+
manifest.files = Object.fromEntries(
|
|
210
|
+
Object.entries(manifest.files).map(([key, value]) => [rewriteIdentifiers(key), value]),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
await fse.writeJson(toPath, manifest ?? { version: 1, files: {} }, { spaces: 2 })
|
|
214
|
+
await fse.remove(fromPath)
|
|
215
|
+
} else {
|
|
216
|
+
await fse.move(fromPath, toPath, { overwrite: true })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const name of plan.deletes) await fse.remove(path.join(opencodeDir, name))
|
|
220
|
+
|
|
221
|
+
// 2. Skills: rename what holds project content, drop what update reinstalls.
|
|
222
|
+
const skillsDir = path.join(cwd, '.agents', 'skills')
|
|
223
|
+
for (const [from, to] of plan.skillsToRename) {
|
|
224
|
+
await fse.move(path.join(skillsDir, from), path.join(skillsDir, to), { overwrite: true })
|
|
225
|
+
}
|
|
226
|
+
for (const name of plan.skillsToDrop) await fse.remove(path.join(skillsDir, name))
|
|
227
|
+
|
|
228
|
+
// 3. Stale tier variants: the plugin rebuilds these from the base templates.
|
|
229
|
+
const agentsDir = path.join(opencodeDir, 'agents')
|
|
230
|
+
for (const file of plan.staleVariants) await fse.remove(path.join(agentsDir, file))
|
|
231
|
+
|
|
232
|
+
// 3b. Stale plugins and TUI panel, so only one generation loads.
|
|
233
|
+
for (const relative of plan.stalePlugins) await fse.remove(path.join(opencodeDir, relative))
|
|
234
|
+
|
|
235
|
+
// 4. Rewrite identifiers everywhere that survives, so preserved skills and
|
|
236
|
+
// custom agents stop pointing at names that no longer exist.
|
|
237
|
+
let rewritten = 0
|
|
238
|
+
// .github/workflows is included because GitHub Agentic Workflow definitions
|
|
239
|
+
// call the skills by name (skill("pc-repo-audit")), so a stale ob- name there
|
|
240
|
+
// fails at run time in CI rather than locally. Their compiled .lock.yml files
|
|
241
|
+
// embed the same prompt text, so recompile with `gh aw compile` afterwards.
|
|
242
|
+
for (const dir of [skillsDir, opencodeDir, path.join(cwd, 'openspec'), path.join(cwd, '.github', 'workflows')]) {
|
|
243
|
+
if (!await fse.pathExists(dir)) continue
|
|
244
|
+
for (const file of await collectTextFiles(dir)) {
|
|
245
|
+
const before = await fse.readFile(file, 'utf-8')
|
|
246
|
+
const after = rewriteIdentifiers(before)
|
|
247
|
+
if (after !== before) {
|
|
248
|
+
await fse.writeFile(file, after, 'utf-8')
|
|
249
|
+
rewritten++
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'DESIGN.md', 'opencode.jsonc', 'opencode.json']) {
|
|
254
|
+
const file = path.join(cwd, name)
|
|
255
|
+
if (!await fse.pathExists(file)) continue
|
|
256
|
+
const before = await fse.readFile(file, 'utf-8')
|
|
257
|
+
const after = rewriteIdentifiers(before)
|
|
258
|
+
if (after !== before) {
|
|
259
|
+
await fse.writeFile(file, after, 'utf-8')
|
|
260
|
+
rewritten++
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 5. Engineers become subagents; build and plan are the only primaries now.
|
|
265
|
+
// The plugin also does this at startup, but doing it here keeps the
|
|
266
|
+
// migration commit self-consistent.
|
|
267
|
+
let demoted = 0
|
|
268
|
+
for (const file of await fse.readdir(agentsDir).catch(() => [])) {
|
|
269
|
+
if (!file.endsWith('.md')) continue
|
|
270
|
+
const full = path.join(agentsDir, file)
|
|
271
|
+
const before = await fse.readFile(full, 'utf-8')
|
|
272
|
+
const after = demoteToSubagent(before)
|
|
273
|
+
if (after !== before) {
|
|
274
|
+
await fse.writeFile(full, after, 'utf-8')
|
|
275
|
+
demoted++
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
success(`Renamed ${plan.moves.length} state file(s), kept ${plan.skillsToRename.length} project-owned skill(s)`)
|
|
280
|
+
success(`Rewrote identifiers in ${rewritten} file(s), demoted ${demoted} agent(s) to subagent`)
|
|
281
|
+
if (plan.stalePlugins.length > 0) {
|
|
282
|
+
success(`Removed ${plan.stalePlugins.length} superseded plugin/tui file(s)`)
|
|
283
|
+
}
|
|
284
|
+
console.log()
|
|
285
|
+
console.log(chalk.bold('Next: run update to install the current harness.'))
|
|
286
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness@latest update'))
|
|
287
|
+
|
|
288
|
+
// The .md definitions were rewritten above, but the compiled .lock.yml files
|
|
289
|
+
// embed the same prompt text and are what CI actually runs.
|
|
290
|
+
if (await fse.pathExists(path.join(cwd, '.github', 'workflows'))) {
|
|
291
|
+
const locks = (await fse.readdir(path.join(cwd, '.github', 'workflows')).catch(() => []))
|
|
292
|
+
.filter(f => f.endsWith('.lock.yml'))
|
|
293
|
+
if (locks.length > 0) {
|
|
294
|
+
console.log()
|
|
295
|
+
console.log(chalk.bold(`Then recompile ${locks.length} agentic workflow(s), whose .lock.yml still holds the old skill names:`))
|
|
296
|
+
console.log(chalk.dim(' gh aw compile'))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { migrated: true, plan, rewritten, demoted }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function runMigrateCommand(args = []) {
|
|
304
|
+
const result = await runMigrate({
|
|
305
|
+
force: args.includes('--force'),
|
|
306
|
+
dryRun: args.includes('--dry-run'),
|
|
307
|
+
})
|
|
308
|
+
if (result.dirty) exit(1)
|
|
309
|
+
return result
|
|
310
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runUpdate } from './commands/update.js'
|
|
|
6
6
|
import { runSingleCommand } from './commands/single.js'
|
|
7
7
|
import { runWizard } from './commands/wizard.js'
|
|
8
8
|
import { exit } from './utils/process.js'
|
|
9
|
+
import { runMigrateCommand } from './commands/migrate.js'
|
|
9
10
|
import { findLegacyInstall } from './utils/legacy-check.js'
|
|
10
11
|
|
|
11
12
|
function printHelp(version) {
|
|
@@ -17,6 +18,7 @@ function printHelp(version) {
|
|
|
17
18
|
console.log()
|
|
18
19
|
console.log('Commands:')
|
|
19
20
|
console.log(' update Bring the harness up to date from saved config (no prompts)')
|
|
21
|
+
console.log(' migrate Move a v1 (opencode-onboard) project to v2, keeping your content')
|
|
20
22
|
console.log(' join Set up a teammate\'s machine (checks & local installs only)')
|
|
21
23
|
console.log(' clean Run AI files cleanup step')
|
|
22
24
|
console.log(' platform Run platform selection step')
|
|
@@ -59,16 +61,23 @@ async function refuseLegacyInstall() {
|
|
|
59
61
|
console.log('and it does not migrate v1 projects. Continuing would leave the harness')
|
|
60
62
|
console.log('half-patched without reporting an error.')
|
|
61
63
|
console.log()
|
|
62
|
-
console.log('
|
|
63
|
-
console.log(chalk.dim('
|
|
64
|
-
console.log(chalk.dim('
|
|
65
|
-
console.log(
|
|
64
|
+
console.log('Migrate it, keeping your generated skills and custom engineers:')
|
|
65
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness migrate'))
|
|
66
|
+
console.log(chalk.dim(' npx @plainconceptsplatform/agent-harness update'))
|
|
67
|
+
console.log()
|
|
68
|
+
console.log(chalk.dim('Add --dry-run to see what migrate would change first.'))
|
|
66
69
|
return true
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
// Ctrl-C out of an @inquirer prompt throws ExitPromptError; that is a normal
|
|
70
73
|
// cancellation, not a crash, so it must not exit non-zero.
|
|
71
74
|
async function main() {
|
|
75
|
+
// migrate is the way out of a v1 project, so it must run before the guard
|
|
76
|
+
// that refuses v1 projects.
|
|
77
|
+
if (args[0] === 'migrate') {
|
|
78
|
+
await runMigrateCommand(args.slice(1))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
72
81
|
if (await refuseLegacyInstall()) {
|
|
73
82
|
exit(1)
|
|
74
83
|
return
|
package/src/steps/copy/agents.js
CHANGED
|
@@ -1,118 +1,119 @@
|
|
|
1
|
-
import fse from 'fs-extra'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import { fileURLToPath } from 'url'
|
|
4
|
-
import { info, success, warn } from '../../utils/exec.js'
|
|
5
|
-
|
|
6
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
-
const agentsContent = await fse.readJson(path.resolve(__dirname, '../../presets/agents-content.json'))
|
|
8
|
-
|
|
9
|
-
// Steps are matched by their title text, not by exact heading string:
|
|
10
|
-
// heading level (###/####) and step numbers have drifted before and silently
|
|
11
|
-
// broke removal. Matching `^#{3,4} Step N, <title>` survives both.
|
|
12
|
-
const HISTORY_STEP_TITLE = 'Archive project history'
|
|
13
|
-
const CHAIN_STEP_TITLE = 'Chain make commands'
|
|
14
|
-
|
|
15
|
-
const CHAIN_CONFIRM_LINE = '- ARCHITECTURE.md generated'
|
|
16
|
-
|
|
17
|
-
function stepHeadingPattern(title) {
|
|
18
|
-
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
19
|
-
return new RegExp(`^#{2,4} Step \\d+, ${escaped}`)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// The step heading is kept and its body replaced with an explicit skip note.
|
|
23
|
-
// Removing the block and renumbering the remaining steps (the old approach)
|
|
24
|
-
// went stale against prose cross-references like "Skip steps 2, 3, and 4";
|
|
25
|
-
// stable numbering plus a visible reason is unambiguous for the agent.
|
|
26
|
-
export function skipStepBlock(content, title, note) {
|
|
27
|
-
const lines = content.split('\n')
|
|
28
|
-
const pattern = stepHeadingPattern(title)
|
|
29
|
-
const start = lines.findIndex(l => pattern.test(l.trim()))
|
|
30
|
-
if (start === -1) return { content, matched: false }
|
|
31
|
-
|
|
32
|
-
let end = lines.length
|
|
33
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
34
|
-
if (lines[i].trim() === '---') { end = i; break }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
lines.splice(start + 1, end - start - 1, '', `> ${note}`, '')
|
|
38
|
-
return { content: lines.join('\n'), matched: true }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function removeConfirmLine(content, line) {
|
|
42
|
-
return content.split('\n').filter(l => l.trim() !== line.trim()).join('\n')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const PLATFORM_WORKFLOW_START = '<!-- PC-PLATFORM-WORKFLOW-START -->'
|
|
46
|
-
const PLATFORM_WORKFLOW_END = '<!-- PC-PLATFORM-WORKFLOW-END -->'
|
|
47
|
-
const PLATFORM_SKILLS_GUIDE_START = '<!-- PC-PLATFORM-SKILLS-GUIDE-START -->'
|
|
48
|
-
const PLATFORM_SKILLS_GUIDE_END = '<!-- PC-PLATFORM-SKILLS-GUIDE-END -->'
|
|
49
|
-
|
|
50
|
-
function platformContent(platform, key) {
|
|
51
|
-
const p = agentsContent.platform[platform] ?? agentsContent.platform.github
|
|
52
|
-
return p[key] ?? ''
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function mixedPlatformContent(backlogPlatform, repoPlatform, key) {
|
|
56
|
-
if (backlogPlatform === repoPlatform) {
|
|
57
|
-
return platformContent(backlogPlatform, key)
|
|
58
|
-
}
|
|
59
|
-
const backlog = agentsContent.platform[backlogPlatform] ?? agentsContent.platform.github
|
|
60
|
-
const repo = agentsContent.platform[repoPlatform] ?? agentsContent.platform.github
|
|
61
|
-
const mixed = agentsContent.platform.mixed ?? {}
|
|
62
|
-
const mixedKey = `${backlogPlatform}-${repoPlatform}`
|
|
63
|
-
return mixed[mixedKey]?.[key] ?? mixed.default?.[key]?.replace('{backlog}', backlog[key] ?? '').replace('{repo}', repo[key] ?? '') ?? platformContent(repoPlatform, key)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function replaceBetween(content, start, end, replacement) {
|
|
67
|
-
if (!content.includes(start) || !content.includes(end)) return content
|
|
68
|
-
const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
content =
|
|
111
|
-
content = replaceBetween(content,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { info, success, warn } from '../../utils/exec.js'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const agentsContent = await fse.readJson(path.resolve(__dirname, '../../presets/agents-content.json'))
|
|
8
|
+
|
|
9
|
+
// Steps are matched by their title text, not by exact heading string:
|
|
10
|
+
// heading level (###/####) and step numbers have drifted before and silently
|
|
11
|
+
// broke removal. Matching `^#{3,4} Step N, <title>` survives both.
|
|
12
|
+
const HISTORY_STEP_TITLE = 'Archive project history'
|
|
13
|
+
const CHAIN_STEP_TITLE = 'Chain make commands'
|
|
14
|
+
|
|
15
|
+
const CHAIN_CONFIRM_LINE = '- ARCHITECTURE.md generated'
|
|
16
|
+
|
|
17
|
+
function stepHeadingPattern(title) {
|
|
18
|
+
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
19
|
+
return new RegExp(`^#{2,4} Step \\d+, ${escaped}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The step heading is kept and its body replaced with an explicit skip note.
|
|
23
|
+
// Removing the block and renumbering the remaining steps (the old approach)
|
|
24
|
+
// went stale against prose cross-references like "Skip steps 2, 3, and 4";
|
|
25
|
+
// stable numbering plus a visible reason is unambiguous for the agent.
|
|
26
|
+
export function skipStepBlock(content, title, note) {
|
|
27
|
+
const lines = content.split('\n')
|
|
28
|
+
const pattern = stepHeadingPattern(title)
|
|
29
|
+
const start = lines.findIndex(l => pattern.test(l.trim()))
|
|
30
|
+
if (start === -1) return { content, matched: false }
|
|
31
|
+
|
|
32
|
+
let end = lines.length
|
|
33
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
34
|
+
if (lines[i].trim() === '---') { end = i; break }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
lines.splice(start + 1, end - start - 1, '', `> ${note}`, '')
|
|
38
|
+
return { content: lines.join('\n'), matched: true }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function removeConfirmLine(content, line) {
|
|
42
|
+
return content.split('\n').filter(l => l.trim() !== line.trim()).join('\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PLATFORM_WORKFLOW_START = '<!-- PC-PLATFORM-WORKFLOW-START -->'
|
|
46
|
+
const PLATFORM_WORKFLOW_END = '<!-- PC-PLATFORM-WORKFLOW-END -->'
|
|
47
|
+
const PLATFORM_SKILLS_GUIDE_START = '<!-- PC-PLATFORM-SKILLS-GUIDE-START -->'
|
|
48
|
+
const PLATFORM_SKILLS_GUIDE_END = '<!-- PC-PLATFORM-SKILLS-GUIDE-END -->'
|
|
49
|
+
|
|
50
|
+
function platformContent(platform, key) {
|
|
51
|
+
const p = agentsContent.platform[platform] ?? agentsContent.platform.github
|
|
52
|
+
return p[key] ?? ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mixedPlatformContent(backlogPlatform, repoPlatform, key) {
|
|
56
|
+
if (backlogPlatform === repoPlatform) {
|
|
57
|
+
return platformContent(backlogPlatform, key)
|
|
58
|
+
}
|
|
59
|
+
const backlog = agentsContent.platform[backlogPlatform] ?? agentsContent.platform.github
|
|
60
|
+
const repo = agentsContent.platform[repoPlatform] ?? agentsContent.platform.github
|
|
61
|
+
const mixed = agentsContent.platform.mixed ?? {}
|
|
62
|
+
const mixedKey = `${backlogPlatform}-${repoPlatform}`
|
|
63
|
+
return mixed[mixedKey]?.[key] ?? mixed.default?.[key]?.replace('{backlog}', backlog[key] ?? '').replace('{repo}', repo[key] ?? '') ?? platformContent(repoPlatform, key)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function replaceBetween(content, start, end, replacement) {
|
|
67
|
+
if (!content.includes(start) || !content.includes(end)) return content
|
|
68
|
+
const pattern = new RegExp(`${start}[\\s\\S]*?${end}`)
|
|
69
|
+
// Replacer function so $&, $' and $` in injected content stay literal.
|
|
70
|
+
return content.replace(pattern, () => `${start}\n${replacement.trim()}\n${end}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function patchAgentsMd(ctx) {
|
|
74
|
+
const obInitSkillPath = path.join(process.cwd(), '.agents', 'skills', 'pc-repo-initialize', 'SKILL.md')
|
|
75
|
+
if (!await fse.pathExists(obInitSkillPath)) return
|
|
76
|
+
|
|
77
|
+
let content = await fse.readFile(obInitSkillPath, 'utf-8')
|
|
78
|
+
const patches = []
|
|
79
|
+
|
|
80
|
+
const skips = [
|
|
81
|
+
[ctx.hasOpenspec, HISTORY_STEP_TITLE, null,
|
|
82
|
+
'Skipped during onboarding: this project already had an openspec/ history. Do not archive again; continue with the next step.'],
|
|
83
|
+
[ctx.hasDesign || ctx.hasArchitecture, CHAIN_STEP_TITLE, CHAIN_CONFIRM_LINE,
|
|
84
|
+
'Skipped during onboarding: project files already exist. Run /make-architecture or /make-design individually to regenerate.'],
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
for (const [enabled, title, confirmLine, note] of skips) {
|
|
88
|
+
if (!enabled) continue
|
|
89
|
+
const result = skipStepBlock(content, title, note)
|
|
90
|
+
if (!result.matched) {
|
|
91
|
+
warn(`pc-repo-initialize SKILL.md step "${title}" not found: template drift? Skipping this patch.`)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
content = result.content
|
|
95
|
+
if (confirmLine) content = removeConfirmLine(content, confirmLine)
|
|
96
|
+
patches.push(`Step "${title}" marked as skipped, file already exists`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (patches.length > 0) {
|
|
100
|
+
await fse.writeFile(obInitSkillPath, content, 'utf-8')
|
|
101
|
+
for (const msg of patches) info(msg)
|
|
102
|
+
success('pc-repo-initialize SKILL.md patched for existing project state')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function patchAgentGuidance(backlogPlatform, repoPlatform, cwd = process.cwd()) {
|
|
107
|
+
const agentsMdPath = path.join(cwd, 'AGENTS.md')
|
|
108
|
+
if (await fse.pathExists(agentsMdPath)) {
|
|
109
|
+
const repo = repoPlatform ?? backlogPlatform ?? 'github'
|
|
110
|
+
let content = await fse.readFile(agentsMdPath, 'utf-8')
|
|
111
|
+
content = replaceBetween(content, PLATFORM_WORKFLOW_START, PLATFORM_WORKFLOW_END, mixedPlatformContent(backlogPlatform, repo, 'workflow'))
|
|
112
|
+
content = replaceBetween(content, PLATFORM_SKILLS_GUIDE_START, PLATFORM_SKILLS_GUIDE_END, mixedPlatformContent(backlogPlatform, repo, 'skillsGuide'))
|
|
113
|
+
await fse.writeFile(agentsMdPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
|
|
114
|
+
const label = backlogPlatform === repo ? repo : `${backlogPlatform}→${repo}`
|
|
115
|
+
success(`AGENTS.md patched for platform workflow: ${label}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
@@ -1,91 +1,95 @@
|
|
|
1
|
-
import fse from 'fs-extra'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import { fileURLToPath } from 'url'
|
|
4
|
-
import { success } from '../../utils/exec.js'
|
|
5
|
-
|
|
6
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
-
|
|
8
|
-
const _archive = {
|
|
9
|
-
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/az.md'), 'utf-8'),
|
|
10
|
-
github: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/gh.md'), 'utf-8'),
|
|
11
|
-
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/gl.md'), 'utf-8'),
|
|
12
|
-
none: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/none.md'), 'utf-8'),
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const _ship = {
|
|
16
|
-
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/gh.md'), 'utf-8'),
|
|
17
|
-
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/az.md'), 'utf-8'),
|
|
18
|
-
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/gl.md'), 'utf-8'),
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const _review = {
|
|
22
|
-
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/gh.md'), 'utf-8'),
|
|
23
|
-
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/az.md'), 'utf-8'),
|
|
24
|
-
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/gl.md'), 'utf-8'),
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const _backlog = {
|
|
28
|
-
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/gh.md'), 'utf-8'),
|
|
29
|
-
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/az.md'), 'utf-8'),
|
|
30
|
-
jira: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/jira.md'), 'utf-8'),
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Evidence comments target the backlog platform (where the issue/work-item lives).
|
|
34
|
-
const _evidence = {
|
|
35
|
-
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/gh.md'), 'utf-8'),
|
|
36
|
-
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/az.md'), 'utf-8'),
|
|
37
|
-
jira: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/jira.md'), 'utf-8'),
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// relativePath is POSIX-style relative to the project root; commands live in
|
|
41
|
-
// .opencode/commands/, skill-backed procedures (plan-archive, ops-ship) in
|
|
42
|
-
// .agents/skills/<name>/SKILL.md.
|
|
43
|
-
function patchFile(relativePath, startMarker, endMarker, content, platform, cwd = process.cwd()) {
|
|
44
|
-
const targetPath = path.join(cwd, ...relativePath.split('/'))
|
|
45
|
-
if (!fse.pathExistsSync(targetPath)) return
|
|
46
|
-
|
|
47
|
-
let fileContent = fse.readFileSync(targetPath, 'utf-8')
|
|
48
|
-
if (!fileContent.includes(startMarker) || !fileContent.includes(endMarker)) return
|
|
49
|
-
|
|
50
|
-
const pattern = new RegExp(`${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { success } from '../../utils/exec.js'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
const _archive = {
|
|
9
|
+
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/az.md'), 'utf-8'),
|
|
10
|
+
github: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/gh.md'), 'utf-8'),
|
|
11
|
+
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/gl.md'), 'utf-8'),
|
|
12
|
+
none: await fse.readFile(path.resolve(__dirname, '../../fragments/archive/none.md'), 'utf-8'),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const _ship = {
|
|
16
|
+
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/gh.md'), 'utf-8'),
|
|
17
|
+
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/az.md'), 'utf-8'),
|
|
18
|
+
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-ship/gl.md'), 'utf-8'),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const _review = {
|
|
22
|
+
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/gh.md'), 'utf-8'),
|
|
23
|
+
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/az.md'), 'utf-8'),
|
|
24
|
+
gitlab: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-review/gl.md'), 'utf-8'),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const _backlog = {
|
|
28
|
+
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/gh.md'), 'utf-8'),
|
|
29
|
+
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/az.md'), 'utf-8'),
|
|
30
|
+
jira: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-backlog/jira.md'), 'utf-8'),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Evidence comments target the backlog platform (where the issue/work-item lives).
|
|
34
|
+
const _evidence = {
|
|
35
|
+
github: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/gh.md'), 'utf-8'),
|
|
36
|
+
azure: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/az.md'), 'utf-8'),
|
|
37
|
+
jira: await fse.readFile(path.resolve(__dirname, '../../fragments/ops-evidence/jira.md'), 'utf-8'),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// relativePath is POSIX-style relative to the project root; commands live in
|
|
41
|
+
// .opencode/commands/, skill-backed procedures (plan-archive, ops-ship) in
|
|
42
|
+
// .agents/skills/<name>/SKILL.md.
|
|
43
|
+
function patchFile(relativePath, startMarker, endMarker, content, platform, cwd = process.cwd()) {
|
|
44
|
+
const targetPath = path.join(cwd, ...relativePath.split('/'))
|
|
45
|
+
if (!fse.pathExistsSync(targetPath)) return
|
|
46
|
+
|
|
47
|
+
let fileContent = fse.readFileSync(targetPath, 'utf-8')
|
|
48
|
+
if (!fileContent.includes(startMarker) || !fileContent.includes(endMarker)) return
|
|
49
|
+
|
|
50
|
+
const pattern = new RegExp(`${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
|
51
|
+
// Replacer function, not a replacement string: the injected fragments contain
|
|
52
|
+
// shell quoting like $'...' and a string replacement expands $' as "everything
|
|
53
|
+
// after the match", silently truncating the command. That is how the ops-ship
|
|
54
|
+
// screenshot comment lost its entire --body argument.
|
|
55
|
+
fileContent = fileContent.replace(pattern, () => `${startMarker}\n${content.trim()}\n${endMarker}`)
|
|
56
|
+
fse.writeFileSync(targetPath, `${fileContent.replace(/\s*$/, '')}\n`, 'utf-8')
|
|
57
|
+
success(`${relativePath} content injected for platform: ${platform}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function patchArchiveCommand(platform, cwd = process.cwd()) {
|
|
61
|
+
const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? platform.backlogPlatform ?? 'github') : platform
|
|
62
|
+
const replacement = _archive[repoPlatform]
|
|
63
|
+
if (!replacement) return
|
|
64
|
+
patchFile('.agents/skills/pc-plan-archive/SKILL.md', '<!-- PC-PLATFORM-ARCHIVE-START -->', '<!-- PC-PLATFORM-ARCHIVE-END -->', replacement, repoPlatform, cwd)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function patchOpsShip(platform, cwd = process.cwd()) {
|
|
68
|
+
const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
|
|
69
|
+
const replacement = _ship[repoPlatform]
|
|
70
|
+
if (!replacement) return
|
|
71
|
+
patchFile('.agents/skills/pc-ops-ship/SKILL.md', '<!-- PC-PLATFORM-SHIP-START -->', '<!-- PC-PLATFORM-SHIP-END -->', replacement, repoPlatform, cwd)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function patchOpsReview(platform, cwd = process.cwd()) {
|
|
75
|
+
const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
|
|
76
|
+
const replacement = _review[repoPlatform]
|
|
77
|
+
if (!replacement) return
|
|
78
|
+
patchFile('.opencode/commands/ops-review.md', '<!-- PC-PLATFORM-REVIEW-START -->', '<!-- PC-PLATFORM-REVIEW-END -->', replacement, repoPlatform, cwd)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function patchOpsBacklog(platform, cwd = process.cwd()) {
|
|
82
|
+
const backlogPlatform = typeof platform === 'object' ? (platform.backlogPlatform ?? 'github') : platform
|
|
83
|
+
const replacement = _backlog[backlogPlatform]
|
|
84
|
+
if (!replacement) return
|
|
85
|
+
patchFile('.opencode/commands/ops-backlog.md', '<!-- PC-PLATFORM-BACKLOG-START -->', '<!-- PC-PLATFORM-BACKLOG-END -->', replacement, backlogPlatform, cwd)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function patchOpsEvidence(platform, cwd = process.cwd()) {
|
|
89
|
+
const backlogPlatform = typeof platform === 'object' ? (platform.backlogPlatform ?? 'github') : platform
|
|
90
|
+
const replacement = _evidence[backlogPlatform]
|
|
91
|
+
// browser/none have no CLI to post with: leave the markers empty (the skill skips commenting).
|
|
92
|
+
if (!replacement) return
|
|
93
|
+
patchFile('.agents/skills/pc-ops-evidence/SKILL.md', '<!-- PC-PLATFORM-EVIDENCE-START -->', '<!-- PC-PLATFORM-EVIDENCE-END -->', replacement, backlogPlatform, cwd)
|
|
94
|
+
}
|
|
95
|
+
|
package/src/steps/copy/skills.js
CHANGED
|
@@ -21,7 +21,7 @@ const BACKLOG_PLATFORM_SKILLS = {
|
|
|
21
21
|
// Platform-specific skills are renamed to their generic form on install.
|
|
22
22
|
// The -gh / -az / -jira / -gl suffix is only needed here to keep all variants in source.
|
|
23
23
|
// After install only one platform is present so no suffix is needed.
|
|
24
|
-
const SKILL_RENAME = {
|
|
24
|
+
export const SKILL_RENAME = {
|
|
25
25
|
'pc-userstory-gh': 'pc-userstory',
|
|
26
26
|
'pc-userstory-az': 'pc-userstory',
|
|
27
27
|
'pc-userstory-jira': 'pc-userstory',
|
|
@@ -38,7 +38,7 @@ function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
|
|
|
38
38
|
// these must be preserved when they have already been generated (detected
|
|
39
39
|
// by the <!-- Last updated: marker). Without this check, the update would
|
|
40
40
|
// wipe project-specific guardrails, risk assessments, etc.
|
|
41
|
-
const GENERATABLE_SKILLS = new Set([
|
|
41
|
+
export const GENERATABLE_SKILLS = new Set([
|
|
42
42
|
'pc-guardrails-project',
|
|
43
43
|
'pc-merge-risk-assess',
|
|
44
44
|
])
|
|
@@ -1,108 +1,109 @@
|
|
|
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
|
-
|
|
6
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
-
const GUARDRAILS_FRAGMENT_DIR = path.resolve(__dirname, '../../fragments/guardrails')
|
|
8
|
-
|
|
9
|
-
const MARKER_SECTIONS = {
|
|
10
|
-
rtk: 'RTK',
|
|
11
|
-
codegraph: 'CODEGRAPH',
|
|
12
|
-
memory: 'MEMORY',
|
|
13
|
-
simpleEnglish: 'SIMPLE-ENGLISH',
|
|
14
|
-
humanizer: 'HUMANIZER',
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const OPTIONAL_TOOL_GUIDANCE = {
|
|
18
|
-
codegraph: 'Use `codegraph_explore` to refine relevant symbols and file-disjointness for the current wave.',
|
|
19
|
-
memory: 'Use Agentmemory MCP tools for cross-session context and task-result notes.',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function replaceMarkerSection(content, startMarker, endMarker, replacement) {
|
|
23
|
-
if (!content.includes(startMarker) || !content.includes(endMarker)) return content
|
|
24
|
-
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
25
|
-
const pattern = new RegExp(`${escape(startMarker)}[\\s\\S]*?${escape(endMarker)}`)
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`<!-- PC-OPTIMIZATION-${suffix}-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
content =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'<!-- PC-GUARDRAILS-CAVEMAN-
|
|
75
|
-
'',
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'<!-- PC-GUARDRAILS-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'1.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`<!-- PC-GUARDRAILS-${markerSuffix}-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
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
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const GUARDRAILS_FRAGMENT_DIR = path.resolve(__dirname, '../../fragments/guardrails')
|
|
8
|
+
|
|
9
|
+
const MARKER_SECTIONS = {
|
|
10
|
+
rtk: 'RTK',
|
|
11
|
+
codegraph: 'CODEGRAPH',
|
|
12
|
+
memory: 'MEMORY',
|
|
13
|
+
simpleEnglish: 'SIMPLE-ENGLISH',
|
|
14
|
+
humanizer: 'HUMANIZER',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OPTIONAL_TOOL_GUIDANCE = {
|
|
18
|
+
codegraph: 'Use `codegraph_explore` to refine relevant symbols and file-disjointness for the current wave.',
|
|
19
|
+
memory: 'Use Agentmemory MCP tools for cross-session context and task-result notes.',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function replaceMarkerSection(content, startMarker, endMarker, replacement) {
|
|
23
|
+
if (!content.includes(startMarker) || !content.includes(endMarker)) return content
|
|
24
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
25
|
+
const pattern = new RegExp(`${escape(startMarker)}[\\s\\S]*?${escape(endMarker)}`)
|
|
26
|
+
// Replacer function so $&, $' and $` in injected content stay literal.
|
|
27
|
+
return content.replace(pattern, () => `${startMarker}\n${replacement}\n${endMarker}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function patchOptionalToolGuidance(destSkillsDir, selections) {
|
|
31
|
+
const files = []
|
|
32
|
+
|
|
33
|
+
async function collect(dir) {
|
|
34
|
+
for (const entry of await fse.readdir(dir, { withFileTypes: true })) {
|
|
35
|
+
const entryPath = path.join(dir, entry.name)
|
|
36
|
+
if (entry.isDirectory()) await collect(entryPath)
|
|
37
|
+
else if (entry.name.endsWith('.md')) files.push(entryPath)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!await fse.pathExists(destSkillsDir)) return 0
|
|
42
|
+
await collect(destSkillsDir)
|
|
43
|
+
|
|
44
|
+
let patched = 0
|
|
45
|
+
for (const filePath of files) {
|
|
46
|
+
let content = await fse.readFile(filePath, 'utf-8')
|
|
47
|
+
const original = content
|
|
48
|
+
for (const [key, guidance] of Object.entries(OPTIONAL_TOOL_GUIDANCE)) {
|
|
49
|
+
const suffix = key.toUpperCase()
|
|
50
|
+
content = replaceMarkerSection(
|
|
51
|
+
content,
|
|
52
|
+
`<!-- PC-OPTIMIZATION-${suffix}-START -->`,
|
|
53
|
+
`<!-- PC-OPTIMIZATION-${suffix}-END -->`,
|
|
54
|
+
selections[key] ? guidance : '',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if (content !== original) {
|
|
58
|
+
await fse.writeFile(filePath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
|
|
59
|
+
patched++
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return patched
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function patchGuardrails(selections = {}, { cwd = process.cwd() } = {}) {
|
|
66
|
+
const destSkillsDir = path.join(cwd, '.agents', 'skills')
|
|
67
|
+
const guardrailsPath = path.join(destSkillsDir, 'pc-guardrails-generic', 'SKILL.md')
|
|
68
|
+
let patchedCount = 0
|
|
69
|
+
|
|
70
|
+
if (await fse.pathExists(guardrailsPath)) {
|
|
71
|
+
let content = await fse.readFile(guardrailsPath, 'utf-8')
|
|
72
|
+
content = replaceMarkerSection(
|
|
73
|
+
content,
|
|
74
|
+
'<!-- PC-GUARDRAILS-CAVEMAN-START -->',
|
|
75
|
+
'<!-- PC-GUARDRAILS-CAVEMAN-END -->',
|
|
76
|
+
'',
|
|
77
|
+
).replace(
|
|
78
|
+
'<!-- PC-GUARDRAILS-CAVEMAN-START -->\n\n<!-- PC-GUARDRAILS-CAVEMAN-END -->',
|
|
79
|
+
'<!-- PC-GUARDRAILS-SIMPLE-ENGLISH-START -->\n<!-- PC-GUARDRAILS-SIMPLE-ENGLISH-END -->',
|
|
80
|
+
).replace(
|
|
81
|
+
'1. Load ALL skills listed under your own `## Abilities` now (Guardrails first, then the rest), by calling the `skill` tool once per `@skill-name`.',
|
|
82
|
+
'1. The `pc-system-reminders` plugin has already loaded the skills listed under your `## Abilities`, guardrails first.',
|
|
83
|
+
)
|
|
84
|
+
for (const [key, markerSuffix] of Object.entries(MARKER_SECTIONS)) {
|
|
85
|
+
let sectionContent = ''
|
|
86
|
+
if (selections[key]) {
|
|
87
|
+
const presetName = key === 'simpleEnglish' ? 'simple-english' : key
|
|
88
|
+
const presetPath = path.join(GUARDRAILS_FRAGMENT_DIR, `${presetName}.md`)
|
|
89
|
+
if (await fse.pathExists(presetPath)) {
|
|
90
|
+
sectionContent = (await fse.readFile(presetPath, 'utf-8')).trim()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
content = replaceMarkerSection(
|
|
94
|
+
content,
|
|
95
|
+
`<!-- PC-GUARDRAILS-${markerSuffix}-START -->`,
|
|
96
|
+
`<!-- PC-GUARDRAILS-${markerSuffix}-END -->`,
|
|
97
|
+
sectionContent,
|
|
98
|
+
)
|
|
99
|
+
if (selections[key]) patchedCount++
|
|
100
|
+
}
|
|
101
|
+
await fse.writeFile(guardrailsPath, `${content.replace(/\s*$/, '')}\n`, 'utf-8')
|
|
102
|
+
} else {
|
|
103
|
+
info('pc-guardrails-generic not found, skipping guardrails patching')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const optionalPatched = await patchOptionalToolGuidance(destSkillsDir, selections)
|
|
107
|
+
success(`Guardrails patched: ${patchedCount} sections injected; ${optionalPatched} optional tool file(s) updated`)
|
|
108
|
+
return { patched: true, count: patchedCount, optionalPatched }
|
|
109
|
+
}
|