@tech-leads-club/harness-toolkit 0.16.4 → 0.16.6

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/docs/diagnose.md CHANGED
@@ -55,6 +55,27 @@ tlc harness doctor
55
55
  state, and only the floor keeps running, because a limit an agent can turn off is not a limit. A pause flag that
56
56
  appears without the CLI is a policy change, not a pause, and is ignored.
57
57
 
58
+ ## The host says a hook returned no output
59
+
60
+ Every hook invocation leaves two lines in `~/.tlc/harness/state/hook-trace.jsonl`: `start`, written by the launcher
61
+ before the handler runs, and `end`, with the duration, exit status, signal and the verdict it printed. Each line
62
+ carries the host's `tool_use_id`, `generation_id` and `conversation_id`, never the tool input, so a refusal the host
63
+ reports can be matched to the invocation that produced it:
64
+
65
+ - no `start` for that tool call: the host never ran the launcher, or it died before its first line;
66
+ - a `start` with no `end`: the process was killed mid-run, usually by the host's own timeout;
67
+ - an `end` with a verdict: the harness answered, and the host discarded it.
68
+
69
+ The file rotates to `hook-trace.jsonl.1` at 5 MB.
70
+
71
+ ## A push is refused: no passing run is recorded for this tree
72
+
73
+ The push battery reads a verdict recorded for the exact working tree; it does not run the suite inside the hook.
74
+ Run `tlc harness verify` in the shell and retry. Give that command a timeout long enough for the whole suite, or run
75
+ it in the background and wait — Cursor's agent shell gives up on a command after about 30 seconds by default. Any
76
+ edit after the run changes the tree and needs a new run. A gate that rewrites files (a formatter) is not recorded:
77
+ review what it changed and run `verify` again.
78
+
58
79
  ## Node vs Bun
59
80
 
60
81
  - Preferred: **Bun** on PATH — every hook runs the TypeScript source directly, ~1 ms per invocation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -56,6 +56,7 @@ import { filesFromOutput } from "./gate/gate.findings.ts";
56
56
  import { cachedVerdict, computeInputsHash, isCacheHit } from "./gate/gate.inputs.ts";
57
57
  import { describeHolder, GATE_LOCK_WAIT_MS, GateLockTimeoutError, withGateLock } from "./gate/gate.lock.ts";
58
58
  import { gapsFromArtifact } from "./gate/gate.service.ts";
59
+ import { findShipVerdict, lastShipVerdictFor, recordShipVerdict } from "./gate/gate.verdicts.ts";
59
60
  import {
60
61
  clearStuckSignals,
61
62
  handoffInjectable,
@@ -317,6 +318,9 @@ export const coreFacade = {
317
318
  isRecipeRunner,
318
319
  isCommandResolutionFailure,
319
320
  filesFromOutput,
321
+ findShipVerdict,
322
+ lastShipVerdictFor,
323
+ recordShipVerdict,
320
324
  },
321
325
  stagnation: {
322
326
  computeFingerprint,
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { updateJsonAtomic } from "../../platform/fs-atomic.ts";
4
+ import { linkedWorktreeMainRoot } from "../../platform/git.ts";
5
+ import { projectStateDir } from "../../platform/paths.ts";
6
+ import { trimOutputTail } from "./gate.artifact.ts";
7
+
8
+ const SHIP_VERDICT_SCHEMA = "harness.ship-verdicts.v1";
9
+ export const SHIP_VERDICT_LIMIT = 50;
10
+
11
+ export type ShipVerdict = {
12
+ gate: string;
13
+ command: string[];
14
+ tree: string;
15
+ passed: boolean;
16
+ exitCode: number;
17
+ durationMs: number;
18
+ outputTail: string;
19
+ at: string;
20
+ };
21
+
22
+ type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
23
+
24
+ function shipVerdictsPath(root: string): string {
25
+ return join(projectStateDir(linkedWorktreeMainRoot(root) ?? root), "ship-verdicts.json");
26
+ }
27
+
28
+ function sameCommand(left: string[], right: string[]): boolean {
29
+ return JSON.stringify(left) === JSON.stringify(right);
30
+ }
31
+
32
+ export function readShipVerdicts(root: string): ShipVerdict[] {
33
+ const path = shipVerdictsPath(root);
34
+ if (!existsSync(path)) {
35
+ return [];
36
+ }
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ShipVerdictFile>;
39
+ return Array.isArray(parsed.verdicts) ? parsed.verdicts : [];
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ export function findShipVerdict(
46
+ root: string,
47
+ gate: string,
48
+ command: string[],
49
+ tree: string,
50
+ ): ShipVerdict | null {
51
+ const matches = readShipVerdicts(root).filter(
52
+ (verdict) => verdict.gate === gate && verdict.tree === tree && sameCommand(verdict.command, command),
53
+ );
54
+ return matches.at(-1) ?? null;
55
+ }
56
+
57
+ export function lastShipVerdictFor(root: string, gate: string, command: string[]): ShipVerdict | null {
58
+ const matches = readShipVerdicts(root).filter(
59
+ (verdict) => verdict.gate === gate && sameCommand(verdict.command, command),
60
+ );
61
+ return matches.at(-1) ?? null;
62
+ }
63
+
64
+ export async function recordShipVerdict(
65
+ root: string,
66
+ input: Omit<ShipVerdict, "at" | "outputTail" | "passed"> & { output: string; at?: string },
67
+ ): Promise<ShipVerdict> {
68
+ const verdict: ShipVerdict = {
69
+ gate: input.gate,
70
+ command: input.command,
71
+ tree: input.tree,
72
+ passed: input.exitCode === 0,
73
+ exitCode: input.exitCode,
74
+ durationMs: input.durationMs,
75
+ outputTail: trimOutputTail(input.output),
76
+ at: input.at ?? new Date().toISOString(),
77
+ };
78
+ const path = shipVerdictsPath(root);
79
+ await updateJsonAtomic<ShipVerdictFile>(
80
+ path,
81
+ (current) => {
82
+ const kept = (current?.verdicts ?? []).filter(
83
+ (existing) =>
84
+ !(
85
+ existing.gate === verdict.gate &&
86
+ existing.tree === verdict.tree &&
87
+ sameCommand(existing.command, verdict.command)
88
+ ),
89
+ );
90
+ return { schema: SHIP_VERDICT_SCHEMA, verdicts: [...kept, verdict].slice(-SHIP_VERDICT_LIMIT) };
91
+ },
92
+ { lockPath: `${path}.lock` },
93
+ );
94
+ return verdict;
95
+ }
package/src/core/index.ts CHANGED
@@ -8,6 +8,7 @@ export type { CoreFacade } from "./core.facade.ts";
8
8
  export { coreFacade } from "./core.facade.ts";
9
9
  export type { FloorInput, FloorRule } from "./floor/floor.service.ts";
10
10
  export type { FailureCategory, GateFinding, GateGap, LastGateArtifact, LockBody } from "./gate/gate.types.ts";
11
+ export type { ShipVerdict } from "./gate/gate.verdicts.ts";
11
12
  export type {
12
13
  ForeignSlice,
13
14
  GateResult,
@@ -5,16 +5,15 @@ import { readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import type { Decision, HarnessEvent } from "../contracts/index.ts";
7
7
  import { coreFacade } from "../core/index.ts";
8
- import { gitRootOf, listAddedLines, listTrackedFiles, localRepoRemote } from "../platform/git.ts";
9
- import type { HandlerContext } from "./run.ts";
10
- import { runLockedGate } from "./stop.ts";
11
8
  import {
12
- computeTurnScope,
13
- currentGitSha,
14
- pendingCommentViolations,
15
- sessionIdFromKey,
16
- shaScopeRoot,
17
- } from "./support.ts";
9
+ gitRootOf,
10
+ listAddedLines,
11
+ listTrackedFiles,
12
+ localRepoRemote,
13
+ workingTreeId,
14
+ } from "../platform/git.ts";
15
+ import type { HandlerContext } from "./run.ts";
16
+ import { computeTurnScope, currentGitSha, pendingCommentViolations, shaScopeRoot } from "./support.ts";
18
17
 
19
18
  // why: pr-merge is the moment code actually lands on the shared branch. Usually no local diff exists at merge
20
19
  // time, so the battery abstains — the merged commits already paid it at push/pr-open time
@@ -62,6 +61,54 @@ function gateFailureMessage(
62
61
  ].join("\n");
63
62
  }
64
63
 
64
+ function formatDuration(ms: number): string {
65
+ const seconds = Math.round(ms / 1000);
66
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
67
+ }
68
+
69
+ function verdictRefusal(
70
+ root: string,
71
+ tree: string,
72
+ required: Array<{ gate: string; command: string[] }>,
73
+ diag: ReturnType<typeof coreFacade.diagnostics.rootDiagnostic>,
74
+ ): Decision | null {
75
+ const missing: Array<{ gate: string; command: string[] }> = [];
76
+ for (const { gate, command } of required) {
77
+ const verdict = coreFacade.gate.findShipVerdict(root, gate, command, tree);
78
+ if (verdict === null) {
79
+ missing.push({ gate, command });
80
+ continue;
81
+ }
82
+ if (!verdict.passed) {
83
+ return {
84
+ kind: "deny",
85
+ reason: gateFailureMessage(gate, verdict.command, verdict.outputTail, diag),
86
+ rule: `ship-gate-${gate}`,
87
+ diagnostic: diag.summary,
88
+ };
89
+ }
90
+ }
91
+ if (missing.length === 0) {
92
+ return null;
93
+ }
94
+ const last = missing
95
+ .map(({ gate, command }) => coreFacade.gate.lastShipVerdictFor(root, gate, command))
96
+ .reduce((total, verdict) => total + (verdict?.durationMs ?? 0), 0);
97
+ const estimate = last > 0 ? ` The last run took ${formatDuration(last)}.` : "";
98
+ return {
99
+ kind: "deny",
100
+ reason: [
101
+ `BLOCKED: no passing ${missing.map(({ gate }) => gate).join(" + ")} run is recorded for this tree, and a hook cannot wait for one.`,
102
+ `TRIED: looked up the verdicts recorded for tree ${tree.slice(0, 12)}.`,
103
+ `NEED: run \`tlc harness verify\` in the shell, let it finish, then retry the commit/push/PR.${estimate} Give the command a timeout long enough for the whole suite, or run it in the background and wait for it.`,
104
+ "",
105
+ diag.footer,
106
+ ].join("\n"),
107
+ rule: "ship-gate-unverified",
108
+ diagnostic: diag.summary,
109
+ };
110
+ }
111
+
65
112
  /**
66
113
  * why after comments and before the stop-rules check: cheapest first. A comment violation costs a
67
114
  * regex scan; a gate command is a real process; the stop-rules check is a directory read plus, only
@@ -76,7 +123,6 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
76
123
  const root = event.projectDir;
77
124
  const provider = event.provider;
78
125
  const sessionKey = event.sessionKey;
79
- const session = sessionIdFromKey(event);
80
126
  const scope = await computeTurnScope(root, shaRoot, provider, sessionKey, policy);
81
127
  const rootDiag = coreFacade.diagnostics.rootDiagnostic(shaRoot);
82
128
 
@@ -91,84 +137,27 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
91
137
  };
92
138
  }
93
139
 
140
+ const required: Array<{ gate: string; command: string[] }> = [];
94
141
  if (policy.grind.enabled && policy.grind.lintCommand && scope.codeTargets.length > 0) {
95
- const run = await runLockedGate({
96
- root,
97
- shaRoot,
98
- provider,
99
- session,
100
- pendingCredit: scope.pendingCredit,
101
- sessionKey,
102
- policy,
103
- gate: "lint",
104
- command: policy.grind.lintCommand,
105
- argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
106
- ? scope.codeTargets
107
- : [],
108
- recordFiles: scope.codeTargets,
109
- });
110
- if (run.kind === "ran" && !run.artifact.passed) {
111
- return {
112
- kind: "deny",
113
- reason: gateFailureMessage("lint", run.artifact.command, run.artifact.outputTail, rootDiag),
114
- rule: "ship-gate-lint",
115
- diagnostic: rootDiag.summary,
116
- };
117
- }
142
+ required.push({ gate: "lint", command: policy.grind.lintCommand });
118
143
  }
119
-
120
144
  if (
121
145
  policy.grind.enabled &&
122
146
  policy.grind.testCommand &&
123
147
  (scope.testTargets.length > 0 || scope.codeTargets.length > 0)
124
148
  ) {
125
- const recordFiles = scope.testTargets.length > 0 ? scope.testTargets : scope.codeTargets;
126
- const run = await runLockedGate({
127
- root,
128
- shaRoot,
129
- provider,
130
- session,
131
- pendingCredit: scope.pendingCredit,
132
- sessionKey,
133
- policy,
134
- gate: "test",
135
- command: policy.grind.testCommand,
136
- argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
137
- ? recordFiles
138
- : [],
139
- recordFiles,
140
- });
141
- if (run.kind === "ran" && !run.artifact.passed) {
142
- return {
143
- kind: "deny",
144
- reason: gateFailureMessage("test", run.artifact.command, run.artifact.outputTail, rootDiag),
145
- rule: "ship-gate-test",
146
- diagnostic: rootDiag.summary,
147
- };
148
- }
149
+ required.push({ gate: "test", command: policy.grind.testCommand });
149
150
  }
150
-
151
151
  if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
152
- const run = await runLockedGate({
153
- root,
154
- shaRoot,
155
- provider,
156
- session,
157
- pendingCredit: scope.pendingCredit,
158
- sessionKey,
159
- policy,
160
- gate: "docs",
161
- command: policy.docs.command,
162
- argvFiles: [],
163
- recordFiles: scope.changedFiles,
164
- });
165
- if (run.kind === "ran" && !run.artifact.passed) {
166
- return {
167
- kind: "deny",
168
- reason: gateFailureMessage("docs", run.artifact.command, run.artifact.outputTail, rootDiag),
169
- rule: "ship-gate-docs",
170
- diagnostic: rootDiag.summary,
171
- };
152
+ required.push({ gate: "docs", command: policy.docs.command });
153
+ }
154
+ if (required.length > 0) {
155
+ const tree = await workingTreeId(shaRoot);
156
+ if (tree !== null) {
157
+ const refusal = verdictRefusal(root, tree, required, rootDiag);
158
+ if (refusal) {
159
+ return refusal;
160
+ }
172
161
  }
173
162
  }
174
163
 
@@ -9,6 +9,7 @@ import {
9
9
  listAddedLines,
10
10
  listChangedRepoFiles,
11
11
  listTrackedFiles,
12
+ workingTreeId,
12
13
  } from "../platform/git.ts";
13
14
  import { flagsDir } from "../platform/paths.ts";
14
15
  import { runCommand } from "../platform/process.ts";
@@ -226,7 +227,18 @@ export async function runLockedGate(args: {
226
227
  args.provider,
227
228
  args.session,
228
229
  async () => {
230
+ const treeBefore = await workingTreeId(args.shaRoot);
229
231
  const result = await runCommand(args.shaRoot, args.command, args.argvFiles);
232
+ if (treeBefore !== null && (await workingTreeId(args.shaRoot)) === treeBefore) {
233
+ await coreFacade.gate.recordShipVerdict(args.root, {
234
+ gate: args.gate,
235
+ command: args.command,
236
+ tree: treeBefore,
237
+ exitCode: result.exitCode,
238
+ durationMs: result.durationMs,
239
+ output: result.output,
240
+ });
241
+ }
230
242
  return coreFacade.gate.writeLastGate({
231
243
  root: args.root,
232
244
  sessionKey: args.sessionKey,
@@ -1,4 +1,14 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ utimesSync,
10
+ writeFileSync,
11
+ } from "node:fs";
2
12
  import { realpath } from "node:fs/promises";
3
13
  import { tmpdir } from "node:os";
4
14
  import { join, resolve } from "node:path";
@@ -58,6 +68,54 @@ export async function gitCommonDirOf(dir: string): Promise<string | null> {
58
68
  }
59
69
  }
60
70
 
71
+ export async function workingTreeId(dir: string): Promise<string | null> {
72
+ let indexPath: string;
73
+ let objectsPath: string;
74
+ try {
75
+ const located = await runProcess({
76
+ command: ["git", "rev-parse", "--path-format=absolute", "--git-path", "index", "--git-path", "objects"],
77
+ cwd: dir,
78
+ });
79
+ const [index, objects] = located.stdout.trim().split("\n");
80
+ if (located.exitCode !== 0 || !index || !objects) {
81
+ return null;
82
+ }
83
+ indexPath = index;
84
+ objectsPath = objects;
85
+ } catch {
86
+ return null;
87
+ }
88
+ const scratch = mkdtempSync(join(tmpdir(), "tlc-tree-"));
89
+ const env = {
90
+ ...process.env,
91
+ GIT_INDEX_FILE: join(scratch, "index"),
92
+ GIT_OBJECT_DIRECTORY: join(scratch, "objects"),
93
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: objectsPath,
94
+ };
95
+ const git = (args: string[]) =>
96
+ runProcess({ command: ["git", "-c", "core.fsmonitor=false", ...args], cwd: dir, env });
97
+ try {
98
+ mkdirSync(env.GIT_OBJECT_DIRECTORY);
99
+ if (existsSync(indexPath)) {
100
+ copyFileSync(indexPath, env.GIT_INDEX_FILE);
101
+ const original = statSync(indexPath);
102
+ utimesSync(env.GIT_INDEX_FILE, original.atime, original.mtime);
103
+ } else if ((await git(["read-tree", "--empty"])).exitCode !== 0) {
104
+ return null;
105
+ }
106
+ if ((await git(["add", "-A"])).exitCode !== 0) {
107
+ return null;
108
+ }
109
+ const tree = await git(["write-tree"]);
110
+ const id = tree.stdout.trim();
111
+ return tree.exitCode === 0 && id.length > 0 ? id : null;
112
+ } catch {
113
+ return null;
114
+ } finally {
115
+ rmSync(scratch, { recursive: true, force: true });
116
+ }
117
+ }
118
+
61
119
  export function linkedWorktreeMainRoot(root: string): string | null {
62
120
  let text: string;
63
121
  try {
@@ -0,0 +1,145 @@
1
+ import { spawn } from "node:child_process";
2
+ import { coreFacade } from "../src/core/index.ts";
3
+ import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
4
+ import { gitRootOf, workingTreeId } from "../src/platform/git.ts";
5
+ import { findProjectRoot } from "../src/platform/paths.ts";
6
+
7
+ const OUTPUT_KEEP_CHARS = 64 * 1024;
8
+
9
+ export type VerifyGate = { gate: string; command: string[] };
10
+
11
+ export type VerifyOutcome = {
12
+ gate: string;
13
+ command: string[];
14
+ exitCode: number;
15
+ durationMs: number;
16
+ recorded: boolean;
17
+ treeChanged: boolean;
18
+ };
19
+
20
+ export function gatesToVerify(policy: ReturnType<typeof coreFacade.policy.loadPolicy>): VerifyGate[] {
21
+ const gates: VerifyGate[] = [];
22
+ if (policy.grind.enabled && policy.grind.lintCommand && policy.grind.lintCommand.length > 0) {
23
+ gates.push({ gate: "lint", command: policy.grind.lintCommand });
24
+ }
25
+ if (policy.grind.enabled && policy.grind.testCommand && policy.grind.testCommand.length > 0) {
26
+ gates.push({ gate: "test", command: policy.grind.testCommand });
27
+ }
28
+ if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
29
+ gates.push({ gate: "docs", command: policy.docs.command });
30
+ }
31
+ return gates;
32
+ }
33
+
34
+ function runStreaming(
35
+ command: string[],
36
+ cwd: string,
37
+ echo: boolean,
38
+ ): Promise<{ exitCode: number; output: string; durationMs: number }> {
39
+ const started = Date.now();
40
+ const [file, ...args] = command;
41
+ return new Promise((resolve) => {
42
+ let output = "";
43
+ const keep = (chunk: Buffer, sink: NodeJS.WriteStream) => {
44
+ if (echo) {
45
+ sink.write(chunk);
46
+ }
47
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_KEEP_CHARS);
48
+ };
49
+ const child = spawn(file ?? "", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
50
+ child.stdout.on("data", (chunk: Buffer) => keep(chunk, process.stdout));
51
+ child.stderr.on("data", (chunk: Buffer) => keep(chunk, process.stderr));
52
+ child.on("error", (error) => {
53
+ resolve({ exitCode: 127, output: `${output}\n${error.message}`, durationMs: Date.now() - started });
54
+ });
55
+ child.on("close", (code) => {
56
+ resolve({ exitCode: code ?? 1, output, durationMs: Date.now() - started });
57
+ });
58
+ });
59
+ }
60
+
61
+ export async function verify(args: {
62
+ root: string;
63
+ cwd: string;
64
+ gates: VerifyGate[];
65
+ echo?: boolean;
66
+ log?: (line: string) => void;
67
+ }): Promise<{ tree: string | null; outcomes: VerifyOutcome[] }> {
68
+ const log = args.log ?? ((line: string) => console.log(line));
69
+ const gitRoot = (await gitRootOf(args.cwd)) ?? args.cwd;
70
+ const outcomes: VerifyOutcome[] = [];
71
+ let tree = await workingTreeId(gitRoot);
72
+ for (const { gate, command } of args.gates) {
73
+ const before = tree;
74
+ log(`▸ ${gate}: ${command.join(" ")}`);
75
+ const result = await runStreaming(command, gitRoot, args.echo ?? true);
76
+ tree = await workingTreeId(gitRoot);
77
+ const treeChanged = before === null || tree !== before;
78
+ if (!treeChanged && before !== null) {
79
+ await coreFacade.gate.recordShipVerdict(args.root, {
80
+ gate,
81
+ command,
82
+ tree: before,
83
+ exitCode: result.exitCode,
84
+ durationMs: result.durationMs,
85
+ output: result.output,
86
+ });
87
+ }
88
+ outcomes.push({
89
+ gate,
90
+ command,
91
+ exitCode: result.exitCode,
92
+ durationMs: result.durationMs,
93
+ recorded: !treeChanged,
94
+ treeChanged,
95
+ });
96
+ log(
97
+ `${result.exitCode === 0 ? "✔" : "✖"} ${gate} — exit ${result.exitCode} in ${Math.round(result.durationMs / 1000)}s`,
98
+ );
99
+ if (result.exitCode !== 0) {
100
+ break;
101
+ }
102
+ }
103
+ return { tree, outcomes };
104
+ }
105
+
106
+ export async function main(argv: string[]): Promise<void> {
107
+ const { json } = takeJsonFlag(argv);
108
+ const root = process.env.TLC_PROJECT_DIR ?? findProjectRoot(process.cwd()) ?? process.cwd();
109
+ const policy = coreFacade.policy.loadPolicy(root);
110
+ const gates = gatesToVerify(policy);
111
+ if (gates.length === 0) {
112
+ const message = "verify: no lint, test or docs gate is configured — nothing to verify";
113
+ if (json) {
114
+ emitJson({ tree: null, outcomes: [], message });
115
+ } else {
116
+ console.log(message);
117
+ }
118
+ return;
119
+ }
120
+ const { tree, outcomes } = await verify({
121
+ root,
122
+ cwd: process.cwd(),
123
+ gates,
124
+ echo: !json,
125
+ ...(json ? { log: (line: string) => console.error(line) } : {}),
126
+ });
127
+ const failed = outcomes.find((outcome) => outcome.exitCode !== 0);
128
+ const changed = outcomes.find((outcome) => outcome.treeChanged);
129
+ if (json) {
130
+ emitJson({ tree, outcomes });
131
+ } else if (failed) {
132
+ console.log(`verify: ${failed.gate} failed — fix it and run \`tlc harness verify\` again`);
133
+ } else if (changed) {
134
+ console.log(
135
+ `verify: ${changed.gate} changed files while it ran, so its result describes a tree that no longer exists — review the changes and run \`tlc harness verify\` again`,
136
+ );
137
+ } else {
138
+ console.log(`verify: all gates passed for tree ${tree?.slice(0, 12)} — commit, push and PR may proceed`);
139
+ }
140
+ process.exit(failed || changed ? 1 : 0);
141
+ }
142
+
143
+ if (import.meta.main) {
144
+ await main(process.argv.slice(2));
145
+ }