@kodevibe/harness 0.11.0 → 0.11.2

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/src/init.js CHANGED
@@ -3,9 +3,13 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const readline = require('node:readline');
6
+ const crypto = require('node:crypto');
6
7
 
7
8
  const HARNESS_DIR = path.join(__dirname, '..', 'harness');
9
+ const MANIFEST_PATH = '.harness/install-manifest.json';
8
10
  let currentBackupTimestamp = null;
11
+ let currentInstallRecords = null;
12
+ let currentInstallPatches = null;
9
13
 
10
14
  // ─── Template reader ─────────────────────────────────────────
11
15
  function readTemplate(name) {
@@ -23,12 +27,77 @@ function getBackupTimestamp() {
23
27
  return currentBackupTimestamp;
24
28
  }
25
29
 
30
+ function toPosixPath(filePath) {
31
+ return filePath.split(path.sep).join('/');
32
+ }
33
+
34
+ function hashContent(content) {
35
+ return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
36
+ }
37
+
38
+ function hashFile(fullPath) {
39
+ return hashContent(fs.readFileSync(fullPath));
40
+ }
41
+
42
+ function isStatePath(relPath) {
43
+ return STATE_FILES.some(file => relPath === `docs/${file}` || relPath === `.harness/${file}`)
44
+ || AGENT_MEMORY_FILES.some(file => relPath === `docs/${file}` || relPath === `.harness/${file}`);
45
+ }
46
+
47
+ function classifyManagedPath(relPath) {
48
+ if (isStatePath(relPath)) return 'state';
49
+ if (relPath === '.gitignore' || relPath === '.gitattributes') return 'team-helper';
50
+ if (relPath === 'CLAUDE.md' || relPath === 'AGENTS.md') return 'root-dispatcher';
51
+ if (relPath.includes('/skills/')) return 'skill';
52
+ if (relPath.includes('/agents/') || relPath.includes('/rules/')) return 'agent-or-rule';
53
+ return 'ide-config';
54
+ }
55
+
56
+ function isProtectedPath(relPath) {
57
+ return isStatePath(relPath) || classifyManagedPath(relPath) === 'team-helper';
58
+ }
59
+
60
+ function fileHasFrameworkMarker(fullPath) {
61
+ try {
62
+ const stat = fs.lstatSync(fullPath);
63
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
64
+ return hasFrameworkMarker(fs.readFileSync(fullPath, 'utf8'));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function startInstallRecording() {
71
+ currentInstallRecords = [];
72
+ currentInstallPatches = [];
73
+ }
74
+
75
+ function stopInstallRecording() {
76
+ const records = currentInstallRecords || [];
77
+ const patches = currentInstallPatches || [];
78
+ currentInstallRecords = null;
79
+ currentInstallPatches = null;
80
+ return { records, patches };
81
+ }
82
+
26
83
  // ─── File writer (mkdir -p + conflict check) ─────────────────
27
84
  function writeFile(targetDir, relPath, content, overwrite) {
28
85
  const fullPath = path.join(targetDir, relPath);
29
86
  const exists = fs.existsSync(fullPath);
87
+ const hashBefore = exists ? hashFile(fullPath) : null;
30
88
  if (exists && !overwrite) {
31
89
  console.log(` ⏭ Skipped (exists): ${relPath}`);
90
+ if (currentInstallRecords && relPath !== MANIFEST_PATH) {
91
+ currentInstallRecords.push({
92
+ path: toPosixPath(relPath),
93
+ type: classifyManagedPath(relPath),
94
+ action: 'skipped',
95
+ protected: isProtectedPath(relPath),
96
+ hashBefore,
97
+ hashAfter: hashBefore,
98
+ backupPath: null,
99
+ });
100
+ }
32
101
  return false;
33
102
  }
34
103
  let backupRelPath = null;
@@ -42,8 +111,20 @@ function writeFile(targetDir, relPath, content, overwrite) {
42
111
  }
43
112
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
44
113
  fs.writeFileSync(fullPath, content, 'utf8');
114
+ const hashAfter = hashFile(fullPath);
45
115
  const backupNote = backupRelPath ? ` (backup: ${backupRelPath})` : '';
46
116
  console.log(` ${exists ? '↻' : '✓'} ${relPath}${backupNote}`);
117
+ if (currentInstallRecords && relPath !== MANIFEST_PATH) {
118
+ currentInstallRecords.push({
119
+ path: toPosixPath(relPath),
120
+ type: classifyManagedPath(relPath),
121
+ action: exists ? 'overwritten' : 'created',
122
+ protected: isProtectedPath(relPath),
123
+ hashBefore,
124
+ hashAfter,
125
+ backupPath: backupRelPath ? toPosixPath(backupRelPath) : null,
126
+ });
127
+ }
47
128
  return true;
48
129
  }
49
130
 
@@ -55,6 +136,7 @@ const SKILLS = [
55
136
  { id: 'debug', desc: 'Investigate and diagnose issues. Use when debugging or analyzing unexpected behavior.' },
56
137
  { id: 'check-impact', desc: 'Assess change blast radius. Use when modifying shared modules or interfaces.' },
57
138
  { id: 'breakdown', desc: 'Break down features into implementable stories. Use when planning new features.' },
139
+ { id: 'docs-bridge', desc: 'Connect project-local documentation to a wiki or docs hub through a safe local index while preserving original sources and visibility boundaries.' },
58
140
  { id: 'setup', desc: 'Onboard project into kode:harness. Scans codebase and fills state files. Use after harness init or when state files are empty.' },
59
141
  { id: 'wrap-up', desc: 'Capture session lessons and update state files. Use at the end of every session.' },
60
142
  { id: 'pivot', desc: 'Propagate direction changes across all state files. Use when project goals, technology, scope, or architecture changes.' },
@@ -90,6 +172,11 @@ const PERSONAL_DIRS = ['agent-memory/'];
90
172
 
91
173
  const STATE_DEST_DIR = 'docs';
92
174
  const PERSONAL_DEST_DIR = '.harness';
175
+ const TEAM_GITIGNORE_ENTRY = '\n# kode:harness personal state (Team mode)\n.harness/\n';
176
+ const TEAM_GITATTRIBUTES_CONTENT =
177
+ '# kode:harness Team mode — merge strategy for shared state files\n' +
178
+ 'docs/features.md merge=union\n' +
179
+ 'docs/dependency-map.md merge=union\n';
93
180
 
94
181
  function hasFrameworkMarker(content) {
95
182
  return content.includes('kode:harness')
@@ -451,16 +538,47 @@ async function promptMode() {
451
538
  // ─── Team mode helpers ───────────────────────────────────────
452
539
  function appendGitignore(targetDir) {
453
540
  const gitignorePath = path.join(targetDir, '.gitignore');
454
- const entry = '\n# kode:harness personal state (Team mode)\n.harness/\n';
541
+ const entry = TEAM_GITIGNORE_ENTRY;
455
542
  if (fs.existsSync(gitignorePath)) {
456
543
  const content = fs.readFileSync(gitignorePath, 'utf8');
457
544
  if (content.includes('.harness/')) {
458
545
  console.log(' ⏭ Skipped (exists): .gitignore entry');
546
+ if (currentInstallPatches) {
547
+ currentInstallPatches.push({
548
+ path: '.gitignore',
549
+ action: 'skipped',
550
+ type: 'team-helper',
551
+ block: entry,
552
+ hashBefore: hashFile(gitignorePath),
553
+ hashAfter: hashFile(gitignorePath),
554
+ });
555
+ }
459
556
  return;
460
557
  }
461
558
  fs.appendFileSync(gitignorePath, entry);
559
+ if (currentInstallPatches) {
560
+ currentInstallPatches.push({
561
+ path: '.gitignore',
562
+ action: 'appended',
563
+ type: 'team-helper',
564
+ block: entry,
565
+ hashBefore: hashContent(content),
566
+ hashAfter: hashFile(gitignorePath),
567
+ });
568
+ }
462
569
  } else {
463
570
  fs.writeFileSync(gitignorePath, entry.trimStart());
571
+ if (currentInstallRecords) {
572
+ currentInstallRecords.push({
573
+ path: '.gitignore',
574
+ type: 'team-helper',
575
+ action: 'created',
576
+ protected: true,
577
+ hashBefore: null,
578
+ hashAfter: hashFile(gitignorePath),
579
+ backupPath: null,
580
+ });
581
+ }
464
582
  }
465
583
  console.log(' ✓ .gitignore — added .harness/');
466
584
  }
@@ -500,11 +618,72 @@ function detectExistingInstall(targetDir) {
500
618
  }
501
619
 
502
620
  function writeGitattributes(targetDir) {
503
- const content =
504
- '# kode:harness Team mode — merge strategy for shared state files\n' +
505
- 'docs/features.md merge=union\n' +
506
- 'docs/dependency-map.md merge=union\n';
507
- writeFile(targetDir, '.gitattributes', content, false);
621
+ writeFile(targetDir, '.gitattributes', TEAM_GITATTRIBUTES_CONTENT, false);
622
+ }
623
+
624
+ function readPackageVersion() {
625
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
626
+ return pkg.version;
627
+ }
628
+
629
+ function readManifest(targetDir) {
630
+ const manifestFile = path.join(targetDir, MANIFEST_PATH);
631
+ if (!fs.existsSync(manifestFile)) return null;
632
+ try {
633
+ return JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
638
+
639
+ function writeManifest(targetDir, manifest) {
640
+ const manifestFile = path.join(targetDir, MANIFEST_PATH);
641
+ fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
642
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
643
+ }
644
+
645
+ function saveInstallManifest(targetDir, runRecord) {
646
+ const manifest = readManifest(targetDir) || {
647
+ schemaVersion: 1,
648
+ tool: '@kodevibe/harness',
649
+ packageVersion: readPackageVersion(),
650
+ runs: [],
651
+ };
652
+ manifest.schemaVersion = 1;
653
+ manifest.tool = '@kodevibe/harness';
654
+ manifest.packageVersion = readPackageVersion();
655
+ manifest.updatedAt = new Date().toISOString();
656
+ manifest.runs = Array.isArray(manifest.runs) ? manifest.runs : [];
657
+ manifest.runs.push(runRecord);
658
+ writeManifest(targetDir, manifest);
659
+ console.log(` ✓ ${MANIFEST_PATH}`);
660
+ }
661
+
662
+ function isSafeRelativePath(relPath) {
663
+ if (!relPath || path.isAbsolute(relPath)) return false;
664
+ const parts = relPath.split(/[\\/]+/);
665
+ return !parts.includes('..') && !parts.includes('');
666
+ }
667
+
668
+ function safeResolve(targetDir, relPath) {
669
+ const normalized = toPosixPath(relPath);
670
+ if (!isSafeRelativePath(normalized)) {
671
+ throw new Error(`Unsafe path in manifest: ${relPath}`);
672
+ }
673
+ const root = path.resolve(targetDir);
674
+ const resolved = path.resolve(root, normalized);
675
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
676
+ throw new Error(`Path escapes target directory: ${relPath}`);
677
+ }
678
+ return resolved;
679
+ }
680
+
681
+ function isDirectoryEmpty(dirPath) {
682
+ try {
683
+ return fs.readdirSync(dirPath).length === 0;
684
+ } catch {
685
+ return false;
686
+ }
508
687
  }
509
688
 
510
689
  // ─── Post-install guide ──────────────────────────────────────
@@ -686,6 +865,516 @@ function runValidate(targetDir) {
686
865
  return warnings === 0;
687
866
  }
688
867
 
868
+ function getKnownIdeFiles(ide) {
869
+ const skillIds = SKILLS.map(skill => skill.id);
870
+ const agentIds = AGENTS.map(agent => agent.id);
871
+ const skillFiles = (base) => skillIds.map(id => `${base}/${id}/SKILL.md`);
872
+ const filesByIde = {
873
+ vscode: [
874
+ '.github/copilot-instructions.md',
875
+ ...skillFiles('.github/skills'),
876
+ ...agentIds.map(id => `.github/agents/${id}.agent.md`),
877
+ ],
878
+ claude: [
879
+ 'CLAUDE.md',
880
+ '.claude/rules/core.md',
881
+ ...skillFiles('.claude/skills'),
882
+ ...agentIds.map(id => `.claude/agents/${id}.md`),
883
+ ],
884
+ cursor: [
885
+ '.cursor/rules/core.mdc',
886
+ 'AGENTS.md',
887
+ ...skillFiles('.agents/skills'),
888
+ ...agentIds.map(id => `.cursor/rules/${id}.mdc`),
889
+ ],
890
+ codex: [
891
+ 'AGENTS.md',
892
+ ...skillFiles('.agents/skills'),
893
+ ...agentIds.map(id => `.codex/agents/${id}.toml`),
894
+ ],
895
+ windsurf: [
896
+ '.windsurf/rules/core.md',
897
+ ...skillFiles('.windsurf/skills'),
898
+ ...agentIds.map(id => `.windsurf/skills/${id}/SKILL.md`),
899
+ ],
900
+ antigravity: [
901
+ 'AGENTS.md',
902
+ '.agents/rules/core.md',
903
+ ...skillFiles('.agents/skills'),
904
+ ...agentIds.map(id => `.agents/rules/${id}.md`),
905
+ ],
906
+ };
907
+ return filesByIde[ide] || [];
908
+ }
909
+
910
+ function getLegacyRuns(targetDir, ideFilter) {
911
+ const ides = Object.keys(GENERATORS).filter(ide => (ideFilter ? ide === ideFilter : hasIdeLayout(targetDir, ide)));
912
+ return ides
913
+ .filter(ide => hasIdeLayout(targetDir, ide))
914
+ .map(ide => ({
915
+ runId: `legacy-${ide}`,
916
+ ide,
917
+ mode: 'unknown',
918
+ crew: false,
919
+ status: 'installed',
920
+ legacy: true,
921
+ files: getKnownIdeFiles(ide)
922
+ .filter(relPath => fs.existsSync(path.join(targetDir, relPath)))
923
+ .filter(relPath => fileHasFrameworkMarker(path.join(targetDir, relPath)))
924
+ .map(relPath => {
925
+ const fullPath = path.join(targetDir, relPath);
926
+ return {
927
+ path: relPath,
928
+ type: classifyManagedPath(relPath),
929
+ action: 'legacy',
930
+ protected: false,
931
+ hashBefore: null,
932
+ hashAfter: hashFile(fullPath),
933
+ backupPath: null,
934
+ };
935
+ }),
936
+ patches: [],
937
+ }));
938
+ }
939
+
940
+ function getInstalledRuns(targetDir, manifest) {
941
+ const manifestRuns = manifest && Array.isArray(manifest.runs)
942
+ ? manifest.runs.filter(run => run && run.status !== 'uninstalled')
943
+ : [];
944
+ const manifestIdes = new Set(manifestRuns.map(run => run.ide).filter(Boolean));
945
+ const legacyRuns = getLegacyRuns(targetDir, null).filter(run => !manifestIdes.has(run.ide));
946
+ return [...manifestRuns, ...legacyRuns];
947
+ }
948
+
949
+ function mergeSelectedFileRecords(records) {
950
+ let candidate = null;
951
+ let restoreBackupPath = null;
952
+ let previousManagedHash = null;
953
+ let fallbackSkipped = null;
954
+ let sawManagedRecord = false;
955
+
956
+ for (const record of records) {
957
+ if (record.action === 'skipped') {
958
+ fallbackSkipped = record;
959
+ continue;
960
+ }
961
+
962
+ if (!sawManagedRecord) {
963
+ if (record.action === 'overwritten' && record.backupPath) {
964
+ restoreBackupPath = record.backupPath;
965
+ }
966
+ sawManagedRecord = true;
967
+ } else if (record.action === 'overwritten' && record.backupPath && record.hashBefore && previousManagedHash && record.hashBefore !== previousManagedHash) {
968
+ restoreBackupPath = record.backupPath;
969
+ }
970
+
971
+ previousManagedHash = record.hashAfter || previousManagedHash;
972
+ candidate = { ...record };
973
+ }
974
+
975
+ if (!candidate) return fallbackSkipped || records[records.length - 1];
976
+ candidate.backupPath = restoreBackupPath;
977
+ if (restoreBackupPath) candidate.action = 'overwritten';
978
+ else if (candidate.action === 'overwritten') candidate.action = 'created';
979
+ return candidate;
980
+ }
981
+
982
+ function getSelectedFiles(selectedRuns) {
983
+ const filesByPath = new Map();
984
+ for (const run of selectedRuns) {
985
+ for (const file of (run.files || [])) {
986
+ const relPath = toPosixPath(file.path);
987
+ if (!filesByPath.has(relPath)) filesByPath.set(relPath, []);
988
+ filesByPath.get(relPath).push({ ...file, path: relPath });
989
+ }
990
+ }
991
+ return [...filesByPath.values()].map(mergeSelectedFileRecords);
992
+ }
993
+
994
+ function getHistoricalFileRecord(manifest, relPath) {
995
+ if (!manifest || !Array.isArray(manifest.runs)) return null;
996
+ const records = [];
997
+ for (const run of manifest.runs) {
998
+ for (const file of (run.files || [])) {
999
+ if (toPosixPath(file.path) === relPath) records.push({ ...file, path: relPath });
1000
+ }
1001
+ }
1002
+ return records.length > 0 ? mergeSelectedFileRecords(records) : null;
1003
+ }
1004
+
1005
+ function getOtherOwnerRecords(activeRuns, selectedRunIds, relPath, includeProtected = false) {
1006
+ const owners = [];
1007
+ for (const run of activeRuns) {
1008
+ if (selectedRunIds.has(run.runId)) continue;
1009
+ const files = Array.isArray(run.files) ? run.files : [];
1010
+ for (const file of files) {
1011
+ const skippedProtectedOwner = includeProtected && file.protected && file.action === 'skipped';
1012
+ if (toPosixPath(file.path) === relPath && (file.action !== 'skipped' || skippedProtectedOwner) && (includeProtected || !file.protected)) {
1013
+ owners.push({ ...file, path: relPath, runId: run.runId, ide: run.ide });
1014
+ }
1015
+ }
1016
+ }
1017
+ return owners;
1018
+ }
1019
+
1020
+ function findLatestUserBackupPath(targetDir, manifest, relPath) {
1021
+ if (!manifest || !Array.isArray(manifest.runs)) return null;
1022
+ let backupPath = null;
1023
+ for (const run of manifest.runs) {
1024
+ for (const file of (run.files || [])) {
1025
+ if (toPosixPath(file.path) !== relPath || !file.backupPath) continue;
1026
+ let fullPath;
1027
+ try {
1028
+ fullPath = safeResolve(targetDir, file.backupPath);
1029
+ } catch {
1030
+ continue;
1031
+ }
1032
+ if (fs.existsSync(fullPath) && !fileHasFrameworkMarker(fullPath)) {
1033
+ backupPath = file.backupPath;
1034
+ }
1035
+ }
1036
+ }
1037
+ return backupPath;
1038
+ }
1039
+
1040
+ function getRestoreBackupPath(targetDir, manifest, file) {
1041
+ if (!file.backupPath) return null;
1042
+ const backupPath = safeResolve(targetDir, file.backupPath);
1043
+ if (!fs.existsSync(backupPath)) return null;
1044
+ if (!fileHasFrameworkMarker(backupPath)) return file.backupPath;
1045
+ return findLatestUserBackupPath(targetDir, manifest, toPosixPath(file.path));
1046
+ }
1047
+
1048
+ function shouldKeepFile(file, options) {
1049
+ if (file.action === 'skipped') return true;
1050
+ if (file.protected && !options.purgeState) return true;
1051
+ return false;
1052
+ }
1053
+
1054
+ function buildUninstallPlan(targetDir, options) {
1055
+ const manifest = readManifest(targetDir);
1056
+ const activeRuns = getInstalledRuns(targetDir, manifest);
1057
+ const activeIdes = [...new Set(activeRuns.map(run => run.ide))].filter(Boolean);
1058
+
1059
+ if (activeIdes.length === 0) {
1060
+ return { manifest, selectedRuns: [], actions: [], conflicts: [], keeps: [], warnings: ['No kode:harness install manifest or known IDE layout found.'] };
1061
+ }
1062
+
1063
+ let selectedIde = options.ide;
1064
+ if (selectedIde === 'all') selectedIde = null;
1065
+ if (selectedIde && !GENERATORS[selectedIde]) {
1066
+ throw new Error(`Unknown IDE: ${selectedIde}`);
1067
+ }
1068
+
1069
+ if (!options.all && !selectedIde) {
1070
+ if (activeIdes.length > 1) {
1071
+ throw new Error(`Multiple IDE installs detected (${activeIdes.join(', ')}). Pass --ide <name> or --all.`);
1072
+ }
1073
+ selectedIde = activeIdes[0];
1074
+ }
1075
+
1076
+ const selectedRuns = activeRuns.filter(run => options.all || run.ide === selectedIde);
1077
+ const selectedRunIds = new Set(selectedRuns.map(run => run.runId));
1078
+ const hasUnselectedRuns = activeRuns.some(run => !selectedRunIds.has(run.runId));
1079
+ const actions = [];
1080
+ const keeps = [];
1081
+ const conflicts = [];
1082
+ const warnings = [];
1083
+
1084
+ if (selectedRuns.length === 0) {
1085
+ warnings.push(selectedIde ? `No active ${selectedIde} install found.` : 'No active install selected.');
1086
+ }
1087
+
1088
+ for (let file of getSelectedFiles(selectedRuns)) {
1089
+ const relPath = toPosixPath(file.path);
1090
+
1091
+ if (file.protected && options.purgeState && hasUnselectedRuns) {
1092
+ keeps.push({ path: relPath, reason: 'shared-with-other-install' });
1093
+ continue;
1094
+ }
1095
+
1096
+ if (file.protected && options.purgeState && file.action === 'skipped' && !hasUnselectedRuns) {
1097
+ const historicalFile = getHistoricalFileRecord(manifest, relPath);
1098
+ if (historicalFile && historicalFile.action !== 'skipped') file = historicalFile;
1099
+ }
1100
+
1101
+ if (shouldKeepFile(file, options)) {
1102
+ keeps.push({ path: relPath, reason: file.protected ? 'state-preserved' : 'not-owned' });
1103
+ continue;
1104
+ }
1105
+
1106
+ const fullPath = safeResolve(targetDir, relPath);
1107
+ if (!fs.existsSync(fullPath)) {
1108
+ keeps.push({ path: relPath, reason: 'already-missing' });
1109
+ continue;
1110
+ }
1111
+
1112
+ const stat = fs.lstatSync(fullPath);
1113
+ if (stat.isDirectory()) {
1114
+ keeps.push({ path: relPath, reason: 'directory-entry-skipped' });
1115
+ continue;
1116
+ }
1117
+ if (stat.isSymbolicLink()) {
1118
+ conflicts.push({ path: relPath, reason: 'symlink-skipped' });
1119
+ continue;
1120
+ }
1121
+
1122
+ const currentHash = hashFile(fullPath);
1123
+ const otherOwners = getOtherOwnerRecords(activeRuns, selectedRunIds, relPath, file.protected && options.purgeState);
1124
+ if (otherOwners.length > 0) {
1125
+ const latestOtherOwner = otherOwners[otherOwners.length - 1];
1126
+ if (file.protected) {
1127
+ keeps.push({ path: relPath, reason: `shared-with-${otherOwners.length}-other-install${otherOwners.length > 1 ? 's' : ''}` });
1128
+ continue;
1129
+ }
1130
+ if (!latestOtherOwner.hashAfter || currentHash === latestOtherOwner.hashAfter) {
1131
+ keeps.push({ path: relPath, reason: `shared-with-${otherOwners.length}-other-install${otherOwners.length > 1 ? 's' : ''}` });
1132
+ continue;
1133
+ }
1134
+ if (file.backupPath) {
1135
+ const backupPath = safeResolve(targetDir, file.backupPath);
1136
+ if (fs.existsSync(backupPath) && hashFile(backupPath) === latestOtherOwner.hashAfter) {
1137
+ actions.push({ type: 'restore', path: relPath, backupPath: file.backupPath });
1138
+ continue;
1139
+ }
1140
+ }
1141
+ conflicts.push({ path: relPath, reason: 'shared-content-mismatch' });
1142
+ continue;
1143
+ }
1144
+
1145
+ const unchanged = !file.hashAfter || currentHash === file.hashAfter;
1146
+ const canUseLegacyMarker = file.action === 'legacy' && hasFrameworkMarker(fs.readFileSync(fullPath, 'utf8'));
1147
+ if (!unchanged && !options.force && !canUseLegacyMarker) {
1148
+ conflicts.push({ path: relPath, reason: 'modified-since-install' });
1149
+ continue;
1150
+ }
1151
+
1152
+ if (file.action === 'overwritten' && file.backupPath) {
1153
+ const backupPath = getRestoreBackupPath(targetDir, manifest, file);
1154
+ if (backupPath) {
1155
+ actions.push({ type: 'restore', path: relPath, backupPath });
1156
+ continue;
1157
+ }
1158
+ const originalBackupPath = safeResolve(targetDir, file.backupPath);
1159
+ if (!fs.existsSync(originalBackupPath)) {
1160
+ warnings.push(`Backup missing for ${relPath}; deleting generated file instead.`);
1161
+ }
1162
+ }
1163
+
1164
+ actions.push({ type: 'delete', path: relPath });
1165
+ }
1166
+
1167
+ if (options.purgeState) {
1168
+ if (hasUnselectedRuns) {
1169
+ warnings.push('State and team helper patches are preserved because other active installs remain. Use --all to purge them together.');
1170
+ }
1171
+ for (const run of selectedRuns) {
1172
+ for (const patch of (run.patches || [])) {
1173
+ if (patch.action !== 'appended') continue;
1174
+ if (hasUnselectedRuns) {
1175
+ keeps.push({ path: patch.path, reason: 'shared-with-other-install' });
1176
+ continue;
1177
+ }
1178
+ const fullPath = safeResolve(targetDir, patch.path);
1179
+ if (fs.existsSync(fullPath)) {
1180
+ actions.push({ type: 'remove-block', path: patch.path, block: patch.block });
1181
+ }
1182
+ }
1183
+ }
1184
+ if (!hasUnselectedRuns) {
1185
+ const helperBlocks = [
1186
+ { path: '.gitignore', block: TEAM_GITIGNORE_ENTRY },
1187
+ { path: '.gitattributes', block: TEAM_GITATTRIBUTES_CONTENT },
1188
+ ];
1189
+ for (const helper of helperBlocks) {
1190
+ if (actions.some(action => action.path === helper.path)) continue;
1191
+ const fullPath = safeResolve(targetDir, helper.path);
1192
+ if (!fs.existsSync(fullPath)) continue;
1193
+ const content = fs.readFileSync(fullPath, 'utf8');
1194
+ if (content.includes(helper.block) || content.includes(helper.block.trimStart())) {
1195
+ actions.push({ type: 'remove-block', path: helper.path, block: helper.block });
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ if (options.purgeBackups) {
1202
+ if (hasUnselectedRuns) {
1203
+ warnings.push('Backup purge skipped because other active installs remain. Use --all to purge backups after all installs are removed.');
1204
+ } else {
1205
+ for (const relPath of ['.harness/init-backups', '.harness/uninstall-backups']) {
1206
+ actions.push({ type: 'delete-tree', path: relPath });
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ return { manifest, selectedRuns, actions, conflicts, keeps, warnings };
1212
+ }
1213
+
1214
+ function backupBeforeUninstall(targetDir, backupRoot, relPath) {
1215
+ const source = safeResolve(targetDir, relPath);
1216
+ if (!fs.existsSync(source)) return null;
1217
+ const stat = fs.lstatSync(source);
1218
+ if (!stat.isFile()) return null;
1219
+ const backupPath = path.join(backupRoot, relPath);
1220
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1221
+ fs.copyFileSync(source, backupPath);
1222
+ return toPosixPath(path.relative(targetDir, backupPath));
1223
+ }
1224
+
1225
+ function removeEmptyParents(targetDir, relPath) {
1226
+ let current = path.dirname(safeResolve(targetDir, relPath));
1227
+ const root = path.resolve(targetDir);
1228
+ while (current !== root && current.startsWith(root + path.sep)) {
1229
+ if (!fs.existsSync(current) || !isDirectoryEmpty(current)) break;
1230
+ fs.rmdirSync(current);
1231
+ current = path.dirname(current);
1232
+ }
1233
+ }
1234
+
1235
+ function applyUninstallPlan(targetDir, plan, options) {
1236
+ const uninstallRunId = getBackupTimestamp();
1237
+ const backupRoot = path.join(targetDir, '.harness', 'uninstall-backups', uninstallRunId);
1238
+ const applied = [];
1239
+
1240
+ for (const action of plan.actions) {
1241
+ if (action.type === 'delete') {
1242
+ const fullPath = safeResolve(targetDir, action.path);
1243
+ if (!fs.existsSync(fullPath)) continue;
1244
+ backupBeforeUninstall(targetDir, backupRoot, action.path);
1245
+ fs.unlinkSync(fullPath);
1246
+ removeEmptyParents(targetDir, action.path);
1247
+ applied.push(action);
1248
+ } else if (action.type === 'restore') {
1249
+ const fullPath = safeResolve(targetDir, action.path);
1250
+ const backupPath = safeResolve(targetDir, action.backupPath);
1251
+ backupBeforeUninstall(targetDir, backupRoot, action.path);
1252
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
1253
+ fs.copyFileSync(backupPath, fullPath);
1254
+ applied.push(action);
1255
+ } else if (action.type === 'remove-block') {
1256
+ const fullPath = safeResolve(targetDir, action.path);
1257
+ if (!fs.existsSync(fullPath)) continue;
1258
+ const content = fs.readFileSync(fullPath, 'utf8');
1259
+ let updated = content;
1260
+ if (updated.includes(action.block)) updated = updated.replace(action.block, '');
1261
+ else if (updated.includes(action.block.trimStart())) updated = updated.replace(action.block.trimStart(), '');
1262
+ else continue;
1263
+ backupBeforeUninstall(targetDir, backupRoot, action.path);
1264
+ if (updated.trim() === '') {
1265
+ fs.unlinkSync(fullPath);
1266
+ removeEmptyParents(targetDir, action.path);
1267
+ } else {
1268
+ fs.writeFileSync(fullPath, updated, 'utf8');
1269
+ }
1270
+ applied.push(action);
1271
+ } else if (action.type === 'delete-tree') {
1272
+ const fullPath = safeResolve(targetDir, action.path);
1273
+ if (!fs.existsSync(fullPath)) continue;
1274
+ fs.rmSync(fullPath, { recursive: true, force: true });
1275
+ applied.push(action);
1276
+ }
1277
+ }
1278
+
1279
+ if (plan.manifest && Array.isArray(plan.manifest.runs)) {
1280
+ const selectedRunIds = new Set(plan.selectedRuns.map(run => run.runId));
1281
+ const purgesUninstallBackups = plan.actions.some(action => action.type === 'delete-tree' && action.path === '.harness/uninstall-backups');
1282
+ for (const run of plan.manifest.runs) {
1283
+ if (selectedRunIds.has(run.runId)) {
1284
+ run.status = 'uninstalled';
1285
+ run.uninstalledAt = new Date().toISOString();
1286
+ run.uninstallBackupPath = purgesUninstallBackups ? null : toPosixPath(path.relative(targetDir, backupRoot));
1287
+ }
1288
+ }
1289
+ plan.manifest.updatedAt = new Date().toISOString();
1290
+ const hasActiveRuns = plan.manifest.runs.some(run => run.status !== 'uninstalled');
1291
+ if (options.purgeState && options.purgeBackups && !hasActiveRuns) {
1292
+ const manifestPath = safeResolve(targetDir, MANIFEST_PATH);
1293
+ if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath);
1294
+ removeEmptyParents(targetDir, MANIFEST_PATH);
1295
+ } else {
1296
+ writeManifest(targetDir, plan.manifest);
1297
+ }
1298
+ }
1299
+
1300
+ if (fs.existsSync(backupRoot) && isDirectoryEmpty(backupRoot)) {
1301
+ fs.rmdirSync(backupRoot);
1302
+ }
1303
+
1304
+ return applied;
1305
+ }
1306
+
1307
+ function printUninstallPlan(plan, options) {
1308
+ const mode = options.dryRun ? 'dry-run' : 'apply';
1309
+ console.log(`\n kode:harness uninstall plan (${mode})\n`);
1310
+ for (const warning of plan.warnings) console.log(` WARN ${warning}`);
1311
+ for (const keep of plan.keeps) console.log(` KEEP ${keep.path} — ${keep.reason}`);
1312
+ for (const conflict of plan.conflicts) console.log(` SKIP ${conflict.path} — ${conflict.reason}`);
1313
+ for (const action of plan.actions) {
1314
+ const verb = action.type === 'restore' ? 'RESTORE' : action.type === 'remove-block' ? 'PATCH' : 'REMOVE';
1315
+ console.log(` ${verb.padEnd(7)}${action.path}`);
1316
+ }
1317
+ console.log(`\n Summary: ${plan.actions.length} action(s), ${plan.keeps.length} kept, ${plan.conflicts.length} conflict(s)\n`);
1318
+ }
1319
+
1320
+ async function runUninstall(args) {
1321
+ const options = {
1322
+ ide: args.ide,
1323
+ all: args.all,
1324
+ dryRun: args.dryRun,
1325
+ yes: args.yes,
1326
+ purgeState: args.purgeState,
1327
+ purgeBackups: args.purgeBackups,
1328
+ force: args.force,
1329
+ };
1330
+
1331
+ let plan;
1332
+ try {
1333
+ plan = buildUninstallPlan(args.dir, options);
1334
+ } catch (error) {
1335
+ console.error(` ${error.message}`);
1336
+ process.exit(1);
1337
+ }
1338
+
1339
+ if (args.json) {
1340
+ console.log(JSON.stringify(plan, null, 2));
1341
+ } else {
1342
+ printUninstallPlan(plan, options);
1343
+ }
1344
+
1345
+ if (options.dryRun || plan.actions.length === 0) return true;
1346
+
1347
+ if (!options.yes) {
1348
+ if (!process.stdin.isTTY) {
1349
+ console.error(' Refusing to uninstall non-interactively without --yes. Re-run with --dry-run to preview or --yes to apply.');
1350
+ process.exit(1);
1351
+ }
1352
+ const answer = await askQuestion(' Apply this uninstall plan? (y/N): ');
1353
+ if (!['y', 'yes'].includes(answer.toLowerCase())) {
1354
+ console.log(' Uninstall cancelled.');
1355
+ process.exit(2);
1356
+ }
1357
+ }
1358
+
1359
+ if (options.purgeState && !options.yes && process.stdin.isTTY) {
1360
+ const answer = await askQuestion(' Type "delete state" to confirm state deletion: ');
1361
+ if (answer !== 'delete state') {
1362
+ console.log(' State purge cancelled.');
1363
+ process.exit(2);
1364
+ }
1365
+ }
1366
+
1367
+ try {
1368
+ const applied = applyUninstallPlan(args.dir, plan, options);
1369
+ if (!args.json) console.log(` Applied ${applied.length} action(s).`);
1370
+ if (plan.conflicts.length > 0) process.exit(3);
1371
+ return true;
1372
+ } catch (error) {
1373
+ console.error(` Uninstall failed: ${error.message}`);
1374
+ process.exit(3);
1375
+ }
1376
+ }
1377
+
689
1378
  // ─── CLI entry ───────────────────────────────────────────────
690
1379
  function showHelp() {
691
1380
  console.log(`
@@ -695,17 +1384,26 @@ function showHelp() {
695
1384
  npx @kodevibe/harness init [options]
696
1385
  npx @kodevibe/harness doctor [--dir <path>]
697
1386
  npx @kodevibe/harness validate [--dir <path>]
1387
+ npx @kodevibe/harness uninstall [options]
698
1388
 
699
1389
  Commands:
700
1390
  init Install kode:harness files for your IDE
701
1391
  doctor Check if kode:harness files are installed and healthy
702
1392
  validate Verify state files have content (not just placeholders)
1393
+ uninstall Safely remove kode:harness IDE files (state preserved by default)
703
1394
 
704
1395
  Options:
705
1396
  --ide <name> IDE target: vscode, claude, cursor, codex, windsurf, antigravity
1397
+ --all Uninstall all detected IDE layouts
706
1398
  --mode <mode> Project mode: solo (default) or team
707
1399
  --dir <path> Target directory (default: current directory)
708
1400
  --overwrite Overwrite existing files (including state files)
1401
+ --dry-run Print uninstall plan without changing files
1402
+ --yes Confirm uninstall non-interactively
1403
+ --purge-state Also remove generated state files when unchanged
1404
+ --purge-backups Also remove kode:harness backup directories
1405
+ --force Apply despite changed managed files
1406
+ --json Print machine-readable uninstall plan
709
1407
  --batch Non-interactive mode (requires --ide; defaults to solo mode)
710
1408
  --version Show version number
711
1409
  --help Show this help
@@ -717,16 +1415,36 @@ function showHelp() {
717
1415
  npx @kodevibe/harness init --ide claude --dir ./my-project
718
1416
  npx @kodevibe/harness doctor
719
1417
  npx @kodevibe/harness validate
1418
+ npx @kodevibe/harness uninstall --ide claude --dry-run
1419
+ npx @kodevibe/harness uninstall --ide claude --yes
720
1420
  `);
721
1421
  }
722
1422
 
723
1423
  function parseArgs(argv) {
724
- const args = { command: null, ide: null, mode: null, dir: process.cwd(), overwrite: false, help: false, batch: false, version: false, crew: false };
1424
+ const args = {
1425
+ command: null,
1426
+ ide: null,
1427
+ mode: null,
1428
+ dir: process.cwd(),
1429
+ overwrite: false,
1430
+ help: false,
1431
+ batch: false,
1432
+ version: false,
1433
+ crew: false,
1434
+ all: false,
1435
+ dryRun: false,
1436
+ yes: false,
1437
+ purgeState: false,
1438
+ purgeBackups: false,
1439
+ force: false,
1440
+ json: false,
1441
+ };
725
1442
  for (let i = 0; i < argv.length; i++) {
726
1443
  const arg = argv[i];
727
1444
  if (arg === 'init') args.command = 'init';
728
1445
  else if (arg === 'doctor') args.command = 'doctor';
729
1446
  else if (arg === 'validate') args.command = 'validate';
1447
+ else if (arg === 'uninstall') args.command = 'uninstall';
730
1448
  else if (arg === '--ide' && argv[i + 1]) { args.ide = argv[++i]; }
731
1449
  else if (arg === '--mode' && argv[i + 1]) { args.mode = argv[++i]; }
732
1450
  else if (arg === '--team') { args.mode = 'team'; }
@@ -734,6 +1452,13 @@ function parseArgs(argv) {
734
1452
  else if (arg === '--dir' && argv[i + 1]) { args.dir = path.resolve(argv[++i]); }
735
1453
  else if (arg === '--overwrite') args.overwrite = true;
736
1454
  else if (arg === '--batch') args.batch = true;
1455
+ else if (arg === '--all') args.all = true;
1456
+ else if (arg === '--dry-run') args.dryRun = true;
1457
+ else if (arg === '--yes' || arg === '-y') args.yes = true;
1458
+ else if (arg === '--purge-state' || arg === '--include-state') args.purgeState = true;
1459
+ else if (arg === '--purge-backups') args.purgeBackups = true;
1460
+ else if (arg === '--force') args.force = true;
1461
+ else if (arg === '--json') args.json = true;
737
1462
  else if (arg === '--help' || arg === '-h') args.help = true;
738
1463
  else if (arg === '--version') args.version = true;
739
1464
  }
@@ -764,6 +1489,11 @@ async function run(argv) {
764
1489
  process.exit(ok ? 0 : 1);
765
1490
  }
766
1491
 
1492
+ if (args.command === 'uninstall') {
1493
+ await runUninstall(args);
1494
+ return;
1495
+ }
1496
+
767
1497
  if (args.command === 'init') {
768
1498
  console.log('\n kode:harness — Harness Engineering\n');
769
1499
 
@@ -833,6 +1563,8 @@ async function run(argv) {
833
1563
  const modeDesc = crew ? `${mode} + crew` : mode;
834
1564
  console.log(`\n Installing for ${gen.name} (${modeDesc} mode)... (detected language: ${lang})\n`);
835
1565
  resetBackupTimestamp();
1566
+ const runId = getBackupTimestamp();
1567
+ startInstallRecording();
836
1568
  gen.fn(args.dir, overwrite, mode, crew);
837
1569
 
838
1570
  // Team mode extras
@@ -841,8 +1573,22 @@ async function run(argv) {
841
1573
  writeGitattributes(args.dir);
842
1574
  }
843
1575
 
1576
+ const { records, patches } = stopInstallRecording();
1577
+ saveInstallManifest(args.dir, {
1578
+ runId,
1579
+ command: 'init',
1580
+ ide,
1581
+ mode,
1582
+ crew,
1583
+ status: 'installed',
1584
+ packageVersion: readPackageVersion(),
1585
+ createdAt: new Date().toISOString(),
1586
+ files: records,
1587
+ patches,
1588
+ });
1589
+
844
1590
  showPostInstallGuide(gen.name, mode);
845
1591
  }
846
1592
  }
847
1593
 
848
- module.exports = { run, detectLanguage, runDoctor, runValidate };
1594
+ module.exports = { run, detectLanguage, runDoctor, runValidate, buildUninstallPlan };