@plainconceptsplatform/agent-harness 3.9.0 → 3.11.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 (145) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -551
  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 +52 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +176 -176
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +81 -81
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +359 -324
  42. package/cli/steps/copy/skills.js +353 -316
  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 -141
  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 -91
  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 +82 -82
  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 +12 -12
  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 +10 -10
  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 +7 -7
  81. package/harness/.opencode/commands/plan-explore.md +11 -11
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-mockup.md +11 -0
  84. package/harness/.opencode/commands/plan-propose.md +11 -11
  85. package/harness/.opencode/commands/plan-quick.md +7 -7
  86. package/harness/.opencode/commands/plan-story.md +11 -11
  87. package/harness/.opencode/commands/repo-audit.md +7 -7
  88. package/harness/.opencode/commands/repo-help.md +5 -5
  89. package/harness/.opencode/commands/repo-initialize.md +5 -5
  90. package/harness/.opencode/commands/repo-onboard.md +5 -5
  91. package/harness/.opencode/commands/repo-verify.md +5 -5
  92. package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
  93. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  94. package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
  95. package/harness/.opencode/scripts/check-diff.mjs +305 -305
  96. package/harness/.opencode/scripts/code-rules.mjs +288 -288
  97. package/harness/.opencode/scripts/platform-contract.mjs +366 -366
  98. package/harness/AGENTS.md +55 -55
  99. package/harness/ARCHITECTURE.md +13 -13
  100. package/harness/DESIGN.md +13 -13
  101. package/harness/opencode.jsonc +37 -36
  102. package/harness/openspec/config.yaml +20 -20
  103. package/harness/skills-lock.json +23 -17
  104. package/package.json +14 -12
  105. package/skills/README.md +18 -18
  106. package/skills/agent-harness-cli/SKILL.md +86 -86
  107. package/skills/browser-automation/SKILL.md +100 -72
  108. package/skills/pc-guardrails-generic/SKILL.md +53 -53
  109. package/skills/pc-guardrails-project/SKILL.md +8 -8
  110. package/skills/pc-make-architecture/SKILL.md +33 -33
  111. package/skills/pc-make-architecture/structure-template.md +64 -64
  112. package/skills/pc-make-design/SKILL.md +45 -45
  113. package/skills/pc-make-engineer/SKILL.md +59 -59
  114. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  115. package/skills/pc-make-engineer/template.md +36 -36
  116. package/skills/pc-make-guardrails/SKILL.md +44 -44
  117. package/skills/pc-make-guardrails/category-reference.md +112 -112
  118. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  119. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  120. package/skills/pc-make-user-model/SKILL.md +56 -56
  121. package/skills/pc-ops-ship/SKILL.md +22 -22
  122. package/skills/pc-plan-apply/SKILL.md +92 -90
  123. package/skills/pc-plan-apply/simple-mode.md +21 -21
  124. package/skills/pc-plan-archive/SKILL.md +66 -66
  125. package/skills/pc-plan-explore/SKILL.md +22 -21
  126. package/skills/pc-plan-goal/SKILL.md +91 -91
  127. package/skills/pc-plan-goal/branching.md +39 -39
  128. package/skills/pc-plan-goal/failure-policy.md +30 -30
  129. package/skills/pc-plan-goal/output-mode.md +10 -10
  130. package/skills/pc-plan-goal/output.md +71 -71
  131. package/skills/pc-plan-mockup/SKILL.md +85 -0
  132. package/skills/pc-plan-mockup/mockup-contract.md +65 -0
  133. package/skills/pc-plan-propose/SKILL.md +127 -127
  134. package/skills/pc-plan-propose/task-annotation.md +39 -39
  135. package/skills/pc-plan-quick/SKILL.md +46 -46
  136. package/skills/pc-plan-story/SKILL.md +96 -93
  137. package/skills/pc-repo-audit/SKILL.md +44 -44
  138. package/skills/pc-repo-help/SKILL.md +90 -87
  139. package/skills/pc-repo-initialize/SKILL.md +119 -119
  140. package/skills/pc-repo-onboard/SKILL.md +32 -32
  141. package/skills/pc-repo-verify/SKILL.md +150 -150
  142. package/skills/pc-userstory-az/SKILL.md +71 -71
  143. package/skills/pc-userstory-browser/SKILL.md +73 -64
  144. package/skills/pc-userstory-gh/SKILL.md +63 -63
  145. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,111 +1,111 @@
1
- /**
2
- * Deterministic colour for an agent, derived from its name.
3
- *
4
- * opencode accepts either a hex value or one of its theme keywords. Two of
5
- * those keywords are reserved for the primaries so they stay recognisable
6
- * across every project: build is `primary`, plan is `warning`. Every other
7
- * agent gets a hex derived from its name, so a `butterfly-engineer` is the
8
- * same colour in every repository that has one, with no central registry and
9
- * no chance of two agents in one project colliding by accident.
10
- *
11
- * Saturation and lightness are fixed rather than hashed. That is what keeps
12
- * the result usable: hashing them too would eventually produce near-grey,
13
- * near-black or near-white agents that are invisible against one theme or the
14
- * other. Only the hue varies.
15
- */
16
-
17
- /** opencode's theme keywords. Treated as unset so a name-derived hex wins. */
18
- export const THEME_COLORS = new Set([
19
- 'primary',
20
- 'secondary',
21
- 'accent',
22
- 'success',
23
- 'warning',
24
- 'error',
25
- 'info',
26
- ])
27
-
28
- /** Reserved: the two primaries, which never take a derived colour. */
29
- export const RESERVED_COLORS = { build: 'primary', plan: 'warning' }
30
-
31
- const SATURATION = 68
32
- const LIGHTNESS = 52
33
-
34
- /**
35
- * FNV-1a, 32 bit. Chosen because it is short enough to duplicate verbatim in
36
- * the shipped plugin and produces the same hue everywhere: the same name must
37
- * resolve to the same colour on every machine and in every project.
38
- */
39
- function hash(value) {
40
- let h = 0x811c9dc5
41
- for (let i = 0; i < value.length; i++) {
42
- h ^= value.charCodeAt(i)
43
- h = Math.imul(h, 0x01000193) >>> 0
44
- }
45
- return h >>> 0
46
- }
47
-
48
- function hslToHex(h, s, l) {
49
- const sat = s / 100
50
- const light = l / 100
51
- const c = (1 - Math.abs(2 * light - 1)) * sat
52
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
53
- const m = light - c / 2
54
-
55
- const [r, g, b] =
56
- h < 60 ? [c, x, 0] :
57
- h < 120 ? [x, c, 0] :
58
- h < 180 ? [0, c, x] :
59
- h < 240 ? [0, x, c] :
60
- h < 300 ? [x, 0, c] :
61
- [c, 0, x]
62
-
63
- const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, '0')
64
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
65
- }
66
-
67
- /** The colour for an agent, honouring the two reserved primaries. */
68
- export function agentColor(name) {
69
- if (name in RESERVED_COLORS) return RESERVED_COLORS[name]
70
- return hslToHex(hash(name) % 360, SATURATION, LIGHTNESS)
71
- }
72
-
73
- /**
74
- * Render a colour for YAML frontmatter.
75
- *
76
- * A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
77
- * parses as null and opencode rejects the agent outright with "Invalid input
78
- * color". Theme keywords need no quoting and are left bare.
79
- */
80
- export function yamlColor(value) {
81
- return value.startsWith('#') ? `"${value}"` : value
82
- }
83
-
84
- /** The value of a frontmatter colour, with any quoting removed. */
85
- export function unquoteColor(raw) {
86
- return (raw ?? '').trim().replace(/^["']/, '').replace(/["']$/, '')
87
- }
88
-
89
- /**
90
- * The frontmatter colour line an agent should have, or null when the current
91
- * one is already correct. Covers both jobs: choosing the colour, and quoting it
92
- * so the file actually parses.
93
- */
94
- export function colorLineFor(name, rawCurrent) {
95
- const current = unquoteColor(rawCurrent)
96
- const target = shouldDeriveColor(current) ? agentColor(name) : current
97
- const rendered = yamlColor(target)
98
- return (rawCurrent ?? '').trim() === rendered ? null : `color: ${rendered}`
99
- }
100
-
101
- /**
102
- * Whether a frontmatter colour should be replaced by the derived one.
103
- *
104
- * A theme keyword is replaced: those came from the old template, which asked
105
- * whoever ran /make-engineer to pick one and avoid collisions by hand. An
106
- * explicit hex is left alone, because someone chose it deliberately.
107
- */
108
- export function shouldDeriveColor(current) {
109
- if (!current) return true
110
- return THEME_COLORS.has(current.trim().toLowerCase())
111
- }
1
+ /**
2
+ * Deterministic colour for an agent, derived from its name.
3
+ *
4
+ * opencode accepts either a hex value or one of its theme keywords. Two of
5
+ * those keywords are reserved for the primaries so they stay recognisable
6
+ * across every project: build is `primary`, plan is `warning`. Every other
7
+ * agent gets a hex derived from its name, so a `butterfly-engineer` is the
8
+ * same colour in every repository that has one, with no central registry and
9
+ * no chance of two agents in one project colliding by accident.
10
+ *
11
+ * Saturation and lightness are fixed rather than hashed. That is what keeps
12
+ * the result usable: hashing them too would eventually produce near-grey,
13
+ * near-black or near-white agents that are invisible against one theme or the
14
+ * other. Only the hue varies.
15
+ */
16
+
17
+ /** opencode's theme keywords. Treated as unset so a name-derived hex wins. */
18
+ export const THEME_COLORS = new Set([
19
+ 'primary',
20
+ 'secondary',
21
+ 'accent',
22
+ 'success',
23
+ 'warning',
24
+ 'error',
25
+ 'info',
26
+ ])
27
+
28
+ /** Reserved: the two primaries, which never take a derived colour. */
29
+ export const RESERVED_COLORS = { build: 'primary', plan: 'warning' }
30
+
31
+ const SATURATION = 68
32
+ const LIGHTNESS = 52
33
+
34
+ /**
35
+ * FNV-1a, 32 bit. Chosen because it is short enough to duplicate verbatim in
36
+ * the shipped plugin and produces the same hue everywhere: the same name must
37
+ * resolve to the same colour on every machine and in every project.
38
+ */
39
+ function hash(value) {
40
+ let h = 0x811c9dc5
41
+ for (let i = 0; i < value.length; i++) {
42
+ h ^= value.charCodeAt(i)
43
+ h = Math.imul(h, 0x01000193) >>> 0
44
+ }
45
+ return h >>> 0
46
+ }
47
+
48
+ function hslToHex(h, s, l) {
49
+ const sat = s / 100
50
+ const light = l / 100
51
+ const c = (1 - Math.abs(2 * light - 1)) * sat
52
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
53
+ const m = light - c / 2
54
+
55
+ const [r, g, b] =
56
+ h < 60 ? [c, x, 0] :
57
+ h < 120 ? [x, c, 0] :
58
+ h < 180 ? [0, c, x] :
59
+ h < 240 ? [0, x, c] :
60
+ h < 300 ? [x, 0, c] :
61
+ [c, 0, x]
62
+
63
+ const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, '0')
64
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
65
+ }
66
+
67
+ /** The colour for an agent, honouring the two reserved primaries. */
68
+ export function agentColor(name) {
69
+ if (name in RESERVED_COLORS) return RESERVED_COLORS[name]
70
+ return hslToHex(hash(name) % 360, SATURATION, LIGHTNESS)
71
+ }
72
+
73
+ /**
74
+ * Render a colour for YAML frontmatter.
75
+ *
76
+ * A hex has to be quoted. `#` opens a comment in YAML, so `color: #D83155`
77
+ * parses as null and opencode rejects the agent outright with "Invalid input
78
+ * color". Theme keywords need no quoting and are left bare.
79
+ */
80
+ export function yamlColor(value) {
81
+ return value.startsWith('#') ? `"${value}"` : value
82
+ }
83
+
84
+ /** The value of a frontmatter colour, with any quoting removed. */
85
+ export function unquoteColor(raw) {
86
+ return (raw ?? '').trim().replace(/^["']/, '').replace(/["']$/, '')
87
+ }
88
+
89
+ /**
90
+ * The frontmatter colour line an agent should have, or null when the current
91
+ * one is already correct. Covers both jobs: choosing the colour, and quoting it
92
+ * so the file actually parses.
93
+ */
94
+ export function colorLineFor(name, rawCurrent) {
95
+ const current = unquoteColor(rawCurrent)
96
+ const target = shouldDeriveColor(current) ? agentColor(name) : current
97
+ const rendered = yamlColor(target)
98
+ return (rawCurrent ?? '').trim() === rendered ? null : `color: ${rendered}`
99
+ }
100
+
101
+ /**
102
+ * Whether a frontmatter colour should be replaced by the derived one.
103
+ *
104
+ * A theme keyword is replaced: those came from the old template, which asked
105
+ * whoever ran /make-engineer to pick one and avoid collisions by hand. An
106
+ * explicit hex is left alone, because someone chose it deliberately.
107
+ */
108
+ export function shouldDeriveColor(current) {
109
+ if (!current) return true
110
+ return THEME_COLORS.has(current.trim().toLowerCase())
111
+ }
package/cli/utils/copy.js CHANGED
@@ -1,134 +1,134 @@
1
- import fse from 'fs-extra'
2
- import path from 'path'
3
- import { canUpdateManagedFile, hashComparableFile, readUpdateManifest, recordManagedFile, writeUpdateManifest } from './update-manifest.js'
4
-
5
- // Folders never copied (skills handled separately by installSkills, .bootstrap is internal tooling)
6
- const ALWAYS_EXCLUDE = ['.bootstrap', 'skills', 'node_modules']
7
-
8
- // Files never overwritten even with forceOverwrite: user owns these.
9
- // The CLI ships templates, but init / the wizard / the user populate them
10
- // with project-specific content. Overwriting on update would destroy user work.
11
- const NEVER_OVERWRITE = [
12
- `openspec${path.sep}config.yaml`,
13
- 'opencode.jsonc',
14
- ]
15
-
16
- const MARKER_COMMANDS = new Set(['.opencode/commands/ops-review.md', '.opencode/commands/ops-backlog.md'])
17
-
18
- // Where a project may legitimately keep the doc instead of the repository root.
19
- // Foundations puts both under ai/, and projects that follow it point their own
20
- // AGENTS.md there.
21
- const ROOT_DOCS = new Set(['ARCHITECTURE.md', 'DESIGN.md'])
22
- const ALTERNATE_DOC_DIRS = ['ai', 'docs']
23
-
24
- // Seeding the "NOT GENERATED YET, run /make-architecture" placeholder next to a
25
- // real architecture document is worse than seeding nothing: pc-make-guardrails
26
- // reads the root file as its primary source, and an agent that finds the
27
- // placeholder there concludes the project has no architecture written down.
28
- async function documentedElsewhere(destDir, fileName) {
29
- if (!ROOT_DOCS.has(fileName)) return false
30
- for (const directory of ALTERNATE_DOC_DIRS) {
31
- if (await fse.pathExists(path.join(destDir, directory, fileName))) return true
32
- }
33
- return false
34
- }
35
-
36
- const MANAGED_DIRS = ['.opencode/plugins/', '.opencode/scripts/']
37
-
38
- function isTemplateTest(relativePath) {
39
- const normalized = relativePath.split(path.sep).join('/')
40
- return MANAGED_DIRS.some(dir => normalized.startsWith(dir)) && normalized.endsWith('.test.js')
41
- }
42
-
43
- export function isManagedContentPath(relativePath) {
44
- const normalized = relativePath.split(path.sep).join('/')
45
- if (MANAGED_DIRS.some(dir => normalized.startsWith(dir))) return true
46
- if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
47
- return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
48
- }
49
-
50
- /**
51
- * Copy content/ directory to destination.
52
- * Excludes:
53
- * - .agents/skills and .opencode/skills (handled separately)
54
- * - .bootstrap (internal tooling)
55
- * - node_modules
56
- * - DESIGN.md and ARCHITECTURE.md if ctx says they already exist (preserve user's files)
57
- * @param {string} contentDir - absolute path to content/
58
- * @param {string} destDir - absolute path to destination (project root)
59
- * @param {'azure'|'github'} platform
60
- * @param {{ hasDesign?: boolean, hasArchitecture?: boolean, updateMode?: boolean }} ctx
61
- */
62
- export async function copyContent(contentDir, destDir, platform, ctx = {}) {
63
- const manifest = ctx.updateMode ? await readUpdateManifest(destDir) : null
64
- await fse.copy(contentDir, destDir, {
65
- overwrite: ctx.updateMode || ctx.forceOverwrite || false,
66
- filter: async (src) => {
67
- const rel = path.relative(contentDir, src)
68
- const parts = rel.split(path.sep)
69
- if (parts.some(part => ALWAYS_EXCLUDE.includes(part))) return false
70
- if (isTemplateTest(rel)) return false
71
- if (ctx.hasDesign && rel === 'DESIGN.md') return false
72
- if (ctx.hasArchitecture && rel === 'ARCHITECTURE.md') return false
73
- if (await documentedElsewhere(destDir, rel)) return false
74
- // User-owned config files are never overwritten, even with forceOverwrite.
75
- // The update command calls writeModelsToConfigs separately to set the
76
- // model field in opencode.jsonc without destroying user additions.
77
- if (NEVER_OVERWRITE.includes(rel)) return false
78
- if (!ctx.updateMode) return true
79
-
80
- const stat = await fse.stat(src)
81
- if (stat.isDirectory()) return true
82
- if (!isManagedContentPath(rel)) {
83
- return !(await fse.pathExists(path.join(destDir, rel)))
84
- }
85
- return canUpdateManagedFile(rel, destDir, manifest)
86
- },
87
- })
88
- }
89
-
90
- export async function recordManagedContent(contentDir, destDir, { updateMode = false } = {}) {
91
- const manifest = await readUpdateManifest(destDir)
92
-
93
- async function walk(directory) {
94
- for (const entry of await fse.readdir(directory, { withFileTypes: true })) {
95
- const sourcePath = path.join(directory, entry.name)
96
- const relativePath = path.relative(contentDir, sourcePath)
97
- if (entry.isDirectory()) {
98
- await walk(sourcePath)
99
- continue
100
- }
101
- if (isTemplateTest(relativePath)) continue
102
- if (!isManagedContentPath(relativePath)) continue
103
-
104
- const destinationPath = path.join(destDir, relativePath)
105
- if (!await fse.pathExists(destinationPath)) continue
106
- // Comparable, not raw: this runs after the patchers, so a managed file's
107
- // destination never equals its source byte for byte once anything has
108
- // been injected into a marker pair. Comparing raw hashes here meant no
109
- // entry was ever recorded for those files, which left them permanently
110
- // indistinguishable from files a project had edited by hand.
111
- const sourceHash = await hashComparableFile(sourcePath)
112
- const destinationHash = await hashComparableFile(destinationPath)
113
- const manifestPath = relativePath.split(path.sep).join('/')
114
- const previousHash = manifest.files?.[manifestPath]
115
- if (!updateMode || destinationHash === sourceHash || destinationHash === previousHash) {
116
- await recordManagedFile(manifest, relativePath, sourcePath)
117
- }
118
- }
119
- }
120
-
121
- await walk(contentDir)
122
- await writeUpdateManifest(manifest, destDir)
123
- }
124
-
125
- export async function findAiFiles(dir, files) {
126
- const found = []
127
- for (const file of files) {
128
- const fullPath = path.join(dir, file)
129
- if (await fse.pathExists(fullPath)) {
130
- found.push(fullPath)
131
- }
132
- }
133
- return found
134
- }
1
+ import fse from 'fs-extra'
2
+ import path from 'path'
3
+ import { canUpdateManagedFile, hashComparableFile, readUpdateManifest, recordManagedFile, writeUpdateManifest } from './update-manifest.js'
4
+
5
+ // Folders never copied (skills handled separately by installSkills, .bootstrap is internal tooling)
6
+ const ALWAYS_EXCLUDE = ['.bootstrap', 'skills', 'node_modules']
7
+
8
+ // Files never overwritten even with forceOverwrite: user owns these.
9
+ // The CLI ships templates, but init / the wizard / the user populate them
10
+ // with project-specific content. Overwriting on update would destroy user work.
11
+ const NEVER_OVERWRITE = [
12
+ `openspec${path.sep}config.yaml`,
13
+ 'opencode.jsonc',
14
+ ]
15
+
16
+ const MARKER_COMMANDS = new Set(['.opencode/commands/ops-review.md', '.opencode/commands/ops-backlog.md'])
17
+
18
+ // Where a project may legitimately keep the doc instead of the repository root.
19
+ // Foundations puts both under ai/, and projects that follow it point their own
20
+ // AGENTS.md there.
21
+ const ROOT_DOCS = new Set(['ARCHITECTURE.md', 'DESIGN.md'])
22
+ const ALTERNATE_DOC_DIRS = ['ai', 'docs']
23
+
24
+ // Seeding the "NOT GENERATED YET, run /make-architecture" placeholder next to a
25
+ // real architecture document is worse than seeding nothing: pc-make-guardrails
26
+ // reads the root file as its primary source, and an agent that finds the
27
+ // placeholder there concludes the project has no architecture written down.
28
+ async function documentedElsewhere(destDir, fileName) {
29
+ if (!ROOT_DOCS.has(fileName)) return false
30
+ for (const directory of ALTERNATE_DOC_DIRS) {
31
+ if (await fse.pathExists(path.join(destDir, directory, fileName))) return true
32
+ }
33
+ return false
34
+ }
35
+
36
+ const MANAGED_DIRS = ['.opencode/plugins/', '.opencode/scripts/']
37
+
38
+ function isTemplateTest(relativePath) {
39
+ const normalized = relativePath.split(path.sep).join('/')
40
+ return MANAGED_DIRS.some(dir => normalized.startsWith(dir)) && normalized.endsWith('.test.js')
41
+ }
42
+
43
+ export function isManagedContentPath(relativePath) {
44
+ const normalized = relativePath.split(path.sep).join('/')
45
+ if (MANAGED_DIRS.some(dir => normalized.startsWith(dir))) return true
46
+ if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
47
+ return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
48
+ }
49
+
50
+ /**
51
+ * Copy content/ directory to destination.
52
+ * Excludes:
53
+ * - .agents/skills and .opencode/skills (handled separately)
54
+ * - .bootstrap (internal tooling)
55
+ * - node_modules
56
+ * - DESIGN.md and ARCHITECTURE.md if ctx says they already exist (preserve user's files)
57
+ * @param {string} contentDir - absolute path to content/
58
+ * @param {string} destDir - absolute path to destination (project root)
59
+ * @param {'azure'|'github'} platform
60
+ * @param {{ hasDesign?: boolean, hasArchitecture?: boolean, updateMode?: boolean }} ctx
61
+ */
62
+ export async function copyContent(contentDir, destDir, platform, ctx = {}) {
63
+ const manifest = ctx.updateMode ? await readUpdateManifest(destDir) : null
64
+ await fse.copy(contentDir, destDir, {
65
+ overwrite: ctx.updateMode || ctx.forceOverwrite || false,
66
+ filter: async (src) => {
67
+ const rel = path.relative(contentDir, src)
68
+ const parts = rel.split(path.sep)
69
+ if (parts.some(part => ALWAYS_EXCLUDE.includes(part))) return false
70
+ if (isTemplateTest(rel)) return false
71
+ if (ctx.hasDesign && rel === 'DESIGN.md') return false
72
+ if (ctx.hasArchitecture && rel === 'ARCHITECTURE.md') return false
73
+ if (await documentedElsewhere(destDir, rel)) return false
74
+ // User-owned config files are never overwritten, even with forceOverwrite.
75
+ // The update command calls writeModelsToConfigs separately to set the
76
+ // model field in opencode.jsonc without destroying user additions.
77
+ if (NEVER_OVERWRITE.includes(rel)) return false
78
+ if (!ctx.updateMode) return true
79
+
80
+ const stat = await fse.stat(src)
81
+ if (stat.isDirectory()) return true
82
+ if (!isManagedContentPath(rel)) {
83
+ return !(await fse.pathExists(path.join(destDir, rel)))
84
+ }
85
+ return canUpdateManagedFile(rel, destDir, manifest)
86
+ },
87
+ })
88
+ }
89
+
90
+ export async function recordManagedContent(contentDir, destDir, { updateMode = false } = {}) {
91
+ const manifest = await readUpdateManifest(destDir)
92
+
93
+ async function walk(directory) {
94
+ for (const entry of await fse.readdir(directory, { withFileTypes: true })) {
95
+ const sourcePath = path.join(directory, entry.name)
96
+ const relativePath = path.relative(contentDir, sourcePath)
97
+ if (entry.isDirectory()) {
98
+ await walk(sourcePath)
99
+ continue
100
+ }
101
+ if (isTemplateTest(relativePath)) continue
102
+ if (!isManagedContentPath(relativePath)) continue
103
+
104
+ const destinationPath = path.join(destDir, relativePath)
105
+ if (!await fse.pathExists(destinationPath)) continue
106
+ // Comparable, not raw: this runs after the patchers, so a managed file's
107
+ // destination never equals its source byte for byte once anything has
108
+ // been injected into a marker pair. Comparing raw hashes here meant no
109
+ // entry was ever recorded for those files, which left them permanently
110
+ // indistinguishable from files a project had edited by hand.
111
+ const sourceHash = await hashComparableFile(sourcePath)
112
+ const destinationHash = await hashComparableFile(destinationPath)
113
+ const manifestPath = relativePath.split(path.sep).join('/')
114
+ const previousHash = manifest.files?.[manifestPath]
115
+ if (!updateMode || destinationHash === sourceHash || destinationHash === previousHash) {
116
+ await recordManagedFile(manifest, relativePath, sourcePath)
117
+ }
118
+ }
119
+ }
120
+
121
+ await walk(contentDir)
122
+ await writeUpdateManifest(manifest, destDir)
123
+ }
124
+
125
+ export async function findAiFiles(dir, files) {
126
+ const found = []
127
+ for (const file of files) {
128
+ const fullPath = path.join(dir, file)
129
+ if (await fse.pathExists(fullPath)) {
130
+ found.push(fullPath)
131
+ }
132
+ }
133
+ return found
134
+ }
@@ -1,47 +1,47 @@
1
- import chalk from 'chalk'
2
- import ora from 'ora'
3
-
4
- // ── Screen / step state ──────────────────────────────────────────────────────
5
-
6
- const previousSteps = []; // up to 2 completed steps, each is an array of lines
7
- let currentStepLines = []; // lines accumulated in the current step
8
- let stepSpinner = null; // ora spinner shown while step is working
9
-
10
- export function appendLine(line) {
11
- currentStepLines.push(line);
12
- }
13
-
14
- export function stopSpinner() {
15
- if (stepSpinner) {
16
- stepSpinner.stop();
17
- stepSpinner = null;
18
- }
19
- }
20
-
21
- export function startSpinner(text = 'working...') {
22
- stopSpinner();
23
- stepSpinner = ora({ text: chalk.dim(text), color: 'red' }).start();
24
- }
25
-
26
- export function redraw() {
27
- if (process.stdout.isTTY) console.clear();
28
-
29
- // Show up to 2 previous steps dimmed
30
- for (const stepLines of previousSteps) {
31
- for (const line of stepLines) {
32
- process.stdout.write(`${chalk.dim(line)}\n`);
33
- }
34
- process.stdout.write('\n');
35
- }
36
-
37
- // Current step output
38
- for (const line of currentStepLines) {
39
- process.stdout.write(`${line}\n`);
40
- }
41
- }
42
-
43
- export function rotateStep() {
44
- previousSteps.push(currentStepLines);
45
- if (previousSteps.length > 2) previousSteps.shift();
46
- currentStepLines = [];
47
- }
1
+ import chalk from 'chalk'
2
+ import ora from 'ora'
3
+
4
+ // ── Screen / step state ──────────────────────────────────────────────────────
5
+
6
+ const previousSteps = []; // up to 2 completed steps, each is an array of lines
7
+ let currentStepLines = []; // lines accumulated in the current step
8
+ let stepSpinner = null; // ora spinner shown while step is working
9
+
10
+ export function appendLine(line) {
11
+ currentStepLines.push(line);
12
+ }
13
+
14
+ export function stopSpinner() {
15
+ if (stepSpinner) {
16
+ stepSpinner.stop();
17
+ stepSpinner = null;
18
+ }
19
+ }
20
+
21
+ export function startSpinner(text = 'working...') {
22
+ stopSpinner();
23
+ stepSpinner = ora({ text: chalk.dim(text), color: 'red' }).start();
24
+ }
25
+
26
+ export function redraw() {
27
+ if (process.stdout.isTTY) console.clear();
28
+
29
+ // Show up to 2 previous steps dimmed
30
+ for (const stepLines of previousSteps) {
31
+ for (const line of stepLines) {
32
+ process.stdout.write(`${chalk.dim(line)}\n`);
33
+ }
34
+ process.stdout.write('\n');
35
+ }
36
+
37
+ // Current step output
38
+ for (const line of currentStepLines) {
39
+ process.stdout.write(`${line}\n`);
40
+ }
41
+ }
42
+
43
+ export function rotateStep() {
44
+ previousSteps.push(currentStepLines);
45
+ if (previousSteps.length > 2) previousSteps.shift();
46
+ currentStepLines = [];
47
+ }