@mjasnikovs/pi-task 0.38.16 → 0.38.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/task/accept-debt.js +2 -1
  4. package/dist/task/artifact-closure.js +18 -63
  5. package/dist/task/auto-orchestrator.js +205 -214
  6. package/dist/task/boot-probe.d.ts +46 -0
  7. package/dist/task/boot-probe.js +41 -21
  8. package/dist/task/coverage-loop.d.ts +11 -0
  9. package/dist/task/coverage-loop.js +16 -0
  10. package/dist/task/final-gate-fix.js +14 -24
  11. package/dist/task/final-gate.js +6 -1
  12. package/dist/task/fix-child.d.ts +64 -0
  13. package/dist/task/fix-child.js +66 -0
  14. package/dist/task/lint-fix.d.ts +7 -0
  15. package/dist/task/lint-fix.js +45 -9
  16. package/dist/task/orchestrator.js +9 -2
  17. package/dist/task/phases.d.ts +66 -4
  18. package/dist/task/phases.js +94 -34
  19. package/dist/task/plan-rounds.d.ts +86 -0
  20. package/dist/task/plan-rounds.js +105 -0
  21. package/dist/task/plan-session.d.ts +31 -21
  22. package/dist/task/plan-session.js +97 -120
  23. package/dist/task/qa-transcript.d.ts +100 -0
  24. package/dist/task/qa-transcript.js +99 -0
  25. package/dist/task/question-source.d.ts +117 -0
  26. package/dist/task/question-source.js +174 -0
  27. package/dist/task/serve-entry.js +6 -57
  28. package/dist/task/shipped-source.d.ts +67 -0
  29. package/dist/task/shipped-source.js +144 -0
  30. package/dist/task/task-gates.d.ts +1 -1
  31. package/dist/task/task-gates.js +4 -2
  32. package/dist/task/verify-work.d.ts +46 -0
  33. package/dist/task/verify-work.js +51 -3
  34. package/dist/workers/docs-core.d.ts +71 -1
  35. package/dist/workers/docs-core.js +131 -71
  36. package/dist/workers/pi-worker-core.js +23 -8
  37. package/package.json +1 -1
@@ -40,7 +40,8 @@ import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPen
40
40
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
41
41
  import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
42
42
  import { SessionUI } from '../remote/bridge.js';
43
- 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';
44
45
  // ─── Re-export constants from their home modules ────────────────────────────
45
46
  export { MAX_GRILL_QUESTIONS };
46
47
  // ─── Tooling helpers ─────────────────────────────────────────────────────────
@@ -1211,8 +1212,11 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1211
1212
  // nothing ambiguous remains. Kept in sync with /task-auto's clarify dialog.
1212
1213
  const theme = ctx.ui.theme;
1213
1214
  const ui = new SessionUI(ctx);
1214
- const out = []; // human-facing Q&A transcript (with auto-worker debug lines)
1215
- 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);
1216
1220
  const askedQuestions = []; // plain text of each question, for the dup backstop
1217
1221
  // Deterministic backstop against a model that ignores "never re-ask": a
1218
1222
  // near-duplicate question is reprompted (not auto-answered or shown), and after
@@ -1226,7 +1230,7 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1226
1230
  for (let n = 0; n < MAX_GRILL_QUESTIONS; n++) {
1227
1231
  const tGenStart = Date.now();
1228
1232
  const genHint = dupHint;
1229
- const raw = await runPhaseChild(deps, 'grill-gen', 'read', prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n'))), { verb: 'restart' });
1233
+ const raw = await runPhaseChild(deps, 'grill-gen', 'read', prependHint(genHint, GRILL_GEN_PROMPT(refined, research, transcript.forGenerator())), { verb: 'restart' });
1230
1234
  deps.recordSubStep?.('gen', Date.now() - tGenStart);
1231
1235
  const questions = parseGrillQuestions(raw);
1232
1236
  if (questions.length === 0)
@@ -1252,11 +1256,8 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1252
1256
  // for the editable default and the persisted file.
1253
1257
  const shownQ = renderInlineMarkdown(q, theme);
1254
1258
  const plainQ = stripInlineMarkdown(q);
1255
- out.push(`Q${n + 1}: ${plainQ}`);
1256
- let answer;
1257
1259
  if (auto.kind === 'answered') {
1258
- answer = stripInlineMarkdown(auto.text);
1259
- out.push(`A${n + 1}: ${answer} (auto)`);
1260
+ transcript.add('auto', plainQ, stripInlineMarkdown(auto.text));
1260
1261
  }
1261
1262
  else {
1262
1263
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
@@ -1268,12 +1269,15 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1268
1269
  // guard direction, promoting a hallucination is not.
1269
1270
  const yolo = yoloPickAutoAnswer(isYoloMode(), auto);
1270
1271
  if (yolo !== null) {
1271
- answer =
1272
- yolo.kind === 'answer' ?
1273
- stripInlineMarkdown(yolo.answer)
1274
- : `(skipped ${yolo.note})`;
1275
- out.push(`A${n + 1}: ${answer} ${YOLO_STAMP}`);
1276
- 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
+ }
1277
1281
  continue;
1278
1282
  }
1279
1283
  // The picker cards and the reply mapping are shared with /task-auto's
@@ -1304,17 +1308,17 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1304
1308
  });
1305
1309
  if (a === undefined)
1306
1310
  throw new Error(USER_CANCELLED);
1307
- // No provenance stamp here, unlike clarify's transcript: this string is
1308
- // fed back VERBATIM into the next grill-gen prompt, so a
1309
- // "(accepted recommendation)" suffix would become model input.
1310
- answer = resolveAnswer(pending, a).answer;
1311
- 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);
1312
1317
  }
1313
- qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer}`);
1314
1318
  }
1315
- if (out.length === 0)
1319
+ if (transcript.length === 0)
1316
1320
  return '(no questions produced)';
1317
- return out.join('\n');
1321
+ return transcript.forRecord();
1318
1322
  }
1319
1323
  /**
1320
1324
  * A refutation is a DELETION. Where the run's own research explicitly says a
@@ -1340,11 +1344,32 @@ export async function dropRefutedConstraints(deps, refined, research) {
1340
1344
  const refuted = applyRefutations(refined, research);
1341
1345
  if (refuted.trail.length === 0)
1342
1346
  return refined;
1343
- for (const line of refuted.trail) {
1344
- 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}`);
1345
1371
  await appendGateRecord(deps.cwd, deps.taskId, line).catch(() => { });
1346
1372
  }
1347
- return refuted.refined;
1348
1373
  }
1349
1374
  export async function phaseCompose(deps, refined, research, qa) {
1350
1375
  // CLAIM before the belt is built: an obligation an earlier task had to
@@ -1558,14 +1583,14 @@ export function grillPhase(d, p) {
1558
1583
  return phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research);
1559
1584
  }
1560
1585
  /**
1561
- * COMPOSE — drop constraints research REFUTED before composing, then compose.
1586
+ * COMPOSE — compose the spec from the refined task, the research and the Q&A.
1562
1587
  *
1563
- * The drop mutates `p.refined` in place on purpose: the refuted constraint must be
1564
- * gone from every later reader of the refined spec, not just from this call's
1565
- * argument. Critique re-reads it.
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.
1566
1592
  */
1567
1593
  export async function composePhase(d, p) {
1568
- p.refined = await dropRefutedConstraints(d, p.refined, p.research);
1569
1594
  return await phaseCompose(d, p.refined, p.research, p.qa);
1570
1595
  }
1571
1596
  /**
@@ -1604,12 +1629,40 @@ export async function critiquePhase(d, p) {
1604
1629
  * they run in is now asserted by driving the row rather than retyped in a test.
1605
1630
  */
1606
1631
  export const PHASES = [
1607
- { name: 'refine', section: 'refined prompt', field: 'refined', run: refinePhase },
1632
+ {
1633
+ name: 'refine',
1634
+ section: 'refined prompt',
1635
+ field: 'refined',
1636
+ run: refinePhase,
1637
+ postCommit: refinePostCommit
1638
+ },
1608
1639
  { name: 'research', section: 'research', field: 'research', run: researchPhase },
1609
1640
  { name: 'grill', section: 'grill Q&A', field: 'qa', run: grillPhase },
1610
- { name: 'compose', section: 'spec', field: 'spec', run: composePhase },
1641
+ { name: 'compose', section: 'spec', field: 'spec', carry: composeCarry, run: composePhase },
1611
1642
  { name: 'critique', section: 'spec', field: 'spec', run: critiquePhase }
1612
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
+ }
1613
1666
  // INTEGRATION-DEPTH APPEND (2026-07-27): the lever proposed for this exact site —
1614
1667
  // deterministically append a known-runnable integration command to the VERIFY block
1615
1668
  // whenever a task's ACCEPTANCE claims runtime behaviour — is REFUTED at STEP 0 and was
@@ -1654,9 +1707,16 @@ export const PHASES = [
1654
1707
  //
1655
1708
  // Durable assets kept: both rigs above and their unit tests. Do NOT wire an append here
1656
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. */
1657
1711
  export async function postCommitPhase(phase, deps, pc, out) {
1658
- if (phase.name !== 'refine')
1659
- 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) {
1660
1720
  const title = deriveTitle(out);
1661
1721
  pc.widgetState.title = title;
1662
1722
  // Compress the (often paragraph-long) title into a short display label. This
@@ -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
+ }
@@ -0,0 +1,105 @@
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 { decideAdoption, normMissingArea } from './coverage-loop.js';
31
+ /**
32
+ * The coverage loop's record: the best plan seen, the rounds spent, and the
33
+ * one-shot bonus round.
34
+ *
35
+ * Methods are named for what they MEAN, not for the field they touch.
36
+ */
37
+ export class CoverageLedger {
38
+ _best;
39
+ _opts;
40
+ _round = 0;
41
+ _cap;
42
+ _bonusUsed = false;
43
+ constructor(_best, _opts) {
44
+ this._best = _best;
45
+ this._opts = _opts;
46
+ this._cap = _opts.cap;
47
+ }
48
+ /** The best-covered plan seen so far — the one that reprompts, and the one that ships. */
49
+ best() {
50
+ return this._best;
51
+ }
52
+ /** Rounds spent so far. */
53
+ round() {
54
+ return this._round;
55
+ }
56
+ /** May another reprompt round run? */
57
+ mayRetry() {
58
+ return this._round < this._cap;
59
+ }
60
+ /** Spend a round. Call once per reprompt, before the child runs. */
61
+ startRound() {
62
+ return ++this._round;
63
+ }
64
+ /** What is still uncovered in the shipping plan, or null when nothing is. */
65
+ unresolved() {
66
+ return this._best.plan.missing.length > 0 ? this._best.plan.missing : null;
67
+ }
68
+ /**
69
+ * Judge one candidate against the best plan and, if it wins, adopt it.
70
+ *
71
+ * The bonus-round grant is decided HERE, against the pre-adoption plan this
72
+ * method still holds — not by a caller reading a snapshot it took beforehand.
73
+ * Two guards keep it off generic judge churn: the grounded covered-set must
74
+ * strictly GROW (a flaky judge relabelling the same-shaped plan's gap does not
75
+ * qualify), and the candidate must expose a NEW area (a gap already present is
76
+ * one we have reprompted against or will). Bounded to one, so a judge that
77
+ * flags forever still cannot loop the plan phase.
78
+ */
79
+ consider(cand) {
80
+ const decision = decideAdoption(this._best.plan, cand.plan, this._opts.hasRequirements);
81
+ if (!decision.adopt)
82
+ return { adopted: false, decision, grantedBonusRound: false };
83
+ const priorCovered = this._best.plan.covered.size;
84
+ const priorMissing = new Set(this._best.plan.missing.map(normMissingArea));
85
+ // WHOLE, titles and accounting together. They are one value; splitting them
86
+ // is a bug this codebase has already had.
87
+ this._best = cand;
88
+ const grant = !this._bonusUsed
89
+ && this._round >= this._cap
90
+ && this._opts.hasRequirements
91
+ && cand.plan.covered.size > priorCovered
92
+ && cand.plan.missing.some((m) => !priorMissing.has(normMissingArea(m)));
93
+ if (grant) {
94
+ this._bonusUsed = true;
95
+ this._cap++;
96
+ }
97
+ return { adopted: true, decision, grantedBonusRound: grant };
98
+ }
99
+ }
100
+ // DECOMPOSE's two retry budgets (`emptyAttempts`, `smallRetryUsed`) are NOT here.
101
+ // They look like this shape and are not: that loop keys on `isSuspectPlan`, a
102
+ // predicate over the SPEC LENGTH rather than a title-count floor, and its two
103
+ // counters already sit inside a nine-line comment explaining why a single counter
104
+ // was wrong. Wrapping them in a class that does not model `isSuspectPlan` would
105
+ // move the code without concentrating the decision.
@@ -30,6 +30,7 @@
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 QuestionRule } from './question-source.js';
33
34
  import { type PlanEntry } from './plan-io.js';
34
35
  import { type PendingQuestion } from './question-dialog.js';
35
36
  export { resolveAnswer } from './question-dialog.js';
@@ -65,27 +66,7 @@ export declare const MAX_PLAN_QUESTIONS = 8;
65
66
  * recommend.
66
67
  */
67
68
  export declare const PLAN_FORMAT_HINT: string;
68
- /** True when the reply is the deliberate "nothing left to ask" sentinel, as
69
- * opposed to output the parser simply could not read. */
70
- export declare function isNoneReply(raw: string): boolean;
71
- /**
72
- * Which of the parsed entries is the actual question.
73
- *
74
- * parseClarifyList turns EVERY numbered line into an entry, and the local model
75
- * sometimes writes a numbered analysis note or two before the question it was
76
- * asked for (measured live: the first numbered line was a note like
77
- * "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
78
- * entry 0 blindly then shows the note as the question and loses the SUGGESTED
79
- * line that was attached further down.
80
- *
81
- * The SUGGESTED line is the reliable marker of the real question — the prompt
82
- * requires exactly one, and parseClarifyList attaches it to the entry it follows.
83
- * So: prefer the first entry that has one; fall back to the first entry when none
84
- * does, which is the case the format re-prompt then covers.
85
- */
86
- export declare function pickQuestion<T extends {
87
- suggested?: string;
88
- }>(parsed: T[]): T | undefined;
69
+ export { isNoneReply, pickQuestion } from './question-source.js';
89
70
  /**
90
71
  * Does the question offer the user a choice between two named alternatives?
91
72
  * Deliberately shallow — an "X or Y?" in the question's own clause.
@@ -132,6 +113,35 @@ export declare function planDecisiveHint(question: string, suggested: string): s
132
113
  * subject. It costs one extra child call on the questions where it fires.
133
114
  */
134
115
  export declare function planForkHint(question: string): string;
116
+ /**
117
+ * PLAN's quality rules, in order.
118
+ *
119
+ * Each is worth exactly one corrective re-prompt (the child is stateless, so each
120
+ * hint quotes the question back), and each DEGRADES rather than discards when the
121
+ * defect survives — a question with a weak default still beats no question.
122
+ *
123
+ * Only {@link CLARIFY_QUALITY_RULES} is shared with `/task-auto`, and only the
124
+ * deferral rule is in it. The other two were MEASURED here (10/15 fork-shaped
125
+ * questions shipped one option; the SUGGESTED requirement is in both prompts) but
126
+ * each costs one extra child call every time it fires, and clarify is the most
127
+ * A/B'd path in the codebase — moving them there is its own experiment, not a
128
+ * side effect of sharing a state machine. Recorded rather than done.
129
+ */
130
+ export declare const PLAN_QUALITY_RULES: ReadonlyArray<QuestionRule>;
131
+ /**
132
+ * The deferral rule alone — the one clarify shares.
133
+ *
134
+ * It exists because an accepted "clarify with the user before proceeding" rode
135
+ * into `/task`'s handoff AS AN AUTHORITATIVE DECISION and produced a task whose
136
+ * ACCEPTANCE was "a planning document with placeholder sections" and whose VERIFY
137
+ * asserted that no source file had changed. Clarify's answers ride into the
138
+ * decompose prompt and the AUTO file with exactly the same authority and had no
139
+ * guard at all — the same bug, one command over, waiting.
140
+ *
141
+ * It is also the only one of the three that costs nothing on the happy path: a
142
+ * decisive default never triggers it.
143
+ */
144
+ export declare const CLARIFY_QUALITY_RULES: ReadonlyArray<QuestionRule>;
135
145
  /** The ask spec the session hands to the UI: an {@link AskSpec} plus the picker
136
146
  * entries. Kept structurally identical to what phaseGrill/planAuto build so the
137
147
  * same SessionUI.ask serves all three. */