pi-pier 0.2.6 → 0.3.0

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 (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -0,0 +1,458 @@
1
+ /**
2
+ * ask_user_question I/O: normalize params, select+Other dialog, result envelope.
3
+ *
4
+ * Why: the herdr gate (blocked / pi-ask / widget collapse) stays in index.ts.
5
+ * This module is the questionnaire — authored options plus a runtime Other row —
6
+ * so the model cannot forget a free-text escape and Esc is a real decline.
7
+ */
8
+ import { Type } from 'typebox';
9
+ import { runMultiSelect } from './ask-multi.ts';
10
+
11
+ export const ASK_TOOL_NAME = 'ask_user_question';
12
+ export const OTHER_OPTION = 'Other (type your own)';
13
+ export const OTHER_ANSWER = 'Other';
14
+ export const RECOMMENDED_SUFFIX = ' (Recommended)';
15
+ export const MIN_OPTIONS = 2;
16
+ export const MAX_OPTIONS = 5;
17
+ export const MAX_QUESTIONS = 4;
18
+ export const MAX_LABEL_CHARS = 60;
19
+
20
+ export const DECLINE_TEXT =
21
+ 'User declined to answer the questions. Continue with your best judgment, or ask different questions.';
22
+ export const NO_UI_TEXT =
23
+ 'Error: UI not available (running in non-interactive mode). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.';
24
+
25
+ const CUSTOM_ANSWER_TITLE = 'Type your answer:';
26
+ const MULTI_INSTRUCTIONS =
27
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
28
+ const NUMBERED_HINT = 'Enter a number, or type a custom answer.';
29
+
30
+ const RESERVED_LOWER: Record<string, true> = {
31
+ [OTHER_OPTION.toLowerCase()]: true,
32
+ other: true,
33
+ 'type something.': true,
34
+ next: true,
35
+ 'next →': true,
36
+ };
37
+
38
+ const OptionSchema = Type.Object({
39
+ label: Type.String({ description: 'Short display label (1-5 words)' }),
40
+ description: Type.Optional(Type.String({ description: 'Tradeoff / meaning of this choice' })),
41
+ });
42
+
43
+ export const ASK_PARAMETERS = Type.Object({
44
+ question: Type.Optional(Type.String({ description: 'The question to ask the human. Required unless questions is set.' })),
45
+ options: Type.Optional(Type.Array(OptionSchema, {
46
+ description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
47
+ })),
48
+ multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms, esc declines).' })),
49
+ allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
50
+ recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
51
+ questions: Type.Optional(Type.Array(
52
+ Type.Object({
53
+ id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
54
+ question: Type.String({ description: 'Question text' }),
55
+ options: Type.Array(OptionSchema, {
56
+ description: '2-5 authored choices. Do NOT include Other; the UI appends it unless allowOther is false.',
57
+ }),
58
+ multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms).' })),
59
+ allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
60
+ recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
61
+ }),
62
+ { description: '1-4 related questions in one call. Wins over top-level question/options when set.' },
63
+ )),
64
+ });
65
+
66
+ export const ASK_TOOL_DESCRIPTION = [
67
+ 'Ask the human a question and wait for their answer.',
68
+ 'Use this when you genuinely need a human decision (approval, direction, trade-off) — not for information you can find yourself.',
69
+ 'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
70
+ 'Prefer 2-5 options with short labels; put tradeoffs in description.',
71
+ 'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
72
+ 'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
73
+ 'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
74
+ 'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
75
+ 'The answer comes back as the tool result; then continue your work.',
76
+ ].join(' ');
77
+
78
+ export const ASK_PROMPT_GUIDELINES = [
79
+ 'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
80
+ 'Do NOT include "Other" in options; the UI appends it unless allowOther is false. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
81
+ 'For multi questions the human toggles rows in a list and confirms with enter — never instruct them to type indices.',
82
+ 'Short option labels; explanatory tradeoffs in description.',
83
+ ];
84
+
85
+ export type AskOption = { label: string; description?: string };
86
+ export type AskQuestion = {
87
+ id?: string;
88
+ question: string;
89
+ options: AskOption[];
90
+ multi: boolean;
91
+ /** When false the UI offers the authored choices only (no free-text row). */
92
+ allowOther: boolean;
93
+ recommended?: number;
94
+ };
95
+ export type AskSpec =
96
+ | { mode: 'freeform'; question: string }
97
+ | { mode: 'questionnaire'; questions: AskQuestion[] };
98
+
99
+ export type AskAnswer = {
100
+ question: string;
101
+ id?: string;
102
+ kind: 'option' | 'custom' | 'multi';
103
+ answer: string | null;
104
+ selected?: string[];
105
+ customInput?: string;
106
+ };
107
+
108
+ export type AskDetails = {
109
+ answers: AskAnswer[];
110
+ cancelled: boolean;
111
+ error?: string;
112
+ };
113
+
114
+ export type AskToolResult = {
115
+ content: Array<{ type: 'text'; text: string }>;
116
+ details: AskDetails;
117
+ };
118
+
119
+ export type AskUi = {
120
+ select?: (title: string, options: string[], opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
121
+ input: (title: string, placeholder?: string, opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
122
+ /** TUI-only: full custom components (absent in RPC mode, where it returns undefined). */
123
+ custom?: (factory: unknown) => Promise<unknown>;
124
+ };
125
+
126
+ export type PrepareAskResult =
127
+ | { ok: true; spec: AskSpec }
128
+ | { ok: false; result: AskToolResult };
129
+
130
+ function asRecord(value: unknown): Record<string, unknown> | null {
131
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
132
+ ? value as Record<string, unknown>
133
+ : null;
134
+ }
135
+
136
+ function fail(text: string, code: string): { ok: false; result: AskToolResult } {
137
+ return { ok: false, result: errorResult(text, code) };
138
+ }
139
+
140
+ function errorResult(text: string, code: string): AskToolResult {
141
+ return {
142
+ content: [{ type: 'text', text }],
143
+ details: { answers: [], cancelled: true, error: code },
144
+ };
145
+ }
146
+
147
+ function okResult(details: AskDetails): AskToolResult {
148
+ return {
149
+ content: [{ type: 'text', text: formatAskContent(details) }],
150
+ details,
151
+ };
152
+ }
153
+
154
+ export function noUiResult(): AskToolResult {
155
+ return errorResult(NO_UI_TEXT, 'no_ui');
156
+ }
157
+
158
+ export function hasAskUi(ui: unknown): ui is AskUi {
159
+ return typeof (ui as { input?: unknown } | null | undefined)?.input === 'function';
160
+ }
161
+
162
+ function parseRecommended(raw: unknown, count: number): number | undefined {
163
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0 || raw >= count) return undefined;
164
+ return raw;
165
+ }
166
+
167
+ function parseOptions(raw: unknown): { ok: true; options: AskOption[] } | { ok: false; result: AskToolResult } {
168
+ if (!Array.isArray(raw)) {
169
+ return fail('Error: `options` must be an array of 2-5 choices', 'empty_options');
170
+ }
171
+ if (raw.length < MIN_OPTIONS) {
172
+ return fail(`Error: each question needs ${MIN_OPTIONS}-${MAX_OPTIONS} options`, 'empty_options');
173
+ }
174
+ if (raw.length > MAX_OPTIONS) {
175
+ return fail(`Error: each question needs ${MIN_OPTIONS}-${MAX_OPTIONS} options`, 'too_many_options');
176
+ }
177
+ const options: AskOption[] = [];
178
+ const seen = new Set<string>();
179
+ for (const item of raw) {
180
+ const rec = asRecord(item);
181
+ const rawLabel = typeof rec?.label === 'string' ? rec.label.trim() : '';
182
+ // B3: models often repeat the UI's own marker (`label: 'Redis (Recommended)'`), which rendered
183
+ // as "Redis (Recommended) (Recommended)". Only a trailing bracketed marker is stripped, so a
184
+ // label that merely mentions the word ("Recommended approach") survives untouched.
185
+ const label = rawLabel.replace(/\s*[([]\s*recommended\s*[)\]]\s*$/i, '').trim();
186
+ if (!label) {
187
+ return fail(
188
+ rawLabel
189
+ ? `Error: option label "${rawLabel}" is only the marker the UI appends — give the choice a real name and pass "recommended" instead`
190
+ : 'Error: each option needs a non-empty label',
191
+ 'empty_options',
192
+ );
193
+ }
194
+ if (label.length > MAX_LABEL_CHARS) {
195
+ return fail(`Error: option label exceeds ${MAX_LABEL_CHARS} characters`, 'label_too_long');
196
+ }
197
+ if (RESERVED_LOWER[label.toLowerCase()]) {
198
+ return fail(
199
+ `Error: option label "${label}" is reserved — the UI appends "${OTHER_OPTION}" automatically`,
200
+ 'reserved_label',
201
+ );
202
+ }
203
+ if (seen.has(label)) {
204
+ return fail(`Error: duplicate option label "${label}"`, 'duplicate_option_label');
205
+ }
206
+ seen.add(label);
207
+ const description = typeof rec?.description === 'string' ? rec.description.replace(/\s+/g, ' ').trim() : '';
208
+ options.push(description ? { label, description } : { label });
209
+ }
210
+ return { ok: true, options };
211
+ }
212
+
213
+ function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok: false; result: AskToolResult } {
214
+ const rec = asRecord(raw);
215
+ if (!rec) return fail('Error: each questions[] entry must be an object', 'empty_question');
216
+ const question = typeof rec.question === 'string' ? rec.question.trim() : '';
217
+ if (!question) return fail('Error: `question` must be a non-empty string', 'empty_question');
218
+ const parsed = parseOptions(rec.options);
219
+ if (!parsed.ok) return parsed;
220
+ const recommended = parseRecommended(rec.recommended, parsed.options.length);
221
+ const id = typeof rec.id === 'string' && rec.id.trim() ? rec.id.trim() : undefined;
222
+ return {
223
+ ok: true,
224
+ question: {
225
+ question,
226
+ options: parsed.options,
227
+ multi: rec.multi === true,
228
+ allowOther: rec.allowOther !== false,
229
+ ...(id ? { id } : {}),
230
+ ...(recommended !== undefined ? { recommended } : {}),
231
+ },
232
+ };
233
+ }
234
+
235
+ export function prepareAsk(params: unknown): PrepareAskResult {
236
+ const rec = asRecord(params);
237
+ if (!rec) return fail('Error: `question` must be a non-empty string', 'empty_question');
238
+
239
+ if ('questions' in rec && rec.questions !== undefined) {
240
+ if (!Array.isArray(rec.questions)) {
241
+ return fail('Error: `questions` must be an array of 1-4 questions', 'no_questions');
242
+ }
243
+ if (rec.questions.length < 1) {
244
+ return fail('Error: `questions` must contain 1-4 entries', 'no_questions');
245
+ }
246
+ if (rec.questions.length > MAX_QUESTIONS) {
247
+ return fail('Error: `questions` must contain 1-4 entries', 'too_many_questions');
248
+ }
249
+ const questions: AskQuestion[] = [];
250
+ const seen = new Set<string>();
251
+ for (const item of rec.questions) {
252
+ const parsed = parseQuestion(item);
253
+ if (!parsed.ok) return parsed;
254
+ if (seen.has(parsed.question.question)) {
255
+ return fail(`Error: duplicate question "${parsed.question.question}"`, 'duplicate_question');
256
+ }
257
+ seen.add(parsed.question.question);
258
+ questions.push(parsed.question);
259
+ }
260
+ return { ok: true, spec: { mode: 'questionnaire', questions } };
261
+ }
262
+
263
+ const question = typeof rec.question === 'string' ? rec.question.trim() : '';
264
+ if (!question) return fail('Error: `question` must be a non-empty string', 'empty_question');
265
+ if (rec.options === undefined) {
266
+ return { ok: true, spec: { mode: 'freeform', question } };
267
+ }
268
+ const parsed = parseOptions(rec.options);
269
+ if (!parsed.ok) return parsed;
270
+ const recommended = parseRecommended(rec.recommended, parsed.options.length);
271
+ return {
272
+ ok: true,
273
+ spec: {
274
+ mode: 'questionnaire',
275
+ questions: [{
276
+ question,
277
+ options: parsed.options,
278
+ multi: rec.multi === true,
279
+ allowOther: rec.allowOther !== false,
280
+ ...(recommended !== undefined ? { recommended } : {}),
281
+ }],
282
+ },
283
+ };
284
+ }
285
+
286
+ export function gateLabel(spec: AskSpec): string {
287
+ if (spec.mode === 'freeform') return spec.question;
288
+ const first = spec.questions[0]?.question ?? '';
289
+ const extra = spec.questions.length - 1;
290
+ return extra > 0 ? `${first} (+${extra})` : first;
291
+ }
292
+
293
+ export function formatAuthoredLine(option: AskOption, index: number, recommended?: number): string {
294
+ const rec = recommended === index ? RECOMMENDED_SUFFIX : '';
295
+ const desc = option.description ? ` — ${option.description}` : '';
296
+ return `${index + 1}. ${option.label}${rec}${desc}`;
297
+ }
298
+
299
+ export function optionLines(question: AskQuestion): string[] {
300
+ const lines = question.options.map((option, i) => formatAuthoredLine(option, i, question.recommended));
301
+ if (question.allowOther !== false) lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
302
+ return lines;
303
+ }
304
+
305
+ export function parseChoice(chosen: string, lines: readonly string[]): number | null {
306
+ const trimmed = chosen.trim();
307
+ const exact = lines.indexOf(trimmed);
308
+ if (exact >= 0) return exact;
309
+ const parsed = Number.parseInt(trimmed, 10);
310
+ if (!Number.isFinite(parsed)) return null;
311
+ const idx = parsed - 1;
312
+ return idx >= 0 && idx < lines.length ? idx : null;
313
+ }
314
+
315
+ function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
316
+ return question.id ? { ...answer, id: question.id } : answer;
317
+ }
318
+
319
+ async function askSingle(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
320
+ const lines = optionLines(question);
321
+ const opts = signal ? { signal } : undefined;
322
+ const chosen = ui.select
323
+ ? await ui.select(question.question, lines, opts)
324
+ : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${NUMBERED_HINT}`, '1', opts);
325
+ if (chosen == null) return undefined;
326
+ const trimmed = chosen.trim();
327
+ if (!trimmed) return undefined;
328
+ const idx = parseChoice(trimmed, lines);
329
+ if (idx != null && idx < question.options.length) {
330
+ return withId(question, {
331
+ question: question.question,
332
+ kind: 'option',
333
+ answer: question.options[idx]!.label,
334
+ });
335
+ }
336
+ if (idx === question.options.length) {
337
+ const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', opts);
338
+ if (typed == null || !typed.trim()) return undefined;
339
+ return withId(question, {
340
+ question: question.question,
341
+ kind: 'custom',
342
+ answer: OTHER_ANSWER,
343
+ customInput: typed.trim(),
344
+ });
345
+ }
346
+ if (!ui.select) {
347
+ return withId(question, {
348
+ question: question.question,
349
+ kind: 'custom',
350
+ answer: OTHER_ANSWER,
351
+ customInput: trimmed,
352
+ });
353
+ }
354
+ return undefined;
355
+ }
356
+
357
+ async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
358
+ // TUI path: a real toggle list. ctx.ui.custom is absent in RPC mode (or returns undefined),
359
+ // in which case we fall through to the typed-index prompt below.
360
+ if (typeof ui.custom === 'function') {
361
+ const outcome = await runMultiSelect(ui.custom, question.question, {
362
+ options: question.options,
363
+ allowOther: question.allowOther,
364
+ ...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
365
+ });
366
+ if (outcome?.kind === 'cancel') return undefined;
367
+ if (outcome?.kind === 'other') {
368
+ const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
369
+ if (typed == null || !typed.trim()) return undefined;
370
+ return withId(question, {
371
+ question: question.question,
372
+ kind: 'custom',
373
+ answer: OTHER_ANSWER,
374
+ customInput: typed.trim(),
375
+ });
376
+ }
377
+ if (outcome?.kind === 'selected') {
378
+ return withId(question, { question: question.question, kind: 'multi', answer: null, selected: outcome.labels });
379
+ }
380
+ }
381
+ const list = question.options
382
+ .map((option, i) => formatAuthoredLine(option, i, question.recommended))
383
+ .join('\n');
384
+ const hint = question.allowOther === false
385
+ ? `Enter the numbers of all that apply, comma-separated (e.g. "1,3"). There is no free-text answer for this question.`
386
+ : MULTI_INSTRUCTIONS;
387
+ const value = await ui.input(
388
+ `${question.question}\n\n${list}\n\n${hint}`,
389
+ '1,3',
390
+ signal ? { signal } : undefined,
391
+ );
392
+ if (value == null) return undefined;
393
+ const trimmed = value.trim();
394
+ if (!trimmed) {
395
+ return withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] });
396
+ }
397
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
398
+ const indices = tokens.map((tok) => {
399
+ if (!/^\d+\.?$/.test(tok)) return null;
400
+ const idx = Number.parseInt(tok, 10) - 1;
401
+ return idx >= 0 && idx < question.options.length ? idx : null;
402
+ });
403
+ if (indices.every((i): i is number => i != null)) {
404
+ const selected: string[] = [];
405
+ for (const i of indices) {
406
+ const label = question.options[i]!.label;
407
+ if (!selected.includes(label)) selected.push(label);
408
+ }
409
+ return withId(question, { question: question.question, kind: 'multi', answer: null, selected });
410
+ }
411
+ // Unparsable input is a free-text answer only when the free-text row was offered.
412
+ if (question.allowOther === false) return undefined;
413
+ return withId(question, {
414
+ question: question.question,
415
+ kind: 'custom',
416
+ answer: OTHER_ANSWER,
417
+ customInput: trimmed,
418
+ });
419
+ }
420
+
421
+ export function formatAskContent(details: AskDetails): string {
422
+ if (details.cancelled || details.answers.length === 0) return DECLINE_TEXT;
423
+ const entries = details.answers.map((answer) => {
424
+ if (answer.kind === 'custom' && answer.answer === OTHER_ANSWER) {
425
+ const notes = answer.customInput ? ` user notes: ${answer.customInput}` : '';
426
+ return `"${answer.question}"="${OTHER_ANSWER}"${notes}`;
427
+ }
428
+ if (answer.kind === 'multi') {
429
+ const labels = answer.selected?.length ? answer.selected.join(', ') : '(none)';
430
+ return `"${answer.question}"="${labels}"`;
431
+ }
432
+ return `"${answer.question}"="${answer.answer ?? ''}"`;
433
+ });
434
+ return `User has answered your questions: ${entries.join(', ')}. You can now continue with the user's answers in mind.`;
435
+ }
436
+
437
+ export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Promise<AskToolResult> {
438
+ if (spec.mode === 'freeform') {
439
+ const answer = await ui.input(spec.question, 'your answer', signal ? { signal } : undefined);
440
+ if (answer == null || !answer.trim()) {
441
+ return okResult({ answers: [], cancelled: true });
442
+ }
443
+ const trimmed = answer.trim();
444
+ return okResult({
445
+ answers: [{ question: spec.question, kind: 'custom', answer: trimmed, customInput: trimmed }],
446
+ cancelled: false,
447
+ });
448
+ }
449
+ const answers: AskAnswer[] = [];
450
+ for (const question of spec.questions) {
451
+ const answer = question.multi
452
+ ? await askMulti(ui, question, signal)
453
+ : await askSingle(ui, question, signal);
454
+ if (!answer) return okResult({ answers, cancelled: true });
455
+ answers.push(answer);
456
+ }
457
+ return okResult({ answers, cancelled: false });
458
+ }
package/src/bootstrap.ts CHANGED
@@ -15,6 +15,7 @@ import Group from '@deepseek-ai/cordis-plugin-group';
15
15
  import { pathToFileURL, fileURLToPath } from 'node:url';
16
16
  import * as path from 'node:path';
17
17
  import { DisposeLedger } from './ledger.ts';
18
+ import { pierOption } from './pier-options.ts';
18
19
 
19
20
  export interface BootstrapHooks {
20
21
  onDispose?: () => void;
@@ -60,7 +61,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
60
61
  console.error(`[pi-herdr] cordis loader degraded (bare tree): ${err instanceof Error ? err.message : String(err)}`);
61
62
  }
62
63
 
63
- if (loaderReady && detectExposeInternals() && process.env.PI_HERDR_HMR === '1') {
64
+ // B10: canonical PIER_HMR, legacy PI_HERDR_HMR.
65
+ if (loaderReady && detectExposeInternals() && pierOption('PIER_HMR') === '1') {
64
66
  try {
65
67
  const withLoader = root as Context & {
66
68
  loader?: { create: (o: { name: string; config?: unknown }) => Promise<unknown> };
@@ -78,7 +80,8 @@ export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<Cordi
78
80
  console.error('[pi-herdr] hmr active (dev posture: --expose-internals + PI_HERDR_HMR=1)');
79
81
  // D80⑤: HMR already disposes old fiber effects; this hook additionally retires old pi-surface generations.
80
82
  // d87 keeps generations registered after the reload boundary alive; disposeKey retires only older entries.
81
- root.on('hmr/reload', (reloads: unknown) => {
83
+ // Cordis' own HMR boundary event (not part of pi's typed Events map).
84
+ (root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
82
85
  try {
83
86
  const files: string[] = [];
84
87
  for (const r of (reloads as Map<unknown, { filename?: string }>).values()) {