create-harness-vibe-coding 0.8.7 → 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 (77) 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 +5 -5
  8. package/src/index.js +14 -9
  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/researcher.md +41 -41
  19. package/templates/common/.claude/agents/review-manager.md +56 -56
  20. package/templates/common/.claude/agents/verifier.md +29 -29
  21. package/templates/common/.claude/commands/wf-help.md +1 -1
  22. package/templates/common/.claude/rules/ecc/common.md +44 -44
  23. package/templates/common/.claude/skills/wf-auto/SKILL.md +8 -6
  24. package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
  25. package/templates/common/.claude/skills/wf-remove/SKILL.md +7 -7
  26. package/templates/common/.harness-version +54 -14
  27. package/templates/common/.opencode/agents/architect-manager.md +52 -0
  28. package/templates/common/.opencode/agents/architect.md +35 -0
  29. package/templates/common/.opencode/agents/context-master.md +81 -0
  30. package/templates/common/.opencode/agents/debugger.md +43 -0
  31. package/templates/common/.opencode/agents/docs-researcher.md +42 -0
  32. package/templates/common/.opencode/agents/explore-manager.md +49 -0
  33. package/templates/common/.opencode/agents/implement-manager.md +56 -0
  34. package/templates/common/.opencode/agents/implementer.md +42 -0
  35. package/templates/common/.opencode/agents/memory-master.md +70 -0
  36. package/templates/common/.opencode/agents/planner.md +38 -0
  37. package/templates/common/.opencode/agents/reflector.md +39 -0
  38. package/templates/common/.opencode/agents/researcher.md +42 -0
  39. package/templates/common/.opencode/agents/review-manager.md +63 -0
  40. package/templates/common/.opencode/agents/reviewer.md +37 -0
  41. package/templates/common/.opencode/agents/tdd-guide.md +83 -0
  42. package/templates/common/.opencode/agents/test-writer.md +54 -0
  43. package/templates/common/.opencode/agents/verifier.md +37 -0
  44. package/templates/common/.opencode/commands/wf-help.md +23 -0
  45. package/templates/common/CLAUDE.md +85 -88
  46. package/templates/common/Harness/PROGRESS.md +17 -17
  47. package/templates/common/Harness/README.md +16 -5
  48. package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
  49. package/templates/common/Harness/WF-AUTO-SPARK.md +5 -5
  50. package/templates/common/Harness/WF-AUTO.md +85 -96
  51. package/templates/common/Harness/architecture.md +124 -124
  52. package/templates/common/Harness/context-loading.md +111 -111
  53. package/templates/common/Harness/extension.md +66 -66
  54. package/templates/common/Harness/lifecycle.md +20 -20
  55. package/templates/common/Harness/research/PRD.md +56 -56
  56. package/templates/common/Harness/research/README.md +169 -169
  57. package/templates/common/Harness/research/research-results.md +66 -66
  58. package/templates/common/Harness/subagents.md +208 -208
  59. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +2 -2
  60. package/templates/common/Harness/tasks/_template/NOTES.md +2 -2
  61. package/templates/common/MEMORY.md +1 -1
  62. package/templates/common/README.md +36 -36
  63. package/templates/common/memory/agent-lessons-patterns.md +21 -21
  64. package/templates/common/memory/tool-usage-reflections.md +21 -21
  65. package/templates/common/memory/user-corrections-preferences.md +21 -21
  66. package/templates/common/opencode.json +19 -0
  67. package/templates/common/scripts/scan-clean.mjs +415 -415
  68. package/templates/common/scripts/validate-harness.mjs +274 -245
  69. package/templates/common/scripts/wf-remove.mjs +56 -39
  70. package/templates/common/scripts/wf-update-check.mjs +599 -599
  71. package/templates/optional/catalog.json +41 -33
  72. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +193 -193
  73. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +48 -48
  74. package/templates/optional/skills/github-pr-review/Harness/workflows/github-pr-review.md +28 -28
  75. package/templates/optional/skills/python-backend/Harness/workflows/python-backend.md +34 -34
  76. package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +34 -34
  77. package/templates/optional/skills/ui-ux-review/Harness/workflows/ui-ux-review.md +26 -26
@@ -1,43 +1,43 @@
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
- * Usage:
8
- * node Harness/scripts/wf-update-check.mjs # full dry-run plan
9
- * node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
10
- * node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
11
- * node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
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
-
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
- const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
21
- const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
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. */
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
+ * Usage:
8
+ * node Harness/scripts/wf-update-check.mjs # full dry-run plan
9
+ * node Harness/scripts/wf-update-check.mjs --json # JSON output for AI consumption
10
+ * node Harness/scripts/wf-update-check.mjs --apply-safe # apply SAFE+NEW, leave CONFLICT for AI
11
+ * node Harness/scripts/wf-update-check.mjs --apply # apply only when no CONFLICT exists
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
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
21
+ const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
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
41
  const MERGE_PATTERNS = [
42
42
  /^CLAUDE\.md$/,
43
43
  /^AGENTS\.md$/,
@@ -51,48 +51,48 @@ const OPTIONAL_REGISTRATION_FILES = new Set([
51
51
  'Harness/MEMORY.md',
52
52
  'Harness/README.md',
53
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. */
70
- function canonicalPath(file) {
71
- return file.replace(/\\/g, '/').replace(/\/+/g, '/');
72
- }
73
-
74
- function readFlagValue(args, flagName) {
75
- const idx = args.indexOf(flagName);
76
- if (idx === -1) return null;
77
- const value = args[idx + 1];
78
- if (!value || value.startsWith('--')) return null;
79
- return value;
80
- }
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
-
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. */
70
+ function canonicalPath(file) {
71
+ return file.replace(/\\/g, '/').replace(/\/+/g, '/');
72
+ }
73
+
74
+ function readFlagValue(args, flagName) {
75
+ const idx = args.indexOf(flagName);
76
+ if (idx === -1) return null;
77
+ const value = args[idx + 1];
78
+ if (!value || value.startsWith('--')) return null;
79
+ return value;
80
+ }
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
+
96
96
  function normalizeSourceBase(sourceBase) {
97
97
  return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
98
98
  }
@@ -119,312 +119,312 @@ function isInstalledOptionalFile(file, localVersion) {
119
119
  function hasInstalledOptions(localVersion) {
120
120
  return selectedOptionIds(localVersion).size > 0;
121
121
  }
122
-
123
- /** Detect template placeholders in remote content. */
124
- function isTemplate(raw) {
125
- return /\{\{[a-zA-Z]+\}\}/.test(raw);
126
- }
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
-
157
- async function fetchRemote(url, timeoutMs = 30000) {
158
- if (url.startsWith('file://')) {
159
- return readFileSync(fileURLToPath(url), 'utf-8');
160
- }
161
-
162
- const controller = new AbortController();
163
- const timer = setTimeout(() => controller.abort(), timeoutMs);
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
-
175
- async function main() {
176
- const args = process.argv.slice(2);
177
- const apply = args.includes('--apply');
178
- const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
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');
184
- const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
185
- const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
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
- }
122
+
123
+ /** Detect template placeholders in remote content. */
124
+ function isTemplate(raw) {
125
+ return /\{\{[a-zA-Z]+\}\}/.test(raw);
126
+ }
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
+
157
+ async function fetchRemote(url, timeoutMs = 30000) {
158
+ if (url.startsWith('file://')) {
159
+ return readFileSync(fileURLToPath(url), 'utf-8');
160
+ }
161
+
162
+ const controller = new AbortController();
163
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
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
+
175
+ async function main() {
176
+ const args = process.argv.slice(2);
177
+ const apply = args.includes('--apply');
178
+ const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
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');
184
+ const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
185
+ const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
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
194
  process.exitCode = 1;
195
195
  return;
196
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
- }
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
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 {
227
- const raw = await fetchRemote(sourceBase + '.harness-version');
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
-
262
- const localGen = localVersion.generator || '0.0.0';
263
- const remoteGen = remoteVersion.generator || '0.0.0';
264
- const versionCmp = cmpSemver(remoteGen, localGen);
265
-
266
- if (!ignoreVersion && versionCmp <= 0) {
267
- if (jsonOut) {
268
- console.log(JSON.stringify({
269
- status: versionCmp < 0 ? 'downgrade-refused' : 'up-to-date',
270
- version: localGen,
271
- remote: remoteGen,
272
- sourceBase,
273
- }));
274
- } else if (versionCmp < 0) {
275
- console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
276
- } else {
277
- console.log(`✅ Already up to date (v${localGen})`);
278
- }
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 {
227
+ const raw = await fetchRemote(sourceBase + '.harness-version');
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
+
262
+ const localGen = localVersion.generator || '0.0.0';
263
+ const remoteGen = remoteVersion.generator || '0.0.0';
264
+ const versionCmp = cmpSemver(remoteGen, localGen);
265
+
266
+ if (!ignoreVersion && versionCmp <= 0) {
267
+ if (jsonOut) {
268
+ console.log(JSON.stringify({
269
+ status: versionCmp < 0 ? 'downgrade-refused' : 'up-to-date',
270
+ version: localGen,
271
+ remote: remoteGen,
272
+ sourceBase,
273
+ }));
274
+ } else if (versionCmp < 0) {
275
+ console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
276
+ } else {
277
+ console.log(`✅ Already up to date (v${localGen})`);
278
+ }
279
279
  if (versionCmp < 0) process.exitCode = 1;
280
280
  return;
281
281
  }
282
282
 
283
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. */
292
- function remotePath(file) {
293
- return remoteSources[file] || file;
294
- }
295
-
296
- function withRemoteMeta(entry) {
297
- if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
298
- const templateHint = remotePath(entry.file);
299
- return {
300
- ...entry,
301
- templateHint,
302
- remoteUrl: sourceBase + templateHint,
303
- };
304
- }
305
-
306
- function withConflictActions(entry) {
307
- return {
308
- ...withRemoteMeta(entry),
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
- },
315
- };
316
- }
317
-
318
- function shellArg(value) {
319
- return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
320
- }
321
-
322
- function buildJsonPlan() {
323
- return {
324
- updated: plan.updated.map(withRemoteMeta),
325
- created: plan.created.map(withRemoteMeta),
326
- adopted: plan.adopted.map(withRemoteMeta),
327
- conflict: plan.conflict.map(withConflictActions),
328
- skipped: plan.skipped.map(withRemoteMeta),
329
- };
330
- }
331
-
332
- function buildAgentHints(jsonPlan) {
333
- return {
334
- mode: 'script-first-ai-conflicts',
335
- dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
336
- safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
337
- strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
338
- finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
339
- partialUpdate: localVersion.partialUpdate || null,
340
- acceptedConflicts: localVersion.acceptedConflicts || {},
341
- aiMergeRequired: jsonPlan.conflict,
342
- aiMergeRequiredCount: jsonPlan.conflict.length,
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.',
344
- postUpdateCommands: [
345
- 'node Harness/scripts/validate-harness.mjs',
346
- 'node Harness/scripts/scan-clean.mjs --json',
347
- ],
348
- bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
349
- };
350
- }
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
-
414
- const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
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
-
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. */
292
+ function remotePath(file) {
293
+ return remoteSources[file] || file;
294
+ }
295
+
296
+ function withRemoteMeta(entry) {
297
+ if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
298
+ const templateHint = remotePath(entry.file);
299
+ return {
300
+ ...entry,
301
+ templateHint,
302
+ remoteUrl: sourceBase + templateHint,
303
+ };
304
+ }
305
+
306
+ function withConflictActions(entry) {
307
+ return {
308
+ ...withRemoteMeta(entry),
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
+ },
315
+ };
316
+ }
317
+
318
+ function shellArg(value) {
319
+ return /^[A-Za-z0-9@._/\\:-]+$/.test(value) ? value : JSON.stringify(value);
320
+ }
321
+
322
+ function buildJsonPlan() {
323
+ return {
324
+ updated: plan.updated.map(withRemoteMeta),
325
+ created: plan.created.map(withRemoteMeta),
326
+ adopted: plan.adopted.map(withRemoteMeta),
327
+ conflict: plan.conflict.map(withConflictActions),
328
+ skipped: plan.skipped.map(withRemoteMeta),
329
+ };
330
+ }
331
+
332
+ function buildAgentHints(jsonPlan) {
333
+ return {
334
+ mode: 'script-first-ai-conflicts',
335
+ dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
336
+ safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
337
+ strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
338
+ finalizeCommand: 'node Harness/scripts/wf-update-check.mjs --finalize',
339
+ partialUpdate: localVersion.partialUpdate || null,
340
+ acceptedConflicts: localVersion.acceptedConflicts || {},
341
+ aiMergeRequired: jsonPlan.conflict,
342
+ aiMergeRequiredCount: jsonPlan.conflict.length,
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.',
344
+ postUpdateCommands: [
345
+ 'node Harness/scripts/validate-harness.mjs',
346
+ 'node Harness/scripts/scan-clean.mjs --json',
347
+ ],
348
+ bootstrapDebtCommand: 'node Harness/scripts/validate-harness.mjs --strict',
349
+ };
350
+ }
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
+
414
+ const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
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
428
  const localHash = sha256File(diskPath);
429
429
  const storedHash = localChecksums[file];
430
430
  const remoteHash = remoteChecksums[file];
@@ -437,31 +437,31 @@ async function main() {
437
437
  plan.skipped.push({ file, reason: 'not in remote' });
438
438
  continue;
439
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
-
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
465
  if (
466
466
  storedHash
467
467
  && hasInstalledOptions(localVersion)
@@ -484,204 +484,204 @@ async function main() {
484
484
  }
485
485
 
486
486
  const tier = classify(canonical, localHash, storedHash);
487
-
488
- if (tier === 'PRESERVE') {
489
- plan.skipped.push({ file, reason: 'PRESERVE — user data' });
490
- } else if (tier === 'SAFE') {
491
- if (localHash === remoteHash) {
492
- plan.skipped.push({ file, reason: 'already current' });
493
- } else {
494
- plan.updated.push({ file, remoteHash });
495
- }
496
- } else if (tier === 'CONFLICT') {
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
-
513
- // 3. Output
514
- if (jsonOut) {
515
- const jsonPlan = buildJsonPlan();
516
- console.log(JSON.stringify({
517
- status: localVersion.partialUpdate ? 'partial-update' : 'update-available',
518
- from: localGen,
519
- to: remoteGen,
520
- sourceBase,
521
- partialUpdate: localVersion.partialUpdate || null,
522
- acceptedConflicts: localVersion.acceptedConflicts || {},
523
- updated: plan.updated.length,
524
- created: plan.created.length,
525
- adopted: plan.adopted.length,
526
- conflict: plan.conflict.length,
527
- skipped: plan.skipped.length,
528
- plan: jsonPlan,
529
- agent: buildAgentHints(jsonPlan),
530
- }, null, 2));
531
- return;
532
- }
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
- }
543
- if (plan.updated.length + plan.created.length > 0) {
544
- console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
545
- }
546
- console.log('');
547
- }
548
-
549
- // Show what will be auto-updated
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
558
- if (apply || applySafe) {
559
- // Refuse to apply when conflicts exist — must resolve first
560
- if (apply && !applySafe && plan.conflict.length > 0) {
561
- console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
562
- console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
563
- process.exitCode = 1;
564
- return plan;
565
- }
566
- if (applySafe && plan.conflict.length > 0) {
567
- console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
568
- }
569
-
570
- const lexists = existsSync;
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
- }
582
- const content = await fetchRemote(sourceBase + remotePath(u.file));
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
- }
608
- const content = await fetchRemote(sourceBase + remotePath(c.file));
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
-
624
- // Only version-track applied files after complete script success.
625
- if (failed === 0) {
626
- const appliedAt = new Date().toISOString();
627
- localVersion.checksums = localVersion.checksums || {};
628
- for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
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;
631
- if (plan.conflict.length === 0) {
632
- localVersion.generator = remoteGen;
633
- localVersion.generated = appliedAt;
634
- delete localVersion.partialUpdate;
635
- } else {
636
- localVersion.partialUpdate = {
637
- targetGenerator: remoteGen,
638
- updatedAt: appliedAt,
639
- appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
640
- adoptedFiles: plan.adopted.map(x => x.file),
641
- conflicts: plan.conflict.map(x => x.file),
642
- };
643
- }
644
- writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
645
- if (plan.conflict.length === 0) {
646
- console.log(`✅ Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
647
- } else {
648
- console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
649
- }
650
- } else {
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.`);
487
+
488
+ if (tier === 'PRESERVE') {
489
+ plan.skipped.push({ file, reason: 'PRESERVE — user data' });
490
+ } else if (tier === 'SAFE') {
491
+ if (localHash === remoteHash) {
492
+ plan.skipped.push({ file, reason: 'already current' });
493
+ } else {
494
+ plan.updated.push({ file, remoteHash });
495
+ }
496
+ } else if (tier === 'CONFLICT') {
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
+
513
+ // 3. Output
514
+ if (jsonOut) {
515
+ const jsonPlan = buildJsonPlan();
516
+ console.log(JSON.stringify({
517
+ status: localVersion.partialUpdate ? 'partial-update' : 'update-available',
518
+ from: localGen,
519
+ to: remoteGen,
520
+ sourceBase,
521
+ partialUpdate: localVersion.partialUpdate || null,
522
+ acceptedConflicts: localVersion.acceptedConflicts || {},
523
+ updated: plan.updated.length,
524
+ created: plan.created.length,
525
+ adopted: plan.adopted.length,
526
+ conflict: plan.conflict.length,
527
+ skipped: plan.skipped.length,
528
+ plan: jsonPlan,
529
+ agent: buildAgentHints(jsonPlan),
530
+ }, null, 2));
531
+ return;
532
+ }
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
+ }
543
+ if (plan.updated.length + plan.created.length > 0) {
544
+ console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
545
+ }
546
+ console.log('');
547
+ }
548
+
549
+ // Show what will be auto-updated
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
558
+ if (apply || applySafe) {
559
+ // Refuse to apply when conflicts exist — must resolve first
560
+ if (apply && !applySafe && plan.conflict.length > 0) {
561
+ console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
562
+ console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
563
+ process.exitCode = 1;
564
+ return plan;
565
+ }
566
+ if (applySafe && plan.conflict.length > 0) {
567
+ console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
568
+ }
569
+
570
+ const lexists = existsSync;
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
+ }
582
+ const content = await fetchRemote(sourceBase + remotePath(u.file));
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
+ }
608
+ const content = await fetchRemote(sourceBase + remotePath(c.file));
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
+
624
+ // Only version-track applied files after complete script success.
625
+ if (failed === 0) {
626
+ const appliedAt = new Date().toISOString();
627
+ localVersion.checksums = localVersion.checksums || {};
628
+ for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
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;
631
+ if (plan.conflict.length === 0) {
632
+ localVersion.generator = remoteGen;
633
+ localVersion.generated = appliedAt;
634
+ delete localVersion.partialUpdate;
635
+ } else {
636
+ localVersion.partialUpdate = {
637
+ targetGenerator: remoteGen,
638
+ updatedAt: appliedAt,
639
+ appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
640
+ adoptedFiles: plan.adopted.map(x => x.file),
641
+ conflicts: plan.conflict.map(x => x.file),
642
+ };
643
+ }
644
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
645
+ if (plan.conflict.length === 0) {
646
+ console.log(`✅ Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
647
+ } else {
648
+ console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
649
+ }
650
+ } else {
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.`);
667
659
  if (decisionMetadataDirty) {
668
660
  writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
669
661
  }
670
662
  process.exitCode = 1;
671
663
  return plan;
672
664
  }
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); });
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); });