create-harness-vibe-coding 0.8.13 → 0.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README-CN.md +110 -107
  3. package/README.md +93 -44
  4. package/package.json +2 -1
  5. package/src/generator.js +609 -489
  6. package/src/index.js +5 -5
  7. package/templates/common/.claude/agents/architect-manager.md +1 -0
  8. package/templates/common/.claude/agents/architect.md +2 -1
  9. package/templates/common/.claude/agents/codebase-explorer.md +1 -0
  10. package/templates/common/.claude/agents/context-master.md +2 -1
  11. package/templates/common/.claude/agents/debugger.md +1 -0
  12. package/templates/common/.claude/agents/docs-researcher.md +2 -1
  13. package/templates/common/.claude/agents/explore-manager.md +1 -0
  14. package/templates/common/.claude/agents/implement-manager.md +1 -0
  15. package/templates/common/.claude/agents/implementer.md +1 -0
  16. package/templates/common/.claude/agents/memory-master.md +2 -1
  17. package/templates/common/.claude/agents/planner.md +3 -2
  18. package/templates/common/.claude/agents/reflector.md +1 -0
  19. package/templates/common/.claude/agents/researcher.md +1 -0
  20. package/templates/common/.claude/agents/review-manager.md +1 -0
  21. package/templates/common/.claude/agents/reviewer.md +2 -1
  22. package/templates/common/.claude/agents/task-scribe.md +1 -0
  23. package/templates/common/.claude/agents/tdd-guide.md +5 -4
  24. package/templates/common/.claude/agents/test-writer.md +6 -5
  25. package/templates/common/.claude/agents/verifier.md +1 -0
  26. package/templates/common/.claude/commands/wf-auto-spark.md +16 -0
  27. package/templates/common/.claude/commands/wf-auto.md +16 -0
  28. package/templates/{optional/skills/browser-e2e/.opencode → common/.claude}/commands/wf-browser.md +3 -2
  29. package/templates/common/.claude/commands/wf-help.md +9 -5
  30. package/templates/common/.claude/commands/wf-learn.md +16 -0
  31. package/templates/common/.claude/commands/wf-max.md +20 -0
  32. package/templates/common/.claude/commands/wf-readme.md +16 -0
  33. package/templates/common/.claude/commands/wf-remove.md +16 -0
  34. package/templates/common/.claude/commands/wf-review.md +16 -0
  35. package/templates/common/.claude/commands/wf-update.md +68 -10
  36. package/templates/common/.claude/commands/wf.md +16 -0
  37. package/templates/common/.claude/rules/ecc/common.md +6 -5
  38. package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +12 -6
  39. package/templates/common/.claude/skills/tdd/SKILL.md +5 -5
  40. package/templates/common/.claude/skills/wf/SKILL.md +13 -5
  41. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +119 -0
  42. package/templates/common/.claude/skills/wf-auto/SKILL.md +27 -7
  43. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +12 -5
  44. package/templates/common/.claude/skills/wf-browser/SKILL.md +176 -0
  45. package/templates/common/.claude/skills/wf-help/SKILL.md +30 -0
  46. package/templates/common/.claude/skills/wf-learn/SKILL.md +6 -0
  47. package/templates/common/.claude/skills/wf-max/SKILL.md +43 -14
  48. package/templates/common/.claude/skills/wf-readme/SKILL.md +8 -2
  49. package/templates/common/.claude/skills/wf-remove/SKILL.md +6 -0
  50. package/templates/common/.claude/skills/wf-review/SKILL.md +10 -3
  51. package/templates/common/.claude/skills/wf-update/SKILL.md +41 -4
  52. package/templates/common/.codex/config.toml +3 -5
  53. package/templates/common/.harness-version +326 -131
  54. package/templates/common/.opencode/agents/architect-manager.md +8 -1
  55. package/templates/common/.opencode/agents/architect.md +2 -1
  56. package/templates/common/.opencode/agents/codebase-explorer.md +1 -0
  57. package/templates/common/.opencode/agents/context-master.md +2 -1
  58. package/templates/common/.opencode/agents/debugger.md +1 -0
  59. package/templates/common/.opencode/agents/docs-researcher.md +2 -1
  60. package/templates/common/.opencode/agents/explore-manager.md +11 -1
  61. package/templates/common/.opencode/agents/implement-manager.md +6 -3
  62. package/templates/common/.opencode/agents/implementer.md +1 -0
  63. package/templates/common/.opencode/agents/memory-master.md +2 -1
  64. package/templates/common/.opencode/agents/planner.md +3 -2
  65. package/templates/common/.opencode/agents/reflector.md +1 -0
  66. package/templates/common/.opencode/agents/researcher.md +1 -0
  67. package/templates/common/.opencode/agents/review-manager.md +6 -2
  68. package/templates/common/.opencode/agents/reviewer.md +2 -1
  69. package/templates/common/.opencode/agents/task-scribe.md +1 -0
  70. package/templates/common/.opencode/agents/tdd-guide.md +5 -4
  71. package/templates/common/.opencode/agents/test-writer.md +6 -5
  72. package/templates/common/.opencode/agents/verifier.md +1 -0
  73. package/templates/common/.opencode/commands/wf-auto-spark.md +3 -2
  74. package/templates/common/.opencode/commands/wf-auto.md +3 -2
  75. package/templates/common/.opencode/commands/wf-browser.md +16 -0
  76. package/templates/common/.opencode/commands/wf-help.md +9 -5
  77. package/templates/common/.opencode/commands/wf-learn.md +3 -2
  78. package/templates/common/.opencode/commands/wf-max.md +12 -6
  79. package/templates/common/.opencode/commands/wf-readme.md +3 -2
  80. package/templates/common/.opencode/commands/wf-remove.md +3 -2
  81. package/templates/common/.opencode/commands/wf-review.md +3 -2
  82. package/templates/common/.opencode/commands/wf-update.md +68 -10
  83. package/templates/common/.opencode/commands/wf.md +3 -2
  84. package/templates/common/CLAUDE.md +15 -13
  85. package/templates/common/Harness/MEMORY.md +24 -21
  86. package/templates/common/Harness/README.md +50 -47
  87. package/templates/common/Harness/ownership.manifest.json +878 -0
  88. package/templates/common/Harness/{architecture.md → project/architecture.md} +1 -1
  89. package/templates/common/Harness/research/README.md +3 -3
  90. package/templates/common/Harness/scripts/archive-tasks.mjs +12 -220
  91. package/templates/common/Harness/scripts/context-budget.mjs +95 -0
  92. package/templates/common/Harness/scripts/l2-cache-telemetry.mjs +703 -0
  93. package/templates/common/Harness/scripts/scan-clean.mjs +16 -12
  94. package/templates/common/Harness/scripts/task-state.mjs +889 -0
  95. package/templates/common/Harness/scripts/validate-harness.mjs +553 -191
  96. package/templates/common/Harness/scripts/wf-remove.mjs +61 -35
  97. package/templates/common/Harness/scripts/wf-update-check.mjs +358 -45
  98. package/templates/common/Harness/settings.json +43 -0
  99. package/templates/common/Harness/{ECC-GUIDE.md → specs/guides/ECC-GUIDE.md} +4 -4
  100. package/templates/common/Harness/{SETUP.md → specs/guides/SETUP.md} +34 -37
  101. package/templates/common/Harness/{extension.md → specs/guides/extension.md} +3 -3
  102. package/templates/common/Harness/{lifecycle.md → specs/guides/lifecycle.md} +2 -2
  103. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +76 -0
  104. package/templates/common/Harness/{agent-workflow.md → specs/runtime/agent-workflow.md} +6 -6
  105. package/templates/common/Harness/{context-loading.md → specs/runtime/context-loading.md} +85 -21
  106. package/templates/common/Harness/{dispatch.md → specs/runtime/dispatch.md} +4 -3
  107. package/templates/common/Harness/{subagents.md → specs/runtime/subagents.md} +19 -10
  108. package/templates/common/Harness/{WF-AUTO-SPARK.md → specs/workflows/WF-AUTO-SPARK.md} +2 -2
  109. package/templates/common/Harness/{WF-AUTO.md → specs/workflows/WF-AUTO.md} +12 -7
  110. package/templates/common/Harness/{WF-KERNEL.md → specs/workflows/WF-KERNEL.md} +11 -1
  111. package/templates/common/Harness/{WF-MAX.md → specs/workflows/WF-MAX.md} +40 -7
  112. package/templates/common/Harness/specs/workflows/WF-STATE.md +141 -0
  113. package/templates/common/Harness/{WF.md → specs/workflows/WF.md} +12 -1
  114. package/templates/common/README.md +8 -6
  115. package/templates/common/memory/startup-hints.md +19 -17
  116. package/templates/common/opencode.json +1 -0
  117. package/templates/optional/catalog.json +2 -9
  118. package/templates/optional/skills/github-pr-review/.claude/skills/github-pr-review/SKILL.md +1 -1
  119. package/templates/optional/skills/python-backend/.claude/skills/python-backend/SKILL.md +1 -1
  120. package/templates/optional/skills/ts-react-frontend/.claude/skills/ts-react-frontend/SKILL.md +1 -1
  121. package/templates/optional/skills/ui-ux-review/.claude/skills/ui-ux-review/SKILL.md +1 -1
  122. package/templates/common/Harness/TASK_ARCHIVE.md +0 -56
  123. package/templates/common/Harness/WF-STATE.md +0 -83
  124. package/templates/optional/skills/browser-e2e/.claude/skills/browser-e2e/SKILL.md +0 -42
  125. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +0 -194
  126. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +0 -119
  127. /package/templates/common/Harness/{ACCEPTANCE_PROTOCOL.md → specs/protocols/ACCEPTANCE_PROTOCOL.md} +0 -0
  128. /package/templates/common/Harness/{AGENT_ISOLATION.md → specs/protocols/AGENT_ISOLATION.md} +0 -0
  129. /package/templates/common/Harness/{DEBUG_PROTOCOL.md → specs/protocols/DEBUG_PROTOCOL.md} +0 -0
  130. /package/templates/common/Harness/{HARNESS_BRIDGE.md → specs/protocols/HARNESS_BRIDGE.md} +0 -0
  131. /package/templates/common/Harness/{MEMORY_PROTOCOL.md → specs/protocols/MEMORY_PROTOCOL.md} +0 -0
  132. /package/templates/common/Harness/{TDD-GUIDE.md → specs/protocols/TDD-GUIDE.md} +0 -0
  133. /package/templates/common/Harness/{WF-AUTO-ANGLES.md → specs/workflows/WF-AUTO-ANGLES.md} +0 -0
@@ -15,7 +15,7 @@
15
15
  * --full-plan and --verbose are aliases.
16
16
  */
17
17
 
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from 'fs';
19
19
  import { createHash } from 'crypto';
20
20
  import { resolve, dirname, sep } from 'path';
21
21
  import { fileURLToPath } from 'url';
@@ -46,6 +46,7 @@ const PRESERVE_PATTERNS = [
46
46
  /^Harness\/research\/PRD\.md$/,
47
47
  /^Harness\/research\/research-results\.md$/,
48
48
  /^Harness\/architecture\.md$/,
49
+ /^Harness\/project\/architecture\.md$/,
49
50
  /^README\.md$/,
50
51
  /^\.gitignore$/,
51
52
  /^package\.json$/,
@@ -59,6 +60,7 @@ const MERGE_PATTERNS = [
59
60
  /^MEMORY\.md$/,
60
61
  /^Harness\/MEMORY\.md$/,
61
62
  /^Harness\/README\.md$/,
63
+ /^Harness\/settings\.json$/,
62
64
  ];
63
65
 
64
66
  const OPTIONAL_REGISTRATION_FILES = new Set([
@@ -67,9 +69,25 @@ const OPTIONAL_REGISTRATION_FILES = new Set([
67
69
  'Harness/README.md',
68
70
  ]);
69
71
 
70
- const BOOTSTRAP_ONLY_FILES = new Set([
71
- 'Harness/SETUP.md',
72
- ]);
72
+ const HARNESS_OWNED_CANDIDATE_PATTERNS = [
73
+ /^\.claude\/agents\/[^/]+\.md$/,
74
+ /^\.opencode\/agents\/[^/]+\.md$/,
75
+ /^\.claude\/commands\/wf(?:-[^/]+)?\.md$/,
76
+ /^\.opencode\/commands\/wf(?:-[^/]+)?\.md$/,
77
+ /^\.claude\/skills\/(?:wf|wf-[^/]+|subagent-orchestrator|tdd)\/SKILL\.md$/,
78
+ /^\.agents\/skills\/(?:wf|wf-[^/]+|subagent-orchestrator|tdd)\/SKILL\.md$/,
79
+ /^\.codex\//,
80
+ /^\.opencode\/plugins\/harness-/,
81
+ /^Harness\/scripts\//,
82
+ ];
83
+
84
+ const HARNESS_OWNED_CONTENT_MARKERS = [
85
+ /^harness:\s*(?:wf-agent|wf-framework|create-harness-vibe-coding)\b/im,
86
+ /\bcreate-harness-vibe-coding\b/i,
87
+ /\bproject harness\b/i,
88
+ /\bHarness\/(?:specs|WF|MEMORY|tasks|scripts|subagents|dispatch|context-loading|lifecycle|SETUP)\b/,
89
+ /\bWF-(?:MAX|AUTO|KERNEL|STATE)\b/,
90
+ ];
73
91
 
74
92
  // Helpers
75
93
 
@@ -131,8 +149,11 @@ function optionalSkillFromSource(source) {
131
149
  return match ? match[1] : null;
132
150
  }
133
151
 
152
+ const RETIRED_OPTION_IDS = new Set(['browser-e2e']);
153
+
134
154
  function isInstalledOptionalFile(file, localVersion) {
135
155
  const skillId = optionalSkillFromSource(localVersion?.sources?.[file]);
156
+ if (skillId && RETIRED_OPTION_IDS.has(skillId)) return false;
136
157
  return Boolean(skillId && selectedOptionIds(localVersion).has(skillId));
137
158
  }
138
159
 
@@ -157,18 +178,146 @@ function sha256File(path) {
157
178
  return sha256(content);
158
179
  }
159
180
 
160
- function classify(file, localHash, storedHash) {
161
- // PRESERVE
162
- for (const p of PRESERVE_PATTERNS) {
163
- if (p.test(file)) return 'PRESERVE';
181
+ // ── Ownership manifest (manifest-first classification) ─────────────────────
182
+ //
183
+ // Harness/ownership.manifest.json is the machine-readable source of truth for
184
+ // file classification. When present it drives PRESERVE/MERGE/frameworkOwned/
185
+ // optionalOwned decisions; the path-regex + content-marker logic below remains
186
+ // as a FALLBACK for old installs that ship no manifest. The updater cannot
187
+ // import from scripts/lib (different package layer), so a small local glob
188
+ // matcher mirrors the one in scripts/lib/ownership-manifest.mjs.
189
+
190
+ function globToRegex(pattern) {
191
+ let re = '';
192
+ let i = 0;
193
+ while (i < pattern.length) {
194
+ const c = pattern[i];
195
+ if (c === '*' && pattern[i + 1] === '*') {
196
+ re += '.*';
197
+ i += 2;
198
+ if (pattern[i] === '/') i += 1; // swallow a trailing slash after **
199
+ } else if (c === '*') {
200
+ re += '[^/]*';
201
+ i += 1;
202
+ } else if ('.+?^${}()|[]\\'.includes(c)) {
203
+ re += '\\' + c;
204
+ i += 1;
205
+ } else {
206
+ re += c;
207
+ i += 1;
208
+ }
164
209
  }
165
- // MERGE: dual-purpose, check if user modified.
166
- for (const p of MERGE_PATTERNS) {
167
- if (p.test(file)) {
168
- if (localHash === storedHash) return 'SAFE'; // unmodified, safe
169
- return 'CONFLICT'; // user modified, needs decision
210
+ return new RegExp('^' + re + '$');
211
+ }
212
+
213
+ function matchesGlob(dest, pattern) {
214
+ if (!pattern.includes('*')) return dest === pattern;
215
+ return globToRegex(pattern).test(dest);
216
+ }
217
+
218
+ function matchesAnyGlob(dest, patterns) {
219
+ return (patterns || []).some(p => matchesGlob(dest, p));
220
+ }
221
+
222
+ function buildOptionalOwnedMap(optionalOwned) {
223
+ const map = new Map();
224
+ for (const entry of optionalOwned || []) {
225
+ if (!entry || typeof entry.option !== 'string') continue;
226
+ map.set(entry.option, new Set(entry.paths || []));
227
+ }
228
+ return map;
229
+ }
230
+
231
+ /** Load + validate the ownership manifest. Missing/unreadable/invalid -> null (fallback mode). */
232
+ function loadOwnershipManifest(root) {
233
+ const manifestPath = resolve(root, 'Harness', 'ownership.manifest.json');
234
+ try {
235
+ if (!existsSync(manifestPath)) return null;
236
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
237
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.preserve)) return null;
238
+ return parsed;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function buildManifestCtx(manifest) {
245
+ if (!manifest) {
246
+ return { present: false, preserveGlobs: [], mergeSet: new Set(), frameworkOwnedSet: new Set(), optionalOwned: new Map() };
247
+ }
248
+ return {
249
+ present: true,
250
+ preserveGlobs: manifest.preserve || [],
251
+ mergeSet: new Set(manifest.merge || []),
252
+ frameworkOwnedSet: new Set((manifest.frameworkOwned || []).map(e => e && e.path).filter(Boolean)),
253
+ optionalOwned: buildOptionalOwnedMap(manifest.optionalOwned),
254
+ };
255
+ }
256
+
257
+ function isPreserveFile(file, ctx) {
258
+ if (ctx.present) return matchesAnyGlob(file, ctx.preserveGlobs);
259
+ return PRESERVE_PATTERNS.some(p => p.test(file));
260
+ }
261
+
262
+ function isMergeFile(file, ctx) {
263
+ if (ctx.present) return ctx.mergeSet.has(file);
264
+ return MERGE_PATTERNS.some(p => p.test(file));
265
+ }
266
+
267
+ /** Manifest-first declared ownership: frameworkOwned, or optionalOwned whose option is selected. */
268
+ function isDeclaredHarnessOwned(file, ctx, selectedOptions) {
269
+ if (!ctx.present) return false;
270
+ if (ctx.frameworkOwnedSet.has(file)) return true;
271
+ for (const [option, paths] of ctx.optionalOwned) {
272
+ if (paths.has(file) && selectedOptions.has(option)) return true;
273
+ }
274
+ return false;
275
+ }
276
+
277
+ /** An optional-owned file whose owning option is NOT selected for this install. */
278
+ function isOptionalOwnedUnselected(file, ctx, selectedOptions) {
279
+ if (!ctx.present) return false;
280
+ let owned = false;
281
+ for (const [option, paths] of ctx.optionalOwned) {
282
+ if (paths.has(file)) {
283
+ owned = true;
284
+ if (selectedOptions.has(option)) return false;
170
285
  }
171
286
  }
287
+ return owned;
288
+ }
289
+
290
+ /**
291
+ * Manifest-first untracked-existing-file ownership. When a remote file is NEW
292
+ * (not in local checksums) but exists on disk, decide Harness-owned vs
293
+ * user-owned. The MARKER decides instance ownership: a prior Harness install
294
+ * leaves a marker, a user-authored file at a Harness-looking path does not.
295
+ * Manifest declaration (frameworkOwned / installed-optional) and the candidate
296
+ * regex only build the Harness-interest candidate set; they never by themselves
297
+ * authorize overwriting an untracked file. A user file at a Harness-interest
298
+ * path with NO marker stays a conflict (protected) in BOTH manifest and
299
+ * fallback modes. This matches the installer and AC #5: a user's same-name
300
+ * agent/command/skill must never be overwritten.
301
+ */
302
+ function isHarnessOwnedExistingFileManifestFirst(file, diskPath, ctx, selectedOptions) {
303
+ // A manifest-declared path joins the candidate set, but — like a regex
304
+ // candidate — still requires a Harness content marker to be adopted. The
305
+ // marker (not the declaration) decides adopt-vs-conflict for an untracked
306
+ // existing file, in both manifest and fallback modes.
307
+ if (isDeclaredHarnessOwned(file, ctx, selectedOptions)) {
308
+ return fileHasHarnessOwnedMarker(diskPath);
309
+ }
310
+ return isHarnessOwnedExistingFile(file, diskPath);
311
+ }
312
+
313
+ function classify(file, localHash, storedHash, ctx) {
314
+ // PRESERVE
315
+ if (isPreserveFile(file, ctx)) return 'PRESERVE';
316
+ // MERGE: dual-purpose, check if user modified.
317
+ if (isMergeFile(file, ctx)) {
318
+ if (localHash === storedHash) return 'SAFE'; // unmodified, safe
319
+ return 'CONFLICT'; // user modified, needs decision
320
+ }
172
321
  // Everything else is SAFE runtime file: always overwrite.
173
322
  // Harness system files (scripts, skills, agents, commands, WF docs) are
174
323
  // not user data; the template is authoritative. Only PRESERVE and MERGE
@@ -176,6 +325,25 @@ function classify(file, localHash, storedHash) {
176
325
  return 'SAFE';
177
326
  }
178
327
 
328
+ function isHarnessOwnedCandidate(file) {
329
+ return HARNESS_OWNED_CANDIDATE_PATTERNS.some(pattern => pattern.test(file));
330
+ }
331
+
332
+ /** Read on-disk content and test for a Harness ownership marker. */
333
+ function fileHasHarnessOwnedMarker(diskPath) {
334
+ try {
335
+ const content = readFileSync(diskPath, 'utf-8');
336
+ return HARNESS_OWNED_CONTENT_MARKERS.some(pattern => pattern.test(content));
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ function isHarnessOwnedExistingFile(file, diskPath) {
343
+ if (!isHarnessOwnedCandidate(file)) return false;
344
+ return fileHasHarnessOwnedMarker(diskPath);
345
+ }
346
+
179
347
  async function fetchRemote(url, timeoutMs = 30000) {
180
348
  if (url.startsWith('file://')) {
181
349
  return readFileSync(fileURLToPath(url), 'utf-8');
@@ -438,6 +606,12 @@ async function main() {
438
606
 
439
607
  const localChecksums = localVersion.checksums || {};
440
608
 
609
+ // Ownership manifest: manifest-first classification with regex/marker fallback
610
+ // for old installs that ship no manifest. manifest=null -> fallback mode.
611
+ const manifest = loadOwnershipManifest(ROOT);
612
+ const manifestCtx = buildManifestCtx(manifest);
613
+ const selectedOptions = selectedOptionIds(localVersion);
614
+
441
615
  // 2. Read remote version metadata from the selected update source.
442
616
  let remoteVersion = source?.manifest;
443
617
  if (!remoteVersion) {
@@ -493,6 +667,18 @@ async function main() {
493
667
 
494
668
  const remoteChecksums = remoteVersion.checksums || {};
495
669
  const remoteSources = remoteVersion.sources || {};
670
+ const remoteMoves = Array.isArray(remoteVersion.moves)
671
+ ? remoteVersion.moves
672
+ .map(move => ({
673
+ from: canonicalPath(move?.from || ''),
674
+ to: canonicalPath(move?.to || ''),
675
+ deleteOldIfChecksumMatches: move?.deleteOldIfChecksumMatches !== false,
676
+ preserveOldIfModified: move?.preserveOldIfModified !== false,
677
+ }))
678
+ .filter(move => move.from && move.to && remoteChecksums[move.to])
679
+ : [];
680
+ const moveByFrom = new Map(remoteMoves.map(move => [move.from, move]));
681
+ const movedToFiles = new Set();
496
682
  localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
497
683
 
498
684
  /** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
@@ -510,6 +696,20 @@ async function main() {
510
696
  return source.base + templateRelPath;
511
697
  }
512
698
 
699
+ function releaseHighlights(versionObj) {
700
+ const highlights = versionObj?.releaseNotes?.highlights;
701
+ return Array.isArray(highlights)
702
+ ? highlights.map(String).map(s => s.trim()).filter(Boolean)
703
+ : [];
704
+ }
705
+
706
+ function printReleaseHighlights(versionObj) {
707
+ const highlights = releaseHighlights(versionObj).slice(0, 6);
708
+ if (highlights.length === 0) return;
709
+ console.log('\nRelease highlights:');
710
+ for (const item of highlights) console.log(` - ${item}`);
711
+ }
712
+
513
713
  function withRemoteMeta(entry) {
514
714
  if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
515
715
  const templateHint = remotePath(entry.file);
@@ -544,6 +744,7 @@ async function main() {
544
744
  adopted: plan.adopted.map(withRemoteMeta),
545
745
  conflict: verbose ? plan.conflict.map(withConflictActions) : plan.conflict.map(withConflictActions).slice(0, 5),
546
746
  conflictTruncated: !verbose && plan.conflict.length > 5 ? plan.conflict.length - 5 : undefined,
747
+ moved: plan.moved.map(withRemoteMeta),
547
748
  skipped: plan.skipped.map(withRemoteMeta),
548
749
  };
549
750
  }
@@ -563,8 +764,12 @@ async function main() {
563
764
  safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
564
765
  strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
565
766
  finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
767
+ pathMoves: remoteMoves,
566
768
  partialUpdate: localVersion.partialUpdate || null,
567
769
  acceptedConflicts: localVersion.acceptedConflicts || {},
770
+ releaseNotes: remoteVersion.releaseNotes || null,
771
+ releaseHighlights: releaseHighlights(remoteVersion),
772
+ updateReportRequired: 'After apply/finalize, tell the user the version, safe/new/conflict counts, validation results, and the core releaseHighlights from this update.',
568
773
  // aiMergeRequired array is attached only in verbose mode to keep default output token-safe.
569
774
  ...(verbose ? { aiMergeRequired: conflicts } : {}),
570
775
  aiMergeRequiredCount: totalConflicts,
@@ -615,12 +820,18 @@ async function main() {
615
820
  const decision = localVersion.acceptedConflicts?.[file];
616
821
  return Boolean(
617
822
  decision
618
- && decision.targetGenerator === remoteGen
619
823
  && decision.localHash === localHash
620
824
  && decision.remoteHash === remoteHash
621
825
  );
622
826
  }
623
827
 
828
+ function acceptedDecisionReason(file) {
829
+ const decision = localVersion.acceptedConflicts?.[file];
830
+ if (!decision) return 'accepted conflict decision';
831
+ if (decision.targetGenerator === remoteGen) return `accepted conflict decision: ${decision.decision}`;
832
+ return `accepted conflict decision carried forward from ${decision.targetGenerator}: ${decision.decision}`;
833
+ }
834
+
624
835
  let decisionMetadataDirty = false;
625
836
  if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
626
837
  const appliedAt = new Date().toISOString();
@@ -642,10 +853,11 @@ async function main() {
642
853
 
643
854
  const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
644
855
 
645
- const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
856
+ const plan = { updated: [], created: [], adopted: [], moved: [], conflict: [], skipped: [] };
646
857
 
647
858
  for (const file of [...allFiles].sort()) {
648
859
  const canonical = canonicalPath(file);
860
+ if (movedToFiles.has(canonical)) continue;
649
861
 
650
862
  // Reject paths that escape ROOT before any file access
651
863
  const diskPath = safePath(file);
@@ -658,6 +870,47 @@ async function main() {
658
870
  const storedHash = localChecksums[file];
659
871
  const remoteHash = remoteChecksums[file];
660
872
 
873
+ const move = moveByFrom.get(canonical);
874
+ if (move && storedHash && !remoteHash) {
875
+ const toDiskPath = safePath(move.to);
876
+ if (!toDiskPath) {
877
+ plan.skipped.push({ file, movedTo: move.to, reason: 'path move target traversal rejected' });
878
+ continue;
879
+ }
880
+ const toLocalHash = sha256File(toDiskPath);
881
+ const toRemoteHash = remoteChecksums[move.to];
882
+ if (localHash && localHash !== storedHash) {
883
+ plan.skipped.push({
884
+ file,
885
+ movedTo: move.to,
886
+ reason: 'legacy moved file modified; preserved for manual review',
887
+ });
888
+ continue;
889
+ }
890
+ if (toLocalHash && toLocalHash !== toRemoteHash) {
891
+ plan.skipped.push({
892
+ file,
893
+ movedTo: move.to,
894
+ reason: 'legacy moved file preserved until canonical path conflict is resolved',
895
+ });
896
+ continue;
897
+ }
898
+ plan.moved.push({
899
+ file: move.to,
900
+ from: file,
901
+ to: move.to,
902
+ localHash,
903
+ storedHash,
904
+ remoteHash: toRemoteHash,
905
+ write: toLocalHash !== toRemoteHash,
906
+ reason: toLocalHash === toRemoteHash
907
+ ? 'legacy framework path cleanup; canonical file already current'
908
+ : 'safe framework path move',
909
+ });
910
+ movedToFiles.add(move.to);
911
+ continue;
912
+ }
913
+
661
914
  if (!remoteHash) {
662
915
  if (storedHash && isInstalledOptionalFile(file, localVersion)) {
663
916
  plan.skipped.push({ file, reason: 'installed optional workflow file' });
@@ -667,8 +920,11 @@ async function main() {
667
920
  continue;
668
921
  }
669
922
 
670
- if (BOOTSTRAP_ONLY_FILES.has(canonical) && localHash === null) {
671
- plan.skipped.push({ file, reason: 'bootstrap-only file already removed locally' });
923
+ // Manifest-first: an optional-owned file whose option is NOT selected for
924
+ // this install is not in this install's plan; skip it rather than
925
+ // force-applying an optional workflow the user never opted into.
926
+ if (isOptionalOwnedUnselected(canonical, manifestCtx, selectedOptions)) {
927
+ plan.skipped.push({ file, reason: 'optional workflow not selected for this install' });
672
928
  continue;
673
929
  }
674
930
 
@@ -680,16 +936,22 @@ async function main() {
680
936
  continue;
681
937
  }
682
938
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
683
- plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
939
+ plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
940
+ continue;
941
+ }
942
+ if (isHarnessOwnedExistingFileManifestFirst(canonical, diskPath, manifestCtx, selectedOptions)) {
943
+ plan.updated.push({ file, localHash, remoteHash, reason: 'existing untracked Harness-owned file' });
684
944
  continue;
685
945
  }
686
946
  plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
687
947
  continue;
688
948
  }
689
- // New file: still respect PRESERVE classification.
690
- const tier = classify(canonical, null, null);
949
+ // New file: still respect PRESERVE classification for existing user data,
950
+ // but create missing scaffold starter files. PRESERVE means "do not
951
+ // overwrite local user data", not "leave required new docs absent".
952
+ const tier = classify(canonical, null, null, manifestCtx);
691
953
  if (tier === 'PRESERVE') {
692
- plan.skipped.push({ file, reason: 'PRESERVE: new file would overwrite user data' });
954
+ plan.created.push({ file, remoteHash, reason: 'PRESERVE: missing scaffold starter; no local user data to overwrite' });
693
955
  } else {
694
956
  plan.created.push({ file, remoteHash });
695
957
  }
@@ -704,7 +966,7 @@ async function main() {
704
966
  && localHash !== remoteHash
705
967
  ) {
706
968
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
707
- plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
969
+ plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
708
970
  continue;
709
971
  }
710
972
  plan.conflict.push({
@@ -717,7 +979,7 @@ async function main() {
717
979
  continue;
718
980
  }
719
981
 
720
- const tier = classify(canonical, localHash, storedHash);
982
+ const tier = classify(canonical, localHash, storedHash, manifestCtx);
721
983
 
722
984
  if (tier === 'PRESERVE') {
723
985
  plan.skipped.push({ file, reason: 'PRESERVE: user data' });
@@ -729,7 +991,7 @@ async function main() {
729
991
  }
730
992
  } else if (tier === 'CONFLICT') {
731
993
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
732
- plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
994
+ plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
733
995
  continue;
734
996
  }
735
997
  plan.conflict.push({
@@ -737,7 +999,7 @@ async function main() {
737
999
  localHash,
738
1000
  storedHash,
739
1001
  remoteHash,
740
- reason: MERGE_PATTERNS.some(p => p.test(canonical))
1002
+ reason: isMergeFile(canonical, manifestCtx)
741
1003
  ? 'user modified MERGE file'
742
1004
  : 'user modified runtime file',
743
1005
  });
@@ -753,11 +1015,13 @@ async function main() {
753
1015
  from: localGen,
754
1016
  to: remoteGen,
755
1017
  sourceBase,
1018
+ releaseNotes: remoteVersion.releaseNotes || null,
756
1019
  partialUpdate: localVersion.partialUpdate || null,
757
1020
  acceptedConflicts: localVersion.acceptedConflicts || {},
758
1021
  updated: plan.updated.length,
759
1022
  created: plan.created.length,
760
1023
  adopted: plan.adopted.length,
1024
+ moved: plan.moved.length,
761
1025
  conflict: plan.conflict.length,
762
1026
  skipped: plan.skipped.length,
763
1027
  // Token-safe by default: attach the full plan only when --verbose / --full-plan is passed.
@@ -768,7 +1032,8 @@ async function main() {
768
1032
  }
769
1033
 
770
1034
  console.log(`\nUpdate: v${localGen} -> v${remoteGen}`);
771
- console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
1035
+ console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.moved.length} moved, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
1036
+ printReleaseHighlights(remoteVersion);
772
1037
 
773
1038
  // Show conflicts (these need AI/user decision)
774
1039
  if (plan.conflict.length > 0) {
@@ -783,10 +1048,11 @@ async function main() {
783
1048
  }
784
1049
 
785
1050
  // Show what will be auto-updated
786
- if (plan.updated.length + plan.created.length > 0) {
1051
+ if (plan.updated.length + plan.created.length + plan.moved.length > 0) {
787
1052
  console.log('AUTO (safe to apply):');
788
1053
  for (const u of plan.updated) console.log(` ^ ${u.file}`);
789
1054
  for (const c of plan.created) console.log(` + ${c.file}`);
1055
+ for (const m of plan.moved) console.log(` > ${m.from} -> ${m.to}`);
790
1056
  console.log('');
791
1057
  }
792
1058
 
@@ -809,35 +1075,43 @@ async function main() {
809
1075
  async function prepareWrite(entry, { mustNotExist = false } = {}) {
810
1076
  try {
811
1077
  const dest = safePath(entry.file);
812
- if (!dest) { console.error(` x Traversal rejected: ${entry.file}`); failed++; return; }
1078
+ if (!dest) return { ok: false, message: ` x Traversal rejected: ${entry.file}` };
813
1079
  // Symlink rejection: do not follow symlinks.
814
1080
  if (existsSync(dest)) {
815
- try { if (lstatSync(dest).isSymbolicLink()) { console.error(` x Symlink rejected: ${entry.file}`); failed++; return; } } catch (_) {}
1081
+ try { if (lstatSync(dest).isSymbolicLink()) return { ok: false, message: ` x Symlink rejected: ${entry.file}` }; } catch (_) {}
816
1082
  if (mustNotExist) {
817
- console.error(` x File created since plan: ${entry.file} - treating as CONFLICT`);
818
- failed++; return;
1083
+ return { ok: false, message: ` x File created since plan: ${entry.file} - treating as CONFLICT` };
819
1084
  }
820
1085
  }
821
1086
  const content = await fetchSourceFile(remotePath(entry.file));
822
1087
  const normalized = content.replace(/\r\n/g, '\n');
823
1088
  const fetchedHash = sha256(normalized);
824
1089
  if (fetchedHash !== entry.remoteHash) {
825
- console.error(` x Hash mismatch: ${entry.file}`);
826
- failed++; return;
1090
+ return { ok: false, message: ` x Hash mismatch: ${entry.file}` };
827
1091
  }
828
- preparedWrites.push({ file: entry.file, dest, content: normalized, mustNotExist });
1092
+ return { ok: true, prepared: { file: entry.file, dest, content: normalized, mustNotExist } };
829
1093
  } catch (e) {
830
- console.error(` x Failed: ${entry.file} - ${e.message}`);
831
- failed++;
1094
+ return { ok: false, message: ` x Failed: ${entry.file} - ${e.message}` };
832
1095
  }
833
1096
  }
834
1097
 
835
- for (const u of plan.updated) {
836
- await prepareWrite(u);
837
- }
838
-
839
- for (const c of plan.created) {
840
- await prepareWrite(c, { mustNotExist: true });
1098
+ const prepareJobs = [
1099
+ ...plan.updated.map(entry => ({ entry, options: {} })),
1100
+ ...plan.created.map(entry => ({ entry, options: { mustNotExist: true } })),
1101
+ ...plan.moved
1102
+ .filter(entry => entry.write !== false)
1103
+ .map(entry => ({ entry, options: { mustNotExist: !existsSync(safePath(entry.file)) } })),
1104
+ ];
1105
+ const prepareResults = await Promise.all(
1106
+ prepareJobs.map(job => prepareWrite(job.entry, job.options)),
1107
+ );
1108
+ for (const result of prepareResults) {
1109
+ if (result.ok) {
1110
+ preparedWrites.push(result.prepared);
1111
+ } else {
1112
+ console.error(result.message);
1113
+ failed++;
1114
+ }
841
1115
  }
842
1116
 
843
1117
  if (failed === 0) {
@@ -849,6 +1123,12 @@ async function main() {
849
1123
  }
850
1124
  }
851
1125
 
1126
+ // All-or-nothing apply invariant: every prepared file above was hash-validated
1127
+ // against its manifest checksum before ANY writeFileSync below. The version
1128
+ // file is advanced only when failed === 0, so a failed apply leaves version
1129
+ // tracking unchanged and is safe to re-run with --apply-safe. A mid-loop
1130
+ // writeFileSync throw (disk failure) is the only partial-disk edge case;
1131
+ // re-running --apply-safe reconciles it.
852
1132
  let applied = 0;
853
1133
  if (failed === 0) {
854
1134
  try {
@@ -857,6 +1137,17 @@ async function main() {
857
1137
  writeFileSync(prepared.dest, prepared.content, 'utf-8');
858
1138
  applied++;
859
1139
  }
1140
+ for (const moved of plan.moved) {
1141
+ const from = safePath(moved.from);
1142
+ if (!from) throw new Error(`Traversal rejected: ${moved.from}`);
1143
+ if (!existsSync(from)) continue;
1144
+ if (lstatSync(from).isSymbolicLink()) throw new Error(`Symlink rejected: ${moved.from}`);
1145
+ const currentHash = sha256File(from);
1146
+ if (currentHash && currentHash !== moved.storedHash) {
1147
+ throw new Error(`Legacy moved file changed before cleanup: ${moved.from}`);
1148
+ }
1149
+ unlinkSync(from);
1150
+ }
860
1151
  } catch (e) {
861
1152
  console.error(` x Failed while writing prepared files - ${e.message}`);
862
1153
  failed++;
@@ -870,24 +1161,43 @@ async function main() {
870
1161
  for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
871
1162
  for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
872
1163
  for (const a of plan.adopted) localVersion.checksums[a.file] = a.remoteHash;
1164
+ localVersion.sources = localVersion.sources || {};
1165
+ for (const u of plan.updated) localVersion.sources[u.file] = remoteSources[u.file] || u.file;
1166
+ for (const c of plan.created) localVersion.sources[c.file] = remoteSources[c.file] || c.file;
1167
+ for (const a of plan.adopted) localVersion.sources[a.file] = remoteSources[a.file] || a.file;
1168
+ for (const m of plan.moved) {
1169
+ localVersion.checksums[m.file] = m.remoteHash;
1170
+ localVersion.sources[m.file] = remoteSources[m.file] || m.file;
1171
+ delete localVersion.checksums[m.from];
1172
+ delete localVersion.sources[m.from];
1173
+ delete localVersion.acceptedConflicts[m.from];
1174
+ }
1175
+ if (Array.isArray(remoteVersion.moves)) localVersion.moves = remoteVersion.moves;
1176
+ else delete localVersion.moves;
873
1177
  if (plan.conflict.length === 0) {
874
1178
  localVersion.generator = remoteGen;
875
1179
  localVersion.generated = appliedAt;
1180
+ if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
1181
+ else delete localVersion.releaseNotes;
876
1182
  delete localVersion.partialUpdate;
877
1183
  } else {
878
1184
  localVersion.partialUpdate = {
879
1185
  targetGenerator: remoteGen,
880
1186
  updatedAt: appliedAt,
881
- appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
1187
+ appliedFiles: [...plan.updated, ...plan.created, ...plan.moved].map(x => x.file),
882
1188
  adoptedFiles: plan.adopted.map(x => x.file),
1189
+ movedFiles: plan.moved.map(x => ({ from: x.from, to: x.to })),
883
1190
  conflicts: plan.conflict.map(x => x.file),
1191
+ releaseNotes: remoteVersion.releaseNotes || null,
884
1192
  };
885
1193
  }
886
1194
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
887
1195
  if (plan.conflict.length === 0) {
888
1196
  console.log(`Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
1197
+ printReleaseHighlights(remoteVersion);
889
1198
  } else {
890
1199
  console.log(`Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
1200
+ printReleaseHighlights(remoteVersion);
891
1201
  }
892
1202
  } else {
893
1203
  console.log(`${failed} failures. NO files were version-tracked. Fix and re-run.`);
@@ -896,8 +1206,8 @@ async function main() {
896
1206
  }
897
1207
 
898
1208
  if (finalize) {
899
- if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
900
- console.log(`Cannot finalize: ${plan.updated.length} safe updates, ${plan.created.length} new files, and ${plan.adopted.length} adopted metadata entries still need --apply-safe.`);
1209
+ if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0 || plan.moved.length > 0) {
1210
+ console.log(`Cannot finalize: ${plan.updated.length} safe updates, ${plan.created.length} new files, ${plan.moved.length} path moves, and ${plan.adopted.length} adopted metadata entries still need --apply-safe.`);
901
1211
  if (decisionMetadataDirty) {
902
1212
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
903
1213
  }
@@ -915,9 +1225,12 @@ async function main() {
915
1225
  const finalizedAt = new Date().toISOString();
916
1226
  localVersion.generator = remoteGen;
917
1227
  localVersion.generated = finalizedAt;
1228
+ if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
1229
+ else delete localVersion.releaseNotes;
918
1230
  delete localVersion.partialUpdate;
919
1231
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
920
1232
  console.log(`Finalized Harness update to ${remoteGen}.`);
1233
+ printReleaseHighlights(remoteVersion);
921
1234
  } else if (decisionMetadataDirty) {
922
1235
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
923
1236
  console.log('Recorded conflict decision metadata.');