@mjasnikovs/pi-task 0.17.27 → 0.18.1
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/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 +42 -7
- package/dist/task/phases.js +62 -23
- package/dist/task/prohibition-probe.d.ts +53 -0
- package/dist/task/prohibition-probe.js +64 -0
- package/dist/task/prompts.d.ts +1 -1
- package/dist/task/prompts.js +8 -1
- package/dist/task/verify-work.d.ts +32 -2
- package/dist/task/verify-work.js +78 -3
- 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,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
|
+
};
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -19,8 +19,12 @@ import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
|
|
|
19
19
|
import { gitCommitAll, gitDropLastCommit, git } from './auto-commit.js';
|
|
20
20
|
import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
21
21
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
|
+
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
22
23
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
24
|
+
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
25
|
+
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
23
26
|
import { researchResolution } from './verify-resolution.js';
|
|
27
|
+
import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
|
|
24
28
|
import { findSubstitutionSuspects } from './substitution-probe.js';
|
|
25
29
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
26
30
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
@@ -88,6 +92,13 @@ export function buildGateDeps(params) {
|
|
|
88
92
|
// child run (see git-state-guard.ts). runWorkVerification reads this through its
|
|
89
93
|
// mutationCheck dep to discard a verdict computed on a mutated tree.
|
|
90
94
|
let lastGuardReconcile = null;
|
|
95
|
+
// Restore tracked files to HEAD and drop files a pass created; the .pi-tasks
|
|
96
|
+
// trail/log writes made during the pass survive both. Shared by the enforce
|
|
97
|
+
// pre-commit gate (discardEdits) and the final-gate autofix shrink guard.
|
|
98
|
+
const discardTreeEdits = async (cwd2) => {
|
|
99
|
+
await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
100
|
+
await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
|
|
101
|
+
};
|
|
91
102
|
// Shared runner for the per-task GATE children (verify + post-FAIL recommend).
|
|
92
103
|
// Both are read-only passes of the same local model that must run to completion:
|
|
93
104
|
// unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
|
|
@@ -280,12 +291,30 @@ export function buildGateDeps(params) {
|
|
|
280
291
|
// authored/changed become prompt-level findings mandating the child
|
|
281
292
|
// to drive the real artifact before trusting their green result.
|
|
282
293
|
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
|
|
294
|
+
// Deterministic prohibition probe: paths the spec forbids modifying
|
|
295
|
+
// that the task's diff modified anyway become prompt-level findings
|
|
296
|
+
// under the no-waiver rule — the child otherwise rarely runs `git
|
|
297
|
+
// diff` and cannot even see the violation.
|
|
298
|
+
prohibitionProbe: () => {
|
|
299
|
+
const banned = spec ? extractProhibitions(spec) : [];
|
|
300
|
+
if (banned.length === 0)
|
|
301
|
+
return Promise.resolve([]);
|
|
302
|
+
return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
|
|
303
|
+
},
|
|
283
304
|
// Git-state guard result of the most recent child run: a verdict
|
|
284
305
|
// computed on a tree the child itself mutated is discarded (the
|
|
285
306
|
// guard already restored the state — see git-state-guard.ts).
|
|
286
307
|
mutationCheck: () => lastGuardReconcile?.mutated ?
|
|
287
308
|
{ mutated: true, detail: lastGuardReconcile.actions.join('; ') }
|
|
288
|
-
: { mutated: false, detail: '' }
|
|
309
|
+
: { mutated: false, detail: '' },
|
|
310
|
+
// Per-run environment-facts cache under .pi-tasks/ (survives
|
|
311
|
+
// discardEdits): earlier children's discoveries save this child
|
|
312
|
+
// the re-archaeology; its own ENV-NOTE lines are stored for the
|
|
313
|
+
// next one. Facts only — verdict rules unaffected.
|
|
314
|
+
envNotes: {
|
|
315
|
+
read: () => readEnvNotes(cwd2),
|
|
316
|
+
append: notes => appendEnvNotes(cwd2, notes)
|
|
317
|
+
}
|
|
289
318
|
});
|
|
290
319
|
},
|
|
291
320
|
lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
|
|
@@ -305,12 +334,18 @@ export function buildGateDeps(params) {
|
|
|
305
334
|
const r = await git(cwd2, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
306
335
|
return r.exitCode === 0 && r.stdout.trim().length > 0;
|
|
307
336
|
},
|
|
308
|
-
discardEdits:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
337
|
+
discardEdits: discardTreeEdits,
|
|
338
|
+
finalGateFix: (fixCtx, cwd2, failReason) => runFinalGateAutofix({
|
|
339
|
+
cwd: cwd2,
|
|
340
|
+
signal,
|
|
341
|
+
failReason,
|
|
342
|
+
runChild: makeGateChild(fixCtx, cwd2, 'final integration gate', 'final-fix', 'final-gate-debug.log'),
|
|
343
|
+
// The gate re-run is the only arbiter of convergence, and the
|
|
344
|
+
// shrink guard's discovery is the gate's own (see final-gate.ts).
|
|
345
|
+
gate: c => runFinalIntegrationGate(c),
|
|
346
|
+
discoverLabels: discoverGateCommandLabels,
|
|
347
|
+
discard: discardTreeEdits
|
|
348
|
+
}),
|
|
314
349
|
recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
|
|
315
350
|
// Read the same composed spec the verify gate judged against, so the
|
|
316
351
|
// recommendation reasons over the real contract (degrade to the bare title).
|