@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.
- package/dist/config/config.d.ts +7 -0
- package/dist/config/config.js +2 -1
- package/dist/config/register.js +5 -0
- package/dist/remote/bridge.d.ts +8 -0
- package/dist/remote/bridge.js +3 -1
- package/dist/shared/child-process.d.ts +21 -0
- package/dist/shared/child-process.js +52 -4
- package/dist/shared/model-endpoint.d.ts +8 -0
- package/dist/shared/model-endpoint.js +56 -0
- package/dist/task/auto-orchestrator.d.ts +9 -0
- package/dist/task/auto-orchestrator.js +67 -16
- package/dist/task/child-runner.js +1 -1
- package/dist/task/enforce-guidelines.d.ts +1 -0
- package/dist/task/enforce-guidelines.js +6 -0
- package/dist/task/env-notes.d.ts +23 -0
- package/dist/task/env-notes.js +121 -0
- package/dist/task/final-gate-fix.d.ts +91 -0
- package/dist/task/final-gate-fix.js +182 -0
- package/dist/task/final-gate.d.ts +40 -4
- package/dist/task/final-gate.js +205 -23
- package/dist/task/gate-deps.d.ts +8 -1
- package/dist/task/gate-deps.js +31 -7
- package/dist/task/orchestrator.d.ts +3 -2
- package/dist/task/orchestrator.js +19 -2
- package/dist/task/phases.js +62 -23
- package/dist/task/prompts.d.ts +1 -1
- package/dist/task/prompts.js +8 -1
- package/dist/task/verify-work.d.ts +12 -1
- package/dist/task/verify-work.js +28 -2
- package/dist/task/widget.d.ts +3 -2
- package/dist/task/widget.js +4 -2
- package/dist/workers/pi-worker-core.d.ts +16 -0
- package/dist/workers/pi-worker-core.js +21 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
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
|
|
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 {};
|
package/dist/task/final-gate.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
117
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
316
|
+
if (b.outcome === 'pass')
|
|
317
|
+
ran.push(label);
|
|
136
318
|
}
|
|
137
319
|
return {
|
|
138
320
|
ok: true,
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -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
|
+
};
|