@mjasnikovs/pi-task 0.38.15 → 0.38.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -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. */
@@ -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
+ }