@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.
- package/README.md +22 -26
- package/bin/tlc-build.mjs +93 -0
- package/bin/tlc-cli.ts +117 -58
- package/bin/tlc-exec.mjs +16 -13
- package/dist/compact-before.mjs +151 -15
- package/dist/doctor.mjs +288 -45
- package/dist/help-topic.mjs +0 -0
- package/dist/init-project.mjs +15 -14
- package/dist/install-runtime.mjs +100 -17
- package/dist/lessons-cli.mjs +152 -8
- package/dist/obs-cli.mjs +149 -8
- package/dist/price-lookup.mjs +45 -23
- package/dist/prompt-submit.mjs +151 -15
- package/dist/refresh-model-prices.mjs +7190 -46
- package/dist/response-after.mjs +151 -15
- package/dist/run.mjs +151 -15
- package/dist/session-end.mjs +151 -15
- package/dist/session-start.mjs +151 -15
- package/dist/shim.mjs +7040 -16
- package/dist/stop.mjs +151 -15
- package/dist/subagent-start.mjs +151 -15
- package/dist/subagent-stop.mjs +151 -15
- package/dist/support.mjs +149 -8
- package/dist/tlc-cli.mjs +289 -89
- package/dist/tool-after.mjs +196 -38
- package/dist/tool-before.mjs +151 -15
- package/dist/tool-failure.mjs +151 -15
- package/dist/uninstall-runtime.mjs +9 -10
- package/docs/log.md +7 -0
- package/docs/measure.md +35 -31
- package/package.json +6 -5
- package/src/core/core.facade.ts +18 -0
- package/src/core/index.ts +3 -0
- package/src/core/pricing/pricing.freshness.ts +118 -0
- package/src/core/release/release.version.ts +147 -0
- package/src/core/shim/shim.precedence.ts +72 -0
- package/src/core/skill/skill.link.ts +92 -0
- package/src/entrypoints/shim.ts +49 -11
- package/src/platform/fs-atomic.ts +61 -23
- package/src/platform/links.ts +73 -0
- package/src/platform/paths.ts +27 -0
- package/src/platform/pricing.ts +139 -31
- package/src/providers/cursor/cursor.wiring.ts +11 -8
- package/tools/doctor.ts +127 -10
- package/tools/init-project.ts +34 -14
- package/tools/install-runtime.ts +89 -6
- package/tools/refresh-model-prices.ts +242 -75
- package/tools/uninstall-runtime.ts +23 -19
- package/CHANGELOG.md +0 -95
- package/bin/tlc-build +0 -80
- package/bin/tlc-exec +0 -10
- package/bin/tlc-exec.cmd +0 -4
- package/docs/decisions/ad-001.md +0 -32
- package/docs/decisions/ad-002.md +0 -51
- package/docs/decisions/ad-003.md +0 -30
- package/docs/decisions/ad-004.md +0 -37
- package/docs/decisions/ad-005.md +0 -36
- package/docs/decisions/ad-006.md +0 -49
- package/docs/decisions/ad-007.md +0 -36
- package/docs/decisions/ad-008.md +0 -54
- package/docs/decisions/ad-009.md +0 -61
- package/docs/decisions/ad-010.md +0 -45
- package/docs/decisions/ad-011.md +0 -59
- package/docs/decisions/ad-012.md +0 -71
- package/docs/decisions/ad-013.md +0 -87
- package/docs/decisions/ad-014.md +0 -56
- package/docs/decisions/ad-015.md +0 -33
- package/docs/decisions/ad-016.md +0 -98
- package/docs/decisions/ad-017.md +0 -65
- package/docs/decisions/ad-018.md +0 -77
- package/docs/decisions/ad-019.md +0 -75
- package/docs/decisions/ad-020.md +0 -88
- package/docs/decisions/ad-021.md +0 -57
- package/docs/decisions/ad-022.md +0 -120
- package/docs/decisions/ad-023.md +0 -87
- package/docs/decisions/ad-024.md +0 -85
- package/docs/decisions/ad-025.md +0 -92
- package/docs/decisions/ad-026.md +0 -131
- package/docs/decisions/ad-027.md +0 -90
- package/docs/decisions/ad-028.md +0 -90
- package/docs/decisions/ad-029.md +0 -73
- package/docs/decisions/ad-030.md +0 -97
- package/docs/decisions/ad-031.md +0 -94
- package/docs/decisions/ad-032.md +0 -83
- package/docs/decisions/ad-033.md +0 -89
- package/docs/decisions/ad-034.md +0 -86
- package/docs/decisions/ad-035.md +0 -86
- package/docs/decisions/ad-036.md +0 -68
- package/docs/decisions/ad-037.md +0 -47
- package/docs/decisions/ad-038.md +0 -52
- package/docs/decisions/ad-039.md +0 -69
- package/docs/decisions/ad-040.md +0 -89
- package/docs/decisions/ad-041.md +0 -98
- package/docs/decisions/ad-042.md +0 -82
- package/docs/decisions/ad-043.md +0 -79
- package/docs/decisions/ad-044.md +0 -61
- package/docs/decisions/ad-045.md +0 -94
- package/docs/decisions/ad-046.md +0 -111
- package/docs/decisions/ad-047.md +0 -96
- package/docs/decisions/ad-048.md +0 -85
- package/docs/decisions/ad-049.md +0 -66
- package/docs/decisions/ad-050.md +0 -94
- package/docs/decisions/ad-051.md +0 -69
- package/docs/decisions/ad-052.md +0 -69
- package/docs/decisions/ad-053.md +0 -78
- package/docs/decisions/ad-054.md +0 -98
- package/docs/decisions/ad-055.md +0 -74
- package/docs/decisions/ad-056.md +0 -85
- package/docs/decisions/ad-057.md +0 -68
- package/docs/decisions/ad-058.md +0 -97
- package/docs/decisions/ad-059.md +0 -82
- package/docs/decisions/ad-060.md +0 -75
- package/docs/decisions/ad-061.md +0 -68
- package/docs/decisions/ad-062.md +0 -72
- package/docs/decisions/ad-063.md +0 -84
- package/docs/decisions/ad-064.md +0 -79
- package/docs/decisions/ad-065.md +0 -81
- package/docs/decisions/ad-066.md +0 -111
- package/docs/decisions/ad-067.md +0 -64
- package/docs/decisions/ad-068.md +0 -79
- package/docs/decisions/ad-069.md +0 -74
- package/docs/decisions/ad-070.md +0 -86
- package/docs/decisions/ad-071.md +0 -93
- package/docs/decisions/ad-072.md +0 -82
- package/docs/decisions/ad-073.md +0 -102
- package/docs/decisions/ad-074.md +0 -91
- package/docs/decisions/ad-075.md +0 -79
- package/docs/decisions/ad-076.md +0 -102
- package/docs/decisions/ad-077.md +0 -94
- package/docs/decisions/ad-078.md +0 -84
- package/docs/decisions/ad-079.md +0 -73
- package/docs/decisions/ad-080.md +0 -86
- package/docs/decisions/ad-081.md +0 -70
- package/docs/decisions/ad-082.md +0 -79
- package/docs/decisions/ad-083.md +0 -88
- package/docs/decisions/index.md +0 -111
- package/model-aliases.json +0 -12
- package/model-prices.cursor.json +0 -410
- package/model-prices.json +0 -1
- package/tools/test-env.mjs +0 -28
- package/tools/test-env.names.d.mts +0 -1
- 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
|
+
}
|
package/src/entrypoints/shim.ts
CHANGED
|
@@ -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 {
|
|
4
|
+
import { coreFacade, type ProviderSettings } from "../core/index.ts";
|
|
5
|
+
import { runtimeHome, userSettingsPaths } from "../platform/paths.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 <
|
|
127
|
+
for (let attempt = 0; attempt < lockAttempts; attempt++) {
|
|
96
128
|
try {
|
|
97
|
-
|
|
129
|
+
openLock(lockPath);
|
|
98
130
|
acquired = true;
|
|
99
131
|
break;
|
|
100
132
|
} catch (error) {
|
|
101
|
-
if (
|
|
133
|
+
if (!isContention(error)) {
|
|
102
134
|
throw error;
|
|
103
135
|
}
|
|
104
|
-
await
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
package/src/platform/paths.ts
CHANGED
|
@@ -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
|
+
}
|