@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,16 @@
1
+ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
+ import { coreFacade } from "../core/index.ts";
3
+ import type { Handler, HandlerContext } from "./run.ts";
4
+ import { main } from "./run.ts";
5
+ import { subagentSpawnInput } from "./support.ts";
6
+
7
+ export const subagentStartHandler: Handler = (event: HarnessEvent, ctx: HandlerContext): Decision => {
8
+ const { policy, provider } = ctx;
9
+ return coreFacade.subagentPolicy.evaluateSubagentSpawn(
10
+ subagentSpawnInput(event, policy, provider, event.spawnModel ?? ""),
11
+ );
12
+ };
13
+
14
+ if (import.meta.main) {
15
+ await main(subagentStartHandler);
16
+ }
@@ -0,0 +1,32 @@
1
+ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
+ import { coreFacade } from "../core/index.ts";
3
+ import type { Handler, HandlerContext } from "./run.ts";
4
+ import { main } from "./run.ts";
5
+
6
+ // why: no legacy predecessor covers subagent.stop verification — this reuses the same unfinished-work
7
+ // signal (blockers/pending/in_progress/previous_gaps) already carried on the handoff slice.
8
+ export const subagentStopHandler: Handler = (event: HarnessEvent, _ctx: HandlerContext): Decision => {
9
+ const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
10
+ const unfinishedWork =
11
+ Boolean(handoff.blockers) ||
12
+ Boolean(handoff.previous_gaps?.length) ||
13
+ Boolean(handoff.pending?.length) ||
14
+ Boolean(handoff.in_progress?.length);
15
+
16
+ if (!unfinishedWork) {
17
+ return { kind: "abstain" };
18
+ }
19
+
20
+ return {
21
+ kind: "continue",
22
+ text: [
23
+ "BLOCKED: unfinished work remains before this subagent stops.",
24
+ `TRIED: subagent (${event.spawnSubagentType ?? "unknown"}) reported stop.`,
25
+ `NEED: ${handoff.next_action ?? "resolve the open blockers before ending this subagent turn."}`,
26
+ ].join("\n"),
27
+ };
28
+ };
29
+
30
+ if (import.meta.main) {
31
+ await main(subagentStopHandler);
32
+ }
@@ -0,0 +1,182 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { EffortLevel, HarnessEvent } from "../contracts/index.ts";
4
+ import { coreFacade, type HarnessLesson, type ObservabilityConfig, type Policy } from "../core/index.ts";
5
+ import { runProcess } from "../platform/process.ts";
6
+ import { type ProviderPort, renderClaudeLessonsView, renderCursorLessonsView } from "../providers/index.ts";
7
+
8
+ // invariant: one definition, taken from core rather than restated.
9
+ export const OBS_CONFIG = coreFacade.observability.DEFAULT_OBS;
10
+
11
+ // why: tool.end, shell.end, mcp.end and file.edit are debug-level kinds, so the passive audit trail only
12
+ // persists when debug writing is on. The difference from OBS_CONFIG is stated here once instead of being
13
+ // re-declared per entrypoint.
14
+ export const OBS_CONFIG_AUDIT = { ...OBS_CONFIG, debugEnabled: true };
15
+
16
+ // why: the base configs are module constants, so the one operator-controlled field has to be layered on
17
+ // per call rather than baked in at import time.
18
+ export function obsConfigFor(
19
+ policy: { obs: Policy["obs"] },
20
+ base: ObservabilityConfig = OBS_CONFIG,
21
+ ): ObservabilityConfig {
22
+ return {
23
+ ...base,
24
+ globalSpool: policy.obs.globalSpool,
25
+ // why: debugEnabled is deliberately absent from Policy.obs. The only events that resolve to debug level
26
+ // are emitted with OBS_CONFIG_AUDIT, which forces it on for the audit trail (AD-016 item 7), so there is
27
+ // nothing a project could switch. Exposing it would repeat the dead-section mistake this replaces.
28
+ includePayloads: policy.obs.includePayloads,
29
+ maxAttrChars: policy.obs.maxAttrChars,
30
+ sessionCostAlertUsd: policy.obs.sessionCostAlertUsd,
31
+ retentionDays: policy.obs.retentionDays,
32
+ };
33
+ }
34
+
35
+ /** Characters on disk, or zero when the file went away between the write and the read. */
36
+ export function sizeOf(path: string): number {
37
+ try {
38
+ return statSync(path).size;
39
+ } catch {
40
+ return 0;
41
+ }
42
+ }
43
+
44
+ export function sessionIdFromKey(event: HarnessEvent): string {
45
+ const prefix = `${event.provider}-`;
46
+ return event.sessionKey.startsWith(prefix) ? event.sessionKey.slice(prefix.length) : event.sessionKey;
47
+ }
48
+
49
+ export async function currentGitBranch(root: string): Promise<string | null> {
50
+ if (!existsSync(join(root, ".git"))) {
51
+ return null;
52
+ }
53
+ const result = await runProcess({ command: ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd: root });
54
+ if (result.exitCode !== 0) {
55
+ return null;
56
+ }
57
+ const branch = result.stdout.trim();
58
+ return branch.length > 0 ? branch : null;
59
+ }
60
+
61
+ export async function currentGitSha(root: string): Promise<string | null> {
62
+ if (!existsSync(join(root, ".git"))) {
63
+ return null;
64
+ }
65
+ const result = await runProcess({ command: ["git", "rev-parse", "--short", "HEAD"], cwd: root });
66
+ if (result.exitCode !== 0) {
67
+ return null;
68
+ }
69
+ const sha = result.stdout.trim();
70
+ return sha.length > 0 ? sha : null;
71
+ }
72
+
73
+ /**
74
+ * hazard: this fell back to a shipped list whenever the project's was empty, so a spawn could be refused by an
75
+ * allowlist that exists nowhere in the project — and the refusal named no source, so an operator reading `[]` in
76
+ * their own config could only conclude that empty meant none. There is no shipped list now: the effective one is
77
+ * exactly what the project configured ([/decisions/ad-053.md](/decisions/ad-053.md)).
78
+ */
79
+ export function effectiveAllowedModels(
80
+ configured: string[] | Record<string, string[]> | undefined,
81
+ provider: ProviderPort,
82
+ ): string[] {
83
+ return coreFacade.policy.forProvider(configured, provider.name) ?? [];
84
+ }
85
+
86
+ export function effectiveBlockedPatterns(
87
+ configured: string[] | Record<string, string[]> | undefined,
88
+ provider: ProviderPort,
89
+ ): string[] {
90
+ const fromConfig = coreFacade.policy.forProvider(configured, provider.name) ?? [];
91
+ return [...fromConfig, ...provider.policyDefaults().blockedPatterns];
92
+ }
93
+
94
+ export function effectiveMinEffort(
95
+ configured: EffortLevel | null,
96
+ provider: ProviderPort,
97
+ ): EffortLevel | null {
98
+ return configured ?? provider.policyDefaults().minEffort;
99
+ }
100
+
101
+ /**
102
+ * Everything `evaluateSubagentSpawn` needs about a spawn, assembled once.
103
+ *
104
+ * hazard: `subagent-start` and `tool-before` each built this object, seven identical lines apart from the
105
+ * indentation, so a new field in `policy.subagents` had to be remembered in two places — the shape where a
106
+ * consumer stops growing with its producer ([/decisions/ad-065.md](/decisions/ad-065.md)). The duplication rail
107
+ * found it on its first honest run ([/decisions/ad-071.md](/decisions/ad-071.md)).
108
+ */
109
+ export function subagentSpawnInput(
110
+ event: HarnessEvent,
111
+ policy: Policy,
112
+ provider: ProviderPort,
113
+ model: string,
114
+ ): Parameters<typeof coreFacade.subagentPolicy.evaluateSubagentSpawn>[0] {
115
+ return {
116
+ provider: provider.name,
117
+ sessionKey: event.sessionKey,
118
+ projectDir: event.projectDir,
119
+ model,
120
+ effort: event.effort,
121
+ allowedModels: effectiveAllowedModels(policy.subagents.allowedModels, provider),
122
+ blockedPatterns: effectiveBlockedPatterns(policy.subagents.blockedPatterns, provider),
123
+ minEffort: effectiveMinEffort(policy.subagents.minEffort, provider),
124
+ requireModel: policy.subagents.requireModel,
125
+ enforceAllowlist: policy.subagents.enforceAllowlist,
126
+ blockParentFast: policy.subagents.blockParentFast,
127
+ blockMode: policy.subagents.blockMode,
128
+ };
129
+ }
130
+
131
+ export function readModelFromToolInput(toolInput: Record<string, unknown> | undefined): string {
132
+ if (!toolInput) {
133
+ return "";
134
+ }
135
+ const model = toolInput.model ?? toolInput.Model;
136
+ return typeof model === "string" ? model : "";
137
+ }
138
+
139
+ /**
140
+ * hazard: this used to be a copy of the core renderer, on the reasoning that presentation is not core's business.
141
+ * The copy is what the model actually receives, so the tier added to the core block rendered in `lessons list` and
142
+ * in nothing an agent ever saw. Two renderers for one string is the same defect as a consumer without a producer,
143
+ * pointed sideways ([/decisions/ad-040.md](/decisions/ad-040.md)).
144
+ */
145
+ export function renderLessonLine(lesson: HarnessLesson): string {
146
+ return coreFacade.lesson.renderLessonBlock(lesson);
147
+ }
148
+
149
+ /**
150
+ * invariant: one dispatcher, imported by both session entrypoints. The durable view is written at session start and
151
+ * again at session end, and a copy of this switch in each would be the AD-042 defect a second time.
152
+ */
153
+ export function renderProviderLessonsView(providerName: string, root: string): string | null {
154
+ if (providerName === "cursor") {
155
+ return renderCursorLessonsView(root);
156
+ }
157
+ if (providerName === "claude") {
158
+ return renderClaudeLessonsView(root);
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * why: `omitted` is rendered because the char budget silently cuts below `maxInjectSession` — the count promises
165
+ * five and a 900-char budget fits about two. A reader who cannot tell that eligible lessons were dropped has no
166
+ * way to know the budget is the binding constraint ([/decisions/ad-043.md](/decisions/ad-043.md)).
167
+ *
168
+ * invariant: silent when nothing was dropped. A note on every healthy turn is one more line to skim past.
169
+ */
170
+ export function formatLessonsBlock(lessons: HarnessLesson[], title: string, omitted = 0): string {
171
+ if (lessons.length === 0) {
172
+ return "";
173
+ }
174
+ const lines = [title, ...lessons.map(renderLessonLine)];
175
+ if (omitted > 0) {
176
+ const noun = omitted === 1 ? "lesson" : "lessons";
177
+ lines.push(
178
+ ` (${omitted} more eligible ${noun} omitted under the char budget — raise maxCharsSession to see them)`,
179
+ );
180
+ }
181
+ return lines.join("\n");
182
+ }
@@ -0,0 +1,111 @@
1
+ import type { HarnessEvent, HarnessEventKind } from "../contracts/index.ts";
2
+ import { coreFacade, type ObsKind } from "../core/index.ts";
3
+ import { estimateCostUsd, mapPoolToNeutral } from "../platform/pricing.ts";
4
+ import { readClaudeUsage } from "../providers/index.ts";
5
+ import type { Handler, HandlerContext } from "./run.ts";
6
+ import { main } from "./run.ts";
7
+ import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
8
+
9
+ const OBS_KIND_BY_EVENT: Partial<Record<HarnessEventKind, ObsKind>> = {
10
+ "tool.after": "tool.end",
11
+ "shell.after": "shell.end",
12
+ "mcp.after": "mcp.end",
13
+ "edit.after": "file.edit",
14
+ };
15
+
16
+ function rawString(raw: Record<string, unknown>, key: string): string | undefined {
17
+ const value = raw[key];
18
+ return typeof value === "string" ? value : undefined;
19
+ }
20
+
21
+ function rawBoolean(raw: Record<string, unknown>, key: string): boolean | undefined {
22
+ const value = raw[key];
23
+ return typeof value === "boolean" ? value : undefined;
24
+ }
25
+
26
+ function usageGenAi(event: HarnessEvent, ctx: HandlerContext): Record<string, unknown> | undefined {
27
+ if (ctx.capabilities.usageInPayload || !event.transcriptPath) {
28
+ return undefined;
29
+ }
30
+ const usage = readClaudeUsage(event.transcriptPath);
31
+ if (!usage) {
32
+ return undefined;
33
+ }
34
+ const cost = estimateCostUsd(event.provider, event.model, {
35
+ inputTokens: usage.inputTokens,
36
+ outputTokens: usage.outputTokens,
37
+ cacheReadTokens: usage.cacheReadTokens,
38
+ cacheWriteTokens: usage.cacheWriteTokens,
39
+ });
40
+ return {
41
+ input_tokens: usage.inputTokens,
42
+ output_tokens: usage.outputTokens,
43
+ cache_read_tokens: usage.cacheReadTokens,
44
+ cache_write_tokens: usage.cacheWriteTokens,
45
+ cost_usd: cost.costUsd,
46
+ cost_source: cost.source,
47
+ cost_pool: mapPoolToNeutral(cost.pool),
48
+ };
49
+ }
50
+
51
+ export const toolAfterHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
52
+ coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
53
+
54
+ const kind = OBS_KIND_BY_EVENT[event.event];
55
+ if (kind) {
56
+ const attrs: Record<string, unknown> = {
57
+ tool_name: event.toolName,
58
+ command: event.command,
59
+ file_path: event.filePath,
60
+ };
61
+ if (event.event === "shell.after") {
62
+ attrs.cwd = rawString(event.raw, "cwd");
63
+ attrs.sandbox = rawBoolean(event.raw, "sandbox");
64
+ }
65
+
66
+ coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy, OBS_CONFIG_AUDIT), {
67
+ provider: event.provider,
68
+ kind,
69
+ sessionKey: event.sessionKey,
70
+ model: event.model,
71
+ attrs,
72
+ gen_ai: usageGenAi(event, ctx),
73
+ });
74
+ }
75
+
76
+ // hazard: no degrade path consults contextAtToolAfter — the capability is declared but unread, so a
77
+ // provider that cannot carry context on this event would swallow the framing and leave the rail reporting
78
+ // a protection it never delivered. Abstaining keeps the marker unset, so a later event can still speak.
79
+ if (!ctx.capabilities.contextAtToolAfter) {
80
+ return { kind: "abstain" };
81
+ }
82
+
83
+ // why: recorded before the framing is decided, because the framing fires once per turn and the content of every
84
+ // untrusted read still has to be remembered ([/decisions/ad-077.md](/decisions/ad-077.md)).
85
+ if (ctx.provider.capabilities().toolOutputAtAfter) {
86
+ coreFacade.untrusted.rememberUntrustedOutput({
87
+ root: event.projectDir,
88
+ sessionKey: event.sessionKey,
89
+ event: event.event,
90
+ toolName: event.toolName,
91
+ command: event.command,
92
+ toolOutput: event.toolOutput,
93
+ config: ctx.policy.untrustedContent,
94
+ providerTools: ctx.provider.policyDefaults().untrustedTools,
95
+ });
96
+ }
97
+
98
+ return coreFacade.untrusted.evaluateUntrustedContent({
99
+ root: event.projectDir,
100
+ sessionKey: event.sessionKey,
101
+ event: event.event,
102
+ toolName: event.toolName,
103
+ command: event.command,
104
+ config: ctx.policy.untrustedContent,
105
+ providerTools: ctx.provider.policyDefaults().untrustedTools,
106
+ });
107
+ };
108
+
109
+ if (import.meta.main) {
110
+ await main(toolAfterHandler);
111
+ }
@@ -0,0 +1,186 @@
1
+ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
+ import { coreFacade } from "../core/index.ts";
3
+ import type { Handler, HandlerContext } from "./run.ts";
4
+ import { main } from "./run.ts";
5
+ import { obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
6
+
7
+ const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
8
+
9
+ /**
10
+ * hazard: `attrs.permission` was read in two places and written in none. `observability.service.ts` increments
11
+ * `shell.ask`/`shell.deny` from it, `observability.types.ts` grades an event `signal` when it is not `allow`, and
12
+ * the session report prints `Shell allow/ask/deny` — so both counters were structurally zero and the report
13
+ * printed a truthful-looking `0` for every ask that ever happened. Obs was emitted only on `*.after` events,
14
+ * which means the moment a decision is made was the one moment never recorded.
15
+ *
16
+ * why: the base config leaves `debugEnabled` false, and an `allow` grades as debug. So an allow is computed and
17
+ * dropped — costing nothing in the common path — while asks and denials reach disk. `shell.allow` keeps coming
18
+ * from `shell.end`, so nothing is double-counted.
19
+ *
20
+ * invariant: recorded after the decision and never able to change it. A rail that measures interruptions must not
21
+ * become one.
22
+ */
23
+ function recordShellDecision(event: HarnessEvent, ctx: HandlerContext, decision: Decision): void {
24
+ coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy), {
25
+ provider: event.provider,
26
+ kind: "shell.start",
27
+ sessionKey: event.sessionKey,
28
+ model: event.model,
29
+ attrs: {
30
+ command: event.command,
31
+ permission: decision.kind,
32
+ posture: ctx.policy.mode,
33
+ // why: unattributed rather than guessed. A rate an operator cannot trace to a switch is a number, not a
34
+ // signal.
35
+ rule: "rule" in decision && decision.rule ? decision.rule : "none",
36
+ },
37
+ });
38
+ }
39
+
40
+ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, decision: Decision): void {
41
+ if (event.event === "shell.before") {
42
+ recordShellDecision(event, ctx, decision);
43
+ }
44
+ }
45
+
46
+ function handleShellBefore(event: HarnessEvent, ctx: HandlerContext): Decision {
47
+ const { policy } = ctx;
48
+ const decision = coreFacade.shellPolicy.evaluateShellCommand({
49
+ command: event.command ?? "",
50
+ sessionKey: event.sessionKey,
51
+ projectDir: event.projectDir,
52
+ mode: policy.mode,
53
+ catastrophicAsk: policy.shell.catastrophicAsk,
54
+ stallDetection: policy.shell.stallDetection,
55
+ stallRepeatThreshold: policy.shell.stallRepeatThreshold,
56
+ });
57
+ recordShellDecision(event, ctx, decision);
58
+ return decision;
59
+ }
60
+
61
+ // why: a read cannot mutate the policy surface, so it is the one class of event that stays available while a
62
+ // divergence is unresolved. Without it the agent cannot even read the file that explains the block.
63
+ function isReadOnlyEvent(event: HarnessEvent): boolean {
64
+ return event.event === "read.before" || event.event === "mcp.before";
65
+ }
66
+
67
+ function filePathOf(event: HarnessEvent): string | undefined {
68
+ if (event.filePath) {
69
+ return event.filePath;
70
+ }
71
+ const fromInput = event.toolInput?.file_path;
72
+ return typeof fromInput === "string" ? fromInput : undefined;
73
+ }
74
+
75
+ async function handleToolBefore(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
76
+ const { policy, provider } = ctx;
77
+
78
+ const isReadOnlySubagent =
79
+ event.subagentType !== undefined && policy.subagents.readOnlyTypes.includes(event.subagentType);
80
+ if (isReadOnlySubagent && event.toolName !== undefined && READONLY_BLOCKED_TOOLS.has(event.toolName)) {
81
+ return {
82
+ kind: "deny",
83
+ reason: `Explore/read-only subagents cannot use ${event.toolName}. Return findings to the parent agent.`,
84
+ rule: "subagent-read-only",
85
+ };
86
+ }
87
+
88
+ if (event.toolName === "Task") {
89
+ const model = event.spawnModel ?? readModelFromToolInput(event.toolInput);
90
+ const spawnDecision = coreFacade.subagentPolicy.evaluateSubagentSpawn(
91
+ subagentSpawnInput(event, policy, provider, model),
92
+ );
93
+ if (spawnDecision.kind !== "allow") {
94
+ return spawnDecision;
95
+ }
96
+ }
97
+
98
+ if (event.toolName === "Edit" || event.toolName === "Write") {
99
+ const filePath = filePathOf(event);
100
+ if (filePath) {
101
+ const collision = coreFacade.presence.checkCollision(event.projectDir, filePath, event.sessionKey);
102
+ if (collision.kind !== "allow") {
103
+ return collision;
104
+ }
105
+ }
106
+ }
107
+
108
+ return { kind: "allow" };
109
+ }
110
+
111
+ export const toolBeforeHandler: Handler = (
112
+ event: HarnessEvent,
113
+ ctx: HandlerContext,
114
+ ): Decision | Promise<Decision> => {
115
+ // invariant: the floor runs first and reads no policy, so no config value and no agent edit can
116
+ // reach a decision before it.
117
+ const floor = coreFacade.floor.evaluateFloor({
118
+ projectDir: event.projectDir,
119
+ toolName: event.toolName,
120
+ filePath: filePathOf(event),
121
+ command: event.command,
122
+ isReadEvent: event.event === "read.before",
123
+ });
124
+ if (floor.kind !== "allow") {
125
+ // invariant: one rail owns the record of every shell decision. The floor short-circuits before the shell
126
+ // rail runs, so without this a floor denial of a shell command was recorded by nothing at all — and the
127
+ // shared refusal path deliberately skips `shell.before` to avoid double-counting what this rail owns.
128
+ recordShellDecisionIfShell(event, ctx, floor);
129
+ return floor;
130
+ }
131
+
132
+ /**
133
+ * why: after the floor, because the floor is unconditional and this is a rail. A command the floor already
134
+ * refuses never needs an operator's opinion ([/decisions/ad-077.md](/decisions/ad-077.md)).
135
+ */
136
+ const untrustedAsk = coreFacade.untrusted.askIfFromUntrusted({
137
+ root: event.projectDir,
138
+ sessionKey: event.sessionKey,
139
+ command: event.command,
140
+ config: ctx.policy.untrustedContent,
141
+ });
142
+ if (untrustedAsk.kind !== "abstain") {
143
+ return untrustedAsk;
144
+ }
145
+
146
+ // invariant: unconditional, for the same reason the floor is. This detects a policy that changed without
147
+ // a harness command, so reading a policy field to decide whether to look would let the mutation switch
148
+ // off its own detector.
149
+ //
150
+ // hazard: it used to deny every event, reads included, which left the agent unable to look at anything —
151
+ // it could not diagnose the divergence or explain it, only go mute. Measured: it locked its own author out
152
+ // of the file holding the fix. A read cannot change a policy, so reads pass and the agent can investigate
153
+ // and report; everything that acts is still refused until the operator clears it.
154
+ if (!isReadOnlyEvent(event)) {
155
+ const integrity = coreFacade.policy.checkPolicyBaseline(event.projectDir, event.sessionKey);
156
+ if (integrity.kind !== "allow") {
157
+ recordShellDecisionIfShell(event, ctx, integrity);
158
+ return integrity;
159
+ }
160
+ }
161
+
162
+ switch (event.event) {
163
+ case "shell.before":
164
+ return handleShellBefore(event, ctx);
165
+ case "mcp.before":
166
+ case "read.before":
167
+ return { kind: "allow" };
168
+ case "tool.before": {
169
+ const guard = coreFacade.policy.guardPolicySurface({
170
+ projectDir: event.projectDir,
171
+ toolName: event.toolName,
172
+ filePath: filePathOf(event),
173
+ });
174
+ if (guard.kind !== "allow") {
175
+ return guard;
176
+ }
177
+ return handleToolBefore(event, ctx);
178
+ }
179
+ default:
180
+ return { kind: "allow" };
181
+ }
182
+ };
183
+
184
+ if (import.meta.main) {
185
+ await main(toolBeforeHandler);
186
+ }
@@ -0,0 +1,26 @@
1
+ import type { HarnessEvent } from "../contracts/index.ts";
2
+ import { coreFacade } from "../core/index.ts";
3
+ import type { Handler, HandlerContext } from "./run.ts";
4
+ import { main } from "./run.ts";
5
+ import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
6
+
7
+ export const toolFailureHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
8
+ coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
9
+
10
+ coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy, OBS_CONFIG_AUDIT), {
11
+ provider: event.provider,
12
+ kind: "tool.fail",
13
+ sessionKey: event.sessionKey,
14
+ model: event.model,
15
+ attrs: {
16
+ tool_name: event.toolName,
17
+ file_path: event.filePath,
18
+ status: event.status,
19
+ },
20
+ });
21
+ return { kind: "abstain" };
22
+ };
23
+
24
+ if (import.meta.main) {
25
+ await main(toolFailureHandler);
26
+ }
@@ -0,0 +1,49 @@
1
+ export type NextDelayOptions = {
2
+ attempt: number;
3
+ baseMs: number;
4
+ capMs: number;
5
+ random?: () => number;
6
+ };
7
+
8
+ export function nextDelay(options: NextDelayOptions): number {
9
+ const { attempt, baseMs, capMs, random = Math.random } = options;
10
+ const uncapped = baseMs * 2 ** attempt;
11
+ const ceiling = Math.min(capMs, uncapped);
12
+ return random() * ceiling;
13
+ }
14
+
15
+ export type RetryOptions = {
16
+ attempts: number;
17
+ shouldRetry?: (error: unknown) => boolean;
18
+ sleep?: (ms: number) => Promise<void>;
19
+ random?: () => number;
20
+ baseMs?: number;
21
+ capMs?: number;
22
+ };
23
+
24
+ function defaultSleep(ms: number): Promise<void> {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+
28
+ export async function retry<T>(fn: () => Promise<T> | T, options: RetryOptions): Promise<T> {
29
+ const {
30
+ attempts,
31
+ shouldRetry = () => true,
32
+ sleep = defaultSleep,
33
+ random = Math.random,
34
+ baseMs = 50,
35
+ capMs = 2000,
36
+ } = options;
37
+
38
+ for (let attempt = 0; attempt < attempts; attempt++) {
39
+ try {
40
+ return await fn();
41
+ } catch (error) {
42
+ if (attempt === attempts - 1 || !shouldRetry(error)) {
43
+ throw error;
44
+ }
45
+ await sleep(nextDelay({ attempt, baseMs, capMs, random }));
46
+ }
47
+ }
48
+ throw new Error("retry: unreachable");
49
+ }
@@ -0,0 +1,30 @@
1
+ export type JsonFlagResult = { json: boolean; rest: string[] };
2
+
3
+ export const JSON_FLAG = "--json";
4
+
5
+ export function takeJsonFlag(args: readonly string[]): JsonFlagResult {
6
+ const rest: string[] = [];
7
+ let json = false;
8
+ for (const arg of args) {
9
+ if (arg === JSON_FLAG) {
10
+ json = true;
11
+ continue;
12
+ }
13
+ rest.push(arg);
14
+ }
15
+ return { json, rest };
16
+ }
17
+
18
+ // why: one line per invocation keeps the contract stream-parseable — a caller can read a single line and
19
+ // hand it to a JSON parser without buffering to EOF or stripping a trailing newline.
20
+ export function emitJson(value: unknown, write: (text: string) => void = writeStdout): void {
21
+ write(`${JSON.stringify(value)}\n`);
22
+ }
23
+
24
+ function writeStdout(text: string): void {
25
+ process.stdout.write(text);
26
+ }
27
+
28
+ export function unknownFlags(args: readonly string[]): string[] {
29
+ return args.filter((arg) => arg.startsWith("--"));
30
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * why: variables that name WHICH project. A gate that runs with one of these set is reading a project root the
3
+ * hook supplied rather than the one the command assumed, and a suite that builds fixtures in temp directories
4
+ * reads the real repository under them. That is documented in `tools/test-env.mjs` and it cost four stop loops
5
+ * of editing code that was not broken ([/decisions/ad-060.md](/decisions/ad-060.md)).
6
+ *
7
+ * invariant: this list lives in `platform/` because `core/` may not spell a vendor identifier
8
+ * (`tools/dev/check-boundaries.ts`). Core asks which ones are set and never names one.
9
+ *
10
+ * invariant: `TLC_HOME` is deliberately absent. It names which runtime, not which project, and CI sets it on
11
+ * purpose.
12
+ */
13
+ export const PROJECT_SCOPED_ENV_NAMES = [
14
+ "CLAUDE_PROJECT_DIR",
15
+ "CURSOR_PROJECT_DIR",
16
+ "TLC_PROJECT_DIR",
17
+ ] as const;
18
+
19
+ /** The names that are set and non-empty, in declaration order, so the reading is stable across runs. */
20
+ export function setProjectScopedEnv(env: NodeJS.ProcessEnv = process.env): string[] {
21
+ return PROJECT_SCOPED_ENV_NAMES.filter((name) => (env[name] ?? "").trim() !== "");
22
+ }