@tech-leads-club/harness-toolkit 0.3.6 → 0.4.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 (73) hide show
  1. package/README.md +44 -8
  2. package/bin/tlc-cli.ts +40 -1
  3. package/bin/tlc-exec.d.mts +1 -0
  4. package/bin/tlc-exec.mjs +47 -2
  5. package/capabilities/catalog.json +47 -30
  6. package/dist/compact-before.mjs +84 -78
  7. package/dist/doctor.mjs +87 -81
  8. package/dist/help-topic.mjs +6 -5
  9. package/dist/init-project.mjs +147 -9
  10. package/dist/install-runtime.mjs +85 -79
  11. package/dist/lessons-cli.mjs +87 -81
  12. package/dist/obs-cli.mjs +83 -77
  13. package/dist/price-lookup.mjs +2 -2
  14. package/dist/prompt-submit.mjs +84 -78
  15. package/dist/refresh-model-prices.mjs +85 -79
  16. package/dist/response-after.mjs +84 -78
  17. package/dist/run.mjs +84 -78
  18. package/dist/session-end.mjs +90 -84
  19. package/dist/session-start.mjs +92 -86
  20. package/dist/shim.mjs +82 -76
  21. package/dist/stop.mjs +90 -84
  22. package/dist/subagent-start.mjs +84 -78
  23. package/dist/subagent-stop.mjs +85 -79
  24. package/dist/support.mjs +88 -82
  25. package/dist/tlc-cli.mjs +104 -98
  26. package/dist/tool-after.mjs +84 -78
  27. package/dist/tool-before.mjs +84 -78
  28. package/dist/tool-failure.mjs +84 -78
  29. package/dist/uninstall-runtime.mjs +4 -4
  30. package/docs/architecture.md +1 -0
  31. package/docs/concepts.md +86 -0
  32. package/docs/diagnose.md +20 -0
  33. package/docs/init.md +10 -2
  34. package/docs/lessons.md +12 -0
  35. package/docs/log.md +5 -0
  36. package/package.json +1 -1
  37. package/skills/harness-init/references/capabilities.md +54 -0
  38. package/src/core/core.facade.ts +54 -0
  39. package/src/core/floor/floor.paths.ts +2 -2
  40. package/src/core/floor/floor.policy-surface.ts +6 -1
  41. package/src/core/lesson/lesson.select.ts +30 -7
  42. package/src/core/policy/policy.defaults.ts +3 -0
  43. package/src/core/policy/policy.integrity.ts +2 -2
  44. package/src/core/policy/policy.loader.ts +14 -3
  45. package/src/core/policy/policy.shadow.ts +97 -0
  46. package/src/core/policy/policy.types.ts +8 -0
  47. package/src/core/release/release.decisions.ts +3 -13
  48. package/src/core/rules/rules.decide.ts +123 -0
  49. package/src/core/rules/rules.observe.ts +76 -0
  50. package/src/core/rules/rules.parse.ts +142 -0
  51. package/src/core/rules/rules.proof.ts +130 -0
  52. package/src/core/rules/rules.service.ts +141 -0
  53. package/src/core/rules/rules.store.ts +77 -0
  54. package/src/core/rules/rules.trigger.ts +101 -0
  55. package/src/core/rules/rules.types.ts +64 -0
  56. package/src/entrypoints/shim.ts +9 -1
  57. package/src/entrypoints/stop.ts +75 -1
  58. package/src/entrypoints/subagent-stop.ts +9 -1
  59. package/src/entrypoints/support.ts +32 -0
  60. package/src/entrypoints/tool-after.ts +7 -2
  61. package/src/entrypoints/tool-before.ts +44 -3
  62. package/src/platform/frontmatter.ts +142 -0
  63. package/src/platform/links.ts +32 -0
  64. package/src/platform/paths.ts +58 -4
  65. package/src/platform/pricing.ts +3 -3
  66. package/src/platform/screen.ts +62 -3
  67. package/tools/doctor.ts +162 -2
  68. package/tools/help-topic.ts +39 -23
  69. package/tools/init-project.ts +51 -6
  70. package/tools/install-runtime.ts +23 -2
  71. package/tools/lessons-cli.ts +4 -1
  72. package/tools/refresh-model-prices.ts +2 -2
  73. package/tools/uninstall-runtime.ts +11 -3
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The one frontmatter reader.
3
+ *
4
+ * why here: there were two — a full parser in `tools/dev/check-docs-bundle.ts`, which never ships and which
5
+ * `core/` may not import, and a private single-field extractor in `core/release/release.decisions.ts`. Operator
6
+ * rules need a third caller, and a third copy is the duplication this product's own gate refuses. It sits in
7
+ * `platform/` because it is a format primitive with no policy in it, and because that is the one direction all
8
+ * three callers may import from ([/decisions/ad-100.md](/decisions/ad-100.md)).
9
+ *
10
+ * invariant: pure. No filesystem, no clock. The caller reads the file.
11
+ */
12
+
13
+ export type FrontmatterValue = string | string[];
14
+ export type Frontmatter = Record<string, FrontmatterValue>;
15
+
16
+ /** The shape `check-docs-bundle` already consumed, kept so moving this changed no caller's contract. */
17
+ export type ParseResult = { frontmatter: Frontmatter | null; error: string | null };
18
+
19
+ export type FrontmatterDoc = {
20
+ fields: Frontmatter;
21
+ /** Everything after the closing fence, verbatim. An operator rule's instruction lives here. */
22
+ body: string;
23
+ };
24
+
25
+ function extractFrontmatterBlock(content: string): { block: string; bodyAt: number } | null {
26
+ if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
27
+ return null;
28
+ }
29
+ const firstBreak = content.indexOf("\n");
30
+ const rest = content.slice(firstBreak + 1);
31
+ const closingMatch = /^---\s*$/m.exec(rest);
32
+ if (!closingMatch) {
33
+ return null;
34
+ }
35
+ return {
36
+ block: rest.slice(0, closingMatch.index),
37
+ bodyAt: firstBreak + 1 + closingMatch.index + closingMatch[0].length,
38
+ };
39
+ }
40
+
41
+ function stripQuotes(raw: string): string {
42
+ const trimmed = raw.trim();
43
+ if (trimmed.length >= 2) {
44
+ const first = trimmed[0];
45
+ const last = trimmed[trimmed.length - 1];
46
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
47
+ return trimmed.slice(1, -1);
48
+ }
49
+ }
50
+ return trimmed;
51
+ }
52
+
53
+ /**
54
+ * hazard: an escaped quote inside a value survived the outer-quote strip and reached the operator as a literal
55
+ * `\"` in their terminal. Seen in a real update run ([/decisions/ad-034.md](/decisions/ad-034.md)).
56
+ */
57
+ function unescapeQuotes(value: string): string {
58
+ return value.replace(/\\(["'\\])/g, "$1");
59
+ }
60
+
61
+ function parseValue(raw: string): FrontmatterValue {
62
+ const trimmed = raw.trim();
63
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
64
+ const inner = trimmed.slice(1, -1).trim();
65
+ if (inner === "") {
66
+ return [];
67
+ }
68
+ return inner.split(",").map((item) => unescapeQuotes(stripQuotes(item)));
69
+ }
70
+ return unescapeQuotes(stripQuotes(trimmed));
71
+ }
72
+
73
+ /**
74
+ * why block lists: a rule declares several proofs, and `require: [a, b]` on one line is not how anybody writes
75
+ * three of them. `key:` with nothing after it opens a list; `key: value` closes any list before it.
76
+ */
77
+ export function parseFrontmatterDoc(content: string): { doc: FrontmatterDoc | null; error: string | null } {
78
+ const extracted = extractFrontmatterBlock(content);
79
+ if (extracted === null) {
80
+ return { doc: null, error: "missing --- frontmatter block" };
81
+ }
82
+ const fields: Frontmatter = {};
83
+ let listKey: string | null = null;
84
+
85
+ for (const line of extracted.block.split("\n")) {
86
+ const trimmed = line.trim();
87
+ if (trimmed === "" || trimmed.startsWith("#")) {
88
+ continue;
89
+ }
90
+ if (trimmed.startsWith("- ")) {
91
+ if (listKey === null) {
92
+ return { doc: null, error: `list item with no key above it: "${trimmed}"` };
93
+ }
94
+ const current = fields[listKey];
95
+ const item = unescapeQuotes(stripQuotes(trimmed.slice(2)));
96
+ fields[listKey] = Array.isArray(current) ? [...current, item] : [item];
97
+ continue;
98
+ }
99
+ const separator = trimmed.indexOf(":");
100
+ if (separator === -1) {
101
+ return { doc: null, error: `unparseable frontmatter line: "${trimmed}"` };
102
+ }
103
+ const key = trimmed.slice(0, separator).trim();
104
+ if (key === "") {
105
+ return { doc: null, error: `frontmatter line has an empty key: "${trimmed}"` };
106
+ }
107
+ const value = trimmed.slice(separator + 1);
108
+ if (value.trim() === "") {
109
+ fields[key] = [];
110
+ listKey = key;
111
+ continue;
112
+ }
113
+ fields[key] = parseValue(value);
114
+ listKey = null;
115
+ }
116
+
117
+ return { doc: { fields, body: content.slice(extracted.bodyAt).trim() }, error: null };
118
+ }
119
+
120
+ /** invariant: the shape the docs bundle check already used. One implementation, two entry points. */
121
+ export function parseFrontmatter(content: string): ParseResult {
122
+ const { doc, error } = parseFrontmatterDoc(content);
123
+ return doc === null ? { frontmatter: null, error } : { frontmatter: doc.fields, error: null };
124
+ }
125
+
126
+ /** why: one field, by name, for a caller that wants nothing else. */
127
+ export function frontmatterField(content: string, field: string): string | undefined {
128
+ const { doc } = parseFrontmatterDoc(content);
129
+ const value = doc?.fields[field];
130
+ if (typeof value !== "string" || value.trim() === "") {
131
+ return undefined;
132
+ }
133
+ return value.trim();
134
+ }
135
+
136
+ /** why: every field a rule reads is one value or a list of them, and the caller should not care which. */
137
+ export function asList(value: FrontmatterValue | undefined): string[] {
138
+ if (value === undefined) {
139
+ return [];
140
+ }
141
+ return (Array.isArray(value) ? value : [value]).filter((item) => item !== "");
142
+ }
@@ -48,6 +48,38 @@ export function linkDir(source: string, target: string): LinkOutcome {
48
48
  return { kind: replaced ? "relinked" : "linked", target, source };
49
49
  }
50
50
 
51
+ /**
52
+ * Point a *file* `target` at `source`, for the `tlc` command on `PATH`.
53
+ *
54
+ * why not `linkDir`: its link type is `"junction"`, which Windows reads and which only means anything for a
55
+ * directory. A file gets no type argument, which is correct on every platform — and where a platform refuses to
56
+ * create one, the reason is reported rather than thrown, because a missing convenience link must not fail an
57
+ * install that otherwise worked ([/decisions/ad-101.md](/decisions/ad-101.md)).
58
+ *
59
+ * invariant: the same contract as `linkDir` — an existing link is replaced, anything else is refused. A real file
60
+ * called `tlc` in someone's bin directory is theirs.
61
+ */
62
+ export function linkFile(source: string, target: string): LinkOutcome {
63
+ let replaced = false;
64
+ if (isLink(target)) {
65
+ rmSync(target, { force: true });
66
+ replaced = true;
67
+ } else if (existsSync(target)) {
68
+ return {
69
+ kind: "refused",
70
+ target,
71
+ reason: `${target} exists and is not a link — move it aside and re-run`,
72
+ };
73
+ }
74
+ try {
75
+ mkdirSync(dirname(target), { recursive: true });
76
+ symlinkSync(source, target);
77
+ } catch (error) {
78
+ return { kind: "refused", target, reason: (error as Error).message };
79
+ }
80
+ return { kind: replaced ? "relinked" : "linked", target, source };
81
+ }
82
+
51
83
  /**
52
84
  * why lstat: a link whose destination is gone is still a link, and `existsSync` says it is not there — so the
53
85
  * check has to come first, or a dangling link reads as free space. Node reports a Windows junction as a symbolic
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { delimiter, join, resolve } from "node:path";
3
3
 
4
4
  function harnessDir(root: string): string {
5
5
  return join(root, ".tlc", "harness");
@@ -22,15 +22,38 @@ export function runtimeHome(env: NodeJS.ProcessEnv = process.env): string {
22
22
 
23
23
  /**
24
24
  * invariant: `tlc-exec` always sets `TLC_HOME` in the child, so the child cannot tell an operator's choice from
25
- * the launcher's own resolution. This flag is that difference, and only the installer needs it — everything else
26
- * wants the resolved home either way.
25
+ * the launcher's own resolution. This flag is that difference.
27
26
  */
28
27
  export function runtimeHomeWasChosen(env: NodeJS.ProcessEnv = process.env): boolean {
29
28
  return env.TLC_HOME_FROM_ENV === "1";
30
29
  }
31
30
 
31
+ /**
32
+ * Where this **machine's** operator data lives: the user-tier config, the global lesson tier, the global rules,
33
+ * the price catalogue, the cross-repo spool.
34
+ *
35
+ * hazard: all of that used to resolve through `runtimeHome()`, which names where the *code* lives and moves with
36
+ * the install. With two installs on one machine there were two "global" tiers, silently — and switching between
37
+ * them looked like data loss. Measured on an operator's machine: a lesson saved with `--global` landed in a
38
+ * checkout's state directory while the CLI printed "every product on this machine will read it", and a user-tier
39
+ * config with a subagent allowlist stopped being read the moment the runtime home changed
40
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
41
+ *
42
+ * why the flag rather than always the conventional path: an operator who exports `TLC_HOME` is choosing a home and
43
+ * means it, and the test suite pins one for hermeticity. Only the launcher's own resolution — which marks itself
44
+ * `TLC_HOME_FROM_ENV=0` — must not be allowed to invent a second machine.
45
+ */
46
+ export function machineHome(env: NodeJS.ProcessEnv = process.env): string {
47
+ return env.TLC_HOME_FROM_ENV === "0" ? conventionalRuntimeHome() : runtimeHome(env);
48
+ }
49
+
50
+ /** invariant: the one path install seeds and update never writes ([/decisions/ad-056.md](/decisions/ad-056.md)). */
51
+ export function machineConfigPath(env: NodeJS.ProcessEnv = process.env): string {
52
+ return join(machineHome(env), "config.json");
53
+ }
54
+
32
55
  export function runtimeStateDir(): string {
33
- return join(runtimeHome(), "state");
56
+ return join(machineHome(), "state");
34
57
  }
35
58
 
36
59
  // why: one file for every repository on the machine. Per-repo state stays authoritative; this is the
@@ -105,3 +128,34 @@ export function userSettingsPaths(): string[] {
105
128
  export function providerConfigDirs(): string[] {
106
129
  return [cursorConfigDir(), claudeConfigDir()];
107
130
  }
131
+
132
+ /**
133
+ * Where the `tlc` command goes so a shell can find it.
134
+ *
135
+ * hazard: this lived only in `uninstall-runtime.ts`, which removed a launcher `install` never created. The command
136
+ * came from npm's own shim instead — and that shim sits in the `bin` directory of whichever Node version npm ran
137
+ * under, which leaves `PATH` the moment a version manager switches. Measured on an operator's machine: a
138
+ * successful install followed immediately by `tlc: command not found`
139
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
140
+ *
141
+ * invariant: one definition, so what install creates and what uninstall removes cannot drift apart.
142
+ */
143
+ export function launcherBinDir(env: NodeJS.ProcessEnv = process.env): string {
144
+ return env.TLC_BIN_DIR?.trim() || join(homedir(), ".local", "bin");
145
+ }
146
+
147
+ /** why the extensionless wrapper: it is what a shell runs. The `.cmd` beside it is Windows's copy of the same. */
148
+ export function launcherNames(): readonly string[] {
149
+ return ["tlc", "tlc.cmd"];
150
+ }
151
+
152
+ /**
153
+ * Whether a shell would find something in this directory.
154
+ *
155
+ * why it matters at install time: a launcher nobody can reach is worse than none, because `doctor` then reports a
156
+ * healthy link while the command still does not exist.
157
+ */
158
+ export function isOnPath(dir: string, env: NodeJS.ProcessEnv = process.env): boolean {
159
+ const entries = (env.PATH ?? "").split(delimiter).filter((entry) => entry.length > 0);
160
+ return entries.some((entry) => resolve(entry) === resolve(dir));
161
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { runtimeHome } from "./paths.ts";
16
+ import { machineHome } from "./paths.ts";
17
17
 
18
18
  export type VendorPool = "cursor_models" | "anthropic_models" | "other_models" | "auto" | "unknown";
19
19
  export type NeutralPool = "provider_native" | "other" | "auto" | "unknown";
@@ -163,7 +163,7 @@ function fuzzyFind(table: PriceTable, needle: string): { key: string; entry: Mod
163
163
 
164
164
  /** The catalogue the refresh writes. Not versioned, not packaged, per machine. */
165
165
  export function cataloguePath(): string {
166
- return join(runtimeHome(), "model-prices.json");
166
+ return join(machineHome(), "model-prices.json");
167
167
  }
168
168
 
169
169
  /**
@@ -174,7 +174,7 @@ export function cataloguePath(): string {
174
174
  * refresh would replace. The refresh moves such a file here rather than overwriting it.
175
175
  */
176
176
  export function overridesPath(): string {
177
- return join(runtimeHome(), "model-prices.local.json");
177
+ return join(machineHome(), "model-prices.local.json");
178
178
  }
179
179
 
180
180
  export type PriceResolution = {
@@ -2,7 +2,14 @@ import { KV_WIDTH, type StatusLevel, type Style, SYMBOLS } from "./style.ts";
2
2
 
3
3
  export type Row = { label: string; value: string; level?: StatusLevel };
4
4
 
5
- export type Section = { title?: string; rows?: Row[]; lines?: string[] };
5
+ /**
6
+ * `wrap` marks a section's lines as prose, so they are broken at word boundaries to fit the terminal.
7
+ *
8
+ * why opt-in rather than always: a section's lines are sometimes a command to copy — wrapping
9
+ * `tlc harness policy accept <path>` across two lines makes it unpasteable. Prose and payload look identical to a
10
+ * renderer, so the caller says which it has ([/decisions/ad-101.md](/decisions/ad-101.md)).
11
+ */
12
+ export type Section = { title?: string; rows?: Row[]; lines?: string[]; wrap?: boolean };
6
13
 
7
14
  export type Screen = {
8
15
  title: string;
@@ -11,9 +18,54 @@ export type Screen = {
11
18
  footer?: string;
12
19
  };
13
20
 
21
+ /** The two-space indent `render` puts in front of every line, which the wrap width has to leave room for. */
22
+ const INDENT = 2;
23
+
24
+ /**
25
+ * why clamped: a 400-column terminal produces lines nobody tracks across, and an 8-column one produces a word per
26
+ * line. Outside a TTY there is no width to read, and a fixed sensible one beats guessing.
27
+ */
28
+ export function terminalColumns(columns: number | undefined = process.stdout.columns): number {
29
+ return Math.min(110, Math.max(60, columns ?? 100));
30
+ }
31
+
32
+ /**
33
+ * Break prose at word boundaries so nothing is hidden.
34
+ *
35
+ * hazard: the lessons list cut the instruction at 160 characters with no marker — a 263-character lesson lost 103
36
+ * of them mid-word, and the reader could not tell. An operator asked why their lesson had been cut; it had not
37
+ * been, only its display had ([/decisions/ad-101.md](/decisions/ad-101.md)).
38
+ *
39
+ * invariant: a word longer than the width stands on its own line rather than being cut. Losing a character is
40
+ * worse than an overlong line, because only one of the two is visible.
41
+ */
42
+ export function wrapText(text: string, width: number): string[] {
43
+ if (text === "") {
44
+ return [""];
45
+ }
46
+ const lines: string[] = [];
47
+ let current = "";
48
+ for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
49
+ if (current === "") {
50
+ current = word;
51
+ continue;
52
+ }
53
+ if (current.length + 1 + word.length <= width) {
54
+ current = `${current} ${word}`;
55
+ continue;
56
+ }
57
+ lines.push(current);
58
+ current = word;
59
+ }
60
+ if (current !== "") {
61
+ lines.push(current);
62
+ }
63
+ return lines.length === 0 ? [""] : lines;
64
+ }
65
+
14
66
  // why: screens describe their content and never their paint, so spacing, colour and alignment are decided once.
15
67
  // A new screen can only emit this shape, which is what makes "no screen outside the standard" mechanical.
16
- export function render(screen: Screen, style: Style): string {
68
+ export function render(screen: Screen, style: Style, columns = terminalColumns()): string {
17
69
  const out: string[] = [style.heading(screen.title.toUpperCase())];
18
70
 
19
71
  if (screen.summary && screen.summary.length > 0) {
@@ -35,7 +87,14 @@ export function render(screen: Screen, style: Style): string {
35
87
  out.push(style.kv(row.label, value, width));
36
88
  }
37
89
  for (const line of section.lines ?? []) {
38
- out.push(line === "" ? "" : ` ${line}`);
90
+ if (line === "") {
91
+ out.push("");
92
+ continue;
93
+ }
94
+ const parts = section.wrap ? wrapText(line, columns - INDENT) : [line];
95
+ for (const part of parts) {
96
+ out.push(` ${part}`);
97
+ }
39
98
  }
40
99
  }
41
100
 
package/tools/doctor.ts CHANGED
@@ -2,13 +2,14 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
3
  import { homedir, platform as osPlatform } from "node:os";
4
4
  import { basename, delimiter, dirname, join } from "node:path";
5
- import { NPM_PACKAGE, runtimePathKind } from "../bin/tlc-cli.ts";
5
+ import { runtimePathKind } from "../bin/tlc-cli.ts";
6
6
  import { findBunOnPath, writeRuntimeCache } from "../bin/tlc-exec.mjs";
7
7
  import { isCursorWired } from "../bin/write-user-hooks.mjs";
8
8
  import type { ProviderWiring } from "../src/contracts/index.ts";
9
9
  import { coreFacade } from "../src/core/index.ts";
10
10
  import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
11
11
  import {
12
+ launcherBinDir,
12
13
  projectConfigPath,
13
14
  projectStateDir,
14
15
  providerConfigDirs,
@@ -244,6 +245,69 @@ export function resolveOnPath(
244
245
  return null;
245
246
  }
246
247
 
248
+ /**
249
+ * The operator's own rules: which apply, where each came from, and any that can never be satisfied here.
250
+ *
251
+ * why the tier is printed: two tiers apply together, so "why did this fire?" and "why did it not?" are both
252
+ * answered by knowing whether the rule came from this project or from the machine
253
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
254
+ *
255
+ * invariant: silent when the capability is off, and silent when no rule is declared. A row about a mechanism
256
+ * nobody opted into is noise on every healthy install ([/decisions/ad-034.md](/decisions/ad-034.md)).
257
+ */
258
+ export function checkRules(root: string): Check[] {
259
+ const policy = coreFacade.policy.loadPolicy(root);
260
+ // why the switch is not re-read here: `load` owns it, and a second copy of the same condition is a second
261
+ // thing to keep true ([/decisions/ad-100.md](/decisions/ad-100.md)).
262
+ const set = coreFacade.rules.load(root, policy.rules);
263
+ if (set.rules.length === 0 && set.disabled.length === 0 && set.errors.length === 0) {
264
+ return [];
265
+ }
266
+
267
+ const checks: Check[] = [];
268
+ if (set.rules.length > 0) {
269
+ checks.push({
270
+ level: "ok",
271
+ name: "operator rules",
272
+ detail: set.rules
273
+ .map((rule) => `${rule.name} (${rule.tier}) on ${rule.on.kind} → ${rule.otherwise}`)
274
+ .join("; "),
275
+ });
276
+ }
277
+
278
+ // why an `ok` row: switching a global off in one repository is a decision, not a fault. It is reported because
279
+ // an operator who forgot they did it would otherwise wonder why nothing fires.
280
+ if (set.disabled.length > 0) {
281
+ checks.push({
282
+ level: "ok",
283
+ name: "operator rules (off here)",
284
+ detail: set.disabled.map((rule) => rule.name).join(", "),
285
+ });
286
+ }
287
+
288
+ for (const error of set.errors) {
289
+ checks.push({
290
+ level: "fail",
291
+ name: `operator rule (${error.name})`,
292
+ detail: `${error.error} — the other rules still apply`,
293
+ });
294
+ }
295
+
296
+ /**
297
+ * hazard: a rule whose proof kind this project has never recorded reads as protection and enforces nothing an
298
+ * operator can satisfy. Saying so is factual; guessing at the host's capabilities would not be.
299
+ */
300
+ for (const entry of coreFacade.rules.unobservedKinds(set.rules, coreFacade.rules.observations(root))) {
301
+ checks.push({
302
+ level: "warn",
303
+ name: `operator rule (${entry.rule})`,
304
+ detail: `needs ${entry.kinds.join(", ")}, and no observation of that kind has been recorded in this project yet`,
305
+ });
306
+ }
307
+
308
+ return checks;
309
+ }
310
+
247
311
  export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
248
312
  const launcher = join(home, "bin", "tlc-exec.mjs");
249
313
  const distSample = join(home, "dist", "stop.mjs");
@@ -262,7 +326,12 @@ export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Chec
262
326
  {
263
327
  level: onPath === null ? "fail" : "ok",
264
328
  name: "CLI on PATH",
265
- detail: onPath ?? `no \`tlc\` on PATH — npm i -g ${NPM_PACKAGE}, or \`npm link\` from a clone`,
329
+ // hazard: this said "npm i -g <package>" — advice an operator who had just done exactly that could not act
330
+ // on. npm's shim lives in the bin directory of whichever Node version npm ran under, and leaves PATH the
331
+ // moment a version manager switches ([/decisions/ad-101.md](/decisions/ad-101.md)).
332
+ detail:
333
+ onPath ??
334
+ `no \`tlc\` on PATH — link it: ln -s ${join(home, "bin", "tlc")} ${join(launcherBinDir(), "tlc")} (or re-run \`tlc harness install\`, which does it)`,
266
335
  },
267
336
  ];
268
337
  }
@@ -470,6 +539,94 @@ export function checkSubagentAllowlist(root: string): Check[] {
470
539
  ];
471
540
  }
472
541
 
542
+ /**
543
+ * What the operator cannot otherwise find out: how many of their lessons actually reach the model.
544
+ *
545
+ * hazard: the char budget drops lessons, and the only place that said so was the injected block itself — text the
546
+ * model reads and the operator never sees. `lessons list` answers a different question (`not-injected` is about
547
+ * grading history, not about the budget), and `status` does not answer it at all. So an operator with six lessons
548
+ * and a 900-char budget saw six healthy lessons and had four that never left the file
549
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
550
+ *
551
+ * why the real selector rather than a size sum: pinning, staleness, validity windows and the mode all bind before
552
+ * the budget does. Re-deriving the arithmetic here would be a second answer that drifts from the first.
553
+ */
554
+ /**
555
+ * Which keys this project restates instead of deciding.
556
+ *
557
+ * hazard: the layers are `DEFAULTS < user < project`, and `init` writes the whole default policy when there is no
558
+ * config yet while the wizard writes every knob it collected. So a project config typically names dozens of values
559
+ * it did not choose — and each one shadows the machine-wide tier for ever. An operator who raises
560
+ * `maxCharsSession` once, on the machine, sees no change in any repository that restated the old number, and
561
+ * nothing said why ([/decisions/ad-100.md](/decisions/ad-100.md)).
562
+ *
563
+ * invariant: a warning, never a failure. Restating a value is legitimate — pinning a project to a number on
564
+ * purpose is a real intent. What is not legitimate is not knowing.
565
+ */
566
+ export function checkShadowedPolicy(root: string): Check[] {
567
+ const project = coreFacade.capability.readProjectPolicyRaw(root);
568
+ if (!project) {
569
+ return [];
570
+ }
571
+ const shadowed = coreFacade.policy.shadowedKeys(project, coreFacade.policy.resolvedWithoutProjectTier());
572
+ if (shadowed.length === 0) {
573
+ return [];
574
+ }
575
+ const shown = shadowed.slice(0, 6).map((key) => key.path);
576
+ const rest = shadowed.length - shown.length;
577
+ return [
578
+ {
579
+ level: "warn",
580
+ name: "project policy restates lower tiers",
581
+ detail: `${plural(shadowed.length, "key")} in this project's config name the value ${shadowed.length === 1 ? "it" : "they"} would already have: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. Each one stops tracking ${join(runtimeHome(), "config.json")}, so a machine-wide change will not reach this repository. Delete what this project did not decide.`,
582
+ },
583
+ ];
584
+ }
585
+
586
+ export function checkLessonBudget(root: string): Check[] {
587
+ const policy = coreFacade.policy.loadPolicy(root);
588
+ const config = policy.intelligence.lessons;
589
+ if (!config.enabled) {
590
+ return [];
591
+ }
592
+ const selected = coreFacade.lesson.previewLessonSelection({ projectDir: root, config, mode: "session" });
593
+ const reaching = selected.lessons.length;
594
+ if (selected.omitted === 0) {
595
+ return reaching === 0
596
+ ? []
597
+ : [
598
+ {
599
+ level: "ok",
600
+ name: "lesson budget",
601
+ detail: `every eligible lesson reaches the model at session start (${plural(reaching, "lesson")}, maxCharsSession ${config.maxCharsSession})`,
602
+ },
603
+ ];
604
+ }
605
+ const chars = selected.lessons.reduce(
606
+ (total, lesson) => total + coreFacade.lesson.renderLessonBlock(lesson).length,
607
+ 0,
608
+ );
609
+ const hogs = selected.lessons
610
+ .filter((lesson) => lesson.pinned)
611
+ .map((lesson) => `${lesson.id} (pinned, ${coreFacade.lesson.renderLessonBlock(lesson).length} chars)`);
612
+ return [
613
+ {
614
+ level: "warn",
615
+ name: "lesson budget",
616
+ detail: [
617
+ // hazard: this read "4 eligible lessons never reaches". `plural` handles the noun and the verb still has to
618
+ // agree — the same slip the unproven row already carries a note about.
619
+ `${plural(selected.omitted, "eligible lesson")} ${selected.omitted === 1 ? "never reaches" : "never reach"} the model at session start:`,
620
+ `${reaching} of ${reaching + selected.omitted} fit in maxCharsSession ${config.maxCharsSession} (${chars} used).`,
621
+ hogs.length > 0 ? `Pinned lessons go first and take the room: ${hogs.join(", ")}.` : "",
622
+ "Raise intelligence.lessons.maxCharsSession, shorten a lesson, or unpin one. Run: tlc harness lessons list",
623
+ ]
624
+ .filter(Boolean)
625
+ .join(" "),
626
+ },
627
+ ];
628
+ }
629
+
473
630
  export function checkLessonHealth(root: string): Check[] {
474
631
  const policy = coreFacade.policy.loadPolicy(root);
475
632
  if (!policy.intelligence.lessons.enabled) {
@@ -597,6 +754,8 @@ export function checkProjectPolicy(root: string): Check[] {
597
754
  checkPosture(root),
598
755
  ...checkObservedRails(root),
599
756
  ...checkLessonHealth(root),
757
+ ...checkLessonBudget(root),
758
+ ...checkShadowedPolicy(root),
600
759
  ...checkSubagentAllowlist(root),
601
760
  ...checkPolicyDivergence(root),
602
761
  ...checkGateScope(root),
@@ -643,6 +802,7 @@ export function runChecks(ctx: DoctorContext): Check[] {
643
802
  ...checkProjectPolicy(ctx.root),
644
803
  ...checkCapabilities(ctx.root, ctx.runtimeHome),
645
804
  ...checkPrices(),
805
+ ...checkRules(ctx.root),
646
806
  checkGlobalCommands(ctx.home),
647
807
  ];
648
808
  }