@tech-leads-club/harness-toolkit 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -8
- package/bin/tlc-cli.ts +40 -1
- package/bin/tlc-exec.d.mts +1 -0
- package/bin/tlc-exec.mjs +47 -2
- package/capabilities/catalog.json +47 -30
- package/dist/compact-before.mjs +84 -78
- package/dist/doctor.mjs +87 -81
- package/dist/help-topic.mjs +6 -5
- package/dist/init-project.mjs +147 -9
- package/dist/install-runtime.mjs +85 -79
- package/dist/lessons-cli.mjs +87 -81
- package/dist/obs-cli.mjs +83 -77
- package/dist/price-lookup.mjs +2 -2
- package/dist/prompt-submit.mjs +84 -78
- package/dist/refresh-model-prices.mjs +85 -79
- package/dist/response-after.mjs +84 -78
- package/dist/run.mjs +84 -78
- package/dist/session-end.mjs +90 -84
- package/dist/session-start.mjs +92 -86
- package/dist/shim.mjs +82 -76
- package/dist/stop.mjs +90 -84
- package/dist/subagent-start.mjs +84 -78
- package/dist/subagent-stop.mjs +85 -79
- package/dist/support.mjs +88 -82
- package/dist/tlc-cli.mjs +104 -98
- package/dist/tool-after.mjs +84 -78
- package/dist/tool-before.mjs +84 -78
- package/dist/tool-failure.mjs +84 -78
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/architecture.md +1 -0
- package/docs/concepts.md +86 -0
- package/docs/diagnose.md +20 -0
- package/docs/init.md +10 -2
- package/docs/lessons.md +12 -0
- package/docs/log.md +5 -0
- package/package.json +1 -1
- package/skills/harness-init/references/capabilities.md +54 -0
- package/src/core/core.facade.ts +54 -0
- package/src/core/floor/floor.paths.ts +2 -2
- package/src/core/floor/floor.policy-surface.ts +6 -1
- package/src/core/lesson/lesson.select.ts +30 -7
- package/src/core/policy/policy.defaults.ts +3 -0
- package/src/core/policy/policy.integrity.ts +2 -2
- package/src/core/policy/policy.loader.ts +14 -3
- package/src/core/policy/policy.shadow.ts +97 -0
- package/src/core/policy/policy.types.ts +8 -0
- package/src/core/release/release.decisions.ts +3 -13
- package/src/core/rules/rules.decide.ts +123 -0
- package/src/core/rules/rules.observe.ts +76 -0
- package/src/core/rules/rules.parse.ts +142 -0
- package/src/core/rules/rules.proof.ts +130 -0
- package/src/core/rules/rules.service.ts +141 -0
- package/src/core/rules/rules.store.ts +77 -0
- package/src/core/rules/rules.trigger.ts +101 -0
- package/src/core/rules/rules.types.ts +64 -0
- package/src/entrypoints/shim.ts +9 -1
- package/src/entrypoints/stop.ts +75 -1
- package/src/entrypoints/subagent-stop.ts +9 -1
- package/src/entrypoints/support.ts +32 -0
- package/src/entrypoints/tool-after.ts +7 -2
- package/src/entrypoints/tool-before.ts +44 -3
- package/src/platform/frontmatter.ts +142 -0
- package/src/platform/links.ts +32 -0
- package/src/platform/paths.ts +58 -4
- package/src/platform/pricing.ts +3 -3
- package/src/platform/screen.ts +62 -3
- package/tools/doctor.ts +162 -2
- package/tools/help-topic.ts +39 -23
- package/tools/init-project.ts +51 -6
- package/tools/install-runtime.ts +23 -2
- package/tools/lessons-cli.ts +4 -1
- package/tools/refresh-model-prices.ts +2 -2
- package/tools/uninstall-runtime.ts +11 -3
package/tools/help-topic.ts
CHANGED
|
@@ -5,22 +5,34 @@ import { runtimeHome } from "../src/platform/paths.ts";
|
|
|
5
5
|
const docsDir = join(runtimeHome(), "docs");
|
|
6
6
|
const topic = (process.argv[2] ?? "").toLowerCase();
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A topic is a document, or one heading inside one. `section` exists because two topics live inside a larger
|
|
10
|
+
* document and printing the whole of it would bury the answer.
|
|
11
|
+
*/
|
|
12
|
+
type Topic = { file: string; section?: string };
|
|
13
|
+
|
|
14
|
+
const TOPICS: Record<string, Topic> = {
|
|
15
|
+
architecture: { file: "architecture.md" },
|
|
16
|
+
concepts: { file: "concepts.md" },
|
|
17
|
+
measure: { file: "measure.md" },
|
|
18
|
+
metrics: { file: "measure.md" },
|
|
19
|
+
prices: { file: "measure.md", section: "## Prices" },
|
|
20
|
+
price: { file: "measure.md", section: "## Prices" },
|
|
21
|
+
cost: { file: "measure.md", section: "## Prices" },
|
|
22
|
+
costs: { file: "measure.md", section: "## Prices" },
|
|
23
|
+
diagnose: { file: "diagnose.md" },
|
|
24
|
+
doctor: { file: "diagnose.md" },
|
|
25
|
+
debug: { file: "diagnose.md" },
|
|
26
|
+
init: { file: "init.md" },
|
|
27
|
+
setup: { file: "init.md" },
|
|
28
|
+
lessons: { file: "lessons.md" },
|
|
29
|
+
lesson: { file: "lessons.md" },
|
|
30
|
+
rules: { file: "concepts.md", section: "## operator rules" },
|
|
31
|
+
rule: { file: "concepts.md", section: "## operator rules" },
|
|
32
|
+
runtime: { file: "concepts.md", section: "## which runtime answers a hook" },
|
|
33
|
+
dev: { file: "concepts.md", section: "## which runtime answers a hook" },
|
|
34
|
+
settings: { file: "concepts.md", section: "## where a setting lives" },
|
|
35
|
+
config: { file: "concepts.md", section: "## where a setting lives" },
|
|
24
36
|
};
|
|
25
37
|
|
|
26
38
|
function printIndex(): void {
|
|
@@ -30,6 +42,9 @@ TOPICS
|
|
|
30
42
|
tlc harness help architecture
|
|
31
43
|
tlc harness help concepts
|
|
32
44
|
tlc harness help lessons
|
|
45
|
+
tlc harness help rules
|
|
46
|
+
tlc harness help settings
|
|
47
|
+
tlc harness help runtime
|
|
33
48
|
tlc harness help measure
|
|
34
49
|
tlc harness help prices
|
|
35
50
|
tlc harness help diagnose
|
|
@@ -49,25 +64,26 @@ if (!topic || topic === "help" || topic === "-h" || topic === "--help") {
|
|
|
49
64
|
process.exit(0);
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
67
|
+
const entry = TOPICS[topic];
|
|
68
|
+
if (!entry) {
|
|
54
69
|
console.error(`unknown topic: ${topic}`);
|
|
55
70
|
printIndex();
|
|
56
71
|
process.exit(1);
|
|
57
72
|
}
|
|
58
73
|
|
|
59
|
-
const path = join(docsDir, file);
|
|
74
|
+
const path = join(docsDir, entry.file);
|
|
60
75
|
if (!existsSync(path)) {
|
|
61
76
|
console.error(`missing doc: ${path}`);
|
|
62
77
|
process.exit(1);
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
let body = readFileSync(path, "utf8");
|
|
66
|
-
if (
|
|
67
|
-
const
|
|
68
|
-
|
|
81
|
+
if (entry.section) {
|
|
82
|
+
const idx = body.indexOf(entry.section);
|
|
83
|
+
// why the whole document when the heading is absent: an answer from the wrong version of a doc beats no answer,
|
|
84
|
+
// and the section check already runs in the gate.
|
|
69
85
|
if (idx >= 0) {
|
|
70
|
-
body =
|
|
86
|
+
body = body.slice(idx);
|
|
71
87
|
}
|
|
72
88
|
}
|
|
73
89
|
|
package/tools/init-project.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, sep } from "node:path";
|
|
3
3
|
import { applyCursorWiring, renderCursorHooksDocument } from "../bin/write-user-hooks.mjs";
|
|
4
4
|
import type { WiringEntry } from "../src/contracts/index.ts";
|
|
5
|
+
import { coreFacade } from "../src/core/index.ts";
|
|
5
6
|
import { DEFAULTS } from "../src/core/policy/policy.defaults.ts";
|
|
6
7
|
import { claudeConfigDir, cursorConfigDir, projectConfigPath, runtimeHome } from "../src/platform/paths.ts";
|
|
7
8
|
import { render, type Screen } from "../src/platform/screen.ts";
|
|
@@ -150,17 +151,37 @@ export function mergeGitignore(root: string): { text: string; changed: boolean }
|
|
|
150
151
|
return { text: `${withoutTrailingBlank.join("\n").replace(/\n+$/, "")}\n`, changed: true };
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
/**
|
|
155
|
+
* hazard: this returned `DEFAULTS` whole, and the wizard's `--stdin-json` carried every knob it had collected. Both
|
|
156
|
+
* wrote values nobody chose, and each one shadows the machine tier for ever — raise a number there afterwards and
|
|
157
|
+
* no such project sees it. Measured on one repository: 29 keys restating the tiers below
|
|
158
|
+
* ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
159
|
+
*
|
|
160
|
+
* invariant: pruning cannot change the effective policy, because a leaf is dropped only when the tiers below
|
|
161
|
+
* already resolve to it. An existing config is still returned untouched — rewriting an operator's file is not
|
|
162
|
+
* this command's business.
|
|
163
|
+
*/
|
|
153
164
|
export function resolvePolicy(root: string, flags: InitFlags, stdinText: string | null): unknown {
|
|
154
165
|
if (flags.stdinJson && !flags.minimal) {
|
|
155
166
|
if (!stdinText || stdinText.trim() === "") {
|
|
156
167
|
throw new Error("stdin-json: empty stdin");
|
|
157
168
|
}
|
|
158
|
-
return JSON.parse(stdinText);
|
|
169
|
+
return prune(JSON.parse(stdinText) as Record<string, unknown>);
|
|
159
170
|
}
|
|
160
171
|
if (!flags.minimal && !flags.stdinJson && existsSync(projectConfigPath(root))) {
|
|
161
172
|
return JSON.parse(readFileSync(projectConfigPath(root), "utf8"));
|
|
162
173
|
}
|
|
163
|
-
|
|
174
|
+
/**
|
|
175
|
+
* hazard: this returned `DEFAULTS`, and pruning it was not enough. Against a machine tier that enabled things,
|
|
176
|
+
* the shipped defaults *differ* — so a fresh project wrote `comments.enabled: false` and turned off, in that
|
|
177
|
+
* repository, a capability the operator had switched on for the machine. A project that has decided nothing must
|
|
178
|
+
* say nothing ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
179
|
+
*/
|
|
180
|
+
return { version: DEFAULTS.version };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function prune(policy: Record<string, unknown>): Record<string, unknown> {
|
|
184
|
+
return coreFacade.policy.pruneShadowed(policy, coreFacade.policy.resolvedWithoutProjectTier());
|
|
164
185
|
}
|
|
165
186
|
|
|
166
187
|
export type ProviderPresence = { cursor: boolean; claude: boolean };
|
|
@@ -197,10 +218,22 @@ export function buildPlan(
|
|
|
197
218
|
|
|
198
219
|
export type ApplyOutcome = {
|
|
199
220
|
configPath: string;
|
|
221
|
+
/** why reported: silence after keeping a file reads as having written it. */
|
|
222
|
+
configKept: boolean;
|
|
200
223
|
cursor: { skipped: true } | { skipped: false; status: string; target: string };
|
|
201
224
|
claude: { skipped: true } | { skipped: false; status: string; target: string };
|
|
202
225
|
};
|
|
203
226
|
|
|
227
|
+
/**
|
|
228
|
+
* why exported: the repository's convention is that text is built by a named function and printed by the caller,
|
|
229
|
+
* so what an operator reads can be asserted. Silence after keeping a file reads as having written it.
|
|
230
|
+
*/
|
|
231
|
+
export function configLine(outcome: ApplyOutcome): string {
|
|
232
|
+
return outcome.configKept
|
|
233
|
+
? `kept ${outcome.configPath} — already configured; delete it to start over, or run the wizard to replace it`
|
|
234
|
+
: `wrote ${outcome.configPath}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
204
237
|
export function applyPlan(
|
|
205
238
|
root: string,
|
|
206
239
|
flags: InitFlags,
|
|
@@ -209,8 +242,20 @@ export function applyPlan(
|
|
|
209
242
|
): ApplyOutcome {
|
|
210
243
|
const policy = resolvePolicy(root, flags, stdinText);
|
|
211
244
|
const configPath = projectConfigPath(root);
|
|
212
|
-
|
|
213
|
-
|
|
245
|
+
/**
|
|
246
|
+
* hazard: this wrote unconditionally. `init --minimal` on a configured project replaced the operator's file —
|
|
247
|
+
* with the whole default policy before, with a bare version marker after that changed. Both destroy choices
|
|
248
|
+
* nobody asked to undo, and neither said so ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
249
|
+
*
|
|
250
|
+
* invariant: an existing config is replaced only when the operator supplied one to replace it with. That is what
|
|
251
|
+
* `--stdin-json` is — the wizard's collected answers — and it is the one route that carries consent. Everything
|
|
252
|
+
* else keeps the file. This is the rule `linkDir` already follows: a real file at the target is somebody's work.
|
|
253
|
+
*/
|
|
254
|
+
const kept = existsSync(configPath) && !flags.stdinJson;
|
|
255
|
+
if (!kept) {
|
|
256
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
257
|
+
writeFileSync(configPath, `${JSON.stringify(policy, null, 2)}\n`);
|
|
258
|
+
}
|
|
214
259
|
|
|
215
260
|
const launcher = launcherPath();
|
|
216
261
|
|
|
@@ -242,7 +287,7 @@ export function applyPlan(
|
|
|
242
287
|
const gitignore = mergeGitignore(root);
|
|
243
288
|
writeFileSync(join(root, ".gitignore"), gitignore.text);
|
|
244
289
|
|
|
245
|
-
return { configPath, cursor, claude };
|
|
290
|
+
return { configPath, configKept: kept, cursor, claude };
|
|
246
291
|
}
|
|
247
292
|
|
|
248
293
|
async function readStdin(): Promise<string> {
|
|
@@ -270,7 +315,7 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
270
315
|
}
|
|
271
316
|
|
|
272
317
|
const outcome = applyPlan(root, flags, presence, stdinText);
|
|
273
|
-
console.log(
|
|
318
|
+
console.log(configLine(outcome));
|
|
274
319
|
if (outcome.cursor.skipped) {
|
|
275
320
|
console.log("init: cursor not installed — skipped project hooks.json");
|
|
276
321
|
} else {
|
package/tools/install-runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { NPM_MARKER, NPM_PACKAGE, wireRuntime } from "../bin/tlc-cli.ts";
|
|
5
5
|
import { linkDir } from "../src/platform/links.ts";
|
|
@@ -71,8 +71,29 @@ export function originRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
71
71
|
return home && home.length > 0 ? resolve(home) : conventionalRuntimeHome();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* hazard: this compared `resolve(source)` with `resolve(dest)`, and `resolve` does not follow a symlink. An
|
|
76
|
+
* operator who installed with `--link` has a runtime home that *is* a link to their checkout, so the two paths
|
|
77
|
+
* differed lexically while naming the same directory — the in-place guard missed, `rmSync` followed the link, and
|
|
78
|
+
* the first entry of the payload deleted the checkout's own `bin/` before `cpSync` failed on the source it had
|
|
79
|
+
* just removed. Measured on this repository: the gate ate its own `bin/` ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
80
|
+
*
|
|
81
|
+
* why the fallback: a destination that does not exist yet has no real path, and a first install is exactly that
|
|
82
|
+
* case. Then the lexical answer is the only one there is, and it is correct — nothing is there to alias.
|
|
83
|
+
*/
|
|
84
|
+
function samePlace(a: string, b: string): boolean {
|
|
85
|
+
const real = (path: string): string => {
|
|
86
|
+
try {
|
|
87
|
+
return realpathSync(path);
|
|
88
|
+
} catch {
|
|
89
|
+
return resolve(path);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return real(a) === real(b);
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
export function installRuntime(source: string, dest: string): InstallReport {
|
|
75
|
-
if (
|
|
96
|
+
if (samePlace(source, dest)) {
|
|
76
97
|
// why: the git route already has the code at the destination. Copying a directory onto itself is the one
|
|
77
98
|
// input that turns a sync into data loss.
|
|
78
99
|
return { kind: "in-place", source, dest, entries: [], missing: [] };
|
package/tools/lessons-cli.ts
CHANGED
|
@@ -159,7 +159,10 @@ export function listScreen(report: LessonsListReport): Screen {
|
|
|
159
159
|
},
|
|
160
160
|
{ label: "notes", value: notes.join(" ") },
|
|
161
161
|
],
|
|
162
|
-
|
|
162
|
+
// why the whole instruction: it is the operator's own text, and a slice with no marker is how 103
|
|
163
|
+
// characters of a 263-character lesson vanished mid-word ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
164
|
+
lines: ["", row.instruction],
|
|
165
|
+
wrap: true,
|
|
163
166
|
});
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import { coreFacade } from "../src/core/index.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { machineHome } from "../src/platform/paths.ts";
|
|
6
6
|
import {
|
|
7
7
|
cataloguePath,
|
|
8
8
|
FALLBACK_PLANE,
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
*
|
|
24
24
|
* invariant: written where it is read. One resolution, `runtimeHome()`, used by both sides.
|
|
25
25
|
*/
|
|
26
|
-
const HARNESS_HOME =
|
|
26
|
+
const HARNESS_HOME = machineHome();
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* The plane a provider's own rates land in. It is the provider's id, because the catalogue is keyed by who bills
|
|
@@ -12,7 +12,13 @@ import {
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
14
14
|
import { NPM_MARKER, NPM_PACKAGE } from "../bin/tlc-cli.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
claudeConfigDir,
|
|
17
|
+
cursorConfigDir,
|
|
18
|
+
launcherBinDir,
|
|
19
|
+
launcherNames,
|
|
20
|
+
runtimeHome,
|
|
21
|
+
} from "../src/platform/paths.ts";
|
|
16
22
|
import { type Row, render, type Screen } from "../src/platform/screen.ts";
|
|
17
23
|
import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
|
|
18
24
|
import { removeClaudeWiring, unmergeClaudeSettings } from "../src/providers/claude/claude.wiring.ts";
|
|
@@ -57,10 +63,12 @@ export type UninstallTargets = {
|
|
|
57
63
|
*/
|
|
58
64
|
export function uninstallTargets(env: NodeJS.ProcessEnv = process.env): UninstallTargets {
|
|
59
65
|
const userHome = homedir();
|
|
60
|
-
|
|
66
|
+
// invariant: the same definition install links into, so what one creates the other removes
|
|
67
|
+
// ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
68
|
+
const binDir = launcherBinDir(env);
|
|
61
69
|
return {
|
|
62
70
|
home: runtimeHome(env),
|
|
63
|
-
binLinks:
|
|
71
|
+
binLinks: launcherNames().map((name) => join(binDir, name)),
|
|
64
72
|
claudeSettings: join(claudeConfigDir(), "settings.json"),
|
|
65
73
|
cursorHooks: join(cursorConfigDir(), "hooks.json"),
|
|
66
74
|
skillLinks: [
|