@tech-leads-club/harness-toolkit 0.3.5 → 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 +6 -0
- package/package.json +1 -1
- package/skills/harness-init/references/capabilities.md +54 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/tool-names.ts +25 -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.guard.ts +2 -3
- 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/presence/presence.service.ts +10 -2
- 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/run.ts +31 -1
- 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/doctor.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir, platform as osPlatform } from "node:os";
|
|
4
4
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
5
|
-
import {
|
|
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
11
|
import {
|
|
12
|
+
launcherBinDir,
|
|
12
13
|
projectConfigPath,
|
|
13
14
|
projectStateDir,
|
|
14
15
|
providerConfigDirs,
|
|
@@ -244,6 +245,69 @@ export function resolveOnPath(
|
|
|
244
245
|
return null;
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The operator's own rules: which apply, where each came from, and any that can never be satisfied here.
|
|
250
|
+
*
|
|
251
|
+
* why the tier is printed: two tiers apply together, so "why did this fire?" and "why did it not?" are both
|
|
252
|
+
* answered by knowing whether the rule came from this project or from the machine
|
|
253
|
+
* ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
254
|
+
*
|
|
255
|
+
* invariant: silent when the capability is off, and silent when no rule is declared. A row about a mechanism
|
|
256
|
+
* nobody opted into is noise on every healthy install ([/decisions/ad-034.md](/decisions/ad-034.md)).
|
|
257
|
+
*/
|
|
258
|
+
export function checkRules(root: string): Check[] {
|
|
259
|
+
const policy = coreFacade.policy.loadPolicy(root);
|
|
260
|
+
// why the switch is not re-read here: `load` owns it, and a second copy of the same condition is a second
|
|
261
|
+
// thing to keep true ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
262
|
+
const set = coreFacade.rules.load(root, policy.rules);
|
|
263
|
+
if (set.rules.length === 0 && set.disabled.length === 0 && set.errors.length === 0) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const checks: Check[] = [];
|
|
268
|
+
if (set.rules.length > 0) {
|
|
269
|
+
checks.push({
|
|
270
|
+
level: "ok",
|
|
271
|
+
name: "operator rules",
|
|
272
|
+
detail: set.rules
|
|
273
|
+
.map((rule) => `${rule.name} (${rule.tier}) on ${rule.on.kind} → ${rule.otherwise}`)
|
|
274
|
+
.join("; "),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// why an `ok` row: switching a global off in one repository is a decision, not a fault. It is reported because
|
|
279
|
+
// an operator who forgot they did it would otherwise wonder why nothing fires.
|
|
280
|
+
if (set.disabled.length > 0) {
|
|
281
|
+
checks.push({
|
|
282
|
+
level: "ok",
|
|
283
|
+
name: "operator rules (off here)",
|
|
284
|
+
detail: set.disabled.map((rule) => rule.name).join(", "),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const error of set.errors) {
|
|
289
|
+
checks.push({
|
|
290
|
+
level: "fail",
|
|
291
|
+
name: `operator rule (${error.name})`,
|
|
292
|
+
detail: `${error.error} — the other rules still apply`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* hazard: a rule whose proof kind this project has never recorded reads as protection and enforces nothing an
|
|
298
|
+
* operator can satisfy. Saying so is factual; guessing at the host's capabilities would not be.
|
|
299
|
+
*/
|
|
300
|
+
for (const entry of coreFacade.rules.unobservedKinds(set.rules, coreFacade.rules.observations(root))) {
|
|
301
|
+
checks.push({
|
|
302
|
+
level: "warn",
|
|
303
|
+
name: `operator rule (${entry.rule})`,
|
|
304
|
+
detail: `needs ${entry.kinds.join(", ")}, and no observation of that kind has been recorded in this project yet`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return checks;
|
|
309
|
+
}
|
|
310
|
+
|
|
247
311
|
export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Check[] {
|
|
248
312
|
const launcher = join(home, "bin", "tlc-exec.mjs");
|
|
249
313
|
const distSample = join(home, "dist", "stop.mjs");
|
|
@@ -262,7 +326,12 @@ export function checkRuntimePaths(home: string, platform: NodeJS.Platform): Chec
|
|
|
262
326
|
{
|
|
263
327
|
level: onPath === null ? "fail" : "ok",
|
|
264
328
|
name: "CLI on PATH",
|
|
265
|
-
|
|
329
|
+
// hazard: this said "npm i -g <package>" — advice an operator who had just done exactly that could not act
|
|
330
|
+
// on. npm's shim lives in the bin directory of whichever Node version npm ran under, and leaves PATH the
|
|
331
|
+
// moment a version manager switches ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
332
|
+
detail:
|
|
333
|
+
onPath ??
|
|
334
|
+
`no \`tlc\` on PATH — link it: ln -s ${join(home, "bin", "tlc")} ${join(launcherBinDir(), "tlc")} (or re-run \`tlc harness install\`, which does it)`,
|
|
266
335
|
},
|
|
267
336
|
];
|
|
268
337
|
}
|
|
@@ -470,6 +539,94 @@ export function checkSubagentAllowlist(root: string): Check[] {
|
|
|
470
539
|
];
|
|
471
540
|
}
|
|
472
541
|
|
|
542
|
+
/**
|
|
543
|
+
* What the operator cannot otherwise find out: how many of their lessons actually reach the model.
|
|
544
|
+
*
|
|
545
|
+
* hazard: the char budget drops lessons, and the only place that said so was the injected block itself — text the
|
|
546
|
+
* model reads and the operator never sees. `lessons list` answers a different question (`not-injected` is about
|
|
547
|
+
* grading history, not about the budget), and `status` does not answer it at all. So an operator with six lessons
|
|
548
|
+
* and a 900-char budget saw six healthy lessons and had four that never left the file
|
|
549
|
+
* ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
550
|
+
*
|
|
551
|
+
* why the real selector rather than a size sum: pinning, staleness, validity windows and the mode all bind before
|
|
552
|
+
* the budget does. Re-deriving the arithmetic here would be a second answer that drifts from the first.
|
|
553
|
+
*/
|
|
554
|
+
/**
|
|
555
|
+
* Which keys this project restates instead of deciding.
|
|
556
|
+
*
|
|
557
|
+
* hazard: the layers are `DEFAULTS < user < project`, and `init` writes the whole default policy when there is no
|
|
558
|
+
* config yet while the wizard writes every knob it collected. So a project config typically names dozens of values
|
|
559
|
+
* it did not choose — and each one shadows the machine-wide tier for ever. An operator who raises
|
|
560
|
+
* `maxCharsSession` once, on the machine, sees no change in any repository that restated the old number, and
|
|
561
|
+
* nothing said why ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
562
|
+
*
|
|
563
|
+
* invariant: a warning, never a failure. Restating a value is legitimate — pinning a project to a number on
|
|
564
|
+
* purpose is a real intent. What is not legitimate is not knowing.
|
|
565
|
+
*/
|
|
566
|
+
export function checkShadowedPolicy(root: string): Check[] {
|
|
567
|
+
const project = coreFacade.capability.readProjectPolicyRaw(root);
|
|
568
|
+
if (!project) {
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
const shadowed = coreFacade.policy.shadowedKeys(project, coreFacade.policy.resolvedWithoutProjectTier());
|
|
572
|
+
if (shadowed.length === 0) {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
const shown = shadowed.slice(0, 6).map((key) => key.path);
|
|
576
|
+
const rest = shadowed.length - shown.length;
|
|
577
|
+
return [
|
|
578
|
+
{
|
|
579
|
+
level: "warn",
|
|
580
|
+
name: "project policy restates lower tiers",
|
|
581
|
+
detail: `${plural(shadowed.length, "key")} in this project's config name the value ${shadowed.length === 1 ? "it" : "they"} would already have: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. Each one stops tracking ${join(runtimeHome(), "config.json")}, so a machine-wide change will not reach this repository. Delete what this project did not decide.`,
|
|
582
|
+
},
|
|
583
|
+
];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function checkLessonBudget(root: string): Check[] {
|
|
587
|
+
const policy = coreFacade.policy.loadPolicy(root);
|
|
588
|
+
const config = policy.intelligence.lessons;
|
|
589
|
+
if (!config.enabled) {
|
|
590
|
+
return [];
|
|
591
|
+
}
|
|
592
|
+
const selected = coreFacade.lesson.previewLessonSelection({ projectDir: root, config, mode: "session" });
|
|
593
|
+
const reaching = selected.lessons.length;
|
|
594
|
+
if (selected.omitted === 0) {
|
|
595
|
+
return reaching === 0
|
|
596
|
+
? []
|
|
597
|
+
: [
|
|
598
|
+
{
|
|
599
|
+
level: "ok",
|
|
600
|
+
name: "lesson budget",
|
|
601
|
+
detail: `every eligible lesson reaches the model at session start (${plural(reaching, "lesson")}, maxCharsSession ${config.maxCharsSession})`,
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
}
|
|
605
|
+
const chars = selected.lessons.reduce(
|
|
606
|
+
(total, lesson) => total + coreFacade.lesson.renderLessonBlock(lesson).length,
|
|
607
|
+
0,
|
|
608
|
+
);
|
|
609
|
+
const hogs = selected.lessons
|
|
610
|
+
.filter((lesson) => lesson.pinned)
|
|
611
|
+
.map((lesson) => `${lesson.id} (pinned, ${coreFacade.lesson.renderLessonBlock(lesson).length} chars)`);
|
|
612
|
+
return [
|
|
613
|
+
{
|
|
614
|
+
level: "warn",
|
|
615
|
+
name: "lesson budget",
|
|
616
|
+
detail: [
|
|
617
|
+
// hazard: this read "4 eligible lessons never reaches". `plural` handles the noun and the verb still has to
|
|
618
|
+
// agree — the same slip the unproven row already carries a note about.
|
|
619
|
+
`${plural(selected.omitted, "eligible lesson")} ${selected.omitted === 1 ? "never reaches" : "never reach"} the model at session start:`,
|
|
620
|
+
`${reaching} of ${reaching + selected.omitted} fit in maxCharsSession ${config.maxCharsSession} (${chars} used).`,
|
|
621
|
+
hogs.length > 0 ? `Pinned lessons go first and take the room: ${hogs.join(", ")}.` : "",
|
|
622
|
+
"Raise intelligence.lessons.maxCharsSession, shorten a lesson, or unpin one. Run: tlc harness lessons list",
|
|
623
|
+
]
|
|
624
|
+
.filter(Boolean)
|
|
625
|
+
.join(" "),
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
|
|
473
630
|
export function checkLessonHealth(root: string): Check[] {
|
|
474
631
|
const policy = coreFacade.policy.loadPolicy(root);
|
|
475
632
|
if (!policy.intelligence.lessons.enabled) {
|
|
@@ -597,6 +754,8 @@ export function checkProjectPolicy(root: string): Check[] {
|
|
|
597
754
|
checkPosture(root),
|
|
598
755
|
...checkObservedRails(root),
|
|
599
756
|
...checkLessonHealth(root),
|
|
757
|
+
...checkLessonBudget(root),
|
|
758
|
+
...checkShadowedPolicy(root),
|
|
600
759
|
...checkSubagentAllowlist(root),
|
|
601
760
|
...checkPolicyDivergence(root),
|
|
602
761
|
...checkGateScope(root),
|
|
@@ -643,6 +802,7 @@ export function runChecks(ctx: DoctorContext): Check[] {
|
|
|
643
802
|
...checkProjectPolicy(ctx.root),
|
|
644
803
|
...checkCapabilities(ctx.root, ctx.runtimeHome),
|
|
645
804
|
...checkPrices(),
|
|
805
|
+
...checkRules(ctx.root),
|
|
646
806
|
checkGlobalCommands(ctx.home),
|
|
647
807
|
];
|
|
648
808
|
}
|
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: [
|