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.
Files changed (70) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README-CN.md +100 -127
  3. package/README.md +63 -42
  4. package/package.json +1 -1
  5. package/src/generator.js +628 -121
  6. package/src/index.js +174 -43
  7. package/src/prompts.js +18 -0
  8. package/templates/common/.claude/commands/wf-auto-spark.md +16 -0
  9. package/templates/common/.claude/commands/wf-auto.md +16 -0
  10. package/templates/{optional/skills/browser-e2e/.opencode → common/.claude}/commands/wf-browser.md +1 -1
  11. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  12. package/templates/common/.claude/commands/wf-help.md +13 -6
  13. package/templates/common/.claude/commands/wf-learn.md +16 -0
  14. package/templates/common/.claude/commands/wf-max.md +20 -0
  15. package/templates/common/.claude/commands/wf-readme.md +16 -0
  16. package/templates/common/.claude/commands/wf-remove.md +16 -0
  17. package/templates/common/.claude/commands/wf-review.md +16 -0
  18. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  19. package/templates/common/.claude/commands/wf-task-list.md +24 -0
  20. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  21. package/templates/common/.claude/commands/wf.md +16 -0
  22. package/templates/common/.claude/rules/ecc/common.md +1 -1
  23. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  24. package/templates/common/.claude/skills/wf-browser/SKILL.md +176 -0
  25. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  26. package/templates/common/.claude/skills/wf-help/SKILL.md +30 -0
  27. package/templates/common/.claude/skills/wf-max/SKILL.md +22 -8
  28. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  29. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  30. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  31. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  32. package/templates/common/.codex/config.toml +3 -5
  33. package/templates/common/.harness-version +108 -41
  34. package/templates/common/.opencode/agents/architect-manager.md +7 -1
  35. package/templates/common/.opencode/agents/explore-manager.md +10 -1
  36. package/templates/common/.opencode/agents/implement-manager.md +5 -3
  37. package/templates/common/.opencode/agents/review-manager.md +5 -2
  38. package/templates/common/.opencode/commands/wf-browser.md +16 -0
  39. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  40. package/templates/common/.opencode/commands/wf-help.md +13 -6
  41. package/templates/common/.opencode/commands/wf-max.md +12 -7
  42. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  43. package/templates/common/.opencode/commands/wf-task-list.md +27 -0
  44. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  45. package/templates/common/CLAUDE.md +9 -7
  46. package/templates/common/Harness/MEMORY.md +12 -3
  47. package/templates/common/Harness/README.md +23 -41
  48. package/templates/common/Harness/ownership.manifest.json +162 -14
  49. package/templates/common/Harness/scripts/archive-tasks.mjs +12 -220
  50. package/templates/common/Harness/scripts/scan-clean.mjs +3 -9
  51. package/templates/common/Harness/scripts/task-state.mjs +1279 -0
  52. package/templates/common/Harness/scripts/validate-harness.mjs +635 -65
  53. package/templates/common/Harness/scripts/wf-remove.mjs +37 -5
  54. package/templates/common/Harness/scripts/wf-update-check.mjs +3 -0
  55. package/templates/common/Harness/specs/guides/SETUP.md +10 -2
  56. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  57. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +49 -23
  58. package/templates/common/Harness/specs/runtime/command-surface.json +215 -0
  59. package/templates/common/Harness/specs/runtime/dispatch.md +2 -2
  60. package/templates/common/Harness/specs/runtime/subagents.md +15 -5
  61. package/templates/common/Harness/specs/workflows/WF-AUTO.md +6 -6
  62. package/templates/common/Harness/specs/workflows/WF-KERNEL.md +1 -1
  63. package/templates/common/Harness/specs/workflows/WF-MAX.md +35 -2
  64. package/templates/common/Harness/specs/workflows/WF-STATE.md +155 -36
  65. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  66. package/templates/common/opencode.json +1 -0
  67. package/templates/optional/catalog.json +2 -9
  68. package/templates/optional/skills/browser-e2e/.claude/skills/browser-e2e/SKILL.md +0 -42
  69. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +0 -201
  70. 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 (!skillsById.has(id)) {
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 (!skillsById.has(id)) {
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
- for (const dir of EMPTY_DIRS) {
303
- dirSet.add(dir);
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 browser-e2e (fixing the bug where same-name user files at
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). Such user-authored files are preserved
409
- * (skip) regardless of the conflict policy, mirroring the updater safety rule.
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
- return !isHarnessOwnedContent(content);
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) reach the normal
445
- // policy switch. Mirrors the wf-update-check.mjs safety rule; see
446
- // existingCandidateIsUserOwned / isHarnessOwnedContent above.
447
- if (existingCandidateIsUserOwned(file, destPath, candidateSet)) {
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
- let next = content;
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
- const fileSpecs = [
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
- fileSpecs,
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
- if (plan.conflict.length > 0) {
663
- errors.push(`Generation stopped because ${plan.conflict.length} file conflict(s) were found: ${plan.conflict.join(', ')}`);
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
- for (const dir of plan.mkdir) {
679
- const dirPath = dir === './'
680
- ? resolvedDir
681
- : path.join(resolvedDir, ...dir.slice(0, -1).split('/'));
682
- const __slMkdir = symlinkInChain(resolvedDir, dirPath);
683
- if (__slMkdir) throw new Error(`Cannot create directory ${dir}: symlinked directory in path (${__slMkdir})`);
684
- fs.mkdirSync(dirPath, { recursive: true });
685
- created.push(dir === './' ? './ (directory)' : `${dir} (directory)`);
686
- }
687
-
688
- for (const file of plan.backup) {
689
- const destPath = path.join(resolvedDir, ...file.split('/'));
690
- const __slBackup = symlinkInChain(resolvedDir, destPath);
691
- if (__slBackup) throw new Error(`Cannot backup ${file}: symlinked directory in path (${__slBackup})`);
692
- // Symlink safety: never rename or follow symlinks to avoid path traversal
693
- if (fs.existsSync(destPath)) {
694
- try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot backup ${file}: ${e.message}`); }
695
- }
696
- fs.renameSync(destPath, nextBackupPath(destPath));
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
- const writableFiles = [
700
- ...plan.create,
701
- ...plan.overwrite,
702
- ...plan.backup,
703
- ];
704
-
705
- const generatedFiles = [];
706
- const HARNESS_VERSION_DEST = 'Harness/.harness-version';
707
- let harnessVersionSpec = null;
708
-
709
- for (const file of writableFiles) {
710
- if (file === HARNESS_VERSION_DEST) {
711
- harnessVersionSpec = specsByDest.get(file);
712
- continue;
713
- }
714
- const spec = specsByDest.get(file);
715
- const destPath = path.join(resolvedDir, ...file.split('/'));
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
  }