@plainconceptsplatform/agent-harness 3.4.1 → 3.5.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.
Files changed (50) hide show
  1. package/cli/commands/update.js +121 -121
  2. package/cli/fragments/archive/az.md +97 -97
  3. package/cli/fragments/archive/gh.md +96 -96
  4. package/cli/fragments/archive/gl.md +96 -96
  5. package/cli/fragments/archive/none.md +75 -75
  6. package/cli/fragments/guardrails/codegraph.md +5 -5
  7. package/cli/fragments/guardrails/humanizer.md +4 -4
  8. package/cli/fragments/guardrails/memory.md +4 -4
  9. package/cli/fragments/guardrails/rtk.md +3 -3
  10. package/cli/fragments/guardrails/simple-english.md +4 -4
  11. package/cli/fragments/ops-ship/az.md +81 -81
  12. package/cli/presets/agents-content.json +34 -34
  13. package/cli/presets/browser.json +10 -10
  14. package/cli/steps/browser/index.js +48 -48
  15. package/cli/steps/copy/commands.js +79 -79
  16. package/cli/steps/copy/index.js +88 -88
  17. package/cli/steps/metadata/index.js +115 -115
  18. package/cli/steps/models/write.js +38 -38
  19. package/harness/.opencode/commands/init.md +5 -5
  20. package/harness/.opencode/commands/make-architecture.md +5 -5
  21. package/harness/.opencode/commands/make-design.md +5 -5
  22. package/harness/.opencode/commands/make-engineer.md +5 -5
  23. package/harness/.opencode/commands/make-guardrails.md +5 -5
  24. package/harness/.opencode/commands/make-user-model.md +5 -5
  25. package/harness/.opencode/commands/plan-apply.md +9 -9
  26. package/harness/.opencode/commands/plan-goal.md +5 -5
  27. package/harness/.opencode/commands/repo-initialize.md +5 -5
  28. package/harness/.opencode/commands/repo-onboard.md +5 -5
  29. package/harness/.opencode/commands/repo-verify.md +5 -5
  30. package/harness/.opencode/scripts/check-diff.mjs +9 -1
  31. package/harness/AGENTS.md +49 -49
  32. package/package.json +1 -1
  33. package/skills/README.md +18 -18
  34. package/skills/browser-automation/SKILL.md +72 -72
  35. package/skills/pc-make-architecture/SKILL.md +32 -32
  36. package/skills/pc-make-design/SKILL.md +45 -45
  37. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  38. package/skills/pc-make-guardrails/SKILL.md +44 -44
  39. package/skills/pc-make-user-model/SKILL.md +56 -56
  40. package/skills/pc-plan-apply/SKILL.md +2 -0
  41. package/skills/pc-plan-archive/SKILL.md +66 -66
  42. package/skills/pc-plan-goal/branching.md +39 -39
  43. package/skills/pc-plan-goal/output.md +71 -71
  44. package/skills/pc-plan-story/SKILL.md +93 -93
  45. package/skills/pc-repo-initialize/SKILL.md +119 -119
  46. package/skills/pc-repo-onboard/SKILL.md +32 -32
  47. package/skills/pc-userstory-az/SKILL.md +71 -71
  48. package/skills/pc-userstory-browser/SKILL.md +64 -64
  49. package/skills/pc-userstory-gh/SKILL.md +63 -63
  50. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,10 +1,10 @@
1
- {
2
- "installer": {
3
- "command": "npm",
4
- "args": ["install", "-g", "agent-browser@0.36.0"]
5
- },
6
- "chromeSetup": {
7
- "command": "agent-browser",
8
- "args": ["install"]
9
- }
10
- }
1
+ {
2
+ "installer": {
3
+ "command": "npm",
4
+ "args": ["install", "-g", "agent-browser@0.36.0"]
5
+ },
6
+ "chromeSetup": {
7
+ "command": "agent-browser",
8
+ "args": ["install"]
9
+ }
10
+ }
@@ -1,48 +1,48 @@
1
- import { execa } from 'execa'
2
- import fse from 'fs-extra'
3
- import { header, info, success, warn, error } from '../../utils/exec.js'
4
- import os from 'os'
5
- import path from 'path'
6
- import { fileURLToPath } from 'url'
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
- const BROWSER_PRESET_PATH = path.resolve(__dirname, '../../presets/browser.json')
10
- const browserPreset = await fse.readJson(BROWSER_PRESET_PATH)
11
-
12
- export async function installBrowser() {
13
- header('Step 9, Installing agent-browser')
14
-
15
- try {
16
- const install = await execa(browserPreset.installer.command, browserPreset.installer.args, {
17
- cwd: os.homedir(),
18
- reject: false,
19
- })
20
-
21
- if (install.exitCode !== 0) {
22
- warn('agent-browser npm install exited with non-zero code')
23
- if (install.stderr) info(install.stderr)
24
- return
25
- }
26
- success('agent-browser installed')
27
- } catch (err) {
28
- error(`Failed to install agent-browser: ${err.message}`)
29
- return
30
- }
31
-
32
- try {
33
- const chrome = await execa(browserPreset.chromeSetup.command, browserPreset.chromeSetup.args, {
34
- cwd: os.homedir(),
35
- reject: false,
36
- })
37
-
38
- if (chrome.exitCode === 0) {
39
- success('agent-browser Chrome ready')
40
- if (chrome.stdout) info(chrome.stdout.trim())
41
- } else {
42
- warn('agent-browser install (Chrome setup) exited with non-zero code')
43
- if (chrome.stderr) info(chrome.stderr)
44
- }
45
- } catch (err) {
46
- error(`Failed to set up Chrome for agent-browser: ${err.message}`)
47
- }
48
- }
1
+ import { execa } from 'execa'
2
+ import fse from 'fs-extra'
3
+ import { header, info, success, warn, error } from '../../utils/exec.js'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import { fileURLToPath } from 'url'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const BROWSER_PRESET_PATH = path.resolve(__dirname, '../../presets/browser.json')
10
+ const browserPreset = await fse.readJson(BROWSER_PRESET_PATH)
11
+
12
+ export async function installBrowser() {
13
+ header('Step 9, Installing agent-browser')
14
+
15
+ try {
16
+ const install = await execa(browserPreset.installer.command, browserPreset.installer.args, {
17
+ cwd: os.homedir(),
18
+ reject: false,
19
+ })
20
+
21
+ if (install.exitCode !== 0) {
22
+ warn('agent-browser npm install exited with non-zero code')
23
+ if (install.stderr) info(install.stderr)
24
+ return
25
+ }
26
+ success('agent-browser installed')
27
+ } catch (err) {
28
+ error(`Failed to install agent-browser: ${err.message}`)
29
+ return
30
+ }
31
+
32
+ try {
33
+ const chrome = await execa(browserPreset.chromeSetup.command, browserPreset.chromeSetup.args, {
34
+ cwd: os.homedir(),
35
+ reject: false,
36
+ })
37
+
38
+ if (chrome.exitCode === 0) {
39
+ success('agent-browser Chrome ready')
40
+ if (chrome.stdout) info(chrome.stdout.trim())
41
+ } else {
42
+ warn('agent-browser install (Chrome setup) exited with non-zero code')
43
+ if (chrome.stderr) info(chrome.stderr)
44
+ }
45
+ } catch (err) {
46
+ error(`Failed to set up Chrome for agent-browser: ${err.message}`)
47
+ }
48
+ }
@@ -1,79 +1,79 @@
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
- // relativePath is POSIX-style relative to the project root; commands live in
34
- // .opencode/commands/, skill-backed procedures (plan-archive, ops-ship) in
35
- // .agents/skills/<name>/SKILL.md.
36
- function patchFile(relativePath, startMarker, endMarker, content, platform, cwd = process.cwd()) {
37
- const targetPath = path.join(cwd, ...relativePath.split('/'))
38
- if (!fse.pathExistsSync(targetPath)) return
39
-
40
- let fileContent = fse.readFileSync(targetPath, 'utf-8')
41
- if (!fileContent.includes(startMarker) || !fileContent.includes(endMarker)) return
42
-
43
- const pattern = new RegExp(`${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
44
- // Replacer function, not a replacement string: the injected fragments contain
45
- // shell quoting like $'...' and a string replacement expands $' as "everything
46
- // after the match", silently truncating the command. That is how the ops-ship
47
- // screenshot comment lost its entire --body argument.
48
- fileContent = fileContent.replace(pattern, () => `${startMarker}\n${content.trim()}\n${endMarker}`)
49
- fse.writeFileSync(targetPath, `${fileContent.replace(/\s*$/, '')}\n`, 'utf-8')
50
- success(`${relativePath} content injected for platform: ${platform}`)
51
- }
52
-
53
- export async function patchArchiveCommand(platform, cwd = process.cwd()) {
54
- const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? platform.backlogPlatform ?? 'github') : platform
55
- const replacement = _archive[repoPlatform]
56
- if (!replacement) return
57
- patchFile('.agents/skills/pc-plan-archive/SKILL.md', '<!-- PC-PLATFORM-ARCHIVE-START -->', '<!-- PC-PLATFORM-ARCHIVE-END -->', replacement, repoPlatform, cwd)
58
- }
59
-
60
- export async function patchOpsShip(platform, cwd = process.cwd()) {
61
- const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
62
- const replacement = _ship[repoPlatform]
63
- if (!replacement) return
64
- patchFile('.agents/skills/pc-ops-ship/SKILL.md', '<!-- PC-PLATFORM-SHIP-START -->', '<!-- PC-PLATFORM-SHIP-END -->', replacement, repoPlatform, cwd)
65
- }
66
-
67
- export async function patchOpsReview(platform, cwd = process.cwd()) {
68
- const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
69
- const replacement = _review[repoPlatform]
70
- if (!replacement) return
71
- patchFile('.opencode/commands/ops-review.md', '<!-- PC-PLATFORM-REVIEW-START -->', '<!-- PC-PLATFORM-REVIEW-END -->', replacement, repoPlatform, cwd)
72
- }
73
-
74
- export async function patchOpsBacklog(platform, cwd = process.cwd()) {
75
- const backlogPlatform = typeof platform === 'object' ? (platform.backlogPlatform ?? 'github') : platform
76
- const replacement = _backlog[backlogPlatform]
77
- if (!replacement) return
78
- patchFile('.opencode/commands/ops-backlog.md', '<!-- PC-PLATFORM-BACKLOG-START -->', '<!-- PC-PLATFORM-BACKLOG-END -->', replacement, backlogPlatform, cwd)
79
- }
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
+ // relativePath is POSIX-style relative to the project root; commands live in
34
+ // .opencode/commands/, skill-backed procedures (plan-archive, ops-ship) in
35
+ // .agents/skills/<name>/SKILL.md.
36
+ function patchFile(relativePath, startMarker, endMarker, content, platform, cwd = process.cwd()) {
37
+ const targetPath = path.join(cwd, ...relativePath.split('/'))
38
+ if (!fse.pathExistsSync(targetPath)) return
39
+
40
+ let fileContent = fse.readFileSync(targetPath, 'utf-8')
41
+ if (!fileContent.includes(startMarker) || !fileContent.includes(endMarker)) return
42
+
43
+ const pattern = new RegExp(`${startMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
44
+ // Replacer function, not a replacement string: the injected fragments contain
45
+ // shell quoting like $'...' and a string replacement expands $' as "everything
46
+ // after the match", silently truncating the command. That is how the ops-ship
47
+ // screenshot comment lost its entire --body argument.
48
+ fileContent = fileContent.replace(pattern, () => `${startMarker}\n${content.trim()}\n${endMarker}`)
49
+ fse.writeFileSync(targetPath, `${fileContent.replace(/\s*$/, '')}\n`, 'utf-8')
50
+ success(`${relativePath} content injected for platform: ${platform}`)
51
+ }
52
+
53
+ export async function patchArchiveCommand(platform, cwd = process.cwd()) {
54
+ const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? platform.backlogPlatform ?? 'github') : platform
55
+ const replacement = _archive[repoPlatform]
56
+ if (!replacement) return
57
+ patchFile('.agents/skills/pc-plan-archive/SKILL.md', '<!-- PC-PLATFORM-ARCHIVE-START -->', '<!-- PC-PLATFORM-ARCHIVE-END -->', replacement, repoPlatform, cwd)
58
+ }
59
+
60
+ export async function patchOpsShip(platform, cwd = process.cwd()) {
61
+ const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
62
+ const replacement = _ship[repoPlatform]
63
+ if (!replacement) return
64
+ patchFile('.agents/skills/pc-ops-ship/SKILL.md', '<!-- PC-PLATFORM-SHIP-START -->', '<!-- PC-PLATFORM-SHIP-END -->', replacement, repoPlatform, cwd)
65
+ }
66
+
67
+ export async function patchOpsReview(platform, cwd = process.cwd()) {
68
+ const repoPlatform = typeof platform === 'object' ? (platform.repoPlatform ?? 'github') : platform
69
+ const replacement = _review[repoPlatform]
70
+ if (!replacement) return
71
+ patchFile('.opencode/commands/ops-review.md', '<!-- PC-PLATFORM-REVIEW-START -->', '<!-- PC-PLATFORM-REVIEW-END -->', replacement, repoPlatform, cwd)
72
+ }
73
+
74
+ export async function patchOpsBacklog(platform, cwd = process.cwd()) {
75
+ const backlogPlatform = typeof platform === 'object' ? (platform.backlogPlatform ?? 'github') : platform
76
+ const replacement = _backlog[backlogPlatform]
77
+ if (!replacement) return
78
+ patchFile('.opencode/commands/ops-backlog.md', '<!-- PC-PLATFORM-BACKLOG-START -->', '<!-- PC-PLATFORM-BACKLOG-END -->', replacement, backlogPlatform, cwd)
79
+ }
@@ -1,88 +1,88 @@
1
- import fse from "fs-extra"
2
- import path from "path"
3
- import { fileURLToPath } from "url"
4
- import { copyContent, recordManagedContent } from "../../utils/copy.js"
5
- import { error, header, success } from "../../utils/exec.js"
6
- import { exit } from "../../utils/process.js"
7
- import { patchAgentGuidance, patchAgentsMd } from "./agents.js"
8
- import { patchArchiveCommand, patchOpsShip, patchOpsReview, patchOpsBacklog } from "./commands.js"
9
- import { installSkills, removeRetiredFiles } from "./skills.js"
10
- import { generateFullstackEngineer, removeLegacyStartupDirectives } from "./fullstack-engineer.js"
11
- import { patchOpencodeJson, patchOpencodePackage } from "./opencode-json.js"
12
-
13
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
- const CONTENT_DIR = path.resolve(__dirname, "../../../harness")
15
-
16
- export async function copyContentStep(platform, ctx = {}) {
17
- header("Step 5, Copying agent-harness files")
18
-
19
- const dest = process.cwd()
20
-
21
- // Support both old single-string platform and new { backlogPlatform, repoPlatform }
22
- const backlogPlatform = typeof platform === 'object' ? platform.backlogPlatform : platform
23
- const repoPlatform = typeof platform === 'object' ? platform.repoPlatform : platform
24
-
25
- try {
26
- await copyContent(CONTENT_DIR, dest, platform, ctx)
27
-
28
- // .gitignore is stripped by npm during publish, so the source file is _gitignore.
29
- // Also, fse.copy with overwrite:false won't overwrite an existing .gitignore,
30
- // so merge manually to preserve both opencode's defaults and our additions.
31
- const srcGitignore = path.join(CONTENT_DIR, ".opencode", "_gitignore")
32
- const destGitignore = path.join(dest, ".opencode", ".gitignore")
33
- if (await fse.pathExists(srcGitignore)) {
34
- const srcLines = (await fse.readFile(srcGitignore, "utf-8")).split("\n").map(l => l.trim()).filter(Boolean)
35
- const destLines = (await fse.pathExists(destGitignore))
36
- ? (await fse.readFile(destGitignore, "utf-8")).split("\n").map(l => l.trim()).filter(Boolean)
37
- : []
38
- const merged = Array.from(new Set([...destLines, ...srcLines]))
39
- await fse.writeFile(destGitignore, `${merged.join("\n")}\n`, "utf-8")
40
- }
41
-
42
- // Remove the _gitignore template from dest: it was only needed for the merge above.
43
- const destGitignoreTemplate = path.join(dest, ".opencode", "_gitignore")
44
- if (await fse.pathExists(destGitignoreTemplate)) {
45
- await fse.remove(destGitignoreTemplate)
46
- }
47
-
48
- const rootsFile = path.join(dest, ".opencode", "source-roots.json")
49
- if (!ctx.updateMode || !await fse.pathExists(rootsFile)) {
50
- await fse.writeJson(
51
- rootsFile,
52
- {
53
- mode: ctx.sourceMode || "current",
54
- // An empty array is truthy, so `|| [dest]` never kicked in and reruns
55
- // wrote `roots: []`, leaving agents with no source scope.
56
- roots: ctx.sourceRoots?.length ? ctx.sourceRoots : [dest],
57
- },
58
- { spaces: 2 },
59
- )
60
- }
61
-
62
- await patchAgentGuidance(backlogPlatform, repoPlatform)
63
- await patchOpsReview({ backlogPlatform, repoPlatform })
64
- await patchOpsBacklog({ backlogPlatform, repoPlatform })
65
- await patchOpencodeJson()
66
- await patchOpencodePackage()
67
- if (!ctx.updateMode) await patchAgentsMd(ctx)
68
-
69
- if (!ctx.skipSkills) {
70
- await installSkills(backlogPlatform, repoPlatform, {
71
- forceOverwrite: ctx.forceOverwrite,
72
- updateMode: ctx.updateMode,
73
- })
74
- }
75
- // These patch SKILL.md files (pc-plan-archive, pc-ops-ship),
76
- // so they must run after installSkills has copied the skills into the project.
77
- await patchArchiveCommand({ backlogPlatform, repoPlatform })
78
- await patchOpsShip({ backlogPlatform, repoPlatform })
79
- await removeRetiredFiles()
80
- await generateFullstackEngineer({ updateMode: ctx.updateMode })
81
- await removeLegacyStartupDirectives()
82
- await recordManagedContent(CONTENT_DIR, dest, { updateMode: ctx.updateMode })
83
- success("Files copied to project root")
84
- } catch (err) {
85
- error(`Failed to copy content: ${err.message}`)
86
- exit(1)
87
- }
88
- }
1
+ import fse from "fs-extra"
2
+ import path from "path"
3
+ import { fileURLToPath } from "url"
4
+ import { copyContent, recordManagedContent } from "../../utils/copy.js"
5
+ import { error, header, success } from "../../utils/exec.js"
6
+ import { exit } from "../../utils/process.js"
7
+ import { patchAgentGuidance, patchAgentsMd } from "./agents.js"
8
+ import { patchArchiveCommand, patchOpsShip, patchOpsReview, patchOpsBacklog } from "./commands.js"
9
+ import { installSkills, removeRetiredFiles } from "./skills.js"
10
+ import { generateFullstackEngineer, removeLegacyStartupDirectives } from "./fullstack-engineer.js"
11
+ import { patchOpencodeJson, patchOpencodePackage } from "./opencode-json.js"
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+ const CONTENT_DIR = path.resolve(__dirname, "../../../harness")
15
+
16
+ export async function copyContentStep(platform, ctx = {}) {
17
+ header("Step 5, Copying agent-harness files")
18
+
19
+ const dest = process.cwd()
20
+
21
+ // Support both old single-string platform and new { backlogPlatform, repoPlatform }
22
+ const backlogPlatform = typeof platform === 'object' ? platform.backlogPlatform : platform
23
+ const repoPlatform = typeof platform === 'object' ? platform.repoPlatform : platform
24
+
25
+ try {
26
+ await copyContent(CONTENT_DIR, dest, platform, ctx)
27
+
28
+ // .gitignore is stripped by npm during publish, so the source file is _gitignore.
29
+ // Also, fse.copy with overwrite:false won't overwrite an existing .gitignore,
30
+ // so merge manually to preserve both opencode's defaults and our additions.
31
+ const srcGitignore = path.join(CONTENT_DIR, ".opencode", "_gitignore")
32
+ const destGitignore = path.join(dest, ".opencode", ".gitignore")
33
+ if (await fse.pathExists(srcGitignore)) {
34
+ const srcLines = (await fse.readFile(srcGitignore, "utf-8")).split("\n").map(l => l.trim()).filter(Boolean)
35
+ const destLines = (await fse.pathExists(destGitignore))
36
+ ? (await fse.readFile(destGitignore, "utf-8")).split("\n").map(l => l.trim()).filter(Boolean)
37
+ : []
38
+ const merged = Array.from(new Set([...destLines, ...srcLines]))
39
+ await fse.writeFile(destGitignore, `${merged.join("\n")}\n`, "utf-8")
40
+ }
41
+
42
+ // Remove the _gitignore template from dest: it was only needed for the merge above.
43
+ const destGitignoreTemplate = path.join(dest, ".opencode", "_gitignore")
44
+ if (await fse.pathExists(destGitignoreTemplate)) {
45
+ await fse.remove(destGitignoreTemplate)
46
+ }
47
+
48
+ const rootsFile = path.join(dest, ".opencode", "source-roots.json")
49
+ if (!ctx.updateMode || !await fse.pathExists(rootsFile)) {
50
+ await fse.writeJson(
51
+ rootsFile,
52
+ {
53
+ mode: ctx.sourceMode || "current",
54
+ // An empty array is truthy, so `|| [dest]` never kicked in and reruns
55
+ // wrote `roots: []`, leaving agents with no source scope.
56
+ roots: ctx.sourceRoots?.length ? ctx.sourceRoots : [dest],
57
+ },
58
+ { spaces: 2 },
59
+ )
60
+ }
61
+
62
+ await patchAgentGuidance(backlogPlatform, repoPlatform)
63
+ await patchOpsReview({ backlogPlatform, repoPlatform })
64
+ await patchOpsBacklog({ backlogPlatform, repoPlatform })
65
+ await patchOpencodeJson()
66
+ await patchOpencodePackage()
67
+ if (!ctx.updateMode) await patchAgentsMd(ctx)
68
+
69
+ if (!ctx.skipSkills) {
70
+ await installSkills(backlogPlatform, repoPlatform, {
71
+ forceOverwrite: ctx.forceOverwrite,
72
+ updateMode: ctx.updateMode,
73
+ })
74
+ }
75
+ // These patch SKILL.md files (pc-plan-archive, pc-ops-ship),
76
+ // so they must run after installSkills has copied the skills into the project.
77
+ await patchArchiveCommand({ backlogPlatform, repoPlatform })
78
+ await patchOpsShip({ backlogPlatform, repoPlatform })
79
+ await removeRetiredFiles()
80
+ await generateFullstackEngineer({ updateMode: ctx.updateMode })
81
+ await removeLegacyStartupDirectives()
82
+ await recordManagedContent(CONTENT_DIR, dest, { updateMode: ctx.updateMode })
83
+ success("Files copied to project root")
84
+ } catch (err) {
85
+ error(`Failed to copy content: ${err.message}`)
86
+ exit(1)
87
+ }
88
+ }