@cratis/pi 7.1.0 → 7.1.2

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.
@@ -9,7 +9,17 @@
9
9
  * Claude PreToolUse (Write|Edit) → Pi `tool_call` → cratis-guard-writes.sh (exit 2 = block)
10
10
  * Claude PreToolUse (Bash) → Pi `tool_call` → cratis-guard-store-mutations.sh (exit 2 = block)
11
11
  * Claude PostToolUse (Write|Edit) → Pi `tool_result` → cratis-pattern-scan.sh (advisory context)
12
- * Claude Stop → Pi `agent_settled` → cratis-quality-gate.sh (exit 2 = keep going)
12
+ * Explicit Pi `cratis_quality_gate` tool → cratis-quality-gate.sh (exit 2 = failed)
13
+ *
14
+ * The quality gate is the one deliberate difference from Claude. It builds and tests the repository, which can
15
+ * take many minutes, and Pi awaits `agent_settled` handlers while deferring the next prompt — so a gate run there
16
+ * froze the session between prompts, invisibly and with no way to cancel it. Here the gate is an explicit tool
17
+ * the model runs in the foreground: it shows progress, Escape cancels it, it stops at a deadline
18
+ * (CRATIS_HOOKS_GATE_TIMEOUT_SECONDS, default 5 minutes, maximum 10 minutes). A timeout or cancellation
19
+ * is reported as "not verified", never as a pass. It never runs in the background, where it would race the agent's next edits.
20
+ * It is not invoked at every prompt boundary: use affected-project checks while iterating, then run the
21
+ * repository's full CI-equivalent checks before declaring completion or pushing/opening a PR. The explicit tool
22
+ * selects gates for all current working-tree changes; it is not a substitute for the full CI gate.
13
23
  *
14
24
  * Nothing here duplicates corpus content: it is adapter machinery, the Pi peer of the Claude
15
25
  * `hooks` block in `.claude/settings.json`. Every environment escape hatch the scripts honor
@@ -25,7 +35,27 @@ import { spawn } from "node:child_process";
25
35
  import * as fs from "node:fs";
26
36
  import * as path from "node:path";
27
37
  import { fileURLToPath } from "node:url";
28
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
39
+ import { Type } from "typebox";
40
+ import {
41
+ gateLogDirectory,
42
+ gateTimeoutSeconds,
43
+ latestGateLog,
44
+ MAX_GATE_TIMEOUT_SECONDS,
45
+ MIN_GATE_TIMEOUT_SECONDS,
46
+ parseGatePlan,
47
+ PLAN_TIMEOUT_MS,
48
+ FINGERPRINT_TIMEOUT_MS,
49
+ runBounded,
50
+ tailLines,
51
+ workingTreeFingerprint,
52
+ } from "./quality-gate.ts";
53
+
54
+ export const QUALITY_GATE_TOOL_NAME = "cratis_quality_gate";
55
+ const HEARTBEAT_MS = 10_000;
56
+ const TAIL_LINES = 60;
57
+ // runBounded escalates after 5s and settles within another 2s if a child holds its pipes.
58
+ const SHUTDOWN_CLEANUP_MS = 8_000;
29
59
 
30
60
  const extensionPath = fileURLToPath(import.meta.url);
31
61
  const bundledCorpusRoot = path.resolve(path.dirname(extensionPath), "..", "..", "..", "..");
@@ -149,11 +179,21 @@ export default function (pi: ExtensionAPI) {
149
179
  const patternScan = path.join(scriptsDir, "cratis-pattern-scan.sh");
150
180
  const qualityGate = path.join(scriptsDir, "cratis-quality-gate.sh");
151
181
 
152
- // Mirrors Claude's `stop_hook_active`: true only while the model is continuing because the
153
- // gate already blocked once this user turn, so the gate never blocks twice in a row (no loop).
154
- let gateActive = false;
155
- pi.on("input", async (event) => {
156
- if (event.source !== "extension") gateActive = false; // a fresh user turn resets the guard
182
+ // The explicit gate owns its subprocesses; shutdown also cancels a still-running tool.
183
+ const activeGateRuns = new Map<AbortController, Promise<void>>();
184
+ pi.on("session_shutdown", async () => {
185
+ const runs = [...activeGateRuns];
186
+ for (const [controller] of runs) controller.abort();
187
+ // Pi exits when shutdown handlers return. Let cancelled runs escalate to SIGKILL first.
188
+ let timer: NodeJS.Timeout | undefined;
189
+ try {
190
+ await Promise.race([
191
+ Promise.allSettled(runs.map(([, cleanup]) => cleanup)),
192
+ new Promise<void>((resolve) => { timer = setTimeout(resolve, SHUTDOWN_CLEANUP_MS); }),
193
+ ]);
194
+ } finally {
195
+ if (timer) clearTimeout(timer);
196
+ }
157
197
  });
158
198
 
159
199
  // ── PreToolUse → guard writes and store-mutating cratis commands (blocking) ──
@@ -214,31 +254,149 @@ export default function (pi: ExtensionAPI) {
214
254
  return { content: [...existing, { type: "text", text: `\n\n[cratis-hooks]\n${reminder.trim()}` }] };
215
255
  });
216
256
 
217
- // ── Stop → quality gate (re-runs the gates the change touched; keeps the model going on failure) ──
218
- pi.on("agent_settled", async (_event, ctx) => {
219
- if (ctx.mode === "print" || ctx.mode === "json") return;
220
- if (!isInstalled(qualityGate)) return;
221
- const payload = JSON.stringify({
222
- session_id: ctx.sessionManager.getSessionId?.() ?? "nosession",
223
- stop_hook_active: gateActive,
257
+ // The quality gate is only run by its explicit tool, never by a turn-end handler.
258
+ const sessionIdOf = (ctx: ExtensionContext) => ctx.sessionManager.getSessionId?.() ?? "nosession";
259
+ const gatePayload = (sessionId: string) => JSON.stringify({ session_id: sessionId, stop_hook_active: false });
260
+
261
+ /** The gates the current changes would run, from the script's own dry run. Bounded; never runs a gate. */
262
+ async function planGates(ctx: ExtensionContext, root: string, signal?: AbortSignal): Promise<{ gates: string[]; problem?: string }> {
263
+ const run = await runBounded("bash", [qualityGate], {
264
+ cwd: ctx.cwd,
265
+ stdin: gatePayload(sessionIdOf(ctx)),
266
+ timeoutMs: PLAN_TIMEOUT_MS,
267
+ signal,
268
+ env: { ...process.env, CLAUDE_PROJECT_DIR: root, CRATIS_HOOKS_GATE_DRYRUN: "1" },
224
269
  });
225
- const run = await runScript(qualityGate, payload, ctx.cwd);
226
-
227
- // A gate that cannot run has not passed. Say so rather than letting the turn end quietly,
228
- // but only once per user turn, on the same re-entry guard a real gate failure uses.
229
- if (run.failed && !gateActive) {
230
- gateActive = true;
231
- pi.sendUserMessage(
232
- `The Cratis quality gate is installed at ${qualityGate} but could not be run, so nothing was verified this turn.` +
233
- `${run.stderr.trim() ? `\n\n${run.stderr.trim()}` : ""}`,
234
- { deliverAs: "followUp" },
235
- );
236
- return;
237
- }
238
- if (run.code === 2 && !gateActive) {
239
- gateActive = true; // don't block again until the next user turn resets it
240
- const message = run.stderr.trim() || "A Cratis quality gate failed. Fix it and re-run the gate.";
241
- pi.sendUserMessage(message, { deliverAs: "followUp" });
242
- }
270
+ const gates = parseGatePlan(run.stderr);
271
+ if (run.aborted) return { gates: [], problem: "its dry run was cancelled" };
272
+ if (run.timedOut) return { gates, problem: `its dry run did not finish within ${PLAN_TIMEOUT_MS / 1000}s` };
273
+ if (run.failed || run.code !== 0) return { gates, problem: `its dry run could not be completed (exit ${run.code ?? "none"})` };
274
+ return { gates };
275
+ }
276
+
277
+ // ── The quality gate itself: explicit, foreground, bounded, cancellable ──
278
+ pi.registerTool({
279
+ name: QUALITY_GATE_TOOL_NAME,
280
+ label: "Cratis quality gate",
281
+ description:
282
+ "Run the Cratis quality gate (.cratis/ai/hooks/scripts/cratis-quality-gate.sh): the build, spec and lint gates the current " +
283
+ "working-tree changes touch, configured in quality-gates.json. Runs in the foreground with a deadline and can be cancelled. " +
284
+ "A failure, a timeout or a cancellation is returned as an error and means the change is NOT verified. " +
285
+ "Run it explicitly at a verification checkpoint, not after every prompt. It selects gates for all current " +
286
+ "working-tree changes, not the complete CI matrix; run CI-equivalent gates before claiming completion or pushing/opening a PR. " +
287
+ "Tracked-file stability is Git-visible, not byte-exact: lossy clean/EOL conversion and assume-unchanged/skip-worktree " +
288
+ "can hide raw disk changes. Verify raw content independently or remove those settings before relying on this check.",
289
+ promptSnippet: "Run the Cratis quality gate for the current changes (bounded, cancellable)",
290
+ parameters: Type.Object({
291
+ timeoutSeconds: Type.Optional(
292
+ Type.Integer({
293
+ minimum: MIN_GATE_TIMEOUT_SECONDS,
294
+ maximum: MAX_GATE_TIMEOUT_SECONDS,
295
+ description:
296
+ "Stop the gate after this many seconds (default 300; maximum 600). Raise it only for a concrete reason within the authorized execution budget.",
297
+ }),
298
+ ),
299
+ }),
300
+ executionMode: "sequential",
301
+
302
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
303
+ if (!isInstalled(qualityGate)) {
304
+ return { content: [{ type: "text", text: `No Cratis quality gate is installed (${qualityGate}), so nothing was checked.` }], details: { status: "not-installed" } };
305
+ }
306
+ if (process.env.CRATIS_HOOKS_SKIP_GATE === "1") {
307
+ return { content: [{ type: "text", text: "CRATIS_HOOKS_SKIP_GATE=1 is set, so the Cratis quality gate was skipped and nothing was verified." }], details: { status: "skipped" } };
308
+ }
309
+ if (process.env.CRATIS_HOOKS_GATE_DRYRUN === "1") {
310
+ return { content: [{ type: "text", text: "CRATIS_HOOKS_GATE_DRYRUN=1 is set: this is a dry run, NOT VERIFIED. No gates were executed." }], details: { status: "dry-run" } };
311
+ }
312
+
313
+ const timeoutSeconds = gateTimeoutSeconds(params.timeoutSeconds);
314
+ const sessionId = sessionIdOf(ctx);
315
+ const started = Date.now();
316
+ const deadline = started + timeoutSeconds * 1000;
317
+ const cancel = new AbortController();
318
+ const expired = new AbortController();
319
+ const deadlineTimer = setTimeout(() => expired.abort(), timeoutSeconds * 1000);
320
+ const operationSignal = AbortSignal.any([...(signal ? [signal] : []), cancel.signal, expired.signal]);
321
+ let completed!: () => void;
322
+ const cleanup = new Promise<void>((resolve) => { completed = resolve; });
323
+ activeGateRuns.set(cancel, cleanup);
324
+ try {
325
+ const reportTimedOut = () => {
326
+ const last = latestGateLog(gateLogDirectory(sessionId), started);
327
+ const tail = last ? tailLines(last.file, TAIL_LINES) : "";
328
+ throw new Error(`The Cratis quality gate TIMED OUT after ${duration(timeoutSeconds * 1000)}${last ? ` while running '${last.gate}'` : ""}. This is not a pass: nothing was verified.` +
329
+ (last ? `\n\nGate log: ${last.file}` : "") +
330
+ (tail ? `\n\n--- last ${TAIL_LINES} lines ---\n${tail}\n--- end ---` : ""));
331
+ };
332
+ const reportInterrupted = () => {
333
+ if (expired.signal.aborted) reportTimedOut();
334
+ if (operationSignal.aborted) throw new Error("The Cratis quality gate was cancelled. Nothing was verified.");
335
+ };
336
+ const remaining = () => Math.max(1, deadline - Date.now());
337
+ const logDirectory = gateLogDirectory(sessionId);
338
+ onUpdate?.({ content: [{ type: "text", text: `Planning Cratis quality gate (${duration(timeoutSeconds * 1000)} deadline). Escape cancels.` }], details: { status: "planning", timeoutSeconds } });
339
+ reportInterrupted();
340
+ const before = await workingTreeFingerprint(ctx.cwd, Math.min(FINGERPRINT_TIMEOUT_MS, remaining()), operationSignal);
341
+ reportInterrupted();
342
+ if (before.kind === "not-repository") throw new Error("The Cratis quality gate is not in a Git repository; nothing was verified.");
343
+ if (before.kind === "unknown") throw new Error(`The working tree could not be checked before the Cratis quality gate${before.reason ? `: ${before.reason}` : ""}. Nothing was verified.`);
344
+ const plan = await planGates(ctx, before.root, operationSignal);
345
+ reportInterrupted();
346
+ if (plan.problem) throw new Error(`The Cratis quality gate plan could not be completed: ${plan.problem}. Nothing was verified.`);
347
+ const planned = plan.gates.length > 0 ? plan.gates.join(", ") : "none planned";
348
+ const progress = (elapsedMs: number) => {
349
+ const current = latestGateLog(logDirectory, started)?.gate;
350
+ onUpdate?.({
351
+ content: [
352
+ {
353
+ type: "text",
354
+ text: `Cratis quality gate running${current ? ` ${current}` : ""}: ${duration(elapsedMs)} of ${duration(timeoutSeconds * 1000)} (gates: ${planned}). Escape cancels.`,
355
+ },
356
+ ],
357
+ details: { status: "running", gates: plan.gates, current, elapsedMs, timeoutSeconds },
358
+ });
359
+ };
360
+ progress(Date.now() - started);
361
+
362
+ const run = await runBounded("bash", [qualityGate], {
363
+ cwd: ctx.cwd,
364
+ stdin: gatePayload(sessionId),
365
+ timeoutMs: remaining(),
366
+ signal: operationSignal,
367
+ env: { ...process.env, CLAUDE_PROJECT_DIR: before.root },
368
+ onHeartbeat: () => progress(Date.now() - started),
369
+ heartbeatMs: HEARTBEAT_MS,
370
+ });
371
+ // Cancellation never starts another Git process. Only a successful gate needs a post-run fingerprint.
372
+ if (run.aborted || operationSignal.aborted) reportInterrupted();
373
+ if (run.timedOut) reportTimedOut();
374
+ const notes = run.stderr.trim() ? `\n\n${run.stderr.trim()}` : "";
375
+ if (run.failed) {
376
+ throw new Error(`The Cratis quality gate is installed at ${qualityGate} but could not be run, so nothing was verified.${notes}`);
377
+ }
378
+ if (run.code === 2) throw new Error(run.stderr.trim() || "A Cratis quality gate failed. Fix it and re-run the gate.");
379
+ if (run.code !== 0) throw new Error(`The Cratis quality gate exited unexpectedly with code ${run.code ?? "none"}; treat the change as not verified.${notes}`);
380
+ const after = await workingTreeFingerprint(ctx.cwd, Math.min(FINGERPRINT_TIMEOUT_MS, remaining()), operationSignal);
381
+ reportInterrupted();
382
+ if (before.kind !== "ok" || after.kind !== "ok" || before.value !== after.value) {
383
+ throw new Error("The working tree changed while the Cratis quality gate ran (or could not be checked afterward). The gate tested an older tree; this tree is NOT VERIFIED. Re-run after changes settle.");
384
+ }
385
+ const summary =
386
+ plan.gates.length > 0
387
+ ? `The Cratis quality gate passed in ${duration(run.durationMs)} (gates: ${planned}).`
388
+ : "No Cratis quality gate was planned for the current changes; nothing was verified by this tool.";
389
+ return { content: [{ type: "text", text: `${summary}${notes}` }], details: { status: plan.gates.length > 0 ? "passed" : "no-applicable-gates", gates: plan.gates, durationMs: run.durationMs } };
390
+ } finally {
391
+ clearTimeout(deadlineTimer);
392
+ activeGateRuns.delete(cancel);
393
+ completed();
394
+ }
395
+ },
243
396
  });
244
397
  }
398
+
399
+ function duration(ms: number): string {
400
+ const seconds = Math.round(ms / 1000);
401
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
402
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Bounded, cancellable process execution for the Cratis quality gate in Pi.
3
+ *
4
+ * The gate builds and tests the repository, which can take many minutes and, when a test host
5
+ * hangs, never finish at all. Everything here exists so that a gate run is always bounded: it
6
+ * stops at a deadline or when its caller cancels, the whole process tree is stopped (not just the
7
+ * `bash` running the script), and the caller learns which of those happened rather than seeing a
8
+ * bare exit code. A gate that timed out or was cancelled verified nothing, and is reported as such.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { StringDecoder } from "node:string_decoder";
16
+
17
+ /** How long one explicit gate run may take before it is stopped and reported as timed out. */
18
+ export const DEFAULT_GATE_TIMEOUT_SECONDS = 300;
19
+ export const MIN_GATE_TIMEOUT_SECONDS = 1;
20
+ export const MAX_GATE_TIMEOUT_SECONDS = 600;
21
+ /** The dispatch plan is `git diff` plus `jq`; anything slower than this is itself a problem. */
22
+ export const PLAN_TIMEOUT_MS = 30_000;
23
+ export const FINGERPRINT_TIMEOUT_MS = 15_000;
24
+
25
+ const OUTPUT_CAP = 256 * 1024;
26
+ const KILL_GRACE_MS = 5_000;
27
+ const CLOSE_GRACE_MS = 2_000;
28
+
29
+ export interface BoundedRun {
30
+ /** Exit code, or null when the process was stopped by a signal or never started. */
31
+ code: number | null;
32
+ stdout: string;
33
+ /** True when stdout exceeded the retained output cap; never interpret a partial Git status. */
34
+ stdoutTruncated: boolean;
35
+ stderr: string;
36
+ /** Stopped because the deadline passed. */
37
+ timedOut: boolean;
38
+ /** Stopped because the caller's signal aborted. */
39
+ aborted: boolean;
40
+ /** Could not be started at all (or bash could not find the script: exit 127). */
41
+ failed: boolean;
42
+ durationMs: number;
43
+ }
44
+
45
+ export interface BoundedRunOptions {
46
+ cwd: string;
47
+ stdin?: string;
48
+ timeoutMs: number;
49
+ signal?: AbortSignal;
50
+ env?: NodeJS.ProcessEnv;
51
+ /** Receives raw stdout chunks, for callers that hash output instead of keeping it. */
52
+ onStdout?: (chunk: Buffer) => void;
53
+ /** Called every `heartbeatMs` while the process runs. */
54
+ onHeartbeat?: (elapsedMs: number) => void;
55
+ heartbeatMs?: number;
56
+ killGraceMs?: number;
57
+ }
58
+
59
+ function appendCapped(current: string, chunk: string): string {
60
+ const next = current + chunk;
61
+ return next.length > OUTPUT_CAP ? next.slice(next.length - OUTPUT_CAP) : next;
62
+ }
63
+
64
+ /**
65
+ * Run a command with a hard deadline. Never throws and always settles.
66
+ *
67
+ * On POSIX the command runs in its own process group, so stopping it also stops what it started —
68
+ * `dotnet test` and its test hosts, not only the `bash` that launched them. Stopping sends SIGTERM,
69
+ * then SIGKILL after a grace period, and the promise settles even if a descendant keeps a pipe open.
70
+ */
71
+ export function runBounded(command: string, args: string[], options: BoundedRunOptions): Promise<BoundedRun> {
72
+ const started = Date.now();
73
+ return new Promise<BoundedRun>((resolve) => {
74
+ const useGroup = process.platform !== "win32";
75
+ let proc: ReturnType<typeof spawn>;
76
+ try {
77
+ proc = spawn(command, args, {
78
+ cwd: options.cwd,
79
+ env: options.env ?? process.env,
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ detached: useGroup,
82
+ });
83
+ } catch (error) {
84
+ resolve({ code: null, stdout: "", stdoutTruncated: false, stderr: String(error), timedOut: false, aborted: false, failed: true, durationMs: 0 });
85
+ return;
86
+ }
87
+
88
+ let stdout = "";
89
+ let stdoutBytes = 0;
90
+ let stderr = "";
91
+ const stdoutDecoder = new StringDecoder("utf8");
92
+ const stderrDecoder = new StringDecoder("utf8");
93
+ let timedOut = false;
94
+ let aborted = false;
95
+ let exitCode: number | null = null;
96
+ let settled = false;
97
+ let stopping = false;
98
+ let closed = false;
99
+ const timers: NodeJS.Timeout[] = [];
100
+
101
+ const signalTree = (signal: NodeJS.Signals) => {
102
+ try {
103
+ if (useGroup && proc.pid !== undefined) process.kill(-proc.pid, signal);
104
+ else proc.kill(signal);
105
+ } catch {
106
+ try {
107
+ proc.kill(signal);
108
+ } catch {
109
+ /* already gone */
110
+ }
111
+ }
112
+ };
113
+ const stop = () => {
114
+ stopping = true;
115
+ signalTree("SIGTERM");
116
+ // The parent may close immediately while its children ignore TERM. Never finish before escalation.
117
+ timers.push(setTimeout(() => {
118
+ signalTree("SIGKILL");
119
+ if (closed) finish();
120
+ }, options.killGraceMs ?? KILL_GRACE_MS));
121
+ // Settle even if a descendant holds a pipe open after escalation.
122
+ timers.push(setTimeout(() => finish(), (options.killGraceMs ?? KILL_GRACE_MS) + CLOSE_GRACE_MS));
123
+ };
124
+ const onAbort = () => {
125
+ if (settled || timedOut || aborted) return;
126
+ aborted = true;
127
+ stop();
128
+ };
129
+ const finish = (failed = false) => {
130
+ if (settled) return;
131
+ settled = true;
132
+ stdout = appendCapped(stdout, stdoutDecoder.end());
133
+ stderr = appendCapped(stderr, stderrDecoder.end());
134
+ for (const timer of timers) clearTimeout(timer);
135
+ options.signal?.removeEventListener("abort", onAbort);
136
+ resolve({
137
+ code: exitCode,
138
+ stdout,
139
+ stdoutTruncated: stdoutBytes > OUTPUT_CAP,
140
+ stderr,
141
+ timedOut,
142
+ aborted,
143
+ failed: failed || exitCode === 127,
144
+ durationMs: Date.now() - started,
145
+ });
146
+ };
147
+
148
+ proc.stdout?.on("data", (chunk: Buffer) => {
149
+ options.onStdout?.(chunk);
150
+ stdoutBytes += chunk.length;
151
+ stdout = appendCapped(stdout, stdoutDecoder.write(chunk));
152
+ });
153
+ proc.stderr?.on("data", (chunk: Buffer) => (stderr = appendCapped(stderr, stderrDecoder.write(chunk))));
154
+ proc.on("error", (error) => {
155
+ stderr = appendCapped(stderr, String(error));
156
+ finish(true);
157
+ });
158
+ proc.on("exit", (code) => {
159
+ exitCode = code;
160
+ // A descendant that inherited stdout/stderr can keep 'close' from ever firing.
161
+ if (!stopping) timers.push(setTimeout(() => { if (!stopping) finish(); }, CLOSE_GRACE_MS));
162
+ });
163
+ proc.on("close", () => {
164
+ closed = true;
165
+ if (!stopping) finish();
166
+ });
167
+
168
+ timers.push(
169
+ setTimeout(() => {
170
+ if (settled || aborted) return;
171
+ timedOut = true;
172
+ stop();
173
+ }, options.timeoutMs),
174
+ );
175
+ if (options.onHeartbeat && options.heartbeatMs) {
176
+ const heartbeat = setInterval(() => {
177
+ if (!settled) options.onHeartbeat?.(Date.now() - started);
178
+ }, options.heartbeatMs);
179
+ timers.push(heartbeat);
180
+ }
181
+ if (options.signal) {
182
+ if (options.signal.aborted) onAbort();
183
+ else options.signal.addEventListener("abort", onAbort, { once: true });
184
+ }
185
+
186
+ proc.stdin?.on("error", () => {
187
+ /* the command may exit without reading stdin */
188
+ });
189
+ try {
190
+ proc.stdin?.end(options.stdin ?? "");
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ });
195
+ }
196
+
197
+ /** Resolve the timeout for one run: explicit request, then the environment, then the default — clamped. */
198
+ export function gateTimeoutSeconds(requested?: number, environment = process.env.CRATIS_HOOKS_GATE_TIMEOUT_SECONDS): number {
199
+ const fromEnvironment = environment !== undefined && environment.trim() !== "" ? Number(environment) : undefined;
200
+ const candidate = requested ?? fromEnvironment ?? DEFAULT_GATE_TIMEOUT_SECONDS;
201
+ if (!Number.isFinite(candidate)) return DEFAULT_GATE_TIMEOUT_SECONDS;
202
+ return Math.min(MAX_GATE_TIMEOUT_SECONDS, Math.max(MIN_GATE_TIMEOUT_SECONDS, Math.round(candidate)));
203
+ }
204
+
205
+ /** The gate ids a `CRATIS_HOOKS_GATE_DRYRUN=1` run says it would execute, in order. */
206
+ export function parseGatePlan(dryRunStderr: string): string[] {
207
+ return [...dryRunStderr.matchAll(/^cratis-quality-gate: RUN\s+(\S+)/gm)].map((match) => match[1]);
208
+ }
209
+
210
+ /** Where cratis-quality-gate.sh writes each gate's full log for a session (mirrors hook_state_dir in hook-lib.sh). */
211
+ export function gateLogDirectory(sessionId: string, environment = process.env): string {
212
+ const session = sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "nosession";
213
+ const base = (environment.TMPDIR || "/tmp").replace(/\/+$/, "");
214
+ return path.join(base, "cratis-hooks", session, "gate-logs");
215
+ }
216
+
217
+ /** The gate log written most recently since `sinceMs` — the gate that is running, or was when it stopped. */
218
+ export function latestGateLog(directory: string, sinceMs: number): { gate: string; file: string } | undefined {
219
+ let latest: { gate: string; file: string; mtime: number } | undefined;
220
+ try {
221
+ for (const name of fs.readdirSync(directory)) {
222
+ if (!name.endsWith(".log")) continue;
223
+ const file = path.join(directory, name);
224
+ const mtime = fs.statSync(file).mtimeMs;
225
+ if (mtime + 1000 < sinceMs) continue;
226
+ if (!latest || mtime > latest.mtime) latest = { gate: name.slice(0, -".log".length), file, mtime };
227
+ }
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ return latest && { gate: latest.gate, file: latest.file };
232
+ }
233
+
234
+ const MAX_LOG_TAIL_BYTES = 64 * 1024;
235
+
236
+ export function tailLines(file: string, lines: number): string {
237
+ if (lines <= 0) return "";
238
+ let descriptor: number | undefined;
239
+ try {
240
+ descriptor = fs.openSync(file, "r");
241
+ const size = fs.fstatSync(descriptor).size;
242
+ const chunks: Buffer[] = [];
243
+ let position = size;
244
+ let newlines = 0;
245
+ while (position > 0 && size - position < MAX_LOG_TAIL_BYTES && newlines <= lines) {
246
+ const length = Math.min(4096, position, MAX_LOG_TAIL_BYTES - (size - position));
247
+ const chunk = Buffer.allocUnsafe(length);
248
+ const read = fs.readSync(descriptor, chunk, 0, length, position - length);
249
+ if (!read) break;
250
+ position -= read;
251
+ const data = chunk.subarray(0, read);
252
+ chunks.unshift(data);
253
+ for (const byte of data) if (byte === 10) newlines++;
254
+ }
255
+ const text = Buffer.concat(chunks).toString("utf8");
256
+ const tail = text.split("\n").slice(-lines - 1).join("\n").trim();
257
+ return position > 0 && newlines <= lines ? `[earlier log output truncated]\n${tail}` : tail;
258
+ } catch {
259
+ return "";
260
+ } finally {
261
+ if (descriptor !== undefined) fs.closeSync(descriptor);
262
+ }
263
+ }
264
+
265
+ export type Fingerprint = { kind: "ok"; value: string; root: string } | { kind: "not-repository" } | { kind: "unknown"; reason?: string };
266
+
267
+ /** Digest the working tree; a Git failure or deadline is unknown, never proof that no gate applies. */
268
+ export async function workingTreeFingerprint(cwd: string, timeoutMs = FINGERPRINT_TIMEOUT_MS, signal?: AbortSignal): Promise<Fingerprint> {
269
+ const hash = createHash("sha256");
270
+ const deadline = Date.now() + timeoutMs;
271
+ // Read-only: never take the index lock, so this can never make the agent's own git commands fail.
272
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
273
+ const git = async (args: string[], onStdout?: (chunk: Buffer) => void) => {
274
+ if (signal?.aborted || Date.now() >= deadline) return undefined;
275
+ return runBounded("git", args, { cwd, timeoutMs: Math.max(1, deadline - Date.now()), env, signal, onStdout });
276
+ };
277
+ const root = await git(["rev-parse", "--show-toplevel"]);
278
+ if (!root || root.aborted || root.timedOut || root.failed) return { kind: "unknown" };
279
+ if (root.code !== 0) return root.stderr.includes("not a git repository (or any of the parent directories)") ? { kind: "not-repository" } : { kind: "unknown" };
280
+ const status = await git(["status", "--porcelain=v2", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
281
+ if (!status || status.code !== 0 || status.aborted || status.timedOut || status.failed || status.stdoutTruncated) return { kind: "unknown" };
282
+ const head = await git(["rev-parse", "--verify", "-q", "HEAD"]);
283
+ if (!head || head.aborted || head.timedOut || head.failed || (head.code !== 0 && head.code !== 1)) return { kind: "unknown" };
284
+ // Without an initial commit, porcelain status alone does not reflect changes to already-added files.
285
+ if (head.code === 1) return { kind: "unknown", reason: "HEAD has no initial commit" };
286
+ // In porcelain v2 the third field of a tracked entry is Git's submodule state (S<c><m><u>).
287
+ // A dirty gitlink's HEAD and diff can stay identical while its nested content changes.
288
+ // A rename (type 2) has an extra NUL-delimited old path; never parse it as another status record.
289
+ const untracked: string[] = [];
290
+ const entries = status.stdout.split("\0");
291
+ for (let index = 0; index < entries.length; index++) {
292
+ const entry = entries[index];
293
+ if (entry.startsWith("u ")) return { kind: "unknown", reason: "unmerged paths; resolve conflicts before verifying" };
294
+ if (entry.startsWith("? ")) untracked.push(entry.slice(2));
295
+ if (!entry.startsWith("1 ") && !entry.startsWith("2 ")) continue;
296
+ const submoduleState = entry.split(" ", 4)[2];
297
+ if (submoduleState?.startsWith("S") && (submoduleState[2] === "M" || submoduleState[3] === "U")) return { kind: "unknown", reason: "dirty tracked submodule" };
298
+ if (entry.startsWith("2 ")) index++;
299
+ }
300
+ hash.update(`head:${head.stdout.trim()}\0status:${status.stdout}\0`);
301
+ const diff = await git(["diff", "HEAD", "--no-relative", "--no-ext-diff", "--no-textconv", "--binary", "--ignore-submodules=none"], (chunk) => hash.update(chunk));
302
+ if (!diff || diff.code !== 0 || diff.aborted || diff.timedOut || diff.failed) return { kind: "unknown" };
303
+ const top = path.resolve(root.stdout.trim());
304
+ const sameFile = (left: fs.Stats, right: fs.Stats) =>
305
+ left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
306
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
307
+ for (const file of untracked) {
308
+ if (signal?.aborted || Date.now() >= deadline) return { kind: "unknown" };
309
+ const parts = file.split("/");
310
+ if (parts.some((part) => !part || part === "." || part === "..")) return { kind: "unknown", reason: `unsupported untracked entry ${JSON.stringify(file)}` };
311
+ const fullPath = path.resolve(top, file);
312
+ const relative = path.relative(top, fullPath);
313
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { kind: "unknown" };
314
+ try {
315
+ // Never traverse an untracked symlink directory to read a file outside this repository.
316
+ let directory = top;
317
+ for (const part of parts.slice(0, -1)) {
318
+ if (signal?.aborted || Date.now() >= deadline) return { kind: "unknown" };
319
+ directory = path.join(directory, part);
320
+ const parent = await fs.promises.lstat(directory);
321
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return { kind: "unknown" };
322
+ }
323
+ const before = await fs.promises.lstat(fullPath);
324
+ hash.update(`untracked:${file}:${before.mode}:${before.size}\0`);
325
+ if (before.isSymbolicLink()) {
326
+ // Hash the link text, not the bytes of its target (which may be outside the repository).
327
+ hash.update(`link:${await fs.promises.readlink(fullPath)}\0`);
328
+ } else if (before.isFile() && typeof fs.constants.O_NOFOLLOW === "number") {
329
+ // O_NOFOLLOW and O_NONBLOCK refuse a last-moment symlink or FIFO replacement.
330
+ const handle = await fs.promises.open(fullPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
331
+ try {
332
+ const opened = await handle.stat();
333
+ if (!opened.isFile() || !sameFile(before, opened)) return { kind: "unknown" };
334
+ const buffer = Buffer.allocUnsafe(64 * 1024);
335
+ let offset = 0;
336
+ while (offset < opened.size) {
337
+ if (signal?.aborted || Date.now() >= deadline) return { kind: "unknown" };
338
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
339
+ if (!bytesRead) return { kind: "unknown" };
340
+ hash.update(buffer.subarray(0, bytesRead));
341
+ offset += bytesRead;
342
+ }
343
+ // A file that grew during reading was not fully fingerprinted.
344
+ if ((await handle.stat()).size !== offset) return { kind: "unknown" };
345
+ } finally {
346
+ await handle.close();
347
+ }
348
+ hash.update("\0");
349
+ } else {
350
+ return { kind: "unknown" };
351
+ }
352
+ if (signal?.aborted || Date.now() >= deadline || !sameFile(before, await fs.promises.lstat(fullPath))) return { kind: "unknown" };
353
+ } catch {
354
+ return { kind: "unknown" };
355
+ }
356
+ }
357
+ return signal?.aborted || Date.now() >= deadline ? { kind: "unknown" } : { kind: "ok", value: hash.digest("hex"), root: top };
358
+ }
@@ -11,7 +11,8 @@ Three layers:
11
11
  | Pattern pass | `PostToolUse` on a write | `scripts/cratis-pattern-scan.sh` | zero tokens until a match | appends a one-line reminder to context, never blocks |
12
12
  | Hard block | `PreToolUse` on a write | `scripts/cratis-guard-writes.sh` | zero | exits **2** — the write does not happen |
13
13
  | Hard block | `PreToolUse` on `Bash` | `scripts/cratis-guard-store-mutations.sh` | zero; parses only commands that mention `cratis` | exits **2** — the store-changing `cratis chronicle` command does not run |
14
- | Quality gate | `Stop` | `scripts/cratis-quality-gate.sh` | one build/test run, only when relevant files changed | exits **2** — the turn does not end |
14
+ | Quality gate (Claude) | `Stop` | `scripts/cratis-quality-gate.sh` | one build/test run, only when relevant files changed | exits **2** — the turn does not end |
15
+ | Quality gate (Pi) | explicit `cratis_quality_gate` tool | same script | only at a requested verification checkpoint | error on failure; no turn-end hook |
15
16
 
16
17
  The Claude Code wiring that fires them is tracked here, in
17
18
  [`settings.template.json`](./settings.template.json). Claude reads `.claude/settings.json`. In a
@@ -36,6 +37,25 @@ they describe what a hook should do for tools that have no wiring yet.
36
37
  > extension under `../harnesses/pi/extensions/` (its `bash` tool feeds the store-mutation guard, its
37
38
  > `write` and `edit` tools the write guard); Copilot would read `.github/hooks/*.json`, and no
38
39
  > Copilot wiring ships yet.
40
+ >
41
+ > In Pi the quality gate is not run at the end of a turn. Pi waits for end-of-turn handlers before it
42
+ > starts the next prompt, so a gate run there froze the session for as long as the build and tests took.
43
+ > Instead the bridge registers a `cratis_quality_gate` tool that runs the same script in the foreground:
44
+ > it shows which gate is running, Escape cancels it, and it stops after
45
+ > `CRATIS_HOOKS_GATE_TIMEOUT_SECONDS` (default 300 seconds, capped at 600), including planning and working-tree checks.
46
+ > A timeout or a cancellation is reported as an error that says nothing was verified, never as a pass.
47
+ > An inherited dry-run setting cannot produce a verified result, and a passing gate checks that the
48
+ > working tree stayed unchanged in the content it can observe during the run. Pi does **not** invoke or request this tool
49
+ > at the end of each prompt: an edit or a read-only turn is not task completion. Run affected-project
50
+ > checks while iterating, and run the full CI-equivalent gates before claiming completion or
51
+ > pushing/opening a PR (Tier 1/2 where required). The explicit tool selects gates from **all** current
52
+ > working-tree changes, not just the last turn, and does not replace the repository's full CI matrix.
53
+ > The gate never runs in the background, where it would race the agent's next edits.
54
+
55
+ Tracked-file stability follows Git-visible contents, not byte-exact disk contents: lossy clean/EOL
56
+ conversion and Git's assume-unchanged or skip-worktree settings can hide raw disk changes. If you
57
+ use those settings, independently verify raw file contents or remove the hiding settings before
58
+ relying on this check.
39
59
 
40
60
  ## What is enforced
41
61
 
@@ -363,6 +383,7 @@ Each is an explicit, auditable opt-out — none of them is a default.
363
383
  | `CRATIS_HOOKS_SKIP_SCAN=1` | disables the pattern pass |
364
384
  | `CRATIS_HOOKS_SKIP_GATE=1` | disables the quality gate |
365
385
  | `CRATIS_HOOKS_GATE_DRYRUN=1` | prints which gates would run, and why, then exits 0 |
386
+ | `CRATIS_HOOKS_GATE_TIMEOUT_SECONDS=<n>` | Pi only: stops a `cratis_quality_gate` run after `n` seconds (default 300, maximum 600) and reports it as timed out |
366
387
  | `CRATIS_HOOKS_PATTERNS=<path>` | replaces the pattern file |
367
388
  | `CRATIS_HOOKS_GATES=<path>` | replaces the gate file (the project override still merges over it) |
368
389
  | `CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1` | allows `cratis chronicle` commands that change a live store; set by the person who authorized the mutation, in the environment the harness was started from |
@@ -94,7 +94,7 @@ the plugin marketplaces) are in [ai-distribution.md](./ai-distribution.md):
94
94
  A claim is only as good as the signal behind it — a build result, a test run, a lint pass, observed app behavior — not the model's own confidence. Internal reasoning *plans* the work; external signals *confirm* it.
95
95
 
96
96
  - **Confirm "done"/"fixed"/"correct" against a fresh signal — never self-assessment.** Run the relevant gate and observe it pass *this time*.
97
- - **After a fix, re-run the gate that failed.** Don't argue yourself to green.
97
+ - **Check at the right frequency.** Run affected-project checks while iterating; a prompt ending is not task completion. Before claiming a task complete or pushing/opening a PR, run the full CI-equivalent gates required for that repository (including its Tier 1/2 policy where applicable). A change-selected hook gate is not a substitute for the repository's full CI matrix. After a fix, re-run the gate that failed. Don't argue yourself to green.
98
98
  - **A green build is not behavioral correctness.** Compilation proves it builds, not that the slice does the right thing — that's what specs and exercising the UI are for.
99
99
  - **Report the conclusion and what you didn't verify, in a line or two.** Show the output when asked, when a claim is contested, or when the check failed — see [verification-discipline.md](./verification-discipline.md).
100
100
 
@@ -9,7 +9,10 @@ paths:
9
9
  Verification answers whether changed behavior works. It does not establish who
10
10
  authored a file or preserve a chain of evidence about how it arrived.
11
11
 
12
- - Run the narrowest relevant build, type check, lint, and specifications.
12
+ - Run the narrowest relevant build, type check, lint, and specifications while iterating; explain when a wider gate is required before completion or push/PR.
13
+ - Run one phase per command with its real exit status. Budget targeted checks around 120 seconds and build/test phases around 300 seconds; explain any longer budget first, and obtain explicit approval before a phase exceeds 600 seconds. Stop on a failure; do not blindly retry or repeat passing tests on unchanged inputs without a flaky-test hypothesis or required matrix.
14
+ - Separate status/fetch from builds, Debug from Release, each test project, and frontend lint/compile/test/build. Do not combine these into an opaque command with a large outer timeout. Expose progress and cancellation; report the precise failed or timed-out phase and do not continue dependent phases after failure.
15
+ - Never use `build/test | grep | sort/head/tail` as gate evidence. Capture the producer's exit status before summarising output; `pipefail` alone is insufficient with early-closing consumers such as `head`. Printed zero-error counts are not proof of success. Preserve needed full diagnostics in task-scoped `.ai-work/` logs rather than hiding them in `/tmp`.
13
16
  - Add a focused specification for every behavior or rejection rule you change.
14
17
  - Keep each check deterministic and runnable locally and in CI.
15
18
  - Never replace a real behavior check with a checksum, inventory, generated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.1.0",
3
+ "version": "7.1.2",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [