@mjasnikovs/pi-task 0.17.26 → 0.18.0

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.
@@ -0,0 +1,91 @@
1
+ /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
2
+ * failing command (and the project's own tooling), not to mutate git state. */
3
+ export declare const FINAL_FIX_TOOLS = "read,edit,bash";
4
+ /**
5
+ * How many fix passes the user may launch from the picker before the option is
6
+ * withdrawn (mirrors the per-task MAX_AUTO_AUTOFIX budget). Each attempt is a
7
+ * full model child plus a full gate re-run — after this many that still FAIL,
8
+ * only Leave-failed / Accept remain so a person breaks the loop.
9
+ */
10
+ export declare const MAX_FINAL_GATE_AUTOFIX = 3;
11
+ /** Picker labels/values. Classification accepts the value token, the label, or
12
+ * free text (→ autofix guidance), same contract as the per-task resolution. */
13
+ export declare const FINAL_LEAVE_VALUE = "fail";
14
+ export declare const FINAL_ACCEPT_VALUE = "accept";
15
+ export declare const FINAL_AUTOFIX_VALUE = "autofix";
16
+ export declare const FINAL_LEAVE_LABEL = "Leave failed \u2014 I will fix and /task-auto-resume";
17
+ export declare const FINAL_ACCEPT_LABEL = "Accept \u2014 complete the run anyway";
18
+ export declare const FINAL_AUTOFIX_LABEL = "Autofix \u2014 run a bounded fix pass and re-run the gate";
19
+ export interface FinalGateChoice {
20
+ /** What the user decided. 'leave' = leave the run failed (also the dismissal
21
+ * default — identical to the pre-autofix behavior). */
22
+ action: 'leave' | 'accept' | 'autofix';
23
+ /** Free-text guidance typed instead of picking a card; folded into the fix
24
+ * child's failure seed. Only set with 'autofix'. */
25
+ guidance?: string;
26
+ }
27
+ /**
28
+ * Map a final-gate picker answer to an action. Dismissal (undefined/empty) and
29
+ * an explicit leave stay "leave" — exactly what the two-option picker did.
30
+ * Free text becomes autofix guidance, mirroring classifyResolutionAnswer; the
31
+ * caller demotes autofix back to "leave" when the option was not offered.
32
+ */
33
+ export declare function classifyFinalGateAnswer(answer: string | undefined): FinalGateChoice;
34
+ /**
35
+ * The gate's fail reason always carries the failing command in backticks
36
+ * (`` `bun run test` exited 1 — … `` / ``static checks: `make lint` exited 2``).
37
+ * Extract it for reporting; the shrink guard itself compares the FULL
38
+ * discovered-command sets, so a reason this cannot parse still guards.
39
+ */
40
+ export declare function extractFailingCommand(reason: string): string | null;
41
+ /**
42
+ * Build the fix child's prompt. Generic by construction: the only project facts
43
+ * in it are the gate's own failure text — the command comes from the project's
44
+ * discovered manifest, never from a hardcoded ecosystem.
45
+ */
46
+ export declare function buildFinalFixPrompt(failReason: string): string;
47
+ /**
48
+ * Parse the child's final marker. Last match wins (the model reasons before
49
+ * concluding and bash output can echo the words). No marker → treated as DONE:
50
+ * the gate re-run is the arbiter either way, so a missing marker only skips the
51
+ * early-out on a self-declared BLOCKED.
52
+ */
53
+ export declare function parseFinalFixMarker(text: string): {
54
+ blocked: boolean;
55
+ note?: string;
56
+ };
57
+ export interface FinalFixResult {
58
+ /** true → the fix child ran AND the re-run gate passed. */
59
+ ok: boolean;
60
+ /** Human-readable outcome (converged gate reason, or why the attempt failed). */
61
+ reason: string;
62
+ /** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
63
+ * picker (and next fix attempt) works from the current state, not the stale one. */
64
+ gateReason?: string;
65
+ }
66
+ export interface FinalFixDeps {
67
+ cwd: string;
68
+ signal?: AbortSignal;
69
+ /** The gate's FAIL reason (command + exit code + output tail), plus any
70
+ * user-typed guidance the caller folded in. */
71
+ failReason: string;
72
+ /** Run the fix child; same closure shape the other gate children use. */
73
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
74
+ /** Re-run the final integration gate — the only arbiter of convergence. */
75
+ gate: (cwd: string) => Promise<{
76
+ ok: boolean;
77
+ reason: string;
78
+ }>;
79
+ /** Labels of every currently-discoverable gate command (static + integration),
80
+ * for the shrink guard. Pure discovery — nothing is executed. */
81
+ discoverLabels: (cwd: string) => string[];
82
+ /** Discard the fix child's working-tree edits (shrink-guard trip only). Absent
83
+ * → the violation is still rejected, edits are left for inspection. */
84
+ discard?: (cwd: string) => Promise<void>;
85
+ }
86
+ /**
87
+ * Run one bounded final-gate fix attempt: snapshot discovery → child → shrink
88
+ * guard → gate re-run. Never throws for an outcome; only a user cancel inside
89
+ * runChild propagates (the caller's USER_CANCELLED path handles it).
90
+ */
91
+ export declare function runFinalGateAutofix(deps: FinalFixDeps): Promise<FinalFixResult>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * final-gate-fix — the bounded, model-driven fix pass offered when the FINAL
3
+ * integration gate fails.
4
+ *
5
+ * The failure this closes (mx5 run 7, validated): the final gate's first live
6
+ * firing was a TRUE POSITIVE — the project's own `test` command genuinely failed
7
+ * whole-repo while every per-slice check was green — but the resolution picker
8
+ * offered only "Leave failed" / "Accept". There was NO automated path to fix a
9
+ * defect the run itself shipped; the user had to leave the run failed, fix by
10
+ * hand, and resume.
11
+ *
12
+ * This mirrors the per-task graduated-resolution shape (lint-fix.ts): a bounded
13
+ * write-enabled child (read, edit, bash) is seeded with the gate's exact failure
14
+ * (command + exit code + output tail), fixes the defect in place, and the gate
15
+ * itself is re-run as the only arbiter — the child's self-report is never
16
+ * trusted. The user always chooses this path from the picker ("Leave failed"
17
+ * stays the recommended default), and attempts are capped so a non-converging
18
+ * loop hands control back to a person.
19
+ *
20
+ * CHEAT GUARD (deterministic): the cheapest way for a fix child to "converge" is
21
+ * to remove the failing command itself — delete the `test` script, drop the
22
+ * Makefile target — so the gate discovers nothing and vacuously passes. Both
23
+ * live lint-fix validation runs cheated exactly this way (via git checkout)
24
+ * until a guard existed. So the discovered gate commands are snapshotted before
25
+ * the child runs; any previously-discovered command that is no longer
26
+ * discoverable afterwards rejects the attempt and discards its edits. A fix may
27
+ * change what a command DOES, never make it disappear.
28
+ */
29
+ import { USER_CANCELLED } from './child-runner.js';
30
+ /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
31
+ * failing command (and the project's own tooling), not to mutate git state. */
32
+ export const FINAL_FIX_TOOLS = 'read,edit,bash';
33
+ /**
34
+ * How many fix passes the user may launch from the picker before the option is
35
+ * withdrawn (mirrors the per-task MAX_AUTO_AUTOFIX budget). Each attempt is a
36
+ * full model child plus a full gate re-run — after this many that still FAIL,
37
+ * only Leave-failed / Accept remain so a person breaks the loop.
38
+ */
39
+ export const MAX_FINAL_GATE_AUTOFIX = 3;
40
+ /** Picker labels/values. Classification accepts the value token, the label, or
41
+ * free text (→ autofix guidance), same contract as the per-task resolution. */
42
+ export const FINAL_LEAVE_VALUE = 'fail';
43
+ export const FINAL_ACCEPT_VALUE = 'accept';
44
+ export const FINAL_AUTOFIX_VALUE = 'autofix';
45
+ export const FINAL_LEAVE_LABEL = 'Leave failed — I will fix and /task-auto-resume';
46
+ export const FINAL_ACCEPT_LABEL = 'Accept — complete the run anyway';
47
+ export const FINAL_AUTOFIX_LABEL = 'Autofix — run a bounded fix pass and re-run the gate';
48
+ /**
49
+ * Map a final-gate picker answer to an action. Dismissal (undefined/empty) and
50
+ * an explicit leave stay "leave" — exactly what the two-option picker did.
51
+ * Free text becomes autofix guidance, mirroring classifyResolutionAnswer; the
52
+ * caller demotes autofix back to "leave" when the option was not offered.
53
+ */
54
+ export function classifyFinalGateAnswer(answer) {
55
+ if (answer === undefined)
56
+ return { action: 'leave' };
57
+ const t = answer.trim();
58
+ if (t.length === 0)
59
+ return { action: 'leave' };
60
+ if (t === FINAL_LEAVE_VALUE || /^leave\b/i.test(t))
61
+ return { action: 'leave' };
62
+ if (t === FINAL_ACCEPT_VALUE || /^accept\b/i.test(t))
63
+ return { action: 'accept' };
64
+ if (t === FINAL_AUTOFIX_VALUE || /^autofix\b/i.test(t))
65
+ return { action: 'autofix' };
66
+ return { action: 'autofix', guidance: t };
67
+ }
68
+ /**
69
+ * The gate's fail reason always carries the failing command in backticks
70
+ * (`` `bun run test` exited 1 — … `` / ``static checks: `make lint` exited 2``).
71
+ * Extract it for reporting; the shrink guard itself compares the FULL
72
+ * discovered-command sets, so a reason this cannot parse still guards.
73
+ */
74
+ export function extractFailingCommand(reason) {
75
+ const m = /`([^`]+)`\s+exited\b/.exec(reason);
76
+ return m ? m[1] : null;
77
+ }
78
+ /**
79
+ * Build the fix child's prompt. Generic by construction: the only project facts
80
+ * in it are the gate's own failure text — the command comes from the project's
81
+ * discovered manifest, never from a hardcoded ecosystem.
82
+ */
83
+ export function buildFinalFixPrompt(failReason) {
84
+ return [
85
+ 'You are a bounded fix pass for a FAILED whole-repo integration gate.',
86
+ 'Every task in this run is complete and committed; then the project’s own',
87
+ 'integration command was run against the assembled repository and failed:',
88
+ '',
89
+ failReason.trim(),
90
+ '',
91
+ 'Your ONLY job is to make that command pass by fixing the DEFECT it reveals.',
92
+ '',
93
+ '1. Re-run the exact failing command first and read its full output.',
94
+ '2. Diagnose the root cause, then fix it with the smallest correct change.',
95
+ ' The project’s own manifests, configs and conventions define what',
96
+ ' correct means — follow them, do not invent new structure.',
97
+ '',
98
+ '3. HARD CONSTRAINTS:',
99
+ ' - Do NOT delete, skip, disable, or weaken tests or checks to make the',
100
+ ' command pass. Relocating or scoping a file the runner was never meant',
101
+ ' to pick up (per the project’s own config) is a legitimate fix;',
102
+ ' deleting it or marking it skipped is not.',
103
+ ' - Do NOT remove or rename the project’s own commands (its test/build/',
104
+ ' lint scripts or targets). Making the gate unable to find the command',
105
+ ' is detected and the whole fix is rejected.',
106
+ ' - Do NOT run git commands that mutate state (checkout, restore, reset,',
107
+ ' revert, stash, clean). The work in this repository is finished and',
108
+ ' committed — reverting it is destroying the run, not fixing it.',
109
+ '',
110
+ '4. Re-run the failing command after your fix and confirm it exits 0. The',
111
+ ' gate is re-run mechanically after you finish — your claim is not the',
112
+ ' verdict, the real exit code is.',
113
+ '',
114
+ 'End with exactly one line:',
115
+ ' FINAL-GATE-FIX: DONE',
116
+ ' FINAL-GATE-FIX: BLOCKED <why you could not fix it>'
117
+ ].join('\n');
118
+ }
119
+ /**
120
+ * Parse the child's final marker. Last match wins (the model reasons before
121
+ * concluding and bash output can echo the words). No marker → treated as DONE:
122
+ * the gate re-run is the arbiter either way, so a missing marker only skips the
123
+ * early-out on a self-declared BLOCKED.
124
+ */
125
+ export function parseFinalFixMarker(text) {
126
+ const re = /FINAL-GATE-FIX:\s*(DONE|BLOCKED)\b[ \t]*(.*)/gi;
127
+ let last = null;
128
+ for (let m = re.exec(text); m !== null; m = re.exec(text))
129
+ last = m;
130
+ if (!last)
131
+ return { blocked: false };
132
+ if (last[1].toUpperCase() === 'BLOCKED') {
133
+ return { blocked: true, note: last[2].trim() || 'no reason given' };
134
+ }
135
+ return { blocked: false, note: last[2].trim() || undefined };
136
+ }
137
+ /**
138
+ * Run one bounded final-gate fix attempt: snapshot discovery → child → shrink
139
+ * guard → gate re-run. Never throws for an outcome; only a user cancel inside
140
+ * runChild propagates (the caller's USER_CANCELLED path handles it).
141
+ */
142
+ export async function runFinalGateAutofix(deps) {
143
+ const before = deps.discoverLabels(deps.cwd);
144
+ let text;
145
+ try {
146
+ text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
147
+ }
148
+ catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err);
150
+ if (msg === USER_CANCELLED)
151
+ throw err;
152
+ return { ok: false, reason: `fix child failed: ${msg}` };
153
+ }
154
+ // SHRINK GUARD: every gate command discoverable before the fix must still be
155
+ // discoverable after it. A vanished command means the child "fixed" the gate
156
+ // by removing the check — reject and (when possible) discard the edits.
157
+ const after = new Set(deps.discoverLabels(deps.cwd));
158
+ const vanished = before.filter(label => !after.has(label));
159
+ if (vanished.length > 0) {
160
+ if (deps.discard)
161
+ await deps.discard(deps.cwd);
162
+ return {
163
+ ok: false,
164
+ reason: `fix pass removed the gate's own command(s) (${vanished.join(', ')}) — `
165
+ + `edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`
166
+ };
167
+ }
168
+ const marker = parseFinalFixMarker(text);
169
+ if (marker.blocked) {
170
+ // Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
171
+ return { ok: false, reason: `fix child blocked: ${marker.note}` };
172
+ }
173
+ const fin = await deps.gate(deps.cwd);
174
+ if (!fin.ok) {
175
+ return {
176
+ ok: false,
177
+ reason: `did not converge: ${fin.reason}`,
178
+ gateReason: fin.reason
179
+ };
180
+ }
181
+ return { ok: true, reason: fin.reason };
182
+ }
@@ -15,9 +15,45 @@ export declare function discoverIntegrationCommands(cwd: string): {
15
15
  ecosystem: string | null;
16
16
  cmds: HealthCommand[];
17
17
  };
18
+ /** Every lockfile consistency check that applies to this tree (possibly none). */
19
+ export declare function discoverLockfileChecks(cwd: string): HealthCommand[];
18
20
  /**
19
- * Run the final gate: static analysis first, then the discovered integration
20
- * commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
21
- * the hood (callers wrap in Promise.resolve). First real failure wins.
21
+ * The project's OWN launch command, if it declares one (package.json `start`,
22
+ * else `dev`; Makefile `run`). null means the project has nothing to boot —
23
+ * the boot check degrades to nothing-to-run.
22
24
  */
23
- export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number): FinalGateOutcome;
25
+ export declare function discoverBootCommand(cwd: string): HealthCommand | null;
26
+ type BootOutcome = {
27
+ outcome: 'skip' | 'pass';
28
+ } | {
29
+ outcome: 'fail';
30
+ detail: string;
31
+ };
32
+ /**
33
+ * Exercise the start command ONCE, with no port/URL/framework knowledge — the
34
+ * command's own fate within the grace window decides:
35
+ *
36
+ * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
37
+ * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
38
+ * - still alive when the window closes → PASS, then the whole process group is
39
+ * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
40
+ *
41
+ * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
42
+ */
43
+ export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number): Promise<BootOutcome>;
44
+ /**
45
+ * Labels (`bin args…`) of every command the gate CAN currently discover — the
46
+ * static half (repo-health) plus the integration half. Pure discovery, nothing
47
+ * runs. Used by the final-gate autofix shrink guard: a fix pass that makes a
48
+ * previously-discoverable command undiscoverable (deleted the script/target) is
49
+ * gaming the gate, not fixing the defect.
50
+ */
51
+ export declare function discoverGateCommandLabels(cwd: string): string[];
52
+ /**
53
+ * Run the final gate: static analysis first, then the lockfile consistency
54
+ * checks, then the discovered integration commands, then one boot exercise of
55
+ * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
56
+ * First real failure wins.
57
+ */
58
+ export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number): Promise<FinalGateOutcome>;
59
+ export {};
@@ -15,7 +15,18 @@
15
15
  * and lets their REAL exit codes decide:
16
16
  *
17
17
  * - static analysis first (runRepoHealthCheck — cheap, precise), then
18
- * - the project's own `test` and `build` commands, run verbatim and unaided.
18
+ * - lockfile↔manifest consistency (mx5 run 7, validated: the lockfile carried a
19
+ * dependency no committed manifest declared, so the tree tested green here
20
+ * but a FRESH CHECKOUT could not even install — each ecosystem's own offline
21
+ * "is the lock in sync" command decides), then
22
+ * - the project's own `test` and `build` commands, run verbatim and unaided, then
23
+ * - one boot exercise of the project's own start command (mx5 run 7, validated:
24
+ * every static and test gate green, yet `bun run start` died in ~1s on a
25
+ * self-inflicted EADDRINUSE — nothing had ever LAUNCHED the finished project).
26
+ * No ports, URLs, or framework knowledge: fast non-zero exit → FAIL, quick
27
+ * exit 0 → PASS (CLI-style), still alive after the grace window → PASS and
28
+ * the whole process group is killed (scripts spawn children; a leaked child
29
+ * server would mask every later boot check with a port collision).
19
30
  *
20
31
  * Environment-gap safety, same contract as repo-health-check: a command that
21
32
  * CANNOT run (ENOENT, exit 127 = command-not-found inside the script chain, or a
@@ -27,10 +38,10 @@
27
38
  * per-task gates kept excusing — and the caller puts a human on the decision
28
39
  * (accept / leave failed), so a genuine external gap can still be overridden.
29
40
  */
30
- import { spawnSync } from 'node:child_process';
41
+ import { spawn, spawnSync } from 'node:child_process';
31
42
  import { existsSync, readFileSync } from 'node:fs';
32
43
  import * as path from 'node:path';
33
- import { runRepoHealthCheck } from './repo-health-check.js';
44
+ import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
34
45
  function packageScripts(cwd) {
35
46
  try {
36
47
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -96,6 +107,141 @@ export function discoverIntegrationCommands(cwd) {
96
107
  }
97
108
  return { ecosystem: null, cmds: [] };
98
109
  }
110
+ /**
111
+ * Per-ecosystem lockfile↔manifest consistency checks. A check applies only when
112
+ * BOTH the manifest and its lockfile exist (no lockfile → nothing to verify),
113
+ * and every command is the ecosystem's own non-mutating "is the lock in sync
114
+ * with the manifest" form — validated to exit 0 fast on an in-sync tree and
115
+ * non-zero on a genuine desync, without touching the tree or (when in sync)
116
+ * the network.
117
+ */
118
+ const LOCKFILE_CHECKS = [
119
+ {
120
+ manifest: 'package.json',
121
+ lockfiles: ['bun.lock', 'bun.lockb'],
122
+ cmd: ['bun', ['install', '--frozen-lockfile', '--dry-run']]
123
+ },
124
+ {
125
+ manifest: 'package.json',
126
+ lockfiles: ['package-lock.json'],
127
+ cmd: ['npm', ['ci', '--dry-run']]
128
+ },
129
+ {
130
+ manifest: 'Cargo.toml',
131
+ lockfiles: ['Cargo.lock'],
132
+ cmd: ['cargo', ['metadata', '--locked', '--format-version', '1']]
133
+ },
134
+ { manifest: 'go.mod', lockfiles: ['go.sum'], cmd: ['go', ['mod', 'verify']] },
135
+ { manifest: 'pyproject.toml', lockfiles: ['uv.lock'], cmd: ['uv', ['lock', '--check']] },
136
+ { manifest: 'pyproject.toml', lockfiles: ['poetry.lock'], cmd: ['poetry', ['check', '--lock']] }
137
+ ];
138
+ /** Every lockfile consistency check that applies to this tree (possibly none). */
139
+ export function discoverLockfileChecks(cwd) {
140
+ const cmds = [];
141
+ for (const { manifest, lockfiles, cmd } of LOCKFILE_CHECKS) {
142
+ if (!existsSync(path.join(cwd, manifest)))
143
+ continue;
144
+ if (!lockfiles.some(f => existsSync(path.join(cwd, f))))
145
+ continue;
146
+ cmds.push(cmd);
147
+ }
148
+ return cmds;
149
+ }
150
+ /**
151
+ * The project's OWN launch command, if it declares one (package.json `start`,
152
+ * else `dev`; Makefile `run`). null means the project has nothing to boot —
153
+ * the boot check degrades to nothing-to-run.
154
+ */
155
+ export function discoverBootCommand(cwd) {
156
+ if (existsSync(path.join(cwd, 'package.json'))) {
157
+ const s = packageScripts(cwd);
158
+ for (const name of ['start', 'dev']) {
159
+ if (s[name])
160
+ return ['bun', ['run', name]];
161
+ }
162
+ return null;
163
+ }
164
+ if (existsSync(path.join(cwd, 'Makefile')) && makeHasTarget(cwd, 'run')) {
165
+ return ['make', ['run']];
166
+ }
167
+ return null;
168
+ }
169
+ /**
170
+ * Exercise the start command ONCE, with no port/URL/framework knowledge — the
171
+ * command's own fate within the grace window decides:
172
+ *
173
+ * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
174
+ * - exit 0 before the window closes → PASS (a CLI-style "run" that finished);
175
+ * - still alive when the window closes → PASS, then the whole process group is
176
+ * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
177
+ *
178
+ * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
179
+ */
180
+ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
181
+ return new Promise(resolve => {
182
+ const child = spawn(bin, args, {
183
+ cwd,
184
+ detached: true,
185
+ stdio: ['ignore', 'pipe', 'pipe'],
186
+ env: { ...process.env }
187
+ });
188
+ let out = '';
189
+ let err = '';
190
+ const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
191
+ child.stdout?.on('data', (d) => (out = cap(out + String(d))));
192
+ child.stderr?.on('data', (d) => (err = cap(err + String(d))));
193
+ let settled = false;
194
+ const settle = (r) => {
195
+ if (settled)
196
+ return;
197
+ settled = true;
198
+ clearTimeout(timer);
199
+ resolve(r);
200
+ };
201
+ const killGroup = (sig) => {
202
+ try {
203
+ if (child.pid)
204
+ process.kill(-child.pid, sig);
205
+ }
206
+ catch {
207
+ // group already gone
208
+ }
209
+ };
210
+ const timer = setTimeout(() => {
211
+ settle({ outcome: 'pass' });
212
+ killGroup('SIGTERM');
213
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
214
+ }, graceMs);
215
+ child.on('error', () => settle({ outcome: 'skip' }));
216
+ child.on('exit', (status, signal) => {
217
+ if (status === 0)
218
+ return settle({ outcome: 'pass' });
219
+ if (status === 127 || (status === null && signal === null)) {
220
+ return settle({ outcome: 'skip' });
221
+ }
222
+ const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
223
+ const tail = outputTail(out, err);
224
+ settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
225
+ });
226
+ });
227
+ }
228
+ /**
229
+ * Labels (`bin args…`) of every command the gate CAN currently discover — the
230
+ * static half (repo-health) plus the integration half. Pure discovery, nothing
231
+ * runs. Used by the final-gate autofix shrink guard: a fix pass that makes a
232
+ * previously-discoverable command undiscoverable (deleted the script/target) is
233
+ * gaming the gate, not fixing the defect.
234
+ */
235
+ export function discoverGateCommandLabels(cwd) {
236
+ const boot = discoverBootCommand(cwd);
237
+ const labels = [
238
+ ...discoverHealthCommands(cwd).cmds,
239
+ ...discoverLockfileChecks(cwd),
240
+ ...discoverIntegrationCommands(cwd).cmds,
241
+ ...(boot ? [boot] : [])
242
+ ].map(([bin, args]) => `${bin} ${args.join(' ')}`);
243
+ return [...new Set(labels)];
244
+ }
99
245
  /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
100
246
  function outputTail(stdout, stderr, limit = 400) {
101
247
  const combined = `${stdout}\n${stderr}`.trim();
@@ -105,34 +251,70 @@ function outputTail(stdout, stderr, limit = 400) {
105
251
  return combined.length > limit ? `…${tail}` : tail;
106
252
  }
107
253
  /**
108
- * Run the final gate: static analysis first, then the discovered integration
109
- * commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
110
- * the hood (callers wrap in Promise.resolve). First real failure wins.
254
+ * Run one gate command with the env-gap contract: tool missing, timeout, or
255
+ * command-not-found inside the script chain (127) → environment gap, not a code
256
+ * fault → skipped (same contract as repo-health). Only a command that actually
257
+ * ran and exited non-zero fails.
258
+ */
259
+ function runGateCommand(cwd, [bin, args], timeoutMs) {
260
+ // env passed explicitly: bun's spawnSync resolves the binary against a
261
+ // startup snapshot of the environment, not the live process.env.
262
+ const r = spawnSync(bin, args, {
263
+ cwd,
264
+ encoding: 'utf8',
265
+ timeout: timeoutMs,
266
+ env: { ...process.env }
267
+ });
268
+ if (r.error || r.status === null || r.status === 127)
269
+ return { outcome: 'skip' };
270
+ if (r.status !== 0) {
271
+ return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
272
+ }
273
+ return { outcome: 'pass' };
274
+ }
275
+ /**
276
+ * Run the final gate: static analysis first, then the lockfile consistency
277
+ * checks, then the discovered integration commands, then one boot exercise of
278
+ * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
279
+ * First real failure wins.
111
280
  */
112
- export function runFinalIntegrationGate(cwd, timeoutMs = 900_000) {
281
+ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000) {
113
282
  const stat = runRepoHealthCheck(cwd);
114
283
  if (!stat.ok)
115
284
  return { ok: false, reason: `static checks: ${stat.reason}` };
116
- const { ecosystem, cmds } = discoverIntegrationCommands(cwd);
117
- if (!ecosystem || cmds.length === 0) {
285
+ const lockCmds = discoverLockfileChecks(cwd);
286
+ const { cmds } = discoverIntegrationCommands(cwd);
287
+ const boot = discoverBootCommand(cwd);
288
+ if (lockCmds.length === 0 && cmds.length === 0 && !boot) {
118
289
  return { ok: true, reason: 'no integration command found (statics passed)' };
119
290
  }
120
291
  const ran = [];
121
- for (const [bin, args] of cmds) {
122
- const label = `${bin} ${args.join(' ')}`;
123
- const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
124
- // Tool missing, timeout, or command-not-found inside the script chain →
125
- // environment gap, not a code fault; skip (same contract as repo-health).
126
- if (r.error || r.status === null || r.status === 127)
127
- continue;
128
- if (r.status !== 0) {
129
- const tail = outputTail(r.stdout ?? '', r.stderr ?? '');
130
- return {
131
- ok: false,
132
- reason: `\`${label}\` exited ${r.status}${tail ? ` — ${tail}` : ''}`
133
- };
292
+ for (const { prefix, list } of [
293
+ { prefix: 'lockfile check: ', list: lockCmds },
294
+ { prefix: '', list: cmds }
295
+ ]) {
296
+ for (const cmd of list) {
297
+ const label = `${cmd[0]} ${cmd[1].join(' ')}`;
298
+ const r = runGateCommand(cwd, cmd, timeoutMs);
299
+ if (r.outcome === 'skip')
300
+ continue;
301
+ if (r.outcome === 'fail') {
302
+ return {
303
+ ok: false,
304
+ reason: `${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
305
+ };
306
+ }
307
+ ran.push(label);
308
+ }
309
+ }
310
+ if (boot) {
311
+ const label = `${boot[0]} ${boot[1].join(' ')}`;
312
+ const b = await runBootCheck(cwd, boot, bootGraceMs);
313
+ if (b.outcome === 'fail') {
314
+ return { ok: false, reason: `boot check: \`${label}\` ${b.detail}` };
134
315
  }
135
- ran.push(label);
316
+ if (b.outcome === 'pass')
317
+ ran.push(label);
136
318
  }
137
319
  return {
138
320
  ok: true,
@@ -1,8 +1,13 @@
1
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
1
2
  import type { GateDeps } from './task-gates.js';
3
+ import { type FinalFixResult } from './final-gate-fix.js';
2
4
  import { type ChangedFile } from './substitution-probe.js';
3
5
  /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
4
6
  * command so this module stays free of the orchestrators (avoids an import cycle). */
5
7
  export type RunTaskFn = GateDeps['runTask'];
8
+ /** One bounded final-gate fix attempt (see final-gate-fix.ts): fix child →
9
+ * shrink guard → gate re-run. Consumed by /task-auto's run-end gate branch. */
10
+ export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string) => Promise<FinalFixResult>;
6
11
  /**
7
12
  * Collect the task's changed files as pure GIT SHAPE — path + added-line count,
8
13
  * no content, no language parsing — for the self-verification probe. Before the
@@ -23,4 +28,6 @@ export declare function buildGateDeps(params: {
23
28
  signal: AbortSignal;
24
29
  parentContextWindow: number;
25
30
  runTask: RunTaskFn;
26
- }): GateDeps;
31
+ }): GateDeps & {
32
+ finalGateFix: FinalGateFixFn;
33
+ };