@mjasnikovs/pi-task 0.38.16 → 0.38.18
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 +37 -0
- package/dist/config/config.js +81 -18
- package/dist/task/accept-debt.js +2 -1
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +205 -214
- package/dist/task/boot-probe.d.ts +46 -0
- package/dist/task/boot-probe.js +41 -21
- package/dist/task/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- package/dist/task/final-gate-fix.js +14 -24
- package/dist/task/final-gate.js +6 -1
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.js +9 -2
- package/dist/task/phases.d.ts +66 -4
- package/dist/task/phases.js +94 -34
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- package/dist/task/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +1 -1
- package/dist/task/task-gates.js +4 -2
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- package/dist/task/widget.js +41 -9
- package/dist/workers/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- package/dist/workers/pi-worker-core.js +23 -8
- package/package.json +1 -1
package/dist/task/boot-probe.js
CHANGED
|
@@ -486,6 +486,30 @@ function holderIsOurs(command, boot) {
|
|
|
486
486
|
return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
|
|
487
487
|
&& (c.includes(` ${script}`) || c.endsWith(script)));
|
|
488
488
|
}
|
|
489
|
+
/** The real spawn. Kept beside the seam so the default is one line to read. */
|
|
490
|
+
function defaultSpawnBoot(bin, args, o) {
|
|
491
|
+
return spawn(bin, args, o);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The real group teardown, best-effort. A group already gone is not an error.
|
|
495
|
+
*
|
|
496
|
+
* Windows has no process groups / negative-pid kill: `taskkill /T` tears down the
|
|
497
|
+
* whole tree (the detached child plus any grandchildren it spawned) and `/F`
|
|
498
|
+
* forces it, so the SIGTERM→SIGKILL escalation collapses to one idempotent call.
|
|
499
|
+
*/
|
|
500
|
+
function defaultKillGroup(pid, sig) {
|
|
501
|
+
try {
|
|
502
|
+
if (process.platform === 'win32') {
|
|
503
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
process.kill(-pid, sig);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// group already gone
|
|
511
|
+
}
|
|
512
|
+
}
|
|
489
513
|
/**
|
|
490
514
|
* Exercise the start command ONCE. For a CLI project (`expectServer` false) the
|
|
491
515
|
* command's own fate within the grace window decides:
|
|
@@ -547,8 +571,9 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
547
571
|
// the runner and carry its directory on PATH so the boot script's own chain
|
|
548
572
|
// can re-invoke it.
|
|
549
573
|
const runner = resolveRunner(bin);
|
|
574
|
+
const spawnBoot = opts.deps?.spawnBoot ?? defaultSpawnBoot;
|
|
550
575
|
return new Promise(resolve => {
|
|
551
|
-
const child =
|
|
576
|
+
const child = spawnBoot(runner.bin, args, {
|
|
552
577
|
cwd,
|
|
553
578
|
detached: true,
|
|
554
579
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -562,13 +587,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
562
587
|
// runner where the group-kill did not take, hanging the whole `bun test
|
|
563
588
|
// --isolate` run on the leaked child's piped stdio). unref() so a child
|
|
564
589
|
// we already tried to kill can never itself keep this process alive.
|
|
565
|
-
child.unref();
|
|
590
|
+
child.unref?.();
|
|
566
591
|
let out = '';
|
|
567
592
|
let err = '';
|
|
568
593
|
let listenerSeen = false;
|
|
569
594
|
const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
|
|
570
|
-
child.stdout?.on('data',
|
|
571
|
-
|
|
595
|
+
child.stdout?.on('data', d => {
|
|
596
|
+
out = cap(out + String(d));
|
|
597
|
+
});
|
|
598
|
+
child.stderr?.on('data', d => {
|
|
599
|
+
err = cap(err + String(d));
|
|
600
|
+
});
|
|
572
601
|
let settled = false;
|
|
573
602
|
const settle = (r) => {
|
|
574
603
|
if (settled)
|
|
@@ -579,24 +608,15 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
579
608
|
clearInterval(poll);
|
|
580
609
|
resolve(r);
|
|
581
610
|
};
|
|
611
|
+
const reapGroup = opts.deps?.killGroup ?? defaultKillGroup;
|
|
582
612
|
const killGroup = (sig) => {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
// SIGKILL escalation collapses to one idempotent call.
|
|
591
|
-
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
process.kill(-child.pid, sig);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
catch {
|
|
598
|
-
// group already gone
|
|
599
|
-
}
|
|
613
|
+
// Truthiness, deliberately: `process.kill(-0, sig)` signals the
|
|
614
|
+
// CALLER's own process group, so a pid of 0 turns a best-effort
|
|
615
|
+
// teardown into self-termination. Node's spawn never yields 0, but
|
|
616
|
+
// `spawnBoot` is a seam now and a fake or future child could.
|
|
617
|
+
if (!child.pid)
|
|
618
|
+
return;
|
|
619
|
+
reapGroup(child.pid, sig);
|
|
600
620
|
};
|
|
601
621
|
const passAndKill = (renderNote) => {
|
|
602
622
|
settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
|
|
@@ -139,3 +139,14 @@ export interface ScoredPlan {
|
|
|
139
139
|
*/
|
|
140
140
|
judgeMissing: string[];
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Normalise a missing-area string for cross-round identity — lowercased alnum
|
|
144
|
+
* words, punctuation and quote-wrapping collapsed. Used only to tell whether an
|
|
145
|
+
* adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
|
|
146
|
+
* round); intentionally coarse, so trivial rewording of the same area does not
|
|
147
|
+
* read as new and buy an extra round.
|
|
148
|
+
*
|
|
149
|
+
* Lives here rather than in `auto-orchestrator.ts` because its only consumer is
|
|
150
|
+
* `CoverageLedger.consider`, which is the adoption rule this file owns.
|
|
151
|
+
*/
|
|
152
|
+
export declare function normMissingArea(s: string): string;
|
|
@@ -290,3 +290,19 @@ export function decideAdoption(current, retry, hasRequirements) {
|
|
|
290
290
|
}
|
|
291
291
|
return { adopt: true, reason: 'count floor met, no coverage regression', dropped: [] };
|
|
292
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Normalise a missing-area string for cross-round identity — lowercased alnum
|
|
295
|
+
* words, punctuation and quote-wrapping collapsed. Used only to tell whether an
|
|
296
|
+
* adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
|
|
297
|
+
* round); intentionally coarse, so trivial rewording of the same area does not
|
|
298
|
+
* read as new and buy an extra round.
|
|
299
|
+
*
|
|
300
|
+
* Lives here rather than in `auto-orchestrator.ts` because its only consumer is
|
|
301
|
+
* `CoverageLedger.consider`, which is the adoption rule this file owns.
|
|
302
|
+
*/
|
|
303
|
+
export function normMissingArea(s) {
|
|
304
|
+
return s
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* legitimate whole-repo fix that run needed (migrate.ts — frozen by its own
|
|
44
44
|
* producing task). It activates only when a run-GLOBAL freeze source exists.
|
|
45
45
|
*/
|
|
46
|
-
import {
|
|
46
|
+
import { parseFixMarker, runFixChild } from './fix-child.js';
|
|
47
47
|
import { findForbiddenDeletions, diffIgnoredSnapshots, ignoredWriteTrailLine, ignoredWriteUnobservedNote } from './write-guard.js';
|
|
48
48
|
import { findNarrowedCommands, narrowingRejectionText } from './command-shrink.js';
|
|
49
49
|
/** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
|
|
@@ -151,16 +151,7 @@ export function buildFinalFixPrompt(failReason) {
|
|
|
151
151
|
* early-out on a self-declared BLOCKED.
|
|
152
152
|
*/
|
|
153
153
|
export function parseFinalFixMarker(text) {
|
|
154
|
-
|
|
155
|
-
let last = null;
|
|
156
|
-
for (let m = re.exec(text); m !== null; m = re.exec(text))
|
|
157
|
-
last = m;
|
|
158
|
-
if (!last)
|
|
159
|
-
return { blocked: false };
|
|
160
|
-
if (last[1].toUpperCase() === 'BLOCKED') {
|
|
161
|
-
return { blocked: true, note: last[2].trim() || 'no reason given' };
|
|
162
|
-
}
|
|
163
|
-
return { blocked: false, note: last[2].trim() || undefined };
|
|
154
|
+
return parseFixMarker('FINAL-GATE-FIX', text);
|
|
164
155
|
}
|
|
165
156
|
/**
|
|
166
157
|
* STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
|
|
@@ -219,16 +210,16 @@ export async function runFinalGateAutofix(deps) {
|
|
|
219
210
|
// ignored files are untracked, so git alone cannot tell a file this pass wrote
|
|
220
211
|
// from one that was already sitting in the worktree.
|
|
221
212
|
const ignoredBefore = deps.ignoredSnapshot ? await deps.ignoredSnapshot() : null;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
213
|
+
// The four-rung ladder is task/fix-child.ts, shared with the per-task pass.
|
|
214
|
+
const end = await runFixChild({
|
|
215
|
+
runChild: deps.runChild,
|
|
216
|
+
tools: FINAL_FIX_TOOLS,
|
|
217
|
+
prompt: buildFinalFixPrompt(deps.failReason),
|
|
218
|
+
signal: deps.signal,
|
|
219
|
+
marker: 'FINAL-GATE-FIX'
|
|
220
|
+
});
|
|
221
|
+
if (end.kind === 'error')
|
|
222
|
+
return { ok: false, reason: `fix child failed: ${end.msg}` };
|
|
232
223
|
// What the child wrote to gitignored paths. Recorded on the trail IMMEDIATELY —
|
|
233
224
|
// before any guard can reject the attempt — because `discard` reverts tracked
|
|
234
225
|
// edits only: an ignored file the pass wrote survives a rejection, and the trail
|
|
@@ -327,10 +318,9 @@ export async function runFinalGateAutofix(deps) {
|
|
|
327
318
|
return r;
|
|
328
319
|
}
|
|
329
320
|
}
|
|
330
|
-
|
|
331
|
-
if (marker.blocked) {
|
|
321
|
+
if (end.kind === 'blocked') {
|
|
332
322
|
// Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
|
|
333
|
-
return withIgnored({ ok: false, reason: `fix child blocked: ${
|
|
323
|
+
return withIgnored({ ok: false, reason: `fix child blocked: ${end.note}` });
|
|
334
324
|
}
|
|
335
325
|
const fin = await deps.gate(deps.cwd);
|
|
336
326
|
if (!fin.ok) {
|
package/dist/task/final-gate.js
CHANGED
|
@@ -62,6 +62,7 @@ import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure,
|
|
|
62
62
|
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
63
63
|
import { makefileRecipe } from './command-shrink.js';
|
|
64
64
|
import { GateTally, observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
|
|
65
|
+
import { VERIFY_FAIL_PREFIX } from './verify-work.js';
|
|
65
66
|
/**
|
|
66
67
|
* The project's OWN whole-repo integration commands (test, then build — test
|
|
67
68
|
* first because it is the richer signal and the more common script). First
|
|
@@ -415,8 +416,12 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
|
|
|
415
416
|
// dynamic counters, the notes) and the verdict is assembled ONCE at the end —
|
|
416
417
|
// see gate-tally.ts for what each method means.
|
|
417
418
|
const tally = new GateTally();
|
|
419
|
+
// The prefix comes from VERIFY_FAIL_PREFIX so this run-level mint and the
|
|
420
|
+
// task-level `repo health:` one stay linked: both are the deterministic
|
|
421
|
+
// whole-repo static check, and `isStaticClassDebt` must recognise a debt that
|
|
422
|
+
// entered the ledger through EITHER altitude.
|
|
418
423
|
if (!stat.ok)
|
|
419
|
-
tally.fail(
|
|
424
|
+
tally.fail(`${VERIFY_FAIL_PREFIX['static-checks']} ${stat.reason}`);
|
|
420
425
|
// Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
|
|
421
426
|
// scripts that fell through decompose and shipped missing, unchecked. Diff the
|
|
422
427
|
// plan-time-extracted declared scripts against the manifest; a missing one is a
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running ONE bounded fix child and deciding what its ending MEANS.
|
|
3
|
+
*
|
|
4
|
+
* Two graduated-resolution passes drive a fix child through the same four rungs —
|
|
5
|
+
* cancel propagates, a thrown child is an error, a self-declared BLOCKED ends the
|
|
6
|
+
* attempt, anything else is DONE — and `final-gate-fix.ts:12` says so out loud:
|
|
7
|
+
* it "mirrors the per-task graduated-resolution shape (lint-fix.ts)". This is that
|
|
8
|
+
* ladder, once.
|
|
9
|
+
*
|
|
10
|
+
* NOT the rejected sharing. CONTEXT.md's "the two resolution loops stay two" is
|
|
11
|
+
* about `runGatesForTask` vs `runFinalGateStage` — the LOOPS, at two altitudes.
|
|
12
|
+
* This is one altitude down: the single child invocation inside each, where the
|
|
13
|
+
* two had actually drifted.
|
|
14
|
+
*
|
|
15
|
+
* - **The lint-fix marker was a DEAD protocol.** `buildLintFixPrompt` instructs
|
|
16
|
+
* the child to end with `LINT-FIX: DONE` or `LINT-FIX: BLOCKED <why>`, and the
|
|
17
|
+
* call site was `await deps.runChild(...)` with the return value DISCARDED —
|
|
18
|
+
* nothing in `src/` or `scripts/` parsed it. The twin parsed its own marker and
|
|
19
|
+
* used it to skip the expensive gate re-run. So a lint-fix child that reported
|
|
20
|
+
* BLOCKED still paid the full guard stack plus a whole repo-health run (15–69s
|
|
21
|
+
* measured), and the user was told `did not converge: <health.reason>` instead
|
|
22
|
+
* of the child's own stated reason. The suite fed `'LINT-FIX: DONE'` as fake
|
|
23
|
+
* output, so it stayed green whether the marker was parsed or deleted.
|
|
24
|
+
* - **The cancel rung re-typed its constant.** `lint-fix.ts` compared against a
|
|
25
|
+
* literal `'__user_cancelled__'`; it was the only production site in `src/` not
|
|
26
|
+
* importing `USER_CANCELLED`.
|
|
27
|
+
*
|
|
28
|
+
* What stays per-site: the arbiter (a gate re-run vs a repo-health re-run), the
|
|
29
|
+
* result shape, and the guard sets. Only the child call is shared.
|
|
30
|
+
*/
|
|
31
|
+
/** How one fix child ended. A CANCEL is not a member: it throws, so the caller's
|
|
32
|
+
* own `USER_CANCELLED` path runs unchanged. */
|
|
33
|
+
export type FixChildEnd = {
|
|
34
|
+
kind: 'done';
|
|
35
|
+
text: string;
|
|
36
|
+
note?: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'blocked';
|
|
39
|
+
text: string;
|
|
40
|
+
note: string;
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'error';
|
|
43
|
+
msg: string;
|
|
44
|
+
};
|
|
45
|
+
export interface FixChildInput {
|
|
46
|
+
runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
|
|
47
|
+
tools: string;
|
|
48
|
+
prompt: string;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
/** The marker word this pass's prompt instructs — `LINT-FIX`, `FINAL-GATE-FIX`. */
|
|
51
|
+
marker: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse a fix child's final marker. Last match wins: the model reasons before
|
|
55
|
+
* concluding, and bash output can echo the words. No marker → DONE, because the
|
|
56
|
+
* pass's own arbiter re-runs either way; a missing marker only forfeits the
|
|
57
|
+
* early-out on a self-declared BLOCKED.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseFixMarker(marker: string, text: string): {
|
|
60
|
+
blocked: boolean;
|
|
61
|
+
note?: string;
|
|
62
|
+
};
|
|
63
|
+
/** Run one fix child through the four-rung ladder. Cancel THROWS; nothing else does. */
|
|
64
|
+
export declare function runFixChild(input: FixChildInput): Promise<FixChildEnd>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running ONE bounded fix child and deciding what its ending MEANS.
|
|
3
|
+
*
|
|
4
|
+
* Two graduated-resolution passes drive a fix child through the same four rungs —
|
|
5
|
+
* cancel propagates, a thrown child is an error, a self-declared BLOCKED ends the
|
|
6
|
+
* attempt, anything else is DONE — and `final-gate-fix.ts:12` says so out loud:
|
|
7
|
+
* it "mirrors the per-task graduated-resolution shape (lint-fix.ts)". This is that
|
|
8
|
+
* ladder, once.
|
|
9
|
+
*
|
|
10
|
+
* NOT the rejected sharing. CONTEXT.md's "the two resolution loops stay two" is
|
|
11
|
+
* about `runGatesForTask` vs `runFinalGateStage` — the LOOPS, at two altitudes.
|
|
12
|
+
* This is one altitude down: the single child invocation inside each, where the
|
|
13
|
+
* two had actually drifted.
|
|
14
|
+
*
|
|
15
|
+
* - **The lint-fix marker was a DEAD protocol.** `buildLintFixPrompt` instructs
|
|
16
|
+
* the child to end with `LINT-FIX: DONE` or `LINT-FIX: BLOCKED <why>`, and the
|
|
17
|
+
* call site was `await deps.runChild(...)` with the return value DISCARDED —
|
|
18
|
+
* nothing in `src/` or `scripts/` parsed it. The twin parsed its own marker and
|
|
19
|
+
* used it to skip the expensive gate re-run. So a lint-fix child that reported
|
|
20
|
+
* BLOCKED still paid the full guard stack plus a whole repo-health run (15–69s
|
|
21
|
+
* measured), and the user was told `did not converge: <health.reason>` instead
|
|
22
|
+
* of the child's own stated reason. The suite fed `'LINT-FIX: DONE'` as fake
|
|
23
|
+
* output, so it stayed green whether the marker was parsed or deleted.
|
|
24
|
+
* - **The cancel rung re-typed its constant.** `lint-fix.ts` compared against a
|
|
25
|
+
* literal `'__user_cancelled__'`; it was the only production site in `src/` not
|
|
26
|
+
* importing `USER_CANCELLED`.
|
|
27
|
+
*
|
|
28
|
+
* What stays per-site: the arbiter (a gate re-run vs a repo-health re-run), the
|
|
29
|
+
* result shape, and the guard sets. Only the child call is shared.
|
|
30
|
+
*/
|
|
31
|
+
import { USER_CANCELLED } from './child-runner.js';
|
|
32
|
+
/**
|
|
33
|
+
* Parse a fix child's final marker. Last match wins: the model reasons before
|
|
34
|
+
* concluding, and bash output can echo the words. No marker → DONE, because the
|
|
35
|
+
* pass's own arbiter re-runs either way; a missing marker only forfeits the
|
|
36
|
+
* early-out on a self-declared BLOCKED.
|
|
37
|
+
*/
|
|
38
|
+
export function parseFixMarker(marker, text) {
|
|
39
|
+
const re = new RegExp(`${marker}:\\s*(DONE|BLOCKED)\\b[ \\t]*(.*)`, 'gi');
|
|
40
|
+
let last = null;
|
|
41
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text))
|
|
42
|
+
last = m;
|
|
43
|
+
if (!last)
|
|
44
|
+
return { blocked: false };
|
|
45
|
+
if (last[1].toUpperCase() === 'BLOCKED') {
|
|
46
|
+
return { blocked: true, note: last[2].trim() || 'no reason given' };
|
|
47
|
+
}
|
|
48
|
+
return { blocked: false, note: last[2].trim() || undefined };
|
|
49
|
+
}
|
|
50
|
+
/** Run one fix child through the four-rung ladder. Cancel THROWS; nothing else does. */
|
|
51
|
+
export async function runFixChild(input) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = await input.runChild(input.tools, input.prompt, input.signal);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
if (msg === USER_CANCELLED)
|
|
59
|
+
throw err;
|
|
60
|
+
return { kind: 'error', msg };
|
|
61
|
+
}
|
|
62
|
+
const parsed = parseFixMarker(input.marker, text);
|
|
63
|
+
if (parsed.blocked)
|
|
64
|
+
return { kind: 'blocked', text, note: parsed.note ?? 'no reason given' };
|
|
65
|
+
return parsed.note ? { kind: 'done', text, note: parsed.note } : { kind: 'done', text };
|
|
66
|
+
}
|
package/dist/task/lint-fix.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface LintFixDeps {
|
|
|
42
42
|
* deletion guard is disarmed (prior behavior).
|
|
43
43
|
*/
|
|
44
44
|
introducedBy?: (rel: string) => Promise<string | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Debug-log sink. This dep did not exist, so all four of this pass's guard
|
|
47
|
+
* trips were invisible in the trail while the twin (`FinalFixDeps.log`) logged
|
|
48
|
+
* three of its own — and a guard whose firing leaves no record cannot be
|
|
49
|
+
* distinguished from one that never armed.
|
|
50
|
+
*/
|
|
51
|
+
log?: (msg: string) => void;
|
|
45
52
|
}
|
|
46
53
|
/** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
|
|
47
54
|
export declare const LINT_FIX_TOOLS = "read,edit,bash";
|
package/dist/task/lint-fix.js
CHANGED
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
* `currentTaskId` and `introducedBy` are wired.
|
|
59
59
|
*/
|
|
60
60
|
import { parseChangedFrozenFiles, pathNamedIn, revertFrozenPaths } from './frozen-path-guard.js';
|
|
61
|
+
import { runFixChild } from './fix-child.js';
|
|
61
62
|
import { parseTreeChanges } from './write-guard.js';
|
|
62
63
|
import { findCrossTaskDeletions } from './task-provenance.js';
|
|
63
64
|
/** The fix child edits and runs the checker; bash exists to RUN the check, not git. */
|
|
@@ -204,16 +205,27 @@ export async function runBoundedLintFix(deps) {
|
|
|
204
205
|
// guard for this run (inconclusive ≠ evidence).
|
|
205
206
|
const deletionGuardArmed = Boolean(deps.currentTaskId && deps.introducedBy);
|
|
206
207
|
const preChanges = deletionGuardArmed ? await treeChanges(deps) : null;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
208
|
+
// The four-rung ladder lives in task/fix-child.ts. A cancel still propagates
|
|
209
|
+
// (it THROWS, so the caller's USER_CANCELLED path is unchanged); what is new
|
|
210
|
+
// here is the BLOCKED rung, which this pass instructed in its prompt and then
|
|
211
|
+
// discarded the reply of — so a child that reported BLOCKED still paid the
|
|
212
|
+
// whole guard stack and a repo-health re-run (15–69s measured) to be told
|
|
213
|
+
// `did not converge`, instead of its own stated reason.
|
|
214
|
+
const end = await runFixChild({
|
|
215
|
+
runChild: deps.runChild,
|
|
216
|
+
tools: LINT_FIX_TOOLS,
|
|
217
|
+
prompt: buildLintFixPrompt(deps.failReason, frozen),
|
|
218
|
+
signal: deps.signal,
|
|
219
|
+
marker: 'LINT-FIX'
|
|
220
|
+
});
|
|
221
|
+
if (end.kind === 'error') {
|
|
222
|
+
deps.log?.(`lint-fix child failed — ${end.msg}`);
|
|
223
|
+
return { ok: false, reason: `fix child failed: ${end.msg}` };
|
|
216
224
|
}
|
|
225
|
+
// A BLOCKED child is NOT an early return from here: the guards below exist to
|
|
226
|
+
// catch a child that discarded work, and a child can discard work and then
|
|
227
|
+
// block. The marker is consulted after them, in place of the re-run — which is
|
|
228
|
+
// where the twin consults its own.
|
|
217
229
|
// REVERT-GUARD: every pre-existing work file must still differ from HEAD, and
|
|
218
230
|
// every pre-existing untracked file must still exist. Trip → restore snapshot.
|
|
219
231
|
// Every comparison requires git to have actually SUCCEEDED: a git error after
|
|
@@ -250,6 +262,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
250
262
|
await deps.git(['checkout', snapshot, '--', '.', EXCLUDE_TASKS_DIR]);
|
|
251
263
|
await deps.git(['reset']);
|
|
252
264
|
}
|
|
265
|
+
deps.log?.(`lint-fix REVERT-GUARD — discarded ${violations.length} work file(s)`);
|
|
253
266
|
return {
|
|
254
267
|
ok: false,
|
|
255
268
|
reason: `revert-guard: fix pass discarded work (${violations.slice(0, 3).join(', ')}`
|
|
@@ -277,6 +290,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
277
290
|
if (crossDeletions.length > 0) {
|
|
278
291
|
await deps.git(['checkout', '-f', 'HEAD', '--', ...crossDeletions.map(d => d.path)]);
|
|
279
292
|
const named = crossDeletions.map(d => `${d.path} — ${d.owner}'s deliverable`);
|
|
293
|
+
deps.log?.(`lint-fix CROSS-TASK-DELETION GUARD — ${named[0]}`);
|
|
280
294
|
return {
|
|
281
295
|
ok: false,
|
|
282
296
|
reason: `cross-task-deletion: fix child DELETED sibling task deliverable(s) `
|
|
@@ -300,6 +314,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
300
314
|
const frozenViolations = [...postFrozenDirty].filter(f => !preFrozenDirty.has(f));
|
|
301
315
|
if (frozenViolations.length > 0) {
|
|
302
316
|
const reverted = await revertFrozenPaths(frozenViolations, deps.git);
|
|
317
|
+
deps.log?.(`lint-fix FROZEN-PATH GUARD — ${frozenViolations.slice(0, 3).join(', ')}`);
|
|
303
318
|
return {
|
|
304
319
|
ok: false,
|
|
305
320
|
reason: `frozen-path: fix child modified spec-frozen path(s) `
|
|
@@ -311,8 +326,28 @@ export async function runBoundedLintFix(deps) {
|
|
|
311
326
|
}
|
|
312
327
|
}
|
|
313
328
|
}
|
|
329
|
+
if (end.kind === 'blocked')
|
|
330
|
+
deps.log?.(`lint-fix BLOCKED — ${end.note}`);
|
|
331
|
+
// The CHECK is the arbiter, including after a BLOCKED marker.
|
|
332
|
+
//
|
|
333
|
+
// The marker is scraped last-match-wins out of arbitrary child output, and
|
|
334
|
+
// LINT_FIX_TOOLS carries bash — so the child's own command output is in that
|
|
335
|
+
// text. A child can also genuinely converge and THEN block: `eslint --fix`
|
|
336
|
+
// silently clears the last finding, and the model still reports
|
|
337
|
+
// `LINT-FIX: BLOCKED the generated file is frozen`. Returning not-applied on
|
|
338
|
+
// the marker alone would send the gate to `deps.recommend(...)` with a
|
|
339
|
+
// failReason that no longer describes the tree, skipping the re-verify and
|
|
340
|
+
// burning a full implementation re-run on findings that are already gone —
|
|
341
|
+
// while the child's edits sit in the working tree.
|
|
342
|
+
//
|
|
343
|
+
// So BLOCKED does not decide; it only supplies a better REASON when the check
|
|
344
|
+
// agrees nothing converged. That was the durable half of the win. Skipping the
|
|
345
|
+
// re-run was the other half, and it is not worth this.
|
|
314
346
|
const health = await deps.repoHealth();
|
|
315
347
|
if (!health.ok) {
|
|
348
|
+
if (end.kind === 'blocked') {
|
|
349
|
+
return { ok: false, reason: `fix child blocked: ${end.note}` };
|
|
350
|
+
}
|
|
316
351
|
// FROZEN-PATH TRACE on non-convergence (PROMPT 1 layer B): when the child
|
|
317
352
|
// was honest — it did NOT touch the frozen path, so the guard above never
|
|
318
353
|
// tripped — but the check is still red and its own output NAMES a frozen
|
|
@@ -324,6 +359,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
324
359
|
// rounds an impl re-run under the same freeze cannot converge out of.
|
|
325
360
|
const implicated = frozen.filter(p => pathNamedIn(`${health.reason}\n${health.output ?? ''}`, p));
|
|
326
361
|
if (implicated.length > 0) {
|
|
362
|
+
deps.log?.(`lint-fix FROZEN-PATH TRACE — ${implicated.slice(0, 3).join(', ')}`);
|
|
327
363
|
return {
|
|
328
364
|
ok: false,
|
|
329
365
|
reason: `frozen-path: static findings implicate spec-frozen path(s) `
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as fsp from 'node:fs/promises';
|
|
19
19
|
import * as path from 'node:path';
|
|
20
|
-
import { PHASES, postCommitPhase } from './phases.js';
|
|
20
|
+
import { PHASES, postCommitPhase, replayPhaseCarry, runPhaseRow } from './phases.js';
|
|
21
21
|
import { handleFailure } from './failure-classifier.js';
|
|
22
22
|
import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
|
|
23
23
|
import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
|
|
@@ -246,6 +246,13 @@ export class TaskRunner {
|
|
|
246
246
|
const idx = PHASE_INDEX[phase.name];
|
|
247
247
|
if (idx < resumeIdx) {
|
|
248
248
|
this._pc[phase.field] = (await readSection(cwd, id, phase.section)) ?? '';
|
|
249
|
+
// A row's `section` restores exactly ONE field. A phase that also
|
|
250
|
+
// settles another one declares that as its `carry`, and the replay
|
|
251
|
+
// is the only thing standing between a resume and losing it — the
|
|
252
|
+
// task file deliberately stores the PRE-carry text for the field
|
|
253
|
+
// compose rewrites. Trail lines are discarded: the live run that
|
|
254
|
+
// wrote this section already recorded them.
|
|
255
|
+
await replayPhaseCarry(phase, this._deps, this._pc);
|
|
249
256
|
continue;
|
|
250
257
|
}
|
|
251
258
|
await advance(phase.name);
|
|
@@ -255,7 +262,7 @@ export class TaskRunner {
|
|
|
255
262
|
const phaseStart = Date.now();
|
|
256
263
|
let out;
|
|
257
264
|
try {
|
|
258
|
-
out = await phase
|
|
265
|
+
out = await runPhaseRow(phase, this._deps, this._pc);
|
|
259
266
|
}
|
|
260
267
|
finally {
|
|
261
268
|
const phaseMs = Date.now() - phaseStart;
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -33,7 +33,34 @@ export interface PhaseConfig {
|
|
|
33
33
|
name: PhaseName;
|
|
34
34
|
section: string;
|
|
35
35
|
field: OutputField;
|
|
36
|
+
/**
|
|
37
|
+
* The pure, idempotent transform this phase performs on `PhaseContext` fields
|
|
38
|
+
* OTHER than its own `field` — the phase's CARRY. Mutates `pc` and returns the
|
|
39
|
+
* trail lines the decision produced; it performs no I/O of its own.
|
|
40
|
+
*
|
|
41
|
+
* A carry runs on BOTH arms of the orchestrator loop: before `run` on the live
|
|
42
|
+
* path, and in place of `run` on the resume path. That is the whole point. A
|
|
43
|
+
* row's `section` is what resume restores from disk, and it restores exactly
|
|
44
|
+
* ONE field — so a phase that settles a second field and does not declare it
|
|
45
|
+
* here silently loses that work on every resume past it. Compose is the case
|
|
46
|
+
* that matters: it drops constraints research REFUTED from `refined`, critique
|
|
47
|
+
* re-reads `refined` as GROUND TRUTH under a prompt that says CONSTRAINTS "MUST
|
|
48
|
+
* be preserved", and `## refined prompt` on disk is deliberately left as refine
|
|
49
|
+
* wrote it. A resume at critique used to hand the refuted constraint straight
|
|
50
|
+
* back — the mx5 run-19 defect, restored by the very machinery that closed it.
|
|
51
|
+
*
|
|
52
|
+
* The trail is returned rather than written so the replay cannot duplicate a
|
|
53
|
+
* gate line the live run already recorded.
|
|
54
|
+
*/
|
|
55
|
+
carry?: (deps: PhaseDeps, pc: PhaseContext) => Promise<string[]>;
|
|
36
56
|
run: (deps: PhaseDeps, pc: PhaseContext) => Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* What this phase must do once its output is PERSISTED — after the section
|
|
59
|
+
* write, so a fault here cannot lose the output. A row field rather than a
|
|
60
|
+
* `phase.name !== 'refine'` test inside one function, so the compiler can tell
|
|
61
|
+
* you which rows have a post-commit effect.
|
|
62
|
+
*/
|
|
63
|
+
postCommit?: (deps: PhaseDeps, pc: PhaseContext, out: string) => Promise<void>;
|
|
37
64
|
}
|
|
38
65
|
/** Extract the TOOLING section commands from a research output string. */
|
|
39
66
|
export declare function extractToolingCommands(research: string): string[] | null;
|
|
@@ -176,6 +203,17 @@ export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext
|
|
|
176
203
|
* STEP 0 `scripts/refuted-constraint-baserate.ts`; A/B-1 `…-ab.ts` (PASS).
|
|
177
204
|
*/
|
|
178
205
|
export declare function dropRefutedConstraints(deps: PhaseDeps, refined: string, research: string): Promise<string>;
|
|
206
|
+
/**
|
|
207
|
+
* COMPOSE's carry: the refutation drop, as a `PhaseConfig.carry`.
|
|
208
|
+
*
|
|
209
|
+
* Same transform as `dropRefutedConstraints` over the same pure core, minus the
|
|
210
|
+
* recording — the caller decides whether this application is the live one or a
|
|
211
|
+
* resume replay. `dropRefutedConstraints` stays exported and unchanged for the
|
|
212
|
+
* harnesses under `scripts/` that drive the drop directly.
|
|
213
|
+
*/
|
|
214
|
+
export declare function composeCarry(_deps: PhaseDeps, pc: PhaseContext): Promise<string[]>;
|
|
215
|
+
/** Write a carry's trail to the debug log and the task file's `## gates` section. */
|
|
216
|
+
export declare function recordPhaseTrail(deps: PhaseDeps, phaseName: string, trail: string[]): Promise<void>;
|
|
179
217
|
export declare function phaseCompose(deps: PhaseDeps, refined: string, research: string, qa: string): Promise<string>;
|
|
180
218
|
export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: string, qa: string, planContext?: string, research?: string,
|
|
181
219
|
/**
|
|
@@ -212,11 +250,12 @@ export declare function researchPhase(d: PhaseDeps, p: PhaseContext): Promise<st
|
|
|
212
250
|
/** GRILL — the adaptive question loop, and the only phase that talks to the user. */
|
|
213
251
|
export declare function grillPhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
|
|
214
252
|
/**
|
|
215
|
-
* COMPOSE —
|
|
253
|
+
* COMPOSE — compose the spec from the refined task, the research and the Q&A.
|
|
216
254
|
*
|
|
217
|
-
* The drop
|
|
218
|
-
*
|
|
219
|
-
*
|
|
255
|
+
* The refutation drop that must happen first is compose's declared `carry`
|
|
256
|
+
* (`composeCarry`), not a line at the top of this function. It settles `p.refined`,
|
|
257
|
+
* which is not compose's own `field`, so the orchestrator has to replay it on the
|
|
258
|
+
* resume path too — and a `run` body cannot be replayed.
|
|
220
259
|
*/
|
|
221
260
|
export declare function composePhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
|
|
222
261
|
/**
|
|
@@ -245,4 +284,27 @@ export declare function critiquePhase(d: PhaseDeps, p: PhaseContext): Promise<st
|
|
|
245
284
|
* they run in is now asserted by driving the row rather than retyped in a test.
|
|
246
285
|
*/
|
|
247
286
|
export declare const PHASES: PhaseConfig[];
|
|
287
|
+
/**
|
|
288
|
+
* Run one phase row the way the orchestrator does: carry, then run.
|
|
289
|
+
*
|
|
290
|
+
* The row is the interface, so this is the surface a row-driving test crosses —
|
|
291
|
+
* calling `row.run` alone tests past it and would not have caught a carry that the
|
|
292
|
+
* resume path drops. The orchestrator adds only persistence, timings and the
|
|
293
|
+
* checkpoint around this.
|
|
294
|
+
*/
|
|
295
|
+
export declare function runPhaseRow(row: PhaseConfig, deps: PhaseDeps, pc: PhaseContext): Promise<string>;
|
|
296
|
+
/**
|
|
297
|
+
* Re-apply one phase row's carry on the RESUME path, where `run` is skipped.
|
|
298
|
+
*
|
|
299
|
+
* The trail is discarded: the live run that produced this phase's output already
|
|
300
|
+
* recorded it on `## gates`, and a replay must not append a second copy.
|
|
301
|
+
*/
|
|
302
|
+
export declare function replayPhaseCarry(row: PhaseConfig, deps: PhaseDeps, pc: PhaseContext): Promise<void>;
|
|
303
|
+
/** Dispatch a row's declared post-commit effect. Rows with none do nothing. */
|
|
248
304
|
export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* REFINE's post-commit: derive the task title from the refined prompt, then a short
|
|
307
|
+
* display label. Runs after the section write, so a fault here cannot lose the
|
|
308
|
+
* output it reads.
|
|
309
|
+
*/
|
|
310
|
+
export declare function refinePostCommit(deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
|