@tech-leads-club/harness-toolkit 0.2.1 → 0.2.4

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 (120) hide show
  1. package/bin/tlc-cli.ts +20 -5
  2. package/dist/compact-before.mjs +86 -8
  3. package/dist/doctor.mjs +116 -10
  4. package/dist/init-project.mjs +13 -7
  5. package/dist/lessons-cli.mjs +86 -8
  6. package/dist/obs-cli.mjs +86 -8
  7. package/dist/prompt-submit.mjs +86 -8
  8. package/dist/response-after.mjs +86 -8
  9. package/dist/run.mjs +86 -8
  10. package/dist/session-end.mjs +86 -8
  11. package/dist/session-start.mjs +86 -8
  12. package/dist/shim.mjs +6976 -15
  13. package/dist/stop.mjs +86 -8
  14. package/dist/subagent-start.mjs +86 -8
  15. package/dist/subagent-stop.mjs +86 -8
  16. package/dist/support.mjs +86 -8
  17. package/dist/tlc-cli.mjs +106 -12
  18. package/dist/tool-after.mjs +86 -8
  19. package/dist/tool-before.mjs +86 -8
  20. package/dist/tool-failure.mjs +86 -8
  21. package/docs/log.md +5 -0
  22. package/package.json +3 -2
  23. package/src/core/core.facade.ts +11 -0
  24. package/src/core/index.ts +2 -0
  25. package/src/core/release/release.version.ts +147 -0
  26. package/src/core/shim/shim.precedence.ts +72 -0
  27. package/src/core/skill/skill.link.ts +81 -0
  28. package/src/entrypoints/shim.ts +41 -9
  29. package/src/platform/fs-atomic.ts +61 -23
  30. package/src/platform/paths.ts +27 -0
  31. package/tools/doctor.ts +50 -3
  32. package/tools/init-project.ts +27 -7
  33. package/CHANGELOG.md +0 -95
  34. package/docs/decisions/ad-001.md +0 -32
  35. package/docs/decisions/ad-002.md +0 -51
  36. package/docs/decisions/ad-003.md +0 -30
  37. package/docs/decisions/ad-004.md +0 -37
  38. package/docs/decisions/ad-005.md +0 -36
  39. package/docs/decisions/ad-006.md +0 -49
  40. package/docs/decisions/ad-007.md +0 -36
  41. package/docs/decisions/ad-008.md +0 -54
  42. package/docs/decisions/ad-009.md +0 -61
  43. package/docs/decisions/ad-010.md +0 -45
  44. package/docs/decisions/ad-011.md +0 -59
  45. package/docs/decisions/ad-012.md +0 -71
  46. package/docs/decisions/ad-013.md +0 -87
  47. package/docs/decisions/ad-014.md +0 -56
  48. package/docs/decisions/ad-015.md +0 -33
  49. package/docs/decisions/ad-016.md +0 -98
  50. package/docs/decisions/ad-017.md +0 -65
  51. package/docs/decisions/ad-018.md +0 -77
  52. package/docs/decisions/ad-019.md +0 -75
  53. package/docs/decisions/ad-020.md +0 -88
  54. package/docs/decisions/ad-021.md +0 -57
  55. package/docs/decisions/ad-022.md +0 -120
  56. package/docs/decisions/ad-023.md +0 -87
  57. package/docs/decisions/ad-024.md +0 -85
  58. package/docs/decisions/ad-025.md +0 -92
  59. package/docs/decisions/ad-026.md +0 -131
  60. package/docs/decisions/ad-027.md +0 -90
  61. package/docs/decisions/ad-028.md +0 -90
  62. package/docs/decisions/ad-029.md +0 -73
  63. package/docs/decisions/ad-030.md +0 -97
  64. package/docs/decisions/ad-031.md +0 -94
  65. package/docs/decisions/ad-032.md +0 -83
  66. package/docs/decisions/ad-033.md +0 -89
  67. package/docs/decisions/ad-034.md +0 -86
  68. package/docs/decisions/ad-035.md +0 -86
  69. package/docs/decisions/ad-036.md +0 -68
  70. package/docs/decisions/ad-037.md +0 -47
  71. package/docs/decisions/ad-038.md +0 -52
  72. package/docs/decisions/ad-039.md +0 -69
  73. package/docs/decisions/ad-040.md +0 -89
  74. package/docs/decisions/ad-041.md +0 -98
  75. package/docs/decisions/ad-042.md +0 -82
  76. package/docs/decisions/ad-043.md +0 -79
  77. package/docs/decisions/ad-044.md +0 -61
  78. package/docs/decisions/ad-045.md +0 -94
  79. package/docs/decisions/ad-046.md +0 -111
  80. package/docs/decisions/ad-047.md +0 -96
  81. package/docs/decisions/ad-048.md +0 -85
  82. package/docs/decisions/ad-049.md +0 -66
  83. package/docs/decisions/ad-050.md +0 -94
  84. package/docs/decisions/ad-051.md +0 -69
  85. package/docs/decisions/ad-052.md +0 -69
  86. package/docs/decisions/ad-053.md +0 -78
  87. package/docs/decisions/ad-054.md +0 -98
  88. package/docs/decisions/ad-055.md +0 -74
  89. package/docs/decisions/ad-056.md +0 -85
  90. package/docs/decisions/ad-057.md +0 -68
  91. package/docs/decisions/ad-058.md +0 -97
  92. package/docs/decisions/ad-059.md +0 -82
  93. package/docs/decisions/ad-060.md +0 -75
  94. package/docs/decisions/ad-061.md +0 -68
  95. package/docs/decisions/ad-062.md +0 -72
  96. package/docs/decisions/ad-063.md +0 -84
  97. package/docs/decisions/ad-064.md +0 -79
  98. package/docs/decisions/ad-065.md +0 -81
  99. package/docs/decisions/ad-066.md +0 -111
  100. package/docs/decisions/ad-067.md +0 -64
  101. package/docs/decisions/ad-068.md +0 -79
  102. package/docs/decisions/ad-069.md +0 -74
  103. package/docs/decisions/ad-070.md +0 -86
  104. package/docs/decisions/ad-071.md +0 -93
  105. package/docs/decisions/ad-072.md +0 -82
  106. package/docs/decisions/ad-073.md +0 -102
  107. package/docs/decisions/ad-074.md +0 -91
  108. package/docs/decisions/ad-075.md +0 -79
  109. package/docs/decisions/ad-076.md +0 -102
  110. package/docs/decisions/ad-077.md +0 -94
  111. package/docs/decisions/ad-078.md +0 -84
  112. package/docs/decisions/ad-079.md +0 -73
  113. package/docs/decisions/ad-080.md +0 -86
  114. package/docs/decisions/ad-081.md +0 -70
  115. package/docs/decisions/ad-082.md +0 -79
  116. package/docs/decisions/ad-083.md +0 -88
  117. package/docs/decisions/index.md +0 -111
  118. package/tools/test-env.mjs +0 -28
  119. package/tools/test-env.names.d.mts +0 -1
  120. 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,81 @@
1
+ /**
2
+ * Where the init skill has to be linked, and what counts as a link that works.
3
+ *
4
+ * hazard: `install.sh` loops over every provider config directory that exists and links
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
+ export function linkHealth(
53
+ target: string,
54
+ runtimeHome: string,
55
+ probe: { linkTarget: (path: string) => string | null; exists: (path: string) => boolean },
56
+ ): LinkHealth {
57
+ const resolved = probe.linkTarget(target);
58
+ if (resolved === null) {
59
+ return { state: "absent", target };
60
+ }
61
+ if (!probe.exists(resolved)) {
62
+ return { state: "dangling", target, resolved };
63
+ }
64
+ const home = runtimeHome.replace(/\/+$/, "");
65
+ return resolved === home || resolved.startsWith(`${home}/`)
66
+ ? { state: "ok", target, resolved }
67
+ : { state: "outside-runtime", target, resolved };
68
+ }
69
+
70
+ export function linkHealthMessage(health: LinkHealth): string {
71
+ switch (health.state) {
72
+ case "ok":
73
+ return `linked → ${health.resolved}`;
74
+ case "dangling":
75
+ return `points at ${health.resolved}, which does not exist — re-run \`tlc harness install\``;
76
+ case "outside-runtime":
77
+ return `points at ${health.resolved}, outside the runtime — it will break when that path goes`;
78
+ default:
79
+ return "not linked — the provider cannot see the init skill";
80
+ }
81
+ }
@@ -1,17 +1,49 @@
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
  }
@@ -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
  }
@@ -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
+ }
package/tools/doctor.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
3
  import { homedir, platform as osPlatform } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
5
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
- import { projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
11
+ import {
12
+ projectConfigPath,
13
+ projectStateDir,
14
+ providerConfigDirs,
15
+ runtimeHome,
16
+ } from "../src/platform/paths.ts";
12
17
  import { type ColorName, createStyle, PLAIN, type Style, SYMBOLS } from "../src/platform/style.ts";
13
18
  import { mergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
14
19
  import {
@@ -126,6 +131,47 @@ export function runtimeOwnershipCheck(home: string): Check {
126
131
  };
127
132
  }
128
133
 
134
+ /**
135
+ * hazard: a skill link whose destination is gone reads as installed to anything that only checks the link exists.
136
+ * One on the machine that prompted this pointed at `/tmp/tlc-recovery-…/install/skills/harness-init` — a directory
137
+ * from a recovery run, gone on the next boot — and nothing in the harness could see it. A provider whose skill link
138
+ * dangles simply never routes a request to the init skill, silently
139
+ * ([/decisions/ad-095.md](/decisions/ad-095.md)).
140
+ *
141
+ * invariant: reported per provider, because each reads only its own skills directory, and one being healthy says
142
+ * nothing about the other.
143
+ */
144
+ export function checkSkillLinks(
145
+ home: string,
146
+ providerDirs: readonly string[] = providerConfigDirs(),
147
+ probe = {
148
+ linkTarget: (path: string) => {
149
+ try {
150
+ return realpathSync(path);
151
+ } catch {
152
+ // a dangling link cannot be realpath'd, so read the link itself before giving up
153
+ try {
154
+ return readlinkSync(path);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ },
160
+ exists: existsSync,
161
+ },
162
+ ): Check[] {
163
+ return providerDirs
164
+ .filter((dir) => existsSync(dir))
165
+ .map((dir) => {
166
+ const health = coreFacade.skill.linkHealth(join(dir, "skills", "harness-init"), home, probe);
167
+ return {
168
+ level: health.state === "ok" ? ("ok" as const) : ("fail" as const),
169
+ name: `init skill (${basename(dir)})`,
170
+ detail: coreFacade.skill.linkHealthMessage(health),
171
+ };
172
+ });
173
+ }
174
+
129
175
  export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
130
176
  const launcher = join(home, "bin", "tlc-exec.mjs");
131
177
  const distSample = join(home, "dist", "stop.mjs");
@@ -522,6 +568,7 @@ export function runChecks(ctx: DoctorContext): Check[] {
522
568
  return [
523
569
  ...checkNodeVersion(ctx.nodeVersion, ctx.bunPath),
524
570
  ...checkRuntimePaths(ctx.runtimeHome, ctx.platform),
571
+ ...checkSkillLinks(ctx.runtimeHome),
525
572
  checkHookRuntime(ctx.runtimeHome, ctx.bunPath),
526
573
  ...checkProviders(ctx.registry, ctx.runtimeHome),
527
574
  ...checkProjectPolicy(ctx.root),
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
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
5
  import { DEFAULTS } from "../src/core/policy/policy.defaults.ts";
@@ -115,17 +115,37 @@ export function claudeShimEntries(launcher: string): WiringEntry[] {
115
115
  }));
116
116
  }
117
117
 
118
- export const GITIGNORE_LINE = ".tlc/harness/state/";
118
+ /**
119
+ * The paths `init` writes into the project that must not be committed.
120
+ *
121
+ * hazard: this was one line, `.tlc/harness/state/`, while `init` also writes two shim documents containing an
122
+ * absolute path to the runtime on the machine that ran it. A user committed a `settings.json` naming their own
123
+ * home directory, and the next developer's hook pointed at a path that does not exist. This repository has
124
+ * ignored both files by hand since 2026-07-30 — commit 81c5830, "keep generated shims out of git", with the
125
+ * comment "per-machine artifacts rather than shared configuration" — so the protection existed here and was never
126
+ * delivered to anyone using the product ([/decisions/ad-095.md](/decisions/ad-095.md)).
127
+ *
128
+ * invariant: derived from what `init` writes. `PROJECT_SHIMS` is the same list the wiring below writes to, so a
129
+ * new shim cannot be added without appearing here.
130
+ */
131
+ export const PROJECT_SHIMS = [join(".cursor", "hooks.json"), join(".claude", "settings.json")] as const;
132
+
133
+ export const GITIGNORE_STATE = ".tlc/harness/state/";
134
+
135
+ /** why: posix separators, because a `.gitignore` is read by git and not by the platform that wrote it. */
136
+ export function gitignoreEntries(): string[] {
137
+ return [GITIGNORE_STATE, ...PROJECT_SHIMS.map((path) => path.split(sep).join("/"))];
138
+ }
119
139
 
120
140
  export function mergeGitignore(root: string): { text: string; changed: boolean } {
121
141
  const path = join(root, ".gitignore");
122
142
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
123
143
  const lines = existing.split("\n");
124
- const alreadyPresent = lines.includes(GITIGNORE_LINE);
125
- if (alreadyPresent) {
144
+ const missing = gitignoreEntries().filter((entry) => !lines.includes(entry));
145
+ if (missing.length === 0) {
126
146
  return { text: existing.endsWith("\n") || existing === "" ? existing : `${existing}\n`, changed: false };
127
147
  }
128
- lines.push(GITIGNORE_LINE);
148
+ lines.push(...missing);
129
149
  const withoutTrailingBlank = lines.filter((line, index, all) => line.length > 0 || index < all.length - 1);
130
150
  return { text: `${withoutTrailingBlank.join("\n").replace(/\n+$/, "")}\n`, changed: true };
131
151
  }
@@ -156,7 +176,7 @@ export type InitPlan = {
156
176
  policy: unknown;
157
177
  cursorHooksDocument: unknown | null;
158
178
  claudeHooksPreview: WiringEntry[] | null;
159
- gitignoreLine: string;
179
+ gitignoreEntries: string[];
160
180
  };
161
181
 
162
182
  export function buildPlan(
@@ -171,7 +191,7 @@ export function buildPlan(
171
191
  policy,
172
192
  cursorHooksDocument: presence.cursor ? renderCursorHooksDocument(cursorShimEntries(launcher)) : null,
173
193
  claudeHooksPreview: presence.claude ? claudeShimEntries(launcher) : null,
174
- gitignoreLine: GITIGNORE_LINE,
194
+ gitignoreEntries: gitignoreEntries(),
175
195
  };
176
196
  }
177
197