@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
@@ -29,9 +29,8 @@
29
29
  * persistence) arrives through {@link PlanSessionDeps}, so the whole interaction
30
30
  * is unit-testable without a TUI or a model.
31
31
  */
32
- import { parseClarifyList } from './parsers.js';
32
+ import { makeQuestionSource } from './question-source.js';
33
33
  import { stripInlineMarkdown } from './inline-markdown.js';
34
- import { isDuplicateQuestion, DUP_REPROMPT_HINT, MAX_DUP_STRIKES } from './question-dedup.js';
35
34
  import { yoloPickAnswer } from './yolo.js';
36
35
  import { formatPlanTranscript } from './plan-io.js';
37
36
  import { buildOptionCards, resolveAnswer } from './question-dialog.js';
@@ -73,29 +72,13 @@ export const PLAN_FORMAT_HINT = '[SYSTEM NOTE: Your previous reply did NOT follo
73
72
  + 'REQUIRED and must never be blank. Add an "ALT: " line only for a binary A-or-B fork. '
74
73
  + 'If nothing is left to ask, output the single token NONE and nothing else. No preamble, '
75
74
  + 'no analysis, no other text.]';
76
- /** True when the reply is the deliberate "nothing left to ask" sentinel, as
77
- * opposed to output the parser simply could not read. */
78
- export function isNoneReply(raw) {
79
- return /^\s*NONE\s*$/m.test(raw);
80
- }
81
- /**
82
- * Which of the parsed entries is the actual question.
83
- *
84
- * parseClarifyList turns EVERY numbered line into an entry, and the local model
85
- * sometimes writes a numbered analysis note or two before the question it was
86
- * asked for (measured live: the first numbered line was a note like
87
- * "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
88
- * entry 0 blindly then shows the note as the question and loses the SUGGESTED
89
- * line that was attached further down.
90
- *
91
- * The SUGGESTED line is the reliable marker of the real question — the prompt
92
- * requires exactly one, and parseClarifyList attaches it to the entry it follows.
93
- * So: prefer the first entry that has one; fall back to the first entry when none
94
- * does, which is the case the format re-prompt then covers.
95
- */
96
- export function pickQuestion(parsed) {
97
- return parsed.find(q => q.suggested !== undefined && q.suggested.length > 0) ?? parsed[0];
98
- }
75
+ // Re-exported, NOT re-implemented. After the state machine moved to
76
+ // question-source.ts these were byte-identical copies: production read THAT
77
+ // module's, while plan-session.test.ts and four `scripts/live-*.ts` A/B harnesses
78
+ // read these — so a fix to `pickQuestion`'s heuristic would land in one copy while
79
+ // the harnesses kept measuring the other, and the measurement would silently stop
80
+ // describing shipped behaviour. That is the drift class this pass removes.
81
+ export { isNoneReply, pickQuestion } from './question-source.js';
99
82
  /**
100
83
  * Does the question offer the user a choice between two named alternatives?
101
84
  * Deliberately shallow — an "X or Y?" in the question's own clause.
@@ -171,6 +154,75 @@ export function planForkHint(question) {
171
154
  + 'time emit BOTH lines:\nSUGGESTED: <the option you recommend>\nALT: <the other option>\n'
172
155
  + 'Nothing else.]');
173
156
  }
157
+ /**
158
+ * A default that DEFERS decides nothing, and an accepted deferral reaches the
159
+ * consumer dressed as an authoritative decision.
160
+ *
161
+ * Declared as its own constant because it is the one rule BOTH dialogs use, and
162
+ * `CLARIFY_QUALITY_RULES` referencing it by `id` string would be the retyped
163
+ * literal with no compile link that this pass exists to remove — a rename would
164
+ * silently yield `[undefined]` and throw on the first clarify question of every
165
+ * run.
166
+ */
167
+ const DEFERRAL_RULE = {
168
+ id: 'SUGGESTED deferred the decision',
169
+ detect: (q, plain) => q.suggested !== undefined && isDeferralSuggestion(q.suggested) ?
170
+ planDecisiveHint(plain, q.suggested)
171
+ : null,
172
+ // When only the recommendation defers, the ALT is still a real commitment:
173
+ // promote it so the question keeps a usable default. With no ALT the option is
174
+ // DROPPED rather than shown, so an empty submit records an unanswered question
175
+ // instead of a decision the user never made.
176
+ repair: q => {
177
+ const { alt: _alt, suggested: _suggested, ...rest } = q;
178
+ return q.alt !== undefined ? { ...rest, suggested: q.alt } : { ...rest };
179
+ }
180
+ };
181
+ /**
182
+ * PLAN's quality rules, in order.
183
+ *
184
+ * Each is worth exactly one corrective re-prompt (the child is stateless, so each
185
+ * hint quotes the question back), and each DEGRADES rather than discards when the
186
+ * defect survives — a question with a weak default still beats no question.
187
+ *
188
+ * Only {@link CLARIFY_QUALITY_RULES} is shared with `/task-auto`, and only the
189
+ * deferral rule is in it. The other two were MEASURED here (10/15 fork-shaped
190
+ * questions shipped one option; the SUGGESTED requirement is in both prompts) but
191
+ * each costs one extra child call every time it fires, and clarify is the most
192
+ * A/B'd path in the codebase — moving them there is its own experiment, not a
193
+ * side effect of sharing a state machine. Recorded rather than done.
194
+ */
195
+ export const PLAN_QUALITY_RULES = [
196
+ {
197
+ // A question with no SUGGESTED leaves the picker with nothing to
198
+ // recommend, which is the one thing the prompt says must never happen.
199
+ id: 'no SUGGESTED',
200
+ detect: q => (q.suggested === undefined ? PLAN_FORMAT_HINT : null)
201
+ },
202
+ DEFERRAL_RULE,
203
+ {
204
+ // A fork-shaped question that ships one option leaves the user typing out
205
+ // the alternative the model itself just named (10/15 measured live).
206
+ id: 'fork-shaped question with no ALT',
207
+ detect: (q, plain) => q.alt === undefined && q.suggested !== undefined && looksLikeFork(plain) ?
208
+ planForkHint(plain)
209
+ : null
210
+ }
211
+ ];
212
+ /**
213
+ * The deferral rule alone — the one clarify shares.
214
+ *
215
+ * It exists because an accepted "clarify with the user before proceeding" rode
216
+ * into `/task`'s handoff AS AN AUTHORITATIVE DECISION and produced a task whose
217
+ * ACCEPTANCE was "a planning document with placeholder sections" and whose VERIFY
218
+ * asserted that no source file had changed. Clarify's answers ride into the
219
+ * decompose prompt and the AUTO file with exactly the same authority and had no
220
+ * guard at all — the same bug, one command over, waiting.
221
+ *
222
+ * It is also the only one of the three that costs nothing on the happy path: a
223
+ * decisive default never triggers it.
224
+ */
225
+ export const CLARIFY_QUALITY_RULES = [DEFERRAL_RULE];
174
226
  // ─── Pending question ────────────────────────────────────────────────────────
175
227
  /**
176
228
  * Build the picker for a pending model question: the recommendation first (index
@@ -237,12 +289,7 @@ export const ASK_TITLE = 'Ask the model';
237
289
  export 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.';
238
290
  export async function runPlanSession(deps) {
239
291
  const entries = [];
240
- const asked = [];
241
292
  const render = (s) => deps.renderMarkdown?.(s) ?? s;
242
- let dupStrikes = 0;
243
- let dupHint = null;
244
- /** Set for exactly one corrective re-prompt after a malformed reply. */
245
- let formatHint = null;
246
293
  /** The model has nothing (more) to ask: NONE, the cap, or the dup backstop. */
247
294
  let exhausted = false;
248
295
  let pending = null;
@@ -250,103 +297,33 @@ export async function runPlanSession(deps) {
250
297
  entries.push(entry);
251
298
  await deps.onEntries?.(entries);
252
299
  };
300
+ const questions = makeQuestionSource({
301
+ generate: hint => deps.generateQuestion(formatPlanTranscript(entries), hint),
302
+ formatHint: PLAN_FORMAT_HINT,
303
+ rules: PLAN_QUALITY_RULES,
304
+ cap: MAX_PLAN_QUESTIONS,
305
+ log: msg => deps.logDebug?.(`plan: ${msg}`)
306
+ });
253
307
  for (;;) {
254
308
  if (pending === null && !exhausted) {
255
- if (asked.length >= MAX_PLAN_QUESTIONS) {
256
- deps.logDebug?.(`plan: question cap (${MAX_PLAN_QUESTIONS}) reached`);
257
- exhausted = true;
258
- continue;
259
- }
260
- deps.setStatus?.(`thinking of question ${asked.length + 1}…`);
261
- const raw = await deps.generateQuestion(formatPlanTranscript(entries), formatHint ?? dupHint);
309
+ deps.setStatus?.(`thinking of question ${questions.asked().length + 1}…`);
310
+ const drawn = await questions.next();
262
311
  deps.setStatus?.(undefined);
263
- const parsed = parseClarifyList(raw);
264
- // parseClarifyList returns [] both for a deliberate NONE and for
265
- // output it could not parse at all — treating the second as "no
266
- // questions left" would end the planning session on a formatting
267
- // slip. Tell them apart, and give a malformed reply exactly one
268
- // corrective re-prompt (the same one-shot recovery the grill
269
- // auto-answer does with GRILL_AUTO_FORMAT_HINT).
270
- if (parsed.length === 0) {
271
- if (!isNoneReply(raw) && formatHint === null) {
272
- deps.logDebug?.('plan: unparseable question reply — one format re-prompt');
273
- formatHint = PLAN_FORMAT_HINT;
274
- continue;
275
- }
276
- deps.logDebug?.('plan: model has no further questions (NONE)');
277
- formatHint = null;
312
+ if (drawn.kind === 'exhausted') {
278
313
  exhausted = true;
279
314
  continue;
280
315
  }
281
- const picked = pickQuestion(parsed);
282
- const { question, suggested, alt } = picked;
283
- const plain = stripInlineMarkdown(question);
284
- // The duplicate backstop runs BEFORE either quality re-prompt: a
285
- // question that is about to be discarded as a re-ask must not first
286
- // buy itself an extra child call to be polished.
287
- if (isDuplicateQuestion(asked, plain)) {
288
- dupStrikes++;
289
- deps.logDebug?.(`plan: duplicate question, strike ${dupStrikes}/${MAX_DUP_STRIKES}`);
290
- formatHint = null;
291
- if (dupStrikes >= MAX_DUP_STRIKES) {
292
- exhausted = true;
293
- continue;
294
- }
295
- dupHint = DUP_REPROMPT_HINT;
296
- continue;
297
- }
298
- // A question with no SUGGESTED line leaves the picker with nothing to
299
- // recommend, which is the one thing the prompt says must never happen.
300
- // One-shot recovery; if it still comes back bare we show the question
301
- // anyway — a question with no default beats no question.
302
- if (suggested === undefined && formatHint === null) {
303
- deps.logDebug?.('plan: question had no SUGGESTED — one format re-prompt');
304
- formatHint = PLAN_FORMAT_HINT;
305
- continue;
306
- }
307
- // A default that defers decides nothing, and an accepted deferral
308
- // reaches /task dressed as an authoritative decision. One re-prompt to
309
- // make it decisive; if it comes back deferring anyway the option is
310
- // DROPPED rather than shown, so an empty submit records "(skipped)" —
311
- // an unanswered question — instead of a decision the user never made.
312
- const defers = suggested !== undefined && isDeferralSuggestion(suggested);
313
- if (defers && formatHint === null) {
314
- deps.logDebug?.('plan: SUGGESTED deferred the decision — one re-prompt');
315
- formatHint = planDecisiveHint(plain, suggested);
316
- continue;
317
- }
318
- // When only the recommendation defers, the ALT is still a real
319
- // commitment: promote it so the question keeps a usable default.
320
- const usableSuggested = defers ? alt : suggested;
321
- const usableAlt = defers ? undefined : alt;
322
- if (defers) {
323
- deps.logDebug?.(usableSuggested === undefined ?
324
- 'plan: SUGGESTED still deferred — question shown with no recommendation'
325
- : 'plan: SUGGESTED still deferred — promoted the ALT to the recommendation');
326
- }
327
- // A question that offers a choice but ships one option leaves the
328
- // user typing out the alternative the model just named. Same one-shot
329
- // budget, quoting the question back so the (stateless) child re-asks
330
- // this one instead of a new one.
331
- if (usableAlt === undefined && formatHint === null && !defers && looksLikeFork(plain)) {
332
- deps.logDebug?.('plan: fork-shaped question with no ALT — one re-prompt');
333
- formatHint = planForkHint(plain);
334
- continue;
335
- }
336
- formatHint = null;
337
- dupStrikes = 0;
338
- dupHint = null;
339
- asked.push(plain);
316
+ const { question, suggested, alt } = drawn.q;
340
317
  pending = {
341
- plain,
318
+ plain: drawn.plain,
342
319
  shown: render(question),
343
- ...(usableSuggested !== undefined && {
344
- suggested: stripInlineMarkdown(usableSuggested),
345
- shownSuggested: render(usableSuggested)
320
+ ...(suggested !== undefined && {
321
+ suggested: stripInlineMarkdown(suggested),
322
+ shownSuggested: render(suggested)
346
323
  }),
347
- ...(usableAlt !== undefined && {
348
- alt: stripInlineMarkdown(usableAlt),
349
- shownAlt: render(usableAlt)
324
+ ...(alt !== undefined && {
325
+ alt: stripInlineMarkdown(alt),
326
+ shownAlt: render(alt)
350
327
  })
351
328
  };
352
329
  }
@@ -394,7 +371,7 @@ export async function runPlanSession(deps) {
394
371
  // had run out of questions may now have one. Re-open the generator.
395
372
  if (exhausted) {
396
373
  exhausted = false;
397
- dupStrikes = 0;
374
+ questions.reopen();
398
375
  }
399
376
  continue; // the pending question, if any, is still unanswered
400
377
  }
@@ -408,7 +385,7 @@ export async function runPlanSession(deps) {
408
385
  await commit({ kind: 'stated', text });
409
386
  if (exhausted) {
410
387
  exhausted = false;
411
- dupStrikes = 0;
388
+ questions.reopen();
412
389
  }
413
390
  continue;
414
391
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The Q&A TRANSCRIPT of one adaptive dialog — grill (`/task`) or clarify
3
+ * (`/task-auto`) — and the one place its numbering, its formatting and its
4
+ * provenance policy live.
5
+ *
6
+ * `question-dialog.ts` unified the ANSWER side of these loops: the picker cards
7
+ * and the reply mapping. It did not unify what the loop RECORDS, and that was
8
+ * eight retyped push sites across two files, each choosing a suffix by hand
9
+ * according to which branch of an `if/else` it stood in.
10
+ *
11
+ * The cost is written into the code. `phases.ts` states the invariant in a
12
+ * comment — *"No provenance stamp here, unlike clarify's transcript: this string
13
+ * is fed back VERBATIM into the next grill-gen prompt, so a `(accepted
14
+ * recommendation)` suffix would become model input"* — and TWELVE LINES ABOVE it,
15
+ * the YOLO branch pushes `${answer} ${YOLO_STAMP}` into that very array. The rule
16
+ * was violated inside the loop body that declares it, because the rule lived in
17
+ * prose and the decision lived at each push.
18
+ *
19
+ * Here it is a property of a value: an entry states its KIND, and the policy says
20
+ * where that kind's provenance is allowed to appear.
21
+ *
22
+ * NOT unified here: `plan-session.ts`'s `PlanEntry` transcript. It is a different
23
+ * shape — decisions vs advisory notes, persisted to its own task file, with no
24
+ * `Qn:`/`An:` numbering — and folding it in would be a rename, not a deepening.
25
+ */
26
+ /** How one answer was arrived at. */
27
+ export type QaKind =
28
+ /** grill: the research-backed auto-answer resolved it without asking. */
29
+ 'auto'
30
+ /** clarify: answer-side triage found the spec already settles it. */
31
+ | 'auto-resolved'
32
+ /** clarify: the host answers this fork deterministically (plan shape). */
33
+ | 'host-set'
34
+ /** unattended: took the recommended option. */
35
+ | 'yolo'
36
+ /** unattended: nothing safe to take, so the fork is left open. */
37
+ | 'yolo-skip'
38
+ /** the human took the recommendation — green card, or an empty submit. */
39
+ | 'accepted'
40
+ /** the human wrote their own answer. */
41
+ | 'typed';
42
+ /**
43
+ * The provenance suffix each kind carries. A new kind is a compile error until it
44
+ * declares one; `typed` declares the empty string, which is a statement, not a
45
+ * gap — a human's own words are the baseline everything else is marked against.
46
+ */
47
+ export declare const QA_PROVENANCE: Record<QaKind, string>;
48
+ export interface QaPolicy {
49
+ /** Kinds whose provenance appears in the RECORD — persisted, and handed on. */
50
+ record: ReadonlySet<QaKind>;
51
+ /**
52
+ * Does the text fed back to the QUESTION GENERATOR carry provenance?
53
+ *
54
+ * The two dialogs genuinely disagree, and both say so in their own comments.
55
+ * Grill's is fed VERBATIM into the next grill-gen prompt, so a suffix there
56
+ * becomes model input describing how the answer was obtained rather than what
57
+ * it was. Clarify deliberately shows its generator the provenance, so a
58
+ * question the triage already settled reads as settled and is not re-asked.
59
+ * An option, not a unification — it is observable either way.
60
+ */
61
+ generatorSeesProvenance: boolean;
62
+ }
63
+ /**
64
+ * GRILL: stamps `auto` and both YOLO kinds in the record, and shows the generator
65
+ * nothing.
66
+ *
67
+ * `accepted` is deliberately NOT in the record set. Grill's record reaches
68
+ * `COMPOSE_PROMPT` and `CRITIQUE_PROMPT`, where it is named GROUND TRUTH; clarify's
69
+ * reaches a decompose prompt. Whether those two should agree is a prompt question
70
+ * with its own A/B, so today's answer is preserved rather than harmonised here.
71
+ */
72
+ export declare const GRILL_QA_POLICY: QaPolicy;
73
+ /** CLARIFY: stamps every non-typed kind, in the record and to the generator alike. */
74
+ export declare const CLARIFY_QA_POLICY: QaPolicy;
75
+ export interface QaEntry {
76
+ kind: QaKind;
77
+ question: string;
78
+ answer: string;
79
+ }
80
+ /**
81
+ * One dialog's transcript. Owns the numbering and both renderings; performs no
82
+ * I/O and knows nothing about children, pickers or sessions — which is what makes
83
+ * it drivable by a test that only wants to know what a model would have been
84
+ * shown.
85
+ */
86
+ export declare class QaTranscript {
87
+ private readonly _policy;
88
+ private readonly _entries;
89
+ constructor(_policy: QaPolicy);
90
+ /** How many questions have been answered. Also the next question's number − 1. */
91
+ get length(): number;
92
+ get entries(): ReadonlyArray<QaEntry>;
93
+ /** Record one answered question. The number is assigned here, not by the caller. */
94
+ add(kind: QaKind, question: string, answer: string): void;
95
+ /** The persisted / handed-on transcript, with provenance per the policy. */
96
+ forRecord(): string;
97
+ /** The text fed back into the next question-generation prompt. */
98
+ forGenerator(): string;
99
+ private _render;
100
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The Q&A TRANSCRIPT of one adaptive dialog — grill (`/task`) or clarify
3
+ * (`/task-auto`) — and the one place its numbering, its formatting and its
4
+ * provenance policy live.
5
+ *
6
+ * `question-dialog.ts` unified the ANSWER side of these loops: the picker cards
7
+ * and the reply mapping. It did not unify what the loop RECORDS, and that was
8
+ * eight retyped push sites across two files, each choosing a suffix by hand
9
+ * according to which branch of an `if/else` it stood in.
10
+ *
11
+ * The cost is written into the code. `phases.ts` states the invariant in a
12
+ * comment — *"No provenance stamp here, unlike clarify's transcript: this string
13
+ * is fed back VERBATIM into the next grill-gen prompt, so a `(accepted
14
+ * recommendation)` suffix would become model input"* — and TWELVE LINES ABOVE it,
15
+ * the YOLO branch pushes `${answer} ${YOLO_STAMP}` into that very array. The rule
16
+ * was violated inside the loop body that declares it, because the rule lived in
17
+ * prose and the decision lived at each push.
18
+ *
19
+ * Here it is a property of a value: an entry states its KIND, and the policy says
20
+ * where that kind's provenance is allowed to appear.
21
+ *
22
+ * NOT unified here: `plan-session.ts`'s `PlanEntry` transcript. It is a different
23
+ * shape — decisions vs advisory notes, persisted to its own task file, with no
24
+ * `Qn:`/`An:` numbering — and folding it in would be a rename, not a deepening.
25
+ */
26
+ import { YOLO_STAMP } from './yolo.js';
27
+ /**
28
+ * The provenance suffix each kind carries. A new kind is a compile error until it
29
+ * declares one; `typed` declares the empty string, which is a statement, not a
30
+ * gap — a human's own words are the baseline everything else is marked against.
31
+ */
32
+ export const QA_PROVENANCE = {
33
+ auto: '(auto)',
34
+ 'auto-resolved': '(auto-resolved — already settled by the spec)',
35
+ 'host-set': '(host-set — plan granularity is not left to chance)',
36
+ yolo: YOLO_STAMP,
37
+ 'yolo-skip': YOLO_STAMP,
38
+ accepted: '(accepted recommendation)',
39
+ typed: ''
40
+ };
41
+ /**
42
+ * GRILL: stamps `auto` and both YOLO kinds in the record, and shows the generator
43
+ * nothing.
44
+ *
45
+ * `accepted` is deliberately NOT in the record set. Grill's record reaches
46
+ * `COMPOSE_PROMPT` and `CRITIQUE_PROMPT`, where it is named GROUND TRUTH; clarify's
47
+ * reaches a decompose prompt. Whether those two should agree is a prompt question
48
+ * with its own A/B, so today's answer is preserved rather than harmonised here.
49
+ */
50
+ export const GRILL_QA_POLICY = {
51
+ record: new Set(['auto', 'yolo', 'yolo-skip']),
52
+ generatorSeesProvenance: false
53
+ };
54
+ /** CLARIFY: stamps every non-typed kind, in the record and to the generator alike. */
55
+ export const CLARIFY_QA_POLICY = {
56
+ record: new Set(['auto-resolved', 'host-set', 'yolo', 'yolo-skip', 'accepted']),
57
+ generatorSeesProvenance: true
58
+ };
59
+ /**
60
+ * One dialog's transcript. Owns the numbering and both renderings; performs no
61
+ * I/O and knows nothing about children, pickers or sessions — which is what makes
62
+ * it drivable by a test that only wants to know what a model would have been
63
+ * shown.
64
+ */
65
+ export class QaTranscript {
66
+ _policy;
67
+ _entries = [];
68
+ constructor(_policy) {
69
+ this._policy = _policy;
70
+ }
71
+ /** How many questions have been answered. Also the next question's number − 1. */
72
+ get length() {
73
+ return this._entries.length;
74
+ }
75
+ get entries() {
76
+ return this._entries;
77
+ }
78
+ /** Record one answered question. The number is assigned here, not by the caller. */
79
+ add(kind, question, answer) {
80
+ this._entries.push({ kind, question, answer });
81
+ }
82
+ /** The persisted / handed-on transcript, with provenance per the policy. */
83
+ forRecord() {
84
+ return this._render(e => this._policy.record.has(e.kind));
85
+ }
86
+ /** The text fed back into the next question-generation prompt. */
87
+ forGenerator() {
88
+ return this._render(e => this._policy.generatorSeesProvenance && this._policy.record.has(e.kind));
89
+ }
90
+ _render(stamped) {
91
+ return this._entries
92
+ .map((e, i) => {
93
+ const suffix = stamped(e) ? QA_PROVENANCE[e.kind] : '';
94
+ const answer = suffix ? `${e.answer} ${suffix}` : e.answer;
95
+ return `Q${i + 1}: ${e.question}\nA${i + 1}: ${answer}`;
96
+ })
97
+ .join('\n');
98
+ }
99
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Where the NEXT question comes from — the other half of `question-dialog.ts`.
3
+ *
4
+ * `question-dialog.ts` unified the ANSWER side of the adaptive dialogs (the picker
5
+ * cards, the reply mapping) and its own docstring makes the argument for doing so:
6
+ * *"It was written three times… the two mirrors were never converted, and they had
7
+ * already drifted apart in three ways… The next edit to any of them is where the
8
+ * bug lands."* The QUESTION side — generate → parse → pick the real question →
9
+ * dedupe → spend one corrective re-prompt → yield or exhaust — was left behind,
10
+ * and it had drifted between the two loops that use the SAME parser on the SAME
11
+ * prompt format (`plan-prompts.ts` says `parseClarifyList` parses it "UNCHANGED";
12
+ * `auto-prompts.ts` specifies the identical shape).
13
+ *
14
+ * The five drifts, all in clarify's favour of being wrong:
15
+ *
16
+ * 1. **Which entry is the question.** `parseClarifyList` pushes an entry for
17
+ * EVERY numbered line, and the local model writes numbered analysis notes
18
+ * before the question it was asked for (measured live). `pickQuestion` prefers
19
+ * the first entry carrying a `SUGGESTED:` line; clarify took `parsed[0]`
20
+ * blindly, showing the note as the question and losing the recommendation
21
+ * attached further down.
22
+ * 2. **NONE vs unparseable.** The parser returns `[]` for both. Clarify's
23
+ * `if (parsed.length === 0) break` ended the whole clarify — and decomposed the
24
+ * feature with ZERO clarifications — on a formatting slip.
25
+ * 3. **A re-typed sentinel.** `isNoneReply`'s regex was a byte-identical second
26
+ * copy of the parser's own.
27
+ * 4. **Missing SUGGESTED** bought one corrective re-prompt in plan and none in
28
+ * clarify, so clarify showed a card-less question.
29
+ * 5. **The deferral guard.** It exists because an accepted "clarify with the user
30
+ * before proceeding" rode into `/task`'s handoff AS AN AUTHORITATIVE DECISION
31
+ * and produced a task whose VERIFY asserted no source file had changed.
32
+ * Clarify's answers ride into the decompose prompt and the AUTO file with
33
+ * exactly the same authority, and had no guard.
34
+ *
35
+ * NOT unified here: grill's generation loop. It uses a different parser
36
+ * (`parseGrillQuestions`, which yields bare strings) and has no `SUGGESTED` at
37
+ * generation time at all — grill's recommendation comes from `phaseAutoAnswer`
38
+ * one step later, so every quality rule below is inapplicable to it. Folding it in
39
+ * would mean a generic over the parsed shape with one consumer opting out of the
40
+ * entire rule table: a wider interface for less behaviour.
41
+ */
42
+ import { type ClarifyQuestion } from './parsers.js';
43
+ /**
44
+ * The cap on distinct questions ONE adaptive dialog may ask.
45
+ *
46
+ * Clarify and plan each declared their own `8`, linked only by a comment saying
47
+ * "matches /task-auto's MAX_CLARIFY_QUESTIONS, for the same reason". They bound
48
+ * the same thing for the same reason; this is that reason, once.
49
+ */
50
+ export declare const MAX_DIALOG_QUESTIONS = 8;
51
+ /** True when the reply is the deliberate "nothing left to ask" sentinel, as
52
+ * opposed to output the parser simply could not read. */
53
+ export declare function isNoneReply(raw: string): boolean;
54
+ /**
55
+ * Which of the parsed entries is the actual question.
56
+ *
57
+ * `parseClarifyList` turns EVERY numbered line into an entry, and the local model
58
+ * sometimes writes a numbered analysis note or two before the question it was
59
+ * asked for (measured live: the first numbered line was a note like
60
+ * "1. gateDebugWriter in orchestrator.ts — wraps a raw append function"). Taking
61
+ * entry 0 blindly then shows the note as the question and loses the SUGGESTED line
62
+ * attached further down. The prompt requires exactly one SUGGESTED, and the parser
63
+ * attaches it to the entry it follows.
64
+ */
65
+ export declare function pickQuestion<T extends {
66
+ suggested?: string;
67
+ }>(parsed: T[]): T | undefined;
68
+ /**
69
+ * One QUALITY rule: a defect in an otherwise-usable question that is worth exactly
70
+ * one corrective re-prompt.
71
+ *
72
+ * `detect` returns the hint to re-prompt with, or null to pass. `repair` is what
73
+ * to do when the SAME defect survives the re-prompt — a question with a bad
74
+ * default still beats no question, so a rule degrades rather than discards.
75
+ */
76
+ export interface QuestionRule {
77
+ id: string;
78
+ detect: (q: ClarifyQuestion, plain: string) => string | null;
79
+ /** Applied only when the defect survived its one re-prompt. */
80
+ repair?: (q: ClarifyQuestion) => ClarifyQuestion;
81
+ }
82
+ export interface QuestionSourceDeps {
83
+ /**
84
+ * Ask the model for the next question. `hint` is the corrective re-prompt to
85
+ * prepend, or null. The caller closes over its own transcript and prompt — the
86
+ * source never builds one.
87
+ */
88
+ generate: (hint: string | null) => Promise<string>;
89
+ /** The corrective re-prompt for a reply the parser could not read. */
90
+ formatHint: string;
91
+ /** Ordered quality rules; each may fire at most once per question. */
92
+ rules?: ReadonlyArray<QuestionRule>;
93
+ cap?: number;
94
+ log?: (msg: string) => void;
95
+ }
96
+ export type NextQuestion = {
97
+ kind: 'question';
98
+ q: ClarifyQuestion;
99
+ plain: string;
100
+ index: number;
101
+ } | {
102
+ kind: 'exhausted';
103
+ why: 'none' | 'cap' | 'dups' | 'unparseable';
104
+ };
105
+ /**
106
+ * A deep module over a state machine that was five mutable locals per site.
107
+ *
108
+ * The interface is one method. Behind it: the cap, the duplicate backstop and its
109
+ * strike budget, the NONE-vs-unparseable distinction, `pickQuestion`, the one-shot
110
+ * budget shared by every quality rule, and the hint precedence between a format
111
+ * re-prompt and a duplicate re-prompt.
112
+ */
113
+ export declare function makeQuestionSource(deps: QuestionSourceDeps): {
114
+ next: () => Promise<NextQuestion>;
115
+ asked: () => ReadonlyArray<string>;
116
+ reopen: () => void;
117
+ };