create-harness-vibe-coding 0.8.5 → 0.8.7

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 (39) hide show
  1. package/README.md +20 -4
  2. package/package.json +1 -1
  3. package/src/generator.js +30 -11
  4. package/src/index.js +129 -5
  5. package/templates/common/.claude/agents/reflector.md +35 -0
  6. package/templates/common/.claude/agents/verifier.md +5 -3
  7. package/templates/common/.claude/commands/wf-help.md +1 -2
  8. package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +10 -4
  9. package/templates/common/.claude/skills/wf/SKILL.md +7 -3
  10. package/templates/common/.claude/skills/wf-auto/SKILL.md +59 -107
  11. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +19 -17
  12. package/templates/common/.claude/skills/wf-max/SKILL.md +40 -21
  13. package/templates/common/.claude/skills/wf-update/SKILL.md +9 -4
  14. package/templates/common/.codex/config.toml +5 -0
  15. package/templates/common/.harness-version +36 -34
  16. package/templates/common/AGENTS.md +26 -25
  17. package/templates/common/CLAUDE.md +10 -9
  18. package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +12 -4
  19. package/templates/common/Harness/README.md +12 -13
  20. package/templates/common/Harness/WF-AUTO-SPARK.md +18 -1
  21. package/templates/common/Harness/WF-AUTO.md +518 -492
  22. package/templates/common/Harness/WF-MAX.md +284 -232
  23. package/templates/common/Harness/WF.md +47 -29
  24. package/templates/common/Harness/agent-workflow.md +108 -76
  25. package/templates/common/Harness/dispatch.md +96 -95
  26. package/templates/common/Harness/extension.md +1 -1
  27. package/templates/common/Harness/subagents.md +78 -56
  28. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +1 -1
  29. package/templates/common/Harness/tasks/_template/NOTES.md +1 -1
  30. package/templates/common/Harness/tasks/_template/PLAN.md +53 -60
  31. package/templates/common/Harness/tasks/_template/PROGRESS.md +26 -29
  32. package/templates/common/MEMORY.md +26 -29
  33. package/templates/common/SETUP.md +29 -22
  34. package/templates/common/scripts/scan-clean.mjs +80 -41
  35. package/templates/common/scripts/validate-harness.mjs +101 -31
  36. package/templates/common/scripts/wf-remove.mjs +279 -278
  37. package/templates/common/scripts/wf-update-check.mjs +395 -195
  38. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +1 -1
  39. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +57 -21
@@ -4,22 +4,22 @@
4
4
  * Fetches remote checksums, compares locally, classifies all files instantly.
5
5
  * Only CONFLICT files need AI/user decision.
6
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
- */
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
13
 
14
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync, lstatSync } from 'fs';
15
15
  import { createHash } from 'crypto';
16
16
  import { resolve, dirname, sep } from 'path';
17
17
  import { fileURLToPath } from 'url';
18
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/';
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
23
 
24
24
  // ── Tier classification ──────────────────────────────────────────
25
25
 
@@ -38,13 +38,19 @@ const PRESERVE_PATTERNS = [
38
38
  ];
39
39
 
40
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
- ];
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
+ ]);
48
54
 
49
55
  // ── Helpers ────────────────────────────────────────────────────────
50
56
 
@@ -61,27 +67,64 @@ function safePath(file) {
61
67
  }
62
68
 
63
69
  /** Canonical normalization for classification matching. */
64
- function canonicalPath(file) {
65
- return file.replace(/\\/g, '/').replace(/\/+/g, '/');
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
+ function normalizeSourceBase(sourceBase) {
97
+ return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
66
98
  }
67
99
 
68
- function readFlagValue(args, flagName) {
69
- const idx = args.indexOf(flagName);
70
- if (idx === -1) return null;
71
- const value = args[idx + 1];
72
- if (!value || value.startsWith('--')) return null;
73
- return value;
100
+ function selectedOptionIds(localVersion) {
101
+ return new Set(
102
+ Array.isArray(localVersion?.options)
103
+ ? localVersion.options.map(String).filter(Boolean)
104
+ : [],
105
+ );
74
106
  }
75
107
 
76
- function normalizeSourceBase(sourceBase) {
77
- return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
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));
78
117
  }
79
118
 
80
- /** Detect template placeholders in remote content. */
81
- function isTemplate(raw) {
82
- return /\{\{[a-zA-Z]+\}\}/.test(raw);
119
+ function hasInstalledOptions(localVersion) {
120
+ return selectedOptionIds(localVersion).size > 0;
83
121
  }
84
122
 
123
+ /** Detect template placeholders in remote content. */
124
+ function isTemplate(raw) {
125
+ return /\{\{[a-zA-Z]+\}\}/.test(raw);
126
+ }
127
+
85
128
  function sha256(content) {
86
129
  return 'sha256-' + createHash('sha256').update(content).digest('hex');
87
130
  }
@@ -111,13 +154,13 @@ function classify(file, localHash, storedHash) {
111
154
  return 'CONFLICT'; // modified runtime file — unexpected
112
155
  }
113
156
 
114
- async function fetchRemote(url, timeoutMs = 30000) {
115
- if (url.startsWith('file://')) {
116
- return readFileSync(fileURLToPath(url), 'utf-8');
117
- }
118
-
119
- const controller = new AbortController();
120
- const timer = setTimeout(() => controller.abort(), timeoutMs);
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);
121
164
  try {
122
165
  const res = await fetch(url, { signal: controller.signal });
123
166
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
@@ -129,13 +172,17 @@ async function fetchRemote(url, timeoutMs = 30000) {
129
172
 
130
173
  // ── Main ───────────────────────────────────────────────────────────
131
174
 
132
- async function main() {
133
- const args = process.argv.slice(2);
134
- const apply = args.includes('--apply');
135
- const applySafe = args.includes('--apply-safe') || args.includes('--safe-only');
136
- const jsonOut = args.includes('--json');
137
- const ignoreVersion = args.includes('--ignore-version') || args.includes('--force-check');
138
- const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
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);
139
186
 
140
187
  // 1. Read local state
141
188
  if (!existsSync(VERSION_FILE)) {
@@ -144,8 +191,9 @@ async function main() {
144
191
  } else {
145
192
  console.error('ERROR: Harness/.harness-version not found. Is Harness installed?');
146
193
  }
147
- return;
148
- }
194
+ process.exitCode = 1;
195
+ return;
196
+ }
149
197
 
150
198
  let localVersion;
151
199
  try {
@@ -157,6 +205,7 @@ async function main() {
157
205
  console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
158
206
  console.error(' The file may be corrupted. If this is an old project, try reinstalling the harness.');
159
207
  }
208
+ process.exitCode = 1;
160
209
  return;
161
210
  }
162
211
 
@@ -166,6 +215,7 @@ async function main() {
166
215
  } else {
167
216
  console.error('ERROR: Harness/.harness-version has unexpected structure.');
168
217
  }
218
+ process.exitCode = 1;
169
219
  return;
170
220
  }
171
221
 
@@ -174,7 +224,7 @@ async function main() {
174
224
  // 2. Fetch remote version file
175
225
  let remoteVersion;
176
226
  try {
177
- const raw = await fetchRemote(sourceBase + '.harness-version');
227
+ const raw = await fetchRemote(sourceBase + '.harness-version');
178
228
  if (isTemplate(raw)) {
179
229
  if (jsonOut) {
180
230
  console.log(JSON.stringify({ status: 'template-remote', message: 'Remote .harness-version has not been generated yet.' }));
@@ -183,6 +233,7 @@ async function main() {
183
233
  console.log(' The generate step has not been run on the remote repo. No update possible.');
184
234
  console.log(' This is expected during development — the update mechanism works once the remote is live.');
185
235
  }
236
+ process.exitCode = 1;
186
237
  return;
187
238
  }
188
239
  remoteVersion = JSON.parse(raw);
@@ -193,6 +244,7 @@ async function main() {
193
244
  console.error('ERROR: Cannot reach GitHub or invalid JSON. Offline?');
194
245
  console.error(e.message);
195
246
  }
247
+ process.exitCode = 1;
196
248
  return;
197
249
  }
198
250
 
@@ -207,84 +259,161 @@ async function main() {
207
259
  return 0;
208
260
  }
209
261
 
210
- const localGen = localVersion.generator || '0.0.0';
211
- const remoteGen = remoteVersion.generator || '0.0.0';
212
- const versionCmp = cmpSemver(remoteGen, localGen);
213
-
214
- if (!ignoreVersion && versionCmp <= 0) {
215
- if (jsonOut) {
216
- console.log(JSON.stringify({
217
- status: versionCmp < 0 ? 'downgrade-refused' : 'up-to-date',
218
- version: localGen,
219
- remote: remoteGen,
220
- sourceBase,
221
- }));
222
- } else if (versionCmp < 0) {
223
- console.log(`⚠ Remote (v${remoteGen}) is OLDER than local (v${localGen}). Downgrade refused.`);
224
- } else {
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 {
225
277
  console.log(`✅ Already up to date (v${localGen})`);
226
278
  }
227
- return;
228
- }
229
-
230
- if (ignoreVersion) {
279
+ if (versionCmp < 0) process.exitCode = 1;
280
+ return;
281
+ }
282
+
283
+ if (ignoreVersion) {
231
284
  if (!jsonOut) console.log('🔧 Version check bypassed (--ignore-version). Comparing files anyway.');
232
285
  }
233
286
 
234
287
  const remoteChecksums = remoteVersion.checksums || {};
235
288
  const remoteSources = remoteVersion.sources || {};
289
+ localVersion.acceptedConflicts = localVersion.acceptedConflicts || {};
236
290
 
237
291
  /** Resolve the remote template-relative path for a dest-keyed file. Falls back to the key itself for back-compat. */
238
- function remotePath(file) {
239
- return remoteSources[file] || file;
240
- }
241
-
242
- function withRemoteMeta(entry) {
243
- if (!entry || !entry.file || !remoteChecksums[entry.file]) return entry;
244
- const templateHint = remotePath(entry.file);
245
- return {
246
- ...entry,
247
- templateHint,
248
- remoteUrl: sourceBase + templateHint,
249
- };
250
- }
251
-
252
- function withConflictActions(entry) {
253
- return {
254
- ...withRemoteMeta(entry),
255
- choices: ['merge', 'keep-local', 'overwrite-from-template'],
256
- };
257
- }
258
-
259
- function buildJsonPlan() {
260
- return {
261
- updated: plan.updated.map(withRemoteMeta),
262
- created: plan.created.map(withRemoteMeta),
263
- conflict: plan.conflict.map(withConflictActions),
264
- skipped: plan.skipped.map(withRemoteMeta),
265
- };
266
- }
267
-
268
- function buildAgentHints(jsonPlan) {
269
- return {
270
- mode: 'script-first-ai-conflicts',
271
- dryRunJsonCommand: 'node Harness/scripts/wf-update-check.mjs --json',
272
- safeApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply-safe',
273
- strictApplyCommand: 'node Harness/scripts/wf-update-check.mjs --apply',
274
- partialUpdate: localVersion.partialUpdate || null,
275
- aiMergeRequired: jsonPlan.conflict,
276
- 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.',
278
- postUpdateCommands: [
279
- 'node Harness/scripts/validate-harness.mjs --strict',
280
- 'node Harness/scripts/scan-clean.mjs',
281
- ],
282
- };
283
- }
284
-
285
- const allFiles = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
292
+ function remotePath(file) {
293
+ return remoteSources[file] || file;
294
+ }
286
295
 
287
- const plan = { updated: [], created: [], conflict: [], skipped: [] };
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: [] };
288
417
 
289
418
  for (const file of [...allFiles].sort()) {
290
419
  const canonical = canonicalPath(file);
@@ -296,18 +425,30 @@ async function main() {
296
425
  continue;
297
426
  }
298
427
 
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
- }
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
+ }
307
440
 
308
441
  if (!storedHash) {
309
442
  // New file from remote — if local file exists, it's a CONFLICT
310
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
+ }
311
452
  plan.conflict.push({ file, localHash, storedHash: 'none', remoteHash, reason: 'new remote file conflicts with existing local file' });
312
453
  continue;
313
454
  }
@@ -321,17 +462,42 @@ async function main() {
321
462
  continue;
322
463
  }
323
464
 
324
- const tier = classify(canonical, localHash, storedHash);
325
-
326
- if (tier === 'PRESERVE') {
327
- plan.skipped.push({ file, reason: 'PRESERVE — user data' });
328
- } else if (tier === 'SAFE') {
329
- if (localHash === remoteHash) {
330
- plan.skipped.push({ file, reason: 'already current' });
331
- } else {
332
- plan.updated.push({ file, remoteHash });
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;
333
475
  }
334
- } else if (tier === 'CONFLICT') {
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
+
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
+ }
335
501
  plan.conflict.push({
336
502
  file,
337
503
  localHash,
@@ -344,24 +510,26 @@ async function main() {
344
510
  }
345
511
  }
346
512
 
347
- // 3. Output
348
- if (jsonOut) {
349
- const jsonPlan = buildJsonPlan();
350
- console.log(JSON.stringify({
351
- status: localVersion.partialUpdate ? 'partial-update' : 'update-available',
352
- from: localGen,
353
- to: remoteGen,
354
- sourceBase,
355
- partialUpdate: localVersion.partialUpdate || null,
356
- updated: plan.updated.length,
357
- created: plan.created.length,
358
- conflict: plan.conflict.length,
359
- skipped: plan.skipped.length,
360
- plan: jsonPlan,
361
- agent: buildAgentHints(jsonPlan),
362
- }, null, 2));
363
- return;
364
- }
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
+ }
365
533
 
366
534
  console.log(`\n🔄 Update: v${localGen} → v${remoteGen}`);
367
535
  console.log(` ${plan.updated.length} safe update, ${plan.created.length} new, ${plan.conflict.length} conflict, ${plan.skipped.length} skipped\n`);
@@ -372,13 +540,13 @@ async function main() {
372
540
  for (const c of plan.conflict) {
373
541
  console.log(` 📄 ${c.file} [${c.reason}]`);
374
542
  }
375
- if (plan.updated.length + plan.created.length > 0) {
376
- console.log(' Tip: run --apply-safe to apply SAFE/NEW files first, then merge conflicts.');
377
- }
378
- console.log('');
379
- }
380
-
381
- // Show what will be auto-updated
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
382
550
  if (plan.updated.length + plan.created.length > 0) {
383
551
  console.log('✅ AUTO (safe to apply):');
384
552
  for (const u of plan.updated) console.log(` ↑ ${u.file}`);
@@ -387,18 +555,19 @@ async function main() {
387
555
  }
388
556
 
389
557
  // 4. Apply if requested
390
- if (apply || applySafe) {
558
+ if (apply || applySafe) {
391
559
  // Refuse to apply when conflicts exist — must resolve first
392
- if (apply && !applySafe && plan.conflict.length > 0) {
560
+ if (apply && !applySafe && plan.conflict.length > 0) {
393
561
  console.log(`❌ Cannot apply: ${plan.conflict.length} conflicts must be resolved first.`);
394
- console.log(' Run --apply-safe to apply SAFE/NEW files first, or resolve conflicts manually then re-run --apply.');
395
- return plan;
396
- }
397
- if (applySafe && plan.conflict.length > 0) {
398
- console.log(`Applying SAFE/NEW files only; ${plan.conflict.length} conflicts will remain for AI/user merge.`);
399
- }
400
-
401
- const lexists = existsSync;
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;
402
571
  let applied = 0;
403
572
  let failed = 0;
404
573
 
@@ -410,7 +579,7 @@ async function main() {
410
579
  if (lexists(dest)) {
411
580
  try { if (lstatSync(dest).isSymbolicLink()) { console.error(` ✗ Symlink rejected: ${u.file}`); failed++; continue; } } catch (_) {}
412
581
  }
413
- const content = await fetchRemote(sourceBase + remotePath(u.file));
582
+ const content = await fetchRemote(sourceBase + remotePath(u.file));
414
583
  const normalized = content.replace(/\r\n/g, '\n');
415
584
  const fetchedHash = sha256(normalized);
416
585
  if (fetchedHash !== u.remoteHash) {
@@ -436,7 +605,7 @@ async function main() {
436
605
  console.error(` ✗ File created since plan: ${c.file} — treating as CONFLICT`);
437
606
  failed++; continue;
438
607
  }
439
- const content = await fetchRemote(sourceBase + remotePath(c.file));
608
+ const content = await fetchRemote(sourceBase + remotePath(c.file));
440
609
  const normalized = content.replace(/\r\n/g, '\n');
441
610
  const fetchedHash = sha256(normalized);
442
611
  if (fetchedHash !== c.remoteHash) {
@@ -452,35 +621,66 @@ async function main() {
452
621
  }
453
622
  }
454
623
 
455
- // Only version-track applied files after complete script success.
456
- if (failed === 0) {
457
- const appliedAt = new Date().toISOString();
458
- localVersion.checksums = localVersion.checksums || {};
459
- for (const u of plan.updated) localVersion.checksums[u.file] = u.remoteHash;
460
- for (const c of plan.created) localVersion.checksums[c.file] = c.remoteHash;
461
- if (plan.conflict.length === 0) {
462
- localVersion.generator = remoteGen;
463
- localVersion.generated = appliedAt;
464
- delete localVersion.partialUpdate;
465
- } else {
466
- localVersion.partialUpdate = {
467
- targetGenerator: remoteGen,
468
- updatedAt: appliedAt,
469
- appliedFiles: [...plan.updated, ...plan.created].map(x => x.file),
470
- conflicts: plan.conflict.map(x => x.file),
471
- };
472
- }
473
- writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
474
- if (plan.conflict.length === 0) {
475
- console.log(`✅ Applied ${applied} files. Version updated to ${remoteVersion.generator}.`);
476
- } else {
477
- console.log(`✅ Applied ${applied} SAFE/NEW files. Version remains ${localGen}; ${plan.conflict.length} conflicts still need merge.`);
478
- }
479
- } else {
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 {
480
651
  console.log(`❌ ${failed} failures. NO files were version-tracked. Fix and re-run.`);
652
+ process.exitCode = 1;
481
653
  }
482
654
  }
483
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
+
484
684
  return plan;
485
685
  }
486
686