@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.
- package/README.md +22 -26
- package/bin/tlc-build.mjs +93 -0
- package/bin/tlc-cli.ts +117 -58
- package/bin/tlc-exec.mjs +16 -13
- package/dist/compact-before.mjs +151 -15
- package/dist/doctor.mjs +288 -45
- package/dist/help-topic.mjs +0 -0
- package/dist/init-project.mjs +15 -14
- package/dist/install-runtime.mjs +100 -17
- package/dist/lessons-cli.mjs +152 -8
- package/dist/obs-cli.mjs +149 -8
- package/dist/price-lookup.mjs +45 -23
- package/dist/prompt-submit.mjs +151 -15
- package/dist/refresh-model-prices.mjs +7190 -46
- package/dist/response-after.mjs +151 -15
- package/dist/run.mjs +151 -15
- package/dist/session-end.mjs +151 -15
- package/dist/session-start.mjs +151 -15
- package/dist/shim.mjs +7040 -16
- package/dist/stop.mjs +151 -15
- package/dist/subagent-start.mjs +151 -15
- package/dist/subagent-stop.mjs +151 -15
- package/dist/support.mjs +149 -8
- package/dist/tlc-cli.mjs +289 -89
- package/dist/tool-after.mjs +196 -38
- package/dist/tool-before.mjs +151 -15
- package/dist/tool-failure.mjs +151 -15
- package/dist/uninstall-runtime.mjs +9 -10
- package/docs/log.md +7 -0
- package/docs/measure.md +35 -31
- package/package.json +6 -5
- package/src/core/core.facade.ts +18 -0
- package/src/core/index.ts +3 -0
- package/src/core/pricing/pricing.freshness.ts +118 -0
- package/src/core/release/release.version.ts +147 -0
- package/src/core/shim/shim.precedence.ts +72 -0
- package/src/core/skill/skill.link.ts +92 -0
- package/src/entrypoints/shim.ts +49 -11
- package/src/platform/fs-atomic.ts +61 -23
- package/src/platform/links.ts +73 -0
- package/src/platform/paths.ts +27 -0
- package/src/platform/pricing.ts +139 -31
- package/src/providers/cursor/cursor.wiring.ts +11 -8
- package/tools/doctor.ts +127 -10
- package/tools/init-project.ts +34 -14
- package/tools/install-runtime.ts +89 -6
- package/tools/refresh-model-prices.ts +242 -75
- package/tools/uninstall-runtime.ts +23 -19
- package/CHANGELOG.md +0 -95
- package/bin/tlc-build +0 -80
- package/bin/tlc-exec +0 -10
- package/bin/tlc-exec.cmd +0 -4
- package/docs/decisions/ad-001.md +0 -32
- package/docs/decisions/ad-002.md +0 -51
- package/docs/decisions/ad-003.md +0 -30
- package/docs/decisions/ad-004.md +0 -37
- package/docs/decisions/ad-005.md +0 -36
- package/docs/decisions/ad-006.md +0 -49
- package/docs/decisions/ad-007.md +0 -36
- package/docs/decisions/ad-008.md +0 -54
- package/docs/decisions/ad-009.md +0 -61
- package/docs/decisions/ad-010.md +0 -45
- package/docs/decisions/ad-011.md +0 -59
- package/docs/decisions/ad-012.md +0 -71
- package/docs/decisions/ad-013.md +0 -87
- package/docs/decisions/ad-014.md +0 -56
- package/docs/decisions/ad-015.md +0 -33
- package/docs/decisions/ad-016.md +0 -98
- package/docs/decisions/ad-017.md +0 -65
- package/docs/decisions/ad-018.md +0 -77
- package/docs/decisions/ad-019.md +0 -75
- package/docs/decisions/ad-020.md +0 -88
- package/docs/decisions/ad-021.md +0 -57
- package/docs/decisions/ad-022.md +0 -120
- package/docs/decisions/ad-023.md +0 -87
- package/docs/decisions/ad-024.md +0 -85
- package/docs/decisions/ad-025.md +0 -92
- package/docs/decisions/ad-026.md +0 -131
- package/docs/decisions/ad-027.md +0 -90
- package/docs/decisions/ad-028.md +0 -90
- package/docs/decisions/ad-029.md +0 -73
- package/docs/decisions/ad-030.md +0 -97
- package/docs/decisions/ad-031.md +0 -94
- package/docs/decisions/ad-032.md +0 -83
- package/docs/decisions/ad-033.md +0 -89
- package/docs/decisions/ad-034.md +0 -86
- package/docs/decisions/ad-035.md +0 -86
- package/docs/decisions/ad-036.md +0 -68
- package/docs/decisions/ad-037.md +0 -47
- package/docs/decisions/ad-038.md +0 -52
- package/docs/decisions/ad-039.md +0 -69
- package/docs/decisions/ad-040.md +0 -89
- package/docs/decisions/ad-041.md +0 -98
- package/docs/decisions/ad-042.md +0 -82
- package/docs/decisions/ad-043.md +0 -79
- package/docs/decisions/ad-044.md +0 -61
- package/docs/decisions/ad-045.md +0 -94
- package/docs/decisions/ad-046.md +0 -111
- package/docs/decisions/ad-047.md +0 -96
- package/docs/decisions/ad-048.md +0 -85
- package/docs/decisions/ad-049.md +0 -66
- package/docs/decisions/ad-050.md +0 -94
- package/docs/decisions/ad-051.md +0 -69
- package/docs/decisions/ad-052.md +0 -69
- package/docs/decisions/ad-053.md +0 -78
- package/docs/decisions/ad-054.md +0 -98
- package/docs/decisions/ad-055.md +0 -74
- package/docs/decisions/ad-056.md +0 -85
- package/docs/decisions/ad-057.md +0 -68
- package/docs/decisions/ad-058.md +0 -97
- package/docs/decisions/ad-059.md +0 -82
- package/docs/decisions/ad-060.md +0 -75
- package/docs/decisions/ad-061.md +0 -68
- package/docs/decisions/ad-062.md +0 -72
- package/docs/decisions/ad-063.md +0 -84
- package/docs/decisions/ad-064.md +0 -79
- package/docs/decisions/ad-065.md +0 -81
- package/docs/decisions/ad-066.md +0 -111
- package/docs/decisions/ad-067.md +0 -64
- package/docs/decisions/ad-068.md +0 -79
- package/docs/decisions/ad-069.md +0 -74
- package/docs/decisions/ad-070.md +0 -86
- package/docs/decisions/ad-071.md +0 -93
- package/docs/decisions/ad-072.md +0 -82
- package/docs/decisions/ad-073.md +0 -102
- package/docs/decisions/ad-074.md +0 -91
- package/docs/decisions/ad-075.md +0 -79
- package/docs/decisions/ad-076.md +0 -102
- package/docs/decisions/ad-077.md +0 -94
- package/docs/decisions/ad-078.md +0 -84
- package/docs/decisions/ad-079.md +0 -73
- package/docs/decisions/ad-080.md +0 -86
- package/docs/decisions/ad-081.md +0 -70
- package/docs/decisions/ad-082.md +0 -79
- package/docs/decisions/ad-083.md +0 -88
- package/docs/decisions/index.md +0 -111
- package/model-aliases.json +0 -12
- package/model-prices.cursor.json +0 -410
- package/model-prices.json +0 -1
- package/tools/test-env.mjs +0 -28
- package/tools/test-env.names.d.mts +0 -1
- package/tools/test-env.names.mjs +0 -14
package/dist/tool-failure.mjs
CHANGED
|
@@ -276,20 +276,27 @@ function readJson(path) {
|
|
|
276
276
|
return null;
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
|
-
|
|
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 <
|
|
290
|
+
for (let attempt = 0;attempt < lockAttempts; attempt++) {
|
|
284
291
|
try {
|
|
285
|
-
|
|
292
|
+
openLock(lockPath);
|
|
286
293
|
acquired = true;
|
|
287
294
|
break;
|
|
288
295
|
} catch (error) {
|
|
289
|
-
if (
|
|
296
|
+
if (!isContention(error)) {
|
|
290
297
|
throw error;
|
|
291
298
|
}
|
|
292
|
-
await
|
|
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
|
|
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
|
|
993
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
tlc harness prices refresh
|
|
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
|
-
|
|
|
126
|
+
| Trigger | Effect |
|
|
128
127
|
|---------|--------|
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
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
|
-
###
|
|
132
|
+
### Files
|
|
135
133
|
|
|
136
134
|
| File | Role | In git |
|
|
137
135
|
|------|------|--------|
|
|
138
|
-
|
|
|
139
|
-
|
|
|
140
|
-
|
|
141
|
-
|
|
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`
|
|
146
|
-
2. `
|
|
147
|
-
3. `
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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.
|
|
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
|
-
"
|
|
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": {
|
package/src/core/core.facade.ts
CHANGED
|
@@ -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
|
+
}
|