create-harness-vibe-coding 0.8.6 → 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.
- package/package.json +1 -1
- package/src/generator.js +30 -11
- package/src/index.js +129 -5
- package/templates/common/.claude/agents/reflector.md +35 -0
- package/templates/common/.claude/agents/verifier.md +5 -3
- package/templates/common/.claude/commands/wf-help.md +1 -2
- package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +10 -4
- package/templates/common/.claude/skills/wf/SKILL.md +7 -3
- package/templates/common/.claude/skills/wf-auto/SKILL.md +59 -107
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +19 -17
- package/templates/common/.claude/skills/wf-max/SKILL.md +40 -21
- package/templates/common/.claude/skills/wf-update/SKILL.md +9 -4
- package/templates/common/.codex/config.toml +5 -0
- package/templates/common/.harness-version +36 -34
- package/templates/common/AGENTS.md +26 -25
- package/templates/common/CLAUDE.md +10 -9
- package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +12 -4
- package/templates/common/Harness/README.md +10 -11
- package/templates/common/Harness/WF-AUTO-SPARK.md +18 -1
- package/templates/common/Harness/WF-AUTO.md +518 -492
- package/templates/common/Harness/WF-MAX.md +284 -232
- package/templates/common/Harness/WF.md +47 -29
- package/templates/common/Harness/agent-workflow.md +108 -76
- package/templates/common/Harness/dispatch.md +96 -95
- package/templates/common/Harness/extension.md +1 -1
- package/templates/common/Harness/subagents.md +78 -56
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +1 -1
- package/templates/common/Harness/tasks/_template/NOTES.md +1 -1
- package/templates/common/Harness/tasks/_template/PLAN.md +53 -60
- package/templates/common/Harness/tasks/_template/PROGRESS.md +26 -29
- package/templates/common/MEMORY.md +26 -29
- package/templates/common/SETUP.md +1 -1
- package/templates/common/scripts/scan-clean.mjs +80 -41
- package/templates/common/scripts/validate-harness.mjs +101 -31
- package/templates/common/scripts/wf-remove.mjs +279 -278
- package/templates/common/scripts/wf-update-check.mjs +395 -195
- package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +1 -1
- 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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
138
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
plan.
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
localVersion.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
console.log(`✅ Applied ${applied}
|
|
478
|
-
}
|
|
479
|
-
|
|
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
|
|