@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.
Files changed (38) hide show
  1. package/dist/config/config.d.ts +37 -0
  2. package/dist/config/config.js +81 -18
  3. package/dist/task/accept-debt.js +2 -1
  4. package/dist/task/artifact-closure.js +18 -63
  5. package/dist/task/auto-orchestrator.js +205 -214
  6. package/dist/task/boot-probe.d.ts +46 -0
  7. package/dist/task/boot-probe.js +41 -21
  8. package/dist/task/coverage-loop.d.ts +11 -0
  9. package/dist/task/coverage-loop.js +16 -0
  10. package/dist/task/final-gate-fix.js +14 -24
  11. package/dist/task/final-gate.js +6 -1
  12. package/dist/task/fix-child.d.ts +64 -0
  13. package/dist/task/fix-child.js +66 -0
  14. package/dist/task/lint-fix.d.ts +7 -0
  15. package/dist/task/lint-fix.js +45 -9
  16. package/dist/task/orchestrator.js +9 -2
  17. package/dist/task/phases.d.ts +66 -4
  18. package/dist/task/phases.js +94 -34
  19. package/dist/task/plan-rounds.d.ts +86 -0
  20. package/dist/task/plan-rounds.js +105 -0
  21. package/dist/task/plan-session.d.ts +31 -21
  22. package/dist/task/plan-session.js +97 -120
  23. package/dist/task/qa-transcript.d.ts +100 -0
  24. package/dist/task/qa-transcript.js +99 -0
  25. package/dist/task/question-source.d.ts +117 -0
  26. package/dist/task/question-source.js +174 -0
  27. package/dist/task/serve-entry.js +6 -57
  28. package/dist/task/shipped-source.d.ts +67 -0
  29. package/dist/task/shipped-source.js +144 -0
  30. package/dist/task/task-gates.d.ts +1 -1
  31. package/dist/task/task-gates.js +4 -2
  32. package/dist/task/verify-work.d.ts +46 -0
  33. package/dist/task/verify-work.js +51 -3
  34. package/dist/task/widget.js +41 -9
  35. package/dist/workers/docs-core.d.ts +71 -1
  36. package/dist/workers/docs-core.js +131 -71
  37. package/dist/workers/pi-worker-core.js +23 -8
  38. package/package.json +1 -1
@@ -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 = spawn(runner.bin, args, {
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', (d) => (out = cap(out + String(d))));
571
- child.stderr?.on('data', (d) => (err = cap(err + String(d))));
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
- try {
584
- if (!child.pid)
585
- return;
586
- if (process.platform === 'win32') {
587
- // Windows has no process groups / negative-pid kill. taskkill
588
- // /T tears down the whole tree (the detached child plus any
589
- // grandchildren it spawned); /F forces it, so the SIGTERM→
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 { USER_CANCELLED } from './child-runner.js';
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
- const re = /FINAL-GATE-FIX:\s*(DONE|BLOCKED)\b[ \t]*(.*)/gi;
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
- let text;
223
- try {
224
- text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
225
- }
226
- catch (err) {
227
- const msg = err instanceof Error ? err.message : String(err);
228
- if (msg === USER_CANCELLED)
229
- throw err;
230
- return { ok: false, reason: `fix child failed: ${msg}` };
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
- const marker = parseFinalFixMarker(text);
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: ${marker.note}` });
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) {
@@ -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(`static checks: ${stat.reason}`);
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
+ }
@@ -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";
@@ -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
- try {
208
- await deps.runChild(LINT_FIX_TOOLS, buildLintFixPrompt(deps.failReason, frozen), deps.signal);
209
- }
210
- catch (err) {
211
- const msg = err instanceof Error ? err.message : String(err);
212
- // A cancel must propagate so the caller's USER_CANCELLED path runs.
213
- if (msg === '__user_cancelled__')
214
- throw err;
215
- return { ok: false, reason: `fix child failed: ${msg}` };
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.run(this._deps, this._pc);
265
+ out = await runPhaseRow(phase, this._deps, this._pc);
259
266
  }
260
267
  finally {
261
268
  const phaseMs = Date.now() - phaseStart;
@@ -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 — drop constraints research REFUTED before composing, then compose.
253
+ * COMPOSE — compose the spec from the refined task, the research and the Q&A.
216
254
  *
217
- * The drop mutates `p.refined` in place on purpose: the refuted constraint must be
218
- * gone from every later reader of the refined spec, not just from this call's
219
- * argument. Critique re-reads it.
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>;