@tech-leads-club/harness-toolkit 0.3.5 → 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 (78) 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 +6 -0
  36. package/package.json +1 -1
  37. package/skills/harness-init/references/capabilities.md +54 -0
  38. package/src/contracts/index.ts +1 -0
  39. package/src/contracts/tool-names.ts +25 -0
  40. package/src/core/core.facade.ts +54 -0
  41. package/src/core/floor/floor.paths.ts +2 -2
  42. package/src/core/floor/floor.policy-surface.ts +6 -1
  43. package/src/core/lesson/lesson.select.ts +30 -7
  44. package/src/core/policy/policy.defaults.ts +3 -0
  45. package/src/core/policy/policy.guard.ts +2 -3
  46. package/src/core/policy/policy.integrity.ts +2 -2
  47. package/src/core/policy/policy.loader.ts +14 -3
  48. package/src/core/policy/policy.shadow.ts +97 -0
  49. package/src/core/policy/policy.types.ts +8 -0
  50. package/src/core/presence/presence.service.ts +10 -2
  51. package/src/core/release/release.decisions.ts +3 -13
  52. package/src/core/rules/rules.decide.ts +123 -0
  53. package/src/core/rules/rules.observe.ts +76 -0
  54. package/src/core/rules/rules.parse.ts +142 -0
  55. package/src/core/rules/rules.proof.ts +130 -0
  56. package/src/core/rules/rules.service.ts +141 -0
  57. package/src/core/rules/rules.store.ts +77 -0
  58. package/src/core/rules/rules.trigger.ts +101 -0
  59. package/src/core/rules/rules.types.ts +64 -0
  60. package/src/entrypoints/run.ts +31 -1
  61. package/src/entrypoints/shim.ts +9 -1
  62. package/src/entrypoints/stop.ts +75 -1
  63. package/src/entrypoints/subagent-stop.ts +9 -1
  64. package/src/entrypoints/support.ts +32 -0
  65. package/src/entrypoints/tool-after.ts +7 -2
  66. package/src/entrypoints/tool-before.ts +44 -3
  67. package/src/platform/frontmatter.ts +142 -0
  68. package/src/platform/links.ts +32 -0
  69. package/src/platform/paths.ts +58 -4
  70. package/src/platform/pricing.ts +3 -3
  71. package/src/platform/screen.ts +62 -3
  72. package/tools/doctor.ts +162 -2
  73. package/tools/help-topic.ts +39 -23
  74. package/tools/init-project.ts +51 -6
  75. package/tools/install-runtime.ts +23 -2
  76. package/tools/lessons-cli.ts +4 -1
  77. package/tools/refresh-model-prices.ts +2 -2
  78. package/tools/uninstall-runtime.ts +11 -3
@@ -13,7 +13,7 @@ import {
13
13
  import { flagsDir } from "../platform/paths.ts";
14
14
  import type { Handler, HandlerContext } from "./run.ts";
15
15
  import { main } from "./run.ts";
16
- import { formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
16
+ import { currentGitSha, formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
17
17
 
18
18
  const STAGNATION_FOLLOWUP = [
19
19
  "BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
@@ -21,6 +21,31 @@ const STAGNATION_FOLLOWUP = [
21
21
  "NEED: change approach. Do not repeat the same fix. Inspect root cause, try a different path, or escalate with BLOCKED/TRIED/NEED.",
22
22
  ].join("\n");
23
23
 
24
+ /**
25
+ * A gate is the one proof the harness decides rather than witnesses, so it is recorded where every gate already
26
+ * funnels through — the same argument `recordGateOutcome` makes for itself: a gate added later cannot be
27
+ * forgotten ([/decisions/ad-100.md](/decisions/ad-100.md)).
28
+ *
29
+ * invariant: only a gate that passed. Recording a failure would let "the gate ran" satisfy a rule that asked for
30
+ * "the gate passed", which is the whole point of asking.
31
+ */
32
+ async function observeGateForRules(args: {
33
+ root: string;
34
+ sessionKey: string;
35
+ policy: Policy;
36
+ gate: string;
37
+ passed: boolean;
38
+ }): Promise<void> {
39
+ if (!args.passed || !coreFacade.rules.wantsGate(args.root, args.policy.rules)) {
40
+ return;
41
+ }
42
+ coreFacade.rules.observeGate(args.root, args.policy.rules, args.gate, {
43
+ sha: await currentGitSha(args.root),
44
+ sessionKey: args.sessionKey,
45
+ at: new Date().toISOString(),
46
+ });
47
+ }
48
+
24
49
  /**
25
50
  * hazard: `gate.outcome` was consumed in two places — the rollup counter and the session report's
26
51
  * "Gates pass/fail" line — and emitted by nothing. Both read structurally zero, so the report printed a
@@ -200,6 +225,7 @@ async function runLockedGate(args: {
200
225
 
201
226
  // invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
202
227
  recordGateOutcome({ ...args, artifact, reused: cached !== null });
228
+ await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
203
229
  await creditPendingLessons({
204
230
  root: args.root,
205
231
  provider: args.provider,
@@ -387,6 +413,28 @@ async function failGate(args: {
387
413
  return { kind: "continue", text: parts.join("\n") };
388
414
  }
389
415
 
416
+ /**
417
+ * why the sha is fetched twice-or-never: `git rev-parse` is a process spawn and this runs on every stop. The first
418
+ * pass answers whether any `on: stop` rule fired at all, which costs two directory reads; only then is a sha worth
419
+ * a process, and the second pass is the one whose verdict counts. Same shape as the action-time rail
420
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
421
+ */
422
+ async function decideStopRules(
423
+ root: string,
424
+ policy: Policy,
425
+ sessionKey: string,
426
+ ): Promise<ReturnType<typeof coreFacade.rules.decideStop>> {
427
+ const context = { sessionKey, mode: policy.mode };
428
+ const dryRun = coreFacade.rules.decideStop(root, policy.rules, { ...context, sha: null });
429
+ if (dryRun.outcomes.length === 0) {
430
+ return dryRun;
431
+ }
432
+ return coreFacade.rules.decideStop(root, policy.rules, {
433
+ ...context,
434
+ sha: await currentGitSha(root),
435
+ });
436
+ }
437
+
390
438
  export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
391
439
  const { policy, capabilities } = ctx;
392
440
  const root = event.projectDir;
@@ -829,6 +877,32 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
829
877
  });
830
878
  }
831
879
 
880
+ /**
881
+ * The operator's own bar, last among the blockers so every gate the harness owns has already spoken — and after
882
+ * the gates specifically, because a rule asking for `gate(lint) since HEAD` can only be satisfied once lint has
883
+ * run this turn ([/decisions/ad-100.md](/decisions/ad-100.md)).
884
+ *
885
+ * invariant: `warn` returns `context`, which does not block. Everything else refuses the stop, so a rule the
886
+ * operator wrote cannot be ended past.
887
+ */
888
+ const stopRules = await decideStopRules(root, policy, sessionKey);
889
+ if (stopRules.decision.kind !== "abstain") {
890
+ if (stopRules.decision.kind !== "context") {
891
+ const worst = coreFacade.rules.strictest(stopRules.outcomes);
892
+ await coreFacade.handoff.patchHandoff(root, provider, {
893
+ slice: {
894
+ last_gate_result: "fail",
895
+ last_failure_category: "policy",
896
+ blockers: `Rule ${worst?.rule.name ?? "unknown"} is not satisfied for this HEAD.`,
897
+ next_action: worst?.missing.length
898
+ ? `Produce ${worst.missing.join(", ")}, then stop again.`
899
+ : "Satisfy the rule named in the follow-up, then stop again.",
900
+ },
901
+ });
902
+ }
903
+ return stopRules.decision;
904
+ }
905
+
832
906
  // why: the pairing of a failure with what resolved it is captured here, immediately before the record that
833
907
  // holds the failure identity is cleared. This is the one moment both halves exist
834
908
  // ([/decisions/ad-028.md](/decisions/ad-028.md)).
@@ -2,10 +2,18 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
2
  import { coreFacade } from "../core/index.ts";
3
3
  import type { Handler, HandlerContext } from "./run.ts";
4
4
  import { main } from "./run.ts";
5
+ import { observeForRules } from "./support.ts";
5
6
 
6
7
  // why: no legacy predecessor covers subagent.stop verification — this reuses the same unfinished-work
7
8
  // signal (blockers/pending/in_progress/previous_gaps) already carried on the handoff slice.
8
- export const subagentStopHandler: Handler = (event: HarnessEvent, _ctx: HandlerContext): Decision => {
9
+ export const subagentStopHandler: Handler = async (
10
+ event: HarnessEvent,
11
+ ctx: HandlerContext,
12
+ ): Promise<Decision> => {
13
+ // why before the verdict: this records that a subagent of this type finished, which is the proof an operator
14
+ // rule asks for. It cannot change the decision below ([/decisions/ad-100.md](/decisions/ad-100.md)).
15
+ await observeForRules(event, ctx);
16
+
9
17
  const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
10
18
  const unfinishedWork =
11
19
  Boolean(handoff.blockers) ||
@@ -180,3 +180,35 @@ export function formatLessonsBlock(lessons: HarnessLesson[], title: string, omit
180
180
  }
181
181
  return lines.join("\n");
182
182
  }
183
+
184
+ /**
185
+ * The producer half of the feature: what the harness witnessed, written where only the harness can write it.
186
+ *
187
+ * hazard: this did not exist in the first cut. `observe` had no caller, so the store was never written, no proof
188
+ * could ever be satisfied, and every rule that parsed denied for ever — `require:` is mandatory, so that was
189
+ * every rule ([/decisions/ad-100.md](/decisions/ad-100.md)).
190
+ *
191
+ * why `wants` first: this runs on every tool call and the sha is a process spawn. Nothing is asked of git unless
192
+ * a declared rule requires this kind of proof, so an operator whose only rule wants a subagent pays no git on any
193
+ * command.
194
+ *
195
+ * invariant: after the event, never able to change it. A rail that records what happened must not become a rail
196
+ * that decides whether it may.
197
+ */
198
+ export async function observeForRules(
199
+ event: HarnessEvent,
200
+ // why the shape and not `HandlerContext`: `run.ts` already imports this module, so naming its type here would
201
+ // close an import cycle. Only the one field is needed.
202
+ ctx: { policy: { rules: Policy["rules"] } },
203
+ ): Promise<void> {
204
+ const config = ctx.policy.rules;
205
+ if (!coreFacade.rules.wants(event.projectDir, config, event)) {
206
+ return;
207
+ }
208
+ const sha = await currentGitSha(event.projectDir);
209
+ coreFacade.rules.observe(event.projectDir, config, event, {
210
+ sha,
211
+ sessionKey: event.sessionKey,
212
+ at: new Date().toISOString(),
213
+ });
214
+ }
@@ -4,7 +4,7 @@ import { estimateCostUsd, mapPoolToNeutral } from "../platform/pricing.ts";
4
4
  import { readClaudeUsage } from "../providers/index.ts";
5
5
  import type { Handler, HandlerContext } from "./run.ts";
6
6
  import { main } from "./run.ts";
7
- import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
7
+ import { OBS_CONFIG_AUDIT, obsConfigFor, observeForRules } from "./support.ts";
8
8
 
9
9
  const OBS_KIND_BY_EVENT: Partial<Record<HarnessEventKind, ObsKind>> = {
10
10
  "tool.after": "tool.end",
@@ -48,7 +48,12 @@ function usageGenAi(event: HarnessEvent, ctx: HandlerContext): Record<string, un
48
48
  };
49
49
  }
50
50
 
51
- export const toolAfterHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
51
+ export const toolAfterHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext) => {
52
+ // why here and not at `*.before`: arriving on an after-event is what says the tool ran and did not fail. A
53
+ // failure comes as `tool.failure`, a different event this rail never sees, and the payload carries no exit code
54
+ // in any of the three shapes the two hosts send ([/decisions/ad-100.md](/decisions/ad-100.md)).
55
+ await observeForRules(event, ctx);
56
+
52
57
  coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
53
58
 
54
59
  const kind = OBS_KIND_BY_EVENT[event.event];
@@ -2,7 +2,7 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
2
  import { coreFacade } from "../core/index.ts";
3
3
  import type { Handler, HandlerContext } from "./run.ts";
4
4
  import { main } from "./run.ts";
5
- import { obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
5
+ import { currentGitSha, obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
6
6
 
7
7
  const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
8
8
 
@@ -43,6 +43,33 @@ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, de
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * why the sha is read here and not in `run.ts`: `git rev-parse` is a process spawn, and this fires on every tool
48
+ * call. It is asked for only once a rule has actually fired, so an operator who declared nothing pays nothing
49
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
50
+ */
51
+ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
52
+ const config = ctx.policy.rules;
53
+ const trigger = { event: event.event, toolName: event.toolName, command: event.command };
54
+ const dryRun = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
55
+ sha: null,
56
+ sessionKey: event.sessionKey,
57
+ mode: ctx.policy.mode,
58
+ });
59
+ if (dryRun.outcomes.length === 0) {
60
+ return { kind: "abstain" };
61
+ }
62
+ // why twice: the first pass answers whether any rule fired at all, which costs no git. Only then is the sha
63
+ // worth a process, and the second pass is the one whose verdict counts.
64
+ const sha = await currentGitSha(event.projectDir);
65
+ const verdict = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
66
+ sha,
67
+ sessionKey: event.sessionKey,
68
+ mode: ctx.policy.mode,
69
+ });
70
+ return verdict.decision;
71
+ }
72
+
46
73
  function handleShellBefore(event: HarnessEvent, ctx: HandlerContext): Decision {
47
74
  const { policy } = ctx;
48
75
  const decision = coreFacade.shellPolicy.evaluateShellCommand({
@@ -108,10 +135,10 @@ async function handleToolBefore(event: HarnessEvent, ctx: HandlerContext): Promi
108
135
  return { kind: "allow" };
109
136
  }
110
137
 
111
- export const toolBeforeHandler: Handler = (
138
+ export const toolBeforeHandler: Handler = async (
112
139
  event: HarnessEvent,
113
140
  ctx: HandlerContext,
114
- ): Decision | Promise<Decision> => {
141
+ ): Promise<Decision> => {
115
142
  // invariant: the floor runs first and reads no policy, so no config value and no agent edit can
116
143
  // reach a decision before it.
117
144
  const floor = coreFacade.floor.evaluateFloor({
@@ -159,6 +186,20 @@ export const toolBeforeHandler: Handler = (
159
186
  }
160
187
  }
161
188
 
189
+ /**
190
+ * The operator's own rules, after the floor and after the integrity check, because both are unconditional and a
191
+ * rail comes second ([/decisions/ad-077.md](/decisions/ad-077.md)).
192
+ *
193
+ * invariant: `deny` and `ask` answer here; `follow-up` and `warn` abstain and are the stop rail's business. With
194
+ * the capability off, or with no rule files, `decideAction` reads two directory entries and abstains — which is
195
+ * what keeps a machine that never opted in byte-identical to before ([/decisions/ad-100.md](/decisions/ad-100.md)).
196
+ */
197
+ const rulesVerdict = await rulesDecision(event, ctx);
198
+ if (rulesVerdict.kind !== "abstain") {
199
+ recordShellDecisionIfShell(event, ctx, rulesVerdict);
200
+ return rulesVerdict;
201
+ }
202
+
162
203
  switch (event.event) {
163
204
  case "shell.before":
164
205
  return handleShellBefore(event, ctx);
@@ -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