@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,283 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { projectConfigPath } from "../../platform/paths.ts";
4
+ import { isCommandResolutionFailure } from "../gate/gate.command.ts";
5
+ import type { LessonsPolicyConfig } from "../policy/policy.types.ts";
6
+ import { lessonLinkVerdict } from "./lesson.link.ts";
7
+ import { hoursSince } from "./lesson.score.ts";
8
+ import { appliesHere, isInjectable, packLessonsUnderBudget, rankLessonsForSync } from "./lesson.select.ts";
9
+ import { allLessons, gardenGlobalLessons, gardenProjectLessons } from "./lesson.store.ts";
10
+ import type { HarnessLesson } from "./lesson.types.ts";
11
+ import { validityReason } from "./lesson.validity.ts";
12
+
13
+ export type GardenReport = {
14
+ promoted: string[];
15
+ quarantined: string[];
16
+ pruned: string[];
17
+ /** Project lessons whose refs no longer resolve. Reported, never auto-deleted — a rename can be reverted. */
18
+ stale: string[];
19
+ /** Lessons whose staleness cleared because every ref resolves again. */
20
+ refreshed: string[];
21
+ expired: string[];
22
+ active: number;
23
+ candidates: number;
24
+ };
25
+
26
+ /**
27
+ * A lesson recorded before AD-021, when a gate command that never resolved was classified `verification`
28
+ * instead of `config`. Such a lesson teaches an agent to go fix tests in response to a malformed command,
29
+ * and post-AD-021 the same output classifies `config`, so the signature cannot legitimately recur.
30
+ *
31
+ * hazard: exitCode 0 is passed deliberately. `isCommandResolutionFailure` returns true for 127, and stored
32
+ * instruction text can contain any number — only the message patterns may decide this.
33
+ */
34
+ function isStaleResolutionMisfile(lesson: HarnessLesson): boolean {
35
+ return (
36
+ lesson.category === "verification" &&
37
+ isCommandResolutionFailure({ exitCode: 0, output: lesson.instruction })
38
+ );
39
+ }
40
+
41
+ /**
42
+ * invariant: promotion counts distinct sessions. `hitCount` counts fingerprint recurrences, which one stuck
43
+ * session can drive to any number, so it promoted lessons that had occurred once in the world.
44
+ *
45
+ * why: a record written before `sessionKeys` existed has none, and falling back to `hitCount` is what keeps it
46
+ * promotable rather than frozen as a candidate forever.
47
+ */
48
+ export function promotionCount(lesson: HarnessLesson): number {
49
+ return lesson.sessionKeys.length > 0 ? lesson.sessionKeys.length : lesson.hitCount;
50
+ }
51
+
52
+ type StaleOutcome = { lesson: HarnessLesson; marked: boolean; cleared: boolean };
53
+
54
+ /**
55
+ * invariant: only the tier whose store shares this repository is graded. A global lesson is read from many
56
+ * repositories, so one persisted flag cannot be true for all of them — that case is decided per repository at
57
+ * selection time instead.
58
+ */
59
+ function applyStaleness(root: string, lesson: HarnessLesson, now: Date): StaleOutcome {
60
+ if (lesson.tier !== "project" || lesson.refs.length === 0) {
61
+ return { lesson, marked: false, cleared: false };
62
+ }
63
+ const verdict = lessonLinkVerdict(root, lesson.refs);
64
+ const checkedAt = now.toISOString();
65
+ if (verdict.stale) {
66
+ return {
67
+ lesson: { ...lesson, staleReason: verdict.status, staleCheckedAt: checkedAt, updatedAt: checkedAt },
68
+ marked: lesson.staleReason === undefined,
69
+ cleared: false,
70
+ };
71
+ }
72
+ if (lesson.staleReason === undefined) {
73
+ return { lesson: { ...lesson, staleCheckedAt: checkedAt }, marked: false, cleared: false };
74
+ }
75
+ const { staleReason: _dropped, ...rest } = lesson;
76
+ return {
77
+ lesson: { ...rest, staleCheckedAt: checkedAt, updatedAt: checkedAt },
78
+ marked: false,
79
+ cleared: true,
80
+ };
81
+ }
82
+
83
+ function gardenOne(
84
+ root: string,
85
+ lesson: HarnessLesson,
86
+ config: LessonsPolicyConfig,
87
+ now: Date,
88
+ report: GardenReport,
89
+ ): HarnessLesson | null {
90
+ if (isStaleResolutionMisfile(lesson)) {
91
+ report.pruned.push(lesson.id);
92
+ return null;
93
+ }
94
+
95
+ // why: an expired lesson is the one case the author already decided. Prune it, unlike a broken ref, which is
96
+ // a filesystem observation that a revert could undo.
97
+ if (validityReason(lesson, now) === "expired") {
98
+ report.expired.push(lesson.id);
99
+ return null;
100
+ }
101
+
102
+ const outcome = applyStaleness(root, lesson, now);
103
+ let candidate = outcome.lesson;
104
+ if (outcome.marked) {
105
+ report.stale.push(candidate.id);
106
+ }
107
+ if (outcome.cleared) {
108
+ report.refreshed.push(candidate.id);
109
+ }
110
+
111
+ if (candidate.status === "candidate" && promotionCount(candidate) >= config.promoteHitCount) {
112
+ candidate = {
113
+ ...candidate,
114
+ status: "active",
115
+ confidence: Math.max(candidate.confidence, 0.7),
116
+ updatedAt: now.toISOString(),
117
+ };
118
+ report.promoted.push(candidate.id);
119
+ }
120
+
121
+ const idleHours = hoursSince(candidate.lastSeenAt, now);
122
+ if (
123
+ candidate.status === "active" &&
124
+ idleHours > 24 * 90 &&
125
+ promotionCount(candidate) < config.promoteHitCount
126
+ ) {
127
+ candidate = { ...candidate, status: "quarantine", updatedAt: now.toISOString() };
128
+ report.quarantined.push(candidate.id);
129
+ }
130
+
131
+ if (candidate.status === "quarantine" && idleHours > 24 * 180) {
132
+ report.pruned.push(candidate.id);
133
+ return null;
134
+ }
135
+
136
+ // invariant: pruning measures the same clock the ranking does — recurrence, not injection. Reading
137
+ // lastAccessedAt here let an injected lesson postpone its own pruning indefinitely.
138
+ const decayed =
139
+ candidate.confidence * Math.exp(-config.decayLambda * hoursSince(candidate.lastSeenAt, now));
140
+ if (decayed < 0.05 && candidate.status !== "quarantine" && candidate.hitCount < 2) {
141
+ report.pruned.push(candidate.id);
142
+ return null;
143
+ }
144
+
145
+ return candidate;
146
+ }
147
+
148
+ function emptyReport(): GardenReport {
149
+ return {
150
+ promoted: [],
151
+ quarantined: [],
152
+ pruned: [],
153
+ stale: [],
154
+ refreshed: [],
155
+ expired: [],
156
+ active: 0,
157
+ candidates: 0,
158
+ };
159
+ }
160
+
161
+ export async function gardenLessons(
162
+ root: string,
163
+ config: LessonsPolicyConfig,
164
+ now = new Date(),
165
+ ): Promise<GardenReport> {
166
+ const report = emptyReport();
167
+ const sweep = (current: HarnessLesson[]): HarnessLesson[] => {
168
+ const next: HarnessLesson[] = [];
169
+ for (const lesson of current) {
170
+ if (lesson.source === "core") {
171
+ continue;
172
+ }
173
+ const kept = gardenOne(root, lesson, config, now, report);
174
+ if (kept) {
175
+ next.push(kept);
176
+ }
177
+ }
178
+ return next;
179
+ };
180
+
181
+ const project = await gardenProjectLessons(root, sweep);
182
+ // why: the global tier is gardened in the same pass. Decay, promotion and expiry apply to it identically;
183
+ // only staleness is tier-specific, and `applyStaleness` is what draws that line.
184
+ const global = await gardenGlobalLessons(sweep);
185
+ const kept = [...project, ...global];
186
+
187
+ report.active = kept.filter((l) => l.status === "active").length;
188
+ report.candidates = kept.filter((l) => l.status === "candidate").length;
189
+ return report;
190
+ }
191
+
192
+ const SYNC_TITLE = "Learned harness lessons (auto-synced; do not hand-edit):";
193
+
194
+ export function lessonsMarkdownPath(root: string): string {
195
+ return join(dirname(projectConfigPath(root)), "lessons.md");
196
+ }
197
+
198
+ /**
199
+ * Why the synced file has nothing in it, in the operator's terms.
200
+ *
201
+ * hazard: one sentence — "No active project lessons yet." — covered a switched-off capability, an enabled one that
202
+ * had never seen a repeat failure, and a store full of candidates none of which had been promoted. Three different
203
+ * situations and nothing to tell them apart, so the file read as broken. An operator reported it as never updating,
204
+ * and it was being rewritten every session with the same empty text ([/decisions/ad-049.md](/decisions/ad-049.md)).
205
+ */
206
+ export function emptySyncReason(
207
+ lessons: readonly HarnessLesson[],
208
+ config: LessonsPolicyConfig,
209
+ now: Date,
210
+ ): string {
211
+ if (!config.enabled) {
212
+ return "Lessons are switched off for this project (`intelligence.lessons.enabled` is false), so no gate failure is ever recorded. Ask the agent to run the harness-init skill to turn them on.";
213
+ }
214
+ if (lessons.length === 0) {
215
+ return "No lesson recorded yet. One is written when the *same* gate failure repeats inside a session — a gate that fails once, or fails differently each time, records nothing.";
216
+ }
217
+ const candidates = lessons.filter((lesson) => lesson.status === "candidate").length;
218
+ if (candidates > 0 && lessons.every((lesson) => lesson.status !== "active")) {
219
+ const noun = candidates === 1 ? "lesson" : "lessons";
220
+ return `${candidates} candidate ${noun} recorded, none promoted yet. Promotion needs the same failure in ${config.promoteHitCount} distinct sessions — see \`tlc harness lessons list\`.`;
221
+ }
222
+ const withheld = lessons.filter(
223
+ (lesson) => lesson.status === "active" && !isInjectable(lesson, now),
224
+ ).length;
225
+ if (withheld > 0) {
226
+ const noun = withheld === 1 ? "lesson is" : "lessons are";
227
+ return `${withheld} active ${noun} withheld — a named reference stopped resolving, or a validity window closed. Run \`tlc harness lessons list\` to see which.`;
228
+ }
229
+ return "No active project lessons yet.";
230
+ }
231
+
232
+ export function renderLessonsMarkdown(
233
+ root: string,
234
+ lessons: HarnessLesson[],
235
+ config: LessonsPolicyConfig,
236
+ ): string {
237
+ // invariant: the synced file is what an operator reads as current guidance, so it carries exactly what would
238
+ // be injected — a withheld lesson appearing here would contradict the store.
239
+ const now = new Date();
240
+ // hazard: `appliesHere` as well as `isInjectable`. A global lesson whose refs miss in this repo is withheld from
241
+ // the injected block, so listing it here would make the file contradict the guidance the model receives.
242
+ const ranked = rankLessonsForSync(
243
+ lessons.filter((lesson) => isInjectable(lesson, now) && appliesHere(root, lesson)),
244
+ ).slice(0, 12);
245
+ const { body } = packLessonsUnderBudget({
246
+ lessons: ranked,
247
+ maxChars: config.maxCharsSession,
248
+ title: SYNC_TITLE,
249
+ });
250
+ const path = lessonsMarkdownPath(root);
251
+ mkdirSync(dirname(path), { recursive: true });
252
+ const content = `# Harness lessons
253
+
254
+ Auto-synced from gate failures; do not hand-edit.
255
+
256
+ ${body || emptySyncReason(lessons, config, now)}
257
+ `;
258
+ writeFileSync(path, content, "utf8");
259
+ return path;
260
+ }
261
+
262
+ /**
263
+ * `writeDurableView` is the caller's verdict, not a re-read of the config: the mode alone cannot decide it, because
264
+ * `auto` depends on whether the host delivers context from its session-start hook. Core takes that as data rather
265
+ * than importing a provider ([/decisions/ad-050.md](/decisions/ad-050.md)).
266
+ */
267
+ export function gardenAndPersistLessons(
268
+ root: string,
269
+ config: LessonsPolicyConfig,
270
+ options: { writeDurableView: boolean },
271
+ now = new Date(),
272
+ ): Promise<{ report: GardenReport; markdownPath: string | null }> {
273
+ return gardenLessons(root, config, now).then((report) => {
274
+ if (!options.writeDurableView) {
275
+ return { report, markdownPath: null };
276
+ }
277
+ // hazard: this read `readProjectLessons`, so the file carried one of the three tiers. On the host where it is
278
+ // the only route to the model, a core or global lesson therefore reached nothing
279
+ // ([/decisions/ad-040.md](/decisions/ad-040.md), [/decisions/ad-050.md](/decisions/ad-050.md)).
280
+ const path = renderLessonsMarkdown(root, allLessons(root), config);
281
+ return { report, markdownPath: path };
282
+ });
283
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import type { HarnessLesson, LessonLink, LessonLinkStatus } from "./lesson.types.ts";
4
+
5
+ const LINK_SEPARATOR = ":";
6
+
7
+ export function parseLessonLink(raw: string): LessonLink | null {
8
+ const trimmed = raw.trim();
9
+ if (!trimmed) {
10
+ return null;
11
+ }
12
+ const at = trimmed.lastIndexOf(LINK_SEPARATOR);
13
+ if (at <= 0 || at === trimmed.length - 1) {
14
+ return { path: trimmed };
15
+ }
16
+ const path = trimmed.slice(0, at).trim();
17
+ const symbol = trimmed.slice(at + 1).trim();
18
+ if (!path) {
19
+ return null;
20
+ }
21
+ return symbol ? { path, symbol } : { path };
22
+ }
23
+
24
+ export function formatLessonLink(link: LessonLink): string {
25
+ return link.symbol ? `${link.path}${LINK_SEPARATOR}${link.symbol}` : link.path;
26
+ }
27
+
28
+ // hazard: an absolute path resolves against a machine, not a repository, so a global lesson would report
29
+ // `present` in every product on the machine that happens to contain that file.
30
+ function resolveLinkPath(root: string, link: LessonLink): string | null {
31
+ return isAbsolute(link.path) ? null : resolve(root, link.path);
32
+ }
33
+
34
+ export function checkLessonLink(root: string, link: LessonLink): LessonLinkStatus {
35
+ const absolute = resolveLinkPath(root, link);
36
+ if (absolute === null || !existsSync(absolute)) {
37
+ return "path-missing";
38
+ }
39
+ if (!link.symbol) {
40
+ return "present";
41
+ }
42
+ try {
43
+ // why: substring, not parsing — a symbol table needs a language per extension and a parse per check, and a
44
+ // false `present` only costs keeping a lesson a rename would have retired.
45
+ return readFileSync(absolute, "utf8").includes(link.symbol) ? "present" : "symbol-missing";
46
+ } catch {
47
+ // invariant: unreadable is deferred, never stale. A file this process cannot open is not evidence the
48
+ // lesson stopped being true.
49
+ return "unreadable";
50
+ }
51
+ }
52
+
53
+ const STATUS_SEVERITY: Record<LessonLinkStatus, number> = {
54
+ present: 0,
55
+ unreadable: 1,
56
+ "symbol-missing": 2,
57
+ "path-missing": 3,
58
+ };
59
+
60
+ export function worstLinkStatus(statuses: readonly LessonLinkStatus[]): LessonLinkStatus {
61
+ let worst: LessonLinkStatus = "present";
62
+ for (const status of statuses) {
63
+ if (STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) {
64
+ worst = status;
65
+ }
66
+ }
67
+ return worst;
68
+ }
69
+
70
+ export type LessonLinkVerdict = {
71
+ status: LessonLinkStatus;
72
+ stale: boolean;
73
+ brokenRefs: string[];
74
+ };
75
+
76
+ export function lessonLinkVerdict(root: string, refs: readonly LessonLink[]): LessonLinkVerdict {
77
+ if (refs.length === 0) {
78
+ return { status: "present", stale: false, brokenRefs: [] };
79
+ }
80
+ const statuses = refs.map((ref) => checkLessonLink(root, ref));
81
+ const brokenRefs = refs
82
+ .filter((_, index) => statuses[index] === "path-missing" || statuses[index] === "symbol-missing")
83
+ .map(formatLessonLink);
84
+ return { status: worstLinkStatus(statuses), stale: brokenRefs.length > 0, brokenRefs };
85
+ }
86
+
87
+ export function lessonRefs(lesson: HarnessLesson): LessonLink[] {
88
+ return Array.isArray(lesson.refs) ? lesson.refs : [];
89
+ }
90
+
91
+ export function isStaleLesson(lesson: HarnessLesson): boolean {
92
+ return typeof lesson.staleReason === "string" && lesson.staleReason.length > 0;
93
+ }
@@ -0,0 +1,61 @@
1
+ import type { HarnessLesson } from "./lesson.types.ts";
2
+
3
+ const MS_PER_HOUR = 3_600_000;
4
+
5
+ // hazard: an unparseable or missing timestamp used to yield NaN, which does not throw but poisons every
6
+ // comparator that sorts on the decayed value. Unknown reads as "just seen", which is the conservative side.
7
+ export function hoursSince(iso: string, now: Date): number {
8
+ const then = new Date(iso).getTime();
9
+ if (!Number.isFinite(then)) {
10
+ return 0;
11
+ }
12
+ return Math.max(0, (now.getTime() - then) / MS_PER_HOUR);
13
+ }
14
+
15
+ /**
16
+ * invariant: decay is measured from `lastSeenAt` — the last time the failure actually recurred — and never
17
+ * from `lastAccessedAt`.
18
+ *
19
+ * hazard: `lastAccessedAt` is written by `touchAccessed` when a lesson is *selected for injection*, so
20
+ * reading it here made relevance self-fulfilling: showing a lesson reset the clock that decided whether to
21
+ * keep showing it. Any lesson matching a gate name became immortal and never pruned.
22
+ */
23
+ export function decayedConfidence(lesson: HarnessLesson, decayLambda: number, now: Date): number {
24
+ if (lesson.source === "core") {
25
+ return lesson.confidence;
26
+ }
27
+ return lesson.confidence * Math.exp(-decayLambda * hoursSince(lesson.lastSeenAt, now));
28
+ }
29
+
30
+ export function relevanceScore(lesson: HarnessLesson, args: { gate?: string; text?: string }): number {
31
+ let score = 0.25;
32
+ const gate = (args.gate ?? "").toLowerCase();
33
+ const text = (args.text ?? "").toLowerCase();
34
+ if (gate && lesson.failedGate.toLowerCase() === gate) {
35
+ score += 1.2;
36
+ }
37
+ if (gate && lesson.triggerTokens.some((token) => gate.includes(token.toLowerCase()))) {
38
+ score += 0.35;
39
+ }
40
+ for (const token of lesson.triggerTokens) {
41
+ const t = token.toLowerCase();
42
+ if (t && text.includes(t)) {
43
+ score += 0.2;
44
+ }
45
+ }
46
+ score += lesson.priority / 200;
47
+ return score;
48
+ }
49
+
50
+ export function rankScore(
51
+ lesson: HarnessLesson,
52
+ args: { gate?: string; text?: string; decayLambda: number; projectBoost: number; now?: Date },
53
+ ): number {
54
+ const now = args.now ?? new Date();
55
+ const relevance = relevanceScore(lesson, { gate: args.gate, text: args.text });
56
+ const confidence = decayedConfidence(lesson, args.decayLambda, now);
57
+ // why: the boost favours the nearest tier. A lesson written for this repository outranks one carried in from
58
+ // another product, which is what keeps a cross-product tier from drowning local knowledge.
59
+ const boost = lesson.tier === "project" ? args.projectBoost : 1;
60
+ return relevance * confidence * boost;
61
+ }
@@ -0,0 +1,209 @@
1
+ import type { LessonsPolicyConfig } from "../policy/policy.types.ts";
2
+ import { isStaleLesson, lessonLinkVerdict } from "./lesson.link.ts";
3
+ import { rankScore } from "./lesson.score.ts";
4
+ import { allLessons, touchAccessed } from "./lesson.store.ts";
5
+ import type { HarnessLesson } from "./lesson.types.ts";
6
+ import { isWithinValidity } from "./lesson.validity.ts";
7
+
8
+ export type SelectMode = "session" | "retry";
9
+
10
+ const OMIT_NOTE_RESERVE = 96;
11
+
12
+ // invariant: injectable is decided before ranking, not by scoring a withheld lesson low. A stale or out-of-window
13
+ // lesson must not reach the turn at any score.
14
+ export function isInjectable(lesson: HarnessLesson, now: Date): boolean {
15
+ return !isStaleLesson(lesson) && isWithinValidity(lesson, now);
16
+ }
17
+
18
+ /**
19
+ * why: a global lesson is read from many repositories, so one stored `staleReason` cannot be true for all of
20
+ * them — a ref that is missing here may be present in the product the lesson came from. Applicability is
21
+ * therefore computed per repository for the global tier, while a project lesson relies on the flag `garden`
22
+ * wrote against the same repository it lives in.
23
+ */
24
+ export function appliesHere(root: string, lesson: HarnessLesson): boolean {
25
+ if (lesson.tier !== "global" || lesson.refs.length === 0) {
26
+ return true;
27
+ }
28
+ return !lessonLinkVerdict(root, lesson.refs).stale;
29
+ }
30
+
31
+ function allowedForMode(lesson: HarnessLesson, mode: SelectMode, gate?: string): boolean {
32
+ if (lesson.status === "quarantine") {
33
+ return false;
34
+ }
35
+ if (mode === "session") {
36
+ return lesson.status === "active";
37
+ }
38
+ if (lesson.status === "active") {
39
+ return !gate || lesson.failedGate === gate || lesson.failedGate === "stagnation";
40
+ }
41
+ if (lesson.status === "candidate") {
42
+ return Boolean(gate) && lesson.failedGate === gate;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // why: the tier is rendered because it calibrates trust — a lesson carried in from another product is advice
48
+ // about a different repository, and the turn should be able to tell.
49
+ export function renderLessonBlock(lesson: HarnessLesson): string {
50
+ const lines = [
51
+ `- [${lesson.failedGate}/${lesson.status}/${lesson.tier}] ${lesson.instruction}`,
52
+ ` avoid: ${lesson.avoid}`,
53
+ ` prefer: ${lesson.prefer}`,
54
+ ` before retrying: ${lesson.preRetryCheck}`,
55
+ ];
56
+ return lines.join("\n");
57
+ }
58
+
59
+ export function formatLessonsSection(lessons: HarnessLesson[], title: string): string {
60
+ if (lessons.length === 0) {
61
+ return "";
62
+ }
63
+ return [title, ...lessons.map((lesson) => renderLessonBlock(lesson))].join("\n");
64
+ }
65
+
66
+ export function omitLessonsNote(omitted: number): string {
67
+ if (omitted <= 0) {
68
+ return "";
69
+ }
70
+ const noun = omitted === 1 ? "lesson" : "lessons";
71
+ return `_(${omitted} more active ${noun} omitted under char budget)_`;
72
+ }
73
+
74
+ export function packLessonsUnderBudget(args: { lessons: HarnessLesson[]; maxChars: number; title: string }): {
75
+ body: string;
76
+ included: HarnessLesson[];
77
+ omitted: number;
78
+ } {
79
+ const { lessons, title } = args;
80
+ const maxChars = Math.max(0, args.maxChars);
81
+ if (lessons.length === 0) {
82
+ return { body: "", included: [], omitted: 0 };
83
+ }
84
+
85
+ const packBudget = Math.max(0, maxChars - OMIT_NOTE_RESERVE);
86
+ const included: HarnessLesson[] = [];
87
+
88
+ for (const lesson of lessons) {
89
+ const candidate = formatLessonsSection([...included, lesson], title);
90
+ if (included.length === 0) {
91
+ included.push(lesson);
92
+ if (candidate.length > packBudget) {
93
+ break;
94
+ }
95
+ continue;
96
+ }
97
+ if (candidate.length <= packBudget) {
98
+ included.push(lesson);
99
+ continue;
100
+ }
101
+ break;
102
+ }
103
+
104
+ let omitted = lessons.length - included.length;
105
+ let body = formatLessonsSection(included, title);
106
+ const note = omitLessonsNote(omitted);
107
+ if (!note) {
108
+ return { body, included, omitted };
109
+ }
110
+
111
+ const withNote = `${body}\n${note}`;
112
+ if (withNote.length <= maxChars) {
113
+ return { body: withNote, included, omitted };
114
+ }
115
+
116
+ while (included.length > 1) {
117
+ included.pop();
118
+ omitted = lessons.length - included.length;
119
+ body = formatLessonsSection(included, title);
120
+ const next = `${body}\n${omitLessonsNote(omitted)}`;
121
+ if (next.length <= maxChars) {
122
+ return { body: next, included: [...included], omitted };
123
+ }
124
+ }
125
+
126
+ return { body, included: [...included], omitted: lessons.length - included.length };
127
+ }
128
+
129
+ export function rankLessonsForSync(lessons: HarnessLesson[]): HarnessLesson[] {
130
+ return [...lessons]
131
+ .filter((lesson) => lesson.status === "active")
132
+ .sort(
133
+ (a, b) =>
134
+ b.priority - a.priority ||
135
+ b.hitCount - a.hitCount ||
136
+ b.confidence - a.confidence ||
137
+ new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime() ||
138
+ a.id.localeCompare(b.id),
139
+ );
140
+ }
141
+
142
+ export async function selectLessons(args: {
143
+ projectDir: string;
144
+ config: LessonsPolicyConfig;
145
+ mode: SelectMode;
146
+ gate?: string;
147
+ text?: string;
148
+ now?: Date;
149
+ }): Promise<{ lessons: HarnessLesson[]; usedIds: string[]; omitted: number }> {
150
+ if (!args.config.enabled) {
151
+ return { lessons: [], usedIds: [], omitted: 0 };
152
+ }
153
+
154
+ const maxCount = args.mode === "session" ? args.config.maxInjectSession : args.config.maxInjectRetry;
155
+ const maxChars = args.mode === "session" ? args.config.maxCharsSession : args.config.maxCharsRetry;
156
+ const now = args.now ?? new Date();
157
+
158
+ const ranked = allLessons(args.projectDir)
159
+ .filter(
160
+ (lesson) =>
161
+ isInjectable(lesson, now) &&
162
+ appliesHere(args.projectDir, lesson) &&
163
+ allowedForMode(lesson, args.mode, args.gate),
164
+ )
165
+ .map((lesson) => ({
166
+ lesson,
167
+ score: rankScore(lesson, {
168
+ gate: args.gate,
169
+ text: args.text,
170
+ decayLambda: args.config.decayLambda,
171
+ projectBoost: args.config.projectBoost,
172
+ now,
173
+ }),
174
+ }))
175
+ .sort((a, b) => b.score - a.score || b.lesson.priority - a.lesson.priority);
176
+
177
+ // invariant: pinned first, in the operator's own order, before anything scored. Everything else about a pinned
178
+ // lesson still applies — staleness, validity, mode and the char budget all bind
179
+ // ([/decisions/ad-043.md](/decisions/ad-043.md)).
180
+ const ordered = [
181
+ ...ranked.filter((row) => row.lesson.pinned),
182
+ ...ranked.filter((row) => !row.lesson.pinned),
183
+ ];
184
+
185
+ const picked: HarnessLesson[] = [];
186
+ let chars = 0;
187
+ for (const row of ordered) {
188
+ if (picked.length >= maxCount) {
189
+ break;
190
+ }
191
+ const block = renderLessonBlock(row.lesson);
192
+ if (chars + block.length > maxChars && picked.length > 0) {
193
+ break;
194
+ }
195
+ if (block.length > maxChars && picked.length === 0) {
196
+ picked.push(row.lesson);
197
+ break;
198
+ }
199
+ picked.push(row.lesson);
200
+ chars += block.length;
201
+ }
202
+
203
+ const usedIds = picked.filter((l) => l.source !== "core").map((l) => l.id);
204
+ await touchAccessed(args.projectDir, usedIds, now);
205
+ // hazard: `maxInjectSession` defaults to 5 and `maxCharsSession` to 900, which fits about two rendered blocks —
206
+ // so the count promises five and delivers two, silently. Whoever reads the injected block has to be able to tell
207
+ // that eligible lessons were dropped ([/decisions/ad-043.md](/decisions/ad-043.md)).
208
+ return { lessons: picked, usedIds: picked.map((l) => l.id), omitted: ordered.length - picked.length };
209
+ }