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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +22 -26
  2. package/bin/tlc-build.mjs +93 -0
  3. package/bin/tlc-cli.ts +117 -58
  4. package/bin/tlc-exec.mjs +16 -13
  5. package/dist/compact-before.mjs +151 -15
  6. package/dist/doctor.mjs +288 -45
  7. package/dist/help-topic.mjs +0 -0
  8. package/dist/init-project.mjs +15 -14
  9. package/dist/install-runtime.mjs +100 -17
  10. package/dist/lessons-cli.mjs +152 -8
  11. package/dist/obs-cli.mjs +149 -8
  12. package/dist/price-lookup.mjs +45 -23
  13. package/dist/prompt-submit.mjs +151 -15
  14. package/dist/refresh-model-prices.mjs +7190 -46
  15. package/dist/response-after.mjs +151 -15
  16. package/dist/run.mjs +151 -15
  17. package/dist/session-end.mjs +151 -15
  18. package/dist/session-start.mjs +151 -15
  19. package/dist/shim.mjs +7040 -16
  20. package/dist/stop.mjs +151 -15
  21. package/dist/subagent-start.mjs +151 -15
  22. package/dist/subagent-stop.mjs +151 -15
  23. package/dist/support.mjs +149 -8
  24. package/dist/tlc-cli.mjs +289 -89
  25. package/dist/tool-after.mjs +196 -38
  26. package/dist/tool-before.mjs +151 -15
  27. package/dist/tool-failure.mjs +151 -15
  28. package/dist/uninstall-runtime.mjs +9 -10
  29. package/docs/log.md +7 -0
  30. package/docs/measure.md +35 -31
  31. package/package.json +6 -5
  32. package/src/core/core.facade.ts +18 -0
  33. package/src/core/index.ts +3 -0
  34. package/src/core/pricing/pricing.freshness.ts +118 -0
  35. package/src/core/release/release.version.ts +147 -0
  36. package/src/core/shim/shim.precedence.ts +72 -0
  37. package/src/core/skill/skill.link.ts +92 -0
  38. package/src/entrypoints/shim.ts +49 -11
  39. package/src/platform/fs-atomic.ts +61 -23
  40. package/src/platform/links.ts +73 -0
  41. package/src/platform/paths.ts +27 -0
  42. package/src/platform/pricing.ts +139 -31
  43. package/src/providers/cursor/cursor.wiring.ts +11 -8
  44. package/tools/doctor.ts +127 -10
  45. package/tools/init-project.ts +34 -14
  46. package/tools/install-runtime.ts +89 -6
  47. package/tools/refresh-model-prices.ts +242 -75
  48. package/tools/uninstall-runtime.ts +23 -19
  49. package/CHANGELOG.md +0 -95
  50. package/bin/tlc-build +0 -80
  51. package/bin/tlc-exec +0 -10
  52. package/bin/tlc-exec.cmd +0 -4
  53. package/docs/decisions/ad-001.md +0 -32
  54. package/docs/decisions/ad-002.md +0 -51
  55. package/docs/decisions/ad-003.md +0 -30
  56. package/docs/decisions/ad-004.md +0 -37
  57. package/docs/decisions/ad-005.md +0 -36
  58. package/docs/decisions/ad-006.md +0 -49
  59. package/docs/decisions/ad-007.md +0 -36
  60. package/docs/decisions/ad-008.md +0 -54
  61. package/docs/decisions/ad-009.md +0 -61
  62. package/docs/decisions/ad-010.md +0 -45
  63. package/docs/decisions/ad-011.md +0 -59
  64. package/docs/decisions/ad-012.md +0 -71
  65. package/docs/decisions/ad-013.md +0 -87
  66. package/docs/decisions/ad-014.md +0 -56
  67. package/docs/decisions/ad-015.md +0 -33
  68. package/docs/decisions/ad-016.md +0 -98
  69. package/docs/decisions/ad-017.md +0 -65
  70. package/docs/decisions/ad-018.md +0 -77
  71. package/docs/decisions/ad-019.md +0 -75
  72. package/docs/decisions/ad-020.md +0 -88
  73. package/docs/decisions/ad-021.md +0 -57
  74. package/docs/decisions/ad-022.md +0 -120
  75. package/docs/decisions/ad-023.md +0 -87
  76. package/docs/decisions/ad-024.md +0 -85
  77. package/docs/decisions/ad-025.md +0 -92
  78. package/docs/decisions/ad-026.md +0 -131
  79. package/docs/decisions/ad-027.md +0 -90
  80. package/docs/decisions/ad-028.md +0 -90
  81. package/docs/decisions/ad-029.md +0 -73
  82. package/docs/decisions/ad-030.md +0 -97
  83. package/docs/decisions/ad-031.md +0 -94
  84. package/docs/decisions/ad-032.md +0 -83
  85. package/docs/decisions/ad-033.md +0 -89
  86. package/docs/decisions/ad-034.md +0 -86
  87. package/docs/decisions/ad-035.md +0 -86
  88. package/docs/decisions/ad-036.md +0 -68
  89. package/docs/decisions/ad-037.md +0 -47
  90. package/docs/decisions/ad-038.md +0 -52
  91. package/docs/decisions/ad-039.md +0 -69
  92. package/docs/decisions/ad-040.md +0 -89
  93. package/docs/decisions/ad-041.md +0 -98
  94. package/docs/decisions/ad-042.md +0 -82
  95. package/docs/decisions/ad-043.md +0 -79
  96. package/docs/decisions/ad-044.md +0 -61
  97. package/docs/decisions/ad-045.md +0 -94
  98. package/docs/decisions/ad-046.md +0 -111
  99. package/docs/decisions/ad-047.md +0 -96
  100. package/docs/decisions/ad-048.md +0 -85
  101. package/docs/decisions/ad-049.md +0 -66
  102. package/docs/decisions/ad-050.md +0 -94
  103. package/docs/decisions/ad-051.md +0 -69
  104. package/docs/decisions/ad-052.md +0 -69
  105. package/docs/decisions/ad-053.md +0 -78
  106. package/docs/decisions/ad-054.md +0 -98
  107. package/docs/decisions/ad-055.md +0 -74
  108. package/docs/decisions/ad-056.md +0 -85
  109. package/docs/decisions/ad-057.md +0 -68
  110. package/docs/decisions/ad-058.md +0 -97
  111. package/docs/decisions/ad-059.md +0 -82
  112. package/docs/decisions/ad-060.md +0 -75
  113. package/docs/decisions/ad-061.md +0 -68
  114. package/docs/decisions/ad-062.md +0 -72
  115. package/docs/decisions/ad-063.md +0 -84
  116. package/docs/decisions/ad-064.md +0 -79
  117. package/docs/decisions/ad-065.md +0 -81
  118. package/docs/decisions/ad-066.md +0 -111
  119. package/docs/decisions/ad-067.md +0 -64
  120. package/docs/decisions/ad-068.md +0 -79
  121. package/docs/decisions/ad-069.md +0 -74
  122. package/docs/decisions/ad-070.md +0 -86
  123. package/docs/decisions/ad-071.md +0 -93
  124. package/docs/decisions/ad-072.md +0 -82
  125. package/docs/decisions/ad-073.md +0 -102
  126. package/docs/decisions/ad-074.md +0 -91
  127. package/docs/decisions/ad-075.md +0 -79
  128. package/docs/decisions/ad-076.md +0 -102
  129. package/docs/decisions/ad-077.md +0 -94
  130. package/docs/decisions/ad-078.md +0 -84
  131. package/docs/decisions/ad-079.md +0 -73
  132. package/docs/decisions/ad-080.md +0 -86
  133. package/docs/decisions/ad-081.md +0 -70
  134. package/docs/decisions/ad-082.md +0 -79
  135. package/docs/decisions/ad-083.md +0 -88
  136. package/docs/decisions/index.md +0 -111
  137. package/model-aliases.json +0 -12
  138. package/model-prices.cursor.json +0 -410
  139. package/model-prices.json +0 -1
  140. package/tools/test-env.mjs +0 -28
  141. package/tools/test-env.names.d.mts +0 -1
  142. package/tools/test-env.names.mjs +0 -14
@@ -276,20 +276,27 @@ function readJson(path) {
276
276
  return null;
277
277
  }
278
278
  }
279
- async function withFileLock(lockPath, fn) {
279
+ function isContention(error) {
280
+ return errorCode(error) === "EEXIST" || isRetryableFsError(error);
281
+ }
282
+ async function withFileLock(lockPath, fn, options = {}) {
283
+ const {
284
+ openLock = (path) => closeSync(openSync(path, "wx")),
285
+ lockSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
286
+ lockAttempts = 200
287
+ } = options;
280
288
  mkdirSync2(dirname2(lockPath), { recursive: true });
281
- const attempts = 200;
282
289
  let acquired = false;
283
- for (let attempt = 0;attempt < attempts; attempt++) {
290
+ for (let attempt = 0;attempt < lockAttempts; attempt++) {
284
291
  try {
285
- closeSync(openSync(lockPath, "wx"));
292
+ openLock(lockPath);
286
293
  acquired = true;
287
294
  break;
288
295
  } catch (error) {
289
- if (errorCode(error) !== "EEXIST") {
296
+ if (!isContention(error)) {
290
297
  throw error;
291
298
  }
292
- await new Promise((resolve) => setTimeout(resolve, nextDelay({ attempt, baseMs: 10, capMs: 200 })));
299
+ await lockSleep(nextDelay({ attempt, baseMs: 10, capMs: 200 }));
293
300
  }
294
301
  }
295
302
  if (!acquired) {
@@ -304,14 +311,14 @@ async function withFileLock(lockPath, fn) {
304
311
  }
305
312
  }
306
313
  async function updateJsonAtomic(path, mutator, options) {
307
- const { lockPath, afterWrite, ...atomicOptions } = options;
314
+ const { lockPath, afterWrite, openLock, lockSleep, lockAttempts, ...atomicOptions } = options;
308
315
  return withFileLock(lockPath, async () => {
309
316
  const current = readJson(path);
310
317
  const next = mutator(current);
311
318
  await writeJsonAtomic(path, next, atomicOptions);
312
319
  afterWrite?.(path);
313
320
  return next;
314
- });
321
+ }, { openLock, lockSleep, lockAttempts });
315
322
  }
316
323
 
317
324
  // src/core/capability/capability.store.ts
@@ -5266,6 +5273,62 @@ function release(root, provider, session) {
5266
5273
  deletePresenceRecord(root, provider, session);
5267
5274
  }
5268
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
+
5269
5332
  // src/core/release/release.decisions.ts
5270
5333
  import { existsSync as existsSync17, readdirSync as readdirSync5, readFileSync as readFileSync19 } from "node:fs";
5271
5334
  import { join as join18 } from "node:path";
@@ -5569,6 +5632,69 @@ function evaluateShellCommand(args) {
5569
5632
  return { kind: "allow" };
5570
5633
  }
5571
5634
 
5635
+ // src/core/shim/shim.precedence.ts
5636
+ var LAUNCHER = "tlc-exec";
5637
+ function invocationText(entry) {
5638
+ return [entry.command ?? "", ...entry.args ?? []].join(" ");
5639
+ }
5640
+ function coversHandler(settings, handler) {
5641
+ for (const matchers of Object.values(settings.hooks ?? {})) {
5642
+ for (const matcher of matchers) {
5643
+ for (const entry of matcher.hooks ?? []) {
5644
+ const text = invocationText(entry);
5645
+ if (text.includes(LAUNCHER) && new RegExp(`(^|\\s)${handler}(\\s|$)`).test(text)) {
5646
+ return true;
5647
+ }
5648
+ }
5649
+ }
5650
+ }
5651
+ return false;
5652
+ }
5653
+ function decideShim(userSettings, handler) {
5654
+ if (userSettings === null) {
5655
+ return { run: true, reason: "no user-level settings — this shim is the only hook for this event" };
5656
+ }
5657
+ return coversHandler(userSettings, handler) ? {
5658
+ run: false,
5659
+ reason: `a user-level hook already runs ${handler} — standing down to avoid a second run`
5660
+ } : { run: true, reason: `no user-level hook runs ${handler}` };
5661
+ }
5662
+
5663
+ // src/core/skill/skill.link.ts
5664
+ var SKILL_NAME = "harness-init";
5665
+ function skillLinks(runtimeHome2, providerDirs, present) {
5666
+ const source = `${runtimeHome2}/skills/${SKILL_NAME}`;
5667
+ return providerDirs.filter((dir) => present(dir)).map((providerDir) => ({
5668
+ providerDir,
5669
+ source,
5670
+ target: `${providerDir}/skills/${SKILL_NAME}`
5671
+ }));
5672
+ }
5673
+ function linkHealth(target, runtimeHome2, probe) {
5674
+ const resolved = probe.linkTarget(target);
5675
+ if (resolved === null) {
5676
+ return { state: "absent", target };
5677
+ }
5678
+ if (!probe.exists(resolved)) {
5679
+ return { state: "dangling", target, resolved };
5680
+ }
5681
+ const resolveHome = probe.realpath ?? ((path) => path);
5682
+ const home = resolveHome(runtimeHome2).replace(/\/+$/, "");
5683
+ return resolved === home || resolved.startsWith(`${home}/`) ? { state: "ok", target, resolved } : { state: "outside-runtime", target, resolved };
5684
+ }
5685
+ function linkHealthMessage(health) {
5686
+ switch (health.state) {
5687
+ case "ok":
5688
+ return `linked → ${health.resolved}`;
5689
+ case "dangling":
5690
+ return `points at ${health.resolved}, which does not exist — re-run \`tlc harness install\``;
5691
+ case "outside-runtime":
5692
+ return `points at ${health.resolved}, outside the runtime — it will break when that path goes`;
5693
+ default:
5694
+ return "not linked — the provider cannot see the init skill";
5695
+ }
5696
+ }
5697
+
5572
5698
  // src/core/stagnation/stagnation.resolution.ts
5573
5699
  import { existsSync as existsSync20, mkdirSync as mkdirSync11, readFileSync as readFileSync22, writeFileSync as writeFileSync10 } from "node:fs";
5574
5700
  import { join as join21 } from "node:path";
@@ -6696,6 +6822,21 @@ var coreFacade = {
6696
6822
  readHandoffFile,
6697
6823
  readForeignSlices
6698
6824
  },
6825
+ shim: {
6826
+ coversHandler,
6827
+ decideShim
6828
+ },
6829
+ pricing: {
6830
+ freshness,
6831
+ freshnessMessage,
6832
+ mayReplace,
6833
+ shouldRefetch
6834
+ },
6835
+ skill: {
6836
+ linkHealth,
6837
+ linkHealthMessage,
6838
+ skillLinks
6839
+ },
6699
6840
  lesson: {
6700
6841
  projectLessonsInjectable,
6701
6842
  recordLessonFromFailure,
@@ -7641,14 +7782,9 @@ var ENTRY_SPECS2 = [
7641
7782
  { hookEvent: "stop", handler: "stop", timeoutSeconds: 120, loopLimit: 5 },
7642
7783
  { hookEvent: "afterAgentResponse", handler: "response-after", timeoutSeconds: 5, matcher: "AgentResponse" }
7643
7784
  ];
7644
- function commandFor(runtime) {
7645
- if (process.platform === "win32") {
7646
- return { command: "cmd", argsPrefix: ["/c", "node", runtime.launcherPath] };
7647
- }
7648
- return { command: "node", argsPrefix: [runtime.launcherPath] };
7649
- }
7650
7785
  function cursorWiring(runtime) {
7651
- const { command, argsPrefix } = commandFor(runtime);
7786
+ const command = "node";
7787
+ const argsPrefix = [runtime.launcherPath];
7652
7788
  const entries = ENTRY_SPECS2.map((spec) => ({
7653
7789
  hookEvent: spec.hookEvent,
7654
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
@@ -18,6 +18,13 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
18
18
  - **AD-081** — The manifest npm publishes is checked here, because the release runner was the only thing reading it ([/decisions/ad-081.md](/decisions/ad-081.md))
19
19
  - **AD-082** — npm is the documented install, and the first version cannot come from CI ([/decisions/ad-082.md](/decisions/ad-082.md))
20
20
  - **AD-083** — Going public switched the branch ruleset on, and an unattended release needs the App to bypass it ([/decisions/ad-083.md](/decisions/ad-083.md))
21
+ - **AD-084** — The rule about unpinned dependencies is applied to this repository too, and the release gate stops being a copy ([/decisions/ad-084.md](/decisions/ad-084.md))
22
+ - **AD-085** — The flake was a test measuring a file the whole machine writes to ([/decisions/ad-085.md](/decisions/ad-085.md))
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
+ - **AD-087** — How the release works, and the six wrong shapes it took first ([/decisions/ad-087.md](/decisions/ad-087.md))
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))
21
28
 
22
29
  ## 2026-08-17
23
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.1",
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/",
@@ -30,11 +33,9 @@
30
33
  "!src/**/__test__",
31
34
  "!tools/__test__",
32
35
  "!tools/dev",
33
- "CHANGELOG.md",
36
+ "!tools/test-env*",
37
+ "!docs/decisions",
34
38
  "config.example.json",
35
- "model-aliases.json",
36
- "model-prices.cursor.json",
37
- "model-prices.json",
38
39
  "NOTICE"
39
40
  ],
40
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,
@@ -140,6 +141,7 @@ import {
140
141
  import { readReleaseSeen, writeReleaseSeen } from "./release/release.seen.ts";
141
142
  import { evaluateShellCommand } from "./shell-policy/shell-policy.service.ts";
142
143
  import { clearShellStall } from "./shell-policy/shell-policy.stall.ts";
144
+ import { coversHandler, decideShim } from "./shim/shim.precedence.ts";
143
145
  import { appendShipLedger, hasRecentEvidence, newestChangeMs, readShipLedger } from "./ship/ship.ledger.ts";
144
146
  import {
145
147
  detectShipClaim,
@@ -148,6 +150,7 @@ import {
148
150
  recentShipClaimActive,
149
151
  touchesRuntime,
150
152
  } from "./ship/ship.service.ts";
153
+ import { linkHealth, linkHealthMessage, skillLinks } from "./skill/skill.link.ts";
151
154
  import {
152
155
  recordResolution,
153
156
  resolutionFor,
@@ -271,6 +274,21 @@ export const coreFacade = {
271
274
  readHandoffFile,
272
275
  readForeignSlices,
273
276
  },
277
+ shim: {
278
+ coversHandler,
279
+ decideShim,
280
+ },
281
+ pricing: {
282
+ freshness,
283
+ freshnessMessage,
284
+ mayReplace,
285
+ shouldRefetch,
286
+ },
287
+ skill: {
288
+ linkHealth,
289
+ linkHealthMessage,
290
+ skillLinks,
291
+ },
274
292
  lesson: {
275
293
  projectLessonsInjectable,
276
294
  recordLessonFromFailure,
package/src/core/index.ts CHANGED
@@ -44,8 +44,11 @@ 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";
49
+ export type { ProviderSettings } from "./shim/shim.precedence.ts";
48
50
  export type { ShipClaim, ShipClaimKind, ShipLedgerEvent, ShipLedgerRow } from "./ship/ship.types.ts";
51
+ export type { LinkHealth, SkillLink } from "./skill/skill.link.ts";
49
52
  export type { FingerprintEntry, FingerprintStore } from "./stagnation/stagnation.types.ts";
50
53
  export type { ModelParam, ParentModelSnapshot } from "./subagent-policy/subagent-policy.types.ts";
51
54
  export type { AutopilotPlan } from "./turn/turn.autopilot.ts";
@@ -0,0 +1,118 @@
1
+ /**
2
+ * How old a price catalogue is, and whether that is old enough to refetch.
3
+ *
4
+ * why: prices belong to the machine, not to a release. Shipping them in the package means a rate published today
5
+ * reaches an operator only when they update the tool, and the catalogue in the repository was 23 days stale while
6
+ * three versions went out ([/decisions/ad-096.md](/decisions/ad-096.md)).
7
+ *
8
+ * hazard: `refreshedAt` was already written into every catalogue's `_meta` by the refresh command, and read by
9
+ * nothing. No age was reported and no refetch was ever skipped or triggered by it — a metadatum recorded and never
10
+ * consulted, which is the same shape as the guard that read an environment variable nobody set.
11
+ *
12
+ * invariant: no clock of its own. `now` is a parameter, because a function that reads the wall clock cannot be
13
+ * tested against a boundary.
14
+ */
15
+
16
+ export const DEFAULT_TTL_DAYS = 7;
17
+
18
+ const MS_PER_DAY = 86_400_000;
19
+
20
+ export type CatalogueMeta = { refreshedAt?: string; source?: string };
21
+
22
+ export type Freshness =
23
+ | { state: "absent" }
24
+ | { state: "undated" }
25
+ | { state: "fresh"; ageDays: number; refreshedAt: string }
26
+ | { state: "stale"; ageDays: number; refreshedAt: string };
27
+
28
+ /**
29
+ * why: `undated` is its own answer rather than "infinitely old". A catalogue written by a version that did not
30
+ * record the date is present and usable; treating it as stale would refetch on every run, and treating it as fresh
31
+ * would never refetch. Naming it lets the caller decide once, visibly.
32
+ */
33
+ export function freshness(
34
+ meta: CatalogueMeta | null,
35
+ now: Date,
36
+ ttlDays: number = DEFAULT_TTL_DAYS,
37
+ ): Freshness {
38
+ if (meta === null) {
39
+ return { state: "absent" };
40
+ }
41
+ const stamp = meta.refreshedAt;
42
+ if (stamp === undefined || Number.isNaN(Date.parse(stamp))) {
43
+ return { state: "undated" };
44
+ }
45
+ const ageMs = now.getTime() - Date.parse(stamp);
46
+ // invariant: a stamp from the future is age zero, not a negative age. A clock skew must not read as fresh
47
+ // forever nor as stale immediately.
48
+ const ageDays = Math.max(0, ageMs / MS_PER_DAY);
49
+ return ageDays > ttlDays
50
+ ? { state: "stale", ageDays, refreshedAt: stamp }
51
+ : { state: "fresh", ageDays, refreshedAt: stamp };
52
+ }
53
+
54
+ /** invariant: `undated` refetches. Once, on the next run that can, after which it has a date like everything else. */
55
+ export function shouldRefetch(state: Freshness): boolean {
56
+ return state.state === "absent" || state.state === "undated" || state.state === "stale";
57
+ }
58
+
59
+ export function freshnessMessage(state: Freshness, catalogue: string): string {
60
+ switch (state.state) {
61
+ case "absent":
62
+ return `${catalogue}: not on this machine — run \`tlc harness prices refresh\``;
63
+ case "undated":
64
+ return `${catalogue}: present but carries no date — it will be refetched`;
65
+ case "fresh":
66
+ return `${catalogue}: ${describeAge(state.ageDays)} old`;
67
+ default:
68
+ return `${catalogue}: ${describeAge(state.ageDays)} old — run \`tlc harness prices refresh\``;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Whether a freshly parsed catalogue may replace the one on disk.
74
+ *
75
+ * hazard: the only guard was "did we parse zero entries". The upstream page grew from one table to three, the
76
+ * parser read the first and stopped, and 43 models became 3 — which is not zero, so it passed and overwrote the
77
+ * good catalogue. A refresh that silently loses nine tenths of its content is worse than a stale one, because the
78
+ * staleness is at least visible in the date ([/decisions/ad-096.md](/decisions/ad-096.md)).
79
+ *
80
+ * invariant: growing is always allowed, and a first catalogue is always allowed. Only a large drop is refused, and
81
+ * the refusal names both numbers so the operator can see whether upstream really shrank.
82
+ */
83
+ export const MIN_RETAINED_RATIO = 0.5;
84
+
85
+ export type ReplaceVerdict = { replace: boolean; reason: string };
86
+
87
+ export function mayReplace(
88
+ existingCount: number,
89
+ incomingCount: number,
90
+ minRatio: number = MIN_RETAINED_RATIO,
91
+ ): ReplaceVerdict {
92
+ if (incomingCount === 0) {
93
+ return { replace: false, reason: "parsed no entries at all — the upstream format has changed" };
94
+ }
95
+ if (existingCount === 0) {
96
+ return { replace: true, reason: `first catalogue, ${incomingCount} entries` };
97
+ }
98
+ if (incomingCount >= existingCount) {
99
+ return { replace: true, reason: `${existingCount} → ${incomingCount} entries` };
100
+ }
101
+ const retained = incomingCount / existingCount;
102
+ return retained >= minRatio
103
+ ? { replace: true, reason: `${existingCount} → ${incomingCount} entries` }
104
+ : {
105
+ replace: false,
106
+ reason: `would drop from ${existingCount} to ${incomingCount} entries, keeping the existing catalogue — the upstream format has probably changed`,
107
+ };
108
+ }
109
+
110
+ /** why: an operator reads "3 days", not "3.4179". Hours below a day, because "0 days" reads as no information. */
111
+ export function describeAge(ageDays: number): string {
112
+ if (ageDays < 1) {
113
+ const hours = Math.max(1, Math.round(ageDays * 24));
114
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
115
+ }
116
+ const days = Math.round(ageDays);
117
+ return `${days} day${days === 1 ? "" : "s"}`;
118
+ }