@tech-leads-club/harness-toolkit 0.2.1 → 0.3.0

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 (142) hide show
  1. package/README.md +22 -26
  2. package/bin/tlc-build.mjs +93 -0
  3. package/bin/tlc-cli.ts +117 -58
  4. package/bin/tlc-exec.mjs +16 -13
  5. package/dist/compact-before.mjs +151 -15
  6. package/dist/doctor.mjs +288 -45
  7. package/dist/help-topic.mjs +0 -0
  8. package/dist/init-project.mjs +15 -14
  9. package/dist/install-runtime.mjs +100 -17
  10. package/dist/lessons-cli.mjs +152 -8
  11. package/dist/obs-cli.mjs +149 -8
  12. package/dist/price-lookup.mjs +45 -23
  13. package/dist/prompt-submit.mjs +151 -15
  14. package/dist/refresh-model-prices.mjs +7190 -46
  15. package/dist/response-after.mjs +151 -15
  16. package/dist/run.mjs +151 -15
  17. package/dist/session-end.mjs +151 -15
  18. package/dist/session-start.mjs +151 -15
  19. package/dist/shim.mjs +7040 -16
  20. package/dist/stop.mjs +151 -15
  21. package/dist/subagent-start.mjs +151 -15
  22. package/dist/subagent-stop.mjs +151 -15
  23. package/dist/support.mjs +149 -8
  24. package/dist/tlc-cli.mjs +289 -89
  25. package/dist/tool-after.mjs +196 -38
  26. package/dist/tool-before.mjs +151 -15
  27. package/dist/tool-failure.mjs +151 -15
  28. package/dist/uninstall-runtime.mjs +9 -10
  29. package/docs/log.md +7 -0
  30. package/docs/measure.md +35 -31
  31. package/package.json +6 -5
  32. package/src/core/core.facade.ts +18 -0
  33. package/src/core/index.ts +3 -0
  34. package/src/core/pricing/pricing.freshness.ts +118 -0
  35. package/src/core/release/release.version.ts +147 -0
  36. package/src/core/shim/shim.precedence.ts +72 -0
  37. package/src/core/skill/skill.link.ts +92 -0
  38. package/src/entrypoints/shim.ts +49 -11
  39. package/src/platform/fs-atomic.ts +61 -23
  40. package/src/platform/links.ts +73 -0
  41. package/src/platform/paths.ts +27 -0
  42. package/src/platform/pricing.ts +139 -31
  43. package/src/providers/cursor/cursor.wiring.ts +11 -8
  44. package/tools/doctor.ts +127 -10
  45. package/tools/init-project.ts +34 -14
  46. package/tools/install-runtime.ts +89 -6
  47. package/tools/refresh-model-prices.ts +242 -75
  48. package/tools/uninstall-runtime.ts +23 -19
  49. package/CHANGELOG.md +0 -95
  50. package/bin/tlc-build +0 -80
  51. package/bin/tlc-exec +0 -10
  52. package/bin/tlc-exec.cmd +0 -4
  53. package/docs/decisions/ad-001.md +0 -32
  54. package/docs/decisions/ad-002.md +0 -51
  55. package/docs/decisions/ad-003.md +0 -30
  56. package/docs/decisions/ad-004.md +0 -37
  57. package/docs/decisions/ad-005.md +0 -36
  58. package/docs/decisions/ad-006.md +0 -49
  59. package/docs/decisions/ad-007.md +0 -36
  60. package/docs/decisions/ad-008.md +0 -54
  61. package/docs/decisions/ad-009.md +0 -61
  62. package/docs/decisions/ad-010.md +0 -45
  63. package/docs/decisions/ad-011.md +0 -59
  64. package/docs/decisions/ad-012.md +0 -71
  65. package/docs/decisions/ad-013.md +0 -87
  66. package/docs/decisions/ad-014.md +0 -56
  67. package/docs/decisions/ad-015.md +0 -33
  68. package/docs/decisions/ad-016.md +0 -98
  69. package/docs/decisions/ad-017.md +0 -65
  70. package/docs/decisions/ad-018.md +0 -77
  71. package/docs/decisions/ad-019.md +0 -75
  72. package/docs/decisions/ad-020.md +0 -88
  73. package/docs/decisions/ad-021.md +0 -57
  74. package/docs/decisions/ad-022.md +0 -120
  75. package/docs/decisions/ad-023.md +0 -87
  76. package/docs/decisions/ad-024.md +0 -85
  77. package/docs/decisions/ad-025.md +0 -92
  78. package/docs/decisions/ad-026.md +0 -131
  79. package/docs/decisions/ad-027.md +0 -90
  80. package/docs/decisions/ad-028.md +0 -90
  81. package/docs/decisions/ad-029.md +0 -73
  82. package/docs/decisions/ad-030.md +0 -97
  83. package/docs/decisions/ad-031.md +0 -94
  84. package/docs/decisions/ad-032.md +0 -83
  85. package/docs/decisions/ad-033.md +0 -89
  86. package/docs/decisions/ad-034.md +0 -86
  87. package/docs/decisions/ad-035.md +0 -86
  88. package/docs/decisions/ad-036.md +0 -68
  89. package/docs/decisions/ad-037.md +0 -47
  90. package/docs/decisions/ad-038.md +0 -52
  91. package/docs/decisions/ad-039.md +0 -69
  92. package/docs/decisions/ad-040.md +0 -89
  93. package/docs/decisions/ad-041.md +0 -98
  94. package/docs/decisions/ad-042.md +0 -82
  95. package/docs/decisions/ad-043.md +0 -79
  96. package/docs/decisions/ad-044.md +0 -61
  97. package/docs/decisions/ad-045.md +0 -94
  98. package/docs/decisions/ad-046.md +0 -111
  99. package/docs/decisions/ad-047.md +0 -96
  100. package/docs/decisions/ad-048.md +0 -85
  101. package/docs/decisions/ad-049.md +0 -66
  102. package/docs/decisions/ad-050.md +0 -94
  103. package/docs/decisions/ad-051.md +0 -69
  104. package/docs/decisions/ad-052.md +0 -69
  105. package/docs/decisions/ad-053.md +0 -78
  106. package/docs/decisions/ad-054.md +0 -98
  107. package/docs/decisions/ad-055.md +0 -74
  108. package/docs/decisions/ad-056.md +0 -85
  109. package/docs/decisions/ad-057.md +0 -68
  110. package/docs/decisions/ad-058.md +0 -97
  111. package/docs/decisions/ad-059.md +0 -82
  112. package/docs/decisions/ad-060.md +0 -75
  113. package/docs/decisions/ad-061.md +0 -68
  114. package/docs/decisions/ad-062.md +0 -72
  115. package/docs/decisions/ad-063.md +0 -84
  116. package/docs/decisions/ad-064.md +0 -79
  117. package/docs/decisions/ad-065.md +0 -81
  118. package/docs/decisions/ad-066.md +0 -111
  119. package/docs/decisions/ad-067.md +0 -64
  120. package/docs/decisions/ad-068.md +0 -79
  121. package/docs/decisions/ad-069.md +0 -74
  122. package/docs/decisions/ad-070.md +0 -86
  123. package/docs/decisions/ad-071.md +0 -93
  124. package/docs/decisions/ad-072.md +0 -82
  125. package/docs/decisions/ad-073.md +0 -102
  126. package/docs/decisions/ad-074.md +0 -91
  127. package/docs/decisions/ad-075.md +0 -79
  128. package/docs/decisions/ad-076.md +0 -102
  129. package/docs/decisions/ad-077.md +0 -94
  130. package/docs/decisions/ad-078.md +0 -84
  131. package/docs/decisions/ad-079.md +0 -73
  132. package/docs/decisions/ad-080.md +0 -86
  133. package/docs/decisions/ad-081.md +0 -70
  134. package/docs/decisions/ad-082.md +0 -79
  135. package/docs/decisions/ad-083.md +0 -88
  136. package/docs/decisions/index.md +0 -111
  137. package/model-aliases.json +0 -12
  138. package/model-prices.cursor.json +0 -410
  139. package/model-prices.json +0 -1
  140. package/tools/test-env.mjs +0 -28
  141. package/tools/test-env.names.d.mts +0 -1
  142. package/tools/test-env.names.mjs +0 -14
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Where a model's price comes from, and how it is read.
3
+ *
4
+ * There is one catalogue file on the machine. It used to be four — a per-provider table, a ~1 MB fallback table,
5
+ * an overrides table and an alias table — three of them versioned in this repository and two of them holding the
6
+ * same models at different prices. That is not duplication to be collapsed: the same model genuinely has two
7
+ * prices depending on who bills the call, and a provider reselling a vendor's model charges its own rate. So the
8
+ * planes stay, and the file does not: one catalogue, one refresh, one read path, with each plane named by its
9
+ * provenance ([/decisions/ad-096.md](/decisions/ad-096.md)).
10
+ *
11
+ * invariant: nothing about prices is versioned. A rate published today has to reach an operator without a release,
12
+ * and a rate in the package is stale the moment it is packed.
13
+ */
1
14
  import { existsSync, readFileSync } from "node:fs";
2
15
  import { join } from "node:path";
3
16
  import { runtimeHome } from "./paths.ts";
@@ -33,8 +46,40 @@ export type CostEstimate = {
33
46
  catalogKey?: string;
34
47
  };
35
48
 
36
- type PriceTable = Record<string, ModelPriceEntry>;
37
- type AliasTable = Record<string, string>;
49
+ export type PriceTable = Record<string, ModelPriceEntry>;
50
+
51
+ /**
52
+ * The catalogue on disk. `planes` is keyed by provenance: a provider's id for what that provider bills, and
53
+ * `litellm` for the vendors' own list prices.
54
+ *
55
+ * why: keyed rather than merged. `claude-sonnet-4-5` is sold by its vendor and resold by other providers at a
56
+ * different rate; merging the two rows would pick one at random and report the other's calls at the wrong price.
57
+ */
58
+ export type PriceCatalogue = {
59
+ _meta?: { refreshedAt?: string; planes?: Record<string, PlaneMeta> };
60
+ planes?: Record<string, PriceTable>;
61
+ };
62
+
63
+ export type PlaneMeta = { source?: string; count?: number; refreshedAt?: string };
64
+
65
+ /** The plane that holds the vendors' own list prices, used when the asking provider has no rate of its own. */
66
+ export const FALLBACK_PLANE = "litellm";
67
+
68
+ /**
69
+ * Model ids a host reports that are not the catalogue's key for them.
70
+ *
71
+ * why: in code, not in a versioned JSON file. This is a hand-curated mapping of what hosts call things — it changes
72
+ * when a host renames a model, which is a code change with a test, not machine state an operator maintains. The
73
+ * file it replaces held ten entries of which six were `"x": "x"` no-ops and two were already covered by the effort
74
+ * suffix stripped below ([/decisions/ad-096.md](/decisions/ad-096.md)).
75
+ *
76
+ * invariant: an operator who needs a mapping of their own writes the key straight into their overrides file. There
77
+ * is no second alias file to keep in sync.
78
+ */
79
+ export const MODEL_ALIASES: Readonly<Record<string, string>> = {
80
+ "cursor-grok-4.5": "grok-4.5",
81
+ auto: "auto-cost",
82
+ };
38
83
 
39
84
  const VENDOR_TO_NEUTRAL_POOL: Record<VendorPool, NeutralPool> = {
40
85
  cursor_models: "provider_native",
@@ -48,17 +93,6 @@ export function mapPoolToNeutral(pool: VendorPool): NeutralPool {
48
93
  return VENDOR_TO_NEUTRAL_POOL[pool];
49
94
  }
50
95
 
51
- function readJsonFile<T>(path: string): T | null {
52
- if (!existsSync(path)) {
53
- return null;
54
- }
55
- try {
56
- return JSON.parse(readFileSync(path, "utf8")) as T;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
96
  function stripMeta(table: PriceTable | null): PriceTable {
63
97
  if (!table) {
64
98
  return {};
@@ -67,17 +101,30 @@ function stripMeta(table: PriceTable | null): PriceTable {
67
101
  return rest;
68
102
  }
69
103
 
104
+ /**
105
+ * The one way a model name becomes a catalogue key. Both sides use it: the refresh that writes the catalogue and
106
+ * the lookup that reads it.
107
+ *
108
+ * hazard: there were two of these, and they disagreed about parentheses. This one erased them, so a lookup for
109
+ * `Model X (Fast)` asked for `model-x` — the standard model's price. The writer's copy erased them too, so the two
110
+ * rows collapsed onto one key and the second overwrote the first. Measured on the real catalogue: 51 rows on the
111
+ * upstream page became 44 stored keys, and every model with a `(Fast)` variant carried the wrong price — `$3/$15`
112
+ * where the page said `$0.5/$2.5` ([/decisions/ad-096.md](/decisions/ad-096.md)).
113
+ *
114
+ * invariant: a qualifier is part of the identity. A markdown link keeps its text and loses its URL; everything
115
+ * else that is not alphanumeric is a separator. Two names that differ produce two keys.
116
+ */
70
117
  export function slugifyModelName(name: string): string {
71
118
  return name
72
119
  .trim()
73
120
  .toLowerCase()
121
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
74
122
  .replace(/[[\]]/g, "")
75
- .replace(/\(.*?\)/g, "")
76
123
  .replace(/[^a-z0-9.+]+/g, "-")
77
124
  .replace(/^-+|-+$/g, "");
78
125
  }
79
126
 
80
- function candidatesFor(model: string, aliases: AliasTable): string[] {
127
+ function candidatesFor(model: string, aliases: Readonly<Record<string, string>>): string[] {
81
128
  const trimmed = model.trim();
82
129
  const out: string[] = [];
83
130
  const push = (v: string | undefined) => {
@@ -114,20 +161,20 @@ function fuzzyFind(table: PriceTable, needle: string): { key: string; entry: Mod
114
161
  return undefined;
115
162
  }
116
163
 
117
- function overridesPath(): string {
164
+ /** The catalogue the refresh writes. Not versioned, not packaged, per machine. */
165
+ export function cataloguePath(): string {
118
166
  return join(runtimeHome(), "model-prices.json");
119
167
  }
120
168
 
121
- function providerNativePath(provider: string): string {
122
- return join(runtimeHome(), `model-prices.${provider}.json`);
123
- }
124
-
125
- function litellmPath(): string {
126
- return join(runtimeHome(), "model-prices.litellm.json");
127
- }
128
-
129
- function aliasesPath(): string {
130
- return join(runtimeHome(), "model-aliases.json");
169
+ /**
170
+ * The operator's own rates, which win over everything fetched.
171
+ *
172
+ * hazard: this filename was in `.gitignore` and read by nothing. The overrides that were actually read lived in
173
+ * `model-prices.json` — the same name the refresh now writes — so an operator's edits sat in a file the next
174
+ * refresh would replace. The refresh moves such a file here rather than overwriting it.
175
+ */
176
+ export function overridesPath(): string {
177
+ return join(runtimeHome(), "model-prices.local.json");
131
178
  }
132
179
 
133
180
  export type PriceResolution = {
@@ -136,18 +183,79 @@ export type PriceResolution = {
136
183
  source: "override" | "provider" | "litellm";
137
184
  };
138
185
 
186
+ type CacheSlot = { text: string; value: PriceCatalogue };
187
+ const cache = new Map<string, CacheSlot>();
188
+
189
+ /**
190
+ * why a cache at all: the fallback plane is around a megabyte and a cost estimate happens on every tool result.
191
+ * This used to parse every catalogue file on every single lookup.
192
+ *
193
+ * hazard: the first version keyed on mtime and size, and Windows CI caught it. Two writes of the same length
194
+ * inside one clock tick are indistinguishable that way — the system clock there advances about every 15 ms, so
195
+ * both writes carry the same timestamp however fine the filesystem's resolution is. A refresh mid-session would
196
+ * then serve the previous prices for the rest of the session, silently, because a price was still returned
197
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
198
+ *
199
+ * invariant: the content decides. Reading 1 MB is the cheap part and the parse is what this avoids, so the file is
200
+ * always read and only reparsed when its bytes differ. Nothing has to remember to invalidate anything.
201
+ */
202
+ function readCatalogue<T>(path: string): T | null {
203
+ if (!existsSync(path)) {
204
+ cache.delete(path);
205
+ return null;
206
+ }
207
+ let text: string;
208
+ try {
209
+ text = readFileSync(path, "utf8");
210
+ } catch {
211
+ cache.delete(path);
212
+ return null;
213
+ }
214
+ const hit = cache.get(path);
215
+ if (hit && hit.text === text) {
216
+ return hit.value as T;
217
+ }
218
+ let parsed: PriceCatalogue;
219
+ try {
220
+ parsed = JSON.parse(text) as PriceCatalogue;
221
+ } catch {
222
+ cache.delete(path);
223
+ return null;
224
+ }
225
+ cache.set(path, { text, value: parsed });
226
+ return parsed as T;
227
+ }
228
+
229
+ /** invariant: an absent or unparseable catalogue is an empty one. A missing price is never a thrown error. */
230
+ export function loadCatalogue(): PriceCatalogue {
231
+ return readCatalogue<PriceCatalogue>(cataloguePath()) ?? {};
232
+ }
233
+
234
+ export function planeMeta(): Record<string, PlaneMeta> {
235
+ return loadCatalogue()._meta?.planes ?? {};
236
+ }
237
+
238
+ export function catalogueMeta(): { refreshedAt?: string } | null {
239
+ const parsed = readCatalogue<PriceCatalogue>(cataloguePath());
240
+ return parsed === null ? null : (parsed._meta ?? {});
241
+ }
242
+
243
+ function planeFor(catalogue: PriceCatalogue, plane: string): PriceTable {
244
+ return stripMeta(catalogue.planes?.[plane] ?? null);
245
+ }
246
+
139
247
  export function resolveModelPrice(provider: string, model: string): PriceResolution | undefined {
140
248
  const trimmed = model.trim();
141
249
  if (!trimmed) {
142
250
  return undefined;
143
251
  }
144
252
 
145
- const overrides = stripMeta(readJsonFile<PriceTable>(overridesPath()));
146
- const native = stripMeta(readJsonFile<PriceTable>(providerNativePath(provider)));
147
- const litellm = stripMeta(readJsonFile<PriceTable>(litellmPath()));
148
- const aliases = readJsonFile<AliasTable>(aliasesPath()) ?? {};
253
+ const catalogue = loadCatalogue();
254
+ const overrides = stripMeta(readCatalogue<PriceTable>(overridesPath()));
255
+ const native = provider ? planeFor(catalogue, provider) : {};
256
+ const litellm = planeFor(catalogue, FALLBACK_PLANE);
149
257
 
150
- const candidates = candidatesFor(trimmed, aliases);
258
+ const candidates = candidatesFor(trimmed, MODEL_ALIASES);
151
259
 
152
260
  for (const id of candidates) {
153
261
  const entry = overrides[id];
@@ -33,15 +33,18 @@ const ENTRY_SPECS: readonly EntrySpec[] = [
33
33
  { hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" },
34
34
  ];
35
35
 
36
- function commandFor(runtime: RuntimePaths): { command: string; argsPrefix: string[] } {
37
- if (process.platform === "win32") {
38
- return { command: "cmd", argsPrefix: ["/c", "node", runtime.launcherPath] };
39
- }
40
- return { command: "node", argsPrefix: [runtime.launcherPath] };
41
- }
42
-
36
+ /**
37
+ * hazard: this used to write `cmd /c node <launcher>` on Windows and `node <launcher>` elsewhere, from the first
38
+ * commit, with no recorded reason. The other provider's wiring has always written plain `node` on every platform,
39
+ * including Windows — so the branch was the odd one, not the safe one, and it was the branch no contributor here
40
+ * could exercise ([/decisions/ad-097.md](/decisions/ad-097.md)).
41
+ *
42
+ * invariant: `node` is resolved by the host's own process spawn, which appends the executable extension on the
43
+ * platform that needs one. If a Windows session ever proves otherwise, this is the one line to change.
44
+ */
43
45
  export function cursorWiring(runtime: RuntimePaths): ProviderWiring {
44
- const { command, argsPrefix } = commandFor(runtime);
46
+ const command = "node";
47
+ const argsPrefix = [runtime.launcherPath];
45
48
  const entries: WiringEntry[] = ENTRY_SPECS.map((spec) => ({
46
49
  hookEvent: spec.hookEvent,
47
50
  handler: spec.handler,
package/tools/doctor.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
3
  import { homedir, platform as osPlatform } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { runtimePathKind } from "../bin/tlc-cli.ts";
4
+ import { basename, delimiter, dirname, join } from "node:path";
5
+ import { NPM_PACKAGE, runtimePathKind } from "../bin/tlc-cli.ts";
6
6
  import { findBunOnPath, writeRuntimeCache } from "../bin/tlc-exec.mjs";
7
7
  import { isCursorWired } from "../bin/write-user-hooks.mjs";
8
8
  import type { ProviderWiring } from "../src/contracts/index.ts";
9
9
  import { coreFacade } from "../src/core/index.ts";
10
10
  import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
11
- import { projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
11
+ import {
12
+ projectConfigPath,
13
+ projectStateDir,
14
+ providerConfigDirs,
15
+ runtimeHome,
16
+ } from "../src/platform/paths.ts";
17
+ import { catalogueMeta, planeMeta } from "../src/platform/pricing.ts";
12
18
  import { type ColorName, createStyle, PLAIN, type Style, SYMBOLS } from "../src/platform/style.ts";
13
19
  import { mergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
14
20
  import {
@@ -126,10 +132,122 @@ export function runtimeOwnershipCheck(home: string): Check {
126
132
  };
127
133
  }
128
134
 
135
+ /**
136
+ * hazard: a skill link whose destination is gone reads as installed to anything that only checks the link exists.
137
+ * One on the machine that prompted this pointed at `/tmp/tlc-recovery-…/install/skills/harness-init` — a directory
138
+ * from a recovery run, gone on the next boot — and nothing in the harness could see it. A provider whose skill link
139
+ * dangles simply never routes a request to the init skill, silently
140
+ * ([/decisions/ad-095.md](/decisions/ad-095.md)).
141
+ *
142
+ * invariant: reported per provider, because each reads only its own skills directory, and one being healthy says
143
+ * nothing about the other.
144
+ */
145
+ export function checkSkillLinks(
146
+ home: string,
147
+ providerDirs: readonly string[] = providerConfigDirs(),
148
+ probe = {
149
+ linkTarget: (path: string) => {
150
+ try {
151
+ return realpathSync(path);
152
+ } catch {
153
+ // a dangling link cannot be realpath'd, so read the link itself before giving up
154
+ try {
155
+ return readlinkSync(path);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ },
161
+ exists: existsSync,
162
+ // why: the runtime home is itself a symlink on a contributor install, so it has to be resolved before the
163
+ // comparison. Without this both sides are spelled differently and every healthy link reads as foreign.
164
+ realpath: (path: string) => {
165
+ try {
166
+ return realpathSync(path);
167
+ } catch {
168
+ return path;
169
+ }
170
+ },
171
+ },
172
+ ): Check[] {
173
+ return providerDirs
174
+ .filter((dir) => existsSync(dir))
175
+ .map((dir) => {
176
+ const health = coreFacade.skill.linkHealth(join(dir, "skills", "harness-init"), home, probe);
177
+ return {
178
+ level: health.state === "ok" ? ("ok" as const) : ("fail" as const),
179
+ name: `init skill (${basename(dir)})`,
180
+ detail: coreFacade.skill.linkHealthMessage(health),
181
+ };
182
+ });
183
+ }
184
+
185
+ /**
186
+ * How old the price catalogue on this machine is.
187
+ *
188
+ * hazard: nothing reported this. `docs/measure.md` claimed `doctor` "requires at least one provider catalog to be
189
+ * present" and no such check existed, while the catalogue this repository shipped was 23 days stale across three
190
+ * published versions. An absent catalogue is equally invisible: cost estimates simply come back null, which reads
191
+ * the same as a turn that spent nothing ([/decisions/ad-096.md](/decisions/ad-096.md)).
192
+ *
193
+ * invariant: a fresh catalogue is an `ok` row that states its age and asks for nothing. A warning that fires on a
194
+ * healthy install is not a warning ([/decisions/ad-034.md](/decisions/ad-034.md)).
195
+ */
196
+ export function checkPrices(
197
+ now: Date = new Date(),
198
+ read = { meta: catalogueMeta, planes: planeMeta },
199
+ ): Check[] {
200
+ const state = coreFacade.pricing.freshness(read.meta(), now);
201
+ const planes = read.planes();
202
+ const named = Object.entries(planes)
203
+ .map(([plane, meta]) => `${plane} ${meta.count ?? 0}`)
204
+ .join(", ");
205
+ return [
206
+ {
207
+ level: state.state === "fresh" ? "ok" : "warn",
208
+ name: "prices",
209
+ detail:
210
+ state.state === "fresh"
211
+ ? `${coreFacade.pricing.freshnessMessage(state, "catalogue")}${named ? ` (${named})` : ""}`
212
+ : coreFacade.pricing.freshnessMessage(state, "catalogue"),
213
+ },
214
+ ];
215
+ }
216
+
217
+ /**
218
+ * Where a shell would find the `tlc` command, if anywhere.
219
+ *
220
+ * hazard: this row used to pass when `~/.local/bin/tlc` existed **or** `<runtime home>/bin/tlc` existed. The
221
+ * second is part of every install, so the check could not fail — and it printed the first path either way, so an
222
+ * operator whose command was not on PATH read a passing row naming a file they did not have
223
+ * ([/decisions/ad-034.md](/decisions/ad-034.md), [/decisions/ad-097.md](/decisions/ad-097.md)).
224
+ *
225
+ * why the four names: an npm global install writes the shims for its platform — bare on POSIX, `.cmd` and `.ps1`
226
+ * on Windows. Trying all four everywhere costs four `existsSync` calls and needs no platform branch.
227
+ */
228
+ export function resolveOnPath(
229
+ command: string,
230
+ env: NodeJS.ProcessEnv = process.env,
231
+ exists = existsSync,
232
+ ): string | null {
233
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
234
+ if (!dir) {
235
+ continue;
236
+ }
237
+ for (const name of [command, `${command}.cmd`, `${command}.exe`, `${command}.ps1`]) {
238
+ const candidate = join(dir, name);
239
+ if (exists(candidate)) {
240
+ return candidate;
241
+ }
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
129
247
  export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
130
248
  const launcher = join(home, "bin", "tlc-exec.mjs");
131
249
  const distSample = join(home, "dist", "stop.mjs");
132
- const cliLink = join(homedir(), ".local", "bin", platform === "win32" ? "tlc.cmd" : "tlc");
250
+ const onPath = resolveOnPath("tlc");
133
251
  return [
134
252
  { level: "ok", name: "platform", detail: platform },
135
253
  { level: existsSync(launcher) ? "ok" : "fail", name: "global runtime", detail: home },
@@ -142,12 +260,9 @@ export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Chec
142
260
  },
143
261
  { level: existsSync(launcher) ? "ok" : "fail", name: "portable launcher", detail: launcher },
144
262
  {
145
- level:
146
- existsSync(cliLink) || existsSync(join(home, "bin", platform === "win32" ? "tlc.cmd" : "tlc"))
147
- ? "ok"
148
- : "fail",
263
+ level: onPath === null ? "fail" : "ok",
149
264
  name: "CLI on PATH",
150
- detail: cliLink,
265
+ detail: onPath ?? `no \`tlc\` on PATH — npm i -g ${NPM_PACKAGE}, or \`npm link\` from a clone`,
151
266
  },
152
267
  ];
153
268
  }
@@ -522,10 +637,12 @@ export function runChecks(ctx: DoctorContext): Check[] {
522
637
  return [
523
638
  ...checkNodeVersion(ctx.nodeVersion, ctx.bunPath),
524
639
  ...checkRuntimePaths(ctx.runtimeHome, ctx.platform),
640
+ ...checkSkillLinks(ctx.runtimeHome),
525
641
  checkHookRuntime(ctx.runtimeHome, ctx.bunPath),
526
642
  ...checkProviders(ctx.registry, ctx.runtimeHome),
527
643
  ...checkProjectPolicy(ctx.root),
528
644
  ...checkCapabilities(ctx.root, ctx.runtimeHome),
645
+ ...checkPrices(),
529
646
  checkGlobalCommands(ctx.home),
530
647
  ];
531
648
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, join, sep } from "node:path";
3
3
  import { applyCursorWiring, renderCursorHooksDocument } from "../bin/write-user-hooks.mjs";
4
4
  import type { WiringEntry } from "../src/contracts/index.ts";
5
5
  import { DEFAULTS } from "../src/core/policy/policy.defaults.ts";
@@ -55,12 +55,12 @@ export function launcherPath(home = runtimeHome()): string {
55
55
  return join(home, "bin", "tlc-exec.mjs");
56
56
  }
57
57
 
58
- function shimCommand(platform = process.platform): { command: string; argsPrefix: string[] } {
59
- if (platform === "win32") {
60
- return { command: "cmd", argsPrefix: ["/c", "node"] };
61
- }
62
- return { command: "node", argsPrefix: [] };
63
- }
58
+ /**
59
+ * invariant: the same command the provider wiring writes, on every platform `node`, resolved by the host that
60
+ * spawns it. The Windows `cmd /c` wrapper this replaced existed on one of the two providers only
61
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
62
+ */
63
+ const SHIM_COMMAND = { command: "node", argsPrefix: [] as string[] };
64
64
 
65
65
  type ShimSpec = {
66
66
  hookEvent: string;
@@ -92,7 +92,7 @@ const CLAUDE_SHIM_SPECS: readonly ShimSpec[] = [
92
92
  ];
93
93
 
94
94
  export function cursorShimEntries(launcher: string): WiringEntry[] {
95
- const { command, argsPrefix } = shimCommand();
95
+ const { command, argsPrefix } = SHIM_COMMAND;
96
96
  return CURSOR_SHIM_SPECS.map((spec) => ({
97
97
  hookEvent: spec.hookEvent,
98
98
  handler: spec.handler,
@@ -115,17 +115,37 @@ export function claudeShimEntries(launcher: string): WiringEntry[] {
115
115
  }));
116
116
  }
117
117
 
118
- export const GITIGNORE_LINE = ".tlc/harness/state/";
118
+ /**
119
+ * The paths `init` writes into the project that must not be committed.
120
+ *
121
+ * hazard: this was one line, `.tlc/harness/state/`, while `init` also writes two shim documents containing an
122
+ * absolute path to the runtime on the machine that ran it. A user committed a `settings.json` naming their own
123
+ * home directory, and the next developer's hook pointed at a path that does not exist. This repository has
124
+ * ignored both files by hand since 2026-07-30 — commit 81c5830, "keep generated shims out of git", with the
125
+ * comment "per-machine artifacts rather than shared configuration" — so the protection existed here and was never
126
+ * delivered to anyone using the product ([/decisions/ad-095.md](/decisions/ad-095.md)).
127
+ *
128
+ * invariant: derived from what `init` writes. `PROJECT_SHIMS` is the same list the wiring below writes to, so a
129
+ * new shim cannot be added without appearing here.
130
+ */
131
+ export const PROJECT_SHIMS = [join(".cursor", "hooks.json"), join(".claude", "settings.json")] as const;
132
+
133
+ export const GITIGNORE_STATE = ".tlc/harness/state/";
134
+
135
+ /** why: posix separators, because a `.gitignore` is read by git and not by the platform that wrote it. */
136
+ export function gitignoreEntries(): string[] {
137
+ return [GITIGNORE_STATE, ...PROJECT_SHIMS.map((path) => path.split(sep).join("/"))];
138
+ }
119
139
 
120
140
  export function mergeGitignore(root: string): { text: string; changed: boolean } {
121
141
  const path = join(root, ".gitignore");
122
142
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
123
143
  const lines = existing.split("\n");
124
- const alreadyPresent = lines.includes(GITIGNORE_LINE);
125
- if (alreadyPresent) {
144
+ const missing = gitignoreEntries().filter((entry) => !lines.includes(entry));
145
+ if (missing.length === 0) {
126
146
  return { text: existing.endsWith("\n") || existing === "" ? existing : `${existing}\n`, changed: false };
127
147
  }
128
- lines.push(GITIGNORE_LINE);
148
+ lines.push(...missing);
129
149
  const withoutTrailingBlank = lines.filter((line, index, all) => line.length > 0 || index < all.length - 1);
130
150
  return { text: `${withoutTrailingBlank.join("\n").replace(/\n+$/, "")}\n`, changed: true };
131
151
  }
@@ -156,7 +176,7 @@ export type InitPlan = {
156
176
  policy: unknown;
157
177
  cursorHooksDocument: unknown | null;
158
178
  claudeHooksPreview: WiringEntry[] | null;
159
- gitignoreLine: string;
179
+ gitignoreEntries: string[];
160
180
  };
161
181
 
162
182
  export function buildPlan(
@@ -171,7 +191,7 @@ export function buildPlan(
171
191
  policy,
172
192
  cursorHooksDocument: presence.cursor ? renderCursorHooksDocument(cursorShimEntries(launcher)) : null,
173
193
  claudeHooksPreview: presence.claude ? claudeShimEntries(launcher) : null,
174
- gitignoreLine: GITIGNORE_LINE,
194
+ gitignoreEntries: gitignoreEntries(),
175
195
  };
176
196
  }
177
197
 
@@ -1,6 +1,8 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join, relative, resolve, sep } from "node:path";
3
4
  import { NPM_MARKER, NPM_PACKAGE } from "../bin/tlc-cli.ts";
5
+ import { linkDir } from "../src/platform/links.ts";
4
6
  import { conventionalRuntimeHome, runtimeHome, runtimeHomeWasChosen } from "../src/platform/paths.ts";
5
7
  import { type Row, render, type Screen } from "../src/platform/screen.ts";
6
8
  import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
@@ -23,9 +25,6 @@ export const RUNTIME_PAYLOAD = [
23
25
  "src",
24
26
  "tools",
25
27
  "config.example.json",
26
- "model-aliases.json",
27
- "model-prices.cursor.json",
28
- "model-prices.json",
29
28
  "package.json",
30
29
  ] as const;
31
30
 
@@ -52,11 +51,12 @@ export function isShipped(relativePath: string): boolean {
52
51
  }
53
52
 
54
53
  export type InstallReport = {
55
- kind: "copied" | "in-place";
54
+ kind: "copied" | "in-place" | "linked" | "relinked" | "refused";
56
55
  source: string;
57
56
  dest: string;
58
57
  entries: string[];
59
58
  missing: string[];
59
+ reason?: string;
60
60
  };
61
61
 
62
62
  /** The physical location of the copy that launched us, which is not the home once an npm shim is driving one. */
@@ -110,7 +110,62 @@ export function installRuntime(source: string, dest: string): InstallReport {
110
110
  return { kind: "copied", source, dest, entries, missing };
111
111
  }
112
112
 
113
+ /**
114
+ * The contributor route: the runtime home points at a checkout, so an edit is live in the next hook with no
115
+ * install step.
116
+ *
117
+ * why it is here and not in a shell script: it was `ln -sfn` in bash and `mklink /J` in PowerShell, and the
118
+ * PowerShell one asked for Developer Mode. One `symlinkSync` covers all three platforms
119
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
120
+ *
121
+ * invariant: the checkout is never written to, and a destination that is not already a link is refused rather
122
+ * than removed ([/decisions/ad-046.md](/decisions/ad-046.md)).
123
+ */
124
+ export function linkRuntime(source: string, dest: string): InstallReport {
125
+ if (resolve(source) === resolve(dest)) {
126
+ return { kind: "in-place", source, dest, entries: [], missing: [] };
127
+ }
128
+ const outcome = linkDir(resolve(source), dest);
129
+ if (outcome.kind === "refused") {
130
+ return { kind: "refused", source, dest, entries: [], missing: [], reason: outcome.reason };
131
+ }
132
+ const missing = RUNTIME_PAYLOAD.filter((entry) => !existsSync(join(dest, entry)));
133
+ return { kind: outcome.kind === "relinked" ? "relinked" : "linked", source, dest, entries: [], missing };
134
+ }
135
+
113
136
  export function installScreen(report: InstallReport): Screen {
137
+ if (report.kind === "refused") {
138
+ return {
139
+ title: "harness install",
140
+ sections: [{ rows: [{ label: "refused", value: report.reason ?? "", level: "fail" }] }],
141
+ };
142
+ }
143
+ if (report.kind === "linked" || report.kind === "relinked") {
144
+ return {
145
+ title: "harness install",
146
+ sections: [
147
+ {
148
+ rows: [
149
+ {
150
+ label: report.kind === "linked" ? "linked" : "relinked",
151
+ value: `${report.dest} → ${report.source}`,
152
+ level: "ok",
153
+ },
154
+ ...(report.missing.length > 0
155
+ ? [
156
+ {
157
+ label: "incomplete",
158
+ value: `the checkout has no ${report.missing.join(", ")} — run the build`,
159
+ level: "fail" as const,
160
+ },
161
+ ]
162
+ : []),
163
+ ],
164
+ },
165
+ ],
166
+ footer: "an edit in the checkout is live in the next hook · `npm link` puts `tlc` on PATH",
167
+ };
168
+ }
114
169
  if (report.kind === "in-place") {
115
170
  return {
116
171
  title: "harness install",
@@ -155,10 +210,38 @@ export function installDest(env: NodeJS.ProcessEnv = process.env): string {
155
210
  return runtimeHomeWasChosen(env) ? runtimeHome(env) : conventionalRuntimeHome();
156
211
  }
157
212
 
213
+ /**
214
+ * The first price fetch, on the machine, at install time.
215
+ *
216
+ * why: prices are no longer in the package, so a fresh install has no catalogue at all until something fetches
217
+ * one. This is that something ([/decisions/ad-096.md](/decisions/ad-096.md)).
218
+ *
219
+ * invariant: never fails the install. An operator installing behind a proxy, on a plane, or against a page that
220
+ * moved still gets a working harness — they get no cost figures until the next refresh, which `doctor` reports.
221
+ */
222
+ export function fetchPrices(dest: string, spawn = spawnSync): void {
223
+ const result = spawn(process.execPath, [join(dest, "bin", "tlc-exec.mjs"), "refresh-model-prices"], {
224
+ stdio: "inherit",
225
+ env: { ...process.env, TLC_HOME: dest },
226
+ });
227
+ if ((result.status ?? 1) !== 0) {
228
+ console.log("install: prices not fetched — cost estimates stay empty until `tlc harness prices refresh`");
229
+ }
230
+ }
231
+
158
232
  if (import.meta.main) {
159
- const source = originRoot();
233
+ /**
234
+ * why a flag rather than a second command: install is install. The only difference is whether the runtime home
235
+ * holds a copy of the package or points at a checkout ([/decisions/ad-097.md](/decisions/ad-097.md)).
236
+ */
237
+ const link = process.argv.includes("--link");
238
+ const source = link ? process.cwd() : originRoot();
160
239
  const dest = installDest();
161
- const report = installRuntime(source, dest);
240
+ const report = link ? linkRuntime(source, dest) : installRuntime(source, dest);
162
241
  console.log(installReportText(report, createStyle()));
242
+ if (report.kind === "refused") {
243
+ process.exit(1);
244
+ }
245
+ fetchPrices(dest);
163
246
  process.exit(report.missing.length > 0 ? 1 : 0);
164
247
  }