create-harness-vibe-coding 0.8.13 → 0.8.16

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