@tech-leads-club/harness-toolkit 0.2.1 → 0.3.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 (142) hide show
  1. package/README.md +22 -26
  2. package/bin/tlc-build.mjs +93 -0
  3. package/bin/tlc-cli.ts +117 -58
  4. package/bin/tlc-exec.mjs +16 -13
  5. package/dist/compact-before.mjs +151 -15
  6. package/dist/doctor.mjs +288 -45
  7. package/dist/help-topic.mjs +0 -0
  8. package/dist/init-project.mjs +15 -14
  9. package/dist/install-runtime.mjs +100 -17
  10. package/dist/lessons-cli.mjs +152 -8
  11. package/dist/obs-cli.mjs +149 -8
  12. package/dist/price-lookup.mjs +45 -23
  13. package/dist/prompt-submit.mjs +151 -15
  14. package/dist/refresh-model-prices.mjs +7190 -46
  15. package/dist/response-after.mjs +151 -15
  16. package/dist/run.mjs +151 -15
  17. package/dist/session-end.mjs +151 -15
  18. package/dist/session-start.mjs +151 -15
  19. package/dist/shim.mjs +7040 -16
  20. package/dist/stop.mjs +151 -15
  21. package/dist/subagent-start.mjs +151 -15
  22. package/dist/subagent-stop.mjs +151 -15
  23. package/dist/support.mjs +149 -8
  24. package/dist/tlc-cli.mjs +289 -89
  25. package/dist/tool-after.mjs +196 -38
  26. package/dist/tool-before.mjs +151 -15
  27. package/dist/tool-failure.mjs +151 -15
  28. package/dist/uninstall-runtime.mjs +9 -10
  29. package/docs/log.md +7 -0
  30. package/docs/measure.md +35 -31
  31. package/package.json +6 -5
  32. package/src/core/core.facade.ts +18 -0
  33. package/src/core/index.ts +3 -0
  34. package/src/core/pricing/pricing.freshness.ts +118 -0
  35. package/src/core/release/release.version.ts +147 -0
  36. package/src/core/shim/shim.precedence.ts +72 -0
  37. package/src/core/skill/skill.link.ts +92 -0
  38. package/src/entrypoints/shim.ts +49 -11
  39. package/src/platform/fs-atomic.ts +61 -23
  40. package/src/platform/links.ts +73 -0
  41. package/src/platform/paths.ts +27 -0
  42. package/src/platform/pricing.ts +139 -31
  43. package/src/providers/cursor/cursor.wiring.ts +11 -8
  44. package/tools/doctor.ts +127 -10
  45. package/tools/init-project.ts +34 -14
  46. package/tools/install-runtime.ts +89 -6
  47. package/tools/refresh-model-prices.ts +242 -75
  48. package/tools/uninstall-runtime.ts +23 -19
  49. package/CHANGELOG.md +0 -95
  50. package/bin/tlc-build +0 -80
  51. package/bin/tlc-exec +0 -10
  52. package/bin/tlc-exec.cmd +0 -4
  53. package/docs/decisions/ad-001.md +0 -32
  54. package/docs/decisions/ad-002.md +0 -51
  55. package/docs/decisions/ad-003.md +0 -30
  56. package/docs/decisions/ad-004.md +0 -37
  57. package/docs/decisions/ad-005.md +0 -36
  58. package/docs/decisions/ad-006.md +0 -49
  59. package/docs/decisions/ad-007.md +0 -36
  60. package/docs/decisions/ad-008.md +0 -54
  61. package/docs/decisions/ad-009.md +0 -61
  62. package/docs/decisions/ad-010.md +0 -45
  63. package/docs/decisions/ad-011.md +0 -59
  64. package/docs/decisions/ad-012.md +0 -71
  65. package/docs/decisions/ad-013.md +0 -87
  66. package/docs/decisions/ad-014.md +0 -56
  67. package/docs/decisions/ad-015.md +0 -33
  68. package/docs/decisions/ad-016.md +0 -98
  69. package/docs/decisions/ad-017.md +0 -65
  70. package/docs/decisions/ad-018.md +0 -77
  71. package/docs/decisions/ad-019.md +0 -75
  72. package/docs/decisions/ad-020.md +0 -88
  73. package/docs/decisions/ad-021.md +0 -57
  74. package/docs/decisions/ad-022.md +0 -120
  75. package/docs/decisions/ad-023.md +0 -87
  76. package/docs/decisions/ad-024.md +0 -85
  77. package/docs/decisions/ad-025.md +0 -92
  78. package/docs/decisions/ad-026.md +0 -131
  79. package/docs/decisions/ad-027.md +0 -90
  80. package/docs/decisions/ad-028.md +0 -90
  81. package/docs/decisions/ad-029.md +0 -73
  82. package/docs/decisions/ad-030.md +0 -97
  83. package/docs/decisions/ad-031.md +0 -94
  84. package/docs/decisions/ad-032.md +0 -83
  85. package/docs/decisions/ad-033.md +0 -89
  86. package/docs/decisions/ad-034.md +0 -86
  87. package/docs/decisions/ad-035.md +0 -86
  88. package/docs/decisions/ad-036.md +0 -68
  89. package/docs/decisions/ad-037.md +0 -47
  90. package/docs/decisions/ad-038.md +0 -52
  91. package/docs/decisions/ad-039.md +0 -69
  92. package/docs/decisions/ad-040.md +0 -89
  93. package/docs/decisions/ad-041.md +0 -98
  94. package/docs/decisions/ad-042.md +0 -82
  95. package/docs/decisions/ad-043.md +0 -79
  96. package/docs/decisions/ad-044.md +0 -61
  97. package/docs/decisions/ad-045.md +0 -94
  98. package/docs/decisions/ad-046.md +0 -111
  99. package/docs/decisions/ad-047.md +0 -96
  100. package/docs/decisions/ad-048.md +0 -85
  101. package/docs/decisions/ad-049.md +0 -66
  102. package/docs/decisions/ad-050.md +0 -94
  103. package/docs/decisions/ad-051.md +0 -69
  104. package/docs/decisions/ad-052.md +0 -69
  105. package/docs/decisions/ad-053.md +0 -78
  106. package/docs/decisions/ad-054.md +0 -98
  107. package/docs/decisions/ad-055.md +0 -74
  108. package/docs/decisions/ad-056.md +0 -85
  109. package/docs/decisions/ad-057.md +0 -68
  110. package/docs/decisions/ad-058.md +0 -97
  111. package/docs/decisions/ad-059.md +0 -82
  112. package/docs/decisions/ad-060.md +0 -75
  113. package/docs/decisions/ad-061.md +0 -68
  114. package/docs/decisions/ad-062.md +0 -72
  115. package/docs/decisions/ad-063.md +0 -84
  116. package/docs/decisions/ad-064.md +0 -79
  117. package/docs/decisions/ad-065.md +0 -81
  118. package/docs/decisions/ad-066.md +0 -111
  119. package/docs/decisions/ad-067.md +0 -64
  120. package/docs/decisions/ad-068.md +0 -79
  121. package/docs/decisions/ad-069.md +0 -74
  122. package/docs/decisions/ad-070.md +0 -86
  123. package/docs/decisions/ad-071.md +0 -93
  124. package/docs/decisions/ad-072.md +0 -82
  125. package/docs/decisions/ad-073.md +0 -102
  126. package/docs/decisions/ad-074.md +0 -91
  127. package/docs/decisions/ad-075.md +0 -79
  128. package/docs/decisions/ad-076.md +0 -102
  129. package/docs/decisions/ad-077.md +0 -94
  130. package/docs/decisions/ad-078.md +0 -84
  131. package/docs/decisions/ad-079.md +0 -73
  132. package/docs/decisions/ad-080.md +0 -86
  133. package/docs/decisions/ad-081.md +0 -70
  134. package/docs/decisions/ad-082.md +0 -79
  135. package/docs/decisions/ad-083.md +0 -88
  136. package/docs/decisions/index.md +0 -111
  137. package/model-aliases.json +0 -12
  138. package/model-prices.cursor.json +0 -410
  139. package/model-prices.json +0 -1
  140. package/tools/test-env.mjs +0 -28
  141. package/tools/test-env.names.d.mts +0 -1
  142. package/tools/test-env.names.mjs +0 -14
@@ -0,0 +1,147 @@
1
+ /**
2
+ * How a release tag is spelled.
3
+ *
4
+ * hazard: the changelog generator listed tags with the glob `v*` while every tag this repository has ever created
5
+ * is `harness-toolkit-v…`. It matched none, put all 88 decision records under `## Unreleased`, and passed its own
6
+ * `--check` because the generated document and the committed one were wrong in the same way. Three releases shipped
7
+ * with a changelog attributing nothing to any of them ([/decisions/ad-087.md](/decisions/ad-087.md)).
8
+ *
9
+ * invariant: derived from the package name, which is the same source `release-please-config.json` writes the tag
10
+ * from. A prefix written down twice is a prefix that drifts once.
11
+ */
12
+ export function tagPrefixFor(packageName: string): string {
13
+ const unscoped = packageName.includes("/") ? (packageName.split("/").pop() ?? packageName) : packageName;
14
+ return `${unscoped}-v`;
15
+ }
16
+
17
+ export type SemVer = { major: number; minor: number; patch: number };
18
+
19
+ export function parseVersion(text: string): SemVer | null {
20
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text.trim());
21
+ if (match === null) {
22
+ return null;
23
+ }
24
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
25
+ }
26
+
27
+ /** The version inside a tag, or null when the tag belongs to another package or another scheme. */
28
+ export function versionInTag(packageName: string, tag: string): string | null {
29
+ const prefix = tagPrefixFor(packageName);
30
+ if (!tag.startsWith(prefix)) {
31
+ return null;
32
+ }
33
+ const rest = tag.slice(prefix.length);
34
+ return parseVersion(rest) === null ? null : rest;
35
+ }
36
+
37
+ /**
38
+ * The version a set of commits earns, from Conventional Commit subjects.
39
+ *
40
+ * why: the release writes to `main` directly and publishes before it tags, so nothing assembles the bump in a pull
41
+ * request for a human to look at. The arithmetic therefore has to be a pure function with tests rather than a step
42
+ * inside somebody else's action ([/decisions/ad-087.md](/decisions/ad-087.md)).
43
+ */
44
+ export type Bump = "major" | "minor" | "patch" | "none";
45
+
46
+ export type Commit = {
47
+ /** The subject line, e.g. `fix(gate): …`. */
48
+ subject: string;
49
+ /** The body, where a `BREAKING CHANGE:` footer lives. */
50
+ body?: string;
51
+ };
52
+
53
+ const SUBJECT = /^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<breaking>!)?:\s*(?<rest>.+)$/;
54
+ const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:/m;
55
+
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.
60
+ */
61
+ export const MINOR_TYPES: ReadonlySet<string> = new Set(["feat"]);
62
+ export const PATCH_TYPES: ReadonlySet<string> = new Set(["fix"]);
63
+
64
+ /**
65
+ * invariant: a scope on this list never releases, whatever the type. `fix(ci)` and `fix(gate)` are repository
66
+ * plumbing that cannot reach anyone who installed the package — three versions were published for exactly that
67
+ * kind of work before this existed.
68
+ */
69
+ export const INERT_SCOPES: ReadonlySet<string> = new Set(["ci", "gate", "release", "docs", "deps-dev"]);
70
+
71
+ export function bumpFor(commit: Commit): Bump {
72
+ const match = SUBJECT.exec(commit.subject.trim());
73
+ if (match?.groups === undefined) {
74
+ return "none";
75
+ }
76
+ const { type, scope, breaking } = match.groups as { type: string; scope?: string; breaking?: string };
77
+ if (scope !== undefined && INERT_SCOPES.has(scope)) {
78
+ return "none";
79
+ }
80
+ if (breaking === "!" || BREAKING_FOOTER.test(commit.body ?? "")) {
81
+ return "major";
82
+ }
83
+ if (MINOR_TYPES.has(type)) {
84
+ return "minor";
85
+ }
86
+ return PATCH_TYPES.has(type) ? "patch" : "none";
87
+ }
88
+
89
+ const RANK: Record<Bump, number> = { none: 0, patch: 1, minor: 2, major: 3 };
90
+
91
+ export function highestBump(commits: readonly Commit[]): Bump {
92
+ return commits.reduce<Bump>((best, commit) => {
93
+ const bump = bumpFor(commit);
94
+ return RANK[bump] > RANK[best] ? bump : best;
95
+ }, "none");
96
+ }
97
+
98
+ /**
99
+ * why: below 1.0.0 a breaking change takes the minor, not the major. Reaching 1.0.0 is a claim about stability that
100
+ * a commit message must not be able to make on its own.
101
+ *
102
+ * hazard: a feature still takes the minor below 1.0.0. Rebasing `feat` down to a patch is a *different* convention
103
+ * and this repository does not use it — the history says so: 0.1.0 went to 0.2.0 on a `feat:`. Implementing both at
104
+ * once would silently renumber every future feature.
105
+ */
106
+ export function applyBump(current: SemVer, bump: Bump): SemVer {
107
+ if (bump === "none") {
108
+ return current;
109
+ }
110
+ const effective: Exclude<Bump, "none"> = current.major === 0 && bump === "major" ? "minor" : bump;
111
+ switch (effective) {
112
+ case "major":
113
+ return { major: current.major + 1, minor: 0, patch: 0 };
114
+ case "minor":
115
+ return { major: current.major, minor: current.minor + 1, patch: 0 };
116
+ default:
117
+ return { major: current.major, minor: current.minor, patch: current.patch + 1 };
118
+ }
119
+ }
120
+
121
+ export type VersionPlan = {
122
+ current: string;
123
+ next: string;
124
+ bump: Bump;
125
+ released: boolean;
126
+ /** The subjects that earned the bump, for the run's log. */
127
+ reasons: string[];
128
+ };
129
+
130
+ export function planVersion(currentVersion: string, commits: readonly Commit[]): VersionPlan {
131
+ const current = parseVersion(currentVersion);
132
+ if (current === null) {
133
+ throw new Error(`release: \`${currentVersion}\` is not a version this can bump`);
134
+ }
135
+ const bump = highestBump(commits);
136
+ return {
137
+ current: formatVersion(current),
138
+ next: formatVersion(applyBump(current, bump)),
139
+ bump,
140
+ released: bump !== "none",
141
+ reasons: commits.filter((commit) => bumpFor(commit) !== "none").map((commit) => commit.subject),
142
+ };
143
+ }
144
+
145
+ export function formatVersion(version: SemVer): string {
146
+ return `${version.major}.${version.minor}.${version.patch}`;
147
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Whether the project-level shim should stand down because a user-level hook already covers this event.
3
+ *
4
+ * hazard: this was `if (process.env.TLC_ACTIVE === "1")`, and nothing in the repository ever set `TLC_ACTIVE`.
5
+ * Four documents stated that the user-level `sessionStart` hook sets it; none could, because a hook cannot export
6
+ * an environment variable to a later hook process — the host's own documentation says there is no field that
7
+ * passes state between hook invocations. So the condition was never true, the shim never stood down, and both
8
+ * levels ran the handler on every overlapping event. Measured on one machine: eleven hook groups registered at
9
+ * user level, six at project level, six events overlapping, and the host merges rather than replaces — it
10
+ * deduplicates only byte-identical handlers, and these differ (`… tlc-exec.mjs shim stop` against
11
+ * `… tlc-exec.mjs stop`) ([/decisions/ad-095.md](/decisions/ad-095.md)).
12
+ *
13
+ * invariant: the answer is derived from what is on disk, not from what a previous process might have exported.
14
+ * Two hooks in the same event are separate processes with no channel between them, so the only thing they can
15
+ * agree on is a file both can read.
16
+ */
17
+
18
+ export type HookEntry = { command?: string; args?: readonly string[] };
19
+ export type HookMatcher = { hooks?: readonly HookEntry[] };
20
+ export type ProviderSettings = { hooks?: Record<string, readonly HookMatcher[]> };
21
+
22
+ /**
23
+ * why: the launcher's file name, not its full path. The user-level hook names the runtime home
24
+ * (`~/.tlc/harness/bin/tlc-exec.mjs`) and the project shim names wherever init resolved the runtime from — the
25
+ * paths differ by construction, so comparing them would never match. What identifies the harness is the launcher
26
+ * it runs and the handler it runs it with.
27
+ */
28
+ export const LAUNCHER = "tlc-exec";
29
+
30
+ export function invocationText(entry: HookEntry): string {
31
+ return [entry.command ?? "", ...(entry.args ?? [])].join(" ");
32
+ }
33
+
34
+ /**
35
+ * Does this settings document already run the harness for this handler?
36
+ *
37
+ * invariant: `shim` is not part of the comparison. The user-level hook runs `tlc-exec <handler>` and the project
38
+ * shim runs `tlc-exec shim <handler>` — the same handler reached two ways. Requiring the spellings to match is
39
+ * what made the host's own deduplication miss them.
40
+ */
41
+ export function coversHandler(settings: ProviderSettings, handler: string): boolean {
42
+ for (const matchers of Object.values(settings.hooks ?? {})) {
43
+ for (const matcher of matchers) {
44
+ for (const entry of matcher.hooks ?? []) {
45
+ const text = invocationText(entry);
46
+ if (text.includes(LAUNCHER) && new RegExp(`(^|\\s)${handler}(\\s|$)`).test(text)) {
47
+ return true;
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export type ShimDecision = { run: boolean; reason: string };
56
+
57
+ /**
58
+ * why: `null` for the user-level settings means "there is no user-level install", which is the case the project
59
+ * shim exists for — a cloud agent with no user-level hooks runs the real handler through it
60
+ * (`docs/architecture.md`). Absence must therefore mean run, and only a positive match means stand down.
61
+ */
62
+ export function decideShim(userSettings: ProviderSettings | null, handler: string): ShimDecision {
63
+ if (userSettings === null) {
64
+ return { run: true, reason: "no user-level settings — this shim is the only hook for this event" };
65
+ }
66
+ return coversHandler(userSettings, handler)
67
+ ? {
68
+ run: false,
69
+ reason: `a user-level hook already runs ${handler} — standing down to avoid a second run`,
70
+ }
71
+ : { run: true, reason: `no user-level hook runs ${handler}` };
72
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where the init skill has to be linked, and what counts as a link that works.
3
+ *
4
+ * hazard: the installer looped over every provider config directory that exists and linked
5
+ * `<provider>/skills/harness-init`, with the comment "Each provider only reads its own skills directory".
6
+ * `tlc harness update` linked to `<runtime-home>/../skills/harness-init` instead — a directory no provider reads,
7
+ * and which did not exist at all on the machine where this was found. So an update never refreshed the skill
8
+ * anywhere a provider looks, and the evidence was a link left pointing into a `/tmp` recovery directory that does
9
+ * not survive a reboot ([/decisions/ad-095.md](/decisions/ad-095.md)).
10
+ *
11
+ * invariant: one function, used by both, so the two cannot disagree again.
12
+ */
13
+
14
+ export const SKILL_NAME = "harness-init";
15
+
16
+ export type SkillLink = { providerDir: string; source: string; target: string };
17
+
18
+ /**
19
+ * why: the provider directories are given rather than resolved here. Both tools relocate their config directory by
20
+ * environment variable, and this repository is itself installed under a relocated one — so the caller resolves and
21
+ * this decides.
22
+ */
23
+ export function skillLinks(
24
+ runtimeHome: string,
25
+ providerDirs: readonly string[],
26
+ present: (path: string) => boolean,
27
+ ) {
28
+ const source = `${runtimeHome}/skills/${SKILL_NAME}`;
29
+ return providerDirs
30
+ .filter((dir) => present(dir))
31
+ .map((providerDir) => ({
32
+ providerDir,
33
+ source,
34
+ target: `${providerDir}/skills/${SKILL_NAME}`,
35
+ }));
36
+ }
37
+
38
+ export type LinkHealth =
39
+ | { state: "ok"; target: string; resolved: string }
40
+ | { state: "dangling"; target: string; resolved: string }
41
+ | { state: "outside-runtime"; target: string; resolved: string }
42
+ | { state: "absent"; target: string };
43
+
44
+ /**
45
+ * hazard: a link whose destination does not exist reads as installed to anything that only checks the link. The
46
+ * one found on the machine that prompted this pointed at `/tmp/tlc-recovery-…/install/skills/harness-init` — a
47
+ * directory from a recovery run, gone on the next boot, and invisible to every check the harness had.
48
+ *
49
+ * invariant: a link that resolves outside the runtime home is reported even when it currently exists, because
50
+ * a source that is not the runtime is a source that will move.
51
+ *
52
+ * hazard: BOTH sides have to be resolved. The first version compared the link's realpath against the runtime home
53
+ * as configured, and on a contributor install — where `~/.tlc/harness` is a symlink to a working clone, which
54
+ * `doctor` reports as healthy — the two never share a prefix. It printed two failures on a machine where nothing
55
+ * was wrong, which is the reading AD-034 exists to forbid: a warning that fires on a healthy install is not a
56
+ * warning ([/decisions/ad-095.md](/decisions/ad-095.md)).
57
+ */
58
+ export function linkHealth(
59
+ target: string,
60
+ runtimeHome: string,
61
+ probe: {
62
+ linkTarget: (path: string) => string | null;
63
+ exists: (path: string) => boolean;
64
+ realpath?: (path: string) => string;
65
+ },
66
+ ): LinkHealth {
67
+ const resolved = probe.linkTarget(target);
68
+ if (resolved === null) {
69
+ return { state: "absent", target };
70
+ }
71
+ if (!probe.exists(resolved)) {
72
+ return { state: "dangling", target, resolved };
73
+ }
74
+ const resolveHome = probe.realpath ?? ((path: string) => path);
75
+ const home = resolveHome(runtimeHome).replace(/\/+$/, "");
76
+ return resolved === home || resolved.startsWith(`${home}/`)
77
+ ? { state: "ok", target, resolved }
78
+ : { state: "outside-runtime", target, resolved };
79
+ }
80
+
81
+ export function linkHealthMessage(health: LinkHealth): string {
82
+ switch (health.state) {
83
+ case "ok":
84
+ return `linked → ${health.resolved}`;
85
+ case "dangling":
86
+ return `points at ${health.resolved}, which does not exist — re-run \`tlc harness install\``;
87
+ case "outside-runtime":
88
+ return `points at ${health.resolved}, outside the runtime — it will break when that path goes`;
89
+ default:
90
+ return "not linked — the provider cannot see the init skill";
91
+ }
92
+ }
@@ -1,23 +1,61 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { runtimeHome } from "../platform/paths.ts";
4
+ import { coreFacade, type ProviderSettings } from "../core/index.ts";
5
+ import { runtimeHome, userSettingsPaths } from "../platform/paths.ts";
5
6
 
6
- const handler = process.argv[2];
7
- if (!handler) {
8
- console.error("usage: shim <handler-name>");
9
- process.exit(1);
7
+ function requireHandler(): string {
8
+ const name = process.argv[2];
9
+ if (!name) {
10
+ console.error("usage: shim <handler-name>");
11
+ process.exit(1);
12
+ }
13
+ return name;
10
14
  }
11
15
 
12
- // hazard: a project-level hook and a user-level hook both fire for the same event. The user-level
13
- // session start sets TLC_ACTIVE, so the project shim stands down rather than double-firing.
14
- if (process.env.TLC_ACTIVE === "1") {
16
+ const handler = requireHandler();
17
+
18
+ /**
19
+ * hazard: a project-level hook and a user-level hook both fire for the same event, and the host merges the two
20
+ * rather than replacing one with the other. This used to read `TLC_ACTIVE`, which nothing ever set — a hook cannot
21
+ * export an environment variable to a later hook process, so the condition was never true and both levels ran the
22
+ * handler every time ([/decisions/ad-095.md](/decisions/ad-095.md)).
23
+ *
24
+ * invariant: read from disk, because two hooks in one event share no channel except the filesystem.
25
+ */
26
+ // why: the first document that covers the handler wins, and a document that does not cover it must not stop the
27
+ // next one being read — otherwise a user with only one editor installed globally would decide for both.
28
+ function coveringSettings(): ProviderSettings | null {
29
+ let seen: ProviderSettings | null = null;
30
+ for (const path of userSettingsPaths()) {
31
+ let parsed: ProviderSettings;
32
+ try {
33
+ parsed = JSON.parse(readFileSync(path, "utf8")) as ProviderSettings;
34
+ } catch {
35
+ continue;
36
+ }
37
+ seen = parsed;
38
+ if (coreFacade.shim.coversHandler(parsed, handler)) {
39
+ return parsed;
40
+ }
41
+ }
42
+ return seen;
43
+ }
44
+
45
+ const decision = coreFacade.shim.decideShim(coveringSettings(), handler);
46
+ if (!decision.run) {
15
47
  process.stdout.write("{}\n");
16
48
  process.exit(0);
17
49
  }
18
50
 
19
51
  const home = runtimeHome();
20
- const execBin = join(home, "bin", "tlc-exec");
52
+ /**
53
+ * hazard: this was the extensionless bash wrapper, which Windows cannot execute — so the first branch simply
54
+ * never fired there and the shim fell through to the bundle. The launcher is a `.mjs` run by the interpreter
55
+ * already running this, which behaves the same on every platform
56
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
57
+ */
58
+ const execBin = join(home, "bin", "tlc-exec.mjs");
21
59
  const distHandler = join(home, "dist", `${handler}.mjs`);
22
60
  const srcHandler = join(home, "src", "entrypoints", `${handler}.ts`);
23
61
 
@@ -35,7 +73,7 @@ function run(command: string, args: string[]): void {
35
73
  }
36
74
 
37
75
  if (existsSync(execBin)) {
38
- run(execBin, [handler]);
76
+ run(process.execPath, [execBin, handler]);
39
77
  } else if (existsSync(distHandler)) {
40
78
  run(process.execPath, [distHandler]);
41
79
  } else if (existsSync(srcHandler)) {
@@ -88,20 +88,52 @@ function readJson<T>(path: string): T | null {
88
88
  }
89
89
  }
90
90
 
91
- async function withFileLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
91
+ /**
92
+ * hazard: `EEXIST` is what a held lock looks like on POSIX and only sometimes on Windows. There, a concurrent
93
+ * `wx` open against a file another process is creating or unlinking answers `EPERM` — so the loop below threw
94
+ * instead of waiting, and the two-concurrent-writers test failed in Windows CI with
95
+ * `EPERM: operation not permitted, open '…\handoff.json.lock'`. This module already listed the codes that mean
96
+ * "busy, come back" for its rename; the lock, which is the one place contention is the expected case, did not
97
+ * consult them ([/decisions/ad-086.md](/decisions/ad-086.md)).
98
+ */
99
+ function isContention(error: unknown): boolean {
100
+ return errorCode(error) === "EEXIST" || isRetryableFsError(error);
101
+ }
102
+
103
+ /**
104
+ * invariant: prefixed names. `FsAtomicOptions` already carries `attempts` and `sleep` for the rename retry, and
105
+ * `UpdateJsonAtomicOptions` is the intersection of both — sharing a name there would silently hand the lock's
106
+ * budget to the rename, or the other way round.
107
+ */
108
+ export type FileLockOptions = {
109
+ /** Injected so the contention branch has a test that does not need a second process or Windows. */
110
+ openLock?: (path: string) => void;
111
+ lockSleep?: (ms: number) => Promise<void>;
112
+ lockAttempts?: number;
113
+ };
114
+
115
+ export async function withFileLock<T>(
116
+ lockPath: string,
117
+ fn: () => Promise<T>,
118
+ options: FileLockOptions = {},
119
+ ): Promise<T> {
120
+ const {
121
+ openLock = (path: string) => closeSync(openSync(path, "wx")),
122
+ lockSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
123
+ lockAttempts = 200,
124
+ } = options;
92
125
  mkdirSync(dirname(lockPath), { recursive: true });
93
- const attempts = 200;
94
126
  let acquired = false;
95
- for (let attempt = 0; attempt < attempts; attempt++) {
127
+ for (let attempt = 0; attempt < lockAttempts; attempt++) {
96
128
  try {
97
- closeSync(openSync(lockPath, "wx"));
129
+ openLock(lockPath);
98
130
  acquired = true;
99
131
  break;
100
132
  } catch (error) {
101
- if (errorCode(error) !== "EEXIST") {
133
+ if (!isContention(error)) {
102
134
  throw error;
103
135
  }
104
- await new Promise((resolve) => setTimeout(resolve, nextDelay({ attempt, baseMs: 10, capMs: 200 })));
136
+ await lockSleep(nextDelay({ attempt, baseMs: 10, capMs: 200 }));
105
137
  }
106
138
  }
107
139
  if (!acquired) {
@@ -116,27 +148,33 @@ async function withFileLock<T>(lockPath: string, fn: () => Promise<T>): Promise<
116
148
  }
117
149
  }
118
150
 
119
- export type UpdateJsonAtomicOptions = FsAtomicOptions & {
120
- lockPath: string /**
121
- * Run inside the write lock, after the file lands. The platform does not care what it does — recording a
122
- * content hash is core's business, and doing it here is what makes the record and the content one write.
123
- */;
124
- afterWrite?: (path: string) => void;
125
- };
151
+ export type UpdateJsonAtomicOptions = FsAtomicOptions &
152
+ FileLockOptions & {
153
+ lockPath: string;
154
+ /**
155
+ * Run inside the write lock, after the file lands. The platform does not care what it does — recording a
156
+ * content hash is core's business, and doing it here is what makes the record and the content one write.
157
+ */
158
+ afterWrite?: (path: string) => void;
159
+ };
126
160
 
127
161
  export async function updateJsonAtomic<T>(
128
162
  path: string,
129
163
  mutator: (current: T | null) => T,
130
164
  options: UpdateJsonAtomicOptions,
131
165
  ): Promise<T> {
132
- const { lockPath, afterWrite, ...atomicOptions } = options;
133
- return withFileLock(lockPath, async () => {
134
- const current = readJson<T>(path);
135
- const next = mutator(current);
136
- await writeJsonAtomic(path, next, atomicOptions);
137
- // why: inside the lock. A caller that sealed after the lock released would race the next writer, and the pair
138
- // that lost would leave a record matching neither content ([/decisions/ad-078.md](/decisions/ad-078.md)).
139
- afterWrite?.(path);
140
- return next;
141
- });
166
+ const { lockPath, afterWrite, openLock, lockSleep, lockAttempts, ...atomicOptions } = options;
167
+ return withFileLock(
168
+ lockPath,
169
+ async () => {
170
+ const current = readJson<T>(path);
171
+ const next = mutator(current);
172
+ await writeJsonAtomic(path, next, atomicOptions);
173
+ // why: inside the lock. A caller that sealed after the lock released would race the next writer, and the
174
+ // pair that lost would leave a record matching neither content ([/decisions/ad-078.md](/decisions/ad-078.md)).
175
+ afterWrite?.(path);
176
+ return next;
177
+ },
178
+ { openLock, lockSleep, lockAttempts },
179
+ );
142
180
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The two filesystem primitives an install needs: a directory link, and a config seeded once.
3
+ *
4
+ * why there is no platform branch here: the code this replaces shelled out to `ln -sfn` on POSIX and to
5
+ * PowerShell on Windows, and *that* is where the branch came from — not from anything Node cannot do. Node's
6
+ * `symlinkSync` takes a link type that is only meaningful on Windows and is ignored elsewhere, so `"junction"`
7
+ * is correct on all three platforms: a junction on Windows, a plain symlink on Linux and macOS. Measured on
8
+ * Linux, and stated in Node's own API history ([/decisions/ad-097.md](/decisions/ad-097.md)).
9
+ *
10
+ * why junction rather than a Windows symlink: a directory symlink needs Developer Mode or an elevated shell,
11
+ * which the PowerShell installer demanded of a contributor. A junction needs neither.
12
+ *
13
+ * The launcher on PATH is not here either. `npm i -g` generates the shims for the platform it runs on, and
14
+ * `npm link` does the same from a checkout — a second implementation of that is a second thing to get wrong.
15
+ */
16
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+
19
+ /** invariant: one link type, chosen because Windows is the only platform that reads it. */
20
+ export const LINK_TYPE = "junction";
21
+
22
+ export type LinkOutcome =
23
+ | { kind: "linked"; target: string; source: string }
24
+ | { kind: "relinked"; target: string; source: string }
25
+ | { kind: "refused"; target: string; reason: string };
26
+
27
+ /**
28
+ * Point `target` at `source`.
29
+ *
30
+ * invariant: an existing *link* is replaced; anything else is refused. A real directory at the target is either
31
+ * somebody's install or somebody's work, and removing it to make room is not a decision a tool makes
32
+ * ([/decisions/ad-046.md](/decisions/ad-046.md)). The bash installer removed it.
33
+ */
34
+ export function linkDir(source: string, target: string): LinkOutcome {
35
+ let replaced = false;
36
+ if (isLink(target)) {
37
+ rmSync(target, { recursive: true, force: true });
38
+ replaced = true;
39
+ } else if (existsSync(target)) {
40
+ return {
41
+ kind: "refused",
42
+ target,
43
+ reason: `${target} exists and is not a link — move it aside and re-run`,
44
+ };
45
+ }
46
+ mkdirSync(dirname(target), { recursive: true });
47
+ symlinkSync(source, target, LINK_TYPE);
48
+ return { kind: replaced ? "relinked" : "linked", target, source };
49
+ }
50
+
51
+ /**
52
+ * why lstat: a link whose destination is gone is still a link, and `existsSync` says it is not there — so the
53
+ * check has to come first, or a dangling link reads as free space. Node reports a Windows junction as a symbolic
54
+ * link, so one call covers both kinds.
55
+ */
56
+ export function isLink(path: string): boolean {
57
+ try {
58
+ return lstatSync(path).isSymbolicLink();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /** invariant: seeded once, never overwritten. The operator's config is theirs from the moment it exists. */
65
+ export function seedConfig(dest: string): { seeded: boolean; path: string } {
66
+ const path = join(dest, "config.json");
67
+ const example = join(dest, "config.example.json");
68
+ if (existsSync(path) || !existsSync(example)) {
69
+ return { seeded: false, path };
70
+ }
71
+ copyFileSync(example, path);
72
+ return { seeded: true, path };
73
+ }
@@ -78,3 +78,30 @@ export function cursorConfigDir(): string {
78
78
  const custom = process.env.CURSOR_CONFIG_DIR?.trim();
79
79
  return custom && custom.length > 0 ? custom : join(homedir(), ".cursor");
80
80
  }
81
+
82
+ /**
83
+ * The user-level hook documents, in the order the shim reads them.
84
+ *
85
+ * why: the project shim is told a handler and nothing else — it does not know which provider invoked it. So it
86
+ * asks both: if any user-level document already runs this handler, a second run would be a duplicate. The cost of
87
+ * that imprecision is under-running in a setup where one editor is installed globally and the other only in the
88
+ * project, which is the safe direction — the alternative was running the handler twice, which is what actually
89
+ * happened ([/decisions/ad-095.md](/decisions/ad-095.md)).
90
+ *
91
+ * invariant: both paths are resolved, never assumed. Either tool can relocate its config directory by env, and
92
+ * this repository is itself installed under a relocated one.
93
+ */
94
+ export function userSettingsPaths(): string[] {
95
+ return [join(claudeConfigDir(), "settings.json"), join(cursorConfigDir(), "hooks.json")];
96
+ }
97
+
98
+ /**
99
+ * The provider config directories, resolved. Each provider reads only its own skills directory, so this is the
100
+ * list anything linking the init skill must walk.
101
+ *
102
+ * invariant: resolved, never assumed — either tool relocates its config directory by env, and this repository is
103
+ * itself installed under a relocated one ([/decisions/ad-095.md](/decisions/ad-095.md)).
104
+ */
105
+ export function providerConfigDirs(): string[] {
106
+ return [cursorConfigDir(), claudeConfigDir()];
107
+ }