create-harness-vibe-coding 0.8.13 → 0.8.17

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 (133) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README-CN.md +110 -107
  3. package/README.md +93 -44
  4. package/package.json +2 -1
  5. package/src/generator.js +609 -489
  6. package/src/index.js +5 -5
  7. package/templates/common/.claude/agents/architect-manager.md +1 -0
  8. package/templates/common/.claude/agents/architect.md +2 -1
  9. package/templates/common/.claude/agents/codebase-explorer.md +1 -0
  10. package/templates/common/.claude/agents/context-master.md +2 -1
  11. package/templates/common/.claude/agents/debugger.md +1 -0
  12. package/templates/common/.claude/agents/docs-researcher.md +2 -1
  13. package/templates/common/.claude/agents/explore-manager.md +1 -0
  14. package/templates/common/.claude/agents/implement-manager.md +1 -0
  15. package/templates/common/.claude/agents/implementer.md +1 -0
  16. package/templates/common/.claude/agents/memory-master.md +2 -1
  17. package/templates/common/.claude/agents/planner.md +3 -2
  18. package/templates/common/.claude/agents/reflector.md +1 -0
  19. package/templates/common/.claude/agents/researcher.md +1 -0
  20. package/templates/common/.claude/agents/review-manager.md +1 -0
  21. package/templates/common/.claude/agents/reviewer.md +2 -1
  22. package/templates/common/.claude/agents/task-scribe.md +1 -0
  23. package/templates/common/.claude/agents/tdd-guide.md +5 -4
  24. package/templates/common/.claude/agents/test-writer.md +6 -5
  25. package/templates/common/.claude/agents/verifier.md +1 -0
  26. package/templates/common/.claude/commands/wf-auto-spark.md +16 -0
  27. package/templates/common/.claude/commands/wf-auto.md +16 -0
  28. package/templates/{optional/skills/browser-e2e/.opencode → common/.claude}/commands/wf-browser.md +3 -2
  29. package/templates/common/.claude/commands/wf-help.md +9 -5
  30. package/templates/common/.claude/commands/wf-learn.md +16 -0
  31. package/templates/common/.claude/commands/wf-max.md +20 -0
  32. package/templates/common/.claude/commands/wf-readme.md +16 -0
  33. package/templates/common/.claude/commands/wf-remove.md +16 -0
  34. package/templates/common/.claude/commands/wf-review.md +16 -0
  35. package/templates/common/.claude/commands/wf-update.md +68 -10
  36. package/templates/common/.claude/commands/wf.md +16 -0
  37. package/templates/common/.claude/rules/ecc/common.md +6 -5
  38. package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +12 -6
  39. package/templates/common/.claude/skills/tdd/SKILL.md +5 -5
  40. package/templates/common/.claude/skills/wf/SKILL.md +13 -5
  41. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +119 -0
  42. package/templates/common/.claude/skills/wf-auto/SKILL.md +27 -7
  43. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +12 -5
  44. package/templates/common/.claude/skills/wf-browser/SKILL.md +176 -0
  45. package/templates/common/.claude/skills/wf-help/SKILL.md +30 -0
  46. package/templates/common/.claude/skills/wf-learn/SKILL.md +6 -0
  47. package/templates/common/.claude/skills/wf-max/SKILL.md +43 -14
  48. package/templates/common/.claude/skills/wf-readme/SKILL.md +8 -2
  49. package/templates/common/.claude/skills/wf-remove/SKILL.md +6 -0
  50. package/templates/common/.claude/skills/wf-review/SKILL.md +10 -3
  51. package/templates/common/.claude/skills/wf-update/SKILL.md +41 -4
  52. package/templates/common/.codex/config.toml +3 -5
  53. package/templates/common/.harness-version +326 -131
  54. package/templates/common/.opencode/agents/architect-manager.md +8 -1
  55. package/templates/common/.opencode/agents/architect.md +2 -1
  56. package/templates/common/.opencode/agents/codebase-explorer.md +1 -0
  57. package/templates/common/.opencode/agents/context-master.md +2 -1
  58. package/templates/common/.opencode/agents/debugger.md +1 -0
  59. package/templates/common/.opencode/agents/docs-researcher.md +2 -1
  60. package/templates/common/.opencode/agents/explore-manager.md +11 -1
  61. package/templates/common/.opencode/agents/implement-manager.md +6 -3
  62. package/templates/common/.opencode/agents/implementer.md +1 -0
  63. package/templates/common/.opencode/agents/memory-master.md +2 -1
  64. package/templates/common/.opencode/agents/planner.md +3 -2
  65. package/templates/common/.opencode/agents/reflector.md +1 -0
  66. package/templates/common/.opencode/agents/researcher.md +1 -0
  67. package/templates/common/.opencode/agents/review-manager.md +6 -2
  68. package/templates/common/.opencode/agents/reviewer.md +2 -1
  69. package/templates/common/.opencode/agents/task-scribe.md +1 -0
  70. package/templates/common/.opencode/agents/tdd-guide.md +5 -4
  71. package/templates/common/.opencode/agents/test-writer.md +6 -5
  72. package/templates/common/.opencode/agents/verifier.md +1 -0
  73. package/templates/common/.opencode/commands/wf-auto-spark.md +3 -2
  74. package/templates/common/.opencode/commands/wf-auto.md +3 -2
  75. package/templates/common/.opencode/commands/wf-browser.md +16 -0
  76. package/templates/common/.opencode/commands/wf-help.md +9 -5
  77. package/templates/common/.opencode/commands/wf-learn.md +3 -2
  78. package/templates/common/.opencode/commands/wf-max.md +12 -6
  79. package/templates/common/.opencode/commands/wf-readme.md +3 -2
  80. package/templates/common/.opencode/commands/wf-remove.md +3 -2
  81. package/templates/common/.opencode/commands/wf-review.md +3 -2
  82. package/templates/common/.opencode/commands/wf-update.md +68 -10
  83. package/templates/common/.opencode/commands/wf.md +3 -2
  84. package/templates/common/CLAUDE.md +15 -13
  85. package/templates/common/Harness/MEMORY.md +24 -21
  86. package/templates/common/Harness/README.md +50 -47
  87. package/templates/common/Harness/ownership.manifest.json +878 -0
  88. package/templates/common/Harness/{architecture.md → project/architecture.md} +1 -1
  89. package/templates/common/Harness/research/README.md +3 -3
  90. package/templates/common/Harness/scripts/archive-tasks.mjs +12 -220
  91. package/templates/common/Harness/scripts/context-budget.mjs +95 -0
  92. package/templates/common/Harness/scripts/l2-cache-telemetry.mjs +703 -0
  93. package/templates/common/Harness/scripts/scan-clean.mjs +16 -12
  94. package/templates/common/Harness/scripts/task-state.mjs +889 -0
  95. package/templates/common/Harness/scripts/validate-harness.mjs +553 -191
  96. package/templates/common/Harness/scripts/wf-remove.mjs +61 -35
  97. package/templates/common/Harness/scripts/wf-update-check.mjs +358 -45
  98. package/templates/common/Harness/settings.json +43 -0
  99. package/templates/common/Harness/{ECC-GUIDE.md → specs/guides/ECC-GUIDE.md} +4 -4
  100. package/templates/common/Harness/{SETUP.md → specs/guides/SETUP.md} +34 -37
  101. package/templates/common/Harness/{extension.md → specs/guides/extension.md} +3 -3
  102. package/templates/common/Harness/{lifecycle.md → specs/guides/lifecycle.md} +2 -2
  103. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +76 -0
  104. package/templates/common/Harness/{agent-workflow.md → specs/runtime/agent-workflow.md} +6 -6
  105. package/templates/common/Harness/{context-loading.md → specs/runtime/context-loading.md} +85 -21
  106. package/templates/common/Harness/{dispatch.md → specs/runtime/dispatch.md} +4 -3
  107. package/templates/common/Harness/{subagents.md → specs/runtime/subagents.md} +19 -10
  108. package/templates/common/Harness/{WF-AUTO-SPARK.md → specs/workflows/WF-AUTO-SPARK.md} +2 -2
  109. package/templates/common/Harness/{WF-AUTO.md → specs/workflows/WF-AUTO.md} +12 -7
  110. package/templates/common/Harness/{WF-KERNEL.md → specs/workflows/WF-KERNEL.md} +11 -1
  111. package/templates/common/Harness/{WF-MAX.md → specs/workflows/WF-MAX.md} +40 -7
  112. package/templates/common/Harness/specs/workflows/WF-STATE.md +141 -0
  113. package/templates/common/Harness/{WF.md → specs/workflows/WF.md} +12 -1
  114. package/templates/common/README.md +8 -6
  115. package/templates/common/memory/startup-hints.md +19 -17
  116. package/templates/common/opencode.json +1 -0
  117. package/templates/optional/catalog.json +2 -9
  118. package/templates/optional/skills/github-pr-review/.claude/skills/github-pr-review/SKILL.md +1 -1
  119. package/templates/optional/skills/python-backend/.claude/skills/python-backend/SKILL.md +1 -1
  120. package/templates/optional/skills/ts-react-frontend/.claude/skills/ts-react-frontend/SKILL.md +1 -1
  121. package/templates/optional/skills/ui-ux-review/.claude/skills/ui-ux-review/SKILL.md +1 -1
  122. package/templates/common/Harness/TASK_ARCHIVE.md +0 -56
  123. package/templates/common/Harness/WF-STATE.md +0 -83
  124. package/templates/optional/skills/browser-e2e/.claude/skills/browser-e2e/SKILL.md +0 -42
  125. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +0 -194
  126. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +0 -119
  127. /package/templates/common/Harness/{ACCEPTANCE_PROTOCOL.md → specs/protocols/ACCEPTANCE_PROTOCOL.md} +0 -0
  128. /package/templates/common/Harness/{AGENT_ISOLATION.md → specs/protocols/AGENT_ISOLATION.md} +0 -0
  129. /package/templates/common/Harness/{DEBUG_PROTOCOL.md → specs/protocols/DEBUG_PROTOCOL.md} +0 -0
  130. /package/templates/common/Harness/{HARNESS_BRIDGE.md → specs/protocols/HARNESS_BRIDGE.md} +0 -0
  131. /package/templates/common/Harness/{MEMORY_PROTOCOL.md → specs/protocols/MEMORY_PROTOCOL.md} +0 -0
  132. /package/templates/common/Harness/{TDD-GUIDE.md → specs/protocols/TDD-GUIDE.md} +0 -0
  133. /package/templates/common/Harness/{WF-AUTO-ANGLES.md → specs/workflows/WF-AUTO-ANGLES.md} +0 -0
package/src/generator.js CHANGED
@@ -1,138 +1,143 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { createHash } from 'node:crypto';
5
-
6
- /** PRESERVE patterns files excluded from checksums (user data). Mirror of wf-update-check.mjs. */
7
- const CHECKSUM_EXCLUDE = [
8
- /^Harness\/PROGRESS\.md$/,
9
- /^Harness\/tasks\//,
10
- /^Harness\/memory\//,
11
- /^Harness\/research\/PRD\.md$/,
12
- /^Harness\/research\/research-results\.md$/,
13
- /^Harness\/architecture\.md$/,
14
- /^README\.md$/,
15
- /^\.gitignore$/,
16
- /^package\.json$/,
17
- /^package-lock\.json$/,
18
- /^Harness\/\.harness-version$/,
19
- ];
20
-
21
- export function isChecksumExcluded(dest) {
22
- return CHECKSUM_EXCLUDE.some(p => p.test(dest));
23
- }
24
-
25
- /**
26
- * Compute LF-normalized SHA-256 checksums for a list of generated files.
27
- * @param {Array<{dest: string, content: string}>} files - dest is POSIX harnessDest path
28
- * @returns {Object} sorted map of dest -> "sha256-<hex>"
29
- */
30
- export function computeChecksums(files) {
31
- const checksums = {};
32
- for (const { dest, content } of files) {
33
- if (isChecksumExcluded(dest)) continue;
34
- const normalized = content.replace(/\r\n/g, '\n');
35
- checksums[dest] = 'sha256-' + createHash('sha256').update(normalized).digest('hex');
36
- }
37
- return Object.fromEntries(Object.keys(checksums).sort().map(k => [k, checksums[k]]));
38
- }
39
-
40
- /**
41
- * Build a sources map: dest → template-relative POSIX path.
42
- * Maps each generated file back to its source under templates/common/ or templates/optional/.
43
- * @param {Array<{dest: string, src: string, type: string}>} fileSpecs
44
- * @returns {Object} sorted map of dest templateRelPath
45
- */
46
- export function computeSources(fileSpecs) {
47
- const sources = {};
48
- for (const spec of fileSpecs) {
49
- if (spec.type === 'empty') continue;
50
- if (!spec.src) continue;
51
- let rel;
52
- if (spec.type === 'common') {
53
- rel = normalizePath(path.relative(TEMPLATES_DIR, spec.src));
54
- } else if (spec.type === 'optional') {
55
- rel = normalizePath(path.relative(OPTIONAL_DIR, spec.src));
56
- } else {
57
- continue;
58
- }
59
- sources[spec.dest] = rel;
60
- }
61
- return Object.fromEntries(Object.keys(sources).sort().map(k => [k, sources[k]]));
62
- }
63
-
64
- const __filename = fileURLToPath(import.meta.url);
65
- const __dirname = path.dirname(__filename);
66
- const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8'));
67
- const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates', 'common');
68
- const OPTIONAL_DIR = path.resolve(__dirname, '..', 'templates', 'optional');
69
- const OPTIONAL_CATALOG = path.join(OPTIONAL_DIR, 'catalog.json');
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ /** Files excluded from checksum tracking. Most are PRESERVE user data; starter
7
+ * docs that need move/create upgrade tracking may remain PRESERVE while still
8
+ * being checksummed. */
9
+ const CHECKSUM_EXCLUDE = [
10
+ /^Harness\/PROGRESS\.md$/,
11
+ /^Harness\/tasks\//,
12
+ /^Harness\/memory\//,
13
+ /^Harness\/research\/PRD\.md$/,
14
+ /^Harness\/research\/research-results\.md$/,
15
+ /^Harness\/architecture\.md$/,
16
+ /^README\.md$/,
17
+ /^\.gitignore$/,
18
+ /^package\.json$/,
19
+ /^package-lock\.json$/,
20
+ /^Harness\/\.harness-version$/,
21
+ ];
22
+
23
+ export function isChecksumExcluded(dest) {
24
+ return CHECKSUM_EXCLUDE.some(p => p.test(dest));
25
+ }
26
+
27
+ /**
28
+ * Compute LF-normalized SHA-256 checksums for a list of generated files.
29
+ * @param {Array<{dest: string, content: string}>} files - dest is POSIX harnessDest path
30
+ * @returns {Object} sorted map of dest -> "sha256-<hex>"
31
+ */
32
+ export function computeChecksums(files) {
33
+ const checksums = {};
34
+ for (const { dest, content } of files) {
35
+ if (isChecksumExcluded(dest)) continue;
36
+ const normalized = content.replace(/\r\n/g, '\n');
37
+ checksums[dest] = 'sha256-' + createHash('sha256').update(normalized).digest('hex');
38
+ }
39
+ return Object.fromEntries(Object.keys(checksums).sort().map(k => [k, checksums[k]]));
40
+ }
41
+
42
+ /**
43
+ * Build a sources map: dest template-relative POSIX path.
44
+ * Maps each generated file back to its source under templates/common/ or templates/optional/.
45
+ * @param {Array<{dest: string, src: string, type: string}>} fileSpecs
46
+ * @returns {Object} sorted map of dest → templateRelPath
47
+ */
48
+ export function computeSources(fileSpecs) {
49
+ const sources = {};
50
+ for (const spec of fileSpecs) {
51
+ if (spec.type === 'empty') continue;
52
+ if (!spec.src) continue;
53
+ let rel;
54
+ if (spec.type === 'common') {
55
+ rel = normalizePath(path.relative(TEMPLATES_DIR, spec.src));
56
+ } else if (spec.type === 'optional') {
57
+ rel = normalizePath(path.relative(OPTIONAL_DIR, spec.src));
58
+ } else {
59
+ continue;
60
+ }
61
+ sources[spec.dest] = rel;
62
+ }
63
+ return Object.fromEntries(Object.keys(sources).sort().map(k => [k, sources[k]]));
64
+ }
65
+
66
+ const __filename = fileURLToPath(import.meta.url);
67
+ const __dirname = path.dirname(__filename);
68
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8'));
69
+ const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates', 'common');
70
+ const OPTIONAL_DIR = path.resolve(__dirname, '..', 'templates', 'optional');
71
+ const OPTIONAL_CATALOG = path.join(OPTIONAL_DIR, 'catalog.json');
70
72
  const VALID_CONFLICT_POLICIES = new Set(['fail', 'skip', 'backup', 'overwrite']);
73
+ const RETIRED_OPTION_MESSAGES = new Map([
74
+ ['browser-e2e', 'Optional workflow "browser-e2e" has been retired; wf-browser is built in by default. Use /wf-browser, $wf-browser, or /skills wf-browser instead.'],
75
+ ]);
71
76
  const EMPTY_DIRS = [
72
77
  'tests',
73
78
  ];
74
-
75
- export function harnessDest(file) {
76
- if (file === '.harness-version') return 'Harness/.harness-version';
77
- if (file === 'SETUP.md') return 'Harness/SETUP.md';
79
+
80
+ export function harnessDest(file) {
81
+ if (file === '.harness-version') return 'Harness/.harness-version';
82
+ if (file === 'SETUP.md') return 'Harness/specs/guides/SETUP.md';
78
83
  if (file === 'MEMORY.md') return 'Harness/MEMORY.md';
79
84
  if (file.startsWith('scripts/')) return `Harness/${file}`;
80
85
  if (file.startsWith('memory/')) return `Harness/${file}`;
81
86
  if (file.startsWith('Harness/')) return file;
82
87
  return file;
83
88
  }
84
-
85
- /**
86
- * Replace {{vars}} in file contents.
87
- */
88
- function renderTemplate(src, vars) {
89
- let content = fs.readFileSync(src, 'utf-8');
90
-
91
- for (const [key, value] of Object.entries(vars)) {
92
- content = content.replaceAll(`{{${key}}}`, value);
93
- }
94
-
95
- return content;
96
- }
97
-
98
- /**
99
- * Walk a directory and return relative paths of all files.
100
- */
101
- function walkFiles(dir, base = dir) {
102
- const results = [];
103
- const entries = fs.readdirSync(dir, { withFileTypes: true });
104
-
105
- for (const entry of entries) {
106
- const full = path.join(dir, entry.name);
107
- if (entry.isDirectory()) {
108
- results.push(...walkFiles(full, base));
109
- } else {
110
- results.push(path.relative(base, full));
111
- }
112
- }
113
-
114
- return results;
115
- }
116
-
117
- function normalizePath(filePath) {
118
- return filePath.replace(/\\/g, '/');
119
- }
120
-
121
- export function getOptionalCatalog() {
89
+
90
+ /**
91
+ * Replace {{vars}} in file contents.
92
+ */
93
+ function renderTemplate(src, vars) {
94
+ let content = fs.readFileSync(src, 'utf-8');
95
+
96
+ for (const [key, value] of Object.entries(vars)) {
97
+ content = content.replaceAll(`{{${key}}}`, value);
98
+ }
99
+
100
+ return content;
101
+ }
102
+
103
+ /**
104
+ * Walk a directory and return relative paths of all files.
105
+ */
106
+ function walkFiles(dir, base = dir) {
107
+ const results = [];
108
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
109
+
110
+ for (const entry of entries) {
111
+ const full = path.join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ results.push(...walkFiles(full, base));
114
+ } else {
115
+ results.push(path.relative(base, full));
116
+ }
117
+ }
118
+
119
+ return results;
120
+ }
121
+
122
+ function normalizePath(filePath) {
123
+ return filePath.replace(/\\/g, '/');
124
+ }
125
+
126
+ export function getOptionalCatalog() {
122
127
  if (!fs.existsSync(OPTIONAL_CATALOG)) {
123
128
  return { skills: [], presets: {}, externalRecommendations: [] };
124
129
  }
125
-
126
- return JSON.parse(fs.readFileSync(OPTIONAL_CATALOG, 'utf-8'));
127
- }
128
-
130
+
131
+ return JSON.parse(fs.readFileSync(OPTIONAL_CATALOG, 'utf-8'));
132
+ }
133
+
129
134
  function normalizeOptionList(value) {
130
- const values = Array.isArray(value) ? value : [value];
131
- return values
132
- .filter(Boolean)
133
- .flatMap(item => String(item).split(','))
134
- .map(item => item.trim())
135
- .filter(Boolean);
135
+ const values = Array.isArray(value) ? value : [value];
136
+ return values
137
+ .filter(Boolean)
138
+ .flatMap(item => String(item).split(','))
139
+ .map(item => item.trim())
140
+ .filter(Boolean);
136
141
  }
137
142
 
138
143
  function externalRecommendations(catalog) {
@@ -152,26 +157,31 @@ function resolveOptionalSelection({
152
157
  const recommendationsById = new Map(externalRecommendations(catalog).map(item => [item.id, item]));
153
158
  const selected = [];
154
159
  const errors = [];
155
-
156
- if (preset) {
157
- if (!catalog.presets[preset]) {
158
- errors.push(`Unknown preset "${preset}". Run --list-options to see available presets.`);
159
- } else {
160
- selected.push(...catalog.presets[preset]);
161
- }
162
- }
163
-
164
- selected.push(...normalizeOptionList(withOptions));
165
-
166
- const ids = [...new Set(selected)];
167
- const withoutIds = [...new Set(normalizeOptionList(withoutOptions))];
168
- for (const id of ids) {
169
- if (!skillsById.has(id)) {
170
- errors.push(`Unknown optional skill "${id}". Run --list-options to see available options.`);
171
- }
172
- }
160
+ const warnings = [];
161
+
162
+ if (preset) {
163
+ if (!catalog.presets[preset]) {
164
+ errors.push(`Unknown preset "${preset}". Run --list-options to see available presets.`);
165
+ } else {
166
+ selected.push(...catalog.presets[preset]);
167
+ }
168
+ }
169
+
170
+ selected.push(...normalizeOptionList(withOptions));
171
+
172
+ const ids = [...new Set(selected)];
173
+ const withoutIds = [...new Set(normalizeOptionList(withoutOptions))];
174
+ for (const id of ids) {
175
+ if (RETIRED_OPTION_MESSAGES.has(id)) {
176
+ warnings.push(RETIRED_OPTION_MESSAGES.get(id));
177
+ } else if (!skillsById.has(id)) {
178
+ errors.push(`Unknown optional skill "${id}". Run --list-options to see available options.`);
179
+ }
180
+ }
173
181
  for (const id of withoutIds) {
174
- if (!skillsById.has(id)) {
182
+ if (RETIRED_OPTION_MESSAGES.has(id)) {
183
+ warnings.push(`Optional workflow "${id}" is retired and built in/no-op for --without.`);
184
+ } else if (!skillsById.has(id)) {
175
185
  errors.push(`Unknown optional skill in --without "${id}". Run --list-options to see available options.`);
176
186
  }
177
187
  }
@@ -184,13 +194,14 @@ function resolveOptionalSelection({
184
194
  }
185
195
 
186
196
  const withoutSet = new Set(withoutIds);
187
- const selectedIds = ids.filter(id => !withoutSet.has(id));
197
+ const selectedIds = ids.filter(id => !withoutSet.has(id) && !RETIRED_OPTION_MESSAGES.has(id));
188
198
 
189
199
  return {
190
200
  catalog,
191
201
  selectedSkills: errors.length ? [] : selectedIds.map(id => skillsById.get(id)),
192
202
  selectedRecommendations: errors.length ? [] : recommendationIds.map(id => recommendationsById.get(id)),
193
203
  errors,
204
+ warnings,
194
205
  };
195
206
  }
196
207
 
@@ -210,18 +221,18 @@ function createCoreFileSpecs() {
210
221
  }
211
222
 
212
223
  function createOptionalFileSpecs(selectedSkills) {
213
- const specs = [];
214
-
215
- for (const skill of selectedSkills) {
216
- for (const fileRoot of skill.files) {
217
- const absRoot = path.join(OPTIONAL_DIR, ...fileRoot.split('/'));
218
- for (const file of walkFiles(absRoot).map(normalizePath).sort()) {
219
- specs.push({
220
- dest: harnessDest(file),
221
- src: path.join(absRoot, ...file.split('/')),
222
- type: 'optional',
223
- skillId: skill.id,
224
- });
224
+ const specs = [];
225
+
226
+ for (const skill of selectedSkills) {
227
+ for (const fileRoot of skill.files) {
228
+ const absRoot = path.join(OPTIONAL_DIR, ...fileRoot.split('/'));
229
+ for (const file of walkFiles(absRoot).map(normalizePath).sort()) {
230
+ specs.push({
231
+ dest: harnessDest(file),
232
+ src: path.join(absRoot, ...file.split('/')),
233
+ type: 'optional',
234
+ skillId: skill.id,
235
+ });
225
236
  }
226
237
  }
227
238
  }
@@ -238,22 +249,22 @@ function createCodexSkillMirrors(fileSpecs) {
238
249
  dest: spec.dest.replace(/^\.claude\/skills\//, '.agents/skills/'),
239
250
  }));
240
251
  }
241
-
242
- function duplicateDests(fileSpecs) {
243
- const seen = new Set();
244
- const duplicates = new Set();
245
-
246
- for (const spec of fileSpecs) {
247
- if (seen.has(spec.dest)) {
248
- duplicates.add(spec.dest);
249
- } else {
250
- seen.add(spec.dest);
251
- }
252
- }
253
-
254
- return [...duplicates].sort();
255
- }
256
-
252
+
253
+ function duplicateDests(fileSpecs) {
254
+ const seen = new Set();
255
+ const duplicates = new Set();
256
+
257
+ for (const spec of fileSpecs) {
258
+ if (seen.has(spec.dest)) {
259
+ duplicates.add(spec.dest);
260
+ } else {
261
+ seen.add(spec.dest);
262
+ }
263
+ }
264
+
265
+ return [...duplicates].sort();
266
+ }
267
+
257
268
  /**
258
269
  * Walk the directory chain from rootDir down to the parent of absPath.
259
270
  * Returns the first ancestor that is a symbolic link, or null if none.
@@ -276,145 +287,244 @@ function symlinkInChain(rootDir, absPath) {
276
287
  return null;
277
288
  }
278
289
 
279
- function createPlan(resolvedDir, fileSpecs) {
280
- const plan = {
281
- create: [],
282
- skip: [],
283
- overwrite: [],
284
- backup: [],
285
- conflict: [],
286
- mkdir: [],
287
- };
288
-
289
- const fileSet = new Set(fileSpecs.map(spec => spec.dest));
290
-
291
- const dirSet = new Set(['.']);
292
- for (const file of fileSet) {
293
- let dir = path.posix.dirname(file);
294
- while (dir && dir !== '.') {
295
- dirSet.add(dir);
296
- dir = path.posix.dirname(dir);
297
- }
298
- }
299
- for (const dir of EMPTY_DIRS) {
300
- dirSet.add(dir);
301
- }
302
-
303
- for (const dir of [...dirSet].sort()) {
304
- const absDir = dir === '.'
305
- ? resolvedDir
306
- : path.join(resolvedDir, ...dir.split('/'));
307
- if (fs.existsSync(absDir)) {
308
- if (!fs.statSync(absDir).isDirectory()) {
309
- plan.conflict.push(dir === '.' ? './' : `${dir}/`);
310
- }
311
- } else {
312
- plan.mkdir.push(dir === '.' ? './' : `${dir}/`);
313
- }
314
- }
315
-
316
- return plan;
317
- }
318
-
319
- function addFileActions(plan, fileSpecs, resolvedDir, onConflict) {
320
- for (const { dest: file } of fileSpecs) {
321
- const destPath = path.join(resolvedDir, ...file.split('/'));
322
-
323
- if (!fs.existsSync(destPath)) {
324
- plan.create.push(file);
325
- continue;
326
- }
327
-
328
- if (fs.statSync(destPath).isDirectory()) {
329
- if (onConflict === 'skip') {
330
- plan.skip.push(file);
331
- } else {
332
- plan.conflict.push(file);
333
- }
334
- continue;
335
- }
336
-
337
- if (onConflict === 'skip') {
338
- plan.skip.push(file);
339
- } else if (onConflict === 'backup') {
340
- plan.backup.push(file);
341
- } else if (onConflict === 'overwrite') {
342
- plan.overwrite.push(file);
343
- } else {
344
- plan.conflict.push(file);
345
- }
346
- }
347
- }
348
-
349
- function createSummary(plan, { noWrites = false } = {}) {
350
- return {
351
- created: noWrites ? 0 : plan.create.length,
352
- skipped: noWrites ? 0 : plan.skip.length,
353
- overwritten: noWrites ? 0 : plan.overwrite.length,
354
- backedUp: noWrites ? 0 : plan.backup.length,
355
- conflicts: plan.conflict.length,
356
- mkdir: noWrites ? 0 : plan.mkdir.length,
357
- };
358
- }
359
-
360
- function nextBackupPath(destPath) {
361
- const first = `${destPath}.harness-backup`;
362
- if (!fs.existsSync(first)) {
363
- return first;
364
- }
365
-
366
- for (let index = 1; ; index += 1) {
367
- const candidate = `${first}.${index}`;
368
- if (!fs.existsSync(candidate)) {
369
- return candidate;
370
- }
371
- }
372
- }
373
-
290
+ function createPlan(resolvedDir, fileSpecs) {
291
+ const plan = {
292
+ create: [],
293
+ skip: [],
294
+ overwrite: [],
295
+ backup: [],
296
+ conflict: [],
297
+ mkdir: [],
298
+ userOwnedSkip: [],
299
+ };
300
+
301
+ const fileSet = new Set(fileSpecs.map(spec => spec.dest));
302
+
303
+ const dirSet = new Set(['.']);
304
+ for (const file of fileSet) {
305
+ let dir = path.posix.dirname(file);
306
+ while (dir && dir !== '.') {
307
+ dirSet.add(dir);
308
+ dir = path.posix.dirname(dir);
309
+ }
310
+ }
311
+ for (const dir of EMPTY_DIRS) {
312
+ dirSet.add(dir);
313
+ }
314
+
315
+ for (const dir of [...dirSet].sort()) {
316
+ const absDir = dir === '.'
317
+ ? resolvedDir
318
+ : path.join(resolvedDir, ...dir.split('/'));
319
+ if (fs.existsSync(absDir)) {
320
+ if (!fs.statSync(absDir).isDirectory()) {
321
+ plan.conflict.push(dir === '.' ? './' : `${dir}/`);
322
+ }
323
+ } else {
324
+ plan.mkdir.push(dir === '.' ? './' : `${dir}/`);
325
+ }
326
+ }
327
+
328
+ return plan;
329
+ }
330
+
331
+ /**
332
+ * The package-side ownership manifest (the machine-readable source of truth for
333
+ * which dest paths are Harness-interest). The generator runs from src/, so this
334
+ * resolves to templates/common/Harness/ownership.manifest.json. Read defensively;
335
+ * if missing/unparseable the candidate set falls back to fileSpec dests.
336
+ */
337
+ const MANIFEST_PATH = path.resolve(__dirname, '..', 'templates', 'common', 'Harness', 'ownership.manifest.json');
338
+
339
+ /**
340
+ * Read and parse the ownership manifest defensively. Returns null when the file
341
+ * is missing, unreadable, unparseable, or lacks a frameworkOwned array so the
342
+ * caller falls back to the fileSpecs-derived candidate set.
343
+ */
344
+ function readOwnershipManifest() {
345
+ try {
346
+ if (!fs.existsSync(MANIFEST_PATH)) return null;
347
+ const parsed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
348
+ if (!parsed || !Array.isArray(parsed.frameworkOwned)) return null;
349
+ return parsed;
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Build the Harness-interest candidate dest set (manifest-first, fileSpecs
357
+ * fallback). Pure (no filesystem) so the manifest/fallback branches are unit
358
+ * testable directly.
359
+ *
360
+ * Manifest-present: candidate set = manifest.frameworkOwned paths UNION the
361
+ * selected optional skills' manifest.optionalOwned paths. This includes optional
362
+ * skill paths like ui-ux-review (fixing the bug where same-name user files at
363
+ * optional-skill paths were clobbered).
364
+ *
365
+ * Manifest-absent (null): candidate set = all fileSpec dests (the exact set of
366
+ * paths being written), so optional skill paths remain protected.
367
+ *
368
+ * Instance ownership is still decided by MARKER in existingCandidateIsUserOwned:
369
+ * a candidate path with no Harness marker is user-owned and protected.
370
+ *
371
+ * @param {object|null} manifest - parsed ownership manifest, or null for fallback
372
+ * @param {Array<{dest: string}>} fileSpecs - installer write specs
373
+ * @param {Array<{id: string}>} selectedSkills - selected optional skills
374
+ * @returns {Set<string>} candidate dest paths
375
+ */
376
+ export function buildHarnessInterestCandidateSet(manifest, fileSpecs, selectedSkills) {
377
+ if (!manifest || !Array.isArray(manifest.frameworkOwned)) {
378
+ return new Set(fileSpecs.map(spec => spec.dest));
379
+ }
380
+ const selectedIds = new Set((selectedSkills || []).map(skill => skill && skill.id));
381
+ const candidates = new Set();
382
+ for (const entry of manifest.frameworkOwned) {
383
+ if (entry && typeof entry.path === 'string') candidates.add(entry.path);
384
+ }
385
+ for (const entry of manifest.optionalOwned || []) {
386
+ if (!entry || !selectedIds.has(entry.option)) continue;
387
+ for (const p of entry.paths || []) {
388
+ if (typeof p === 'string') candidates.add(p);
389
+ }
390
+ }
391
+ return candidates;
392
+ }
393
+
394
+ /**
395
+ * Content markers proving a file was authored by this scaffold. Mirror of
396
+ * wf-update-check.mjs HARNESS_OWNED_CONTENT_MARKERS.
397
+ */
398
+ const HARNESS_OWNED_CONTENT_MARKERS = [
399
+ /^harness:\s*(?:wf-agent|wf-framework|create-harness-vibe-coding)\b/im,
400
+ /\bcreate-harness-vibe-coding\b/i,
401
+ /\bproject harness\b/i,
402
+ /\bHarness\/(?:specs|WF|MEMORY|tasks|scripts|subagents|dispatch|context-loading|lifecycle|SETUP)\b/,
403
+ /\bWF-(?:MAX|AUTO|KERNEL|STATE)\b/,
404
+ ];
405
+
406
+ /**
407
+ * Returns true if the given file content carries a Harness ownership marker.
408
+ * Pure (no filesystem access) so it can be unit-tested directly.
409
+ */
410
+ export function isHarnessOwnedContent(content) {
411
+ return HARNESS_OWNED_CONTENT_MARKERS.some(pattern => pattern.test(content));
412
+ }
413
+
414
+ /**
415
+ * Returns true if an existing on-disk file is a Harness-interest candidate path
416
+ * (per the manifest-first candidate set) that was NOT authored by this scaffold
417
+ * (candidate path + no ownership marker). Such user-authored files are preserved
418
+ * (skip) regardless of the conflict policy, mirroring the updater safety rule.
419
+ */
420
+ function existingCandidateIsUserOwned(dest, destPath, candidateSet) {
421
+ if (!candidateSet.has(dest)) return false;
422
+ try {
423
+ const content = fs.readFileSync(destPath, 'utf-8');
424
+ return !isHarnessOwnedContent(content);
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
430
+ function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet) {
431
+ for (const { dest: file } of fileSpecs) {
432
+ const destPath = path.join(resolvedDir, ...file.split('/'));
433
+
434
+ if (!fs.existsSync(destPath)) {
435
+ plan.create.push(file);
436
+ continue;
437
+ }
438
+
439
+ if (fs.statSync(destPath).isDirectory()) {
440
+ if (onConflict === 'skip') {
441
+ plan.skip.push(file);
442
+ } else {
443
+ plan.conflict.push(file);
444
+ }
445
+ continue;
446
+ }
447
+
448
+ // G4 guard: a non-Harness same-name candidate (any Harness-interest path
449
+ // — agent/command/skill/optional-skill/script per the manifest-first
450
+ // candidate set — without an ownership marker) must NEVER be overwritten.
451
+ // Under overwrite/backup/skip it is preserved via plan.skip; under fail it
452
+ // is blocked via plan.conflict so the collision surfaces (matching the fail
453
+ // branch below). Only Harness-owned files (marker present) reach the normal
454
+ // policy switch. Mirrors the wf-update-check.mjs safety rule; see
455
+ // existingCandidateIsUserOwned / isHarnessOwnedContent above.
456
+ if (existingCandidateIsUserOwned(file, destPath, candidateSet)) {
457
+ if (onConflict === 'fail') {
458
+ plan.conflict.push(file);
459
+ } else {
460
+ plan.skip.push(file);
461
+ plan.userOwnedSkip.push(file);
462
+ }
463
+ continue;
464
+ }
465
+
466
+ if (onConflict === 'skip') {
467
+ plan.skip.push(file);
468
+ } else if (onConflict === 'backup') {
469
+ plan.backup.push(file);
470
+ } else if (onConflict === 'overwrite') {
471
+ plan.overwrite.push(file);
472
+ } else {
473
+ plan.conflict.push(file);
474
+ }
475
+ }
476
+ }
477
+
478
+ function createSummary(plan, { noWrites = false } = {}) {
479
+ return {
480
+ created: noWrites ? 0 : plan.create.length,
481
+ skipped: noWrites ? 0 : plan.skip.length,
482
+ overwritten: noWrites ? 0 : plan.overwrite.length,
483
+ backedUp: noWrites ? 0 : plan.backup.length,
484
+ conflicts: plan.conflict.length,
485
+ mkdir: noWrites ? 0 : plan.mkdir.length,
486
+ };
487
+ }
488
+
489
+ function nextBackupPath(destPath) {
490
+ const first = `${destPath}.harness-backup`;
491
+ if (!fs.existsSync(first)) {
492
+ return first;
493
+ }
494
+
495
+ for (let index = 1; ; index += 1) {
496
+ const candidate = `${first}.${index}`;
497
+ if (!fs.existsSync(candidate)) {
498
+ return candidate;
499
+ }
500
+ }
501
+ }
502
+
374
503
  function registerOptionalContent(file, content, selectedSkills) {
375
504
  if (!selectedSkills.length) return content;
376
- const hasBrowserE2e = selectedSkills.some(skill => skill.id === 'browser-e2e');
377
505
 
378
506
  if (file === 'Harness/MEMORY.md') {
379
507
  const lines = selectedSkills.map(skill => (
380
508
  `- [${skill.id}](../.claude/skills/${skill.id}/SKILL.md) - ${skill.description} Codex mirror: [${skill.id}](../.agents/skills/${skill.id}/SKILL.md). Workflow: [workflows/${skill.id}.md](workflows/${skill.id}.md)`
381
509
  ));
382
- return content.replace(
383
- 'Stack-specific skills can be added after the product shape is known.',
384
- `Installed optional skills:\n\n${lines.join('\n')}\n\nStack-specific skills can be added after the product shape is known.`,
385
- );
510
+ return content.replace(
511
+ 'Stack-specific skills can be added after the product shape is known.',
512
+ `Installed optional skills:\n\n${lines.join('\n')}\n\nStack-specific skills can be added after the product shape is known.`,
513
+ );
386
514
  }
387
515
 
388
516
  if (file === 'Harness/README.md') {
389
517
  const lines = selectedSkills.map(skill => (
390
518
  `- [${skill.title}](workflows/${skill.id}.md) - ${skill.description}`
391
519
  ));
392
- let next = content;
393
- if (hasBrowserE2e) {
394
- next = next.replace(
395
- '| Optional workflow installed |',
396
- '| Browser E2E testing or automation | /wf-browser, $wf-browser, browser, e2e, web automation, screenshot verify, page test, browser test, Playwright, CDP | [workflows/browser-e2e.md](workflows/browser-e2e.md), [HARNESS_BRIDGE.md](HARNESS_BRIDGE.md) | UI/API contract, CLI commands, screenshots, traces, validation matrix |\n| Optional workflow installed |',
397
- );
398
- next = next.replace(
399
- '| `/wf-readme [task]` |',
400
- '| `/wf-browser [task]` | `$wf-browser [task]` | Optional browser automation/E2E workflow when `browser-e2e` is installed |\n| `/wf-readme [task]` |',
401
- );
402
- }
403
- return `${next.trimEnd()}\n\n## Installed Optional Workflows\n\n${lines.join('\n')}\n`;
404
- }
405
-
406
- if ((file === '.claude/commands/wf-help.md' || file === '.opencode/commands/wf-help.md') && hasBrowserE2e) {
407
- return content.replace(
408
- '| `/wf-readme <task>` |',
409
- '| `/wf-browser <task>` | optional workflow skill | `/wf-browser verify checkout flow` | Browser automation/E2E workflow with real UI interaction, screenshots, traces, and CDP/network evidence. |\n| `/wf-readme <task>` |',
410
- );
520
+ return `${content.trimEnd()}\n\n## Installed Optional Workflows\n\n${lines.join('\n')}\n`;
411
521
  }
412
522
 
413
523
  return content;
414
524
  }
415
525
 
416
526
  function registerExternalRecommendations(file, content, selectedRecommendations) {
417
- if (!selectedRecommendations.length || file !== 'Harness/SETUP.md') return content;
527
+ if (!selectedRecommendations.length || file !== 'Harness/specs/guides/SETUP.md') return content;
418
528
 
419
529
  const lines = selectedRecommendations.map(item => (
420
530
  `- \`${item.id}\` - ${item.description} Recommendation only; not installed by this scaffold. Source: ${item.url}`
@@ -422,168 +532,178 @@ function registerExternalRecommendations(file, content, selectedRecommendations)
422
532
 
423
533
  return `${content.trimEnd()}\n\n## Selected External Recommendations\n\n${lines.join('\n')}\n`;
424
534
  }
425
-
426
- function registrationWarnings(plan, selectedSkills) {
427
- const warnings = [];
428
-
429
- if (selectedSkills.length) {
430
- if (plan.skip.includes('Harness/MEMORY.md')) {
431
- warnings.push('Harness/MEMORY.md was skipped; manually register selected optional skills under #Skills.');
432
- }
433
- if (plan.skip.includes('Harness/README.md')) {
434
- warnings.push('Harness/README.md was skipped; manually register selected optional workflow paths.');
435
- }
436
- }
437
-
438
- if (plan.skip.includes('README.md')) {
439
- warnings.push('README.md was skipped; keep project run, build, test, and git conventions there, not in CLAUDE.md.');
440
- }
441
- if (plan.skip.includes('CLAUDE.md') || plan.conflict.includes('CLAUDE.md')) {
442
- warnings.push('CLAUDE.md already exists; ask the user to confirm refactoring or merging it with the Harness root-entry contract before editing.');
443
- }
444
- if (plan.skip.includes('AGENTS.md')) {
445
- warnings.push('AGENTS.md was skipped; ask for user consent before merging or replacing the project agent entry contract.');
446
- }
447
-
448
- return warnings;
449
- }
450
-
451
- export function generate({
452
- projectName,
453
- targetDir,
454
- dryRun = false,
455
- onConflict = 'fail',
535
+
536
+ function registrationWarnings(plan, selectedSkills) {
537
+ const warnings = [];
538
+
539
+ if (selectedSkills.length) {
540
+ if (plan.skip.includes('Harness/MEMORY.md')) {
541
+ warnings.push('Harness/MEMORY.md was skipped; manually register selected optional skills under #Skills.');
542
+ }
543
+ if (plan.skip.includes('Harness/README.md')) {
544
+ warnings.push('Harness/README.md was skipped; manually register selected optional workflow paths.');
545
+ }
546
+ }
547
+
548
+ if (plan.skip.includes('README.md')) {
549
+ warnings.push('README.md was skipped; keep project run, build, test, and git conventions there, not in CLAUDE.md.');
550
+ }
551
+ if (plan.skip.includes('CLAUDE.md') || plan.conflict.includes('CLAUDE.md')) {
552
+ warnings.push('CLAUDE.md already exists; ask the user to confirm refactoring or merging it with the Harness root-entry contract before editing.');
553
+ }
554
+ if (plan.skip.includes('AGENTS.md')) {
555
+ warnings.push('AGENTS.md was skipped; ask for user consent before merging or replacing the project agent entry contract.');
556
+ }
557
+
558
+ for (const file of plan.userOwnedSkip) {
559
+ warnings.push(`Skipped user-authored Harness-candidate '${file}' (no Harness marker); not overwritten. Remove the file or add a harness: wf-agent marker to upgrade.`);
560
+ }
561
+
562
+ return warnings;
563
+ }
564
+
565
+ export function generate({
566
+ projectName,
567
+ targetDir,
568
+ dryRun = false,
569
+ onConflict = 'fail',
456
570
  withOptions = [],
457
571
  withoutOptions = [],
458
572
  preset = undefined,
459
573
  externalOptions = [],
460
574
  }) {
461
- const created = [];
462
- const errors = [];
463
- const warnings = [];
464
-
465
- // Resolve targetDir relative to cwd
466
- const resolvedDir = path.resolve(process.cwd(), targetDir);
467
- const vars = {
468
- projectName,
469
- generatorVersion: pkg.version,
470
- generatedTimestamp: new Date().toISOString(),
471
- };
472
-
575
+ const created = [];
576
+ const errors = [];
577
+ const warnings = [];
578
+
579
+ // Resolve targetDir relative to cwd
580
+ const resolvedDir = path.resolve(process.cwd(), targetDir);
581
+ const vars = {
582
+ projectName,
583
+ generatorVersion: pkg.version,
584
+ generatedTimestamp: new Date().toISOString(),
585
+ };
586
+
473
587
  const optional = resolveOptionalSelection({ withOptions, withoutOptions, preset, externalOptions });
474
- const fileSpecs = [
475
- ...createCoreFileSpecs(),
476
- ...createOptionalFileSpecs(optional.selectedSkills),
477
- ];
478
- const specsByDest = new Map(fileSpecs.map(spec => [spec.dest, spec]));
479
- const plan = createPlan(resolvedDir, fileSpecs);
480
- const duplicateDestinations = duplicateDests(fileSpecs);
481
-
482
- if (!VALID_CONFLICT_POLICIES.has(onConflict)) {
483
- errors.push(`Unknown conflict policy "${onConflict}". Use fail, skip, backup, or overwrite.`);
484
- return {
485
- success: false,
486
- created,
487
- errors,
488
- plan,
489
- summary: createSummary(plan, { noWrites: true }),
490
- warnings,
491
- };
492
- }
493
-
494
- if (optional.errors.length > 0) {
495
- errors.push(...optional.errors);
496
- return {
497
- success: false,
498
- created,
499
- errors,
500
- plan,
501
- summary: createSummary(plan, { noWrites: true }),
502
- warnings,
503
- };
504
- }
505
-
506
- if (duplicateDestinations.length > 0) {
507
- errors.push(`Generation stopped because duplicate template destination(s) were found: ${duplicateDestinations.join(', ')}`);
508
- return {
509
- success: false,
510
- created,
511
- errors,
512
- plan,
513
- summary: createSummary(plan, { noWrites: true }),
514
- warnings,
515
- };
516
- }
517
-
518
- addFileActions(plan, fileSpecs, resolvedDir, onConflict);
519
- warnings.push(...registrationWarnings(plan, optional.selectedSkills));
520
-
521
- if (dryRun) {
522
- return {
523
- success: true,
524
- created,
525
- errors,
526
- plan,
527
- summary: createSummary(plan),
528
- warnings,
529
- dryRun: true,
530
- };
531
- }
532
-
533
- if (plan.conflict.length > 0) {
534
- errors.push(`Generation stopped because ${plan.conflict.length} file conflict(s) were found: ${plan.conflict.join(', ')}`);
535
- if (plan.conflict.includes('CLAUDE.md')) {
536
- errors.push('CLAUDE.md already exists. It is the root agent entry contract; ask the user to confirm refactoring or merging it with the Harness entry contract before editing, backing up, or overwriting.');
537
- }
538
- return {
539
- success: false,
540
- created,
541
- errors,
542
- plan,
543
- summary: createSummary(plan, { noWrites: true }),
544
- warnings,
545
- };
546
- }
547
-
548
- try {
549
- for (const dir of plan.mkdir) {
550
- const dirPath = dir === './'
551
- ? resolvedDir
552
- : path.join(resolvedDir, ...dir.slice(0, -1).split('/'));
588
+ warnings.push(...optional.warnings);
589
+ const fileSpecs = [
590
+ ...createCoreFileSpecs(),
591
+ ...createOptionalFileSpecs(optional.selectedSkills),
592
+ ];
593
+ const specsByDest = new Map(fileSpecs.map(spec => [spec.dest, spec]));
594
+ const plan = createPlan(resolvedDir, fileSpecs);
595
+ const duplicateDestinations = duplicateDests(fileSpecs);
596
+ const candidateSet = buildHarnessInterestCandidateSet(
597
+ readOwnershipManifest(),
598
+ fileSpecs,
599
+ optional.selectedSkills,
600
+ );
601
+
602
+ if (!VALID_CONFLICT_POLICIES.has(onConflict)) {
603
+ errors.push(`Unknown conflict policy "${onConflict}". Use fail, skip, backup, or overwrite.`);
604
+ return {
605
+ success: false,
606
+ created,
607
+ errors,
608
+ plan,
609
+ summary: createSummary(plan, { noWrites: true }),
610
+ warnings,
611
+ };
612
+ }
613
+
614
+ if (optional.errors.length > 0) {
615
+ errors.push(...optional.errors);
616
+ return {
617
+ success: false,
618
+ created,
619
+ errors,
620
+ plan,
621
+ summary: createSummary(plan, { noWrites: true }),
622
+ warnings,
623
+ };
624
+ }
625
+
626
+ if (duplicateDestinations.length > 0) {
627
+ errors.push(`Generation stopped because duplicate template destination(s) were found: ${duplicateDestinations.join(', ')}`);
628
+ return {
629
+ success: false,
630
+ created,
631
+ errors,
632
+ plan,
633
+ summary: createSummary(plan, { noWrites: true }),
634
+ warnings,
635
+ };
636
+ }
637
+
638
+ addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet);
639
+ warnings.push(...registrationWarnings(plan, optional.selectedSkills));
640
+
641
+ if (dryRun) {
642
+ return {
643
+ success: true,
644
+ created,
645
+ errors,
646
+ plan,
647
+ summary: createSummary(plan),
648
+ warnings,
649
+ dryRun: true,
650
+ };
651
+ }
652
+
653
+ if (plan.conflict.length > 0) {
654
+ errors.push(`Generation stopped because ${plan.conflict.length} file conflict(s) were found: ${plan.conflict.join(', ')}`);
655
+ if (plan.conflict.includes('CLAUDE.md')) {
656
+ errors.push('CLAUDE.md already exists. It is the root agent entry contract; ask the user to confirm refactoring or merging it with the Harness entry contract before editing, backing up, or overwriting.');
657
+ }
658
+ return {
659
+ success: false,
660
+ created,
661
+ errors,
662
+ plan,
663
+ summary: createSummary(plan, { noWrites: true }),
664
+ warnings,
665
+ };
666
+ }
667
+
668
+ try {
669
+ for (const dir of plan.mkdir) {
670
+ const dirPath = dir === './'
671
+ ? resolvedDir
672
+ : path.join(resolvedDir, ...dir.slice(0, -1).split('/'));
553
673
  const __slMkdir = symlinkInChain(resolvedDir, dirPath);
554
674
  if (__slMkdir) throw new Error(`Cannot create directory ${dir}: symlinked directory in path (${__slMkdir})`);
555
- fs.mkdirSync(dirPath, { recursive: true });
556
- created.push(dir === './' ? './ (directory)' : `${dir} (directory)`);
557
- }
558
-
559
- for (const file of plan.backup) {
560
- const destPath = path.join(resolvedDir, ...file.split('/'));
675
+ fs.mkdirSync(dirPath, { recursive: true });
676
+ created.push(dir === './' ? './ (directory)' : `${dir} (directory)`);
677
+ }
678
+
679
+ for (const file of plan.backup) {
680
+ const destPath = path.join(resolvedDir, ...file.split('/'));
561
681
  const __slBackup = symlinkInChain(resolvedDir, destPath);
562
682
  if (__slBackup) throw new Error(`Cannot backup ${file}: symlinked directory in path (${__slBackup})`);
563
683
  // Symlink safety: never rename or follow symlinks to avoid path traversal
564
684
  if (fs.existsSync(destPath)) {
565
685
  try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot backup ${file}: ${e.message}`); }
566
686
  }
567
- fs.renameSync(destPath, nextBackupPath(destPath));
568
- }
569
-
570
- const writableFiles = [
571
- ...plan.create,
572
- ...plan.overwrite,
573
- ...plan.backup,
574
- ];
575
-
576
- const generatedFiles = [];
577
- const HARNESS_VERSION_DEST = 'Harness/.harness-version';
578
- let harnessVersionSpec = null;
579
-
580
- for (const file of writableFiles) {
581
- if (file === HARNESS_VERSION_DEST) {
582
- harnessVersionSpec = specsByDest.get(file);
583
- continue;
584
- }
585
- const spec = specsByDest.get(file);
586
- const destPath = path.join(resolvedDir, ...file.split('/'));
687
+ fs.renameSync(destPath, nextBackupPath(destPath));
688
+ }
689
+
690
+ const writableFiles = [
691
+ ...plan.create,
692
+ ...plan.overwrite,
693
+ ...plan.backup,
694
+ ];
695
+
696
+ const generatedFiles = [];
697
+ const HARNESS_VERSION_DEST = 'Harness/.harness-version';
698
+ let harnessVersionSpec = null;
699
+
700
+ for (const file of writableFiles) {
701
+ if (file === HARNESS_VERSION_DEST) {
702
+ harnessVersionSpec = specsByDest.get(file);
703
+ continue;
704
+ }
705
+ const spec = specsByDest.get(file);
706
+ const destPath = path.join(resolvedDir, ...file.split('/'));
587
707
  const __slWrite = symlinkInChain(resolvedDir, destPath);
588
708
  if (__slWrite) throw new Error(`Cannot create/overwrite ${file}: symlinked directory in path (${__slWrite})`);
589
709
  // Symlink safety: reject symlinks before writing to prevent path traversal outside workspace
@@ -595,57 +715,57 @@ export function generate({
595
715
  : renderTemplate(spec.src, vars);
596
716
  content = registerOptionalContent(file, content, optional.selectedSkills);
597
717
  content = registerExternalRecommendations(file, content, optional.selectedRecommendations);
598
-
599
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
600
- fs.writeFileSync(destPath, content, 'utf-8');
601
- created.push(file);
602
- generatedFiles.push({ dest: file, content });
603
- }
604
-
605
- if (harnessVersionSpec) {
606
- const rendered = renderTemplate(harnessVersionSpec.src, vars);
607
- let parsed;
608
- try {
609
- parsed = JSON.parse(rendered);
610
- } catch {
611
- parsed = {};
612
- }
613
- parsed.generator = vars.generatorVersion;
614
- parsed.generated = vars.generatedTimestamp;
718
+
719
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
720
+ fs.writeFileSync(destPath, content, 'utf-8');
721
+ created.push(file);
722
+ generatedFiles.push({ dest: file, content });
723
+ }
724
+
725
+ if (harnessVersionSpec) {
726
+ const rendered = renderTemplate(harnessVersionSpec.src, vars);
727
+ let parsed;
728
+ try {
729
+ parsed = JSON.parse(rendered);
730
+ } catch {
731
+ parsed = {};
732
+ }
733
+ parsed.generator = vars.generatorVersion;
734
+ parsed.generated = vars.generatedTimestamp;
615
735
  parsed.options = optional.selectedSkills.map(s => s.id);
616
736
  parsed.externalRecommendations = optional.selectedRecommendations.map(item => item.id);
617
- parsed.autoCheck = true;
618
- parsed.checksums = computeChecksums(generatedFiles);
619
- // Build sources map from fileSpecs (all writable files, not just generatedFiles)
620
- const allWritableSpecs = writableFiles
621
- .map(f => specsByDest.get(f))
622
- .filter(Boolean);
623
- parsed.sources = computeSources(allWritableSpecs);
624
- const hvContent = JSON.stringify(parsed, null, 2) + '\n';
625
- const hvDestPath = path.join(resolvedDir, ...HARNESS_VERSION_DEST.split('/'));
626
- fs.mkdirSync(path.dirname(hvDestPath), { recursive: true });
627
- fs.writeFileSync(hvDestPath, hvContent, 'utf-8');
628
- created.push(HARNESS_VERSION_DEST);
629
- }
630
-
631
- return {
632
- success: true,
633
- created,
634
- errors,
635
- plan,
636
- summary: createSummary(plan),
637
- warnings,
638
- };
639
-
640
- } catch (err) {
641
- errors.push(err.message);
642
- return {
643
- success: false,
644
- created,
645
- errors,
646
- plan,
647
- summary: createSummary(plan),
648
- warnings,
649
- };
650
- }
651
- }
737
+ parsed.autoCheck = true;
738
+ parsed.checksums = computeChecksums(generatedFiles);
739
+ // Build sources map from fileSpecs (all writable files, not just generatedFiles)
740
+ const allWritableSpecs = writableFiles
741
+ .map(f => specsByDest.get(f))
742
+ .filter(Boolean);
743
+ parsed.sources = computeSources(allWritableSpecs);
744
+ const hvContent = JSON.stringify(parsed, null, 2) + '\n';
745
+ const hvDestPath = path.join(resolvedDir, ...HARNESS_VERSION_DEST.split('/'));
746
+ fs.mkdirSync(path.dirname(hvDestPath), { recursive: true });
747
+ fs.writeFileSync(hvDestPath, hvContent, 'utf-8');
748
+ created.push(HARNESS_VERSION_DEST);
749
+ }
750
+
751
+ return {
752
+ success: true,
753
+ created,
754
+ errors,
755
+ plan,
756
+ summary: createSummary(plan),
757
+ warnings,
758
+ };
759
+
760
+ } catch (err) {
761
+ errors.push(err.message);
762
+ return {
763
+ success: false,
764
+ created,
765
+ errors,
766
+ plan,
767
+ summary: createSummary(plan),
768
+ warnings,
769
+ };
770
+ }
771
+ }