@plainconceptsplatform/agent-harness 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +454 -452
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +120 -120
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +89 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +316 -307
  43. package/cli/steps/metadata/index.js +115 -115
  44. package/cli/steps/models/format.js +88 -88
  45. package/cli/steps/models/index.js +64 -64
  46. package/cli/steps/models/write.js +38 -38
  47. package/cli/steps/openspec/index.js +136 -136
  48. package/cli/steps/optimization/codegraph.js +141 -127
  49. package/cli/steps/optimization/detect.js +66 -66
  50. package/cli/steps/optimization/humanizer.js +17 -17
  51. package/cli/steps/optimization/index.js +158 -158
  52. package/cli/steps/optimization/memory.js +91 -88
  53. package/cli/steps/optimization/patch-guardrails.js +111 -111
  54. package/cli/steps/optimization/simple-english.js +17 -17
  55. package/cli/steps/optimization/skills-lock.js +30 -30
  56. package/cli/steps/platform/index.js +109 -109
  57. package/cli/steps/source/index.js +123 -123
  58. package/cli/utils/agent-color.js +111 -111
  59. package/cli/utils/copy.js +134 -134
  60. package/cli/utils/exec-spinner.js +47 -47
  61. package/cli/utils/exec.js +134 -134
  62. package/cli/utils/legacy-check.js +30 -30
  63. package/cli/utils/models-cache.js +58 -58
  64. package/cli/utils/models-pricing.js +42 -42
  65. package/cli/utils/paths.js +80 -80
  66. package/cli/utils/process.js +3 -3
  67. package/cli/utils/terminal.js +6 -6
  68. package/cli/utils/update-manifest.js +75 -75
  69. package/harness/.opencode/_gitignore +9 -9
  70. package/harness/.opencode/commands/init.md +5 -5
  71. package/harness/.opencode/commands/make-architecture.md +5 -5
  72. package/harness/.opencode/commands/make-design.md +5 -5
  73. package/harness/.opencode/commands/make-engineer.md +5 -5
  74. package/harness/.opencode/commands/make-guardrails.md +5 -5
  75. package/harness/.opencode/commands/make-user-model.md +5 -5
  76. package/harness/.opencode/commands/ops-backlog.md +10 -10
  77. package/harness/.opencode/commands/ops-review.md +8 -8
  78. package/harness/.opencode/commands/ops-ship.md +9 -9
  79. package/harness/.opencode/commands/plan-apply.md +9 -9
  80. package/harness/.opencode/commands/plan-archive.md +5 -5
  81. package/harness/.opencode/commands/plan-explore.md +9 -9
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-propose.md +9 -9
  84. package/harness/.opencode/commands/plan-quick.md +5 -5
  85. package/harness/.opencode/commands/plan-story.md +9 -9
  86. package/harness/.opencode/commands/repo-audit.md +5 -5
  87. package/harness/.opencode/commands/repo-help.md +5 -5
  88. package/harness/.opencode/commands/repo-initialize.md +5 -5
  89. package/harness/.opencode/commands/repo-onboard.md +5 -5
  90. package/harness/.opencode/commands/repo-verify.md +5 -5
  91. package/harness/.opencode/package.json +5 -5
  92. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  93. package/harness/.opencode/plugins/pc-subagent-tiers.js +354 -354
  94. package/harness/.opencode/plugins/pc-system-reminders.js +386 -386
  95. package/harness/.opencode/scripts/check-diff.mjs +26 -7
  96. package/harness/.opencode/scripts/code-rules.mjs +288 -280
  97. package/harness/AGENTS.md +49 -49
  98. package/harness/ARCHITECTURE.md +13 -13
  99. package/harness/DESIGN.md +13 -13
  100. package/harness/opencode.jsonc +36 -36
  101. package/harness/openspec/config.yaml +20 -20
  102. package/harness/skills-lock.json +17 -17
  103. package/package.json +14 -12
  104. package/skills/README.md +18 -18
  105. package/skills/agent-harness-cli/SKILL.md +86 -86
  106. package/skills/browser-automation/SKILL.md +72 -72
  107. package/skills/pc-guardrails-project/SKILL.md +8 -8
  108. package/skills/pc-make-architecture/SKILL.md +32 -32
  109. package/skills/pc-make-architecture/structure-template.md +64 -64
  110. package/skills/pc-make-design/SKILL.md +45 -45
  111. package/skills/pc-make-engineer/SKILL.md +59 -59
  112. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  113. package/skills/pc-make-engineer/template.md +36 -36
  114. package/skills/pc-make-guardrails/SKILL.md +44 -44
  115. package/skills/pc-make-guardrails/category-reference.md +112 -112
  116. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  117. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  118. package/skills/pc-make-user-model/SKILL.md +56 -56
  119. package/skills/pc-ops-ship/SKILL.md +22 -18
  120. package/skills/pc-plan-apply/SKILL.md +86 -86
  121. package/skills/pc-plan-apply/simple-mode.md +21 -21
  122. package/skills/pc-plan-archive/SKILL.md +66 -66
  123. package/skills/pc-plan-explore/SKILL.md +21 -21
  124. package/skills/pc-plan-goal/SKILL.md +91 -91
  125. package/skills/pc-plan-goal/branching.md +39 -39
  126. package/skills/pc-plan-goal/failure-policy.md +30 -30
  127. package/skills/pc-plan-goal/output-mode.md +10 -10
  128. package/skills/pc-plan-goal/output.md +71 -71
  129. package/skills/pc-plan-propose/SKILL.md +125 -125
  130. package/skills/pc-plan-propose/task-annotation.md +39 -39
  131. package/skills/pc-plan-quick/SKILL.md +46 -46
  132. package/skills/pc-plan-story/SKILL.md +93 -93
  133. package/skills/pc-repo-audit/SKILL.md +44 -44
  134. package/skills/pc-repo-help/SKILL.md +87 -87
  135. package/skills/pc-repo-initialize/SKILL.md +119 -119
  136. package/skills/pc-repo-onboard/SKILL.md +32 -32
  137. package/skills/pc-repo-verify/SKILL.md +150 -150
  138. package/skills/pc-userstory-az/SKILL.md +71 -71
  139. package/skills/pc-userstory-browser/SKILL.md +64 -64
  140. package/skills/pc-userstory-gh/SKILL.md +63 -63
  141. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,307 +1,316 @@
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
- import { RETIRED_PATHS } from '../../utils/paths.js'
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
- const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../../skills')
10
- const CONTENT_SKILLS_LOCK = path.resolve(__dirname, '../../../harness/skills-lock.json')
11
-
12
- // Userstory skills parse backlog work items: selected by backlogPlatform only.
13
- // Mixing the two axes here installs the wrong variant on mixed setups, because
14
- // all variants rename to the same generic dir and the first copy wins.
15
- const BACKLOG_PLATFORM_SKILLS = {
16
- 'pc-userstory-gh': 'github',
17
- 'pc-userstory-az': 'azure',
18
- 'pc-userstory-jira': 'jira',
19
- 'pc-userstory-browser': 'browser',
20
- }
21
-
22
- // Platform-specific skills are renamed to their generic form on install.
23
- // The -gh / -az / -jira / -gl suffix is only needed here to keep all variants in source.
24
- // After install only one platform is present so no suffix is needed.
25
- export const SKILL_RENAME = {
26
- 'pc-userstory-gh': 'pc-userstory',
27
- 'pc-userstory-az': 'pc-userstory',
28
- 'pc-userstory-jira': 'pc-userstory',
29
- 'pc-userstory-browser': 'pc-userstory',
30
- }
31
-
32
- // Skills that ship in the source tree but must never be copied into a target
33
- // project. agent-harness-cli documents the CLI itself for agents working on
34
- // THIS repo; it has no value in a consumer repo and would clutter .agents/skills.
35
- // Listed by directory name as read from CONTENT_SKILLS_DIR.
36
- export const NON_INSTALLABLE_SKILLS = new Set([
37
- 'agent-harness-cli',
38
- ])
39
-
40
- function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
41
- if (NON_INSTALLABLE_SKILLS.has(skill)) return false
42
- if (skill in BACKLOG_PLATFORM_SKILLS) return BACKLOG_PLATFORM_SKILLS[skill] === backlogPlatform
43
- return true
44
- }
45
-
46
- // Shipped skills whose content is a PLACEHOLDER — a /make-* command
47
- // overwrites them with project-specific content. During forceOverwrite,
48
- // these must be preserved when they have already been generated (detected
49
- // by the <!-- Last updated: marker). Without this check, the update would
50
- // wipe project-specific guardrails, risk assessments, etc.
51
- export const GENERATABLE_SKILLS = new Set([
52
- 'pc-guardrails-project',
53
- 'pc-merge-risk-assess',
54
- ])
55
-
56
- const MARKER_SKILLS = new Set([
57
- 'pc-guardrails-generic',
58
- 'pc-plan-archive',
59
- 'pc-ops-ship',
60
- 'pc-repo-initialize',
61
- ])
62
-
63
- // Marker skills whose prose must track the shipped version on update.
64
- //
65
- // These are shipped content, not project content: the project-specific part is
66
- // injected into their marker pairs, and the platform patchers plus
67
- // patchGuardrails re-inject it immediately after this copy. Treating them as
68
- // project-owned froze their prose at whatever version a consumer first
69
- // installed, so a skill rewrite never reached an existing repo. That is also
70
- // how browser-automation sat on v1.0 in five repos while the harness shipped
71
- // v2.0.
72
- //
73
- // pc-repo-initialize is deliberately absent. patchAgentsMd stamps "skipped
74
- // during onboarding" notes into it that only run on a fresh install
75
- // (copy/index.js runs it under `!ctx.updateMode`), so refreshing it would
76
- // discard decisions made at onboarding.
77
- const REFRESHABLE_MARKER_SKILLS = new Set([
78
- 'pc-guardrails-generic',
79
- 'pc-plan-archive',
80
- 'pc-ops-ship',
81
- ])
82
-
83
- // A project-owned slot inside a shipped skill: the content between the markers
84
- // survives a refresh, everything around it takes the new shipped version. This
85
- // is the supported way to adapt one example to a project without forking the
86
- // file, which consumers have otherwise done by editing shipped prose in place.
87
- const PROJECT_SLOT_RE = /<!-- (PC-PROJECT-[A-Z0-9-]+)-START -->([\s\S]*?)<!-- \1-END -->/g
88
- // Separate, non-global: `.test()` on a /g/ regex advances lastIndex, so reusing
89
- // PROJECT_SLOT_RE for the check would answer false on every other call.
90
- const HAS_PROJECT_SLOT = /<!-- PC-PROJECT-[A-Z0-9-]+-START -->/
91
-
92
- function readProjectSlots(text) {
93
- const slots = new Map()
94
- for (const match of text.matchAll(PROJECT_SLOT_RE)) slots.set(match[1], match[2])
95
- return slots
96
- }
97
-
98
- function writeProjectSlots(text, slots) {
99
- if (slots.size === 0) return text
100
- // Replacer function, not a replacement string: carried content can contain
101
- // shell quoting like $'...', and a string replacement expands $' as
102
- // "everything after the match" and truncates the file.
103
- return text.replace(PROJECT_SLOT_RE, (whole, name) => {
104
- const kept = slots.get(name)
105
- if (kept === undefined || kept.trim() === '') return whole
106
- return `<!-- ${name}-START -->${kept}<!-- ${name}-END -->`
107
- })
108
- }
109
-
110
- async function refreshMarkerSkill(src, dest, relativeRoot) {
111
- for (const entry of await fse.readdir(src, { withFileTypes: true })) {
112
- const sourcePath = path.join(src, entry.name)
113
- const destinationPath = path.join(dest, entry.name)
114
- const relativePath = path.join(relativeRoot, entry.name)
115
- if (entry.isDirectory()) {
116
- await refreshMarkerSkill(sourcePath, destinationPath, relativePath)
117
- continue
118
- }
119
- const shipped = await fse.readFile(sourcePath, 'utf-8')
120
- const existing = await fse.pathExists(destinationPath)
121
- ? await fse.readFile(destinationPath, 'utf-8')
122
- : ''
123
- const merged = writeProjectSlots(shipped, readProjectSlots(existing))
124
- if (merged === existing) continue
125
- await fse.ensureDir(path.dirname(destinationPath))
126
- await fse.writeFile(destinationPath, merged, 'utf-8')
127
- success(`Refreshed skill: ${relativePath}`)
128
- }
129
- }
130
-
131
- async function isGeneratedSkill(dest) {
132
- const skillMd = path.join(dest, 'SKILL.md')
133
- if (!await fse.pathExists(skillMd)) return false
134
- const content = await fse.readFile(skillMd, 'utf-8')
135
- return content.includes('<!-- Last updated:')
136
- }
137
-
138
- async function syncSkillFiles(src, dest, relativeRoot, cwd, manifest) {
139
- for (const entry of await fse.readdir(src, { withFileTypes: true })) {
140
- const sourcePath = path.join(src, entry.name)
141
- const destinationPath = path.join(dest, entry.name)
142
- const relativePath = path.join(relativeRoot, entry.name)
143
- if (entry.isDirectory()) {
144
- await syncSkillFiles(sourcePath, destinationPath, relativePath, cwd, manifest)
145
- continue
146
- }
147
- if (!await fse.pathExists(destinationPath) || await canUpdateManagedFile(relativePath, cwd, manifest)) {
148
- await fse.ensureDir(path.dirname(destinationPath))
149
- await fse.copyFile(sourcePath, destinationPath)
150
- await recordManagedFile(manifest, relativePath, sourcePath)
151
- success(`Updated skill: ${relativePath}`)
152
- continue
153
- }
154
-
155
- // A shipped file that declares a PC-PROJECT-* slot states in its own prose
156
- // that the slot is the project's and everything else is the harness's, so a
157
- // hand-edited copy still takes the update: merge rather than preserve.
158
- // Without this the file is frozen at whatever version the project edited,
159
- // which is how five repos ended up with stale skills.
160
- const shipped = await fse.readFile(sourcePath, 'utf-8')
161
- if (HAS_PROJECT_SLOT.test(shipped)) {
162
- const existing = await fse.readFile(destinationPath, 'utf-8')
163
- const merged = writeProjectSlots(shipped, readProjectSlots(existing))
164
- if (merged !== existing) {
165
- await fse.writeFile(destinationPath, merged, 'utf-8')
166
- success(`Refreshed skill, project slot kept: ${relativePath}`)
167
- }
168
- continue
169
- }
170
-
171
- info(`Preserving modified skill file: ${relativePath}`)
172
- }
173
- }
174
-
175
- async function recordSkillSourceFiles(src, relativeRoot, manifest) {
176
- for (const entry of await fse.readdir(src, { withFileTypes: true })) {
177
- const sourcePath = path.join(src, entry.name)
178
- const relativePath = path.join(relativeRoot, entry.name)
179
- if (entry.isDirectory()) {
180
- await recordSkillSourceFiles(sourcePath, relativePath, manifest)
181
- } else {
182
- await recordManagedFile(manifest, relativePath, sourcePath)
183
- }
184
- }
185
- }
186
-
187
- async function installObSkills(backlogPlatform = 'github', repoPlatform, { forceOverwrite = false, updateMode = false } = {}) {
188
- const repo = repoPlatform ?? backlogPlatform
189
- const destSkillsDir = path.join(process.cwd(), '.agents', 'skills')
190
- await fse.ensureDir(destSkillsDir)
191
- const manifest = await readUpdateManifest()
192
-
193
- // Build the set of skill names we ship (source dirs, after rename).
194
- // Only these may be removed during forceOverwrite — project-generated
195
- // skills (pc-merge-risk-assess, custom loops, etc.) must survive.
196
- const contentSkills = (await fse.readdir(CONTENT_SKILLS_DIR))
197
- .filter(name => !NON_INSTALLABLE_SKILLS.has(name))
198
- const shippedNames = new Set()
199
- for (const skill of contentSkills) {
200
- const stat = await fse.stat(path.join(CONTENT_SKILLS_DIR, skill)).catch(() => null)
201
- if (stat?.isDirectory()) shippedNames.add(SKILL_RENAME[skill] ?? skill)
202
- }
203
- // Also account for stale platform-variant names (e.g. pc-userstory-gh
204
- // when the current platform is azure).
205
- Object.keys(SKILL_RENAME).forEach(k => shippedNames.add(k))
206
-
207
- if (forceOverwrite && !updateMode) {
208
- for (const entry of await fse.readdir(destSkillsDir)) {
209
- if (!entry.startsWith('pc-')) continue
210
- if (!shippedNames.has(entry)) {
211
- info(`Preserving project-generated skill: ${entry}`)
212
- continue
213
- }
214
- // Generatable skills: preserve if already populated by /make-*
215
- if (GENERATABLE_SKILLS.has(entry) && await isGeneratedSkill(path.join(destSkillsDir, entry))) {
216
- info(`Preserving generated skill: ${entry}`)
217
- continue
218
- }
219
- await fse.remove(path.join(destSkillsDir, entry))
220
- info(`Removing shipped skill: ${entry}`)
221
- }
222
- }
223
-
224
- const skills = contentSkills
225
- for (const skill of skills) {
226
- const src = path.join(CONTENT_SKILLS_DIR, skill)
227
- const destName = SKILL_RENAME[skill] ?? skill
228
- const dest = path.join(destSkillsDir, destName)
229
- const stat = await fse.stat(src)
230
- if (!stat.isDirectory()) continue
231
- if (!shouldInstallSkill(skill, backlogPlatform, repo)) {
232
- if (forceOverwrite) {
233
- const staleDest = path.join(destSkillsDir, skill)
234
- if (skill !== destName && await fse.pathExists(staleDest)) {
235
- await fse.remove(staleDest)
236
- info(`Removing stale skill: ${skill}`)
237
- }
238
- }
239
- info(`Skipping skill: ${skill} (not needed for platforms: ${backlogPlatform}/${repo})`)
240
- continue
241
- }
242
- if (updateMode) {
243
- const relativeRoot = path.join('.agents', 'skills', destName)
244
- if (!await fse.pathExists(dest)) {
245
- await syncSkillFiles(src, dest, relativeRoot, process.cwd(), manifest)
246
- } else if (GENERATABLE_SKILLS.has(destName)) {
247
- info(`Preserving generated skill: ${destName}`)
248
- } else if (REFRESHABLE_MARKER_SKILLS.has(destName)) {
249
- // Shipped prose refreshes; the patchers re-inject the marker pairs and
250
- // any PC-PROJECT-* slot is carried over.
251
- await refreshMarkerSkill(src, dest, relativeRoot)
252
- } else if (MARKER_SKILLS.has(destName)) {
253
- info(`Preserving project-owned skill: ${destName}`)
254
- } else {
255
- await syncSkillFiles(src, dest, relativeRoot, process.cwd(), manifest)
256
- }
257
- continue
258
- }
259
- if (await fse.pathExists(dest) && !forceOverwrite) {
260
- info(`${destName} already exists, skipping`)
261
- continue
262
- }
263
- // Even with forceOverwrite, never overwrite an already-generated
264
- // generatable skill — its content was produced by /make-guardrails or
265
- // similar, not by the shipped placeholder.
266
- if (forceOverwrite && GENERATABLE_SKILLS.has(destName) && await isGeneratedSkill(dest)) {
267
- info(`Preserving generated skill: ${destName}`)
268
- continue
269
- }
270
- await fse.copy(src, dest, { overwrite: true })
271
- if (!MARKER_SKILLS.has(destName) && !GENERATABLE_SKILLS.has(destName)) {
272
- await recordSkillSourceFiles(src, path.join('.agents', 'skills', destName), manifest)
273
- }
274
- success(`${forceOverwrite ? 'Updated' : 'Installed'} skill: ${destName}`)
275
- }
276
-
277
- await writeUpdateManifest(manifest)
278
- }
279
-
280
- export async function installSkills(backlogPlatform = 'github', repoPlatform, opts = {}) {
281
- info('Installing built-in pc-skills...')
282
- await installObSkills(backlogPlatform, repoPlatform, opts)
283
- console.log()
284
-
285
- if (await fse.pathExists(CONTENT_SKILLS_LOCK)) {
286
- const destLock = path.join(process.cwd(), 'skills-lock.json')
287
- if (await fse.pathExists(destLock) && (opts.updateMode || !opts.forceOverwrite)) {
288
- info('skills-lock.json already exists, skipping')
289
- } else {
290
- await fse.copy(CONTENT_SKILLS_LOCK, destLock, { overwrite: true })
291
- success(`${opts.forceOverwrite ? 'Updated' : 'Installed'} skills-lock.json`)
292
- }
293
- }
294
-
295
- // npx skills experimental_install is now called once at the end of the
296
- // optimization step, after the user has selected which tools to enable.
297
- // This avoids multiple npx skills add calls during onboarding.
298
- }
299
-
300
- export async function removeRetiredFiles(cwd = process.cwd()) {
301
- for (const relative of RETIRED_PATHS) {
302
- const target = path.join(cwd, relative)
303
- if (!await fse.pathExists(target)) continue
304
- await fse.remove(target)
305
- info(`Removed retired: ${relative}`)
306
- }
307
- }
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
+ import { RETIRED_PATHS } from '../../utils/paths.js'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../../skills')
10
+ const CONTENT_SKILLS_LOCK = path.resolve(__dirname, '../../../harness/skills-lock.json')
11
+
12
+ // Userstory skills parse backlog work items: selected by backlogPlatform only.
13
+ // Mixing the two axes here installs the wrong variant on mixed setups, because
14
+ // all variants rename to the same generic dir and the first copy wins.
15
+ const BACKLOG_PLATFORM_SKILLS = {
16
+ 'pc-userstory-gh': 'github',
17
+ 'pc-userstory-az': 'azure',
18
+ 'pc-userstory-jira': 'jira',
19
+ 'pc-userstory-browser': 'browser',
20
+ }
21
+
22
+ // Platform-specific skills are renamed to their generic form on install.
23
+ // The -gh / -az / -jira / -gl suffix is only needed here to keep all variants in source.
24
+ // After install only one platform is present so no suffix is needed.
25
+ export const SKILL_RENAME = {
26
+ 'pc-userstory-gh': 'pc-userstory',
27
+ 'pc-userstory-az': 'pc-userstory',
28
+ 'pc-userstory-jira': 'pc-userstory',
29
+ 'pc-userstory-browser': 'pc-userstory',
30
+ }
31
+
32
+ // Skills that ship in the source tree but must never be copied into a target
33
+ // project. agent-harness-cli documents the CLI itself for agents working on
34
+ // THIS repo; it has no value in a consumer repo and would clutter .agents/skills.
35
+ // Listed by directory name as read from CONTENT_SKILLS_DIR.
36
+ export const NON_INSTALLABLE_SKILLS = new Set([
37
+ 'agent-harness-cli',
38
+ ])
39
+
40
+ function shouldInstallSkill(skill, backlogPlatform, _repoPlatform) {
41
+ if (NON_INSTALLABLE_SKILLS.has(skill)) return false
42
+ if (skill in BACKLOG_PLATFORM_SKILLS) return BACKLOG_PLATFORM_SKILLS[skill] === backlogPlatform
43
+ return true
44
+ }
45
+
46
+ // Shipped skills whose content is a PLACEHOLDER — a /make-* command
47
+ // overwrites them with project-specific content. During forceOverwrite,
48
+ // these must be preserved when they have already been generated (detected
49
+ // by the <!-- Last updated: marker). Without this check, the update would
50
+ // wipe project-specific guardrails, risk assessments, etc.
51
+ export const GENERATABLE_SKILLS = new Set([
52
+ 'pc-guardrails-project',
53
+ 'pc-merge-risk-assess',
54
+ ])
55
+
56
+ const MARKER_SKILLS = new Set([
57
+ 'pc-guardrails-generic',
58
+ 'pc-plan-archive',
59
+ 'pc-ops-ship',
60
+ 'pc-repo-initialize',
61
+ ])
62
+
63
+ // Marker skills whose prose must track the shipped version on update.
64
+ //
65
+ // These are shipped content, not project content: the project-specific part is
66
+ // injected into their marker pairs, and the platform patchers plus
67
+ // patchGuardrails re-inject it immediately after this copy. Treating them as
68
+ // project-owned froze their prose at whatever version a consumer first
69
+ // installed, so a skill rewrite never reached an existing repo. That is also
70
+ // how browser-automation sat on v1.0 in five repos while the harness shipped
71
+ // v2.0.
72
+ //
73
+ // pc-repo-initialize is deliberately absent. patchAgentsMd stamps "skipped
74
+ // during onboarding" notes into it that only run on a fresh install
75
+ // (copy/index.js runs it under `!ctx.updateMode`), so refreshing it would
76
+ // discard decisions made at onboarding.
77
+ const REFRESHABLE_MARKER_SKILLS = new Set([
78
+ 'pc-guardrails-generic',
79
+ 'pc-plan-archive',
80
+ 'pc-ops-ship',
81
+ ])
82
+
83
+ // A project-owned slot inside a shipped skill: the content between the markers
84
+ // survives a refresh, everything around it takes the new shipped version. This
85
+ // is the supported way to adapt one example to a project without forking the
86
+ // file, which consumers have otherwise done by editing shipped prose in place.
87
+ const PROJECT_SLOT_RE = /<!-- (PC-PROJECT-[A-Z0-9-]+)-START -->([\s\S]*?)<!-- \1-END -->/g
88
+ // Separate, non-global: `.test()` on a /g/ regex advances lastIndex, so reusing
89
+ // PROJECT_SLOT_RE for the check would answer false on every other call.
90
+ const HAS_PROJECT_SLOT = /<!-- PC-PROJECT-[A-Z0-9-]+-START -->/
91
+
92
+ function readProjectSlots(text) {
93
+ const slots = new Map()
94
+ for (const match of text.matchAll(PROJECT_SLOT_RE)) slots.set(match[1], match[2])
95
+ return slots
96
+ }
97
+
98
+ function writeProjectSlots(text, slots) {
99
+ if (slots.size === 0) return text
100
+ // Replacer function, not a replacement string: carried content can contain
101
+ // shell quoting like $'...', and a string replacement expands $' as
102
+ // "everything after the match" and truncates the file.
103
+ return text.replace(PROJECT_SLOT_RE, (whole, name) => {
104
+ const kept = slots.get(name)
105
+ if (kept === undefined || kept.trim() === '') return whole
106
+ return `<!-- ${name}-START -->${kept}<!-- ${name}-END -->`
107
+ })
108
+ }
109
+
110
+ async function refreshMarkerSkill(src, dest, relativeRoot) {
111
+ for (const entry of await fse.readdir(src, { withFileTypes: true })) {
112
+ const sourcePath = path.join(src, entry.name)
113
+ const destinationPath = path.join(dest, entry.name)
114
+ const relativePath = path.join(relativeRoot, entry.name)
115
+ if (entry.isDirectory()) {
116
+ await refreshMarkerSkill(sourcePath, destinationPath, relativePath)
117
+ continue
118
+ }
119
+ const shipped = await fse.readFile(sourcePath, 'utf-8')
120
+ const existing = await fse.pathExists(destinationPath)
121
+ ? await fse.readFile(destinationPath, 'utf-8')
122
+ : ''
123
+ const merged = writeProjectSlots(shipped, readProjectSlots(existing))
124
+ if (merged === existing) continue
125
+ await fse.ensureDir(path.dirname(destinationPath))
126
+ await fse.writeFile(destinationPath, merged, 'utf-8')
127
+ success(`Refreshed skill: ${relativePath}`)
128
+ }
129
+ }
130
+
131
+ async function isGeneratedSkill(dest) {
132
+ const skillMd = path.join(dest, 'SKILL.md')
133
+ if (!await fse.pathExists(skillMd)) return false
134
+ const content = await fse.readFile(skillMd, 'utf-8')
135
+ return content.includes('<!-- Last updated:')
136
+ }
137
+
138
+ async function syncSkillFiles(src, dest, relativeRoot, cwd, manifest) {
139
+ for (const entry of await fse.readdir(src, { withFileTypes: true })) {
140
+ const sourcePath = path.join(src, entry.name)
141
+ const destinationPath = path.join(dest, entry.name)
142
+ const relativePath = path.join(relativeRoot, entry.name)
143
+ if (entry.isDirectory()) {
144
+ await syncSkillFiles(sourcePath, destinationPath, relativePath, cwd, manifest)
145
+ continue
146
+ }
147
+ const exists = await fse.pathExists(destinationPath)
148
+ if (!exists || await canUpdateManagedFile(relativePath, cwd, manifest)) {
149
+ await fse.ensureDir(path.dirname(destinationPath))
150
+ // The managed check ignores what sits between markers, so an untouched file can still hold
151
+ // a filled PC-PROJECT-* slot; copying the source over it erased Numa's sign-in steps.
152
+ const shipped = await fse.readFile(sourcePath, 'utf-8')
153
+ if (exists && HAS_PROJECT_SLOT.test(shipped)) {
154
+ const existing = await fse.readFile(destinationPath, 'utf-8')
155
+ await fse.writeFile(destinationPath, writeProjectSlots(shipped, readProjectSlots(existing)), 'utf-8')
156
+ } else {
157
+ await fse.copyFile(sourcePath, destinationPath)
158
+ }
159
+ await recordManagedFile(manifest, relativePath, sourcePath)
160
+ success(`Updated skill: ${relativePath}`)
161
+ continue
162
+ }
163
+
164
+ // A shipped file that declares a PC-PROJECT-* slot states in its own prose
165
+ // that the slot is the project's and everything else is the harness's, so a
166
+ // hand-edited copy still takes the update: merge rather than preserve.
167
+ // Without this the file is frozen at whatever version the project edited,
168
+ // which is how five repos ended up with stale skills.
169
+ const shipped = await fse.readFile(sourcePath, 'utf-8')
170
+ if (HAS_PROJECT_SLOT.test(shipped)) {
171
+ const existing = await fse.readFile(destinationPath, 'utf-8')
172
+ const merged = writeProjectSlots(shipped, readProjectSlots(existing))
173
+ if (merged !== existing) {
174
+ await fse.writeFile(destinationPath, merged, 'utf-8')
175
+ success(`Refreshed skill, project slot kept: ${relativePath}`)
176
+ }
177
+ continue
178
+ }
179
+
180
+ info(`Preserving modified skill file: ${relativePath}`)
181
+ }
182
+ }
183
+
184
+ async function recordSkillSourceFiles(src, relativeRoot, manifest) {
185
+ for (const entry of await fse.readdir(src, { withFileTypes: true })) {
186
+ const sourcePath = path.join(src, entry.name)
187
+ const relativePath = path.join(relativeRoot, entry.name)
188
+ if (entry.isDirectory()) {
189
+ await recordSkillSourceFiles(sourcePath, relativePath, manifest)
190
+ } else {
191
+ await recordManagedFile(manifest, relativePath, sourcePath)
192
+ }
193
+ }
194
+ }
195
+
196
+ async function installObSkills(backlogPlatform = 'github', repoPlatform, { forceOverwrite = false, updateMode = false } = {}) {
197
+ const repo = repoPlatform ?? backlogPlatform
198
+ const destSkillsDir = path.join(process.cwd(), '.agents', 'skills')
199
+ await fse.ensureDir(destSkillsDir)
200
+ const manifest = await readUpdateManifest()
201
+
202
+ // Build the set of skill names we ship (source dirs, after rename).
203
+ // Only these may be removed during forceOverwrite — project-generated
204
+ // skills (pc-merge-risk-assess, custom loops, etc.) must survive.
205
+ const contentSkills = (await fse.readdir(CONTENT_SKILLS_DIR))
206
+ .filter(name => !NON_INSTALLABLE_SKILLS.has(name))
207
+ const shippedNames = new Set()
208
+ for (const skill of contentSkills) {
209
+ const stat = await fse.stat(path.join(CONTENT_SKILLS_DIR, skill)).catch(() => null)
210
+ if (stat?.isDirectory()) shippedNames.add(SKILL_RENAME[skill] ?? skill)
211
+ }
212
+ // Also account for stale platform-variant names (e.g. pc-userstory-gh
213
+ // when the current platform is azure).
214
+ Object.keys(SKILL_RENAME).forEach(k => shippedNames.add(k))
215
+
216
+ if (forceOverwrite && !updateMode) {
217
+ for (const entry of await fse.readdir(destSkillsDir)) {
218
+ if (!entry.startsWith('pc-')) continue
219
+ if (!shippedNames.has(entry)) {
220
+ info(`Preserving project-generated skill: ${entry}`)
221
+ continue
222
+ }
223
+ // Generatable skills: preserve if already populated by /make-*
224
+ if (GENERATABLE_SKILLS.has(entry) && await isGeneratedSkill(path.join(destSkillsDir, entry))) {
225
+ info(`Preserving generated skill: ${entry}`)
226
+ continue
227
+ }
228
+ await fse.remove(path.join(destSkillsDir, entry))
229
+ info(`Removing shipped skill: ${entry}`)
230
+ }
231
+ }
232
+
233
+ const skills = contentSkills
234
+ for (const skill of skills) {
235
+ const src = path.join(CONTENT_SKILLS_DIR, skill)
236
+ const destName = SKILL_RENAME[skill] ?? skill
237
+ const dest = path.join(destSkillsDir, destName)
238
+ const stat = await fse.stat(src)
239
+ if (!stat.isDirectory()) continue
240
+ if (!shouldInstallSkill(skill, backlogPlatform, repo)) {
241
+ if (forceOverwrite) {
242
+ const staleDest = path.join(destSkillsDir, skill)
243
+ if (skill !== destName && await fse.pathExists(staleDest)) {
244
+ await fse.remove(staleDest)
245
+ info(`Removing stale skill: ${skill}`)
246
+ }
247
+ }
248
+ info(`Skipping skill: ${skill} (not needed for platforms: ${backlogPlatform}/${repo})`)
249
+ continue
250
+ }
251
+ if (updateMode) {
252
+ const relativeRoot = path.join('.agents', 'skills', destName)
253
+ if (!await fse.pathExists(dest)) {
254
+ await syncSkillFiles(src, dest, relativeRoot, process.cwd(), manifest)
255
+ } else if (GENERATABLE_SKILLS.has(destName)) {
256
+ info(`Preserving generated skill: ${destName}`)
257
+ } else if (REFRESHABLE_MARKER_SKILLS.has(destName)) {
258
+ // Shipped prose refreshes; the patchers re-inject the marker pairs and
259
+ // any PC-PROJECT-* slot is carried over.
260
+ await refreshMarkerSkill(src, dest, relativeRoot)
261
+ } else if (MARKER_SKILLS.has(destName)) {
262
+ info(`Preserving project-owned skill: ${destName}`)
263
+ } else {
264
+ await syncSkillFiles(src, dest, relativeRoot, process.cwd(), manifest)
265
+ }
266
+ continue
267
+ }
268
+ if (await fse.pathExists(dest) && !forceOverwrite) {
269
+ info(`${destName} already exists, skipping`)
270
+ continue
271
+ }
272
+ // Even with forceOverwrite, never overwrite an already-generated
273
+ // generatable skill — its content was produced by /make-guardrails or
274
+ // similar, not by the shipped placeholder.
275
+ if (forceOverwrite && GENERATABLE_SKILLS.has(destName) && await isGeneratedSkill(dest)) {
276
+ info(`Preserving generated skill: ${destName}`)
277
+ continue
278
+ }
279
+ await fse.copy(src, dest, { overwrite: true })
280
+ if (!MARKER_SKILLS.has(destName) && !GENERATABLE_SKILLS.has(destName)) {
281
+ await recordSkillSourceFiles(src, path.join('.agents', 'skills', destName), manifest)
282
+ }
283
+ success(`${forceOverwrite ? 'Updated' : 'Installed'} skill: ${destName}`)
284
+ }
285
+
286
+ await writeUpdateManifest(manifest)
287
+ }
288
+
289
+ export async function installSkills(backlogPlatform = 'github', repoPlatform, opts = {}) {
290
+ info('Installing built-in pc-skills...')
291
+ await installObSkills(backlogPlatform, repoPlatform, opts)
292
+ console.log()
293
+
294
+ if (await fse.pathExists(CONTENT_SKILLS_LOCK)) {
295
+ const destLock = path.join(process.cwd(), 'skills-lock.json')
296
+ if (await fse.pathExists(destLock) && (opts.updateMode || !opts.forceOverwrite)) {
297
+ info('skills-lock.json already exists, skipping')
298
+ } else {
299
+ await fse.copy(CONTENT_SKILLS_LOCK, destLock, { overwrite: true })
300
+ success(`${opts.forceOverwrite ? 'Updated' : 'Installed'} skills-lock.json`)
301
+ }
302
+ }
303
+
304
+ // npx skills experimental_install is now called once at the end of the
305
+ // optimization step, after the user has selected which tools to enable.
306
+ // This avoids multiple npx skills add calls during onboarding.
307
+ }
308
+
309
+ export async function removeRetiredFiles(cwd = process.cwd()) {
310
+ for (const relative of RETIRED_PATHS) {
311
+ const target = path.join(cwd, relative)
312
+ if (!await fse.pathExists(target)) continue
313
+ await fse.remove(target)
314
+ info(`Removed retired: ${relative}`)
315
+ }
316
+ }