@mjasnikovs/pi-task 0.38.11 → 0.38.12
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/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +5 -151
- package/dist/task/final-gate.js +81 -379
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +67 -294
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
package/dist/task/contracts.js
CHANGED
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
* Stack-agnostic: an "interface fact" is any pinned boundary string; the guard is
|
|
28
28
|
* pure substring matching over the source text, with no assumption about its shape.
|
|
29
29
|
*/
|
|
30
|
-
import
|
|
31
|
-
import * as path from 'node:path';
|
|
32
|
-
import { tasksDir } from './task-io.js';
|
|
30
|
+
import { makeLedger } from './ledger.js';
|
|
33
31
|
const CONTRACTS_FILE = 'contracts.md';
|
|
34
32
|
/** Cap kept entries so the injected block stays bounded on a large design. */
|
|
35
33
|
const MAX_CONTRACTS = 40;
|
|
@@ -37,17 +35,26 @@ const MAX_CONTRACTS = 40;
|
|
|
37
35
|
const MAX_CONTRACT_LENGTH = 300;
|
|
38
36
|
/** A quote shorter than this is too generic to anchor a contract (and to match). */
|
|
39
37
|
const MIN_QUOTE_LENGTH = 6;
|
|
38
|
+
function lineKey(line) {
|
|
39
|
+
const q = /"([^"]+)"/.exec(line);
|
|
40
|
+
return normalise(q ? q[1] : line);
|
|
41
|
+
}
|
|
42
|
+
const ledger = makeLedger({
|
|
43
|
+
file: CONTRACTS_FILE,
|
|
44
|
+
max: MAX_CONTRACTS,
|
|
45
|
+
key: c => c.key,
|
|
46
|
+
serialize: c => c.line,
|
|
47
|
+
parse: raw => raw
|
|
48
|
+
.split('\n')
|
|
49
|
+
.filter(l => l.trim().length > 0)
|
|
50
|
+
.map(line => ({ line, key: lineKey(line) }))
|
|
51
|
+
});
|
|
40
52
|
export function contractsFile(cwd) {
|
|
41
|
-
return path
|
|
53
|
+
return ledger.path(cwd);
|
|
42
54
|
}
|
|
43
55
|
/** The stored registry text ('' when none recorded yet). */
|
|
44
56
|
export async function readContracts(cwd) {
|
|
45
|
-
|
|
46
|
-
return (await fsp.readFile(contractsFile(cwd), 'utf8')).trim();
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return '';
|
|
50
|
-
}
|
|
57
|
+
return ledger.readRaw(cwd);
|
|
51
58
|
}
|
|
52
59
|
/**
|
|
53
60
|
* Normalise for substring matching: collapse all whitespace runs to one space and
|
|
@@ -114,31 +121,7 @@ function formatEntry(e) {
|
|
|
114
121
|
* are swallowed — the registry is a sharpener, never a blocker.
|
|
115
122
|
*/
|
|
116
123
|
export async function appendContracts(cwd, entries) {
|
|
117
|
-
|
|
118
|
-
return;
|
|
119
|
-
try {
|
|
120
|
-
const existingLines = (await readContracts(cwd))
|
|
121
|
-
.split('\n')
|
|
122
|
-
.filter(l => l.trim().length > 0);
|
|
123
|
-
const seen = new Set(existingLines.map(l => {
|
|
124
|
-
const q = /"([^"]+)"/.exec(l);
|
|
125
|
-
return normalise(q ? q[1] : l);
|
|
126
|
-
}));
|
|
127
|
-
const merged = [...existingLines];
|
|
128
|
-
for (const e of entries) {
|
|
129
|
-
const key = normalise(e.quote);
|
|
130
|
-
if (seen.has(key))
|
|
131
|
-
continue;
|
|
132
|
-
seen.add(key);
|
|
133
|
-
merged.push(formatEntry(e));
|
|
134
|
-
}
|
|
135
|
-
const kept = merged.slice(-MAX_CONTRACTS);
|
|
136
|
-
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
137
|
-
await fsp.writeFile(contractsFile(cwd), kept.join('\n') + '\n', 'utf8');
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
// best-effort registry
|
|
141
|
-
}
|
|
124
|
+
await ledger.append(cwd, entries.map(e => ({ line: formatEntry(e), key: normalise(e.quote) })));
|
|
142
125
|
}
|
|
143
126
|
/**
|
|
144
127
|
* The read-only prompt block a downstream slice (refine/compose) receives when the
|
|
@@ -177,3 +177,50 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
|
|
|
177
177
|
* is the whole runtime of a driver test. The gate never passes it. */
|
|
178
178
|
quietMs?: number;
|
|
179
179
|
}): Promise<DeepRenderOutcome>;
|
|
180
|
+
/** A launched, connected browser: the client to speak to it and the one way to
|
|
181
|
+
* tear it down. `close` is idempotent and never throws. */
|
|
182
|
+
export interface LaunchedBrowser {
|
|
183
|
+
cdp: Cdp;
|
|
184
|
+
close: () => Promise<void>;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Everything that touches a real process or the filesystem: spawn the Chrome-family
|
|
188
|
+
* binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
|
|
189
|
+
* URL off its output, connect. Rejects if the browser exits or errors before it
|
|
190
|
+
* listens, or the socket cannot open; whatever was started by then is torn down.
|
|
191
|
+
* `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
|
|
192
|
+
* or after, which is how a budget timeout reaches a browser that never listened.
|
|
193
|
+
* The caller owns `userDataDir` (creation and removal) — this only points Chrome
|
|
194
|
+
* at it.
|
|
195
|
+
*
|
|
196
|
+
* @internal Exported for `drive` and its own harness; the gate goes through
|
|
197
|
+
* `runDeepRenderCheck`.
|
|
198
|
+
*/
|
|
199
|
+
export declare function launchBrowser(bin: string, userDataDir: string, { signal }?: {
|
|
200
|
+
signal?: AbortSignal;
|
|
201
|
+
}): Promise<LaunchedBrowser>;
|
|
202
|
+
/** The subset of `Cdp` the session drives: request/response and event fan-out.
|
|
203
|
+
* Defined from what `driveSession` calls, so a scripted fake is a dozen lines. */
|
|
204
|
+
export interface CdpLike {
|
|
205
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
|
|
206
|
+
on(method: string, cb: (params: Record<string, unknown>) => void): void;
|
|
207
|
+
}
|
|
208
|
+
export interface DriveSessionOptions {
|
|
209
|
+
url: string;
|
|
210
|
+
credentials: LoginCredentials | null;
|
|
211
|
+
/** Turns the facts the session gathered into the verdict. `drive` wraps
|
|
212
|
+
* `judgeDeepSession` with the recorder hook here. */
|
|
213
|
+
judge: (f: DeepSessionFacts) => DeepRenderOutcome;
|
|
214
|
+
/** Settle quiet window; defaults to QUIET_MS. */
|
|
215
|
+
quietMs?: number;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The session over an already-connected browser: navigate, inspect, sign in if the
|
|
219
|
+
* landing is a wall and credentials exist, settle, phase the same-origin request
|
|
220
|
+
* log against the sign-in request, re-enter once the sign-in was accepted, and
|
|
221
|
+
* hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
|
|
222
|
+
* socket — so every branch is testable against a fake `CdpLike`.
|
|
223
|
+
*
|
|
224
|
+
* @internal Exported for its own tests.
|
|
225
|
+
*/
|
|
226
|
+
export declare function driveSession(cdp: CdpLike, { url, credentials, judge, quietMs }: DriveSessionOptions): Promise<DeepRenderOutcome>;
|
|
@@ -494,35 +494,11 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
|
|
|
494
494
|
}
|
|
495
495
|
const budget = opts.timeoutMs ?? DEEP_RENDER_TIMEOUT_MS;
|
|
496
496
|
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'pi-task-deep-render-'));
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
try {
|
|
501
|
-
socket?.close();
|
|
502
|
-
}
|
|
503
|
-
catch {
|
|
504
|
-
// socket already gone
|
|
505
|
-
}
|
|
506
|
-
try {
|
|
507
|
-
if (child?.pid)
|
|
508
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
// group already gone
|
|
512
|
-
}
|
|
513
|
-
try {
|
|
514
|
-
rmSync(userDataDir, { recursive: true, force: true });
|
|
515
|
-
}
|
|
516
|
-
catch {
|
|
517
|
-
// best-effort temp cleanup
|
|
518
|
-
}
|
|
519
|
-
};
|
|
497
|
+
// Aborted in `finally`, so a browser still mid-launch when the budget expires is
|
|
498
|
+
// torn down too — the launch owns the process, and this is how it hears about it.
|
|
499
|
+
const teardown = new AbortController();
|
|
520
500
|
try {
|
|
521
|
-
return await withTimeout(drive(url, bin, userDataDir, credentials,
|
|
522
|
-
child = c;
|
|
523
|
-
}, s => {
|
|
524
|
-
socket = s;
|
|
525
|
-
}, opts.onFacts, opts.quietMs), budget);
|
|
501
|
+
return await withTimeout(drive(url, bin, userDataDir, credentials, opts.onFacts, opts.quietMs, teardown.signal), budget);
|
|
526
502
|
}
|
|
527
503
|
catch (e) {
|
|
528
504
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -532,7 +508,13 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
|
|
|
532
508
|
};
|
|
533
509
|
}
|
|
534
510
|
finally {
|
|
535
|
-
|
|
511
|
+
teardown.abort();
|
|
512
|
+
try {
|
|
513
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// best-effort temp cleanup
|
|
517
|
+
}
|
|
536
518
|
}
|
|
537
519
|
}
|
|
538
520
|
function withTimeout(p, ms) {
|
|
@@ -548,48 +530,111 @@ function withTimeout(p, ms) {
|
|
|
548
530
|
});
|
|
549
531
|
});
|
|
550
532
|
}
|
|
551
|
-
|
|
552
|
-
|
|
533
|
+
/** launch → session → close. The two halves are separately testable: the launch
|
|
534
|
+
* against a fake browser on disk, the session against an in-process fake CDP. */
|
|
535
|
+
async function drive(url, bin, userDataDir, credentials, onFacts, quietMs, signal) {
|
|
553
536
|
/** Every verdict goes through here, so a recorder sees the same facts the judge
|
|
554
537
|
* does — the corpus is what the gate itself read, not a reconstruction. */
|
|
555
538
|
const judge = (f) => {
|
|
556
539
|
onFacts?.(f);
|
|
557
540
|
return judgeDeepSession(f);
|
|
558
541
|
};
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
542
|
+
const browser = await launchBrowser(bin, userDataDir, { signal });
|
|
543
|
+
try {
|
|
544
|
+
return await driveSession(browser.cdp, { url, credentials, judge, quietMs });
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
await browser.close();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Everything that touches a real process or the filesystem: spawn the Chrome-family
|
|
552
|
+
* binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
|
|
553
|
+
* URL off its output, connect. Rejects if the browser exits or errors before it
|
|
554
|
+
* listens, or the socket cannot open; whatever was started by then is torn down.
|
|
555
|
+
* `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
|
|
556
|
+
* or after, which is how a budget timeout reaches a browser that never listened.
|
|
557
|
+
* The caller owns `userDataDir` (creation and removal) — this only points Chrome
|
|
558
|
+
* at it.
|
|
559
|
+
*
|
|
560
|
+
* @internal Exported for `drive` and its own harness; the gate goes through
|
|
561
|
+
* `runDeepRenderCheck`.
|
|
562
|
+
*/
|
|
563
|
+
export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
564
|
+
let child = null;
|
|
565
|
+
let socket = null;
|
|
566
|
+
const close = () => {
|
|
567
|
+
try {
|
|
568
|
+
socket?.close();
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// socket already gone
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
if (child?.pid)
|
|
575
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// group already gone
|
|
579
|
+
}
|
|
580
|
+
return Promise.resolve();
|
|
581
|
+
};
|
|
582
|
+
signal?.addEventListener('abort', () => void close(), { once: true });
|
|
583
|
+
if (signal?.aborted) {
|
|
584
|
+
throw new Error('aborted before launch');
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
child = spawn(bin, [
|
|
588
|
+
'--headless',
|
|
589
|
+
'--disable-gpu',
|
|
590
|
+
'--no-sandbox',
|
|
591
|
+
'--disable-dev-shm-usage',
|
|
592
|
+
'--no-first-run',
|
|
593
|
+
'--no-default-browser-check',
|
|
594
|
+
'--disable-extensions',
|
|
595
|
+
`--user-data-dir=${userDataDir}`,
|
|
596
|
+
'--remote-debugging-port=0',
|
|
597
|
+
'about:blank'
|
|
598
|
+
], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
599
|
+
const proc = child;
|
|
600
|
+
proc.unref();
|
|
601
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
602
|
+
let buf = '';
|
|
603
|
+
const onData = (d) => {
|
|
604
|
+
buf += String(d);
|
|
605
|
+
const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
|
|
606
|
+
if (m)
|
|
607
|
+
resolve(m[1]);
|
|
608
|
+
};
|
|
609
|
+
proc.stderr?.on('data', onData);
|
|
610
|
+
proc.stdout?.on('data', onData);
|
|
611
|
+
proc.on('error', e => reject(e));
|
|
612
|
+
proc.on('exit', code => reject(new Error(`browser exited ${code} before listening`)));
|
|
613
|
+
});
|
|
614
|
+
const ws = new WebSocket(wsUrl, { perMessageDeflate: false, maxPayload: 128 * 1024 * 1024 });
|
|
615
|
+
socket = ws;
|
|
616
|
+
await new Promise((resolve, reject) => {
|
|
617
|
+
ws.once('open', () => resolve());
|
|
618
|
+
ws.once('error', e => reject(e instanceof Error ? e : new Error(String(e))));
|
|
619
|
+
});
|
|
620
|
+
return { cdp: new Cdp(ws), close };
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
await close();
|
|
624
|
+
throw e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The session over an already-connected browser: navigate, inspect, sign in if the
|
|
629
|
+
* landing is a wall and credentials exist, settle, phase the same-origin request
|
|
630
|
+
* log against the sign-in request, re-enter once the sign-in was accepted, and
|
|
631
|
+
* hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
|
|
632
|
+
* socket — so every branch is testable against a fake `CdpLike`.
|
|
633
|
+
*
|
|
634
|
+
* @internal Exported for its own tests.
|
|
635
|
+
*/
|
|
636
|
+
export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
|
|
637
|
+
const origin = new URL(url).origin;
|
|
593
638
|
const requests = new Map();
|
|
594
639
|
let lastActivity = Date.now();
|
|
595
640
|
cdp.on('Network.requestWillBeSent', p => {
|
package/dist/task/env-notes.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
export declare function envNotesFile(cwd: string): string;
|
|
2
|
-
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
3
|
-
export declare function readEnvNotes(cwd: string): Promise<string>;
|
|
4
1
|
/** One recorded fact plus the origin task that established it (may be ''). */
|
|
5
2
|
export interface EnvNote {
|
|
6
3
|
fact: string;
|
|
@@ -11,6 +8,9 @@ export interface EnvNote {
|
|
|
11
8
|
* provenance (no separator) parse with an empty origin, so old caches still read.
|
|
12
9
|
*/
|
|
13
10
|
export declare function parseEnvNotes(raw: string): EnvNote[];
|
|
11
|
+
export declare function envNotesFile(cwd: string): string;
|
|
12
|
+
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
13
|
+
export declare function readEnvNotes(cwd: string): Promise<string>;
|
|
14
14
|
/**
|
|
15
15
|
* Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
|
|
16
16
|
* length-capped; verdict markers can never match (different prefix).
|
package/dist/task/env-notes.js
CHANGED
|
@@ -32,9 +32,7 @@
|
|
|
32
32
|
* exactly that scrutiny, and forbids treating a grep of a generated artifact as
|
|
33
33
|
* evidence of absence. Provenance is mechanical; re-validation is prompt-level.
|
|
34
34
|
*/
|
|
35
|
-
import
|
|
36
|
-
import * as path from 'node:path';
|
|
37
|
-
import { tasksDir } from './task-io.js';
|
|
35
|
+
import { makeLedger } from './ledger.js';
|
|
38
36
|
const ENV_NOTES_FILE = 'env-notes.md';
|
|
39
37
|
/** Cap kept notes so a chatty run cannot grow the prompt block unboundedly. */
|
|
40
38
|
const MAX_NOTES = 40;
|
|
@@ -46,18 +44,6 @@ const MAX_NOTE_LENGTH = 240;
|
|
|
46
44
|
* stray tab in an emitted fact is normalised to a space before storage.
|
|
47
45
|
*/
|
|
48
46
|
const ORIGIN_SEP = '\t';
|
|
49
|
-
export function envNotesFile(cwd) {
|
|
50
|
-
return path.join(tasksDir(cwd), ENV_NOTES_FILE);
|
|
51
|
-
}
|
|
52
|
-
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
53
|
-
export async function readEnvNotes(cwd) {
|
|
54
|
-
try {
|
|
55
|
-
return (await fsp.readFile(envNotesFile(cwd), 'utf8')).trim();
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return '';
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
47
|
/**
|
|
62
48
|
* Parse the stored file into fact+origin records. Legacy lines written before
|
|
63
49
|
* provenance (no separator) parse with an empty origin, so old caches still read.
|
|
@@ -79,6 +65,22 @@ export function parseEnvNotes(raw) {
|
|
|
79
65
|
function serializeNote(n) {
|
|
80
66
|
return n.origin ? `${n.fact}${ORIGIN_SEP}${n.origin}` : n.fact;
|
|
81
67
|
}
|
|
68
|
+
/** Keyed on the fact alone (case-insensitive): a fact already present keeps its
|
|
69
|
+
* ORIGINAL origin — provenance traces to who first established it. */
|
|
70
|
+
const ledger = makeLedger({
|
|
71
|
+
file: ENV_NOTES_FILE,
|
|
72
|
+
max: MAX_NOTES,
|
|
73
|
+
key: n => n.fact.toLowerCase(),
|
|
74
|
+
serialize: serializeNote,
|
|
75
|
+
parse: parseEnvNotes
|
|
76
|
+
});
|
|
77
|
+
export function envNotesFile(cwd) {
|
|
78
|
+
return ledger.path(cwd);
|
|
79
|
+
}
|
|
80
|
+
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
81
|
+
export async function readEnvNotes(cwd) {
|
|
82
|
+
return ledger.readRaw(cwd);
|
|
83
|
+
}
|
|
82
84
|
/**
|
|
83
85
|
* Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
|
|
84
86
|
* length-capped; verdict markers can never match (different prefix).
|
|
@@ -134,27 +136,14 @@ export function isExcuseNote(fact) {
|
|
|
134
136
|
* blocker.
|
|
135
137
|
*/
|
|
136
138
|
export async function appendEnvNotes(cwd, notes, origin = '') {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
for (const note of notes) {
|
|
144
|
-
const fact = note.trim().replace(/\t/g, ' ');
|
|
145
|
-
const key = fact.toLowerCase();
|
|
146
|
-
if (key.length === 0 || seen.has(key))
|
|
147
|
-
continue;
|
|
148
|
-
seen.add(key);
|
|
149
|
-
merged.push({ fact, origin: origin.trim() });
|
|
150
|
-
}
|
|
151
|
-
const kept = merged.slice(-MAX_NOTES);
|
|
152
|
-
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
153
|
-
await fsp.writeFile(envNotesFile(cwd), kept.map(serializeNote).join('\n') + '\n', 'utf8');
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
// best-effort cache
|
|
139
|
+
const fresh = [];
|
|
140
|
+
for (const note of notes) {
|
|
141
|
+
const fact = note.trim().replace(/\t/g, ' ');
|
|
142
|
+
if (fact.length === 0)
|
|
143
|
+
continue;
|
|
144
|
+
fresh.push({ fact, origin: origin.trim() });
|
|
157
145
|
}
|
|
146
|
+
await ledger.append(cwd, fresh);
|
|
158
147
|
}
|
|
159
148
|
/**
|
|
160
149
|
* The prompt block a gate child receives when notes exist. Two things are
|
|
@@ -38,7 +38,7 @@ export declare function classifyFinalGateAnswer(answer: string | undefined): Fin
|
|
|
38
38
|
* Extract it for reporting; the shrink guard itself compares the FULL
|
|
39
39
|
* discovered-command sets, so a reason this cannot parse still guards.
|
|
40
40
|
*/
|
|
41
|
-
export declare function
|
|
41
|
+
export declare function exitedCommandFromReason(reason: string): string | null;
|
|
42
42
|
/**
|
|
43
43
|
* Build the fix child's prompt. Generic by construction: the only project facts
|
|
44
44
|
* in it are the gate's own failure text — the command comes from the project's
|
|
@@ -90,7 +90,7 @@ export function classifyFinalGateAnswer(answer) {
|
|
|
90
90
|
* Extract it for reporting; the shrink guard itself compares the FULL
|
|
91
91
|
* discovered-command sets, so a reason this cannot parse still guards.
|
|
92
92
|
*/
|
|
93
|
-
export function
|
|
93
|
+
export function exitedCommandFromReason(reason) {
|
|
94
94
|
const m = /`([^`]+)`\s+exited\b/.exec(reason);
|
|
95
95
|
return m ? m[1] : null;
|
|
96
96
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type HealthCommand } from './repo-health-check.js';
|
|
2
|
-
import {
|
|
2
|
+
import { deriveOpenDebts, rerunDebtVerifyCommand, type AcceptDebt } from './accept-debt.js';
|
|
3
3
|
import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, type BootDeps } from './boot-probe.js';
|
|
4
4
|
import { type CommandRunner } from './command-run.js';
|
|
5
5
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
6
6
|
import { type EnvClosure } from './env-template-closure.js';
|
|
7
|
+
import { observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
|
|
7
8
|
export interface FinalGateOutcome {
|
|
8
9
|
/** true → statics and every runnable integration command passed (or nothing to run). */
|
|
9
10
|
ok: boolean;
|
|
@@ -129,159 +130,12 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
|
129
130
|
* guard already owns.
|
|
130
131
|
*/
|
|
131
132
|
export declare function discoverGateCommandBodies(cwd: string): Record<string, string>;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* pass — it ran and exited 0. The ONLY outcome that may close a debt.
|
|
135
|
-
* fail — it ran and exited non-zero for a real reason. Debt stays open.
|
|
136
|
-
* gap — nothing was observed: the shell/runner never spawned, 127 inside the
|
|
137
|
-
* chain, a timeout, a missing browser, or absent external infrastructure.
|
|
138
|
-
* INCONCLUSIVE, so the debt stays open (surface, never re-hide).
|
|
139
|
-
*/
|
|
140
|
-
export type VerifyRerunOutcome = {
|
|
141
|
-
outcome: 'pass';
|
|
142
|
-
} | {
|
|
143
|
-
outcome: 'fail';
|
|
144
|
-
status: number;
|
|
145
|
-
tail: string;
|
|
146
|
-
} | {
|
|
147
|
-
outcome: 'gap';
|
|
148
|
-
detail: string;
|
|
149
|
-
};
|
|
150
|
-
/**
|
|
151
|
-
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
152
|
-
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
153
|
-
* command itself rather than by a judgement about it.
|
|
154
|
-
*
|
|
155
|
-
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
156
|
-
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
157
|
-
* are all ordinary there. The leading command word is still resolved through
|
|
158
|
-
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
159
|
-
* gap (mx5 run 16's blindness, one level down).
|
|
160
|
-
*
|
|
161
|
-
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
162
|
-
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
163
|
-
* debt exactly as open as it was.
|
|
164
|
-
*/
|
|
165
|
-
export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
|
|
166
|
-
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
167
|
-
run?: CommandRunner): VerifyRerunOutcome;
|
|
168
|
-
/**
|
|
169
|
-
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
170
|
-
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
171
|
-
* decided on statics alone and stamped a permanently blank app green. Per-command
|
|
172
|
-
* env-gap skips stay legitimate (a missing browser must not fail a suite); what
|
|
173
|
-
* may never happen again is ALL of them skipping while the gate still reports
|
|
174
|
-
* PASS — a gate that observed nothing dynamic has no basis to vouch for the
|
|
175
|
-
* assembled app. Pure so the semantics are unit-tested; the caller feeds it the
|
|
176
|
-
* attempt/observation counters and runner resolvability.
|
|
177
|
-
*/
|
|
178
|
-
export declare function observabilityGapFailure(args: {
|
|
179
|
-
/** Dynamic commands the gate discovered and tried to run. */
|
|
180
|
-
attempted: number;
|
|
181
|
-
/** Of those, how many it actually OBSERVED (a real pass OR a real fail —
|
|
182
|
-
* either proves the command ran; only skips observe nothing). */
|
|
183
|
-
observed: number;
|
|
184
|
-
/** Of the skips, how many were SPAWN failures (runner never ran, ENOENT).
|
|
185
|
-
* Tool-level gaps (missing browser, 127 inside the chain, timeout) prove
|
|
186
|
-
* the runner itself works and keep the classic env-gap contract — the
|
|
187
|
-
* blindness class fires only when EVERY attempt failed to even spawn. */
|
|
188
|
-
spawnFailures: number;
|
|
189
|
-
/** Distinct runner bins across the attempted commands. */
|
|
190
|
-
runnerBins: string[];
|
|
191
|
-
/** Is this runner spawnable (bare or via a known install location)? */
|
|
192
|
-
runnerResolvable: (bin: string) => boolean;
|
|
193
|
-
}): string | null;
|
|
194
|
-
/**
|
|
195
|
-
* The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
|
|
196
|
-
* but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
|
|
197
|
-
* `attempted === 0`, and until now that silence fell straight through to
|
|
198
|
-
* `PASS — no integration command found (statics passed)`: the run-16 blindness class
|
|
199
|
-
* entering through a different door, where "we never checked" reads exactly like "we
|
|
200
|
-
* checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
|
|
201
|
-
* shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
|
|
202
|
-
* godot-engine (package.json whose only script is `verify`) reproduces it live today.
|
|
203
|
-
*
|
|
204
|
-
* So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
|
|
205
|
-
* existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
|
|
206
|
-
*
|
|
207
|
-
* WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
|
|
208
|
-
* the resolution):
|
|
209
|
-
* - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
|
|
210
|
-
* the runs with no dynamic evidence were exactly the runs already known to be
|
|
211
|
-
* carrying defects.
|
|
212
|
-
* - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
|
|
213
|
-
* gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
|
|
214
|
-
* integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
|
|
215
|
-
* missing signal was never the debt, it was the word PASS endorsing the run, and
|
|
216
|
-
* that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
|
|
217
|
-
* seed is `reason`; "no integration command is discoverable" is not fixable by
|
|
218
|
-
* editing code, so the highest-probability child response is to FABRICATE a
|
|
219
|
-
* runnable command to satisfy the gate — the same fabrication class that refuted
|
|
220
|
-
* the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
|
|
221
|
-
* run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
|
|
222
|
-
* means IAR1 and godot-engine can NEVER discover a command, so blocking would end
|
|
223
|
-
* every non-npm run in `failed` permanently, with no remedy — the task's own I3
|
|
224
|
-
* ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
|
|
225
|
-
* its stated consequence is to downgrade to a warning and say so. This is that.
|
|
226
|
-
* The teeth are elsewhere and are real: the verdict word changes, the gate trail says
|
|
227
|
-
* UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
|
|
228
|
-
* gate re-surfaces (it can never auto-close — it is not static-class).
|
|
229
|
-
*/
|
|
230
|
-
export declare function unobservedVerdict(args: {
|
|
231
|
-
/** Dynamic commands the gate discovered and tried to run (0 ⇒ nothing existed). */
|
|
232
|
-
discovered: number;
|
|
233
|
-
/** Of those, how many actually RAN (a real pass or a real fail). */
|
|
234
|
-
observed: number;
|
|
235
|
-
}): string | null;
|
|
133
|
+
export { runVerifyCommandLine, type VerifyRerunOutcome } from './command-run.js';
|
|
134
|
+
export { observabilityGapFailure, unobservedVerdict };
|
|
236
135
|
export { taskThatIntroduced };
|
|
237
136
|
export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
|
|
238
137
|
export type { BootDeps };
|
|
239
|
-
|
|
240
|
-
* ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
|
|
241
|
-
* the user accepted despite a verify-FAIL and re-check each against the CURRENT
|
|
242
|
-
* tree. A static-class debt whose statics now pass is provably RESOLVED (a later
|
|
243
|
-
* task fixed it) and pruned from the ledger; every other debt cannot be proven
|
|
244
|
-
* resolved deterministically, so it stays OPEN and is surfaced — a run may not
|
|
245
|
-
* complete silently carrying an accepted defect. FP-safe by construction (see
|
|
246
|
-
* accept-debt.ts). Best-effort: a ledger read/write failure must never break the
|
|
247
|
-
* caller.
|
|
248
|
-
*
|
|
249
|
-
* FACTORED OUT of runFinalIntegrationGate (nexttask 6): the derivation has to be
|
|
250
|
-
* runnable at a SECOND moment — after a converged final-gate autofix, where the
|
|
251
|
-
* orchestrator used to rebuild its gate outcome as a bare `{ok, reason}` and drop
|
|
252
|
-
* `openDebts` entirely. The report a run ends on has to be derived from the tree
|
|
253
|
-
* the run ends with, not from the tree as it was before the fix pass.
|
|
254
|
-
*
|
|
255
|
-
* `staticOk` is the caller's claim about the CURRENT statics, and it is the only
|
|
256
|
-
* thing that can auto-close a static-class debt — so a caller that does not know
|
|
257
|
-
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
258
|
-
*/
|
|
259
|
-
export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise<{
|
|
260
|
-
openDebts: AcceptDebt[];
|
|
261
|
-
debtNote?: string;
|
|
262
|
-
trail?: string[];
|
|
263
|
-
}>;
|
|
264
|
-
/**
|
|
265
|
-
* Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
|
|
266
|
-
* (`inv-no-write`) wrapped around it.
|
|
267
|
-
*
|
|
268
|
-
* A VERIFY command is the project's own command and may legitimately write (a build
|
|
269
|
-
* emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
|
|
270
|
-
* a passing tree and have that count as the debt being fixed — the run would then be
|
|
271
|
-
* certifying its own side effect. So tracked state is captured before and after, and
|
|
272
|
-
* a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
|
|
273
|
-
* change named. Untracked output is left alone: it is what a build legitimately
|
|
274
|
-
* produces, and `git status --porcelain` in a repo with the usual ignores does not
|
|
275
|
-
* see it.
|
|
276
|
-
*
|
|
277
|
-
* A repository the guard cannot read (no git, git absent) is not a licence to skip
|
|
278
|
-
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
279
|
-
* assumption rather than an observation.
|
|
280
|
-
*/
|
|
281
|
-
export declare function rerunDebtVerifyCommand(cwd: string, command: string,
|
|
282
|
-
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
283
|
-
* the guard's four outcomes are testable without a repo or a real command. */
|
|
284
|
-
run?: CommandRunner): VerifyRerunResult;
|
|
138
|
+
export { deriveOpenDebts, rerunDebtVerifyCommand };
|
|
285
139
|
/**
|
|
286
140
|
* Where in the gate a closure scan runs. The two stages are NOT interchangeable
|
|
287
141
|
* and neither is a scheduling preference:
|