@tech-leads-club/harness-toolkit 0.2.1
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/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
package/bin/tlc-cli.ts
ADDED
|
@@ -0,0 +1,1509 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { delimiter, join } from "node:path";
|
|
14
|
+
import { coreFacade } from "../src/core/index.ts";
|
|
15
|
+
import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts";
|
|
16
|
+
import { flagsDir, projectConfigPath, projectStateDir, runtimeHome } from "../src/platform/paths.ts";
|
|
17
|
+
import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts";
|
|
18
|
+
import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
|
|
19
|
+
|
|
20
|
+
export class UsageError extends Error {}
|
|
21
|
+
|
|
22
|
+
// why: derived from the facade rather than imported from inside the policy aggregate, so the CLI keeps its
|
|
23
|
+
// single door into core and the two cannot drift apart.
|
|
24
|
+
type Posture = ReturnType<typeof coreFacade.policy.resolveProjectPosture>;
|
|
25
|
+
|
|
26
|
+
export function resolveProjectRoot(): string {
|
|
27
|
+
return process.env.TLC_PROJECT_DIR ?? process.cwd();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function modeFilePath(root: string): string {
|
|
31
|
+
return join(projectStateDir(root), "harness-mode");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function grindFlagPath(root: string): string {
|
|
35
|
+
return join(flagsDir(root), "grind-on");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function skipFlagPath(root: string): string {
|
|
39
|
+
return join(flagsDir(root), "skip-verify");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// why: the posture flag files carry the posture names, so there is one spelling per posture across the config
|
|
43
|
+
// field, the state file, the flag file and this command.
|
|
44
|
+
export function focusFlagPath(root: string): string {
|
|
45
|
+
return join(flagsDir(root), "focus");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function pairedFlagPath(root: string): string {
|
|
49
|
+
return join(flagsDir(root), "paired");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ensureFlagsDir(root: string): void {
|
|
53
|
+
mkdirSync(flagsDir(root), { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readMode(root: string): string {
|
|
57
|
+
return coreFacade.policy.loadPolicy(root).mode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function grindOn(root: string): boolean {
|
|
61
|
+
return coreFacade.policy.loadPolicy(root).grind.enabled;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function gatesPaused(root: string): boolean {
|
|
65
|
+
return existsSync(skipFlagPath(root));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function acceptedModes(): string {
|
|
69
|
+
return coreFacade.policy.OPERATOR_MODES.join(" | ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function statusScreen(root: string): Screen {
|
|
73
|
+
const report = statusJson(root);
|
|
74
|
+
// why: a rejected value is reported next to the posture that replaced it. Printing only `fallback` would leave
|
|
75
|
+
// the operator with a posture they did not set and no way to see which word was refused.
|
|
76
|
+
const origin =
|
|
77
|
+
report.modeInvalid === undefined
|
|
78
|
+
? `from ${report.modeOrigin}`
|
|
79
|
+
: `${report.modeOrigin} — \`${report.modeInvalid}\` is not a posture; accepted: ${acceptedModes()}`;
|
|
80
|
+
return {
|
|
81
|
+
title: "harness status",
|
|
82
|
+
summary: [root],
|
|
83
|
+
sections: [
|
|
84
|
+
{
|
|
85
|
+
rows: [
|
|
86
|
+
{ label: "mode", value: `${report.mode} [${origin}]`, level: "info" },
|
|
87
|
+
{
|
|
88
|
+
label: "grind",
|
|
89
|
+
value: report.grind
|
|
90
|
+
? "ON — stop hook re-runs lint/tests and auto-retries on fail"
|
|
91
|
+
: "OFF — no auto fix loops",
|
|
92
|
+
level: report.grind ? "ok" : "info",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "gates",
|
|
96
|
+
value: report.gatesPaused ? "PAUSED — stop checks disabled" : "active",
|
|
97
|
+
level: report.gatesPaused ? "warn" : "ok",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
title: "Postures",
|
|
103
|
+
lines: [
|
|
104
|
+
"paired explains as it goes, and asks before any sizable move",
|
|
105
|
+
"solo works on its own; a destructive action, a dead-end or real ambiguity reaches you",
|
|
106
|
+
"focus only a destructive action or a dead-end reaches you; it settles ambiguity itself",
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
footer: "verification is identical at all three postures · tlc harness why · tlc harness doctor",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function statusText(root: string, style: Style = PLAIN): string {
|
|
115
|
+
return render(statusScreen(root), style);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type StatusReport = {
|
|
119
|
+
root: string;
|
|
120
|
+
mode: string;
|
|
121
|
+
modeOrigin: Posture["origin"];
|
|
122
|
+
modeInvalid?: string;
|
|
123
|
+
grind: boolean;
|
|
124
|
+
gatesPaused: boolean;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function statusJson(root: string): StatusReport {
|
|
128
|
+
const policy = coreFacade.policy.loadPolicy(root);
|
|
129
|
+
// invariant: posture and its origin come from the resolver the loader itself uses. Status recomputing either
|
|
130
|
+
// one is what made it report the opposite of every hook ([/decisions/ad-020.md](/decisions/ad-020.md)).
|
|
131
|
+
const posture = coreFacade.policy.resolveProjectPosture(root);
|
|
132
|
+
return {
|
|
133
|
+
root,
|
|
134
|
+
mode: posture.mode,
|
|
135
|
+
modeOrigin: posture.origin,
|
|
136
|
+
...(posture.invalid === undefined ? {} : { modeInvalid: posture.invalid }),
|
|
137
|
+
grind: policy.grind.enabled,
|
|
138
|
+
gatesPaused: gatesPaused(root),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// invariant: every sanctioned mutation re-records the baselines. That is what makes "a harness command did
|
|
143
|
+
// this" and "the baseline matches" a single fact — an out-of-band write skips this call and stays visible.
|
|
144
|
+
export function setGrind(root: string, on: boolean): string {
|
|
145
|
+
ensureFlagsDir(root);
|
|
146
|
+
const path = grindFlagPath(root);
|
|
147
|
+
if (on) {
|
|
148
|
+
writeFileSync(path, "");
|
|
149
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
150
|
+
return "grind ON — stop hook will lint/test and auto-retry on failure";
|
|
151
|
+
}
|
|
152
|
+
if (existsSync(path)) {
|
|
153
|
+
rmSync(path);
|
|
154
|
+
}
|
|
155
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
156
|
+
return "grind OFF — no auto fix loops";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function setPaused(root: string, on: boolean): string {
|
|
160
|
+
ensureFlagsDir(root);
|
|
161
|
+
const path = skipFlagPath(root);
|
|
162
|
+
if (on) {
|
|
163
|
+
writeFileSync(path, "");
|
|
164
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
165
|
+
return "gates PAUSED — stop checks disabled until `tlc harness resume`";
|
|
166
|
+
}
|
|
167
|
+
if (existsSync(path)) {
|
|
168
|
+
rmSync(path);
|
|
169
|
+
}
|
|
170
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
171
|
+
return "gates ACTIVE again";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// hazard: this used to map `focus` onto a second spelling before writing, so the word the operator typed and the
|
|
175
|
+
// word the config field stored were different — and a config written from the documented word then matched no
|
|
176
|
+
// branch at all. One word per posture, and nothing translates.
|
|
177
|
+
const MODE_CONFIRMATION: Record<Posture["mode"], string> = {
|
|
178
|
+
paired: "mode paired — explains as it goes, and asks before any sizable move",
|
|
179
|
+
solo: "mode solo — a destructive action, a dead-end or real ambiguity reaches you",
|
|
180
|
+
focus: "mode focus — only a destructive action or a dead-end reaches you; ambiguity is settled for you",
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function setMode(root: string, raw: string): string {
|
|
184
|
+
const mode = raw.toLowerCase();
|
|
185
|
+
if (!coreFacade.policy.isOperatorMode(mode)) {
|
|
186
|
+
throw new UsageError(`mode must be: ${acceptedModes()}`);
|
|
187
|
+
}
|
|
188
|
+
ensureFlagsDir(root);
|
|
189
|
+
writeFileSync(modeFilePath(root), `${mode}\n`);
|
|
190
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
191
|
+
// why: posture governs surfacing only. Announcing grind here would claim a capability this command does not
|
|
192
|
+
// touch — it has its own switch, its own flag and its own trade-off.
|
|
193
|
+
return MODE_CONFIRMATION[mode];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type HandoffReport = {
|
|
197
|
+
root: string;
|
|
198
|
+
providers: Record<string, ReturnType<typeof coreFacade.handoff.readHandoff>>;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The sanctioned way to read handoff state.
|
|
203
|
+
*
|
|
204
|
+
* why: the bootstrap used to tell the agent to read `.tlc/harness/state/handoff.json`, a path the floor guards. So
|
|
205
|
+
* the instruction and the permission disagreed, and the obvious command — `test -f … && head -c 2000 …` — was
|
|
206
|
+
* refused with advice about writing policy. An instruction is not an affordance; the route the harness asks for has
|
|
207
|
+
* to be one it grants ([/decisions/ad-047.md](/decisions/ad-047.md)).
|
|
208
|
+
*/
|
|
209
|
+
export function handoffJson(root: string): HandoffReport {
|
|
210
|
+
const file = coreFacade.handoff.readHandoffFile(root);
|
|
211
|
+
const providers: HandoffReport["providers"] = {};
|
|
212
|
+
for (const provider of Object.keys(file.by_provider)) {
|
|
213
|
+
providers[provider] = coreFacade.handoff.readHandoff(root, provider);
|
|
214
|
+
}
|
|
215
|
+
return { root, providers };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function handoffScreen(report: HandoffReport): Screen {
|
|
219
|
+
const names = Object.keys(report.providers).sort();
|
|
220
|
+
if (names.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
title: "handoff",
|
|
223
|
+
summary: [report.root],
|
|
224
|
+
sections: [{ lines: ["nothing recorded yet — this is a fresh start, not a missing file"] }],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const sections: Section[] = [];
|
|
228
|
+
for (const name of names) {
|
|
229
|
+
const slice = report.providers[name];
|
|
230
|
+
if (!slice) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const rows: Row[] = [];
|
|
234
|
+
for (const [label, value, level] of [
|
|
235
|
+
["blockers", slice.blockers, "warn"],
|
|
236
|
+
["next", slice.next_action, "info"],
|
|
237
|
+
["last gate", slice.last_gate_result, slice.last_gate_result === "pass" ? "ok" : "warn"],
|
|
238
|
+
["last failure", slice.last_failure_category, "fail"],
|
|
239
|
+
] as const) {
|
|
240
|
+
if (value) {
|
|
241
|
+
rows.push({ label, value: String(value), level });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [label, list] of [
|
|
245
|
+
["in progress", slice.in_progress],
|
|
246
|
+
["pending", slice.pending],
|
|
247
|
+
["gaps", slice.previous_gaps?.map((gap) => gap.summary)],
|
|
248
|
+
] as const) {
|
|
249
|
+
if (list && list.length > 0) {
|
|
250
|
+
rows.push({ label, value: list.slice(0, 6).join(" | ") });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
sections.push({ title: `${name} (updated ${slice.updated_at})`, rows });
|
|
254
|
+
}
|
|
255
|
+
return { title: "handoff", summary: [report.root], sections };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function handoffText(report: HandoffReport, style: Style = PLAIN): string {
|
|
259
|
+
return render(handoffScreen(report), style);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* why: the artifact a reviewer can read. Everything in it is something the harness observed, and the chain is what
|
|
264
|
+
* makes a rewritten middle detectable ([/decisions/ad-028.md](/decisions/ad-028.md)).
|
|
265
|
+
*/
|
|
266
|
+
export function attestScreen(root: string): Screen {
|
|
267
|
+
const records = coreFacade.attest.readAttestations(root);
|
|
268
|
+
const verdict = coreFacade.attest.verifyChain(records);
|
|
269
|
+
const head: Row = verdict.ok
|
|
270
|
+
? { label: "chain", value: `attestation chain OK — ${verdict.length} session(s)`, level: "ok" }
|
|
271
|
+
: {
|
|
272
|
+
label: "chain",
|
|
273
|
+
value: `attestation chain BROKEN at record ${verdict.brokenAt} (${verdict.reason})`,
|
|
274
|
+
level: "fail",
|
|
275
|
+
};
|
|
276
|
+
if (records.length === 0) {
|
|
277
|
+
return {
|
|
278
|
+
title: "attestation",
|
|
279
|
+
summary: [root],
|
|
280
|
+
sections: [{ rows: [head] }, { lines: ["no sessions recorded yet"] }],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const sections: Section[] = [{ rows: [head] }];
|
|
284
|
+
for (const record of records.slice(-10).reverse()) {
|
|
285
|
+
const rules = Object.entries(record.decisionsByRule)
|
|
286
|
+
.map(([rule, count]) => `${rule}=${count}`)
|
|
287
|
+
.join(" ");
|
|
288
|
+
sections.push({
|
|
289
|
+
title: `${record.ts} ${record.provider}/${record.session}`,
|
|
290
|
+
rows: [
|
|
291
|
+
{
|
|
292
|
+
label: "policy",
|
|
293
|
+
value: `${record.policyFingerprint}${record.policyDiverged ? " (DIVERGED mid-session)" : ""}`,
|
|
294
|
+
level: record.policyDiverged ? "warn" : "ok",
|
|
295
|
+
},
|
|
296
|
+
{ label: "rails", value: record.railsActive.join(", ") || "none" },
|
|
297
|
+
{
|
|
298
|
+
label: "gates",
|
|
299
|
+
value: `${record.gates.pass} pass / ${record.gates.fail} fail${rules ? ` | ${rules}` : ""}`,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
title: "attestation",
|
|
306
|
+
summary: [root],
|
|
307
|
+
sections,
|
|
308
|
+
footer: "chained, not signed — it detects a rewritten record and proves nothing about authorship",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function attestText(root: string, style: Style = PLAIN): string {
|
|
313
|
+
return render(attestScreen(root), style);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export type AttestReport = {
|
|
317
|
+
ok: boolean;
|
|
318
|
+
brokenAt?: number;
|
|
319
|
+
reason?: string;
|
|
320
|
+
sessions: number;
|
|
321
|
+
records: ReturnType<typeof coreFacade.attest.readAttestations>;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export function attestJson(root: string): AttestReport {
|
|
325
|
+
const records = coreFacade.attest.readAttestations(root);
|
|
326
|
+
const verdict = coreFacade.attest.verifyChain(records);
|
|
327
|
+
return verdict.ok
|
|
328
|
+
? { ok: true, sessions: verdict.length, records }
|
|
329
|
+
: { ok: false, brokenAt: verdict.brokenAt, reason: verdict.reason, sessions: records.length, records };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The one command whose job is to clear a tampering signal, which is why four independent locks sit between it and
|
|
334
|
+
* an agent ([/decisions/ad-030.md](/decisions/ad-030.md)):
|
|
335
|
+
*
|
|
336
|
+
* 1. the floor refuses `tlc harness policy` from inside any agent session, with no config switch;
|
|
337
|
+
* 2. this refuses without an interactive terminal, so a script cannot reach it either;
|
|
338
|
+
* 3. the operator names each path, so accepting is an act rather than a keystroke and its blast radius is exactly
|
|
339
|
+
* what was typed;
|
|
340
|
+
* 4. acceptance is per source, so the other divergences keep blocking.
|
|
341
|
+
*
|
|
342
|
+
* hazard: `interactive` is a parameter rather than an `isTTY` read, so the refusal is testable without a pty. That
|
|
343
|
+
* matters most on the rail whose failure mode is silence.
|
|
344
|
+
*/
|
|
345
|
+
export function acceptPolicy(root: string, paths: string[], interactive: boolean): string {
|
|
346
|
+
if (!interactive) {
|
|
347
|
+
throw new UsageError(
|
|
348
|
+
"tlc harness policy accept needs an interactive terminal — clearing a policy divergence is the operator's call, not a script's.",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* why: `--all` accepts exactly what `tlc harness policy` just listed, in this project. Typing each absolute
|
|
353
|
+
* path was the friction, and the four locks are about *who* clears a divergence and *where from*, not about how
|
|
354
|
+
* much they have to type ([/decisions/ad-058.md](/decisions/ad-058.md)).
|
|
355
|
+
*
|
|
356
|
+
* invariant: still per source. It expands to the diverged list at this moment, so a divergence that appears
|
|
357
|
+
* afterwards is not covered by it — there is no blanket permission here either.
|
|
358
|
+
*/
|
|
359
|
+
const requested = paths.includes("--all") ? coreFacade.policy.allDivergedPaths(root) : paths;
|
|
360
|
+
if (paths.includes("--all") && requested.length === 0) {
|
|
361
|
+
return `nothing to accept — no policy source diverged in ${root}`;
|
|
362
|
+
}
|
|
363
|
+
if (requested.length === 0) {
|
|
364
|
+
throw new UsageError(
|
|
365
|
+
[
|
|
366
|
+
"usage: tlc harness policy accept <path> [path...]",
|
|
367
|
+
" tlc harness policy accept --all accept everything `tlc harness policy` lists here",
|
|
368
|
+
].join("\n"),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* hazard: acceptance is written into this project's baseline directory, and the success line used to claim
|
|
373
|
+
* "every live session". Run from another directory it printed success and cleared nothing — measured while
|
|
374
|
+
* unblocking a live session, twice, because the message gave no way to tell. It now names the project, and
|
|
375
|
+
* says so loudly when this project has no blocked session at all.
|
|
376
|
+
*/
|
|
377
|
+
const blocked = coreFacade.policy.allDivergedPaths(root);
|
|
378
|
+
const notHere = requested.filter((path) => !blocked.includes(path));
|
|
379
|
+
const outcome = coreFacade.policy.acceptPolicySources(root, requested);
|
|
380
|
+
if (outcome.kind === "not-a-source") {
|
|
381
|
+
throw new UsageError(
|
|
382
|
+
[
|
|
383
|
+
`not a policy source: ${outcome.paths.join(", ")}`,
|
|
384
|
+
"The sources the loader reads are:",
|
|
385
|
+
...outcome.sources.map((source) => ` ${source}`),
|
|
386
|
+
].join("\n"),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (outcome.kind === "nothing-to-accept") {
|
|
390
|
+
return [
|
|
391
|
+
`nothing to accept — ${root} has no recorded session baseline.`,
|
|
392
|
+
"Acceptance is written per project. Run this from the repository whose session is blocked:",
|
|
393
|
+
` cd <that repo> && tlc harness policy accept ${requested.join(" ")}`,
|
|
394
|
+
].join("\n");
|
|
395
|
+
}
|
|
396
|
+
const lines = [
|
|
397
|
+
`accepted: ${outcome.paths.join(", ")}`,
|
|
398
|
+
` for sessions in ${root} — acceptance is per project, not machine-wide`,
|
|
399
|
+
];
|
|
400
|
+
if (notHere.length > 0) {
|
|
401
|
+
lines.push(
|
|
402
|
+
` note: ${notHere.join(", ")} was not diverging here. If a session elsewhere is blocked, run this in that repository too.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return lines.join("\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function policyScreen(root: string): Screen {
|
|
409
|
+
const diverged = coreFacade.policy.allDivergedPaths(root);
|
|
410
|
+
if (diverged.length === 0) {
|
|
411
|
+
return {
|
|
412
|
+
title: "policy baseline",
|
|
413
|
+
sections: [
|
|
414
|
+
{
|
|
415
|
+
rows: [
|
|
416
|
+
{
|
|
417
|
+
label: "baseline",
|
|
418
|
+
value: "matches — nothing changed out of band during any live session",
|
|
419
|
+
level: "ok",
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
title: "policy baseline",
|
|
428
|
+
summary: [`policy changed out of band during a live session (${diverged.length})`],
|
|
429
|
+
sections: [
|
|
430
|
+
{ rows: diverged.map((path) => ({ label: "changed", value: path, level: "warn" as const })) },
|
|
431
|
+
{
|
|
432
|
+
title: "If that was you, accept it from your own terminal with",
|
|
433
|
+
lines: [`tlc harness policy accept ${diverged.join(" ")}`, "", "or: tlc harness policy accept --all"],
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
footer: "accepting is per path, so anything you leave out keeps blocking",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function policyText(root: string, style: Style = PLAIN): string {
|
|
441
|
+
return render(policyScreen(root), style);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export type PolicyReport = { diverged: string[]; ok: boolean };
|
|
445
|
+
|
|
446
|
+
export function policyJson(root: string): PolicyReport {
|
|
447
|
+
const diverged = coreFacade.policy.allDivergedPaths(root);
|
|
448
|
+
return { diverged, ok: diverged.length === 0 };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** why: computed once and read by both `update` and `update --check`, so the two cannot disagree about what is upstream. */
|
|
452
|
+
export function upstreamRef(dest: string): string {
|
|
453
|
+
const read = (args: string[]): string => {
|
|
454
|
+
const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env });
|
|
455
|
+
return (r.status ?? 1) === 0 ? (r.stdout ?? "").trim() : "";
|
|
456
|
+
};
|
|
457
|
+
const tracked = read(["rev-parse", "--abbrev-ref", "@{u}"]);
|
|
458
|
+
if (tracked !== "") {
|
|
459
|
+
return tracked;
|
|
460
|
+
}
|
|
461
|
+
return `origin/${read(["rev-parse", "--abbrev-ref", "HEAD"]) || "main"}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* What kind of runtime path this is, which decides what `update` may write to it.
|
|
466
|
+
*
|
|
467
|
+
* The distinction is the whole fix. A `managed` path is an artifact the installer created and the harness owns, so
|
|
468
|
+
* a conflict in it is not a decision for the operator — it is discarded. A `linked` path is a symlink to somebody's
|
|
469
|
+
* working clone, so nothing there may be written by a harness command at all
|
|
470
|
+
* ([/decisions/ad-046.md](/decisions/ad-046.md)).
|
|
471
|
+
*/
|
|
472
|
+
export type RuntimePathKind = "managed" | "linked" | "npm" | "unmanaged" | "absent";
|
|
473
|
+
|
|
474
|
+
export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit";
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* why: an npm-delivered runtime is a real directory with no `.git`, which the old classifier called `unmanaged`
|
|
478
|
+
* and `doctor` reported as a failure — on a perfectly healthy install. It is told apart by the marker the
|
|
479
|
+
* installer leaves, not by guessing from the contents, because a directory can be many things and only the thing
|
|
480
|
+
* that created it knows which ([/decisions/ad-056.md](/decisions/ad-056.md)).
|
|
481
|
+
*/
|
|
482
|
+
export const NPM_MARKER = "installed-from-npm";
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* hazard: `install.sh` links the runtime path to the clone it was run from, so on a contributor's machine
|
|
486
|
+
* `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run
|
|
487
|
+
* `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine.
|
|
488
|
+
*
|
|
489
|
+
* invariant: the symlink test comes first and is decided by the path, never by its contents. A linked clone
|
|
490
|
+
* contains a `.git` too, so testing for that first would classify it as ours.
|
|
491
|
+
*/
|
|
492
|
+
export function classifyRuntimePath(
|
|
493
|
+
dest: string,
|
|
494
|
+
probe: { isSymlink: (path: string) => boolean; exists: (path: string) => boolean },
|
|
495
|
+
): RuntimePathKind {
|
|
496
|
+
if (probe.isSymlink(dest)) {
|
|
497
|
+
return "linked";
|
|
498
|
+
}
|
|
499
|
+
if (!probe.exists(dest)) {
|
|
500
|
+
return "absent";
|
|
501
|
+
}
|
|
502
|
+
if (probe.exists(join(dest, ".git"))) {
|
|
503
|
+
return "managed";
|
|
504
|
+
}
|
|
505
|
+
return probe.exists(join(dest, NPM_MARKER)) ? "npm" : "unmanaged";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* hazard: this must be asked about the **configured** home, not a resolved one. `resolveHarnessRoot` calls
|
|
510
|
+
* `realpathSync`, so passing its result made a linked clone look like a managed checkout — and `update` then ran
|
|
511
|
+
* `git fetch` inside a contributor's repository. Caught by driving the real command against a linked install rather
|
|
512
|
+
* than by any unit test ([/decisions/ad-046.md](/decisions/ad-046.md)).
|
|
513
|
+
*
|
|
514
|
+
* hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI
|
|
515
|
+
* refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves
|
|
516
|
+
* elsewhere and a **managed** checkout was classified as linked — which would silently stop updates on the very
|
|
517
|
+
* platform the reporter uses. Only the last hop decides, which is the one thing `install.sh` actually creates.
|
|
518
|
+
*/
|
|
519
|
+
export function runtimePathKind(dest: string): RuntimePathKind {
|
|
520
|
+
return classifyRuntimePath(dest, {
|
|
521
|
+
isSymlink: (path) => {
|
|
522
|
+
try {
|
|
523
|
+
return lstatSync(path).isSymbolicLink();
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
exists: existsSync,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The bundles an install needs but does not have.
|
|
534
|
+
*
|
|
535
|
+
* why: derived from the entrypoints on disk, the same way `bin/tlc-build` derives them. A fixed list would stop
|
|
536
|
+
* naming a new entrypoint and the missing bundle would only surface when a hook fired.
|
|
537
|
+
*/
|
|
538
|
+
export function missingBundles(dest: string): string[] {
|
|
539
|
+
const entrypoints = join(dest, "src", "entrypoints");
|
|
540
|
+
if (!existsSync(entrypoints)) {
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
const expected = readdirSync(entrypoints)
|
|
544
|
+
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
|
545
|
+
.map((name) => `${name.slice(0, -3)}.mjs`);
|
|
546
|
+
return expected.filter((bundle) => !existsSync(join(dest, "dist", bundle)));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* hazard: `dest` was accepted and never used, so the message said "the runtime path" without naming it while both
|
|
551
|
+
* sibling messages name theirs. An operator with more than one runtime could not tell which link was meant.
|
|
552
|
+
*/
|
|
553
|
+
export function linkedRuntimeMessage(dest: string, target: string | null): string {
|
|
554
|
+
return [
|
|
555
|
+
`update: ${dest} is a link to a working clone${target ? ` → ${target}` : ""}.`,
|
|
556
|
+
"Nothing in it is touched by this command — updating that clone is your own `git pull`.",
|
|
557
|
+
"Refreshing the machine-local parts only: CLI link, init skill, provider hooks.",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* hazard: this printed `update: git fetch failed.` and stopped. git's own error names a transport problem and not
|
|
563
|
+
* the route that works, which is the same shape as the refusal AD-047 was written about
|
|
564
|
+
* ([/decisions/ad-052.md](/decisions/ad-052.md)).
|
|
565
|
+
*/
|
|
566
|
+
export function fetchFailureMessage(dest: string): string {
|
|
567
|
+
return [
|
|
568
|
+
`update: git fetch failed in ${dest}.`,
|
|
569
|
+
` The published package needs no clone: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
|
|
570
|
+
" If this runtime predates the move to tech-leads-club/harness-toolkit, it is still pointing at the old",
|
|
571
|
+
" repository, and that install is what the package replaces.",
|
|
572
|
+
" For a private fork, this needs a GitHub credential: `gh auth login`, then `gh auth setup-git`.",
|
|
573
|
+
].join("\n");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function unmanagedRuntimeMessage(dest: string): string {
|
|
577
|
+
return [
|
|
578
|
+
`update: ${dest} is not a git checkout, so there is nothing to pull.`,
|
|
579
|
+
`Install the package to get a runtime update can move: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
|
|
580
|
+
].join("\n");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* hazard: the git route's bare `update: git fetch failed.` sent an operator to the wrong problem for a week. A
|
|
585
|
+
* failing global install has its own small set of causes and each has a different fix, so they are named.
|
|
586
|
+
*/
|
|
587
|
+
export function npmUpdateFailureMessage(): string {
|
|
588
|
+
return [
|
|
589
|
+
`update: npm could not install ${NPM_PACKAGE}@latest.`,
|
|
590
|
+
" permissions — a global prefix owned by root needs sudo, or an npm prefix you own:",
|
|
591
|
+
" npm config set prefix ~/.local",
|
|
592
|
+
" not found — the package is published; check the network and any registry proxy in ~/.npmrc",
|
|
593
|
+
" offline — nothing was changed; the runtime you have still works.",
|
|
594
|
+
].join("\n");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function resetFailureMessage(dest: string, mergeRef: string, gitOutput: string): string {
|
|
598
|
+
return [
|
|
599
|
+
`update: could not move the runtime to ${mergeRef}.`,
|
|
600
|
+
` path: ${dest} (managed checkout)`,
|
|
601
|
+
gitOutput.trim() ? ` git: ${gitOutput.trim().split("\n").slice(-3).join(" / ")}` : "",
|
|
602
|
+
`Nothing was changed. If this persists, install the package instead: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`,
|
|
603
|
+
]
|
|
604
|
+
.filter(Boolean)
|
|
605
|
+
.join("\n");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export type RuntimeRevision = { revision: string | null; date: string | null };
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* why: the revision is what `update` already moves, so it cannot drift the way a hand-edited version number does.
|
|
612
|
+
* `package.json` has said `0.1.0` since the first commit, which is the failure mode a number invites. And a semantic
|
|
613
|
+
* version is a promise about compatibility that AD-003 refuses to make ([/decisions/ad-031.md](/decisions/ad-031.md)).
|
|
614
|
+
*/
|
|
615
|
+
export function runtimeRevision(dest: string): RuntimeRevision {
|
|
616
|
+
if (!existsSync(join(dest, ".git"))) {
|
|
617
|
+
return { revision: null, date: null };
|
|
618
|
+
}
|
|
619
|
+
const read = (args: string[]): string | null => {
|
|
620
|
+
const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env });
|
|
621
|
+
const out = (r.stdout ?? "").trim();
|
|
622
|
+
return (r.status ?? 1) === 0 && out !== "" ? out : null;
|
|
623
|
+
};
|
|
624
|
+
return { revision: read(["rev-parse", "--short", "HEAD"]), date: read(["log", "-1", "--format=%cs"]) };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export type VersionReport = {
|
|
628
|
+
runtime: string;
|
|
629
|
+
revision: string | null;
|
|
630
|
+
date: string | null;
|
|
631
|
+
seenRevision: string | null;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
export function versionJson(root: string): VersionReport {
|
|
635
|
+
const dest = resolveHarnessRoot();
|
|
636
|
+
const { revision, date } = runtimeRevision(dest);
|
|
637
|
+
return {
|
|
638
|
+
runtime: dest,
|
|
639
|
+
revision,
|
|
640
|
+
date,
|
|
641
|
+
seenRevision: coreFacade.release.readReleaseSeen(root)?.revision ?? null,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function versionScreen(root: string): Screen {
|
|
646
|
+
const report = versionJson(root);
|
|
647
|
+
const rows: Row[] =
|
|
648
|
+
report.revision === null
|
|
649
|
+
? [
|
|
650
|
+
{ label: "runtime", value: report.runtime },
|
|
651
|
+
// why: says so rather than printing an empty revision. A linked checkout with no `.git` is a real shape.
|
|
652
|
+
{
|
|
653
|
+
label: "revision",
|
|
654
|
+
value: "unknown — the runtime path is not a git checkout, so `update` cannot pull either",
|
|
655
|
+
level: "warn",
|
|
656
|
+
},
|
|
657
|
+
]
|
|
658
|
+
: [
|
|
659
|
+
{ label: "runtime", value: report.runtime },
|
|
660
|
+
{ label: "revision", value: `${report.revision} (${report.date ?? "date unknown"})`, level: "ok" },
|
|
661
|
+
{
|
|
662
|
+
label: "project last saw",
|
|
663
|
+
value: report.seenRevision ?? "nothing yet — the next update will announce what landed",
|
|
664
|
+
},
|
|
665
|
+
];
|
|
666
|
+
return { title: "harness version", sections: [{ rows }] };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function versionText(root: string, style: Style = PLAIN): string {
|
|
670
|
+
return render(versionScreen(root), style);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export type PendingReport = {
|
|
674
|
+
ok: boolean;
|
|
675
|
+
reason?: string;
|
|
676
|
+
commits: number;
|
|
677
|
+
decisions: ReturnType<typeof coreFacade.release.readDecisions>;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* why: fetches and never merges. "Look before you leap" that changes something is just leaping, so the merge is not
|
|
682
|
+
* reachable from this path at all rather than guarded by a flag.
|
|
683
|
+
*/
|
|
684
|
+
export function pendingUpdate(dest: string, mergeRef: string): PendingReport {
|
|
685
|
+
if (!existsSync(join(dest, ".git"))) {
|
|
686
|
+
return { ok: false, reason: "the runtime path is not a git checkout", commits: 0, decisions: [] };
|
|
687
|
+
}
|
|
688
|
+
const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], { stdio: "inherit", env: process.env });
|
|
689
|
+
if ((fetch.status ?? 1) !== 0) {
|
|
690
|
+
return { ok: false, reason: "git fetch failed", commits: 0, decisions: [] };
|
|
691
|
+
}
|
|
692
|
+
const count = spawnSync("git", ["-C", dest, "rev-list", "--count", `HEAD..${mergeRef}`], {
|
|
693
|
+
encoding: "utf8",
|
|
694
|
+
env: process.env,
|
|
695
|
+
});
|
|
696
|
+
const commits = Number.parseInt((count.stdout ?? "0").trim(), 10) || 0;
|
|
697
|
+
const added = spawnSync(
|
|
698
|
+
"git",
|
|
699
|
+
["-C", dest, "diff", "--name-only", "--diff-filter=A", `HEAD..${mergeRef}`, "--", "docs/decisions"],
|
|
700
|
+
{ encoding: "utf8", env: process.env },
|
|
701
|
+
);
|
|
702
|
+
const files = (added.stdout ?? "")
|
|
703
|
+
.split("\n")
|
|
704
|
+
.map((line) => line.trim().split("/").pop() ?? "")
|
|
705
|
+
.filter(Boolean);
|
|
706
|
+
return { ok: true, commits, decisions: coreFacade.release.readDecisions(dest, files) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function pendingScreen(report: PendingReport): Screen {
|
|
710
|
+
if (!report.ok) {
|
|
711
|
+
return {
|
|
712
|
+
title: "update --check",
|
|
713
|
+
sections: [
|
|
714
|
+
{
|
|
715
|
+
rows: [{ label: "status", value: `${report.reason} — nothing to compare against`, level: "warn" }],
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (report.commits === 0) {
|
|
721
|
+
return {
|
|
722
|
+
title: "update --check",
|
|
723
|
+
sections: [
|
|
724
|
+
{ rows: [{ label: "status", value: "the runtime is current — nothing to pull", level: "ok" }] },
|
|
725
|
+
],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const digest = coreFacade.release.formatDecisionDigest(report.decisions);
|
|
729
|
+
return {
|
|
730
|
+
title: "update --check",
|
|
731
|
+
summary: [`${report.commits} commit(s) would be pulled`, "Nothing has changed yet."],
|
|
732
|
+
sections: [{ lines: digest === "" ? ["no decisions landed in that range"] : digest.split("\n") }],
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function pendingText(report: PendingReport, style: Style = PLAIN): string {
|
|
737
|
+
return render(pendingScreen(report), style);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export type GateField = "test" | "lint";
|
|
741
|
+
|
|
742
|
+
const GATE_FIELDS: Record<string, GateField> = {
|
|
743
|
+
"test-command": "test",
|
|
744
|
+
"lint-command": "lint",
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// why: resolved without executing. Running the binary to see whether it exists would run it, which is not
|
|
748
|
+
// something a config write is allowed to do.
|
|
749
|
+
export function resolveExecutable(
|
|
750
|
+
name: string,
|
|
751
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
752
|
+
platform: string = process.platform,
|
|
753
|
+
): string | null {
|
|
754
|
+
const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
755
|
+
const candidates = (base: string): string[] => [base, ...extensions.map((ext) => `${base}${ext}`)];
|
|
756
|
+
|
|
757
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
758
|
+
return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
|
|
759
|
+
}
|
|
760
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
761
|
+
if (!dir) {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const found = candidates(join(dir, name)).find((candidate) => existsSync(candidate));
|
|
765
|
+
if (found) {
|
|
766
|
+
return found;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The only legitimate route to `grind.testCommand` and `grind.lintCommand`. Its absence is what produced the
|
|
774
|
+
* bypass this rail exists to stop: the guard refused the edit and the CLI offered nothing in its place.
|
|
775
|
+
*
|
|
776
|
+
* hazard: `interactive` is a parameter rather than a `process.stdin.isTTY` read here, so the refusal can be
|
|
777
|
+
* tested without a pty. It is a second layer only — the floor already refuses this command from inside an
|
|
778
|
+
* agent session, and the operator's own terminal never reaches that check.
|
|
779
|
+
*/
|
|
780
|
+
export function setGateCommand(root: string, field: GateField, argv: string[], interactive: boolean): string {
|
|
781
|
+
if (argv.length === 0) {
|
|
782
|
+
throw new UsageError(`usage: tlc harness gate ${field}-command <command> [args...]`);
|
|
783
|
+
}
|
|
784
|
+
if (!interactive) {
|
|
785
|
+
throw new UsageError(
|
|
786
|
+
`tlc harness gate ${field}-command needs an interactive terminal — harness policy is the operator's to set, not a script's.`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
const binary = argv[0] as string;
|
|
790
|
+
if (resolveExecutable(binary) === null) {
|
|
791
|
+
// why: AD-021 already treats a gate command that never resolved as a config fault. Refusing it at the
|
|
792
|
+
// point of writing turns that fault into something the operator sees now instead of at the next gate.
|
|
793
|
+
throw new UsageError(
|
|
794
|
+
`\`${binary}\` was not found on PATH, and a gate command that cannot run is a config fault ([/decisions/ad-021.md](/decisions/ad-021.md)).`,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const path = projectConfigPath(root);
|
|
799
|
+
const parsed = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>) : {};
|
|
800
|
+
const grind = { ...((parsed.grind as Record<string, unknown> | undefined) ?? {}) };
|
|
801
|
+
grind[field === "test" ? "testCommand" : "lintCommand"] = argv;
|
|
802
|
+
parsed.grind = grind;
|
|
803
|
+
|
|
804
|
+
mkdirSync(join(root, ".tlc", "harness"), { recursive: true });
|
|
805
|
+
// why: canonical 2-space JSON is byte-for-byte what these configs already are, so the diff is the changed
|
|
806
|
+
// field and nothing else.
|
|
807
|
+
writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
|
|
808
|
+
coreFacade.policy.refreshPolicyBaselines(root);
|
|
809
|
+
|
|
810
|
+
return `grind.${field}Command = ${JSON.stringify(argv)}`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function helpScreen(): Screen {
|
|
814
|
+
return {
|
|
815
|
+
title: "tlc harness",
|
|
816
|
+
sections: [
|
|
817
|
+
{
|
|
818
|
+
lines: `Requires Node.js 24+ (Active LTS 24 or Current 26).
|
|
819
|
+
|
|
820
|
+
Read commands accept --json: status, doctor, obs, lessons, prices lookup, attest, policy.
|
|
821
|
+
|
|
822
|
+
QUICK
|
|
823
|
+
tlc harness status mode / grind / gates
|
|
824
|
+
tlc harness version runtime revision, and what this project last saw
|
|
825
|
+
tlc harness update --check what an update would pull, without pulling it
|
|
826
|
+
tlc harness update pull runtime + refresh skill/CLI, then doctor
|
|
827
|
+
tlc harness doctor health checklist
|
|
828
|
+
tlc harness why [n] the last n decisions this tool made, with the rule behind each
|
|
829
|
+
tlc harness install put the runtime in place from the installed npm package
|
|
830
|
+
tlc harness uninstall print what would be undone; --yes applies it, --purge includes state
|
|
831
|
+
tlc harness build compile dist/ for Node
|
|
832
|
+
tlc harness test run the full local gate
|
|
833
|
+
tlc harness help <topic> documentation
|
|
834
|
+
|
|
835
|
+
TOPICS
|
|
836
|
+
architecture | concepts | lessons | measure | prices | diagnose | init
|
|
837
|
+
|
|
838
|
+
CONTROL
|
|
839
|
+
tlc harness grind [on|off] tlc harness pause | resume tlc harness mode solo|paired|focus
|
|
840
|
+
tlc harness gate test-command <cmd> [args...] tlc harness gate lint-command <cmd> [args...]
|
|
841
|
+
tlc harness attest tamper-evident record of what each session ran under
|
|
842
|
+
tlc harness policy show a policy that changed out of band; accept <path> to clear it
|
|
843
|
+
|
|
844
|
+
MEASURE
|
|
845
|
+
tlc harness obs live|events|report|prune
|
|
846
|
+
tlc harness prices refresh [all|cursor|litellm]
|
|
847
|
+
tlc harness prices lookup <model-id>
|
|
848
|
+
tlc harness lessons list|show|garden|sync-rules
|
|
849
|
+
|
|
850
|
+
PROJECT
|
|
851
|
+
tlc harness init --minimal | tlc harness init --write --stdin-json`.split("\n"),
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
footer: "tlc harness help <topic> for a document · tlc harness why to see what it decided",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export function helpText(style: Style = PLAIN): string {
|
|
859
|
+
return render(helpScreen(), style);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function pricesHelpScreen(): Screen {
|
|
863
|
+
return {
|
|
864
|
+
title: "price catalogs",
|
|
865
|
+
sections: [
|
|
866
|
+
{
|
|
867
|
+
lines: ` tlc harness prices refresh [all|cursor|litellm]
|
|
868
|
+
tlc harness prices lookup <model-id>
|
|
869
|
+
|
|
870
|
+
refresh / refresh all Cursor catalog + LiteLLM fallback
|
|
871
|
+
refresh cursor model-prices.cursor.json (tracked)
|
|
872
|
+
refresh litellm model-prices.litellm.json (local)
|
|
873
|
+
lookup <model-id> catalog key, pool, USD for 1M in + 1M out
|
|
874
|
+
|
|
875
|
+
Resolution: overrides → Cursor → LiteLLM → null
|
|
876
|
+
Documentation: tlc harness help prices`.split("\n"),
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
footer: "resolution: local overrides → the provider's own catalog → LiteLLM → null",
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export function pricesHelpText(style: Style = PLAIN): string {
|
|
884
|
+
return render(pricesHelpScreen(), style);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export function resolveHarnessRoot(): string {
|
|
888
|
+
const home = runtimeHome();
|
|
889
|
+
try {
|
|
890
|
+
return realpathSync(home);
|
|
891
|
+
} catch {
|
|
892
|
+
return home;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export function execBinPath(): string {
|
|
897
|
+
return join(resolveHarnessRoot(), "bin", "tlc-exec");
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function buildBinPath(): string {
|
|
901
|
+
return join(resolveHarnessRoot(), "bin", "tlc-build");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export type Action =
|
|
905
|
+
| { kind: "status" }
|
|
906
|
+
| { kind: "help" }
|
|
907
|
+
| { kind: "build" }
|
|
908
|
+
| { kind: "update" }
|
|
909
|
+
| { kind: "test" }
|
|
910
|
+
| { kind: "grind"; on: boolean }
|
|
911
|
+
| { kind: "pause" }
|
|
912
|
+
| { kind: "resume" }
|
|
913
|
+
| { kind: "mode"; value: string }
|
|
914
|
+
| { kind: "gate"; field: GateField; argv: string[] }
|
|
915
|
+
| { kind: "attest" }
|
|
916
|
+
| { kind: "handoff" }
|
|
917
|
+
| { kind: "version" }
|
|
918
|
+
| { kind: "update-check" }
|
|
919
|
+
| { kind: "policy"; accept: string[] }
|
|
920
|
+
| { kind: "prices-help" }
|
|
921
|
+
| { kind: "prices-refresh"; scope: string }
|
|
922
|
+
| { kind: "prices-lookup"; modelId: string }
|
|
923
|
+
| { kind: "entry"; entry: string; args: string[] }
|
|
924
|
+
| { kind: "unknown"; cmd: string };
|
|
925
|
+
|
|
926
|
+
export function route(args: string[]): Action {
|
|
927
|
+
const cmd = (args[0] ?? "status").toLowerCase();
|
|
928
|
+
switch (cmd) {
|
|
929
|
+
case "status":
|
|
930
|
+
case "st":
|
|
931
|
+
case "s":
|
|
932
|
+
return { kind: "status" };
|
|
933
|
+
case "build":
|
|
934
|
+
case "rebuild":
|
|
935
|
+
return { kind: "build" };
|
|
936
|
+
case "update":
|
|
937
|
+
case "upgrade": {
|
|
938
|
+
const flags = args.slice(1);
|
|
939
|
+
if (flags.includes("--check")) {
|
|
940
|
+
return { kind: "update-check" };
|
|
941
|
+
}
|
|
942
|
+
// hazard: this accepted any flag in silence. An operator whose update had failed typed `--force`, got no
|
|
943
|
+
// acknowledgement that it does not exist, and read the same failure as a refusal to force
|
|
944
|
+
// ([/decisions/ad-048.md](/decisions/ad-048.md)).
|
|
945
|
+
const leftover = unknownFlags(flags);
|
|
946
|
+
if (leftover.length > 0) {
|
|
947
|
+
throw new UsageError(
|
|
948
|
+
leftover[0] === "--force"
|
|
949
|
+
? `update takes no --force: a managed runtime is already reset to upstream, and a linked clone is never written to. If update cannot move it, install the package instead: npm i -g ${NPM_PACKAGE}@latest.`
|
|
950
|
+
: `unknown flag: ${leftover[0]}\nusage: tlc harness update [--check]`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
return { kind: "update" };
|
|
954
|
+
}
|
|
955
|
+
case "version":
|
|
956
|
+
case "--version":
|
|
957
|
+
return { kind: "version" };
|
|
958
|
+
case "test":
|
|
959
|
+
return { kind: "test" };
|
|
960
|
+
case "grind":
|
|
961
|
+
case "g": {
|
|
962
|
+
const arg = (args[1] ?? "on").toLowerCase();
|
|
963
|
+
if (arg === "on" || arg === "1" || arg === "true") {
|
|
964
|
+
return { kind: "grind", on: true };
|
|
965
|
+
}
|
|
966
|
+
if (arg === "off" || arg === "0" || arg === "false") {
|
|
967
|
+
return { kind: "grind", on: false };
|
|
968
|
+
}
|
|
969
|
+
throw new UsageError("usage: tlc harness grind [on|off]");
|
|
970
|
+
}
|
|
971
|
+
case "pause":
|
|
972
|
+
case "p":
|
|
973
|
+
return { kind: "pause" };
|
|
974
|
+
case "resume":
|
|
975
|
+
case "r":
|
|
976
|
+
return { kind: "resume" };
|
|
977
|
+
case "mode":
|
|
978
|
+
case "m": {
|
|
979
|
+
const modeArg = args[1];
|
|
980
|
+
if (!modeArg) {
|
|
981
|
+
throw new UsageError("usage: tlc harness mode <solo|paired|focus>");
|
|
982
|
+
}
|
|
983
|
+
return { kind: "mode", value: modeArg };
|
|
984
|
+
}
|
|
985
|
+
case "attest":
|
|
986
|
+
return { kind: "attest" };
|
|
987
|
+
case "handoff":
|
|
988
|
+
return { kind: "handoff" };
|
|
989
|
+
case "policy": {
|
|
990
|
+
const sub = (args[1] ?? "").toLowerCase();
|
|
991
|
+
if (!sub) {
|
|
992
|
+
return { kind: "policy", accept: [] };
|
|
993
|
+
}
|
|
994
|
+
if (sub !== "accept") {
|
|
995
|
+
throw new UsageError("usage: tlc harness policy [accept <path> [path...]]");
|
|
996
|
+
}
|
|
997
|
+
// why: `--all` reaches acceptPolicy as a marker in the list, where it expands to what diverged here.
|
|
998
|
+
return { kind: "policy", accept: args.slice(2) };
|
|
999
|
+
}
|
|
1000
|
+
case "gate": {
|
|
1001
|
+
const field = GATE_FIELDS[(args[1] ?? "").toLowerCase()];
|
|
1002
|
+
if (!field) {
|
|
1003
|
+
throw new UsageError("usage: tlc harness gate <test-command|lint-command> <command> [args...]");
|
|
1004
|
+
}
|
|
1005
|
+
return { kind: "gate", field, argv: args.slice(2) };
|
|
1006
|
+
}
|
|
1007
|
+
case "prices": {
|
|
1008
|
+
const sub = (args[1] ?? "").toLowerCase();
|
|
1009
|
+
if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
|
|
1010
|
+
return { kind: "prices-help" };
|
|
1011
|
+
}
|
|
1012
|
+
if (sub === "refresh") {
|
|
1013
|
+
return { kind: "prices-refresh", scope: args[2] ?? "all" };
|
|
1014
|
+
}
|
|
1015
|
+
if (sub === "lookup" || sub === "get") {
|
|
1016
|
+
const modelId = args[2];
|
|
1017
|
+
if (!modelId) {
|
|
1018
|
+
throw new UsageError(
|
|
1019
|
+
"usage: tlc harness prices lookup <model-id>\ndetail: tlc harness help prices",
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
return { kind: "prices-lookup", modelId };
|
|
1023
|
+
}
|
|
1024
|
+
throw new UsageError(
|
|
1025
|
+
"usage: tlc harness prices refresh [all|cursor|litellm] | tlc harness prices lookup <model>\ndetail: tlc harness help prices",
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
case "obs":
|
|
1029
|
+
case "o":
|
|
1030
|
+
return { kind: "entry", entry: "obs-cli", args: args.slice(1) };
|
|
1031
|
+
// why: doctor used to drop its arguments, so every flag reached the entry as an empty list. It forwards
|
|
1032
|
+
// them now, which is what lets --json arrive at the tool.
|
|
1033
|
+
case "doctor":
|
|
1034
|
+
case "doc":
|
|
1035
|
+
return { kind: "entry", entry: "doctor", args: args.slice(1) };
|
|
1036
|
+
case "lessons":
|
|
1037
|
+
case "lesson":
|
|
1038
|
+
return { kind: "entry", entry: "lessons-cli", args: args.slice(1) };
|
|
1039
|
+
case "init":
|
|
1040
|
+
return { kind: "entry", entry: "init-project", args: args.slice(1) };
|
|
1041
|
+
case "install":
|
|
1042
|
+
return { kind: "entry", entry: "install-runtime", args: args.slice(1) };
|
|
1043
|
+
// why: the exit has to be as easy to find as the entrance. An operator who cannot get the harness off their
|
|
1044
|
+
// machine without hand-editing settings.json will not try it on a second one
|
|
1045
|
+
// ([/decisions/ad-066.md](/decisions/ad-066.md)).
|
|
1046
|
+
case "uninstall":
|
|
1047
|
+
return { kind: "entry", entry: "uninstall-runtime", args: args.slice(1) };
|
|
1048
|
+
// why: a first-class verb, not `obs why`. It is the command an operator reaches for when they cannot tell a
|
|
1049
|
+
// harness decision from the model, and nobody in that moment remembers it lives under `obs`.
|
|
1050
|
+
case "why":
|
|
1051
|
+
return { kind: "entry", entry: "obs-cli", args: ["why", ...args.slice(1)] };
|
|
1052
|
+
case "help":
|
|
1053
|
+
case "-h":
|
|
1054
|
+
case "--help": {
|
|
1055
|
+
const topic = args[1];
|
|
1056
|
+
if (!topic) {
|
|
1057
|
+
return { kind: "help" };
|
|
1058
|
+
}
|
|
1059
|
+
return { kind: "entry", entry: "help-topic", args: [topic] };
|
|
1060
|
+
}
|
|
1061
|
+
default:
|
|
1062
|
+
return { kind: "unknown", cmd };
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export type TestStep = { label: string; bin: string; args: string[] };
|
|
1067
|
+
|
|
1068
|
+
// invariant: every suite is launched through the hermetic setup module. Without it the suite reads
|
|
1069
|
+
// CLAUDE_PROJECT_DIR from whatever started it, so 22 tests that build a fixture in a temp directory resolved
|
|
1070
|
+
// against the real repository — green from a shell, red from inside a hook.
|
|
1071
|
+
export const TEST_ENV_IMPORT = ["--import", "./tools/test-env.mjs"];
|
|
1072
|
+
|
|
1073
|
+
export function buildTestSteps(): TestStep[] {
|
|
1074
|
+
return [
|
|
1075
|
+
// why: `--error-on-warnings`. A warn-level rule does not change biome's exit code, so three fixable warnings
|
|
1076
|
+
// sat in this repo across several green gates until someone read the output by hand. Escalating every group to
|
|
1077
|
+
// `error` in biome.json was measured instead and rejected: it enables each group's non-recommended rules too,
|
|
1078
|
+
// which produced 3763 findings and included `noBarrelFile` and `noReExportAll` — the two rules that forbid the
|
|
1079
|
+
// core facade this architecture is built on ([/decisions/ad-004.md](/decisions/ad-004.md)) — and `noNodejsModules` in a Node CLI.
|
|
1080
|
+
{ label: "biome check", bin: "npx", args: ["biome", "check", "--error-on-warnings"] },
|
|
1081
|
+
{ label: "tsc --noEmit", bin: "npx", args: ["tsc", "--noEmit"] },
|
|
1082
|
+
{ label: "src suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "src/**/__test__/*.test.ts"] },
|
|
1083
|
+
{ label: "tools suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "tools/__test__/*.test.ts"] },
|
|
1084
|
+
{ label: "check-boundaries", bin: "node", args: ["tools/dev/check-boundaries.ts"] },
|
|
1085
|
+
// why: `--error-on-warnings` above cannot see a rule that was suppressed rather than fixed, and biome accepts
|
|
1086
|
+
// any text after the colon. This is what makes the reason a reason ([/decisions/ad-051.md](/decisions/ad-051.md)).
|
|
1087
|
+
{ label: "check-suppressions", bin: "node", args: ["tools/dev/check-suppressions.ts"] },
|
|
1088
|
+
{ label: "check-wiring", bin: "node", args: ["tools/dev/check-wiring.ts"] },
|
|
1089
|
+
{ label: "check-docs-bundle", bin: "node", args: ["tools/dev/check-docs-bundle.ts"] },
|
|
1090
|
+
// why: the bundle check validates frontmatter and links; this one validates that a decision record still has
|
|
1091
|
+
// the shape that makes it worth reading, and that it is cited in a form a move cannot break
|
|
1092
|
+
// ([/decisions/ad-069.md](/decisions/ad-069.md)).
|
|
1093
|
+
{ label: "check-decisions", bin: "node", args: ["tools/dev/check-decisions.ts"] },
|
|
1094
|
+
{ label: "check-screens", bin: "node", args: ["tools/dev/check-screens.ts"] },
|
|
1095
|
+
{ label: "check-obs-contract", bin: "node", args: ["tools/dev/check-obs-contract.ts"] },
|
|
1096
|
+
// why: `bin` declared `./bin/tlc.mjs` and npm dropped both executables on publish, so the package installed no
|
|
1097
|
+
// command at all. The release runner was the only thing that saw it, in a warning on a build that then failed
|
|
1098
|
+
// for an unrelated reason ([/decisions/ad-081.md](/decisions/ad-081.md)).
|
|
1099
|
+
{ label: "check-manifest", bin: "node", args: ["tools/dev/check-manifest.ts"] },
|
|
1100
|
+
{ label: "capabilities in sync", bin: "node", args: ["tools/dev/render-capabilities.ts", "--check"] },
|
|
1101
|
+
{ label: "changelog in sync", bin: "node", args: ["tools/dev/render-changelog.ts", "--check"] },
|
|
1102
|
+
// why: the OKF bundle's log is a reserved file that cannot be retired, and hand-maintaining it drifted to 19
|
|
1103
|
+
// of 66 records before anyone noticed ([/decisions/ad-067.md](/decisions/ad-067.md)).
|
|
1104
|
+
{ label: "log in sync", bin: "node", args: ["tools/dev/render-log.ts", "--check"] },
|
|
1105
|
+
// why: the coverage page names floor rules and capability ids, and every hand-written list of this project's
|
|
1106
|
+
// own rules has drifted ([/decisions/ad-079.md](/decisions/ad-079.md)).
|
|
1107
|
+
{ label: "coverage in sync", bin: "node", args: ["tools/dev/render-coverage.ts", "--check"] },
|
|
1108
|
+
];
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
export type StepSpawner = (bin: string, args: string[], cwd: string) => { status: number | null };
|
|
1112
|
+
|
|
1113
|
+
export function runTestSteps(
|
|
1114
|
+
steps: TestStep[],
|
|
1115
|
+
cwd: string,
|
|
1116
|
+
spawner: StepSpawner = (bin, spawnArgs, spawnCwd) =>
|
|
1117
|
+
spawnSync(bin, spawnArgs, { cwd: spawnCwd, stdio: "inherit" }),
|
|
1118
|
+
): number {
|
|
1119
|
+
for (const step of steps) {
|
|
1120
|
+
console.log(`tlc harness test: running ${step.label}`);
|
|
1121
|
+
const result = spawner(step.bin, step.args, cwd);
|
|
1122
|
+
const status = result.status ?? 1;
|
|
1123
|
+
if (status !== 0) {
|
|
1124
|
+
console.error(`tlc harness test: FAILED at "${step.label}" (exit ${status})`);
|
|
1125
|
+
return status;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
console.log("tlc harness test: all steps passed");
|
|
1129
|
+
return 0;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function announceNewCapabilities(root: string, runtimeRoot: string): void {
|
|
1133
|
+
const catalog = coreFacade.capability.loadCatalog(runtimeRoot);
|
|
1134
|
+
const policy = coreFacade.capability.readProjectPolicyRaw(root);
|
|
1135
|
+
if (!catalog || !policy) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const seen = coreFacade.capability.readRuntimeSeen(root);
|
|
1139
|
+
const fresh = coreFacade.capability.listNewlyAnnounceable(policy, catalog, seen.catalogVersion);
|
|
1140
|
+
if (fresh.length === 0) {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
console.log("");
|
|
1144
|
+
console.log(coreFacade.capability.formatCapabilityDigest(fresh));
|
|
1145
|
+
console.log("");
|
|
1146
|
+
void coreFacade.capability.writeRuntimeSeen(root, catalog.catalogVersion);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* why: the shape the capability digest established — what is new, what it costs you, announced once. A per-project
|
|
1151
|
+
* seen revision is what makes "once" true, and the reason it matters is that an announcement which repeats becomes
|
|
1152
|
+
* noise, and noise is filtered out by the reader ([/decisions/ad-031.md](/decisions/ad-031.md)).
|
|
1153
|
+
*
|
|
1154
|
+
* invariant: a project with no seen marker is not shown every decision ever written. The first update records where
|
|
1155
|
+
* it stands and announces nothing, because a wall of thirty entries is indistinguishable from no message at all.
|
|
1156
|
+
*/
|
|
1157
|
+
function announceLandedDecisions(root: string, dest: string, before: string | null): void {
|
|
1158
|
+
const now = runtimeRevision(dest).revision;
|
|
1159
|
+
if (now === null) {
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const seen = coreFacade.release.readReleaseSeen(root)?.revision ?? before;
|
|
1163
|
+
if (seen === null || seen === now) {
|
|
1164
|
+
void coreFacade.release.writeReleaseSeen(root, now);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const added = spawnSync(
|
|
1168
|
+
"git",
|
|
1169
|
+
["-C", dest, "diff", "--name-only", "--diff-filter=A", `${seen}..${now}`, "--", "docs/decisions"],
|
|
1170
|
+
{ encoding: "utf8", env: process.env },
|
|
1171
|
+
);
|
|
1172
|
+
if ((added.status ?? 1) !== 0) {
|
|
1173
|
+
// why: a force-push upstream can leave the seen revision unreachable. Reporting that beats throwing on the
|
|
1174
|
+
// path an operator is standing in front of.
|
|
1175
|
+
console.log(`update: cannot list what landed since ${seen} — that revision is no longer in the checkout`);
|
|
1176
|
+
void coreFacade.release.writeReleaseSeen(root, now);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const files = (added.stdout ?? "")
|
|
1180
|
+
.split("\n")
|
|
1181
|
+
.map((line) => line.trim().split("/").pop() ?? "")
|
|
1182
|
+
.filter(Boolean);
|
|
1183
|
+
const digest = coreFacade.release.formatDecisionDigest(coreFacade.release.readDecisions(dest, files));
|
|
1184
|
+
if (digest !== "") {
|
|
1185
|
+
console.log("");
|
|
1186
|
+
console.log(digest);
|
|
1187
|
+
console.log("");
|
|
1188
|
+
}
|
|
1189
|
+
void coreFacade.release.writeReleaseSeen(root, now);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function runUpdate(root: string): never {
|
|
1193
|
+
const dest = resolveHarnessRoot();
|
|
1194
|
+
const revisionBefore = runtimeRevision(dest).revision;
|
|
1195
|
+
const home = runtimeHome();
|
|
1196
|
+
console.log(`update: runtime → ${dest}`);
|
|
1197
|
+
|
|
1198
|
+
if (!existsSync(join(dest, "bin", "tlc-exec.mjs"))) {
|
|
1199
|
+
console.error(`update: missing install at ${home}`);
|
|
1200
|
+
console.error(
|
|
1201
|
+
`update: install once with \`npm i -g ${NPM_PACKAGE}\`, then \`tlc harness install\`, then retry.`,
|
|
1202
|
+
);
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// invariant: classified from the configured home, never from `dest`. `dest` is `realpathSync`-resolved, so asking
|
|
1207
|
+
// it hides the link and update writes into somebody's clone.
|
|
1208
|
+
const kind = runtimePathKind(home);
|
|
1209
|
+
if (kind === "linked") {
|
|
1210
|
+
// invariant: no git command runs against a linked clone, not even a read. The machine-local refresh below is
|
|
1211
|
+
// the whole of what update may do here ([/decisions/ad-046.md](/decisions/ad-046.md)).
|
|
1212
|
+
console.log(linkedRuntimeMessage(home, dest === home ? null : dest));
|
|
1213
|
+
} else if (kind === "npm") {
|
|
1214
|
+
// why: the registry owns fetch, integrity and rollback here, so update's whole job is to bump the package and
|
|
1215
|
+
// re-materialise. No git command runs against an npm-delivered runtime, for the same reason none runs against
|
|
1216
|
+
// a linked clone: it is not a checkout ([/decisions/ad-056.md](/decisions/ad-056.md)).
|
|
1217
|
+
const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], {
|
|
1218
|
+
stdio: "inherit",
|
|
1219
|
+
env: process.env,
|
|
1220
|
+
shell: process.platform === "win32",
|
|
1221
|
+
});
|
|
1222
|
+
if ((bump.status ?? 1) !== 0) {
|
|
1223
|
+
console.error(npmUpdateFailureMessage());
|
|
1224
|
+
process.exit(bump.status ?? 1);
|
|
1225
|
+
}
|
|
1226
|
+
const sync = spawnSync(execBinPath(), ["install-runtime"], { stdio: "inherit", env: process.env });
|
|
1227
|
+
if ((sync.status ?? 1) !== 0) {
|
|
1228
|
+
process.exit(sync.status ?? 1);
|
|
1229
|
+
}
|
|
1230
|
+
} else if (kind === "unmanaged") {
|
|
1231
|
+
console.log(unmanagedRuntimeMessage(dest));
|
|
1232
|
+
} else {
|
|
1233
|
+
const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], {
|
|
1234
|
+
stdio: "inherit",
|
|
1235
|
+
env: process.env,
|
|
1236
|
+
});
|
|
1237
|
+
if ((fetch.status ?? 1) !== 0) {
|
|
1238
|
+
console.error(fetchFailureMessage(dest));
|
|
1239
|
+
process.exit(fetch.status ?? 1);
|
|
1240
|
+
}
|
|
1241
|
+
const mergeRef = upstreamRef(dest);
|
|
1242
|
+
// why: a hard reset, not a fast-forward merge. The artifact is the harness's own, so a local change in it is
|
|
1243
|
+
// never the operator's work and never a conflict they have to resolve. `dist/` bundles rebuilt by an older
|
|
1244
|
+
// update with a different bundler made every fast-forward fail — measured 223,390 bytes from Bun against
|
|
1245
|
+
// 228,018 from esbuild for the same source ([/decisions/ad-046.md](/decisions/ad-046.md)).
|
|
1246
|
+
//
|
|
1247
|
+
// invariant: `state/` and `config.json` are gitignored, so a reset cannot remove them. A test asserts that
|
|
1248
|
+
// rather than trusting it.
|
|
1249
|
+
const reset = spawnSync("git", ["-C", dest, "reset", "--hard", mergeRef], {
|
|
1250
|
+
encoding: "utf8",
|
|
1251
|
+
env: process.env,
|
|
1252
|
+
});
|
|
1253
|
+
if ((reset.status ?? 1) !== 0) {
|
|
1254
|
+
console.error(resetFailureMessage(dest, mergeRef, `${reset.stderr ?? ""}${reset.stdout ?? ""}`));
|
|
1255
|
+
process.exit(reset.status ?? 1);
|
|
1256
|
+
}
|
|
1257
|
+
const after = runtimeRevision(dest).revision;
|
|
1258
|
+
console.log(
|
|
1259
|
+
revisionBefore === after
|
|
1260
|
+
? `update: runtime already at ${after ?? "unknown"} — nothing to move`
|
|
1261
|
+
: `update: runtime ${revisionBefore ?? "unknown"} → ${after ?? "unknown"}`,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const binDir = process.env.TLC_BIN_DIR || join(homedir(), ".local", "bin");
|
|
1266
|
+
mkdirSync(binDir, { recursive: true });
|
|
1267
|
+
mkdirSync(join(home, "..", "skills"), { recursive: true });
|
|
1268
|
+
|
|
1269
|
+
if (process.platform === "win32") {
|
|
1270
|
+
const installPs1 = join(dest, "install.ps1");
|
|
1271
|
+
const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installPs1], {
|
|
1272
|
+
stdio: "inherit",
|
|
1273
|
+
env: { ...process.env, TLC_HOME: home },
|
|
1274
|
+
cwd: dest,
|
|
1275
|
+
});
|
|
1276
|
+
if ((r.status ?? 1) !== 0) {
|
|
1277
|
+
process.exit(r.status ?? 1);
|
|
1278
|
+
}
|
|
1279
|
+
} else {
|
|
1280
|
+
const tlcBin = join(dest, "bin", "tlc");
|
|
1281
|
+
const skillSrc = join(dest, "skills", "harness-init");
|
|
1282
|
+
const skillDest = join(home, "..", "skills", "harness-init");
|
|
1283
|
+
spawnSync("ln", ["-sfn", tlcBin, join(binDir, "tlc")], { stdio: "inherit" });
|
|
1284
|
+
if (!existsSync(skillSrc)) {
|
|
1285
|
+
console.error(`update: missing skill at ${skillSrc}`);
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
spawnSync("ln", ["-sfn", skillSrc, skillDest], { stdio: "inherit" });
|
|
1289
|
+
console.log(`update: skill → ${skillDest}`);
|
|
1290
|
+
const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
|
|
1291
|
+
stdio: "inherit",
|
|
1292
|
+
env: { ...process.env, TLC_HOME: home },
|
|
1293
|
+
});
|
|
1294
|
+
if ((hooks.status ?? 1) !== 0) {
|
|
1295
|
+
console.log("update: hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node
|
|
1300
|
+
// fallback ([/decisions/ad-012.md](/decisions/ad-012.md)) and the gate keeps it matching `src/`, so the pulled revision already carries the right
|
|
1301
|
+
// bundles. Rebuilding them with a different bundler is what dirtied every user's checkout
|
|
1302
|
+
// ([/decisions/ad-046.md](/decisions/ad-046.md)).
|
|
1303
|
+
const missing = missingBundles(dest);
|
|
1304
|
+
if (missing.length === 0) {
|
|
1305
|
+
console.log("update: dist/ complete — no rebuild, so the runtime path stays clean");
|
|
1306
|
+
} else if (existsSync(buildBinPath())) {
|
|
1307
|
+
console.log(`update: ${missing.length} bundle(s) missing — building`);
|
|
1308
|
+
const build = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
|
|
1309
|
+
if ((build.status ?? 1) !== 0) {
|
|
1310
|
+
console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
announceNewCapabilities(root, dest);
|
|
1315
|
+
announceLandedDecisions(root, dest, revisionBefore);
|
|
1316
|
+
|
|
1317
|
+
console.log("update: running doctor…");
|
|
1318
|
+
const doctor = spawnSync(execBinPath(), ["doctor"], {
|
|
1319
|
+
stdio: "inherit",
|
|
1320
|
+
env: { ...process.env, TLC_PROJECT_DIR: root },
|
|
1321
|
+
});
|
|
1322
|
+
console.log("update: ok — reload if hooks/skill should refresh");
|
|
1323
|
+
process.exit(doctor.status ?? 0);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function runEntry(entry: string, toolArgs: string[], root: string): never {
|
|
1327
|
+
const r = spawnSync(execBinPath(), [entry, ...toolArgs], {
|
|
1328
|
+
stdio: "inherit",
|
|
1329
|
+
env: { ...process.env, TLC_PROJECT_DIR: root },
|
|
1330
|
+
});
|
|
1331
|
+
process.exit(r.status ?? 1);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function main(argv: string[]): void {
|
|
1335
|
+
const root = resolveProjectRoot();
|
|
1336
|
+
const group = (argv[0] ?? "").toLowerCase();
|
|
1337
|
+
if (group !== "harness") {
|
|
1338
|
+
console.error(`unknown: ${argv[0] ?? ""}`);
|
|
1339
|
+
console.error(
|
|
1340
|
+
"usage: tlc harness <status|doctor|help|grind|pause|resume|mode|obs|prices|lessons|init|update|test|build>",
|
|
1341
|
+
);
|
|
1342
|
+
process.exit(1);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const { json, rest: args } = takeJsonFlag(argv.slice(1));
|
|
1346
|
+
let action: Action;
|
|
1347
|
+
try {
|
|
1348
|
+
action = route(args);
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
if (error instanceof UsageError) {
|
|
1351
|
+
console.error(error.message);
|
|
1352
|
+
process.exit(1);
|
|
1353
|
+
}
|
|
1354
|
+
throw error;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
switch (action.kind) {
|
|
1358
|
+
case "status": {
|
|
1359
|
+
const leftover = unknownFlags(args.slice(1));
|
|
1360
|
+
if (leftover.length > 0) {
|
|
1361
|
+
console.error(`unknown flag: ${leftover[0]}`);
|
|
1362
|
+
console.error("usage: tlc harness status [--json]");
|
|
1363
|
+
process.exit(1);
|
|
1364
|
+
}
|
|
1365
|
+
if (json) {
|
|
1366
|
+
emitJson(statusJson(root));
|
|
1367
|
+
} else {
|
|
1368
|
+
console.log(statusText(root, createStyle()));
|
|
1369
|
+
}
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
case "handoff": {
|
|
1373
|
+
const leftover = unknownFlags(args.slice(1));
|
|
1374
|
+
if (leftover.length > 0) {
|
|
1375
|
+
console.error(`unknown flag: ${leftover[0]}`);
|
|
1376
|
+
console.error("usage: tlc harness handoff [--json]");
|
|
1377
|
+
process.exit(1);
|
|
1378
|
+
}
|
|
1379
|
+
const report = handoffJson(root);
|
|
1380
|
+
if (json) {
|
|
1381
|
+
emitJson(report);
|
|
1382
|
+
} else {
|
|
1383
|
+
console.log(handoffText(report));
|
|
1384
|
+
}
|
|
1385
|
+
break;
|
|
1386
|
+
}
|
|
1387
|
+
case "attest": {
|
|
1388
|
+
const leftover = unknownFlags(args.slice(1));
|
|
1389
|
+
if (leftover.length > 0) {
|
|
1390
|
+
console.error(`unknown flag: ${leftover[0]}`);
|
|
1391
|
+
console.error("usage: tlc harness attest [--json]");
|
|
1392
|
+
process.exit(1);
|
|
1393
|
+
}
|
|
1394
|
+
const report = attestJson(root);
|
|
1395
|
+
if (json) {
|
|
1396
|
+
emitJson(report);
|
|
1397
|
+
} else {
|
|
1398
|
+
console.log(attestText(root, createStyle()));
|
|
1399
|
+
}
|
|
1400
|
+
// why: a broken chain exits non-zero so a pipeline can gate on it. An empty chain is not broken.
|
|
1401
|
+
process.exit(report.ok ? 0 : 1);
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
case "policy": {
|
|
1405
|
+
if (action.accept.length === 0 && !args.includes("accept")) {
|
|
1406
|
+
if (json) {
|
|
1407
|
+
emitJson(policyJson(root));
|
|
1408
|
+
} else {
|
|
1409
|
+
console.log(policyText(root, createStyle()));
|
|
1410
|
+
}
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
console.log(acceptPolicy(root, action.accept, Boolean(process.stdin.isTTY)));
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
if (error instanceof UsageError) {
|
|
1417
|
+
console.error(error.message);
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
throw error;
|
|
1421
|
+
}
|
|
1422
|
+
break;
|
|
1423
|
+
}
|
|
1424
|
+
case "help":
|
|
1425
|
+
console.log(helpText(createStyle()));
|
|
1426
|
+
break;
|
|
1427
|
+
case "build": {
|
|
1428
|
+
const r = spawnSync(buildBinPath(), [], { stdio: "inherit", env: process.env });
|
|
1429
|
+
process.exit(r.status ?? 1);
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
case "version":
|
|
1433
|
+
if (json) {
|
|
1434
|
+
emitJson(versionJson(root));
|
|
1435
|
+
} else {
|
|
1436
|
+
console.log(versionText(root, createStyle()));
|
|
1437
|
+
}
|
|
1438
|
+
break;
|
|
1439
|
+
case "update-check": {
|
|
1440
|
+
const dest = resolveHarnessRoot();
|
|
1441
|
+
const report = pendingUpdate(dest, upstreamRef(dest));
|
|
1442
|
+
if (json) {
|
|
1443
|
+
emitJson(report);
|
|
1444
|
+
} else {
|
|
1445
|
+
console.log(pendingText(report, createStyle()));
|
|
1446
|
+
}
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
case "update":
|
|
1450
|
+
runUpdate(root);
|
|
1451
|
+
break;
|
|
1452
|
+
case "test": {
|
|
1453
|
+
const status = runTestSteps(buildTestSteps(), process.cwd());
|
|
1454
|
+
process.exit(status);
|
|
1455
|
+
break;
|
|
1456
|
+
}
|
|
1457
|
+
case "grind":
|
|
1458
|
+
console.log(setGrind(root, action.on));
|
|
1459
|
+
break;
|
|
1460
|
+
case "pause":
|
|
1461
|
+
console.log(setPaused(root, true));
|
|
1462
|
+
break;
|
|
1463
|
+
case "resume":
|
|
1464
|
+
console.log(setPaused(root, false));
|
|
1465
|
+
break;
|
|
1466
|
+
case "mode":
|
|
1467
|
+
try {
|
|
1468
|
+
console.log(setMode(root, action.value));
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
if (error instanceof UsageError) {
|
|
1471
|
+
console.error(error.message);
|
|
1472
|
+
process.exit(1);
|
|
1473
|
+
}
|
|
1474
|
+
throw error;
|
|
1475
|
+
}
|
|
1476
|
+
break;
|
|
1477
|
+
case "gate":
|
|
1478
|
+
try {
|
|
1479
|
+
console.log(setGateCommand(root, action.field, action.argv, process.stdin.isTTY === true));
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
if (error instanceof UsageError) {
|
|
1482
|
+
console.error(error.message);
|
|
1483
|
+
process.exit(1);
|
|
1484
|
+
}
|
|
1485
|
+
throw error;
|
|
1486
|
+
}
|
|
1487
|
+
break;
|
|
1488
|
+
case "prices-help":
|
|
1489
|
+
console.log(pricesHelpText(createStyle()));
|
|
1490
|
+
break;
|
|
1491
|
+
case "prices-refresh":
|
|
1492
|
+
runEntry("refresh-model-prices", [action.scope], root);
|
|
1493
|
+
break;
|
|
1494
|
+
case "prices-lookup":
|
|
1495
|
+
runEntry("price-lookup", json ? [action.modelId, JSON_FLAG] : [action.modelId], root);
|
|
1496
|
+
break;
|
|
1497
|
+
case "entry":
|
|
1498
|
+
runEntry(action.entry, json ? [...action.args, JSON_FLAG] : action.args, root);
|
|
1499
|
+
break;
|
|
1500
|
+
case "unknown":
|
|
1501
|
+
console.error(`unknown: ${action.cmd}`);
|
|
1502
|
+
console.log(helpText(createStyle()));
|
|
1503
|
+
process.exit(1);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (import.meta.main) {
|
|
1508
|
+
main(process.argv.slice(2));
|
|
1509
|
+
}
|