@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/src/entrypoints/stop.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { flagsDir } from "../platform/paths.ts";
|
|
14
14
|
import type { Handler, HandlerContext } from "./run.ts";
|
|
15
15
|
import { main } from "./run.ts";
|
|
16
|
-
import { formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
|
|
16
|
+
import { currentGitSha, formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
|
|
17
17
|
|
|
18
18
|
const STAGNATION_FOLLOWUP = [
|
|
19
19
|
"BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
|
|
@@ -21,6 +21,31 @@ const STAGNATION_FOLLOWUP = [
|
|
|
21
21
|
"NEED: change approach. Do not repeat the same fix. Inspect root cause, try a different path, or escalate with BLOCKED/TRIED/NEED.",
|
|
22
22
|
].join("\n");
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A gate is the one proof the harness decides rather than witnesses, so it is recorded where every gate already
|
|
26
|
+
* funnels through — the same argument `recordGateOutcome` makes for itself: a gate added later cannot be
|
|
27
|
+
* forgotten ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
28
|
+
*
|
|
29
|
+
* invariant: only a gate that passed. Recording a failure would let "the gate ran" satisfy a rule that asked for
|
|
30
|
+
* "the gate passed", which is the whole point of asking.
|
|
31
|
+
*/
|
|
32
|
+
async function observeGateForRules(args: {
|
|
33
|
+
root: string;
|
|
34
|
+
sessionKey: string;
|
|
35
|
+
policy: Policy;
|
|
36
|
+
gate: string;
|
|
37
|
+
passed: boolean;
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
if (!args.passed || !coreFacade.rules.wantsGate(args.root, args.policy.rules)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
coreFacade.rules.observeGate(args.root, args.policy.rules, args.gate, {
|
|
43
|
+
sha: await currentGitSha(args.root),
|
|
44
|
+
sessionKey: args.sessionKey,
|
|
45
|
+
at: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
/**
|
|
25
50
|
* hazard: `gate.outcome` was consumed in two places — the rollup counter and the session report's
|
|
26
51
|
* "Gates pass/fail" line — and emitted by nothing. Both read structurally zero, so the report printed a
|
|
@@ -200,6 +225,7 @@ async function runLockedGate(args: {
|
|
|
200
225
|
|
|
201
226
|
// invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
|
|
202
227
|
recordGateOutcome({ ...args, artifact, reused: cached !== null });
|
|
228
|
+
await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
|
|
203
229
|
await creditPendingLessons({
|
|
204
230
|
root: args.root,
|
|
205
231
|
provider: args.provider,
|
|
@@ -387,6 +413,28 @@ async function failGate(args: {
|
|
|
387
413
|
return { kind: "continue", text: parts.join("\n") };
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
/**
|
|
417
|
+
* why the sha is fetched twice-or-never: `git rev-parse` is a process spawn and this runs on every stop. The first
|
|
418
|
+
* pass answers whether any `on: stop` rule fired at all, which costs two directory reads; only then is a sha worth
|
|
419
|
+
* a process, and the second pass is the one whose verdict counts. Same shape as the action-time rail
|
|
420
|
+
* ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
421
|
+
*/
|
|
422
|
+
async function decideStopRules(
|
|
423
|
+
root: string,
|
|
424
|
+
policy: Policy,
|
|
425
|
+
sessionKey: string,
|
|
426
|
+
): Promise<ReturnType<typeof coreFacade.rules.decideStop>> {
|
|
427
|
+
const context = { sessionKey, mode: policy.mode };
|
|
428
|
+
const dryRun = coreFacade.rules.decideStop(root, policy.rules, { ...context, sha: null });
|
|
429
|
+
if (dryRun.outcomes.length === 0) {
|
|
430
|
+
return dryRun;
|
|
431
|
+
}
|
|
432
|
+
return coreFacade.rules.decideStop(root, policy.rules, {
|
|
433
|
+
...context,
|
|
434
|
+
sha: await currentGitSha(root),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
390
438
|
export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
|
|
391
439
|
const { policy, capabilities } = ctx;
|
|
392
440
|
const root = event.projectDir;
|
|
@@ -829,6 +877,32 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
829
877
|
});
|
|
830
878
|
}
|
|
831
879
|
|
|
880
|
+
/**
|
|
881
|
+
* The operator's own bar, last among the blockers so every gate the harness owns has already spoken — and after
|
|
882
|
+
* the gates specifically, because a rule asking for `gate(lint) since HEAD` can only be satisfied once lint has
|
|
883
|
+
* run this turn ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
884
|
+
*
|
|
885
|
+
* invariant: `warn` returns `context`, which does not block. Everything else refuses the stop, so a rule the
|
|
886
|
+
* operator wrote cannot be ended past.
|
|
887
|
+
*/
|
|
888
|
+
const stopRules = await decideStopRules(root, policy, sessionKey);
|
|
889
|
+
if (stopRules.decision.kind !== "abstain") {
|
|
890
|
+
if (stopRules.decision.kind !== "context") {
|
|
891
|
+
const worst = coreFacade.rules.strictest(stopRules.outcomes);
|
|
892
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
893
|
+
slice: {
|
|
894
|
+
last_gate_result: "fail",
|
|
895
|
+
last_failure_category: "policy",
|
|
896
|
+
blockers: `Rule ${worst?.rule.name ?? "unknown"} is not satisfied for this HEAD.`,
|
|
897
|
+
next_action: worst?.missing.length
|
|
898
|
+
? `Produce ${worst.missing.join(", ")}, then stop again.`
|
|
899
|
+
: "Satisfy the rule named in the follow-up, then stop again.",
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return stopRules.decision;
|
|
904
|
+
}
|
|
905
|
+
|
|
832
906
|
// why: the pairing of a failure with what resolved it is captured here, immediately before the record that
|
|
833
907
|
// holds the failure identity is cleared. This is the one moment both halves exist
|
|
834
908
|
// ([/decisions/ad-028.md](/decisions/ad-028.md)).
|
|
@@ -2,10 +2,18 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
|
2
2
|
import { coreFacade } from "../core/index.ts";
|
|
3
3
|
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
4
|
import { main } from "./run.ts";
|
|
5
|
+
import { observeForRules } from "./support.ts";
|
|
5
6
|
|
|
6
7
|
// why: no legacy predecessor covers subagent.stop verification — this reuses the same unfinished-work
|
|
7
8
|
// signal (blockers/pending/in_progress/previous_gaps) already carried on the handoff slice.
|
|
8
|
-
export const subagentStopHandler: Handler = (
|
|
9
|
+
export const subagentStopHandler: Handler = async (
|
|
10
|
+
event: HarnessEvent,
|
|
11
|
+
ctx: HandlerContext,
|
|
12
|
+
): Promise<Decision> => {
|
|
13
|
+
// why before the verdict: this records that a subagent of this type finished, which is the proof an operator
|
|
14
|
+
// rule asks for. It cannot change the decision below ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
15
|
+
await observeForRules(event, ctx);
|
|
16
|
+
|
|
9
17
|
const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
|
|
10
18
|
const unfinishedWork =
|
|
11
19
|
Boolean(handoff.blockers) ||
|
|
@@ -180,3 +180,35 @@ export function formatLessonsBlock(lessons: HarnessLesson[], title: string, omit
|
|
|
180
180
|
}
|
|
181
181
|
return lines.join("\n");
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The producer half of the feature: what the harness witnessed, written where only the harness can write it.
|
|
186
|
+
*
|
|
187
|
+
* hazard: this did not exist in the first cut. `observe` had no caller, so the store was never written, no proof
|
|
188
|
+
* could ever be satisfied, and every rule that parsed denied for ever — `require:` is mandatory, so that was
|
|
189
|
+
* every rule ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
190
|
+
*
|
|
191
|
+
* why `wants` first: this runs on every tool call and the sha is a process spawn. Nothing is asked of git unless
|
|
192
|
+
* a declared rule requires this kind of proof, so an operator whose only rule wants a subagent pays no git on any
|
|
193
|
+
* command.
|
|
194
|
+
*
|
|
195
|
+
* invariant: after the event, never able to change it. A rail that records what happened must not become a rail
|
|
196
|
+
* that decides whether it may.
|
|
197
|
+
*/
|
|
198
|
+
export async function observeForRules(
|
|
199
|
+
event: HarnessEvent,
|
|
200
|
+
// why the shape and not `HandlerContext`: `run.ts` already imports this module, so naming its type here would
|
|
201
|
+
// close an import cycle. Only the one field is needed.
|
|
202
|
+
ctx: { policy: { rules: Policy["rules"] } },
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const config = ctx.policy.rules;
|
|
205
|
+
if (!coreFacade.rules.wants(event.projectDir, config, event)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const sha = await currentGitSha(event.projectDir);
|
|
209
|
+
coreFacade.rules.observe(event.projectDir, config, event, {
|
|
210
|
+
sha,
|
|
211
|
+
sessionKey: event.sessionKey,
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -4,7 +4,7 @@ import { estimateCostUsd, mapPoolToNeutral } from "../platform/pricing.ts";
|
|
|
4
4
|
import { readClaudeUsage } from "../providers/index.ts";
|
|
5
5
|
import type { Handler, HandlerContext } from "./run.ts";
|
|
6
6
|
import { main } from "./run.ts";
|
|
7
|
-
import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
|
|
7
|
+
import { OBS_CONFIG_AUDIT, obsConfigFor, observeForRules } from "./support.ts";
|
|
8
8
|
|
|
9
9
|
const OBS_KIND_BY_EVENT: Partial<Record<HarnessEventKind, ObsKind>> = {
|
|
10
10
|
"tool.after": "tool.end",
|
|
@@ -48,7 +48,12 @@ function usageGenAi(event: HarnessEvent, ctx: HandlerContext): Record<string, un
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const toolAfterHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
|
|
51
|
+
export const toolAfterHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext) => {
|
|
52
|
+
// why here and not at `*.before`: arriving on an after-event is what says the tool ran and did not fail. A
|
|
53
|
+
// failure comes as `tool.failure`, a different event this rail never sees, and the payload carries no exit code
|
|
54
|
+
// in any of the three shapes the two hosts send ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
55
|
+
await observeForRules(event, ctx);
|
|
56
|
+
|
|
52
57
|
coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
|
|
53
58
|
|
|
54
59
|
const kind = OBS_KIND_BY_EVENT[event.event];
|
|
@@ -2,7 +2,7 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
|
2
2
|
import { coreFacade } from "../core/index.ts";
|
|
3
3
|
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
4
|
import { main } from "./run.ts";
|
|
5
|
-
import { obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
|
|
5
|
+
import { currentGitSha, obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
|
|
6
6
|
|
|
7
7
|
const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
|
|
8
8
|
|
|
@@ -43,6 +43,33 @@ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, de
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* why the sha is read here and not in `run.ts`: `git rev-parse` is a process spawn, and this fires on every tool
|
|
48
|
+
* call. It is asked for only once a rule has actually fired, so an operator who declared nothing pays nothing
|
|
49
|
+
* ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
50
|
+
*/
|
|
51
|
+
async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
|
|
52
|
+
const config = ctx.policy.rules;
|
|
53
|
+
const trigger = { event: event.event, toolName: event.toolName, command: event.command };
|
|
54
|
+
const dryRun = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
|
|
55
|
+
sha: null,
|
|
56
|
+
sessionKey: event.sessionKey,
|
|
57
|
+
mode: ctx.policy.mode,
|
|
58
|
+
});
|
|
59
|
+
if (dryRun.outcomes.length === 0) {
|
|
60
|
+
return { kind: "abstain" };
|
|
61
|
+
}
|
|
62
|
+
// why twice: the first pass answers whether any rule fired at all, which costs no git. Only then is the sha
|
|
63
|
+
// worth a process, and the second pass is the one whose verdict counts.
|
|
64
|
+
const sha = await currentGitSha(event.projectDir);
|
|
65
|
+
const verdict = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
|
|
66
|
+
sha,
|
|
67
|
+
sessionKey: event.sessionKey,
|
|
68
|
+
mode: ctx.policy.mode,
|
|
69
|
+
});
|
|
70
|
+
return verdict.decision;
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
function handleShellBefore(event: HarnessEvent, ctx: HandlerContext): Decision {
|
|
47
74
|
const { policy } = ctx;
|
|
48
75
|
const decision = coreFacade.shellPolicy.evaluateShellCommand({
|
|
@@ -108,10 +135,10 @@ async function handleToolBefore(event: HarnessEvent, ctx: HandlerContext): Promi
|
|
|
108
135
|
return { kind: "allow" };
|
|
109
136
|
}
|
|
110
137
|
|
|
111
|
-
export const toolBeforeHandler: Handler = (
|
|
138
|
+
export const toolBeforeHandler: Handler = async (
|
|
112
139
|
event: HarnessEvent,
|
|
113
140
|
ctx: HandlerContext,
|
|
114
|
-
):
|
|
141
|
+
): Promise<Decision> => {
|
|
115
142
|
// invariant: the floor runs first and reads no policy, so no config value and no agent edit can
|
|
116
143
|
// reach a decision before it.
|
|
117
144
|
const floor = coreFacade.floor.evaluateFloor({
|
|
@@ -159,6 +186,20 @@ export const toolBeforeHandler: Handler = (
|
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The operator's own rules, after the floor and after the integrity check, because both are unconditional and a
|
|
191
|
+
* rail comes second ([/decisions/ad-077.md](/decisions/ad-077.md)).
|
|
192
|
+
*
|
|
193
|
+
* invariant: `deny` and `ask` answer here; `follow-up` and `warn` abstain and are the stop rail's business. With
|
|
194
|
+
* the capability off, or with no rule files, `decideAction` reads two directory entries and abstains — which is
|
|
195
|
+
* what keeps a machine that never opted in byte-identical to before ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
196
|
+
*/
|
|
197
|
+
const rulesVerdict = await rulesDecision(event, ctx);
|
|
198
|
+
if (rulesVerdict.kind !== "abstain") {
|
|
199
|
+
recordShellDecisionIfShell(event, ctx, rulesVerdict);
|
|
200
|
+
return rulesVerdict;
|
|
201
|
+
}
|
|
202
|
+
|
|
162
203
|
switch (event.event) {
|
|
163
204
|
case "shell.before":
|
|
164
205
|
return handleShellBefore(event, ctx);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frontmatter reader.
|
|
3
|
+
*
|
|
4
|
+
* why here: there were two — a full parser in `tools/dev/check-docs-bundle.ts`, which never ships and which
|
|
5
|
+
* `core/` may not import, and a private single-field extractor in `core/release/release.decisions.ts`. Operator
|
|
6
|
+
* rules need a third caller, and a third copy is the duplication this product's own gate refuses. It sits in
|
|
7
|
+
* `platform/` because it is a format primitive with no policy in it, and because that is the one direction all
|
|
8
|
+
* three callers may import from ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
9
|
+
*
|
|
10
|
+
* invariant: pure. No filesystem, no clock. The caller reads the file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type FrontmatterValue = string | string[];
|
|
14
|
+
export type Frontmatter = Record<string, FrontmatterValue>;
|
|
15
|
+
|
|
16
|
+
/** The shape `check-docs-bundle` already consumed, kept so moving this changed no caller's contract. */
|
|
17
|
+
export type ParseResult = { frontmatter: Frontmatter | null; error: string | null };
|
|
18
|
+
|
|
19
|
+
export type FrontmatterDoc = {
|
|
20
|
+
fields: Frontmatter;
|
|
21
|
+
/** Everything after the closing fence, verbatim. An operator rule's instruction lives here. */
|
|
22
|
+
body: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function extractFrontmatterBlock(content: string): { block: string; bodyAt: number } | null {
|
|
26
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const firstBreak = content.indexOf("\n");
|
|
30
|
+
const rest = content.slice(firstBreak + 1);
|
|
31
|
+
const closingMatch = /^---\s*$/m.exec(rest);
|
|
32
|
+
if (!closingMatch) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
block: rest.slice(0, closingMatch.index),
|
|
37
|
+
bodyAt: firstBreak + 1 + closingMatch.index + closingMatch[0].length,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripQuotes(raw: string): string {
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed.length >= 2) {
|
|
44
|
+
const first = trimmed[0];
|
|
45
|
+
const last = trimmed[trimmed.length - 1];
|
|
46
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
47
|
+
return trimmed.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* hazard: an escaped quote inside a value survived the outer-quote strip and reached the operator as a literal
|
|
55
|
+
* `\"` in their terminal. Seen in a real update run ([/decisions/ad-034.md](/decisions/ad-034.md)).
|
|
56
|
+
*/
|
|
57
|
+
function unescapeQuotes(value: string): string {
|
|
58
|
+
return value.replace(/\\(["'\\])/g, "$1");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseValue(raw: string): FrontmatterValue {
|
|
62
|
+
const trimmed = raw.trim();
|
|
63
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
64
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
65
|
+
if (inner === "") {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return inner.split(",").map((item) => unescapeQuotes(stripQuotes(item)));
|
|
69
|
+
}
|
|
70
|
+
return unescapeQuotes(stripQuotes(trimmed));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* why block lists: a rule declares several proofs, and `require: [a, b]` on one line is not how anybody writes
|
|
75
|
+
* three of them. `key:` with nothing after it opens a list; `key: value` closes any list before it.
|
|
76
|
+
*/
|
|
77
|
+
export function parseFrontmatterDoc(content: string): { doc: FrontmatterDoc | null; error: string | null } {
|
|
78
|
+
const extracted = extractFrontmatterBlock(content);
|
|
79
|
+
if (extracted === null) {
|
|
80
|
+
return { doc: null, error: "missing --- frontmatter block" };
|
|
81
|
+
}
|
|
82
|
+
const fields: Frontmatter = {};
|
|
83
|
+
let listKey: string | null = null;
|
|
84
|
+
|
|
85
|
+
for (const line of extracted.block.split("\n")) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (trimmed.startsWith("- ")) {
|
|
91
|
+
if (listKey === null) {
|
|
92
|
+
return { doc: null, error: `list item with no key above it: "${trimmed}"` };
|
|
93
|
+
}
|
|
94
|
+
const current = fields[listKey];
|
|
95
|
+
const item = unescapeQuotes(stripQuotes(trimmed.slice(2)));
|
|
96
|
+
fields[listKey] = Array.isArray(current) ? [...current, item] : [item];
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const separator = trimmed.indexOf(":");
|
|
100
|
+
if (separator === -1) {
|
|
101
|
+
return { doc: null, error: `unparseable frontmatter line: "${trimmed}"` };
|
|
102
|
+
}
|
|
103
|
+
const key = trimmed.slice(0, separator).trim();
|
|
104
|
+
if (key === "") {
|
|
105
|
+
return { doc: null, error: `frontmatter line has an empty key: "${trimmed}"` };
|
|
106
|
+
}
|
|
107
|
+
const value = trimmed.slice(separator + 1);
|
|
108
|
+
if (value.trim() === "") {
|
|
109
|
+
fields[key] = [];
|
|
110
|
+
listKey = key;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
fields[key] = parseValue(value);
|
|
114
|
+
listKey = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { doc: { fields, body: content.slice(extracted.bodyAt).trim() }, error: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** invariant: the shape the docs bundle check already used. One implementation, two entry points. */
|
|
121
|
+
export function parseFrontmatter(content: string): ParseResult {
|
|
122
|
+
const { doc, error } = parseFrontmatterDoc(content);
|
|
123
|
+
return doc === null ? { frontmatter: null, error } : { frontmatter: doc.fields, error: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** why: one field, by name, for a caller that wants nothing else. */
|
|
127
|
+
export function frontmatterField(content: string, field: string): string | undefined {
|
|
128
|
+
const { doc } = parseFrontmatterDoc(content);
|
|
129
|
+
const value = doc?.fields[field];
|
|
130
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return value.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** why: every field a rule reads is one value or a list of them, and the caller should not care which. */
|
|
137
|
+
export function asList(value: FrontmatterValue | undefined): string[] {
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return (Array.isArray(value) ? value : [value]).filter((item) => item !== "");
|
|
142
|
+
}
|
package/src/platform/links.ts
CHANGED
|
@@ -48,6 +48,38 @@ export function linkDir(source: string, target: string): LinkOutcome {
|
|
|
48
48
|
return { kind: replaced ? "relinked" : "linked", target, source };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Point a *file* `target` at `source`, for the `tlc` command on `PATH`.
|
|
53
|
+
*
|
|
54
|
+
* why not `linkDir`: its link type is `"junction"`, which Windows reads and which only means anything for a
|
|
55
|
+
* directory. A file gets no type argument, which is correct on every platform — and where a platform refuses to
|
|
56
|
+
* create one, the reason is reported rather than thrown, because a missing convenience link must not fail an
|
|
57
|
+
* install that otherwise worked ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
58
|
+
*
|
|
59
|
+
* invariant: the same contract as `linkDir` — an existing link is replaced, anything else is refused. A real file
|
|
60
|
+
* called `tlc` in someone's bin directory is theirs.
|
|
61
|
+
*/
|
|
62
|
+
export function linkFile(source: string, target: string): LinkOutcome {
|
|
63
|
+
let replaced = false;
|
|
64
|
+
if (isLink(target)) {
|
|
65
|
+
rmSync(target, { force: true });
|
|
66
|
+
replaced = true;
|
|
67
|
+
} else if (existsSync(target)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "refused",
|
|
70
|
+
target,
|
|
71
|
+
reason: `${target} exists and is not a link — move it aside and re-run`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
76
|
+
symlinkSync(source, target);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { kind: "refused", target, reason: (error as Error).message };
|
|
79
|
+
}
|
|
80
|
+
return { kind: replaced ? "relinked" : "linked", target, source };
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
/**
|
|
52
84
|
* why lstat: a link whose destination is gone is still a link, and `existsSync` says it is not there — so the
|
|
53
85
|
* check has to come first, or a dangling link reads as free space. Node reports a Windows junction as a symbolic
|
package/src/platform/paths.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { delimiter, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
function harnessDir(root: string): string {
|
|
5
5
|
return join(root, ".tlc", "harness");
|
|
@@ -22,15 +22,38 @@ export function runtimeHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* invariant: `tlc-exec` always sets `TLC_HOME` in the child, so the child cannot tell an operator's choice from
|
|
25
|
-
* the launcher's own resolution. This flag is that difference
|
|
26
|
-
* wants the resolved home either way.
|
|
25
|
+
* the launcher's own resolution. This flag is that difference.
|
|
27
26
|
*/
|
|
28
27
|
export function runtimeHomeWasChosen(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
28
|
return env.TLC_HOME_FROM_ENV === "1";
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Where this **machine's** operator data lives: the user-tier config, the global lesson tier, the global rules,
|
|
33
|
+
* the price catalogue, the cross-repo spool.
|
|
34
|
+
*
|
|
35
|
+
* hazard: all of that used to resolve through `runtimeHome()`, which names where the *code* lives and moves with
|
|
36
|
+
* the install. With two installs on one machine there were two "global" tiers, silently — and switching between
|
|
37
|
+
* them looked like data loss. Measured on an operator's machine: a lesson saved with `--global` landed in a
|
|
38
|
+
* checkout's state directory while the CLI printed "every product on this machine will read it", and a user-tier
|
|
39
|
+
* config with a subagent allowlist stopped being read the moment the runtime home changed
|
|
40
|
+
* ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
41
|
+
*
|
|
42
|
+
* why the flag rather than always the conventional path: an operator who exports `TLC_HOME` is choosing a home and
|
|
43
|
+
* means it, and the test suite pins one for hermeticity. Only the launcher's own resolution — which marks itself
|
|
44
|
+
* `TLC_HOME_FROM_ENV=0` — must not be allowed to invent a second machine.
|
|
45
|
+
*/
|
|
46
|
+
export function machineHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
47
|
+
return env.TLC_HOME_FROM_ENV === "0" ? conventionalRuntimeHome() : runtimeHome(env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** invariant: the one path install seeds and update never writes ([/decisions/ad-056.md](/decisions/ad-056.md)). */
|
|
51
|
+
export function machineConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
52
|
+
return join(machineHome(env), "config.json");
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
export function runtimeStateDir(): string {
|
|
33
|
-
return join(
|
|
56
|
+
return join(machineHome(), "state");
|
|
34
57
|
}
|
|
35
58
|
|
|
36
59
|
// why: one file for every repository on the machine. Per-repo state stays authoritative; this is the
|
|
@@ -105,3 +128,34 @@ export function userSettingsPaths(): string[] {
|
|
|
105
128
|
export function providerConfigDirs(): string[] {
|
|
106
129
|
return [cursorConfigDir(), claudeConfigDir()];
|
|
107
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where the `tlc` command goes so a shell can find it.
|
|
134
|
+
*
|
|
135
|
+
* hazard: this lived only in `uninstall-runtime.ts`, which removed a launcher `install` never created. The command
|
|
136
|
+
* came from npm's own shim instead — and that shim sits in the `bin` directory of whichever Node version npm ran
|
|
137
|
+
* under, which leaves `PATH` the moment a version manager switches. Measured on an operator's machine: a
|
|
138
|
+
* successful install followed immediately by `tlc: command not found`
|
|
139
|
+
* ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
140
|
+
*
|
|
141
|
+
* invariant: one definition, so what install creates and what uninstall removes cannot drift apart.
|
|
142
|
+
*/
|
|
143
|
+
export function launcherBinDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
144
|
+
return env.TLC_BIN_DIR?.trim() || join(homedir(), ".local", "bin");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** why the extensionless wrapper: it is what a shell runs. The `.cmd` beside it is Windows's copy of the same. */
|
|
148
|
+
export function launcherNames(): readonly string[] {
|
|
149
|
+
return ["tlc", "tlc.cmd"];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether a shell would find something in this directory.
|
|
154
|
+
*
|
|
155
|
+
* why it matters at install time: a launcher nobody can reach is worse than none, because `doctor` then reports a
|
|
156
|
+
* healthy link while the command still does not exist.
|
|
157
|
+
*/
|
|
158
|
+
export function isOnPath(dir: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
159
|
+
const entries = (env.PATH ?? "").split(delimiter).filter((entry) => entry.length > 0);
|
|
160
|
+
return entries.some((entry) => resolve(entry) === resolve(dir));
|
|
161
|
+
}
|
package/src/platform/pricing.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import {
|
|
16
|
+
import { machineHome } from "./paths.ts";
|
|
17
17
|
|
|
18
18
|
export type VendorPool = "cursor_models" | "anthropic_models" | "other_models" | "auto" | "unknown";
|
|
19
19
|
export type NeutralPool = "provider_native" | "other" | "auto" | "unknown";
|
|
@@ -163,7 +163,7 @@ function fuzzyFind(table: PriceTable, needle: string): { key: string; entry: Mod
|
|
|
163
163
|
|
|
164
164
|
/** The catalogue the refresh writes. Not versioned, not packaged, per machine. */
|
|
165
165
|
export function cataloguePath(): string {
|
|
166
|
-
return join(
|
|
166
|
+
return join(machineHome(), "model-prices.json");
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
@@ -174,7 +174,7 @@ export function cataloguePath(): string {
|
|
|
174
174
|
* refresh would replace. The refresh moves such a file here rather than overwriting it.
|
|
175
175
|
*/
|
|
176
176
|
export function overridesPath(): string {
|
|
177
|
-
return join(
|
|
177
|
+
return join(machineHome(), "model-prices.local.json");
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
export type PriceResolution = {
|
package/src/platform/screen.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { KV_WIDTH, type StatusLevel, type Style, SYMBOLS } from "./style.ts";
|
|
|
2
2
|
|
|
3
3
|
export type Row = { label: string; value: string; level?: StatusLevel };
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `wrap` marks a section's lines as prose, so they are broken at word boundaries to fit the terminal.
|
|
7
|
+
*
|
|
8
|
+
* why opt-in rather than always: a section's lines are sometimes a command to copy — wrapping
|
|
9
|
+
* `tlc harness policy accept <path>` across two lines makes it unpasteable. Prose and payload look identical to a
|
|
10
|
+
* renderer, so the caller says which it has ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
11
|
+
*/
|
|
12
|
+
export type Section = { title?: string; rows?: Row[]; lines?: string[]; wrap?: boolean };
|
|
6
13
|
|
|
7
14
|
export type Screen = {
|
|
8
15
|
title: string;
|
|
@@ -11,9 +18,54 @@ export type Screen = {
|
|
|
11
18
|
footer?: string;
|
|
12
19
|
};
|
|
13
20
|
|
|
21
|
+
/** The two-space indent `render` puts in front of every line, which the wrap width has to leave room for. */
|
|
22
|
+
const INDENT = 2;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* why clamped: a 400-column terminal produces lines nobody tracks across, and an 8-column one produces a word per
|
|
26
|
+
* line. Outside a TTY there is no width to read, and a fixed sensible one beats guessing.
|
|
27
|
+
*/
|
|
28
|
+
export function terminalColumns(columns: number | undefined = process.stdout.columns): number {
|
|
29
|
+
return Math.min(110, Math.max(60, columns ?? 100));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Break prose at word boundaries so nothing is hidden.
|
|
34
|
+
*
|
|
35
|
+
* hazard: the lessons list cut the instruction at 160 characters with no marker — a 263-character lesson lost 103
|
|
36
|
+
* of them mid-word, and the reader could not tell. An operator asked why their lesson had been cut; it had not
|
|
37
|
+
* been, only its display had ([/decisions/ad-101.md](/decisions/ad-101.md)).
|
|
38
|
+
*
|
|
39
|
+
* invariant: a word longer than the width stands on its own line rather than being cut. Losing a character is
|
|
40
|
+
* worse than an overlong line, because only one of the two is visible.
|
|
41
|
+
*/
|
|
42
|
+
export function wrapText(text: string, width: number): string[] {
|
|
43
|
+
if (text === "") {
|
|
44
|
+
return [""];
|
|
45
|
+
}
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
let current = "";
|
|
48
|
+
for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
|
|
49
|
+
if (current === "") {
|
|
50
|
+
current = word;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (current.length + 1 + word.length <= width) {
|
|
54
|
+
current = `${current} ${word}`;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
lines.push(current);
|
|
58
|
+
current = word;
|
|
59
|
+
}
|
|
60
|
+
if (current !== "") {
|
|
61
|
+
lines.push(current);
|
|
62
|
+
}
|
|
63
|
+
return lines.length === 0 ? [""] : lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
14
66
|
// why: screens describe their content and never their paint, so spacing, colour and alignment are decided once.
|
|
15
67
|
// A new screen can only emit this shape, which is what makes "no screen outside the standard" mechanical.
|
|
16
|
-
export function render(screen: Screen, style: Style): string {
|
|
68
|
+
export function render(screen: Screen, style: Style, columns = terminalColumns()): string {
|
|
17
69
|
const out: string[] = [style.heading(screen.title.toUpperCase())];
|
|
18
70
|
|
|
19
71
|
if (screen.summary && screen.summary.length > 0) {
|
|
@@ -35,7 +87,14 @@ export function render(screen: Screen, style: Style): string {
|
|
|
35
87
|
out.push(style.kv(row.label, value, width));
|
|
36
88
|
}
|
|
37
89
|
for (const line of section.lines ?? []) {
|
|
38
|
-
|
|
90
|
+
if (line === "") {
|
|
91
|
+
out.push("");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const parts = section.wrap ? wrapText(line, columns - INDENT) : [line];
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
out.push(` ${part}`);
|
|
97
|
+
}
|
|
39
98
|
}
|
|
40
99
|
}
|
|
41
100
|
|