@tech-leads-club/harness-toolkit 0.2.1

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 (300) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/LICENSE +84 -0
  3. package/NOTICE +9 -0
  4. package/README.md +480 -0
  5. package/bin/tlc +10 -0
  6. package/bin/tlc-build +80 -0
  7. package/bin/tlc-cli.ts +1509 -0
  8. package/bin/tlc-exec +10 -0
  9. package/bin/tlc-exec.cmd +4 -0
  10. package/bin/tlc-exec.d.mts +60 -0
  11. package/bin/tlc-exec.mjs +248 -0
  12. package/bin/tlc.cmd +4 -0
  13. package/bin/tlc.mjs +7 -0
  14. package/bin/write-user-hooks.d.mts +40 -0
  15. package/bin/write-user-hooks.mjs +123 -0
  16. package/capabilities/catalog.json +418 -0
  17. package/config.example.json +51 -0
  18. package/dist/compact-before.mjs +7839 -0
  19. package/dist/doctor.mjs +8263 -0
  20. package/dist/help-topic.mjs +82 -0
  21. package/dist/init-project.mjs +993 -0
  22. package/dist/install-runtime.mjs +969 -0
  23. package/dist/lessons-cli.mjs +7258 -0
  24. package/dist/obs-cli.mjs +7025 -0
  25. package/dist/price-lookup.mjs +221 -0
  26. package/dist/prompt-submit.mjs +7860 -0
  27. package/dist/refresh-model-prices.mjs +158 -0
  28. package/dist/response-after.mjs +7879 -0
  29. package/dist/run.mjs +7830 -0
  30. package/dist/session-end.mjs +7937 -0
  31. package/dist/session-start.mjs +8056 -0
  32. package/dist/shim.mjs +52 -0
  33. package/dist/stop.mjs +8494 -0
  34. package/dist/subagent-start.mjs +7861 -0
  35. package/dist/subagent-stop.mjs +7851 -0
  36. package/dist/support.mjs +7045 -0
  37. package/dist/tlc-cli.mjs +8063 -0
  38. package/dist/tool-after.mjs +8113 -0
  39. package/dist/tool-before.mjs +7985 -0
  40. package/dist/tool-failure.mjs +7850 -0
  41. package/dist/uninstall-runtime.mjs +1307 -0
  42. package/docs/architecture.md +270 -0
  43. package/docs/concepts.md +562 -0
  44. package/docs/coverage.md +55 -0
  45. package/docs/decisions/ad-001.md +32 -0
  46. package/docs/decisions/ad-002.md +51 -0
  47. package/docs/decisions/ad-003.md +30 -0
  48. package/docs/decisions/ad-004.md +37 -0
  49. package/docs/decisions/ad-005.md +36 -0
  50. package/docs/decisions/ad-006.md +49 -0
  51. package/docs/decisions/ad-007.md +36 -0
  52. package/docs/decisions/ad-008.md +54 -0
  53. package/docs/decisions/ad-009.md +61 -0
  54. package/docs/decisions/ad-010.md +45 -0
  55. package/docs/decisions/ad-011.md +59 -0
  56. package/docs/decisions/ad-012.md +71 -0
  57. package/docs/decisions/ad-013.md +87 -0
  58. package/docs/decisions/ad-014.md +56 -0
  59. package/docs/decisions/ad-015.md +33 -0
  60. package/docs/decisions/ad-016.md +98 -0
  61. package/docs/decisions/ad-017.md +65 -0
  62. package/docs/decisions/ad-018.md +77 -0
  63. package/docs/decisions/ad-019.md +75 -0
  64. package/docs/decisions/ad-020.md +88 -0
  65. package/docs/decisions/ad-021.md +57 -0
  66. package/docs/decisions/ad-022.md +120 -0
  67. package/docs/decisions/ad-023.md +87 -0
  68. package/docs/decisions/ad-024.md +85 -0
  69. package/docs/decisions/ad-025.md +92 -0
  70. package/docs/decisions/ad-026.md +131 -0
  71. package/docs/decisions/ad-027.md +90 -0
  72. package/docs/decisions/ad-028.md +90 -0
  73. package/docs/decisions/ad-029.md +73 -0
  74. package/docs/decisions/ad-030.md +97 -0
  75. package/docs/decisions/ad-031.md +94 -0
  76. package/docs/decisions/ad-032.md +83 -0
  77. package/docs/decisions/ad-033.md +89 -0
  78. package/docs/decisions/ad-034.md +86 -0
  79. package/docs/decisions/ad-035.md +86 -0
  80. package/docs/decisions/ad-036.md +68 -0
  81. package/docs/decisions/ad-037.md +47 -0
  82. package/docs/decisions/ad-038.md +52 -0
  83. package/docs/decisions/ad-039.md +69 -0
  84. package/docs/decisions/ad-040.md +89 -0
  85. package/docs/decisions/ad-041.md +98 -0
  86. package/docs/decisions/ad-042.md +82 -0
  87. package/docs/decisions/ad-043.md +79 -0
  88. package/docs/decisions/ad-044.md +61 -0
  89. package/docs/decisions/ad-045.md +94 -0
  90. package/docs/decisions/ad-046.md +111 -0
  91. package/docs/decisions/ad-047.md +96 -0
  92. package/docs/decisions/ad-048.md +85 -0
  93. package/docs/decisions/ad-049.md +66 -0
  94. package/docs/decisions/ad-050.md +94 -0
  95. package/docs/decisions/ad-051.md +69 -0
  96. package/docs/decisions/ad-052.md +69 -0
  97. package/docs/decisions/ad-053.md +78 -0
  98. package/docs/decisions/ad-054.md +98 -0
  99. package/docs/decisions/ad-055.md +74 -0
  100. package/docs/decisions/ad-056.md +85 -0
  101. package/docs/decisions/ad-057.md +68 -0
  102. package/docs/decisions/ad-058.md +97 -0
  103. package/docs/decisions/ad-059.md +82 -0
  104. package/docs/decisions/ad-060.md +75 -0
  105. package/docs/decisions/ad-061.md +68 -0
  106. package/docs/decisions/ad-062.md +72 -0
  107. package/docs/decisions/ad-063.md +84 -0
  108. package/docs/decisions/ad-064.md +79 -0
  109. package/docs/decisions/ad-065.md +81 -0
  110. package/docs/decisions/ad-066.md +111 -0
  111. package/docs/decisions/ad-067.md +64 -0
  112. package/docs/decisions/ad-068.md +79 -0
  113. package/docs/decisions/ad-069.md +74 -0
  114. package/docs/decisions/ad-070.md +86 -0
  115. package/docs/decisions/ad-071.md +93 -0
  116. package/docs/decisions/ad-072.md +82 -0
  117. package/docs/decisions/ad-073.md +102 -0
  118. package/docs/decisions/ad-074.md +91 -0
  119. package/docs/decisions/ad-075.md +79 -0
  120. package/docs/decisions/ad-076.md +102 -0
  121. package/docs/decisions/ad-077.md +94 -0
  122. package/docs/decisions/ad-078.md +84 -0
  123. package/docs/decisions/ad-079.md +73 -0
  124. package/docs/decisions/ad-080.md +86 -0
  125. package/docs/decisions/ad-081.md +70 -0
  126. package/docs/decisions/ad-082.md +79 -0
  127. package/docs/decisions/ad-083.md +88 -0
  128. package/docs/decisions/index.md +111 -0
  129. package/docs/diagnose.md +248 -0
  130. package/docs/index.md +52 -0
  131. package/docs/init.md +101 -0
  132. package/docs/lessons.md +294 -0
  133. package/docs/log.md +139 -0
  134. package/docs/measure.md +178 -0
  135. package/docs/providers/claude-code.md +118 -0
  136. package/docs/providers/cursor.md +110 -0
  137. package/docs/providers/index.md +71 -0
  138. package/docs/troubleshooting.md +138 -0
  139. package/model-aliases.json +12 -0
  140. package/model-prices.cursor.json +410 -0
  141. package/model-prices.json +1 -0
  142. package/package.json +57 -0
  143. package/skills/harness-init/SKILL.md +241 -0
  144. package/skills/harness-init/evals/trigger_evals.json +86 -0
  145. package/skills/harness-init/references/capabilities.md +113 -0
  146. package/src/contracts/capabilities.ts +30 -0
  147. package/src/contracts/decision.ts +21 -0
  148. package/src/contracts/effort.ts +15 -0
  149. package/src/contracts/harness-event.ts +87 -0
  150. package/src/contracts/index.ts +6 -0
  151. package/src/contracts/policy-defaults.ts +14 -0
  152. package/src/contracts/wiring.ts +20 -0
  153. package/src/core/attest/attest.service.ts +0 -0
  154. package/src/core/capability/capability.service.ts +62 -0
  155. package/src/core/capability/capability.store.ts +51 -0
  156. package/src/core/capability/capability.types.ts +56 -0
  157. package/src/core/comment-policy/comment-policy.service.ts +307 -0
  158. package/src/core/comment-policy/comment-policy.types.ts +6 -0
  159. package/src/core/comment-policy/comment-resolvability.ts +134 -0
  160. package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
  161. package/src/core/comment-policy/comment-syntax.store.ts +65 -0
  162. package/src/core/comment-policy/comment-syntax.types.ts +24 -0
  163. package/src/core/core.facade.ts +461 -0
  164. package/src/core/duplication/duplication.service.ts +219 -0
  165. package/src/core/floor/floor.catalog.ts +54 -0
  166. package/src/core/floor/floor.paths.ts +90 -0
  167. package/src/core/floor/floor.policy-surface.ts +311 -0
  168. package/src/core/floor/floor.service.ts +306 -0
  169. package/src/core/floor/floor.tokenize.ts +179 -0
  170. package/src/core/floor/floor.verb.ts +28 -0
  171. package/src/core/floor/index.ts +1 -0
  172. package/src/core/gate/gate.artifact.ts +157 -0
  173. package/src/core/gate/gate.command.ts +130 -0
  174. package/src/core/gate/gate.findings.ts +201 -0
  175. package/src/core/gate/gate.inputs.ts +0 -0
  176. package/src/core/gate/gate.lock.ts +283 -0
  177. package/src/core/gate/gate.service.ts +28 -0
  178. package/src/core/gate/gate.types.ts +67 -0
  179. package/src/core/handoff/handoff.service.ts +43 -0
  180. package/src/core/handoff/handoff.store.ts +59 -0
  181. package/src/core/handoff/handoff.types.ts +89 -0
  182. package/src/core/index.ts +52 -0
  183. package/src/core/integrity/state-seal.ts +81 -0
  184. package/src/core/lesson/lesson.authored.ts +96 -0
  185. package/src/core/lesson/lesson.credit.ts +51 -0
  186. package/src/core/lesson/lesson.garden.ts +283 -0
  187. package/src/core/lesson/lesson.link.ts +93 -0
  188. package/src/core/lesson/lesson.score.ts +61 -0
  189. package/src/core/lesson/lesson.select.ts +209 -0
  190. package/src/core/lesson/lesson.service.ts +110 -0
  191. package/src/core/lesson/lesson.store.ts +317 -0
  192. package/src/core/lesson/lesson.sync.ts +70 -0
  193. package/src/core/lesson/lesson.types.ts +92 -0
  194. package/src/core/lesson/lesson.validity.ts +44 -0
  195. package/src/core/observability/observability.report.ts +326 -0
  196. package/src/core/observability/observability.service.ts +328 -0
  197. package/src/core/observability/observability.store.ts +269 -0
  198. package/src/core/observability/observability.types.ts +193 -0
  199. package/src/core/observability/observability.why.ts +206 -0
  200. package/src/core/observe/observe.service.ts +76 -0
  201. package/src/core/plan/plan.detect.ts +47 -0
  202. package/src/core/plan/plan.service.ts +81 -0
  203. package/src/core/plan/plan.types.ts +25 -0
  204. package/src/core/policy/policy.defaults.ts +103 -0
  205. package/src/core/policy/policy.guard.ts +34 -0
  206. package/src/core/policy/policy.integrity.ts +238 -0
  207. package/src/core/policy/policy.loader.ts +118 -0
  208. package/src/core/policy/policy.operator.ts +82 -0
  209. package/src/core/policy/policy.posture.ts +79 -0
  210. package/src/core/policy/policy.rails.ts +45 -0
  211. package/src/core/policy/policy.types.ts +157 -0
  212. package/src/core/presence/presence.service.ts +111 -0
  213. package/src/core/presence/presence.store.ts +59 -0
  214. package/src/core/presence/presence.types.ts +9 -0
  215. package/src/core/release/release.decisions.ts +147 -0
  216. package/src/core/release/release.seen.ts +38 -0
  217. package/src/core/shell-policy/shell-policy.service.ts +215 -0
  218. package/src/core/shell-policy/shell-policy.stall.ts +57 -0
  219. package/src/core/shell-policy/shell-policy.types.ts +26 -0
  220. package/src/core/ship/ship.ledger.ts +76 -0
  221. package/src/core/ship/ship.service.ts +137 -0
  222. package/src/core/ship/ship.types.ts +19 -0
  223. package/src/core/stagnation/stagnation.resolution.ts +87 -0
  224. package/src/core/stagnation/stagnation.service.ts +20 -0
  225. package/src/core/stagnation/stagnation.store.ts +49 -0
  226. package/src/core/stagnation/stagnation.types.ts +12 -0
  227. package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
  228. package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
  229. package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
  230. package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
  231. package/src/core/supply-chain/supply-chain.service.ts +217 -0
  232. package/src/core/turn/turn.activity.ts +94 -0
  233. package/src/core/turn/turn.autopilot.ts +131 -0
  234. package/src/core/turn/turn.failure-signals.ts +193 -0
  235. package/src/core/turn/turn.loop-counter.ts +71 -0
  236. package/src/core/turn/turn.types.ts +14 -0
  237. package/src/core/untrusted/untrusted.detect.ts +53 -0
  238. package/src/core/untrusted/untrusted.recall.ts +87 -0
  239. package/src/core/untrusted/untrusted.service.ts +129 -0
  240. package/src/core/untrusted/untrusted.store.ts +75 -0
  241. package/src/core/untrusted/untrusted.types.ts +42 -0
  242. package/src/entrypoints/compact-before.ts +14 -0
  243. package/src/entrypoints/prompt-submit.ts +25 -0
  244. package/src/entrypoints/response-after.ts +60 -0
  245. package/src/entrypoints/run.ts +179 -0
  246. package/src/entrypoints/session-end.ts +58 -0
  247. package/src/entrypoints/session-start.ts +172 -0
  248. package/src/entrypoints/shim.ts +46 -0
  249. package/src/entrypoints/stop.ts +876 -0
  250. package/src/entrypoints/subagent-start.ts +16 -0
  251. package/src/entrypoints/subagent-stop.ts +32 -0
  252. package/src/entrypoints/support.ts +182 -0
  253. package/src/entrypoints/tool-after.ts +111 -0
  254. package/src/entrypoints/tool-before.ts +186 -0
  255. package/src/entrypoints/tool-failure.ts +26 -0
  256. package/src/platform/backoff.ts +49 -0
  257. package/src/platform/cli-output.ts +30 -0
  258. package/src/platform/env-scope.ts +22 -0
  259. package/src/platform/fs-atomic.ts +142 -0
  260. package/src/platform/fs-jsonl.ts +24 -0
  261. package/src/platform/git.ts +187 -0
  262. package/src/platform/paths.ts +80 -0
  263. package/src/platform/pricing.ts +229 -0
  264. package/src/platform/process.ts +64 -0
  265. package/src/platform/sanitize.ts +23 -0
  266. package/src/platform/screen.ts +46 -0
  267. package/src/platform/style.ts +103 -0
  268. package/src/providers/claude/claude.capabilities.ts +25 -0
  269. package/src/providers/claude/claude.detect.ts +14 -0
  270. package/src/providers/claude/claude.inbound.ts +252 -0
  271. package/src/providers/claude/claude.lessons-view.ts +33 -0
  272. package/src/providers/claude/claude.outbound.ts +65 -0
  273. package/src/providers/claude/claude.policy-defaults.ts +9 -0
  274. package/src/providers/claude/claude.transcript.ts +62 -0
  275. package/src/providers/claude/claude.wiring.ts +245 -0
  276. package/src/providers/claude/index.ts +17 -0
  277. package/src/providers/cursor/cursor.capabilities.ts +28 -0
  278. package/src/providers/cursor/cursor.detect.ts +14 -0
  279. package/src/providers/cursor/cursor.inbound.ts +203 -0
  280. package/src/providers/cursor/cursor.lessons-view.ts +30 -0
  281. package/src/providers/cursor/cursor.outbound.ts +43 -0
  282. package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
  283. package/src/providers/cursor/cursor.wiring.ts +214 -0
  284. package/src/providers/cursor/index.ts +17 -0
  285. package/src/providers/index.ts +9 -0
  286. package/src/providers/provider.degrade.ts +152 -0
  287. package/src/providers/provider.port.ts +20 -0
  288. package/src/providers/provider.registry.ts +28 -0
  289. package/tools/doctor.ts +631 -0
  290. package/tools/help-topic.ts +74 -0
  291. package/tools/init-project.ts +277 -0
  292. package/tools/install-runtime.ts +164 -0
  293. package/tools/lessons-cli.ts +474 -0
  294. package/tools/obs-cli.ts +175 -0
  295. package/tools/price-lookup.ts +57 -0
  296. package/tools/refresh-model-prices.ts +210 -0
  297. package/tools/test-env.mjs +28 -0
  298. package/tools/test-env.names.d.mts +1 -0
  299. package/tools/test-env.names.mjs +14 -0
  300. package/tools/uninstall-runtime.ts +463 -0
@@ -0,0 +1,463 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ readlinkSync,
6
+ realpathSync,
7
+ rmSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
14
+ import { NPM_MARKER, NPM_PACKAGE } from "../bin/tlc-cli.ts";
15
+ import { claudeConfigDir, cursorConfigDir, runtimeHome } from "../src/platform/paths.ts";
16
+ import { type Row, render, type Screen } from "../src/platform/screen.ts";
17
+ import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
18
+ import { removeClaudeWiring, unmergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
19
+ import { unwireCursorHooks } from "../src/providers/cursor/cursor.wiring.ts";
20
+ import { OPERATOR_OWNED, RUNTIME_PAYLOAD } from "./install-runtime.ts";
21
+
22
+ /**
23
+ * `unmerge` rewrites a file the operator owns, keeping everything that is not ours. `unlink` removes a symlink
24
+ * without reading through it. `remove` deletes a path the installer created. `keep` is an artefact examined and
25
+ * deliberately left. `manual` is work this command will not do, printed so it is not silently skipped.
26
+ */
27
+ export type ItemAction = "unmerge" | "unlink" | "remove" | "keep" | "manual";
28
+
29
+ export type PlanItem = { action: ItemAction; target: string; detail: string };
30
+
31
+ export type UninstallPlan = {
32
+ items: PlanItem[];
33
+ purge: boolean;
34
+ /** hazard: a symlinked home points at somebody's working clone. Nothing inside it may be removed. */
35
+ homeIsLink: boolean;
36
+ };
37
+
38
+ export type UninstallTargets = {
39
+ home: string;
40
+ binLink: string;
41
+ claudeSettings: string;
42
+ cursorHooks: string;
43
+ skillLinks: string[];
44
+ };
45
+
46
+ /**
47
+ * hazard: `install.ps1` does not write the same artefacts as `install.sh`. It resolves the home from
48
+ * `USERPROFILE`, **copies** `tlc.cmd` into the bin directory instead of linking it, and puts one skill junction
49
+ * at `~/.tlc/skills/harness-init` rather than one inside each provider's directory. Reading the POSIX layout on
50
+ * Windows finds none of them and reports a clean machine ([/decisions/ad-066.md](/decisions/ad-066.md)).
51
+ */
52
+ export function uninstallTargets(
53
+ env: NodeJS.ProcessEnv = process.env,
54
+ platform: NodeJS.Platform = process.platform,
55
+ ): UninstallTargets {
56
+ const windows = platform === "win32";
57
+ const userHome = (windows ? env.USERPROFILE : env.HOME)?.trim() || homedir();
58
+ const binDir = env.TLC_BIN_DIR?.trim() || join(userHome, ".local", "bin");
59
+ return {
60
+ home: runtimeHome(env),
61
+ binLink: join(binDir, windows ? "tlc.cmd" : "tlc"),
62
+ claudeSettings: join(claudeConfigDir(), "settings.json"),
63
+ cursorHooks: join(cursorConfigDir(), "hooks.json"),
64
+ skillLinks: windows
65
+ ? [join(userHome, ".tlc", "skills", "harness-init")]
66
+ : [
67
+ join(claudeConfigDir(), "skills", "harness-init"),
68
+ join(cursorConfigDir(), "skills", "harness-init"),
69
+ ],
70
+ };
71
+ }
72
+
73
+ function isSymlink(path: string): boolean {
74
+ try {
75
+ return lstatSync(path).isSymbolicLink();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The real path of `path`, resolving as much of it as exists on disk and re-appending the rest.
83
+ *
84
+ * hazard: `realpathSync` throws on a path whose tail is absent, and a link into a runtime home already removed is
85
+ * exactly the state a partial run leaves. Returning the literal text in that case compared a resolved path against
86
+ * an unresolved one — and on macOS the OS temp directory sits under `/var`, itself a symlink to `/private/var`, so
87
+ * the two forms never matched and a dangling link of ours read as somebody else's. Green on Linux, red on macOS
88
+ * CI ([/decisions/ad-066.md](/decisions/ad-066.md)).
89
+ */
90
+ export function canonicalise(path: string): string {
91
+ let head = resolve(path);
92
+ const tail: string[] = [];
93
+ for (;;) {
94
+ try {
95
+ return join(realpathSync(head), ...tail);
96
+ } catch {
97
+ const parent = dirname(head);
98
+ if (parent === head) {
99
+ return resolve(path);
100
+ }
101
+ tail.unshift(basename(head));
102
+ head = parent;
103
+ }
104
+ }
105
+ }
106
+
107
+ function resolveLink(path: string): string {
108
+ try {
109
+ return realpathSync(path);
110
+ } catch {
111
+ try {
112
+ const target = readlinkSync(path);
113
+ return canonicalise(isAbsolute(target) ? target : resolve(dirname(path), target));
114
+ } catch {
115
+ return canonicalise(path);
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * hazard: this was `target.startsWith(`${root}/`)`, and Windows separates with `\`. Every path landed outside
122
+ * every root, so on Windows CI the harness's own links read as somebody else's. `relative` is separator-aware and
123
+ * case-folds the drive the way the platform does; the `pathApi` parameter is what lets the win32 rules be tested
124
+ * from any machine ([/decisions/ad-066.md](/decisions/ad-066.md)).
125
+ */
126
+ export function isInsideRoot(
127
+ target: string,
128
+ root: string,
129
+ pathApi: Pick<typeof import("node:path"), "relative" | "isAbsolute"> = { relative, isAbsolute },
130
+ ): boolean {
131
+ if (target === root) {
132
+ return true;
133
+ }
134
+ const step = pathApi.relative(root, target);
135
+ return step !== "" && !step.startsWith("..") && !pathApi.isAbsolute(step);
136
+ }
137
+
138
+ // invariant: a link is ours only when it lands inside the runtime home. A `tlc` on PATH belonging to something
139
+ // else keeps its name, and this command is not the place to argue about it. Both sides go through the same
140
+ // resolution, because comparing a resolved path with an unresolved one is how this broke.
141
+ function pointsInto(link: string, home: string): boolean {
142
+ return isInsideRoot(resolveLink(link), canonicalise(home));
143
+ }
144
+
145
+ /**
146
+ * `target` — the link is ours only when it lands inside the runtime home. `location` — the path itself is the
147
+ * installer's artefact and the target is irrelevant.
148
+ *
149
+ * hazard: both rules were `target` at first, and running the command on the machine it was written on found a
150
+ * `~/.claude/skills/harness-init` pointing at a `/tmp` install deleted weeks earlier. Under the target rule that
151
+ * dangling link is "not ours" and survives every uninstall forever. `tlc` on PATH is a name anybody may own, so
152
+ * it keeps the target rule; `skills/harness-init` is a path only this installer writes
153
+ * ([/decisions/ad-066.md](/decisions/ad-066.md)).
154
+ */
155
+ type LinkOwnership = "target" | "location";
156
+
157
+ const LAUNCHER_MARKER = "tlc-exec.mjs";
158
+
159
+ function carriesLauncherMarker(path: string): boolean {
160
+ try {
161
+ return statSync(path).size < 4096 && readFileSync(path, "utf8").includes(LAUNCHER_MARKER);
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ function planLink(
168
+ items: PlanItem[],
169
+ path: string,
170
+ home: string,
171
+ label: string,
172
+ ownership: LinkOwnership,
173
+ ): void {
174
+ if (!existsSync(path) && !isSymlink(path)) {
175
+ return;
176
+ }
177
+ if (!isSymlink(path)) {
178
+ // why: on Windows the launcher is a copy, not a link, so "not a link" is not the same as "not ours". The
179
+ // copy carries the launcher name in its one command line, which is the same marker every other artefact
180
+ // identifies itself by.
181
+ if (carriesLauncherMarker(path)) {
182
+ items.push({ action: "remove", target: path, detail: `${label}, installed as a copy` });
183
+ return;
184
+ }
185
+ items.push({
186
+ action: "keep",
187
+ target: path,
188
+ detail: `${label} is a real file the installer did not write`,
189
+ });
190
+ return;
191
+ }
192
+ if (ownership === "target" && !pointsInto(path, home)) {
193
+ items.push({ action: "keep", target: path, detail: `points at ${resolveLink(path)} — not ours` });
194
+ return;
195
+ }
196
+ const stale = ownership === "location" && !pointsInto(path, home);
197
+ items.push({
198
+ action: "unlink",
199
+ target: path,
200
+ detail: stale ? `${label}, stale — points at ${resolveLink(path)}` : label,
201
+ });
202
+ }
203
+
204
+ function planClaude(items: PlanItem[], settingsPath: string): void {
205
+ if (!existsSync(settingsPath)) {
206
+ return;
207
+ }
208
+ const result = unmergeClaudeSettings(readFileSync(settingsPath, "utf8"));
209
+ if (!result.ok) {
210
+ items.push({
211
+ action: "keep",
212
+ target: settingsPath,
213
+ detail: `left untouched — it does not parse as JSON: ${result.error}`,
214
+ });
215
+ return;
216
+ }
217
+ if (!result.changed) {
218
+ return;
219
+ }
220
+ items.push({
221
+ action: "unmerge",
222
+ target: settingsPath,
223
+ detail: "drop the harness hook groups, keep every other key and every foreign hook",
224
+ });
225
+ }
226
+
227
+ function planCursor(items: PlanItem[], hooksPath: string): void {
228
+ const text = existsSync(hooksPath) ? readFileSync(hooksPath, "utf8") : null;
229
+ const result = unwireCursorHooks(text);
230
+ switch (result.kind) {
231
+ case "absent":
232
+ return;
233
+ case "unparsed":
234
+ items.push({ action: "keep", target: hooksPath, detail: "left untouched — it does not parse as JSON" });
235
+ return;
236
+ case "empty":
237
+ if (result.removed > 0) {
238
+ items.push({ action: "remove", target: hooksPath, detail: `${result.removed} entries, all ours` });
239
+ }
240
+ return;
241
+ default:
242
+ items.push({
243
+ action: "unmerge",
244
+ target: hooksPath,
245
+ detail: `drop ${result.removed} harness entries, keep the rest`,
246
+ });
247
+ }
248
+ }
249
+
250
+ function planRuntime(items: PlanItem[], home: string, purge: boolean): boolean {
251
+ const homeIsLink = isSymlink(home);
252
+ if (homeIsLink) {
253
+ // hazard: this is the contributor's checkout on every development machine. `rm -rf` through the link deletes
254
+ // the repository. The installer refuses to touch it for the same reason ([/decisions/ad-046.md](/decisions/ad-046.md)) and so does this.
255
+ items.push({
256
+ action: "unlink",
257
+ target: home,
258
+ detail: `a link to ${resolveLink(home)} — the checkout it points at is left exactly as it is`,
259
+ });
260
+ return true;
261
+ }
262
+ if (!existsSync(home)) {
263
+ return false;
264
+ }
265
+ for (const entry of RUNTIME_PAYLOAD) {
266
+ const path = join(home, entry);
267
+ if (existsSync(path)) {
268
+ items.push({ action: "remove", target: path, detail: "runtime payload" });
269
+ }
270
+ }
271
+ const marker = join(home, NPM_MARKER);
272
+ if (existsSync(marker)) {
273
+ items.push({ action: "remove", target: marker, detail: "install marker" });
274
+ }
275
+ for (const entry of OPERATOR_OWNED) {
276
+ const path = join(home, entry);
277
+ if (!existsSync(path)) {
278
+ continue;
279
+ }
280
+ items.push(
281
+ purge
282
+ ? { action: "remove", target: path, detail: "--purge" }
283
+ : { action: "keep", target: path, detail: "yours — add --purge to remove it" },
284
+ );
285
+ }
286
+ return false;
287
+ }
288
+
289
+ function planManual(items: PlanItem[], home: string): void {
290
+ if (existsSync(join(home, NPM_MARKER))) {
291
+ items.push({
292
+ action: "manual",
293
+ target: `npm uninstall -g ${NPM_PACKAGE}`,
294
+ // why: a global prefix owned by root needs sudo, and an npm call failing halfway through a teardown leaves
295
+ // a worse state than one that never started ([/decisions/ad-066.md](/decisions/ad-066.md)).
296
+ detail: "the global package is reported, never removed for you",
297
+ });
298
+ }
299
+ items.push({
300
+ action: "manual",
301
+ target: "rm -rf .tlc/ in each repository",
302
+ detail: "per-project config and state — this command does not search your disk for them",
303
+ });
304
+ }
305
+
306
+ export function planUninstall(targets: UninstallTargets, options: { purge?: boolean } = {}): UninstallPlan {
307
+ const purge = options.purge === true;
308
+ const items: PlanItem[] = [];
309
+
310
+ planClaude(items, targets.claudeSettings);
311
+ planCursor(items, targets.cursorHooks);
312
+ for (const link of targets.skillLinks) {
313
+ planLink(items, link, targets.home, "skill link", "location");
314
+ }
315
+ planLink(items, targets.binLink, targets.home, "the tlc launcher on PATH", "target");
316
+ const homeIsLink = planRuntime(items, targets.home, purge);
317
+ planManual(items, targets.home);
318
+
319
+ return { items, purge, homeIsLink };
320
+ }
321
+
322
+ /** Everything the plan would change. `keep` and `manual` are reported, not counted. */
323
+ export function pendingItems(plan: UninstallPlan): PlanItem[] {
324
+ return plan.items.filter(
325
+ (item) => item.action === "unmerge" || item.action === "unlink" || item.action === "remove",
326
+ );
327
+ }
328
+
329
+ export type UninstallResult = { applied: PlanItem[]; failed: { item: PlanItem; reason: string }[] };
330
+
331
+ export function applyUninstall(plan: UninstallPlan, targets: UninstallTargets): UninstallResult {
332
+ const applied: PlanItem[] = [];
333
+ const failed: { item: PlanItem; reason: string }[] = [];
334
+
335
+ for (const item of pendingItems(plan)) {
336
+ try {
337
+ if (item.action === "unmerge" && item.target === targets.claudeSettings) {
338
+ removeClaudeWiring(item.target);
339
+ } else if (item.action === "unmerge") {
340
+ const result = unwireCursorHooks(readFileSync(item.target, "utf8"));
341
+ if (result.kind === "rewritten") {
342
+ writeFileSync(item.target, result.text, "utf8");
343
+ }
344
+ } else if (item.action === "unlink") {
345
+ // why: `unlinkSync` states the intent — remove the link, never what it points at. Measured: `rmSync`
346
+ // with `recursive` happens to agree, unlinking a symlink rather than descending it. The guard that
347
+ // actually prevents the data loss is `planRuntime` returning before any payload path is planned, since
348
+ // `home/src` under a linked home resolves inside the checkout.
349
+ unlinkSync(item.target);
350
+ } else {
351
+ rmSync(item.target, { recursive: true, force: true });
352
+ }
353
+ applied.push(item);
354
+ } catch (error) {
355
+ failed.push({ item, reason: error instanceof Error ? error.message : String(error) });
356
+ }
357
+ }
358
+ return { applied, failed };
359
+ }
360
+
361
+ const ACTION_LEVEL = {
362
+ unmerge: "warn",
363
+ unlink: "warn",
364
+ remove: "warn",
365
+ keep: "ok",
366
+ manual: "info",
367
+ } as const;
368
+
369
+ export function uninstallScreen(plan: UninstallPlan, result: UninstallResult | null): Screen {
370
+ const pending = pendingItems(plan);
371
+ const applied = result !== null;
372
+
373
+ if (pending.length === 0) {
374
+ return {
375
+ title: "harness uninstall",
376
+ summary: ["nothing wired"],
377
+ sections: [
378
+ {
379
+ rows: [{ label: "state", value: "no harness artefact found — nothing to undo", level: "ok" }],
380
+ },
381
+ { title: "STILL YOURS TO DO", rows: manualRows(plan) },
382
+ ],
383
+ footer: `already clean · reinstall with npm i -g ${NPM_PACKAGE}, then \`tlc harness install\``,
384
+ };
385
+ }
386
+
387
+ const changes: Row[] = pending.map((item) => ({
388
+ label: item.action,
389
+ value: `${item.target} — ${item.detail}`,
390
+ level: ACTION_LEVEL[item.action],
391
+ }));
392
+ const kept = plan.items.filter((item) => item.action === "keep");
393
+
394
+ const sections = [
395
+ { title: applied ? "REMOVED" : "WOULD REMOVE", rows: changes },
396
+ ...(kept.length > 0
397
+ ? [
398
+ {
399
+ title: "KEPT",
400
+ rows: kept.map((item) => ({
401
+ label: "keep",
402
+ value: `${item.target} — ${item.detail}`,
403
+ level: "ok" as const,
404
+ })),
405
+ },
406
+ ]
407
+ : []),
408
+ { title: "STILL YOURS TO DO", rows: manualRows(plan) },
409
+ ...(result !== null && result.failed.length > 0
410
+ ? [
411
+ {
412
+ title: "FAILED",
413
+ rows: result.failed.map((entry) => ({
414
+ label: "error",
415
+ value: `${entry.item.target} — ${entry.reason}`,
416
+ level: "fail" as const,
417
+ })),
418
+ },
419
+ ]
420
+ : []),
421
+ ];
422
+
423
+ return {
424
+ title: "harness uninstall",
425
+ summary: [
426
+ applied ? `${result.applied.length} applied` : `${pending.length} pending`,
427
+ plan.purge ? "purge: state included" : "purge: state kept",
428
+ plan.homeIsLink ? "runtime: linked checkout, unlinked only" : "runtime: owned by the installer",
429
+ ],
430
+ sections,
431
+ footer: applied
432
+ ? `reinstall any time with npm i -g ${NPM_PACKAGE}, then \`tlc harness install\``
433
+ : "nothing was changed · re-run with --yes to apply this plan",
434
+ };
435
+ }
436
+
437
+ function manualRows(plan: UninstallPlan): Row[] {
438
+ return plan.items
439
+ .filter((item) => item.action === "manual")
440
+ .map((item) => ({ label: "run", value: `${item.target} — ${item.detail}`, level: "info" as const }));
441
+ }
442
+
443
+ export function uninstallReportText(
444
+ plan: UninstallPlan,
445
+ result: UninstallResult | null,
446
+ style: Style = PLAIN,
447
+ ): string {
448
+ return render(uninstallScreen(plan, result), style);
449
+ }
450
+
451
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
452
+ const targets = uninstallTargets();
453
+ const plan = planUninstall(targets, { purge: argv.includes("--purge") });
454
+ // why: the plan is the confirmation. A prompt needs a TTY, and the operator reaching for this is as likely to
455
+ // be in CI or a shell that is already half-broken ([/decisions/ad-066.md](/decisions/ad-066.md)).
456
+ const result = argv.includes("--yes") ? applyUninstall(plan, targets) : null;
457
+ console.log(uninstallReportText(plan, result, createStyle()));
458
+ return result !== null && result.failed.length > 0 ? 1 : 0;
459
+ }
460
+
461
+ if (import.meta.main) {
462
+ process.exitCode = main();
463
+ }