@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
@@ -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,
@@ -6958,6 +7021,11 @@ var coreFacade = {
6958
7021
  // src/platform/pricing.ts
6959
7022
  import { existsSync as existsSync25, readFileSync as readFileSync27 } from "node:fs";
6960
7023
  import { join as join26 } from "node:path";
7024
+ var FALLBACK_PLANE = "litellm";
7025
+ var MODEL_ALIASES = {
7026
+ "cursor-grok-4.5": "grok-4.5",
7027
+ auto: "auto-cost"
7028
+ };
6961
7029
  var VENDOR_TO_NEUTRAL_POOL = {
6962
7030
  cursor_models: "provider_native",
6963
7031
  anthropic_models: "provider_native",
@@ -6968,16 +7036,6 @@ var VENDOR_TO_NEUTRAL_POOL = {
6968
7036
  function mapPoolToNeutral(pool) {
6969
7037
  return VENDOR_TO_NEUTRAL_POOL[pool];
6970
7038
  }
6971
- function readJsonFile2(path) {
6972
- if (!existsSync25(path)) {
6973
- return null;
6974
- }
6975
- try {
6976
- return JSON.parse(readFileSync27(path, "utf8"));
6977
- } catch {
6978
- return null;
6979
- }
6980
- }
6981
7039
  function stripMeta(table) {
6982
7040
  if (!table) {
6983
7041
  return {};
@@ -6986,7 +7044,7 @@ function stripMeta(table) {
6986
7044
  return rest;
6987
7045
  }
6988
7046
  function slugifyModelName(name) {
6989
- return name.trim().toLowerCase().replace(/[[\]]/g, "").replace(/\(.*?\)/g, "").replace(/[^a-z0-9.+]+/g, "-").replace(/^-+|-+$/g, "");
7047
+ return name.trim().toLowerCase().replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[[\]]/g, "").replace(/[^a-z0-9.+]+/g, "-").replace(/^-+|-+$/g, "");
6990
7048
  }
6991
7049
  function candidatesFor(model, aliases) {
6992
7050
  const trimmed = model.trim();
@@ -7023,28 +7081,55 @@ function fuzzyFind(table, needle) {
7023
7081
  }
7024
7082
  return;
7025
7083
  }
7026
- function overridesPath() {
7084
+ function cataloguePath() {
7027
7085
  return join26(runtimeHome(), "model-prices.json");
7028
7086
  }
7029
- function providerNativePath(provider) {
7030
- return join26(runtimeHome(), `model-prices.${provider}.json`);
7087
+ function overridesPath() {
7088
+ return join26(runtimeHome(), "model-prices.local.json");
7089
+ }
7090
+ var cache = new Map;
7091
+ function readCatalogue(path) {
7092
+ if (!existsSync25(path)) {
7093
+ cache.delete(path);
7094
+ return null;
7095
+ }
7096
+ let text;
7097
+ try {
7098
+ text = readFileSync27(path, "utf8");
7099
+ } catch {
7100
+ cache.delete(path);
7101
+ return null;
7102
+ }
7103
+ const hit = cache.get(path);
7104
+ if (hit && hit.text === text) {
7105
+ return hit.value;
7106
+ }
7107
+ let parsed;
7108
+ try {
7109
+ parsed = JSON.parse(text);
7110
+ } catch {
7111
+ cache.delete(path);
7112
+ return null;
7113
+ }
7114
+ cache.set(path, { text, value: parsed });
7115
+ return parsed;
7031
7116
  }
7032
- function litellmPath() {
7033
- return join26(runtimeHome(), "model-prices.litellm.json");
7117
+ function loadCatalogue() {
7118
+ return readCatalogue(cataloguePath()) ?? {};
7034
7119
  }
7035
- function aliasesPath() {
7036
- return join26(runtimeHome(), "model-aliases.json");
7120
+ function planeFor(catalogue, plane) {
7121
+ return stripMeta(catalogue.planes?.[plane] ?? null);
7037
7122
  }
7038
7123
  function resolveModelPrice(provider, model) {
7039
7124
  const trimmed = model.trim();
7040
7125
  if (!trimmed) {
7041
7126
  return;
7042
7127
  }
7043
- const overrides = stripMeta(readJsonFile2(overridesPath()));
7044
- const native = stripMeta(readJsonFile2(providerNativePath(provider)));
7045
- const litellm = stripMeta(readJsonFile2(litellmPath()));
7046
- const aliases = readJsonFile2(aliasesPath()) ?? {};
7047
- const candidates = candidatesFor(trimmed, aliases);
7128
+ const catalogue = loadCatalogue();
7129
+ const overrides = stripMeta(readCatalogue(overridesPath()));
7130
+ const native = provider ? planeFor(catalogue, provider) : {};
7131
+ const litellm = planeFor(catalogue, FALLBACK_PLANE);
7132
+ const candidates = candidatesFor(trimmed, MODEL_ALIASES);
7048
7133
  for (const id of candidates) {
7049
7134
  const entry = overrides[id];
7050
7135
  if (entry) {
@@ -7909,14 +7994,9 @@ var ENTRY_SPECS2 = [
7909
7994
  { hookEvent: "stop", handler: "stop", timeoutSeconds: 120, loopLimit: 5 },
7910
7995
  { hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" }
7911
7996
  ];
7912
- function commandFor(runtime) {
7913
- if (process.platform === "win32") {
7914
- return { command: "cmd", argsPrefix: ["/c", "node", runtime.launcherPath] };
7915
- }
7916
- return { command: "node", argsPrefix: [runtime.launcherPath] };
7917
- }
7918
7997
  function cursorWiring(runtime) {
7919
- const { command, argsPrefix } = commandFor(runtime);
7998
+ const command = "node";
7999
+ const argsPrefix = [runtime.launcherPath];
7920
8000
  const entries = ENTRY_SPECS2.map((spec) => ({
7921
8001
  hookEvent: spec.hookEvent,
7922
8002
  handler: spec.handler,
@@ -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,
@@ -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,
@@ -979,9 +979,6 @@ var RUNTIME_PAYLOAD = [
979
979
  "src",
980
980
  "tools",
981
981
  "config.example.json",
982
- "model-aliases.json",
983
- "model-prices.cursor.json",
984
- "model-prices.json",
985
982
  "package.json"
986
983
  ];
987
984
  var OPERATOR_OWNED = ["config.json", "state", "flags"];
@@ -989,18 +986,18 @@ var NOT_SHIPPED = [join2("tools", "dev"), join2("tools", "__test__")];
989
986
  if (false) {}
990
987
 
991
988
  // tools/uninstall-runtime.ts
992
- function uninstallTargets(env = process.env, platform = process.platform) {
993
- const windows = platform === "win32";
994
- const userHome = (windows ? env.USERPROFILE : env.HOME)?.trim() || homedir2();
989
+ function uninstallTargets(env = process.env) {
990
+ const userHome = homedir2();
995
991
  const binDir = env.TLC_BIN_DIR?.trim() || join3(userHome, ".local", "bin");
996
992
  return {
997
993
  home: runtimeHome(env),
998
- binLink: join3(binDir, windows ? "tlc.cmd" : "tlc"),
994
+ binLinks: [join3(binDir, "tlc"), join3(binDir, "tlc.cmd")],
999
995
  claudeSettings: join3(claudeConfigDir(), "settings.json"),
1000
996
  cursorHooks: join3(cursorConfigDir(), "hooks.json"),
1001
- skillLinks: windows ? [join3(userHome, ".tlc", "skills", "harness-init")] : [
997
+ skillLinks: [
1002
998
  join3(claudeConfigDir(), "skills", "harness-init"),
1003
- join3(cursorConfigDir(), "skills", "harness-init")
999
+ join3(cursorConfigDir(), "skills", "harness-init"),
1000
+ join3(userHome, ".tlc", "skills", "harness-init")
1004
1001
  ]
1005
1002
  };
1006
1003
  }
@@ -1182,7 +1179,9 @@ function planUninstall(targets, options = {}) {
1182
1179
  for (const link of targets.skillLinks) {
1183
1180
  planLink(items, link, targets.home, "skill link", "location");
1184
1181
  }
1185
- planLink(items, targets.binLink, targets.home, "the tlc launcher on PATH", "target");
1182
+ for (const link of targets.binLinks) {
1183
+ planLink(items, link, targets.home, "the tlc launcher on PATH", "target");
1184
+ }
1186
1185
  const homeIsLink = planRuntime(items, targets.home, purge);
1187
1186
  planManual(items, targets.home);
1188
1187
  return { items, purge, homeIsLink };
package/docs/log.md CHANGED
@@ -23,6 +23,8 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
23
23
  - **AD-086** — The write lock read the wrong error code on Windows, in a module that already listed the right ones ([/decisions/ad-086.md](/decisions/ad-086.md))
24
24
  - **AD-087** — How the release works, and the six wrong shapes it took first ([/decisions/ad-087.md](/decisions/ad-087.md))
25
25
  - **AD-095** — Four defects about where things are written, and one of them made every hook run twice ([/decisions/ad-095.md](/decisions/ad-095.md))
26
+ - **AD-096** — Prices are the machine's, in one file, and the parser that fills it was wrong twice ([/decisions/ad-096.md](/decisions/ad-096.md))
27
+ - **AD-097** — The shell layer goes, and with it every platform branch that only existed because of it ([/decisions/ad-097.md](/decisions/ad-097.md))
26
28
 
27
29
  ## 2026-08-17
28
30
 
package/docs/measure.md CHANGED
@@ -114,55 +114,59 @@ full mapping table.
114
114
 
115
115
  ## Prices
116
116
 
117
- Cost estimates use on-disk catalogs under `~/.tlc/harness/`, resolved provider-first:
117
+ Fetched per machine, never versioned ([/decisions/ad-096.md](/decisions/ad-096.md)).
118
118
 
119
119
  ```bash
120
- tlc harness prices refresh
121
- tlc harness prices refresh all
122
- tlc harness prices refresh cursor
123
- tlc harness prices refresh litellm
120
+ tlc harness prices refresh # both planes, now
121
+ tlc harness prices refresh cursor # one plane
122
+ tlc harness prices refresh --if-stale # only past the TTL; what install and update run
124
123
  tlc harness prices lookup <model-id> [provider]
125
124
  ```
126
125
 
127
- | Command | Effect |
126
+ | Trigger | Effect |
128
127
  |---------|--------|
129
- | `refresh` / `refresh all` | Update the Cursor catalog and the LiteLLM fallback |
130
- | `refresh cursor` | Write `model-prices.cursor.json` (commit when rates change) |
131
- | `refresh litellm` | Write `model-prices.litellm.json` (gitignored; regenerate locally) |
132
- | `lookup <model-id> [provider]` | Resolve catalog key, pool, and USD for 1M input + 1M output |
128
+ | `tlc harness install` | first fetch; a network failure does not fail the install |
129
+ | `tlc harness update` | `--if-stale`, TTL 7 days |
130
+ | `tlc harness doctor` | reports the catalogue's age, or that it is absent |
133
131
 
134
- ### Catalogs
132
+ ### Files
135
133
 
136
134
  | File | Role | In git |
137
135
  |------|------|--------|
138
- | `model-prices.<provider>.json` (e.g. `model-prices.cursor.json`) | Primary, per provider | Yes |
139
- | `model-prices.litellm.json` | Fallback (LiteLLM public JSON) | No |
140
- | `model-prices.json` | Local overrides | Empty `{}` template only |
141
- | `model-aliases.json` | Model id → catalog key | Yes |
136
+ | `~/.tlc/harness/model-prices.json` | the catalogue | No |
137
+ | `~/.tlc/harness/model-prices.local.json` | hand-written overrides | No |
138
+
139
+ ### Planes
140
+
141
+ `planes` is keyed by who bills the call. They are not merged: the same model has one rate from its vendor and
142
+ another from a provider reselling it.
143
+
144
+ | Plane | Holds | Source |
145
+ |-------|-------|--------|
146
+ | `cursor` | what that provider charges | its pricing page |
147
+ | `litellm` | vendor list prices | the LiteLLM public JSON |
148
+
149
+ `_meta.planes[<plane>]` records the source, the model count and the fetch time.
142
150
 
143
151
  ### Resolution order
144
152
 
145
- 1. `model-prices.json` (local overrides)
146
- 2. `model-prices.<provider>.json` (this provider's own catalog)
147
- 3. `model-prices.litellm.json`
153
+ 1. `model-prices.local.json`
154
+ 2. `planes[<asking provider>]`
155
+ 3. `planes.litellm`
148
156
  4. otherwise `cost_usd: null`
149
157
 
150
- Pools (neutral names in observability records; see [/decisions/ad-011.md](/decisions/ad-011.md) item 2):
151
- `provider_native` | `other` | `auto` | `unknown`. The on-disk catalog files still use vendor-named pool
152
- keys internally (`cursor_models`, `anthropic_models`, …) since pricing must name real vendors — those are
153
- mapped to the neutral names before they reach `core/`.
158
+ Host ids that differ from a catalogue key are mapped in `MODEL_ALIASES` (`src/platform/pricing.ts`). Add your own
159
+ by writing the key into the overrides file.
154
160
 
155
- ### When to refresh
161
+ Pools (neutral names in observability records; see [/decisions/ad-011.md](/decisions/ad-011.md) item 2):
162
+ `provider_native` | `other` | `auto` | `unknown`. The catalogue uses vendor-named pool keys internally
163
+ (`cursor_models`, `anthropic_models`, …) since pricing must name real vendors — those are mapped to the neutral
164
+ names before they reach `core/`.
156
165
 
157
- | Situation | Command |
158
- |-----------|---------|
159
- | A provider published new rates or models | `tlc harness prices refresh cursor` (then commit) |
160
- | Missing LiteLLM file or obscure model | `tlc harness prices refresh litellm` |
161
- | Update both catalogs | `tlc harness prices refresh` |
162
- | Inspect one model | `tlc harness prices lookup <model-id> [provider]` |
166
+ ### Refusal
163
167
 
164
- `tlc harness doctor` requires at least one provider catalog to be present. LiteLLM is optional until needed
165
- as fallback.
168
+ A plane is replaced only if the incoming table keeps at least half of what is on disk. Below that the refresh
169
+ refuses, names both counts, and leaves every plane untouched.
166
170
 
167
171
  ## Project state files
168
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -19,6 +19,9 @@
19
19
  "tlc": "bin/tlc.mjs",
20
20
  "tlc-exec": "bin/tlc-exec.mjs"
21
21
  },
22
+ "scripts": {
23
+ "prepack": "node bin/tlc-build.mjs"
24
+ },
22
25
  "files": [
23
26
  "bin/",
24
27
  "capabilities/",
@@ -33,9 +36,6 @@
33
36
  "!tools/test-env*",
34
37
  "!docs/decisions",
35
38
  "config.example.json",
36
- "model-aliases.json",
37
- "model-prices.cursor.json",
38
- "model-prices.json",
39
39
  "NOTICE"
40
40
  ],
41
41
  "publishConfig": {
@@ -130,6 +130,7 @@ import { isOperatorMode, OPERATOR_MODES } from "./policy/policy.posture.ts";
130
130
  import { activeRails } from "./policy/policy.rails.ts";
131
131
  import { forProvider } from "./policy/policy.types.ts";
132
132
  import { checkCollision, heartbeat, register, release, sweepStale } from "./presence/presence.service.ts";
133
+ import { freshness, freshnessMessage, mayReplace, shouldRefetch } from "./pricing/pricing.freshness.ts";
133
134
  import {
134
135
  allDecisionFiles,
135
136
  formatDecisionDigest,
@@ -277,6 +278,12 @@ export const coreFacade = {
277
278
  coversHandler,
278
279
  decideShim,
279
280
  },
281
+ pricing: {
282
+ freshness,
283
+ freshnessMessage,
284
+ mayReplace,
285
+ shouldRefetch,
286
+ },
280
287
  skill: {
281
288
  linkHealth,
282
289
  linkHealthMessage,
package/src/core/index.ts CHANGED
@@ -44,6 +44,7 @@ export type {
44
44
  ProviderScoped,
45
45
  } from "./policy/policy.types.ts";
46
46
  export type { PresenceRecord } from "./presence/presence.types.ts";
47
+ export type { CatalogueMeta, Freshness } from "./pricing/pricing.freshness.ts";
47
48
  export type { ShellEffectClass } from "./shell-policy/shell-policy.types.ts";
48
49
  export type { ProviderSettings } from "./shim/shim.precedence.ts";
49
50
  export type { ShipClaim, ShipClaimKind, ShipLedgerEvent, ShipLedgerRow } from "./ship/ship.types.ts";