create-harness-vibe-coding 0.8.7 → 0.8.9

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