@tech-leads-club/harness-toolkit 0.16.5 → 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/diagnose.md CHANGED
@@ -68,6 +68,14 @@ reports can be matched to the invocation that produced it:
68
68
 
69
69
  The file rotates to `hook-trace.jsonl.1` at 5 MB.
70
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
+
71
79
  ## Node vs Bun
72
80
 
73
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.5",
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,6 +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 {
60
+ findShipVerdict,
61
+ lastShipVerdictFor,
62
+ recordShipVerdict,
63
+ VERIFY_SOURCE,
64
+ } from "./gate/gate.verdicts.ts";
59
65
  import {
60
66
  clearStuckSignals,
61
67
  handoffInjectable,
@@ -317,6 +323,10 @@ export const coreFacade = {
317
323
  isRecipeRunner,
318
324
  isCommandResolutionFailure,
319
325
  filesFromOutput,
326
+ findShipVerdict,
327
+ lastShipVerdictFor,
328
+ recordShipVerdict,
329
+ VERIFY_SOURCE,
320
330
  },
321
331
  stagnation: {
322
332
  computeFingerprint,
@@ -0,0 +1,107 @@
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
+ export const VERIFY_SOURCE = "verify";
11
+
12
+ export type ShipVerdict = {
13
+ gate: string;
14
+ command: string[];
15
+ tree: string;
16
+ passed: boolean;
17
+ exitCode: number;
18
+ durationMs: number;
19
+ outputTail: string;
20
+ at: string;
21
+ timedOut?: boolean;
22
+ source?: string;
23
+ };
24
+
25
+ type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
26
+
27
+ function shipVerdictsPath(root: string): string {
28
+ return join(projectStateDir(linkedWorktreeMainRoot(root) ?? root), "ship-verdicts.json");
29
+ }
30
+
31
+ function sameCommand(left: string[], right: string[]): boolean {
32
+ return JSON.stringify(left) === JSON.stringify(right);
33
+ }
34
+
35
+ export function readShipVerdicts(root: string): ShipVerdict[] {
36
+ const path = shipVerdictsPath(root);
37
+ if (!existsSync(path)) {
38
+ return [];
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ShipVerdictFile>;
42
+ return Array.isArray(parsed.verdicts) ? parsed.verdicts : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ export function findShipVerdict(
49
+ root: string,
50
+ gate: string,
51
+ command: string[],
52
+ tree: string,
53
+ sources?: readonly string[],
54
+ ): ShipVerdict | null {
55
+ const matches = readShipVerdicts(root).filter(
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),
62
+ );
63
+ return matches.at(-1) ?? null;
64
+ }
65
+
66
+ export function lastShipVerdictFor(root: string, gate: string, command: string[]): ShipVerdict | null {
67
+ const matches = readShipVerdicts(root).filter(
68
+ (verdict) => verdict.gate === gate && sameCommand(verdict.command, command),
69
+ );
70
+ return matches.at(-1) ?? null;
71
+ }
72
+
73
+ export async function recordShipVerdict(
74
+ root: string,
75
+ input: Omit<ShipVerdict, "at" | "outputTail" | "passed"> & { output: string; at?: string },
76
+ ): Promise<ShipVerdict> {
77
+ const verdict: ShipVerdict = {
78
+ gate: input.gate,
79
+ command: input.command,
80
+ tree: input.tree,
81
+ passed: input.exitCode === 0 && input.timedOut !== true,
82
+ exitCode: input.exitCode,
83
+ durationMs: input.durationMs,
84
+ outputTail: trimOutputTail(input.output),
85
+ at: input.at ?? new Date().toISOString(),
86
+ ...(input.timedOut === true ? { timedOut: true } : {}),
87
+ ...(input.source !== undefined ? { source: input.source } : {}),
88
+ };
89
+ const path = shipVerdictsPath(root);
90
+ await updateJsonAtomic<ShipVerdictFile>(
91
+ path,
92
+ (current) => {
93
+ const kept = (current?.verdicts ?? []).filter(
94
+ (existing) =>
95
+ !(
96
+ existing.gate === verdict.gate &&
97
+ existing.tree === verdict.tree &&
98
+ existing.source === verdict.source &&
99
+ sameCommand(existing.command, verdict.command)
100
+ ),
101
+ );
102
+ return { schema: SHIP_VERDICT_SCHEMA, verdicts: [...kept, verdict].slice(-SHIP_VERDICT_LIMIT) };
103
+ },
104
+ { lockPath: `${path}.lock` },
105
+ );
106
+ return verdict;
107
+ }
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,72 @@ 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 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
+
87
+ function verdictRefusal(
88
+ root: string,
89
+ tree: string,
90
+ required: Array<{ gate: string; command: string[] }>,
91
+ diag: ReturnType<typeof coreFacade.diagnostics.rootDiagnostic>,
92
+ ): Decision | null {
93
+ const missing: Array<{ gate: string; command: string[] }> = [];
94
+ for (const { gate, command } of required) {
95
+ const verdict = coreFacade.gate.findShipVerdict(root, gate, command, tree);
96
+ if (verdict === null) {
97
+ missing.push({ gate, command });
98
+ continue;
99
+ }
100
+ if (!verdict.passed) {
101
+ return {
102
+ kind: "deny",
103
+ reason: gateFailureMessage(gate, verdict.command, verdict.outputTail, diag),
104
+ rule: `ship-gate-${gate}`,
105
+ diagnostic: diag.summary,
106
+ };
107
+ }
108
+ }
109
+ if (missing.length === 0) {
110
+ return null;
111
+ }
112
+ const last = missing
113
+ .map(({ gate, command }) => coreFacade.gate.lastShipVerdictFor(root, gate, command))
114
+ .reduce((total, verdict) => total + (verdict?.durationMs ?? 0), 0);
115
+ const estimate = last > 0 ? ` The last run took ${formatDuration(last)}.` : "";
116
+ return {
117
+ kind: "deny",
118
+ reason: [
119
+ `BLOCKED: no passing ${missing.map(({ gate }) => gate).join(" + ")} run is recorded for this tree, and a hook cannot wait for one.`,
120
+ `TRIED: looked up the verdicts recorded for tree ${tree.slice(0, 12)}.`,
121
+ `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.`,
122
+ "",
123
+ diag.footer,
124
+ ].join("\n"),
125
+ rule: "ship-gate-unverified",
126
+ diagnostic: diag.summary,
127
+ };
128
+ }
129
+
65
130
  /**
66
131
  * why after comments and before the stop-rules check: cheapest first. A comment violation costs a
67
132
  * regex scan; a gate command is a real process; the stop-rules check is a directory read plus, only
@@ -76,7 +141,6 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
76
141
  const root = event.projectDir;
77
142
  const provider = event.provider;
78
143
  const sessionKey = event.sessionKey;
79
- const session = sessionIdFromKey(event);
80
144
  const scope = await computeTurnScope(root, shaRoot, provider, sessionKey, policy);
81
145
  const rootDiag = coreFacade.diagnostics.rootDiagnostic(shaRoot);
82
146
 
@@ -91,84 +155,14 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
91
155
  };
92
156
  }
93
157
 
94
- 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
- }
118
- }
119
-
120
- if (
121
- policy.grind.enabled &&
122
- policy.grind.testCommand &&
123
- (scope.testTargets.length > 0 || scope.codeTargets.length > 0)
124
- ) {
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
- }
150
-
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
- };
158
+ const required = requiredShipGates(policy, scope);
159
+ if (required.length > 0) {
160
+ const tree = await workingTreeId(shaRoot);
161
+ if (tree !== null) {
162
+ const refusal = verdictRefusal(root, tree, required, rootDiag);
163
+ if (refusal) {
164
+ return refusal;
165
+ }
172
166
  }
173
167
  }
174
168
 
@@ -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";
@@ -134,7 +135,8 @@ function recordGateDeferred(args: {
134
135
  */
135
136
  export type GateRun =
136
137
  | { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
137
- | { kind: "deferred"; holder: string };
138
+ | { kind: "deferred"; holder: string }
139
+ | { kind: "slow"; estimateMs: number; budgetMs: number };
138
140
 
139
141
  /**
140
142
  * hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
@@ -143,6 +145,15 @@ export type GateRun =
143
145
  */
144
146
  export const STOP_LOCK_WAIT_MS = 10_000;
145
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
+
146
157
  /**
147
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
148
159
  * run in every environment. This is the only seam and it is read here, so the production default is a constant
@@ -191,19 +202,7 @@ async function creditPendingLessons(args: {
191
202
  * ([/decisions/ad-116.md](/decisions/ad-116.md)) can ask the identical question `stop` asks — same lock, same
192
203
  * content-hash cache, same observability. A second implementation is the drift AD-071 already named once.
193
204
  */
194
- export async function runLockedGate(args: {
195
- root: string;
196
- shaRoot: string;
197
- provider: string;
198
- session: string;
199
- gate: "lint" | "test" | "docs";
200
- command: string[];
201
- argvFiles: string[];
202
- recordFiles: string[];
203
- sessionKey: string;
204
- policy: Policy;
205
- pendingCredit: PendingLessonCredit | undefined;
206
- }): Promise<GateRun> {
205
+ export async function runLockedGate(args: LockedGateArgs): Promise<GateRun> {
207
206
  const command = [...args.command, ...args.argvFiles];
208
207
  // why: `args.recordFiles`/`args.argvFiles` are the turn's changed files, resolved against `shaRoot` — the
209
208
  // same split AD-129 already applied to diffing. Hashing or running against `args.root` instead reads a
@@ -217,28 +216,30 @@ export async function runLockedGate(args: {
217
216
  );
218
217
 
219
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);
220
222
  if (cached !== null) {
221
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
+ });
222
231
  } else {
232
+ const slow = overBudget(args);
233
+ if (slow !== null) {
234
+ return slow;
235
+ }
236
+ let ran: LastGateArtifact | { timedOutAfterMs: number };
223
237
  try {
224
- artifact = await coreFacade.gate.withGateLock(
238
+ ran = await coreFacade.gate.withGateLock(
225
239
  args.root,
226
240
  args.provider,
227
241
  args.session,
228
- async () => {
229
- const result = await runCommand(args.shaRoot, args.command, args.argvFiles);
230
- return coreFacade.gate.writeLastGate({
231
- root: args.root,
232
- sessionKey: args.sessionKey,
233
- gate: args.gate,
234
- exitCode: result.exitCode,
235
- command,
236
- files: args.recordFiles,
237
- durationMs: result.durationMs,
238
- output: result.output,
239
- ...(inputs.complete ? { inputsHash: inputs.hash } : {}),
240
- });
241
- },
242
+ () => runAndRecord(args, command, inputs, tree),
242
243
  { waitMs: stopLockWaitMs() },
243
244
  );
244
245
  } catch (error) {
@@ -251,10 +252,14 @@ export async function runLockedGate(args: {
251
252
  recordGateDeferred({ ...args, holder });
252
253
  return { kind: "deferred", holder };
253
254
  }
255
+ if ("timedOutAfterMs" in ran) {
256
+ return { kind: "slow", estimateMs: ran.timedOutAfterMs, budgetMs: ran.timedOutAfterMs };
257
+ }
258
+ artifact = ran;
254
259
  }
255
260
 
256
261
  // invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
257
- recordGateOutcome({ ...args, artifact, reused: cached !== null });
262
+ recordGateOutcome({ ...args, artifact, reused });
258
263
  await observeGateForRules({ ...args, gate: args.gate, passed: artifact.passed });
259
264
  await creditPendingLessons({
260
265
  root: args.root,
@@ -264,7 +269,97 @@ export async function runLockedGate(args: {
264
269
  sessionKey: args.sessionKey,
265
270
  passed: artifact.passed,
266
271
  });
267
- 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);
268
363
  }
269
364
 
270
365
  async function failGate(args: {
@@ -502,6 +597,8 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
502
597
  * says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
503
598
  */
504
599
  const deferred: string[] = [];
600
+ const slow: Array<{ gate: string; estimateMs: number }> = [];
601
+ const gateDeadline = ctx.now.getTime() + stopGateBudgetMs();
505
602
  const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
506
603
  const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
507
604
  root,
@@ -608,9 +705,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
608
705
  ? codeTargets
609
706
  : [],
610
707
  recordFiles: codeTargets,
708
+ deadline: gateDeadline,
611
709
  });
612
710
  if (run.kind === "deferred") {
613
711
  deferred.push(run.holder);
712
+ } else if (run.kind === "slow") {
713
+ slow.push({ gate: "lint", estimateMs: run.estimateMs });
614
714
  } else if (!run.artifact.passed) {
615
715
  return failGate({
616
716
  root,
@@ -646,9 +746,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
646
746
  ? testTargets
647
747
  : [],
648
748
  recordFiles,
749
+ deadline: gateDeadline,
649
750
  });
650
751
  if (run.kind === "deferred") {
651
752
  deferred.push(run.holder);
753
+ } else if (run.kind === "slow") {
754
+ slow.push({ gate: "test", estimateMs: run.estimateMs });
652
755
  } else if (!run.artifact.passed) {
653
756
  return failGate({
654
757
  root,
@@ -805,9 +908,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
805
908
  command: policy.docs.command,
806
909
  argvFiles: [],
807
910
  recordFiles: changedFiles,
911
+ deadline: gateDeadline,
808
912
  });
809
913
  if (run.kind === "deferred") {
810
914
  deferred.push(run.holder);
915
+ } else if (run.kind === "slow") {
916
+ slow.push({ gate: "docs", estimateMs: run.estimateMs });
811
917
  } else if (!run.artifact.passed) {
812
918
  if (policy.docs.severity === "deny") {
813
919
  return failGate({
@@ -980,6 +1086,24 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
980
1086
  * `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
981
1087
  * a `why` kind, so `tlc harness why` answers it after the fact.
982
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
+ }
983
1107
  const holders = [...new Set(deferred)];
984
1108
  await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
985
1109
  slice: {