create-harness-vibe-coding 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README-CN.md +157 -105
  2. package/README.md +160 -244
  3. package/bin/create-harness-vibe-coding.js +2 -2
  4. package/docs/images/harness-architecture-light.png +0 -0
  5. package/docs/images/harness-architecture.drawio +164 -0
  6. package/package.json +46 -44
  7. package/src/generator.js +5 -5
  8. package/src/index.js +14 -9
  9. package/src/prompts.js +37 -37
  10. package/templates/common/.claude/agents/architect-manager.md +45 -45
  11. package/templates/common/.claude/agents/context-master.md +75 -75
  12. package/templates/common/.claude/agents/debugger.md +41 -41
  13. package/templates/common/.claude/agents/explore-manager.md +41 -41
  14. package/templates/common/.claude/agents/implement-manager.md +49 -49
  15. package/templates/common/.claude/agents/implementer.md +40 -40
  16. package/templates/common/.claude/agents/memory-master.md +64 -64
  17. package/templates/common/.claude/agents/planner.md +34 -34
  18. package/templates/common/.claude/agents/researcher.md +41 -41
  19. package/templates/common/.claude/agents/review-manager.md +56 -56
  20. package/templates/common/.claude/agents/verifier.md +29 -29
  21. package/templates/common/.claude/commands/wf-help.md +1 -1
  22. package/templates/common/.claude/rules/ecc/common.md +44 -44
  23. package/templates/common/.claude/skills/wf-auto/SKILL.md +8 -6
  24. package/templates/common/.claude/skills/wf-readme/SKILL.md +49 -49
  25. package/templates/common/.claude/skills/wf-remove/SKILL.md +7 -7
  26. package/templates/common/.harness-version +54 -14
  27. package/templates/common/.opencode/agents/architect-manager.md +52 -0
  28. package/templates/common/.opencode/agents/architect.md +35 -0
  29. package/templates/common/.opencode/agents/context-master.md +81 -0
  30. package/templates/common/.opencode/agents/debugger.md +43 -0
  31. package/templates/common/.opencode/agents/docs-researcher.md +42 -0
  32. package/templates/common/.opencode/agents/explore-manager.md +49 -0
  33. package/templates/common/.opencode/agents/implement-manager.md +56 -0
  34. package/templates/common/.opencode/agents/implementer.md +42 -0
  35. package/templates/common/.opencode/agents/memory-master.md +70 -0
  36. package/templates/common/.opencode/agents/planner.md +38 -0
  37. package/templates/common/.opencode/agents/reflector.md +39 -0
  38. package/templates/common/.opencode/agents/researcher.md +42 -0
  39. package/templates/common/.opencode/agents/review-manager.md +63 -0
  40. package/templates/common/.opencode/agents/reviewer.md +37 -0
  41. package/templates/common/.opencode/agents/tdd-guide.md +83 -0
  42. package/templates/common/.opencode/agents/test-writer.md +54 -0
  43. package/templates/common/.opencode/agents/verifier.md +37 -0
  44. package/templates/common/.opencode/commands/wf-help.md +23 -0
  45. package/templates/common/CLAUDE.md +85 -88
  46. package/templates/common/Harness/PROGRESS.md +17 -17
  47. package/templates/common/Harness/README.md +16 -5
  48. package/templates/common/Harness/WF-AUTO-ANGLES.md +170 -0
  49. package/templates/common/Harness/WF-AUTO-SPARK.md +5 -5
  50. package/templates/common/Harness/WF-AUTO.md +85 -96
  51. package/templates/common/Harness/architecture.md +124 -124
  52. package/templates/common/Harness/context-loading.md +111 -111
  53. package/templates/common/Harness/extension.md +66 -66
  54. package/templates/common/Harness/lifecycle.md +20 -20
  55. package/templates/common/Harness/research/PRD.md +56 -56
  56. package/templates/common/Harness/research/README.md +169 -169
  57. package/templates/common/Harness/research/research-results.md +66 -66
  58. package/templates/common/Harness/subagents.md +208 -208
  59. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +2 -2
  60. package/templates/common/Harness/tasks/_template/NOTES.md +2 -2
  61. package/templates/common/MEMORY.md +1 -1
  62. package/templates/common/README.md +36 -36
  63. package/templates/common/memory/agent-lessons-patterns.md +21 -21
  64. package/templates/common/memory/tool-usage-reflections.md +21 -21
  65. package/templates/common/memory/user-corrections-preferences.md +21 -21
  66. package/templates/common/opencode.json +19 -0
  67. package/templates/common/scripts/scan-clean.mjs +415 -415
  68. package/templates/common/scripts/validate-harness.mjs +274 -245
  69. package/templates/common/scripts/wf-remove.mjs +56 -39
  70. package/templates/common/scripts/wf-update-check.mjs +599 -599
  71. package/templates/optional/catalog.json +41 -33
  72. package/templates/optional/skills/browser-e2e/.claude/skills/wf-browser/SKILL.md +193 -193
  73. package/templates/optional/skills/browser-e2e/Harness/workflows/browser-e2e.md +48 -48
  74. package/templates/optional/skills/github-pr-review/Harness/workflows/github-pr-review.md +28 -28
  75. package/templates/optional/skills/python-backend/Harness/workflows/python-backend.md +34 -34
  76. package/templates/optional/skills/ts-react-frontend/Harness/workflows/ts-react-frontend.md +34 -34
  77. package/templates/optional/skills/ui-ux-review/Harness/workflows/ui-ux-review.md +26 -26
@@ -1,33 +1,33 @@
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
-
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
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const ROOT = process.env.WF_ROOT ? resolve(process.env.WF_ROOT) : resolve(__dirname, '..', '..');
25
25
  const VERSION_FILE = resolve(ROOT, 'Harness', '.harness-version');
26
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. */
27
+
28
+ // ── Classification constants ────────────────────────────────────────
29
+
30
+ /** Files we NEVER delete, even if dead. */
31
31
  const PRESERVE_PATTERNS = [
32
32
  /^Harness\/PROGRESS\.md$/,
33
33
  /^Harness\/tasks\//,
@@ -41,34 +41,34 @@ const PRESERVE_PATTERNS = [
41
41
  /^package\.json$/,
42
42
  /^package-lock\.json$/,
43
43
  ];
44
-
45
- /** Directories scanned for orphan files (untracked by either checksum set). */
44
+
45
+ /** Directories scanned for orphan files (untracked by either checksum set). */
46
46
  const FRAMEWORK_DIRS = [
47
47
  '.claude/agents',
48
48
  '.claude/commands',
49
49
  '.claude/skills',
50
50
  '.claude/rules',
51
51
  '.agents/skills',
52
- '.codex',
53
- 'Harness/scripts',
54
- 'Harness/workflows',
55
- ];
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. */
52
+ '.codex',
53
+ 'Harness/scripts',
54
+ 'Harness/workflows',
55
+ ];
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
72
  function canonicalPath(file) {
73
73
  return file.replace(/\\/g, '/').replace(/\/+/g, '/');
74
74
  }
@@ -84,8 +84,8 @@ function readFlagValue(args, flagName) {
84
84
  function normalizeSourceBase(sourceBase) {
85
85
  return sourceBase.endsWith('/') ? sourceBase : sourceBase + '/';
86
86
  }
87
-
88
- /** Check whether a canonical path matches any PRESERVE pattern. */
87
+
88
+ /** Check whether a canonical path matches any PRESERVE pattern. */
89
89
  function isPreserved(canonical) {
90
90
  for (const p of PRESERVE_PATTERNS) {
91
91
  if (p.test(canonical)) return true;
@@ -111,208 +111,208 @@ function isInstalledOptionalFile(file, localVersion) {
111
111
  const skillId = optionalSkillFromSource(localVersion?.sources?.[file]);
112
112
  return Boolean(skillId && selectedOptionIds(localVersion).has(skillId));
113
113
  }
114
-
115
- /** Detect template placeholders in remote content. */
116
- function isTemplate(raw) {
117
- return /\{\{[a-zA-Z]+\}\}/.test(raw);
118
- }
119
-
114
+
115
+ /** Detect template placeholders in remote content. */
116
+ function isTemplate(raw) {
117
+ return /\{\{[a-zA-Z]+\}\}/.test(raw);
118
+ }
119
+
120
120
  async function fetchRemote(url, timeoutMs = 30000) {
121
121
  if (url.startsWith('file://')) {
122
122
  return readFileSync(fileURLToPath(url), 'utf-8');
123
123
  }
124
124
 
125
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);
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
257
  const clean = args.includes('--clean');
258
258
  const yes = args.includes('--yes');
259
259
  const jsonOut = args.includes('--json');
260
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 {
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
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 = [];
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
316
  for (const file of Object.keys(localChecksums)) {
317
317
  const canonical = canonicalPath(file);
318
318
  if (remoteChecksums[file]) continue; // still in remote, not dead
@@ -320,177 +320,177 @@ async function main() {
320
320
  if (isInstalledOptionalFile(file, localVersion)) continue; // installed optional workflow/skill
321
321
  deadFiles.push({ file, reason: 'not in remote template' });
322
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); });
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); });