@mjasnikovs/pi-task 0.18.12 → 0.18.14
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 +17 -0
- package/dist/config/config.js +12 -1
- package/dist/config/extension-list.d.ts +40 -0
- package/dist/config/extension-list.js +66 -0
- package/dist/config/register.d.ts +10 -0
- package/dist/config/register.js +49 -10
- package/dist/shared/child-extensions.d.ts +28 -0
- package/dist/shared/child-extensions.js +47 -0
- package/dist/shared/child-process.d.ts +11 -0
- package/dist/shared/child-process.js +18 -0
- package/dist/task/accept-debt.d.ts +28 -9
- package/dist/task/accept-debt.js +0 -0
- package/dist/task/auto-orchestrator.d.ts +2 -2
- package/dist/task/auto-orchestrator.js +31 -7
- package/dist/task/child-runner.js +3 -2
- package/dist/task/final-gate.d.ts +32 -3
- package/dist/task/final-gate.js +181 -15
- package/dist/task/gate-deps.d.ts +6 -0
- package/dist/task/gate-deps.js +20 -1
- package/dist/task/git-state-guard.js +58 -4
- package/dist/task/launch-contract.d.ts +31 -0
- package/dist/task/launch-contract.js +162 -0
- package/dist/task/task-gates.d.ts +8 -0
- package/dist/task/task-gates.js +7 -0
- package/dist/workers/docs-core.js +4 -3
- package/dist/workers/fetch-core.js +4 -3
- package/dist/workers/pi-worker-core.d.ts +8 -1
- package/dist/workers/pi-worker-core.js +4 -3
- package/dist/workers/pi-worker-docs.js +5 -4
- package/package.json +1 -1
package/dist/task/final-gate.js
CHANGED
|
@@ -43,6 +43,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
43
43
|
import * as path from 'node:path';
|
|
44
44
|
import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
|
|
45
45
|
import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote } from './accept-debt.js';
|
|
46
|
+
import { readDeclaredScripts, missingDeclaredScripts } from './launch-contract.js';
|
|
46
47
|
function packageScripts(cwd) {
|
|
47
48
|
try {
|
|
48
49
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -71,10 +72,18 @@ export function discoverIntegrationCommands(cwd) {
|
|
|
71
72
|
if (existsSync(path.join(cwd, 'package.json'))) {
|
|
72
73
|
const s = packageScripts(cwd);
|
|
73
74
|
const cmds = [];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// Every test-shaped script, not just the one literally named `test` (mx5 run
|
|
76
|
+
// 10: `test:ct` — 89 Playwright component tests, the ONLY client-executing
|
|
77
|
+
// suite — never ran because the gate looked only for `test`/`build`). Plain
|
|
78
|
+
// `test` leads (richer, most common), then `test:*`/`test-*` in declaration
|
|
79
|
+
// order, then `build`. Env-gap SKIP still applies per command (a suite whose
|
|
80
|
+
// browser/runtime is absent skips, it does not fail — see runGateCommand).
|
|
81
|
+
const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
|
|
82
|
+
testNames.sort((a, b) => (a === 'test' ? -1 : b === 'test' ? 1 : 0));
|
|
83
|
+
for (const name of testNames)
|
|
84
|
+
cmds.push(['bun', ['run', name]]);
|
|
85
|
+
if (s.build)
|
|
86
|
+
cmds.push(['bun', ['run', 'build']]);
|
|
78
87
|
return { ecosystem: 'package.json', cmds };
|
|
79
88
|
}
|
|
80
89
|
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
@@ -183,6 +192,90 @@ function extractPort(text) {
|
|
|
183
192
|
const n = Number(m[1]);
|
|
184
193
|
return n > 0 && n < 65536 ? n : null;
|
|
185
194
|
}
|
|
195
|
+
/** Package deps that mean "this project stands up an HTTP server" — the deterministic
|
|
196
|
+
* proxy for "the plan/spec promised a served app". Bare framework names plus the
|
|
197
|
+
* scoped families whose presence implies a listener at runtime. */
|
|
198
|
+
function isServerFrameworkDep(name) {
|
|
199
|
+
return (/^(?:hono|express|fastify|koa|polka|restify|next|nuxt|http-server|serve|ws|socket\.io)$/.test(name) || /^@(?:hono|fastify|koa|nestjs|sveltejs|remix-run)\//.test(name));
|
|
200
|
+
}
|
|
201
|
+
/** Spec/plan phrasings that promise a listening server, for the text signal. */
|
|
202
|
+
const SERVE_TEXT_RE = /\b(?:https?\s+server|web\s+server|serves?\b|listen(?:s|ing)?\b|Bun\.serve|app\.listen|createServer|serve\s+(?:static|the)|\/api\/|endpoints?\b)/i;
|
|
203
|
+
/**
|
|
204
|
+
* Does the finished run stand up a listening HTTP server? Deterministic, from the
|
|
205
|
+
* built manifest (a server-framework dependency is the plan's own artifact) OR, when
|
|
206
|
+
* available, the plan/spec text. Used to decide whether the boot check must observe a
|
|
207
|
+
* LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
|
|
208
|
+
*/
|
|
209
|
+
export function detectsServedApp(cwd, planText) {
|
|
210
|
+
try {
|
|
211
|
+
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
212
|
+
const all = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
|
|
213
|
+
if (Object.keys(all).some(isServerFrameworkDep))
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// no/unreadable manifest → fall through to the text signal
|
|
218
|
+
}
|
|
219
|
+
return planText !== undefined && SERVE_TEXT_RE.test(planText);
|
|
220
|
+
}
|
|
221
|
+
/** Pids currently owning a LISTENing TCP socket (best-effort; ss first, then lsof).
|
|
222
|
+
* Empty on any failure — the caller then cannot attribute a listener to our group
|
|
223
|
+
* and the served-app check degrades to survival (never a false FAIL). */
|
|
224
|
+
function listeningSocketPids() {
|
|
225
|
+
const pids = new Set();
|
|
226
|
+
try {
|
|
227
|
+
const t = spawnSync('ss', ['-tlnpH'], { encoding: 'utf8', timeout: 4000 });
|
|
228
|
+
if (!t.error && t.stdout) {
|
|
229
|
+
for (const m of t.stdout.matchAll(/pid=(\d+)/g))
|
|
230
|
+
pids.add(Number(m[1]));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// ss missing — try lsof
|
|
235
|
+
}
|
|
236
|
+
if (pids.size === 0) {
|
|
237
|
+
try {
|
|
238
|
+
const t = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-t', '-n', '-P'], {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
timeout: 4000
|
|
241
|
+
});
|
|
242
|
+
if (!t.error && t.stdout) {
|
|
243
|
+
for (const line of t.stdout.split('\n')) {
|
|
244
|
+
const n = Number(line.trim());
|
|
245
|
+
if (Number.isInteger(n) && n > 0)
|
|
246
|
+
pids.add(n);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// neither tool available
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...pids];
|
|
255
|
+
}
|
|
256
|
+
/** Process-group id of `pid`, or null if it cannot be read. */
|
|
257
|
+
function pgidOf(pid) {
|
|
258
|
+
try {
|
|
259
|
+
const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
|
|
260
|
+
encoding: 'utf8',
|
|
261
|
+
timeout: 4000
|
|
262
|
+
});
|
|
263
|
+
const n = Number((r.stdout ?? '').trim());
|
|
264
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Default listener probe: any LISTENing socket owned by a pid in process group
|
|
271
|
+
* `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
|
|
272
|
+
function defaultGroupHasListener(pgid) {
|
|
273
|
+
for (const pid of listeningSocketPids()) {
|
|
274
|
+
if (pgidOf(pid) === pgid)
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
186
279
|
/** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
|
|
187
280
|
* failure (the diagnosis then omits the pid — never blocks). */
|
|
188
281
|
function defaultFindPortHolder(port) {
|
|
@@ -231,7 +324,7 @@ function holderIsOurs(command, boot) {
|
|
|
231
324
|
&& (c.includes(` ${script}`) || c.endsWith(script)));
|
|
232
325
|
}
|
|
233
326
|
/**
|
|
234
|
-
* Exercise the start command ONCE
|
|
327
|
+
* Exercise the start command ONCE. For a CLI project (`expectServer` false) the
|
|
235
328
|
* command's own fate within the grace window decides:
|
|
236
329
|
*
|
|
237
330
|
* - non-zero exit (or signal death) before the window closes → FAIL, output tail;
|
|
@@ -239,9 +332,21 @@ function holderIsOurs(command, boot) {
|
|
|
239
332
|
* - still alive when the window closes → PASS, then the whole process group is
|
|
240
333
|
* killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
|
|
241
334
|
*
|
|
335
|
+
* For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
|
|
336
|
+
* survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
|
|
337
|
+
* forever without ever listening, and a type-only entrypoint exits 0 in <1s having
|
|
338
|
+
* served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
|
|
339
|
+
* PASSes only once a LISTENing socket owned by our process group is observed; if the
|
|
340
|
+
* command exits, or the grace window closes, with no listener ever seen → FAIL naming
|
|
341
|
+
* that a listening server was expected. (The listener requirement needs pgid probing,
|
|
342
|
+
* absent on win32, where `expectServer` collapses to the survival rule — best-effort,
|
|
343
|
+
* never a false FAIL on a platform we cannot probe.)
|
|
344
|
+
*
|
|
242
345
|
* Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
|
|
243
346
|
*/
|
|
244
|
-
export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
|
|
347
|
+
export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
348
|
+
const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
|
|
349
|
+
const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
|
|
245
350
|
return new Promise(resolve => {
|
|
246
351
|
const child = spawn(bin, args, {
|
|
247
352
|
cwd,
|
|
@@ -251,6 +356,7 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
|
|
|
251
356
|
});
|
|
252
357
|
let out = '';
|
|
253
358
|
let err = '';
|
|
359
|
+
let listenerSeen = false;
|
|
254
360
|
const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
|
|
255
361
|
child.stdout?.on('data', (d) => (out = cap(out + String(d))));
|
|
256
362
|
child.stderr?.on('data', (d) => (err = cap(err + String(d))));
|
|
@@ -260,6 +366,8 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
|
|
|
260
366
|
return;
|
|
261
367
|
settled = true;
|
|
262
368
|
clearTimeout(timer);
|
|
369
|
+
if (poll)
|
|
370
|
+
clearInterval(poll);
|
|
263
371
|
resolve(r);
|
|
264
372
|
};
|
|
265
373
|
const killGroup = (sig) => {
|
|
@@ -281,15 +389,47 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
|
|
|
281
389
|
// group already gone
|
|
282
390
|
}
|
|
283
391
|
};
|
|
284
|
-
const
|
|
392
|
+
const passAndKill = () => {
|
|
285
393
|
settle({ outcome: 'pass' });
|
|
286
394
|
killGroup('SIGTERM');
|
|
287
395
|
setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
|
|
396
|
+
};
|
|
397
|
+
// Served apps only: poll for a listening socket owned by our process group.
|
|
398
|
+
// As soon as one appears the boot has demonstrably served → PASS early.
|
|
399
|
+
const poll = expectServer ?
|
|
400
|
+
setInterval(() => {
|
|
401
|
+
if (settled || !child.pid)
|
|
402
|
+
return;
|
|
403
|
+
if (groupHasListener(child.pid)) {
|
|
404
|
+
listenerSeen = true;
|
|
405
|
+
passAndKill();
|
|
406
|
+
}
|
|
407
|
+
}, 500)
|
|
408
|
+
: null;
|
|
409
|
+
const timer = setTimeout(() => {
|
|
410
|
+
if (expectServer && !listenerSeen) {
|
|
411
|
+
settle({
|
|
412
|
+
outcome: 'fail',
|
|
413
|
+
detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
|
|
414
|
+
});
|
|
415
|
+
killGroup('SIGTERM');
|
|
416
|
+
setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
passAndKill();
|
|
288
420
|
}, graceMs);
|
|
289
421
|
child.on('error', () => settle({ outcome: 'skip' }));
|
|
290
422
|
child.on('exit', (status, signal) => {
|
|
291
|
-
if (status === 0)
|
|
423
|
+
if (status === 0) {
|
|
424
|
+
if (expectServer && !listenerSeen) {
|
|
425
|
+
return settle({
|
|
426
|
+
outcome: 'fail',
|
|
427
|
+
detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
|
|
428
|
+
+ 'promise an HTTP server, so a boot that serves nothing is not a launch'
|
|
429
|
+
});
|
|
430
|
+
}
|
|
292
431
|
return settle({ outcome: 'pass' });
|
|
432
|
+
}
|
|
293
433
|
if (status === 127 || (status === null && signal === null)) {
|
|
294
434
|
return settle({ outcome: 'skip' });
|
|
295
435
|
}
|
|
@@ -335,11 +475,20 @@ function outputTail(stdout, stderr, limit = 400) {
|
|
|
335
475
|
const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
|
|
336
476
|
return combined.length > limit ? `…${tail}` : tail;
|
|
337
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
|
|
480
|
+
* a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
|
|
481
|
+
* libraries were never installed here (mx5 run 10 item 2: `test:ct` must run in the
|
|
482
|
+
* gate, but on a box with no Playwright browsers it is an environment gap, not a FAIL).
|
|
483
|
+
* These exit non-zero (not 127), so they need output-shape recognition to skip.
|
|
484
|
+
*/
|
|
485
|
+
const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|browserType\.\w+: Executable|(?:wasn't|weren't) installed|Host system is missing dependencies|No usable sandbox|Cypress verification|Cypress executable (?:not found|was not found)|browser(?:s)? (?:is|are)? ?not installed/i;
|
|
338
486
|
/**
|
|
339
487
|
* Run one gate command with the env-gap contract: tool missing, timeout, or
|
|
340
488
|
* command-not-found inside the script chain (127) → environment gap, not a code
|
|
341
|
-
* fault → skipped (same contract as repo-health).
|
|
342
|
-
*
|
|
489
|
+
* fault → skipped (same contract as repo-health). Also skips a non-zero exit whose
|
|
490
|
+
* output shows a missing browser/runtime (ENV_GAP_OUTPUT_RE). Only a command that
|
|
491
|
+
* actually ran and exited non-zero for a real reason fails.
|
|
343
492
|
*/
|
|
344
493
|
function runGateCommand(cwd, [bin, args], timeoutMs) {
|
|
345
494
|
// env passed explicitly: bun's spawnSync resolves the binary against a
|
|
@@ -353,6 +502,8 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
|
|
|
353
502
|
if (r.error || r.status === null || r.status === 127)
|
|
354
503
|
return { outcome: 'skip' };
|
|
355
504
|
if (r.status !== 0) {
|
|
505
|
+
if (ENV_GAP_OUTPUT_RE.test(`${r.stdout ?? ''}\n${r.stderr ?? ''}`))
|
|
506
|
+
return { outcome: 'skip' };
|
|
356
507
|
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
357
508
|
}
|
|
358
509
|
return { outcome: 'pass' };
|
|
@@ -364,7 +515,7 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
|
|
|
364
515
|
* the caller emit the harness diagnosis. Never reaps a process we cannot attribute
|
|
365
516
|
* to ourselves.
|
|
366
517
|
*/
|
|
367
|
-
async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
|
|
518
|
+
async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
|
|
368
519
|
if (first.port === null)
|
|
369
520
|
return first;
|
|
370
521
|
const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
|
|
@@ -375,7 +526,7 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
|
|
|
375
526
|
return first;
|
|
376
527
|
// Give the OS a moment to release the socket, then re-run the boot once.
|
|
377
528
|
await new Promise(r => setTimeout(r, 1_500));
|
|
378
|
-
return runBootCheck(cwd, boot, bootGraceMs);
|
|
529
|
+
return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
|
|
379
530
|
}
|
|
380
531
|
/**
|
|
381
532
|
* Run the final gate: static analysis first, then the lockfile consistency
|
|
@@ -383,7 +534,7 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
|
|
|
383
534
|
* the start command — whole-repo, verbatim, unaided. Deterministic (no model).
|
|
384
535
|
* First real failure wins.
|
|
385
536
|
*/
|
|
386
|
-
export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}) {
|
|
537
|
+
export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
|
|
387
538
|
const stat = runRepoHealthCheck(cwd);
|
|
388
539
|
// ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
|
|
389
540
|
// the user accepted despite a verify-FAIL and re-check each against the current
|
|
@@ -406,6 +557,20 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
406
557
|
});
|
|
407
558
|
if (!stat.ok)
|
|
408
559
|
return withDebts({ ok: false, reason: `static checks: ${stat.reason}` });
|
|
560
|
+
// Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
|
|
561
|
+
// scripts that fell through decompose and shipped missing, unchecked. Diff the
|
|
562
|
+
// plan-time-extracted declared scripts against the manifest; a missing one is a
|
|
563
|
+
// launch-surface defect. FP-safe: empty declared list (nothing grounded) → no check.
|
|
564
|
+
const declared = await readDeclaredScripts(cwd);
|
|
565
|
+
if (declared.length > 0) {
|
|
566
|
+
const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
|
|
567
|
+
if (missing.length > 0) {
|
|
568
|
+
return withDebts({
|
|
569
|
+
ok: false,
|
|
570
|
+
reason: `launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
409
574
|
const lockCmds = discoverLockfileChecks(cwd);
|
|
410
575
|
const { cmds } = discoverIntegrationCommands(cwd);
|
|
411
576
|
const boot = discoverBootCommand(cwd);
|
|
@@ -433,9 +598,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
433
598
|
}
|
|
434
599
|
if (boot) {
|
|
435
600
|
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
436
|
-
|
|
601
|
+
const expectServer = detectsServedApp(cwd, planText);
|
|
602
|
+
let b = await runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps: bootDeps });
|
|
437
603
|
if (b.outcome === 'orphan-port') {
|
|
438
|
-
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps);
|
|
604
|
+
b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps, expectServer);
|
|
439
605
|
}
|
|
440
606
|
if (b.outcome === 'fail') {
|
|
441
607
|
return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { type ChangedFile } from './substitution-probe.js';
|
|
|
6
6
|
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
7
7
|
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
8
8
|
export type RunTaskFn = GateDeps['runTask'];
|
|
9
|
+
/**
|
|
10
|
+
* One-line, tail-kept, whitespace-flattened summary of a tool's output for the gate
|
|
11
|
+
* debug log. The TAIL is kept (a bind failure / final status / assertion lands at the
|
|
12
|
+
* end of the output) with a leading ellipsis when truncated; empty output → "(no output)".
|
|
13
|
+
*/
|
|
14
|
+
export declare function truncateToolResult(text: string, limit?: number): string;
|
|
9
15
|
/** One bounded final-gate fix attempt (see final-gate-fix.ts): fix child →
|
|
10
16
|
* shrink guard → gate re-run. Consumed by /task-auto's run-end gate branch. */
|
|
11
17
|
export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string) => Promise<FinalFixResult>;
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -21,7 +21,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
|
|
|
21
21
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
22
|
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
23
23
|
import { readContracts } from './contracts.js';
|
|
24
|
-
import { recordAcceptDebt } from './accept-debt.js';
|
|
24
|
+
import { recordAcceptDebt, recordEnforceRevertDebt } from './accept-debt.js';
|
|
25
25
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
26
26
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
27
27
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
@@ -38,6 +38,19 @@ import { formatLoopHint } from './child-runner.js';
|
|
|
38
38
|
import { getConfig } from '../config/config.js';
|
|
39
39
|
import { startAutoLoader } from './widget.js';
|
|
40
40
|
import { resolveContextUsage } from './context-usage.js';
|
|
41
|
+
/** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
|
|
42
|
+
const TOOL_RESULT_LOG_LIMIT = 300;
|
|
43
|
+
/**
|
|
44
|
+
* One-line, tail-kept, whitespace-flattened summary of a tool's output for the gate
|
|
45
|
+
* debug log. The TAIL is kept (a bind failure / final status / assertion lands at the
|
|
46
|
+
* end of the output) with a leading ellipsis when truncated; empty output → "(no output)".
|
|
47
|
+
*/
|
|
48
|
+
export function truncateToolResult(text, limit = TOOL_RESULT_LOG_LIMIT) {
|
|
49
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
50
|
+
if (flat.length === 0)
|
|
51
|
+
return '(no output)';
|
|
52
|
+
return flat.length > limit ? `…${flat.slice(-limit)}` : flat;
|
|
53
|
+
}
|
|
41
54
|
/** Keep the gate machinery's own artifacts out of every git pathspec below. */
|
|
42
55
|
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
43
56
|
const splitLines = (s) => s
|
|
@@ -230,6 +243,11 @@ export function buildGateDeps(params) {
|
|
|
230
243
|
lastLine = line;
|
|
231
244
|
log(line);
|
|
232
245
|
},
|
|
246
|
+
// Log tool OUTPUTS, not just the command (mx5 run 10 item 6):
|
|
247
|
+
// without the result "verify claimed curl PASS on a server that
|
|
248
|
+
// cannot serve" is undecidable from the log. Truncated, tail-kept
|
|
249
|
+
// (a bind failure / status usually lands at the end), error-flagged.
|
|
250
|
+
onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`),
|
|
233
251
|
onContextUsage: snapshot => {
|
|
234
252
|
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
235
253
|
}
|
|
@@ -278,6 +296,7 @@ export function buildGateDeps(params) {
|
|
|
278
296
|
// Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
|
|
279
297
|
// discardEdits): the final integration gate re-checks each debt at run end.
|
|
280
298
|
recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
|
|
299
|
+
recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
|
|
281
300
|
// Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
|
|
282
301
|
// task's spec forbids modifying, so the gate sequence can UNDO any edit the
|
|
283
302
|
// enforce EDIT pass makes to them before those edits are committed. Reads the
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* missing) disables the guard (capture returns ok:false and reconcile no-ops) —
|
|
39
39
|
* the gate must keep working in non-git projects exactly as before.
|
|
40
40
|
*/
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
41
42
|
import * as fsp from 'node:fs/promises';
|
|
42
43
|
import * as os from 'node:os';
|
|
43
44
|
import * as path from 'node:path';
|
|
@@ -62,6 +63,55 @@ function isBenignArtifact(relPath) {
|
|
|
62
63
|
const p = relPath.replace(/\\/g, '/');
|
|
63
64
|
return ARTIFACT_PATTERNS.some(re => re.test(p));
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Regenerable machine state that is benign EVEN WHEN TRACKED — a project that
|
|
68
|
+
* mistakenly commits it (mx5 run 10 does exactly this) must not have a gate child's
|
|
69
|
+
* incidental rewrite of it discard the verdict. Two classes:
|
|
70
|
+
* - Playwright component-test build cache (`ctCacheDir` — run 10 committed 60+
|
|
71
|
+
* `.playwright-cache/assets/*.js` bundles; a `test:ct` run rewrites them every
|
|
72
|
+
* time), and
|
|
73
|
+
* - the test runner's `.last-run.json` run-state file.
|
|
74
|
+
* DELIBERATELY narrow: snapshot BASELINE images (`*-snapshots/*.png`) are NOT here —
|
|
75
|
+
* a child that rewrites a baseline to make a screenshot test pass is the real
|
|
76
|
+
* mutate-to-pass catch (run 10's other half), so those stay verdict-tainting.
|
|
77
|
+
*/
|
|
78
|
+
const ALWAYS_REGENERABLE_PATTERNS = [/(?:^|\/)\.last-run\.json$/];
|
|
79
|
+
/** Playwright config files that may declare a custom `ctCacheDir`. */
|
|
80
|
+
const CT_CONFIG_FILES = [
|
|
81
|
+
'playwright-ct.config.ts',
|
|
82
|
+
'playwright-ct.config.js',
|
|
83
|
+
'playwright.config.ts',
|
|
84
|
+
'playwright.config.js'
|
|
85
|
+
];
|
|
86
|
+
/** ctCacheDir defaults Playwright uses when a config does not override it. */
|
|
87
|
+
const DEFAULT_CT_CACHE_DIRS = ['.playwright-cache', 'playwright/.cache'];
|
|
88
|
+
/**
|
|
89
|
+
* The component-test cache dir(s) for this project: the `ctCacheDir` any Playwright
|
|
90
|
+
* config declares, plus the known defaults. Read once per reconcile (best-effort — a
|
|
91
|
+
* missing/odd config just leaves the defaults). Normalised to a repo-relative prefix.
|
|
92
|
+
*/
|
|
93
|
+
function readCtCacheDirs(cwd) {
|
|
94
|
+
const dirs = new Set(DEFAULT_CT_CACHE_DIRS);
|
|
95
|
+
for (const f of CT_CONFIG_FILES) {
|
|
96
|
+
try {
|
|
97
|
+
const text = readFileSync(path.join(cwd, f), 'utf8');
|
|
98
|
+
const m = /ctCacheDir\s*:\s*['"`]([^'"`]+)['"`]/.exec(text);
|
|
99
|
+
if (m)
|
|
100
|
+
dirs.add(m[1].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// no such config, or unreadable — defaults stand
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...dirs].filter(d => d.length > 0);
|
|
107
|
+
}
|
|
108
|
+
/** Is this path regenerable machine state that is benign even when tracked-in-HEAD? */
|
|
109
|
+
function isAlwaysRegenerable(relPath, ctCacheDirs) {
|
|
110
|
+
const p = relPath.replace(/\\/g, '/');
|
|
111
|
+
if (ALWAYS_REGENERABLE_PATTERNS.some(re => re.test(p)))
|
|
112
|
+
return true;
|
|
113
|
+
return ctCacheDirs.some(d => p === d || p.startsWith(d + '/'));
|
|
114
|
+
}
|
|
65
115
|
function makeGit(cwd, signal, spawnFn) {
|
|
66
116
|
return async (args, env) => {
|
|
67
117
|
const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
@@ -151,7 +201,7 @@ function pushCapped(actions, verb, paths) {
|
|
|
151
201
|
* Creations and test-runner-artifact churn restore identically but do NOT taint.
|
|
152
202
|
* Each changed path is itemised (capped) so the gate trail says WHICH files moved.
|
|
153
203
|
*/
|
|
154
|
-
async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions) {
|
|
204
|
+
async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCacheDirs, actions) {
|
|
155
205
|
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
156
206
|
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
157
207
|
let tainted = false;
|
|
@@ -180,8 +230,11 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions
|
|
|
180
230
|
if (code === 'A') {
|
|
181
231
|
created.push(name);
|
|
182
232
|
}
|
|
183
|
-
else if (
|
|
184
|
-
|
|
233
|
+
else if (isAlwaysRegenerable(name, ctCacheDirs)
|
|
234
|
+
|| (isBenignArtifact(name) && !tracked.has(name))) {
|
|
235
|
+
// Regenerable test/build output — not graded work. Either an
|
|
236
|
+
// always-regenerable class (ct cache / run-state, benign even when
|
|
237
|
+
// tracked — mx5 run 10) or untracked test-runner output.
|
|
185
238
|
artifactChanges.push(name);
|
|
186
239
|
}
|
|
187
240
|
else if (code === 'D') {
|
|
@@ -258,7 +311,8 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
|
258
311
|
const afterTree = await captureWorktreeTree(git);
|
|
259
312
|
if (afterTree && afterTree !== before.treeSha) {
|
|
260
313
|
const tracked = await trackedPathsAt(git, before.headSha);
|
|
261
|
-
const
|
|
314
|
+
const ctCacheDirs = readCtCacheDirs(cwd);
|
|
315
|
+
const { tainted: worktreeTainted } = await restoreWorktree(cwd, git, before.treeSha, afterTree, tracked, ctCacheDirs, actions);
|
|
262
316
|
tainted = tainted || worktreeTainted;
|
|
263
317
|
}
|
|
264
318
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare function launchContractFile(cwd: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
|
|
4
|
+
* whose token is not script-name-shaped is skipped (an accidental sentence, a path).
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseScriptLines(text: string): string[];
|
|
7
|
+
/**
|
|
8
|
+
* THE GROUNDING GUARD: keep only names the design declares as an inline-code token
|
|
9
|
+
* (`` `name` ``) — the form a design uses to name a script. A name the model
|
|
10
|
+
* paraphrased or invented has no such token, so it is dropped and the diff cannot
|
|
11
|
+
* false-flag on it. Deduplicated, case-insensitive.
|
|
12
|
+
*/
|
|
13
|
+
export declare function keepGroundedScripts(names: string[], sourceDoc: string): string[];
|
|
14
|
+
/** The stored declared-script list ('' when none recorded). */
|
|
15
|
+
export declare function readLaunchContractRaw(cwd: string): Promise<string>;
|
|
16
|
+
/** The declared script names recorded for this run (deduped, order preserved). */
|
|
17
|
+
export declare function readDeclaredScripts(cwd: string): Promise<string[]>;
|
|
18
|
+
/** Append grounded script names, deduped against what is stored, keeping newest MAX. */
|
|
19
|
+
export declare function appendDeclaredScripts(cwd: string, names: string[]): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
|
|
22
|
+
* declared script is present, or when nothing was declared (no check). This is the
|
|
23
|
+
* deterministic lever the final gate FAILs on.
|
|
24
|
+
*/
|
|
25
|
+
export declare function missingDeclaredScripts(declared: string[], manifestScripts: string[]): string[];
|
|
26
|
+
/**
|
|
27
|
+
* The plan-time extraction prompt: the design in hand, emit the scripts it declares.
|
|
28
|
+
* Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
|
|
29
|
+
* (keepGroundedScripts), so a hallucinated script cannot reach the diff.
|
|
30
|
+
*/
|
|
31
|
+
export declare const LAUNCH_EXTRACT_PROMPT: (feature: string) => string;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-contract — a per-run record of the package/build SCRIPTS the SOURCE design
|
|
3
|
+
* declares the finished project must expose, extracted once at plan time and diffed
|
|
4
|
+
* by the final gate against the shipped manifest.
|
|
5
|
+
*
|
|
6
|
+
* The failure this closes (mx5 run 10 item 4): the design's §9 "Build & run" listed
|
|
7
|
+
* the required scripts verbatim — `dev`, `build`, `migrate`, `seed`, `test` — but the
|
|
8
|
+
* shipped package.json declared only `dev`, `build`, `lint`, `test`, `test:ct`. No
|
|
9
|
+
* task owned `migrate`/`seed` (they fell through decompose), and NOTHING re-checked
|
|
10
|
+
* the finished manifest against the design's own list, so the run completed missing
|
|
11
|
+
* two of its declared entrypoints. A per-slice gate cannot catch this — it is a
|
|
12
|
+
* whole-project launch-surface fact — and the final gate never had the design's list.
|
|
13
|
+
*
|
|
14
|
+
* Mechanism (mirrors contracts.ts): a plan-time child EMITs `SCRIPT:` lines naming the
|
|
15
|
+
* scripts the design declares; the host GROUNDS each against the design — a name is
|
|
16
|
+
* kept only if the design mentions it as an inline-code token (`` `migrate` ``), the
|
|
17
|
+
* form designs use to declare a script. A paraphrase or a script the model invented is
|
|
18
|
+
* not grounded and is dropped, so the diff can never false-flag on a hallucinated
|
|
19
|
+
* requirement. The grounded list is appended HOST-SIDE to `.pi-tasks/launch-contract.md`
|
|
20
|
+
* (children never write it), which survives discardEdits and the git-state guard.
|
|
21
|
+
*
|
|
22
|
+
* At run end the final gate reads the list, reads the manifest's `scripts`, and FAILs
|
|
23
|
+
* naming any declared script the manifest is missing. FP-safe by construction: an
|
|
24
|
+
* empty/ungrounded list (a design that never backticks a script name) yields no check.
|
|
25
|
+
*/
|
|
26
|
+
import * as fsp from 'node:fs/promises';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
import { tasksDir } from './task-io.js';
|
|
29
|
+
const LAUNCH_CONTRACT_FILE = 'launch-contract.md';
|
|
30
|
+
/** Cap kept entries so a noisy extraction cannot grow the artifact unboundedly. */
|
|
31
|
+
const MAX_SCRIPTS = 40;
|
|
32
|
+
/** npm/package script names are short kebab/colon tokens; reject anything unscript-like. */
|
|
33
|
+
const SCRIPT_NAME_RE = /^[a-z0-9][a-z0-9:_-]{0,39}$/i;
|
|
34
|
+
export function launchContractFile(cwd) {
|
|
35
|
+
return path.join(tasksDir(cwd), LAUNCH_CONTRACT_FILE);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
|
|
39
|
+
* whose token is not script-name-shaped is skipped (an accidental sentence, a path).
|
|
40
|
+
*/
|
|
41
|
+
export function parseScriptLines(text) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const m of text.matchAll(/^[ \t]*SCRIPT:[ \t]*(.+)$/gim)) {
|
|
44
|
+
// Take the first whitespace/comma-delimited token, stripping backticks/quotes.
|
|
45
|
+
const raw = m[1].trim().split(/[\s,]+/)[0]?.replace(/[`'"]/g, '') ?? '';
|
|
46
|
+
if (SCRIPT_NAME_RE.test(raw))
|
|
47
|
+
out.push(raw);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* THE GROUNDING GUARD: keep only names the design declares as an inline-code token
|
|
53
|
+
* (`` `name` ``) — the form a design uses to name a script. A name the model
|
|
54
|
+
* paraphrased or invented has no such token, so it is dropped and the diff cannot
|
|
55
|
+
* false-flag on it. Deduplicated, case-insensitive.
|
|
56
|
+
*/
|
|
57
|
+
export function keepGroundedScripts(names, sourceDoc) {
|
|
58
|
+
const haystack = sourceDoc.toLowerCase();
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const kept = [];
|
|
61
|
+
for (const n of names) {
|
|
62
|
+
const key = n.toLowerCase();
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
if (!haystack.includes('`' + key + '`'))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(key);
|
|
68
|
+
kept.push(n);
|
|
69
|
+
}
|
|
70
|
+
return kept;
|
|
71
|
+
}
|
|
72
|
+
/** The stored declared-script list ('' when none recorded). */
|
|
73
|
+
export async function readLaunchContractRaw(cwd) {
|
|
74
|
+
try {
|
|
75
|
+
return (await fsp.readFile(launchContractFile(cwd), 'utf8')).trim();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The declared script names recorded for this run (deduped, order preserved). */
|
|
82
|
+
export async function readDeclaredScripts(cwd) {
|
|
83
|
+
const raw = await readLaunchContractRaw(cwd);
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const line of raw.split('\n')) {
|
|
87
|
+
const n = line.trim();
|
|
88
|
+
if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
|
|
89
|
+
continue;
|
|
90
|
+
const key = n.toLowerCase();
|
|
91
|
+
if (seen.has(key))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
out.push(n);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Append grounded script names, deduped against what is stored, keeping newest MAX. */
|
|
99
|
+
export async function appendDeclaredScripts(cwd, names) {
|
|
100
|
+
if (names.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
const existing = await readDeclaredScripts(cwd);
|
|
104
|
+
const seen = new Set(existing.map(n => n.toLowerCase()));
|
|
105
|
+
const merged = [...existing];
|
|
106
|
+
for (const n of names) {
|
|
107
|
+
if (seen.has(n.toLowerCase()))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(n.toLowerCase());
|
|
110
|
+
merged.push(n);
|
|
111
|
+
}
|
|
112
|
+
const kept = merged.slice(-MAX_SCRIPTS);
|
|
113
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
114
|
+
await fsp.writeFile(launchContractFile(cwd), kept.join('\n') + '\n', 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// best-effort artifact
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
|
|
122
|
+
* declared script is present, or when nothing was declared (no check). This is the
|
|
123
|
+
* deterministic lever the final gate FAILs on.
|
|
124
|
+
*/
|
|
125
|
+
export function missingDeclaredScripts(declared, manifestScripts) {
|
|
126
|
+
const have = new Set(manifestScripts.map(s => s.toLowerCase()));
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const missing = [];
|
|
129
|
+
for (const d of declared) {
|
|
130
|
+
const key = d.toLowerCase();
|
|
131
|
+
if (have.has(key) || seen.has(key))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(key);
|
|
134
|
+
missing.push(d);
|
|
135
|
+
}
|
|
136
|
+
return missing;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The plan-time extraction prompt: the design in hand, emit the scripts it declares.
|
|
140
|
+
* Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
|
|
141
|
+
* (keepGroundedScripts), so a hallucinated script cannot reach the diff.
|
|
142
|
+
*/
|
|
143
|
+
export const LAUNCH_EXTRACT_PROMPT = (feature) => [
|
|
144
|
+
'You are recording the PACKAGE/BUILD SCRIPTS the design below says the finished',
|
|
145
|
+
'project MUST expose (the `scripts` a package.json / Makefile / task runner must',
|
|
146
|
+
'declare — e.g. build, test, a migration runner, a seed step, a start/serve command).',
|
|
147
|
+
'These are launch-surface entrypoints the whole project shares; if one the design',
|
|
148
|
+
'names is missing from the shipped manifest, the project cannot be run as specified.',
|
|
149
|
+
'',
|
|
150
|
+
'DESIGN (the ONLY source — name only scripts the design itself declares):',
|
|
151
|
+
feature.trim(),
|
|
152
|
+
'',
|
|
153
|
+
'For each script the design declares by name, emit exactly:',
|
|
154
|
+
' SCRIPT: <name>',
|
|
155
|
+
'one per line, the bare script name only (e.g. `SCRIPT: migrate`). RULES: (1) name',
|
|
156
|
+
'ONLY scripts the design explicitly lists — do NOT invent conventional ones it does',
|
|
157
|
+
'not mention. (2) Use the exact name the design uses. (3) A name that is not literally',
|
|
158
|
+
'in the design is DISCARDED host-side, so guessing wastes effort. If the design',
|
|
159
|
+
'declares no scripts, output nothing.',
|
|
160
|
+
'',
|
|
161
|
+
'Output the SCRIPT: lines and nothing else.'
|
|
162
|
+
].join('\n');
|