create-harness-vibe-coding 0.8.13 → 0.8.16

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