import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { coreFacade } from "../src/core/index.ts"; import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts"; import { linkDir, linkFile, seedConfig } from "../src/platform/links.ts"; import { EXECUTABLE_EXTENSIONS, executableOnPath, findProjectRoot, flagsDir, isOnPath, launcherBinDir, loopsDir, machineHome, projectConfigPath, projectStateDir, providerConfigDirs, runtimeHome, sameLocation, } from "../src/platform/paths.ts"; import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts"; import { createStyle, PLAIN, type Style } from "../src/platform/style.ts"; export class UsageError extends Error {} // why: derived from the facade rather than imported from inside the policy aggregate, so the CLI keeps its // single door into core and the two cannot drift apart. type Posture = ReturnType; /** * invariant: an explicit `TLC_PROJECT_DIR` still wins — the hooks set it from the host's own payload, which knows * the workspace better than a directory walk can. Everything else discovers the project the way `git` does * ([/decisions/ad-101.md](/decisions/ad-101.md)). */ export function resolveProjectRoot(): string { const declared = process.env.TLC_PROJECT_DIR; if (declared) { return declared; } return findProjectRoot(process.cwd()) ?? process.cwd(); } export function modeFilePath(root: string): string { return join(projectStateDir(root), "harness-mode"); } export function grindFlagPath(root: string): string { return join(flagsDir(root), "grind-on"); } export function skipFlagPath(root: string): string { return join(flagsDir(root), "skip-verify"); } // why: the posture flag files carry the posture names, so there is one spelling per posture across the config // field, the state file, the flag file and this command. export function focusFlagPath(root: string): string { return join(flagsDir(root), "focus"); } export function pairedFlagPath(root: string): string { return join(flagsDir(root), "paired"); } export function ensureFlagsDir(root: string): void { mkdirSync(flagsDir(root), { recursive: true }); } export function readMode(root: string): string { return coreFacade.policy.loadPolicy(root).mode; } export function grindOn(root: string): boolean { return coreFacade.policy.loadPolicy(root).grind.enabled; } export function gatesPaused(root: string): boolean { return existsSync(skipFlagPath(root)); } export function acceptedModes(): string { return coreFacade.policy.OPERATOR_MODES.join(" | "); } export function statusScreen(root: string): Screen { const report = statusJson(root); // why: a rejected value is reported next to the posture that replaced it. Printing only `fallback` would leave // the operator with a posture they did not set and no way to see which word was refused. const origin = report.modeInvalid === undefined ? `from ${report.modeOrigin}` : `${report.modeOrigin} — \`${report.modeInvalid}\` is not a posture; accepted: ${acceptedModes()}`; return { title: "harness status", summary: [root], sections: [ { rows: [ { label: "mode", value: `${report.mode} [${origin}]`, level: "info" }, { label: "grind", value: report.grind ? "ON — stop hook re-runs lint/tests and auto-retries on fail" : "OFF — no auto fix loops", level: report.grind ? "ok" : "info", }, { label: "gates", value: report.gatesPaused ? "PAUSED — stop checks disabled" : "active", level: report.gatesPaused ? "warn" : "ok", }, ], }, { title: "Postures", lines: [ "paired explains as it goes, and asks before any sizable move", "solo works on its own; a destructive action, a dead-end or real ambiguity reaches you", "focus only a destructive action or a dead-end reaches you; it settles ambiguity itself", ], }, ], footer: "verification is identical at all three postures · tlc harness why · tlc harness doctor", }; } export function statusText(root: string, style: Style = PLAIN): string { return render(statusScreen(root), style); } export type StatusReport = { root: string; mode: string; modeOrigin: Posture["origin"]; modeInvalid?: string; grind: boolean; gatesPaused: boolean; }; export function statusJson(root: string): StatusReport { const policy = coreFacade.policy.loadPolicy(root); // invariant: posture and its origin come from the resolver the loader itself uses. Status recomputing either // one is what made it report the opposite of every hook ([/decisions/ad-020.md](/decisions/ad-020.md)). const posture = coreFacade.policy.resolveProjectPosture(root); return { root, mode: posture.mode, modeOrigin: posture.origin, ...(posture.invalid === undefined ? {} : { modeInvalid: posture.invalid }), grind: policy.grind.enabled, gatesPaused: gatesPaused(root), }; } // invariant: every sanctioned mutation re-records the baselines. That is what makes "a harness command did // this" and "the baseline matches" a single fact — an out-of-band write skips this call and stays visible. export function setGrind(root: string, on: boolean): string { ensureFlagsDir(root); const path = grindFlagPath(root); if (on) { writeFileSync(path, ""); coreFacade.policy.refreshPolicyBaselines(root); return "grind ON — stop hook will lint/test and auto-retry on failure"; } if (existsSync(path)) { rmSync(path); } coreFacade.policy.refreshPolicyBaselines(root); return "grind OFF — no auto fix loops"; } export function setPaused(root: string, on: boolean): string { ensureFlagsDir(root); const path = skipFlagPath(root); if (on) { writeFileSync(path, ""); coreFacade.policy.refreshPolicyBaselines(root); return "gates PAUSED — stop checks disabled until `tlc harness resume`"; } if (existsSync(path)) { rmSync(path); } coreFacade.policy.refreshPolicyBaselines(root); return "gates ACTIVE again"; } /** * why: the operator's escape hatch for a stuck gate — `blockers`/`previous_gaps` are per session * ([/decisions/ad-122.md](/decisions/ad-122.md)), and a subagent inheriting its parent's session key inherits * a stuck signal the same way, until either a clean stop clears it or this runs across every session on * record. Denied from inside a session by `policy-surface-write`. */ export async function resetStuckState(root: string): Promise { const cleared = await coreFacade.handoff.clearStuckSignals(root); const dir = loopsDir(root); let loopFiles = 0; if (existsSync(dir)) { loopFiles = readdirSync(dir).length; rmSync(dir, { recursive: true, force: true }); } if (cleared.length === 0 && loopFiles === 0) { return "nothing stuck — no blockers and no grind-loop state to clear"; } const parts: string[] = []; if (cleared.length > 0) { parts.push(`cleared blockers for: ${cleared.join(", ")}`); } if (loopFiles > 0) { parts.push(`reset ${loopFiles} grind-loop counter(s)`); } return parts.join("; "); } // hazard: this used to map `focus` onto a second spelling before writing, so the word the operator typed and the // word the config field stored were different — and a config written from the documented word then matched no // branch at all. One word per posture, and nothing translates. const MODE_CONFIRMATION: Record = { paired: "mode paired — explains as it goes, and asks before any sizable move", solo: "mode solo — a destructive action, a dead-end or real ambiguity reaches you", focus: "mode focus — only a destructive action or a dead-end reaches you; ambiguity is settled for you", }; export function setMode(root: string, raw: string): string { const mode = raw.toLowerCase(); if (!coreFacade.policy.isOperatorMode(mode)) { throw new UsageError(`mode must be: ${acceptedModes()}`); } ensureFlagsDir(root); writeFileSync(modeFilePath(root), `${mode}\n`); coreFacade.policy.refreshPolicyBaselines(root); // why: posture governs surfacing only. Announcing grind here would claim a capability this command does not // touch — it has its own switch, its own flag and its own trade-off. return MODE_CONFIRMATION[mode]; } export type HandoffReport = { root: string; providers: Record>; }; /** * The sanctioned way to read handoff state. * * why: the route the floor grants instead of the raw file path it guards ([/decisions/ad-047.md](/decisions/ad-047.md)). * One row per provider, the most recent session, not liveness-gated — a diagnostic summary, not a turn's own * decision ([/decisions/ad-122.md](/decisions/ad-122.md)). */ export function handoffJson(root: string): HandoffReport { const providerNames = new Set( coreFacade.handoff.listHandoffSessionFiles(root).map((file) => file.owner.provider), ); const providers: HandoffReport["providers"] = {}; for (const provider of providerNames) { providers[provider] = coreFacade.handoff.readLatestSlice(root, provider); } return { root, providers }; } export function handoffScreen(report: HandoffReport): Screen { const names = Object.keys(report.providers).sort(); if (names.length === 0) { return { title: "handoff", summary: [report.root], sections: [{ lines: ["nothing recorded yet — this is a fresh start, not a missing file"] }], }; } const sections: Section[] = []; for (const name of names) { const slice = report.providers[name]; if (!slice) { continue; } const rows: Row[] = []; for (const [label, value, level] of [ ["blockers", slice.blockers, "warn"], ["next", slice.next_action, "info"], ["last gate", slice.last_gate_result, slice.last_gate_result === "pass" ? "ok" : "warn"], ["last failure", slice.last_failure_category, "fail"], ] as const) { if (value) { rows.push({ label, value: String(value), level }); } } const gaps = slice.previous_gaps?.map((gap) => gap.summary); if (gaps && gaps.length > 0) { rows.push({ label: "gaps", value: gaps.slice(0, 6).join(" | ") }); } sections.push({ title: `${name} (updated ${slice.updated_at})`, rows }); } return { title: "handoff", summary: [report.root], sections }; } export function handoffText(report: HandoffReport, style: Style = PLAIN): string { return render(handoffScreen(report), style); } /** * why: the artifact a reviewer can read. Everything in it is something the harness observed, and the chain is what * makes a rewritten middle detectable ([/decisions/ad-028.md](/decisions/ad-028.md)). */ export function attestScreen(root: string): Screen { const records = coreFacade.attest.readAttestations(root); const verdict = coreFacade.attest.verifyChain(records); const head: Row = verdict.ok ? { label: "chain", value: `attestation chain OK — ${verdict.length} session(s)`, level: "ok" } : { label: "chain", value: `attestation chain BROKEN at record ${verdict.brokenAt} (${verdict.reason})`, level: "fail", }; if (records.length === 0) { return { title: "attestation", summary: [root], sections: [{ rows: [head] }, { lines: ["no sessions recorded yet"] }], }; } const sections: Section[] = [{ rows: [head] }]; for (const record of records.slice(-10).reverse()) { const rules = Object.entries(record.decisionsByRule) .map(([rule, count]) => `${rule}=${count}`) .join(" "); sections.push({ title: `${record.ts} ${record.provider}/${record.session}`, rows: [ { label: "policy", value: `${record.policyFingerprint}${record.policyDiverged ? " (DIVERGED mid-session)" : ""}`, level: record.policyDiverged ? "warn" : "ok", }, { label: "rails", value: record.railsActive.join(", ") || "none" }, { label: "gates", value: `${record.gates.pass} pass / ${record.gates.fail} fail${rules ? ` | ${rules}` : ""}`, }, ], }); } return { title: "attestation", summary: [root], sections, footer: "chained, not signed — it detects a rewritten record and proves nothing about authorship", }; } export function attestText(root: string, style: Style = PLAIN): string { return render(attestScreen(root), style); } export type AttestReport = { ok: boolean; brokenAt?: number; reason?: string; sessions: number; records: ReturnType; }; export function attestJson(root: string): AttestReport { const records = coreFacade.attest.readAttestations(root); const verdict = coreFacade.attest.verifyChain(records); return verdict.ok ? { ok: true, sessions: verdict.length, records } : { ok: false, brokenAt: verdict.brokenAt, reason: verdict.reason, sessions: records.length, records }; } /** * The one command whose job is to clear a tampering signal, which is why four independent locks sit between it and * an agent ([/decisions/ad-030.md](/decisions/ad-030.md)): * * 1. the floor refuses `tlc harness policy` from inside any agent session, with no config switch; * 2. this refuses without an interactive terminal, so a script cannot reach it either; * 3. the operator names each path, so accepting is an act rather than a keystroke and its blast radius is exactly * what was typed; * 4. acceptance is per source, so the other divergences keep blocking. * * hazard: `interactive` is a parameter rather than an `isTTY` read, so the refusal is testable without a pty. That * matters most on the rail whose failure mode is silence. */ export function acceptPolicy(root: string, paths: string[], interactive: boolean): string { if (!interactive) { throw new UsageError( "tlc harness policy accept needs an interactive terminal — clearing a policy divergence is the operator's call, not a script's.", ); } /** * why: `--all` accepts exactly what `tlc harness policy` just listed, in this project. Typing each absolute * path was the friction, and the four locks are about *who* clears a divergence and *where from*, not about how * much they have to type ([/decisions/ad-058.md](/decisions/ad-058.md)). * * invariant: still per source. It expands to the diverged list at this moment, so a divergence that appears * afterwards is not covered by it — there is no blanket permission here either. */ const requested = paths.includes("--all") ? coreFacade.policy.allDivergedPaths(root) : paths; if (paths.includes("--all") && requested.length === 0) { return `nothing to accept — no policy source diverged in ${root}`; } if (requested.length === 0) { throw new UsageError( [ "usage: tlc harness policy accept [path...]", " tlc harness policy accept --all accept everything `tlc harness policy` lists here", ].join("\n"), ); } /** * hazard: acceptance is written into this project's baseline directory, and the success line used to claim * "every live session". Run from another directory it printed success and cleared nothing — measured while * unblocking a live session, twice, because the message gave no way to tell. It now names the project, and * says so loudly when this project has no blocked session at all. */ const blocked = coreFacade.policy.allDivergedPaths(root); const resolvedRequested = requested.map((path) => (isAbsolute(path) ? path : resolve(root, path))); const notHere = resolvedRequested.filter((path) => !blocked.includes(path)); const outcome = coreFacade.policy.acceptPolicySources(root, requested); if (outcome.kind === "not-a-source") { /** * hazard: this listed the sources and never said which project it had resolved. Run from a home directory, * `projectConfigPath(root)` *is* the machine config path — so the list showed the same file twice, none of the * repository's own paths, and no hint that the root was wrong. An operator read it as a defect in the product * and lost the afternoon to it ([/decisions/ad-101.md](/decisions/ad-101.md)). * * invariant: the success path already names the project. The failure path is the one that needed it. */ throw new UsageError( [ `not a policy source: ${outcome.paths.join(", ")}`, `project: ${root} — pass TLC_PROJECT_DIR or run this from the repository whose session is blocked`, "The sources the loader reads are:", ...outcome.sources.map((source) => ` ${source}`), ].join("\n"), ); } if (outcome.kind === "nothing-to-accept") { return [ `nothing to accept — ${root} has no recorded session baseline.`, "Acceptance is written per project. Run this from the repository whose session is blocked:", ` cd && tlc harness policy accept ${requested.join(" ")}`, ].join("\n"); } const lines = [ `accepted: ${outcome.paths.join(", ")}`, ` for sessions in ${root} — acceptance is per project, not machine-wide`, ]; if (notHere.length > 0) { lines.push( ` note: ${notHere.join(", ")} was not diverging here. If a session elsewhere is blocked, run this in that repository too.`, ); } return lines.join("\n"); } export function policyScreen(root: string): Screen { const diverged = coreFacade.policy.allDivergedPaths(root); if (diverged.length === 0) { return { title: "policy baseline", sections: [ { rows: [ { label: "baseline", value: "matches — nothing changed out of band during any live session", level: "ok", }, ], }, ], }; } return { title: "policy baseline", summary: [`policy changed out of band during a live session (${diverged.length})`], sections: [ { rows: diverged.map((path) => ({ label: "changed", value: path, level: "warn" as const })) }, { title: "If that was you, accept it from your own terminal with", lines: [`tlc harness policy accept ${diverged.join(" ")}`, "", "or: tlc harness policy accept --all"], }, ], footer: "accepting is per path, so anything you leave out keeps blocking", }; } export function policyText(root: string, style: Style = PLAIN): string { return render(policyScreen(root), style); } export type PolicyReport = { diverged: string[]; ok: boolean }; export function policyJson(root: string): PolicyReport { const diverged = coreFacade.policy.allDivergedPaths(root); return { diverged, ok: diverged.length === 0 }; } /** why: computed once and read by both `update` and `update --check`, so the two cannot disagree about what is upstream. */ export function upstreamRef(dest: string): string { const read = (args: string[]): string => { const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env }); return (r.status ?? 1) === 0 ? (r.stdout ?? "").trim() : ""; }; const tracked = read(["rev-parse", "--abbrev-ref", "@{u}"]); if (tracked !== "") { return tracked; } return `origin/${read(["rev-parse", "--abbrev-ref", "HEAD"]) || "main"}`; } /** * What kind of runtime path this is, which decides what `update` may write to it. * * The distinction is the whole fix. A `managed` path is an artifact the installer created and the harness owns, so * a conflict in it is not a decision for the operator — it is discarded. A `linked` path is a symlink to somebody's * working clone, so nothing there may be written by a harness command at all * ([/decisions/ad-046.md](/decisions/ad-046.md)). */ export type RuntimePathKind = "managed" | "linked" | "npm" | "unmanaged" | "absent"; export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit"; /** * why: an npm-delivered runtime is a real directory with no `.git`, which the old classifier called `unmanaged` * and `doctor` reported as a failure — on a perfectly healthy install. It is told apart by the marker the * installer leaves, not by guessing from the contents, because a directory can be many things and only the thing * that created it knows which ([/decisions/ad-056.md](/decisions/ad-056.md)). */ export const NPM_MARKER = "installed-from-npm"; /** * hazard: `install --link` points the runtime path at the clone it was run from, so on a contributor's machine * `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run * `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine. * * invariant: the symlink test comes first and is decided by the path, never by its contents. A linked clone * contains a `.git` too, so testing for that first would classify it as ours. */ export function classifyRuntimePath( dest: string, probe: { isSymlink: (path: string) => boolean; exists: (path: string) => boolean }, ): RuntimePathKind { if (probe.isSymlink(dest)) { return "linked"; } if (!probe.exists(dest)) { return "absent"; } if (probe.exists(join(dest, ".git"))) { return "managed"; } return probe.exists(join(dest, NPM_MARKER)) ? "npm" : "unmanaged"; } /** * hazard: this must be asked about the **configured** home, not a resolved one. `resolveHarnessRoot` calls * `realpathSync`, so passing its result made a linked clone look like a managed checkout — and `update` then ran * `git fetch` inside a contributor's repository. Caught by driving the real command against a linked install rather * than by any unit test ([/decisions/ad-046.md](/decisions/ad-046.md)). * * hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI * refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves * elsewhere and a **managed** checkout was classified as linked — which would silently stop updates on the very * platform the reporter uses. Only the last hop decides, which is the one thing the install actually creates. */ export function runtimePathKind(dest: string): RuntimePathKind { return classifyRuntimePath(dest, { isSymlink: (path) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } }, exists: existsSync, }); } /** * The bundles an install needs but does not have. * * why: derived from the entrypoints on disk, the same way `bin/tlc-build` derives them. A fixed list would stop * naming a new entrypoint and the missing bundle would only surface when a hook fired. */ export function missingBundles(dest: string): string[] { const entrypoints = join(dest, "src", "entrypoints"); if (!existsSync(entrypoints)) { return []; } const expected = readdirSync(entrypoints) .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) .map((name) => `${name.slice(0, -3)}.mjs`); return expected.filter((bundle) => !existsSync(join(dest, "dist", bundle))); } /** * hazard: `dest` was accepted and never used, so the message said "the runtime path" without naming it while both * sibling messages name theirs. An operator with more than one runtime could not tell which link was meant. */ export function linkedRuntimeMessage(dest: string, target: string | null): string { return [ `update: ${dest} is a link to a working clone${target ? ` → ${target}` : ""}.`, "Nothing in it is touched by this command — updating that clone is your own `git pull`.", "Refreshing the machine-local parts only: CLI link, init skill, provider hooks.", ].join("\n"); } /** * hazard: this printed `update: git fetch failed.` and stopped. git's own error names a transport problem and not * the route that works, which is the same shape as the refusal AD-047 was written about * ([/decisions/ad-052.md](/decisions/ad-052.md)). */ export function fetchFailureMessage(dest: string): string { return [ `update: git fetch failed in ${dest}.`, ` The published package needs no clone: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`, " If this runtime predates the move to tech-leads-club/harness-toolkit, it is still pointing at the old", " repository, and that install is what the package replaces.", " For a private fork, this needs a GitHub credential: `gh auth login`, then `gh auth setup-git`.", ].join("\n"); } export function unmanagedRuntimeMessage(dest: string): string { return [ `update: ${dest} is not a git checkout, so there is nothing to pull.`, `Install the package to get a runtime update can move: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`, ].join("\n"); } /** * hazard: the git route's bare `update: git fetch failed.` sent an operator to the wrong problem for a week. A * failing global install has its own small set of causes and each has a different fix, so they are named. */ export function npmUpdateFailureMessage(): string { return [ `update: npm could not install ${NPM_PACKAGE}@latest.`, " permissions — a global prefix owned by root needs sudo, or an npm prefix you own:", " npm config set prefix ~/.local", " not found — the package is published; check the network and any registry proxy in ~/.npmrc", " offline — nothing was changed; the runtime you have still works.", ].join("\n"); } export function resetFailureMessage(dest: string, mergeRef: string, gitOutput: string): string { return [ `update: could not move the runtime to ${mergeRef}.`, ` path: ${dest} (managed checkout)`, gitOutput.trim() ? ` git: ${gitOutput.trim().split("\n").slice(-3).join(" / ")}` : "", `Nothing was changed. If this persists, install the package instead: npm i -g ${NPM_PACKAGE}@latest, then \`tlc harness install\`.`, ] .filter(Boolean) .join("\n"); } export type RuntimeRevision = { revision: string | null; date: string | null }; /** * why: the revision is what `update` already moves, so it cannot drift the way a hand-edited version number does. * `package.json` has said `0.1.0` since the first commit, which is the failure mode a number invites. And a semantic * version is a promise about compatibility that AD-003 refuses to make ([/decisions/ad-031.md](/decisions/ad-031.md)). */ export function runtimeRevision(dest: string): RuntimeRevision { if (!existsSync(join(dest, ".git"))) { return { revision: null, date: null }; } const read = (args: string[]): string | null => { const r = spawnSync("git", ["-C", dest, ...args], { encoding: "utf8", env: process.env }); const out = (r.stdout ?? "").trim(); return (r.status ?? 1) === 0 && out !== "" ? out : null; }; return { revision: read(["rev-parse", "--short", "HEAD"]), date: read(["log", "-1", "--format=%cs"]) }; } export type VersionReport = { runtime: string; revision: string | null; date: string | null; seenRevision: string | null; }; export function versionJson(root: string): VersionReport { const dest = resolveHarnessRoot(); const { revision, date } = runtimeRevision(dest); return { runtime: dest, revision, date, seenRevision: coreFacade.release.readReleaseSeen(root)?.revision ?? null, }; } export function versionScreen(root: string): Screen { const report = versionJson(root); const rows: Row[] = report.revision === null ? [ { label: "runtime", value: report.runtime }, // why: says so rather than printing an empty revision. A linked checkout with no `.git` is a real shape. { label: "revision", value: "unknown — the runtime path is not a git checkout, so `update` cannot pull either", level: "warn", }, ] : [ { label: "runtime", value: report.runtime }, { label: "revision", value: `${report.revision} (${report.date ?? "date unknown"})`, level: "ok" }, { label: "project last saw", value: report.seenRevision ?? "nothing yet — the next update will announce what landed", }, ]; return { title: "harness version", sections: [{ rows }] }; } export function versionText(root: string, style: Style = PLAIN): string { return render(versionScreen(root), style); } export type PendingReport = { ok: boolean; reason?: string; commits: number; decisions: ReturnType; }; /** * why: fetches and never merges. "Look before you leap" that changes something is just leaping, so the merge is not * reachable from this path at all rather than guarded by a flag. */ export function pendingUpdate(dest: string, mergeRef: string): PendingReport { if (!existsSync(join(dest, ".git"))) { return { ok: false, reason: "the runtime path is not a git checkout", commits: 0, decisions: [] }; } const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], { stdio: "inherit", env: process.env }); if ((fetch.status ?? 1) !== 0) { return { ok: false, reason: "git fetch failed", commits: 0, decisions: [] }; } const count = spawnSync("git", ["-C", dest, "rev-list", "--count", `HEAD..${mergeRef}`], { encoding: "utf8", env: process.env, }); const commits = Number.parseInt((count.stdout ?? "0").trim(), 10) || 0; const added = spawnSync( "git", ["-C", dest, "diff", "--name-only", "--diff-filter=A", `HEAD..${mergeRef}`, "--", "docs/decisions"], { encoding: "utf8", env: process.env }, ); const files = (added.stdout ?? "") .split("\n") .map((line) => line.trim().split("/").pop() ?? "") .filter(Boolean); return { ok: true, commits, decisions: coreFacade.release.readDecisions(dest, files) }; } export function pendingScreen(report: PendingReport): Screen { if (!report.ok) { return { title: "update --check", sections: [ { rows: [{ label: "status", value: `${report.reason} — nothing to compare against`, level: "warn" }], }, ], }; } if (report.commits === 0) { return { title: "update --check", sections: [ { rows: [{ label: "status", value: "the runtime is current — nothing to pull", level: "ok" }] }, ], }; } const digest = coreFacade.release.formatDecisionDigest(report.decisions); return { title: "update --check", summary: [`${report.commits} commit(s) would be pulled`, "Nothing has changed yet."], sections: [{ lines: digest === "" ? ["no decisions landed in that range"] : digest.split("\n") }], }; } export function pendingText(report: PendingReport, style: Style = PLAIN): string { return render(pendingScreen(report), style); } export type GateField = "test" | "lint"; const GATE_FIELDS: Record = { "test-command": "test", "lint-command": "lint", }; // why: resolved without executing. Running the binary to see whether it exists would run it, which is not // something a config write is allowed to do. /** * why one list rather than a platform branch reading PATHEXT: the extensions only exist on the platform that uses * them, so trying all of them everywhere finds the same file and removes a branch nobody can test twice. The bare * name is first, so a POSIX `foo` is never beaten by a stray `foo.exe` * ([/decisions/ad-097.md](/decisions/ad-097.md)). */ /** * why this still exists beside `executableOnPath`: it also answers for a name that is already a path, which a PATH * walk has nothing to say about. The walk itself is not repeated here * ([/decisions/ad-101.md](/decisions/ad-101.md)). */ export function resolveExecutable(name: string, env: NodeJS.ProcessEnv = process.env): string | null { if (name.includes("/") || name.includes("\\")) { return EXECUTABLE_EXTENSIONS.map((ext) => `${name}${ext}`).find((c) => existsSync(c)) ?? null; } return executableOnPath(name, env); } /** * The only legitimate route to `grind.testCommand` and `grind.lintCommand`. Its absence is what produced the * bypass this rail exists to stop: the guard refused the edit and the CLI offered nothing in its place. * * hazard: `interactive` is a parameter rather than a `process.stdin.isTTY` read here, so the refusal can be * tested without a pty. It is a second layer only — the floor already refuses this command from inside an * agent session, and the operator's own terminal never reaches that check. */ export function setGateCommand(root: string, field: GateField, argv: string[], interactive: boolean): string { if (argv.length === 0) { throw new UsageError(`usage: tlc harness gate ${field}-command [args...]`); } if (!interactive) { throw new UsageError( `tlc harness gate ${field}-command needs an interactive terminal — harness policy is the operator's to set, not a script's.`, ); } const binary = argv[0] as string; if (resolveExecutable(binary) === null) { // why: AD-021 already treats a gate command that never resolved as a config fault. Refusing it at the // point of writing turns that fault into something the operator sees now instead of at the next gate. throw new UsageError( `\`${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)).`, ); } const path = projectConfigPath(root); const parsed = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Record) : {}; const grind = { ...((parsed.grind as Record | undefined) ?? {}) }; grind[field === "test" ? "testCommand" : "lintCommand"] = argv; parsed.grind = grind; mkdirSync(join(root, ".tlc", "harness"), { recursive: true }); // why: canonical 2-space JSON is byte-for-byte what these configs already are, so the diff is the changed // field and nothing else. writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); coreFacade.policy.refreshPolicyBaselines(root); return `grind.${field}Command = ${JSON.stringify(argv)}`; } export function helpScreen(): Screen { return { title: "tlc harness", sections: [ { lines: `Requires Node.js 24+ (Active LTS 24 or Current 26). Read commands accept --json: status, doctor, obs, lessons, prices lookup, attest, policy. QUICK tlc harness status mode / grind / gates tlc harness version runtime revision, and what this project last saw tlc harness update --check what an update would pull, without pulling it tlc harness update pull runtime + refresh skill/CLI, then doctor tlc harness doctor health checklist tlc harness why [n] the last n decisions this tool made, with the rule behind each tlc harness install put the runtime in place from the installed npm package tlc harness uninstall print what would be undone; --yes applies it, --purge includes state tlc harness build compile dist/ for Node tlc harness test run the full local gate tlc harness help documentation TOPICS architecture | concepts | lessons | measure | prices | diagnose | init CONTROL tlc harness grind [on|off] tlc harness pause | resume tlc harness mode solo|paired|focus tlc harness reset clear a stuck blocker/grind-loop signal from the handoff tlc harness gate test-command [args...] tlc harness gate lint-command [args...] tlc harness attest tamper-evident record of what each session ran under tlc harness policy show a policy that changed out of band; accept to clear it MEASURE tlc harness obs live|events|report|prune tlc harness prices refresh [all|cursor|litellm] tlc harness prices lookup tlc harness lessons list|show|garden|sync-rules PROJECT tlc harness init --minimal | tlc harness init --write --stdin-json`.split("\n"), }, ], footer: "tlc harness help for a document · tlc harness why to see what it decided", }; } export function helpText(style: Style = PLAIN): string { return render(helpScreen(), style); } export function pricesHelpScreen(): Screen { return { title: "prices", sections: [ { lines: ` tlc harness prices refresh [all|cursor|litellm] [--if-stale] tlc harness prices lookup refresh / refresh all both planes of model-prices.json refresh cursor the provider's own rates refresh litellm the vendors' list prices --if-stale fetch only past the 7-day TTL lookup catalog key, pool, USD for 1M in + 1M out Catalogue: /model-prices.json — fetched per machine, never versioned Overrides: /model-prices.local.json — yours, hand-written Documentation: tlc harness help prices`.split("\n"), }, ], footer: "resolution: your overrides → the asking provider's plane → the vendor plane → null", }; } export function pricesHelpText(style: Style = PLAIN): string { return render(pricesHelpScreen(), style); } /** * The installed version, read from the runtime's own manifest. * * hazard: nothing showed it. `doctor` printed twenty rows and not one carried a version, and `update` on the npm * route said `runtime → ` without naming what it was on or what it moved to * ([/decisions/ad-101.md](/decisions/ad-101.md)). */ export function runtimeVersion(home: string): string | null { try { const raw = JSON.parse(readFileSync(join(home, "package.json"), "utf8")) as { version?: unknown }; return typeof raw.version === "string" ? raw.version : null; } catch { return null; } } /** why one line: an unchanged version is the common case and reads better as a sentence than as two rows. */ export function versionMoveLine(before: string | null, after: string | null): string { return before !== null && after !== null && before !== after ? `update: ${before} → ${after}` : `update: already at ${after ?? before ?? "unknown"}`; } export function resolveHarnessRoot(): string { const home = runtimeHome(); try { return realpathSync(home); } catch { return home; } } /** * Where npm put the package this command was installed from. * * hazard: `update` spawned `install-runtime` through the **runtime home's** launcher, so the tool resolved its * source and its destination to the same directory and reported "already at … — nothing to copy". Measured on a * scratch machine: `npm i -g` moved the package from 0.3.0 to 0.3.2 and the runtime the hooks execute stayed on * 0.3.0. Every npm install that ever ran `update` bumped a package and kept its old code, while `doctor` said * update "re-materialises this directory" ([/decisions/ad-098.md](/decisions/ad-098.md)). * * invariant: asked of npm rather than derived from this process. The CLI can be running from the runtime home, * from the package, or from a linked clone, and only npm knows where it installs globally. */ export function globalPackageRoot( probe = { npmRoot: () => spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: true }).stdout ?? "", exists: existsSync, }, ): string | null { // invariant: trimmed here rather than in the probe. `npm root -g` ends in a newline, and a path with a newline // in it fails as a directory while reading as a plausible string in an error message. const root = probe.npmRoot().trim(); if (root.length === 0) { return null; } const candidate = join(root, ...NPM_PACKAGE.split("/")); return probe.exists(candidate) ? candidate : null; } /** * invariant: the *package's* launcher runs the materialisation, not the runtime home's. A release that fixes * `install` has to be able to deliver that fix, and the old code cannot do it. * * invariant: only the **source** is named. `TLC_ORIGIN` is the one end this can know; the destination belongs to * `installDest`, which exists because on a first npm run the *resolved* home is the package itself * ([/decisions/ad-056.md](/decisions/ad-056.md)). * * hazard: the first version of this named the destination too, as `runtimeHome()`, and walked straight into that. * On a clean machine it wrote `config.json` and the price catalogue into * `node_modules/@tech-leads-club/harness-toolkit`, copied nothing, and then crashed writing hooks — Node refuses * to strip types under `node_modules` ([/decisions/ad-098.md](/decisions/ad-098.md)). */ export function npmSyncPlan(packageRoot: string): { command: string; args: string[]; env: Record; } { return { command: process.execPath, args: [join(packageRoot, "bin", "tlc-exec.mjs"), "install-runtime"], env: { TLC_ORIGIN: packageRoot }, }; } export function npmRootFailureMessage(home: string): string { return [ `update: npm reported no global root, so the package it just installed cannot be found.`, ` The runtime at ${home} is unchanged — nothing was half-written.`, ` Run \`npm root -g\` yourself; then \`npm i -g ${NPM_PACKAGE}@latest\` and \`tlc harness install\`.`, ].join("\n"); } /** * Everything an install has to put in place outside the runtime directory itself: the init skill where each * provider reads it, the user-level hooks, and a seeded config. * * hazard: there were three implementations of this — bash, PowerShell, and the POSIX branch here — and they * disagreed. The PowerShell one linked the init skill into `~/.tlc/skills/harness-init`, which no provider reads, * so on Windows `update` refreshed a skill nothing could route to. That is the defect * [/decisions/ad-095.md](/decisions/ad-095.md) fixed on the other side, still live on this one * ([/decisions/ad-097.md](/decisions/ad-097.md)). * * invariant: one function, no platform branch, and the launcher on PATH is npm's business. */ /** * The `tlc` command on `PATH`. * * hazard: install never created this. `uninstall` removed it, `doctor` failed without it, and the README claimed * install added it — three halves of a thing that did not exist. The command came from npm's own shim instead, * which lives in the `bin` directory of whichever Node version npm ran under and leaves `PATH` the moment a * version manager switches. Measured on an operator's machine: a successful install followed immediately by * `tlc: command not found` ([/decisions/ad-101.md](/decisions/ad-101.md)). * * invariant: never fatal. A link is a convenience — npm's shim is still there — so a refusal is reported and the * install continues. * * why the `PATH` check is separate from the link: a link nobody can reach is worse than none, because `doctor` * then reports it healthy while the command still does not exist. */ export function launcherLines(dest: string): string[] { /** * hazard: this linked unconditionally. An install to a throwaway `TLC_INSTALL_DEST` therefore pointed the * machine's `tlc` at that directory — measured: a proof-of-concept install into a temp directory left the * operator's command running from `/tmp`, and every `tlc harness ...` after it resolved its runtime there * ([/decisions/ad-101.md](/decisions/ad-101.md)). * * invariant: only the machine's own runtime home owns the command on `PATH`. Installing somewhere else is a * deliberate act and must not reach into anybody's shell. */ if (!sameLocation(dest, machineHome())) { return [`tlc not linked — ${dest} is not this machine's runtime home`]; } const dir = launcherBinDir(); const source = join(dest, "bin", "tlc"); // hazard: `symlinkSync` happily creates a link to a path that is not there, and `existsSync` on a dangling link // is false — so a broken launcher would report as linked and `doctor` would say no `tlc` on PATH with a healthy // install beside it. Found by the test for this function ([/decisions/ad-101.md](/decisions/ad-101.md)). if (!existsSync(source)) { return [`tlc not linked — ${source} is missing from the runtime`]; } const outcome = linkFile(source, join(dir, "tlc")); if (outcome.kind === "refused") { return [`tlc not linked — ${outcome.reason}`]; } const lines = [`tlc → ${outcome.target}`]; if (!isOnPath(dir)) { lines.push(`${dir} is not on PATH — add it, or use the shim npm installed`); } return lines; } export function wireRuntime(dest: string, home: string): { lines: string[]; missingSkill: boolean } { const lines: string[] = []; const seeded = seedConfig(dest); if (seeded.seeded) { lines.push(`config seeded → ${seeded.path}`); } if (!existsSync(join(dest, "skills", "harness-init"))) { return { lines, missingSkill: true }; } const links = coreFacade.skill.skillLinks(dest, providerConfigDirs(), existsSync); if (links.length === 0) { lines.push("no provider config dir found — skill not linked"); } for (const link of links) { const outcome = linkDir(link.source, link.target); lines.push( outcome.kind === "refused" ? `skill not linked — ${outcome.reason}` : `skill → ${outcome.target}`, ); } lines.push(...launcherLines(dest)); const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], { stdio: "inherit", env: { ...process.env, TLC_HOME: home }, }); if ((hooks.status ?? 1) !== 0) { lines.push("hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)"); } return { lines, missingSkill: false }; } /** * hazard: this was the extensionless bash wrapper, so every `runEntry` spawn — `doctor`, `prices refresh`, * `install-runtime`, `price-lookup` — named a file Windows cannot execute. The hooks never had this problem * because they name the `.mjs` ([/decisions/ad-097.md](/decisions/ad-097.md)). * * invariant: paired with `process.execPath`, so the entry runs under the interpreter that is already running. */ export function execBinPath(): string { return join(resolveHarnessRoot(), "bin", "tlc-exec.mjs"); } /** * why the `.mjs` and not a wrapper: the wrapper was bash, so `spawnSync` could not run it on Windows and * `update` there could never rebuild a missing bundle ([/decisions/ad-097.md](/decisions/ad-097.md)). * * invariant: spawned with `process.execPath`, so the interpreter running the CLI is the one that builds. */ export function buildBinPath(): string { return join(resolveHarnessRoot(), "bin", "tlc-build.mjs"); } export type Action = | { kind: "status" } | { kind: "help" } | { kind: "build" } | { kind: "update" } | { kind: "test" } | { kind: "grind"; on: boolean } | { kind: "pause" } | { kind: "resume" } | { kind: "reset" } | { kind: "mode"; value: string } | { kind: "gate"; field: GateField; argv: string[] } | { kind: "attest" } | { kind: "handoff" } | { kind: "version" } | { kind: "update-check" } | { kind: "policy"; accept: string[] } | { kind: "prices-help" } | { kind: "prices-refresh"; scope: string } | { kind: "prices-lookup"; modelId: string; provider: string } | { kind: "entry"; entry: string; args: string[] } | { kind: "install"; args: string[] } | { kind: "unknown"; cmd: string }; export function route(args: string[]): Action { const cmd = (args[0] ?? "status").toLowerCase(); switch (cmd) { case "status": case "st": case "s": return { kind: "status" }; case "build": case "rebuild": return { kind: "build" }; case "update": case "upgrade": { const flags = args.slice(1); if (flags.includes("--check")) { return { kind: "update-check" }; } // hazard: this accepted any flag in silence. An operator whose update had failed typed `--force`, got no // acknowledgement that it does not exist, and read the same failure as a refusal to force // ([/decisions/ad-048.md](/decisions/ad-048.md)). const leftover = unknownFlags(flags); if (leftover.length > 0) { throw new UsageError( leftover[0] === "--force" ? `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.` : `unknown flag: ${leftover[0]}\nusage: tlc harness update [--check]`, ); } return { kind: "update" }; } case "version": case "--version": return { kind: "version" }; case "test": return { kind: "test" }; case "grind": case "g": { const arg = (args[1] ?? "on").toLowerCase(); if (arg === "on" || arg === "1" || arg === "true") { return { kind: "grind", on: true }; } if (arg === "off" || arg === "0" || arg === "false") { return { kind: "grind", on: false }; } throw new UsageError("usage: tlc harness grind [on|off]"); } case "pause": case "p": return { kind: "pause" }; case "resume": case "r": return { kind: "resume" }; case "reset": return { kind: "reset" }; case "mode": case "m": { const modeArg = args[1]; if (!modeArg) { throw new UsageError("usage: tlc harness mode "); } return { kind: "mode", value: modeArg }; } case "attest": return { kind: "attest" }; case "handoff": return { kind: "handoff" }; case "policy": { const sub = (args[1] ?? "").toLowerCase(); if (!sub) { return { kind: "policy", accept: [] }; } if (sub !== "accept") { throw new UsageError("usage: tlc harness policy [accept [path...]]"); } // why: `--all` reaches acceptPolicy as a marker in the list, where it expands to what diverged here. return { kind: "policy", accept: args.slice(2) }; } case "gate": { const field = GATE_FIELDS[(args[1] ?? "").toLowerCase()]; if (!field) { throw new UsageError("usage: tlc harness gate [args...]"); } return { kind: "gate", field, argv: args.slice(2) }; } case "prices": { const sub = (args[1] ?? "").toLowerCase(); if (!sub || sub === "help" || sub === "-h" || sub === "--help") { return { kind: "prices-help" }; } if (sub === "refresh") { return { kind: "prices-refresh", scope: args[2] ?? "all" }; } if (sub === "lookup" || sub === "get") { const modelId = args[2]; if (!modelId) { throw new UsageError( "usage: tlc harness prices lookup [provider]\ndetail: tlc harness help prices", ); } /** * hazard: the provider was parsed by the tool and dropped by this route, so every lookup ran with an empty * provider — which is the one input that matches no provider plane. `prices lookup composer-2.5 cursor` * answered `source: missing` for a model priced `$0.5/$2.5`, while the same call straight to the tool * resolved it. The help and `docs/measure.md` had documented the argument all along * ([/decisions/ad-098.md](/decisions/ad-098.md)). */ return { kind: "prices-lookup", modelId, provider: args[3] ?? "" }; } throw new UsageError( "usage: tlc harness prices refresh [all|cursor|litellm] | tlc harness prices lookup \ndetail: tlc harness help prices", ); } case "obs": case "o": return { kind: "entry", entry: "obs-cli", args: args.slice(1) }; // why: doctor used to drop its arguments, so every flag reached the entry as an empty list. It forwards // them now, which is what lets --json arrive at the tool. case "doctor": case "doc": return { kind: "entry", entry: "doctor", args: args.slice(1) }; case "lessons": case "lesson": return { kind: "entry", entry: "lessons-cli", args: args.slice(1) }; case "init": return { kind: "entry", entry: "init-project", args: args.slice(1) }; case "install": return { kind: "install", args: args.slice(1) }; // why: the exit has to be as easy to find as the entrance. An operator who cannot get the harness off their // machine without hand-editing settings.json will not try it on a second one // ([/decisions/ad-066.md](/decisions/ad-066.md)). case "uninstall": return { kind: "entry", entry: "uninstall-runtime", args: args.slice(1) }; // why: a first-class verb, not `obs why`. It is the command an operator reaches for when they cannot tell a // harness decision from the model, and nobody in that moment remembers it lives under `obs`. case "why": return { kind: "entry", entry: "obs-cli", args: ["why", ...args.slice(1)] }; case "help": case "-h": case "--help": { const topic = args[1]; if (!topic) { return { kind: "help" }; } return { kind: "entry", entry: "help-topic", args: [topic] }; } default: return { kind: "unknown", cmd }; } } export type TestStep = { label: string; bin: string; args: string[] }; // invariant: every suite is launched through the hermetic setup module. Without it the suite reads // CLAUDE_PROJECT_DIR from whatever started it, so 22 tests that build a fixture in a temp directory resolved // against the real repository — green from a shell, red from inside a hook. export const TEST_ENV_IMPORT = ["--import", "./tools/test-env.mjs"]; /** * The number of unused exports this repository carries today. * * why a number in code and not a snapshot file: it is a debt, and a debt that has to be edited down in a reviewed * commit is one somebody looks at. Lowering it is the point; raising it needs an argument in the diff * ([/decisions/ad-102.md](/decisions/ad-102.md)). */ // why: raised from 76 to 80 — knip cannot trace render-provider-docs.ts's dynamic import() of each provider's inbound module, so EVENT_KIND_BY_HOOK (both adapters) and Claude's PRE_TOOL_USE_FAN_OUT/POST_TOOL_USE_FAN_OUT report as unused though the drift gate depends on them. export const KNIP_EXPORTS_CEILING = 80; export function buildTestSteps(): TestStep[] { return [ // why: `--error-on-warnings`. A warn-level rule does not change biome's exit code, so three fixable warnings // sat in this repo across several green gates until someone read the output by hand. Escalating every group to // `error` in biome.json was measured instead and rejected: it enables each group's non-recommended rules too, // which produced 3763 findings and included `noBarrelFile` and `noReExportAll` — the two rules that forbid the // core facade this architecture is built on ([/decisions/ad-004.md](/decisions/ad-004.md)) — and `noNodejsModules` in a Node CLI. // why: `--max-diagnostics=none`. Biome defaults to showing 20, and an `info`-level rule with a standing count // above that (`noExcessiveCognitiveComplexity`, at `info` so it does not block on pre-existing debt — see // `check-complexity.ts`) can fill the whole budget, leaving a real `error` finding unprinted though it still // fails the step ([/decisions/ad-139.md](/decisions/ad-139.md)). { label: "biome check", bin: "npx", args: ["biome", "check", "--error-on-warnings", "--max-diagnostics=none"], }, { label: "tsc --noEmit", bin: "npx", args: ["tsc", "--noEmit"] }, { label: "src suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "src/**/__test__/*.test.ts"] }, { label: "tools suite", bin: "node", args: [...TEST_ENV_IMPORT, "--test", "tools/__test__/*.test.ts"] }, /** * why two knip steps and not one: `files` and `dependencies` are already at zero, so they block. `exports` has a * backlog of legitimately-exported-for-tests symbols, and a step that reports without failing is a signal that * never fires — so it blocks on *growth* instead, which is the published way to adopt this without a sweep * ([/decisions/ad-102.md](/decisions/ad-102.md)). * * hazard: `observe` was exported, wired into the facade and called by nothing, so no proof could ever exist and * every operator rule denied for ever. 113 tests passed. This is the check that sees that class * ([/decisions/ad-100.md](/decisions/ad-100.md)). */ { label: "knip: dead files and dependencies", bin: "npx", args: ["knip", "--files", "--dependencies"] }, { label: "knip: unused exports do not grow", bin: "npx", args: ["knip", "--exports", "--max-issues", String(KNIP_EXPORTS_CEILING)], }, { label: "check-boundaries", bin: "node", args: ["tools/dev/check-boundaries.ts"] }, // why: `noExcessiveCognitiveComplexity` runs at `info` in biome.json, so step 1 above never fails on it — // this is the ceiling that catches growth instead, the same shape as the knip step above // ([/decisions/ad-139.md](/decisions/ad-139.md)). { label: "check-complexity", bin: "node", args: ["tools/dev/check-complexity.ts"] }, // why: `--error-on-warnings` above cannot see a rule that was suppressed rather than fixed, and biome accepts // any text after the colon. This is what makes the reason a reason ([/decisions/ad-051.md](/decisions/ad-051.md)). { label: "check-suppressions", bin: "node", args: ["tools/dev/check-suppressions.ts"] }, { label: "check-wiring", bin: "node", args: ["tools/dev/check-wiring.ts"] }, { label: "check-docs-bundle", bin: "node", args: ["tools/dev/check-docs-bundle.ts"] }, // why: the bundle check validates frontmatter and links; this one validates that a decision record still has // the shape that makes it worth reading, and that it is cited in a form a move cannot break // ([/decisions/ad-069.md](/decisions/ad-069.md)). { label: "check-decisions", bin: "node", args: ["tools/dev/check-decisions.ts"] }, { label: "check-screens", bin: "node", args: ["tools/dev/check-screens.ts"] }, { label: "check-obs-contract", bin: "node", args: ["tools/dev/check-obs-contract.ts"] }, // why: `bin` declared `./bin/tlc.mjs` and npm dropped both executables on publish, so the package installed no // command at all. The release runner was the only thing that saw it, in a warning on a build that then failed // for an unrelated reason ([/decisions/ad-081.md](/decisions/ad-081.md)). { label: "check-manifest", bin: "node", args: ["tools/dev/check-manifest.ts"] }, { label: "capabilities in sync", bin: "node", args: ["tools/dev/render-capabilities.ts", "--check"] }, { label: "provider docs in sync", bin: "node", args: ["tools/dev/render-provider-docs.ts", "--check"] }, { label: "changelog in sync", bin: "node", args: ["tools/dev/render-changelog.ts", "--check"] }, // why: the OKF bundle's log is a reserved file that cannot be retired, and hand-maintaining it drifted to 19 // of 66 records before anyone noticed ([/decisions/ad-067.md](/decisions/ad-067.md)). { label: "log in sync", bin: "node", args: ["tools/dev/render-log.ts", "--check"] }, // why: the coverage page names floor rules and capability ids, and every hand-written list of this project's // own rules has drifted ([/decisions/ad-079.md](/decisions/ad-079.md)). { label: "coverage in sync", bin: "node", args: ["tools/dev/render-coverage.ts", "--check"] }, ]; } export type StepSpawner = (bin: string, args: string[], cwd: string) => { status: number | null }; export function runTestSteps( steps: TestStep[], cwd: string, spawner: StepSpawner = (bin, spawnArgs, spawnCwd) => spawnSync(bin, spawnArgs, { cwd: spawnCwd, stdio: "inherit" }), ): number { for (const step of steps) { console.log(`tlc harness test: running ${step.label}`); const result = spawner(step.bin, step.args, cwd); const status = result.status ?? 1; if (status !== 0) { console.error(`tlc harness test: FAILED at "${step.label}" (exit ${status})`); return status; } } console.log("tlc harness test: all steps passed"); return 0; } function announceNewCapabilities(root: string, runtimeRoot: string): void { const catalog = coreFacade.capability.loadCatalog(runtimeRoot); // why the effective policy and not the project file: announcing a capability the operator already switched on // machine-wide is noise, and the file alone cannot see that ([/decisions/ad-103.md](/decisions/ad-103.md)). const connected = coreFacade.capability.readProjectPolicyRaw(root); if (!catalog || !connected) { return; } const seen = coreFacade.capability.readRuntimeSeen(root); const fresh = coreFacade.capability.listNewlyAnnounceable( coreFacade.policy.loadPolicy(root), catalog, seen.catalogVersion, ); if (fresh.length === 0) { return; } console.log(""); console.log(coreFacade.capability.formatCapabilityDigest(fresh)); console.log(""); void coreFacade.capability.writeRuntimeSeen(root, catalog.catalogVersion); } /** * why: the shape the capability digest established — what is new, what it costs you, announced once. A per-project * seen revision is what makes "once" true, and the reason it matters is that an announcement which repeats becomes * noise, and noise is filtered out by the reader ([/decisions/ad-031.md](/decisions/ad-031.md)). * * invariant: a project with no seen marker is not shown every decision ever written. The first update records where * it stands and announces nothing, because a wall of thirty entries is indistinguishable from no message at all. */ function announceLandedDecisions(root: string, dest: string, before: string | null): void { const now = runtimeRevision(dest).revision; if (now === null) { return; } const seen = coreFacade.release.readReleaseSeen(root)?.revision ?? before; if (seen === null || seen === now) { void coreFacade.release.writeReleaseSeen(root, now); return; } const added = spawnSync( "git", ["-C", dest, "diff", "--name-only", "--diff-filter=A", `${seen}..${now}`, "--", "docs/decisions"], { encoding: "utf8", env: process.env }, ); if ((added.status ?? 1) !== 0) { // why: a force-push upstream can leave the seen revision unreachable. Reporting that beats throwing on the // path an operator is standing in front of. console.log(`update: cannot list what landed since ${seen} — that revision is no longer in the checkout`); void coreFacade.release.writeReleaseSeen(root, now); return; } const files = (added.stdout ?? "") .split("\n") .map((line) => line.trim().split("/").pop() ?? "") .filter(Boolean); const digest = coreFacade.release.formatDecisionDigest(coreFacade.release.readDecisions(dest, files)); if (digest !== "") { console.log(""); console.log(digest); console.log(""); } void coreFacade.release.writeReleaseSeen(root, now); } function runUpdate(root: string): never { const dest = resolveHarnessRoot(); const revisionBefore = runtimeRevision(dest).revision; const home = runtimeHome(); const versionBefore = runtimeVersion(dest); console.log(`update: runtime → ${dest} (${versionBefore ?? "unknown"})`); if (!existsSync(join(dest, "bin", "tlc-exec.mjs"))) { console.error(`update: missing install at ${home}`); console.error( `update: install once with \`npm i -g ${NPM_PACKAGE}\`, then \`tlc harness install\`, then retry.`, ); process.exit(1); } // invariant: classified from the configured home, never from `dest`. `dest` is `realpathSync`-resolved, so asking // it hides the link and update writes into somebody's clone. const kind = runtimePathKind(home); if (kind === "linked") { // invariant: no git command runs against a linked clone, not even a read. The machine-local refresh below is // the whole of what update may do here ([/decisions/ad-046.md](/decisions/ad-046.md)). console.log(linkedRuntimeMessage(home, dest === home ? null : dest)); } else if (kind === "npm") { // why: the registry owns fetch, integrity and rollback here, so update's whole job is to bump the package and // re-materialise. No git command runs against an npm-delivered runtime, for the same reason none runs against // a linked clone: it is not a checkout ([/decisions/ad-056.md](/decisions/ad-056.md)). const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], { stdio: "inherit", env: process.env, // why: `npm` is `npm.cmd` on Windows and a shell is how that resolves; on POSIX it costs one `/bin/sh`, // and the argv here is fixed ([/decisions/ad-097.md](/decisions/ad-097.md)). shell: true, }); if ((bump.status ?? 1) !== 0) { console.error(npmUpdateFailureMessage()); process.exit(bump.status ?? 1); } const packageRoot = globalPackageRoot(); if (packageRoot === null) { console.error(npmRootFailureMessage(home)); process.exit(1); } const plan = npmSyncPlan(packageRoot); const sync = spawnSync(plan.command, plan.args, { stdio: "inherit", env: { ...process.env, ...plan.env }, }); if ((sync.status ?? 1) !== 0) { process.exit(sync.status ?? 1); } console.log(versionMoveLine(versionBefore, runtimeVersion(dest))); } else if (kind === "unmanaged") { console.log(unmanagedRuntimeMessage(dest)); } else { const fetch = spawnSync("git", ["-C", dest, "fetch", "origin"], { stdio: "inherit", env: process.env, }); if ((fetch.status ?? 1) !== 0) { console.error(fetchFailureMessage(dest)); process.exit(fetch.status ?? 1); } const mergeRef = upstreamRef(dest); // why: a hard reset, not a fast-forward merge. The artifact is the harness's own, so a local change in it is // never the operator's work and never a conflict they have to resolve. `dist/` bundles rebuilt by an older // update with a different bundler made every fast-forward fail — measured 223,390 bytes from Bun against // 228,018 from esbuild for the same source ([/decisions/ad-046.md](/decisions/ad-046.md)). // // invariant: `state/` and `config.json` are gitignored, so a reset cannot remove them. A test asserts that // rather than trusting it. const reset = spawnSync("git", ["-C", dest, "reset", "--hard", mergeRef], { encoding: "utf8", env: process.env, }); if ((reset.status ?? 1) !== 0) { console.error(resetFailureMessage(dest, mergeRef, `${reset.stderr ?? ""}${reset.stdout ?? ""}`)); process.exit(reset.status ?? 1); } const after = runtimeRevision(dest).revision; console.log( revisionBefore === after ? `update: runtime already at ${after ?? "unknown"} — nothing to move` : `update: runtime ${revisionBefore ?? "unknown"} → ${after ?? "unknown"}`, ); } const wired = wireRuntime(dest, home); for (const line of wired.lines) { console.log(`update: ${line}`); } if (wired.missingSkill) { console.error(`update: missing skill at ${join(dest, "skills", "harness-init")}`); process.exit(1); } // invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node // fallback ([/decisions/ad-012.md](/decisions/ad-012.md)) and the gate keeps it matching `src/`, so the pulled revision already carries the right // bundles. Rebuilding them with a different bundler is what dirtied every user's checkout // ([/decisions/ad-046.md](/decisions/ad-046.md)). const missing = missingBundles(dest); if (missing.length === 0) { console.log("update: dist/ complete — no rebuild, so the runtime path stays clean"); } else if (existsSync(buildBinPath())) { console.log(`update: ${missing.length} bundle(s) missing — building`); const build = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env }); if ((build.status ?? 1) !== 0) { console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`); } } announceNewCapabilities(root, dest); announceLandedDecisions(root, dest, revisionBefore); /** * why: an update is the moment a machine is already reaching the network, so it is the natural place to notice * that its prices are a week old. `--if-stale` is what keeps this from being a fetch on every update, and the * failure is tolerated — a rate that could not be fetched is not a reason for a failed update * ([/decisions/ad-096.md](/decisions/ad-096.md)). */ spawnSync(process.execPath, [execBinPath(), "refresh-model-prices", "all", "--if-stale"], { stdio: "inherit", env: { ...process.env, TLC_PROJECT_DIR: root }, }); console.log("update: running doctor…"); const doctor = spawnSync(process.execPath, [execBinPath(), "doctor"], { stdio: "inherit", env: { ...process.env, TLC_PROJECT_DIR: root }, }); console.log("update: ok — reload if hooks/skill should refresh"); process.exit(doctor.status ?? 0); } function runEntry(entry: string, toolArgs: string[], root: string): never { const r = spawnSync(process.execPath, [execBinPath(), entry, ...toolArgs], { stdio: "inherit", env: { ...process.env, TLC_PROJECT_DIR: root }, }); process.exit(r.status ?? 1); } /** * `install`, which is the only entry that must run from the *package* rather than from the runtime it is about to * replace. * * hazard: it went through `runEntry`, so the runtime home's own launcher ran the runtime home's own * `install-runtime`, whose source and destination then resolved to the same directory. Measured: package at 0.3.3, * runtime left on 0.3.1, with the command reporting success — and this is the recovery route the README and every * failure message name ([/decisions/ad-098.md](/decisions/ad-098.md)). * * invariant: `--link` stays local. It points the runtime at the checkout the operator is standing in, so its * source is the working directory and never the package. */ function runInstall(toolArgs: string[], root: string): never { const packageRoot = toolArgs.includes("--link") ? null : globalPackageRoot(); if (packageRoot === null) { runEntry("install-runtime", toolArgs, root); } const plan = npmSyncPlan(packageRoot); const r = spawnSync(plan.command, [...plan.args, ...toolArgs], { stdio: "inherit", env: { ...process.env, ...plan.env, TLC_PROJECT_DIR: root }, }); process.exit(r.status ?? 1); } async function main(argv: string[]): Promise { const root = resolveProjectRoot(); const group = (argv[0] ?? "").toLowerCase(); if (group !== "harness") { console.error(`unknown: ${argv[0] ?? ""}`); console.error( "usage: tlc harness ", ); process.exit(1); } const { json, rest: args } = takeJsonFlag(argv.slice(1)); let action: Action; try { action = route(args); } catch (error) { if (error instanceof UsageError) { console.error(error.message); process.exit(1); } throw error; } switch (action.kind) { case "status": { const leftover = unknownFlags(args.slice(1)); if (leftover.length > 0) { console.error(`unknown flag: ${leftover[0]}`); console.error("usage: tlc harness status [--json]"); process.exit(1); } if (json) { emitJson(statusJson(root)); } else { console.log(statusText(root, createStyle())); } break; } case "handoff": { const leftover = unknownFlags(args.slice(1)); if (leftover.length > 0) { console.error(`unknown flag: ${leftover[0]}`); console.error("usage: tlc harness handoff [--json]"); process.exit(1); } const report = handoffJson(root); if (json) { emitJson(report); } else { console.log(handoffText(report)); } break; } case "attest": { const leftover = unknownFlags(args.slice(1)); if (leftover.length > 0) { console.error(`unknown flag: ${leftover[0]}`); console.error("usage: tlc harness attest [--json]"); process.exit(1); } const report = attestJson(root); if (json) { emitJson(report); } else { console.log(attestText(root, createStyle())); } // why: a broken chain exits non-zero so a pipeline can gate on it. An empty chain is not broken. process.exit(report.ok ? 0 : 1); break; } case "policy": { if (action.accept.length === 0 && !args.includes("accept")) { if (json) { emitJson(policyJson(root)); } else { console.log(policyText(root, createStyle())); } break; } try { console.log(acceptPolicy(root, action.accept, Boolean(process.stdin.isTTY))); } catch (error) { if (error instanceof UsageError) { console.error(error.message); process.exit(1); } throw error; } break; } case "help": console.log(helpText(createStyle())); break; case "build": { const r = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env }); process.exit(r.status ?? 1); break; } case "version": if (json) { emitJson(versionJson(root)); } else { console.log(versionText(root, createStyle())); } break; case "update-check": { const dest = resolveHarnessRoot(); const report = pendingUpdate(dest, upstreamRef(dest)); if (json) { emitJson(report); } else { console.log(pendingText(report, createStyle())); } break; } case "update": runUpdate(root); break; case "test": { const status = runTestSteps(buildTestSteps(), process.cwd()); process.exit(status); break; } case "grind": console.log(setGrind(root, action.on)); break; case "pause": console.log(setPaused(root, true)); break; case "resume": console.log(setPaused(root, false)); break; case "reset": console.log(await resetStuckState(root)); break; case "mode": try { console.log(setMode(root, action.value)); } catch (error) { if (error instanceof UsageError) { console.error(error.message); process.exit(1); } throw error; } break; case "gate": try { console.log(setGateCommand(root, action.field, action.argv, process.stdin.isTTY === true)); } catch (error) { if (error instanceof UsageError) { console.error(error.message); process.exit(1); } throw error; } break; case "prices-help": console.log(pricesHelpText(createStyle())); break; case "prices-refresh": runEntry("refresh-model-prices", [action.scope], root); break; case "prices-lookup": runEntry( "price-lookup", // invariant: the provider reaches the tool, or the lookup can only ever match the vendor plane. [action.modelId, ...(action.provider ? [action.provider] : []), ...(json ? [JSON_FLAG] : [])], root, ); break; case "install": runInstall(action.args, root); break; case "entry": runEntry(action.entry, json ? [...action.args, JSON_FLAG] : action.args, root); break; case "unknown": console.error(`unknown: ${action.cmd}`); console.log(helpText(createStyle())); process.exit(1); } } if (import.meta.main) { main(process.argv.slice(2)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); }