create-harness-vibe-coding 0.8.17 → 0.8.19

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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
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,12 +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']);
73
75
  const RETIRED_OPTION_MESSAGES = new Map([
74
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.'],
75
77
  ]);
76
78
  const EMPTY_DIRS = [
77
79
  'tests',
78
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
+ ];
79
113
 
80
114
  export function harnessDest(file) {
81
115
  if (file === '.harness-version') return 'Harness/.harness-version';
@@ -123,6 +157,262 @@ function normalizePath(filePath) {
123
157
  return filePath.replace(/\\/g, '/');
124
158
  }
125
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
+
126
416
  export function getOptionalCatalog() {
127
417
  if (!fs.existsSync(OPTIONAL_CATALOG)) {
128
418
  return { skills: [], presets: {}, externalRecommendations: [] };
@@ -287,7 +577,7 @@ function symlinkInChain(rootDir, absPath) {
287
577
  return null;
288
578
  }
289
579
 
290
- function createPlan(resolvedDir, fileSpecs) {
580
+ function createPlan(resolvedDir, fileSpecs, { includeEmptyDirs = true } = {}) {
291
581
  const plan = {
292
582
  create: [],
293
583
  skip: [],
@@ -308,8 +598,10 @@ function createPlan(resolvedDir, fileSpecs) {
308
598
  dir = path.posix.dirname(dir);
309
599
  }
310
600
  }
311
- for (const dir of EMPTY_DIRS) {
312
- dirSet.add(dir);
601
+ if (includeEmptyDirs) {
602
+ for (const dir of EMPTY_DIRS) {
603
+ dirSet.add(dir);
604
+ }
313
605
  }
314
606
 
315
607
  for (const dir of [...dirSet].sort()) {
@@ -414,20 +706,33 @@ export function isHarnessOwnedContent(content) {
414
706
  /**
415
707
  * Returns true if an existing on-disk file is a Harness-interest candidate path
416
708
  * (per the manifest-first candidate set) that was NOT authored by this scaffold
417
- * (candidate path + no ownership marker). Such user-authored files are preserved
418
- * (skip) regardless of the conflict policy, mirroring the updater safety rule.
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.
419
717
  */
420
- function existingCandidateIsUserOwned(dest, destPath, candidateSet) {
718
+ function existingCandidateIsUserOwned(dest, destPath, candidateSet, srcPath = null) {
421
719
  if (!candidateSet.has(dest)) return false;
422
720
  try {
423
721
  const content = fs.readFileSync(destPath, 'utf-8');
424
- 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
425
730
  } catch {
426
731
  return false;
427
732
  }
428
733
  }
429
734
 
430
- function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet) {
735
+ function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet, specsByDest = null) {
431
736
  for (const { dest: file } of fileSpecs) {
432
737
  const destPath = path.join(resolvedDir, ...file.split('/'));
433
738
 
@@ -445,15 +750,20 @@ function addFileActions(plan, fileSpecs, resolvedDir, onConflict, candidateSet)
445
750
  continue;
446
751
  }
447
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
+
448
757
  // G4 guard: a non-Harness same-name candidate (any Harness-interest path
449
758
  // — agent/command/skill/optional-skill/script per the manifest-first
450
759
  // candidate set — without an ownership marker) must NEVER be overwritten.
451
760
  // Under overwrite/backup/skip it is preserved via plan.skip; under fail it
452
761
  // is blocked via plan.conflict so the collision surfaces (matching the fail
453
- // branch below). Only Harness-owned files (marker present) reach the normal
454
- // policy switch. Mirrors the wf-update-check.mjs safety rule; see
455
- // existingCandidateIsUserOwned / isHarnessOwnedContent above.
456
- if (existingCandidateIsUserOwned(file, destPath, candidateSet)) {
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)) {
457
767
  if (onConflict === 'fail') {
458
768
  plan.conflict.push(file);
459
769
  } else {
@@ -486,6 +796,14 @@ function createSummary(plan, { noWrites = false } = {}) {
486
796
  };
487
797
  }
488
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
+
489
807
  function nextBackupPath(destPath) {
490
808
  const first = `${destPath}.harness-backup`;
491
809
  if (!fs.existsSync(first)) {
@@ -562,6 +880,103 @@ function registrationWarnings(plan, selectedSkills) {
562
880
  return warnings;
563
881
  }
564
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
+
565
980
  export function generate({
566
981
  projectName,
567
982
  targetDir,
@@ -571,6 +986,9 @@ export function generate({
571
986
  withoutOptions = [],
572
987
  preset = undefined,
573
988
  externalOptions = [],
989
+ installScope = 'project',
990
+ globalDir = undefined,
991
+ hostGlobalDir = undefined,
574
992
  }) {
575
993
  const created = [];
576
994
  const errors = [];
@@ -578,6 +996,10 @@ export function generate({
578
996
 
579
997
  // Resolve targetDir relative to cwd
580
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;
581
1003
  const vars = {
582
1004
  projectName,
583
1005
  generatorVersion: pkg.version,
@@ -586,19 +1008,96 @@ export function generate({
586
1008
 
587
1009
  const optional = resolveOptionalSelection({ withOptions, withoutOptions, preset, externalOptions });
588
1010
  warnings.push(...optional.warnings);
589
- const fileSpecs = [
1011
+ const allFileSpecs = [
590
1012
  ...createCoreFileSpecs(),
591
1013
  ...createOptionalFileSpecs(optional.selectedSkills),
592
1014
  ];
1015
+ const fileSpecs = normalizedInstallScope === 'global'
1016
+ ? [
1017
+ ...createGlobalProjectBridgeSpecs({ globalDir: resolvedGlobalDir }),
1018
+ ...allFileSpecs.filter(spec => isGlobalProjectStateDest(spec.dest)),
1019
+ ]
1020
+ : allFileSpecs;
593
1021
  const specsByDest = new Map(fileSpecs.map(spec => [spec.dest, spec]));
594
- 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
+ : [];
595
1033
  const duplicateDestinations = duplicateDests(fileSpecs);
1034
+ const duplicateHostDestinations = hostPlans
1035
+ .flatMap(hostPlan => duplicateDests(hostPlan.fileSpecs).map(dest => `${hostPlan.host}:${dest}`));
596
1036
  const candidateSet = buildHarnessInterestCandidateSet(
597
1037
  readOwnershipManifest(),
598
- fileSpecs,
1038
+ allFileSpecs,
599
1039
  optional.selectedSkills,
600
1040
  );
601
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
+
602
1101
  if (!VALID_CONFLICT_POLICIES.has(onConflict)) {
603
1102
  errors.push(`Unknown conflict policy "${onConflict}". Use fail, skip, backup, or overwrite.`);
604
1103
  return {
@@ -634,9 +1133,34 @@ export function generate({
634
1133
  warnings,
635
1134
  };
636
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
+ }
637
1152
 
638
- 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
+ }
639
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
+ }
640
1164
 
641
1165
  if (dryRun) {
642
1166
  return {
@@ -644,14 +1168,30 @@ export function generate({
644
1168
  created,
645
1169
  errors,
646
1170
  plan,
1171
+ ...(globalPlan ? { globalPlan } : {}),
1172
+ ...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
647
1173
  summary: createSummary(plan),
1174
+ ...(globalPlan ? { globalSummary: createSummary(globalPlan), globalDir: normalizePath(resolvedGlobalDir) } : {}),
1175
+ ...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans) } : {}),
1176
+ ...(globalPlan ? { ownershipClasses: createOwnershipClasses({ resolvedGlobalDir, hostPlans }) } : {}),
648
1177
  warnings,
649
1178
  dryRun: true,
1179
+ installScope: normalizedInstallScope,
650
1180
  };
651
1181
  }
652
1182
 
653
- if (plan.conflict.length > 0) {
654
- 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
+ }
655
1195
  if (plan.conflict.includes('CLAUDE.md')) {
656
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.');
657
1197
  }
@@ -660,92 +1200,58 @@ export function generate({
660
1200
  created,
661
1201
  errors,
662
1202
  plan,
1203
+ ...(globalPlan ? { globalPlan } : {}),
1204
+ ...(hostPlans.length ? { hostPlans: hostPlans.map(hostPlan => ({ host: hostPlan.host, root: normalizePath(hostPlan.root), plan: hostPlan.plan })) } : {}),
663
1205
  summary: createSummary(plan, { noWrites: true }),
1206
+ ...(globalPlan ? { globalSummary: createSummary(globalPlan, { noWrites: true }), globalDir: normalizePath(resolvedGlobalDir) } : {}),
1207
+ ...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans, { noWrites: true }) } : {}),
664
1208
  warnings,
1209
+ installScope: normalizedInstallScope,
665
1210
  };
666
1211
  }
667
1212
 
668
1213
  try {
669
- for (const dir of plan.mkdir) {
670
- const dirPath = dir === './'
671
- ? resolvedDir
672
- : path.join(resolvedDir, ...dir.slice(0, -1).split('/'));
673
- const __slMkdir = symlinkInChain(resolvedDir, dirPath);
674
- if (__slMkdir) throw new Error(`Cannot create directory ${dir}: symlinked directory in path (${__slMkdir})`);
675
- fs.mkdirSync(dirPath, { recursive: true });
676
- created.push(dir === './' ? './ (directory)' : `${dir} (directory)`);
677
- }
678
-
679
- for (const file of plan.backup) {
680
- const destPath = path.join(resolvedDir, ...file.split('/'));
681
- const __slBackup = symlinkInChain(resolvedDir, destPath);
682
- if (__slBackup) throw new Error(`Cannot backup ${file}: symlinked directory in path (${__slBackup})`);
683
- // Symlink safety: never rename or follow symlinks to avoid path traversal
684
- if (fs.existsSync(destPath)) {
685
- try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot backup ${file}: ${e.message}`); }
686
- }
687
- 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;
688
1241
  }
689
-
690
- const writableFiles = [
691
- ...plan.create,
692
- ...plan.overwrite,
693
- ...plan.backup,
694
- ];
695
-
696
- const generatedFiles = [];
697
- const HARNESS_VERSION_DEST = 'Harness/.harness-version';
698
- let harnessVersionSpec = null;
699
-
700
- for (const file of writableFiles) {
701
- if (file === HARNESS_VERSION_DEST) {
702
- harnessVersionSpec = specsByDest.get(file);
703
- continue;
704
- }
705
- const spec = specsByDest.get(file);
706
- const destPath = path.join(resolvedDir, ...file.split('/'));
707
- const __slWrite = symlinkInChain(resolvedDir, destPath);
708
- if (__slWrite) throw new Error(`Cannot create/overwrite ${file}: symlinked directory in path (${__slWrite})`);
709
- // Symlink safety: reject symlinks before writing to prevent path traversal outside workspace
710
- if (fs.existsSync(destPath)) {
711
- try { if (fs.lstatSync(destPath).isSymbolicLink()) throw new Error('Symlink rejected'); } catch (e) { throw new Error(`Cannot create/overwrite ${file}: ${e.message}`); }
712
- }
713
- let content = spec.type === 'empty'
714
- ? ''
715
- : renderTemplate(spec.src, vars);
716
- content = registerOptionalContent(file, content, optional.selectedSkills);
717
- content = registerExternalRecommendations(file, content, optional.selectedRecommendations);
718
-
719
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
720
- fs.writeFileSync(destPath, content, 'utf-8');
721
- created.push(file);
722
- generatedFiles.push({ dest: file, content });
723
- }
724
-
725
- if (harnessVersionSpec) {
726
- const rendered = renderTemplate(harnessVersionSpec.src, vars);
727
- let parsed;
728
- try {
729
- parsed = JSON.parse(rendered);
730
- } catch {
731
- parsed = {};
732
- }
733
- parsed.generator = vars.generatorVersion;
734
- parsed.generated = vars.generatedTimestamp;
735
- parsed.options = optional.selectedSkills.map(s => s.id);
736
- parsed.externalRecommendations = optional.selectedRecommendations.map(item => item.id);
737
- parsed.autoCheck = true;
738
- parsed.checksums = computeChecksums(generatedFiles);
739
- // Build sources map from fileSpecs (all writable files, not just generatedFiles)
740
- const allWritableSpecs = writableFiles
741
- .map(f => specsByDest.get(f))
742
- .filter(Boolean);
743
- parsed.sources = computeSources(allWritableSpecs);
744
- const hvContent = JSON.stringify(parsed, null, 2) + '\n';
745
- const hvDestPath = path.join(resolvedDir, ...HARNESS_VERSION_DEST.split('/'));
746
- fs.mkdirSync(path.dirname(hvDestPath), { recursive: true });
747
- fs.writeFileSync(hvDestPath, hvContent, 'utf-8');
748
- created.push(HARNESS_VERSION_DEST);
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);
749
1255
  }
750
1256
 
751
1257
  return {
@@ -753,8 +1259,13 @@ export function generate({
753
1259
  created,
754
1260
  errors,
755
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 } : {}),
756
1264
  summary: createSummary(plan),
1265
+ ...(globalPlan ? { globalSummary: createSummary(globalPlan) } : {}),
1266
+ ...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans), ownershipClasses: createOwnershipClasses({ resolvedGlobalDir, hostPlans }) } : {}),
757
1267
  warnings,
1268
+ installScope: normalizedInstallScope,
758
1269
  };
759
1270
 
760
1271
  } catch (err) {
@@ -764,8 +1275,13 @@ export function generate({
764
1275
  created,
765
1276
  errors,
766
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 })) } : {}),
767
1280
  summary: createSummary(plan),
1281
+ ...(globalPlan ? { globalSummary: createSummary(globalPlan) } : {}),
1282
+ ...(hostPlans.length ? { hostSummary: createHostSummary(hostPlans) } : {}),
768
1283
  warnings,
1284
+ installScope: normalizedInstallScope,
769
1285
  };
770
1286
  }
771
1287
  }