@tech-leads-club/harness-toolkit 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +68 -37
  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/config.example.json +1 -1
  6. package/dist/compact-before.mjs +66 -8
  7. package/dist/doctor.mjs +178 -41
  8. package/dist/help-topic.mjs +0 -0
  9. package/dist/init-project.mjs +2 -7
  10. package/dist/install-runtime.mjs +6594 -281
  11. package/dist/lessons-cli.mjs +67 -1
  12. package/dist/obs-cli.mjs +64 -1
  13. package/dist/price-lookup.mjs +45 -23
  14. package/dist/prompt-submit.mjs +66 -8
  15. package/dist/refresh-model-prices.mjs +7190 -46
  16. package/dist/response-after.mjs +66 -8
  17. package/dist/run.mjs +66 -8
  18. package/dist/session-end.mjs +66 -8
  19. package/dist/session-start.mjs +66 -8
  20. package/dist/shim.mjs +66 -3
  21. package/dist/stop.mjs +66 -8
  22. package/dist/subagent-start.mjs +66 -8
  23. package/dist/subagent-stop.mjs +66 -8
  24. package/dist/support.mjs +64 -1
  25. package/dist/tlc-cli.mjs +193 -87
  26. package/dist/tool-after.mjs +111 -31
  27. package/dist/tool-before.mjs +66 -8
  28. package/dist/tool-failure.mjs +66 -8
  29. package/dist/uninstall-runtime.mjs +9 -10
  30. package/docs/log.md +2 -0
  31. package/docs/measure.md +35 -31
  32. package/package.json +5 -5
  33. package/src/core/core.facade.ts +7 -0
  34. package/src/core/index.ts +1 -0
  35. package/src/core/pricing/pricing.freshness.ts +118 -0
  36. package/src/core/skill/skill.link.ts +14 -3
  37. package/src/entrypoints/shim.ts +8 -2
  38. package/src/platform/links.ts +73 -0
  39. package/src/platform/pricing.ts +139 -31
  40. package/src/providers/cursor/cursor.wiring.ts +11 -8
  41. package/tools/doctor.ts +78 -8
  42. package/tools/init-project.ts +7 -7
  43. package/tools/install-runtime.ts +110 -7
  44. package/tools/refresh-model-prices.ts +242 -75
  45. package/tools/uninstall-runtime.ts +23 -19
  46. package/bin/tlc-build +0 -80
  47. package/bin/tlc-exec +0 -10
  48. package/bin/tlc-exec.cmd +0 -4
  49. package/model-aliases.json +0 -12
  50. package/model-prices.cursor.json +0 -410
  51. package/model-prices.json +0 -1
package/README.md CHANGED
@@ -1,9 +1,46 @@
1
- # harness-toolkit
2
-
3
- Steers Cursor and Claude Code agents with **gates → follow-up → handoff → policy**.
4
-
5
- Hooks fire on the editor's own events. The harness answers each one with a decision — allow, ask, deny, or
6
- text injected into the turn — and writes a record of what it decided and why.
1
+ <p align="center">
2
+ <img src=".github/assets/logo.png" alt="Tech Leads Club" width="400" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="npm version" />
7
+ <img src="https://img.shields.io/npm/dt/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="total downloads" />
8
+ <img src="https://img.shields.io/npm/dm/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="monthly downloads" />
9
+ <img src="https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue?style=flat-square" alt="license" />
10
+ <img src="https://img.shields.io/github/actions/workflow/status/tech-leads-club/harness-toolkit/release.yml?style=flat-square" alt="build status" />
11
+ </p>
12
+
13
+ <p align="center">
14
+ <img src="https://img.shields.io/badge/Bun-recommended-black?style=flat-square&logo=bun" alt="bun" />
15
+ <img src="https://img.shields.io/badge/node-%3E%3D24-brightgreen?style=flat-square&logo=node.js" alt="node version" />
16
+ <img src="https://img.shields.io/badge/TypeScript-strict-blue?style=flat-square&logo=typescript" alt="typescript" />
17
+ <img src="https://img.shields.io/badge/provenance-SLSA%20v1-green?style=flat-square&logo=npm" alt="npm provenance" />
18
+ <img src="https://img.shields.io/badge/dependencies-0%20runtime-brightgreen?style=flat-square" alt="zero runtime dependencies" />
19
+ </p>
20
+
21
+ <p align="center">
22
+ <img src="https://img.shields.io/badge/Cursor-supported-black?style=flat-square" alt="cursor" />
23
+ <img src="https://img.shields.io/badge/Claude%20Code-supported-black?style=flat-square&logo=anthropic" alt="claude code" />
24
+ <img src="https://img.shields.io/badge/Linux%20%C2%B7%20macOS%20%C2%B7%20Windows-tested%20in%20CI-informational?style=flat-square" alt="platforms" />
25
+ </p>
26
+
27
+ <p align="center">
28
+ <img src="https://img.shields.io/github/stars/tech-leads-club/harness-toolkit?style=flat-square&color=yellow" alt="github stars" />
29
+ <img src="https://img.shields.io/github/contributors/tech-leads-club/harness-toolkit?style=flat-square&color=orange" alt="contributors" />
30
+ <img src="https://img.shields.io/github/last-commit/tech-leads-club/harness-toolkit?style=flat-square" alt="last commit" />
31
+ </p>
32
+
33
+ <h1 align="center">🛡️ Harness Toolkit</h1>
34
+
35
+ <p align="center">
36
+ <strong>Steers Cursor and Claude Code agents with gates → follow-up → handoff → policy</strong>
37
+ </p>
38
+
39
+ <p align="center">
40
+ Hooks fire on the editor's own events. The harness answers each one with a decision — <b>allow</b>, <b>ask</b>,
41
+ <b>deny</b>, or text injected into the turn — and writes a record of what it decided and why.
42
+ <b>7 floor rules</b> no configuration can reach, <b>3 always-on checks</b>, and <b>23 rails</b> you choose.
43
+ </p>
7
44
 
8
45
  - **[Everything it validates](#everything-it-validates)** — the whole list, one row per check
9
46
  - **[How to see any of it](#how-to-see-any-of-it)** — the command behind each row
@@ -12,11 +49,10 @@ text injected into the turn — and writes a record of what it decided and why.
12
49
  ## Start here
13
50
 
14
51
  ```bash
15
- npm i -g @tech-leads-club/harness-toolkit
16
- tlc harness install
52
+ npm i -g @tech-leads-club/harness-toolkit && tlc harness install
17
53
  ```
18
54
 
19
- Then restart Cursor or Claude Code. That is the whole setup — the installer finds which of the two you
55
+ Then restart Cursor or Claude Code. That is the whole setup — `install` finds which of the two you
20
56
  have and wires only those, and the harness works in every repository right away with a safe baseline.
21
57
 
22
58
  To give one project its own rules, open it and say **"setup harness"** to the agent, or run
@@ -41,12 +77,11 @@ To give one project its own rules, open it and say **"setup harness"** to the ag
41
77
  12. [Connect a project](#connect-a-project)
42
78
  13. [Paths and shared state](#paths-and-shared-state)
43
79
  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)
80
+ 15. [Prices](#prices)
81
+ 16. [Troubleshooting](#troubleshooting)
82
+ 17. [Documentation](#documentation)
83
+ 18. [Contributing](#contributing)
84
+ 19. [License](#license)
50
85
 
51
86
  ## Everything it validates
52
87
 
@@ -214,17 +249,19 @@ nothing reads — `src/providers/provider.degrade.ts`.
214
249
 
215
250
  | Dependency | Notes |
216
251
  |------------|--------|
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 |
252
+ | **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 |
253
+ | **npm** | Delivers the package, and generates the `tlc` shim for the platform it runs on |
254
+
255
+ Linux, macOS and Windows, same commands and same code path. CI runs the full suite and the bundle build on all
256
+ three on every push; hooks firing inside an editor session on Windows are outside that coverage
257
+ ([`docs/decisions/ad-006.md`](docs/decisions/ad-006.md), [`docs/decisions/ad-097.md`](docs/decisions/ad-097.md)).
220
258
 
221
259
  ## Install
222
260
 
223
- The same two commands on every platform.
261
+ One command, the same on every platform.
224
262
 
225
263
  ```bash
226
- npm i -g @tech-leads-club/harness-toolkit
227
- tlc harness install
264
+ npm i -g @tech-leads-club/harness-toolkit && tlc harness install
228
265
  ```
229
266
 
230
267
  The package is the delivery vehicle and `tlc harness install` is what puts the runtime in place. They are
@@ -301,8 +338,8 @@ Reload/restart the provider session afterward if hooks or the init skill should
301
338
  | `managed checkout` | moves it to upstream with a hard reset. Do not develop there — a local change is discarded |
302
339
  | `link to a working clone` | nothing in the clone. That is a contributor install; you pull it yourself |
303
340
 
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.
341
+ The bundles are rebuilt only when one is missing, and only Bun builds them a second bundler emits different
342
+ bytes for the same source.
306
343
 
307
344
  **If `update` aborts on `dist/`, install the package again.** A stuck install cannot deliver its own fix — the fix
308
345
  lives in the revision `update` has to fetch — so `npm i -g @tech-leads-club/harness-toolkit@latest` followed by
@@ -352,7 +389,8 @@ graph LR
352
389
  | Project shim (per provider) | `<repo>/.cursor/hooks.json`, `<repo>/.claude/settings.json` |
353
390
 
354
391
  Entrypoint: `bin/tlc-exec.mjs`.
355
- Wrappers: `bin/tlc`, `bin/tlc-exec` (Unix); `bin/tlc.cmd`, `bin/tlc-exec.cmd` (Windows).
392
+ Wrappers: `bin/tlc` and `bin/tlc.cmd`, kept for installs made before the package existed. Everything else
393
+ spawns `bin/tlc-exec.mjs` with the interpreter already running.
356
394
 
357
395
  See `tlc harness help architecture` or [`docs/architecture.md`](docs/architecture.md).
358
396
 
@@ -373,7 +411,7 @@ See `tlc harness help architecture` or [`docs/architecture.md`](docs/architectur
373
411
  | `tlc harness attest` | Tamper-evident record of what each session ran under |
374
412
  | `tlc harness handoff` | Handoff state between turns and sessions — the sanctioned reader |
375
413
  | `tlc harness obs live` / `obs report` | Signal / session rollup |
376
- | `tlc harness prices refresh` / `lookup` | Cost catalogs |
414
+ | `tlc harness prices refresh` / `lookup` | The machine's price catalogue |
377
415
  | `tlc harness lessons list` | Lessons across the three tiers, with staleness and effectiveness |
378
416
  | `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
417
  | `tlc harness policy` / `policy accept <path>` | List a mid-session policy change; accept exactly those paths |
@@ -427,26 +465,19 @@ HARNESS_PLAN_DEVIATION: src/x.ts — the call site moved with the type
427
465
  Both are protocol lines on purpose. A gate that fires on free-English "done" fires on the word, not the
428
466
  claim.
429
467
 
430
- ## Price catalogs
468
+ ## Prices
431
469
 
432
470
  ```bash
433
471
  tlc harness prices refresh
434
472
  tlc harness prices refresh cursor
435
- tlc harness prices refresh litellm
436
473
  tlc harness prices lookup <model-id> [provider]
437
474
  ```
438
475
 
439
- See `tlc harness help prices` or [`docs/measure.md`](docs/measure.md).
440
-
441
- ## Windows
442
-
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)).
476
+ Catalogue: `~/.tlc/harness/model-prices.json`, one plane per billing origin. Overrides:
477
+ `model-prices.local.json`. Neither is versioned. `install` fetches once, `update` refreshes past a 7-day TTL,
478
+ `doctor` reports the age.
446
479
 
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.
480
+ See `tlc harness help prices` or [`docs/measure.md`](docs/measure.md).
450
481
 
451
482
  ## Troubleshooting
452
483
 
@@ -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
  }
@@ -2,7 +2,7 @@
2
2
  "version": 1,
3
3
  "mode": "solo",
4
4
  "subagents": {
5
- "enforceAllowlist": true,
5
+ "enforceAllowlist": false,
6
6
  "requireModel": true,
7
7
  "blockMode": "deny",
8
8
  "blockParentFast": false,