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

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/concepts.md CHANGED
@@ -150,6 +150,11 @@ step. The tree id covers uncommitted and untracked files, so a verdict recorded
150
150
  push of that commit, and any edit after it does not. Linked worktrees share one record with their main checkout. A
151
151
  gate that rewrites files while it runs is not recorded, because its verdict describes a tree that no longer exists.
152
152
 
153
+ The `stop` grind follows the same rule. It reuses a verdict recorded for the current tree by this session or by
154
+ `tlc harness verify`, and otherwise runs the gate inline within a 90-second budget, ending the whole process tree if
155
+ it overruns. A suite that does not fit is handed back: the turn continues with an instruction to run `tlc harness
156
+ verify`, and the overrun is remembered as an estimate — never as a verdict — so the next stop does not start it again.
157
+
153
158
  ## pause / resume
154
159
 
155
160
  `tlc harness pause` is the circuit breaker: every hook steps aside and answers at once, reading no policy and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.16.6",
3
+ "version": "0.16.7",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -56,7 +56,12 @@ 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
+ import {
60
+ findShipVerdict,
61
+ lastShipVerdictFor,
62
+ recordShipVerdict,
63
+ VERIFY_SOURCE,
64
+ } from "./gate/gate.verdicts.ts";
60
65
  import {
61
66
  clearStuckSignals,
62
67
  handoffInjectable,
@@ -321,6 +326,7 @@ export const coreFacade = {
321
326
  findShipVerdict,
322
327
  lastShipVerdictFor,
323
328
  recordShipVerdict,
329
+ VERIFY_SOURCE,
324
330
  },
325
331
  stagnation: {
326
332
  computeFingerprint,
@@ -7,6 +7,7 @@ import { trimOutputTail } from "./gate.artifact.ts";
7
7
 
8
8
  const SHIP_VERDICT_SCHEMA = "harness.ship-verdicts.v1";
9
9
  export const SHIP_VERDICT_LIMIT = 50;
10
+ export const VERIFY_SOURCE = "verify";
10
11
 
11
12
  export type ShipVerdict = {
12
13
  gate: string;
@@ -17,6 +18,8 @@ export type ShipVerdict = {
17
18
  durationMs: number;
18
19
  outputTail: string;
19
20
  at: string;
21
+ timedOut?: boolean;
22
+ source?: string;
20
23
  };
21
24
 
22
25
  type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
@@ -47,9 +50,15 @@ export function findShipVerdict(
47
50
  gate: string,
48
51
  command: string[],
49
52
  tree: string,
53
+ sources?: readonly string[],
50
54
  ): ShipVerdict | null {
51
55
  const matches = readShipVerdicts(root).filter(
52
- (verdict) => verdict.gate === gate && verdict.tree === tree && sameCommand(verdict.command, command),
56
+ (verdict) =>
57
+ verdict.timedOut !== true &&
58
+ (sources === undefined || (verdict.source !== undefined && sources.includes(verdict.source))) &&
59
+ verdict.gate === gate &&
60
+ verdict.tree === tree &&
61
+ sameCommand(verdict.command, command),
53
62
  );
54
63
  return matches.at(-1) ?? null;
55
64
  }
@@ -69,11 +78,13 @@ export async function recordShipVerdict(
69
78
  gate: input.gate,
70
79
  command: input.command,
71
80
  tree: input.tree,
72
- passed: input.exitCode === 0,
81
+ passed: input.exitCode === 0 && input.timedOut !== true,
73
82
  exitCode: input.exitCode,
74
83
  durationMs: input.durationMs,
75
84
  outputTail: trimOutputTail(input.output),
76
85
  at: input.at ?? new Date().toISOString(),
86
+ ...(input.timedOut === true ? { timedOut: true } : {}),
87
+ ...(input.source !== undefined ? { source: input.source } : {}),
77
88
  };
78
89
  const path = shipVerdictsPath(root);
79
90
  await updateJsonAtomic<ShipVerdictFile>(
@@ -84,6 +95,7 @@ export async function recordShipVerdict(
84
95
  !(
85
96
  existing.gate === verdict.gate &&
86
97
  existing.tree === verdict.tree &&
98
+ existing.source === verdict.source &&
87
99
  sameCommand(existing.command, verdict.command)
88
100
  ),
89
101
  );
@@ -66,6 +66,24 @@ function formatDuration(ms: number): string {
66
66
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
67
67
  }
68
68
 
69
+ function requiredShipGates(
70
+ policy: HandlerContext["policy"],
71
+ scope: { codeTargets: string[]; testTargets: string[] },
72
+ ): Array<{ gate: string; command: string[] }> {
73
+ const required: Array<{ gate: string; command: string[] }> = [];
74
+ const changedCode = scope.codeTargets.length > 0;
75
+ if (policy.grind.enabled && policy.grind.lintCommand && changedCode) {
76
+ required.push({ gate: "lint", command: policy.grind.lintCommand });
77
+ }
78
+ if (policy.grind.enabled && policy.grind.testCommand && (changedCode || scope.testTargets.length > 0)) {
79
+ required.push({ gate: "test", command: policy.grind.testCommand });
80
+ }
81
+ if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
82
+ required.push({ gate: "docs", command: policy.docs.command });
83
+ }
84
+ return required;
85
+ }
86
+
69
87
  function verdictRefusal(
70
88
  root: string,
71
89
  tree: string,
@@ -137,20 +155,7 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
137
155
  };
138
156
  }
139
157
 
140
- const required: Array<{ gate: string; command: string[] }> = [];
141
- if (policy.grind.enabled && policy.grind.lintCommand && scope.codeTargets.length > 0) {
142
- required.push({ gate: "lint", command: policy.grind.lintCommand });
143
- }
144
- if (
145
- policy.grind.enabled &&
146
- policy.grind.testCommand &&
147
- (scope.testTargets.length > 0 || scope.codeTargets.length > 0)
148
- ) {
149
- required.push({ gate: "test", command: policy.grind.testCommand });
150
- }
151
- if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
152
- required.push({ gate: "docs", command: policy.docs.command });
153
- }
158
+ const required = requiredShipGates(policy, scope);
154
159
  if (required.length > 0) {
155
160
  const tree = await workingTreeId(shaRoot);
156
161
  if (tree !== null) {
@@ -135,7 +135,8 @@ function recordGateDeferred(args: {
135
135
  */
136
136
  export type GateRun =
137
137
  | { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
138
- | { kind: "deferred"; holder: string };
138
+ | { kind: "deferred"; holder: string }
139
+ | { kind: "slow"; estimateMs: number; budgetMs: number };
139
140
 
140
141
  /**
141
142
  * hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
@@ -144,6 +145,15 @@ export type GateRun =
144
145
  */
145
146
  export const STOP_LOCK_WAIT_MS = 10_000;
146
147
 
148
+ export const STOP_GATE_BUDGET_MS = 90_000;
149
+
150
+ const TIMED_OUT_HEADROOM = 1.25;
151
+
152
+ export function stopGateBudgetMs(env: NodeJS.ProcessEnv = process.env): number {
153
+ const declared = Number(env.TLC_TEST_STOP_GATE_BUDGET_MS);
154
+ return Number.isFinite(declared) && declared > 0 ? declared : STOP_GATE_BUDGET_MS;
155
+ }
156
+
147
157
  /**
148
158
  * why: a test that proves the wait has to wait, and a suite that pays ten seconds for it pays that on every gate
149
159
  * run in every environment. This is the only seam and it is read here, so the production default is a constant
@@ -192,19 +202,7 @@ async function creditPendingLessons(args: {
192
202
  * ([/decisions/ad-116.md](/decisions/ad-116.md)) can ask the identical question `stop` asks — same lock, same
193
203
  * content-hash cache, same observability. A second implementation is the drift AD-071 already named once.
194
204
  */
195
- export async function runLockedGate(args: {
196
- root: string;
197
- shaRoot: string;
198
- provider: string;
199
- session: string;
200
- gate: "lint" | "test" | "docs";
201
- command: string[];
202
- argvFiles: string[];
203
- recordFiles: string[];
204
- sessionKey: string;
205
- policy: Policy;
206
- pendingCredit: PendingLessonCredit | undefined;
207
- }): Promise<GateRun> {
205
+ export async function runLockedGate(args: LockedGateArgs): Promise<GateRun> {
208
206
  const command = [...args.command, ...args.argvFiles];
209
207
  // why: `args.recordFiles`/`args.argvFiles` are the turn's changed files, resolved against `shaRoot` — the
210
208
  // same split AD-129 already applied to diffing. Hashing or running against `args.root` instead reads a
@@ -218,39 +216,30 @@ export async function runLockedGate(args: {
218
216
  );
219
217
 
220
218
  let artifact: LastGateArtifact;
219
+ let reused = cached !== null;
220
+ const tree = cached === null ? await workingTreeId(args.shaRoot) : null;
221
+ const recorded = recordedVerdictFor(args, tree);
221
222
  if (cached !== null) {
222
223
  artifact = cached;
224
+ } else if (recorded !== null) {
225
+ reused = true;
226
+ artifact = writeArtifact(args, command, inputs, {
227
+ exitCode: recorded.exitCode,
228
+ durationMs: recorded.durationMs,
229
+ output: recorded.outputTail,
230
+ });
223
231
  } else {
232
+ const slow = overBudget(args);
233
+ if (slow !== null) {
234
+ return slow;
235
+ }
236
+ let ran: LastGateArtifact | { timedOutAfterMs: number };
224
237
  try {
225
- artifact = await coreFacade.gate.withGateLock(
238
+ ran = await coreFacade.gate.withGateLock(
226
239
  args.root,
227
240
  args.provider,
228
241
  args.session,
229
- async () => {
230
- const treeBefore = await workingTreeId(args.shaRoot);
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
- }
242
- return coreFacade.gate.writeLastGate({
243
- root: args.root,
244
- sessionKey: args.sessionKey,
245
- gate: args.gate,
246
- exitCode: result.exitCode,
247
- command,
248
- files: args.recordFiles,
249
- durationMs: result.durationMs,
250
- output: result.output,
251
- ...(inputs.complete ? { inputsHash: inputs.hash } : {}),
252
- });
253
- },
242
+ () => runAndRecord(args, command, inputs, tree),
254
243
  { waitMs: stopLockWaitMs() },
255
244
  );
256
245
  } catch (error) {
@@ -263,10 +252,14 @@ export async function runLockedGate(args: {
263
252
  recordGateDeferred({ ...args, holder });
264
253
  return { kind: "deferred", holder };
265
254
  }
255
+ if ("timedOutAfterMs" in ran) {
256
+ return { kind: "slow", estimateMs: ran.timedOutAfterMs, budgetMs: ran.timedOutAfterMs };
257
+ }
258
+ artifact = ran;
266
259
  }
267
260
 
268
261
  // invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
269
- recordGateOutcome({ ...args, artifact, reused: cached !== null });
262
+ recordGateOutcome({ ...args, artifact, reused });
270
263
  await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
271
264
  await creditPendingLessons({
272
265
  root: args.root,
@@ -276,7 +269,97 @@ export async function runLockedGate(args: {
276
269
  sessionKey: args.sessionKey,
277
270
  passed: artifact.passed,
278
271
  });
279
- return { kind: "ran", artifact, reused: cached !== null };
272
+ return { kind: "ran", artifact, reused };
273
+ }
274
+
275
+ type LockedGateArgs = {
276
+ root: string;
277
+ shaRoot: string;
278
+ provider: string;
279
+ session: string;
280
+ gate: "lint" | "test" | "docs";
281
+ command: string[];
282
+ argvFiles: string[];
283
+ recordFiles: string[];
284
+ sessionKey: string;
285
+ policy: Policy;
286
+ pendingCredit: PendingLessonCredit | undefined;
287
+ deadline?: number;
288
+ };
289
+
290
+ type GateInputs = ReturnType<typeof coreFacade.gate.computeInputsHash>;
291
+
292
+ function recordedVerdictFor(args: LockedGateArgs, tree: string | null) {
293
+ if (tree === null) {
294
+ return null;
295
+ }
296
+ return coreFacade.gate.findShipVerdict(args.root, args.gate, args.command, tree, [
297
+ args.sessionKey,
298
+ coreFacade.gate.VERIFY_SOURCE,
299
+ ]);
300
+ }
301
+
302
+ function writeArtifact(
303
+ args: LockedGateArgs,
304
+ command: string[],
305
+ inputs: GateInputs,
306
+ result: { exitCode: number; durationMs: number; output: string },
307
+ ): LastGateArtifact {
308
+ return coreFacade.gate.writeLastGate({
309
+ root: args.root,
310
+ sessionKey: args.sessionKey,
311
+ gate: args.gate,
312
+ exitCode: result.exitCode,
313
+ command,
314
+ files: args.recordFiles,
315
+ durationMs: result.durationMs,
316
+ output: result.output,
317
+ ...(inputs.complete ? { inputsHash: inputs.hash } : {}),
318
+ });
319
+ }
320
+
321
+ function overBudget(args: LockedGateArgs): GateRun | null {
322
+ if (args.deadline === undefined) {
323
+ return null;
324
+ }
325
+ const last = coreFacade.gate.lastShipVerdictFor(args.root, args.gate, args.command);
326
+ const needed = last?.timedOut === true ? last.durationMs * TIMED_OUT_HEADROOM : last?.durationMs;
327
+ const budgetMs = args.deadline - Date.now();
328
+ if (budgetMs > 0 && (needed === undefined || needed < budgetMs)) {
329
+ return null;
330
+ }
331
+ return { kind: "slow", estimateMs: last?.durationMs ?? 0, budgetMs: Math.max(0, budgetMs) };
332
+ }
333
+
334
+ async function runAndRecord(
335
+ args: LockedGateArgs,
336
+ command: string[],
337
+ inputs: GateInputs,
338
+ tree: string | null,
339
+ ): Promise<LastGateArtifact | { timedOutAfterMs: number }> {
340
+ const timeoutMs = args.deadline !== undefined ? Math.max(1, args.deadline - Date.now()) : undefined;
341
+ const result = await runCommand(
342
+ args.shaRoot,
343
+ args.command,
344
+ args.argvFiles,
345
+ timeoutMs !== undefined ? { timeoutMs } : {},
346
+ );
347
+ if (tree !== null && (await workingTreeId(args.shaRoot)) === tree) {
348
+ await coreFacade.gate.recordShipVerdict(args.root, {
349
+ gate: args.gate,
350
+ command: args.command,
351
+ tree,
352
+ exitCode: result.exitCode,
353
+ durationMs: result.durationMs,
354
+ output: result.output,
355
+ source: args.sessionKey,
356
+ ...(result.timedOut ? { timedOut: true } : {}),
357
+ });
358
+ }
359
+ if (result.timedOut) {
360
+ return { timedOutAfterMs: result.durationMs };
361
+ }
362
+ return writeArtifact(args, command, inputs, result);
280
363
  }
281
364
 
282
365
  async function failGate(args: {
@@ -514,6 +597,8 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
514
597
  * says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
515
598
  */
516
599
  const deferred: string[] = [];
600
+ const slow: Array<{ gate: string; estimateMs: number }> = [];
601
+ const gateDeadline = ctx.now.getTime() + stopGateBudgetMs();
517
602
  const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
518
603
  const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
519
604
  root,
@@ -620,9 +705,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
620
705
  ? codeTargets
621
706
  : [],
622
707
  recordFiles: codeTargets,
708
+ deadline: gateDeadline,
623
709
  });
624
710
  if (run.kind === "deferred") {
625
711
  deferred.push(run.holder);
712
+ } else if (run.kind === "slow") {
713
+ slow.push({ gate: "lint", estimateMs: run.estimateMs });
626
714
  } else if (!run.artifact.passed) {
627
715
  return failGate({
628
716
  root,
@@ -658,9 +746,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
658
746
  ? testTargets
659
747
  : [],
660
748
  recordFiles,
749
+ deadline: gateDeadline,
661
750
  });
662
751
  if (run.kind === "deferred") {
663
752
  deferred.push(run.holder);
753
+ } else if (run.kind === "slow") {
754
+ slow.push({ gate: "test", estimateMs: run.estimateMs });
664
755
  } else if (!run.artifact.passed) {
665
756
  return failGate({
666
757
  root,
@@ -817,9 +908,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
817
908
  command: policy.docs.command,
818
909
  argvFiles: [],
819
910
  recordFiles: changedFiles,
911
+ deadline: gateDeadline,
820
912
  });
821
913
  if (run.kind === "deferred") {
822
914
  deferred.push(run.holder);
915
+ } else if (run.kind === "slow") {
916
+ slow.push({ gate: "docs", estimateMs: run.estimateMs });
823
917
  } else if (!run.artifact.passed) {
824
918
  if (policy.docs.severity === "deny") {
825
919
  return failGate({
@@ -992,6 +1086,24 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
992
1086
  * `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
993
1087
  * a `why` kind, so `tlc harness why` answers it after the fact.
994
1088
  */
1089
+ if (slow.length > 0) {
1090
+ const names = slow.map(({ gate }) => gate).join(" + ");
1091
+ const longest = Math.max(...slow.map(({ estimateMs }) => estimateMs));
1092
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
1093
+ slice: {
1094
+ last_gate_result: "skipped",
1095
+ next_action: "Run `tlc harness verify` and let it finish before ending the turn.",
1096
+ },
1097
+ });
1098
+ return {
1099
+ kind: "continue",
1100
+ text: [
1101
+ `BLOCKED: the ${names} gate needs longer than a stop hook may run, so this turn's changes are not verified yet.`,
1102
+ `TRIED: ${longest > 0 ? `the last run took ${Math.round(longest / 1000)}s` : "it did not finish"}; the stop hook gives its gates ${Math.round(stopGateBudgetMs() / 1000)}s.`,
1103
+ "NEED: run `tlc harness verify` in the shell and let it finish — give it a timeout long enough for the whole suite, or run it in the background and wait — then end the turn. Its verdict is reused here and at push.",
1104
+ ].join("\n"),
1105
+ };
1106
+ }
995
1107
  const holders = [...new Set(deferred)];
996
1108
  await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
997
1109
  slice: {
@@ -103,9 +103,7 @@ export async function workingTreeId(dir: string): Promise<string | null> {
103
103
  } else if ((await git(["read-tree", "--empty"])).exitCode !== 0) {
104
104
  return null;
105
105
  }
106
- if ((await git(["add", "-A"])).exitCode !== 0) {
107
- return null;
108
- }
106
+ await git(["add", "-A", "--ignore-errors"]);
109
107
  const tree = await git(["write-tree"]);
110
108
  const id = tree.stdout.trim();
111
109
  return tree.exitCode === 0 && id.length > 0 ? id : null;
@@ -52,16 +52,18 @@ export async function runProcess(args: {
52
52
  input?: string;
53
53
  env?: NodeJS.ProcessEnv;
54
54
  timeoutMs?: number;
55
- }): Promise<{ exitCode: number; stdout: string; stderr: string }> {
55
+ }): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut?: boolean }> {
56
56
  const [file, ...argv] = args.command;
57
57
  if (file === undefined) {
58
58
  return { exitCode: 0, stdout: "", stderr: "" };
59
59
  }
60
+ const ownGroup = args.timeoutMs !== undefined && process.platform !== "win32";
60
61
  return await new Promise((resolve, reject) => {
61
62
  const child = spawn(file, argv, {
62
63
  cwd: args.cwd,
63
64
  stdio: ["pipe", "pipe", "pipe"] as const,
64
65
  env: args.env ?? process.env,
66
+ detached: ownGroup,
65
67
  });
66
68
  let stdout = "";
67
69
  let stderr = "";
@@ -71,6 +73,12 @@ export async function runProcess(args: {
71
73
  ? undefined
72
74
  : setTimeout(() => {
73
75
  timedOut = true;
76
+ try {
77
+ if (ownGroup && child.pid !== undefined) {
78
+ process.kill(-child.pid, "SIGKILL");
79
+ return;
80
+ }
81
+ } catch {}
74
82
  child.kill("SIGKILL");
75
83
  }, args.timeoutMs);
76
84
 
@@ -87,7 +95,12 @@ export async function runProcess(args: {
87
95
  child.on("close", (code) => {
88
96
  if (timer) clearTimeout(timer);
89
97
  if (timedOut) {
90
- resolve({ exitCode: TIMEOUT_EXIT_CODE, stdout, stderr: `${stderr}\n(process timed out)` });
98
+ resolve({
99
+ exitCode: TIMEOUT_EXIT_CODE,
100
+ stdout,
101
+ stderr: `${stderr}\n(process timed out)`,
102
+ timedOut: true,
103
+ });
91
104
  return;
92
105
  }
93
106
  resolve({ exitCode: code ?? 1, stdout, stderr });
@@ -99,7 +112,7 @@ export async function runProcess(args: {
99
112
  });
100
113
  }
101
114
 
102
- export type CommandResult = { exitCode: number; output: string; durationMs: number };
115
+ export type CommandResult = { exitCode: number; output: string; durationMs: number; timedOut?: boolean };
103
116
 
104
117
  export const NO_OUTPUT_CAPTURED = "(no output captured)";
105
118
 
@@ -114,7 +127,7 @@ export async function runCommand(
114
127
  projectDir: string,
115
128
  command: string[],
116
129
  extraArgs: string[] = [],
117
- options: { env?: NodeJS.ProcessEnv } = {},
130
+ options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
118
131
  ): Promise<CommandResult> {
119
132
  if (command.length === 0) {
120
133
  return { exitCode: 0, output: "", durationMs: 0 };
@@ -124,6 +137,7 @@ export async function runCommand(
124
137
  command: [...command, ...extraArgs],
125
138
  cwd: projectDir,
126
139
  env: options.env ? { ...process.env, ...options.env } : process.env,
140
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
127
141
  });
128
142
  const combined = (result.stdout + result.stderr).trim();
129
143
  const output = combined.length === 0 ? NO_OUTPUT_CAPTURED : combined;
@@ -131,5 +145,6 @@ export async function runCommand(
131
145
  exitCode: result.exitCode,
132
146
  output,
133
147
  durationMs: Date.now() - started,
148
+ ...(result.timedOut ? { timedOut: true } : {}),
134
149
  };
135
150
  }
package/tools/verify.ts CHANGED
@@ -15,6 +15,7 @@ export type VerifyOutcome = {
15
15
  durationMs: number;
16
16
  recorded: boolean;
17
17
  treeChanged: boolean;
18
+ unidentified: boolean;
18
19
  };
19
20
 
20
21
  export function gatesToVerify(policy: ReturnType<typeof coreFacade.policy.loadPolicy>): VerifyGate[] {
@@ -74,8 +75,9 @@ export async function verify(args: {
74
75
  log(`▸ ${gate}: ${command.join(" ")}`);
75
76
  const result = await runStreaming(command, gitRoot, args.echo ?? true);
76
77
  tree = await workingTreeId(gitRoot);
77
- const treeChanged = before === null || tree !== before;
78
- if (!treeChanged && before !== null) {
78
+ const unidentified = before === null || tree === null;
79
+ const treeChanged = !unidentified && tree !== before;
80
+ if (!unidentified && !treeChanged && before !== null) {
79
81
  await coreFacade.gate.recordShipVerdict(args.root, {
80
82
  gate,
81
83
  command,
@@ -83,6 +85,7 @@ export async function verify(args: {
83
85
  exitCode: result.exitCode,
84
86
  durationMs: result.durationMs,
85
87
  output: result.output,
88
+ source: coreFacade.gate.VERIFY_SOURCE,
86
89
  });
87
90
  }
88
91
  outcomes.push({
@@ -90,8 +93,9 @@ export async function verify(args: {
90
93
  command,
91
94
  exitCode: result.exitCode,
92
95
  durationMs: result.durationMs,
93
- recorded: !treeChanged,
96
+ recorded: !unidentified && !treeChanged,
94
97
  treeChanged,
98
+ unidentified,
95
99
  });
96
100
  log(
97
101
  `${result.exitCode === 0 ? "✔" : "✖"} ${gate} — exit ${result.exitCode} in ${Math.round(result.durationMs / 1000)}s`,
@@ -126,10 +130,15 @@ export async function main(argv: string[]): Promise<void> {
126
130
  });
127
131
  const failed = outcomes.find((outcome) => outcome.exitCode !== 0);
128
132
  const changed = outcomes.find((outcome) => outcome.treeChanged);
133
+ const unidentified = outcomes.find((outcome) => outcome.unidentified);
129
134
  if (json) {
130
135
  emitJson({ tree, outcomes });
131
136
  } else if (failed) {
132
137
  console.log(`verify: ${failed.gate} failed — fix it and run \`tlc harness verify\` again`);
138
+ } else if (unidentified) {
139
+ console.log(
140
+ "verify: git could not identify the working tree, so no verdict was recorded — run `git status` to see what it cannot read",
141
+ );
133
142
  } else if (changed) {
134
143
  console.log(
135
144
  `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`,
@@ -137,7 +146,7 @@ export async function main(argv: string[]): Promise<void> {
137
146
  } else {
138
147
  console.log(`verify: all gates passed for tree ${tree?.slice(0, 12)} — commit, push and PR may proceed`);
139
148
  }
140
- process.exit(failed || changed ? 1 : 0);
149
+ process.exit(failed || changed || unidentified ? 1 : 0);
141
150
  }
142
151
 
143
152
  if (import.meta.main) {