@mjasnikovs/pi-task 0.38.16 → 0.38.17

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 (37) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  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/workers/docs-core.d.ts +71 -1
  35. package/dist/workers/docs-core.js +131 -71
  36. package/dist/workers/pi-worker-core.js +23 -8
  37. package/package.json +1 -1
@@ -10,11 +10,10 @@ import * as fsp from 'node:fs/promises';
10
10
  import * as path from 'node:path';
11
11
  import { gateRunTask, markResumable } from './orchestrator.js';
12
12
  import { RUN_END_POLICY, runSucceeded } from './run-end.js';
13
- import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
13
+ import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
14
14
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
15
15
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
16
16
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
17
- import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
18
17
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
19
18
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
20
19
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
@@ -36,14 +35,18 @@ import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
36
35
  import { spawnCommand } from './command-run.js';
37
36
  import { getConfig } from '../config/config.js';
38
37
  import { debugLogLevel, shouldLogDebug } from './debug-log.js';
39
- import { isYoloMode, yoloPickAnswer, YOLO_STAMP } from './yolo.js';
38
+ import { isYoloMode, yoloPickAnswer } from './yolo.js';
39
+ import { QaTranscript, CLARIFY_QA_POLICY } from './qa-transcript.js';
40
+ import { makeQuestionSource } from './question-source.js';
41
+ import { CoverageLedger } from './plan-rounds.js';
42
+ import { CLARIFY_QUALITY_RULES, PLAN_FORMAT_HINT } from './plan-session.js';
40
43
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
41
44
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
42
45
  import { reconcileTitleSources } from './decompose-fidelity.js';
43
46
  import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
44
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
45
48
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
46
- import { decideAdoption, groundedCoverage } from './coverage-loop.js';
49
+ import { groundedCoverage } from './coverage-loop.js';
47
50
  import { buildOptionCards, resolveAnswer } from './question-dialog.js';
48
51
  import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
49
52
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
@@ -155,17 +158,6 @@ function logPlanDebug(cwd, msg) {
155
158
  .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
156
159
  .catch(() => { });
157
160
  }
158
- /** Normalise a missing-area string for cross-round identity — lowercased alnum
159
- * words, punctuation and quote-wrapping collapsed. Used only to tell whether an
160
- * adopted plan introduced a NEW gap versus re-surfacing the same one (#2 bonus
161
- * round); intentionally coarse, so trivial rewording of the same area does not
162
- * read as new and buy an extra round. */
163
- function normMissingArea(s) {
164
- return s
165
- .toLowerCase()
166
- .replace(/[^a-z0-9]+/g, ' ')
167
- .trim();
168
- }
169
161
  /**
170
162
  * Clarify's answer-side TRIAGE — the second stage /task-auto's clarify gate was
171
163
  * missing that /task's grill already had. /task-auto's clarify was single-stage:
@@ -509,40 +501,43 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
509
501
  const { featureForModel, existingFilesBlock, ownableRequirements } = oriented;
510
502
  const theme = ctx.ui.theme;
511
503
  const ui = new SessionUI(ctx);
512
- const answers = [];
513
- // Plain text of every question already shown, for the duplicate backstop.
514
- const askedQuestions = [];
504
+ // ONE record, one numbering, one provenance table (task/qa-transcript.ts).
505
+ // Clarify's generator DOES see provenance a question the triage already
506
+ // settled must read as settled so it is not re-asked — which is the one thing
507
+ // it and grill genuinely disagree about, and is now a named policy field.
508
+ const transcript = new QaTranscript(CLARIFY_QA_POLICY);
515
509
  // Deterministic guard against a model that ignores "never re-ask": consecutive
516
510
  // near-duplicate questions are reprompted with an explicit hint, and once it
517
511
  // strikes out (can't produce anything novel) we stop instead of barraging the
518
512
  // user with the same decision worded N ways. Also caps the absolute count.
519
- let dupStrikes = 0;
520
- let dupHint = null;
521
- // Open-ended: keep asking until the model emits NONE or the user dismisses
522
- // but never past MAX_CLARIFY_QUESTIONS distinct questions.
523
- while (askedQuestions.length < MAX_CLARIFY_QUESTIONS) {
524
- const qRaw = await deps.runChild('auto-clarify', 'read', prependHint(dupHint, AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n'))));
525
- const parsed = parseClarifyList(qRaw);
526
- if (parsed.length === 0)
527
- break; // NONE / nothing left to ask
528
- // Backstop: if the model re-asked a topic already settled, don't surface it.
529
- // Reprompt it to move on or finish; give up after MAX_DUP_STRIKES so a model
530
- // stuck on one fork can't loop forever.
531
- if (isDuplicateQuestion(askedQuestions, stripInlineMarkdown(parsed[0].question))) {
532
- dupStrikes++;
533
- if (dupStrikes >= MAX_DUP_STRIKES)
534
- break;
535
- dupHint = DUP_REPROMPT_HINT;
536
- continue;
537
- }
538
- dupStrikes = 0;
539
- dupHint = null;
540
- const { question, suggested, alt } = parsed[0];
513
+ // The generate → parse → pick → dedupe → re-prompt state machine is
514
+ // task/question-source.ts, shared with the plan session. Clarify used to write
515
+ // its own, and it had drifted from the sibling in five ways every one of
516
+ // which the shared source closes for free: `parsed[0]` became `pickQuestion`
517
+ // (an analysis note is no longer shown as the question, and the SUGGESTED
518
+ // attached further down is no longer lost), and an unparseable reply now buys
519
+ // one format re-prompt instead of ending the whole clarify — and decomposing
520
+ // the feature with ZERO clarifications — on a formatting slip.
521
+ //
522
+ // Of plan's three quality rules only the DEFERRAL guard crosses. The other two
523
+ // cost an extra child call every time they fire, and clarify is the most A/B'd
524
+ // path here; moving them is its own experiment. See CLARIFY_QUALITY_RULES.
525
+ const source = makeQuestionSource({
526
+ generate: hint => deps.runChild('auto-clarify', 'read', prependHint(hint, AUTO_CLARIFY_PROMPT(featureForModel, transcript.forGenerator()))),
527
+ formatHint: PLAN_FORMAT_HINT,
528
+ rules: CLARIFY_QUALITY_RULES,
529
+ cap: MAX_CLARIFY_QUESTIONS,
530
+ log: msg => logPlanDebug(cwd, `clarify: ${msg}`)
531
+ });
532
+ for (;;) {
533
+ const drawn = await source.next();
534
+ if (drawn.kind === 'exhausted')
535
+ break;
536
+ const { question, suggested, alt } = drawn.q;
541
537
  // Render markdown (bold/code) for the displayed prompt; keep plain text
542
538
  // for the editable default and the persisted file.
543
539
  const shownQ = renderInlineMarkdown(question, theme);
544
- const plainQ = stripInlineMarkdown(question);
545
- askedQuestions.push(plainQ);
540
+ const plainQ = drawn.plain;
546
541
  // PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
547
542
  // same spec, same base commit, same code — see decompose-granularity.ts).
548
543
  // The triage answers this fork for itself in 8/8 live reps and stamps it
@@ -552,8 +547,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
552
547
  // fixed value the user can read in the AUTO file and override next run.
553
548
  if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
554
549
  logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
555
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
556
- + `A${answers.length + 1}: ${PLAN_SHAPE_ANSWER} (host-set — plan granularity is not left to chance)`);
550
+ transcript.add('host-set', plainQ, PLAN_SHAPE_ANSWER);
557
551
  continue;
558
552
  }
559
553
  // Answer-side triage (grill parity): if the inlined spec already settles
@@ -562,8 +556,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
562
556
  // won't re-ask it.
563
557
  const autoResolved = await triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBlock, plainQ);
564
558
  if (autoResolved !== null) {
565
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
566
- + `A${answers.length + 1}: ${autoResolved} (auto-resolved — already settled by the spec)`);
559
+ transcript.add('auto-resolved', plainQ, autoResolved);
567
560
  continue;
568
561
  }
569
562
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
@@ -577,9 +570,10 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
577
570
  ...(plainAlt !== undefined && { alt: plainAlt })
578
571
  });
579
572
  if (yolo !== null) {
580
- const auto = yolo.kind === 'answer' ? yolo.answer : `(skipped — ${yolo.note})`;
581
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
582
- + `A${answers.length + 1}: ${auto} ${YOLO_STAMP}`);
573
+ if (yolo.kind === 'answer')
574
+ transcript.add('yolo', plainQ, yolo.answer);
575
+ else
576
+ transcript.add('yolo-skip', plainQ, `(skipped — ${yolo.note})`);
583
577
  continue;
584
578
  }
585
579
  // The picker cards and the reply mapping are shared with /task's grill
@@ -611,21 +605,15 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
611
605
  announceDone(ctx, '/task-auto cancelled.', 'warning');
612
606
  return null;
613
607
  }
614
- const resolved = resolveAnswer(pending, a);
615
- // Clarify's transcript records PROVENANCE; grill's deliberately does not,
616
- // because grill's is fed back verbatim into the next grill-gen prompt. That
617
- // is now the only difference between the two dialogs, and it is one line.
618
608
  // An accept covers both routes to it: submitting empty, and pressing the
619
- // single green card.
620
- const answer = resolved.source === 'accepted' ?
621
- `${resolved.answer} (accepted recommendation)`
622
- : resolved.answer;
623
- answers.push(`Q${answers.length + 1}: ${plainQ}\nA${answers.length + 1}: ${answer}`);
609
+ // single green card. The suffix is the policy's, not this call site's.
610
+ const resolved = resolveAnswer(pending, a);
611
+ transcript.add(resolved.source === 'accepted' ? 'accepted' : 'typed', plainQ, resolved.answer);
624
612
  }
625
- if (answers.length === 0) {
613
+ if (transcript.length === 0) {
626
614
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
627
615
  }
628
- return answers.join('\n');
616
+ return transcript.forRecord();
629
617
  }
630
618
  /**
631
619
  * DECOMPOSE — turn the settled feature into a task list, then defend that list's
@@ -824,20 +812,16 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
824
812
  // It is also the ONLY handle on the accounting. There used to be a second,
825
813
  // `let accounting`, carried alongside — see the ScoredPlan doc comment for the
826
814
  // requirement-to-wrong-task bug that cost us.
827
- let best = await scorePlan(planTitles);
828
- let round = 0;
829
- // #2: the round cap can be lifted ONCE. An adoption is a fresh whole-plan roll,
830
- // so the plan that gets adopted can expose an uncovered area the pre-adoption
831
- // plan never had and if that adoption lands on the last allowed round, the
832
- // loop breaks before the new gap ever gets a reprompt (mx5 2026-07-16: the
833
- // 55-title plan was adopted on the final round AND was the first to reveal §10's
834
- // test-infra gap; the cap fired the same instant, so it was never chased). Grant
835
- // exactly one bonus round when — and only when — an adoption introduces a NEW
836
- // missing area at the cap. Bounded to one so a judge that flags forever still
837
- // cannot loop the plan phase; a persistent (non-new) gap never re-triggers it.
838
- let roundCap = MAX_COVERAGE_ROUNDS;
839
- let bonusRoundUsed = false;
815
+ // The record, and the decisions it makes: task/plan-rounds.ts. This was five
816
+ // locals threaded by closure through a ~90-line loop, plus a
817
+ // snapshot-before-overwrite pair that existed only because the bonus-round
818
+ // decision was made downstream from the evidence it needed.
819
+ const rounds = new CoverageLedger(await scorePlan(planTitles), {
820
+ cap: MAX_COVERAGE_ROUNDS,
821
+ hasRequirements
822
+ });
840
823
  for (;;) {
824
+ const best = rounds.best();
841
825
  if (best.plan.titles.length === 0)
842
826
  break;
843
827
  if (best.plan.missing.length === 0) {
@@ -854,40 +838,19 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
854
838
  }
855
839
  break;
856
840
  }
857
- if (round >= roundCap)
841
+ if (!rounds.mayRetry())
858
842
  break;
859
- round++;
843
+ const round = rounds.startRound();
860
844
  logPlanDebug(cwd, `decompose-coverage round ${round}: INCOMPLETE — missing: `
861
845
  + best.plan.missing.join('; ').slice(0, 300));
862
846
  const retryTitles = parsePlan(await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(best.plan.missing), decomposePrompt)));
863
847
  logPlanDebug(cwd, `decompose retry produced ${retryTitles.length} title(s)`);
864
- const cand = await scorePlan(retryTitles);
865
- const decision = decideAdoption(best.plan, cand.plan, hasRequirements);
866
- if (decision.adopt) {
867
- // Snapshot the pre-adoption plan to decide whether this adoption earns a
868
- // bonus round. Two guards keep the bonus off generic judge churn: it must
869
- // be a real coverage GAIN (grounded covered-set strictly grew — a flaky
870
- // judge that just relabels the same-shaped plan's gap does not qualify),
871
- // and it must expose a NEW area (a gap already present is one we have or
872
- // will reprompt against anyway). Requirements-path only: without grounded
873
- // requirements "missing" is pure holistic-judge free-text that can change
874
- // every round, so there is no trustworthy "grew"/"new" signal to gate on.
875
- const priorCovered = best.plan.covered.size;
876
- const priorMissing = new Set(best.plan.missing.map(normMissingArea));
877
- // The WHOLE scored plan is adopted, titles and accounting together.
878
- // This used to be two assignments, and the second one kept the OLD
879
- // plan's accounting whenever the new plan's coverage-map child faulted
880
- // (`cand.accounting ?? accounting`) — binding requirements to titles
881
- // they were never mapped against. See the ScoredPlan doc comment.
882
- best = cand;
883
- logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
884
- if (!bonusRoundUsed
885
- && round >= roundCap
886
- && hasRequirements
887
- && cand.plan.covered.size > priorCovered
888
- && cand.plan.missing.some(m => !priorMissing.has(normMissingArea(m)))) {
889
- bonusRoundUsed = true;
890
- roundCap++;
848
+ // Compare, replace the plan WHOLE, and decide the bonus round — one call,
849
+ // so there is no window in which a snapshot and a replacement disagree.
850
+ const outcome = rounds.consider(await scorePlan(retryTitles));
851
+ if (outcome.adopted) {
852
+ logPlanDebug(cwd, `decompose retry ADOPTED ${outcome.decision.reason}`);
853
+ if (outcome.grantedBonusRound) {
891
854
  logPlanDebug(cwd, 'decompose-coverage: bonus round granted — adoption grew coverage and '
892
855
  + 'exposed a new uncovered area at the cap');
893
856
  }
@@ -896,20 +859,22 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
896
859
  // Rejected: keep the better current plan. The loop re-checks it at the
897
860
  // top (still incomplete ⇒ another bounded reprompt) but its coverage is
898
861
  // never sacrificed to a worse regeneration.
899
- logPlanDebug(cwd, `decompose retry REJECTED — ${decision.reason}`
900
- + (decision.dropped.length > 0 ?
901
- ` [would drop: ${decision.dropped
862
+ logPlanDebug(cwd, `decompose retry REJECTED — ${outcome.decision.reason}`
863
+ + (outcome.decision.dropped.length > 0 ?
864
+ ` [would drop: ${outcome.decision.dropped
902
865
  .map(i => `"${reqEntries[i].quote}"`)
903
866
  .join('; ')
904
867
  .slice(0, 200)}]`
905
868
  : ''));
906
869
  }
907
870
  }
871
+ const best = rounds.best();
872
+ const round = rounds.round();
908
873
  planTitles = best.plan.titles;
909
874
  // Exhausted still INCOMPLETE: the best plan ships (the gate is best-effort), but
910
875
  // silently shipping a KNOWN-gapped plan is how mx5 run 5 lost its whole test
911
876
  // suite — tell the user what is still uncovered.
912
- const unresolvedMissing = best.plan.missing.length > 0 ? best.plan.missing : null;
877
+ const unresolvedMissing = rounds.unresolved();
913
878
  if (unresolvedMissing !== null) {
914
879
  logPlanDebug(cwd, `decompose-coverage exhausted ${round} round(s) still INCOMPLETE — missing: `
915
880
  + unresolvedMissing.join('; ').slice(0, 300));
@@ -1195,6 +1160,29 @@ let autoRunning = false;
1195
1160
  export function requestAutoCancel() {
1196
1161
  requestCancel();
1197
1162
  }
1163
+ /**
1164
+ * Report a stash pushed during one task and left behind.
1165
+ *
1166
+ * An orphan stash later pops as an unresolvable conflict (mx5 run 6), so the
1167
+ * capture before the task and this check after it are ONE fact. They were a local
1168
+ * and a check ~120 lines and three returns apart, which is how the check came to
1169
+ * run only on the success path. Best-effort: never throws, so it cannot mask the
1170
+ * outcome of the attempt it closes.
1171
+ */
1172
+ async function reportStashDrift(active, deps, cwd, id, title, before) {
1173
+ if (!deps.stashRef || before === undefined)
1174
+ return;
1175
+ try {
1176
+ const after = await deps.stashRef(cwd);
1177
+ if (after === before)
1178
+ return;
1179
+ active.ui.notify(`${id}: the git stash stack changed during "${title}" and was left that way — `
1180
+ + 'inspect `git stash list`; an orphan stash later pops as an unresolvable conflict.', 'warning');
1181
+ }
1182
+ catch {
1183
+ // a git failure here is inconclusive, never a report
1184
+ }
1185
+ }
1198
1186
  /**
1199
1187
  * Announce a terminal /task-auto-overall outcome both in the terminal and to
1200
1188
  * subscribed devices. The push body reuses the exact terminal message, so a
@@ -1302,117 +1290,120 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1302
1290
  // during the task (impl model or any child) and left behind is called
1303
1291
  // out instead of silently waiting to detonate in a later task.
1304
1292
  const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
1305
- // SAFE CHECKPOINT (pre-task): the tree is committed and no inner task
1306
- // is stamped yet, so stopping here just leaves this entry unchecked —
1307
- // a resume restarts it from scratch. Cheapest possible stop, and the
1308
- // last one before we commit to a ~30-minute task.
1309
- if (cancelCheckpoint('pre-task')) {
1310
- announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
1311
- return;
1312
- }
1313
- const res = await deps.runTask(active, cwd, next.title, {
1314
- resumeId,
1315
- // Fence this step against re-expanding the whole referenced spec:
1316
- // name the sibling steps so refine bounds this step's slice. Only
1317
- // matters when refine runs fresh (a resumed task past refine ignores
1318
- // it), but always supplied so a resume that restarts at refine is
1319
- // fenced too.
1320
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1321
- onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1322
- });
1323
- active = res.ctx ?? active;
1324
- // One dispatch over the named ending. The five-branch ladder this
1325
- // replaces had to ask `isCancelRequested()` — a module global
1326
- // `/task-cancel` never sets — to tell a user stop from a fault, so a
1327
- // cancel during a task was announced in red as "stopped … fix and
1328
- // resume" and the inner file's `cancelled` was overwritten with
1329
- // `failed`. The runner names the ending now; resumability is
1330
- // RUN_END_POLICY's; only the wording is this command's.
1331
- if (!runSucceeded(res.end)) {
1332
- const policy = RUN_END_POLICY[res.end.kind];
1333
- // Demote the INNER task file: it reads `completed` from
1334
- // spec-handoff, and leaving it that way is how a failed run's task
1335
- // file claimed success in the run 6 audit.
1336
- if (policy.resumable)
1337
- await markResumable(cwd, res.taskId);
1338
- // The PLAN fails only on a fault. A declined-steer interrupt leaves
1339
- // it in progress so /task-auto-resume re-delivers this task's spec.
1340
- if (policy.failsRun)
1341
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1342
- const why = res.end.kind === 'failed' && res.end.reason ?
1343
- ` — ${res.end.reason.slice(0, 160)}`
1344
- : '';
1345
- const msg = res.end.kind === 'no-session' ?
1346
- `${id} paused — could not start a session. Run /task-auto-resume to retry.`
1347
- : res.end.kind === 'cancelled' ?
1348
- `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
1349
- : res.end.kind === 'interrupted' ?
1350
- // The user interrupted implementation (ESC) and then declined
1351
- // to steer — they want to stop here. Paused without checking
1352
- // the task off, so /task-auto-resume re-delivers this task's
1353
- // spec. (A plain ESC followed by steering text never reaches
1354
- // here — that loops inside runSingleTask until a turn ends.)
1355
- `${id} paused at "${next.title}" — resume with /task-auto-resume.`
1356
- : `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
1357
- announceDone(active, msg, policy.level);
1358
- return;
1359
- }
1360
- // GATE: actually RUN the task's verification against the just-finished
1361
- // work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
1362
- // verbatim with /task so both commands gate identically — see
1363
- // runGatesForTask. `done` means the work verified (or was accepted),
1364
- // was checked off + committed, and enforcement ran; every other kind is
1365
- // a terminal stop this loop announces with its own /task-auto-resume
1366
- // wording (the shared gate is command-agnostic).
1367
- const gate = await runGatesForTask(active, deps, {
1368
- cwd,
1369
- taskId: res.taskId,
1370
- title: next.title,
1371
- tag: id,
1372
- // Fence an AUTOFIX re-run against re-expanding the whole spec.
1373
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1374
- // res.ok === true means runner.run() completed, so res.taskId is the
1375
- // allocated TASK_NNNN id (never empty here). The parent task-list
1376
- // check-off runs after verify passes/accepts and before the commit,
1377
- // so the commit captures the checked box too.
1378
- onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
1379
- });
1380
- active = gate.ctx;
1381
- // Every terminal gate outcome — what to demote, what to fail, what to
1382
- // say — comes from TERMINAL_OUTCOMES, shared verbatim with /task's
1383
- // loop. `done` alone is not terminal here: it falls through to the
1384
- // next task.
1385
- if (gate.kind !== 'done') {
1386
- const outcome = TERMINAL_OUTCOMES[gate.kind];
1387
- if (outcome.markResumable)
1388
- await markResumable(cwd, res.taskId);
1389
- if (outcome.failParent) {
1390
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1293
+ try {
1294
+ // SAFE CHECKPOINT (pre-task): the tree is committed and no inner task
1295
+ // is stamped yet, so stopping here just leaves this entry unchecked —
1296
+ // a resume restarts it from scratch. Cheapest possible stop, and the
1297
+ // last one before we commit to a ~30-minute task.
1298
+ if (cancelCheckpoint('pre-task')) {
1299
+ announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
1300
+ return;
1391
1301
  }
1392
- announceDone(active, outcome.message({
1302
+ const res = await deps.runTask(active, cwd, next.title, {
1303
+ resumeId,
1304
+ // Fence this step against re-expanding the whole referenced spec:
1305
+ // name the sibling steps so refine bounds this step's slice. Only
1306
+ // matters when refine runs fresh (a resumed task past refine ignores
1307
+ // it), but always supplied so a resume that restarts at refine is
1308
+ // fenced too.
1309
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1310
+ onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1311
+ });
1312
+ active = res.ctx ?? active;
1313
+ // One dispatch over the named ending. The five-branch ladder this
1314
+ // replaces had to ask `isCancelRequested()` — a module global
1315
+ // `/task-cancel` never sets — to tell a user stop from a fault, so a
1316
+ // cancel during a task was announced in red as "stopped … fix and
1317
+ // resume" and the inner file's `cancelled` was overwritten with
1318
+ // `failed`. The runner names the ending now; resumability is
1319
+ // RUN_END_POLICY's; only the wording is this command's.
1320
+ if (!runSucceeded(res.end)) {
1321
+ const policy = RUN_END_POLICY[res.end.kind];
1322
+ // Demote the INNER task file: it reads `completed` from
1323
+ // spec-handoff, and leaving it that way is how a failed run's task
1324
+ // file claimed success in the run 6 audit.
1325
+ if (policy.resumable)
1326
+ await markResumable(cwd, res.taskId);
1327
+ // The PLAN fails only on a fault. A declined-steer interrupt leaves
1328
+ // it in progress so /task-auto-resume re-delivers this task's spec.
1329
+ if (policy.failsRun)
1330
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1331
+ const why = res.end.kind === 'failed' && res.end.reason ?
1332
+ ` — ${res.end.reason.slice(0, 160)}`
1333
+ : '';
1334
+ const msg = res.end.kind === 'no-session' ?
1335
+ `${id} paused — could not start a session. Run /task-auto-resume to retry.`
1336
+ : res.end.kind === 'cancelled' ?
1337
+ `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
1338
+ : res.end.kind === 'interrupted' ?
1339
+ // The user interrupted implementation (ESC) and then declined
1340
+ // to steer — they want to stop here. Paused without checking
1341
+ // the task off, so /task-auto-resume re-delivers this task's
1342
+ // spec. (A plain ESC followed by steering text never reaches
1343
+ // here — that loops inside runSingleTask until a turn ends.)
1344
+ `${id} paused at "${next.title}" — resume with /task-auto-resume.`
1345
+ : `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
1346
+ announceDone(active, msg, policy.level);
1347
+ return;
1348
+ }
1349
+ // GATE: actually RUN the task's verification against the just-finished
1350
+ // work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
1351
+ // verbatim with /task so both commands gate identically — see
1352
+ // runGatesForTask. `done` means the work verified (or was accepted),
1353
+ // was checked off + committed, and enforcement ran; every other kind is
1354
+ // a terminal stop this loop announces with its own /task-auto-resume
1355
+ // wording (the shared gate is command-agnostic).
1356
+ const gate = await runGatesForTask(active, deps, {
1357
+ cwd,
1358
+ taskId: res.taskId,
1359
+ title: next.title,
1393
1360
  tag: id,
1394
- at: formatAt(next.title),
1395
- why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
1396
- resumeCmd: '/task-auto-resume'
1397
- }), outcome.level);
1398
- return;
1399
- }
1400
- // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
1401
- // FAIL to a pre-existing defect in a file some OTHER task created. It can
1402
- // only QUEUE that finding — mutating the plan is this loop's job. Drain
1403
- // the queue and splice a scoped repair step in right after the step that
1404
- // just finished, so the defect is fixed BEFORE the next dependent task
1405
- // trips over it too (run 14 recorded the same `test/teardown.ts` cause
1406
- // twice, scheduled nothing, and the bug outlived ~24h of the run).
1407
- await schedulePendingRepairs(cwd, id, next.index, active, deps);
1408
- // gate.kind === 'done' → fall through to the next task, after checking
1409
- // no landmine stash was left behind by anything that ran in between.
1410
- if (deps.stashRef && stashBefore !== undefined) {
1411
- const stashAfter = await deps.stashRef(cwd);
1412
- if (stashAfter !== stashBefore) {
1413
- active.ui.notify(`${id}: the git stash stack changed during "${next.title}" and was left that way — `
1414
- + 'inspect `git stash list`; an orphan stash later pops as an unresolvable conflict.', 'warning');
1361
+ // Fence an AUTOFIX re-run against re-expanding the whole spec.
1362
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1363
+ // res.ok === true means runner.run() completed, so res.taskId is the
1364
+ // allocated TASK_NNNN id (never empty here). The parent task-list
1365
+ // check-off runs after verify passes/accepts and before the commit,
1366
+ // so the commit captures the checked box too.
1367
+ onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
1368
+ });
1369
+ active = gate.ctx;
1370
+ // Every terminal gate outcome what to demote, what to fail, what to
1371
+ // say comes from TERMINAL_OUTCOMES, shared verbatim with /task's
1372
+ // loop. `done` alone is not terminal here: it falls through to the
1373
+ // next task.
1374
+ if (gate.kind !== 'done') {
1375
+ const outcome = TERMINAL_OUTCOMES[gate.kind];
1376
+ if (outcome.markResumable)
1377
+ await markResumable(cwd, res.taskId);
1378
+ if (outcome.failParent) {
1379
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1380
+ }
1381
+ announceDone(active, outcome.message({
1382
+ tag: id,
1383
+ at: formatAt(next.title),
1384
+ why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
1385
+ resumeCmd: '/task-auto-resume'
1386
+ }), outcome.level);
1387
+ return;
1415
1388
  }
1389
+ // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
1390
+ // FAIL to a pre-existing defect in a file some OTHER task created. It can
1391
+ // only QUEUE that finding — mutating the plan is this loop's job. Drain
1392
+ // the queue and splice a scoped repair step in right after the step that
1393
+ // just finished, so the defect is fixed BEFORE the next dependent task
1394
+ // trips over it too (run 14 recorded the same `test/teardown.ts` cause
1395
+ // twice, scheduled nothing, and the bug outlived ~24h of the run).
1396
+ await schedulePendingRepairs(cwd, id, next.index, active, deps);
1397
+ }
1398
+ finally {
1399
+ // EVERY exit from this attempt passes here — the two mid-attempt
1400
+ // returns and a throw included. The check used to sit at the very
1401
+ // end of the fall-through, ~120 lines and three returns below the
1402
+ // capture, so it ran only when the task SUCCEEDED and the gate said
1403
+ // `done`. On a failed or interrupted task the user is told to run
1404
+ // /task-auto-resume, straight onto the landmine the guard exists to
1405
+ // name. The pairing is structural now, not positional.
1406
+ await reportStashDrift(active, deps, cwd, id, next.title, stashBefore);
1416
1407
  }
1417
1408
  }
1418
1409
  }
@@ -135,6 +135,52 @@ export interface BootDeps {
135
135
  preferredPort?: () => Promise<number | null>;
136
136
  /** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
137
137
  httpProbe?: (port: number) => boolean;
138
+ /**
139
+ * Spawn the boot child. THE SUBJECT of this check, and the one thing `BootDeps`
140
+ * did not seam.
141
+ *
142
+ * Nine fields above inject something the check LOOKS AT; `spawn` was imported
143
+ * directly, so the ~220-line state machine below — seven locals threaded by
144
+ * closure, four `BootOutcome` kinds, five exit arms — was reachable only through
145
+ * a real process on a real clock. Measured: 52 tests / 13.6s, with 300–5000ms
146
+ * grace windows scripted as real `process.execPath -e` children.
147
+ *
148
+ * `BootChild` is defined from what this function CALLS, not from Node's
149
+ * `ChildProcess` — the same way `driveSession(cdp: CdpLike, …)` was defined from
150
+ * the two `Cdp` methods it uses. A scripted fake is a dozen lines.
151
+ */
152
+ spawnBoot?: (bin: string, args: string[], opts: BootSpawnOptions) => BootChild;
153
+ /**
154
+ * Tear down the child's whole process group. Injected with `spawnBoot`, because
155
+ * a fake child has no group to kill and a real `process.kill(-pid)` against a
156
+ * fake pid would signal something else entirely.
157
+ */
158
+ killGroup?: (pid: number, signal: NodeJS.Signals) => void;
159
+ }
160
+ /** What `runBootCheck` passes to its spawn. */
161
+ export interface BootSpawnOptions {
162
+ cwd: string;
163
+ detached: true;
164
+ stdio: ['ignore', 'pipe', 'pipe'];
165
+ env: Record<string, string | undefined>;
166
+ }
167
+ /** A stream the boot check reads output from. */
168
+ export interface BootStream {
169
+ on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
170
+ }
171
+ /**
172
+ * The boot child, as the check actually uses it: a pid, two output streams, an
173
+ * `error` event and an `exit` event carrying (status, signal).
174
+ */
175
+ export interface BootChild {
176
+ pid?: number | undefined;
177
+ unref?: () => void;
178
+ stdout?: BootStream | null;
179
+ stderr?: BootStream | null;
180
+ on: {
181
+ (event: 'error', cb: (err: Error) => void): void;
182
+ (event: 'exit', cb: (status: number | null, signal: NodeJS.Signals | null) => void): void;
183
+ };
138
184
  }
139
185
  /**
140
186
  * Does the finished run stand up a listening HTTP server? Deterministic, from the