create-harness-vibe-coding 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README-CN.md +157 -105
  2. package/README.md +160 -244
  3. package/bin/create-harness-vibe-coding.js +2 -2
  4. package/docs/images/harness-architecture-light.png +0 -0
  5. package/docs/images/harness-architecture.drawio +164 -0
  6. package/package.json +46 -44
  7. package/src/generator.js +30 -11
  8. package/src/index.js +143 -14
  9. package/src/prompts.js +37 -37
  10. package/templates/common/.claude/agents/architect-manager.md +45 -45
  11. package/templates/common/.claude/agents/context-master.md +75 -75
  12. package/templates/common/.claude/agents/debugger.md +41 -41
  13. package/templates/common/.claude/agents/explore-manager.md +41 -41
  14. package/templates/common/.claude/agents/implement-manager.md +49 -49
  15. package/templates/common/.claude/agents/implementer.md +40 -40
  16. package/templates/common/.claude/agents/memory-master.md +64 -64
  17. package/templates/common/.claude/agents/planner.md +34 -34
  18. package/templates/common/.claude/agents/reflector.md +35 -0
  19. package/templates/common/.claude/agents/researcher.md +41 -41
  20. package/templates/common/.claude/agents/review-manager.md +56 -56
  21. package/templates/common/.claude/agents/verifier.md +34 -32
  22. package/templates/common/.claude/commands/wf-help.md +2 -3
  23. package/templates/common/.claude/rules/ecc/common.md +44 -44
  24. package/templates/common/.claude/skills/subagent-orchestrator/SKILL.md +10 -4
  25. package/templates/common/.claude/skills/wf/SKILL.md +7 -3
  26. package/templates/common/.claude/skills/wf-auto/SKILL.md +61 -107
  27. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +19 -17
  28. package/templates/common/.claude/skills/wf-max/SKILL.md +40 -21
  29. package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
  30. package/templates/common/.claude/skills/wf-remove/SKILL.md +7 -7
  31. package/templates/common/.claude/skills/wf-update/SKILL.md +9 -4
  32. package/templates/common/.codex/config.toml +5 -0
  33. package/templates/common/.harness-version +78 -36
  34. package/templates/common/.opencode/agents/architect-manager.md +52 -0
  35. package/templates/common/.opencode/agents/architect.md +35 -0
  36. package/templates/common/.opencode/agents/context-master.md +81 -0
  37. package/templates/common/.opencode/agents/debugger.md +43 -0
  38. package/templates/common/.opencode/agents/docs-researcher.md +42 -0
  39. package/templates/common/.opencode/agents/explore-manager.md +49 -0
  40. package/templates/common/.opencode/agents/implement-manager.md +56 -0
  41. package/templates/common/.opencode/agents/implementer.md +42 -0
  42. package/templates/common/.opencode/agents/memory-master.md +70 -0
  43. package/templates/common/.opencode/agents/planner.md +38 -0
  44. package/templates/common/.opencode/agents/reflector.md +39 -0
  45. package/templates/common/.opencode/agents/researcher.md +42 -0
  46. package/templates/common/.opencode/agents/review-manager.md +63 -0
  47. package/templates/common/.opencode/agents/reviewer.md +37 -0
  48. package/templates/common/.opencode/agents/tdd-guide.md +83 -0
  49. package/templates/common/.opencode/agents/test-writer.md +54 -0
  50. package/templates/common/.opencode/agents/verifier.md +37 -0
  51. package/templates/common/.opencode/commands/wf-help.md +23 -0
  52. package/templates/common/AGENTS.md +26 -25
  53. package/templates/common/CLAUDE.md +86 -88
  54. package/templates/common/Harness/ACCEPTANCE_PROTOCOL.md +12 -4
  55. package/templates/common/Harness/PROGRESS.md +17 -17
  56. package/templates/common/Harness/README.md +26 -16
  57. package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
  58. package/templates/common/Harness/WF-AUTO-SPARK.md +23 -6
  59. package/templates/common/Harness/WF-AUTO.md +508 -493
  60. package/templates/common/Harness/WF-MAX.md +284 -232
  61. package/templates/common/Harness/WF.md +47 -29
  62. package/templates/common/Harness/agent-workflow.md +108 -76
  63. package/templates/common/Harness/architecture.md +124 -124
  64. package/templates/common/Harness/context-loading.md +111 -111
  65. package/templates/common/Harness/dispatch.md +96 -95
  66. package/templates/common/Harness/extension.md +67 -67
  67. package/templates/common/Harness/lifecycle.md +20 -20
  68. package/templates/common/Harness/research/PRD.md +56 -56
  69. package/templates/common/Harness/research/README.md +169 -169
  70. package/templates/common/Harness/research/research-results.md +66 -66
  71. package/templates/common/Harness/subagents.md +192 -170
  72. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +3 -3
  73. package/templates/common/Harness/tasks/_template/NOTES.md +3 -3
  74. package/templates/common/Harness/tasks/_template/PLAN.md +53 -60
  75. package/templates/common/Harness/tasks/_template/PROGRESS.md +26 -29
  76. package/templates/common/MEMORY.md +27 -30
  77. package/templates/common/README.md +36 -36
  78. package/templates/common/SETUP.md +1 -1
  79. package/templates/common/memory/agent-lessons-patterns.md +21 -21
  80. package/templates/common/memory/tool-usage-reflections.md +21 -21
  81. package/templates/common/memory/user-corrections-preferences.md +21 -21
  82. package/templates/common/opencode.json +19 -0
  83. package/templates/common/scripts/scan-clean.mjs +487 -448
  84. package/templates/common/scripts/validate-harness.mjs +245 -146
  85. package/templates/common/scripts/wf-remove.mjs +311 -293
  86. package/templates/common/scripts/wf-update-check.mjs +511 -311
  87. package/templates/optional/catalog.json +41 -33
  88. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +194 -194
  89. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +105 -69
  90. package/templates/optional/skills/github-pr-review/Harness/workflows/github-pr-review.md +28 -28
  91. package/templates/optional/skills/python-backend/Harness/workflows/python-backend.md +34 -34
  92. package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +34 -34
  93. package/templates/optional/skills/ui-ux-review/Harness/workflows/ui-ux-review.md +26 -26
@@ -1,49 +1,51 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scan-clean.mjs — Dead file scanner & cleaner for the Harness framework update pipeline.
4
- *
5
- * Runs AFTER a successful wf-update to find and remove dead files — files that were
6
- * tracked in the previous harness version but removed from the new remote template.
7
- *
8
- * Also detects orphan files: files physically on disk in framework-managed directories
9
- * that are not tracked by either local or remote checksums.
10
- *
11
- * Usage:
12
- * node Harness/scripts/scan-clean.mjs # report mode: show what WOULD be cleaned
13
- * node Harness/scripts/scan-clean.mjs --clean # delete DEAD files (prompts for confirmation)
14
- * node Harness/scripts/scan-clean.mjs --clean --yes # delete without confirmation prompt
15
- * node Harness/scripts/scan-clean.mjs --json # machine-readable JSON output
16
- */
17
-
18
- import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, unlinkSync, rmdirSync } from 'fs';
19
- import { resolve, dirname, sep, join } from 'path';
20
- import { fileURLToPath } from 'url';
21
- import { createInterface } from 'readline';
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 SOURCE_BASE = 'https://raw.githubusercontent.com/zingspark/create-harness-vibe-coding/main/templates/common/';
27
-
28
- // ── Classification constants ────────────────────────────────────────
29
-
30
- /** Files we NEVER delete, even if dead. */
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
- /** Directories scanned for orphan files (untracked by either checksum set). */
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scan-clean.mjs — Dead file scanner & cleaner for the Harness framework update pipeline.
4
+ *
5
+ * Runs AFTER a successful wf-update to find and remove dead files — files that were
6
+ * tracked in the previous harness version but removed from the new remote template.
7
+ *
8
+ * Also detects orphan files: files physically on disk in framework-managed directories
9
+ * that are not tracked by either local or remote checksums.
10
+ *
11
+ * Usage:
12
+ * node Harness/scripts/scan-clean.mjs # report mode: show what WOULD be cleaned
13
+ * node Harness/scripts/scan-clean.mjs --clean # delete DEAD files (prompts for confirmation)
14
+ * node Harness/scripts/scan-clean.mjs --clean --yes # delete without confirmation prompt
15
+ * node Harness/scripts/scan-clean.mjs --json # machine-readable JSON output
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, unlinkSync, rmdirSync } from 'fs';
19
+ import { resolve, dirname, sep, join } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+ import { createInterface } from 'readline';
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
+ // ── Classification constants ────────────────────────────────────────
29
+
30
+ /** Files we NEVER delete, even if dead. */
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
+ /^tests\/\.gitkeep$/,
39
+ /^README\.md$/,
40
+ /^\.gitignore$/,
41
+ /^package\.json$/,
42
+ /^package-lock\.json$/,
43
+ ];
44
+
45
+ /** Directories scanned for orphan files (untracked by either checksum set). */
45
46
  const FRAMEWORK_DIRS = [
46
47
  '.claude/agents',
48
+ '.claude/commands',
47
49
  '.claude/skills',
48
50
  '.claude/rules',
49
51
  '.agents/skills',
@@ -51,407 +53,444 @@ const FRAMEWORK_DIRS = [
51
53
  'Harness/scripts',
52
54
  'Harness/workflows',
53
55
  ];
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
- /** Check whether a canonical path matches any PRESERVE pattern. */
75
- function isPreserved(canonical) {
76
- for (const p of PRESERVE_PATTERNS) {
77
- if (p.test(canonical)) return true;
78
- }
79
- return false;
80
- }
81
-
82
- /** Detect template placeholders in remote content. */
83
- function isTemplate(raw) {
84
- return /\{\{[a-zA-Z]+\}\}/.test(raw);
85
- }
86
-
87
- async function fetchRemote(url, timeoutMs = 30000) {
88
- const controller = new AbortController();
89
- const timer = setTimeout(() => controller.abort(), timeoutMs);
90
- try {
91
- const res = await fetch(url, { signal: controller.signal });
92
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
93
- return res.text();
94
- } finally {
95
- clearTimeout(timer);
96
- }
97
- }
98
-
99
- // ── Orphan detection ─────────────────────────────────────────────────
100
-
101
- /**
102
- * Recursively list all files under a directory, returning canonical paths
103
- * relative to ROOT. Skips symlinks and directories that don't exist.
104
- */
105
- function listFilesRecursive(relDir) {
106
- const absDir = resolve(ROOT, relDir);
107
- if (!existsSync(absDir)) return [];
108
- const results = [];
109
- try {
110
- const entries = readdirSync(absDir, { withFileTypes: true });
111
- for (const entry of entries) {
112
- const absPath = join(absDir, entry.name);
113
- const relPath = canonicalPath(join(relDir, entry.name));
114
- try {
115
- if (lstatSync(absPath).isSymbolicLink()) continue;
116
- } catch (_) { continue; }
117
- if (entry.isFile()) {
118
- results.push(relPath);
119
- } else if (entry.isDirectory()) {
120
- results.push(...listFilesRecursive(relPath));
121
- }
122
- }
123
- } catch (_) { /* permission errors etc. — skip */ }
124
- return results;
125
- }
126
-
127
- /**
128
- * Find orphan files: files physically in FRAMEWORK_DIRS that are not
129
- * tracked by local or remote checksums AND do not match PRESERVE patterns.
130
- */
131
- function findOrphanFiles(localChecksums, remoteChecksums) {
132
- const allTracked = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
133
- const orphans = [];
134
-
135
- for (const dir of FRAMEWORK_DIRS) {
136
- const diskFiles = listFilesRecursive(dir);
137
- for (const file of diskFiles) {
138
- if (allTracked.has(file)) continue;
139
- if (isPreserved(file)) continue;
140
- orphans.push({ file, reason: 'untracked in framework directory' });
141
- }
142
- }
143
-
144
- return orphans;
145
- }
146
-
147
- // ── Empty directory detection ────────────────────────────────────────
148
-
149
- /**
150
- * Given a set of file paths that will be removed, find directories that
151
- * would become empty. Returns canonical paths of empty directories.
152
- */
153
- function findEmptyDirs(filesToRemove) {
154
- const removeSet = new Set(filesToRemove.map(canonicalPath));
155
- // Collect all unique directories touched by the files to remove
156
- const dirSet = new Set();
157
- for (const file of removeSet) {
158
- let dir = dirname(file);
159
- while (dir !== '.' && dir !== '/') {
160
- dirSet.add(dir);
161
- dir = dirname(dir);
162
- }
163
- }
164
-
165
- const emptyDirs = [];
166
- for (const dir of dirSet) {
167
- const absDir = resolve(ROOT, dir);
168
- if (!existsSync(absDir)) continue;
169
- // Gather all files recursively under this directory
170
- const allFiles = listFilesRecursive(dir);
171
- // If every file in this tree is slated for removal, the dir becomes empty
172
- if (allFiles.length > 0 && allFiles.every(f => removeSet.has(f))) {
173
- emptyDirs.push(dir);
174
- }
175
- }
176
-
177
- // Return sorted, removing children whose parents are also empty
178
- // (only report the highest-level empty dirs)
179
- const result = [];
180
- for (const dir of emptyDirs.sort()) {
181
- // Skip if any parent is already in the result
182
- if (result.some(p => dir.startsWith(p + '/'))) continue;
183
- result.push(dir);
184
- }
185
-
186
- return result;
187
- }
188
-
189
- // ── Clean helpers ────────────────────────────────────────────────────
190
-
191
- /**
192
- * Remove empty parent directories, walking up from the given file path.
193
- * Stops when a directory is non-empty or we reach ROOT.
194
- */
195
- function removeEmptyParents(fileAbsPath) {
196
- let dir = dirname(fileAbsPath);
197
- const removed = [];
198
-
199
- while (dir !== ROOT && dir.startsWith(ROOT + sep)) {
200
- if (!existsSync(dir)) break;
201
- try {
202
- const entries = readdirSync(dir);
203
- if (entries.length === 0) {
204
- rmdirSync(dir);
205
- removed.push(dir);
206
- dir = dirname(dir);
207
- } else {
208
- break;
209
- }
210
- } catch (_) { break; }
211
- }
212
-
213
- return removed;
214
- }
215
-
216
- // ── Main ─────────────────────────────────────────────────────────────
217
-
218
- async function main() {
219
- const args = process.argv.slice(2);
220
- const clean = args.includes('--clean');
221
- const yes = args.includes('--yes');
222
- const jsonOut = args.includes('--json');
223
-
224
- // 1. Read local state
225
- if (!existsSync(VERSION_FILE)) {
226
- if (jsonOut) {
227
- console.log(JSON.stringify({ status: 'error', message: 'Local .harness-version not found.' }));
228
- } else {
229
- console.error('ERROR: Harness/.harness-version not found. Nothing to scan.');
230
- }
231
- process.exit(1);
232
- }
233
-
234
- let localVersion;
235
- try {
236
- localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
237
- } catch (e) {
238
- if (jsonOut) {
239
- console.log(JSON.stringify({ status: 'error', message: 'Failed to parse local .harness-version: ' + e.message }));
240
- } else {
241
- console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
242
- }
243
- process.exit(1);
244
- }
245
-
246
- const localChecksums = localVersion.checksums || {};
247
-
248
- // 2. Fetch remote version file
249
- let remoteVersion;
250
- try {
251
- const raw = await fetchRemote(SOURCE_BASE + '.harness-version');
252
- if (isTemplate(raw)) {
253
- if (jsonOut) {
254
- console.log(JSON.stringify({ status: 'error', message: 'Remote .harness-version is a template (contains {{placeholders}}). Cannot determine dead files.' }));
255
- } else {
256
- console.error('ERROR: Remote .harness-version is a template (contains placeholders).');
257
- console.error(' Cannot determine what files have been removed from the template.');
258
- console.error(' Re-run after the remote template is generated.');
259
- }
260
- process.exit(1);
261
- }
262
- remoteVersion = JSON.parse(raw);
263
- } catch (e) {
264
- if (jsonOut) {
265
- console.log(JSON.stringify({ status: 'error', message: 'Cannot reach GitHub or invalid remote JSON: ' + e.message }));
266
- } else {
267
- console.error('ERROR: Cannot reach GitHub or invalid remote JSON. Offline?');
268
- console.error(e.message);
269
- }
270
- process.exit(1);
271
- }
272
-
273
- const remoteChecksums = remoteVersion.checksums || {};
274
-
275
- // 3. Classify dead files
276
- // DEAD = in local checksums but NOT in remote checksums, AND NOT preserved
277
- const deadFiles = [];
278
- for (const file of Object.keys(localChecksums)) {
279
- const canonical = canonicalPath(file);
280
- if (remoteChecksums[file]) continue; // still in remote, not dead
281
- if (isPreserved(canonical)) continue; // user data, never dead
282
- deadFiles.push({ file, reason: 'not in remote template' });
283
- }
284
-
285
- // 4. Find orphan files
286
- const orphanFiles = findOrphanFiles(localChecksums, remoteChecksums);
287
-
288
- // 5. Find empty dirs (predictive — what WOULD become empty)
289
- const deadPaths = deadFiles.map(d => canonicalPath(d.file));
290
- const emptyDirs = findEmptyDirs(deadPaths);
291
-
292
- // 6. Output
293
- if (jsonOut) {
294
- const status = deadFiles.length > 0 || orphanFiles.length > 0 ? 'dead-found' : 'clean';
295
- console.log(JSON.stringify({
296
- status,
297
- dead: deadFiles,
298
- orphan: orphanFiles,
299
- emptyDirs,
300
- summary: {
301
- dead: deadFiles.length,
302
- orphan: orphanFiles.length,
303
- emptyDirs: emptyDirs.length,
304
- },
305
- }, null, 2));
306
- return;
307
- }
308
-
309
- // ── Report mode ──────────────────────────────────────────────────
310
-
311
- if (!clean) {
312
- if (deadFiles.length === 0 && orphanFiles.length === 0) {
313
- console.log('✅ Harness is clean — no dead files detected.');
314
- return;
315
- }
316
-
317
- if (deadFiles.length > 0) {
318
- console.log('DEAD FILES (safe to delete tracked locally but not in remote template):');
319
- for (const d of deadFiles) {
320
- const onDisk = existsSync(safePath(d.file));
321
- const marker = onDisk ? '' : ' [already deleted on disk]';
322
- console.log(` ${d.file}${marker}`);
323
- }
324
- console.log('');
325
- }
326
-
327
- if (orphanFiles.length > 0) {
328
- console.log('ORPHAN FILES (review needed — untracked in framework directories):');
329
- for (const o of orphanFiles) {
330
- console.log(` ${o.file}`);
331
- }
332
- console.log('');
333
- }
334
-
335
- if (emptyDirs.length > 0) {
336
- console.log('EMPTY DIRS (would become empty after cleaning):');
337
- for (const d of emptyDirs) {
338
- console.log(` ${d}/`);
339
- }
340
- console.log('');
341
- }
342
-
343
- console.log(`Summary: ${deadFiles.length} dead, ${orphanFiles.length} orphan, ${emptyDirs.length} empty dirs.`);
344
- if (deadFiles.length > 0) {
345
- console.log('Run with --clean to delete dead files.');
346
- }
347
- return;
348
- }
349
-
350
- // ── Clean mode ───────────────────────────────────────────────────
351
-
352
- if (deadFiles.length === 0) {
353
- console.log('✅ Harness is clean — no dead files to delete.');
354
- return;
355
- }
356
-
357
- // Show what will be deleted
358
- console.log(`Will delete ${deadFiles.length} dead file(s):`);
359
- for (const d of deadFiles) {
360
- console.log(` ${d.file}`);
361
- }
362
- console.log('');
363
-
364
- // Confirmation prompt (skip if --yes)
365
- if (!yes) {
366
- const rl = createInterface({ input: process.stdin, output: process.stdout });
367
- const answer = await new Promise(resolve => {
368
- rl.question(`Delete ${deadFiles.length} dead files? [y/N]: `, ans => {
369
- rl.close();
370
- resolve(ans.trim().toLowerCase());
371
- });
372
- });
373
- if (answer !== 'y' && answer !== 'yes') {
374
- console.log('Aborted.');
375
- return;
376
- }
377
- console.log('');
378
- }
379
-
380
- // Delete dead files
381
- let deletedCount = 0;
382
- let failedCount = 0;
383
-
384
- for (const d of deadFiles) {
385
- const diskPath = safePath(d.file);
386
- if (!diskPath) {
387
- if (!jsonOut) console.error(` ✗ Traversal rejected: ${d.file}`);
388
- failedCount++;
389
- continue;
390
- }
391
-
392
- // Re-verify PRESERVE before deletion (defense in depth)
393
- if (isPreserved(canonicalPath(d.file))) {
394
- if (!jsonOut) console.error(` PRESERVE override skipped: ${d.file}`);
395
- failedCount++;
396
- continue;
397
- }
398
-
399
- // If file doesn't exist on disk, just clean the checksum entry
400
- if (!existsSync(diskPath)) {
401
- delete localChecksums[d.file];
402
- deletedCount++;
403
- continue;
404
- }
405
-
406
- // Symlink rejection
407
- try {
408
- if (lstatSync(diskPath).isSymbolicLink()) {
409
- if (!jsonOut) console.error(` ✗ Symlink rejected: ${d.file}`);
410
- failedCount++;
411
- continue;
412
- }
413
- } catch (_) {
414
- if (!jsonOut) console.error(` ✗ Cannot stat: ${d.file}`);
415
- failedCount++;
416
- continue;
417
- }
418
-
419
- // Delete the file
420
- try {
421
- unlinkSync(diskPath);
422
- delete localChecksums[d.file];
423
- deletedCount++;
424
- } catch (e) {
425
- if (!jsonOut) console.error(` ✗ Failed to delete: ${d.file} — ${e.message}`);
426
- failedCount++;
427
- }
428
- }
429
-
430
- // Remove empty parent directories
431
- let emptyRemoved = 0;
432
- for (const d of deadFiles) {
433
- const diskPath = safePath(d.file);
434
- if (!diskPath) continue;
435
- const parentDir = dirname(diskPath);
436
- if (existsSync(parentDir)) {
437
- const removed = removeEmptyParents(parentDir);
438
- emptyRemoved += removed.length;
439
- }
440
- }
441
-
442
- // Write updated checksums
443
- try {
444
- writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
445
- } catch (e) {
446
- console.error(` ✗ Failed to update .harness-version: ${e.message}`);
447
- }
448
-
449
- // Summary
450
- if (failedCount > 0) {
451
- console.log(`\n⚠ Cleaned ${deletedCount} files, removed ${emptyRemoved} empty directories. ${failedCount} failed (see above).`);
452
- } else {
453
- console.log(`\n✅ Cleaned ${deletedCount} files, removed ${emptyRemoved} empty directories.`);
454
- }
455
- }
456
-
457
- main().catch(e => { console.error(e); process.exit(1); });
56
+
57
+ // ── Helpers ──────────────────────────────────────────────────────────
58
+
59
+ /** Reject paths that escape ROOT (traversal, absolute, .., etc.). */
60
+ function safePath(file) {
61
+ let normalized = file.replace(/\\/g, '/').replace(/^\/+/, '');
62
+ if (/\/\//.test(normalized)) return null;
63
+ if (normalized.split('/').some(p => p === '..')) return null;
64
+ if (file.startsWith('/') || file.startsWith('\\')) return null;
65
+ if (normalized === '.' || normalized === '') return null;
66
+ const resolved = resolve(ROOT, normalized);
67
+ if (!resolved.startsWith(ROOT + sep) && resolved !== ROOT) return null;
68
+ return resolved;
69
+ }
70
+
71
+ /** Canonical normalization for classification matching. */
72
+ function canonicalPath(file) {
73
+ return file.replace(/\\/g, '/').replace(/\/+/g, '/');
74
+ }
75
+
76
+ function readFlagValue(args, flagName) {
77
+ const idx = args.indexOf(flagName);
78
+ if (idx === -1) return null;
79
+ const value = args[idx + 1];
80
+ if (!value || value.startsWith('--')) return null;
81
+ return value;
82
+ }
83
+
84
+ function normalizeSourceBase(sourceBase) {
85
+ return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
86
+ }
87
+
88
+ /** Check whether a canonical path matches any PRESERVE pattern. */
89
+ function isPreserved(canonical) {
90
+ for (const p of PRESERVE_PATTERNS) {
91
+ if (p.test(canonical)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ function selectedOptionIds(localVersion) {
97
+ return new Set(
98
+ Array.isArray(localVersion?.options)
99
+ ? localVersion.options.map(String).filter(Boolean)
100
+ : [],
101
+ );
102
+ }
103
+
104
+ function optionalSkillFromSource(source) {
105
+ const normalized = canonicalPath(source || '');
106
+ const match = normalized.match(/^skills\/([^/]+)\//);
107
+ return match ? match[1] : null;
108
+ }
109
+
110
+ function isInstalledOptionalFile(file, localVersion) {
111
+ const skillId = optionalSkillFromSource(localVersion?.sources?.[file]);
112
+ return Boolean(skillId && selectedOptionIds(localVersion).has(skillId));
113
+ }
114
+
115
+ /** Detect template placeholders in remote content. */
116
+ function isTemplate(raw) {
117
+ return /\{\{[a-zA-Z]+\}\}/.test(raw);
118
+ }
119
+
120
+ async function fetchRemote(url, timeoutMs = 30000) {
121
+ if (url.startsWith('file://')) {
122
+ return readFileSync(fileURLToPath(url), 'utf-8');
123
+ }
124
+
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
127
+ try {
128
+ const res = await fetch(url, { signal: controller.signal });
129
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
130
+ return res.text();
131
+ } finally {
132
+ clearTimeout(timer);
133
+ }
134
+ }
135
+
136
+ // ── Orphan detection ─────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Recursively list all files under a directory, returning canonical paths
140
+ * relative to ROOT. Skips symlinks and directories that don't exist.
141
+ */
142
+ function listFilesRecursive(relDir) {
143
+ const absDir = resolve(ROOT, relDir);
144
+ if (!existsSync(absDir)) return [];
145
+ const results = [];
146
+ try {
147
+ const entries = readdirSync(absDir, { withFileTypes: true });
148
+ for (const entry of entries) {
149
+ const absPath = join(absDir, entry.name);
150
+ const relPath = canonicalPath(join(relDir, entry.name));
151
+ try {
152
+ if (lstatSync(absPath).isSymbolicLink()) continue;
153
+ } catch (_) { continue; }
154
+ if (entry.isFile()) {
155
+ results.push(relPath);
156
+ } else if (entry.isDirectory()) {
157
+ results.push(...listFilesRecursive(relPath));
158
+ }
159
+ }
160
+ } catch (_) { /* permission errors etc. — skip */ }
161
+ return results;
162
+ }
163
+
164
+ /**
165
+ * Find orphan files: files physically in FRAMEWORK_DIRS that are not
166
+ * tracked by local or remote checksums AND do not match PRESERVE patterns.
167
+ */
168
+ function findOrphanFiles(localChecksums, remoteChecksums) {
169
+ const allTracked = new Set([...Object.keys(localChecksums), ...Object.keys(remoteChecksums)]);
170
+ const orphans = [];
171
+
172
+ for (const dir of FRAMEWORK_DIRS) {
173
+ const diskFiles = listFilesRecursive(dir);
174
+ for (const file of diskFiles) {
175
+ if (allTracked.has(file)) continue;
176
+ if (isPreserved(file)) continue;
177
+ orphans.push({ file, reason: 'untracked in framework directory' });
178
+ }
179
+ }
180
+
181
+ return orphans;
182
+ }
183
+
184
+ // ── Empty directory detection ────────────────────────────────────────
185
+
186
+ /**
187
+ * Given a set of file paths that will be removed, find directories that
188
+ * would become empty. Returns canonical paths of empty directories.
189
+ */
190
+ function findEmptyDirs(filesToRemove) {
191
+ const removeSet = new Set(filesToRemove.map(canonicalPath));
192
+ // Collect all unique directories touched by the files to remove
193
+ const dirSet = new Set();
194
+ for (const file of removeSet) {
195
+ let dir = dirname(file);
196
+ while (dir !== '.' && dir !== '/') {
197
+ dirSet.add(dir);
198
+ dir = dirname(dir);
199
+ }
200
+ }
201
+
202
+ const emptyDirs = [];
203
+ for (const dir of dirSet) {
204
+ const absDir = resolve(ROOT, dir);
205
+ if (!existsSync(absDir)) continue;
206
+ // Gather all files recursively under this directory
207
+ const allFiles = listFilesRecursive(dir);
208
+ // If every file in this tree is slated for removal, the dir becomes empty
209
+ if (allFiles.length > 0 && allFiles.every(f => removeSet.has(f))) {
210
+ emptyDirs.push(dir);
211
+ }
212
+ }
213
+
214
+ // Return sorted, removing children whose parents are also empty
215
+ // (only report the highest-level empty dirs)
216
+ const result = [];
217
+ for (const dir of emptyDirs.sort()) {
218
+ // Skip if any parent is already in the result
219
+ if (result.some(p => dir.startsWith(p + '/'))) continue;
220
+ result.push(dir);
221
+ }
222
+
223
+ return result;
224
+ }
225
+
226
+ // ── Clean helpers ────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Remove empty parent directories, walking up from the given file path.
230
+ * Stops when a directory is non-empty or we reach ROOT.
231
+ */
232
+ function removeEmptyParents(fileAbsPath) {
233
+ let dir = dirname(fileAbsPath);
234
+ const removed = [];
235
+
236
+ while (dir !== ROOT && dir.startsWith(ROOT + sep)) {
237
+ if (!existsSync(dir)) break;
238
+ try {
239
+ const entries = readdirSync(dir);
240
+ if (entries.length === 0) {
241
+ rmdirSync(dir);
242
+ removed.push(dir);
243
+ dir = dirname(dir);
244
+ } else {
245
+ break;
246
+ }
247
+ } catch (_) { break; }
248
+ }
249
+
250
+ return removed;
251
+ }
252
+
253
+ // ── Main ─────────────────────────────────────────────────────────────
254
+
255
+ async function main() {
256
+ const args = process.argv.slice(2);
257
+ const clean = args.includes('--clean');
258
+ const yes = args.includes('--yes');
259
+ const jsonOut = args.includes('--json');
260
+ const sourceBase = normalizeSourceBase(readFlagValue(args, '--source-base') || process.env.WF_SOURCE_BASE || DEFAULT_SOURCE_BASE);
261
+
262
+ // 1. Read local state
263
+ if (!existsSync(VERSION_FILE)) {
264
+ if (jsonOut) {
265
+ console.log(JSON.stringify({ status: 'error', message: 'Local .harness-version not found.' }));
266
+ } else {
267
+ console.error('ERROR: Harness/.harness-version not found. Nothing to scan.');
268
+ }
269
+ process.exit(1);
270
+ }
271
+
272
+ let localVersion;
273
+ try {
274
+ localVersion = JSON.parse(readFileSync(VERSION_FILE, 'utf-8'));
275
+ } catch (e) {
276
+ if (jsonOut) {
277
+ console.log(JSON.stringify({ status: 'error', message: 'Failed to parse local .harness-version: ' + e.message }));
278
+ } else {
279
+ console.error('ERROR: Failed to parse Harness/.harness-version:', e.message);
280
+ }
281
+ process.exit(1);
282
+ }
283
+
284
+ const localChecksums = localVersion.checksums || {};
285
+
286
+ // 2. Fetch remote version file
287
+ let remoteVersion;
288
+ try {
289
+ const raw = await fetchRemote(sourceBase + '.harness-version');
290
+ if (isTemplate(raw)) {
291
+ if (jsonOut) {
292
+ console.log(JSON.stringify({ status: 'error', message: 'Remote .harness-version is a template (contains {{placeholders}}). Cannot determine dead files.' }));
293
+ } else {
294
+ console.error('ERROR: Remote .harness-version is a template (contains placeholders).');
295
+ console.error(' Cannot determine what files have been removed from the template.');
296
+ console.error(' Re-run after the remote template is generated.');
297
+ }
298
+ process.exit(1);
299
+ }
300
+ remoteVersion = JSON.parse(raw);
301
+ } catch (e) {
302
+ if (jsonOut) {
303
+ console.log(JSON.stringify({ status: 'error', message: 'Cannot reach GitHub or invalid remote JSON: ' + e.message }));
304
+ } else {
305
+ console.error('ERROR: Cannot reach GitHub or invalid remote JSON. Offline?');
306
+ console.error(e.message);
307
+ }
308
+ process.exit(1);
309
+ }
310
+
311
+ const remoteChecksums = remoteVersion.checksums || {};
312
+
313
+ // 3. Classify dead files
314
+ // DEAD = in local checksums but NOT in remote checksums, AND NOT preserved
315
+ const deadFiles = [];
316
+ for (const file of Object.keys(localChecksums)) {
317
+ const canonical = canonicalPath(file);
318
+ if (remoteChecksums[file]) continue; // still in remote, not dead
319
+ if (isPreserved(canonical)) continue; // user data, never dead
320
+ if (isInstalledOptionalFile(file, localVersion)) continue; // installed optional workflow/skill
321
+ deadFiles.push({ file, reason: 'not in remote template' });
322
+ }
323
+
324
+ // 4. Find orphan files
325
+ const orphanFiles = findOrphanFiles(localChecksums, remoteChecksums);
326
+
327
+ // 5. Find empty dirs (predictive — what WOULD become empty)
328
+ const deadPaths = deadFiles.map(d => canonicalPath(d.file));
329
+ const emptyDirs = findEmptyDirs(deadPaths);
330
+
331
+ // 6. Output
332
+ if (jsonOut) {
333
+ const status = deadFiles.length > 0 || orphanFiles.length > 0 ? 'dead-found' : 'clean';
334
+ console.log(JSON.stringify({
335
+ status,
336
+ dead: deadFiles,
337
+ orphan: orphanFiles,
338
+ emptyDirs,
339
+ summary: {
340
+ dead: deadFiles.length,
341
+ orphan: orphanFiles.length,
342
+ emptyDirs: emptyDirs.length,
343
+ },
344
+ }, null, 2));
345
+ return;
346
+ }
347
+
348
+ // ── Report mode ──────────────────────────────────────────────────
349
+
350
+ if (!clean) {
351
+ if (deadFiles.length === 0 && orphanFiles.length === 0) {
352
+ console.log('✅ Harness is clean — no dead files detected.');
353
+ return;
354
+ }
355
+
356
+ if (deadFiles.length > 0) {
357
+ console.log('DEAD FILES (safe to delete — tracked locally but not in remote template):');
358
+ for (const d of deadFiles) {
359
+ const onDisk = existsSync(safePath(d.file));
360
+ const marker = onDisk ? '' : ' [already deleted on disk]';
361
+ console.log(` ${d.file}${marker}`);
362
+ }
363
+ console.log('');
364
+ }
365
+
366
+ if (orphanFiles.length > 0) {
367
+ console.log('ORPHAN FILES (review needed — untracked in framework directories):');
368
+ for (const o of orphanFiles) {
369
+ console.log(` ${o.file}`);
370
+ }
371
+ console.log('');
372
+ }
373
+
374
+ if (emptyDirs.length > 0) {
375
+ console.log('EMPTY DIRS (would become empty after cleaning):');
376
+ for (const d of emptyDirs) {
377
+ console.log(` ${d}/`);
378
+ }
379
+ console.log('');
380
+ }
381
+
382
+ console.log(`Summary: ${deadFiles.length} dead, ${orphanFiles.length} orphan, ${emptyDirs.length} empty dirs.`);
383
+ if (deadFiles.length > 0) {
384
+ console.log('Run with --clean to delete dead files.');
385
+ }
386
+ return;
387
+ }
388
+
389
+ // ── Clean mode ───────────────────────────────────────────────────
390
+
391
+ if (deadFiles.length === 0) {
392
+ console.log('✅ Harness is clean — no dead files to delete.');
393
+ return;
394
+ }
395
+
396
+ // Show what will be deleted
397
+ console.log(`Will delete ${deadFiles.length} dead file(s):`);
398
+ for (const d of deadFiles) {
399
+ console.log(` ${d.file}`);
400
+ }
401
+ console.log('');
402
+
403
+ // Confirmation prompt (skip if --yes)
404
+ if (!yes) {
405
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
406
+ const answer = await new Promise(resolve => {
407
+ rl.question(`Delete ${deadFiles.length} dead files? [y/N]: `, ans => {
408
+ rl.close();
409
+ resolve(ans.trim().toLowerCase());
410
+ });
411
+ });
412
+ if (answer !== 'y' && answer !== 'yes') {
413
+ console.log('Aborted.');
414
+ return;
415
+ }
416
+ console.log('');
417
+ }
418
+
419
+ // Delete dead files
420
+ let deletedCount = 0;
421
+ let failedCount = 0;
422
+
423
+ for (const d of deadFiles) {
424
+ const diskPath = safePath(d.file);
425
+ if (!diskPath) {
426
+ if (!jsonOut) console.error(` ✗ Traversal rejected: ${d.file}`);
427
+ failedCount++;
428
+ continue;
429
+ }
430
+
431
+ // Re-verify PRESERVE before deletion (defense in depth)
432
+ if (isPreserved(canonicalPath(d.file))) {
433
+ if (!jsonOut) console.error(` PRESERVE override — skipped: ${d.file}`);
434
+ failedCount++;
435
+ continue;
436
+ }
437
+
438
+ // If file doesn't exist on disk, just clean the checksum entry
439
+ if (!existsSync(diskPath)) {
440
+ delete localChecksums[d.file];
441
+ deletedCount++;
442
+ continue;
443
+ }
444
+
445
+ // Symlink rejection
446
+ try {
447
+ if (lstatSync(diskPath).isSymbolicLink()) {
448
+ if (!jsonOut) console.error(` ✗ Symlink rejected: ${d.file}`);
449
+ failedCount++;
450
+ continue;
451
+ }
452
+ } catch (_) {
453
+ if (!jsonOut) console.error(` ✗ Cannot stat: ${d.file}`);
454
+ failedCount++;
455
+ continue;
456
+ }
457
+
458
+ // Delete the file
459
+ try {
460
+ unlinkSync(diskPath);
461
+ delete localChecksums[d.file];
462
+ deletedCount++;
463
+ } catch (e) {
464
+ if (!jsonOut) console.error(` ✗ Failed to delete: ${d.file} — ${e.message}`);
465
+ failedCount++;
466
+ }
467
+ }
468
+
469
+ // Remove empty parent directories
470
+ let emptyRemoved = 0;
471
+ for (const d of deadFiles) {
472
+ const diskPath = safePath(d.file);
473
+ if (!diskPath) continue;
474
+ const parentDir = dirname(diskPath);
475
+ if (existsSync(parentDir)) {
476
+ const removed = removeEmptyParents(parentDir);
477
+ emptyRemoved += removed.length;
478
+ }
479
+ }
480
+
481
+ // Write updated checksums
482
+ try {
483
+ writeFileSync(VERSION_FILE, JSON.stringify(localVersion, null, 2) + '\n', 'utf-8');
484
+ } catch (e) {
485
+ console.error(` ✗ Failed to update .harness-version: ${e.message}`);
486
+ }
487
+
488
+ // Summary
489
+ if (failedCount > 0) {
490
+ console.log(`\n⚠ Cleaned ${deletedCount} files, removed ${emptyRemoved} empty directories. ${failedCount} failed (see above).`);
491
+ } else {
492
+ console.log(`\n✅ Cleaned ${deletedCount} files, removed ${emptyRemoved} empty directories.`);
493
+ }
494
+ }
495
+
496
+ main().catch(e => { console.error(e); process.exit(1); });