@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
@@ -8,6 +8,8 @@ import { fetchFocused } from '../workers/fetch-core.js';
8
8
  import { runWorker } from '../workers/pi-worker-core.js';
9
9
  import { classifyWorkerFailure } from '../workers/worker-failure.js';
10
10
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
11
+ import { searchProviderKey } from '../workers/search-types.js';
12
+ import { channelSet } from '../workers/worker-channels.js';
11
13
  import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
12
14
  import { isIntegrationUnknown } from './unknown-routing.js';
13
15
  import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
@@ -36,9 +38,10 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
36
38
  import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
37
39
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
38
40
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
39
- import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
41
+ import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
40
42
  import { SessionUI } from '../remote/bridge.js';
41
- import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
43
+ import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
44
+ import { QaTranscript, GRILL_QA_POLICY } from './qa-transcript.js';
42
45
  // ─── Re-export constants from their home modules ────────────────────────────
43
46
  export { MAX_GRILL_QUESTIONS };
44
47
  // ─── Tooling helpers ─────────────────────────────────────────────────────────
@@ -262,7 +265,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
262
265
  // re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
263
266
  const directives = extractUserDirectives(raw);
264
267
  const directivesBlock = preserveDirectivesBlock(directives);
265
- const refined = await runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock))),
268
+ const refined = await runPhaseChild(deps, 'refine', 'read', appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock)),
266
269
  // refine's deliverable is a 4-section text rewrite that never strictly
267
270
  // needs a successful read — on a test-writing task against a large
268
271
  // existing codebase the model over-explores (re-reads source hunting for
@@ -270,7 +273,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
270
273
  // attempt instead of hard-failing the whole run. See TASK_0016 (mx5):
271
274
  // refine looped 3×/resume forever; the deliverable was always producible
272
275
  // from the title + design doc alone.
273
- { degradeOnExhaustion: true });
276
+ { degradeOnExhaustion: true, verb: 'restart' });
274
277
  // Deterministic backstop: if the refined spec still dropped a directive, append
275
278
  // it verbatim rather than trusting the paraphrase. No model in this path.
276
279
  const { text, appended } = enforceDirectives(refined, directives);
@@ -300,21 +303,34 @@ export async function phaseVerifyTooling(deps, research) {
300
303
  await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
301
304
  return replaceToolingWithVerified(research, parsed.verified);
302
305
  }
303
- const DOCS_EXTENSION_PATH = fileURLToPath(new URL('../workers/docs-extension.js', import.meta.url));
304
- /** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
305
- * when a Brave key is configured (the tool without a key just errors, and a weak
306
- * model burns calls on it). Search being absent from the research toolset was
307
- * STRUCTURAL: three consecutive audited runs made 0 search calls because the
308
- * child literally did not have the tool. */
309
- const SEARCH_EXTENSION_PATH = fileURLToPath(new URL('../workers/search-extension.js', import.meta.url));
306
+ /**
307
+ * The worker channels the APIS research worker is given.
308
+ *
309
+ * `pi-worker-search` + `pi-worker-fetch` ride along only when the configured
310
+ * engine is usable (a keyless engine always is; brave needs its key). A tool
311
+ * without a key just errors, and a weak model burns calls on it — while search
312
+ * being ABSENT was structural in the other direction: three consecutive audited
313
+ * runs made 0 search calls because the child literally did not have the tool.
314
+ *
315
+ * Both halves of "given a channel" — the tools string and the `-e` path — come
316
+ * from the same rows, so they cannot disagree.
317
+ */
318
+ function apisWorkerChannels() {
319
+ return channelSet([
320
+ 'pi-worker-docs',
321
+ ...(searchConfigured() ? ['pi-worker-search', 'pi-worker-fetch'] : [])
322
+ ]);
323
+ }
310
324
  /**
311
325
  * Is live web search configured for this process? The keyless providers (exa,
312
- * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
326
+ * ddg) always are; only brave needs its API key.
313
327
  */
314
328
  export function searchConfigured(getEnv = k => process.env[k], provider = getConfig().searchProvider) {
315
- if (provider !== 'brave')
316
- return true;
317
- return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
329
+ // Asks the SAME row `search()` asks. This used to re-state brave's env pair
330
+ // under a comment saying it "mirrors search-core's lookup" — two statements of
331
+ // one fact, and the one that decides whether the APIS worker is even handed the
332
+ // search tool.
333
+ return searchProviderKey(provider, getEnv) !== null;
318
334
  }
319
335
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
320
336
  * (the validated shape for getting a local model to actually reach for search). */
@@ -604,10 +620,10 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
604
620
  + 'nothing else. Keep the same rules as before: state an external library/API behaviour as fact '
605
621
  + 'ONLY when quoting an EXTERNAL CONTEXT block; otherwise write it as an "unverified:" open '
606
622
  + 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
607
- export async function phaseResearch(deps, refined, researchDeps = {}) {
608
- const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
609
- const runWorkerFn = researchDeps.runWorker ?? ((_label, input) => runWorker(input));
610
- const externalContext = await gatherExternalContext(refined, deps, researchDeps);
623
+ export async function phaseResearch(deps, refined) {
624
+ const fileInventoryFn = deps.getFileInventory ?? getFileInventory;
625
+ const runWorkerFn = deps.runWorker ?? ((_label, input) => runWorker(input));
626
+ const externalContext = await gatherExternalContext(refined, deps);
611
627
  // Pre-compute the project file inventory once and hand it to every worker.
612
628
  // Workers can then jump straight to targeted read/grep on known paths
613
629
  // instead of each spawning its own discovery loop (find/ls). A '' result
@@ -721,6 +737,9 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
721
737
  // The worker still calls as many tools as it wants; it just stops narrating
722
738
  // between them. See appendNoThink. Result order (files, apis, context,
723
739
  // tooling) is preserved for assembly.
740
+ // Resolved once: `searchConfigured()` reads the environment, and the tools
741
+ // string and the `-e` paths must be derived from the SAME answer.
742
+ const apisChannels = apisWorkerChannels();
724
743
  const workerSpecs = [
725
744
  {
726
745
  section: 'FILES',
@@ -746,13 +765,12 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
746
765
  // budget enforced without being announced would just read
747
766
  // to the worker as a broken tool.
748
767
  + (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))),
749
- tools: 'read,grep,find,ls,pi-worker-docs'
750
- + (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
768
+ // The tools string and the `-e` paths are ONE fact — which worker
769
+ // channels this research worker is given — and used to be two literals
770
+ // kept in step by eye. `channelSet` derives both from the same rows.
771
+ tools: `read,grep,find,ls,${apisChannels.tools}`,
751
772
  fanoutBounded: true,
752
- extensions: [
753
- DOCS_EXTENSION_PATH,
754
- ...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
755
- ],
773
+ extensions: apisChannels.extensions,
756
774
  // ZERO-RETRIEVAL GATE (mx5 run-15 F-1, distinct from the STAGE 1-3 stopping-point
757
775
  // thread). In a MINORITY of reps worker:apis emits a complete, plausible APIS section
758
776
  // having made ZERO retrieval tool calls — the whole thing recalled from memory.
@@ -1069,9 +1087,9 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
1069
1087
  }
1070
1088
  return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
1071
1089
  }
1072
- export async function phaseAutoAnswer(deps, refined, research, question, autoDeps = {}) {
1073
- const docsFocusedFn = autoDeps.docsFocused ?? docsFocused;
1074
- const fetchFocusedFn = autoDeps.fetchFocused ?? fetchFocused;
1090
+ export async function phaseAutoAnswer(deps, refined, research, question) {
1091
+ const docsFocusedFn = deps.docsFocused ?? docsFocused;
1092
+ const fetchFocusedFn = deps.fetchFocused ?? fetchFocused;
1075
1093
  try {
1076
1094
  // Same assembly as the research phase (see external-context.ts); what
1077
1095
  // differs is POLICY and the worker variant, and both are arguments now.
@@ -1109,7 +1127,7 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
1109
1127
  });
1110
1128
  return { body: countBody(r.answer || undefined) };
1111
1129
  },
1112
- search: autoDeps.searchFn
1130
+ search: deps.searchFn
1113
1131
  }, { targetCap: 2, serviceCap: 2 });
1114
1132
  const basePrompt = externalContext + GRILL_AUTO_ANSWER_PROMPT(refined, research, question);
1115
1133
  let text = await runPhaseChild(deps, 'grill-auto', 'read', basePrompt);
@@ -1194,8 +1212,11 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1194
1212
  // nothing ambiguous remains. Kept in sync with /task-auto's clarify dialog.
1195
1213
  const theme = ctx.ui.theme;
1196
1214
  const ui = new SessionUI(ctx);
1197
- const out = []; // human-facing Q&A transcript (with auto-worker debug lines)
1198
- const qa = []; // compact Q&A fed back into the next question
1215
+ // ONE record, two renderings (task/qa-transcript.ts): `forRecord()` is what
1216
+ // compose and critique are handed, `forGenerator()` is what the next grill-gen
1217
+ // call sees. The provenance rule below used to be a comment 12 lines under a
1218
+ // push that broke it.
1219
+ const transcript = new QaTranscript(GRILL_QA_POLICY);
1199
1220
  const askedQuestions = []; // plain text of each question, for the dup backstop
1200
1221
  // Deterministic backstop against a model that ignores "never re-ask": a
1201
1222
  // near-duplicate question is reprompted (not auto-answered or shown), and after
@@ -1209,7 +1230,7 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1209
1230
  for (let n = 0; n < MAX_GRILL_QUESTIONS; n++) {
1210
1231
  const tGenStart = Date.now();
1211
1232
  const genHint = dupHint;
1212
- const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n')))));
1233
+ const raw = await runPhaseChild(deps, 'grill-gen', 'read', prependHint(genHint, GRILL_GEN_PROMPT(refined, research, transcript.forGenerator())), { verb: 'restart' });
1213
1234
  deps.recordSubStep?.('gen', Date.now() - tGenStart);
1214
1235
  const questions = parseGrillQuestions(raw);
1215
1236
  if (questions.length === 0)
@@ -1235,11 +1256,8 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1235
1256
  // for the editable default and the persisted file.
1236
1257
  const shownQ = renderInlineMarkdown(q, theme);
1237
1258
  const plainQ = stripInlineMarkdown(q);
1238
- out.push(`Q${n + 1}: ${plainQ}`);
1239
- let answer;
1240
1259
  if (auto.kind === 'answered') {
1241
- answer = stripInlineMarkdown(auto.text);
1242
- out.push(`A${n + 1}: ${answer} (auto)`);
1260
+ transcript.add('auto', plainQ, stripInlineMarkdown(auto.text));
1243
1261
  }
1244
1262
  else {
1245
1263
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
@@ -1251,12 +1269,15 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1251
1269
  // guard direction, promoting a hallucination is not.
1252
1270
  const yolo = yoloPickAutoAnswer(isYoloMode(), auto);
1253
1271
  if (yolo !== null) {
1254
- answer =
1255
- yolo.kind === 'answer' ?
1256
- stripInlineMarkdown(yolo.answer)
1257
- : `(skipped ${yolo.note})`;
1258
- out.push(`A${n + 1}: ${answer} ${YOLO_STAMP}`);
1259
- qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer} ${YOLO_STAMP}`);
1272
+ // The YOLO stamp is a RECORD fact. It used to be pushed into the
1273
+ // generator feedback too, against the rule stated a dozen lines
1274
+ // below — the policy now decides, so the two cannot disagree.
1275
+ if (yolo.kind === 'answer') {
1276
+ transcript.add('yolo', plainQ, stripInlineMarkdown(yolo.answer));
1277
+ }
1278
+ else {
1279
+ transcript.add('yolo-skip', plainQ, `(skipped — ${yolo.note})`);
1280
+ }
1260
1281
  continue;
1261
1282
  }
1262
1283
  // The picker cards and the reply mapping are shared with /task-auto's
@@ -1287,17 +1308,17 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1287
1308
  });
1288
1309
  if (a === undefined)
1289
1310
  throw new Error(USER_CANCELLED);
1290
- // No provenance stamp here, unlike clarify's transcript: this string is
1291
- // fed back VERBATIM into the next grill-gen prompt, so a
1292
- // "(accepted recommendation)" suffix would become model input.
1293
- answer = resolveAnswer(pending, a).answer;
1294
- out.push(`A${n + 1}: ${answer}`);
1311
+ // Grill's generator sees NO provenance its feedback is fed verbatim
1312
+ // into the next grill-gen prompt, where a suffix would describe how the
1313
+ // answer was obtained rather than what it was. That is
1314
+ // `GRILL_QA_POLICY.generatorSeesProvenance: false`, stated once.
1315
+ const resolved = resolveAnswer(pending, a);
1316
+ transcript.add(resolved.source === 'accepted' ? 'accepted' : 'typed', plainQ, resolved.answer);
1295
1317
  }
1296
- qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer}`);
1297
1318
  }
1298
- if (out.length === 0)
1319
+ if (transcript.length === 0)
1299
1320
  return '(no questions produced)';
1300
- return out.join('\n');
1321
+ return transcript.forRecord();
1301
1322
  }
1302
1323
  /**
1303
1324
  * A refutation is a DELETION. Where the run's own research explicitly says a
@@ -1323,11 +1344,32 @@ export async function dropRefutedConstraints(deps, refined, research) {
1323
1344
  const refuted = applyRefutations(refined, research);
1324
1345
  if (refuted.trail.length === 0)
1325
1346
  return refined;
1326
- for (const line of refuted.trail) {
1327
- deps.logDebug?.(`compose: ${line}`);
1347
+ await recordPhaseTrail(deps, 'compose', refuted.trail);
1348
+ return refuted.refined;
1349
+ }
1350
+ /**
1351
+ * COMPOSE's carry: the refutation drop, as a `PhaseConfig.carry`.
1352
+ *
1353
+ * Same transform as `dropRefutedConstraints` over the same pure core, minus the
1354
+ * recording — the caller decides whether this application is the live one or a
1355
+ * resume replay. `dropRefutedConstraints` stays exported and unchanged for the
1356
+ * harnesses under `scripts/` that drive the drop directly.
1357
+ */
1358
+ export function composeCarry(_deps, pc) {
1359
+ // Not `async`, and that is the shape rather than an oversight: this carry
1360
+ // performs no I/O at all, which is what lets the resume path replay it.
1361
+ const refuted = applyRefutations(pc.refined, pc.research);
1362
+ if (refuted.trail.length === 0)
1363
+ return Promise.resolve([]);
1364
+ pc.refined = refuted.refined;
1365
+ return Promise.resolve(refuted.trail);
1366
+ }
1367
+ /** Write a carry's trail to the debug log and the task file's `## gates` section. */
1368
+ export async function recordPhaseTrail(deps, phaseName, trail) {
1369
+ for (const line of trail) {
1370
+ deps.logDebug?.(`${phaseName}: ${line}`);
1328
1371
  await appendGateRecord(deps.cwd, deps.taskId, line).catch(() => { });
1329
1372
  }
1330
- return refuted.refined;
1331
1373
  }
1332
1374
  export async function phaseCompose(deps, refined, research, qa) {
1333
1375
  // CLAIM before the belt is built: an obligation an earlier task had to
@@ -1497,92 +1539,130 @@ export async function critiqueWithFallback(d, p) {
1497
1539
  }
1498
1540
  }
1499
1541
  // ─── Phase config table ──────────────────────────────────────────────────────
1542
+ /**
1543
+ * REFINE — restate the raw prompt as a bounded 4-section spec, then subtractively
1544
+ * strike any phantom runtime specifier (`bun:sql`) it carried up verbatim from the
1545
+ * spec doc, BEFORE it flows to research/grill/compose. An appended correction alone
1546
+ * loses: the affirmative survives into the composed GOAL and on to the implementer
1547
+ * (proven: compose re-leaks it 4/4). Rewriting the source so compose has nothing to
1548
+ * contradict is the fix. Silent + no-op when nothing is wrong or the runtime's types
1549
+ * aren't installed.
1550
+ */
1551
+ export async function refinePhase(d, p) {
1552
+ const refined = await phaseRefine(d, p.rawPrompt, p.planContext);
1553
+ const phantoms = await findPhantomImports(refined, d.cwd);
1554
+ if (phantoms.length === 0)
1555
+ return refined;
1556
+ d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
1557
+ return rewritePhantomSpecifiers(refined, phantoms);
1558
+ }
1559
+ /**
1560
+ * RESEARCH — the four workers, then the TOOLING verification pass, then a
1561
+ * deterministic check of every runtime builtin specifier the refined task names
1562
+ * (`bun:sql`, `node:…`) against the installed types. A doc can confidently name a
1563
+ * module that does not exist; left unchecked it rides through every phase and the
1564
+ * implementer fabricates a `declare module` shim to compile it. The corrections are
1565
+ * APPENDED so compose folds them into CONSTRAINTS. No LLM cost, silent when clean.
1566
+ */
1567
+ export async function researchPhase(d, p) {
1568
+ const tResearch = Date.now();
1569
+ const rawResearch = await phaseResearch(d, p.refined);
1570
+ d.recordSubStep?.('workers', Date.now() - tResearch);
1571
+ const tVerify = Date.now();
1572
+ const out = await phaseVerifyTooling(d, rawResearch);
1573
+ d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
1574
+ const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
1575
+ if (corrections) {
1576
+ d.logDebug?.(`phantom imports flagged:\n${corrections}`);
1577
+ return `${out}\n\n${corrections}`;
1578
+ }
1579
+ return out;
1580
+ }
1581
+ /** GRILL — the adaptive question loop, and the only phase that talks to the user. */
1582
+ export function grillPhase(d, p) {
1583
+ return phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research);
1584
+ }
1585
+ /**
1586
+ * COMPOSE — compose the spec from the refined task, the research and the Q&A.
1587
+ *
1588
+ * The refutation drop that must happen first is compose's declared `carry`
1589
+ * (`composeCarry`), not a line at the top of this function. It settles `p.refined`,
1590
+ * which is not compose's own `field`, so the orchestrator has to replay it on the
1591
+ * resume path too — and a `run` body cannot be replayed.
1592
+ */
1593
+ export async function composePhase(d, p) {
1594
+ return await phaseCompose(d, p.refined, p.research, p.qa);
1595
+ }
1596
+ /**
1597
+ * CRITIQUE — the last spec-producing step, and the two host-side corrections that
1598
+ * must run after it in THIS ORDER.
1599
+ *
1600
+ * BRACES (mx5 run 16): append any owned design obligation the spec still omits as a
1601
+ * CONSTRAINTS bullet. The belt block upstream is obeyed ~25% (measured); a host-side
1602
+ * append is obeyed by construction. Idempotent — quotes the spec already carries
1603
+ * (belt-obeying reps) are skipped.
1604
+ *
1605
+ * Then DETACH: an owned obligation whose only file this spec also FREEZES is
1606
+ * unsatisfiable here, so it moves to the pending task that writes that file rather
1607
+ * than shipping a requirement no one can meet. It MUST run after the append, because
1608
+ * the append is what writes the stamp the detach reads — a critique-time probe
1609
+ * measured 0/40 because the stamp did not exist yet.
1610
+ */
1611
+ export async function critiquePhase(d, p) {
1612
+ const spec = await critiqueWithFallback(d, p);
1613
+ const owned = await ownedForThisTask(d);
1614
+ if (owned.length === 0)
1615
+ return spec;
1616
+ const out = appendOwnedConstraints(spec, owned);
1617
+ if (out !== spec) {
1618
+ d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
1619
+ }
1620
+ return await resolveOwnedFreezeForThisTask(d, out);
1621
+ }
1622
+ /**
1623
+ * The pipeline, as a table with no bodies.
1624
+ *
1625
+ * Every row's `run` is a named exported function, so the COMPOSITION inside a
1626
+ * step — which is where this codebase's recorded phase defects have lived, not in
1627
+ * the parts — is drivable directly instead of only through a whole TaskRunner run.
1628
+ * The parts stay exported and separately covered; what changed is that the ORDER
1629
+ * they run in is now asserted by driving the row rather than retyped in a test.
1630
+ */
1500
1631
  export const PHASES = [
1501
1632
  {
1502
1633
  name: 'refine',
1503
1634
  section: 'refined prompt',
1504
1635
  field: 'refined',
1505
- run: async (d, p) => {
1506
- const refined = await phaseRefine(d, p.rawPrompt, p.planContext);
1507
- // Subtractively strike any phantom runtime specifier (`bun:sql`) the
1508
- // refine carried up verbatim from the spec doc, BEFORE it flows to
1509
- // research/grill/compose. An appended correction alone loses: the
1510
- // affirmative survives into the composed GOAL and on to the implementer
1511
- // (proven: compose re-leaks it 4/4). Rewriting the source so compose has
1512
- // nothing to contradict is the fix. Silent + no-op when nothing is wrong
1513
- // or the runtime's types aren't installed.
1514
- const phantoms = await findPhantomImports(refined, d.cwd);
1515
- if (phantoms.length === 0)
1516
- return refined;
1517
- d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
1518
- return rewritePhantomSpecifiers(refined, phantoms);
1519
- }
1520
- },
1521
- {
1522
- name: 'research',
1523
- section: 'research',
1524
- field: 'research',
1525
- run: async (d, p) => {
1526
- const tResearch = Date.now();
1527
- const rawResearch = await phaseResearch(d, p.refined);
1528
- d.recordSubStep?.('workers', Date.now() - tResearch);
1529
- const tVerify = Date.now();
1530
- const out = await phaseVerifyTooling(d, rawResearch);
1531
- d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
1532
- // Deterministically verify every runtime builtin specifier the refined
1533
- // task names (`bun:sql`, `node:…`) against the installed types. A doc can
1534
- // confidently name a module that does not exist; left unchecked it rides
1535
- // through every phase and the implementer fabricates a `declare module`
1536
- // shim to compile it. Append the corrections so compose folds them into
1537
- // CONSTRAINTS. No LLM cost and silent when nothing is wrong.
1538
- const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
1539
- if (corrections) {
1540
- d.logDebug?.(`phantom imports flagged:\n${corrections}`);
1541
- return `${out}\n\n${corrections}`;
1542
- }
1543
- return out;
1544
- }
1545
- },
1546
- {
1547
- name: 'grill',
1548
- section: 'grill Q&A',
1549
- field: 'qa',
1550
- run: (d, p) => phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research)
1551
- },
1552
- {
1553
- name: 'compose',
1554
- section: 'spec',
1555
- field: 'spec',
1556
- run: async (d, p) => {
1557
- p.refined = await dropRefutedConstraints(d, p.refined, p.research);
1558
- return await phaseCompose(d, p.refined, p.research, p.qa);
1559
- }
1636
+ run: refinePhase,
1637
+ postCommit: refinePostCommit
1560
1638
  },
1561
- {
1562
- name: 'critique',
1563
- section: 'spec',
1564
- field: 'spec',
1565
- run: async (d, p) => {
1566
- const spec = await critiqueWithFallback(d, p);
1567
- // BRACES (mx5 run 16): after the LAST spec-producing step, append any
1568
- // owned design obligation the spec still omits as a CONSTRAINTS
1569
- // bullet. The belt block upstream is obeyed ~25% (measured); a
1570
- // host-side append is obeyed by construction. Idempotent: quotes the
1571
- // spec already carries (belt-obeying reps) are skipped.
1572
- const owned = await ownedForThisTask(d);
1573
- if (owned.length === 0)
1574
- return spec;
1575
- const out = appendOwnedConstraints(spec, owned);
1576
- if (out !== spec) {
1577
- d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
1578
- }
1579
- // An owned obligation whose only file this spec also FREEZES is
1580
- // unsatisfiable here; move it to the pending task that writes that
1581
- // file rather than shipping a requirement no one can meet.
1582
- return await resolveOwnedFreezeForThisTask(d, out);
1583
- }
1584
- }
1639
+ { name: 'research', section: 'research', field: 'research', run: researchPhase },
1640
+ { name: 'grill', section: 'grill Q&A', field: 'qa', run: grillPhase },
1641
+ { name: 'compose', section: 'spec', field: 'spec', carry: composeCarry, run: composePhase },
1642
+ { name: 'critique', section: 'spec', field: 'spec', run: critiquePhase }
1585
1643
  ];
1644
+ /**
1645
+ * Run one phase row the way the orchestrator does: carry, then run.
1646
+ *
1647
+ * The row is the interface, so this is the surface a row-driving test crosses —
1648
+ * calling `row.run` alone tests past it and would not have caught a carry that the
1649
+ * resume path drops. The orchestrator adds only persistence, timings and the
1650
+ * checkpoint around this.
1651
+ */
1652
+ export async function runPhaseRow(row, deps, pc) {
1653
+ if (row.carry)
1654
+ await recordPhaseTrail(deps, row.name, await row.carry(deps, pc));
1655
+ return await row.run(deps, pc);
1656
+ }
1657
+ /**
1658
+ * Re-apply one phase row's carry on the RESUME path, where `run` is skipped.
1659
+ *
1660
+ * The trail is discarded: the live run that produced this phase's output already
1661
+ * recorded it on `## gates`, and a replay must not append a second copy.
1662
+ */
1663
+ export async function replayPhaseCarry(row, deps, pc) {
1664
+ await row.carry?.(deps, pc);
1665
+ }
1586
1666
  // INTEGRATION-DEPTH APPEND (2026-07-27): the lever proposed for this exact site —
1587
1667
  // deterministically append a known-runnable integration command to the VERIFY block
1588
1668
  // whenever a task's ACCEPTANCE claims runtime behaviour — is REFUTED at STEP 0 and was
@@ -1627,9 +1707,16 @@ export const PHASES = [
1627
1707
  //
1628
1708
  // Durable assets kept: both rigs above and their unit tests. Do NOT wire an append here
1629
1709
  // without a command source that satisfies all four properties at once.
1710
+ /** Dispatch a row's declared post-commit effect. Rows with none do nothing. */
1630
1711
  export async function postCommitPhase(phase, deps, pc, out) {
1631
- if (phase.name !== 'refine')
1632
- return;
1712
+ await phase.postCommit?.(deps, pc, out);
1713
+ }
1714
+ /**
1715
+ * REFINE's post-commit: derive the task title from the refined prompt, then a short
1716
+ * display label. Runs after the section write, so a fault here cannot lose the
1717
+ * output it reads.
1718
+ */
1719
+ export async function refinePostCommit(deps, pc, out) {
1633
1720
  const title = deriveTitle(out);
1634
1721
  pc.widgetState.title = title;
1635
1722
  // Compress the (often paragraph-long) title into a short display label. This
@@ -187,8 +187,8 @@ async function defaultHandoff(ctx, cwd, prompt) {
187
187
  await runGatedTask(ctx, cwd, prompt);
188
188
  return undefined;
189
189
  }
190
- const { taskId, sessionCancelled } = await runSingleTask(ctx, cwd, prompt, { notifyFinish: true });
191
- if (sessionCancelled) {
190
+ const { taskId, end } = await runSingleTask(ctx, cwd, prompt, { notifyFinish: true });
191
+ if (end.kind === 'no-session') {
192
192
  ctx.ui.notify('Could not start a fresh session for /task-plan.', 'warning');
193
193
  return undefined;
194
194
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * What the PLAN-SHAPING loops record, and the decisions that record makes.
3
+ *
4
+ * `GateTally`'s and `AutofixLedger`'s twin, one phase earlier. CONTEXT.md records
5
+ * that shape twice already: a long loop threading mutable locals by closure, with
6
+ * pure helpers extracted for testability while the ORDERING and CARRY-FORWARD
7
+ * decisions stayed in the caller. `coverPlan` was the third instance — five locals
8
+ * (`planTitles`, `best`, `round`, `roundCap`, `bonusRoundUsed`) plus a
9
+ * snapshot-before-overwrite pair (`priorCovered`, `priorMissing`) that existed
10
+ * ONLY because the bonus-round decision was made downstream from the evidence it
11
+ * needed, so the loop had to save a copy of `best` before replacing it.
12
+ *
13
+ * The last real bug here says the shape out loud, in the loop's own comment:
14
+ * *"This used to be two assignments, and the second one kept the OLD plan's
15
+ * accounting whenever the new plan's coverage-map child faulted
16
+ * (`cand.accounting ?? accounting`) — binding requirements to titles they were
17
+ * never mapped against."* `AutofixLedger`'s indictment, verbatim: the decision was
18
+ * made downstream from the evidence.
19
+ *
20
+ * `consider` closes it by construction rather than by comment. It compares, it
21
+ * replaces the plan WHOLE (titles and accounting together, because they are one
22
+ * value), and it grants the bonus round IN THE SAME CALL that adopts — the way
23
+ * `judge(outcome, edited)` enters the demoted signature in the call that demotes.
24
+ * There is no window in which the snapshot and the replacement can disagree.
25
+ *
26
+ * NO I/O. No `logPlanDebug`, no notify, no child — for the same reason `GateTally`
27
+ * performs none: a record that performs effects cannot be driven by a test that
28
+ * only wants the verdict. The caller trails what the returned decision says.
29
+ */
30
+ import { type AdoptionDecision, type ScoredPlan } from './coverage-loop.js';
31
+ /** What `consider` did with a candidate, and why. */
32
+ export interface ConsiderOutcome {
33
+ adopted: boolean;
34
+ /** The adoption verdict's own reasoning, for the caller's trail. */
35
+ decision: AdoptionDecision;
36
+ /** True when this adoption is what bought the one bonus round. */
37
+ grantedBonusRound: boolean;
38
+ }
39
+ export interface CoverageLedgerOptions {
40
+ /** The round cap before any bonus. */
41
+ cap: number;
42
+ /**
43
+ * Are there grounded requirements to judge against?
44
+ *
45
+ * Without them `missing` is pure holistic-judge free text that can change every
46
+ * round, so there is no trustworthy "grew"/"new" signal — which is why the
47
+ * bonus round is requirements-path only.
48
+ */
49
+ hasRequirements: boolean;
50
+ }
51
+ /**
52
+ * The coverage loop's record: the best plan seen, the rounds spent, and the
53
+ * one-shot bonus round.
54
+ *
55
+ * Methods are named for what they MEAN, not for the field they touch.
56
+ */
57
+ export declare class CoverageLedger {
58
+ private _best;
59
+ private readonly _opts;
60
+ private _round;
61
+ private _cap;
62
+ private _bonusUsed;
63
+ constructor(_best: ScoredPlan, _opts: CoverageLedgerOptions);
64
+ /** The best-covered plan seen so far — the one that reprompts, and the one that ships. */
65
+ best(): ScoredPlan;
66
+ /** Rounds spent so far. */
67
+ round(): number;
68
+ /** May another reprompt round run? */
69
+ mayRetry(): boolean;
70
+ /** Spend a round. Call once per reprompt, before the child runs. */
71
+ startRound(): number;
72
+ /** What is still uncovered in the shipping plan, or null when nothing is. */
73
+ unresolved(): string[] | null;
74
+ /**
75
+ * Judge one candidate against the best plan and, if it wins, adopt it.
76
+ *
77
+ * The bonus-round grant is decided HERE, against the pre-adoption plan this
78
+ * method still holds — not by a caller reading a snapshot it took beforehand.
79
+ * Two guards keep it off generic judge churn: the grounded covered-set must
80
+ * strictly GROW (a flaky judge relabelling the same-shaped plan's gap does not
81
+ * qualify), and the candidate must expose a NEW area (a gap already present is
82
+ * one we have reprompted against or will). Bounded to one, so a judge that
83
+ * flags forever still cannot loop the plan phase.
84
+ */
85
+ consider(cand: ScoredPlan): ConsiderOutcome;
86
+ }