@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
package/bin/tlc-cli.ts ADDED
@@ -0,0 +1,1509 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { delimiter, join } from "node:path";
14
+ import { coreFacade } from "../src/core/index.ts";
15
+ import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts";
16
+ import { flagsDir, projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
17
+ import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts";
18
+ import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
19
+
20
+ export class UsageError extends Error {}
21
+
22
+ // why: derived from the facade rather than imported from inside the policy aggregate, so the CLI keeps its
23
+ // single door into core and the two cannot drift apart.
24
+ type Posture = ReturnType<typeof coreFacade.policy.resolveProjectPosture>;
25
+
26
+ export function resolveProjectRoot(): string {
27
+ return process.env.TLC_PROJECT_DIR ?? process.cwd();
28
+ }
29
+
30
+ export function modeFilePath(root: string): string {
31
+ return join(projectStateDir(root), "harness-mode");
32
+ }
33
+
34
+ export function grindFlagPath(root: string): string {
35
+ return join(flagsDir(root), "grind-on");
36
+ }
37
+
38
+ export function skipFlagPath(root: string): string {
39
+ return join(flagsDir(root), "skip-verify");
40
+ }
41
+
42
+ // why: the posture flag files carry the posture names, so there is one spelling per posture across the config
43
+ // field, the state file, the flag file and this command.
44
+ export function focusFlagPath(root: string): string {
45
+ return join(flagsDir(root), "focus");
46
+ }
47
+
48
+ export function pairedFlagPath(root: string): string {
49
+ return join(flagsDir(root), "paired");
50
+ }
51
+
52
+ export function ensureFlagsDir(root: string): void {
53
+ mkdirSync(flagsDir(root), { recursive: true });
54
+ }
55
+
56
+ export function readMode(root: string): string {
57
+ return coreFacade.policy.loadPolicy(root).mode;
58
+ }
59
+
60
+ export function grindOn(root: string): boolean {
61
+ return coreFacade.policy.loadPolicy(root).grind.enabled;
62
+ }
63
+
64
+ export function gatesPaused(root: string): boolean {
65
+ return existsSync(skipFlagPath(root));
66
+ }
67
+
68
+ export function acceptedModes(): string {
69
+ return coreFacade.policy.OPERATOR_MODES.join(" | ");
70
+ }
71
+
72
+ export function statusScreen(root: string): Screen {
73
+ const report = statusJson(root);
74
+ // why: a rejected value is reported next to the posture that replaced it. Printing only `fallback` would leave
75
+ // the operator with a posture they did not set and no way to see which word was refused.
76
+ const origin =
77
+ report.modeInvalid === undefined
78
+ ? `from ${report.modeOrigin}`
79
+ : `${report.modeOrigin} — \`${report.modeInvalid}\` is not a posture; accepted: ${acceptedModes()}`;
80
+ return {
81
+ title: "harness status",
82
+ summary: [root],
83
+ sections: [
84
+ {
85
+ rows: [
86
+ { label: "mode", value: `${report.mode} [${origin}]`, level: "info" },
87
+ {
88
+ label: "grind",
89
+ value: report.grind
90
+ ? "ON — stop hook re-runs lint/tests and auto-retries on fail"
91
+ : "OFF — no auto fix loops",
92
+ level: report.grind ? "ok" : "info",
93
+ },
94
+ {
95
+ label: "gates",
96
+ value: report.gatesPaused ? "PAUSED — stop checks disabled" : "active",
97
+ level: report.gatesPaused ? "warn" : "ok",
98
+ },
99
+ ],
100
+ },
101
+ {
102
+ title: "Postures",
103
+ lines: [
104
+ "paired explains as it goes, and asks before any sizable move",
105
+ "solo works on its own; a destructive action, a dead-end or real ambiguity reaches you",
106
+ "focus only a destructive action or a dead-end reaches you; it settles ambiguity itself",
107
+ ],
108
+ },
109
+ ],
110
+ footer: "verification is identical at all three postures · tlc harness why · tlc harness doctor",
111
+ };
112
+ }
113
+
114
+ export function statusText(root: string, style: Style = PLAIN): string {
115
+ return render(statusScreen(root), style);
116
+ }
117
+
118
+ export type StatusReport = {
119
+ root: string;
120
+ mode: string;
121
+ modeOrigin: Posture["origin"];
122
+ modeInvalid?: string;
123
+ grind: boolean;
124
+ gatesPaused: boolean;
125
+ };
126
+
127
+ export function statusJson(root: string): StatusReport {
128
+ const policy = coreFacade.policy.loadPolicy(root);
129
+ // invariant: posture and its origin come from the resolver the loader itself uses. Status recomputing either
130
+ // one is what made it report the opposite of every hook ([/decisions/ad-020.md](/decisions/ad-020.md)).
131
+ const posture = coreFacade.policy.resolveProjectPosture(root);
132
+ return {
133
+ root,
134
+ mode: posture.mode,
135
+ modeOrigin: posture.origin,
136
+ ...(posture.invalid === undefined ? {} : { modeInvalid: posture.invalid }),
137
+ grind: policy.grind.enabled,
138
+ gatesPaused: gatesPaused(root),
139
+ };
140
+ }
141
+
142
+ // invariant: every sanctioned mutation re-records the baselines. That is what makes "a harness command did
143
+ // this" and "the baseline matches" a single fact — an out-of-band write skips this call and stays visible.
144
+ export function setGrind(root: string, on: boolean): string {
145
+ ensureFlagsDir(root);
146
+ const path = grindFlagPath(root);
147
+ if (on) {
148
+ writeFileSync(path, "");
149
+ coreFacade.policy.refreshPolicyBaselines(root);
150
+ return "grind ON — stop hook will lint/test and auto-retry on failure";
151
+ }
152
+ if (existsSync(path)) {
153
+ rmSync(path);
154
+ }
155
+ coreFacade.policy.refreshPolicyBaselines(root);
156
+ return "grind OFF — no auto fix loops";
157
+ }
158
+
159
+ export function setPaused(root: string, on: boolean): string {
160
+ ensureFlagsDir(root);
161
+ const path = skipFlagPath(root);
162
+ if (on) {
163
+ writeFileSync(path, "");
164
+ coreFacade.policy.refreshPolicyBaselines(root);
165
+ return "gates PAUSED — stop checks disabled until `tlc harness resume`";
166
+ }
167
+ if (existsSync(path)) {
168
+ rmSync(path);
169
+ }
170
+ coreFacade.policy.refreshPolicyBaselines(root);
171
+ return "gates ACTIVE again";
172
+ }
173
+
174
+ // hazard: this used to map `focus` onto a second spelling before writing, so the word the operator typed and the
175
+ // word the config field stored were different — and a config written from the documented word then matched no
176
+ // branch at all. One word per posture, and nothing translates.
177
+ const MODE_CONFIRMATION: Record<Posture["mode"], string> = {
178
+ paired: "mode paired — explains as it goes, and asks before any sizable move",
179
+ solo: "mode solo — a destructive action, a dead-end or real ambiguity reaches you",
180
+ focus: "mode focus — only a destructive action or a dead-end reaches you; ambiguity is settled for you",
181
+ };
182
+
183
+ export function setMode(root: string, raw: string): string {
184
+ const mode = raw.toLowerCase();
185
+ if (!coreFacade.policy.isOperatorMode(mode)) {
186
+ throw new UsageError(`mode must be: ${acceptedModes()}`);
187
+ }
188
+ ensureFlagsDir(root);
189
+ writeFileSync(modeFilePath(root), `${mode}\n`);
190
+ coreFacade.policy.refreshPolicyBaselines(root);
191
+ // why: posture governs surfacing only. Announcing grind here would claim a capability this command does not
192
+ // touch — it has its own switch, its own flag and its own trade-off.
193
+ return MODE_CONFIRMATION[mode];
194
+ }
195
+
196
+ export type HandoffReport = {
197
+ root: string;
198
+ providers: Record<string, ReturnType<typeof coreFacade.handoff.readHandoff>>;
199
+ };
200
+
201
+ /**
202
+ * The sanctioned way to read handoff state.
203
+ *
204
+ * why: the bootstrap used to tell the agent to read `.tlc/harness/state/handoff.json`, a path the floor guards. So
205
+ * the instruction and the permission disagreed, and the obvious command — `test -f … && head -c 2000 …` — was
206
+ * refused with advice about writing policy. An instruction is not an affordance; the route the harness asks for has
207
+ * to be one it grants ([/decisions/ad-047.md](/decisions/ad-047.md)).
208
+ */
209
+ export function handoffJson(root: string): HandoffReport {
210
+ const file = coreFacade.handoff.readHandoffFile(root);
211
+ const providers: HandoffReport["providers"] = {};
212
+ for (const provider of Object.keys(file.by_provider)) {
213
+ providers[provider] = coreFacade.handoff.readHandoff(root, provider);
214
+ }
215
+ return { root, providers };
216
+ }
217
+
218
+ export function handoffScreen(report: HandoffReport): Screen {
219
+ const names = Object.keys(report.providers).sort();
220
+ if (names.length === 0) {
221
+ return {
222
+ title: "handoff",
223
+ summary: [report.root],
224
+ sections: [{ lines: ["nothing recorded yet — this is a fresh start, not a missing file"] }],
225
+ };
226
+ }
227
+ const sections: Section[] = [];
228
+ for (const name of names) {
229
+ const slice = report.providers[name];
230
+ if (!slice) {
231
+ continue;
232
+ }
233
+ const rows: Row[] = [];
234
+ for (const [label, value, level] of [
235
+ ["blockers", slice.blockers, "warn"],
236
+ ["next", slice.next_action, "info"],
237
+ ["last gate", slice.last_gate_result, slice.last_gate_result === "pass" ? "ok" : "warn"],
238
+ ["last failure", slice.last_failure_category, "fail"],
239
+ ] as const) {
240
+ if (value) {
241
+ rows.push({ label, value: String(value), level });
242
+ }
243
+ }
244
+ for (const [label, list] of [
245
+ ["in progress", slice.in_progress],
246
+ ["pending", slice.pending],
247
+ ["gaps", slice.previous_gaps?.map((gap) => gap.summary)],
248
+ ] as const) {
249
+ if (list && list.length > 0) {
250
+ rows.push({ label, value: list.slice(0, 6).join(" | ") });
251
+ }
252
+ }
253
+ sections.push({ title: `${name} (updated ${slice.updated_at})`, rows });
254
+ }
255
+ return { title: "handoff", summary: [report.root], sections };
256
+ }
257
+
258
+ export function handoffText(report: HandoffReport, style: Style = PLAIN): string {
259
+ return render(handoffScreen(report), style);
260
+ }
261
+
262
+ /**
263
+ * why: the artifact a reviewer can read. Everything in it is something the harness observed, and the chain is what
264
+ * makes a rewritten middle detectable ([/decisions/ad-028.md](/decisions/ad-028.md)).
265
+ */
266
+ export function attestScreen(root: string): Screen {
267
+ const records = coreFacade.attest.readAttestations(root);
268
+ const verdict = coreFacade.attest.verifyChain(records);
269
+ const head: Row = verdict.ok
270
+ ? { label: "chain", value: `attestation chain OK — ${verdict.length} session(s)`, level: "ok" }
271
+ : {
272
+ label: "chain",
273
+ value: `attestation chain BROKEN at record ${verdict.brokenAt} (${verdict.reason})`,
274
+ level: "fail",
275
+ };
276
+ if (records.length === 0) {
277
+ return {
278
+ title: "attestation",
279
+ summary: [root],
280
+ sections: [{ rows: [head] }, { lines: ["no sessions recorded yet"] }],
281
+ };
282
+ }
283
+ const sections: Section[] = [{ rows: [head] }];
284
+ for (const record of records.slice(-10).reverse()) {
285
+ const rules = Object.entries(record.decisionsByRule)
286
+ .map(([rule, count]) => `${rule}=${count}`)
287
+ .join(" ");
288
+ sections.push({
289
+ title: `${record.ts} ${record.provider}/${record.session}`,
290
+ rows: [
291
+ {
292
+ label: "policy",
293
+ value: `${record.policyFingerprint}${record.policyDiverged ? " (DIVERGED mid-session)" : ""}`,
294
+ level: record.policyDiverged ? "warn" : "ok",
295
+ },
296
+ { label: "rails", value: record.railsActive.join(", ") || "none" },
297
+ {
298
+ label: "gates",
299
+ value: `${record.gates.pass} pass / ${record.gates.fail} fail${rules ? ` | ${rules}` : ""}`,
300
+ },
301
+ ],
302
+ });
303
+ }
304
+ return {
305
+ title: "attestation",
306
+ summary: [root],
307
+ sections,
308
+ footer: "chained, not signed — it detects a rewritten record and proves nothing about authorship",
309
+ };
310
+ }
311
+
312
+ export function attestText(root: string, style: Style = PLAIN): string {
313
+ return render(attestScreen(root), style);
314
+ }
315
+
316
+ export type AttestReport = {
317
+ ok: boolean;
318
+ brokenAt?: number;
319
+ reason?: string;
320
+ sessions: number;
321
+ records: ReturnType<typeof coreFacade.attest.readAttestations>;
322
+ };
323
+
324
+ export function attestJson(root: string): AttestReport {
325
+ const records = coreFacade.attest.readAttestations(root);
326
+ const verdict = coreFacade.attest.verifyChain(records);
327
+ return verdict.ok
328
+ ? { ok: true, sessions: verdict.length, records }
329
+ : { ok: false, brokenAt: verdict.brokenAt, reason: verdict.reason, sessions: records.length, records };
330
+ }
331
+
332
+ /**
333
+ * The one command whose job is to clear a tampering signal, which is why four independent locks sit between it and
334
+ * an agent ([/decisions/ad-030.md](/decisions/ad-030.md)):
335
+ *
336
+ * 1. the floor refuses `tlc harness policy` from inside any agent session, with no config switch;
337
+ * 2. this refuses without an interactive terminal, so a script cannot reach it either;
338
+ * 3. the operator names each path, so accepting is an act rather than a keystroke and its blast radius is exactly
339
+ * what was typed;
340
+ * 4. acceptance is per source, so the other divergences keep blocking.
341
+ *
342
+ * hazard: `interactive` is a parameter rather than an `isTTY` read, so the refusal is testable without a pty. That
343
+ * matters most on the rail whose failure mode is silence.
344
+ */
345
+ export function acceptPolicy(root: string, paths: string[], interactive: boolean): string {
346
+ if (!interactive) {
347
+ throw new UsageError(
348
+ "tlc harness policy accept needs an interactive terminal — clearing a policy divergence is the operator's call, not a script's.",
349
+ );
350
+ }
351
+ /**
352
+ * why: `--all` accepts exactly what `tlc harness policy` just listed, in this project. Typing each absolute
353
+ * path was the friction, and the four locks are about *who* clears a divergence and *where from*, not about how
354
+ * much they have to type ([/decisions/ad-058.md](/decisions/ad-058.md)).
355
+ *
356
+ * invariant: still per source. It expands to the diverged list at this moment, so a divergence that appears
357
+ * afterwards is not covered by it — there is no blanket permission here either.
358
+ */
359
+ const requested = paths.includes("--all") ? coreFacade.policy.allDivergedPaths(root) : paths;
360
+ if (paths.includes("--all") && requested.length === 0) {
361
+ return `nothing to accept — no policy source diverged in ${root}`;
362
+ }
363
+ if (requested.length === 0) {
364
+ throw new UsageError(
365
+ [
366
+ "usage: tlc harness policy accept <path> [path...]",
367
+ " tlc harness policy accept --all accept everything `tlc harness policy` lists here",
368
+ ].join("\n"),
369
+ );
370
+ }
371
+ /**
372
+ * hazard: acceptance is written into this project's baseline directory, and the success line used to claim
373
+ * "every live session". Run from another directory it printed success and cleared nothing — measured while
374
+ * unblocking a live session, twice, because the message gave no way to tell. It now names the project, and
375
+ * says so loudly when this project has no blocked session at all.
376
+ */
377
+ const blocked = coreFacade.policy.allDivergedPaths(root);
378
+ const notHere = requested.filter((path) => !blocked.includes(path));
379
+ const outcome = coreFacade.policy.acceptPolicySources(root, requested);
380
+ if (outcome.kind === "not-a-source") {
381
+ throw new UsageError(
382
+ [
383
+ `not a policy source: ${outcome.paths.join(", ")}`,
384
+ "The sources the loader reads are:",
385
+ ...outcome.sources.map((source) => ` ${source}`),
386
+ ].join("\n"),
387
+ );
388
+ }
389
+ if (outcome.kind === "nothing-to-accept") {
390
+ return [
391
+ `nothing to accept — ${root} has no recorded session baseline.`,
392
+ "Acceptance is written per project. Run this from the repository whose session is blocked:",
393
+ ` cd <that repo> && tlc harness policy accept ${requested.join(" ")}`,
394
+ ].join("\n");
395
+ }
396
+ const lines = [
397
+ `accepted: ${outcome.paths.join(", ")}`,
398
+ ` for sessions in ${root} — acceptance is per project, not machine-wide`,
399
+ ];
400
+ if (notHere.length > 0) {
401
+ lines.push(
402
+ ` note: ${notHere.join(", ")} was not diverging here. If a session elsewhere is blocked, run this in that repository too.`,
403
+ );
404
+ }
405
+ return lines.join("\n");
406
+ }
407
+
408
+ export function policyScreen(root: string): Screen {
409
+ const diverged = coreFacade.policy.allDivergedPaths(root);
410
+ if (diverged.length === 0) {
411
+ return {
412
+ title: "policy baseline",
413
+ sections: [
414
+ {
415
+ rows: [
416
+ {
417
+ label: "baseline",
418
+ value: "matches — nothing changed out of band during any live session",
419
+ level: "ok",
420
+ },
421
+ ],
422
+ },
423
+ ],
424
+ };
425
+ }
426
+ return {
427
+ title: "policy baseline",
428
+ summary: [`policy changed out of band during a live session (${diverged.length})`],
429
+ sections: [
430
+ { rows: diverged.map((path) => ({ label: "changed", value: path, level: "warn" as const })) },
431
+ {
432
+ title: "If that was you, accept it from your own terminal with",
433
+ lines: [`tlc harness policy accept ${diverged.join(" ")}`, "", "or: tlc harness policy accept --all"],
434
+ },
435
+ ],
436
+ footer: "accepting is per path, so anything you leave out keeps blocking",
437
+ };
438
+ }
439
+
440
+ export function policyText(root: string, style: Style = PLAIN): string {
441
+ return render(policyScreen(root), style);
442
+ }
443
+
444
+ export type PolicyReport = { diverged: string[]; ok: boolean };
445
+
446
+ export function policyJson(root: string): PolicyReport {
447
+ const diverged = coreFacade.policy.allDivergedPaths(root);
448
+ return { diverged, ok: diverged.length === 0 };
449
+ }
450
+
451
+ /** why: computed once and read by both `update` and `update --check`, so the two cannot disagree about what is upstream. */
452
+ export function upstreamRef(dest: string): string {
453
+ const read = (args: string[]): string => {
454
+ const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env });
455
+ return (r.status ?? 1) === 0 ? (r.stdout ?? "").trim() : "";
456
+ };
457
+ const tracked = read(["rev-parse", "--abbrev-ref", "@{u}"]);
458
+ if (tracked !== "") {
459
+ return tracked;
460
+ }
461
+ return `origin/${read(["rev-parse", "--abbrev-ref", "HEAD"]) || "main"}`;
462
+ }
463
+
464
+ /**
465
+ * What kind of runtime path this is, which decides what `update` may write to it.
466
+ *
467
+ * The distinction is the whole fix. A `managed` path is an artifact the installer created and the harness owns, so
468
+ * a conflict in it is not a decision for the operator — it is discarded. A `linked` path is a symlink to somebody's
469
+ * working clone, so nothing there may be written by a harness command at all
470
+ * ([/decisions/ad-046.md](/decisions/ad-046.md)).
471
+ */
472
+ export type RuntimePathKind = "managed" | "linked" | "npm" | "unmanaged" | "absent";
473
+
474
+ export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit";
475
+
476
+ /**
477
+ * why: an npm-delivered runtime is a real directory with no `.git`, which the old classifier called `unmanaged`
478
+ * and `doctor` reported as a failure — on a perfectly healthy install. It is told apart by the marker the
479
+ * installer leaves, not by guessing from the contents, because a directory can be many things and only the thing
480
+ * that created it knows which ([/decisions/ad-056.md](/decisions/ad-056.md)).
481
+ */
482
+ export const NPM_MARKER = "installed-from-npm";
483
+
484
+ /**
485
+ * hazard: `install.sh` links the runtime path to the clone it was run from, so on a contributor's machine
486
+ * `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run
487
+ * `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine.
488
+ *
489
+ * invariant: the symlink test comes first and is decided by the path, never by its contents. A linked clone
490
+ * contains a `.git` too, so testing for that first would classify it as ours.
491
+ */
492
+ export function classifyRuntimePath(
493
+ dest: string,
494
+ probe: { isSymlink: (path: string) => boolean; exists: (path: string) => boolean },
495
+ ): RuntimePathKind {
496
+ if (probe.isSymlink(dest)) {
497
+ return "linked";
498
+ }
499
+ if (!probe.exists(dest)) {
500
+ return "absent";
501
+ }
502
+ if (probe.exists(join(dest, ".git"))) {
503
+ return "managed";
504
+ }
505
+ return probe.exists(join(dest, NPM_MARKER)) ? "npm" : "unmanaged";
506
+ }
507
+
508
+ /**
509
+ * hazard: this must be asked about the **configured** home, not a resolved one. `resolveHarnessRoot` calls
510
+ * `realpathSync`, so passing its result made a linked clone look like a managed checkout — and `update` then ran
511
+ * `git fetch` inside a contributor's repository. Caught by driving the real command against a linked install rather
512
+ * than by any unit test ([/decisions/ad-046.md](/decisions/ad-046.md)).
513
+ *
514
+ * hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI
515
+ * refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves
516
+ * elsewhere and a **managed** checkout was classified as linked — which would silently stop updates on the very
517
+ * platform the reporter uses. Only the last hop decides, which is the one thing `install.sh` actually creates.
518
+ */
519
+ export function runtimePathKind(dest: string): RuntimePathKind {
520
+ return classifyRuntimePath(dest, {
521
+ isSymlink: (path) => {
522
+ try {
523
+ return lstatSync(path).isSymbolicLink();
524
+ } catch {
525
+ return false;
526
+ }
527
+ },
528
+ exists: existsSync,
529
+ });
530
+ }
531
+
532
+ /**
533
+ * The bundles an install needs but does not have.
534
+ *
535
+ * why: derived from the entrypoints on disk, the same way `bin/tlc-build` derives them. A fixed list would stop
536
+ * naming a new entrypoint and the missing bundle would only surface when a hook fired.
537
+ */
538
+ export function missingBundles(dest: string): string[] {
539
+ const entrypoints = join(dest, "src", "entrypoints");
540
+ if (!existsSync(entrypoints)) {
541
+ return [];
542
+ }
543
+ const expected = readdirSync(entrypoints)
544
+ .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
545
+ .map((name) => `${name.slice(0, -3)}.mjs`);
546
+ return expected.filter((bundle) => !existsSync(join(dest, "dist", bundle)));
547
+ }
548
+
549
+ /**
550
+ * hazard: `dest` was accepted and never used, so the message said "the runtime path" without naming it while both
551
+ * sibling messages name theirs. An operator with more than one runtime could not tell which link was meant.
552
+ */
553
+ export function linkedRuntimeMessage(dest: string, target: string | null): string {
554
+ return [
555
+ `update: ${dest} is a link to a working clone${target ? ` → ${target}` : ""}.`,
556
+ "Nothing in it is touched by this command — updating that clone is your own `git pull`.",
557
+ "Refreshing the machine-local parts only: CLI link, init skill, provider hooks.",
558
+ ].join("\n");
559
+ }
560
+
561
+ /**
562
+ * hazard: this printed `update: git fetch failed.` and stopped. git's own error names a transport problem and not
563
+ * the route that works, which is the same shape as the refusal AD-047 was written about
564
+ * ([/decisions/ad-052.md](/decisions/ad-052.md)).
565
+ */
566
+ export function fetchFailureMessage(dest: string): string {
567
+ return [
568
+ `update: git fetch failed in ${dest}.`,
569
+ ` The published package needs no clone: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
570
+ " If this runtime predates the move to tech-leads-club/harness-toolkit, it is still pointing at the old",
571
+ " repository, and that install is what the package replaces.",
572
+ " For a private fork, this needs a GitHub credential: `gh auth login`, then `gh auth setup-git`.",
573
+ ].join("\n");
574
+ }
575
+
576
+ export function unmanagedRuntimeMessage(dest: string): string {
577
+ return [
578
+ `update: ${dest} is not a git checkout, so there is nothing to pull.`,
579
+ `Install the package to get a runtime update can move: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
580
+ ].join("\n");
581
+ }
582
+
583
+ /**
584
+ * hazard: the git route's bare `update: git fetch failed.` sent an operator to the wrong problem for a week. A
585
+ * failing global install has its own small set of causes and each has a different fix, so they are named.
586
+ */
587
+ export function npmUpdateFailureMessage(): string {
588
+ return [
589
+ `update: npm could not install ${NPM_PACKAGE}@latest.`,
590
+ " permissions — a global prefix owned by root needs sudo, or an npm prefix you own:",
591
+ " npm config set prefix ~/.local",
592
+ " not found — the package is published; check the network and any registry proxy in ~/.npmrc",
593
+ " offline — nothing was changed; the runtime you have still works.",
594
+ ].join("\n");
595
+ }
596
+
597
+ export function resetFailureMessage(dest: string, mergeRef: string, gitOutput: string): string {
598
+ return [
599
+ `update: could not move the runtime to ${mergeRef}.`,
600
+ ` path: ${dest} (managed checkout)`,
601
+ gitOutput.trim() ? ` git: ${gitOutput.trim().split("\n").slice(-3).join(" / ")}` : "",
602
+ `Nothing was changed. If this persists, install the package instead: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
603
+ ]
604
+ .filter(Boolean)
605
+ .join("\n");
606
+ }
607
+
608
+ export type RuntimeRevision = { revision: string | null; date: string | null };
609
+
610
+ /**
611
+ * why: the revision is what `update` already moves, so it cannot drift the way a hand-edited version number does.
612
+ * `package.json` has said `0.1.0` since the first commit, which is the failure mode a number invites. And a semantic
613
+ * version is a promise about compatibility that AD-003 refuses to make ([/decisions/ad-031.md](/decisions/ad-031.md)).
614
+ */
615
+ export function runtimeRevision(dest: string): RuntimeRevision {
616
+ if (!existsSync(join(dest, ".git"))) {
617
+ return { revision: null, date: null };
618
+ }
619
+ const read = (args: string[]): string | null => {
620
+ const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env });
621
+ const out = (r.stdout ?? "").trim();
622
+ return (r.status ?? 1) === 0 && out !== "" ? out : null;
623
+ };
624
+ return { revision: read(["rev-parse", "--short", "HEAD"]), date: read(["log", "-1", "--format=%cs"]) };
625
+ }
626
+
627
+ export type VersionReport = {
628
+ runtime: string;
629
+ revision: string | null;
630
+ date: string | null;
631
+ seenRevision: string | null;
632
+ };
633
+
634
+ export function versionJson(root: string): VersionReport {
635
+ const dest = resolveHarnessRoot();
636
+ const { revision, date } = runtimeRevision(dest);
637
+ return {
638
+ runtime: dest,
639
+ revision,
640
+ date,
641
+ seenRevision: coreFacade.release.readReleaseSeen(root)?.revision ?? null,
642
+ };
643
+ }
644
+
645
+ export function versionScreen(root: string): Screen {
646
+ const report = versionJson(root);
647
+ const rows: Row[] =
648
+ report.revision === null
649
+ ? [
650
+ { label: "runtime", value: report.runtime },
651
+ // why: says so rather than printing an empty revision. A linked checkout with no `.git` is a real shape.
652
+ {
653
+ label: "revision",
654
+ value: "unknown — the runtime path is not a git checkout, so `update` cannot pull either",
655
+ level: "warn",
656
+ },
657
+ ]
658
+ : [
659
+ { label: "runtime", value: report.runtime },
660
+ { label: "revision", value: `${report.revision} (${report.date ?? "date unknown"})`, level: "ok" },
661
+ {
662
+ label: "project last saw",
663
+ value: report.seenRevision ?? "nothing yet — the next update will announce what landed",
664
+ },
665
+ ];
666
+ return { title: "harness version", sections: [{ rows }] };
667
+ }
668
+
669
+ export function versionText(root: string, style: Style = PLAIN): string {
670
+ return render(versionScreen(root), style);
671
+ }
672
+
673
+ export type PendingReport = {
674
+ ok: boolean;
675
+ reason?: string;
676
+ commits: number;
677
+ decisions: ReturnType<typeof coreFacade.release.readDecisions>;
678
+ };
679
+
680
+ /**
681
+ * why: fetches and never merges. "Look before you leap" that changes something is just leaping, so the merge is not
682
+ * reachable from this path at all rather than guarded by a flag.
683
+ */
684
+ export function pendingUpdate(dest: string, mergeRef: string): PendingReport {
685
+ if (!existsSync(join(dest, ".git"))) {
686
+ return { ok: false, reason: "the runtime path is not a git checkout", commits: 0, decisions: [] };
687
+ }
688
+ const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], { stdio: "inherit", env: process.env });
689
+ if ((fetch.status ?? 1) !== 0) {
690
+ return { ok: false, reason: "git fetch failed", commits: 0, decisions: [] };
691
+ }
692
+ const count = spawnSync("git", ["-C", dest, "rev-list", "--count", `HEAD..${mergeRef}`], {
693
+ encoding: "utf8",
694
+ env: process.env,
695
+ });
696
+ const commits = Number.parseInt((count.stdout ?? "0").trim(), 10) || 0;
697
+ const added = spawnSync(
698
+ "git",
699
+ ["-C", dest, "diff", "--name-only", "--diff-filter=A", `HEAD..${mergeRef}`, "--", "docs/decisions"],
700
+ { encoding: "utf8", env: process.env },
701
+ );
702
+ const files = (added.stdout ?? "")
703
+ .split("\n")
704
+ .map((line) => line.trim().split("/").pop() ?? "")
705
+ .filter(Boolean);
706
+ return { ok: true, commits, decisions: coreFacade.release.readDecisions(dest, files) };
707
+ }
708
+
709
+ export function pendingScreen(report: PendingReport): Screen {
710
+ if (!report.ok) {
711
+ return {
712
+ title: "update --check",
713
+ sections: [
714
+ {
715
+ rows: [{ label: "status", value: `${report.reason} — nothing to compare against`, level: "warn" }],
716
+ },
717
+ ],
718
+ };
719
+ }
720
+ if (report.commits === 0) {
721
+ return {
722
+ title: "update --check",
723
+ sections: [
724
+ { rows: [{ label: "status", value: "the runtime is current — nothing to pull", level: "ok" }] },
725
+ ],
726
+ };
727
+ }
728
+ const digest = coreFacade.release.formatDecisionDigest(report.decisions);
729
+ return {
730
+ title: "update --check",
731
+ summary: [`${report.commits} commit(s) would be pulled`, "Nothing has changed yet."],
732
+ sections: [{ lines: digest === "" ? ["no decisions landed in that range"] : digest.split("\n") }],
733
+ };
734
+ }
735
+
736
+ export function pendingText(report: PendingReport, style: Style = PLAIN): string {
737
+ return render(pendingScreen(report), style);
738
+ }
739
+
740
+ export type GateField = "test" | "lint";
741
+
742
+ const GATE_FIELDS: Record<string, GateField> = {
743
+ "test-command": "test",
744
+ "lint-command": "lint",
745
+ };
746
+
747
+ // why: resolved without executing. Running the binary to see whether it exists would run it, which is not
748
+ // something a config write is allowed to do.
749
+ export function resolveExecutable(
750
+ name: string,
751
+ env: NodeJS.ProcessEnv = process.env,
752
+ platform: string = process.platform,
753
+ ): string | null {
754
+ const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
755
+ const candidates = (base: string): string[] => [base, ...extensions.map((ext) => `${base}${ext}`)];
756
+
757
+ if (name.includes("/") || name.includes("\\")) {
758
+ return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
759
+ }
760
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
761
+ if (!dir) {
762
+ continue;
763
+ }
764
+ const found = candidates(join(dir, name)).find((candidate) => existsSync(candidate));
765
+ if (found) {
766
+ return found;
767
+ }
768
+ }
769
+ return null;
770
+ }
771
+
772
+ /**
773
+ * The only legitimate route to `grind.testCommand` and `grind.lintCommand`. Its absence is what produced the
774
+ * bypass this rail exists to stop: the guard refused the edit and the CLI offered nothing in its place.
775
+ *
776
+ * hazard: `interactive` is a parameter rather than a `process.stdin.isTTY` read here, so the refusal can be
777
+ * tested without a pty. It is a second layer only — the floor already refuses this command from inside an
778
+ * agent session, and the operator's own terminal never reaches that check.
779
+ */
780
+ export function setGateCommand(root: string, field: GateField, argv: string[], interactive: boolean): string {
781
+ if (argv.length === 0) {
782
+ throw new UsageError(`usage: tlc harness gate ${field}-command <command> [args...]`);
783
+ }
784
+ if (!interactive) {
785
+ throw new UsageError(
786
+ `tlc harness gate ${field}-command needs an interactive terminal — harness policy is the operator's to set, not a script's.`,
787
+ );
788
+ }
789
+ const binary = argv[0] as string;
790
+ if (resolveExecutable(binary) === null) {
791
+ // why: AD-021 already treats a gate command that never resolved as a config fault. Refusing it at the
792
+ // point of writing turns that fault into something the operator sees now instead of at the next gate.
793
+ throw new UsageError(
794
+ `\`${binary}\` was not found on PATH, and a gate command that cannot run is a config fault ([/decisions/ad-021.md](/decisions/ad-021.md)).`,
795
+ );
796
+ }
797
+
798
+ const path = projectConfigPath(root);
799
+ const parsed = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>) : {};
800
+ const grind = { ...((parsed.grind as Record<string, unknown> | undefined) ?? {}) };
801
+ grind[field === "test" ? "testCommand" : "lintCommand"] = argv;
802
+ parsed.grind = grind;
803
+
804
+ mkdirSync(join(root, ".tlc", "harness"), { recursive: true });
805
+ // why: canonical 2-space JSON is byte-for-byte what these configs already are, so the diff is the changed
806
+ // field and nothing else.
807
+ writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
808
+ coreFacade.policy.refreshPolicyBaselines(root);
809
+
810
+ return `grind.${field}Command = ${JSON.stringify(argv)}`;
811
+ }
812
+
813
+ export function helpScreen(): Screen {
814
+ return {
815
+ title: "tlc harness",
816
+ sections: [
817
+ {
818
+ lines: `Requires Node.js 24+ (Active LTS 24 or Current 26).
819
+
820
+ Read commands accept --json: status, doctor, obs, lessons, prices lookup, attest, policy.
821
+
822
+ QUICK
823
+ tlc harness status mode / grind / gates
824
+ tlc harness version runtime revision, and what this project last saw
825
+ tlc harness update --check what an update would pull, without pulling it
826
+ tlc harness update pull runtime + refresh skill/CLI, then doctor
827
+ tlc harness doctor health checklist
828
+ tlc harness why [n] the last n decisions this tool made, with the rule behind each
829
+ tlc harness install put the runtime in place from the installed npm package
830
+ tlc harness uninstall print what would be undone; --yes applies it, --purge includes state
831
+ tlc harness build compile dist/ for Node
832
+ tlc harness test run the full local gate
833
+ tlc harness help <topic> documentation
834
+
835
+ TOPICS
836
+ architecture | concepts | lessons | measure | prices | diagnose | init
837
+
838
+ CONTROL
839
+ tlc harness grind [on|off] tlc harness pause | resume tlc harness mode solo|paired|focus
840
+ tlc harness gate test-command <cmd> [args...] tlc harness gate lint-command <cmd> [args...]
841
+ tlc harness attest tamper-evident record of what each session ran under
842
+ tlc harness policy show a policy that changed out of band; accept <path> to clear it
843
+
844
+ MEASURE
845
+ tlc harness obs live|events|report|prune
846
+ tlc harness prices refresh [all|cursor|litellm]
847
+ tlc harness prices lookup <model-id>
848
+ tlc harness lessons list|show|garden|sync-rules
849
+
850
+ PROJECT
851
+ tlc harness init --minimal | tlc harness init --write --stdin-json`.split("\n"),
852
+ },
853
+ ],
854
+ footer: "tlc harness help <topic> for a document · tlc harness why to see what it decided",
855
+ };
856
+ }
857
+
858
+ export function helpText(style: Style = PLAIN): string {
859
+ return render(helpScreen(), style);
860
+ }
861
+
862
+ export function pricesHelpScreen(): Screen {
863
+ return {
864
+ title: "price catalogs",
865
+ sections: [
866
+ {
867
+ lines: ` tlc harness prices refresh [all|cursor|litellm]
868
+ tlc harness prices lookup <model-id>
869
+
870
+ refresh / refresh all Cursor catalog + LiteLLM fallback
871
+ refresh cursor model-prices.cursor.json (tracked)
872
+ refresh litellm model-prices.litellm.json (local)
873
+ lookup <model-id> catalog key, pool, USD for 1M in + 1M out
874
+
875
+ Resolution: overrides → Cursor → LiteLLM → null
876
+ Documentation: tlc harness help prices`.split("\n"),
877
+ },
878
+ ],
879
+ footer: "resolution: local overrides → the provider's own catalog → LiteLLM → null",
880
+ };
881
+ }
882
+
883
+ export function pricesHelpText(style: Style = PLAIN): string {
884
+ return render(pricesHelpScreen(), style);
885
+ }
886
+
887
+ export function resolveHarnessRoot(): string {
888
+ const home = runtimeHome();
889
+ try {
890
+ return realpathSync(home);
891
+ } catch {
892
+ return home;
893
+ }
894
+ }
895
+
896
+ export function execBinPath(): string {
897
+ return join(resolveHarnessRoot(), "bin", "tlc-exec");
898
+ }
899
+
900
+ export function buildBinPath(): string {
901
+ return join(resolveHarnessRoot(), "bin", "tlc-build");
902
+ }
903
+
904
+ export type Action =
905
+ | { kind: "status" }
906
+ | { kind: "help" }
907
+ | { kind: "build" }
908
+ | { kind: "update" }
909
+ | { kind: "test" }
910
+ | { kind: "grind"; on: boolean }
911
+ | { kind: "pause" }
912
+ | { kind: "resume" }
913
+ | { kind: "mode"; value: string }
914
+ | { kind: "gate"; field: GateField; argv: string[] }
915
+ | { kind: "attest" }
916
+ | { kind: "handoff" }
917
+ | { kind: "version" }
918
+ | { kind: "update-check" }
919
+ | { kind: "policy"; accept: string[] }
920
+ | { kind: "prices-help" }
921
+ | { kind: "prices-refresh"; scope: string }
922
+ | { kind: "prices-lookup"; modelId: string }
923
+ | { kind: "entry"; entry: string; args: string[] }
924
+ | { kind: "unknown"; cmd: string };
925
+
926
+ export function route(args: string[]): Action {
927
+ const cmd = (args[0] ?? "status").toLowerCase();
928
+ switch (cmd) {
929
+ case "status":
930
+ case "st":
931
+ case "s":
932
+ return { kind: "status" };
933
+ case "build":
934
+ case "rebuild":
935
+ return { kind: "build" };
936
+ case "update":
937
+ case "upgrade": {
938
+ const flags = args.slice(1);
939
+ if (flags.includes("--check")) {
940
+ return { kind: "update-check" };
941
+ }
942
+ // hazard: this accepted any flag in silence. An operator whose update had failed typed `--force`, got no
943
+ // acknowledgement that it does not exist, and read the same failure as a refusal to force
944
+ // ([/decisions/ad-048.md](/decisions/ad-048.md)).
945
+ const leftover = unknownFlags(flags);
946
+ if (leftover.length > 0) {
947
+ throw new UsageError(
948
+ leftover[0] === "--force"
949
+ ? `update takes no --force: a managed runtime is already reset to upstream, and a linked clone is never written to. If update cannot move it, install the package instead: npm i -g ${NPM_PACKAGE}@latest.`
950
+ : `unknown flag: ${leftover[0]}\nusage: tlc harness update [--check]`,
951
+ );
952
+ }
953
+ return { kind: "update" };
954
+ }
955
+ case "version":
956
+ case "--version":
957
+ return { kind: "version" };
958
+ case "test":
959
+ return { kind: "test" };
960
+ case "grind":
961
+ case "g": {
962
+ const arg = (args[1] ?? "on").toLowerCase();
963
+ if (arg === "on" || arg === "1" || arg === "true") {
964
+ return { kind: "grind", on: true };
965
+ }
966
+ if (arg === "off" || arg === "0" || arg === "false") {
967
+ return { kind: "grind", on: false };
968
+ }
969
+ throw new UsageError("usage: tlc harness grind [on|off]");
970
+ }
971
+ case "pause":
972
+ case "p":
973
+ return { kind: "pause" };
974
+ case "resume":
975
+ case "r":
976
+ return { kind: "resume" };
977
+ case "mode":
978
+ case "m": {
979
+ const modeArg = args[1];
980
+ if (!modeArg) {
981
+ throw new UsageError("usage: tlc harness mode <solo|paired|focus>");
982
+ }
983
+ return { kind: "mode", value: modeArg };
984
+ }
985
+ case "attest":
986
+ return { kind: "attest" };
987
+ case "handoff":
988
+ return { kind: "handoff" };
989
+ case "policy": {
990
+ const sub = (args[1] ?? "").toLowerCase();
991
+ if (!sub) {
992
+ return { kind: "policy", accept: [] };
993
+ }
994
+ if (sub !== "accept") {
995
+ throw new UsageError("usage: tlc harness policy [accept <path> [path...]]");
996
+ }
997
+ // why: `--all` reaches acceptPolicy as a marker in the list, where it expands to what diverged here.
998
+ return { kind: "policy", accept: args.slice(2) };
999
+ }
1000
+ case "gate": {
1001
+ const field = GATE_FIELDS[(args[1] ?? "").toLowerCase()];
1002
+ if (!field) {
1003
+ throw new UsageError("usage: tlc harness gate <test-command|lint-command> <command> [args...]");
1004
+ }
1005
+ return { kind: "gate", field, argv: args.slice(2) };
1006
+ }
1007
+ case "prices": {
1008
+ const sub = (args[1] ?? "").toLowerCase();
1009
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
1010
+ return { kind: "prices-help" };
1011
+ }
1012
+ if (sub === "refresh") {
1013
+ return { kind: "prices-refresh", scope: args[2] ?? "all" };
1014
+ }
1015
+ if (sub === "lookup" || sub === "get") {
1016
+ const modelId = args[2];
1017
+ if (!modelId) {
1018
+ throw new UsageError(
1019
+ "usage: tlc harness prices lookup <model-id>\ndetail: tlc harness help prices",
1020
+ );
1021
+ }
1022
+ return { kind: "prices-lookup", modelId };
1023
+ }
1024
+ throw new UsageError(
1025
+ "usage: tlc harness prices refresh [all|cursor|litellm] | tlc harness prices lookup <model>\ndetail: tlc harness help prices",
1026
+ );
1027
+ }
1028
+ case "obs":
1029
+ case "o":
1030
+ return { kind: "entry", entry: "obs-cli", args: args.slice(1) };
1031
+ // why: doctor used to drop its arguments, so every flag reached the entry as an empty list. It forwards
1032
+ // them now, which is what lets --json arrive at the tool.
1033
+ case "doctor":
1034
+ case "doc":
1035
+ return { kind: "entry", entry: "doctor", args: args.slice(1) };
1036
+ case "lessons":
1037
+ case "lesson":
1038
+ return { kind: "entry", entry: "lessons-cli", args: args.slice(1) };
1039
+ case "init":
1040
+ return { kind: "entry", entry: "init-project", args: args.slice(1) };
1041
+ case "install":
1042
+ return { kind: "entry", entry: "install-runtime", args: args.slice(1) };
1043
+ // why: the exit has to be as easy to find as the entrance. An operator who cannot get the harness off their
1044
+ // machine without hand-editing settings.json will not try it on a second one
1045
+ // ([/decisions/ad-066.md](/decisions/ad-066.md)).
1046
+ case "uninstall":
1047
+ return { kind: "entry", entry: "uninstall-runtime", args: args.slice(1) };
1048
+ // why: a first-class verb, not `obs why`. It is the command an operator reaches for when they cannot tell a
1049
+ // harness decision from the model, and nobody in that moment remembers it lives under `obs`.
1050
+ case "why":
1051
+ return { kind: "entry", entry: "obs-cli", args: ["why", ...args.slice(1)] };
1052
+ case "help":
1053
+ case "-h":
1054
+ case "--help": {
1055
+ const topic = args[1];
1056
+ if (!topic) {
1057
+ return { kind: "help" };
1058
+ }
1059
+ return { kind: "entry", entry: "help-topic", args: [topic] };
1060
+ }
1061
+ default:
1062
+ return { kind: "unknown", cmd };
1063
+ }
1064
+ }
1065
+
1066
+ export type TestStep = { label: string; bin: string; args: string[] };
1067
+
1068
+ // invariant: every suite is launched through the hermetic setup module. Without it the suite reads
1069
+ // CLAUDE_PROJECT_DIR from whatever started it, so 22 tests that build a fixture in a temp directory resolved
1070
+ // against the real repository — green from a shell, red from inside a hook.
1071
+ export const TEST_ENV_IMPORT = ["--import", "./tools/test-env.mjs"];
1072
+
1073
+ export function buildTestSteps(): TestStep[] {
1074
+ return [
1075
+ // why: `--error-on-warnings`. A warn-level rule does not change biome's exit code, so three fixable warnings
1076
+ // sat in this repo across several green gates until someone read the output by hand. Escalating every group to
1077
+ // `error` in biome.json was measured instead and rejected: it enables each group's non-recommended rules too,
1078
+ // which produced 3763 findings and included `noBarrelFile` and `noReExportAll` — the two rules that forbid the
1079
+ // core facade this architecture is built on ([/decisions/ad-004.md](/decisions/ad-004.md)) — and `noNodejsModules` in a Node CLI.
1080
+ { label: "biome check", bin: "npx", args: ["biome", "check", "--error-on-warnings"] },
1081
+ { label: "tsc --noEmit", bin: "npx", args: ["tsc", "--noEmit"] },
1082
+ { label: "src suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "src/**/__test__/*.test.ts"] },
1083
+ { label: "tools suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "tools/__test__/*.test.ts"] },
1084
+ { label: "check-boundaries", bin: "node", args: ["tools/dev/check-boundaries.ts"] },
1085
+ // why: `--error-on-warnings` above cannot see a rule that was suppressed rather than fixed, and biome accepts
1086
+ // any text after the colon. This is what makes the reason a reason ([/decisions/ad-051.md](/decisions/ad-051.md)).
1087
+ { label: "check-suppressions", bin: "node", args: ["tools/dev/check-suppressions.ts"] },
1088
+ { label: "check-wiring", bin: "node", args: ["tools/dev/check-wiring.ts"] },
1089
+ { label: "check-docs-bundle", bin: "node", args: ["tools/dev/check-docs-bundle.ts"] },
1090
+ // why: the bundle check validates frontmatter and links; this one validates that a decision record still has
1091
+ // the shape that makes it worth reading, and that it is cited in a form a move cannot break
1092
+ // ([/decisions/ad-069.md](/decisions/ad-069.md)).
1093
+ { label: "check-decisions", bin: "node", args: ["tools/dev/check-decisions.ts"] },
1094
+ { label: "check-screens", bin: "node", args: ["tools/dev/check-screens.ts"] },
1095
+ { label: "check-obs-contract", bin: "node", args: ["tools/dev/check-obs-contract.ts"] },
1096
+ // why: `bin` declared `./bin/tlc.mjs` and npm dropped both executables on publish, so the package installed no
1097
+ // command at all. The release runner was the only thing that saw it, in a warning on a build that then failed
1098
+ // for an unrelated reason ([/decisions/ad-081.md](/decisions/ad-081.md)).
1099
+ { label: "check-manifest", bin: "node", args: ["tools/dev/check-manifest.ts"] },
1100
+ { label: "capabilities in sync", bin: "node", args: ["tools/dev/render-capabilities.ts", "--check"] },
1101
+ { label: "changelog in sync", bin: "node", args: ["tools/dev/render-changelog.ts", "--check"] },
1102
+ // why: the OKF bundle's log is a reserved file that cannot be retired, and hand-maintaining it drifted to 19
1103
+ // of 66 records before anyone noticed ([/decisions/ad-067.md](/decisions/ad-067.md)).
1104
+ { label: "log in sync", bin: "node", args: ["tools/dev/render-log.ts", "--check"] },
1105
+ // why: the coverage page names floor rules and capability ids, and every hand-written list of this project's
1106
+ // own rules has drifted ([/decisions/ad-079.md](/decisions/ad-079.md)).
1107
+ { label: "coverage in sync", bin: "node", args: ["tools/dev/render-coverage.ts", "--check"] },
1108
+ ];
1109
+ }
1110
+
1111
+ export type StepSpawner = (bin: string, args: string[], cwd: string) => { status: number | null };
1112
+
1113
+ export function runTestSteps(
1114
+ steps: TestStep[],
1115
+ cwd: string,
1116
+ spawner: StepSpawner = (bin, spawnArgs, spawnCwd) =>
1117
+ spawnSync(bin, spawnArgs, { cwd: spawnCwd, stdio: "inherit" }),
1118
+ ): number {
1119
+ for (const step of steps) {
1120
+ console.log(`tlc harness test: running ${step.label}`);
1121
+ const result = spawner(step.bin, step.args, cwd);
1122
+ const status = result.status ?? 1;
1123
+ if (status !== 0) {
1124
+ console.error(`tlc harness test: FAILED at "${step.label}" (exit ${status})`);
1125
+ return status;
1126
+ }
1127
+ }
1128
+ console.log("tlc harness test: all steps passed");
1129
+ return 0;
1130
+ }
1131
+
1132
+ function announceNewCapabilities(root: string, runtimeRoot: string): void {
1133
+ const catalog = coreFacade.capability.loadCatalog(runtimeRoot);
1134
+ const policy = coreFacade.capability.readProjectPolicyRaw(root);
1135
+ if (!catalog || !policy) {
1136
+ return;
1137
+ }
1138
+ const seen = coreFacade.capability.readRuntimeSeen(root);
1139
+ const fresh = coreFacade.capability.listNewlyAnnounceable(policy, catalog, seen.catalogVersion);
1140
+ if (fresh.length === 0) {
1141
+ return;
1142
+ }
1143
+ console.log("");
1144
+ console.log(coreFacade.capability.formatCapabilityDigest(fresh));
1145
+ console.log("");
1146
+ void coreFacade.capability.writeRuntimeSeen(root, catalog.catalogVersion);
1147
+ }
1148
+
1149
+ /**
1150
+ * why: the shape the capability digest established — what is new, what it costs you, announced once. A per-project
1151
+ * seen revision is what makes "once" true, and the reason it matters is that an announcement which repeats becomes
1152
+ * noise, and noise is filtered out by the reader ([/decisions/ad-031.md](/decisions/ad-031.md)).
1153
+ *
1154
+ * invariant: a project with no seen marker is not shown every decision ever written. The first update records where
1155
+ * it stands and announces nothing, because a wall of thirty entries is indistinguishable from no message at all.
1156
+ */
1157
+ function announceLandedDecisions(root: string, dest: string, before: string | null): void {
1158
+ const now = runtimeRevision(dest).revision;
1159
+ if (now === null) {
1160
+ return;
1161
+ }
1162
+ const seen = coreFacade.release.readReleaseSeen(root)?.revision ?? before;
1163
+ if (seen === null || seen === now) {
1164
+ void coreFacade.release.writeReleaseSeen(root, now);
1165
+ return;
1166
+ }
1167
+ const added = spawnSync(
1168
+ "git",
1169
+ ["-C", dest, "diff", "--name-only", "--diff-filter=A", `${seen}..${now}`, "--", "docs/decisions"],
1170
+ { encoding: "utf8", env: process.env },
1171
+ );
1172
+ if ((added.status ?? 1) !== 0) {
1173
+ // why: a force-push upstream can leave the seen revision unreachable. Reporting that beats throwing on the
1174
+ // path an operator is standing in front of.
1175
+ console.log(`update: cannot list what landed since ${seen} — that revision is no longer in the checkout`);
1176
+ void coreFacade.release.writeReleaseSeen(root, now);
1177
+ return;
1178
+ }
1179
+ const files = (added.stdout ?? "")
1180
+ .split("\n")
1181
+ .map((line) => line.trim().split("/").pop() ?? "")
1182
+ .filter(Boolean);
1183
+ const digest = coreFacade.release.formatDecisionDigest(coreFacade.release.readDecisions(dest, files));
1184
+ if (digest !== "") {
1185
+ console.log("");
1186
+ console.log(digest);
1187
+ console.log("");
1188
+ }
1189
+ void coreFacade.release.writeReleaseSeen(root, now);
1190
+ }
1191
+
1192
+ function runUpdate(root: string): never {
1193
+ const dest = resolveHarnessRoot();
1194
+ const revisionBefore = runtimeRevision(dest).revision;
1195
+ const home = runtimeHome();
1196
+ console.log(`update: runtime → ${dest}`);
1197
+
1198
+ if (!existsSync(join(dest, "bin", "tlc-exec.mjs"))) {
1199
+ console.error(`update: missing install at ${home}`);
1200
+ console.error(
1201
+ `update: install once with \`npm i -g ${NPM_PACKAGE}\`, then \`tlc harness install\`, then retry.`,
1202
+ );
1203
+ process.exit(1);
1204
+ }
1205
+
1206
+ // invariant: classified from the configured home, never from `dest`. `dest` is `realpathSync`-resolved, so asking
1207
+ // it hides the link and update writes into somebody's clone.
1208
+ const kind = runtimePathKind(home);
1209
+ if (kind === "linked") {
1210
+ // invariant: no git command runs against a linked clone, not even a read. The machine-local refresh below is
1211
+ // the whole of what update may do here ([/decisions/ad-046.md](/decisions/ad-046.md)).
1212
+ console.log(linkedRuntimeMessage(home, dest === home ? null : dest));
1213
+ } else if (kind === "npm") {
1214
+ // why: the registry owns fetch, integrity and rollback here, so update's whole job is to bump the package and
1215
+ // re-materialise. No git command runs against an npm-delivered runtime, for the same reason none runs against
1216
+ // a linked clone: it is not a checkout ([/decisions/ad-056.md](/decisions/ad-056.md)).
1217
+ const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], {
1218
+ stdio: "inherit",
1219
+ env: process.env,
1220
+ shell: process.platform === "win32",
1221
+ });
1222
+ if ((bump.status ?? 1) !== 0) {
1223
+ console.error(npmUpdateFailureMessage());
1224
+ process.exit(bump.status ?? 1);
1225
+ }
1226
+ const sync = spawnSync(execBinPath(), ["install-runtime"], { stdio: "inherit", env: process.env });
1227
+ if ((sync.status ?? 1) !== 0) {
1228
+ process.exit(sync.status ?? 1);
1229
+ }
1230
+ } else if (kind === "unmanaged") {
1231
+ console.log(unmanagedRuntimeMessage(dest));
1232
+ } else {
1233
+ const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], {
1234
+ stdio: "inherit",
1235
+ env: process.env,
1236
+ });
1237
+ if ((fetch.status ?? 1) !== 0) {
1238
+ console.error(fetchFailureMessage(dest));
1239
+ process.exit(fetch.status ?? 1);
1240
+ }
1241
+ const mergeRef = upstreamRef(dest);
1242
+ // why: a hard reset, not a fast-forward merge. The artifact is the harness's own, so a local change in it is
1243
+ // never the operator's work and never a conflict they have to resolve. `dist/` bundles rebuilt by an older
1244
+ // update with a different bundler made every fast-forward fail — measured 223,390 bytes from Bun against
1245
+ // 228,018 from esbuild for the same source ([/decisions/ad-046.md](/decisions/ad-046.md)).
1246
+ //
1247
+ // invariant: `state/` and `config.json` are gitignored, so a reset cannot remove them. A test asserts that
1248
+ // rather than trusting it.
1249
+ const reset = spawnSync("git", ["-C", dest, "reset", "--hard", mergeRef], {
1250
+ encoding: "utf8",
1251
+ env: process.env,
1252
+ });
1253
+ if ((reset.status ?? 1) !== 0) {
1254
+ console.error(resetFailureMessage(dest, mergeRef, `${reset.stderr ?? ""}${reset.stdout ?? ""}`));
1255
+ process.exit(reset.status ?? 1);
1256
+ }
1257
+ const after = runtimeRevision(dest).revision;
1258
+ console.log(
1259
+ revisionBefore === after
1260
+ ? `update: runtime already at ${after ?? "unknown"} — nothing to move`
1261
+ : `update: runtime ${revisionBefore ?? "unknown"} → ${after ?? "unknown"}`,
1262
+ );
1263
+ }
1264
+
1265
+ const binDir = process.env.TLC_BIN_DIR || join(homedir(), ".local", "bin");
1266
+ mkdirSync(binDir, { recursive: true });
1267
+ mkdirSync(join(home, "..", "skills"), { recursive: true });
1268
+
1269
+ if (process.platform === "win32") {
1270
+ const installPs1 = join(dest, "install.ps1");
1271
+ const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installPs1], {
1272
+ stdio: "inherit",
1273
+ env: { ...process.env, TLC_HOME: home },
1274
+ cwd: dest,
1275
+ });
1276
+ if ((r.status ?? 1) !== 0) {
1277
+ process.exit(r.status ?? 1);
1278
+ }
1279
+ } else {
1280
+ const tlcBin = join(dest, "bin", "tlc");
1281
+ const skillSrc = join(dest, "skills", "harness-init");
1282
+ const skillDest = join(home, "..", "skills", "harness-init");
1283
+ spawnSync("ln", ["-sfn", tlcBin, join(binDir, "tlc")], { stdio: "inherit" });
1284
+ if (!existsSync(skillSrc)) {
1285
+ console.error(`update: missing skill at ${skillSrc}`);
1286
+ process.exit(1);
1287
+ }
1288
+ spawnSync("ln", ["-sfn", skillSrc, skillDest], { stdio: "inherit" });
1289
+ console.log(`update: skill → ${skillDest}`);
1290
+ const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
1291
+ stdio: "inherit",
1292
+ env: { ...process.env, TLC_HOME: home },
1293
+ });
1294
+ if ((hooks.status ?? 1) !== 0) {
1295
+ console.log("update: hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
1296
+ }
1297
+ }
1298
+
1299
+ // invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node
1300
+ // fallback ([/decisions/ad-012.md](/decisions/ad-012.md)) and the gate keeps it matching `src/`, so the pulled revision already carries the right
1301
+ // bundles. Rebuilding them with a different bundler is what dirtied every user's checkout
1302
+ // ([/decisions/ad-046.md](/decisions/ad-046.md)).
1303
+ const missing = missingBundles(dest);
1304
+ if (missing.length === 0) {
1305
+ console.log("update: dist/ complete — no rebuild, so the runtime path stays clean");
1306
+ } else if (existsSync(buildBinPath())) {
1307
+ console.log(`update: ${missing.length} bundle(s) missing — building`);
1308
+ const build = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1309
+ if ((build.status ?? 1) !== 0) {
1310
+ console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`);
1311
+ }
1312
+ }
1313
+
1314
+ announceNewCapabilities(root, dest);
1315
+ announceLandedDecisions(root, dest, revisionBefore);
1316
+
1317
+ console.log("update: running doctor…");
1318
+ const doctor = spawnSync(execBinPath(), ["doctor"], {
1319
+ stdio: "inherit",
1320
+ env: { ...process.env, TLC_PROJECT_DIR: root },
1321
+ });
1322
+ console.log("update: ok — reload if hooks/skill should refresh");
1323
+ process.exit(doctor.status ?? 0);
1324
+ }
1325
+
1326
+ function runEntry(entry: string, toolArgs: string[], root: string): never {
1327
+ const r = spawnSync(execBinPath(), [entry, ...toolArgs], {
1328
+ stdio: "inherit",
1329
+ env: { ...process.env, TLC_PROJECT_DIR: root },
1330
+ });
1331
+ process.exit(r.status ?? 1);
1332
+ }
1333
+
1334
+ function main(argv: string[]): void {
1335
+ const root = resolveProjectRoot();
1336
+ const group = (argv[0] ?? "").toLowerCase();
1337
+ if (group !== "harness") {
1338
+ console.error(`unknown: ${argv[0] ?? ""}`);
1339
+ console.error(
1340
+ "usage: tlc harness <status|doctor|help|grind|pause|resume|mode|obs|prices|lessons|init|update|test|build>",
1341
+ );
1342
+ process.exit(1);
1343
+ }
1344
+
1345
+ const { json, rest: args } = takeJsonFlag(argv.slice(1));
1346
+ let action: Action;
1347
+ try {
1348
+ action = route(args);
1349
+ } catch (error) {
1350
+ if (error instanceof UsageError) {
1351
+ console.error(error.message);
1352
+ process.exit(1);
1353
+ }
1354
+ throw error;
1355
+ }
1356
+
1357
+ switch (action.kind) {
1358
+ case "status": {
1359
+ const leftover = unknownFlags(args.slice(1));
1360
+ if (leftover.length > 0) {
1361
+ console.error(`unknown flag: ${leftover[0]}`);
1362
+ console.error("usage: tlc harness status [--json]");
1363
+ process.exit(1);
1364
+ }
1365
+ if (json) {
1366
+ emitJson(statusJson(root));
1367
+ } else {
1368
+ console.log(statusText(root, createStyle()));
1369
+ }
1370
+ break;
1371
+ }
1372
+ case "handoff": {
1373
+ const leftover = unknownFlags(args.slice(1));
1374
+ if (leftover.length > 0) {
1375
+ console.error(`unknown flag: ${leftover[0]}`);
1376
+ console.error("usage: tlc harness handoff [--json]");
1377
+ process.exit(1);
1378
+ }
1379
+ const report = handoffJson(root);
1380
+ if (json) {
1381
+ emitJson(report);
1382
+ } else {
1383
+ console.log(handoffText(report));
1384
+ }
1385
+ break;
1386
+ }
1387
+ case "attest": {
1388
+ const leftover = unknownFlags(args.slice(1));
1389
+ if (leftover.length > 0) {
1390
+ console.error(`unknown flag: ${leftover[0]}`);
1391
+ console.error("usage: tlc harness attest [--json]");
1392
+ process.exit(1);
1393
+ }
1394
+ const report = attestJson(root);
1395
+ if (json) {
1396
+ emitJson(report);
1397
+ } else {
1398
+ console.log(attestText(root, createStyle()));
1399
+ }
1400
+ // why: a broken chain exits non-zero so a pipeline can gate on it. An empty chain is not broken.
1401
+ process.exit(report.ok ? 0 : 1);
1402
+ break;
1403
+ }
1404
+ case "policy": {
1405
+ if (action.accept.length === 0 && !args.includes("accept")) {
1406
+ if (json) {
1407
+ emitJson(policyJson(root));
1408
+ } else {
1409
+ console.log(policyText(root, createStyle()));
1410
+ }
1411
+ break;
1412
+ }
1413
+ try {
1414
+ console.log(acceptPolicy(root, action.accept, Boolean(process.stdin.isTTY)));
1415
+ } catch (error) {
1416
+ if (error instanceof UsageError) {
1417
+ console.error(error.message);
1418
+ process.exit(1);
1419
+ }
1420
+ throw error;
1421
+ }
1422
+ break;
1423
+ }
1424
+ case "help":
1425
+ console.log(helpText(createStyle()));
1426
+ break;
1427
+ case "build": {
1428
+ const r = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1429
+ process.exit(r.status ?? 1);
1430
+ break;
1431
+ }
1432
+ case "version":
1433
+ if (json) {
1434
+ emitJson(versionJson(root));
1435
+ } else {
1436
+ console.log(versionText(root, createStyle()));
1437
+ }
1438
+ break;
1439
+ case "update-check": {
1440
+ const dest = resolveHarnessRoot();
1441
+ const report = pendingUpdate(dest, upstreamRef(dest));
1442
+ if (json) {
1443
+ emitJson(report);
1444
+ } else {
1445
+ console.log(pendingText(report, createStyle()));
1446
+ }
1447
+ break;
1448
+ }
1449
+ case "update":
1450
+ runUpdate(root);
1451
+ break;
1452
+ case "test": {
1453
+ const status = runTestSteps(buildTestSteps(), process.cwd());
1454
+ process.exit(status);
1455
+ break;
1456
+ }
1457
+ case "grind":
1458
+ console.log(setGrind(root, action.on));
1459
+ break;
1460
+ case "pause":
1461
+ console.log(setPaused(root, true));
1462
+ break;
1463
+ case "resume":
1464
+ console.log(setPaused(root, false));
1465
+ break;
1466
+ case "mode":
1467
+ try {
1468
+ console.log(setMode(root, action.value));
1469
+ } catch (error) {
1470
+ if (error instanceof UsageError) {
1471
+ console.error(error.message);
1472
+ process.exit(1);
1473
+ }
1474
+ throw error;
1475
+ }
1476
+ break;
1477
+ case "gate":
1478
+ try {
1479
+ console.log(setGateCommand(root, action.field, action.argv, process.stdin.isTTY === true));
1480
+ } catch (error) {
1481
+ if (error instanceof UsageError) {
1482
+ console.error(error.message);
1483
+ process.exit(1);
1484
+ }
1485
+ throw error;
1486
+ }
1487
+ break;
1488
+ case "prices-help":
1489
+ console.log(pricesHelpText(createStyle()));
1490
+ break;
1491
+ case "prices-refresh":
1492
+ runEntry("refresh-model-prices", [action.scope], root);
1493
+ break;
1494
+ case "prices-lookup":
1495
+ runEntry("price-lookup", json ? [action.modelId, JSON_FLAG] : [action.modelId], root);
1496
+ break;
1497
+ case "entry":
1498
+ runEntry(action.entry, json ? [...action.args, JSON_FLAG] : action.args, root);
1499
+ break;
1500
+ case "unknown":
1501
+ console.error(`unknown: ${action.cmd}`);
1502
+ console.log(helpText(createStyle()));
1503
+ process.exit(1);
1504
+ }
1505
+ }
1506
+
1507
+ if (import.meta.main) {
1508
+ main(process.argv.slice(2));
1509
+ }