@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,631 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
3
+ import { homedir, platform as osPlatform } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { runtimePathKind } from "../bin/tlc-cli.ts";
6
+ import { findBunOnPath, writeRuntimeCache } from "../bin/tlc-exec.mjs";
7
+ import { isCursorWired } from "../bin/write-user-hooks.mjs";
8
+ import type { ProviderWiring } from "../src/contracts/index.ts";
9
+ import { coreFacade } from "../src/core/index.ts";
10
+ import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
11
+ import { projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
12
+ import { type ColorName, createStyle, PLAIN, type Style, SYMBOLS } from "../src/platform/style.ts";
13
+ import { mergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
14
+ import {
15
+ cursorWiringProblems,
16
+ formatWiringProblems,
17
+ type WiringProblem,
18
+ } from "../src/providers/cursor/cursor.wiring.ts";
19
+ import { providers } from "../src/providers/index.ts";
20
+ import type { ProviderPort } from "../src/providers/provider.port.ts";
21
+
22
+ export type CheckLevel = "ok" | "warn" | "fail";
23
+
24
+ export type Check = { level: CheckLevel; name: string; detail: string };
25
+
26
+ // why: written as a person would say it. "1 warning(s)" is what a machine writes, and reading the output as the
27
+ // operator is the step that found this ([/decisions/ad-034.md](/decisions/ad-034.md)).
28
+ export function plural(count: number, word: string): string {
29
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
30
+ }
31
+
32
+ const MIN_NODE = 24;
33
+
34
+ export type SpawnProbe = (command: string, args: string[]) => { ok: boolean };
35
+
36
+ /**
37
+ * hazard: this line used to assert "hook cost ~1 ms with Bun vs ~27 ms with Node" on every machine it ran on, and had
38
+ * measured it on none of them. An operator reported the harness as slow and the one number `doctor` offered about
39
+ * speed was prose ([/decisions/ad-033.md](/decisions/ad-033.md)).
40
+ *
41
+ * why: the interpreter's cold start is the dominant term of per-hook overhead and the only part measurable without
42
+ * side effects — every entrypoint writes something, including on an unrecognised payload. The label says which it is,
43
+ * so the number is not read as the whole hook.
44
+ *
45
+ * why median: one scheduling hiccup on a loaded machine should not become the reported figure.
46
+ */
47
+ export function medianMs(samples: readonly number[]): number | null {
48
+ if (samples.length === 0) {
49
+ return null;
50
+ }
51
+ const sorted = [...samples].sort((a, b) => a - b);
52
+ const middle = Math.floor(sorted.length / 2);
53
+ return sorted.length % 2 === 1
54
+ ? (sorted[middle] as number)
55
+ : ((sorted[middle - 1] as number) + (sorted[middle] as number)) / 2;
56
+ }
57
+
58
+ export function measureRuntimeStart(args: {
59
+ command: string;
60
+ args: string[];
61
+ samples?: number;
62
+ spawn?: SpawnProbe;
63
+ now?: () => number;
64
+ }): number | null {
65
+ const spawn =
66
+ args.spawn ??
67
+ ((command: string, argv: string[]) => ({
68
+ ok: (spawnSync(command, argv, { stdio: "ignore" }).status ?? 1) === 0,
69
+ }));
70
+ const now = args.now ?? (() => Date.now());
71
+ const durations: number[] = [];
72
+ for (let i = 0; i < (args.samples ?? 3); i += 1) {
73
+ const started = now();
74
+ if (!spawn(args.command, args.args).ok) {
75
+ return null;
76
+ }
77
+ durations.push(now() - started);
78
+ }
79
+ return medianMs(durations);
80
+ }
81
+
82
+ export function checkNodeVersion(nodeVersion: string, bunPath: string | null = null): Check[] {
83
+ const nodeMajor = Number.parseInt(nodeVersion.replace(/^v/, "").split(".")[0] ?? "0", 10);
84
+ const checks: Check[] = [
85
+ {
86
+ // why: Bun runs hooks directly, so an old Node is not a failure when Bun is present — only the
87
+ // absence of both leaves a hook with nothing to run.
88
+ level: nodeMajor >= MIN_NODE || bunPath !== null ? "ok" : "fail",
89
+ name: "Node.js runtime",
90
+ detail:
91
+ nodeMajor >= MIN_NODE
92
+ ? `${nodeVersion} (>= ${MIN_NODE})`
93
+ : bunPath !== null
94
+ ? `${nodeVersion} — below ${MIN_NODE}, covered by Bun at ${bunPath}`
95
+ : `${nodeVersion} — no runtime for hooks. Install Bun (curl -fsSL https://bun.sh/install | bash) or Node ${MIN_NODE}+ (nodejs.org), then reload the editor.`,
96
+ },
97
+ ];
98
+ if (nodeMajor === 25) {
99
+ checks.push({
100
+ level: "warn",
101
+ name: "Node.js line",
102
+ detail: "Node 25 is EOL — prefer 24 LTS or 26 Current",
103
+ });
104
+ }
105
+ return checks;
106
+ }
107
+
108
+ /**
109
+ * why: both kinds are supported installs, so both are `ok` — but a contributor whose runtime is a link to their own
110
+ * clone needs to see that, or `update` declining to pull reads as a broken update
111
+ * ([/decisions/ad-046.md](/decisions/ad-046.md)).
112
+ */
113
+ export function runtimeOwnershipCheck(home: string): Check {
114
+ const kind = runtimePathKind(home);
115
+ const detail: Record<ReturnType<typeof runtimePathKind>, string> = {
116
+ managed: "managed checkout — `tlc harness update` moves it to upstream and owns its contents",
117
+ linked: "link to a working clone — update never writes here; pull that clone yourself",
118
+ npm: "installed from npm — `tlc harness update` bumps the package and re-materialises this directory",
119
+ unmanaged: "not a git checkout — update cannot pull; install the package and run `tlc harness install`",
120
+ absent: "missing — install the package, then run `tlc harness install`",
121
+ };
122
+ return {
123
+ level: kind === "managed" || kind === "linked" || kind === "npm" ? "ok" : "fail",
124
+ name: "runtime ownership",
125
+ detail: detail[kind],
126
+ };
127
+ }
128
+
129
+ export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
130
+ const launcher = join(home, "bin", "tlc-exec.mjs");
131
+ const distSample = join(home, "dist", "stop.mjs");
132
+ const cliLink = join(homedir(), ".local", "bin", platform === "win32" ? "tlc.cmd" : "tlc");
133
+ return [
134
+ { level: "ok", name: "platform", detail: platform },
135
+ { level: existsSync(launcher) ? "ok" : "fail", name: "global runtime", detail: home },
136
+ runtimeOwnershipCheck(home),
137
+ {
138
+ level: existsSync(distSample) ? "ok" : "fail",
139
+ name: "dist bundles",
140
+ // why: a fixed remediation string reads as an instruction on a passing check.
141
+ detail: existsSync(distSample) ? join(home, "dist") : "missing — run: tlc harness build",
142
+ },
143
+ { level: existsSync(launcher) ? "ok" : "fail", name: "portable launcher", detail: launcher },
144
+ {
145
+ level:
146
+ existsSync(cliLink) || existsSync(join(home, "bin", platform === "win32" ? "tlc.cmd" : "tlc"))
147
+ ? "ok"
148
+ : "fail",
149
+ name: "CLI on PATH",
150
+ detail: cliLink,
151
+ },
152
+ ];
153
+ }
154
+
155
+ export function checkHookRuntime(
156
+ _home: string,
157
+ bunPath: string | null,
158
+ measure: (args: { command: string; args: string[] }) => number | null = measureRuntimeStart,
159
+ ): Check {
160
+ const runtime = bunPath ?? process.execPath;
161
+ const label = bunPath ? `Bun (${bunPath})` : `Node + dist/ (${process.version})`;
162
+ const ms = measure({ command: runtime, args: ["-e", ""] });
163
+ // why: no number rather than a guessed one. A measurement that failed is not a slow machine.
164
+ const timing =
165
+ ms === null
166
+ ? " — interpreter start could not be measured on this machine"
167
+ : ` — interpreter start measured at ${ms} ms here, paid once per hook`;
168
+ return bunPath
169
+ ? { level: "ok", name: "hook runtime", detail: `${label}${timing}` }
170
+ : {
171
+ level: "warn",
172
+ name: "hook runtime",
173
+ detail: `${label}${timing}. Bun runs the source directly and starts faster — install: https://bun.sh`,
174
+ };
175
+ }
176
+
177
+ export type ProviderWiringStatus = "wired" | "detected-but-unwired" | "not-installed";
178
+
179
+ /**
180
+ * hazard: the replace-strategy branch decided health by marker presence alone, so a file carrying the marker in one
181
+ * entry and a broken command in another reported `wired`. A colleague's session was blocked by exactly that shape
182
+ * ([/decisions/ad-032.md](/decisions/ad-032.md)).
183
+ */
184
+ export function wiringProblems(wiring: ProviderWiring): WiringProblem[] {
185
+ if (wiring.strategy !== "replace") {
186
+ return [];
187
+ }
188
+ const text = existsSync(wiring.target) ? readFileSync(wiring.target, "utf8") : null;
189
+ return cursorWiringProblems(text, { launcherPath: launcherPathOf(wiring) }, existsSync);
190
+ }
191
+
192
+ /**
193
+ * why: derived from the wiring's own entries rather than passed in, so the check compares the file against the same
194
+ * launcher path the writer would use. Reading it from anywhere else is how the two come to disagree.
195
+ */
196
+ function launcherPathOf(wiring: ProviderWiring): string {
197
+ const first = wiring.entries[0];
198
+ return first?.args.find((arg) => arg.endsWith(".mjs")) ?? "";
199
+ }
200
+
201
+ export function providerWiringStatus(wiring: ProviderWiring): ProviderWiringStatus {
202
+ if (!existsSync(dirname(wiring.target))) {
203
+ return "not-installed";
204
+ }
205
+ if (wiring.strategy === "replace") {
206
+ if (!isCursorWired(wiring.target)) {
207
+ return "detected-but-unwired";
208
+ }
209
+ // invariant: the marker says the file is ours; the problems say whether it works. Both must pass.
210
+ return wiringProblems(wiring).length === 0 ? "wired" : "detected-but-unwired";
211
+ }
212
+ const existingText = existsSync(wiring.target) ? readFileSync(wiring.target, "utf8") : null;
213
+ const result = mergeClaudeSettings(existingText, wiring.entries);
214
+ return result.ok && !result.changed ? "wired" : "detected-but-unwired";
215
+ }
216
+
217
+ export function checkProviders(registry: readonly ProviderPort[], home: string): Check[] {
218
+ const launcherPath = join(home, "bin", "tlc-exec.mjs");
219
+ return registry.map((provider) => {
220
+ const wiring = provider.wiring({ launcherPath });
221
+ const status = providerWiringStatus(wiring);
222
+ if (status === "not-installed") {
223
+ return { level: "ok", name: `${provider.name} wiring`, detail: "not installed" };
224
+ }
225
+ if (status === "wired") {
226
+ return { level: "ok", name: `${provider.name} wiring`, detail: `wired (${wiring.target})` };
227
+ }
228
+ // why: names the event and the reason. "detected but not wired" told an operator that something was wrong and
229
+ // nothing else, which is one step above silence.
230
+ const problems = wiringProblems(wiring);
231
+ const why = problems.length > 0 ? ` — ${formatWiringProblems(problems)}` : "";
232
+ return {
233
+ level: "warn",
234
+ name: `${provider.name} wiring`,
235
+ detail: `detected but not wired${why} — run: tlc harness update (${wiring.target})`,
236
+ };
237
+ });
238
+ }
239
+
240
+ /**
241
+ * hazard: this returned one `warn` per capability that was not enabled, so a healthy install printed nine warnings
242
+ * and the rows that needed attention — a diverged policy, a gate running in full — sat in the middle of them. One
243
+ * inventory row replaces the wall; `update` still lists each one with its benefit and trade-off, which is where an
244
+ * operator is actually choosing ([/decisions/ad-034.md](/decisions/ad-034.md)).
245
+ */
246
+ export function checkCapabilities(root: string, runtimeRoot: string): Check[] {
247
+ const catalog = coreFacade.capability.loadCatalog(runtimeRoot);
248
+ const policy = coreFacade.capability.readProjectPolicyRaw(root);
249
+ if (!catalog || !policy) {
250
+ return [];
251
+ }
252
+ const available = coreFacade.capability.listAvailableNotEnabled(policy, catalog);
253
+ if (available.length === 0) {
254
+ return [];
255
+ }
256
+ return [
257
+ {
258
+ level: "ok",
259
+ name: "capabilities",
260
+ detail: coreFacade.capability.formatAvailableInventory(available),
261
+ },
262
+ ];
263
+ }
264
+
265
+ // why: a posture that could not be honoured is silently replaced by the default everywhere else — the loader
266
+ // applies it, the hooks obey it, and nothing in a running session says the operator's word was refused. This is
267
+ // the surface that names it, which is what makes the one-word fix findable.
268
+ function checkPosture(root: string): Check {
269
+ const posture = coreFacade.policy.resolveProjectPosture(root);
270
+ if (posture.origin !== "fallback") {
271
+ return { level: "ok", name: "operator posture", detail: `${posture.mode} (from ${posture.origin})` };
272
+ }
273
+ return {
274
+ level: "warn",
275
+ name: "operator posture",
276
+ // hazard: the remediation used to end `tlc harness mode ${posture.mode}` — the posture the fallback landed
277
+ // on, which is the one value the operator demonstrably did not ask for. Suggesting it invites them to make
278
+ // the substitution permanent. The command names the choice instead of guessing it.
279
+ detail: `\`${posture.invalid}\` is not a posture — running as ${posture.mode}. Accepted: ${coreFacade.policy.OPERATOR_MODES.join(" | ")}. Fix \`mode\` in ${projectConfigPath(root)}, or run: tlc harness mode <${coreFacade.policy.OPERATOR_MODES.join("|")}>`,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * hazard: `observe.rails` accepted any string, and a name with no checker behind it did nothing and reported
285
+ * nothing. An operator who asked for an observation and got silence would read the silence as "the property always
286
+ * holds" — the worst possible misreading of a measurement rail
287
+ * ([/decisions/ad-029.md](/decisions/ad-029.md)).
288
+ */
289
+ function checkObservedRails(root: string): Check[] {
290
+ const policy = coreFacade.policy.loadPolicy(root);
291
+ if (!policy.observe.enabled) {
292
+ return [];
293
+ }
294
+ const unusable = coreFacade.observe.unobservableRails(policy.observe.rails);
295
+ const observable = coreFacade.observe.OBSERVABLE_RAILS.join(" | ");
296
+ if (policy.observe.rails.length === 0) {
297
+ return [
298
+ {
299
+ level: "warn",
300
+ name: "observed rails",
301
+ detail: `observation is on with no rails listed, so nothing is measured. Set \`observe.rails\` to one or more of: ${observable}`,
302
+ },
303
+ ];
304
+ }
305
+ if (unusable.length === 0) {
306
+ return [{ level: "ok", name: "observed rails", detail: policy.observe.rails.join(", ") }];
307
+ }
308
+ return [
309
+ {
310
+ level: "warn",
311
+ name: "observed rails",
312
+ detail: `no checker exists for ${unusable.map((rail) => `\`${rail}\``).join(", ")}, so nothing is recorded for them. Observable today: ${observable}`,
313
+ },
314
+ ];
315
+ }
316
+
317
+ /**
318
+ * Reports the three states a lesson can be in that make it worth an operator's attention: withheld because its
319
+ * refs stopped resolving, withheld because its window closed, and injected without ever being graded.
320
+ *
321
+ * invariant: `unproven` is a warning, not an `ok` row. A lesson nothing has tested is spending injected context
322
+ * on an unjustified claim, and reading it as healthy is what lets a store fill with text nobody can defend
323
+ * ([/decisions/ad-039.md](/decisions/ad-039.md)).
324
+ *
325
+ * why: silent when the tier is empty and when everything is healthy. A reassurance on every run is a line to skim
326
+ * past ([/decisions/ad-034.md](/decisions/ad-034.md)).
327
+ */
328
+ /**
329
+ * hazard: `enforceAllowlist: true` with an empty list is a rail declared on and enforcing nothing. It used to deny
330
+ * every spawn, which read as a bug; it now denies none, which is invisible. Neither state should be silent, and
331
+ * this is where an operator already looks ([/decisions/ad-053.md](/decisions/ad-053.md)).
332
+ *
333
+ * invariant: silent when the rail is off, and silent when the list has entries. A row on a healthy install is the
334
+ * AD-034 defect.
335
+ */
336
+ export function checkSubagentAllowlist(root: string): Check[] {
337
+ const { subagents } = coreFacade.policy.loadPolicy(root);
338
+ if (!subagents.enforceAllowlist) {
339
+ return [];
340
+ }
341
+ const configured = subagents.allowedModels;
342
+ const entries = Array.isArray(configured)
343
+ ? configured.length
344
+ : Object.values(configured ?? {}).reduce((total, list) => total + list.length, 0);
345
+ if (entries > 0) {
346
+ return [];
347
+ }
348
+ return [
349
+ {
350
+ level: "fail",
351
+ name: "subagent allowlist",
352
+ detail:
353
+ "enforceAllowlist is on and subagents.allowedModels is empty, so it permits every model. The harness ships no list — add the model slugs you allow, or set enforceAllowlist to false.",
354
+ },
355
+ ];
356
+ }
357
+
358
+ export function checkLessonHealth(root: string): Check[] {
359
+ const policy = coreFacade.policy.loadPolicy(root);
360
+ if (!policy.intelligence.lessons.enabled) {
361
+ return [];
362
+ }
363
+ const now = new Date();
364
+ const writable = [...coreFacade.lesson.readProjectLessons(root), ...coreFacade.lesson.readGlobalLessons()];
365
+ if (writable.length === 0) {
366
+ return [];
367
+ }
368
+ const stale = writable.filter((lesson) => coreFacade.lesson.isStaleLesson(lesson));
369
+ const outOfWindow = writable.filter((lesson) => coreFacade.lesson.validityReason(lesson, now) !== "active");
370
+ // why: `unproven` already means injected-and-ungraded, so a second `injectedCount > 0` test here would be the
371
+ // same fact derived twice.
372
+ const unproven = writable.filter((lesson) => coreFacade.lesson.lessonEffectiveness(lesson) === "unproven");
373
+ const checks: Check[] = [];
374
+ if (stale.length > 0) {
375
+ checks.push({
376
+ level: "warn",
377
+ name: "stale lessons",
378
+ detail: `${plural(stale.length, "lesson")} name a path or symbol that no longer resolves, so ${stale.length === 1 ? "it is" : "they are"} withheld: ${stale.map((lesson) => lesson.id).join(", ")}. Run: tlc harness lessons list`,
379
+ });
380
+ }
381
+ if (outOfWindow.length > 0) {
382
+ checks.push({
383
+ level: "warn",
384
+ name: "lessons out of window",
385
+ detail: `${plural(outOfWindow.length, "lesson")} fall outside their validity window: ${outOfWindow.map((lesson) => lesson.id).join(", ")}. Run: tlc harness lessons garden`,
386
+ });
387
+ }
388
+ if (unproven.length > 0) {
389
+ checks.push({
390
+ level: "warn",
391
+ name: "unproven lessons",
392
+ // hazard: this read "1 lesson have been injected … so nothing shows it help". `plural` handled the noun and
393
+ // the verb agreement was hardcoded — the same defect as "1 warning(s)", one clause further along.
394
+ detail:
395
+ unproven.length === 1
396
+ ? "1 lesson has been injected for a gate and never graded, so nothing shows it helped"
397
+ : `${unproven.length} lessons have been injected for a gate and never graded, so nothing shows they helped`,
398
+ });
399
+ }
400
+ if (checks.length === 0) {
401
+ return [
402
+ {
403
+ level: "ok",
404
+ name: "lesson health",
405
+ detail: `${plural(writable.length, "lesson")} across the writable tiers, none stale, none out of window`,
406
+ },
407
+ ];
408
+ }
409
+ return checks;
410
+ }
411
+
412
+ /**
413
+ * hazard: a policy divergence blocks every acting tool call in a live session, and `doctor` — the one command an
414
+ * operator runs to find out what is wrong — said nothing about it. Measured: a colleague's agent was fully blocked,
415
+ * ran `status`, learned nothing, and stopped ([/decisions/ad-030.md](/decisions/ad-030.md)).
416
+ *
417
+ * why: silent when nothing diverged. A reassurance printed on every healthy run is one more line to skim past.
418
+ */
419
+ function checkPolicyDivergence(root: string): Check[] {
420
+ const diverged = coreFacade.policy.allDivergedPaths(root);
421
+ if (diverged.length === 0) {
422
+ return [];
423
+ }
424
+ return [
425
+ {
426
+ level: "warn",
427
+ name: "policy baseline",
428
+ detail: `changed out of band during a live session: ${diverged.join(", ")}. If that was you: tlc harness policy accept ${diverged.join(" ")}`,
429
+ },
430
+ ];
431
+ }
432
+
433
+ /**
434
+ * hazard: `appendFiles: "auto"` advertises narrowing the gate to the changed files, and for the two most common
435
+ * command shapes it cannot deliver — a package-manager script, and a command that already carries its own glob.
436
+ * Measured on a real install: an eslint command globbing the whole tree ran in full on every stop, three times per
437
+ * turn, and the operator experienced it as "the harness is slow" with nothing to point at
438
+ * ([/decisions/ad-033.md](/decisions/ad-033.md)).
439
+ *
440
+ * why: a warning, not a failure. Running the full suite is a legitimate choice; not knowing you are is not.
441
+ */
442
+ function checkGateScope(root: string): Check[] {
443
+ const policy = coreFacade.policy.loadPolicy(root);
444
+ if (!policy.grind.enabled) {
445
+ return [];
446
+ }
447
+ const checks: Check[] = [];
448
+ for (const [label, command] of [
449
+ ["lintCommand", policy.grind.lintCommand],
450
+ ["testCommand", policy.grind.testCommand],
451
+ ] as const) {
452
+ if (!command || command.length === 0) {
453
+ continue;
454
+ }
455
+ const verdict = coreFacade.gate.appendFilesVerdict(command, policy.grind.appendFiles);
456
+ if (verdict.appends || policy.grind.appendFiles === "never") {
457
+ continue;
458
+ }
459
+ checks.push({
460
+ level: "warn",
461
+ name: `gate scope (${label})`,
462
+ detail: `runs in full on every attempt, up to maxLoops ${policy.grind.maxLoops}, because ${verdict.reason}. Scope the command itself, or accept the cost knowingly.`,
463
+ });
464
+ }
465
+ return checks;
466
+ }
467
+
468
+ export function checkProjectPolicy(root: string): Check[] {
469
+ const configPath = projectConfigPath(root);
470
+ const stateDir = projectStateDir(root);
471
+ return [
472
+ {
473
+ level: "ok",
474
+ name: "project policy",
475
+ detail: existsSync(configPath) ? configPath : "missing — run: tlc harness init",
476
+ },
477
+ {
478
+ level: "ok",
479
+ name: "state dir",
480
+ detail: existsSync(stateDir) ? stateDir : `${stateDir} (created on first session)`,
481
+ },
482
+ checkPosture(root),
483
+ ...checkObservedRails(root),
484
+ ...checkLessonHealth(root),
485
+ ...checkSubagentAllowlist(root),
486
+ ...checkPolicyDivergence(root),
487
+ ...checkGateScope(root),
488
+ ];
489
+ }
490
+
491
+ export function checkGlobalCommands(home: string): Check {
492
+ const globalCommands = join(home, ".cursor", "commands");
493
+ if (!existsSync(globalCommands)) {
494
+ return {
495
+ level: "ok",
496
+ name: "global commands dir",
497
+ detail: "optional — ~/.cursor/commands for slash commands",
498
+ };
499
+ }
500
+ try {
501
+ const st = lstatSync(globalCommands);
502
+ const detail = st.isSymbolicLink()
503
+ ? `${globalCommands} → ${readlinkSync(globalCommands)}`
504
+ : globalCommands;
505
+ return { level: "ok", name: "global commands dir", detail };
506
+ } catch {
507
+ return { level: "ok", name: "global commands dir", detail: globalCommands };
508
+ }
509
+ }
510
+
511
+ export type DoctorContext = {
512
+ root: string;
513
+ home: string;
514
+ runtimeHome: string;
515
+ platform: NodeJS.Platform;
516
+ nodeVersion: string;
517
+ bunPath: string | null;
518
+ registry: readonly ProviderPort[];
519
+ };
520
+
521
+ export function runChecks(ctx: DoctorContext): Check[] {
522
+ return [
523
+ ...checkNodeVersion(ctx.nodeVersion, ctx.bunPath),
524
+ ...checkRuntimePaths(ctx.runtimeHome, ctx.platform),
525
+ checkHookRuntime(ctx.runtimeHome, ctx.bunPath),
526
+ ...checkProviders(ctx.registry, ctx.runtimeHome),
527
+ ...checkProjectPolicy(ctx.root),
528
+ ...checkCapabilities(ctx.root, ctx.runtimeHome),
529
+ checkGlobalCommands(ctx.home),
530
+ ];
531
+ }
532
+
533
+ export function exitCodeFor(checks: readonly Check[]): number {
534
+ return checks.some((c) => c.level === "fail") ? 1 : 0;
535
+ }
536
+
537
+ export type CheckStatus = "OK" | "WARN" | "FAIL";
538
+
539
+ export type CheckReport = {
540
+ id: string;
541
+ name: string;
542
+ status: CheckStatus;
543
+ detail: string;
544
+ };
545
+
546
+ export type DoctorReport = {
547
+ ok: boolean;
548
+ failed: number;
549
+ warned: number;
550
+ checks: CheckReport[];
551
+ };
552
+
553
+ export function checkId(name: string): string {
554
+ return name
555
+ .toLowerCase()
556
+ .replace(/[^a-z0-9]+/g, "-")
557
+ .replace(/^-|-$/g, "");
558
+ }
559
+
560
+ export function toReport(checks: readonly Check[]): DoctorReport {
561
+ const statuses: Record<CheckLevel, CheckStatus> = { ok: "OK", warn: "WARN", fail: "FAIL" };
562
+ return {
563
+ ok: exitCodeFor(checks) === 0,
564
+ failed: checks.filter((check) => check.level === "fail").length,
565
+ warned: checks.filter((check) => check.level === "warn").length,
566
+ checks: checks.map((check) => ({
567
+ id: checkId(check.name),
568
+ name: check.name,
569
+ status: statuses[check.level],
570
+ detail: check.detail,
571
+ })),
572
+ };
573
+ }
574
+
575
+ export function formatReport(checks: readonly Check[], style: Style = PLAIN): string {
576
+ const marks: Record<CheckLevel, string> = { ok: "OK ", warn: "WARN", fail: "FAIL" };
577
+ const paint: Record<CheckLevel, ColorName> = { ok: "success", warn: "warning", fail: "error" };
578
+ const lines = checks.map(
579
+ (c) =>
580
+ `${style.paint(paint[c.level], marks[c.level])} ${style.paint("textMuted", c.name)} ${style.dim("—")} ${c.detail}`,
581
+ );
582
+ const failed = checks.filter((c) => c.level === "fail").length;
583
+ const warned = checks.filter((c) => c.level === "warn").length;
584
+ lines.push("");
585
+ // hazard: this said "all checks passed" under twelve warnings, which is a contradiction the reader has to resolve
586
+ // by deciding one of the two is lying ([/decisions/ad-034.md](/decisions/ad-034.md)).
587
+ if (failed > 0) {
588
+ lines.push(
589
+ style.paint(
590
+ "error",
591
+ `${SYMBOLS.cross} doctor: ${plural(failed, "failure")}${warned > 0 ? `, ${plural(warned, "warning")}` : ""}`,
592
+ ),
593
+ );
594
+ } else if (warned > 0) {
595
+ lines.push(
596
+ style.paint(
597
+ "warning",
598
+ `${SYMBOLS.warning} doctor: no failures, ${plural(warned, "warning")} to read above`,
599
+ ),
600
+ );
601
+ } else {
602
+ lines.push(style.paint("success", `${SYMBOLS.check} doctor: all checks passed`));
603
+ }
604
+ return lines.join("\n");
605
+ }
606
+
607
+ function realContext(): DoctorContext {
608
+ const home = runtimeHome();
609
+ const bunPath = findBunOnPath();
610
+ writeRuntimeCache(home, bunPath);
611
+ return {
612
+ root: process.env.TLC_PROJECT_DIR ?? process.cwd(),
613
+ home: homedir(),
614
+ runtimeHome: home,
615
+ platform: osPlatform(),
616
+ nodeVersion: process.version,
617
+ bunPath,
618
+ registry: providers,
619
+ };
620
+ }
621
+
622
+ if (import.meta.main) {
623
+ const { json } = takeJsonFlag(process.argv.slice(2));
624
+ const checks = runChecks(realContext());
625
+ if (json) {
626
+ emitJson(toReport(checks));
627
+ } else {
628
+ console.log(formatReport(checks, createStyle()));
629
+ }
630
+ process.exit(exitCodeFor(checks));
631
+ }