@mjasnikovs/pi-task 0.38.2 → 0.38.4

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 (49) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/deep-render-check.d.ts +30 -0
  14. package/dist/task/deep-render-check.js +19 -11
  15. package/dist/task/enforce-guidelines.d.ts +14 -17
  16. package/dist/task/enforce-guidelines.js +44 -31
  17. package/dist/task/final-gate.d.ts +8 -10
  18. package/dist/task/final-gate.js +36 -74
  19. package/dist/task/gate-child.d.ts +104 -0
  20. package/dist/task/gate-child.js +177 -0
  21. package/dist/task/gate-deps.d.ts +13 -0
  22. package/dist/task/gate-deps.js +72 -208
  23. package/dist/task/orchestrator.js +13 -22
  24. package/dist/task/phases.js +109 -182
  25. package/dist/task/plan-session.d.ts +4 -22
  26. package/dist/task/plan-session.js +4 -33
  27. package/dist/task/question-dialog.d.ts +71 -0
  28. package/dist/task/question-dialog.js +89 -0
  29. package/dist/task/terminal-outcome.d.ts +67 -0
  30. package/dist/task/terminal-outcome.js +76 -0
  31. package/dist/task/type-only-answer.js +2 -3
  32. package/dist/workers/abstention.d.ts +71 -0
  33. package/dist/workers/abstention.js +108 -0
  34. package/dist/workers/docs-chunk.d.ts +74 -0
  35. package/dist/workers/docs-chunk.js +143 -0
  36. package/dist/workers/docs-core.d.ts +10 -1
  37. package/dist/workers/docs-core.js +22 -19
  38. package/dist/workers/docs-index.js +2 -69
  39. package/dist/workers/docs-project.d.ts +15 -1
  40. package/dist/workers/docs-project.js +27 -66
  41. package/dist/workers/fetch-core.d.ts +1 -1
  42. package/dist/workers/fetch-core.js +2 -1
  43. package/dist/workers/pi-worker-core.js +157 -86
  44. package/dist/workers/pi-worker-docs.js +5 -10
  45. package/dist/workers/pi-worker-fetch.js +8 -1
  46. package/dist/workers/typeonly-log.js +2 -10
  47. package/dist/workers/worker-failure.d.ts +91 -0
  48. package/dist/workers/worker-failure.js +82 -0
  49. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
7
  import { fetchFocused } from '../workers/fetch-core.js';
8
8
  import { runWorker } from '../workers/pi-worker-core.js';
9
+ import { classifyWorkerFailure } from '../workers/worker-failure.js';
9
10
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
10
11
  import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
11
12
  import { isIntegrationUnknown } from './unknown-routing.js';
@@ -27,14 +28,10 @@ import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './quest
27
28
  import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
28
29
  import { compressTitle } from './title-label.js';
29
30
  import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
30
- import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
31
- import { findScriptEscapesInText, scriptEscapeDefectText } from './script-escape.js';
32
- import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
33
- import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
34
- import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
31
+ import { collectCritiqueDefects } from './critique-probes.js';
32
+ import { buildOptionCards, resolveAnswer } from './question-dialog.js';
35
33
  import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
36
- import { findGrepOnlyVerify, grepOnlyVerifyDefectText, GREP_THEATER_RETRY_HINT } from './verify-quality.js';
37
- import { existsSync } from 'node:fs';
34
+ import { GREP_THEATER_RETRY_HINT } from './verify-quality.js';
38
35
  import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
39
36
  import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
40
37
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
@@ -405,23 +402,55 @@ export function scopedToolingGoal(refined) {
405
402
  * Returns null when the result is trustworthy.
406
403
  */
407
404
  function classifyResearchWorker(name, result) {
408
- if (result.loopHit) {
409
- const argsStr = JSON.stringify(result.loopHit.call.args);
410
- return {
411
- kind: 'runaway',
412
- reason: `stuck in a loop called ${result.loopHit.call.name}(${argsStr}) `
413
- + `×${result.loopHit.count} in the last ${result.loopHit.windowSize} calls `
414
- + `and still looped after restarts`
415
- };
416
- }
417
- if (result.timedOut) {
418
- return { kind: 'runaway', reason: 'timed out after restarts' };
419
- }
420
- if (result.exitCode !== 0) {
421
- return {
422
- kind: 'fatal',
423
- error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
424
- };
405
+ // What KILLED the child, if anything — classified once, in the ladder that
406
+ // owns the precedence (workers/worker-failure.ts), because every kill path
407
+ // also sets `aborted` and a non-zero exit. This switch says only what each
408
+ // cause means to RESEARCH; being exhaustive, a new cause is a compile error
409
+ // here instead of falling through to the generic "exit N".
410
+ const failure = classifyWorkerFailure(result);
411
+ if (failure) {
412
+ switch (failure.kind) {
413
+ case 'loop': {
414
+ const argsStr = JSON.stringify(failure.hit.call.args);
415
+ return {
416
+ kind: 'runaway',
417
+ reason: `stuck in a loop — called ${failure.hit.call.name}(${argsStr}) `
418
+ + `×${failure.hit.count} in the last ${failure.hit.windowSize} calls `
419
+ + `and still looped after restarts`
420
+ };
421
+ }
422
+ case 'worker-timeout':
423
+ return { kind: 'runaway', reason: 'timed out after restarts' };
424
+ case 'command-timeout':
425
+ return {
426
+ kind: 'runaway',
427
+ reason: `ran a \`${failure.toolName}\` command that never returned and was killed `
428
+ + 'after restarts'
429
+ };
430
+ case 'stream-stall':
431
+ return {
432
+ kind: 'runaway',
433
+ reason: `model stream went silent for ${failure.idleMs}ms after restarts`
434
+ };
435
+ case 'stalled':
436
+ return {
437
+ kind: 'fatal',
438
+ error: new Error(`Research ${name} worker: model server unreachable — the child produced no `
439
+ + 'output and the model endpoint did not respond')
440
+ };
441
+ case 'leaked-tool-call':
442
+ return {
443
+ kind: 'fatal',
444
+ error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
445
+ + `(${failure.text.trim()}) — it never ran`)
446
+ };
447
+ case 'aborted':
448
+ case 'exit':
449
+ return {
450
+ kind: 'fatal',
451
+ error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
452
+ };
453
+ }
425
454
  }
426
455
  if (result.text.trim().length === 0) {
427
456
  // NOTHING CAME BACK — two different events wear the same face, and the whole
@@ -452,13 +481,6 @@ function classifyResearchWorker(name, result) {
452
481
  }
453
482
  return { kind: 'empty' };
454
483
  }
455
- if (result.leakedToolCall) {
456
- return {
457
- kind: 'fatal',
458
- error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
459
- + `(${result.leakedToolCall.trim()}) — it never ran`)
460
- };
461
- }
462
484
  return null;
463
485
  }
464
486
  /**
@@ -1221,10 +1243,6 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1221
1243
  else {
1222
1244
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
1223
1245
  const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
1224
- // A recommendation (or suggested+alt fork) becomes the boxed picker
1225
- // locally — each answer in its own bounding box, the recommended one
1226
- // tinted green; an open question shows the bare text prompt.
1227
- const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
1228
1246
  // YOLO: take the recommended option and never build the prompt (which
1229
1247
  // is also what suppresses its notification — see yolo.ts). An answer the
1230
1248
  // anti-synthesis guard demoted, or a question with no recommendation at
@@ -1240,17 +1258,22 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1240
1258
  qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer} ${YOLO_STAMP}`);
1241
1259
  continue;
1242
1260
  }
1243
- const options = twoOption ?
1244
- [
1245
- {
1246
- label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
1247
- value: plainSuggested
1248
- },
1249
- { label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
1250
- ]
1251
- : plainSuggested !== undefined ?
1252
- [{ label: renderInlineMarkdown(auto.suggested, theme), value: plainSuggested }]
1253
- : undefined;
1261
+ // The picker cards and the reply mapping are shared with /task-auto's
1262
+ // clarify loop and the plan session (question-dialog.ts) — all three
1263
+ // used to write them out, and had drifted.
1264
+ const pending = {
1265
+ plain: plainQ,
1266
+ shown: shownQ,
1267
+ ...(plainSuggested !== undefined && {
1268
+ suggested: plainSuggested,
1269
+ shownSuggested: renderInlineMarkdown(auto.suggested, theme)
1270
+ }),
1271
+ ...(plainAlt !== undefined && {
1272
+ alt: plainAlt,
1273
+ shownAlt: renderInlineMarkdown(auto.alt, theme)
1274
+ })
1275
+ };
1276
+ const options = buildOptionCards(pending);
1254
1277
  widgetState.lastLine = `awaiting Q${n + 1}`;
1255
1278
  const a = await ui.ask({
1256
1279
  localTitle: shownQ,
@@ -1263,27 +1286,10 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1263
1286
  });
1264
1287
  if (a === undefined)
1265
1288
  throw new Error(USER_CANCELLED);
1266
- const typed = a.trim();
1267
- // The local picker resolves to the chosen option's full value, but a
1268
- // remote user (or the picker's free-text fallback) may still type a
1269
- // bare "A"/"B" — map those back to the option's full text, since
1270
- // storing the literal letter leaves the next grill-gen call a
1271
- // dangling reference it can't decode.
1272
- if (typed.length === 0 && plainSuggested) {
1273
- answer = plainSuggested;
1274
- }
1275
- else if (typed.length === 0) {
1276
- answer = '(skipped)';
1277
- }
1278
- else if (twoOption && /^a[.)]?$/i.test(typed)) {
1279
- answer = plainSuggested;
1280
- }
1281
- else if (twoOption && /^b[.)]?$/i.test(typed)) {
1282
- answer = plainAlt;
1283
- }
1284
- else {
1285
- answer = typed;
1286
- }
1289
+ // No provenance stamp here, unlike clarify's transcript: this string is
1290
+ // fed back VERBATIM into the next grill-gen prompt, so a
1291
+ // "(accepted recommendation)" suffix would become model input.
1292
+ answer = resolveAnswer(pending, a).answer;
1287
1293
  out.push(`A${n + 1}: ${answer}`);
1288
1294
  }
1289
1295
  qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer}`);
@@ -1372,90 +1378,29 @@ extraDefects) {
1372
1378
  // When the draft is structurally sound and triage says CLEAN, return it as
1373
1379
  // is. Otherwise fall through to the rewrite, feeding the triage defects in
1374
1380
  // as a focus list. Triage failures are non-fatal — we just do the rewrite.
1375
- // DETERMINISTIC skip-escape gate (run-8 F2): a required VERIFY check wrapped in a
1376
- // skip-announcing `||` fallback (`… || echo "skipping (tool absent)"`) lets the
1377
- // check pass while never running. FP-measured 0/20 on the historical specs. When
1378
- // present, force the rewrite to strip it never let the triage CLEAN short-circuit
1379
- // ship a self-waiving VERIFY blockand feed the offending lines in as defects.
1380
- const skipEscapes = findSkipEscapes(spec);
1381
- const skipDefects = skipEscapes.length > 0 ? skipEscapeDefectText(skipEscapes) : null;
1382
- // The cross-slice contract registry (run-8 F3): the design's pinned interface
1383
- // facts, quoted verbatim, that more than one slice touches. Threading them into
1384
- // critique lets the triage/rewrite RECONCILE a synthesized wiring specific (a
1385
- // fabricated uniform mount table) against the facts it must reproduce the
1386
- // generation-side complement of the verify-side boundary check. Empty (single
1387
- // /task, or a design that pins no shared boundary) ⇒ no-op.
1381
+ // The DETERMINISTIC half of critique: six scanners, each finding a defect the
1382
+ // model does not self-discover reliably, each forced into the rewrite and each
1383
+ // overriding a CLEAN triage. They live as rows in CRITIQUE_PROBES
1384
+ // (critique-probes.ts) so the override and the merge below are DERIVED from
1385
+ // the table rather than retypeda probe used to be listed by hand in three
1386
+ // places, and forgetting the override term shipped the very defect it was
1387
+ // added to catch.
1388
+ //
1389
+ // The contract registry is read here rather than inside the table because the
1390
+ // critique PROMPT needs it too (run-8 F3): threading the design's pinned
1391
+ // interface facts into the rewrite lets it RECONCILE a synthesized wiring
1392
+ // specific against the facts it must reproduce — the generation-side
1393
+ // complement of the verify-side boundary check.
1388
1394
  const registryRaw = await readContracts(deps.cwd).catch(() => '');
1389
1395
  const contractsBlock = buildContractsVerifyBlock(registryRaw);
1390
- // DETERMINISTIC synthesized-wiring probe (run-8 F3, generation side). The registry
1391
- // alone is a WEAK catcher (live A/B: prompt+registry ~1/8) — the model's attention
1392
- // goes to the obvious VERIFY weakness and it rarely does the path-composition
1393
- // reasoning. The scanner NAMES the inferred mount mappings and juxtaposes the
1394
- // verbatim pinned facts, forcing focused reconciliation (probe+rule pattern, same
1395
- // lever as skip-escape / substitution-probe). FP-clean (1/18 files on the run-8
1396
- // trees). Grounding = the registry any design doc the spec/refined @-reference.
1397
- const wiring = registryRaw.trim().length > 0 ?
1398
- findSynthesizedWiring(spec, registryRaw + '\n' + readReferencedDocs(deps.cwd, refined, spec), registryRaw)
1399
- : [];
1400
- const wiringProbe = wiring.length > 0 ? wiringProbeText(wiring, registryRaw) : null;
1401
- if (wiringProbe) {
1402
- deps.logDebug?.(`synthesized wiring flagged in spec: ${wiring.map(w => w.line).join(' | ')}`);
1403
- }
1404
- // DETERMINISTIC plan-contradiction probe (mx5 run 11, goal D): a VERIFY line
1405
- // asserting the ABSENCE of an artifact the plan pins elsewhere — a path a prior
1406
- // task already shipped to disk, a sibling title's deliverable, a contract-pinned
1407
- // boundary. Run 11: the scope fence leaked into TASK_0009's verify as "the admin
1408
- // page must NOT exist" (TASK_0008's deliverable); the guaranteed FAIL became an
1409
- // accepted debt that the final-gate autofix then "fixed" by deleting the sibling's
1410
- // work. The conflict must die here, at spec time — forced into the rewrite like
1411
- // the skip-escape finding; delete-tasks keep their check by declaring the delete.
1412
- const absenceConflicts = findAbsenceConflicts(spec, {
1413
- fileExists: p => existsSync(resolve(deps.cwd, p)),
1414
- siblingTitles: siblingTitlesFromPlanContext(planContext),
1415
- contracts: registryRaw
1416
- });
1417
- const absenceProbe = absenceConflicts.length > 0 ? absenceProbeText(absenceConflicts) : null;
1418
- if (absenceProbe) {
1419
- deps.logDebug?.('plan-contradiction flagged in VERIFY: '
1420
- + absenceConflicts.map(c => `${c.assertion.target} (${c.against})`).join(' | '));
1421
- }
1422
- // DETERMINISTIC unsatisfiable-pair probe (mx5 run 12 root cause): a blanket
1423
- // frozen path ("Do NOT modify `tsconfig.json` … handled in steps 1–2") whose
1424
- // registration edit the spec's OWN body — or the task's RESEARCH the spec was
1425
- // composed from (live drafts sometimes drop the nuance while shipping the
1426
- // freeze and the creation) — says the deliverable requires ("must also be
1427
- // included …"). Shipped as-is, the created files turn the repo-wide static
1428
- // check permanently red and no task is allowed to fix it — every later task
1429
- // burns its AUTOFIX rounds on it. Forced into the rewrite like the other
1430
- // probes: the rewrite must grant scoped ownership or drop the creation.
1431
- const frozenConflicts = findFrozenPathConflicts(spec, research);
1432
- const frozenProbe = frozenConflicts.length > 0 ? frozenConflictProbeText(frozenConflicts) : null;
1433
- if (frozenProbe) {
1434
- deps.logDebug?.('unsatisfiable freeze/requires-edit pair flagged in spec: '
1435
- + frozenConflicts.map(c => c.path).join(' | '));
1436
- }
1437
- // DETERMINISTIC grep-theater probe (mx5 run 13, Bug B): a VERIFY block that
1438
- // grep-asserts the SOURCE of a runnable deliverable while every command in
1439
- // the block is static inspection — the build script "verified" by three
1440
- // greps that was never run, shipping broken for 14 tasks. Forced into the
1441
- // rewrite like the skip-escape finding: VERIFY must EXECUTE the artifact
1442
- // and assert an observable outcome of that run.
1443
- const grepOnly = findGrepOnlyVerify(spec);
1444
- const grepOnlyProbe = grepOnly.length > 0 ? grepOnlyVerifyDefectText(grepOnly) : null;
1445
- if (grepOnlyProbe) {
1446
- deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
1447
- }
1448
- // DETERMINISTIC neutered-check-script probe (mx5 run 13, PROMPT 4 item 4): a
1449
- // spec that DICTATES a check script which cannot fail — `"lint": "… || true"`,
1450
- // or a checker laundered through an inverted grep. Whatever task implements
1451
- // that spec writes the disarmed script into package.json, and from then on
1452
- // every gate that runs it (repo-health verify, the final integration gate)
1453
- // reads a constant. Cheapest to kill here, in the spec, before it is authored.
1454
- const scriptEscapes = findScriptEscapesInText(spec);
1455
- const scriptProbe = scriptEscapes.length > 0 ? scriptEscapeDefectText(scriptEscapes) : null;
1456
- if (scriptProbe) {
1457
- deps.logDebug?.('neutered check script dictated by spec: ' + scriptEscapes.map(f => f.name).join(' | '));
1458
- }
1396
+ const probes = collectCritiqueDefects({
1397
+ spec,
1398
+ refined,
1399
+ ...(research === undefined ? {} : { research }),
1400
+ cwd: deps.cwd,
1401
+ registryRaw,
1402
+ ...(planContext === undefined ? {} : { planContext })
1403
+ }, deps.logDebug);
1459
1404
  let triageDefects = null;
1460
1405
  if (parseVerifyBlock(spec) !== null) {
1461
1406
  const tTriage = Date.now();
@@ -1472,19 +1417,12 @@ extraDefects) {
1472
1417
  }
1473
1418
  deps.recordSubStep?.('triage', Date.now() - tTriage);
1474
1419
  if (verdict !== null) {
1475
- // A deterministic skip-escape, synthesized-wiring, plan-contradiction,
1476
- // unsatisfiable-pair, grep-theater, or neutered-check-script finding
1477
- // overrides a CLEAN triage: the draft must be rewritten to resolve it
1478
- // even if the model judged the rest clean (the model does not
1479
- // self-discover any of them reliably).
1420
+ // ANY deterministic finding overrides a CLEAN triage: the draft must be
1421
+ // rewritten to resolve it even if the model judged the rest clean (the
1422
+ // model does not self-discover any of them reliably). Derived from the
1423
+ // table, so a new probe joins this rule by existing.
1480
1424
  if (isCritiqueClean(verdict)) {
1481
- if (skipDefects === null
1482
- && wiringProbe === null
1483
- && absenceProbe === null
1484
- && frozenProbe === null
1485
- && grepOnlyProbe === null
1486
- && scriptProbe === null
1487
- && (extraDefects ?? null) === null) {
1425
+ if (!probes.forced && (extraDefects ?? null) === null) {
1488
1426
  return spec;
1489
1427
  }
1490
1428
  }
@@ -1493,21 +1431,9 @@ extraDefects) {
1493
1431
  }
1494
1432
  }
1495
1433
  }
1496
- // Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
1497
- // + unsatisfiable-pair + grep-theater + neutered-script defects with any triage
1498
- // defects for the rewrite (all are forced FOCUS items).
1499
- const rewriteDefects = [
1500
- skipDefects,
1501
- wiringProbe,
1502
- absenceProbe,
1503
- frozenProbe,
1504
- grepOnlyProbe,
1505
- scriptProbe,
1506
- extraDefects ?? null,
1507
- triageDefects
1508
- ]
1509
- .filter(Boolean)
1510
- .join('\n\n') || null;
1434
+ // Merge every deterministic defect with any triage defects for the rewrite
1435
+ // (all are forced FOCUS items).
1436
+ const rewriteDefects = [...probes.blocks, extraDefects ?? null, triageDefects].filter(Boolean).join('\n\n') || null;
1511
1437
  const tRewrite = Date.now();
1512
1438
  try {
1513
1439
  return await runWithEmphasisRetry(deps, 'critique', 'read', problem => {
@@ -1526,13 +1452,14 @@ extraDefects) {
1526
1452
  if (parseVerifyBlock(stripped) === null) {
1527
1453
  return { ok: false, problem: 'no_verify_block' };
1528
1454
  }
1529
- // Detector-backed closure on the grep-theater defect: when the
1530
- // draft was flagged, the rewrite must actually resolve it (live
1531
- // A/B: 1/5 rewrites ignored the injected defect and re-shipped
1532
- // the grep-only block). One emphasis retry with a targeted hint;
1455
+ // Detector-backed closure: a rewrite HANDED a defect and shipping
1456
+ // it anyway is a failed rewrite. Only probes that actually fired
1457
+ // are re-checked a defect the draft never had is not the
1458
+ // rewrite's to resolve. One emphasis retry with a targeted hint;
1533
1459
  // a second miss falls back to the draft in critiqueWithFallback.
1534
- if (grepOnlyProbe !== null && findGrepOnlyVerify(stripped).length > 0) {
1535
- return { ok: false, problem: 'verify_grep_theater' };
1460
+ const unresolved = probes.unresolvedIn(stripped);
1461
+ if (unresolved !== null) {
1462
+ return { ok: false, problem: unresolved };
1536
1463
  }
1537
1464
  return { ok: true, value: stripped };
1538
1465
  }, problem => new Error(problem));
@@ -30,7 +30,10 @@
30
30
  * is unit-testable without a TUI or a model.
31
31
  */
32
32
  import type { AskSpec } from '../remote/bridge.js';
33
- import { type PlanEntry, type AnswerSource } from './plan-io.js';
33
+ import { type PlanEntry } from './plan-io.js';
34
+ import { type PendingQuestion } from './question-dialog.js';
35
+ export { resolveAnswer } from './question-dialog.js';
36
+ export type { PendingQuestion } from './question-dialog.js';
34
37
  /**
35
38
  * Sentinel values the picker resolves to when the user takes a control action
36
39
  * instead of answering. Deliberately shaped like the existing `USER_CANCELLED`
@@ -172,16 +175,6 @@ export type PlanOutcome = {
172
175
  kind: 'cancelled';
173
176
  entries: PlanEntry[];
174
177
  };
175
- interface PendingQuestion {
176
- /** Plain text — persisted, and fed back to the model. */
177
- plain: string;
178
- /** Markdown-rendered — displayed. */
179
- shown: string;
180
- suggested?: string;
181
- shownSuggested?: string;
182
- alt?: string;
183
- shownAlt?: string;
184
- }
185
178
  /**
186
179
  * Build the picker for a pending model question: the recommendation first (index
187
180
  * 0 is the green RECOMMENDED card), the alternative second when the question is a
@@ -200,18 +193,7 @@ export declare function buildQuestionSpec(p: PendingQuestion): PlanAskSpec;
200
193
  * planning, so it never sits where a reflexive first-item press can hit it.
201
194
  */
202
195
  export declare function buildIdleSpec(): PlanAskSpec;
203
- /**
204
- * Map what the picker returned onto the answer that gets recorded. Mirrors the
205
- * identical mapping in phaseGrill and planAuto: an empty submit accepts the
206
- * recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
207
- * back to the option's full text, and anything else is taken verbatim.
208
- */
209
- export declare function resolveAnswer(p: PendingQuestion, raw: string): {
210
- answer: string;
211
- source: AnswerSource;
212
- };
213
196
  /** Copy for the dialog that collects the user's own question. */
214
197
  export declare const ASK_TITLE = "Ask the model";
215
198
  export declare const ASK_QUESTION = "What do you want to ask about this task? The answer is recorded as a note; it does not decide anything by itself.";
216
199
  export declare function runPlanSession(deps: PlanSessionDeps): Promise<PlanOutcome>;
217
- export {};
@@ -34,6 +34,8 @@ import { stripInlineMarkdown } from './inline-markdown.js';
34
34
  import { isDuplicateQuestion, DUP_REPROMPT_HINT, MAX_DUP_STRIKES } from './question-dedup.js';
35
35
  import { yoloPickAnswer } from './yolo.js';
36
36
  import { formatPlanTranscript } from './plan-io.js';
37
+ import { buildOptionCards, resolveAnswer } from './question-dialog.js';
38
+ export { resolveAnswer } from './question-dialog.js';
37
39
  // ─── Control actions ─────────────────────────────────────────────────────────
38
40
  /**
39
41
  * Sentinel values the picker resolves to when the user takes a control action
@@ -169,6 +171,7 @@ export function planForkHint(question) {
169
171
  + 'time emit BOTH lines:\nSUGGESTED: <the option you recommend>\nALT: <the other option>\n'
170
172
  + 'Nothing else.]');
171
173
  }
174
+ // ─── Pending question ────────────────────────────────────────────────────────
172
175
  /**
173
176
  * Build the picker for a pending model question: the recommendation first (index
174
177
  * 0 is the green RECOMMENDED card), the alternative second when the question is a
@@ -178,14 +181,7 @@ export function planForkHint(question) {
178
181
  * trailing "proceed to execution" card so proceed stays last.
179
182
  */
180
183
  export function buildQuestionSpec(p) {
181
- const options = [];
182
- if (p.suggested !== undefined && p.alt !== undefined) {
183
- options.push({ label: `A: ${p.shownSuggested ?? p.suggested}`, value: p.suggested });
184
- options.push({ label: `B: ${p.shownAlt ?? p.alt}`, value: p.alt });
185
- }
186
- else if (p.suggested !== undefined) {
187
- options.push({ label: p.shownSuggested ?? p.suggested, value: p.suggested });
188
- }
184
+ const options = buildOptionCards(p) ?? [];
189
185
  const actions = [
190
186
  { label: PLAN_ASK_LABEL, value: PLAN_ASK },
191
187
  { label: PLAN_PROCEED_LABEL, value: PLAN_PROCEED }
@@ -235,31 +231,6 @@ export function buildIdleSpec() {
235
231
  manualPosition: actions.length - 1
236
232
  };
237
233
  }
238
- /**
239
- * Map what the picker returned onto the answer that gets recorded. Mirrors the
240
- * identical mapping in phaseGrill and planAuto: an empty submit accepts the
241
- * recommendation, a bare "A"/"B" from a remote user or the free-text fallback maps
242
- * back to the option's full text, and anything else is taken verbatim.
243
- */
244
- export function resolveAnswer(p, raw) {
245
- const typed = raw.trim();
246
- const twoOption = p.suggested !== undefined && p.alt !== undefined;
247
- if (typed.length === 0 && p.suggested !== undefined) {
248
- return { answer: p.suggested, source: 'accepted' };
249
- }
250
- if (typed.length === 0)
251
- return { answer: '(skipped)', source: 'skipped' };
252
- if (twoOption && /^a[.)]?$/i.test(typed))
253
- return { answer: p.suggested, source: 'chosen' };
254
- if (twoOption && /^b[.)]?$/i.test(typed))
255
- return { answer: p.alt, source: 'chosen' };
256
- if (p.suggested !== undefined && typed === p.suggested) {
257
- return { answer: p.suggested, source: twoOption ? 'chosen' : 'accepted' };
258
- }
259
- if (p.alt !== undefined && typed === p.alt)
260
- return { answer: p.alt, source: 'chosen' };
261
- return { answer: typed, source: 'typed' };
262
- }
263
234
  // ─── The loop ────────────────────────────────────────────────────────────────
264
235
  /** Copy for the dialog that collects the user's own question. */
265
236
  export const ASK_TITLE = 'Ask the model';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * question-dialog — the one-question-at-a-time picker shared by the three places
3
+ * that ask the user to settle a fork: `/task`'s grill phase, `/task-auto`'s
4
+ * clarify loop, and the Plan session.
5
+ *
6
+ * All three do the same thing. Strip markdown for storage and render it for
7
+ * display; decide whether the question is a binary fork; short-circuit under
8
+ * YOLO; build `A: …` / `B: …` cards; call `ui.ask`; treat `undefined` as a
9
+ * cancel; and map the reply back onto an answer — where an empty submit accepts
10
+ * the recommendation, a bare "A"/"B" from a remote user (or the picker's
11
+ * free-text fallback) maps back to the option's full text, and anything else is
12
+ * taken verbatim.
13
+ *
14
+ * The mapping is the load-bearing part. Storing the literal letter "A" leaves the
15
+ * next generation call a dangling reference it cannot decode, so getting it wrong
16
+ * is not cosmetic.
17
+ *
18
+ * It was written three times. The Plan session factored its copy into a pure
19
+ * `resolveAnswer` returning a typed `AnswerSource`, and its own docstring said so
20
+ * out loud — "Mirrors the identical mapping in phaseGrill and planAuto" — but the
21
+ * two mirrors were never converted, and they had already drifted apart in three
22
+ * ways (which of them stamps an accepted recommendation, which has a
23
+ * single-option card branch, which handles a typed reply that equals an option).
24
+ * None was a crash. The next edit to any of them is where the bug lands, which is
25
+ * why they now share this.
26
+ *
27
+ * What stays at the call sites is POLICY, not mechanics: grill's auto-answer and
28
+ * widget line, clarify's plan-shape and triage pre-emption, plan's control
29
+ * actions. Those genuinely differ.
30
+ */
31
+ import type { AnswerSource } from './plan-io.js';
32
+ /** One question awaiting an answer, in both the stored and the displayed form. */
33
+ export interface PendingQuestion {
34
+ /** Plain text — persisted, and fed back to the model. */
35
+ plain: string;
36
+ /** Markdown-rendered — displayed. */
37
+ shown: string;
38
+ suggested?: string;
39
+ shownSuggested?: string;
40
+ alt?: string;
41
+ shownAlt?: string;
42
+ }
43
+ /** True when the question is a binary fork rather than a single recommendation. */
44
+ export declare function isTwoOption(p: PendingQuestion): boolean;
45
+ /**
46
+ * The answer cards: the recommendation first (index 0 is the green RECOMMENDED
47
+ * card), the alternative second when the question is a fork. `undefined` — not an
48
+ * empty array — when there is nothing to recommend, because that is what makes
49
+ * `ui.ask` fall back to a bare text prompt instead of an empty picker.
50
+ */
51
+ export declare function buildOptionCards(p: PendingQuestion): Array<{
52
+ label: string;
53
+ value: string;
54
+ }> | undefined;
55
+ /**
56
+ * Map what the picker returned onto the answer that gets recorded, and say WHERE
57
+ * the answer came from.
58
+ *
59
+ * The `source` is returned rather than baked into the string because the three
60
+ * call sites disagree about provenance stamping, and that disagreement is real:
61
+ * `/task-auto`'s clarify transcript marks an accepted recommendation
62
+ * ("… (accepted recommendation)") while grill's does not, because grill's
63
+ * transcript is fed back verbatim into the next grill-gen prompt and the stamp
64
+ * would become model input. Keeping the stamp at the call site makes that a
65
+ * one-line difference you can see instead of a divergence hidden inside two
66
+ * six-branch ladders.
67
+ */
68
+ export declare function resolveAnswer(p: PendingQuestion, raw: string): {
69
+ answer: string;
70
+ source: AnswerSource;
71
+ };