@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,62 @@
1
+ import { type CapabilityCatalog, type CatalogCapability, ENABLE_HINT } from "./capability.types.ts";
2
+
3
+ export function resolveConfigPath(policy: Record<string, unknown>, configPath: string): unknown {
4
+ let current: unknown = policy;
5
+ for (const part of configPath.split(".").filter(Boolean)) {
6
+ if (!current || typeof current !== "object") {
7
+ return undefined;
8
+ }
9
+ current = (current as Record<string, unknown>)[part];
10
+ }
11
+ return current;
12
+ }
13
+
14
+ export function isAvailableNotEnabled(policy: Record<string, unknown>, cap: CatalogCapability): boolean {
15
+ const value = resolveConfigPath(policy, cap.configPath);
16
+ return cap.defaultOn ? value === false : value !== true;
17
+ }
18
+
19
+ export function listAvailableNotEnabled(
20
+ policy: Record<string, unknown>,
21
+ catalog: CapabilityCatalog,
22
+ ): CatalogCapability[] {
23
+ return catalog.capabilities.filter((cap) => isAvailableNotEnabled(policy, cap));
24
+ }
25
+
26
+ export function listNewlyAnnounceable(
27
+ policy: Record<string, unknown>,
28
+ catalog: CapabilityCatalog,
29
+ seenCatalogVersion: number,
30
+ ): CatalogCapability[] {
31
+ return listAvailableNotEnabled(policy, catalog).filter(
32
+ (cap) => cap.sinceCatalogVersion > seenCatalogVersion,
33
+ );
34
+ }
35
+
36
+ export function formatCapabilityDigest(caps: CatalogCapability[]): string {
37
+ const lines = ["Available for this project (not enabled yet):", ""];
38
+ for (const cap of caps) {
39
+ lines.push(`• ${cap.title}`);
40
+ lines.push(` Benefit: ${cap.benefit}`);
41
+ lines.push(` Trade-off: ${cap.tradeOff}`);
42
+ lines.push("");
43
+ }
44
+ lines.push(ENABLE_HINT);
45
+ return lines.join("\n").trimEnd();
46
+ }
47
+
48
+ // hazard: this prefixed "WARN:" while the row it lands in already carries its level, so an operator read the word
49
+ // twice. Seen in a real doctor run ([/decisions/ad-034.md](/decisions/ad-034.md)).
50
+ export function formatDoctorWarn(cap: CatalogCapability): string {
51
+ return `${cap.title} off — ${cap.tradeOff} — ${ENABLE_HINT}`;
52
+ }
53
+
54
+ /**
55
+ * hazard: every capability that was merely *not enabled* produced a warning, so a healthy install printed nine of
56
+ * them and the two rows that needed attention were buried in the middle. An optional capability nobody switched on is
57
+ * inventory, not a fault, and a warning that fires on a healthy install teaches the reader to skip warnings — which
58
+ * is how the row that mattered got missed ([/decisions/ad-034.md](/decisions/ad-034.md)).
59
+ */
60
+ export function formatAvailableInventory(caps: readonly CatalogCapability[]): string {
61
+ return `${caps.length} available and not enabled: ${caps.map((cap) => cap.id).join(", ")}. ${ENABLE_HINT}`;
62
+ }
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeJsonAtomic } from "../../platform/fs-atomic.ts";
4
+ import { projectConfigPath, projectStateDir, runtimeHome } from "../../platform/paths.ts";
5
+ import type { CapabilityCatalog, RuntimeSeen } from "./capability.types.ts";
6
+
7
+ function readJson<T>(path: string): T | null {
8
+ if (!existsSync(path)) {
9
+ return null;
10
+ }
11
+ try {
12
+ return JSON.parse(readFileSync(path, "utf8")) as T;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function catalogPath(home = runtimeHome()): string {
19
+ return join(home, "capabilities", "catalog.json");
20
+ }
21
+
22
+ export function loadCatalog(home = runtimeHome()): CapabilityCatalog | null {
23
+ const raw = readJson<CapabilityCatalog>(catalogPath(home));
24
+ if (!raw || typeof raw.catalogVersion !== "number" || !Array.isArray(raw.capabilities)) {
25
+ return null;
26
+ }
27
+ return raw;
28
+ }
29
+
30
+ export function readProjectPolicyRaw(projectDir: string): Record<string, unknown> | null {
31
+ return readJson<Record<string, unknown>>(projectConfigPath(projectDir));
32
+ }
33
+
34
+ function runtimeSeenPath(projectDir: string): string {
35
+ return join(projectStateDir(projectDir), "runtime-seen.json");
36
+ }
37
+
38
+ export function readRuntimeSeen(projectDir: string): RuntimeSeen {
39
+ const raw = readJson<RuntimeSeen>(runtimeSeenPath(projectDir));
40
+ if (!raw || typeof raw.catalogVersion !== "number" || raw.catalogVersion < 0) {
41
+ return { catalogVersion: 0 };
42
+ }
43
+ return raw;
44
+ }
45
+
46
+ export async function writeRuntimeSeen(projectDir: string, catalogVersion: number): Promise<void> {
47
+ await writeJsonAtomic(runtimeSeenPath(projectDir), {
48
+ catalogVersion,
49
+ updatedAt: new Date().toISOString(),
50
+ } satisfies RuntimeSeen);
51
+ }
@@ -0,0 +1,56 @@
1
+ import type { HarnessEventKind } from "../../contracts/harness-event.ts";
2
+
3
+ /**
4
+ * What a capability does at the moment it triggers, in the operator's terms rather than the code's. `deny` and
5
+ * `ask` answer the tool call; `block-stop` and `follow-up` both send the turn back to work and differ in whether
6
+ * the turn may end; `context` and `record` change no decision at all.
7
+ */
8
+ export type CapabilityVerdict = "deny" | "ask" | "block-stop" | "follow-up" | "context" | "record";
9
+
10
+ export const SUMMARY_MAX_CHARS = 120;
11
+
12
+ export type CatalogCapability = {
13
+ id: string;
14
+ configPath: string;
15
+ title: string;
16
+ /**
17
+ * One line stating what this rail checks, short enough to sit in a table cell next to twenty others. Capped at
18
+ * `SUMMARY_MAX_CHARS`: a rail that cannot be stated in a line is one the reader cannot scan past, and the
19
+ * unbounded `benefit` field is where the full case belongs.
20
+ */
21
+ summary: string;
22
+ benefit: string;
23
+ tradeOff: string;
24
+ defaultOn: boolean;
25
+ sinceCatalogVersion: number;
26
+ /**
27
+ * The events whose handler evaluates this capability. Required, and checked against `HARNESS_EVENT_KINDS`
28
+ * — the question "when does this fire?" was the one the README could not answer, and a free-text answer is
29
+ * one refactor away from naming an event that no longer exists.
30
+ */
31
+ fires: HarnessEventKind[];
32
+ /** What happens when it triggers. */
33
+ verdict: CapabilityVerdict;
34
+ /**
35
+ * The command or file that shows this capability's own record. Required, because a rail nobody can observe
36
+ * cannot be explained, and one with no producer would have nothing to name here.
37
+ */
38
+ inspect: string;
39
+ /** Follow-up values the wizard collects when the operator accepts. Presentation only. */
40
+ asks?: string[];
41
+ /** A stated recommendation, where there is one worth stating. */
42
+ recommend?: "on" | "off";
43
+ };
44
+
45
+ export type CapabilityCatalog = {
46
+ catalogVersion: number;
47
+ capabilities: CatalogCapability[];
48
+ };
49
+
50
+ export type RuntimeSeen = {
51
+ catalogVersion: number;
52
+ updatedAt?: string;
53
+ };
54
+
55
+ export const ENABLE_HINT =
56
+ 'Enable: ask the agent "setup harness" (harness-init skill) or edit .tlc/harness/config.json';
@@ -0,0 +1,307 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AddedLine } from "../../platform/git.ts";
4
+ import { listAddedLines } from "../../platform/git.ts";
5
+ import type { CommentMode } from "../policy/policy.types.ts";
6
+ import type { CommentFinding } from "./comment-policy.types.ts";
7
+ import { firstLeak, leakReason } from "./comment-resolvability.ts";
8
+ import { syntaxFor } from "./comment-syntax.store.ts";
9
+ import type { CommentSyntax } from "./comment-syntax.types.ts";
10
+
11
+ /**
12
+ * hazard: this was three hand-written regexes and a ten-extension list. `#` starts a comment in shell and Python
13
+ * but not in TypeScript, where it starts a private field — and markdown inside a template literal made
14
+ * `## Heading` read as seven added comments. Both cases are now decided by the catalog, which says which
15
+ * delimiters a given extension actually has, and a Python docstring is covered because the catalog carries it
16
+ * ([/decisions/ad-058.md](/decisions/ad-058.md)).
17
+ *
18
+ * invariant: a file the catalog does not know produces no findings and is counted as unknown. Guessing a
19
+ * delimiter for an unrecognised language is how `#` came to mean "comment" in TypeScript.
20
+ */
21
+ export function matchesSyntax(text: string, syntax: CommentSyntax): boolean {
22
+ const trimmed = text.trimStart();
23
+ if (trimmed === "") {
24
+ return false;
25
+ }
26
+ if (syntax.line.some((prefix) => prefix !== "" && trimmed.startsWith(prefix))) {
27
+ return true;
28
+ }
29
+ for (const [open, close] of syntax.block) {
30
+ if (trimmed.startsWith(open)) {
31
+ return true;
32
+ }
33
+ // hazard: a lone closer ends a block and is not itself a comment line, so counting it would extend every
34
+ // block by one. A symmetric fence — a Python docstring — is exempt, because its closer is also its opener.
35
+ if (open !== close && trimmed.startsWith(close)) {
36
+ return false;
37
+ }
38
+ }
39
+ // hazard: `*` continues a C-family block and `**` is markdown bold. The regex this replaced spelled that
40
+ // `\*(?![*/])`, and dropping the guard made `**Provider:** \`x\`` in a template literal read as narration —
41
+ // caught by the test written for that exact regression.
42
+ return syntax.middle.some((middle) => trimmed.startsWith(middle) && !trimmed.startsWith(middle + middle));
43
+ }
44
+
45
+ const TOOL_DIRECTIVE =
46
+ /^\s*(?:\/\/|\/\*|\*|#)\s*(?:biome-ignore|eslint|@ts-|prettier-ignore|noqa|type:|shellcheck|!)/;
47
+ const DECLARED_REASON = /^\s*(?:\/\/|\/\*|\*|#)\s*(?:why|hazard|invariant):\s*\S/i;
48
+ const CLOSER_OR_CONTINUATION = /^\s*(?:\*\/|\*|\/\/)/;
49
+
50
+ export const COMMENT_MARKERS = ["why:", "hazard:", "invariant:"] as const;
51
+
52
+ /**
53
+ * why: `file` decides everything now, so an empty one resolves to no syntax rather than to a permissive union of
54
+ * every delimiter the harness has heard of. The facade keeps this signature; the scanner always has a real path.
55
+ */
56
+ export function isCommentLine(text: string, file = ""): boolean {
57
+ const syntax = file === "" ? null : syntaxFor(file);
58
+ if (syntax === null) {
59
+ return false;
60
+ }
61
+ return matchesSyntax(text, syntax) && !TOOL_DIRECTIVE.test(text);
62
+ }
63
+
64
+ export function declaresReason(text: string): boolean {
65
+ return DECLARED_REASON.test(text);
66
+ }
67
+
68
+ const DECLARATION =
69
+ /^\s*(?:(?:export|declare|public|private|protected|readonly|static|async|abstract)\s+)*(?:class|function|const|let|var|type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)|^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)\??\s*[:(<]/;
70
+
71
+ // invariant: attachment is decided by position, the same way every JSDoc tool decides it. A `/** */`
72
+ // floating inside a function body documents nothing, so it is judged as an inline comment instead.
73
+ export function attachedIdentifier(codeLine: string | undefined): string | null {
74
+ const match = codeLine === undefined ? null : DECLARATION.exec(codeLine);
75
+ return match ? (match[1] ?? match[2] ?? null) : null;
76
+ }
77
+
78
+ const STOPWORDS = new Set([
79
+ "a",
80
+ "an",
81
+ "and",
82
+ "are",
83
+ "as",
84
+ "at",
85
+ "be",
86
+ "by",
87
+ "for",
88
+ "from",
89
+ "get",
90
+ "gets",
91
+ "has",
92
+ "in",
93
+ "into",
94
+ "is",
95
+ "it",
96
+ "its",
97
+ "of",
98
+ "on",
99
+ "or",
100
+ "return",
101
+ "returns",
102
+ "set",
103
+ "sets",
104
+ "that",
105
+ "the",
106
+ "then",
107
+ "this",
108
+ "to",
109
+ "true",
110
+ "when",
111
+ "which",
112
+ "with",
113
+ ]);
114
+
115
+ function words(text: string): string[] {
116
+ return text
117
+ .replace(/[_-]+/g, " ")
118
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
119
+ .toLowerCase()
120
+ .split(/[^a-z0-9]+/)
121
+ .filter((word) => word.length > 1);
122
+ }
123
+
124
+ export const MIN_INFORMATIVE_WORDS = 3;
125
+
126
+ // invariant: a doc comment addresses the caller, so the question is whether it carries information the
127
+ // identifier does not. Exempting the form outright would make `/** */` an escape hatch.
128
+ export function isInformativeDoc(commentText: string, identifier: string): boolean {
129
+ const named = new Set(words(identifier));
130
+ const remaining = words(commentText.replace(/^[\s/*]+|[\s*/]+$/g, "")).filter(
131
+ (word) => !named.has(word) && !STOPWORDS.has(word),
132
+ );
133
+ return new Set(remaining).size >= MIN_INFORMATIVE_WORDS;
134
+ }
135
+
136
+ // invariant: a marker sits on the first line of a comment, so continuation lines are part of the same
137
+ // block and are not judged on their own.
138
+ export function groupCommentBlocks(added: AddedLine[]): AddedLine[][] {
139
+ const blocks: AddedLine[][] = [];
140
+ let block: AddedLine[] = [];
141
+
142
+ for (const line of added) {
143
+ if (!isCommentLine(line.text, line.file)) {
144
+ block = [];
145
+ continue;
146
+ }
147
+ const previous = block.at(-1);
148
+ if (previous && previous.file === line.file && previous.line === line.line - 1) {
149
+ block.push(line);
150
+ continue;
151
+ }
152
+ block = [line];
153
+ blocks.push(block);
154
+ }
155
+
156
+ return blocks;
157
+ }
158
+
159
+ // hazard: judging by the head alone lets one marker cover any length of text below it.
160
+ export const MAX_DECLARED_LINES = 4;
161
+
162
+ export type NextCodeLine = (file: string, line: number) => string | undefined;
163
+
164
+ type Verdict = { violates: boolean; reason: string };
165
+
166
+ // hazard: `*/` is not a comment line, so a block ends one line before its closer. The lookup skips the
167
+ // closer and any continuation line to reach the declaration, here rather than in each resolver.
168
+ function declarationAfter(file: string, tailLine: number, nextCodeLine: NextCodeLine): string | undefined {
169
+ for (let line = tailLine + 1; line <= tailLine + 4; line += 1) {
170
+ const text = nextCodeLine(file, line);
171
+ if (text === undefined) {
172
+ continue;
173
+ }
174
+ if (text.trim() === "" || CLOSER_OR_CONTINUATION.test(text)) {
175
+ continue;
176
+ }
177
+ return text;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function judge(block: AddedLine[], mode: CommentMode, nextCodeLine?: NextCodeLine): Verdict {
183
+ const head = block[0] as AddedLine;
184
+ const tail = block.at(-1) as AddedLine;
185
+
186
+ if (head.text.trimStart().startsWith("/**") && nextCodeLine) {
187
+ const identifier = attachedIdentifier(declarationAfter(head.file, tail.line, nextCodeLine));
188
+ if (identifier !== null) {
189
+ const body = block.map((line) => line.text).join(" ");
190
+ if (mode === "strict") {
191
+ return { violates: true, reason: "comment added this turn" };
192
+ }
193
+ if (!isInformativeDoc(body, identifier)) {
194
+ return { violates: true, reason: `doc comment only restates ${identifier}` };
195
+ }
196
+ // why: a doc comment is prose a reader meets first, so it is the surface where a dead citation costs most.
197
+ const docLeak = mode === "resolvable" ? firstLeak(body) : null;
198
+ return docLeak === null
199
+ ? { violates: false, reason: "" }
200
+ : { violates: true, reason: leakReason(docLeak) };
201
+ }
202
+ }
203
+
204
+ if (mode === "strict") {
205
+ return { violates: true, reason: "comment added this turn" };
206
+ }
207
+ if (!declaresReason(head.text)) {
208
+ return { violates: true, reason: "undeclared comment added this turn" };
209
+ }
210
+ if (block.length > MAX_DECLARED_LINES) {
211
+ return { violates: true, reason: `declared comment runs past ${MAX_DECLARED_LINES} lines` };
212
+ }
213
+ // invariant: resolvability is asked last, and only of a comment that already earned its place. A comment with
214
+ // no declared reason is refused for that, and adding a second refusal to the same block would report one
215
+ // problem as two ([/decisions/ad-070.md](/decisions/ad-070.md)).
216
+ if (mode === "resolvable") {
217
+ const leak = firstLeak(block.map((line) => line.text).join(" "));
218
+ if (leak !== null) {
219
+ return { violates: true, reason: leakReason(leak) };
220
+ }
221
+ }
222
+ return { violates: false, reason: "" };
223
+ }
224
+
225
+ export function findAddedComments(
226
+ added: AddedLine[],
227
+ mode: CommentMode = "declared",
228
+ nextCodeLine?: NextCodeLine,
229
+ ): CommentFinding[] {
230
+ const findings: CommentFinding[] = [];
231
+ for (const block of groupCommentBlocks(added)) {
232
+ if (block[0] === undefined) {
233
+ continue;
234
+ }
235
+ const verdict = judge(block, mode, nextCodeLine);
236
+ if (verdict.violates) {
237
+ const head = block[0];
238
+ findings.push({
239
+ file: head.file,
240
+ line: head.line,
241
+ reason: verdict.reason,
242
+ text: head.text.trim().slice(0, 120),
243
+ });
244
+ }
245
+ }
246
+ return findings;
247
+ }
248
+
249
+ // hazard: documenting an existing export touches only the comment, so the declaration it attaches to is
250
+ // absent from the diff and has to be read from disk.
251
+ function diskLineReader(projectDir: string): NextCodeLine {
252
+ const cache = new Map<string, string[]>();
253
+ return (file, line) => {
254
+ let lines = cache.get(file);
255
+ if (lines === undefined) {
256
+ try {
257
+ lines = readFileSync(join(projectDir, file), "utf8").split("\n");
258
+ } catch {
259
+ lines = [];
260
+ }
261
+ cache.set(file, lines);
262
+ }
263
+ return lines[line - 1];
264
+ };
265
+ }
266
+
267
+ export async function scanAddedComments(
268
+ projectDir: string,
269
+ relativePaths: string[],
270
+ mode: CommentMode = "declared",
271
+ base = "HEAD",
272
+ ): Promise<CommentFinding[]> {
273
+ const added = await listAddedLines(projectDir, relativePaths, base);
274
+ return findAddedComments(added, mode, diskLineReader(projectDir));
275
+ }
276
+
277
+ export function commentViolationMessage(hits: CommentFinding[], mode: CommentMode = "declared"): string {
278
+ const need =
279
+ mode === "resolvable"
280
+ ? [
281
+ // why: the fix for an unresolvable comment is not deletion. The passage usually carries a true fact
282
+ // wrapped in the session's vantage — restating it at HEAD keeps the fact and drops the transcript.
283
+ "NEED: restate each line below so a reader at HEAD can check it without the transcript of",
284
+ "this session — state the present behaviour, or state the counterfactual (`without X, Y`).",
285
+ "Delete it when nothing survives that restatement.",
286
+ ]
287
+ : mode === "strict"
288
+ ? [
289
+ "NEED: delete every line below. This project does not accept agent-added comments.",
290
+ "If one is genuinely warranted, say so in your reply and let the operator write it.",
291
+ ]
292
+ : [
293
+ `NEED: delete each line below, or restate it as ${COMMENT_MARKERS.join(" / ")} when it`,
294
+ "records a non-obvious why, a hazard, or an external constraint. Narrating what the code",
295
+ "does is not a reason.",
296
+ ];
297
+ return [
298
+ `BLOCKED: this turn added ${hits.length} comment(s).`,
299
+ "TRIED: compared the lines this turn added against the commit it started from; pre-existing",
300
+ "comments are never counted.",
301
+ "Each entry is one comment, reported at its first line.",
302
+ ...need,
303
+ "Tool directives (biome-ignore, @ts-, noqa, shellcheck, shebang) are exempt.",
304
+ "",
305
+ ...hits.slice(0, 20).map((h) => `${h.file}:${h.line} ${h.text}`),
306
+ ].join("\n");
307
+ }
@@ -0,0 +1,6 @@
1
+ export type CommentFinding = {
2
+ file: string;
3
+ line: number;
4
+ reason: string;
5
+ text: string;
6
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A comment is resolvable when a reader at HEAD — with no transcript of the session that wrote it, no pull
3
+ * request thread and no draft — can resolve every reference in it and check every claim.
4
+ *
5
+ * why: the existing modes ask whether a comment *declares* a reason. They cannot ask whether the reason means
6
+ * anything to somebody who was not there, and that is the comment a model actually writes: it narrates the
7
+ * change it just made, cites a numbered item only the session could see, or argues with a reviewer who has since
8
+ * left. Each one reads as a reason and resolves to nothing
9
+ * ([/decisions/ad-070.md](/decisions/ad-070.md)).
10
+ *
11
+ * invariant: prose, never syntax. Nothing here parses a language, so a new language costs nothing and the rule
12
+ * behaves identically in Go, Python and TypeScript — the constraint that produced the syntax catalog
13
+ * ([/decisions/ad-058.md](/decisions/ad-058.md)).
14
+ */
15
+ export type LeakKind =
16
+ | "change-narration"
17
+ | "dead-citation"
18
+ | "review-vantage"
19
+ | "reviewer-addressed"
20
+ | "flow-narration";
21
+
22
+ export type LeakRule = {
23
+ kind: LeakKind;
24
+ pattern: RegExp;
25
+ /** What the reader at HEAD cannot do, phrased so the fix is obvious from the message. */
26
+ says: string;
27
+ };
28
+
29
+ /**
30
+ * hazard: precision over recall, deliberately. A rail that flags a good comment teaches the operator to switch it
31
+ * off, and the next real finding goes with it ([/decisions/ad-034.md](/decisions/ad-034.md)). Every pattern here
32
+ * is one that cannot be true of a comment describing present behaviour: a past-tense claim about the code is
33
+ * never a statement about what it does now, and a parenthesised decision number never resolves from the
34
+ * repository.
35
+ */
36
+ export const LEAK_RULES: readonly LeakRule[] = [
37
+ /**
38
+ * hazard: `no longer` was here and was dropped on the evidence. Every occurrence in this repository described
39
+ * *runtime* state — a lock owner that no longer exists, a lesson ref that no longer resolves, a path a future
40
+ * refactor would leave behind — and not one described the repository's own history. A phrase that is four
41
+ * times wrong and zero times right is a phrase that trains the operator to switch the rail off.
42
+ */
43
+ {
44
+ kind: "change-narration",
45
+ pattern: /\b(?:used to|previously)\b/i,
46
+ says: "narrates the change instead of the state",
47
+ },
48
+ {
49
+ kind: "change-narration",
50
+ pattern: /\bthis (?:was|used to)\b/i,
51
+ says: "narrates the change instead of the state",
52
+ },
53
+ {
54
+ kind: "change-narration",
55
+ pattern: /\bthe old (?:code|version|implementation|approach|way|behaviou?r)\b/i,
56
+ says: "refers to code that is no longer here",
57
+ },
58
+ {
59
+ kind: "change-narration",
60
+ pattern: /\bbefore (?:this|the) (?:change|commit|fix|patch|refactor)\b/i,
61
+ says: "refers to a state the repository no longer holds",
62
+ },
63
+ {
64
+ kind: "dead-citation",
65
+ pattern: /\((?:decision|item|step|phase|task|audit|option)\s*#?\d+\)/i,
66
+ says: "cites something only the authoring session could see",
67
+ },
68
+ {
69
+ kind: "dead-citation",
70
+ pattern: /§\s*\d/,
71
+ says: "cites a section of a document that is not in the repository",
72
+ },
73
+ {
74
+ kind: "dead-citation",
75
+ pattern: /\bas (?:decided|agreed|discussed|mentioned|described) (?:above|earlier|previously|before)\b/i,
76
+ says: "points at a conversation the reader cannot see",
77
+ },
78
+ {
79
+ kind: "dead-citation",
80
+ pattern: /\b(?:per|from|in) the plan\b|\bthe plan above\b/i,
81
+ says: "points at a plan that is not in the repository",
82
+ },
83
+ {
84
+ kind: "review-vantage",
85
+ pattern: /\bthis (?:PR|MR|commit|patch|diff|changeset)\b/i,
86
+ says: "speaks from the change rather than from the repository",
87
+ },
88
+ {
89
+ kind: "review-vantage",
90
+ pattern: /\ba (?:later|follow-?up|subsequent) (?:PR|MR|commit)\b/i,
91
+ says: "speaks from the change rather than from the repository",
92
+ },
93
+ {
94
+ kind: "reviewer-addressed",
95
+ pattern: /\bthis is (?:safe|correct|fine|ok|okay)\b/i,
96
+ says: "argues its own correctness to a reviewer instead of stating the invariant",
97
+ },
98
+ {
99
+ kind: "reviewer-addressed",
100
+ pattern: /\brejected in review\b|\bthe reviewer\b/i,
101
+ says: "records who said what, which the repository cannot confirm",
102
+ },
103
+ {
104
+ kind: "flow-narration",
105
+ pattern: /\bfirst (?:we|it|this)\b[\s\S]{0,80}\bthen (?:we|it|this)\b/i,
106
+ says: "restates the control flow the code already shows",
107
+ },
108
+ ];
109
+
110
+ export type Leak = { kind: LeakKind; says: string; match: string };
111
+
112
+ /**
113
+ * why: the whole block is judged as one string. A sentence split across two comment lines is still one sentence,
114
+ * and a pattern spanning a clause only matches once the lines are joined.
115
+ */
116
+ export function findLeaks(blockText: string): Leak[] {
117
+ const leaks: Leak[] = [];
118
+ for (const rule of LEAK_RULES) {
119
+ const found = rule.pattern.exec(blockText);
120
+ if (found !== null) {
121
+ leaks.push({ kind: rule.kind, says: rule.says, match: found[0] });
122
+ }
123
+ }
124
+ return leaks;
125
+ }
126
+
127
+ /** why: one comment yields one finding, so a block matching three rules does not read as three problems. */
128
+ export function firstLeak(blockText: string): Leak | null {
129
+ return findLeaks(blockText)[0] ?? null;
130
+ }
131
+
132
+ export function leakReason(leak: Leak): string {
133
+ return `unresolvable comment — ${leak.says} (\`${leak.match}\`)`;
134
+ }