@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
package/README.md CHANGED
@@ -16,7 +16,7 @@ npm i -g @tech-leads-club/harness-toolkit
16
16
  tlc harness install
17
17
  ```
18
18
 
19
- Then restart Cursor or Claude Code. That is the whole setup — the installer finds which of the two you
19
+ Then restart Cursor or Claude Code. That is the whole setup — `install` finds which of the two you
20
20
  have and wires only those, and the harness works in every repository right away with a safe baseline.
21
21
 
22
22
  To give one project its own rules, open it and say **"setup harness"** to the agent, or run
@@ -41,12 +41,11 @@ To give one project its own rules, open it and say **"setup harness"** to the ag
41
41
  12. [Connect a project](#connect-a-project)
42
42
  13. [Paths and shared state](#paths-and-shared-state)
43
43
  14. [Ship claims](#ship-claims)
44
- 15. [Price catalogs](#price-catalogs)
45
- 16. [Windows](#windows)
46
- 17. [Troubleshooting](#troubleshooting)
47
- 18. [Documentation](#documentation)
48
- 19. [Contributing](#contributing)
49
- 20. [License](#license)
44
+ 15. [Prices](#prices)
45
+ 16. [Troubleshooting](#troubleshooting)
46
+ 17. [Documentation](#documentation)
47
+ 18. [Contributing](#contributing)
48
+ 19. [License](#license)
50
49
 
51
50
  ## Everything it validates
52
51
 
@@ -214,9 +213,12 @@ nothing reads — `src/providers/provider.degrade.ts`.
214
213
 
215
214
  | Dependency | Notes |
216
215
  |------------|--------|
217
- | **Bun** *or* **Node.js 24+** | Either one is enough. Bun runs every hook directly with no build step (~1 ms/hook); Node needs 24 LTS or 26 and the shipped `dist/` (~27 ms/hook). With neither, the installer stops and names both fixes |
218
- | **npm** | Delivers the package on every platform |
219
- | **esbuild** (only for the Node path) | Needed once to recompile `dist/`; the published `dist/` already works |
216
+ | **Bun** *or* **Node.js 24+** | Either one is enough. Bun runs every hook directly (~1 ms/hook); Node runs the shipped bundles (~27 ms/hook). With neither, the install stops and names both fixes |
217
+ | **npm** | Delivers the package, and generates the `tlc` shim for the platform it runs on |
218
+
219
+ Linux, macOS and Windows, same commands and same code path. CI runs the full suite and the bundle build on all
220
+ three on every push; hooks firing inside an editor session on Windows are outside that coverage
221
+ ([`docs/decisions/ad-006.md`](docs/decisions/ad-006.md), [`docs/decisions/ad-097.md`](docs/decisions/ad-097.md)).
220
222
 
221
223
  ## Install
222
224
 
@@ -301,8 +303,8 @@ Reload/restart the provider session afterward if hooks or the init skill should
301
303
  | `managed checkout` | moves it to upstream with a hard reset. Do not develop there — a local change is discarded |
302
304
  | `link to a working clone` | nothing in the clone. That is a contributor install; you pull it yourself |
303
305
 
304
- `dist/` is rebuilt only when a bundle is missing. Rebuilding a complete `dist/` is what used to dirty the checkout
305
- and break every later update, because Bun and esbuild emit different bytes for the same source.
306
+ The bundles are rebuilt only when one is missing, and only Bun builds them a second bundler emits different
307
+ bytes for the same source.
306
308
 
307
309
  **If `update` aborts on `dist/`, install the package again.** A stuck install cannot deliver its own fix — the fix
308
310
  lives in the revision `update` has to fetch — so `npm i -g @tech-leads-club/harness-toolkit@latest` followed by
@@ -352,7 +354,8 @@ graph LR
352
354
  | Project shim (per provider) | `<repo>/.cursor/hooks.json`, `<repo>/.claude/settings.json` |
353
355
 
354
356
  Entrypoint: `bin/tlc-exec.mjs`.
355
- Wrappers: `bin/tlc`, `bin/tlc-exec` (Unix); `bin/tlc.cmd`, `bin/tlc-exec.cmd` (Windows).
357
+ Wrappers: `bin/tlc` and `bin/tlc.cmd`, kept for installs made before the package existed. Everything else
358
+ spawns `bin/tlc-exec.mjs` with the interpreter already running.
356
359
 
357
360
  See `tlc harness help architecture` or [`docs/architecture.md`](docs/architecture.md).
358
361
 
@@ -373,7 +376,7 @@ See `tlc harness help architecture` or [`docs/architecture.md`](docs/architectur
373
376
  | `tlc harness attest` | Tamper-evident record of what each session ran under |
374
377
  | `tlc harness handoff` | Handoff state between turns and sessions — the sanctioned reader |
375
378
  | `tlc harness obs live` / `obs report` | Signal / session rollup |
376
- | `tlc harness prices refresh` / `lookup` | Cost catalogs |
379
+ | `tlc harness prices refresh` / `lookup` | The machine's price catalogue |
377
380
  | `tlc harness lessons list` | Lessons across the three tiers, with staleness and effectiveness |
378
381
  | `tlc harness lessons add "…" [--ref path:symbol] [--global] [--pin]` | Write a lesson; `--ref` retires it when that stops resolving, `--pin` puts it ahead of ranking |
379
382
  | `tlc harness policy` / `policy accept <path>` | List a mid-session policy change; accept exactly those paths |
@@ -427,26 +430,19 @@ HARNESS_PLAN_DEVIATION: src/x.ts — the call site moved with the type
427
430
  Both are protocol lines on purpose. A gate that fires on free-English "done" fires on the word, not the
428
431
  claim.
429
432
 
430
- ## Price catalogs
433
+ ## Prices
431
434
 
432
435
  ```bash
433
436
  tlc harness prices refresh
434
437
  tlc harness prices refresh cursor
435
- tlc harness prices refresh litellm
436
438
  tlc harness prices lookup <model-id> [provider]
437
439
  ```
438
440
 
439
- See `tlc harness help prices` or [`docs/measure.md`](docs/measure.md).
440
-
441
- ## Windows
441
+ Catalogue: `~/.tlc/harness/model-prices.json`, one plane per billing origin. Overrides:
442
+ `model-prices.local.json`. Neither is versioned. `install` fetches once, `update` refreshes past a 7-day TTL,
443
+ `doctor` reports the age.
442
444
 
443
- Path resolution goes through `os.homedir()` only, hooks use exec form, filenames are sanitized,
444
- atomic writes retry, and the CLI ships a `.cmd` shim alongside directory junctions
445
- ([`docs/decisions/ad-006.md`](docs/decisions/ad-006.md)).
446
-
447
- CI runs the full suite and the `dist/` build on `windows-latest` on every push.
448
-
449
- Outside CI coverage: `install.ps1`, and hooks firing inside a Cursor or Claude Code session on Windows.
445
+ See `tlc harness help prices` or [`docs/measure.md`](docs/measure.md).
450
446
 
451
447
  ## Troubleshooting
452
448
 
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the Node-runnable ESM bundles under `dist/`.
4
+ *
5
+ * why this is not a shell script any more: the bash version ran only where bash runs, so `update` could not
6
+ * rebuild a missing bundle on Windows — `spawnSync` cannot execute an extensionless bash file there — and CI had
7
+ * to invoke it through `shell: bash` on all three legs. Nothing it does needs a shell
8
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
9
+ *
10
+ * why Bun only: `dist/` is committed and `check-dist-fresh` compares bytes, so the bundler is part of the
11
+ * artefact. The bash version preferred Bun and fell back to esbuild, which means a contributor without Bun
12
+ * produced different bytes for the same source — measured at 223,390 against 228,018 for one bundle
13
+ * ([/decisions/ad-046.md](/decisions/ad-046.md)). One bundler is the only reproducible answer.
14
+ *
15
+ * invariant: what to build is derived from disk, one level deep. A hardcoded list silently stops building a new
16
+ * entrypoint, and the missing bundle only surfaces when a hook fires on somebody's machine.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
20
+ import { basename, dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const dist = join(root, "dist");
25
+
26
+ /**
27
+ * why one level: `tools/dev/` holds the checks that validate *this* repository — a user's install has no
28
+ * `src/core` of ours to validate — so the directory boundary is the whole declaration and nothing under it is
29
+ * ever a bundle ([/decisions/ad-068.md](/decisions/ad-068.md)).
30
+ */
31
+ function sourcesIn(dir) {
32
+ if (!existsSync(dir)) {
33
+ return [];
34
+ }
35
+ return readdirSync(dir, { withFileTypes: true })
36
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"))
37
+ .map((entry) => ({ name: basename(entry.name, ".ts"), source: join(dir, entry.name) }));
38
+ }
39
+
40
+ function buildOne(source, out) {
41
+ const result = spawnSync(
42
+ "bun",
43
+ ["build", "--target=node", "--format=esm", `--outfile=${out}`, source],
44
+ { stdio: "inherit" },
45
+ );
46
+ if (result.error?.code === "ENOENT") {
47
+ console.error("tlc-build: Bun is not on PATH, and dist/ is committed so the bundler is part of the artefact.");
48
+ console.error(" curl -fsSL https://bun.sh/install | bash");
49
+ process.exit(1);
50
+ }
51
+ if ((result.status ?? 1) !== 0) {
52
+ process.exit(result.status ?? 1);
53
+ }
54
+ }
55
+
56
+ const targets = [
57
+ ...sourcesIn(join(root, "src", "entrypoints")),
58
+ ...sourcesIn(join(root, "tools")),
59
+ { name: "tlc-cli", source: join(root, "bin", "tlc-cli.ts") },
60
+ ];
61
+
62
+ mkdirSync(dist, { recursive: true });
63
+ console.log(`tlc-build → ${dist}`);
64
+ for (const target of targets) {
65
+ buildOne(target.source, join(dist, `${target.name}.mjs`));
66
+ }
67
+
68
+ // invariant: the launcher stays executable on a filesystem that tracks the bit. A no-op where it does not.
69
+ try {
70
+ chmodSync(join(root, "bin", "tlc"), 0o755);
71
+ } catch {
72
+ // a checkout on a filesystem without a mode bit is not a build failure
73
+ }
74
+
75
+ /**
76
+ * hazard: a bundle whose source moved or was deleted is not rebuilt, so it is never diffed either — it stays in
77
+ * `dist/` and ships. Deriving what to remove from the same disk that decides what to build closes that.
78
+ */
79
+ const wanted = new Set(targets.map((target) => `${target.name}.mjs`));
80
+ let bundles = 0;
81
+ for (const entry of readdirSync(dist, { withFileTypes: true })) {
82
+ if (!entry.isFile() || !entry.name.endsWith(".mjs")) {
83
+ continue;
84
+ }
85
+ if (wanted.has(entry.name)) {
86
+ bundles += 1;
87
+ continue;
88
+ }
89
+ console.log(`tlc-build: pruning ${entry.name} — no source`);
90
+ rmSync(join(dist, entry.name), { force: true });
91
+ }
92
+
93
+ console.log(`tlc-build: ok (${bundles} bundles)`);
package/bin/tlc-cli.ts CHANGED
@@ -9,11 +9,17 @@ import {
9
9
  rmSync,
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
- import { homedir } from "node:os";
13
12
  import { delimiter, join } from "node:path";
14
13
  import { coreFacade } from "../src/core/index.ts";
15
14
  import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts";
16
- import { flagsDir, projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
15
+ import { linkDir, seedConfig } from "../src/platform/links.ts";
16
+ import {
17
+ flagsDir,
18
+ projectConfigPath,
19
+ projectStateDir,
20
+ providerConfigDirs,
21
+ runtimeHome,
22
+ } from "../src/platform/paths.ts";
17
23
  import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts";
18
24
  import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
19
25
 
@@ -482,7 +488,7 @@ export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit";
482
488
  export const NPM_MARKER = "installed-from-npm";
483
489
 
484
490
  /**
485
- * hazard: `install.sh` links the runtime path to the clone it was run from, so on a contributor's machine
491
+ * hazard: `install --link` points the runtime path at the clone it was run from, so on a contributor's machine
486
492
  * `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run
487
493
  * `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine.
488
494
  *
@@ -514,7 +520,7 @@ export function classifyRuntimePath(
514
520
  * hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI
515
521
  * refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves
516
522
  * 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.
523
+ * platform the reporter uses. Only the last hop decides, which is the one thing the install actually creates.
518
524
  */
519
525
  export function runtimePathKind(dest: string): RuntimePathKind {
520
526
  return classifyRuntimePath(dest, {
@@ -746,13 +752,16 @@ const GATE_FIELDS: Record<string, GateField> = {
746
752
 
747
753
  // why: resolved without executing. Running the binary to see whether it exists would run it, which is not
748
754
  // 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}`)];
755
+ /**
756
+ * why one list rather than a platform branch reading PATHEXT: the extensions only exist on the platform that uses
757
+ * them, so trying all of them everywhere finds the same file and removes a branch nobody can test twice. The bare
758
+ * name is first, so a POSIX `foo` is never beaten by a stray `foo.exe`
759
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
760
+ */
761
+ const EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".ps1"];
762
+
763
+ export function resolveExecutable(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
764
+ const candidates = (base: string): string[] => EXECUTABLE_EXTENSIONS.map((ext) => `${base}${ext}`);
756
765
 
757
766
  if (name.includes("/") || name.includes("\\")) {
758
767
  return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
@@ -861,22 +870,24 @@ export function helpText(style: Style = PLAIN): string {
861
870
 
862
871
  export function pricesHelpScreen(): Screen {
863
872
  return {
864
- title: "price catalogs",
873
+ title: "prices",
865
874
  sections: [
866
875
  {
867
- lines: ` tlc harness prices refresh [all|cursor|litellm]
876
+ lines: ` tlc harness prices refresh [all|cursor|litellm] [--if-stale]
868
877
  tlc harness prices lookup <model-id>
869
878
 
870
- refresh / refresh all Cursor catalog + LiteLLM fallback
871
- refresh cursor model-prices.cursor.json (tracked)
872
- refresh litellm model-prices.litellm.json (local)
879
+ refresh / refresh all both planes of model-prices.json
880
+ refresh cursor the provider's own rates
881
+ refresh litellm the vendors' list prices
882
+ --if-stale fetch only past the 7-day TTL
873
883
  lookup <model-id> catalog key, pool, USD for 1M in + 1M out
874
884
 
875
- Resolution: overrides Cursor LiteLLM null
885
+ Catalogue: <runtime home>/model-prices.json fetched per machine, never versioned
886
+ Overrides: <runtime home>/model-prices.local.json — yours, hand-written
876
887
  Documentation: tlc harness help prices`.split("\n"),
877
888
  },
878
889
  ],
879
- footer: "resolution: local overrides → the provider's own catalog LiteLLM → null",
890
+ footer: "resolution: your overrides → the asking provider's planethe vendor plane → null",
880
891
  };
881
892
  }
882
893
 
@@ -893,12 +904,69 @@ export function resolveHarnessRoot(): string {
893
904
  }
894
905
  }
895
906
 
907
+ /**
908
+ * Everything an install has to put in place outside the runtime directory itself: the init skill where each
909
+ * provider reads it, the user-level hooks, and a seeded config.
910
+ *
911
+ * hazard: there were three implementations of this — bash, PowerShell, and the POSIX branch here — and they
912
+ * disagreed. The PowerShell one linked the init skill into `~/.tlc/skills/harness-init`, which no provider reads,
913
+ * so on Windows `update` refreshed a skill nothing could route to. That is the defect
914
+ * [/decisions/ad-095.md](/decisions/ad-095.md) fixed on the other side, still live on this one
915
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
916
+ *
917
+ * invariant: one function, no platform branch, and the launcher on PATH is npm's business.
918
+ */
919
+ export function wireRuntime(dest: string, home: string): { lines: string[]; missingSkill: boolean } {
920
+ const lines: string[] = [];
921
+ const seeded = seedConfig(dest);
922
+ if (seeded.seeded) {
923
+ lines.push(`config seeded → ${seeded.path}`);
924
+ }
925
+
926
+ if (!existsSync(join(dest, "skills", "harness-init"))) {
927
+ return { lines, missingSkill: true };
928
+ }
929
+
930
+ const links = coreFacade.skill.skillLinks(dest, providerConfigDirs(), existsSync);
931
+ if (links.length === 0) {
932
+ lines.push("no provider config dir found — skill not linked");
933
+ }
934
+ for (const link of links) {
935
+ const outcome = linkDir(link.source, link.target);
936
+ lines.push(
937
+ outcome.kind === "refused" ? `skill not linked — ${outcome.reason}` : `skill → ${outcome.target}`,
938
+ );
939
+ }
940
+
941
+ const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
942
+ stdio: "inherit",
943
+ env: { ...process.env, TLC_HOME: home },
944
+ });
945
+ if ((hooks.status ?? 1) !== 0) {
946
+ lines.push("hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
947
+ }
948
+ return { lines, missingSkill: false };
949
+ }
950
+
951
+ /**
952
+ * hazard: this was the extensionless bash wrapper, so every `runEntry` spawn — `doctor`, `prices refresh`,
953
+ * `install-runtime`, `price-lookup` — named a file Windows cannot execute. The hooks never had this problem
954
+ * because they name the `.mjs` ([/decisions/ad-097.md](/decisions/ad-097.md)).
955
+ *
956
+ * invariant: paired with `process.execPath`, so the entry runs under the interpreter that is already running.
957
+ */
896
958
  export function execBinPath(): string {
897
- return join(resolveHarnessRoot(), "bin", "tlc-exec");
959
+ return join(resolveHarnessRoot(), "bin", "tlc-exec.mjs");
898
960
  }
899
961
 
962
+ /**
963
+ * why the `.mjs` and not a wrapper: the wrapper was bash, so `spawnSync` could not run it on Windows and
964
+ * `update` there could never rebuild a missing bundle ([/decisions/ad-097.md](/decisions/ad-097.md)).
965
+ *
966
+ * invariant: spawned with `process.execPath`, so the interpreter running the CLI is the one that builds.
967
+ */
900
968
  export function buildBinPath(): string {
901
- return join(resolveHarnessRoot(), "bin", "tlc-build");
969
+ return join(resolveHarnessRoot(), "bin", "tlc-build.mjs");
902
970
  }
903
971
 
904
972
  export type Action =
@@ -1217,13 +1285,18 @@ function runUpdate(root: string): never {
1217
1285
  const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], {
1218
1286
  stdio: "inherit",
1219
1287
  env: process.env,
1220
- shell: process.platform === "win32",
1288
+ // why: `npm` is `npm.cmd` on Windows and a shell is how that resolves; on POSIX it costs one `/bin/sh`,
1289
+ // and the argv here is fixed ([/decisions/ad-097.md](/decisions/ad-097.md)).
1290
+ shell: true,
1221
1291
  });
1222
1292
  if ((bump.status ?? 1) !== 0) {
1223
1293
  console.error(npmUpdateFailureMessage());
1224
1294
  process.exit(bump.status ?? 1);
1225
1295
  }
1226
- const sync = spawnSync(execBinPath(), ["install-runtime"], { stdio: "inherit", env: process.env });
1296
+ const sync = spawnSync(process.execPath, [execBinPath(), "install-runtime"], {
1297
+ stdio: "inherit",
1298
+ env: process.env,
1299
+ });
1227
1300
  if ((sync.status ?? 1) !== 0) {
1228
1301
  process.exit(sync.status ?? 1);
1229
1302
  }
@@ -1262,38 +1335,13 @@ function runUpdate(root: string): never {
1262
1335
  );
1263
1336
  }
1264
1337
 
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
- }
1338
+ const wired = wireRuntime(dest, home);
1339
+ for (const line of wired.lines) {
1340
+ console.log(`update: ${line}`);
1341
+ }
1342
+ if (wired.missingSkill) {
1343
+ console.error(`update: missing skill at ${join(dest, "skills", "harness-init")}`);
1344
+ process.exit(1);
1297
1345
  }
1298
1346
 
1299
1347
  // invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node
@@ -1305,7 +1353,7 @@ function runUpdate(root: string): never {
1305
1353
  console.log("update: dist/ complete — no rebuild, so the runtime path stays clean");
1306
1354
  } else if (existsSync(buildBinPath())) {
1307
1355
  console.log(`update: ${missing.length} bundle(s) missing — building`);
1308
- const build = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1356
+ const build = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
1309
1357
  if ((build.status ?? 1) !== 0) {
1310
1358
  console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`);
1311
1359
  }
@@ -1314,8 +1362,19 @@ function runUpdate(root: string): never {
1314
1362
  announceNewCapabilities(root, dest);
1315
1363
  announceLandedDecisions(root, dest, revisionBefore);
1316
1364
 
1365
+ /**
1366
+ * why: an update is the moment a machine is already reaching the network, so it is the natural place to notice
1367
+ * that its prices are a week old. `--if-stale` is what keeps this from being a fetch on every update, and the
1368
+ * failure is tolerated — a rate that could not be fetched is not a reason for a failed update
1369
+ * ([/decisions/ad-096.md](/decisions/ad-096.md)).
1370
+ */
1371
+ spawnSync(process.execPath, [execBinPath(), "refresh-model-prices", "all", "--if-stale"], {
1372
+ stdio: "inherit",
1373
+ env: { ...process.env, TLC_PROJECT_DIR: root },
1374
+ });
1375
+
1317
1376
  console.log("update: running doctor…");
1318
- const doctor = spawnSync(execBinPath(), ["doctor"], {
1377
+ const doctor = spawnSync(process.execPath, [execBinPath(), "doctor"], {
1319
1378
  stdio: "inherit",
1320
1379
  env: { ...process.env, TLC_PROJECT_DIR: root },
1321
1380
  });
@@ -1324,7 +1383,7 @@ function runUpdate(root: string): never {
1324
1383
  }
1325
1384
 
1326
1385
  function runEntry(entry: string, toolArgs: string[], root: string): never {
1327
- const r = spawnSync(execBinPath(), [entry, ...toolArgs], {
1386
+ const r = spawnSync(process.execPath, [execBinPath(), entry, ...toolArgs], {
1328
1387
  stdio: "inherit",
1329
1388
  env: { ...process.env, TLC_PROJECT_DIR: root },
1330
1389
  });
@@ -1425,7 +1484,7 @@ function main(argv: string[]): void {
1425
1484
  console.log(helpText(createStyle()));
1426
1485
  break;
1427
1486
  case "build": {
1428
- const r = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1487
+ const r = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
1429
1488
  process.exit(r.status ?? 1);
1430
1489
  break;
1431
1490
  }
package/bin/tlc-exec.mjs CHANGED
@@ -62,20 +62,23 @@ export function resolveHarnessHome(
62
62
  return candidate;
63
63
  }
64
64
 
65
- export function bunExecutableName(platform = process.platform) {
66
- return platform === "win32" ? "bun.exe" : "bun";
67
- }
65
+ /**
66
+ * why a list and not a branch: the only difference between platforms is which of these names exists on disk, and
67
+ * asking for both costs one extra `existsSync` per PATH entry. `bun.exe` never exists on Linux and `bun` never
68
+ * shadows it on Windows ([/decisions/ad-097.md](/decisions/ad-097.md)).
69
+ */
70
+ export const BUN_EXECUTABLE_NAMES = ["bun", "bun.exe"];
68
71
 
69
- export function findBunOnPath(env = process.env, platform = process.platform) {
70
- const pathValue = env.PATH ?? "";
71
- const bunName = bunExecutableName(platform);
72
- for (const dir of pathValue.split(delimiter)) {
72
+ export function findBunOnPath(env = process.env) {
73
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
73
74
  if (!dir) {
74
75
  continue;
75
76
  }
76
- const candidate = join(dir, bunName);
77
- if (existsSync(candidate)) {
78
- return candidate;
77
+ for (const name of BUN_EXECUTABLE_NAMES) {
78
+ const candidate = join(dir, name);
79
+ if (existsSync(candidate)) {
80
+ return candidate;
81
+ }
79
82
  }
80
83
  }
81
84
  return null;
@@ -122,12 +125,12 @@ export function writeRuntimeCache(harnessHome, bunPath) {
122
125
  return record;
123
126
  }
124
127
 
125
- export function resolveBunPath(harnessHome, env = process.env, platform = process.platform) {
128
+ export function resolveBunPath(harnessHome, env = process.env) {
126
129
  const cached = readRuntimeCache(harnessHome);
127
130
  if (cached) {
128
131
  return cached.bunPath;
129
132
  }
130
- const found = findBunOnPath(env, platform);
133
+ const found = findBunOnPath(env);
131
134
  writeRuntimeCache(harnessHome, found);
132
135
  return found;
133
136
  }
@@ -167,7 +170,7 @@ export function decideRuntime({ harnessHome, entry, bunPath, nodeMajor, distExis
167
170
  status: 1,
168
171
  message: [
169
172
  `tlc: Node ${process.version} found, but dist/${entry}.mjs is missing.`,
170
- ` Run: ${join(harnessHome, "bin", "tlc-build")}`,
173
+ ` Run: node ${join(harnessHome, "bin", "tlc-build.mjs")}`,
171
174
  ].join("\n"),
172
175
  };
173
176
  }