create-harness-vibe-coding 0.8.16 → 0.8.18
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 +18 -0
- package/README-CN.md +100 -127
- package/README.md +63 -42
- package/package.json +1 -1
- package/src/generator.js +628 -121
- package/src/index.js +174 -43
- package/src/prompts.js +18 -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 +1 -1
- package/templates/common/.claude/commands/wf-command-create.md +58 -0
- package/templates/common/.claude/commands/wf-help.md +13 -6
- 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-task-archive.md +26 -0
- package/templates/common/.claude/commands/wf-task-list.md +24 -0
- package/templates/common/.claude/commands/wf-task-record.md +24 -0
- package/templates/common/.claude/commands/wf.md +16 -0
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
- package/templates/common/.claude/skills/wf-browser/SKILL.md +176 -0
- package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
- package/templates/common/.claude/skills/wf-help/SKILL.md +30 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +22 -8
- package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
- package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
- package/templates/common/.codex/config.toml +3 -5
- package/templates/common/.harness-version +108 -41
- package/templates/common/.opencode/agents/architect-manager.md +7 -1
- package/templates/common/.opencode/agents/explore-manager.md +10 -1
- package/templates/common/.opencode/agents/implement-manager.md +5 -3
- package/templates/common/.opencode/agents/review-manager.md +5 -2
- package/templates/common/.opencode/commands/wf-browser.md +16 -0
- package/templates/common/.opencode/commands/wf-command-create.md +61 -0
- package/templates/common/.opencode/commands/wf-help.md +13 -6
- package/templates/common/.opencode/commands/wf-max.md +12 -7
- package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
- package/templates/common/.opencode/commands/wf-task-list.md +27 -0
- package/templates/common/.opencode/commands/wf-task-record.md +27 -0
- package/templates/common/CLAUDE.md +9 -7
- package/templates/common/Harness/MEMORY.md +12 -3
- package/templates/common/Harness/README.md +23 -41
- package/templates/common/Harness/ownership.manifest.json +162 -14
- package/templates/common/Harness/scripts/archive-tasks.mjs +12 -220
- package/templates/common/Harness/scripts/scan-clean.mjs +3 -9
- package/templates/common/Harness/scripts/task-state.mjs +1279 -0
- package/templates/common/Harness/scripts/validate-harness.mjs +635 -65
- package/templates/common/Harness/scripts/wf-remove.mjs +37 -5
- package/templates/common/Harness/scripts/wf-update-check.mjs +3 -0
- package/templates/common/Harness/specs/guides/SETUP.md +10 -2
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +49 -23
- package/templates/common/Harness/specs/runtime/command-surface.json +215 -0
- package/templates/common/Harness/specs/runtime/dispatch.md +2 -2
- package/templates/common/Harness/specs/runtime/subagents.md +15 -5
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +6 -6
- package/templates/common/Harness/specs/workflows/WF-KERNEL.md +1 -1
- package/templates/common/Harness/specs/workflows/WF-MAX.md +35 -2
- package/templates/common/Harness/specs/workflows/WF-STATE.md +155 -36
- package/templates/common/Harness/tasks/_template/STATE.json +6 -0
- package/templates/common/opencode.json +1 -0
- package/templates/optional/catalog.json +2 -9
- 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 -201
- package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +0 -119
package/src/generator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
@@ -70,9 +71,45 @@ const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates', 'common');
|
|
|
70
71
|
const OPTIONAL_DIR = path.resolve(__dirname, '..', 'templates', 'optional');
|
|
71
72
|
const OPTIONAL_CATALOG = path.join(OPTIONAL_DIR, 'catalog.json');
|
|
72
73
|
const VALID_CONFLICT_POLICIES = new Set(['fail', 'skip', 'backup', 'overwrite']);
|
|
74
|
+
const VALID_INSTALL_SCOPES = new Set(['project', 'global']);
|
|
75
|
+
const RETIRED_OPTION_MESSAGES = new Map([
|
|
76
|
+
['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.'],
|
|
77
|
+
]);
|
|
73
78
|
const EMPTY_DIRS = [
|
|
74
79
|
'tests',
|
|
75
80
|
];
|
|
81
|
+
const GLOBAL_RUNTIME_EXCLUDE = [
|
|
82
|
+
/^Harness\/PROGRESS\.md$/,
|
|
83
|
+
/^Harness\/tasks\//,
|
|
84
|
+
/^Harness\/research\//,
|
|
85
|
+
/^Harness\/project\//,
|
|
86
|
+
];
|
|
87
|
+
const GLOBAL_PROJECT_STATE_INCLUDE = [
|
|
88
|
+
/^Harness\/\.harness-version$/,
|
|
89
|
+
/^Harness\/PROGRESS\.md$/,
|
|
90
|
+
/^Harness\/settings\.json$/,
|
|
91
|
+
/^Harness\/tasks\//,
|
|
92
|
+
/^Harness\/memory\//,
|
|
93
|
+
/^Harness\/research\//,
|
|
94
|
+
/^Harness\/project\//,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const GLOBAL_PROJECT_TEMPLATE_DESTS = [
|
|
98
|
+
'AGENTS.md',
|
|
99
|
+
'CLAUDE.md',
|
|
100
|
+
'Harness/README.md',
|
|
101
|
+
'Harness/MEMORY.md',
|
|
102
|
+
'Harness/settings.json',
|
|
103
|
+
'Harness/specs/guides/SETUP.md',
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const PROJECT_USER_STATE_PATTERNS = [
|
|
107
|
+
'Harness/PROGRESS.md',
|
|
108
|
+
'Harness/tasks/**',
|
|
109
|
+
'Harness/memory/**',
|
|
110
|
+
'Harness/research/**',
|
|
111
|
+
'Harness/project/**',
|
|
112
|
+
];
|
|
76
113
|
|
|
77
114
|
export function harnessDest(file) {
|
|
78
115
|
if (file === '.harness-version') return 'Harness/.harness-version';
|
|
@@ -120,6 +157,262 @@ function normalizePath(filePath) {
|
|
|
120
157
|
return filePath.replace(/\\/g, '/');
|
|
121
158
|
}
|
|
122
159
|
|
|
160
|
+
function comparablePath(filePath) {
|
|
161
|
+
const resolved = path.resolve(filePath);
|
|
162
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function containsPath(parent, child) {
|
|
166
|
+
const rel = path.relative(comparablePath(parent), comparablePath(child));
|
|
167
|
+
return rel === '' || (rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pathsOverlap(a, b) {
|
|
171
|
+
return containsPath(a, b) || containsPath(b, a);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function defaultGlobalDir() {
|
|
175
|
+
return process.env.HARNESS_GLOBAL_HOME
|
|
176
|
+
|| path.join(os.homedir(), '.harness', 'create-harness-vibe-coding');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function defaultHostGlobalRoots(hostGlobalDir = undefined) {
|
|
180
|
+
if (hostGlobalDir || process.env.HARNESS_HOST_GLOBAL_HOME) {
|
|
181
|
+
const base = path.resolve(process.cwd(), hostGlobalDir || process.env.HARNESS_HOST_GLOBAL_HOME);
|
|
182
|
+
return {
|
|
183
|
+
claude: path.join(base, 'claude'),
|
|
184
|
+
codex: path.join(base, 'codex'),
|
|
185
|
+
opencode: path.join(base, 'opencode'),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
claude: process.env.CLAUDE_HOME || path.join(os.homedir(), '.claude'),
|
|
191
|
+
codex: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
192
|
+
opencode: process.env.OPENCODE_CONFIG_HOME || path.join(os.homedir(), '.config', 'opencode'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isGlobalRuntimeDest(dest) {
|
|
197
|
+
return !GLOBAL_RUNTIME_EXCLUDE.some(pattern => pattern.test(dest));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isGlobalProjectStateDest(dest) {
|
|
201
|
+
return GLOBAL_PROJECT_STATE_INCLUDE.some(pattern => pattern.test(dest));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderInlineTemplate(content, vars) {
|
|
205
|
+
let rendered = content;
|
|
206
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
207
|
+
rendered = rendered.replaceAll(`{{${key}}}`, value);
|
|
208
|
+
}
|
|
209
|
+
return rendered;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function virtualSpec(dest, content) {
|
|
213
|
+
return {
|
|
214
|
+
dest,
|
|
215
|
+
type: 'virtual',
|
|
216
|
+
content,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mapHostSpec(spec, fromPrefix, toPrefix = '') {
|
|
221
|
+
return {
|
|
222
|
+
...spec,
|
|
223
|
+
dest: `${toPrefix}${spec.dest.slice(fromPrefix.length)}`,
|
|
224
|
+
projectDest: spec.dest,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mapExactHostSpec(spec, dest) {
|
|
229
|
+
return {
|
|
230
|
+
...spec,
|
|
231
|
+
dest,
|
|
232
|
+
projectDest: spec.dest,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createHostGlobalFileSpecs(allFileSpecs) {
|
|
237
|
+
const byDest = new Map(allFileSpecs.map(spec => [spec.dest, spec]));
|
|
238
|
+
const claude = [];
|
|
239
|
+
const codex = [];
|
|
240
|
+
const opencode = [];
|
|
241
|
+
|
|
242
|
+
for (const spec of allFileSpecs) {
|
|
243
|
+
if (spec.dest.startsWith('.claude/commands/')) claude.push(mapHostSpec(spec, '.claude/'));
|
|
244
|
+
if (spec.dest.startsWith('.claude/skills/')) claude.push(mapHostSpec(spec, '.claude/'));
|
|
245
|
+
if (spec.dest.startsWith('.claude/agents/')) claude.push(mapHostSpec(spec, '.claude/'));
|
|
246
|
+
if (spec.dest.startsWith('.claude/rules/')) claude.push(mapHostSpec(spec, '.claude/'));
|
|
247
|
+
if (spec.dest.startsWith('.agents/skills/')) codex.push(mapHostSpec(spec, '.agents/'));
|
|
248
|
+
if (spec.dest.startsWith('.opencode/commands/')) opencode.push(mapHostSpec(spec, '.opencode/'));
|
|
249
|
+
if (spec.dest.startsWith('.opencode/agents/')) opencode.push(mapHostSpec(spec, '.opencode/'));
|
|
250
|
+
if (spec.dest.startsWith('.opencode/plugins/')) opencode.push(mapHostSpec(spec, '.opencode/'));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const claudeSettings = byDest.get('.claude/settings.json');
|
|
254
|
+
if (claudeSettings) claude.push(mapExactHostSpec(claudeSettings, 'settings.json'));
|
|
255
|
+
|
|
256
|
+
const codexConfig = byDest.get('.codex/config.toml');
|
|
257
|
+
if (codexConfig) codex.push(mapExactHostSpec(codexConfig, 'config.toml'));
|
|
258
|
+
const codexHooks = byDest.get('.codex/hooks.json');
|
|
259
|
+
if (codexHooks) codex.push(mapExactHostSpec(codexHooks, 'hooks.json'));
|
|
260
|
+
|
|
261
|
+
const opencodeConfig = byDest.get('opencode.json');
|
|
262
|
+
if (opencodeConfig) opencode.push(mapExactHostSpec(opencodeConfig, 'opencode.json'));
|
|
263
|
+
|
|
264
|
+
return { claude, codex, opencode };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createHostGlobalPlans(allFileSpecs, hostGlobalDir = undefined) {
|
|
268
|
+
const roots = defaultHostGlobalRoots(hostGlobalDir);
|
|
269
|
+
const specs = createHostGlobalFileSpecs(allFileSpecs);
|
|
270
|
+
return Object.entries(specs).map(([host, fileSpecs]) => ({
|
|
271
|
+
host,
|
|
272
|
+
root: roots[host],
|
|
273
|
+
fileSpecs,
|
|
274
|
+
specsByDest: new Map(fileSpecs.map(spec => [spec.dest, spec])),
|
|
275
|
+
plan: createPlan(roots[host], fileSpecs),
|
|
276
|
+
candidateSet: new Set(fileSpecs.map(spec => spec.dest)),
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createOwnershipClasses({ resolvedGlobalDir, hostPlans }) {
|
|
281
|
+
const hostGlobalTemplates = {};
|
|
282
|
+
for (const hostPlan of hostPlans || []) {
|
|
283
|
+
hostGlobalTemplates[hostPlan.host] = {
|
|
284
|
+
root: normalizePath(hostPlan.root),
|
|
285
|
+
paths: hostPlan.fileSpecs.map(spec => spec.dest).sort(),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
projectUserState: [...PROJECT_USER_STATE_PATTERNS],
|
|
291
|
+
projectTemplateBridge: [...GLOBAL_PROJECT_TEMPLATE_DESTS],
|
|
292
|
+
globalRuntimeTemplate: resolvedGlobalDir ? normalizePath(resolvedGlobalDir) : null,
|
|
293
|
+
hostGlobalTemplates,
|
|
294
|
+
userOwnedProtection: 'Existing non-Harness files at Harness-interest paths are preserved or surfaced as conflicts; generated project template files follow the selected conflict policy.',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function createGlobalProjectBridgeSpecs({ globalDir }) {
|
|
299
|
+
const globalPath = normalizePath(globalDir);
|
|
300
|
+
return [
|
|
301
|
+
virtualSpec('CLAUDE.md', `# CLAUDE.md
|
|
302
|
+
|
|
303
|
+
This project uses a global Harness runtime at \`${globalPath}\`.
|
|
304
|
+
|
|
305
|
+
Project-local state is authoritative:
|
|
306
|
+
|
|
307
|
+
- Tasks: \`Harness/tasks/\`
|
|
308
|
+
- Active pointer: \`Harness/PROGRESS.md\`
|
|
309
|
+
- Project memory: \`Harness/MEMORY.md\` and \`Harness/memory/\`
|
|
310
|
+
- Project settings: \`Harness/settings.json\`
|
|
311
|
+
|
|
312
|
+
Load global workflow docs, command surfaces, agents, and scripts from \`${globalPath}\`.
|
|
313
|
+
Project settings override global settings. Do not store project task state in the global runtime. Do not store secrets or raw transcripts in project or global memory/settings.
|
|
314
|
+
`),
|
|
315
|
+
virtualSpec('AGENTS.md', `# AGENTS.md
|
|
316
|
+
|
|
317
|
+
Read \`CLAUDE.md\` first. This project uses the global Harness runtime at \`${globalPath}\`.
|
|
318
|
+
|
|
319
|
+
Keep task capsules, project memory, and project settings under local \`Harness/\`; use the global runtime only for shared framework docs, commands, skills, agents, settings defaults, and scripts.
|
|
320
|
+
`),
|
|
321
|
+
virtualSpec('Harness/README.md', `# {{projectName}} - Project Harness State
|
|
322
|
+
|
|
323
|
+
This project is bound to a global Harness runtime at \`${globalPath}\`.
|
|
324
|
+
|
|
325
|
+
Project-local files:
|
|
326
|
+
|
|
327
|
+
- \`Harness/PROGRESS.md\` - active task pointer and task index.
|
|
328
|
+
- \`Harness/tasks/\` - task capsules; always project-local.
|
|
329
|
+
- \`Harness/MEMORY.md\` and \`Harness/memory/\` - project memory.
|
|
330
|
+
- \`Harness/settings.json\` - project settings and overrides.
|
|
331
|
+
- \`Harness/research/\` and \`Harness/project/\` - project facts.
|
|
332
|
+
|
|
333
|
+
Global runtime files:
|
|
334
|
+
|
|
335
|
+
- \`${globalPath}/Harness/README.md\` - shared Harness router.
|
|
336
|
+
- \`${globalPath}/Harness/MEMORY.md\` and \`${globalPath}/Harness/memory/\` - global memory and resource index.
|
|
337
|
+
- \`${globalPath}/Harness/settings.json\` - global settings defaults.
|
|
338
|
+
- \`${globalPath}/.claude/\`, \`${globalPath}/.agents/\`, \`${globalPath}/.opencode/\` - shared runtime command, skill, and agent surfaces.
|
|
339
|
+
|
|
340
|
+
Never discover task context from the global runtime. Resume work from local \`Harness/PROGRESS.md\` and local task \`STATE.json\`.
|
|
341
|
+
Project settings override global settings. Existing user-authored files are preserved unless the user explicitly chooses an overwrite-capable conflict policy.
|
|
342
|
+
`),
|
|
343
|
+
virtualSpec('Harness/MEMORY.md', `# MEMORY.md - {{projectName}} Project Memory
|
|
344
|
+
|
|
345
|
+
Project memory lives here and under \`Harness/memory/\`.
|
|
346
|
+
|
|
347
|
+
Global memory lives under \`${globalPath}/Harness/memory/\` and is only for cross-project lessons and preferences.
|
|
348
|
+
|
|
349
|
+
Project settings live in \`Harness/settings.json\`; global settings defaults live in \`${globalPath}/Harness/settings.json\`. Project settings win.
|
|
350
|
+
|
|
351
|
+
Rules:
|
|
352
|
+
|
|
353
|
+
- Keep project-specific decisions, task evidence, and repo facts in this project.
|
|
354
|
+
- Keep reusable cross-project lessons in global memory only when safe and non-secret.
|
|
355
|
+
- Never store project task state, secrets, raw transcripts, or private project facts in global memory/settings.
|
|
356
|
+
`),
|
|
357
|
+
virtualSpec('Harness/specs/guides/SETUP.md', `# SETUP.md - {{projectName}} Global Harness Bridge
|
|
358
|
+
|
|
359
|
+
This project uses a global Harness runtime at \`${globalPath}\`.
|
|
360
|
+
|
|
361
|
+
Read the full setup guide from \`${globalPath}/Harness/specs/guides/SETUP.md\`.
|
|
362
|
+
|
|
363
|
+
Project-local state remains authoritative:
|
|
364
|
+
|
|
365
|
+
- \`Harness/PROGRESS.md\`
|
|
366
|
+
- \`Harness/tasks/\`
|
|
367
|
+
- \`Harness/memory/\`
|
|
368
|
+
- \`Harness/settings.json\`
|
|
369
|
+
- \`Harness/research/\`
|
|
370
|
+
- \`Harness/project/\`
|
|
371
|
+
|
|
372
|
+
Run validators and shared scripts from the global runtime unless this project later installs a full project-local Harness runtime.
|
|
373
|
+
Project settings override global settings. Existing user-authored files at Harness-interest paths are preserved or surfaced for review; generated project template files follow the selected conflict policy.
|
|
374
|
+
`),
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function installMetadata({ installScope, resolvedGlobalDir, hostPlans = [] }) {
|
|
379
|
+
if (installScope !== 'global') return { installScope: 'project' };
|
|
380
|
+
return {
|
|
381
|
+
installScope: 'global',
|
|
382
|
+
globalDir: normalizePath(resolvedGlobalDir),
|
|
383
|
+
copyMode: 'copy',
|
|
384
|
+
projectState: {
|
|
385
|
+
tasks: 'Harness/tasks/',
|
|
386
|
+
progress: 'Harness/PROGRESS.md',
|
|
387
|
+
memory: 'Harness/memory/',
|
|
388
|
+
settings: 'Harness/settings.json',
|
|
389
|
+
research: 'Harness/research/',
|
|
390
|
+
project: 'Harness/project/',
|
|
391
|
+
},
|
|
392
|
+
memoryScopes: {
|
|
393
|
+
project: 'Harness/memory/',
|
|
394
|
+
global: `${normalizePath(resolvedGlobalDir)}/Harness/memory/`,
|
|
395
|
+
},
|
|
396
|
+
settingsScopes: {
|
|
397
|
+
precedence: ['project', 'global'],
|
|
398
|
+
project: 'Harness/settings.json',
|
|
399
|
+
global: `${normalizePath(resolvedGlobalDir)}/Harness/settings.json`,
|
|
400
|
+
secretsPolicy: 'never store secrets in project/global settings or memory',
|
|
401
|
+
},
|
|
402
|
+
hostGlobal: {
|
|
403
|
+
copyMode: 'copy',
|
|
404
|
+
targets: Object.fromEntries(hostPlans.map(hostPlan => [
|
|
405
|
+
hostPlan.host,
|
|
406
|
+
{
|
|
407
|
+
root: normalizePath(hostPlan.root),
|
|
408
|
+
files: hostPlan.fileSpecs.map(spec => spec.dest).sort(),
|
|
409
|
+
},
|
|
410
|
+
])),
|
|
411
|
+
},
|
|
412
|
+
ownershipClasses: createOwnershipClasses({ resolvedGlobalDir, hostPlans }),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
123
416
|
export function getOptionalCatalog() {
|
|
124
417
|
if (!fs.existsSync(OPTIONAL_CATALOG)) {
|
|
125
418
|
return { skills: [], presets: {}, externalRecommendations: [] };
|
|
@@ -154,6 +447,7 @@ function resolveOptionalSelection({
|
|
|
154
447
|
const recommendationsById = new Map(externalRecommendations(catalog).map(item => [item.id, item]));
|
|
155
448
|
const selected = [];
|
|
156
449
|
const errors = [];
|
|
450
|
+
const warnings = [];
|
|
157
451
|
|
|
158
452
|
if (preset) {
|
|
159
453
|
if (!catalog.presets[preset]) {
|
|
@@ -168,12 +462,16 @@ function resolveOptionalSelection({
|
|
|
168
462
|
const ids = [...new Set(selected)];
|
|
169
463
|
const withoutIds = [...new Set(normalizeOptionList(withoutOptions))];
|
|
170
464
|
for (const id of ids) {
|
|
171
|
-
if (
|
|
465
|
+
if (RETIRED_OPTION_MESSAGES.has(id)) {
|
|
466
|
+
warnings.push(RETIRED_OPTION_MESSAGES.get(id));
|
|
467
|
+
} else if (!skillsById.has(id)) {
|
|
172
468
|
errors.push(`Unknown optional skill "${id}". Run --list-options to see available options.`);
|
|
173
469
|
}
|
|
174
470
|
}
|
|
175
471
|
for (const id of withoutIds) {
|
|
176
|
-
if (
|
|
472
|
+
if (RETIRED_OPTION_MESSAGES.has(id)) {
|
|
473
|
+
warnings.push(`Optional workflow "${id}" is retired and built in/no-op for --without.`);
|
|
474
|
+
} else if (!skillsById.has(id)) {
|
|
177
475
|
errors.push(`Unknown optional skill in --without "${id}". Run --list-options to see available options.`);
|
|
178
476
|
}
|
|
179
477
|
}
|
|
@@ -186,13 +484,14 @@ function resolveOptionalSelection({
|
|
|
186
484
|
}
|
|
187
485
|
|
|
188
486
|
const withoutSet = new Set(withoutIds);
|
|
189
|
-
const selectedIds = ids.filter(id => !withoutSet.has(id));
|
|
487
|
+
const selectedIds = ids.filter(id => !withoutSet.has(id) && !RETIRED_OPTION_MESSAGES.has(id));
|
|
190
488
|
|
|
191
489
|
return {
|
|
192
490
|
catalog,
|
|
193
491
|
selectedSkills: errors.length ? [] : selectedIds.map(id => skillsById.get(id)),
|
|
194
492
|
selectedRecommendations: errors.length ? [] : recommendationIds.map(id => recommendationsById.get(id)),
|
|
195
493
|
errors,
|
|
494
|
+
warnings,
|
|
196
495
|
};
|
|
197
496
|
}
|
|
198
497
|
|
|
@@ -278,7 +577,7 @@ function symlinkInChain(rootDir, absPath) {
|
|
|
278
577
|
return null;
|
|
279
578
|
}
|
|
280
579
|
|
|
281
|
-
function createPlan(resolvedDir, fileSpecs) {
|
|
580
|
+
function createPlan(resolvedDir, fileSpecs, { includeEmptyDirs = true } = {}) {
|
|
282
581
|
const plan = {
|
|
283
582
|
create: [],
|
|
284
583
|
skip: [],
|
|
@@ -299,8 +598,10 @@ function createPlan(resolvedDir, fileSpecs) {
|
|
|
299
598
|
dir = path.posix.dirname(dir);
|
|
300
599
|
}
|
|
301
600
|
}
|
|
302
|
-
|
|
303
|
-
|
|
601
|
+
if (includeEmptyDirs) {
|
|
602
|
+
for (const dir of EMPTY_DIRS) {
|
|
603
|
+
dirSet.add(dir);
|
|
604
|
+
}
|
|
304
605
|
}
|
|
305
606
|
|
|
306
607
|
for (const dir of [...dirSet].sort()) {
|
|
@@ -350,7 +651,7 @@ function readOwnershipManifest() {
|
|
|
350
651
|
*
|
|
351
652
|
* Manifest-present: candidate set = manifest.frameworkOwned paths UNION the
|
|
352
653
|
* selected optional skills' manifest.optionalOwned paths. This includes optional
|
|
353
|
-
* skill paths like
|
|
654
|
+
* skill paths like ui-ux-review (fixing the bug where same-name user files at
|
|
354
655
|
* optional-skill paths were clobbered).
|
|
355
656
|
*
|
|
356
657
|
* Manifest-absent (null): candidate set = all fileSpec dests (the exact set of
|
|
@@ -405,20 +706,33 @@ export function isHarnessOwnedContent(content) {
|
|
|
405
706
|
/**
|
|
406
707
|
* Returns true if an existing on-disk file is a Harness-interest candidate path
|
|
407
708
|
* (per the manifest-first candidate set) that was NOT authored by this scaffold
|
|
408
|
-
* (candidate path + no ownership marker
|
|
409
|
-
* (skip) regardless of the conflict policy,
|
|
709
|
+
* (candidate path + no ownership marker + no matching template content).
|
|
710
|
+
* Such user-authored files are preserved (skip) regardless of the conflict policy,
|
|
711
|
+
* mirroring the updater safety rule.
|
|
712
|
+
*
|
|
713
|
+
* Ownership is determined by content marker first, then by exact template content
|
|
714
|
+
* match (for JSON/TOML configs without markers). Manifest declaration alone does
|
|
715
|
+
* NOT grant ownership — a user file at a manifest path without marker is still
|
|
716
|
+
* user-owned.
|
|
410
717
|
*/
|
|
411
|
-
function existingCandidateIsUserOwned(dest, destPath, candidateSet) {
|
|
718
|
+
function existingCandidateIsUserOwned(dest, destPath, candidateSet, srcPath = null) {
|
|
412
719
|
if (!candidateSet.has(dest)) return false;
|
|
413
720
|
try {
|
|
414
721
|
const content = fs.readFileSync(destPath, 'utf-8');
|
|
415
|
-
|
|
722
|
+
// Harness content marker present → Harness-owned
|
|
723
|
+
if (isHarnessOwnedContent(content)) return false;
|
|
724
|
+
// Same content as template source → Harness-generated → Harness-owned
|
|
725
|
+
if (srcPath && fs.existsSync(srcPath)) {
|
|
726
|
+
const templateContent = fs.readFileSync(srcPath, 'utf-8');
|
|
727
|
+
if (content === templateContent) return false;
|
|
728
|
+
}
|
|
729
|
+
return true; // user-owned: no marker, not template content
|
|
416
730
|
} catch {
|
|
417
731
|
return false;
|
|
418
732
|
}
|
|
419
733
|
}
|
|
420
734
|
|
|
421
|
-
function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet) {
|
|
735
|
+
function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet, specsByDest = null) {
|
|
422
736
|
for (const { dest: file } of fileSpecs) {
|
|
423
737
|
const destPath = path.join(resolvedDir, ...file.split('/'));
|
|
424
738
|
|
|
@@ -436,15 +750,20 @@ function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet)
|
|
|
436
750
|
continue;
|
|
437
751
|
}
|
|
438
752
|
|
|
753
|
+
// Look up the template source path for content comparison
|
|
754
|
+
const spec = specsByDest ? specsByDest.get(file) : null;
|
|
755
|
+
const srcPath = spec && spec.type !== 'empty' && spec.type !== 'virtual' ? spec.src : null;
|
|
756
|
+
|
|
439
757
|
// G4 guard: a non-Harness same-name candidate (any Harness-interest path
|
|
440
758
|
// — agent/command/skill/optional-skill/script per the manifest-first
|
|
441
759
|
// candidate set — without an ownership marker) must NEVER be overwritten.
|
|
442
760
|
// Under overwrite/backup/skip it is preserved via plan.skip; under fail it
|
|
443
761
|
// is blocked via plan.conflict so the collision surfaces (matching the fail
|
|
444
|
-
// branch below). Only Harness-owned files (marker present
|
|
445
|
-
// policy switch. Mirrors the
|
|
446
|
-
// existingCandidateIsUserOwned /
|
|
447
|
-
|
|
762
|
+
// branch below). Only Harness-owned files (marker present or template
|
|
763
|
+
// content match) reach the normal policy switch. Mirrors the
|
|
764
|
+
// wf-update-check.mjs safety rule; see existingCandidateIsUserOwned /
|
|
765
|
+
// isHarnessOwnedContent above.
|
|
766
|
+
if (existingCandidateIsUserOwned(file, destPath, candidateSet, srcPath)) {
|
|
448
767
|
if (onConflict === 'fail') {
|
|
449
768
|
plan.conflict.push(file);
|
|
450
769
|
} else {
|
|
@@ -477,6 +796,14 @@ function createSummary(plan, { noWrites = false } = {}) {
|
|
|
477
796
|
};
|
|
478
797
|
}
|
|
479
798
|
|
|
799
|
+
function createHostSummary(hostPlans, options = {}) {
|
|
800
|
+
return hostPlans.map(hostPlan => ({
|
|
801
|
+
host: hostPlan.host,
|
|
802
|
+
root: normalizePath(hostPlan.root),
|
|
803
|
+
summary: createSummary(hostPlan.plan, options),
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
|
|
480
807
|
function nextBackupPath(destPath) {
|
|
481
808
|
const first = `${destPath}.harness-backup`;
|
|
482
809
|
if (!fs.existsSync(first)) {
|
|
@@ -493,7 +820,6 @@ function nextBackupPath(destPath) {
|
|
|
493
820
|
|
|
494
821
|
function registerOptionalContent(file, content, selectedSkills) {
|
|
495
822
|
if (!selectedSkills.length) return content;
|
|
496
|
-
const hasBrowserE2e = selectedSkills.some(skill => skill.id === 'browser-e2e');
|
|
497
823
|
|
|
498
824
|
if (file === 'Harness/MEMORY.md') {
|
|
499
825
|
const lines = selectedSkills.map(skill => (
|
|
@@ -509,25 +835,7 @@ function registerOptionalContent(file, content, selectedSkills) {
|
|
|
509
835
|
const lines = selectedSkills.map(skill => (
|
|
510
836
|
`- [${skill.title}](workflows/${skill.id}.md) - ${skill.description}`
|
|
511
837
|
));
|
|
512
|
-
|
|
513
|
-
if (hasBrowserE2e) {
|
|
514
|
-
next = next.replace(
|
|
515
|
-
'| Optional workflow installed |',
|
|
516
|
-
'| 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 |',
|
|
517
|
-
);
|
|
518
|
-
next = next.replace(
|
|
519
|
-
'| `/wf-readme [task]` |',
|
|
520
|
-
'| `/wf-browser [task]` | `$wf-browser [task]` | Optional browser automation/E2E workflow when `browser-e2e` is installed |\n| `/wf-readme [task]` |',
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
return `${next.trimEnd()}\n\n## Installed Optional Workflows\n\n${lines.join('\n')}\n`;
|
|
524
|
-
}
|
|
525
|
-
|
|
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
|
-
);
|
|
838
|
+
return `${content.trimEnd()}\n\n## Installed Optional Workflows\n\n${lines.join('\n')}\n`;
|
|
531
839
|
}
|
|
532
840
|
|
|
533
841
|
return content;
|
|
@@ -572,6 +880,103 @@ function registrationWarnings(plan, selectedSkills) {
|
|
|
572
880
|
return warnings;
|
|
573
881
|
}
|
|
574
882
|
|
|
883
|
+
function writeGeneratedPlan({
|
|
884
|
+
plan,
|
|
885
|
+
fileSpecs,
|
|
886
|
+
specsByDest,
|
|
887
|
+
resolvedDir,
|
|
888
|
+
vars,
|
|
889
|
+
optional,
|
|
890
|
+
metadata,
|
|
891
|
+
createdPrefix = '',
|
|
892
|
+
}) {
|
|
893
|
+
const created = [];
|
|
894
|
+
const generatedFiles = [];
|
|
895
|
+
|
|
896
|
+
for (const dir of plan.mkdir) {
|
|
897
|
+
const dirPath = dir === './'
|
|
898
|
+
? resolvedDir
|
|
899
|
+
: path.join(resolvedDir, ...dir.slice(0, -1).split('/'));
|
|
900
|
+
const __slMkdir = symlinkInChain(resolvedDir, dirPath);
|
|
901
|
+
if (__slMkdir) throw new Error(`Cannot create directory ${createdPrefix}${dir}: symlinked directory in path (${__slMkdir})`);
|
|
902
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
903
|
+
created.push(`${createdPrefix}${dir === './' ? './ (directory)' : `${dir} (directory)`}`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
for (const file of plan.backup) {
|
|
907
|
+
const destPath = path.join(resolvedDir, ...file.split('/'));
|
|
908
|
+
const __slBackup = symlinkInChain(resolvedDir, destPath);
|
|
909
|
+
if (__slBackup) throw new Error(`Cannot backup ${createdPrefix}${file}: symlinked directory in path (${__slBackup})`);
|
|
910
|
+
if (fs.existsSync(destPath)) {
|
|
911
|
+
try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot backup ${createdPrefix}${file}: ${e.message}`); }
|
|
912
|
+
}
|
|
913
|
+
fs.renameSync(destPath, nextBackupPath(destPath));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const writableFiles = [
|
|
917
|
+
...plan.create,
|
|
918
|
+
...plan.overwrite,
|
|
919
|
+
...plan.backup,
|
|
920
|
+
];
|
|
921
|
+
|
|
922
|
+
const HARNESS_VERSION_DEST = 'Harness/.harness-version';
|
|
923
|
+
let harnessVersionSpec = null;
|
|
924
|
+
|
|
925
|
+
for (const file of writableFiles) {
|
|
926
|
+
if (file === HARNESS_VERSION_DEST) {
|
|
927
|
+
harnessVersionSpec = specsByDest.get(file);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const spec = specsByDest.get(file);
|
|
931
|
+
const destPath = path.join(resolvedDir, ...file.split('/'));
|
|
932
|
+
const __slWrite = symlinkInChain(resolvedDir, destPath);
|
|
933
|
+
if (__slWrite) throw new Error(`Cannot create/overwrite ${createdPrefix}${file}: symlinked directory in path (${__slWrite})`);
|
|
934
|
+
if (fs.existsSync(destPath)) {
|
|
935
|
+
try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot create/overwrite ${createdPrefix}${file}: ${e.message}`); }
|
|
936
|
+
}
|
|
937
|
+
let content = spec.type === 'empty'
|
|
938
|
+
? ''
|
|
939
|
+
: spec.type === 'virtual'
|
|
940
|
+
? renderInlineTemplate(spec.content, vars)
|
|
941
|
+
: renderTemplate(spec.src, vars);
|
|
942
|
+
content = registerOptionalContent(file, content, optional.selectedSkills);
|
|
943
|
+
content = registerExternalRecommendations(file, content, optional.selectedRecommendations);
|
|
944
|
+
|
|
945
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
946
|
+
fs.writeFileSync(destPath, content, 'utf-8');
|
|
947
|
+
created.push(`${createdPrefix}${file}`);
|
|
948
|
+
generatedFiles.push({ dest: file, content });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (harnessVersionSpec) {
|
|
952
|
+
const rendered = renderTemplate(harnessVersionSpec.src, vars);
|
|
953
|
+
let parsed;
|
|
954
|
+
try {
|
|
955
|
+
parsed = JSON.parse(rendered);
|
|
956
|
+
} catch {
|
|
957
|
+
parsed = {};
|
|
958
|
+
}
|
|
959
|
+
parsed.generator = vars.generatorVersion;
|
|
960
|
+
parsed.generated = vars.generatedTimestamp;
|
|
961
|
+
parsed.options = optional.selectedSkills.map(s => s.id);
|
|
962
|
+
parsed.externalRecommendations = optional.selectedRecommendations.map(item => item.id);
|
|
963
|
+
parsed.autoCheck = true;
|
|
964
|
+
Object.assign(parsed, metadata);
|
|
965
|
+
parsed.checksums = computeChecksums(generatedFiles);
|
|
966
|
+
const allWritableSpecs = writableFiles
|
|
967
|
+
.map(f => specsByDest.get(f))
|
|
968
|
+
.filter(Boolean);
|
|
969
|
+
parsed.sources = computeSources(allWritableSpecs);
|
|
970
|
+
const hvContent = JSON.stringify(parsed, null, 2) + '\n';
|
|
971
|
+
const hvDestPath = path.join(resolvedDir, ...HARNESS_VERSION_DEST.split('/'));
|
|
972
|
+
fs.mkdirSync(path.dirname(hvDestPath), { recursive: true });
|
|
973
|
+
fs.writeFileSync(hvDestPath, hvContent, 'utf-8');
|
|
974
|
+
created.push(`${createdPrefix}${HARNESS_VERSION_DEST}`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return { created, generatedFiles };
|
|
978
|
+
}
|
|
979
|
+
|
|
575
980
|
export function generate({
|
|
576
981
|
projectName,
|
|
577
982
|
targetDir,
|
|
@@ -581,6 +986,9 @@ export function generate({
|
|
|
581
986
|
withoutOptions = [],
|
|
582
987
|
preset = undefined,
|
|
583
988
|
externalOptions = [],
|
|
989
|
+
installScope = 'project',
|
|
990
|
+
globalDir = undefined,
|
|
991
|
+
hostGlobalDir = undefined,
|
|
584
992
|
}) {
|
|
585
993
|
const created = [];
|
|
586
994
|
const errors = [];
|
|
@@ -588,6 +996,10 @@ export function generate({
|
|
|
588
996
|
|
|
589
997
|
// Resolve targetDir relative to cwd
|
|
590
998
|
const resolvedDir = path.resolve(process.cwd(), targetDir);
|
|
999
|
+
const normalizedInstallScope = String(installScope || 'project').trim().toLowerCase();
|
|
1000
|
+
const resolvedGlobalDir = normalizedInstallScope === 'global'
|
|
1001
|
+
? path.resolve(process.cwd(), globalDir || defaultGlobalDir())
|
|
1002
|
+
: undefined;
|
|
591
1003
|
const vars = {
|
|
592
1004
|
projectName,
|
|
593
1005
|
generatorVersion: pkg.version,
|
|
@@ -595,19 +1007,97 @@ export function generate({
|
|
|
595
1007
|
};
|
|
596
1008
|
|
|
597
1009
|
const optional = resolveOptionalSelection({ withOptions, withoutOptions, preset, externalOptions });
|
|
598
|
-
|
|
1010
|
+
warnings.push(...optional.warnings);
|
|
1011
|
+
const allFileSpecs = [
|
|
599
1012
|
...createCoreFileSpecs(),
|
|
600
1013
|
...createOptionalFileSpecs(optional.selectedSkills),
|
|
601
1014
|
];
|
|
1015
|
+
const fileSpecs = normalizedInstallScope === 'global'
|
|
1016
|
+
? [
|
|
1017
|
+
...createGlobalProjectBridgeSpecs({ globalDir: resolvedGlobalDir }),
|
|
1018
|
+
...allFileSpecs.filter(spec => isGlobalProjectStateDest(spec.dest)),
|
|
1019
|
+
]
|
|
1020
|
+
: allFileSpecs;
|
|
602
1021
|
const specsByDest = new Map(fileSpecs.map(spec => [spec.dest, spec]));
|
|
603
|
-
const plan = createPlan(resolvedDir, fileSpecs);
|
|
1022
|
+
const plan = createPlan(resolvedDir, fileSpecs, { includeEmptyDirs: normalizedInstallScope !== 'global' });
|
|
1023
|
+
const globalFileSpecs = normalizedInstallScope === 'global'
|
|
1024
|
+
? allFileSpecs.filter(spec => isGlobalRuntimeDest(spec.dest))
|
|
1025
|
+
: [];
|
|
1026
|
+
const globalSpecsByDest = new Map(globalFileSpecs.map(spec => [spec.dest, spec]));
|
|
1027
|
+
const globalPlan = normalizedInstallScope === 'global'
|
|
1028
|
+
? createPlan(resolvedGlobalDir, globalFileSpecs)
|
|
1029
|
+
: undefined;
|
|
1030
|
+
const hostPlans = normalizedInstallScope === 'global'
|
|
1031
|
+
? createHostGlobalPlans(allFileSpecs, hostGlobalDir)
|
|
1032
|
+
: [];
|
|
604
1033
|
const duplicateDestinations = duplicateDests(fileSpecs);
|
|
1034
|
+
const duplicateHostDestinations = hostPlans
|
|
1035
|
+
.flatMap(hostPlan => duplicateDests(hostPlan.fileSpecs).map(dest => `${hostPlan.host}:${dest}`));
|
|
605
1036
|
const candidateSet = buildHarnessInterestCandidateSet(
|
|
606
1037
|
readOwnershipManifest(),
|
|
607
|
-
|
|
1038
|
+
allFileSpecs,
|
|
608
1039
|
optional.selectedSkills,
|
|
609
1040
|
);
|
|
610
1041
|
|
|
1042
|
+
if (!VALID_INSTALL_SCOPES.has(normalizedInstallScope)) {
|
|
1043
|
+
errors.push(`Unknown install scope "${installScope}". Use project or global.`);
|
|
1044
|
+
return {
|
|
1045
|
+
success: false,
|
|
1046
|
+
created,
|
|
1047
|
+
errors,
|
|
1048
|
+
plan,
|
|
1049
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1050
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
1051
|
+
summary: createSummary(plan, { noWrites: true }),
|
|
1052
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }) } : {}),
|
|
1053
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
|
|
1054
|
+
warnings,
|
|
1055
|
+
installScope: normalizedInstallScope,
|
|
1056
|
+
...(resolvedGlobalDir ? { globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (resolvedGlobalDir && pathsOverlap(resolvedDir, resolvedGlobalDir)) {
|
|
1061
|
+
errors.push('Global install directory must be outside the target project and must not contain the target project.');
|
|
1062
|
+
return {
|
|
1063
|
+
success: false,
|
|
1064
|
+
created,
|
|
1065
|
+
errors,
|
|
1066
|
+
plan,
|
|
1067
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1068
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
1069
|
+
summary: createSummary(plan, { noWrites: true }),
|
|
1070
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }), globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1071
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
|
|
1072
|
+
warnings,
|
|
1073
|
+
installScope: normalizedInstallScope,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const hostPlan of hostPlans) {
|
|
1078
|
+
if (pathsOverlap(resolvedDir, hostPlan.root)) {
|
|
1079
|
+
errors.push(`Host-global ${hostPlan.host} directory must be outside the target project.`);
|
|
1080
|
+
}
|
|
1081
|
+
if (resolvedGlobalDir && pathsOverlap(resolvedGlobalDir, hostPlan.root)) {
|
|
1082
|
+
errors.push(`Host-global ${hostPlan.host} directory must be outside the global runtime directory.`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (errors.length > 0) {
|
|
1086
|
+
return {
|
|
1087
|
+
success: false,
|
|
1088
|
+
created,
|
|
1089
|
+
errors,
|
|
1090
|
+
plan,
|
|
1091
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1092
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
1093
|
+
summary: createSummary(plan, { noWrites: true }),
|
|
1094
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }), globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1095
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
|
|
1096
|
+
warnings,
|
|
1097
|
+
installScope: normalizedInstallScope,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
611
1101
|
if (!VALID_CONFLICT_POLICIES.has(onConflict)) {
|
|
612
1102
|
errors.push(`Unknown conflict policy "${onConflict}". Use fail, skip, backup, or overwrite.`);
|
|
613
1103
|
return {
|
|
@@ -643,9 +1133,34 @@ export function generate({
|
|
|
643
1133
|
warnings,
|
|
644
1134
|
};
|
|
645
1135
|
}
|
|
1136
|
+
if (duplicateHostDestinations.length > 0) {
|
|
1137
|
+
errors.push(`Generation stopped because duplicate host-global destination(s) were found: ${duplicateHostDestinations.join(', ')}`);
|
|
1138
|
+
return {
|
|
1139
|
+
success: false,
|
|
1140
|
+
created,
|
|
1141
|
+
errors,
|
|
1142
|
+
plan,
|
|
1143
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1144
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
1145
|
+
summary: createSummary(plan, { noWrites: true }),
|
|
1146
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }) } : {}),
|
|
1147
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
|
|
1148
|
+
warnings,
|
|
1149
|
+
installScope: normalizedInstallScope,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
646
1152
|
|
|
647
|
-
addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet);
|
|
1153
|
+
addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet, specsByDest);
|
|
1154
|
+
if (globalPlan) {
|
|
1155
|
+
addFileActions(globalPlan, globalFileSpecs, resolvedGlobalDir, onConflict, candidateSet, globalSpecsByDest);
|
|
1156
|
+
}
|
|
1157
|
+
for (const hostPlan of hostPlans) {
|
|
1158
|
+
addFileActions(hostPlan.plan, hostPlan.fileSpecs, hostPlan.root, onConflict, hostPlan.candidateSet, hostPlan.specsByDest);
|
|
1159
|
+
}
|
|
648
1160
|
warnings.push(...registrationWarnings(plan, optional.selectedSkills));
|
|
1161
|
+
if (globalPlan) {
|
|
1162
|
+
warnings.push('Global install scope selected: shared runtime assets are written to the global directory and copied into Claude Code, Codex, and OpenCode host-global surfaces; Harness/tasks and Harness/PROGRESS.md remain project-local.');
|
|
1163
|
+
}
|
|
649
1164
|
|
|
650
1165
|
if (dryRun) {
|
|
651
1166
|
return {
|
|
@@ -653,14 +1168,30 @@ export function generate({
|
|
|
653
1168
|
created,
|
|
654
1169
|
errors,
|
|
655
1170
|
plan,
|
|
1171
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1172
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
656
1173
|
summary: createSummary(plan),
|
|
1174
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan), globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1175
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans) } : {}),
|
|
1176
|
+
...(globalPlan ? { ownershipClasses: createOwnershipClasses({ resolvedGlobalDir, hostPlans }) } : {}),
|
|
657
1177
|
warnings,
|
|
658
1178
|
dryRun: true,
|
|
1179
|
+
installScope: normalizedInstallScope,
|
|
659
1180
|
};
|
|
660
1181
|
}
|
|
661
1182
|
|
|
662
|
-
|
|
663
|
-
|
|
1183
|
+
const globalConflictCount = globalPlan?.conflict.length || 0;
|
|
1184
|
+
const hostConflicts = hostPlans.flatMap(hostPlan => hostPlan.plan.conflict.map(file => `${hostPlan.host}:${file}`));
|
|
1185
|
+
if (plan.conflict.length > 0 || globalConflictCount > 0 || hostConflicts.length > 0) {
|
|
1186
|
+
if (plan.conflict.length > 0) {
|
|
1187
|
+
errors.push(`Generation stopped because ${plan.conflict.length} file conflict(s) were found: ${plan.conflict.join(', ')}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (globalConflictCount > 0) {
|
|
1190
|
+
errors.push(`Generation stopped because ${globalConflictCount} global file conflict(s) were found: ${globalPlan.conflict.join(', ')}`);
|
|
1191
|
+
}
|
|
1192
|
+
if (hostConflicts.length > 0) {
|
|
1193
|
+
errors.push(`Generation stopped because ${hostConflicts.length} host-global file conflict(s) were found: ${hostConflicts.join(', ')}`);
|
|
1194
|
+
}
|
|
664
1195
|
if (plan.conflict.includes('CLAUDE.md')) {
|
|
665
1196
|
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
1197
|
}
|
|
@@ -669,92 +1200,58 @@ export function generate({
|
|
|
669
1200
|
created,
|
|
670
1201
|
errors,
|
|
671
1202
|
plan,
|
|
1203
|
+
...(globalPlan ? { globalPlan } : {}),
|
|
1204
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
672
1205
|
summary: createSummary(plan, { noWrites: true }),
|
|
1206
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }), globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1207
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
|
|
673
1208
|
warnings,
|
|
1209
|
+
installScope: normalizedInstallScope,
|
|
674
1210
|
};
|
|
675
1211
|
}
|
|
676
1212
|
|
|
677
1213
|
try {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1214
|
+
const metadata = installMetadata({ installScope: normalizedInstallScope, resolvedGlobalDir, hostPlans });
|
|
1215
|
+
const projectWrite = writeGeneratedPlan({
|
|
1216
|
+
plan,
|
|
1217
|
+
fileSpecs,
|
|
1218
|
+
specsByDest,
|
|
1219
|
+
resolvedDir,
|
|
1220
|
+
vars,
|
|
1221
|
+
optional,
|
|
1222
|
+
metadata,
|
|
1223
|
+
});
|
|
1224
|
+
created.push(...projectWrite.created);
|
|
1225
|
+
|
|
1226
|
+
let globalCreated = [];
|
|
1227
|
+
let hostCreated = [];
|
|
1228
|
+
if (globalPlan) {
|
|
1229
|
+
const globalWrite = writeGeneratedPlan({
|
|
1230
|
+
plan: globalPlan,
|
|
1231
|
+
fileSpecs: globalFileSpecs,
|
|
1232
|
+
specsByDest: globalSpecsByDest,
|
|
1233
|
+
resolvedDir: resolvedGlobalDir,
|
|
1234
|
+
vars,
|
|
1235
|
+
optional,
|
|
1236
|
+
metadata,
|
|
1237
|
+
createdPrefix: 'global:',
|
|
1238
|
+
});
|
|
1239
|
+
created.push(...globalWrite.created);
|
|
1240
|
+
globalCreated = globalWrite.created;
|
|
697
1241
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
harnessVersionSpec = specsByDest.get(file);
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
const spec = specsByDest.get(file);
|
|
715
|
-
const destPath = path.join(resolvedDir, ...file.split('/'));
|
|
716
|
-
const __slWrite = symlinkInChain(resolvedDir, destPath);
|
|
717
|
-
if (__slWrite) throw new Error(`Cannot create/overwrite ${file}: symlinked directory in path (${__slWrite})`);
|
|
718
|
-
// Symlink safety: reject symlinks before writing to prevent path traversal outside workspace
|
|
719
|
-
if (fs.existsSync(destPath)) {
|
|
720
|
-
try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot create/overwrite ${file}: ${e.message}`); }
|
|
721
|
-
}
|
|
722
|
-
let content = spec.type === 'empty'
|
|
723
|
-
? ''
|
|
724
|
-
: renderTemplate(spec.src, vars);
|
|
725
|
-
content = registerOptionalContent(file, content, optional.selectedSkills);
|
|
726
|
-
content = registerExternalRecommendations(file, content, optional.selectedRecommendations);
|
|
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;
|
|
744
|
-
parsed.options = optional.selectedSkills.map(s => s.id);
|
|
745
|
-
parsed.externalRecommendations = optional.selectedRecommendations.map(item => item.id);
|
|
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);
|
|
1242
|
+
for (const hostPlan of hostPlans) {
|
|
1243
|
+
const hostWrite = writeGeneratedPlan({
|
|
1244
|
+
plan: hostPlan.plan,
|
|
1245
|
+
fileSpecs: hostPlan.fileSpecs,
|
|
1246
|
+
specsByDest: hostPlan.specsByDest,
|
|
1247
|
+
resolvedDir: hostPlan.root,
|
|
1248
|
+
vars,
|
|
1249
|
+
optional,
|
|
1250
|
+
metadata,
|
|
1251
|
+
createdPrefix: `host:${hostPlan.host}:`,
|
|
1252
|
+
});
|
|
1253
|
+
created.push(...hostWrite.created);
|
|
1254
|
+
hostCreated.push(...hostWrite.created);
|
|
758
1255
|
}
|
|
759
1256
|
|
|
760
1257
|
return {
|
|
@@ -762,8 +1259,13 @@ export function generate({
|
|
|
762
1259
|
created,
|
|
763
1260
|
errors,
|
|
764
1261
|
plan,
|
|
1262
|
+
...(globalPlan ? { globalPlan, globalCreated, globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1263
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })), hostCreated } : {}),
|
|
765
1264
|
summary: createSummary(plan),
|
|
1265
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan) } : {}),
|
|
1266
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans), ownershipClasses: createOwnershipClasses({ resolvedGlobalDir, hostPlans }) } : {}),
|
|
766
1267
|
warnings,
|
|
1268
|
+
installScope: normalizedInstallScope,
|
|
767
1269
|
};
|
|
768
1270
|
|
|
769
1271
|
} catch (err) {
|
|
@@ -773,8 +1275,13 @@ export function generate({
|
|
|
773
1275
|
created,
|
|
774
1276
|
errors,
|
|
775
1277
|
plan,
|
|
1278
|
+
...(globalPlan ? { globalPlan, globalDir: normalizePath(resolvedGlobalDir) } : {}),
|
|
1279
|
+
...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
|
|
776
1280
|
summary: createSummary(plan),
|
|
1281
|
+
...(globalPlan ? { globalSummary: createSummary(globalPlan) } : {}),
|
|
1282
|
+
...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans) } : {}),
|
|
777
1283
|
warnings,
|
|
1284
|
+
installScope: normalizedInstallScope,
|
|
778
1285
|
};
|
|
779
1286
|
}
|
|
780
1287
|
}
|