@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
|
@@ -1,35 +1,97 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { coreFacade } from "../src/core/index.ts";
|
|
5
|
+
import { runtimeHome } from "../src/platform/paths.ts";
|
|
6
|
+
import {
|
|
7
|
+
cataloguePath,
|
|
8
|
+
FALLBACK_PLANE,
|
|
9
|
+
loadCatalogue,
|
|
10
|
+
type ModelPriceEntry,
|
|
11
|
+
overridesPath,
|
|
12
|
+
type PriceCatalogue,
|
|
13
|
+
type PriceTable,
|
|
14
|
+
slugifyModelName,
|
|
15
|
+
} from "../src/platform/pricing.ts";
|
|
5
16
|
|
|
6
|
-
|
|
7
|
-
|
|
17
|
+
/**
|
|
18
|
+
* hazard: this was `dirname(import.meta.url)/..` — the directory the script lives in. Under an npm install that is
|
|
19
|
+
* inside the package, which npm replaces wholesale on the next update, so the refresh wrote prices into a directory
|
|
20
|
+
* that would be deleted while `pricing.ts` read `runtimeHome()` and never received them. The same reason the
|
|
21
|
+
* runtime is materialised outside the package at all ([/decisions/ad-056.md](/decisions/ad-056.md),
|
|
22
|
+
* [/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
23
|
+
*
|
|
24
|
+
* invariant: written where it is read. One resolution, `runtimeHome()`, used by both sides.
|
|
25
|
+
*/
|
|
26
|
+
const HARNESS_HOME = runtimeHome();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The plane a provider's own rates land in. It is the provider's id, because the catalogue is keyed by who bills
|
|
30
|
+
* the call and a second provider publishing its own rates is a new plane, not a new file.
|
|
31
|
+
*/
|
|
32
|
+
const PROVIDER_PLANE = "cursor";
|
|
33
|
+
const PROVIDER_DOCS_URL = "https://cursor.com/docs/models-and-pricing.md";
|
|
8
34
|
const LITELLM_URL =
|
|
9
35
|
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/model_prices_and_context_window_backup.json";
|
|
10
36
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
contextWindow?: number;
|
|
21
|
-
};
|
|
37
|
+
/**
|
|
38
|
+
* The files this replaced. Left on disk they are stale duplicates of two planes and an alias table nothing reads,
|
|
39
|
+
* and the operator has no way to tell which one a price came from ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
40
|
+
*/
|
|
41
|
+
const SUPERSEDED = [
|
|
42
|
+
`model-prices.${PROVIDER_PLANE}.json`,
|
|
43
|
+
"model-prices.litellm.json",
|
|
44
|
+
"model-aliases.json",
|
|
45
|
+
] as const;
|
|
22
46
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
/**
|
|
48
|
+
* hazard: `model-prices.json` used to be the operator's overrides table, and it is the name this now writes. An
|
|
49
|
+
* operator who had put their own rates in it would have had them replaced by the first refresh. A flat table with
|
|
50
|
+
* no `planes` key is that old file, so it is moved to where overrides are read from rather than overwritten.
|
|
51
|
+
*/
|
|
52
|
+
function adoptLegacyOverrides(quiet: boolean): void {
|
|
53
|
+
const path = cataloguePath();
|
|
54
|
+
if (!existsSync(path)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const parsed = loadCatalogue();
|
|
58
|
+
const keys = Object.keys(parsed).filter((key) => key !== "_meta");
|
|
59
|
+
if (parsed.planes !== undefined || keys.length === 0) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (existsSync(overridesPath())) {
|
|
63
|
+
console.error(
|
|
64
|
+
`refresh: ${basename(path)} holds ${keys.length} legacy entries and ${basename(overridesPath())} already exists — merge them by hand`,
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
renameSync(path, overridesPath());
|
|
69
|
+
if (!quiet) {
|
|
70
|
+
console.log(`refresh: moved ${keys.length} local overrides → ${overridesPath()}`);
|
|
71
|
+
}
|
|
31
72
|
}
|
|
32
73
|
|
|
74
|
+
/** invariant: only the exact names this replaced, only inside the runtime home. Nothing else is removed. */
|
|
75
|
+
function retireSupersededFiles(quiet: boolean): void {
|
|
76
|
+
for (const name of SUPERSEDED) {
|
|
77
|
+
const path = join(HARNESS_HOME, name);
|
|
78
|
+
if (!existsSync(path)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
rmSync(path, { force: true });
|
|
82
|
+
if (!quiet) {
|
|
83
|
+
console.log(`refresh: removed superseded ${name}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* invariant: the key a model is written under is computed by the same function the lookup uses. There were two
|
|
90
|
+
* copies of this and they disagreed about parentheses, which is what let a variant overwrite its base model and be
|
|
91
|
+
* read back at the wrong price ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
92
|
+
*/
|
|
93
|
+
const slugify = slugifyModelName;
|
|
94
|
+
|
|
33
95
|
function parseMoney(cell: string): number | undefined {
|
|
34
96
|
const t = cell.trim();
|
|
35
97
|
if (!t || t === "-" || t === "—" || t.toLowerCase() === "n/a") {
|
|
@@ -50,7 +112,7 @@ function stripCell(cell: string): string {
|
|
|
50
112
|
.trim();
|
|
51
113
|
}
|
|
52
114
|
|
|
53
|
-
function inferPool(displayName: string, provider: string):
|
|
115
|
+
export function inferPool(displayName: string, provider: string): ModelPriceEntry["pool"] {
|
|
54
116
|
const n = displayName.toLowerCase();
|
|
55
117
|
if (n === "auto cost" || n.startsWith("auto ")) {
|
|
56
118
|
return "auto";
|
|
@@ -61,16 +123,24 @@ function inferPool(displayName: string, provider: string): PriceEntry["pool"] {
|
|
|
61
123
|
return "other_models";
|
|
62
124
|
}
|
|
63
125
|
|
|
64
|
-
|
|
65
|
-
|
|
126
|
+
/**
|
|
127
|
+
* hazard: this used to `break` on the first line that is not a table row, so it read the FIRST table and stopped.
|
|
128
|
+
* The page now carries three: the provider's own models, then the rest. The parser went from 43 models to 3
|
|
129
|
+
* overnight and the only guard was `count === 0`, so a mutilated catalogue overwrote a good one and read as a
|
|
130
|
+
* successful refresh ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
131
|
+
*
|
|
132
|
+
* invariant: every table on the page, and the header row of each resets the column expectation rather than ending
|
|
133
|
+
* the parse.
|
|
134
|
+
*/
|
|
135
|
+
export function parseCursorDocs(md: string): PriceTable {
|
|
136
|
+
const out: PriceTable = {};
|
|
66
137
|
const lines = md.split("\n");
|
|
67
138
|
let inTable = false;
|
|
68
139
|
|
|
69
140
|
for (const line of lines) {
|
|
70
141
|
if (!line.startsWith("|")) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
142
|
+
// why: leaving a table is not the end of the document. The next one may be a few lines down.
|
|
143
|
+
inTable = false;
|
|
74
144
|
continue;
|
|
75
145
|
}
|
|
76
146
|
const cells = line
|
|
@@ -125,15 +195,15 @@ type LiteLlmEntry = {
|
|
|
125
195
|
litellm_provider?: string;
|
|
126
196
|
};
|
|
127
197
|
|
|
128
|
-
function parseLiteLlm(raw: Record<string, LiteLlmEntry>):
|
|
129
|
-
const out:
|
|
198
|
+
export function parseLiteLlm(raw: Record<string, LiteLlmEntry>): PriceTable {
|
|
199
|
+
const out: PriceTable = {};
|
|
130
200
|
for (const [id, entry] of Object.entries(raw)) {
|
|
131
201
|
if (id === "sample_spec") {
|
|
132
202
|
continue;
|
|
133
203
|
}
|
|
134
204
|
const contextWindow =
|
|
135
205
|
typeof entry.max_input_tokens === "number" ? entry.max_input_tokens : entry.max_tokens;
|
|
136
|
-
const compact:
|
|
206
|
+
const compact: ModelPriceEntry = {
|
|
137
207
|
displayName: id,
|
|
138
208
|
provider: entry.litellm_provider,
|
|
139
209
|
pool: "unknown",
|
|
@@ -163,48 +233,145 @@ function parseLiteLlm(raw: Record<string, LiteLlmEntry>): Record<string, PriceEn
|
|
|
163
233
|
return out;
|
|
164
234
|
}
|
|
165
235
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
186
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Which planes a refresh may write, and what happens when one of them comes back mutilated.
|
|
238
|
+
*
|
|
239
|
+
* invariant: a plane is replaced only if the incoming table is not a large loss against what is already there, and
|
|
240
|
+
* a refused plane leaves the others alone. One bad fetch must not take a good catalogue down with it
|
|
241
|
+
* ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
242
|
+
*/
|
|
243
|
+
export type PlaneUpdate = { plane: string; source: string; table: PriceTable };
|
|
244
|
+
export type PlaneOutcome = { plane: string; accepted: boolean; reason: string; count: number };
|
|
245
|
+
|
|
246
|
+
function planeCount(catalogue: PriceCatalogue, plane: string): number {
|
|
247
|
+
return Object.keys(catalogue.planes?.[plane] ?? {}).length;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function applyPlanes(
|
|
251
|
+
existing: PriceCatalogue,
|
|
252
|
+
updates: readonly PlaneUpdate[],
|
|
253
|
+
now: Date,
|
|
254
|
+
): { catalogue: PriceCatalogue; outcomes: PlaneOutcome[] } {
|
|
255
|
+
const planes: Record<string, PriceTable> = { ...(existing.planes ?? {}) };
|
|
256
|
+
const planeMeta = { ...(existing._meta?.planes ?? {}) };
|
|
257
|
+
const outcomes: PlaneOutcome[] = [];
|
|
258
|
+
let accepted = 0;
|
|
259
|
+
|
|
260
|
+
for (const update of updates) {
|
|
261
|
+
const count = Object.keys(update.table).length;
|
|
262
|
+
const verdict = coreFacade.pricing.mayReplace(planeCount(existing, update.plane), count);
|
|
263
|
+
outcomes.push({ plane: update.plane, accepted: verdict.replace, reason: verdict.reason, count });
|
|
264
|
+
if (!verdict.replace) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
planes[update.plane] = update.table;
|
|
268
|
+
planeMeta[update.plane] = { source: update.source, count, refreshedAt: now.toISOString() };
|
|
269
|
+
accepted += 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// invariant: the file's own date moves only when something in it actually changed, so a refused refresh stays
|
|
273
|
+
// visibly stale rather than looking fresh.
|
|
274
|
+
const refreshedAt = accepted > 0 ? now.toISOString() : existing._meta?.refreshedAt;
|
|
275
|
+
return {
|
|
276
|
+
catalogue: { _meta: { ...(refreshedAt ? { refreshedAt } : {}), planes: planeMeta }, planes },
|
|
277
|
+
outcomes,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* hazard: this file used to run its fetches at module scope, so importing it to test the parsers would have hit the
|
|
283
|
+
* network. `parseCursorDocs` and the key function were therefore untested — and both carried a defect that reached
|
|
284
|
+
* the catalogue ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
285
|
+
*/
|
|
286
|
+
async function main(): Promise<void> {
|
|
287
|
+
const mode = (process.argv[2] ?? "all").toLowerCase();
|
|
288
|
+
const ifStale = process.argv.includes("--if-stale");
|
|
289
|
+
const quiet = process.argv.includes("--quiet");
|
|
290
|
+
|
|
291
|
+
const path = cataloguePath();
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* why: `--if-stale` is what makes an automatic refresh safe to wire into `install` and `update`. Without it both
|
|
295
|
+
* would reach the network on every run; with it the common case is one file read.
|
|
296
|
+
*
|
|
297
|
+
* invariant: the freshness decision is the core's and takes the clock as a parameter, and it is per plane —
|
|
298
|
+
* the provider's page changes far more often than the vendor list.
|
|
299
|
+
*/
|
|
300
|
+
function wanted(plane: string, label: string): boolean {
|
|
301
|
+
if (!ifStale) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const meta = existing._meta?.planes?.[plane];
|
|
305
|
+
const state = coreFacade.pricing.freshness(
|
|
306
|
+
existing.planes?.[plane] === undefined ? null : (meta ?? {}),
|
|
307
|
+
new Date(),
|
|
308
|
+
);
|
|
309
|
+
if (coreFacade.pricing.shouldRefetch(state)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (!quiet) {
|
|
313
|
+
console.log(`${coreFacade.pricing.freshnessMessage(state, label)} — not refetching`);
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// why: the directory may not exist yet on a first install, and writing into a missing one is the failure this
|
|
319
|
+
// avoids rather than reports.
|
|
320
|
+
mkdirSync(HARNESS_HOME, { recursive: true });
|
|
321
|
+
// invariant: the legacy overrides move out before the catalogue is read, so nothing of the old file's shape —
|
|
322
|
+
// its entries or its date — is carried into the new one.
|
|
323
|
+
adoptLegacyOverrides(quiet);
|
|
324
|
+
const existing = loadCatalogue();
|
|
325
|
+
|
|
326
|
+
const updates: PlaneUpdate[] = [];
|
|
327
|
+
|
|
328
|
+
if ((mode === "all" || mode === PROVIDER_PLANE) && wanted(PROVIDER_PLANE, `${PROVIDER_PLANE} prices`)) {
|
|
329
|
+
const res = await fetch(PROVIDER_DOCS_URL);
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
console.error(`refresh: provider docs answered ${res.status} — keeping the catalogue as it is`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
updates.push({
|
|
335
|
+
plane: PROVIDER_PLANE,
|
|
336
|
+
source: PROVIDER_DOCS_URL,
|
|
337
|
+
table: parseCursorDocs(await res.text()),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if ((mode === "all" || mode === FALLBACK_PLANE) && wanted(FALLBACK_PLANE, `${FALLBACK_PLANE} prices`)) {
|
|
342
|
+
const res = await fetch(LITELLM_URL);
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
console.error(`refresh: ${FALLBACK_PLANE} answered ${res.status} — keeping the catalogue as it is`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
updates.push({
|
|
348
|
+
plane: FALLBACK_PLANE,
|
|
349
|
+
source: LITELLM_URL,
|
|
350
|
+
table: parseLiteLlm((await res.json()) as Record<string, LiteLlmEntry>),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (updates.length === 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const { catalogue, outcomes } = applyPlanes(existing, updates, new Date());
|
|
359
|
+
const refused = outcomes.filter((outcome) => !outcome.accepted);
|
|
360
|
+
if (outcomes.some((outcome) => outcome.accepted)) {
|
|
361
|
+
writeFileSync(path, `${JSON.stringify(catalogue)}\n`);
|
|
362
|
+
for (const outcome of outcomes.filter((o) => o.accepted)) {
|
|
363
|
+
console.log(`${outcome.plane}: ${outcome.count} models (${outcome.reason}) → ${path}`);
|
|
364
|
+
}
|
|
365
|
+
retireSupersededFiles(quiet);
|
|
366
|
+
}
|
|
367
|
+
for (const outcome of refused) {
|
|
368
|
+
console.error(`${outcome.plane}: ${outcome.reason}`);
|
|
369
|
+
}
|
|
370
|
+
if (refused.length > 0) {
|
|
371
|
+
process.exitCode = 1;
|
|
372
|
+
}
|
|
187
373
|
}
|
|
188
374
|
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
if (!res.ok) {
|
|
192
|
-
console.error(`Failed to fetch LiteLLM prices: ${res.status}`);
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
const raw = (await res.json()) as Record<string, LiteLlmEntry>;
|
|
196
|
-
const litellm = parseLiteLlm(raw);
|
|
197
|
-
const path = join(HARNESS_HOME, "model-prices.litellm.json");
|
|
198
|
-
writeFileSync(
|
|
199
|
-
path,
|
|
200
|
-
`${JSON.stringify({
|
|
201
|
-
_meta: {
|
|
202
|
-
source: LITELLM_URL,
|
|
203
|
-
refreshedAt: new Date().toISOString(),
|
|
204
|
-
count: Object.keys(litellm).length,
|
|
205
|
-
},
|
|
206
|
-
...litellm,
|
|
207
|
-
})}\n`,
|
|
208
|
-
);
|
|
209
|
-
console.log(`LiteLLM catalog: ${Object.keys(litellm).length} models → ${path}`);
|
|
375
|
+
if (import.meta.main) {
|
|
376
|
+
await main();
|
|
210
377
|
}
|
|
@@ -37,36 +37,38 @@ export type UninstallPlan = {
|
|
|
37
37
|
|
|
38
38
|
export type UninstallTargets = {
|
|
39
39
|
home: string;
|
|
40
|
-
|
|
40
|
+
binLinks: string[];
|
|
41
41
|
claudeSettings: string;
|
|
42
42
|
cursorHooks: string;
|
|
43
43
|
skillLinks: string[];
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* Windows
|
|
47
|
+
* Everything an install may have left outside the runtime directory, on any machine.
|
|
48
|
+
*
|
|
49
|
+
* hazard: this used to read one layout per platform, chosen by `process.platform` — and the two installers wrote
|
|
50
|
+
* different layouts, so reading the POSIX one on Windows found none of them and reported a clean machine
|
|
51
|
+
* ([/decisions/ad-066.md](/decisions/ad-066.md)).
|
|
52
|
+
*
|
|
53
|
+
* why every name and not a branch: an uninstall has to clean up what is *there*, which includes what an older
|
|
54
|
+
* version put there. `tlc.cmd` does not exist on Linux and the legacy `~/.tlc/skills` junction does not exist on
|
|
55
|
+
* a machine installed after it was retired — an absent path is reported as nothing, so listing them all is both
|
|
56
|
+
* simpler and more complete than deciding ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
51
57
|
*/
|
|
52
|
-
export function uninstallTargets(
|
|
53
|
-
|
|
54
|
-
platform: NodeJS.Platform = process.platform,
|
|
55
|
-
): UninstallTargets {
|
|
56
|
-
const windows = platform === "win32";
|
|
57
|
-
const userHome = (windows ? env.USERPROFILE : env.HOME)?.trim() || homedir();
|
|
58
|
+
export function uninstallTargets(env: NodeJS.ProcessEnv = process.env): UninstallTargets {
|
|
59
|
+
const userHome = homedir();
|
|
58
60
|
const binDir = env.TLC_BIN_DIR?.trim() || join(userHome, ".local", "bin");
|
|
59
61
|
return {
|
|
60
62
|
home: runtimeHome(env),
|
|
61
|
-
|
|
63
|
+
binLinks: [join(binDir, "tlc"), join(binDir, "tlc.cmd")],
|
|
62
64
|
claudeSettings: join(claudeConfigDir(), "settings.json"),
|
|
63
65
|
cursorHooks: join(cursorConfigDir(), "hooks.json"),
|
|
64
|
-
skillLinks:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
skillLinks: [
|
|
67
|
+
join(claudeConfigDir(), "skills", "harness-init"),
|
|
68
|
+
join(cursorConfigDir(), "skills", "harness-init"),
|
|
69
|
+
// the layout the PowerShell installer wrote: one junction no provider ever read
|
|
70
|
+
join(userHome, ".tlc", "skills", "harness-init"),
|
|
71
|
+
],
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -312,7 +314,9 @@ export function planUninstall(targets: UninstallTargets, options: { purge?: bool
|
|
|
312
314
|
for (const link of targets.skillLinks) {
|
|
313
315
|
planLink(items, link, targets.home, "skill link", "location");
|
|
314
316
|
}
|
|
315
|
-
|
|
317
|
+
for (const link of targets.binLinks) {
|
|
318
|
+
planLink(items, link, targets.home, "the tlc launcher on PATH", "target");
|
|
319
|
+
}
|
|
316
320
|
const homeIsLink = planRuntime(items, targets.home, purge);
|
|
317
321
|
planManual(items, targets.home);
|
|
318
322
|
|
package/CHANGELOG.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
Generated from `docs/decisions/` — do not edit by hand. Run `node tools/render-changelog.ts`.
|
|
4
|
-
|
|
5
|
-
Each entry is an architectural decision record: what changed, why, what was refused, and what it costs.
|
|
6
|
-
A **Needs your action** line is a change `tlc harness doctor` cannot detect for you; everything else
|
|
7
|
-
doctor reports against your own configuration.
|
|
8
|
-
|
|
9
|
-
## Unreleased
|
|
10
|
-
|
|
11
|
-
- **AD-001** — Optional parent-Fast sticky deny for Task spawns
|
|
12
|
-
- **AD-002** — Provider-neutral naming and layout
|
|
13
|
-
- **AD-003** — No backward compatibility
|
|
14
|
-
- **AD-004** — Ports and adapters with an anti-corruption layer per provider
|
|
15
|
-
- **AD-005** — Local test runner is the gate; CI matrix runs on every push
|
|
16
|
-
- **AD-006** — Windows ships in scope, with CI covering the suite and the build
|
|
17
|
-
- **AD-007** — Vendor check applies to core tests; absence checks do not
|
|
18
|
-
- **AD-008** — Biome + TypeScript in the gate; @types/node pinned to the declared floor
|
|
19
|
-
- **AD-009** — Event kinds are provider-agnostic; capabilities are data, not flags
|
|
20
|
-
- **AD-010** — Shared vocabulary moves to src/contracts/
|
|
21
|
-
- **AD-011** — Vendor-specific data belongs to the provider, not to core
|
|
22
|
-
- **AD-012** — Prefer Bun at runtime, keep dist/ for the Node fallback, ship no binary
|
|
23
|
-
- **AD-013** — Documentation follows the Open Knowledge Format (OKF v0.1)
|
|
24
|
-
- **AD-014** — Claude Code hook payload field paths, pinned
|
|
25
|
-
- **AD-015** — Wiring handler names are the entrypoint filenames
|
|
26
|
-
- **AD-016** — Field semantics, state writers, and the core export surface
|
|
27
|
-
- **AD-017** — The docs gate delegates to the project's tool, and the catalog is the only source of capability metadata
|
|
28
|
-
- **AD-018** — Three rails adopted from an external review, each off by default and declared rather than inferred
|
|
29
|
-
- **AD-019** — A resource is identified by what it resolves to, and a declared capability must be read where it matters
|
|
30
|
-
- **AD-020** — One resolution for the install path, one source for posture, and a config that only advertises what it reads
|
|
31
|
-
- **AD-021** — A gate command that never resolved is a config fault, and a recipe runner does not receive file arguments
|
|
32
|
-
- **AD-022** — The policy surface is a floor rule, detection sits behind interception, and the operator/agent line is structural
|
|
33
|
-
- **AD-023** — One finding per failure, and lesson relevance is recurrence rather than exposure
|
|
34
|
-
- **AD-024** — The gate tells the truth about its own environment, its own lock, and the cause of a failure
|
|
35
|
-
- **AD-025** — Posture governs surfacing only, and each posture has exactly one name
|
|
36
|
-
- **AD-026** — An interruption is worth what it costs: narrower asks, a deadline on questions, and a rate the operator can see
|
|
37
|
-
- **AD-027** — Evidence is ordered against the code, every rail's firing is recorded, and a checker can run with its rule off
|
|
38
|
-
- **Needs your action:** Re-run your verification after the last code change before citing the verdict — the ship gate now refuses evidence written before the code it certifies, so a claim that used to pass can block. Nothing in doctor can see this one; it shows up as a blocked stop.
|
|
39
|
-
- **AD-028** — A resolved failure is kept, a session attests to itself, and provider neutrality becomes a proof
|
|
40
|
-
- **AD-029** — A capability the init skill cannot correctly initialise is not shipped
|
|
41
|
-
- **AD-030** — Clearing a policy divergence is one operator command behind four independent locks, and a refusal never points an agent at a door the floor holds shut
|
|
42
|
-
- **AD-031** — The decisions are the changelog, a breaking change carries its own instruction, and looking never changes anything
|
|
43
|
-
- **AD-032** — A hook is healthy when it can run, not when a marker string is present
|
|
44
|
-
- **AD-033** — The gate says what it costs, appendFiles stops promising what it cannot deliver, and a dead capability leaves
|
|
45
|
-
- **AD-034** — A warning that fires on a healthy install is not a warning, and the author reads the operator's output before anyone else does
|
|
46
|
-
- **AD-035** — A lesson learned by reasoning can be written down, and the harness never learns where lessons come from
|
|
47
|
-
- **AD-036** — A lesson names what makes it true, and stops being injected when that is gone
|
|
48
|
-
- **AD-037** — A lesson can be true for a period, and an unparseable bound withholds it
|
|
49
|
-
- **AD-038** — Promotion counts distinct sessions, because one stuck session is one observation
|
|
50
|
-
- **AD-039** — A lesson is graded by the gate it was injected for, and unproven is not a passing reading
|
|
51
|
-
- **AD-040** — Three lesson tiers, and nothing crosses between products by itself
|
|
52
|
-
- **AD-041** — A member something reads and nothing writes fails the gate
|
|
53
|
-
- **AD-042** — The suite gets an empty runtime home, and one renderer renders a lesson
|
|
54
|
-
- **AD-043** — A standing rule is pinned, not ranked, and the budget says what it dropped
|
|
55
|
-
- **AD-044** — Only an injection a gate could grade can be unproven
|
|
56
|
-
- **AD-045** — A gate verdict is reused when the content hash of its inputs did not change
|
|
57
|
-
- **AD-046** — The runtime path is an artifact, and update never touches what it does not own
|
|
58
|
-
- **AD-047** — An instruction is not an affordance, and a refusal names the route that works
|
|
59
|
-
- **AD-048** — A broken updater cannot deliver its own fix, so the installer is the recovery route
|
|
60
|
-
- **AD-049** — An empty synced file says which of four reasons made it empty
|
|
61
|
-
- **AD-050** — Lesson transport is a provider capability, not an operator preference
|
|
62
|
-
- **AD-051** — A warning fails the gate, and a suppression states what breaks without it
|
|
63
|
-
- **AD-052** — The repository moves to the org and the runtime paths do not
|
|
64
|
-
- **AD-053** — The harness ships no model allowlist, and a list that names nothing enforces nothing
|
|
65
|
-
- **AD-054** — npm is the distribution, and a merged release PR is the only thing that publishes
|
|
66
|
-
- **AD-055** — The changelog is rendered from the decision records, and git says which release each landed in
|
|
67
|
-
- **AD-056** — The package delivers the runtime, and the runtime path stays where hooks already point
|
|
68
|
-
- **AD-057** — The release PR is a mechanism, not a gate, and the bot merges it
|
|
69
|
-
- **AD-058** — Gates diff against the turn's base, and language knowledge is one table
|
|
70
|
-
- **Needs your action:** If a project has the comment gate on, expect it to start firing on turns that commit and on languages it never covered. Nothing changes in your config.
|
|
71
|
-
- **AD-059** — A rail never writes a field it reads, and a counter reads the plane its events land on
|
|
72
|
-
- **AD-060** — The gate records the environment it ran under, and names it only once the cheap explanations are spent
|
|
73
|
-
- **AD-061** — A decision that refuses names its rule, and degrading preserves it
|
|
74
|
-
- **AD-062** — One command answers whether the harness did that, and says so plainly when it did not
|
|
75
|
-
- **AD-063** — One palette for human output, and a checker keeping it out of everything else
|
|
76
|
-
- **AD-064** — A snapshot is assigned, never accumulated, and a table does not list what it cannot count
|
|
77
|
-
- **AD-065** — The obs bus has a contract, and the gate checks both sides of it
|
|
78
|
-
- **AD-066** — Uninstall reads the artefact, and the plan is the confirmation
|
|
79
|
-
- **AD-067** — A reserved file that cannot be retired is rendered, and the gate holds it there
|
|
80
|
-
- **AD-068** — A directory decides what ships, and dist is derived from disk in both directions
|
|
81
|
-
- **AD-069** — A decision record declares its shape, and cites by link so a move cannot break it
|
|
82
|
-
- **AD-070** — A comment has to read for somebody who was not in the session
|
|
83
|
-
- **AD-071** — The turn's added lines are checked against the code the project already has
|
|
84
|
-
- **AD-072** — A record can leave the corpus, and removing is a change worth recording
|
|
85
|
-
- **AD-073** — A neighbour mid-gate is not a reason to block a turn
|
|
86
|
-
- **AD-074** — Code the gate cannot read is refused, and a credential is not always a file
|
|
87
|
-
- **AD-075** — A dependency a turn adds outlives the turn, so two mechanical failures are worth a stop
|
|
88
|
-
- **AD-076** — Goal-hijack enforcement needs the tool's output, and the host does not deliver it
|
|
89
|
-
- **AD-077** — A command that appears verbatim in fetched content is put to the operator
|
|
90
|
-
- **AD-078** — The two files the harness reads aloud are sealed on write and verified on injection
|
|
91
|
-
- **AD-079** — The coverage claim is a generated page with its gaps in it, not a badge in the README
|
|
92
|
-
- **AD-080** — Every hand-written list of our own rules is now checked, including the one that drifted while writing this
|
|
93
|
-
- **AD-081** — The manifest npm publishes is checked here, because the release runner was the only thing reading it
|
|
94
|
-
- **AD-082** — npm is the documented install, and the first version cannot come from CI
|
|
95
|
-
- **AD-083** — Going public switched the branch ruleset on, and an unattended release needs the App to bypass it
|
package/bin/tlc-build
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Build Node-runnable ESM bundles under dist/. Requires Bun OR esbuild on PATH for compile.
|
|
3
|
-
set -euo pipefail
|
|
4
|
-
|
|
5
|
-
TLC_HOME="$(cd "$(dirname "$0")/.." && pwd)"
|
|
6
|
-
DIST="$TLC_HOME/dist"
|
|
7
|
-
mkdir -p "$DIST"
|
|
8
|
-
|
|
9
|
-
# Derived from disk, never hardcoded: a fixed list silently stops building a new
|
|
10
|
-
# entrypoint, and the missing bundle only surfaces when a hook fires in production.
|
|
11
|
-
collect() {
|
|
12
|
-
local dir="$1"
|
|
13
|
-
local name
|
|
14
|
-
for path in "$dir"/*.ts; do
|
|
15
|
-
[ -e "$path" ] || continue
|
|
16
|
-
name="$(basename "$path" .ts)"
|
|
17
|
-
case "$name" in *.test) continue ;; esac
|
|
18
|
-
printf '%s\n' "$name"
|
|
19
|
-
done
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
# tools/dev/ holds the checks that validate THIS repository's own architecture, docs and conventions. They must
|
|
23
|
-
# not ship — a user's clone has no src/core to validate and no docs/decisions of ours to render — and the
|
|
24
|
-
# directory is the whole declaration: `collect` reads one level, so nothing under tools/dev is ever a bundle.
|
|
25
|
-
# The list this replaced named four while ten qualified, and the six that were added later shipped for weeks.
|
|
26
|
-
|
|
27
|
-
# macOS ships bash 3.2, which has no mapfile — read into arrays the portable way instead.
|
|
28
|
-
ENTRYPOINTS=()
|
|
29
|
-
while IFS= read -r name; do
|
|
30
|
-
[ -n "$name" ] && ENTRYPOINTS+=("$name")
|
|
31
|
-
done < <(collect "$TLC_HOME/src/entrypoints")
|
|
32
|
-
|
|
33
|
-
TOOLS=()
|
|
34
|
-
while IFS= read -r name; do
|
|
35
|
-
[ -n "$name" ] && TOOLS+=("$name")
|
|
36
|
-
done < <(collect "$TLC_HOME/tools")
|
|
37
|
-
|
|
38
|
-
build_one() {
|
|
39
|
-
local src="$1"
|
|
40
|
-
local out="$2"
|
|
41
|
-
if command -v bun >/dev/null 2>&1; then
|
|
42
|
-
bun build --target=node --format=esm --outfile="$out" "$src"
|
|
43
|
-
return
|
|
44
|
-
fi
|
|
45
|
-
if command -v esbuild >/dev/null 2>&1; then
|
|
46
|
-
esbuild --bundle --platform=node --format=esm --outfile="$out" "$src"
|
|
47
|
-
return
|
|
48
|
-
fi
|
|
49
|
-
echo "tlc-build: need Bun or esbuild to compile TypeScript → dist/" >&2
|
|
50
|
-
echo " Install Node.js 24+ (Active LTS) or 26 Current, then either Bun or: npm i -g esbuild" >&2
|
|
51
|
-
exit 1
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
echo "tlc-build → $DIST"
|
|
55
|
-
for name in "${ENTRYPOINTS[@]}"; do
|
|
56
|
-
build_one "$TLC_HOME/src/entrypoints/${name}.ts" "$DIST/${name}.mjs"
|
|
57
|
-
done
|
|
58
|
-
for name in "${TOOLS[@]}"; do
|
|
59
|
-
build_one "$TLC_HOME/tools/${name}.ts" "$DIST/${name}.mjs"
|
|
60
|
-
done
|
|
61
|
-
|
|
62
|
-
build_one "$TLC_HOME/bin/tlc-cli.ts" "$DIST/tlc-cli.mjs"
|
|
63
|
-
|
|
64
|
-
chmod +x "$TLC_HOME/bin/tlc" "$TLC_HOME/bin/tlc-exec" "$TLC_HOME/bin/tlc-build"
|
|
65
|
-
|
|
66
|
-
# A bundle whose source moved or was deleted is not rebuilt, so it is also never diffed — it simply stays in
|
|
67
|
-
# dist/ and ships. Deriving what to remove from the same disk that decides what to build closes that.
|
|
68
|
-
for bundle in "$DIST"/*.mjs; do
|
|
69
|
-
[ -e "$bundle" ] || continue
|
|
70
|
-
name="$(basename "$bundle" .mjs)"
|
|
71
|
-
if [ "$name" = "tlc-cli" ] ||
|
|
72
|
-
[ -f "$TLC_HOME/src/entrypoints/${name}.ts" ] ||
|
|
73
|
-
[ -f "$TLC_HOME/tools/${name}.ts" ]; then
|
|
74
|
-
continue
|
|
75
|
-
fi
|
|
76
|
-
echo "tlc-build: pruning $name.mjs — no source"
|
|
77
|
-
rm -f "$bundle"
|
|
78
|
-
done
|
|
79
|
-
|
|
80
|
-
echo "tlc-build: ok ($(ls -1 "$DIST"/*.mjs | wc -l) bundles)"
|
package/bin/tlc-exec
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
SOURCE="${BASH_SOURCE[0]:-$0}"
|
|
4
|
-
while [[ -L "$SOURCE" ]]; do
|
|
5
|
-
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
|
|
6
|
-
SOURCE="$(readlink "$SOURCE")"
|
|
7
|
-
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
8
|
-
done
|
|
9
|
-
BIN_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
|
|
10
|
-
exec node "$BIN_DIR/tlc-exec.mjs" "$@"
|
package/bin/tlc-exec.cmd
DELETED
package/docs/decisions/ad-001.md
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
type: Decision
|
|
3
|
-
title: "AD-001 — Optional parent-Fast sticky deny for Task spawns"
|
|
4
|
-
description: "subagents.blockParentFast denies Task/subagent spawns while the sticky parent model is Fast, closing a gap where Cursor Fast appears on the parent but not on the Task model string."
|
|
5
|
-
tags: [decision, subagents, policy, cursor]
|
|
6
|
-
timestamp: "2026-07-27"
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
# AD-001 — Optional parent-Fast sticky deny for Task spawns
|
|
10
|
-
|
|
11
|
-
- **status**: active
|
|
12
|
-
- **date**: 2026-07-27
|
|
13
|
-
|
|
14
|
-
## Decision
|
|
15
|
-
|
|
16
|
-
`subagents.blockParentFast` (default `false`) denies Task / `subagentStart` when sticky parent state is
|
|
17
|
-
Fast; bracket/`model_params` Fast is always treated as blocked in policy helpers.
|
|
18
|
-
|
|
19
|
-
## Why
|
|
20
|
-
|
|
21
|
-
Cursor Fast often appears on the parent (`*-fast` / `model_params.fast=true`) while Task `model` stays
|
|
22
|
-
allowlisted without `-fast`. Without this rail, a Fast parent chat can still spawn full-price subagents.
|
|
23
|
-
|
|
24
|
-
## Applies to
|
|
25
|
-
|
|
26
|
-
- agent-harness hooks
|
|
27
|
-
- harness-init capability 6b (see [/init.md](/init.md))
|
|
28
|
-
|
|
29
|
-
## See also
|
|
30
|
-
|
|
31
|
-
- [/decisions/ad-011.md](/decisions/ad-011.md) — provider model catalogs and blocked patterns
|
|
32
|
-
- [/decisions/ad-016.md](/decisions/ad-016.md) — item 2 fixes the missing writer for this state
|