@tech-leads-club/harness-toolkit 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +44 -8
  2. package/bin/tlc-cli.ts +40 -1
  3. package/bin/tlc-exec.d.mts +1 -0
  4. package/bin/tlc-exec.mjs +47 -2
  5. package/capabilities/catalog.json +47 -30
  6. package/dist/compact-before.mjs +84 -78
  7. package/dist/doctor.mjs +87 -81
  8. package/dist/help-topic.mjs +6 -5
  9. package/dist/init-project.mjs +147 -9
  10. package/dist/install-runtime.mjs +85 -79
  11. package/dist/lessons-cli.mjs +87 -81
  12. package/dist/obs-cli.mjs +83 -77
  13. package/dist/price-lookup.mjs +2 -2
  14. package/dist/prompt-submit.mjs +84 -78
  15. package/dist/refresh-model-prices.mjs +85 -79
  16. package/dist/response-after.mjs +84 -78
  17. package/dist/run.mjs +84 -78
  18. package/dist/session-end.mjs +90 -84
  19. package/dist/session-start.mjs +92 -86
  20. package/dist/shim.mjs +82 -76
  21. package/dist/stop.mjs +90 -84
  22. package/dist/subagent-start.mjs +84 -78
  23. package/dist/subagent-stop.mjs +85 -79
  24. package/dist/support.mjs +88 -82
  25. package/dist/tlc-cli.mjs +104 -98
  26. package/dist/tool-after.mjs +84 -78
  27. package/dist/tool-before.mjs +84 -78
  28. package/dist/tool-failure.mjs +84 -78
  29. package/dist/uninstall-runtime.mjs +4 -4
  30. package/docs/architecture.md +1 -0
  31. package/docs/concepts.md +86 -0
  32. package/docs/diagnose.md +20 -0
  33. package/docs/init.md +10 -2
  34. package/docs/lessons.md +12 -0
  35. package/docs/log.md +5 -0
  36. package/package.json +1 -1
  37. package/skills/harness-init/references/capabilities.md +54 -0
  38. package/src/core/core.facade.ts +54 -0
  39. package/src/core/floor/floor.paths.ts +2 -2
  40. package/src/core/floor/floor.policy-surface.ts +6 -1
  41. package/src/core/lesson/lesson.select.ts +30 -7
  42. package/src/core/policy/policy.defaults.ts +3 -0
  43. package/src/core/policy/policy.integrity.ts +2 -2
  44. package/src/core/policy/policy.loader.ts +14 -3
  45. package/src/core/policy/policy.shadow.ts +97 -0
  46. package/src/core/policy/policy.types.ts +8 -0
  47. package/src/core/release/release.decisions.ts +3 -13
  48. package/src/core/rules/rules.decide.ts +123 -0
  49. package/src/core/rules/rules.observe.ts +76 -0
  50. package/src/core/rules/rules.parse.ts +142 -0
  51. package/src/core/rules/rules.proof.ts +130 -0
  52. package/src/core/rules/rules.service.ts +141 -0
  53. package/src/core/rules/rules.store.ts +77 -0
  54. package/src/core/rules/rules.trigger.ts +101 -0
  55. package/src/core/rules/rules.types.ts +64 -0
  56. package/src/entrypoints/shim.ts +9 -1
  57. package/src/entrypoints/stop.ts +75 -1
  58. package/src/entrypoints/subagent-stop.ts +9 -1
  59. package/src/entrypoints/support.ts +32 -0
  60. package/src/entrypoints/tool-after.ts +7 -2
  61. package/src/entrypoints/tool-before.ts +44 -3
  62. package/src/platform/frontmatter.ts +142 -0
  63. package/src/platform/links.ts +32 -0
  64. package/src/platform/paths.ts +58 -4
  65. package/src/platform/pricing.ts +3 -3
  66. package/src/platform/screen.ts +62 -3
  67. package/tools/doctor.ts +162 -2
  68. package/tools/help-topic.ts +39 -23
  69. package/tools/init-project.ts +51 -6
  70. package/tools/install-runtime.ts +23 -2
  71. package/tools/lessons-cli.ts +4 -1
  72. package/tools/refresh-model-prices.ts +2 -2
  73. package/tools/uninstall-runtime.ts +11 -3
@@ -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: [