@mjasnikovs/pi-task 0.38.15 → 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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -9,11 +9,11 @@ import { existsSync } from 'node:fs';
9
9
  import * as fsp from 'node:fs/promises';
10
10
  import * as path from 'node:path';
11
11
  import { gateRunTask, markResumable } from './orchestrator.js';
12
- import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
12
+ import { RUN_END_POLICY, runSucceeded } from './run-end.js';
13
+ import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
13
14
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
14
15
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
15
16
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
16
- import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
17
17
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
18
18
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
19
19
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
@@ -32,16 +32,21 @@ import { runGatesForTask } from './task-gates.js';
32
32
  import { runFinalGateStage } from './run-final-gate.js';
33
33
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
34
34
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
35
+ import { spawnCommand } from './command-run.js';
35
36
  import { getConfig } from '../config/config.js';
36
37
  import { debugLogLevel, shouldLogDebug } from './debug-log.js';
37
- 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';
38
43
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
39
44
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
40
45
  import { reconcileTitleSources } from './decompose-fidelity.js';
41
46
  import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
42
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
43
48
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
44
- import { decideAdoption, groundedCoverage } from './coverage-loop.js';
49
+ import { groundedCoverage } from './coverage-loop.js';
45
50
  import { buildOptionCards, resolveAnswer } from './question-dialog.js';
46
51
  import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
47
52
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
@@ -153,17 +158,6 @@ function logPlanDebug(cwd, msg) {
153
158
  .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
154
159
  .catch(() => { });
155
160
  }
156
- /** Normalise a missing-area string for cross-round identity — lowercased alnum
157
- * words, punctuation and quote-wrapping collapsed. Used only to tell whether an
158
- * adopted plan introduced a NEW gap versus re-surfacing the same one (#2 bonus
159
- * round); intentionally coarse, so trivial rewording of the same area does not
160
- * read as new and buy an extra round. */
161
- function normMissingArea(s) {
162
- return s
163
- .toLowerCase()
164
- .replace(/[^a-z0-9]+/g, ' ')
165
- .trim();
166
- }
167
161
  /**
168
162
  * Clarify's answer-side TRIAGE — the second stage /task-auto's clarify gate was
169
163
  * missing that /task's grill already had. /task-auto's clarify was single-stage:
@@ -507,40 +501,43 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
507
501
  const { featureForModel, existingFilesBlock, ownableRequirements } = oriented;
508
502
  const theme = ctx.ui.theme;
509
503
  const ui = new SessionUI(ctx);
510
- const answers = [];
511
- // Plain text of every question already shown, for the duplicate backstop.
512
- 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);
513
509
  // Deterministic guard against a model that ignores "never re-ask": consecutive
514
510
  // near-duplicate questions are reprompted with an explicit hint, and once it
515
511
  // strikes out (can't produce anything novel) we stop instead of barraging the
516
512
  // user with the same decision worded N ways. Also caps the absolute count.
517
- let dupStrikes = 0;
518
- let dupHint = null;
519
- // Open-ended: keep asking until the model emits NONE or the user dismisses
520
- // but never past MAX_CLARIFY_QUESTIONS distinct questions.
521
- while (askedQuestions.length < MAX_CLARIFY_QUESTIONS) {
522
- const qRaw = await deps.runChild('auto-clarify', 'read', prependHint(dupHint, AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n'))));
523
- const parsed = parseClarifyList(qRaw);
524
- if (parsed.length === 0)
525
- break; // NONE / nothing left to ask
526
- // Backstop: if the model re-asked a topic already settled, don't surface it.
527
- // Reprompt it to move on or finish; give up after MAX_DUP_STRIKES so a model
528
- // stuck on one fork can't loop forever.
529
- if (isDuplicateQuestion(askedQuestions, stripInlineMarkdown(parsed[0].question))) {
530
- dupStrikes++;
531
- if (dupStrikes >= MAX_DUP_STRIKES)
532
- break;
533
- dupHint = DUP_REPROMPT_HINT;
534
- continue;
535
- }
536
- dupStrikes = 0;
537
- dupHint = null;
538
- 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;
539
537
  // Render markdown (bold/code) for the displayed prompt; keep plain text
540
538
  // for the editable default and the persisted file.
541
539
  const shownQ = renderInlineMarkdown(question, theme);
542
- const plainQ = stripInlineMarkdown(question);
543
- askedQuestions.push(plainQ);
540
+ const plainQ = drawn.plain;
544
541
  // PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
545
542
  // same spec, same base commit, same code — see decompose-granularity.ts).
546
543
  // The triage answers this fork for itself in 8/8 live reps and stamps it
@@ -550,8 +547,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
550
547
  // fixed value the user can read in the AUTO file and override next run.
551
548
  if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
552
549
  logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
553
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
554
- + `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);
555
551
  continue;
556
552
  }
557
553
  // Answer-side triage (grill parity): if the inlined spec already settles
@@ -560,8 +556,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
560
556
  // won't re-ask it.
561
557
  const autoResolved = await triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBlock, plainQ);
562
558
  if (autoResolved !== null) {
563
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
564
- + `A${answers.length + 1}: ${autoResolved} (auto-resolved — already settled by the spec)`);
559
+ transcript.add('auto-resolved', plainQ, autoResolved);
565
560
  continue;
566
561
  }
567
562
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
@@ -575,9 +570,10 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
575
570
  ...(plainAlt !== undefined && { alt: plainAlt })
576
571
  });
577
572
  if (yolo !== null) {
578
- const auto = yolo.kind === 'answer' ? yolo.answer : `(skipped — ${yolo.note})`;
579
- answers.push(`Q${answers.length + 1}: ${plainQ}\n`
580
- + `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})`);
581
577
  continue;
582
578
  }
583
579
  // The picker cards and the reply mapping are shared with /task's grill
@@ -609,21 +605,15 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
609
605
  announceDone(ctx, '/task-auto cancelled.', 'warning');
610
606
  return null;
611
607
  }
612
- const resolved = resolveAnswer(pending, a);
613
- // Clarify's transcript records PROVENANCE; grill's deliberately does not,
614
- // because grill's is fed back verbatim into the next grill-gen prompt. That
615
- // is now the only difference between the two dialogs, and it is one line.
616
608
  // An accept covers both routes to it: submitting empty, and pressing the
617
- // single green card.
618
- const answer = resolved.source === 'accepted' ?
619
- `${resolved.answer} (accepted recommendation)`
620
- : resolved.answer;
621
- 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);
622
612
  }
623
- if (answers.length === 0) {
613
+ if (transcript.length === 0) {
624
614
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
625
615
  }
626
- return answers.join('\n');
616
+ return transcript.forRecord();
627
617
  }
628
618
  /**
629
619
  * DECOMPOSE — turn the settled feature into a task list, then defend that list's
@@ -822,20 +812,16 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
822
812
  // It is also the ONLY handle on the accounting. There used to be a second,
823
813
  // `let accounting`, carried alongside — see the ScoredPlan doc comment for the
824
814
  // requirement-to-wrong-task bug that cost us.
825
- let best = await scorePlan(planTitles);
826
- let round = 0;
827
- // #2: the round cap can be lifted ONCE. An adoption is a fresh whole-plan roll,
828
- // so the plan that gets adopted can expose an uncovered area the pre-adoption
829
- // plan never had and if that adoption lands on the last allowed round, the
830
- // loop breaks before the new gap ever gets a reprompt (mx5 2026-07-16: the
831
- // 55-title plan was adopted on the final round AND was the first to reveal §10's
832
- // test-infra gap; the cap fired the same instant, so it was never chased). Grant
833
- // exactly one bonus round when — and only when — an adoption introduces a NEW
834
- // missing area at the cap. Bounded to one so a judge that flags forever still
835
- // cannot loop the plan phase; a persistent (non-new) gap never re-triggers it.
836
- let roundCap = MAX_COVERAGE_ROUNDS;
837
- 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
+ });
838
823
  for (;;) {
824
+ const best = rounds.best();
839
825
  if (best.plan.titles.length === 0)
840
826
  break;
841
827
  if (best.plan.missing.length === 0) {
@@ -852,40 +838,19 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
852
838
  }
853
839
  break;
854
840
  }
855
- if (round >= roundCap)
841
+ if (!rounds.mayRetry())
856
842
  break;
857
- round++;
843
+ const round = rounds.startRound();
858
844
  logPlanDebug(cwd, `decompose-coverage round ${round}: INCOMPLETE — missing: `
859
845
  + best.plan.missing.join('; ').slice(0, 300));
860
846
  const retryTitles = parsePlan(await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(best.plan.missing), decomposePrompt)));
861
847
  logPlanDebug(cwd, `decompose retry produced ${retryTitles.length} title(s)`);
862
- const cand = await scorePlan(retryTitles);
863
- const decision = decideAdoption(best.plan, cand.plan, hasRequirements);
864
- if (decision.adopt) {
865
- // Snapshot the pre-adoption plan to decide whether this adoption earns a
866
- // bonus round. Two guards keep the bonus off generic judge churn: it must
867
- // be a real coverage GAIN (grounded covered-set strictly grew — a flaky
868
- // judge that just relabels the same-shaped plan's gap does not qualify),
869
- // and it must expose a NEW area (a gap already present is one we have or
870
- // will reprompt against anyway). Requirements-path only: without grounded
871
- // requirements "missing" is pure holistic-judge free-text that can change
872
- // every round, so there is no trustworthy "grew"/"new" signal to gate on.
873
- const priorCovered = best.plan.covered.size;
874
- const priorMissing = new Set(best.plan.missing.map(normMissingArea));
875
- // The WHOLE scored plan is adopted, titles and accounting together.
876
- // This used to be two assignments, and the second one kept the OLD
877
- // plan's accounting whenever the new plan's coverage-map child faulted
878
- // (`cand.accounting ?? accounting`) — binding requirements to titles
879
- // they were never mapped against. See the ScoredPlan doc comment.
880
- best = cand;
881
- logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
882
- if (!bonusRoundUsed
883
- && round >= roundCap
884
- && hasRequirements
885
- && cand.plan.covered.size > priorCovered
886
- && cand.plan.missing.some(m => !priorMissing.has(normMissingArea(m)))) {
887
- bonusRoundUsed = true;
888
- 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) {
889
854
  logPlanDebug(cwd, 'decompose-coverage: bonus round granted — adoption grew coverage and '
890
855
  + 'exposed a new uncovered area at the cap');
891
856
  }
@@ -894,20 +859,22 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
894
859
  // Rejected: keep the better current plan. The loop re-checks it at the
895
860
  // top (still incomplete ⇒ another bounded reprompt) but its coverage is
896
861
  // never sacrificed to a worse regeneration.
897
- logPlanDebug(cwd, `decompose retry REJECTED — ${decision.reason}`
898
- + (decision.dropped.length > 0 ?
899
- ` [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
900
865
  .map(i => `"${reqEntries[i].quote}"`)
901
866
  .join('; ')
902
867
  .slice(0, 200)}]`
903
868
  : ''));
904
869
  }
905
870
  }
871
+ const best = rounds.best();
872
+ const round = rounds.round();
906
873
  planTitles = best.plan.titles;
907
874
  // Exhausted still INCOMPLETE: the best plan ships (the gate is best-effort), but
908
875
  // silently shipping a KNOWN-gapped plan is how mx5 run 5 lost its whole test
909
876
  // suite — tell the user what is still uncovered.
910
- const unresolvedMissing = best.plan.missing.length > 0 ? best.plan.missing : null;
877
+ const unresolvedMissing = rounds.unresolved();
911
878
  if (unresolvedMissing !== null) {
912
879
  logPlanDebug(cwd, `decompose-coverage exhausted ${round} round(s) still INCOMPLETE — missing: `
913
880
  + unresolvedMissing.join('; ').slice(0, 300));
@@ -1171,7 +1138,7 @@ function defaultDeps(ctx, cwd, signal, title) {
1171
1138
  // The final integration gate follows the `verify work` switch: it is the
1172
1139
  // run-level half of the same verification story.
1173
1140
  finalGate: (cwd2, planText) => getConfig().verifyWork ?
1174
- runFinalIntegrationGate(cwd2, { planText })
1141
+ runFinalIntegrationGate(cwd2, { planText, signal })
1175
1142
  : Promise.resolve({ ok: true, reason: 'disabled' }),
1176
1143
  // Uncommitted paths, for the stranded-sub-fix handling around the final-gate
1177
1144
  // picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
@@ -1183,7 +1150,9 @@ function defaultDeps(ctx, cwd, signal, title) {
1183
1150
  // Re-derive the debt ledger against the FINAL tree after a converged
1184
1151
  // autofix (nexttask 6). Only ever reached from inside the gate's own
1185
1152
  // resolution loop, so it needs no `verify work` switch of its own.
1186
- recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk)
1153
+ // Same section, same cancel: this re-runs every ACCEPT-debt VERIFY command
1154
+ // against the final tree, each under its own 300s cap.
1155
+ recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk, spawnCommand, signal)
1187
1156
  };
1188
1157
  }
1189
1158
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -1191,6 +1160,29 @@ let autoRunning = false;
1191
1160
  export function requestAutoCancel() {
1192
1161
  requestCancel();
1193
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
+ }
1194
1186
  /**
1195
1187
  * Announce a terminal /task-auto-overall outcome both in the terminal and to
1196
1188
  * subscribed devices. The push body reuses the exact terminal message, so a
@@ -1298,119 +1290,120 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1298
1290
  // during the task (impl model or any child) and left behind is called
1299
1291
  // out instead of silently waiting to detonate in a later task.
1300
1292
  const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
1301
- // SAFE CHECKPOINT (pre-task): the tree is committed and no inner task
1302
- // is stamped yet, so stopping here just leaves this entry unchecked —
1303
- // a resume restarts it from scratch. Cheapest possible stop, and the
1304
- // last one before we commit to a ~30-minute task.
1305
- if (cancelCheckpoint('pre-task')) {
1306
- announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
1307
- return;
1308
- }
1309
- const res = await deps.runTask(active, cwd, next.title, {
1310
- resumeId,
1311
- // Fence this step against re-expanding the whole referenced spec:
1312
- // name the sibling steps so refine bounds this step's slice. Only
1313
- // matters when refine runs fresh (a resumed task past refine ignores
1314
- // it), but always supplied so a resume that restarts at refine is
1315
- // fenced too.
1316
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1317
- onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1318
- });
1319
- active = res.ctx ?? active;
1320
- if (res.sessionCancelled) {
1321
- announceDone(active, `${id} paused could not start a session. Run /task-auto-resume to retry.`, 'warning');
1322
- return;
1323
- }
1324
- if (res.interrupted) {
1325
- // The user interrupted implementation (ESC) and then declined to
1326
- // steer (empty steer prompt) they want to stop here. Pause
1327
- // without checking the task off, so /task-auto-resume re-delivers
1328
- // this task's spec to finish it. (A plain ESC that the user
1329
- // follows with steering text never reaches here — that loops on
1330
- // the same task inside runSingleTask until a turn completes.)
1331
- await markResumable(cwd, res.taskId);
1332
- announceDone(active, `${id} paused at "${next.title}" resume with /task-auto-resume.`, 'warning');
1333
- return;
1334
- }
1335
- // A phase-boundary cancel surfaces here as a plain !res.ok: the runner
1336
- // caught its own USER_CANCELLED and wrote state 'cancelled' (resumable)
1337
- // to the inner file. Claim it BEFORE the failure branch, or a
1338
- // user-requested stop is announced in red as "stopped … fix and
1339
- // resume". The inner file is already resumable and the parent stays
1340
- // in_progress, matching the loop-top cancel.
1341
- if (!res.ok && isCancelRequested()) {
1342
- announceDone(active, `${id} cancelled during "${next.title}" resume with /task-auto-resume.`, 'warning');
1343
- return;
1344
- }
1345
- if (!res.ok) {
1346
- // Demote the INNER task file too: it reads `completed` from
1347
- // spec-handoff, and leaving it that way is how a failed run's task
1348
- // file claimed success in the run 6 audit.
1349
- await markResumable(cwd, res.taskId);
1350
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1351
- // res.reason is set when the implementation turn itself died
1352
- // (e.g. a context-overflow 400) surface it so the real cause
1353
- // isn't lost behind the generic "stopped" message.
1354
- const why = res.reason ? ` — ${res.reason.slice(0, 160)}` : '';
1355
- announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
1356
- return;
1357
- }
1358
- // GATE: actually RUN the task's verification against the just-finished
1359
- // work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
1360
- // verbatim with /task so both commands gate identically — see
1361
- // runGatesForTask. `done` means the work verified (or was accepted),
1362
- // was checked off + committed, and enforcement ran; every other kind is
1363
- // a terminal stop this loop announces with its own /task-auto-resume
1364
- // wording (the shared gate is command-agnostic).
1365
- const gate = await runGatesForTask(active, deps, {
1366
- cwd,
1367
- taskId: res.taskId,
1368
- title: next.title,
1369
- tag: id,
1370
- // Fence an AUTOFIX re-run against re-expanding the whole spec.
1371
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1372
- // res.ok === true means runner.run() completed, so res.taskId is the
1373
- // allocated TASK_NNNN id (never empty here). The parent task-list
1374
- // check-off runs after verify passes/accepts and before the commit,
1375
- // so the commit captures the checked box too.
1376
- onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
1377
- });
1378
- active = gate.ctx;
1379
- // Every terminal gate outcome — what to demote, what to fail, what to
1380
- // say — comes from TERMINAL_OUTCOMES, shared verbatim with /task's
1381
- // loop. `done` alone is not terminal here: it falls through to the
1382
- // next task.
1383
- if (gate.kind !== 'done') {
1384
- const outcome = TERMINAL_OUTCOMES[gate.kind];
1385
- if (outcome.markResumable)
1386
- await markResumable(cwd, res.taskId);
1387
- if (outcome.failParent) {
1388
- 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;
1301
+ }
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;
1389
1348
  }
1390
- announceDone(active, outcome.message({
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,
1391
1360
  tag: id,
1392
- at: formatAt(next.title),
1393
- why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
1394
- resumeCmd: '/task-auto-resume'
1395
- }), outcome.level);
1396
- return;
1397
- }
1398
- // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
1399
- // FAIL to a pre-existing defect in a file some OTHER task created. It can
1400
- // only QUEUE that finding — mutating the plan is this loop's job. Drain
1401
- // the queue and splice a scoped repair step in right after the step that
1402
- // just finished, so the defect is fixed BEFORE the next dependent task
1403
- // trips over it too (run 14 recorded the same `test/teardown.ts` cause
1404
- // twice, scheduled nothing, and the bug outlived ~24h of the run).
1405
- await schedulePendingRepairs(cwd, id, next.index, active, deps);
1406
- // gate.kind === 'done' → fall through to the next task, after checking
1407
- // no landmine stash was left behind by anything that ran in between.
1408
- if (deps.stashRef && stashBefore !== undefined) {
1409
- const stashAfter = await deps.stashRef(cwd);
1410
- if (stashAfter !== stashBefore) {
1411
- active.ui.notify(`${id}: the git stash stack changed during "${next.title}" and was left that way — `
1412
- + '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;
1413
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);
1414
1407
  }
1415
1408
  }
1416
1409
  }