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.
- package/CHANGELOG.md +165 -0
- package/README-CN.md +41 -9
- package/README.md +39 -9
- package/package.json +2 -1
- package/src/generator.js +601 -472
- package/src/index.js +4 -4
- package/templates/common/.claude/agents/architect-manager.md +1 -0
- package/templates/common/.claude/agents/architect.md +2 -1
- package/templates/common/.claude/agents/codebase-explorer.md +1 -0
- package/templates/common/.claude/agents/context-master.md +2 -1
- package/templates/common/.claude/agents/debugger.md +1 -0
- package/templates/common/.claude/agents/docs-researcher.md +2 -1
- package/templates/common/.claude/agents/explore-manager.md +1 -0
- package/templates/common/.claude/agents/implement-manager.md +1 -0
- package/templates/common/.claude/agents/implementer.md +1 -0
- package/templates/common/.claude/agents/memory-master.md +2 -1
- package/templates/common/.claude/agents/planner.md +3 -2
- package/templates/common/.claude/agents/reflector.md +1 -0
- package/templates/common/.claude/agents/researcher.md +1 -0
- package/templates/common/.claude/agents/review-manager.md +1 -0
- package/templates/common/.claude/agents/reviewer.md +2 -1
- package/templates/common/.claude/agents/task-scribe.md +1 -0
- package/templates/common/.claude/agents/tdd-guide.md +5 -4
- package/templates/common/.claude/agents/test-writer.md +6 -5
- package/templates/common/.claude/agents/verifier.md +1 -0
- package/templates/common/.claude/commands/wf-help.md +1 -0
- package/templates/common/.claude/commands/wf-update.md +68 -10
- package/templates/common/.claude/rules/ecc/common.md +6 -5
- package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +12 -6
- package/templates/common/.claude/skills/tdd/SKILL.md +5 -5
- package/templates/common/.claude/skills/wf/SKILL.md +13 -5
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +119 -0
- package/templates/common/.claude/skills/wf-auto/SKILL.md +27 -7
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +12 -5
- package/templates/common/.claude/skills/wf-learn/SKILL.md +6 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +22 -7
- package/templates/common/.claude/skills/wf-readme/SKILL.md +8 -2
- package/templates/common/.claude/skills/wf-remove/SKILL.md +6 -0
- package/templates/common/.claude/skills/wf-review/SKILL.md +10 -3
- package/templates/common/.claude/skills/wf-update/SKILL.md +41 -4
- package/templates/common/.harness-version +290 -128
- package/templates/common/.opencode/agents/architect-manager.md +1 -0
- package/templates/common/.opencode/agents/architect.md +2 -1
- package/templates/common/.opencode/agents/codebase-explorer.md +1 -0
- package/templates/common/.opencode/agents/context-master.md +2 -1
- package/templates/common/.opencode/agents/debugger.md +1 -0
- package/templates/common/.opencode/agents/docs-researcher.md +2 -1
- package/templates/common/.opencode/agents/explore-manager.md +1 -0
- package/templates/common/.opencode/agents/implement-manager.md +1 -0
- package/templates/common/.opencode/agents/implementer.md +1 -0
- package/templates/common/.opencode/agents/memory-master.md +2 -1
- package/templates/common/.opencode/agents/planner.md +3 -2
- package/templates/common/.opencode/agents/reflector.md +1 -0
- package/templates/common/.opencode/agents/researcher.md +1 -0
- package/templates/common/.opencode/agents/review-manager.md +1 -0
- package/templates/common/.opencode/agents/reviewer.md +2 -1
- package/templates/common/.opencode/agents/task-scribe.md +1 -0
- package/templates/common/.opencode/agents/tdd-guide.md +5 -4
- package/templates/common/.opencode/agents/test-writer.md +6 -5
- package/templates/common/.opencode/agents/verifier.md +1 -0
- package/templates/common/.opencode/commands/wf-auto-spark.md +3 -2
- package/templates/common/.opencode/commands/wf-auto.md +3 -2
- package/templates/common/.opencode/commands/wf-help.md +1 -0
- package/templates/common/.opencode/commands/wf-learn.md +3 -2
- package/templates/common/.opencode/commands/wf-max.md +3 -2
- package/templates/common/.opencode/commands/wf-readme.md +3 -2
- package/templates/common/.opencode/commands/wf-remove.md +3 -2
- package/templates/common/.opencode/commands/wf-review.md +3 -2
- package/templates/common/.opencode/commands/wf-update.md +68 -10
- package/templates/common/.opencode/commands/wf.md +3 -2
- package/templates/common/CLAUDE.md +14 -12
- package/templates/common/Harness/MEMORY.md +21 -18
- package/templates/common/Harness/README.md +41 -39
- package/templates/common/Harness/ownership.manifest.json +815 -0
- package/templates/common/Harness/{architecture.md → project/architecture.md} +1 -1
- package/templates/common/Harness/research/README.md +3 -3
- package/templates/common/Harness/scripts/context-budget.mjs +95 -0
- package/templates/common/Harness/scripts/l2-cache-telemetry.mjs +703 -0
- package/templates/common/Harness/scripts/scan-clean.mjs +13 -3
- package/templates/common/Harness/scripts/validate-harness.mjs +332 -175
- package/templates/common/Harness/scripts/wf-remove.mjs +60 -34
- package/templates/common/Harness/scripts/wf-update-check.mjs +355 -45
- package/templates/common/Harness/settings.json +43 -0
- package/templates/common/Harness/{ECC-GUIDE.md → specs/guides/ECC-GUIDE.md} +4 -4
- package/templates/common/Harness/{SETUP.md → specs/guides/SETUP.md} +32 -35
- package/templates/common/Harness/{extension.md → specs/guides/extension.md} +3 -3
- package/templates/common/Harness/{lifecycle.md → specs/guides/lifecycle.md} +2 -2
- package/templates/common/Harness/{agent-workflow.md → specs/runtime/agent-workflow.md} +6 -6
- package/templates/common/Harness/{context-loading.md → specs/runtime/context-loading.md} +85 -21
- package/templates/common/Harness/{dispatch.md → specs/runtime/dispatch.md} +3 -2
- package/templates/common/Harness/{subagents.md → specs/runtime/subagents.md} +10 -5
- package/templates/common/Harness/{WF-AUTO-SPARK.md → specs/workflows/WF-AUTO-SPARK.md} +2 -2
- package/templates/common/Harness/{WF-AUTO.md → specs/workflows/WF-AUTO.md} +6 -1
- package/templates/common/Harness/{WF-KERNEL.md → specs/workflows/WF-KERNEL.md} +10 -0
- package/templates/common/Harness/{WF-MAX.md → specs/workflows/WF-MAX.md} +10 -5
- package/templates/common/Harness/{WF-STATE.md → specs/workflows/WF-STATE.md} +5 -0
- package/templates/common/Harness/{WF.md → specs/workflows/WF.md} +12 -1
- package/templates/common/README.md +8 -6
- package/templates/common/memory/startup-hints.md +19 -17
- package/templates/optional/skills/browser-e2e/.claude/skills/browser-e2e/SKILL.md +1 -1
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +7 -0
- package/templates/optional/skills/browser-e2e/.opencode/commands/wf-browser.md +3 -2
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +4 -4
- package/templates/optional/skills/github-pr-review/.claude/skills/github-pr-review/SKILL.md +1 -1
- package/templates/optional/skills/python-backend/.claude/skills/python-backend/SKILL.md +1 -1
- package/templates/optional/skills/ts-react-frontend/.claude/skills/ts-react-frontend/SKILL.md +1 -1
- package/templates/optional/skills/ui-ux-review/.claude/skills/ui-ux-review/SKILL.md +1 -1
- /package/templates/common/Harness/{ACCEPTANCE_PROTOCOL.md → specs/protocols/ACCEPTANCE_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{AGENT_ISOLATION.md → specs/protocols/AGENT_ISOLATION.md} +0 -0
- /package/templates/common/Harness/{DEBUG_PROTOCOL.md → specs/protocols/DEBUG_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{HARNESS_BRIDGE.md → specs/protocols/HARNESS_BRIDGE.md} +0 -0
- /package/templates/common/Harness/{MEMORY_PROTOCOL.md → specs/protocols/MEMORY_PROTOCOL.md} +0 -0
- /package/templates/common/Harness/{TASK_ARCHIVE.md → specs/protocols/TASK_ARCHIVE.md} +0 -0
- /package/templates/common/Harness/{TDD-GUIDE.md → specs/protocols/TDD-GUIDE.md} +0 -0
- /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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/^Harness\/
|
|
11
|
-
/^Harness\/
|
|
12
|
-
/^Harness\/
|
|
13
|
-
/^Harness\/
|
|
14
|
-
/^
|
|
15
|
-
|
|
16
|
-
/^
|
|
17
|
-
|
|
18
|
-
/^
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (spec.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
rel = normalizePath(path.relative(
|
|
56
|
-
} else {
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
errors,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
+
}
|