@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
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
  }
@@ -5,22 +5,34 @@ import { runtimeHome } from "../src/platform/paths.ts";
5
5
  const docsDir = join(runtimeHome(), "docs");
6
6
  const topic = (process.argv[2] ?? "").toLowerCase();
7
7
 
8
- const TOPICS: Record<string, string> = {
9
- architecture: "architecture.md",
10
- concepts: "concepts.md",
11
- measure: "measure.md",
12
- metrics: "measure.md",
13
- prices: "measure.md",
14
- price: "measure.md",
15
- cost: "measure.md",
16
- costs: "measure.md",
17
- diagnose: "diagnose.md",
18
- doctor: "diagnose.md",
19
- debug: "diagnose.md",
20
- init: "init.md",
21
- setup: "init.md",
22
- lessons: "lessons.md",
23
- lesson: "lessons.md",
8
+ /**
9
+ * A topic is a document, or one heading inside one. `section` exists because two topics live inside a larger
10
+ * document and printing the whole of it would bury the answer.
11
+ */
12
+ type Topic = { file: string; section?: string };
13
+
14
+ const TOPICS: Record<string, Topic> = {
15
+ architecture: { file: "architecture.md" },
16
+ concepts: { file: "concepts.md" },
17
+ measure: { file: "measure.md" },
18
+ metrics: { file: "measure.md" },
19
+ prices: { file: "measure.md", section: "## Prices" },
20
+ price: { file: "measure.md", section: "## Prices" },
21
+ cost: { file: "measure.md", section: "## Prices" },
22
+ costs: { file: "measure.md", section: "## Prices" },
23
+ diagnose: { file: "diagnose.md" },
24
+ doctor: { file: "diagnose.md" },
25
+ debug: { file: "diagnose.md" },
26
+ init: { file: "init.md" },
27
+ setup: { file: "init.md" },
28
+ lessons: { file: "lessons.md" },
29
+ lesson: { file: "lessons.md" },
30
+ rules: { file: "concepts.md", section: "## operator rules" },
31
+ rule: { file: "concepts.md", section: "## operator rules" },
32
+ runtime: { file: "concepts.md", section: "## which runtime answers a hook" },
33
+ dev: { file: "concepts.md", section: "## which runtime answers a hook" },
34
+ settings: { file: "concepts.md", section: "## where a setting lives" },
35
+ config: { file: "concepts.md", section: "## where a setting lives" },
24
36
  };
25
37
 
26
38
  function printIndex(): void {
@@ -30,6 +42,9 @@ TOPICS
30
42
  tlc harness help architecture
31
43
  tlc harness help concepts
32
44
  tlc harness help lessons
45
+ tlc harness help rules
46
+ tlc harness help settings
47
+ tlc harness help runtime
33
48
  tlc harness help measure
34
49
  tlc harness help prices
35
50
  tlc harness help diagnose
@@ -49,25 +64,26 @@ if (!topic || topic === "help" || topic === "-h" || topic === "--help") {
49
64
  process.exit(0);
50
65
  }
51
66
 
52
- const file = TOPICS[topic];
53
- if (!file) {
67
+ const entry = TOPICS[topic];
68
+ if (!entry) {
54
69
  console.error(`unknown topic: ${topic}`);
55
70
  printIndex();
56
71
  process.exit(1);
57
72
  }
58
73
 
59
- const path = join(docsDir, file);
74
+ const path = join(docsDir, entry.file);
60
75
  if (!existsSync(path)) {
61
76
  console.error(`missing doc: ${path}`);
62
77
  process.exit(1);
63
78
  }
64
79
 
65
80
  let body = readFileSync(path, "utf8");
66
- if (topic === "prices" || topic === "price" || topic === "cost" || topic === "costs") {
67
- const marker = "## Prices";
68
- const idx = body.indexOf(marker);
81
+ if (entry.section) {
82
+ const idx = body.indexOf(entry.section);
83
+ // why the whole document when the heading is absent: an answer from the wrong version of a doc beats no answer,
84
+ // and the section check already runs in the gate.
69
85
  if (idx >= 0) {
70
- body = `# Prices\n\n${body.slice(idx)}`;
86
+ body = body.slice(idx);
71
87
  }
72
88
  }
73
89
 
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, sep } from "node:path";
3
3
  import { applyCursorWiring, renderCursorHooksDocument } from "../bin/write-user-hooks.mjs";
4
4
  import type { WiringEntry } from "../src/contracts/index.ts";
5
+ import { coreFacade } from "../src/core/index.ts";
5
6
  import { DEFAULTS } from "../src/core/policy/policy.defaults.ts";
6
7
  import { claudeConfigDir, cursorConfigDir, projectConfigPath, runtimeHome } from "../src/platform/paths.ts";
7
8
  import { render, type Screen } from "../src/platform/screen.ts";
@@ -150,17 +151,37 @@ export function mergeGitignore(root: string): { text: string; changed: boolean }
150
151
  return { text: `${withoutTrailingBlank.join("\n").replace(/\n+$/, "")}\n`, changed: true };
151
152
  }
152
153
 
154
+ /**
155
+ * hazard: this returned `DEFAULTS` whole, and the wizard's `--stdin-json` carried every knob it had collected. Both
156
+ * wrote values nobody chose, and each one shadows the machine tier for ever — raise a number there afterwards and
157
+ * no such project sees it. Measured on one repository: 29 keys restating the tiers below
158
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
159
+ *
160
+ * invariant: pruning cannot change the effective policy, because a leaf is dropped only when the tiers below
161
+ * already resolve to it. An existing config is still returned untouched — rewriting an operator's file is not
162
+ * this command's business.
163
+ */
153
164
  export function resolvePolicy(root: string, flags: InitFlags, stdinText: string | null): unknown {
154
165
  if (flags.stdinJson && !flags.minimal) {
155
166
  if (!stdinText || stdinText.trim() === "") {
156
167
  throw new Error("stdin-json: empty stdin");
157
168
  }
158
- return JSON.parse(stdinText);
169
+ return prune(JSON.parse(stdinText) as Record<string, unknown>);
159
170
  }
160
171
  if (!flags.minimal && !flags.stdinJson && existsSync(projectConfigPath(root))) {
161
172
  return JSON.parse(readFileSync(projectConfigPath(root), "utf8"));
162
173
  }
163
- return DEFAULTS;
174
+ /**
175
+ * hazard: this returned `DEFAULTS`, and pruning it was not enough. Against a machine tier that enabled things,
176
+ * the shipped defaults *differ* — so a fresh project wrote `comments.enabled: false` and turned off, in that
177
+ * repository, a capability the operator had switched on for the machine. A project that has decided nothing must
178
+ * say nothing ([/decisions/ad-101.md](/decisions/ad-101.md)).
179
+ */
180
+ return { version: DEFAULTS.version };
181
+ }
182
+
183
+ function prune(policy: Record<string, unknown>): Record<string, unknown> {
184
+ return coreFacade.policy.pruneShadowed(policy, coreFacade.policy.resolvedWithoutProjectTier());
164
185
  }
165
186
 
166
187
  export type ProviderPresence = { cursor: boolean; claude: boolean };
@@ -197,10 +218,22 @@ export function buildPlan(
197
218
 
198
219
  export type ApplyOutcome = {
199
220
  configPath: string;
221
+ /** why reported: silence after keeping a file reads as having written it. */
222
+ configKept: boolean;
200
223
  cursor: { skipped: true } | { skipped: false; status: string; target: string };
201
224
  claude: { skipped: true } | { skipped: false; status: string; target: string };
202
225
  };
203
226
 
227
+ /**
228
+ * why exported: the repository's convention is that text is built by a named function and printed by the caller,
229
+ * so what an operator reads can be asserted. Silence after keeping a file reads as having written it.
230
+ */
231
+ export function configLine(outcome: ApplyOutcome): string {
232
+ return outcome.configKept
233
+ ? `kept ${outcome.configPath} — already configured; delete it to start over, or run the wizard to replace it`
234
+ : `wrote ${outcome.configPath}`;
235
+ }
236
+
204
237
  export function applyPlan(
205
238
  root: string,
206
239
  flags: InitFlags,
@@ -209,8 +242,20 @@ export function applyPlan(
209
242
  ): ApplyOutcome {
210
243
  const policy = resolvePolicy(root, flags, stdinText);
211
244
  const configPath = projectConfigPath(root);
212
- mkdirSync(dirname(configPath), { recursive: true });
213
- writeFileSync(configPath, `${JSON.stringify(policy, null, 2)}\n`);
245
+ /**
246
+ * hazard: this wrote unconditionally. `init --minimal` on a configured project replaced the operator's file —
247
+ * with the whole default policy before, with a bare version marker after that changed. Both destroy choices
248
+ * nobody asked to undo, and neither said so ([/decisions/ad-101.md](/decisions/ad-101.md)).
249
+ *
250
+ * invariant: an existing config is replaced only when the operator supplied one to replace it with. That is what
251
+ * `--stdin-json` is — the wizard's collected answers — and it is the one route that carries consent. Everything
252
+ * else keeps the file. This is the rule `linkDir` already follows: a real file at the target is somebody's work.
253
+ */
254
+ const kept = existsSync(configPath) && !flags.stdinJson;
255
+ if (!kept) {
256
+ mkdirSync(dirname(configPath), { recursive: true });
257
+ writeFileSync(configPath, `${JSON.stringify(policy, null, 2)}\n`);
258
+ }
214
259
 
215
260
  const launcher = launcherPath();
216
261
 
@@ -242,7 +287,7 @@ export function applyPlan(
242
287
  const gitignore = mergeGitignore(root);
243
288
  writeFileSync(join(root, ".gitignore"), gitignore.text);
244
289
 
245
- return { configPath, cursor, claude };
290
+ return { configPath, configKept: kept, cursor, claude };
246
291
  }
247
292
 
248
293
  async function readStdin(): Promise<string> {
@@ -270,7 +315,7 @@ export async function main(argv: string[]): Promise<void> {
270
315
  }
271
316
 
272
317
  const outcome = applyPlan(root, flags, presence, stdinText);
273
- console.log(`wrote ${outcome.configPath}`);
318
+ console.log(configLine(outcome));
274
319
  if (outcome.cursor.skipped) {
275
320
  console.log("init: cursor not installed — skipped project hooks.json");
276
321
  } else {
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { NPM_MARKER, NPM_PACKAGE, wireRuntime } from "../bin/tlc-cli.ts";
5
5
  import { linkDir } from "../src/platform/links.ts";
@@ -71,8 +71,29 @@ export function originRoot(env: NodeJS.ProcessEnv = process.env): string {
71
71
  return home && home.length > 0 ? resolve(home) : conventionalRuntimeHome();
72
72
  }
73
73
 
74
+ /**
75
+ * hazard: this compared `resolve(source)` with `resolve(dest)`, and `resolve` does not follow a symlink. An
76
+ * operator who installed with `--link` has a runtime home that *is* a link to their checkout, so the two paths
77
+ * differed lexically while naming the same directory — the in-place guard missed, `rmSync` followed the link, and
78
+ * the first entry of the payload deleted the checkout's own `bin/` before `cpSync` failed on the source it had
79
+ * just removed. Measured on this repository: the gate ate its own `bin/` ([/decisions/ad-100.md](/decisions/ad-100.md)).
80
+ *
81
+ * why the fallback: a destination that does not exist yet has no real path, and a first install is exactly that
82
+ * case. Then the lexical answer is the only one there is, and it is correct — nothing is there to alias.
83
+ */
84
+ function samePlace(a: string, b: string): boolean {
85
+ const real = (path: string): string => {
86
+ try {
87
+ return realpathSync(path);
88
+ } catch {
89
+ return resolve(path);
90
+ }
91
+ };
92
+ return real(a) === real(b);
93
+ }
94
+
74
95
  export function installRuntime(source: string, dest: string): InstallReport {
75
- if (resolve(source) === resolve(dest)) {
96
+ if (samePlace(source, dest)) {
76
97
  // why: the git route already has the code at the destination. Copying a directory onto itself is the one
77
98
  // input that turns a sync into data loss.
78
99
  return { kind: "in-place", source, dest, entries: [], missing: [] };
@@ -159,7 +159,10 @@ export function listScreen(report: LessonsListReport): Screen {
159
159
  },
160
160
  { label: "notes", value: notes.join(" ") },
161
161
  ],
162
- lines: ["", row.instruction.slice(0, 160)],
162
+ // why the whole instruction: it is the operator's own text, and a slice with no marker is how 103
163
+ // characters of a 263-character lesson vanished mid-word ([/decisions/ad-101.md](/decisions/ad-101.md)).
164
+ lines: ["", row.instruction],
165
+ wrap: true,
163
166
  });
164
167
  }
165
168
 
@@ -2,7 +2,7 @@
2
2
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
4
  import { coreFacade } from "../src/core/index.ts";
5
- import { runtimeHome } from "../src/platform/paths.ts";
5
+ import { machineHome } from "../src/platform/paths.ts";
6
6
  import {
7
7
  cataloguePath,
8
8
  FALLBACK_PLANE,
@@ -23,7 +23,7 @@ import {
23
23
  *
24
24
  * invariant: written where it is read. One resolution, `runtimeHome()`, used by both sides.
25
25
  */
26
- const HARNESS_HOME = runtimeHome();
26
+ const HARNESS_HOME = machineHome();
27
27
 
28
28
  /**
29
29
  * The plane a provider's own rates land in. It is the provider's id, because the catalogue is keyed by who bills
@@ -12,7 +12,13 @@ import {
12
12
  import { homedir } from "node:os";
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
14
14
  import { NPM_MARKER, NPM_PACKAGE } from "../bin/tlc-cli.ts";
15
- import { claudeConfigDir, cursorConfigDir, runtimeHome } from "../src/platform/paths.ts";
15
+ import {
16
+ claudeConfigDir,
17
+ cursorConfigDir,
18
+ launcherBinDir,
19
+ launcherNames,
20
+ runtimeHome,
21
+ } from "../src/platform/paths.ts";
16
22
  import { type Row, render, type Screen } from "../src/platform/screen.ts";
17
23
  import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
18
24
  import { removeClaudeWiring, unmergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
@@ -57,10 +63,12 @@ export type UninstallTargets = {
57
63
  */
58
64
  export function uninstallTargets(env: NodeJS.ProcessEnv = process.env): UninstallTargets {
59
65
  const userHome = homedir();
60
- const binDir = env.TLC_BIN_DIR?.trim() || join(userHome, ".local", "bin");
66
+ // invariant: the same definition install links into, so what one creates the other removes
67
+ // ([/decisions/ad-101.md](/decisions/ad-101.md)).
68
+ const binDir = launcherBinDir(env);
61
69
  return {
62
70
  home: runtimeHome(env),
63
- binLinks: [join(binDir, "tlc"), join(binDir, "tlc.cmd")],
71
+ binLinks: launcherNames().map((name) => join(binDir, name)),
64
72
  claudeSettings: join(claudeConfigDir(), "settings.json"),
65
73
  cursorHooks: join(cursorConfigDir(), "hooks.json"),
66
74
  skillLinks: [