create-harness-vibe-coding 0.8.12 → 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 +52 -8
  3. package/README.md +50 -8
  4. package/package.json +3 -1
  5. package/src/generator.js +601 -472
  6. package/src/index.js +12 -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 +73 -9
  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 +52 -5
  41. package/templates/common/.harness-version +291 -129
  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 +73 -9
  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 +86 -4
  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 +562 -121
  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,19 +15,26 @@
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';
22
+ import { gunzipSync } from 'zlib';
22
23
 
23
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
25
  const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
25
26
  const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
27
+ const NPM_PACKAGE = 'create-harness-vibe-coding';
28
+ const NPM_REGISTRY = (process.env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org').replace(/\/+$/, '');
26
29
  const GITHUB_REPO = 'LiWeny16/create-harness-vibe-coding';
30
+ const LEGACY_GITHUB_REPO = 'zingspark/create-harness-vibe-coding';
27
31
  const GITHUB_LATEST_STABLE = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
28
32
  const RAW_GITHUB = `https://raw.githubusercontent.com/${GITHUB_REPO}`;
33
+ const LEGACY_RAW_GITHUB = `https://raw.githubusercontent.com/${LEGACY_GITHUB_REPO}`;
29
34
  const TEMPLATE_SUBPATH = 'templates/common/';
30
35
  const DEFAULT_SOURCE_BASE = `${RAW_GITHUB}/main/${TEMPLATE_SUBPATH}`;
36
+ const LEGACY_SOURCE_BASE = `${LEGACY_RAW_GITHUB}/main/${TEMPLATE_SUBPATH}`;
37
+ const npmPackageCache = new Map();
31
38
 
32
39
  // Tier classification
33
40
 
@@ -39,6 +46,7 @@ const PRESERVE_PATTERNS = [
39
46
  /^Harness\/research\/PRD\.md$/,
40
47
  /^Harness\/research\/research-results\.md$/,
41
48
  /^Harness\/architecture\.md$/,
49
+ /^Harness\/project\/architecture\.md$/,
42
50
  /^README\.md$/,
43
51
  /^\.gitignore$/,
44
52
  /^package\.json$/,
@@ -52,6 +60,7 @@ const MERGE_PATTERNS = [
52
60
  /^MEMORY\.md$/,
53
61
  /^Harness\/MEMORY\.md$/,
54
62
  /^Harness\/README\.md$/,
63
+ /^Harness\/settings\.json$/,
55
64
  ];
56
65
 
57
66
  const OPTIONAL_REGISTRATION_FILES = new Set([
@@ -60,9 +69,25 @@ const OPTIONAL_REGISTRATION_FILES = new Set([
60
69
  'Harness/README.md',
61
70
  ]);
62
71
 
63
- const BOOTSTRAP_ONLY_FILES = new Set([
64
- 'Harness/SETUP.md',
65
- ]);
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
+ ];
66
91
 
67
92
  // Helpers
68
93
 
@@ -150,18 +175,146 @@ function sha256File(path) {
150
175
  return sha256(content);
151
176
  }
152
177
 
153
- function classify(file, localHash, storedHash) {
154
- // PRESERVE
155
- for (const p of PRESERVE_PATTERNS) {
156
- 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
+ }
157
206
  }
158
- // MERGE: dual-purpose, check if user modified.
159
- for (const p of MERGE_PATTERNS) {
160
- if (p.test(file)) {
161
- if (localHash === storedHash) return 'SAFE'; // unmodified, safe
162
- 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;
163
282
  }
164
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
+ }
165
318
  // Everything else is SAFE runtime file: always overwrite.
166
319
  // Harness system files (scripts, skills, agents, commands, WF docs) are
167
320
  // not user data; the template is authoritative. Only PRESERVE and MERGE
@@ -169,6 +322,25 @@ function classify(file, localHash, storedHash) {
169
322
  return 'SAFE';
170
323
  }
171
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
+
172
344
  async function fetchRemote(url, timeoutMs = 30000) {
173
345
  if (url.startsWith('file://')) {
174
346
  return readFileSync(fileURLToPath(url), 'utf-8');
@@ -185,6 +357,47 @@ async function fetchRemote(url, timeoutMs = 30000) {
185
357
  }
186
358
  }
187
359
 
360
+ async function fetchBytes(url, timeoutMs = 30000) {
361
+ const controller = new AbortController();
362
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
363
+ try {
364
+ const res = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'harness-wf-update-check' } });
365
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
366
+ return Buffer.from(await res.arrayBuffer());
367
+ } finally {
368
+ clearTimeout(timer);
369
+ }
370
+ }
371
+
372
+ function tarString(header, start, length) {
373
+ const slice = header.subarray(start, start + length);
374
+ const zero = slice.indexOf(0);
375
+ return Buffer.from(zero === -1 ? slice : slice.subarray(0, zero)).toString('utf8').trim();
376
+ }
377
+
378
+ function parseTarFiles(buffer) {
379
+ const files = new Map();
380
+ for (let offset = 0; offset + 512 <= buffer.length;) {
381
+ const header = buffer.subarray(offset, offset + 512);
382
+ if (header.every(byte => byte === 0)) break;
383
+
384
+ const name = tarString(header, 0, 100);
385
+ const prefix = tarString(header, 345, 155);
386
+ const sizeText = tarString(header, 124, 12);
387
+ const size = Number.parseInt(sizeText || '0', 8) || 0;
388
+ const type = String.fromCharCode(header[156] || 0);
389
+ const fullName = prefix ? `${prefix}/${name}` : name;
390
+ const dataStart = offset + 512;
391
+
392
+ if ((type === '\0' || type === '0' || type === '') && fullName) {
393
+ files.set(fullName, buffer.subarray(dataStart, dataStart + size));
394
+ }
395
+
396
+ offset = dataStart + Math.ceil(size / 512) * 512;
397
+ }
398
+ return files;
399
+ }
400
+
188
401
  // Main
189
402
 
190
403
  function isPrerelease(v) {
@@ -192,7 +405,74 @@ function isPrerelease(v) {
192
405
  return /-[0-9A-Za-z.-]+/.test(v.replace(/^[^0-9]*/, ''));
193
406
  }
194
407
 
195
- async function resolveStableSourceBase() {
408
+ function parseSemver(v) {
409
+ if (!v || typeof v !== 'string') return [0, 0, 0];
410
+ return v.replace(/^[^0-9]*/, '').split('.').slice(0, 3).map(n => Number(n) || 0);
411
+ }
412
+
413
+ function cmpSemver(a, b) {
414
+ const va = parseSemver(a), vb = parseSemver(b);
415
+ for (let i = 0; i < 3; i++) {
416
+ if ((va[i] || 0) > (vb[i] || 0)) return 1;
417
+ if ((va[i] || 0) < (vb[i] || 0)) return -1;
418
+ }
419
+ return 0;
420
+ }
421
+
422
+ async function loadNpmPackage(version = 'latest') {
423
+ const cacheKey = `${NPM_REGISTRY}:${version}`;
424
+ if (npmPackageCache.has(cacheKey)) return npmPackageCache.get(cacheKey);
425
+
426
+ const metaUrl = `${NPM_REGISTRY}/${NPM_PACKAGE}/${version}`;
427
+ const meta = JSON.parse(await fetchRemote(metaUrl, 15000));
428
+ const packageVersion = meta.version;
429
+ const tarball = meta?.dist?.tarball;
430
+ if (!packageVersion || !tarball) throw new Error(`npm metadata missing dist.tarball for ${NPM_PACKAGE}@${version}`);
431
+
432
+ const archive = gunzipSync(await fetchBytes(tarball, 30000));
433
+ const files = parseTarFiles(archive);
434
+ const loaded = { version: packageVersion, tarball, files };
435
+ npmPackageCache.set(cacheKey, loaded);
436
+ npmPackageCache.set(`${NPM_REGISTRY}:${packageVersion}`, loaded);
437
+ return loaded;
438
+ }
439
+
440
+ function readNpmFile(pkg, templateRelPath) {
441
+ const key = `package/${TEMPLATE_SUBPATH}${templateRelPath}`;
442
+ const content = pkg.files.get(key);
443
+ if (!content) throw new Error(`npm package missing ${key}`);
444
+ return Buffer.from(content).toString('utf8');
445
+ }
446
+
447
+ async function createNpmSource(version = 'latest') {
448
+ const pkg = await loadNpmPackage(version);
449
+ const raw = readNpmFile(pkg, '.harness-version');
450
+ if (isTemplate(raw)) throw new Error('npm .harness-version has not been generated yet');
451
+ const manifest = JSON.parse(raw);
452
+ return {
453
+ kind: 'npm',
454
+ stable: true,
455
+ label: `npm:${NPM_PACKAGE}@${pkg.version}/${TEMPLATE_SUBPATH}`,
456
+ version: pkg.version,
457
+ manifest,
458
+ package: pkg,
459
+ };
460
+ }
461
+
462
+ async function createUrlSource(sourceBase, { stable = false } = {}) {
463
+ const base = normalizeSourceBase(sourceBase);
464
+ const raw = await fetchRemote(base + '.harness-version');
465
+ if (isTemplate(raw)) throw new Error('remote .harness-version has not been generated yet');
466
+ return {
467
+ kind: 'url',
468
+ stable,
469
+ label: base,
470
+ base,
471
+ manifest: JSON.parse(raw),
472
+ };
473
+ }
474
+
475
+ async function resolveGithubReleaseSource() {
196
476
  try {
197
477
  const controller = new AbortController();
198
478
  const timer = setTimeout(() => controller.abort(), 15000);
@@ -209,26 +489,45 @@ async function resolveStableSourceBase() {
209
489
  const data = JSON.parse(await res.text());
210
490
  const tag = data && data.tag_name;
211
491
  if (!tag || data.prerelease) return null;
212
- const source = `${RAW_GITHUB}/${tag}/${TEMPLATE_SUBPATH}`;
213
- const versionController = new AbortController();
214
- const versionTimer = setTimeout(() => versionController.abort(), 15000);
215
- try {
216
- const versionRes = await fetch(source + '.harness-version', {
217
- signal: versionController.signal,
218
- headers: { 'User-Agent': 'harness-wf-update-check' },
219
- });
220
- if (!versionRes.ok) return null;
221
- const raw = await versionRes.text();
222
- if (isTemplate(raw)) return null;
223
- } finally {
224
- clearTimeout(versionTimer);
225
- }
226
- return source;
492
+ return await createUrlSource(`${RAW_GITHUB}/${tag}/${TEMPLATE_SUBPATH}`, { stable: true });
227
493
  } catch {
228
494
  return null;
229
495
  }
230
496
  }
231
497
 
498
+ async function resolveUpdateSource(explicitSource) {
499
+ if (explicitSource) {
500
+ return createUrlSource(explicitSource, { stable: false });
501
+ }
502
+
503
+ const errors = [];
504
+ try {
505
+ return await createNpmSource('latest');
506
+ } catch (e) {
507
+ errors.push(`npm latest: ${e.message}`);
508
+ }
509
+
510
+ const releaseSource = await resolveGithubReleaseSource();
511
+ if (releaseSource) return releaseSource;
512
+ errors.push('GitHub latest release: unavailable');
513
+
514
+ try {
515
+ return await createUrlSource(DEFAULT_SOURCE_BASE, { stable: false });
516
+ } catch (e) {
517
+ errors.push(`GitHub canonical main: ${e.message}`);
518
+ }
519
+
520
+ try {
521
+ return await createUrlSource(LEGACY_SOURCE_BASE, { stable: false });
522
+ } catch (e) {
523
+ errors.push(`GitHub legacy mirror: ${e.message}`);
524
+ }
525
+
526
+ const err = new Error('Cannot reach npm, canonical GitHub, or legacy mirror update sources.');
527
+ err.sourceErrors = errors;
528
+ throw err;
529
+ }
530
+
232
531
  async function main() {
233
532
  const args = process.argv.slice(2);
234
533
  const apply = args.includes('--apply');
@@ -240,14 +539,16 @@ async function main() {
240
539
  const acceptTemplate = readRepeatedFlagValues(args, '--accept-template');
241
540
  const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
242
541
  const explicitSource = readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE;
243
- let sourceBase = normalizeSourceBase(explicitSource || DEFAULT_SOURCE_BASE);
244
- let stableTagResolved = false;
245
- if (!explicitSource) {
246
- const resolved = await resolveStableSourceBase();
247
- if (resolved) {
248
- sourceBase = normalizeSourceBase(resolved);
249
- stableTagResolved = true;
250
- }
542
+ let source;
543
+ let sourceBase;
544
+ let sourceStable = false;
545
+ let sourceErrors = [];
546
+ try {
547
+ source = await resolveUpdateSource(explicitSource);
548
+ sourceBase = source.label;
549
+ sourceStable = Boolean(source.stable);
550
+ } catch (e) {
551
+ sourceErrors = e.sourceErrors || [e.message];
251
552
  }
252
553
 
253
554
  // 1. Read local state
@@ -302,44 +603,29 @@ async function main() {
302
603
 
303
604
  const localChecksums = localVersion.checksums || {};
304
605
 
305
- // 2. Fetch remote version file
306
- let remoteVersion;
307
- try {
308
- const raw = await fetchRemote(sourceBase + '.harness-version');
309
- if (isTemplate(raw)) {
310
- if (jsonOut) {
311
- console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
312
- } else {
313
- console.log('WARN: Remote .harness-version is a template (contains {{placeholders}}).');
314
- console.log(' The generate step has not been run on the remote repo. No update possible.');
315
- console.log(' This is expected during development; the update mechanism works once the remote is live.');
316
- }
317
- process.exitCode = 1;
318
- return;
319
- }
320
- remoteVersion = JSON.parse(raw);
321
- } catch (e) {
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
+
612
+ // 2. Read remote version metadata from the selected update source.
613
+ let remoteVersion = source?.manifest;
614
+ if (!remoteVersion) {
322
615
  if (jsonOut) {
323
- console.log(JSON.stringify({ status: 'offline', message: 'Cannot reach GitHub.' }));
616
+ console.log(JSON.stringify({
617
+ status: 'offline',
618
+ message: 'Cannot reach npm, canonical GitHub, or legacy mirror update sources.',
619
+ sourceErrors,
620
+ }));
324
621
  } else {
325
- console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
326
- console.error(e.message);
622
+ console.error('ERROR: Cannot reach npm, canonical GitHub, or legacy mirror update sources. Offline?');
623
+ for (const err of sourceErrors) console.error(` ${err}`);
327
624
  }
328
625
  process.exitCode = 1;
329
626
  return;
330
627
  }
331
628
 
332
- // Compare versions and prevent downgrades from explicit custom sources.
333
- function parseSemver(v) {
334
- if (!v || typeof v !== 'string') return [0, 0, 0];
335
- return v.replace(/^[^0-9]*/, '').split('.').slice(0, 3).map(n => Number(n) || 0);
336
- }
337
- function cmpSemver(a, b) {
338
- const va = parseSemver(a), vb = parseSemver(b);
339
- for (let i = 0; i < 3; i++) { if ((va[i]||0) > (vb[i]||0)) return 1; if ((va[i]||0) < (vb[i]||0)) return -1; }
340
- return 0;
341
- }
342
-
343
629
  const localGen = localVersion.generator || '0.0.0';
344
630
  const remoteGen = remoteVersion.generator || '0.0.0';
345
631
 
@@ -355,7 +641,7 @@ async function main() {
355
641
  const versionCmp = cmpSemver(remoteGen, localGen);
356
642
 
357
643
  if (!ignoreVersion && versionCmp <= 0) {
358
- const reportDowngrade = !stableTagResolved;
644
+ const reportDowngrade = !sourceStable;
359
645
  if (jsonOut) {
360
646
  console.log(JSON.stringify({
361
647
  status: (versionCmp < 0 && reportDowngrade) ? 'downgrade-refused' : 'up-to-date',
@@ -378,6 +664,18 @@ async function main() {
378
664
 
379
665
  const remoteChecksums = remoteVersion.checksums || {};
380
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();
381
679
  localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
382
680
 
383
681
  /** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
@@ -385,13 +683,37 @@ async function main() {
385
683
  return remoteSources[file] || file;
386
684
  }
387
685
 
686
+ async function fetchSourceFile(templateRelPath) {
687
+ if (source.kind === 'npm') return readNpmFile(source.package, templateRelPath);
688
+ return fetchRemote(source.base + templateRelPath);
689
+ }
690
+
691
+ function sourceUrl(templateRelPath) {
692
+ if (source.kind === 'npm') return `${source.label}${templateRelPath}`;
693
+ return source.base + templateRelPath;
694
+ }
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
+
388
710
  function withRemoteMeta(entry) {
389
711
  if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
390
712
  const templateHint = remotePath(entry.file);
391
713
  return {
392
714
  ...entry,
393
715
  templateHint,
394
- remoteUrl: sourceBase + templateHint,
716
+ remoteUrl: sourceUrl(templateHint),
395
717
  };
396
718
  }
397
719
 
@@ -419,6 +741,7 @@ async function main() {
419
741
  adopted: plan.adopted.map(withRemoteMeta),
420
742
  conflict: verbose ? plan.conflict.map(withConflictActions) : plan.conflict.map(withConflictActions).slice(0, 5),
421
743
  conflictTruncated: !verbose && plan.conflict.length > 5 ? plan.conflict.length - 5 : undefined,
744
+ moved: plan.moved.map(withRemoteMeta),
422
745
  skipped: plan.skipped.map(withRemoteMeta),
423
746
  };
424
747
  }
@@ -438,8 +761,12 @@ async function main() {
438
761
  safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
439
762
  strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
440
763
  finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
764
+ pathMoves: remoteMoves,
441
765
  partialUpdate: localVersion.partialUpdate || null,
442
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.',
443
770
  // aiMergeRequired array is attached only in verbose mode to keep default output token-safe.
444
771
  ...(verbose ? { aiMergeRequired: conflicts } : {}),
445
772
  aiMergeRequiredCount: totalConflicts,
@@ -459,7 +786,7 @@ async function main() {
459
786
  const dest = safePath(file);
460
787
  if (!dest) throw new Error(`Traversal rejected: ${file}`);
461
788
  if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) throw new Error(`Symlink rejected: ${file}`);
462
- const content = await fetchRemote(sourceBase + remotePath(file));
789
+ const content = await fetchSourceFile(remotePath(file));
463
790
  const normalized = content.replace(/\r\n/g, '\n');
464
791
  const fetchedHash = sha256(normalized);
465
792
  if (fetchedHash !== remoteHash) throw new Error(`Hash mismatch: ${file}`);
@@ -490,12 +817,18 @@ async function main() {
490
817
  const decision = localVersion.acceptedConflicts?.[file];
491
818
  return Boolean(
492
819
  decision
493
- && decision.targetGenerator === remoteGen
494
820
  && decision.localHash === localHash
495
821
  && decision.remoteHash === remoteHash
496
822
  );
497
823
  }
498
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
+
499
832
  let decisionMetadataDirty = false;
500
833
  if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
501
834
  const appliedAt = new Date().toISOString();
@@ -517,10 +850,11 @@ async function main() {
517
850
 
518
851
  const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
519
852
 
520
- const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
853
+ const plan = { updated: [], created: [], adopted: [], moved: [], conflict: [], skipped: [] };
521
854
 
522
855
  for (const file of [...allFiles].sort()) {
523
856
  const canonical = canonicalPath(file);
857
+ if (movedToFiles.has(canonical)) continue;
524
858
 
525
859
  // Reject paths that escape ROOT before any file access
526
860
  const diskPath = safePath(file);
@@ -533,6 +867,47 @@ async function main() {
533
867
  const storedHash = localChecksums[file];
534
868
  const remoteHash = remoteChecksums[file];
535
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
+
536
911
  if (!remoteHash) {
537
912
  if (storedHash && isInstalledOptionalFile(file, localVersion)) {
538
913
  plan.skipped.push({ file, reason: 'installed optional workflow file' });
@@ -542,8 +917,11 @@ async function main() {
542
917
  continue;
543
918
  }
544
919
 
545
- if (BOOTSTRAP_ONLY_FILES.has(canonical) && localHash === null) {
546
- 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' });
547
925
  continue;
548
926
  }
549
927
 
@@ -555,16 +933,22 @@ async function main() {
555
933
  continue;
556
934
  }
557
935
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
558
- 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' });
559
941
  continue;
560
942
  }
561
943
  plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
562
944
  continue;
563
945
  }
564
- // New file: still respect PRESERVE classification.
565
- 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);
566
950
  if (tier === 'PRESERVE') {
567
- 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' });
568
952
  } else {
569
953
  plan.created.push({ file, remoteHash });
570
954
  }
@@ -579,7 +963,7 @@ async function main() {
579
963
  && localHash !== remoteHash
580
964
  ) {
581
965
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
582
- plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
966
+ plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
583
967
  continue;
584
968
  }
585
969
  plan.conflict.push({
@@ -592,7 +976,7 @@ async function main() {
592
976
  continue;
593
977
  }
594
978
 
595
- const tier = classify(canonical, localHash, storedHash);
979
+ const tier = classify(canonical, localHash, storedHash, manifestCtx);
596
980
 
597
981
  if (tier === 'PRESERVE') {
598
982
  plan.skipped.push({ file, reason: 'PRESERVE: user data' });
@@ -604,7 +988,7 @@ async function main() {
604
988
  }
605
989
  } else if (tier === 'CONFLICT') {
606
990
  if (acceptedDecisionMatches(file, localHash, remoteHash)) {
607
- plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
991
+ plan.skipped.push({ file, reason: acceptedDecisionReason(file) });
608
992
  continue;
609
993
  }
610
994
  plan.conflict.push({
@@ -612,7 +996,7 @@ async function main() {
612
996
  localHash,
613
997
  storedHash,
614
998
  remoteHash,
615
- reason: MERGE_PATTERNS.some(p => p.test(canonical))
999
+ reason: isMergeFile(canonical, manifestCtx)
616
1000
  ? 'user modified MERGE file'
617
1001
  : 'user modified runtime file',
618
1002
  });
@@ -628,11 +1012,13 @@ async function main() {
628
1012
  from: localGen,
629
1013
  to: remoteGen,
630
1014
  sourceBase,
1015
+ releaseNotes: remoteVersion.releaseNotes || null,
631
1016
  partialUpdate: localVersion.partialUpdate || null,
632
1017
  acceptedConflicts: localVersion.acceptedConflicts || {},
633
1018
  updated: plan.updated.length,
634
1019
  created: plan.created.length,
635
1020
  adopted: plan.adopted.length,
1021
+ moved: plan.moved.length,
636
1022
  conflict: plan.conflict.length,
637
1023
  skipped: plan.skipped.length,
638
1024
  // Token-safe by default: attach the full plan only when --verbose / --full-plan is passed.
@@ -643,7 +1029,8 @@ async function main() {
643
1029
  }
644
1030
 
645
1031
  console.log(`\nUpdate: v${localGen} -> v${remoteGen}`);
646
- 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);
647
1034
 
648
1035
  // Show conflicts (these need AI/user decision)
649
1036
  if (plan.conflict.length > 0) {
@@ -658,10 +1045,11 @@ async function main() {
658
1045
  }
659
1046
 
660
1047
  // Show what will be auto-updated
661
- if (plan.updated.length + plan.created.length > 0) {
1048
+ if (plan.updated.length + plan.created.length + plan.moved.length > 0) {
662
1049
  console.log('AUTO (safe to apply):');
663
1050
  for (const u of plan.updated) console.log(` ^ ${u.file}`);
664
1051
  for (const c of plan.created) console.log(` + ${c.file}`);
1052
+ for (const m of plan.moved) console.log(` > ${m.from} -> ${m.to}`);
665
1053
  console.log('');
666
1054
  }
667
1055
 
@@ -678,56 +1066,87 @@ async function main() {
678
1066
  console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
679
1067
  }
680
1068
 
681
- const lexists = existsSync;
682
- let applied = 0;
1069
+ const preparedWrites = [];
683
1070
  let failed = 0;
684
1071
 
685
- for (const u of plan.updated) {
1072
+ async function prepareWrite(entry, { mustNotExist = false } = {}) {
686
1073
  try {
687
- const dest = safePath(u.file);
688
- if (!dest) { console.error(` x Traversal rejected: ${u.file}`); failed++; continue; }
1074
+ const dest = safePath(entry.file);
1075
+ if (!dest) return { ok: false, message: ` x Traversal rejected: ${entry.file}` };
689
1076
  // Symlink rejection: do not follow symlinks.
690
- if (lexists(dest)) {
691
- try { if (lstatSync(dest).isSymbolicLink()) { console.error(` x Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
1077
+ if (existsSync(dest)) {
1078
+ try { if (lstatSync(dest).isSymbolicLink()) return { ok: false, message: ` x Symlink rejected: ${entry.file}` }; } catch (_) {}
1079
+ if (mustNotExist) {
1080
+ return { ok: false, message: ` x File created since plan: ${entry.file} - treating as CONFLICT` };
1081
+ }
692
1082
  }
693
- const content = await fetchRemote(sourceBase + remotePath(u.file));
1083
+ const content = await fetchSourceFile(remotePath(entry.file));
694
1084
  const normalized = content.replace(/\r\n/g, '\n');
695
1085
  const fetchedHash = sha256(normalized);
696
- if (fetchedHash !== u.remoteHash) {
697
- console.error(` x Hash mismatch: ${u.file}`);
698
- failed++; continue;
1086
+ if (fetchedHash !== entry.remoteHash) {
1087
+ return { ok: false, message: ` x Hash mismatch: ${entry.file}` };
699
1088
  }
700
- mkdirSync(dirname(dest), { recursive: true });
701
- writeFileSync(dest, normalized, 'utf-8');
702
- applied++;
1089
+ return { ok: true, prepared: { file: entry.file, dest, content: normalized, mustNotExist } };
703
1090
  } catch (e) {
704
- console.error(` x Failed: ${u.file} - ${e.message}`);
1091
+ return { ok: false, message: ` x Failed: ${entry.file} - ${e.message}` };
1092
+ }
1093
+ }
1094
+
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);
705
1110
  failed++;
706
1111
  }
707
1112
  }
708
1113
 
709
- for (const c of plan.created) {
1114
+ if (failed === 0) {
1115
+ for (const prepared of preparedWrites) {
1116
+ if (prepared.mustNotExist && existsSync(prepared.dest)) {
1117
+ console.error(` x File created since plan: ${prepared.file} - treating as CONFLICT`);
1118
+ failed++;
1119
+ }
1120
+ }
1121
+ }
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.
1129
+ let applied = 0;
1130
+ if (failed === 0) {
710
1131
  try {
711
- const dest = safePath(c.file);
712
- if (!dest) { console.error(` x Traversal rejected: ${c.file}`); failed++; continue; }
713
- // TOCTOU: recheck file didn't appear since planning
714
- if (lexists(dest)) {
715
- try { if (lstatSync(dest).isSymbolicLink()) { console.error(` x Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
716
- console.error(` x File created since plan: ${c.file} - treating as CONFLICT`);
717
- failed++; continue;
1132
+ for (const prepared of preparedWrites) {
1133
+ mkdirSync(dirname(prepared.dest), { recursive: true });
1134
+ writeFileSync(prepared.dest, prepared.content, 'utf-8');
1135
+ applied++;
718
1136
  }
719
- const content = await fetchRemote(sourceBase + remotePath(c.file));
720
- const normalized = content.replace(/\r\n/g, '\n');
721
- const fetchedHash = sha256(normalized);
722
- if (fetchedHash !== c.remoteHash) {
723
- console.error(` x Hash mismatch: ${c.file}`);
724
- failed++; continue;
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);
725
1147
  }
726
- mkdirSync(dirname(dest), { recursive: true });
727
- writeFileSync(dest, normalized, 'utf-8');
728
- applied++;
729
1148
  } catch (e) {
730
- console.error(` x Failed: ${c.file} - ${e.message}`);
1149
+ console.error(` x Failed while writing prepared files - ${e.message}`);
731
1150
  failed++;
732
1151
  }
733
1152
  }
@@ -739,24 +1158,43 @@ async function main() {
739
1158
  for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
740
1159
  for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
741
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;
742
1174
  if (plan.conflict.length === 0) {
743
1175
  localVersion.generator = remoteGen;
744
1176
  localVersion.generated = appliedAt;
1177
+ if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
1178
+ else delete localVersion.releaseNotes;
745
1179
  delete localVersion.partialUpdate;
746
1180
  } else {
747
1181
  localVersion.partialUpdate = {
748
1182
  targetGenerator: remoteGen,
749
1183
  updatedAt: appliedAt,
750
- appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
1184
+ appliedFiles: [...plan.updated, ...plan.created, ...plan.moved].map(x => x.file),
751
1185
  adoptedFiles: plan.adopted.map(x => x.file),
1186
+ movedFiles: plan.moved.map(x => ({ from: x.from, to: x.to })),
752
1187
  conflicts: plan.conflict.map(x => x.file),
1188
+ releaseNotes: remoteVersion.releaseNotes || null,
753
1189
  };
754
1190
  }
755
1191
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
756
1192
  if (plan.conflict.length === 0) {
757
1193
  console.log(`Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
1194
+ printReleaseHighlights(remoteVersion);
758
1195
  } else {
759
1196
  console.log(`Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
1197
+ printReleaseHighlights(remoteVersion);
760
1198
  }
761
1199
  } else {
762
1200
  console.log(`${failed} failures. NO files were version-tracked. Fix and re-run.`);
@@ -765,8 +1203,8 @@ async function main() {
765
1203
  }
766
1204
 
767
1205
  if (finalize) {
768
- if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
769
- 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.`);
770
1208
  if (decisionMetadataDirty) {
771
1209
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
772
1210
  }
@@ -784,9 +1222,12 @@ async function main() {
784
1222
  const finalizedAt = new Date().toISOString();
785
1223
  localVersion.generator = remoteGen;
786
1224
  localVersion.generated = finalizedAt;
1225
+ if (remoteVersion.releaseNotes) localVersion.releaseNotes = remoteVersion.releaseNotes;
1226
+ else delete localVersion.releaseNotes;
787
1227
  delete localVersion.partialUpdate;
788
1228
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
789
1229
  console.log(`Finalized Harness update to ${remoteGen}.`);
1230
+ printReleaseHighlights(remoteVersion);
790
1231
  } else if (decisionMetadataDirty) {
791
1232
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
792
1233
  console.log('Recorded conflict decision metadata.');