@tech-leads-club/harness-toolkit 0.2.4 → 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 (50) hide show
  1. package/README.md +22 -26
  2. package/bin/tlc-build.mjs +93 -0
  3. package/bin/tlc-cli.ts +110 -66
  4. package/bin/tlc-exec.mjs +16 -13
  5. package/dist/compact-before.mjs +66 -8
  6. package/dist/doctor.mjs +178 -41
  7. package/dist/help-topic.mjs +0 -0
  8. package/dist/init-project.mjs +2 -7
  9. package/dist/install-runtime.mjs +100 -17
  10. package/dist/lessons-cli.mjs +67 -1
  11. package/dist/obs-cli.mjs +64 -1
  12. package/dist/price-lookup.mjs +45 -23
  13. package/dist/prompt-submit.mjs +66 -8
  14. package/dist/refresh-model-prices.mjs +7190 -46
  15. package/dist/response-after.mjs +66 -8
  16. package/dist/run.mjs +66 -8
  17. package/dist/session-end.mjs +66 -8
  18. package/dist/session-start.mjs +66 -8
  19. package/dist/shim.mjs +66 -3
  20. package/dist/stop.mjs +66 -8
  21. package/dist/subagent-start.mjs +66 -8
  22. package/dist/subagent-stop.mjs +66 -8
  23. package/dist/support.mjs +64 -1
  24. package/dist/tlc-cli.mjs +193 -87
  25. package/dist/tool-after.mjs +111 -31
  26. package/dist/tool-before.mjs +66 -8
  27. package/dist/tool-failure.mjs +66 -8
  28. package/dist/uninstall-runtime.mjs +9 -10
  29. package/docs/log.md +2 -0
  30. package/docs/measure.md +35 -31
  31. package/package.json +4 -4
  32. package/src/core/core.facade.ts +7 -0
  33. package/src/core/index.ts +1 -0
  34. package/src/core/pricing/pricing.freshness.ts +118 -0
  35. package/src/core/skill/skill.link.ts +14 -3
  36. package/src/entrypoints/shim.ts +8 -2
  37. package/src/platform/links.ts +73 -0
  38. package/src/platform/pricing.ts +139 -31
  39. package/src/providers/cursor/cursor.wiring.ts +11 -8
  40. package/tools/doctor.ts +78 -8
  41. package/tools/init-project.ts +7 -7
  42. package/tools/install-runtime.ts +89 -6
  43. package/tools/refresh-model-prices.ts +242 -75
  44. package/tools/uninstall-runtime.ts +23 -19
  45. package/bin/tlc-build +0 -80
  46. package/bin/tlc-exec +0 -10
  47. package/bin/tlc-exec.cmd +0 -4
  48. package/model-aliases.json +0 -12
  49. package/model-prices.cursor.json +0 -410
  50. package/model-prices.json +0 -1
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,10 +9,10 @@ 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";
15
+ import { linkDir, seedConfig } from "../src/platform/links.ts";
16
16
  import {
17
17
  flagsDir,
18
18
  projectConfigPath,
@@ -488,7 +488,7 @@ export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit";
488
488
  export const NPM_MARKER = "installed-from-npm";
489
489
 
490
490
  /**
491
- * 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
492
492
  * `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run
493
493
  * `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine.
494
494
  *
@@ -520,7 +520,7 @@ export function classifyRuntimePath(
520
520
  * hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI
521
521
  * refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves
522
522
  * elsewhere and a **managed** checkout was classified as linked — which would silently stop updates on the very
523
- * 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.
524
524
  */
525
525
  export function runtimePathKind(dest: string): RuntimePathKind {
526
526
  return classifyRuntimePath(dest, {
@@ -752,13 +752,16 @@ const GATE_FIELDS: Record<string, GateField> = {
752
752
 
753
753
  // why: resolved without executing. Running the binary to see whether it exists would run it, which is not
754
754
  // something a config write is allowed to do.
755
- export function resolveExecutable(
756
- name: string,
757
- env: NodeJS.ProcessEnv = process.env,
758
- platform: string = process.platform,
759
- ): string | null {
760
- const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
761
- 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}`);
762
765
 
763
766
  if (name.includes("/") || name.includes("\\")) {
764
767
  return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
@@ -867,22 +870,24 @@ export function helpText(style: Style = PLAIN): string {
867
870
 
868
871
  export function pricesHelpScreen(): Screen {
869
872
  return {
870
- title: "price catalogs",
873
+ title: "prices",
871
874
  sections: [
872
875
  {
873
- lines: ` tlc harness prices refresh [all|cursor|litellm]
876
+ lines: ` tlc harness prices refresh [all|cursor|litellm] [--if-stale]
874
877
  tlc harness prices lookup <model-id>
875
878
 
876
- refresh / refresh all Cursor catalog + LiteLLM fallback
877
- refresh cursor model-prices.cursor.json (tracked)
878
- 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
879
883
  lookup <model-id> catalog key, pool, USD for 1M in + 1M out
880
884
 
881
- 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
882
887
  Documentation: tlc harness help prices`.split("\n"),
883
888
  },
884
889
  ],
885
- footer: "resolution: local overrides → the provider's own catalog LiteLLM → null",
890
+ footer: "resolution: your overrides → the asking provider's planethe vendor plane → null",
886
891
  };
887
892
  }
888
893
 
@@ -899,12 +904,69 @@ export function resolveHarnessRoot(): string {
899
904
  }
900
905
  }
901
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
+ */
902
958
  export function execBinPath(): string {
903
- return join(resolveHarnessRoot(), "bin", "tlc-exec");
959
+ return join(resolveHarnessRoot(), "bin", "tlc-exec.mjs");
904
960
  }
905
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
+ */
906
968
  export function buildBinPath(): string {
907
- return join(resolveHarnessRoot(), "bin", "tlc-build");
969
+ return join(resolveHarnessRoot(), "bin", "tlc-build.mjs");
908
970
  }
909
971
 
910
972
  export type Action =
@@ -1223,13 +1285,18 @@ function runUpdate(root: string): never {
1223
1285
  const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], {
1224
1286
  stdio: "inherit",
1225
1287
  env: process.env,
1226
- 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,
1227
1291
  });
1228
1292
  if ((bump.status ?? 1) !== 0) {
1229
1293
  console.error(npmUpdateFailureMessage());
1230
1294
  process.exit(bump.status ?? 1);
1231
1295
  }
1232
- 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
+ });
1233
1300
  if ((sync.status ?? 1) !== 0) {
1234
1301
  process.exit(sync.status ?? 1);
1235
1302
  }
@@ -1268,47 +1335,13 @@ function runUpdate(root: string): never {
1268
1335
  );
1269
1336
  }
1270
1337
 
1271
- const binDir = process.env.TLC_BIN_DIR || join(homedir(), ".local", "bin");
1272
- mkdirSync(binDir, { recursive: true });
1273
-
1274
- if (process.platform === "win32") {
1275
- const installPs1 = join(dest, "install.ps1");
1276
- const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installPs1], {
1277
- stdio: "inherit",
1278
- env: { ...process.env, TLC_HOME: home },
1279
- cwd: dest,
1280
- });
1281
- if ((r.status ?? 1) !== 0) {
1282
- process.exit(r.status ?? 1);
1283
- }
1284
- } else {
1285
- const tlcBin = join(dest, "bin", "tlc");
1286
- const skillSrc = join(dest, "skills", "harness-init");
1287
- spawnSync("ln", ["-sfn", tlcBin, join(binDir, "tlc")], { stdio: "inherit" });
1288
- if (!existsSync(skillSrc)) {
1289
- console.error(`update: missing skill at ${skillSrc}`);
1290
- process.exit(1);
1291
- }
1292
- // hazard: this linked to `<home>/../skills/harness-init` — a directory no provider reads, and which did not
1293
- // exist at all on the machine where this was found. `install.sh` had it right all along, linking into every
1294
- // provider config directory that exists, because each provider only reads its own. The resolution is now one
1295
- // function both use ([/decisions/ad-095.md](/decisions/ad-095.md)).
1296
- const links = coreFacade.skill.skillLinks(dest, providerConfigDirs(), existsSync);
1297
- if (links.length === 0) {
1298
- console.log("update: no provider config dir found — skill not linked");
1299
- }
1300
- for (const link of links) {
1301
- mkdirSync(join(link.providerDir, "skills"), { recursive: true });
1302
- spawnSync("ln", ["-sfn", link.source, link.target], { stdio: "inherit" });
1303
- console.log(`update: skill → ${link.target}`);
1304
- }
1305
- const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
1306
- stdio: "inherit",
1307
- env: { ...process.env, TLC_HOME: home },
1308
- });
1309
- if ((hooks.status ?? 1) !== 0) {
1310
- console.log("update: hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
1311
- }
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);
1312
1345
  }
1313
1346
 
1314
1347
  // invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node
@@ -1320,7 +1353,7 @@ function runUpdate(root: string): never {
1320
1353
  console.log("update: dist/ complete — no rebuild, so the runtime path stays clean");
1321
1354
  } else if (existsSync(buildBinPath())) {
1322
1355
  console.log(`update: ${missing.length} bundle(s) missing — building`);
1323
- const build = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1356
+ const build = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
1324
1357
  if ((build.status ?? 1) !== 0) {
1325
1358
  console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`);
1326
1359
  }
@@ -1329,8 +1362,19 @@ function runUpdate(root: string): never {
1329
1362
  announceNewCapabilities(root, dest);
1330
1363
  announceLandedDecisions(root, dest, revisionBefore);
1331
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
+
1332
1376
  console.log("update: running doctor…");
1333
- const doctor = spawnSync(execBinPath(), ["doctor"], {
1377
+ const doctor = spawnSync(process.execPath, [execBinPath(), "doctor"], {
1334
1378
  stdio: "inherit",
1335
1379
  env: { ...process.env, TLC_PROJECT_DIR: root },
1336
1380
  });
@@ -1339,7 +1383,7 @@ function runUpdate(root: string): never {
1339
1383
  }
1340
1384
 
1341
1385
  function runEntry(entry: string, toolArgs: string[], root: string): never {
1342
- const r = spawnSync(execBinPath(), [entry, ...toolArgs], {
1386
+ const r = spawnSync(process.execPath, [execBinPath(), entry, ...toolArgs], {
1343
1387
  stdio: "inherit",
1344
1388
  env: { ...process.env, TLC_PROJECT_DIR: root },
1345
1389
  });
@@ -1440,7 +1484,7 @@ function main(argv: string[]): void {
1440
1484
  console.log(helpText(createStyle()));
1441
1485
  break;
1442
1486
  case "build": {
1443
- const r = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
1487
+ const r = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
1444
1488
  process.exit(r.status ?? 1);
1445
1489
  break;
1446
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
  }
@@ -5273,6 +5273,62 @@ function release(root, provider, session) {
5273
5273
  deletePresenceRecord(root, provider, session);
5274
5274
  }
5275
5275
 
5276
+ // src/core/pricing/pricing.freshness.ts
5277
+ var DEFAULT_TTL_DAYS = 7;
5278
+ var MS_PER_DAY = 86400000;
5279
+ function freshness(meta, now, ttlDays = DEFAULT_TTL_DAYS) {
5280
+ if (meta === null) {
5281
+ return { state: "absent" };
5282
+ }
5283
+ const stamp = meta.refreshedAt;
5284
+ if (stamp === undefined || Number.isNaN(Date.parse(stamp))) {
5285
+ return { state: "undated" };
5286
+ }
5287
+ const ageMs = now.getTime() - Date.parse(stamp);
5288
+ const ageDays = Math.max(0, ageMs / MS_PER_DAY);
5289
+ return ageDays > ttlDays ? { state: "stale", ageDays, refreshedAt: stamp } : { state: "fresh", ageDays, refreshedAt: stamp };
5290
+ }
5291
+ function shouldRefetch(state) {
5292
+ return state.state === "absent" || state.state === "undated" || state.state === "stale";
5293
+ }
5294
+ function freshnessMessage(state, catalogue) {
5295
+ switch (state.state) {
5296
+ case "absent":
5297
+ return `${catalogue}: not on this machine — run \`tlc harness prices refresh\``;
5298
+ case "undated":
5299
+ return `${catalogue}: present but carries no date — it will be refetched`;
5300
+ case "fresh":
5301
+ return `${catalogue}: ${describeAge(state.ageDays)} old`;
5302
+ default:
5303
+ return `${catalogue}: ${describeAge(state.ageDays)} old — run \`tlc harness prices refresh\``;
5304
+ }
5305
+ }
5306
+ var MIN_RETAINED_RATIO = 0.5;
5307
+ function mayReplace(existingCount, incomingCount, minRatio = MIN_RETAINED_RATIO) {
5308
+ if (incomingCount === 0) {
5309
+ return { replace: false, reason: "parsed no entries at all — the upstream format has changed" };
5310
+ }
5311
+ if (existingCount === 0) {
5312
+ return { replace: true, reason: `first catalogue, ${incomingCount} entries` };
5313
+ }
5314
+ if (incomingCount >= existingCount) {
5315
+ return { replace: true, reason: `${existingCount} → ${incomingCount} entries` };
5316
+ }
5317
+ const retained = incomingCount / existingCount;
5318
+ return retained >= minRatio ? { replace: true, reason: `${existingCount} → ${incomingCount} entries` } : {
5319
+ replace: false,
5320
+ reason: `would drop from ${existingCount} to ${incomingCount} entries, keeping the existing catalogue — the upstream format has probably changed`
5321
+ };
5322
+ }
5323
+ function describeAge(ageDays) {
5324
+ if (ageDays < 1) {
5325
+ const hours = Math.max(1, Math.round(ageDays * 24));
5326
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
5327
+ }
5328
+ const days = Math.round(ageDays);
5329
+ return `${days} day${days === 1 ? "" : "s"}`;
5330
+ }
5331
+
5276
5332
  // src/core/release/release.decisions.ts
5277
5333
  import { existsSync as existsSync17, readdirSync as readdirSync5, readFileSync as readFileSync19 } from "node:fs";
5278
5334
  import { join as join18 } from "node:path";
@@ -5622,7 +5678,8 @@ function linkHealth(target, runtimeHome2, probe) {
5622
5678
  if (!probe.exists(resolved)) {
5623
5679
  return { state: "dangling", target, resolved };
5624
5680
  }
5625
- const home = runtimeHome2.replace(/\/+$/, "");
5681
+ const resolveHome = probe.realpath ?? ((path) => path);
5682
+ const home = resolveHome(runtimeHome2).replace(/\/+$/, "");
5626
5683
  return resolved === home || resolved.startsWith(`${home}/`) ? { state: "ok", target, resolved } : { state: "outside-runtime", target, resolved };
5627
5684
  }
5628
5685
  function linkHealthMessage(health) {
@@ -6769,6 +6826,12 @@ var coreFacade = {
6769
6826
  coversHandler,
6770
6827
  decideShim
6771
6828
  },
6829
+ pricing: {
6830
+ freshness,
6831
+ freshnessMessage,
6832
+ mayReplace,
6833
+ shouldRefetch
6834
+ },
6772
6835
  skill: {
6773
6836
  linkHealth,
6774
6837
  linkHealthMessage,
@@ -7719,14 +7782,9 @@ var ENTRY_SPECS2 = [
7719
7782
  { hookEvent: "stop", handler: "stop", timeoutSeconds: 120, loopLimit: 5 },
7720
7783
  { hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" }
7721
7784
  ];
7722
- function commandFor(runtime) {
7723
- if (process.platform === "win32") {
7724
- return { command: "cmd", argsPrefix: ["/c", "node", runtime.launcherPath] };
7725
- }
7726
- return { command: "node", argsPrefix: [runtime.launcherPath] };
7727
- }
7728
7785
  function cursorWiring(runtime) {
7729
- const { command, argsPrefix } = commandFor(runtime);
7786
+ const command = "node";
7787
+ const argsPrefix = [runtime.launcherPath];
7730
7788
  const entries = ENTRY_SPECS2.map((spec) => ({
7731
7789
  hookEvent: spec.hookEvent,
7732
7790
  handler: spec.handler,