@tech-leads-club/harness-toolkit 0.3.1 → 0.3.3

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/bin/tlc-build.mjs CHANGED
@@ -37,6 +37,18 @@ function sourcesIn(dir) {
37
37
  .map((entry) => ({ name: basename(entry.name, ".ts"), source: join(dir, entry.name) }));
38
38
  }
39
39
 
40
+ /**
41
+ * hazard: this built every entry in ONE invocation with `--splitting`, which cut `dist/` from 5.0 MB to 548 KB —
42
+ * and shipped a broken CLI. A shared chunk carries the module body of anything two entries both import, including
43
+ * `bin/tlc-cli.ts`, and its `if (import.meta.main)` guard evaluates **true** inside that chunk. So running
44
+ * `install-runtime`, which imports the CLI for `NPM_MARKER` and `wireRuntime`, ran the CLI's `main` instead:
45
+ * `tlc harness install` printed `unknown:` and installed nothing. Published as 0.3.2 and caught by installing it
46
+ * ([/decisions/ad-098.md](/decisions/ad-098.md)).
47
+ *
48
+ * invariant: one bundle per entry, so a module that self-executes behind `import.meta.main` is inlined into the
49
+ * one program that is allowed to run it. Splitting can come back when no library module carries that guard, and
50
+ * not before — the size win is real and it is not worth a CLI that cannot install.
51
+ */
40
52
  function buildOne(source, out) {
41
53
  const result = spawnSync(
42
54
  "bun",
@@ -44,7 +56,7 @@ function buildOne(source, out) {
44
56
  { stdio: "inherit" },
45
57
  );
46
58
  if (result.error?.code === "ENOENT") {
47
- console.error("tlc-build: Bun is not on PATH, and dist/ is committed so the bundler is part of the artefact.");
59
+ console.error("tlc-build: Bun is not on PATH, and it is the only bundler this builds with.");
48
60
  console.error(" curl -fsSL https://bun.sh/install | bash");
49
61
  process.exit(1);
50
62
  }
@@ -61,6 +73,9 @@ const targets = [
61
73
 
62
74
  mkdirSync(dist, { recursive: true });
63
75
  console.log(`tlc-build → ${dist}`);
76
+
77
+ // invariant: a leftover chunk directory from the split build would ship for ever, referenced by nothing.
78
+ rmSync(join(dist, "chunks"), { recursive: true, force: true });
64
79
  for (const target of targets) {
65
80
  buildOne(target.source, join(dist, `${target.name}.mjs`));
66
81
  }
package/bin/tlc-cli.ts CHANGED
@@ -904,6 +904,61 @@ export function resolveHarnessRoot(): string {
904
904
  }
905
905
  }
906
906
 
907
+ /**
908
+ * Where npm put the package this command was installed from.
909
+ *
910
+ * hazard: `update` spawned `install-runtime` through the **runtime home's** launcher, so the tool resolved its
911
+ * source and its destination to the same directory and reported "already at … — nothing to copy". Measured on a
912
+ * scratch machine: `npm i -g` moved the package from 0.3.0 to 0.3.2 and the runtime the hooks execute stayed on
913
+ * 0.3.0. Every npm install that ever ran `update` bumped a package and kept its old code, while `doctor` said
914
+ * update "re-materialises this directory" ([/decisions/ad-098.md](/decisions/ad-098.md)).
915
+ *
916
+ * invariant: asked of npm rather than derived from this process. The CLI can be running from the runtime home,
917
+ * from the package, or from a linked clone, and only npm knows where it installs globally.
918
+ */
919
+ export function globalPackageRoot(
920
+ probe = {
921
+ npmRoot: () => spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: true }).stdout ?? "",
922
+ exists: existsSync,
923
+ },
924
+ ): string | null {
925
+ // invariant: trimmed here rather than in the probe. `npm root -g` ends in a newline, and a path with a newline
926
+ // in it fails as a directory while reading as a plausible string in an error message.
927
+ const root = probe.npmRoot().trim();
928
+ if (root.length === 0) {
929
+ return null;
930
+ }
931
+ const candidate = join(root, ...NPM_PACKAGE.split("/"));
932
+ return probe.exists(candidate) ? candidate : null;
933
+ }
934
+
935
+ /**
936
+ * invariant: the *package's* launcher runs the materialisation, not the runtime home's. A release that fixes
937
+ * `install` has to be able to deliver that fix, and the old code cannot do it.
938
+ *
939
+ * invariant: both ends are named explicitly — `TLC_ORIGIN` is where the code comes from and `TLC_INSTALL_DEST` is
940
+ * where it goes — because each of them defaults to the same conventional home when left unsaid, which is exactly
941
+ * how this became a no-op.
942
+ */
943
+ export function npmSyncPlan(
944
+ packageRoot: string,
945
+ dest: string,
946
+ ): { command: string; args: string[]; env: Record<string, string> } {
947
+ return {
948
+ command: process.execPath,
949
+ args: [join(packageRoot, "bin", "tlc-exec.mjs"), "install-runtime"],
950
+ env: { TLC_ORIGIN: packageRoot, TLC_INSTALL_DEST: dest },
951
+ };
952
+ }
953
+
954
+ export function npmRootFailureMessage(home: string): string {
955
+ return [
956
+ `update: npm reported no global root, so the package it just installed cannot be found.`,
957
+ ` The runtime at ${home} is unchanged — nothing was half-written.`,
958
+ ` Run \`npm root -g\` yourself; then \`npm i -g ${NPM_PACKAGE}@latest\` and \`tlc harness install\`.`,
959
+ ].join("\n");
960
+ }
961
+
907
962
  /**
908
963
  * Everything an install has to put in place outside the runtime directory itself: the init skill where each
909
964
  * provider reads it, the user-level hooks, and a seeded config.
@@ -1293,9 +1348,15 @@ function runUpdate(root: string): never {
1293
1348
  console.error(npmUpdateFailureMessage());
1294
1349
  process.exit(bump.status ?? 1);
1295
1350
  }
1296
- const sync = spawnSync(process.execPath, [execBinPath(), "install-runtime"], {
1351
+ const packageRoot = globalPackageRoot();
1352
+ if (packageRoot === null) {
1353
+ console.error(npmRootFailureMessage(home));
1354
+ process.exit(1);
1355
+ }
1356
+ const plan = npmSyncPlan(packageRoot, home);
1357
+ const sync = spawnSync(plan.command, plan.args, {
1297
1358
  stdio: "inherit",
1298
- env: process.env,
1359
+ env: { ...process.env, ...plan.env },
1299
1360
  });
1300
1361
  if ((sync.status ?? 1) !== 0) {
1301
1362
  process.exit(sync.status ?? 1);
package/dist/tlc-cli.mjs CHANGED
@@ -7706,6 +7706,32 @@ function resolveHarnessRoot() {
7706
7706
  return home;
7707
7707
  }
7708
7708
  }
7709
+ function globalPackageRoot(probe = {
7710
+ npmRoot: () => spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: true }).stdout ?? "",
7711
+ exists: existsSync26
7712
+ }) {
7713
+ const root = probe.npmRoot().trim();
7714
+ if (root.length === 0) {
7715
+ return null;
7716
+ }
7717
+ const candidate = join27(root, ...NPM_PACKAGE.split("/"));
7718
+ return probe.exists(candidate) ? candidate : null;
7719
+ }
7720
+ function npmSyncPlan(packageRoot, dest) {
7721
+ return {
7722
+ command: process.execPath,
7723
+ args: [join27(packageRoot, "bin", "tlc-exec.mjs"), "install-runtime"],
7724
+ env: { TLC_ORIGIN: packageRoot, TLC_INSTALL_DEST: dest }
7725
+ };
7726
+ }
7727
+ function npmRootFailureMessage(home) {
7728
+ return [
7729
+ `update: npm reported no global root, so the package it just installed cannot be found.`,
7730
+ ` The runtime at ${home} is unchanged — nothing was half-written.`,
7731
+ ` Run \`npm root -g\` yourself; then \`npm i -g ${NPM_PACKAGE}@latest\` and \`tlc harness install\`.`
7732
+ ].join(`
7733
+ `);
7734
+ }
7709
7735
  function wireRuntime(dest, home) {
7710
7736
  const lines = [];
7711
7737
  const seeded = seedConfig(dest);
@@ -7960,9 +7986,15 @@ function runUpdate(root) {
7960
7986
  console.error(npmUpdateFailureMessage());
7961
7987
  process.exit(bump.status ?? 1);
7962
7988
  }
7963
- const sync = spawnSync(process.execPath, [execBinPath(), "install-runtime"], {
7989
+ const packageRoot = globalPackageRoot();
7990
+ if (packageRoot === null) {
7991
+ console.error(npmRootFailureMessage(home));
7992
+ process.exit(1);
7993
+ }
7994
+ const plan = npmSyncPlan(packageRoot, home);
7995
+ const sync = spawnSync(plan.command, plan.args, {
7964
7996
  stdio: "inherit",
7965
- env: process.env
7997
+ env: { ...process.env, ...plan.env }
7966
7998
  });
7967
7999
  if ((sync.status ?? 1) !== 0) {
7968
8000
  process.exit(sync.status ?? 1);
@@ -8233,6 +8265,8 @@ export {
8233
8265
  pendingScreen,
8234
8266
  pairedFlagPath,
8235
8267
  npmUpdateFailureMessage,
8268
+ npmSyncPlan,
8269
+ npmRootFailureMessage,
8236
8270
  modeFilePath,
8237
8271
  missingBundles,
8238
8272
  linkedRuntimeMessage,
@@ -8243,6 +8277,7 @@ export {
8243
8277
  handoffJson,
8244
8278
  grindOn,
8245
8279
  grindFlagPath,
8280
+ globalPackageRoot,
8246
8281
  gatesPaused,
8247
8282
  focusFlagPath,
8248
8283
  fetchFailureMessage,
package/docs/log.md CHANGED
@@ -13,6 +13,10 @@ Generated from `docs/decisions/` — do not edit by hand. Run `node tools/render
13
13
  A reserved file of the [OKF v0.1](/decisions/ad-013.md) bundle: entries grouped under ISO 8601 headings,
14
14
  newest first. For what landed in which npm release, see `CHANGELOG.md` at the repository root.
15
15
 
16
+ ## 2026-08-20
17
+
18
+ - **AD-098** — Splitting is reverted: a shared chunk ran the CLI's main ([/decisions/ad-098.md](/decisions/ad-098.md))
19
+
16
20
  ## 2026-08-19
17
21
 
18
22
  - **AD-081** — The manifest npm publishes is checked here, because the release runner was the only thing reading it ([/decisions/ad-081.md](/decisions/ad-081.md))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -54,12 +54,18 @@ const SUBJECT = /^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<breaking>!)?:\s*(?<r
54
54
  const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
55
55
 
56
56
  /**
57
- * invariant: `feat` and `fix` are the only types that release. Everything else — `docs`, `chore`, `refactor`,
58
- * `test`, `ci`, `build`, `perf`, `style` — lands without moving the version. That is what stops the release's own
59
- * commit from earning the next version and looping, which this pipeline did six times in nine minutes.
57
+ * invariant: `feat`, `fix` and `perf` release. Everything else — `docs`, `chore`, `refactor`, `test`, `ci`,
58
+ * `build`, `style` — lands without moving the version, which together with the inert scopes is what stops the
59
+ * release's own `chore(release):` commit from earning the next version and looping, as this pipeline did six times
60
+ * in nine minutes ([/decisions/ad-087.md](/decisions/ad-087.md)).
61
+ *
62
+ * hazard: `perf` was excluded with the loop as the stated reason, and the loop had nothing to do with it — `chore`
63
+ * and the inert scopes cover that on their own. The cost was measured: a change that cut the published package
64
+ * from 1.6 MB to 426 kB and about 4 ms from every hook sat on `main` unreleased, waiting for an unrelated `fix` to
65
+ * carry it to anybody ([/decisions/ad-098.md](/decisions/ad-098.md)).
60
66
  */
61
67
  export const MINOR_TYPES: ReadonlySet<string> = new Set(["feat"]);
62
- export const PATCH_TYPES: ReadonlySet<string> = new Set(["fix"]);
68
+ export const PATCH_TYPES: ReadonlySet<string> = new Set(["fix", "perf"]);
63
69
 
64
70
  /**
65
71
  * invariant: a scope on this list never releases, whatever the type. `fix(ci)` and `fix(gate)` are repository