@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.
- package/README.md +44 -8
- package/bin/tlc-cli.ts +40 -1
- package/bin/tlc-exec.d.mts +1 -0
- package/bin/tlc-exec.mjs +47 -2
- package/capabilities/catalog.json +47 -30
- package/dist/compact-before.mjs +84 -78
- package/dist/doctor.mjs +87 -81
- package/dist/help-topic.mjs +6 -5
- package/dist/init-project.mjs +147 -9
- package/dist/install-runtime.mjs +85 -79
- package/dist/lessons-cli.mjs +87 -81
- package/dist/obs-cli.mjs +83 -77
- package/dist/price-lookup.mjs +2 -2
- package/dist/prompt-submit.mjs +84 -78
- package/dist/refresh-model-prices.mjs +85 -79
- package/dist/response-after.mjs +84 -78
- package/dist/run.mjs +84 -78
- package/dist/session-end.mjs +90 -84
- package/dist/session-start.mjs +92 -86
- package/dist/shim.mjs +82 -76
- package/dist/stop.mjs +90 -84
- package/dist/subagent-start.mjs +84 -78
- package/dist/subagent-stop.mjs +85 -79
- package/dist/support.mjs +88 -82
- package/dist/tlc-cli.mjs +104 -98
- package/dist/tool-after.mjs +84 -78
- package/dist/tool-before.mjs +84 -78
- package/dist/tool-failure.mjs +84 -78
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/architecture.md +1 -0
- package/docs/concepts.md +86 -0
- package/docs/diagnose.md +20 -0
- package/docs/init.md +10 -2
- package/docs/lessons.md +12 -0
- package/docs/log.md +5 -0
- package/package.json +1 -1
- package/skills/harness-init/references/capabilities.md +54 -0
- package/src/core/core.facade.ts +54 -0
- package/src/core/floor/floor.paths.ts +2 -2
- package/src/core/floor/floor.policy-surface.ts +6 -1
- package/src/core/lesson/lesson.select.ts +30 -7
- package/src/core/policy/policy.defaults.ts +3 -0
- package/src/core/policy/policy.integrity.ts +2 -2
- package/src/core/policy/policy.loader.ts +14 -3
- package/src/core/policy/policy.shadow.ts +97 -0
- package/src/core/policy/policy.types.ts +8 -0
- package/src/core/release/release.decisions.ts +3 -13
- package/src/core/rules/rules.decide.ts +123 -0
- package/src/core/rules/rules.observe.ts +76 -0
- package/src/core/rules/rules.parse.ts +142 -0
- package/src/core/rules/rules.proof.ts +130 -0
- package/src/core/rules/rules.service.ts +141 -0
- package/src/core/rules/rules.store.ts +77 -0
- package/src/core/rules/rules.trigger.ts +101 -0
- package/src/core/rules/rules.types.ts +64 -0
- package/src/entrypoints/shim.ts +9 -1
- package/src/entrypoints/stop.ts +75 -1
- package/src/entrypoints/subagent-stop.ts +9 -1
- package/src/entrypoints/support.ts +32 -0
- package/src/entrypoints/tool-after.ts +7 -2
- package/src/entrypoints/tool-before.ts +44 -3
- package/src/platform/frontmatter.ts +142 -0
- package/src/platform/links.ts +32 -0
- package/src/platform/paths.ts +58 -4
- package/src/platform/pricing.ts +3 -3
- package/src/platform/screen.ts +62 -3
- package/tools/doctor.ts +162 -2
- package/tools/help-topic.ts +39 -23
- package/tools/init-project.ts +51 -6
- package/tools/install-runtime.ts +23 -2
- package/tools/lessons-cli.ts +4 -1
- package/tools/refresh-model-prices.ts +2 -2
- 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
|
+
}
|
package/src/platform/links.ts
CHANGED
|
@@ -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
|
package/src/platform/paths.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
+
}
|
package/src/platform/pricing.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import {
|
|
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(
|
|
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(
|
|
177
|
+
return join(machineHome(), "model-prices.local.json");
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
export type PriceResolution = {
|
package/src/platform/screen.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|