create-harness-vibe-coding 0.8.6 → 0.8.8

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 (93) hide show
  1. package/README-CN.md +157 -105
  2. package/README.md +160 -244
  3. package/bin/create-harness-vibe-coding.js +2 -2
  4. package/docs/images/harness-architecture-light.png +0 -0
  5. package/docs/images/harness-architecture.drawio +164 -0
  6. package/package.json +46 -44
  7. package/src/generator.js +30 -11
  8. package/src/index.js +143 -14
  9. package/src/prompts.js +37 -37
  10. package/templates/common/.claude/agents/architect-manager.md +45 -45
  11. package/templates/common/.claude/agents/context-master.md +75 -75
  12. package/templates/common/.claude/agents/debugger.md +41 -41
  13. package/templates/common/.claude/agents/explore-manager.md +41 -41
  14. package/templates/common/.claude/agents/implement-manager.md +49 -49
  15. package/templates/common/.claude/agents/implementer.md +40 -40
  16. package/templates/common/.claude/agents/memory-master.md +64 -64
  17. package/templates/common/.claude/agents/planner.md +34 -34
  18. package/templates/common/.claude/agents/reflector.md +35 -0
  19. package/templates/common/.claude/agents/researcher.md +41 -41
  20. package/templates/common/.claude/agents/review-manager.md +56 -56
  21. package/templates/common/.claude/agents/verifier.md +34 -32
  22. package/templates/common/.claude/commands/wf-help.md +2 -3
  23. package/templates/common/.claude/rules/ecc/common.md +44 -44
  24. package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +10 -4
  25. package/templates/common/.claude/skills/wf/SKILL.md +7 -3
  26. package/templates/common/.claude/skills/wf-auto/SKILL.md +61 -107
  27. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +19 -17
  28. package/templates/common/.claude/skills/wf-max/SKILL.md +40 -21
  29. package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
  30. package/templates/common/.claude/skills/wf-remove/SKILL.md +7 -7
  31. package/templates/common/.claude/skills/wf-update/SKILL.md +9 -4
  32. package/templates/common/.codex/config.toml +5 -0
  33. package/templates/common/.harness-version +78 -36
  34. package/templates/common/.opencode/agents/architect-manager.md +52 -0
  35. package/templates/common/.opencode/agents/architect.md +35 -0
  36. package/templates/common/.opencode/agents/context-master.md +81 -0
  37. package/templates/common/.opencode/agents/debugger.md +43 -0
  38. package/templates/common/.opencode/agents/docs-researcher.md +42 -0
  39. package/templates/common/.opencode/agents/explore-manager.md +49 -0
  40. package/templates/common/.opencode/agents/implement-manager.md +56 -0
  41. package/templates/common/.opencode/agents/implementer.md +42 -0
  42. package/templates/common/.opencode/agents/memory-master.md +70 -0
  43. package/templates/common/.opencode/agents/planner.md +38 -0
  44. package/templates/common/.opencode/agents/reflector.md +39 -0
  45. package/templates/common/.opencode/agents/researcher.md +42 -0
  46. package/templates/common/.opencode/agents/review-manager.md +63 -0
  47. package/templates/common/.opencode/agents/reviewer.md +37 -0
  48. package/templates/common/.opencode/agents/tdd-guide.md +83 -0
  49. package/templates/common/.opencode/agents/test-writer.md +54 -0
  50. package/templates/common/.opencode/agents/verifier.md +37 -0
  51. package/templates/common/.opencode/commands/wf-help.md +23 -0
  52. package/templates/common/AGENTS.md +26 -25
  53. package/templates/common/CLAUDE.md +86 -88
  54. package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +12 -4
  55. package/templates/common/Harness/PROGRESS.md +17 -17
  56. package/templates/common/Harness/README.md +26 -16
  57. package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
  58. package/templates/common/Harness/WF-AUTO-SPARK.md +23 -6
  59. package/templates/common/Harness/WF-AUTO.md +508 -493
  60. package/templates/common/Harness/WF-MAX.md +284 -232
  61. package/templates/common/Harness/WF.md +47 -29
  62. package/templates/common/Harness/agent-workflow.md +108 -76
  63. package/templates/common/Harness/architecture.md +124 -124
  64. package/templates/common/Harness/context-loading.md +111 -111
  65. package/templates/common/Harness/dispatch.md +96 -95
  66. package/templates/common/Harness/extension.md +67 -67
  67. package/templates/common/Harness/lifecycle.md +20 -20
  68. package/templates/common/Harness/research/PRD.md +56 -56
  69. package/templates/common/Harness/research/README.md +169 -169
  70. package/templates/common/Harness/research/research-results.md +66 -66
  71. package/templates/common/Harness/subagents.md +192 -170
  72. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +3 -3
  73. package/templates/common/Harness/tasks/_template/NOTES.md +3 -3
  74. package/templates/common/Harness/tasks/_template/PLAN.md +53 -60
  75. package/templates/common/Harness/tasks/_template/PROGRESS.md +26 -29
  76. package/templates/common/MEMORY.md +27 -30
  77. package/templates/common/README.md +36 -36
  78. package/templates/common/SETUP.md +1 -1
  79. package/templates/common/memory/agent-lessons-patterns.md +21 -21
  80. package/templates/common/memory/tool-usage-reflections.md +21 -21
  81. package/templates/common/memory/user-corrections-preferences.md +21 -21
  82. package/templates/common/opencode.json +19 -0
  83. package/templates/common/scripts/scan-clean.mjs +487 -448
  84. package/templates/common/scripts/validate-harness.mjs +245 -146
  85. package/templates/common/scripts/wf-remove.mjs +311 -293
  86. package/templates/common/scripts/wf-update-check.mjs +511 -311
  87. package/templates/optional/catalog.json +41 -33
  88. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +194 -194
  89. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +105 -69
  90. package/templates/optional/skills/github-pr-review/Harness/workflows/github-pr-review.md +28 -28
  91. package/templates/optional/skills/python-backend/Harness/workflows/python-backend.md +34 -34
  92. package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +34 -34
  93. package/templates/optional/skills/ui-ux-review/Harness/workflows/ui-ux-review.md +26 -26
@@ -1,66 +1,72 @@
1
- #!/usr/bin/env node
2
- /**
3
- * wf-update-check.mjs — Fast harness update comparison.
4
- * Fetches remote checksums, compares locally, classifies all files instantly.
5
- * Only CONFLICT files need AI/user decision.
6
- *
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wf-update-check.mjs — Fast harness update comparison.
4
+ * Fetches remote checksums, compares locally, classifies all files instantly.
5
+ * Only CONFLICT files need AI/user decision.
6
+ *
7
7
  * Usage:
8
8
  * node Harness/scripts/wf-update-check.mjs # full dry-run plan
9
9
  * node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
10
10
  * node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
11
11
  * node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
12
12
  */
13
-
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
15
- import { createHash } from 'crypto';
16
- import { resolve, dirname, sep } from 'path';
17
- import { fileURLToPath } from 'url';
18
-
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
15
+ import { createHash } from 'crypto';
16
+ import { resolve, dirname, sep } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
21
21
  const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
22
22
  const DEFAULT_SOURCE_BASE = 'https://raw.githubusercontent.com/zingspark/create-harness-vibe-coding/main/templates/common/';
23
-
24
- // ── Tier classification ──────────────────────────────────────────
25
-
26
- /** Files we NEVER overwrite or delete. */
27
- const PRESERVE_PATTERNS = [
28
- /^Harness\/PROGRESS\.md$/,
29
- /^Harness\/tasks\//,
30
- /^Harness\/memory\//,
31
- /^Harness\/research\/PRD\.md$/,
32
- /^Harness\/research\/research-results\.md$/,
33
- /^Harness\/architecture\.md$/,
34
- /^README\.md$/,
35
- /^\.gitignore$/,
36
- /^package\.json$/,
37
- /^package-lock\.json$/,
38
- ];
39
-
40
- /** Files that are safe to overwrite if checksums match, otherwise need merge. */
41
- const MERGE_PATTERNS = [
42
- /^CLAUDE\.md$/,
43
- /^AGENTS\.md$/,
44
- /^MEMORY\.md$/,
45
- /^Harness\/MEMORY\.md$/,
46
- /^Harness\/README\.md$/,
47
- ];
48
-
49
- // ── Helpers ────────────────────────────────────────────────────────
50
-
51
- /** Reject paths that escape ROOT (traversal, absolute, .., etc.). */
52
- function safePath(file) {
53
- let normalized = file.replace(/\\/g, '/').replace(/^\/+/, '');
54
- if (/\/\//.test(normalized)) return null;
55
- if (normalized.split('/').some(p => p === '..')) return null;
56
- if (file.startsWith('/') || file.startsWith('\\')) return null;
57
- if (normalized === '.' || normalized === '') return null;
58
- const resolved = resolve(ROOT, normalized);
59
- if (!resolved.startsWith(ROOT + sep) && resolved !== ROOT) return null;
60
- return resolved;
61
- }
62
-
63
- /** Canonical normalization for classification matching. */
23
+
24
+ // ── Tier classification ──────────────────────────────────────────
25
+
26
+ /** Files we NEVER overwrite or delete. */
27
+ const PRESERVE_PATTERNS = [
28
+ /^Harness\/PROGRESS\.md$/,
29
+ /^Harness\/tasks\//,
30
+ /^Harness\/memory\//,
31
+ /^Harness\/research\/PRD\.md$/,
32
+ /^Harness\/research\/research-results\.md$/,
33
+ /^Harness\/architecture\.md$/,
34
+ /^README\.md$/,
35
+ /^\.gitignore$/,
36
+ /^package\.json$/,
37
+ /^package-lock\.json$/,
38
+ ];
39
+
40
+ /** Files that are safe to overwrite if checksums match, otherwise need merge. */
41
+ const MERGE_PATTERNS = [
42
+ /^CLAUDE\.md$/,
43
+ /^AGENTS\.md$/,
44
+ /^MEMORY\.md$/,
45
+ /^Harness\/MEMORY\.md$/,
46
+ /^Harness\/README\.md$/,
47
+ ];
48
+
49
+ const OPTIONAL_REGISTRATION_FILES = new Set([
50
+ '.claude/commands/wf-help.md',
51
+ 'Harness/MEMORY.md',
52
+ 'Harness/README.md',
53
+ ]);
54
+
55
+ // ── Helpers ────────────────────────────────────────────────────────
56
+
57
+ /** Reject paths that escape ROOT (traversal, absolute, .., etc.). */
58
+ function safePath(file) {
59
+ let normalized = file.replace(/\\/g, '/').replace(/^\/+/, '');
60
+ if (/\/\//.test(normalized)) return null;
61
+ if (normalized.split('/').some(p => p === '..')) return null;
62
+ if (file.startsWith('/') || file.startsWith('\\')) return null;
63
+ if (normalized === '.' || normalized === '') return null;
64
+ const resolved = resolve(ROOT, normalized);
65
+ if (!resolved.startsWith(ROOT + sep) && resolved !== ROOT) return null;
66
+ return resolved;
67
+ }
68
+
69
+ /** Canonical normalization for classification matching. */
64
70
  function canonicalPath(file) {
65
71
  return file.replace(/\\/g, '/').replace(/\/+/g, '/');
66
72
  }
@@ -73,44 +79,81 @@ function readFlagValue(args, flagName) {
73
79
  return value;
74
80
  }
75
81
 
82
+ function readRepeatedFlagValues(args, flagName) {
83
+ const values = [];
84
+ for (let i = 0; i < args.length; i += 1) {
85
+ const arg = args[i];
86
+ if (arg === flagName && args[i + 1] && !args[i + 1].startsWith('--')) {
87
+ values.push(args[i + 1]);
88
+ i += 1;
89
+ } else if (arg.startsWith(`${flagName}=`)) {
90
+ values.push(arg.slice(flagName.length + 1));
91
+ }
92
+ }
93
+ return [...new Set(values.map(canonicalPath).filter(Boolean))];
94
+ }
95
+
76
96
  function normalizeSourceBase(sourceBase) {
77
97
  return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
78
98
  }
79
99
 
100
+ function selectedOptionIds(localVersion) {
101
+ return new Set(
102
+ Array.isArray(localVersion?.options)
103
+ ? localVersion.options.map(String).filter(Boolean)
104
+ : [],
105
+ );
106
+ }
107
+
108
+ function optionalSkillFromSource(source) {
109
+ const normalized = canonicalPath(source || '');
110
+ const match = normalized.match(/^skills\/([^/]+)\//);
111
+ return match ? match[1] : null;
112
+ }
113
+
114
+ function isInstalledOptionalFile(file, localVersion) {
115
+ const skillId = optionalSkillFromSource(localVersion?.sources?.[file]);
116
+ return Boolean(skillId && selectedOptionIds(localVersion).has(skillId));
117
+ }
118
+
119
+ function hasInstalledOptions(localVersion) {
120
+ return selectedOptionIds(localVersion).size > 0;
121
+ }
122
+
80
123
  /** Detect template placeholders in remote content. */
81
124
  function isTemplate(raw) {
82
125
  return /\{\{[a-zA-Z]+\}\}/.test(raw);
83
126
  }
84
-
85
- function sha256(content) {
86
- return 'sha256-' + createHash('sha256').update(content).digest('hex');
87
- }
88
-
89
- function sha256File(path) {
90
- if (!existsSync(path)) return null;
91
- let content = readFileSync(path, 'utf-8');
92
- // Normalize CRLF → LF
93
- content = content.replace(/\r\n/g, '\n');
94
- return sha256(content);
95
- }
96
-
97
- function classify(file, localHash, storedHash) {
98
- // PRESERVE
99
- for (const p of PRESERVE_PATTERNS) {
100
- if (p.test(file)) return 'PRESERVE';
101
- }
102
- // MERGE — dual-purpose, check if user modified
103
- for (const p of MERGE_PATTERNS) {
104
- if (p.test(file)) {
105
- if (localHash === storedHash) return 'SAFE'; // unmodified, safe
106
- return 'CONFLICT'; // user modified, needs decision
107
- }
108
- }
109
- // Everything else is SAFE runtime file
110
- if (localHash === storedHash || localHash === null) return 'SAFE';
111
- return 'CONFLICT'; // modified runtime file — unexpected
112
- }
113
-
127
+
128
+ function sha256(content) {
129
+ return 'sha256-' + createHash('sha256').update(content).digest('hex');
130
+ }
131
+
132
+ function sha256File(path) {
133
+ if (!existsSync(path)) return null;
134
+ let content = readFileSync(path, 'utf-8');
135
+ // Normalize CRLF → LF
136
+ content = content.replace(/\r\n/g, '\n');
137
+ return sha256(content);
138
+ }
139
+
140
+ function classify(file, localHash, storedHash) {
141
+ // PRESERVE
142
+ for (const p of PRESERVE_PATTERNS) {
143
+ if (p.test(file)) return 'PRESERVE';
144
+ }
145
+ // MERGE — dual-purpose, check if user modified
146
+ for (const p of MERGE_PATTERNS) {
147
+ if (p.test(file)) {
148
+ if (localHash === storedHash) return 'SAFE'; // unmodified, safe
149
+ return 'CONFLICT'; // user modified, needs decision
150
+ }
151
+ }
152
+ // Everything else is SAFE runtime file
153
+ if (localHash === storedHash || localHash === null) return 'SAFE';
154
+ return 'CONFLICT'; // modified runtime file — unexpected
155
+ }
156
+
114
157
  async function fetchRemote(url, timeoutMs = 30000) {
115
158
  if (url.startsWith('file://')) {
116
159
  return readFileSync(fileURLToPath(url), 'utf-8');
@@ -118,95 +161,104 @@ async function fetchRemote(url, timeoutMs = 30000) {
118
161
 
119
162
  const controller = new AbortController();
120
163
  const timer = setTimeout(() => controller.abort(), timeoutMs);
121
- try {
122
- const res = await fetch(url, { signal: controller.signal });
123
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
124
- return res.text();
125
- } finally {
126
- clearTimeout(timer);
127
- }
128
- }
129
-
130
- // ── Main ───────────────────────────────────────────────────────────
131
-
164
+ try {
165
+ const res = await fetch(url, { signal: controller.signal });
166
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
167
+ return res.text();
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }
172
+
173
+ // ── Main ───────────────────────────────────────────────────────────
174
+
132
175
  async function main() {
133
176
  const args = process.argv.slice(2);
134
177
  const apply = args.includes('--apply');
135
178
  const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
136
179
  const jsonOut = args.includes('--json');
180
+ const finalize = args.includes('--finalize');
181
+ const acceptLocal = readRepeatedFlagValues(args, '--accept-local');
182
+ const acceptMerged = readRepeatedFlagValues(args, '--accept-merged');
183
+ const acceptTemplate = readRepeatedFlagValues(args, '--accept-template');
137
184
  const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
138
185
  const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
139
-
140
- // 1. Read local state
141
- if (!existsSync(VERSION_FILE)) {
142
- if (jsonOut) {
143
- console.log(JSON.stringify({ status: 'error', message: 'Local Harness/.harness-version not found.' }));
144
- } else {
145
- console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
146
- }
147
- return;
148
- }
149
-
150
- let localVersion;
151
- try {
152
- localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
153
- } catch (e) {
154
- if (jsonOut) {
155
- console.log(JSON.stringify({ status: 'error', message: 'Failed to parse Harness/.harness-version: ' + e.message }));
156
- } else {
157
- console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
158
- console.error(' The file may be corrupted. If this is an old project, try reinstalling the harness.');
159
- }
160
- return;
161
- }
162
-
163
- if (!localVersion || typeof localVersion !== 'object') {
164
- if (jsonOut) {
165
- console.log(JSON.stringify({ status: 'error', message: 'Invalid Harness/.harness-version structure.' }));
166
- } else {
167
- console.error('ERROR: Harness/.harness-version has unexpected structure.');
168
- }
169
- return;
170
- }
171
-
172
- const localChecksums = localVersion.checksums || {};
173
-
174
- // 2. Fetch remote version file
175
- let remoteVersion;
176
- try {
186
+
187
+ // 1. Read local state
188
+ if (!existsSync(VERSION_FILE)) {
189
+ if (jsonOut) {
190
+ console.log(JSON.stringify({ status: 'error', message: 'Local Harness/.harness-version not found.' }));
191
+ } else {
192
+ console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
193
+ }
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+
198
+ let localVersion;
199
+ try {
200
+ localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
201
+ } catch (e) {
202
+ if (jsonOut) {
203
+ console.log(JSON.stringify({ status: 'error', message: 'Failed to parse Harness/.harness-version: ' + e.message }));
204
+ } else {
205
+ console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
206
+ console.error(' The file may be corrupted. If this is an old project, try reinstalling the harness.');
207
+ }
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+
212
+ if (!localVersion || typeof localVersion !== 'object') {
213
+ if (jsonOut) {
214
+ console.log(JSON.stringify({ status: 'error', message: 'Invalid Harness/.harness-version structure.' }));
215
+ } else {
216
+ console.error('ERROR: Harness/.harness-version has unexpected structure.');
217
+ }
218
+ process.exitCode = 1;
219
+ return;
220
+ }
221
+
222
+ const localChecksums = localVersion.checksums || {};
223
+
224
+ // 2. Fetch remote version file
225
+ let remoteVersion;
226
+ try {
177
227
  const raw = await fetchRemote(sourceBase + '.harness-version');
178
- if (isTemplate(raw)) {
179
- if (jsonOut) {
180
- console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
181
- } else {
182
- console.log('⚠ Remote .harness-version is a template (contains {{placeholders}}).');
183
- console.log(' The generate step has not been run on the remote repo. No update possible.');
184
- console.log(' This is expected during development — the update mechanism works once the remote is live.');
185
- }
186
- return;
187
- }
188
- remoteVersion = JSON.parse(raw);
189
- } catch (e) {
190
- if (jsonOut) {
191
- console.log(JSON.stringify({ status: 'offline', message: 'Cannot reach GitHub.' }));
192
- } else {
193
- console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
194
- console.error(e.message);
195
- }
196
- return;
197
- }
198
-
199
- // Compare versions — warn if remote is older (downgrade prevention)
200
- function parseSemver(v) {
201
- if (!v || typeof v !== 'string') return [0, 0, 0];
202
- return v.replace(/^[^0-9]*/, '').split('-')[0].split('.').map(Number);
203
- }
204
- function cmpSemver(a, b) {
205
- const va = parseSemver(a), vb = parseSemver(b);
206
- 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; }
207
- return 0;
208
- }
209
-
228
+ if (isTemplate(raw)) {
229
+ if (jsonOut) {
230
+ console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
231
+ } else {
232
+ console.log('⚠ Remote .harness-version is a template (contains {{placeholders}}).');
233
+ console.log(' The generate step has not been run on the remote repo. No update possible.');
234
+ console.log(' This is expected during development — the update mechanism works once the remote is live.');
235
+ }
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+ remoteVersion = JSON.parse(raw);
240
+ } catch (e) {
241
+ if (jsonOut) {
242
+ console.log(JSON.stringify({ status: 'offline', message: 'Cannot reach GitHub.' }));
243
+ } else {
244
+ console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
245
+ console.error(e.message);
246
+ }
247
+ process.exitCode = 1;
248
+ return;
249
+ }
250
+
251
+ // Compare versions warn if remote is older (downgrade prevention)
252
+ function parseSemver(v) {
253
+ if (!v || typeof v !== 'string') return [0, 0, 0];
254
+ return v.replace(/^[^0-9]*/, '').split('-')[0].split('.').map(Number);
255
+ }
256
+ function cmpSemver(a, b) {
257
+ const va = parseSemver(a), vb = parseSemver(b);
258
+ 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; }
259
+ return 0;
260
+ }
261
+
210
262
  const localGen = localVersion.generator || '0.0.0';
211
263
  const remoteGen = remoteVersion.generator || '0.0.0';
212
264
  const versionCmp = cmpSemver(remoteGen, localGen);
@@ -222,19 +274,21 @@ async function main() {
222
274
  } else if (versionCmp < 0) {
223
275
  console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
224
276
  } else {
225
- console.log(`✅ Already up to date (v${localGen})`);
226
- }
227
- return;
228
- }
229
-
230
- if (ignoreVersion) {
231
- if (!jsonOut) console.log('🔧 Version check bypassed (--ignore-version). Comparing files anyway.');
232
- }
233
-
234
- const remoteChecksums = remoteVersion.checksums || {};
235
- const remoteSources = remoteVersion.sources || {};
236
-
237
- /** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
277
+ console.log(`✅ Already up to date (v${localGen})`);
278
+ }
279
+ if (versionCmp < 0) process.exitCode = 1;
280
+ return;
281
+ }
282
+
283
+ if (ignoreVersion) {
284
+ if (!jsonOut) console.log('🔧 Version check bypassed (--ignore-version). Comparing files anyway.');
285
+ }
286
+
287
+ const remoteChecksums = remoteVersion.checksums || {};
288
+ const remoteSources = remoteVersion.sources || {};
289
+ localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
290
+
291
+ /** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
238
292
  function remotePath(file) {
239
293
  return remoteSources[file] || file;
240
294
  }
@@ -253,13 +307,23 @@ async function main() {
253
307
  return {
254
308
  ...withRemoteMeta(entry),
255
309
  choices: ['merge', 'keep-local', 'overwrite-from-template'],
310
+ decisionCommands: {
311
+ keepLocal: `node Harness/scripts/wf-update-check.mjs --accept-local ${shellArg(entry.file)} --finalize`,
312
+ acceptMerged: `node Harness/scripts/wf-update-check.mjs --accept-merged ${shellArg(entry.file)} --finalize`,
313
+ overwriteFromTemplate: `node Harness/scripts/wf-update-check.mjs --accept-template ${shellArg(entry.file)} --finalize`,
314
+ },
256
315
  };
257
316
  }
258
317
 
318
+ function shellArg(value) {
319
+ return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
320
+ }
321
+
259
322
  function buildJsonPlan() {
260
323
  return {
261
324
  updated: plan.updated.map(withRemoteMeta),
262
325
  created: plan.created.map(withRemoteMeta),
326
+ adopted: plan.adopted.map(withRemoteMeta),
263
327
  conflict: plan.conflict.map(withConflictActions),
264
328
  skipped: plan.skipped.map(withRemoteMeta),
265
329
  };
@@ -271,58 +335,156 @@ async function main() {
271
335
  dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
272
336
  safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
273
337
  strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
338
+ finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
274
339
  partialUpdate: localVersion.partialUpdate || null,
340
+ acceptedConflicts: localVersion.acceptedConflicts || {},
275
341
  aiMergeRequired: jsonPlan.conflict,
276
342
  aiMergeRequiredCount: jsonPlan.conflict.length,
277
- conflictPolicy: 'Use the script for SAFE/NEW files. For each CONFLICT file, compare local content with templateHint/remoteUrl and choose merge, keep-local, or overwrite-from-template.',
343
+ conflictPolicy: 'Use the script for SAFE/NEW/adopted files. For each CONFLICT file, compare local content with templateHint/remoteUrl, then record the decision with --accept-local, --accept-merged, or --accept-template. Do not hand-edit Harness/.harness-version.',
278
344
  postUpdateCommands: [
279
- 'node Harness/scripts/validate-harness.mjs --strict',
280
- 'node Harness/scripts/scan-clean.mjs',
345
+ 'node Harness/scripts/validate-harness.mjs',
346
+ 'node Harness/scripts/scan-clean.mjs --json',
281
347
  ],
348
+ bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
282
349
  };
283
350
  }
284
351
 
352
+ async function applyTemplateFile(file) {
353
+ const remoteHash = remoteChecksums[file];
354
+ if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
355
+ const dest = safePath(file);
356
+ if (!dest) throw new Error(`Traversal rejected: ${file}`);
357
+ if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) throw new Error(`Symlink rejected: ${file}`);
358
+ const content = await fetchRemote(sourceBase + remotePath(file));
359
+ const normalized = content.replace(/\r\n/g, '\n');
360
+ const fetchedHash = sha256(normalized);
361
+ if (fetchedHash !== remoteHash) throw new Error(`Hash mismatch: ${file}`);
362
+ mkdirSync(dirname(dest), { recursive: true });
363
+ writeFileSync(dest, normalized, 'utf-8');
364
+ localVersion.checksums[file] = remoteHash;
365
+ delete localVersion.acceptedConflicts[file];
366
+ }
367
+
368
+ function recordLocalDecision(file, decision, appliedAt) {
369
+ const remoteHash = remoteChecksums[file];
370
+ if (!remoteHash) throw new Error(`No remote checksum for ${file}`);
371
+ const diskPath = safePath(file);
372
+ if (!diskPath) throw new Error(`Traversal rejected: ${file}`);
373
+ const localHash = sha256File(diskPath);
374
+ if (!localHash) throw new Error(`File not found for ${decision}: ${file}`);
375
+ localVersion.acceptedConflicts[file] = {
376
+ decision,
377
+ targetGenerator: remoteGen,
378
+ localHash,
379
+ remoteHash,
380
+ templateHint: remotePath(file),
381
+ updatedAt: appliedAt,
382
+ };
383
+ }
384
+
385
+ function acceptedDecisionMatches(file, localHash, remoteHash) {
386
+ const decision = localVersion.acceptedConflicts?.[file];
387
+ return Boolean(
388
+ decision
389
+ && decision.targetGenerator === remoteGen
390
+ && decision.localHash === localHash
391
+ && decision.remoteHash === remoteHash
392
+ );
393
+ }
394
+
395
+ let decisionMetadataDirty = false;
396
+ if (acceptLocal.length || acceptMerged.length || acceptTemplate.length) {
397
+ const appliedAt = new Date().toISOString();
398
+ try {
399
+ for (const file of acceptLocal) recordLocalDecision(file, 'accept-local', appliedAt);
400
+ for (const file of acceptMerged) recordLocalDecision(file, 'accept-merged', appliedAt);
401
+ for (const file of acceptTemplate) await applyTemplateFile(file);
402
+ decisionMetadataDirty = true;
403
+ } catch (e) {
404
+ if (jsonOut) {
405
+ console.log(JSON.stringify({ status: 'error', message: e.message }, null, 2));
406
+ } else {
407
+ console.error(`ERROR: ${e.message}`);
408
+ }
409
+ process.exitCode = 1;
410
+ return;
411
+ }
412
+ }
413
+
285
414
  const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
286
-
287
- const plan = { updated: [], created: [], conflict: [], skipped: [] };
288
-
289
- for (const file of [...allFiles].sort()) {
290
- const canonical = canonicalPath(file);
291
-
292
- // Reject paths that escape ROOT before any file access
293
- const diskPath = safePath(file);
294
- if (!diskPath) {
295
- plan.skipped.push({ file, reason: 'path traversal rejected' });
296
- continue;
297
- }
298
-
299
- const localHash = sha256File(diskPath);
300
- const storedHash = localChecksums[file];
301
- const remoteHash = remoteChecksums[file];
302
-
303
- if (!remoteHash) {
304
- plan.skipped.push({ file, reason: 'not in remote' });
305
- continue;
306
- }
307
-
308
- if (!storedHash) {
309
- // New file from remote — if local file exists, it's a CONFLICT
310
- if (localHash) {
311
- plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
312
- continue;
313
- }
314
- // New file — still respect PRESERVE classification
315
- const tier = classify(canonical, null, null);
316
- if (tier === 'PRESERVE') {
317
- plan.skipped.push({ file, reason: 'PRESERVE — new file would overwrite user data' });
318
- } else {
319
- plan.created.push({ file, remoteHash });
320
- }
321
- continue;
322
- }
323
-
324
- const tier = classify(canonical, localHash, storedHash);
325
-
415
+
416
+ const plan = { updated: [], created: [], adopted: [], conflict: [], skipped: [] };
417
+
418
+ for (const file of [...allFiles].sort()) {
419
+ const canonical = canonicalPath(file);
420
+
421
+ // Reject paths that escape ROOT before any file access
422
+ const diskPath = safePath(file);
423
+ if (!diskPath) {
424
+ plan.skipped.push({ file, reason: 'path traversal rejected' });
425
+ continue;
426
+ }
427
+
428
+ const localHash = sha256File(diskPath);
429
+ const storedHash = localChecksums[file];
430
+ const remoteHash = remoteChecksums[file];
431
+
432
+ if (!remoteHash) {
433
+ if (storedHash && isInstalledOptionalFile(file, localVersion)) {
434
+ plan.skipped.push({ file, reason: 'installed optional workflow file' });
435
+ continue;
436
+ }
437
+ plan.skipped.push({ file, reason: 'not in remote' });
438
+ continue;
439
+ }
440
+
441
+ if (!storedHash) {
442
+ // New file from remote — if local file exists, it's a CONFLICT
443
+ if (localHash) {
444
+ if (localHash === remoteHash) {
445
+ plan.adopted.push({ file, localHash, remoteHash, reason: 'new remote file already matches local file' });
446
+ continue;
447
+ }
448
+ if (acceptedDecisionMatches(file, localHash, remoteHash)) {
449
+ plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
450
+ continue;
451
+ }
452
+ plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
453
+ continue;
454
+ }
455
+ // New file — still respect PRESERVE classification
456
+ const tier = classify(canonical, null, null);
457
+ if (tier === 'PRESERVE') {
458
+ plan.skipped.push({ file, reason: 'PRESERVE — new file would overwrite user data' });
459
+ } else {
460
+ plan.created.push({ file, remoteHash });
461
+ }
462
+ continue;
463
+ }
464
+
465
+ if (
466
+ storedHash
467
+ && hasInstalledOptions(localVersion)
468
+ && OPTIONAL_REGISTRATION_FILES.has(canonical)
469
+ && localHash === storedHash
470
+ && localHash !== remoteHash
471
+ ) {
472
+ if (acceptedDecisionMatches(file, localHash, remoteHash)) {
473
+ plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
474
+ continue;
475
+ }
476
+ plan.conflict.push({
477
+ file,
478
+ localHash,
479
+ storedHash,
480
+ remoteHash,
481
+ reason: 'installed optional registration needs merge',
482
+ });
483
+ continue;
484
+ }
485
+
486
+ const tier = classify(canonical, localHash, storedHash);
487
+
326
488
  if (tier === 'PRESERVE') {
327
489
  plan.skipped.push({ file, reason: 'PRESERVE — user data' });
328
490
  } else if (tier === 'SAFE') {
@@ -332,18 +494,22 @@ async function main() {
332
494
  plan.updated.push({ file, remoteHash });
333
495
  }
334
496
  } else if (tier === 'CONFLICT') {
335
- plan.conflict.push({
336
- file,
337
- localHash,
338
- storedHash,
339
- remoteHash,
340
- reason: MERGE_PATTERNS.some(p => p.test(canonical))
341
- ? 'user modified MERGE file'
342
- : 'user modified runtime file',
343
- });
344
- }
345
- }
346
-
497
+ if (acceptedDecisionMatches(file, localHash, remoteHash)) {
498
+ plan.skipped.push({ file, reason: `accepted conflict decision: ${localVersion.acceptedConflicts[file].decision}` });
499
+ continue;
500
+ }
501
+ plan.conflict.push({
502
+ file,
503
+ localHash,
504
+ storedHash,
505
+ remoteHash,
506
+ reason: MERGE_PATTERNS.some(p => p.test(canonical))
507
+ ? 'user modified MERGE file'
508
+ : 'user modified runtime file',
509
+ });
510
+ }
511
+ }
512
+
347
513
  // 3. Output
348
514
  if (jsonOut) {
349
515
  const jsonPlan = buildJsonPlan();
@@ -353,8 +519,10 @@ async function main() {
353
519
  to: remoteGen,
354
520
  sourceBase,
355
521
  partialUpdate: localVersion.partialUpdate || null,
522
+ acceptedConflicts: localVersion.acceptedConflicts || {},
356
523
  updated: plan.updated.length,
357
524
  created: plan.created.length,
525
+ adopted: plan.adopted.length,
358
526
  conflict: plan.conflict.length,
359
527
  skipped: plan.skipped.length,
360
528
  plan: jsonPlan,
@@ -362,16 +530,16 @@ async function main() {
362
530
  }, null, 2));
363
531
  return;
364
532
  }
365
-
366
- console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
367
- console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
368
-
369
- // Show conflicts (these need AI/user decision)
370
- if (plan.conflict.length > 0) {
371
- console.log('⚠ CONFLICTS (need your decision):');
372
- for (const c of plan.conflict) {
373
- console.log(` 📄 ${c.file} [${c.reason}]`);
374
- }
533
+
534
+ console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
535
+ console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
536
+
537
+ // Show conflicts (these need AI/user decision)
538
+ if (plan.conflict.length > 0) {
539
+ console.log('⚠ CONFLICTS (need your decision):');
540
+ for (const c of plan.conflict) {
541
+ console.log(` 📄 ${c.file} [${c.reason}]`);
542
+ }
375
543
  if (plan.updated.length + plan.created.length > 0) {
376
544
  console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
377
545
  }
@@ -379,19 +547,20 @@ async function main() {
379
547
  }
380
548
 
381
549
  // Show what will be auto-updated
382
- if (plan.updated.length + plan.created.length > 0) {
383
- console.log('✅ AUTO (safe to apply):');
384
- for (const u of plan.updated) console.log(` ↑ ${u.file}`);
385
- for (const c of plan.created) console.log(` + ${c.file}`);
386
- console.log('');
387
- }
388
-
389
- // 4. Apply if requested
550
+ if (plan.updated.length + plan.created.length > 0) {
551
+ console.log('✅ AUTO (safe to apply):');
552
+ for (const u of plan.updated) console.log(` ↑ ${u.file}`);
553
+ for (const c of plan.created) console.log(` + ${c.file}`);
554
+ console.log('');
555
+ }
556
+
557
+ // 4. Apply if requested
390
558
  if (apply || applySafe) {
391
- // Refuse to apply when conflicts exist — must resolve first
559
+ // Refuse to apply when conflicts exist — must resolve first
392
560
  if (apply && !applySafe && plan.conflict.length > 0) {
393
- console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
561
+ console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
394
562
  console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
563
+ process.exitCode = 1;
395
564
  return plan;
396
565
  }
397
566
  if (applySafe && plan.conflict.length > 0) {
@@ -399,65 +568,66 @@ async function main() {
399
568
  }
400
569
 
401
570
  const lexists = existsSync;
402
- let applied = 0;
403
- let failed = 0;
404
-
405
- for (const u of plan.updated) {
406
- try {
407
- const dest = safePath(u.file);
408
- if (!dest) { console.error(` ✗ Traversal rejected: ${u.file}`); failed++; continue; }
409
- // Symlink rejection — don't follow symlinks
410
- if (lexists(dest)) {
411
- try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
412
- }
571
+ let applied = 0;
572
+ let failed = 0;
573
+
574
+ for (const u of plan.updated) {
575
+ try {
576
+ const dest = safePath(u.file);
577
+ if (!dest) { console.error(` ✗ Traversal rejected: ${u.file}`); failed++; continue; }
578
+ // Symlink rejection — don't follow symlinks
579
+ if (lexists(dest)) {
580
+ try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
581
+ }
413
582
  const content = await fetchRemote(sourceBase + remotePath(u.file));
414
- const normalized = content.replace(/\r\n/g, '\n');
415
- const fetchedHash = sha256(normalized);
416
- if (fetchedHash !== u.remoteHash) {
417
- console.error(` ✗ Hash mismatch: ${u.file}`);
418
- failed++; continue;
419
- }
420
- mkdirSync(dirname(dest), { recursive: true });
421
- writeFileSync(dest, normalized, 'utf-8');
422
- applied++;
423
- } catch (e) {
424
- console.error(` ✗ Failed: ${u.file} — ${e.message}`);
425
- failed++;
426
- }
427
- }
428
-
429
- for (const c of plan.created) {
430
- try {
431
- const dest = safePath(c.file);
432
- if (!dest) { console.error(` ✗ Traversal rejected: ${c.file}`); failed++; continue; }
433
- // TOCTOU: recheck file didn't appear since planning
434
- if (lexists(dest)) {
435
- try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
436
- console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
437
- failed++; continue;
438
- }
583
+ const normalized = content.replace(/\r\n/g, '\n');
584
+ const fetchedHash = sha256(normalized);
585
+ if (fetchedHash !== u.remoteHash) {
586
+ console.error(` ✗ Hash mismatch: ${u.file}`);
587
+ failed++; continue;
588
+ }
589
+ mkdirSync(dirname(dest), { recursive: true });
590
+ writeFileSync(dest, normalized, 'utf-8');
591
+ applied++;
592
+ } catch (e) {
593
+ console.error(` ✗ Failed: ${u.file} — ${e.message}`);
594
+ failed++;
595
+ }
596
+ }
597
+
598
+ for (const c of plan.created) {
599
+ try {
600
+ const dest = safePath(c.file);
601
+ if (!dest) { console.error(` ✗ Traversal rejected: ${c.file}`); failed++; continue; }
602
+ // TOCTOU: recheck file didn't appear since planning
603
+ if (lexists(dest)) {
604
+ try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${c.file}`); failed++; continue; } } catch (_) {}
605
+ console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
606
+ failed++; continue;
607
+ }
439
608
  const content = await fetchRemote(sourceBase + remotePath(c.file));
440
- const normalized = content.replace(/\r\n/g, '\n');
441
- const fetchedHash = sha256(normalized);
442
- if (fetchedHash !== c.remoteHash) {
443
- console.error(` ✗ Hash mismatch: ${c.file}`);
444
- failed++; continue;
445
- }
446
- mkdirSync(dirname(dest), { recursive: true });
447
- writeFileSync(dest, normalized, 'utf-8');
448
- applied++;
449
- } catch (e) {
450
- console.error(` ✗ Failed: ${c.file} — ${e.message}`);
451
- failed++;
452
- }
453
- }
454
-
609
+ const normalized = content.replace(/\r\n/g, '\n');
610
+ const fetchedHash = sha256(normalized);
611
+ if (fetchedHash !== c.remoteHash) {
612
+ console.error(` ✗ Hash mismatch: ${c.file}`);
613
+ failed++; continue;
614
+ }
615
+ mkdirSync(dirname(dest), { recursive: true });
616
+ writeFileSync(dest, normalized, 'utf-8');
617
+ applied++;
618
+ } catch (e) {
619
+ console.error(` ✗ Failed: ${c.file} — ${e.message}`);
620
+ failed++;
621
+ }
622
+ }
623
+
455
624
  // Only version-track applied files after complete script success.
456
625
  if (failed === 0) {
457
626
  const appliedAt = new Date().toISOString();
458
627
  localVersion.checksums = localVersion.checksums || {};
459
628
  for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
460
629
  for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
630
+ for (const a of plan.adopted) localVersion.checksums[a.file] = a.remoteHash;
461
631
  if (plan.conflict.length === 0) {
462
632
  localVersion.generator = remoteGen;
463
633
  localVersion.generated = appliedAt;
@@ -467,6 +637,7 @@ async function main() {
467
637
  targetGenerator: remoteGen,
468
638
  updatedAt: appliedAt,
469
639
  appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
640
+ adoptedFiles: plan.adopted.map(x => x.file),
470
641
  conflicts: plan.conflict.map(x => x.file),
471
642
  };
472
643
  }
@@ -477,11 +648,40 @@ async function main() {
477
648
  console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
478
649
  }
479
650
  } else {
480
- console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
481
- }
482
- }
483
-
484
- return plan;
485
- }
486
-
487
- main().catch(e => { console.error(e); process.exit(1); });
651
+ console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
652
+ process.exitCode = 1;
653
+ }
654
+ }
655
+
656
+ if (finalize) {
657
+ if (plan.updated.length > 0 || plan.created.length > 0 || plan.adopted.length > 0) {
658
+ 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.`);
659
+ if (decisionMetadataDirty) {
660
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
661
+ }
662
+ process.exitCode = 1;
663
+ return plan;
664
+ }
665
+ if (plan.conflict.length > 0) {
666
+ console.log(`Cannot finalize: ${plan.conflict.length} conflicts still need a script-recorded decision.`);
667
+ if (decisionMetadataDirty) {
668
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
669
+ }
670
+ process.exitCode = 1;
671
+ return plan;
672
+ }
673
+ const finalizedAt = new Date().toISOString();
674
+ localVersion.generator = remoteGen;
675
+ localVersion.generated = finalizedAt;
676
+ delete localVersion.partialUpdate;
677
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
678
+ console.log(`Finalized Harness update to ${remoteGen}.`);
679
+ } else if (decisionMetadataDirty) {
680
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
681
+ console.log('Recorded conflict decision metadata.');
682
+ }
683
+
684
+ return plan;
685
+ }
686
+
687
+ main().catch(e => { console.error(e); process.exit(1); });