@mjasnikovs/pi-task 0.14.10 → 0.14.12

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.
@@ -20,6 +20,9 @@ export declare function answerPrompt(id: string, value: string | undefined): voi
20
20
  export interface AskSpec {
21
21
  /** Themed, possibly multi-line title for the local TUI input. */
22
22
  localTitle: string;
23
+ /** Themed (markdown-rendered) question shown as the boxed picker header.
24
+ * Falls back to `question` when absent. */
25
+ displayQuestion?: string;
23
26
  /** Plain question text for the browser card. */
24
27
  question: string;
25
28
  /** Primary recommended option, prefilled in the local TUI. */
@@ -52,12 +55,12 @@ export declare class SessionUI {
52
55
  /** Race the local input against a remote answer; first to settle wins. */
53
56
  ask(spec: AskSpec): Promise<string | undefined>;
54
57
  /**
55
- * The local-TUI half of ask(). With `spec.options` it renders a select()
56
- * picker (each option on its own line) plus a trailing "type a different
57
- * answer" entry that drops to a text input; the chosen entry's `value` is
58
- * returned. Without options it falls back to a single text input. Cancelling
59
- * either dialog (or an abort when the remote wins the race) resolves to
60
- * undefined.
58
+ * The local-TUI half of ask(). With `spec.options` it renders the boxed
59
+ * picker (each answer in its own bounding box, the first/recommended one
60
+ * tinted green) plus a trailing "type a different answer" entry that drops to
61
+ * a text input; the chosen entry's `value` is returned. Without options it
62
+ * falls back to a single text input. Cancelling either dialog (or an abort
63
+ * when the remote wins the race) resolves to undefined.
61
64
  */
62
65
  private askLocal;
63
66
  }
@@ -1,6 +1,7 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
2
  import { pushNotify } from './push.js';
3
3
  import { setPrompt, clearPrompt } from './session-state.js';
4
+ import { askQuestionBox } from '../task/question-box.js';
4
5
  const g = globalThis;
5
6
  export function getBridge() {
6
7
  if (!g.__piBridge) {
@@ -25,9 +26,6 @@ export function answerPrompt(id, value) {
25
26
  b.pending.delete(id);
26
27
  settle(value);
27
28
  }
28
- /** Trailing picker entry that drops to a free-text input — the local mirror of
29
- * the remote card's "✎ Manual answer" button. */
30
- const TYPE_OWN_LABEL = 'Type a different answer…';
31
29
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
32
30
  export class SessionUI {
33
31
  ctx;
@@ -83,27 +81,26 @@ export class SessionUI {
83
81
  }
84
82
  }
85
83
  /**
86
- * The local-TUI half of ask(). With `spec.options` it renders a select()
87
- * picker (each option on its own line) plus a trailing "type a different
88
- * answer" entry that drops to a text input; the chosen entry's `value` is
89
- * returned. Without options it falls back to a single text input. Cancelling
90
- * either dialog (or an abort when the remote wins the race) resolves to
91
- * undefined.
84
+ * The local-TUI half of ask(). With `spec.options` it renders the boxed
85
+ * picker (each answer in its own bounding box, the first/recommended one
86
+ * tinted green) plus a trailing "type a different answer" entry that drops to
87
+ * a text input; the chosen entry's `value` is returned. Without options it
88
+ * falls back to a single text input. Cancelling either dialog (or an abort
89
+ * when the remote wins the race) resolves to undefined.
92
90
  */
93
91
  async askLocal(spec, signal) {
94
92
  const opts = spec.options;
95
93
  if (opts && opts.length > 0) {
96
- const labels = opts.map(o => o.label);
97
- const choice = await this.ctx.ui.select(spec.localTitle, [...labels, TYPE_OWN_LABEL], {
94
+ return askQuestionBox(this.ctx, {
95
+ question: spec.displayQuestion ?? spec.question,
96
+ inputTitle: spec.localTitle,
97
+ options: opts.map((o, i) => ({
98
+ label: o.label,
99
+ value: o.value,
100
+ recommended: i === 0
101
+ })),
98
102
  signal
99
103
  });
100
- if (choice === undefined)
101
- return undefined; // cancelled / aborted
102
- if (choice === TYPE_OWN_LABEL) {
103
- return this.ctx.ui.input(spec.localTitle, undefined, { signal });
104
- }
105
- const hit = opts.find(o => o.label === choice);
106
- return hit ? hit.value : choice;
107
104
  }
108
105
  return this.ctx.ui.input(spec.localTitle, spec.recommended, { signal });
109
106
  }
@@ -246,30 +246,30 @@ export async function planAuto(ctx, cwd, feature, deps) {
246
246
  askedQuestions.push(plainQ);
247
247
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
248
248
  const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
249
- // Identical to /task's grill dialog: a binary fork becomes a select()
250
- // picker locally — each option on its own line, labelled A/B; a single
251
- // recommendation rides under the question as the input default; an open
252
- // question shows the bare prompt. No verbose "Recommended:" /
253
- // "press Enter to accept" scaffolding.
249
+ // Identical to /task's grill dialog: a recommendation (or A/B fork)
250
+ // becomes the boxed picker locally — each answer in its own bounding box,
251
+ // the recommended one tinted green; an open question shows the bare text
252
+ // prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
254
253
  const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
255
- const title = !twoOption && plainSuggested ?
256
- `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
257
- : shownQ;
254
+ const options = twoOption ?
255
+ [
256
+ {
257
+ label: `A: ${renderInlineMarkdown(suggested, theme)}`,
258
+ value: plainSuggested
259
+ },
260
+ { label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
261
+ ]
262
+ : plainSuggested !== undefined ?
263
+ [{ label: renderInlineMarkdown(suggested, theme), value: plainSuggested }]
264
+ : undefined;
258
265
  const a = await ui.ask({
259
- localTitle: title,
266
+ localTitle: shownQ,
267
+ displayQuestion: shownQ,
260
268
  question: plainQ,
261
269
  recommended: plainSuggested,
262
270
  ...(plainAlt !== undefined && { recommended2: plainAlt }),
263
271
  allowSkip: plainSuggested === undefined && plainAlt === undefined,
264
- ...(twoOption && {
265
- options: [
266
- {
267
- label: `A: ${renderInlineMarkdown(suggested, theme)}`,
268
- value: plainSuggested
269
- },
270
- { label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
271
- ]
272
- })
272
+ ...(options && { options })
273
273
  });
274
274
  if (a === undefined) {
275
275
  announceDone(ctx, '/task-auto cancelled.', 'warning');
@@ -292,6 +292,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
292
292
  else if (twoOption && /^b[.)]?$/i.test(typed)) {
293
293
  answer = plainAlt;
294
294
  }
295
+ else if (!twoOption && plainSuggested !== undefined && typed === plainSuggested) {
296
+ // Single recommendation accepted by picking its (green) card in the
297
+ // boxed picker — same provenance as an empty-submit accept.
298
+ answer = `${plainSuggested} (accepted recommendation)`;
299
+ }
295
300
  else {
296
301
  answer = typed;
297
302
  }
@@ -57,6 +57,16 @@ export declare class TaskRunner {
57
57
  * finally block's call. */
58
58
  private _disposeWidget;
59
59
  private _deliverSpec;
60
+ /**
61
+ * The spec as the implementer should receive it (Layer B). Layer A strips phantom
62
+ * specifiers from the upstream pipeline text, but a residual affirmative can survive
63
+ * into the composed spec, or arrive when pi expands an `@design.md` the spec
64
+ * references — and the implementer is told that doc is authoritative. Prepend a
65
+ * VERIFIED API OVERRIDES banner that outranks the spec for any specifier the
66
+ * deterministic check proves does not exist. No-op (returns the spec unchanged) when
67
+ * nothing is flagged or the runtime's types aren't installed.
68
+ */
69
+ private _specForDelivery;
60
70
  }
61
71
  export interface RunSingleTaskOptions {
62
72
  /** Await the session going idle after the spec is delivered, so the caller
@@ -26,6 +26,7 @@ import { startWidget } from './widget.js';
26
26
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
27
27
  import { pushNotify } from '../remote/push.js';
28
28
  import { parseVerifyBlock } from './spec-validation.js';
29
+ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phantom-imports.js';
29
30
  import { titleForDisplay } from './parsers.js';
30
31
  import { formatTimings } from './timings.js';
31
32
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
@@ -261,20 +262,38 @@ export class TaskRunner {
261
262
  this._stopWidget = null;
262
263
  }
263
264
  async _deliverSpec(ctx) {
265
+ const spec = this._specForDelivery();
264
266
  if (this._sendSpec) {
265
- await this._sendSpec(this._pc.spec);
267
+ await this._sendSpec(spec);
266
268
  return;
267
269
  }
268
270
  if (!piApi) {
269
271
  throw new Error('extension not initialised (no ExtensionAPI captured)');
270
272
  }
271
273
  if (ctx.isIdle()) {
272
- piApi.sendUserMessage(this._pc.spec);
274
+ piApi.sendUserMessage(spec);
273
275
  }
274
276
  else {
275
- piApi.sendUserMessage(this._pc.spec, { deliverAs: 'followUp' });
277
+ piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
276
278
  }
277
279
  }
280
+ /**
281
+ * The spec as the implementer should receive it (Layer B). Layer A strips phantom
282
+ * specifiers from the upstream pipeline text, but a residual affirmative can survive
283
+ * into the composed spec, or arrive when pi expands an `@design.md` the spec
284
+ * references — and the implementer is told that doc is authoritative. Prepend a
285
+ * VERIFIED API OVERRIDES banner that outranks the spec for any specifier the
286
+ * deterministic check proves does not exist. No-op (returns the spec unchanged) when
287
+ * nothing is flagged or the runtime's types aren't installed.
288
+ */
289
+ _specForDelivery() {
290
+ const phantoms = findDeliveryPhantoms(this._pc.spec, this._cwd);
291
+ const banner = formatApiOverrideBanner(phantoms);
292
+ if (!banner)
293
+ return this._pc.spec;
294
+ this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
295
+ return `${banner}\n\n${this._pc.spec}`;
296
+ }
278
297
  }
279
298
  /** Dialog copy for the post-interrupt steering prompt. */
280
299
  const STEER_TITLE = 'Paused — steer the model';
@@ -517,30 +517,30 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
517
517
  else {
518
518
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
519
519
  const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
520
- // A binary fork (suggested + alt) becomes a select() picker locally —
521
- // each option on its own line, labelled A/B. A single recommendation
522
- // rides along under the question as the input default; an open
523
- // question shows the bare prompt.
520
+ // A recommendation (or suggested+alt fork) becomes the boxed picker
521
+ // locally — each answer in its own bounding box, the recommended one
522
+ // tinted green; an open question shows the bare text prompt.
524
523
  const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
525
- const localTitle = !twoOption && plainSuggested ?
526
- `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
527
- : shownQ;
524
+ const options = twoOption ?
525
+ [
526
+ {
527
+ label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
528
+ value: plainSuggested
529
+ },
530
+ { label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
531
+ ]
532
+ : plainSuggested !== undefined ?
533
+ [{ label: renderInlineMarkdown(auto.suggested, theme), value: plainSuggested }]
534
+ : undefined;
528
535
  widgetState.lastLine = `awaiting Q${n + 1}`;
529
536
  const a = await ui.ask({
530
- localTitle,
537
+ localTitle: shownQ,
538
+ displayQuestion: shownQ,
531
539
  question: plainQ,
532
540
  recommended: plainSuggested,
533
541
  recommended2: plainAlt,
534
542
  allowSkip: plainSuggested === undefined && plainAlt === undefined,
535
- ...(twoOption && {
536
- options: [
537
- {
538
- label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
539
- value: plainSuggested
540
- },
541
- { label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
542
- ]
543
- })
543
+ ...(options && { options })
544
544
  });
545
545
  if (a === undefined)
546
546
  throw new Error(USER_CANCELLED);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Boxed question dialog for the clarify / grill phases.
3
+ *
4
+ * The built-in `ctx.ui.select` renders every option as a bare line under one
5
+ * outer frame — the question, the recommendation, and the choices all read as a
6
+ * single blob. The remote (browser) prompt card instead gives each response its
7
+ * own bounded panel, tints the recommended one green, and separates everything
8
+ * with margins. This module mirrors that remote style in the terminal: a custom
9
+ * `ctx.ui.custom` component that draws each answer in its own bounding box with a
10
+ * green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
11
+ * spacing.
12
+ *
13
+ * `renderQuestionBox` is the pure layout — width in, lines out — so the visual
14
+ * structure (borders, tag, cursor arrow, padding) is unit-testable without a
15
+ * live TUI. The component and `askQuestionBox` wire it to keyboard input and the
16
+ * "type a different answer" free-text fallback.
17
+ */
18
+ import type { Component } from '@earendil-works/pi-tui';
19
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
20
+ /** One answer card. `label` may carry ANSI (rendered markdown); width math is
21
+ * ANSI-aware so the box borders still line up. */
22
+ export interface BoxCard {
23
+ label: string;
24
+ /** Recommended answer → green chrome + RECOMMENDED tag. */
25
+ recommended?: boolean;
26
+ }
27
+ /** Chrome colourers, injected so tests can pass identity fns and assert on the
28
+ * plain box structure. A real theme is adapted by {@link boxColors}. */
29
+ export interface BoxColors {
30
+ border: (s: string) => string;
31
+ borderRecommended: (s: string) => string;
32
+ borderSelected: (s: string) => string;
33
+ recommendedTag: (s: string) => string;
34
+ questionLabel: (s: string) => string;
35
+ hint: (s: string) => string;
36
+ arrow: (s: string) => string;
37
+ }
38
+ type ThemeLike = ExtensionCommandContext['ui']['theme'];
39
+ export declare const QUESTION_LABEL = "QUESTION";
40
+ export declare const HINT_TEXT = "\u2191\u2193 navigate \u00B7 enter select \u00B7 esc cancel";
41
+ export declare const MANUAL_CARD_LABEL = "\u270E Type a different answer\u2026";
42
+ /**
43
+ * Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
44
+ * question, one bounding box per card (recommended tinted green, the cursor box
45
+ * highlighted with an accent border and "▸" arrow), and a key-hint footer.
46
+ */
47
+ export declare function renderQuestionBox(width: number, question: string, cards: BoxCard[], selected: number, c: BoxColors, hintText?: string): string[];
48
+ export declare function boxColors(theme: ThemeLike): BoxColors;
49
+ /** Focusable picker component; navigation mirrors the built-in select dialog. */
50
+ export declare class QuestionBoxComponent implements Component {
51
+ private readonly question;
52
+ private readonly cards;
53
+ private readonly colors;
54
+ private readonly onChoose;
55
+ private readonly onCancel;
56
+ private selected;
57
+ /** Card labels in display order (recommended first, manual last). Read-only
58
+ * view for tests that drive the picker without parsing rendered output. */
59
+ get cardLabels(): string[];
60
+ /** The question header text. Read-only view for tests. */
61
+ get headerText(): string;
62
+ constructor(question: string, cards: BoxCard[], colors: BoxColors, onChoose: (index: number) => void, onCancel: () => void);
63
+ render(width: number): string[];
64
+ invalidate(): void;
65
+ handleInput(data: string): void;
66
+ }
67
+ export interface BoxOption {
68
+ label: string;
69
+ value: string;
70
+ recommended?: boolean;
71
+ }
72
+ export interface AskQuestionBoxSpec {
73
+ /** Display question for the box header (may carry ANSI). */
74
+ question: string;
75
+ /** Plain title for the free-text fallback input dialog. */
76
+ inputTitle: string;
77
+ options: BoxOption[];
78
+ signal: AbortSignal;
79
+ }
80
+ /**
81
+ * Show the boxed picker and resolve to the chosen option's `value`, the text the
82
+ * user typed via "type a different answer", or undefined when cancelled/aborted.
83
+ */
84
+ export declare function askQuestionBox(ctx: ExtensionCommandContext, spec: AskQuestionBoxSpec): Promise<string | undefined>;
85
+ export {};
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Boxed question dialog for the clarify / grill phases.
3
+ *
4
+ * The built-in `ctx.ui.select` renders every option as a bare line under one
5
+ * outer frame — the question, the recommendation, and the choices all read as a
6
+ * single blob. The remote (browser) prompt card instead gives each response its
7
+ * own bounded panel, tints the recommended one green, and separates everything
8
+ * with margins. This module mirrors that remote style in the terminal: a custom
9
+ * `ctx.ui.custom` component that draws each answer in its own bounding box with a
10
+ * green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
11
+ * spacing.
12
+ *
13
+ * `renderQuestionBox` is the pure layout — width in, lines out — so the visual
14
+ * structure (borders, tag, cursor arrow, padding) is unit-testable without a
15
+ * live TUI. The component and `askQuestionBox` wire it to keyboard input and the
16
+ * "type a different answer" free-text fallback.
17
+ */
18
+ import { getKeybindings, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
19
+ // ─── Constants ───────────────────────────────────────────────────────────────
20
+ /** Left gutter: two columns reserved for the "▸ " cursor so boxes stay aligned. */
21
+ const GUTTER = 2;
22
+ const RECOMMENDED_TAG = 'RECOMMENDED';
23
+ /** Smallest box that still fits "┌─ RECOMMENDED ─┐". Below this we drop the tag. */
24
+ const TAG_MIN_BOX_WIDTH = visibleWidth(`┌─ ${RECOMMENDED_TAG} ─┐`);
25
+ export const QUESTION_LABEL = 'QUESTION';
26
+ export const HINT_TEXT = '↑↓ navigate · enter select · esc cancel';
27
+ export const MANUAL_CARD_LABEL = '✎ Type a different answer…';
28
+ // ─── Pure renderer ─────────────────────────────────────────────────────────────
29
+ function padTo(line, width) {
30
+ const pad = width - visibleWidth(line);
31
+ return pad > 0 ? line + ' '.repeat(pad) : line;
32
+ }
33
+ function renderCard(card, selected, boxWidth, c) {
34
+ const borderFn = selected ? c.borderSelected
35
+ : card.recommended ? c.borderRecommended
36
+ : c.border;
37
+ const innerWidth = Math.max(1, boxWidth - 4); // "│ " + content + " │"
38
+ const dash = (n) => '─'.repeat(Math.max(0, n));
39
+ // Top border, with a RECOMMENDED tag woven in when it fits.
40
+ let top;
41
+ if (card.recommended && boxWidth >= TAG_MIN_BOX_WIDTH) {
42
+ // TAG_MIN_BOX_WIDTH is the width at one trailing dash, so add it back.
43
+ const dashes = boxWidth - TAG_MIN_BOX_WIDTH + 1;
44
+ top = borderFn('┌─ ') + c.recommendedTag(RECOMMENDED_TAG) + borderFn(` ${dash(dashes)}┐`);
45
+ }
46
+ else {
47
+ top = borderFn(`┌${dash(boxWidth - 2)}┐`);
48
+ }
49
+ const bodyLines = wrapTextWithAnsi(card.label, innerWidth);
50
+ const body = (bodyLines.length > 0 ? bodyLines : ['']).map(line => borderFn('│ ') + padTo(line, innerWidth) + borderFn(' │'));
51
+ const bottom = borderFn(`└${dash(boxWidth - 2)}┘`);
52
+ const arrow = selected ? c.arrow('▸') + ' ' : ' ';
53
+ const pad = ' ';
54
+ return [arrow + top, ...body.map(b => pad + b), pad + bottom];
55
+ }
56
+ /**
57
+ * Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
58
+ * question, one bounding box per card (recommended tinted green, the cursor box
59
+ * highlighted with an accent border and "▸" arrow), and a key-hint footer.
60
+ */
61
+ export function renderQuestionBox(width, question, cards, selected, c, hintText = HINT_TEXT) {
62
+ const W = Math.max(width, 12);
63
+ const boxWidth = Math.max(6, W - GUTTER);
64
+ const out = [''];
65
+ out.push(' ' + c.questionLabel(QUESTION_LABEL));
66
+ for (const line of wrapTextWithAnsi(question, W - GUTTER))
67
+ out.push(' ' + line);
68
+ out.push('');
69
+ for (let i = 0; i < cards.length; i++) {
70
+ out.push(...renderCard(cards[i], i === selected, boxWidth, c));
71
+ out.push('');
72
+ }
73
+ for (const line of wrapTextWithAnsi(hintText, W - GUTTER))
74
+ out.push(' ' + c.hint(line));
75
+ out.push('');
76
+ return out;
77
+ }
78
+ // ─── Theme adapter ─────────────────────────────────────────────────────────────
79
+ export function boxColors(theme) {
80
+ return {
81
+ border: s => theme.fg('borderMuted', s),
82
+ borderRecommended: s => theme.fg('success', s),
83
+ borderSelected: s => theme.fg('accent', s),
84
+ recommendedTag: s => theme.fg('success', theme.bold(s)),
85
+ questionLabel: s => theme.fg('accent', theme.bold(s)),
86
+ hint: s => theme.fg('muted', s),
87
+ arrow: s => theme.fg('accent', theme.bold(s))
88
+ };
89
+ }
90
+ // ─── Component ─────────────────────────────────────────────────────────────────
91
+ /** Focusable picker component; navigation mirrors the built-in select dialog. */
92
+ export class QuestionBoxComponent {
93
+ question;
94
+ cards;
95
+ colors;
96
+ onChoose;
97
+ onCancel;
98
+ selected = 0;
99
+ /** Card labels in display order (recommended first, manual last). Read-only
100
+ * view for tests that drive the picker without parsing rendered output. */
101
+ get cardLabels() {
102
+ return this.cards.map(c => c.label);
103
+ }
104
+ /** The question header text. Read-only view for tests. */
105
+ get headerText() {
106
+ return this.question;
107
+ }
108
+ constructor(question, cards, colors, onChoose, onCancel) {
109
+ this.question = question;
110
+ this.cards = cards;
111
+ this.colors = colors;
112
+ this.onChoose = onChoose;
113
+ this.onCancel = onCancel;
114
+ }
115
+ render(width) {
116
+ return renderQuestionBox(width, this.question, this.cards, this.selected, this.colors);
117
+ }
118
+ invalidate() { }
119
+ handleInput(data) {
120
+ const kb = getKeybindings();
121
+ if (kb.matches(data, 'tui.select.up') || data === 'k') {
122
+ this.selected = Math.max(0, this.selected - 1);
123
+ }
124
+ else if (kb.matches(data, 'tui.select.down') || data === 'j') {
125
+ this.selected = Math.min(this.cards.length - 1, this.selected + 1);
126
+ }
127
+ else if (kb.matches(data, 'tui.select.confirm') || data === '\n' || data === '\r') {
128
+ this.onChoose(this.selected);
129
+ }
130
+ else if (kb.matches(data, 'tui.select.cancel')) {
131
+ this.onCancel();
132
+ }
133
+ }
134
+ }
135
+ /**
136
+ * Show the boxed picker and resolve to the chosen option's `value`, the text the
137
+ * user typed via "type a different answer", or undefined when cancelled/aborted.
138
+ */
139
+ export async function askQuestionBox(ctx, spec) {
140
+ const { question, options, inputTitle, signal } = spec;
141
+ const cards = [
142
+ ...options.map(o => ({ label: o.label, recommended: o.recommended })),
143
+ { label: MANUAL_CARD_LABEL }
144
+ ];
145
+ const colors = boxColors(ctx.ui.theme);
146
+ const manualIndex = options.length;
147
+ const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
148
+ if (signal.aborted) {
149
+ done(undefined);
150
+ return new QuestionBoxComponent(question, cards, colors, () => { }, () => { });
151
+ }
152
+ const onAbort = () => done(undefined);
153
+ signal.addEventListener('abort', onAbort, { once: true });
154
+ const finish = (result) => {
155
+ signal.removeEventListener('abort', onAbort);
156
+ done(result);
157
+ };
158
+ return new QuestionBoxComponent(question, cards, colors, index => finish(index), () => finish(undefined));
159
+ });
160
+ if (choice === undefined)
161
+ return undefined;
162
+ if (choice === manualIndex) {
163
+ return ctx.ui.input(inputTitle, undefined, { signal });
164
+ }
165
+ return options[choice]?.value;
166
+ }
@@ -36,6 +36,25 @@ export declare function loadRuntimeTypeText(runtime: string, cwd: string): strin
36
36
  export declare function findPhantomImports(text: string, cwd: string, loadText?: (runtime: string, cwd: string) => string | null): PhantomImport[];
37
37
  /** Render flagged phantoms as an authoritative research section, or '' if none. */
38
38
  export declare function formatApiCorrections(phantoms: PhantomImport[]): string;
39
+ /**
40
+ * A top-of-handoff override banner (Layer B). The deterministic check has proven —
41
+ * against the installed type definitions — that the spec, or a design doc it
42
+ * references, names an API that does NOT exist. The implementer is told the spec/doc
43
+ * is authoritative, so a residual affirmative that survived into the composed spec, or
44
+ * one re-injected when pi expands an `@design.md` the spec references, can still push it
45
+ * to write `bun:sql` / a `declare module` shim. This banner sits ABOVE the spec and is
46
+ * declared to outrank it for these specifiers only. Empty when nothing is flagged.
47
+ */
48
+ export declare function formatApiOverrideBanner(phantoms: PhantomImport[]): string;
49
+ /**
50
+ * The phantoms the IMPLEMENTER would actually encounter: those named in the delivered
51
+ * spec text itself, PLUS those in any @-file the spec references (pi expands that
52
+ * mention at send time, re-injecting the doc's affirmative). Concatenates the spec with
53
+ * each readable referenced doc and runs the standard deterministic check over the whole.
54
+ * Unreadable mentions are skipped; silent when types are absent or nothing is flagged.
55
+ * Drives the impl-handoff override banner (Layer B).
56
+ */
57
+ export declare function findDeliveryPhantoms(spec: string, cwd: string): PhantomImport[];
39
58
  /**
40
59
  * Subtractively rewrite every flagged phantom specifier in `text` to the canonical
41
60
  * import the installed types prove, so NO affirmative occurrence of the non-existent
@@ -186,6 +186,56 @@ export function formatApiCorrections(phantoms) {
186
186
  const lines = phantoms.map(p => ` - ${p.suggestion}`);
187
187
  return `API CORRECTIONS\n${lines.join('\n')}`;
188
188
  }
189
+ /**
190
+ * A top-of-handoff override banner (Layer B). The deterministic check has proven —
191
+ * against the installed type definitions — that the spec, or a design doc it
192
+ * references, names an API that does NOT exist. The implementer is told the spec/doc
193
+ * is authoritative, so a residual affirmative that survived into the composed spec, or
194
+ * one re-injected when pi expands an `@design.md` the spec references, can still push it
195
+ * to write `bun:sql` / a `declare module` shim. This banner sits ABOVE the spec and is
196
+ * declared to outrank it for these specifiers only. Empty when nothing is flagged.
197
+ */
198
+ export function formatApiOverrideBanner(phantoms) {
199
+ if (phantoms.length === 0)
200
+ return '';
201
+ const lines = phantoms.map(p => ` - ${p.suggestion}`);
202
+ return ('VERIFIED API OVERRIDES — checked against the installed type definitions. These '
203
+ + 'SUPERSEDE the spec below and any design/spec document it references: the doc is '
204
+ + 'WRONG about these specifiers, so do NOT follow it here.\n'
205
+ + lines.join('\n')
206
+ + '\nUse the corrected import shown for each. Never write the phantom specifier, '
207
+ + 'and never add a `declare module` shim to make it resolve.');
208
+ }
209
+ // An @-file mention in a spec ("@DESIGN/foo.md", path until whitespace) minus the
210
+ // trailing prose punctuation a sentence adds. Mirrors auto-orchestrator's mention
211
+ // rules so we read the same docs pi re-expands when delivering the spec.
212
+ const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
213
+ const MENTION_TRAILING_PUNCT = /[.,;:!?)\]}>"']+$/;
214
+ /**
215
+ * The phantoms the IMPLEMENTER would actually encounter: those named in the delivered
216
+ * spec text itself, PLUS those in any @-file the spec references (pi expands that
217
+ * mention at send time, re-injecting the doc's affirmative). Concatenates the spec with
218
+ * each readable referenced doc and runs the standard deterministic check over the whole.
219
+ * Unreadable mentions are skipped; silent when types are absent or nothing is flagged.
220
+ * Drives the impl-handoff override banner (Layer B).
221
+ */
222
+ export function findDeliveryPhantoms(spec, cwd) {
223
+ let text = spec;
224
+ const seen = new Set();
225
+ for (const m of spec.matchAll(MENTION_RE)) {
226
+ const rel = m[1].replace(MENTION_TRAILING_PUNCT, '');
227
+ if (rel === '' || seen.has(rel))
228
+ continue;
229
+ seen.add(rel);
230
+ try {
231
+ text += '\n' + fs.readFileSync(path.resolve(cwd, rel), 'utf8');
232
+ }
233
+ catch {
234
+ // not a readable file — leave the @token, skip it
235
+ }
236
+ }
237
+ return findPhantomImports(text, cwd);
238
+ }
189
239
  /**
190
240
  * Subtractively rewrite every flagged phantom specifier in `text` to the canonical
191
241
  * import the installed types prove, so NO affirmative occurrence of the non-existent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.10",
3
+ "version": "0.14.12",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",