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