@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,214 @@
1
+ import { join } from "node:path";
2
+ import type { ProviderWiring, RuntimePaths, WiringEntry } from "../../contracts/index.ts";
3
+ import { cursorConfigDir } from "../../platform/paths.ts";
4
+
5
+ type EntrySpec = {
6
+ hookEvent: string;
7
+ handler: string;
8
+ timeoutSeconds: number;
9
+ failClosed?: boolean;
10
+ matcher?: string;
11
+ loopLimit?: number;
12
+ };
13
+
14
+ // why: mirrors bin/write-user-hooks.mjs verbatim — same hook keys, timeouts, failClosed/matcher/loopLimit values, and handler order.
15
+ const ENTRY_SPECS: readonly EntrySpec[] = [
16
+ { hookEvent: "sessionStart", handler: "session-start", timeoutSeconds: 10 },
17
+ { hookEvent: "sessionEnd", handler: "session-end", timeoutSeconds: 10 },
18
+ { hookEvent: "beforeSubmitPrompt", handler: "prompt-submit", timeoutSeconds: 5 },
19
+ { hookEvent: "afterAgentThought", handler: "tool-after", timeoutSeconds: 5 },
20
+ { hookEvent: "preCompact", handler: "compact-before", timeoutSeconds: 5 },
21
+ { hookEvent: "subagentStart", handler: "subagent-start", timeoutSeconds: 5, failClosed: true },
22
+ { hookEvent: "subagentStop", handler: "subagent-stop", timeoutSeconds: 5 },
23
+ { hookEvent: "preToolUse", handler: "tool-before", timeoutSeconds: 5, failClosed: true },
24
+ { hookEvent: "postToolUse", handler: "tool-after", timeoutSeconds: 5 },
25
+ { hookEvent: "postToolUseFailure", handler: "tool-failure", timeoutSeconds: 5 },
26
+ { hookEvent: "beforeShellExecution", handler: "tool-before", timeoutSeconds: 10, failClosed: true },
27
+ { hookEvent: "afterShellExecution", handler: "tool-after", timeoutSeconds: 10 },
28
+ { hookEvent: "beforeMCPExecution", handler: "tool-before", timeoutSeconds: 10 },
29
+ { hookEvent: "afterMCPExecution", handler: "tool-after", timeoutSeconds: 5 },
30
+ { hookEvent: "beforeReadFile", handler: "tool-before", timeoutSeconds: 5 },
31
+ { hookEvent: "afterFileEdit", handler: "tool-after", timeoutSeconds: 30, matcher: "Write" },
32
+ { hookEvent: "stop", handler: "stop", timeoutSeconds: 120, loopLimit: 5 },
33
+ { hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" },
34
+ ];
35
+
36
+ function commandFor(runtime: RuntimePaths): { command: string; argsPrefix: string[] } {
37
+ if (process.platform === "win32") {
38
+ return { command: "cmd", argsPrefix: ["/c", "node", runtime.launcherPath] };
39
+ }
40
+ return { command: "node", argsPrefix: [runtime.launcherPath] };
41
+ }
42
+
43
+ export function cursorWiring(runtime: RuntimePaths): ProviderWiring {
44
+ const { command, argsPrefix } = commandFor(runtime);
45
+ const entries: WiringEntry[] = ENTRY_SPECS.map((spec) => ({
46
+ hookEvent: spec.hookEvent,
47
+ handler: spec.handler,
48
+ command,
49
+ args: [...argsPrefix, spec.handler],
50
+ timeoutSeconds: spec.timeoutSeconds,
51
+ ...(spec.failClosed !== undefined ? { failClosed: spec.failClosed } : {}),
52
+ ...(spec.matcher !== undefined ? { matcher: spec.matcher } : {}),
53
+ ...(spec.loopLimit !== undefined ? { loopLimit: spec.loopLimit } : {}),
54
+ }));
55
+
56
+ return {
57
+ target: join(cursorConfigDir(), "hooks.json"),
58
+ strategy: "replace",
59
+ entries,
60
+ };
61
+ }
62
+
63
+ export type WiringProblem = { hookEvent: string; reason: string };
64
+
65
+ /**
66
+ * hazard: splitting on whitespace broke a quoted path, and the writer quotes precisely because a path can contain
67
+ * spaces — a macOS home under "Application Support" is an ordinary shape. A checker that mis-tokenises
68
+ * reports a healthy wiring as broken, which is the failure mode that gets a check switched off.
69
+ */
70
+ export function commandTokens(command: string): string[] {
71
+ return [...command.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => match[1] ?? match[2] ?? "");
72
+ }
73
+
74
+ /**
75
+ * hazard: health was decided by looking for one string. `isCursorWired` checks whether the file *contains*
76
+ * `tlc-exec.mjs`, so a file carrying the marker in one entry and a broken command in another read as fully wired and
77
+ * `doctor` said `wired`. Marker presence answers "is this file ours", which is the right question when deciding
78
+ * whether to overwrite it and the wrong one when deciding whether the hooks work
79
+ * ([/decisions/ad-032.md](/decisions/ad-032.md)).
80
+ *
81
+ * why: a colleague's session was blocked by a `preToolUse` whose command was a bare `node`, so Node read the hook
82
+ * payload as a program. The entry was `failClosed`, so the crash blocked the tool rather than merely logging. Nothing
83
+ * checked that a wired hook could run.
84
+ *
85
+ * invariant: only the events this provider declares are inspected, and within them only entries whose command names
86
+ * our launcher. A hook belonging to another tool is not ours to judge, and flagging it would train an operator to
87
+ * ignore the check.
88
+ */
89
+ export function cursorWiringProblems(
90
+ text: string | null,
91
+ runtime: RuntimePaths,
92
+ fileExists: (path: string) => boolean,
93
+ ): WiringProblem[] {
94
+ if (text === null) {
95
+ return [{ hookEvent: "(file)", reason: "no hooks file at the expected path" }];
96
+ }
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(text);
100
+ } catch {
101
+ return [{ hookEvent: "(file)", reason: "the hooks file is not valid JSON" }];
102
+ }
103
+ const hooks =
104
+ parsed !== null && typeof parsed === "object"
105
+ ? ((parsed as { hooks?: Record<string, unknown> }).hooks ?? {})
106
+ : {};
107
+
108
+ const problems: WiringProblem[] = [];
109
+ for (const spec of ENTRY_SPECS) {
110
+ const list = Array.isArray(hooks[spec.hookEvent]) ? (hooks[spec.hookEvent] as unknown[]) : [];
111
+ const commands = list
112
+ .map((row) =>
113
+ row !== null && typeof row === "object" ? String((row as { command?: unknown }).command ?? "") : "",
114
+ )
115
+ // why: our entries are the ones naming our launcher. Everything else in the file belongs to someone else.
116
+ .filter((command) => command.includes(runtime.launcherPath));
117
+ if (commands.length === 0) {
118
+ problems.push({ hookEvent: spec.hookEvent, reason: "no harness entry — run: tlc harness update" });
119
+ continue;
120
+ }
121
+ for (const command of commands) {
122
+ const tokens = commandTokens(command);
123
+ const scriptAt = tokens.indexOf(runtime.launcherPath);
124
+ if (scriptAt < 1) {
125
+ problems.push({
126
+ hookEvent: spec.hookEvent,
127
+ reason: `no executable before the script: \`${command}\``,
128
+ });
129
+ continue;
130
+ }
131
+ if (!fileExists(runtime.launcherPath)) {
132
+ problems.push({
133
+ hookEvent: spec.hookEvent,
134
+ reason: `the script does not exist: ${runtime.launcherPath}`,
135
+ });
136
+ continue;
137
+ }
138
+ if (tokens[scriptAt + 1] === undefined || tokens[scriptAt + 1] === "") {
139
+ problems.push({
140
+ hookEvent: spec.hookEvent,
141
+ // why: the exact shape from the incident. A command with the script and no handler makes the launcher
142
+ // exit 2 with usage, and a command with neither makes Node read the payload as a program.
143
+ reason: `no handler after the script: \`${command}\``,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return problems;
149
+ }
150
+
151
+ /** why: bounded, because a fresh install with no wiring produces one problem per declared event. */
152
+ export function formatWiringProblems(problems: readonly WiringProblem[], max = 3): string {
153
+ const shown = problems
154
+ .slice(0, max)
155
+ .map((problem) => `${problem.hookEvent}: ${problem.reason}`)
156
+ .join("; ");
157
+ const rest = problems.length - Math.min(problems.length, max);
158
+ return rest > 0 ? `${shown}; and ${rest} more` : shown;
159
+ }
160
+
161
+ export type CursorUnwire =
162
+ | { kind: "absent" }
163
+ | { kind: "unparsed" }
164
+ | { kind: "empty"; removed: number }
165
+ | { kind: "rewritten"; removed: number; text: string };
166
+
167
+ /**
168
+ * The inverse of the document `bin/write-user-hooks.mjs` writes.
169
+ *
170
+ * invariant: an entry is ours when its command names the launcher — the same test `cursorWiringProblems` applies
171
+ * one function above. `kind: "empty"` means every entry in the file was ours and the file itself can go; a file
172
+ * that still holds somebody else's hook is rewritten without ours ([/decisions/ad-066.md](/decisions/ad-066.md)).
173
+ */
174
+ export function unwireCursorHooks(text: string | null, marker = "tlc-exec.mjs"): CursorUnwire {
175
+ if (text === null || text.trim() === "") {
176
+ return { kind: "absent" };
177
+ }
178
+ let parsed: unknown;
179
+ try {
180
+ parsed = JSON.parse(text);
181
+ } catch {
182
+ return { kind: "unparsed" };
183
+ }
184
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
185
+ return { kind: "unparsed" };
186
+ }
187
+ const document = parsed as { hooks?: unknown };
188
+ const hooks =
189
+ document.hooks !== null && typeof document.hooks === "object" && !Array.isArray(document.hooks)
190
+ ? (document.hooks as Record<string, unknown>)
191
+ : {};
192
+
193
+ const remaining: Record<string, unknown[]> = {};
194
+ let removed = 0;
195
+ let kept = 0;
196
+ for (const [hookEvent, value] of Object.entries(hooks)) {
197
+ const list = Array.isArray(value) ? value : [];
198
+ const foreign = list.filter((row) => !JSON.stringify(row ?? null).includes(marker));
199
+ removed += list.length - foreign.length;
200
+ if (foreign.length > 0) {
201
+ remaining[hookEvent] = foreign;
202
+ kept += foreign.length;
203
+ }
204
+ }
205
+
206
+ if (kept === 0) {
207
+ return { kind: "empty", removed };
208
+ }
209
+ return {
210
+ kind: "rewritten",
211
+ removed,
212
+ text: `${JSON.stringify({ ...document, hooks: remaining }, null, 2)}\n`,
213
+ };
214
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProviderPort } from "../provider.port.ts";
2
+ import { cursorCapabilities } from "./cursor.capabilities.ts";
3
+ import { detectCursor } from "./cursor.detect.ts";
4
+ import { cursorToEvent } from "./cursor.inbound.ts";
5
+ import { cursorRender } from "./cursor.outbound.ts";
6
+ import { cursorPolicyDefaults } from "./cursor.policy-defaults.ts";
7
+ import { cursorWiring } from "./cursor.wiring.ts";
8
+
9
+ export const cursorProvider: ProviderPort = {
10
+ name: "cursor",
11
+ detect: detectCursor,
12
+ capabilities: cursorCapabilities,
13
+ policyDefaults: cursorPolicyDefaults,
14
+ toEvent: cursorToEvent,
15
+ render: cursorRender,
16
+ wiring: cursorWiring,
17
+ };
@@ -0,0 +1,9 @@
1
+ export { renderClaudeLessonsView } from "./claude/claude.lessons-view.ts";
2
+ export type { TranscriptUsage } from "./claude/claude.transcript.ts";
3
+ export { readClaudeUsage } from "./claude/claude.transcript.ts";
4
+ export { renderCursorLessonsView } from "./cursor/cursor.lessons-view.ts";
5
+ export type { DegradeOptions } from "./provider.degrade.ts";
6
+ export { degrade, truncateContext } from "./provider.degrade.ts";
7
+ export type { ProviderPort } from "./provider.port.ts";
8
+ export type { ResolveResult } from "./provider.registry.ts";
9
+ export { providers, resolveFromRegistry, resolveProvider } from "./provider.registry.ts";
@@ -0,0 +1,152 @@
1
+ import type { Decision, HarnessEvent, ProviderCapabilities } from "../contracts/index.ts";
2
+
3
+ export type DegradeOptions = {
4
+ /** Character budget for `context` text. Undefined means no truncation. */
5
+ contextBudgetChars?: number;
6
+ };
7
+
8
+ /**
9
+ * why: the only rule this layer may name. Every other decision arrives with its rule already set by the rail that
10
+ * made it, and degrade preserves it ([/decisions/ad-061.md](/decisions/ad-061.md)).
11
+ */
12
+ export const DEGRADE_RULES = { rewriteUnavailable: "rewrite-unavailable" } as const;
13
+
14
+ const ESCALATION_PREFIX = "Escalation unavailable on this provider — ";
15
+ const NO_HUMAN_PREFIX = "No operator is answering prompts in this permission mode — ";
16
+
17
+ // hazard: these modes exist to stop prompting the operator, so an `ask` raised under them reaches
18
+ // nobody. Whether the provider drops it or silently allows the call is undocumented, and a gate
19
+ // whose outcome depends on undocumented behaviour is not a gate — deny and say why instead.
20
+ const NO_HUMAN_MODES = new Set(["bypassPermissions", "dontAsk"]);
21
+ const ADVISORY_PREFIX = "ADVISORY — this provider cannot enforce: ";
22
+ const TRUNCATION_MARKER = "\n…(truncated — over context budget)";
23
+
24
+ function isEnforcing(decision: Decision): boolean {
25
+ return (
26
+ decision.kind === "deny" ||
27
+ decision.kind === "ask" ||
28
+ decision.kind === "continue" ||
29
+ decision.kind === "rewriteInput"
30
+ );
31
+ }
32
+
33
+ function describeDecision(decision: Decision): string {
34
+ switch (decision.kind) {
35
+ case "deny":
36
+ case "ask":
37
+ return decision.reason;
38
+ case "continue":
39
+ return decision.text;
40
+ case "rewriteInput":
41
+ return `${decision.reason} (proposed input: ${JSON.stringify(decision.input)})`;
42
+ default:
43
+ return "";
44
+ }
45
+ }
46
+
47
+ export function truncateContext(text: string, budgetChars: number): string {
48
+ if (text.length <= budgetChars) {
49
+ return text;
50
+ }
51
+ if (budgetChars <= 0) {
52
+ return "";
53
+ }
54
+ const marker =
55
+ TRUNCATION_MARKER.length <= budgetChars ? TRUNCATION_MARKER : TRUNCATION_MARKER.slice(0, budgetChars);
56
+ const keep = Math.max(0, budgetChars - marker.length);
57
+ return `${text.slice(0, keep)}${marker}`;
58
+ }
59
+
60
+ function canCarryContext(event: HarnessEvent, capabilities: ProviderCapabilities): boolean {
61
+ if (event.event === "tool.before") {
62
+ return capabilities.contextAtToolBefore;
63
+ }
64
+ if (event.event === "tool.after") {
65
+ return capabilities.contextAtToolAfter;
66
+ }
67
+ // hazard: the docs gate raises an advisory here. Cursor's `stop` schema carries `followup_message` and nothing
68
+ // else, so the advisory was rendered into `additional_context` and read by no one. `followup_message` is not the
69
+ // fallback — it auto-submits, and the advisory says in its own words that it does not block the stop.
70
+ if (event.event === "stop") {
71
+ return capabilities.contextAtStop;
72
+ }
73
+ // why: `session.start` stays true even where the host loses the text. Cursor's drop is a race rather than a
74
+ // refusal, so the emission is free when lost and delivered when won — the durable view is the second route,
75
+ // not the replacement ([/decisions/ad-050.md](/decisions/ad-050.md)).
76
+ return true;
77
+ }
78
+
79
+ function applyContextBudget(decision: Decision, budgetChars: number | undefined): Decision {
80
+ if (decision.kind !== "context" || budgetChars === undefined) {
81
+ return decision;
82
+ }
83
+ const truncated = truncateContext(decision.text, budgetChars);
84
+ if (truncated === decision.text) {
85
+ return decision;
86
+ }
87
+ return { ...decision, text: truncated };
88
+ }
89
+
90
+ export function degrade(
91
+ decision: Decision,
92
+ event: HarnessEvent,
93
+ capabilities: ProviderCapabilities,
94
+ options: DegradeOptions = {},
95
+ ): Decision {
96
+ if (!capabilities.enforcesHooks && isEnforcing(decision)) {
97
+ return applyContextBudget(
98
+ { kind: "context", text: `${ADVISORY_PREFIX}${describeDecision(decision)}` },
99
+ options.contextBudgetChars,
100
+ );
101
+ }
102
+
103
+ if (decision.kind === "ask") {
104
+ if (!capabilities.askSupportedOn.includes(event.event)) {
105
+ // invariant: the rule is carried through, never replaced. This is the one place a decision changes shape,
106
+ // so it is the case an operator most needs attributed — and inventing a rule here would attribute a refusal
107
+ // to the transport rather than to the rail that made it ([/decisions/ad-061.md](/decisions/ad-061.md)).
108
+ return {
109
+ kind: "deny",
110
+ reason: `${ESCALATION_PREFIX}${decision.reason}`,
111
+ userNote: decision.userNote,
112
+ rule: decision.rule,
113
+ };
114
+ }
115
+ if (event.permissionMode !== undefined && NO_HUMAN_MODES.has(event.permissionMode)) {
116
+ return {
117
+ kind: "deny",
118
+ reason: `${NO_HUMAN_PREFIX}${decision.reason}`,
119
+ userNote: decision.userNote,
120
+ rule: decision.rule,
121
+ };
122
+ }
123
+ return decision;
124
+ }
125
+
126
+ if (decision.kind === "rewriteInput" && !capabilities.toolInputRewrite) {
127
+ return {
128
+ kind: "ask",
129
+ reason: `Input rewrite unavailable on this provider — proposed input: ${JSON.stringify(decision.input)}. ${decision.reason}`,
130
+ // why: a rewrite carries no rule of its own — nothing refused anything — so the degraded ask names the
131
+ // transport limit that produced it. It is the one rule this layer owns.
132
+ rule: DEGRADE_RULES.rewriteUnavailable,
133
+ };
134
+ }
135
+
136
+ if (decision.kind === "context") {
137
+ // hazard: contextAtToolBefore and contextAtToolAfter were declared by every adapter and read by
138
+ // nothing. A provider that cannot carry context on the current event would have had the text rendered
139
+ // into a field it ignores, leaving the caller believing it was delivered. Abstaining is the honest
140
+ // answer: context is informative, so there is nothing to escalate to.
141
+ if (!canCarryContext(event, capabilities)) {
142
+ return { kind: "abstain" };
143
+ }
144
+ if (decision.env && !capabilities.sessionEnv) {
145
+ const { env: _droppedEnv, ...withoutEnv } = decision;
146
+ return applyContextBudget(withoutEnv, options.contextBudgetChars);
147
+ }
148
+ return applyContextBudget(decision, options.contextBudgetChars);
149
+ }
150
+
151
+ return decision;
152
+ }
@@ -0,0 +1,20 @@
1
+ import type {
2
+ Decision,
3
+ HarnessEvent,
4
+ ProviderCapabilities,
5
+ ProviderPolicyDefaults,
6
+ ProviderWiring,
7
+ Rendered,
8
+ RuntimePaths,
9
+ } from "../contracts/index.ts";
10
+
11
+ /** Core never imports this type — it receives a HarnessEvent and ProviderCapabilities as plain arguments instead. */
12
+ export type ProviderPort = {
13
+ readonly name: string;
14
+ detect(raw: unknown): boolean;
15
+ capabilities(): ProviderCapabilities;
16
+ policyDefaults(): ProviderPolicyDefaults;
17
+ toEvent(raw: Record<string, unknown>): HarnessEvent | null;
18
+ render(decision: Decision, event: HarnessEvent): Rendered;
19
+ wiring(runtime: RuntimePaths): ProviderWiring;
20
+ };
@@ -0,0 +1,28 @@
1
+ import { claudeProvider } from "./claude/index.ts";
2
+ import { cursorProvider } from "./cursor/index.ts";
3
+ import type { ProviderPort } from "./provider.port.ts";
4
+
5
+ export type ResolveResult = {
6
+ provider: ProviderPort | null;
7
+ ambiguous: boolean;
8
+ matchedNames: readonly string[];
9
+ };
10
+
11
+ // invariant: detection order is registry order — deterministic, never re-sorted.
12
+ export const providers: ProviderPort[] = [cursorProvider, claudeProvider];
13
+
14
+ export function resolveFromRegistry(raw: unknown, registry: readonly ProviderPort[]): ResolveResult {
15
+ const matched = registry.filter((provider) => provider.detect(raw));
16
+ if (matched.length === 0) {
17
+ return { provider: null, ambiguous: false, matchedNames: [] };
18
+ }
19
+ return {
20
+ provider: matched[0] ?? null,
21
+ ambiguous: matched.length > 1,
22
+ matchedNames: matched.map((provider) => provider.name),
23
+ };
24
+ }
25
+
26
+ export function resolveProvider(raw: unknown): ResolveResult {
27
+ return resolveFromRegistry(raw, providers);
28
+ }