@rmrdeveloper/sideroom-pi 5.0.0 → 6.0.1

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 (33) hide show
  1. package/README.md +108 -79
  2. package/{src/assets → assets}/artifacts/GUIDELINES_TEMPLATE.md +40 -11
  3. package/extensions/ask/execute.ts +56 -0
  4. package/extensions/ask/index.ts +77 -0
  5. package/extensions/ask/model.ts +196 -0
  6. package/extensions/ask/ui.ts +445 -0
  7. package/extensions/modified-files/index.ts +45 -0
  8. package/extensions/modified-files/model.ts +74 -0
  9. package/extensions/modified-files/session.ts +200 -0
  10. package/extensions/modified-files/ui.ts +207 -0
  11. package/extensions/todo/execute.ts +69 -0
  12. package/extensions/todo/guards.ts +107 -0
  13. package/extensions/todo/index.ts +91 -0
  14. package/extensions/todo/model.ts +327 -0
  15. package/extensions/todo/session.ts +166 -0
  16. package/extensions/todo/ui.ts +41 -0
  17. package/package.json +26 -27
  18. package/dist/pi-extension.js +0 -1175
  19. package/skills/sideroom-critic/SKILL.md +0 -32
  20. package/skills/sideroom-grilling/SKILL.md +0 -81
  21. package/skills/sideroom-spec/SKILL.md +0 -38
  22. package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
  23. package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
  24. package/src/assets/agents/sideroom-code-reviewer.md +0 -30
  25. package/src/assets/agents/sideroom-fixer.md +0 -37
  26. package/src/assets/agents/sideroom-implementer.md +0 -39
  27. package/src/assets/agents/sideroom-planner.md +0 -31
  28. package/src/assets/agents/sideroom-verifier.md +0 -32
  29. package/src/assets/artifacts/guidelines/java.md +0 -211
  30. package/src/assets/artifacts/guidelines/javascript.md +0 -235
  31. package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
  32. package/src/assets/artifacts/guidelines/python.md +0 -227
  33. package/src/assets/artifacts/guidelines/typescript.md +0 -231
package/README.md CHANGED
@@ -1,97 +1,116 @@
1
1
  # Sideroom Pi
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@rmrdeveloper/sideroom-pi?label=npm&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
4
+ [![npm monthly downloads](https://img.shields.io/npm/dm/@rmrdeveloper/sideroom-pi?label=downloads&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
5
+ [![npm license](https://img.shields.io/npm/l/@rmrdeveloper/sideroom-pi?label=license)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
6
+
3
7
  Sideroom Pi is a global [Pi package](https://pi.dev/docs/latest/packages). It
4
- registers the `/sideroom` command inside Pi and runs planner, implementer,
5
- reviewer, verifier, and fixer as isolated Pi SDK sessions.
8
+ registers two parent-agent tools: `sideroom_ask`, a TUI questionnaire for
9
+ clarifying decisions, and `sideroom_todo`, a live work board shown above the
10
+ editor. It also shows a session-scoped list of files successfully edited by Pi.
11
+ It writes no `.pi` configuration, task graph, or other Sideroom files into the
12
+ repository you are working in.
6
13
 
7
- It creates no `.pi` configuration, task graph, harness state, or other files
8
- in the repository being changed.
14
+ ## Quick path
9
15
 
10
- ## Install globally in Pi
16
+ 1. Install globally from npm. Pi writes this installation to your user
17
+ settings, so do not use `-l`.
11
18
 
12
- Build the package, then let Pi install it globally. Do not use `-l`: that
13
- would create project-local configuration.
19
+ ```bash
20
+ pi install npm:@rmrdeveloper/sideroom-pi
21
+ ```
14
22
 
15
- ```bash
16
- npm install
17
- npm run build
18
- pi install /absolute/path/to/sideroom-pi
19
- ```
23
+ 2. Start Pi in the repository you want to work in.
24
+ 3. Ask the parent agent a question that needs a decision or a visible work
25
+ board. It can call `sideroom_ask` or `sideroom_todo` in the TUI.
20
26
 
21
- For a published release:
27
+ For a reproducible environment, pin a release. A pinned version does not move
28
+ when you run Pi's package update command.
22
29
 
23
30
  ```bash
24
- pi install npm:@rmrdeveloper/sideroom-pi@5.0.0
31
+ pi install npm:@rmrdeveloper/sideroom-pi@7.0.0
25
32
  ```
26
33
 
27
- Start Pi from the repository you want to work in, then invoke the extension:
34
+ To update an unpinned installation:
28
35
 
29
- ```text
30
- /sideroom "Add a health endpoint"
31
- /sideroom --language php-laravel "Add invoice export"
32
- /sideroom --read-only "Review the retry behavior"
36
+ ```bash
37
+ pi update --extension npm:@rmrdeveloper/sideroom-pi
33
38
  ```
34
39
 
35
- Pi extension commands are necessarily slash commands, so `/sideroom` is the
36
- single command; there is no subcommand or separate binary to run. When the
37
- request is omitted, Pi asks for it using its own UI.
38
-
39
- Supported guideline variants are `typescript` (default), `javascript`,
40
- `php-laravel`, `python`, and `java`. `--read-only` removes write-capable tools
41
- from every role. `--max-fix-passes <number>` changes the default limit of two
42
- repair passes.
43
-
44
- ## Provenance and isolation
45
-
46
- `/sideroom` is dispatched to the extension before Pi expands skills or sends
47
- the command to the interactive agent. The extension then creates direct,
48
- in-memory SDK sessions for the five Sideroom roles. Those child sessions load
49
- only this package's skills and explicit policy; global Pi skills, extensions,
50
- prompt templates, and global context files are excluded.
51
-
52
- At the end of every run, Pi displays a `Sideroom completed` or `Sideroom
53
- failed` message with its provenance and the completed roles. It also records
54
- the same trace in Pi's session history as `sideroom:run`; this is Pi session
55
- metadata, never a file in the target repository.
56
-
57
- The packaged `sideroom-grilling` skill runs as the design-decision gate. Its
58
- questions use Pi's native decision UI. During a run, a persistent Pi widget
59
- shows the active phase, the waiting-for-answer state, and the direct-SDK
60
- provenance. Selecting a recommendation accepts it; selecting the alternate
61
- option opens a custom-answer field. The widget is removed automatically when a
62
- run completes or fails; the final Pi session message remains as the durable
63
- record. It is not a request to any global skill with the same name.
64
-
65
- ## Content and guidelines
66
-
67
- The five role prompts are in `src/assets/agents/`. The shared policy is
68
- `src/assets/artifacts/GUIDELINES_TEMPLATE.md`; the language layers live in
69
- `src/assets/artifacts/guidelines/`. Implementer and fixer receive those
70
- policies before their write gate and must read them before every code-writing
71
- tool call.
72
-
73
- Reusable Pi skills ship in `skills/`: `sideroom-grilling`, `sideroom-critic`,
74
- `sideroom-spec`, and `sideroom-transcribe-audio`. Pi loads them directly through
75
- the package manifest.
76
- The transcription skill needs `uv`, Python, and `ffmpeg` only when explicitly
77
- used.
40
+ ## Details
41
+
42
+ | Topic | Decision |
43
+ | --- | --- |
44
+ | Surface | Two LLM-callable tools: `sideroom_ask` and `sideroom_todo`. No `/sideroom` command. |
45
+ | Questionnaire | One `sideroom_ask` call is one 1–N question batch; every question has `recommendationIndex`. |
46
+ | Always-on rows | The questionnaire UI adds Out of scope and a custom answer. Do not send those options. |
47
+ | Work board | `sideroom_todo propose` replaces a visible board; `update` patches items by id. |
48
+ | Headless | `sideroom_todo update` works in print, JSON, and RPC. `propose` and `sideroom_ask` return `Error: UI not available` there. |
49
+ | Edited files | Successful `write` and `edit` results appear below an active work board; `F8` opens the full list. |
50
+ | Session state | The work board and edited-file history live in the Pi session branch, not in the target repository. |
51
+ | Seed artifact | `assets/artifacts/GUIDELINES_TEMPLATE.md` ships unwired. |
52
+
53
+ ## Tool
54
+
55
+ `sideroom_ask` follows Pi's questionnaire UI: a simple list for one question,
56
+ tabs plus Submit for several. English copy. Each question needs:
57
+
58
+ - `id`
59
+ - `prompt`
60
+ - at least two `{ value, label, description? }` options
61
+ - `recommendationIndex` pointing at the recommended option
62
+ - optional `label` for the tab bar (defaults to `Q1`, `Q2`, …)
63
+
64
+ The recommended option is marked in the list. Escape cancels the batch.
65
+
66
+ ## Work board
67
+
68
+ `sideroom_todo` keeps an ordered, display-only board above the editor. The
69
+ agent maintains it and the user steers through chat. It never creates a
70
+ repository task file or a `/todos` command.
71
+
72
+ - `propose` replaces the full board and is available only in the interactive
73
+ TUI.
74
+ - `update` applies one or more `{ id, status?, content? }` patches and works in
75
+ every Pi mode.
76
+ - Items have stable string ids, short content, and one of `pending`,
77
+ `in_progress`, `completed`, or `cancelled`.
78
+ - While items are pending, exactly one item must be `in_progress`. Complete the
79
+ current item and start the next one in the same update.
80
+ - Snapshots are stored in the active session branch. The board is rebuilt after
81
+ session navigation and compaction.
82
+
83
+ ## Edited files
84
+
85
+ The package automatically records successful Pi `write` and `edit` calls for
86
+ the active session. It does not infer filesystem changes from shell commands,
87
+ Git, people, or subagents. The compact list is shown below the work board when
88
+ one is active and contains at most five recent paths.
89
+
90
+ - `F8` opens or closes the extended, scrollable list; press `R` there
91
+ to clear the current session's history.
92
+ - Every path is an OSC 8 `file://` link. Your terminal determines whether that
93
+ is a click, Ctrl+click, or another modifier-assisted action.
94
+ - Paths outside the active project are labelled `external`.
95
+ - A new session starts with a new list; the current session's history survives
96
+ reload, tree navigation, and compaction.
78
97
 
79
98
  ## Development
80
99
 
81
100
  ```bash
82
101
  npm install
83
102
  npm run check
84
- npm run build
85
103
  ```
86
104
 
87
- Biome is the sole formatter and linter. Runtime code uses Pi's SDK only; Pi
88
- provides `@earendil-works/pi-coding-agent` as a peer dependency.
105
+ Biome is the sole formatter and linter. Pi loads TypeScript from `extensions/`
106
+ directly; do not bundle. Pi provides `@earendil-works/pi-coding-agent`,
107
+ `@earendil-works/pi-tui`, and `typebox` as peer dependencies.
89
108
 
90
109
  ## Releases and commits
91
110
 
92
111
  Commit messages must follow the Conventional Commits format. The existing
93
112
  Husky `commit-msg` hook validates each local commit with Commitlint, for
94
- example `feat: add a pipeline summary` or `fix: validate malformed findings`.
113
+ example `feat: mark the recommended sideroom_ask option`.
95
114
 
96
115
  Every user-visible package change needs a Changeset. Create one with:
97
116
 
@@ -99,21 +118,31 @@ Every user-visible package change needs a Changeset. Create one with:
99
118
  npm run changeset
100
119
  ```
101
120
 
102
- Review pending release work with `npm run changeset:status`. On the release
103
- branch, run `npm run version-packages`, review and commit the updated
104
- `package.json`, `package-lock.json`, and `CHANGELOG.md`, then publish
105
- deliberately with `npm run release`. The repository does not publish
106
- automatically from CI.
121
+ Review pending release work with `npm run changeset:status`. When Changesets
122
+ are merged to `main`, the `Release` workflow creates or updates a reviewable
123
+ Changesets version PR. That PR contains the generated `package.json`,
124
+ `package-lock.json`, and `CHANGELOG.md` updates. Merge the version PR only after
125
+ review; its merge runs `npm ci` and `npm run check`, then uses
126
+ npm 11.5.1 and npm Trusted Publishing (GitHub OIDC) to publish. Changesets then
127
+ pushes the release Git tag and creates the GitHub Release automatically.
128
+
129
+ Never run `npm publish`, create release tags, or create GitHub Releases locally.
130
+ Publishing is allowed only through the merged Changesets version PR and the
131
+ `Release` workflow. The npm package's Trusted Publisher must be configured for
132
+ this GitHub repository, the workflow filename `release.yml` (not its path),
133
+ and the `npm` environment; configure the matching GitHub `npm` environment
134
+ before the first release.
107
135
 
108
136
  ## Layout
109
137
 
110
138
  ```text
111
- src/
112
- pi-extension.ts Pi extension entry point for /sideroom
113
- pi-command.ts slash-command parsing
114
- app.ts Pi pipeline assembly
115
- core/ orchestration, roles, contracts, and content catalog
116
- runtimes/pi.ts direct Pi SDK adapter for isolated child sessions
117
- assets/ role prompts and coding guidelines
118
- skills/ package-provided Pi skills
139
+ extensions/
140
+ ask/ sideroom_ask (index.ts, execute.ts, model.ts, ui.ts)
141
+ todo/ sideroom_todo (index.ts, execute.ts, session.ts,
142
+ guards.ts, model.ts, ui.ts)
143
+ modified-files/ session-backed edited-file tracker and view
144
+ assets/artifacts/ unwired GUIDELINES_TEMPLATE.md seed
119
145
  ```
146
+
147
+ Add another tool as `extensions/<name>/index.ts`. Pi discovers
148
+ `extensions/*/index.ts`; helpers in that folder are not loaded as extensions.
@@ -8,6 +8,7 @@ Each row is expanded, with examples, in the matching section below.
8
8
 
9
9
  | Do | Don't |
10
10
  | -------------------------------------------------- | ------------------------------------------------------------------------------------------- |
11
+ | Use braces around every `if` body | Write an unbraced, single-statement conditional body |
11
12
  | Early return on bad input | Pyramid `if/else` nesting |
12
13
  | Explicit error, fail now | Multiple fallbacks that hide the real failure |
13
14
  | One responsibility per unit | Validate + transform + persist + notify in one place |
@@ -55,13 +56,26 @@ function getDiscount(user) {
55
56
 
56
57
  // Good — guard clauses, happy path at the end, shallow indent
57
58
  function getDiscount(user) {
58
- if (!user) return 0;
59
- if (!user.isActive) return 0;
60
- if (!user.hasSubscription) return 0;
59
+ if (!user) {
60
+ return 0;
61
+ }
62
+ if (!user.isActive) {
63
+ return 0;
64
+ }
65
+ if (!user.hasSubscription) {
66
+ return 0;
67
+ }
61
68
  return 0.2;
62
69
  }
63
70
  ```
64
71
 
72
+ ### Braced conditionals
73
+
74
+ Every `if` and `else` body must use braces in languages that support them,
75
+ including a one-line guard clause. Never rely on indentation alone to bind a
76
+ statement to a conditional. In Python, which has no braces, use a complete
77
+ indented suite and never a one-line conditional body.
78
+
65
79
  ### Fail fast
66
80
 
67
81
  Invalid input, impossible state, or a broken dependency should fail immediately with a clear error. Prefer that over chains of fallbacks, silent defaults, or "keep going somehow."
@@ -78,7 +92,9 @@ function getShippingCost(order) {
78
92
  // Good — fails immediately with a clear error
79
93
  function getShippingCost(order) {
80
94
  const zone = ZONES[order.zoneId];
81
- if (!zone) throw new Error(`Unknown zoneId: ${order.zoneId}`);
95
+ if (!zone) {
96
+ throw new Error(`Unknown zoneId: ${order.zoneId}`);
97
+ }
82
98
  return zone.baseCost;
83
99
  }
84
100
  ```
@@ -90,7 +106,9 @@ A function, class, or module has one reason to change. If it does two jobs, spli
90
106
  ```js
91
107
  // Bad — validates, transforms, persists, and notifies all in one place
92
108
  async function saveUser(data) {
93
- if (!data.email) throw new Error('email required');
109
+ if (!data.email) {
110
+ throw new Error('email required');
111
+ }
94
112
  const normalized = { ...data, email: data.email.trim().toLowerCase() };
95
113
  await db.users.insert(normalized);
96
114
  await mailer.send(normalized.email, 'welcome');
@@ -98,7 +116,9 @@ async function saveUser(data) {
98
116
 
99
117
  // Good — each unit has a single reason to change
100
118
  function validateUser(data) {
101
- if (!data.email) throw new Error('email required');
119
+ if (!data.email) {
120
+ throw new Error('email required');
121
+ }
102
122
  }
103
123
  function normalizeUser(data) {
104
124
  return { ...data, email: data.email.trim().toLowerCase() };
@@ -284,14 +304,22 @@ Pick one convention and apply it consistently: e.g. `undefined` for "not yet set
284
304
  ```js
285
305
  // Bad — null is overloaded to mean three different things
286
306
  function getDiscount(user) {
287
- if (!user) return null; // no user
288
- if (!user.plan) return null; // no plan
289
- if (user.plan.discount === 0) return null; // legitimately zero discount
307
+ if (!user) {
308
+ return null; // no user
309
+ }
310
+ if (!user.plan) {
311
+ return null; // no plan
312
+ }
313
+ if (user.plan.discount === 0) {
314
+ return null; // legitimately zero discount
315
+ }
290
316
  }
291
317
 
292
318
  // Good — each case is explicit, zero is a real value
293
319
  function getDiscount(user) {
294
- if (!user || !user.plan) return 0;
320
+ if (!user || !user.plan) {
321
+ return 0;
322
+ }
295
323
  return user.plan.discount;
296
324
  }
297
325
  ```
@@ -439,7 +467,8 @@ class UpdateProfileRequest extends FormRequest {
439
467
  }
440
468
  class ProfileService {
441
469
  public function update(array $data) {
442
- if (empty($data['email'])) { // redundant re-validation
470
+ // Redundant re-validation.
471
+ if (empty($data['email'])) {
443
472
  throw new \InvalidArgumentException('email required');
444
473
  }
445
474
  $this->user->update($data);
@@ -0,0 +1,56 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ type AskParams,
4
+ type AskQuestion,
5
+ type AskResult,
6
+ formatAnswerLines,
7
+ parseAskParams,
8
+ UI_UNAVAILABLE,
9
+ } from './model.ts';
10
+ import { runAskUi } from './ui.ts';
11
+
12
+ interface AskToolResult {
13
+ readonly content: [{ readonly type: 'text'; readonly text: string }];
14
+ readonly details: AskResult;
15
+ }
16
+
17
+ export async function executeAsk(
18
+ params: AskParams,
19
+ ctx: Pick<ExtensionContext, 'mode' | 'ui'>,
20
+ ): Promise<AskToolResult> {
21
+ const parsed = parseAskParams(params);
22
+ if (!parsed.ok) {
23
+ return cancelledResult(parsed.message);
24
+ }
25
+ if (ctx.mode !== 'tui') {
26
+ return cancelledResult(UI_UNAVAILABLE, parsed.questions);
27
+ }
28
+
29
+ const result = await runAskUi(ctx.ui, parsed.questions);
30
+ if (result.cancelled) {
31
+ return {
32
+ content: [{ type: 'text', text: 'User cancelled the questionnaire' }],
33
+ details: result,
34
+ };
35
+ }
36
+
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: formatAnswerLines(parsed.questions, result.answers).join('\n'),
42
+ },
43
+ ],
44
+ details: result,
45
+ };
46
+ }
47
+
48
+ function cancelledResult(
49
+ message: string,
50
+ questions: readonly AskQuestion[] = [],
51
+ ): AskToolResult {
52
+ return {
53
+ content: [{ type: 'text', text: message }],
54
+ details: { questions, answers: [], cancelled: true },
55
+ };
56
+ }
@@ -0,0 +1,77 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { Text } from '@earendil-works/pi-tui';
3
+ import { executeAsk } from './execute.ts';
4
+ import { AskParamsSchema, type AskResult, TOOL_NAME } from './model.ts';
5
+
6
+ export const ASK_DESCRIPTION =
7
+ 'Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
8
+
9
+ export const ASK_PROMPT_SNIPPET =
10
+ 'Ask the user one or more questions with a recommended option.';
11
+
12
+ export const ASK_PROMPT_GUIDELINES = [
13
+ 'Use sideroom_ask to clarify requirements, preferences, or decisions with the user.',
14
+ 'Each sideroom_ask question needs an id, a prompt, at least two options, and a recommendationIndex pointing at the recommended option.',
15
+ 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself.',
16
+ 'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
17
+ 'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
18
+ ];
19
+
20
+ // Pi loads extensions/*/index.ts through export default.
21
+ export default function registerAsk(pi: ExtensionAPI): void {
22
+ pi.registerTool({
23
+ name: TOOL_NAME,
24
+ label: 'Sideroom Ask',
25
+ description: ASK_DESCRIPTION,
26
+ promptSnippet: ASK_PROMPT_SNIPPET,
27
+ promptGuidelines: ASK_PROMPT_GUIDELINES,
28
+ parameters: AskParamsSchema,
29
+ executionMode: 'sequential',
30
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) =>
31
+ executeAsk(params, ctx),
32
+ renderCall(args, theme) {
33
+ const questions = args.questions;
34
+ const count = questions.length;
35
+ const labels = questions
36
+ .map(
37
+ (question, index) =>
38
+ question.label || question.id || `Q${String(index + 1)}`,
39
+ )
40
+ .join(', ');
41
+ let text = theme.fg('toolTitle', theme.bold(`${TOOL_NAME} `));
42
+ text += theme.fg(
43
+ 'muted',
44
+ `${String(count)} question${count !== 1 ? 's' : ''}`,
45
+ );
46
+ if (labels.length > 0) {
47
+ text += theme.fg('dim', ` (${labels})`);
48
+ }
49
+ return new Text(text, 0, 0);
50
+ },
51
+ renderResult(result, _options, theme) {
52
+ const details = result.details as AskResult | undefined;
53
+ if (details === undefined) {
54
+ const text = result.content[0];
55
+ return new Text(text?.type === 'text' ? text.text : '', 0, 0);
56
+ }
57
+ if (details.cancelled) {
58
+ return new Text(theme.fg('warning', 'Cancelled'), 0, 0);
59
+ }
60
+ const lines = details.answers.map((answer) => {
61
+ const mark = theme.fg('success', '✓ ');
62
+ const id = theme.fg('accent', answer.id);
63
+ if (answer.outOfScope) {
64
+ return `${mark}${id}: ${theme.fg('muted', '(out of scope)')}`;
65
+ }
66
+ if (answer.wasCustom) {
67
+ return `${mark}${id}: ${theme.fg('muted', '(wrote) ')}${answer.label}`;
68
+ }
69
+ if (answer.index !== undefined) {
70
+ return `${mark}${id}: ${String(answer.index)}. ${answer.label}`;
71
+ }
72
+ return `${mark}${id}: ${answer.label}`;
73
+ });
74
+ return new Text(lines.join('\n'), 0, 0);
75
+ },
76
+ });
77
+ }
@@ -0,0 +1,196 @@
1
+ import { type Static, Type } from 'typebox';
2
+ import { Check, Errors } from 'typebox/value';
3
+
4
+ export const TOOL_NAME = 'sideroom_ask';
5
+ export const OUT_OF_SCOPE_VALUE = 'Out of scope';
6
+ export const OUT_OF_SCOPE_LABEL = 'Out of scope';
7
+ export const CUSTOM_VALUE = '__other__';
8
+ export const CUSTOM_LABEL = 'Write a custom answer';
9
+ export const UI_UNAVAILABLE =
10
+ 'Error: UI not available (running in non-interactive mode)';
11
+
12
+ const QuestionOptionSchema = Type.Object({
13
+ value: Type.String({ description: 'The value returned when selected' }),
14
+ label: Type.String({ description: 'Display label for the option' }),
15
+ description: Type.Optional(
16
+ Type.String({ description: 'Optional description shown below the label' }),
17
+ ),
18
+ });
19
+
20
+ const QuestionSchema = Type.Object({
21
+ id: Type.String({ description: 'Unique identifier for this question' }),
22
+ label: Type.Optional(
23
+ Type.String({
24
+ description:
25
+ "Short contextual label for the tab bar, e.g. 'Scope' (defaults to Q1, Q2)",
26
+ }),
27
+ ),
28
+ prompt: Type.String({ description: 'The full question text to display' }),
29
+ options: Type.Array(QuestionOptionSchema, {
30
+ minItems: 2,
31
+ description: 'At least two mutually exclusive options',
32
+ }),
33
+ recommendationIndex: Type.Integer({
34
+ minimum: 0,
35
+ description: 'Index into options pointing at the recommended option',
36
+ }),
37
+ });
38
+
39
+ export const AskParamsSchema = Type.Object({
40
+ questions: Type.Array(QuestionSchema, {
41
+ minItems: 1,
42
+ description: 'Questions to ask the user in this batch',
43
+ }),
44
+ });
45
+
46
+ export type AskParams = Static<typeof AskParamsSchema>;
47
+ export type QuestionOption = AskParams['questions'][number]['options'][number];
48
+
49
+ export interface AskQuestion {
50
+ readonly id: string;
51
+ readonly label: string;
52
+ readonly prompt: string;
53
+ readonly options: readonly QuestionOption[];
54
+ readonly recommendationIndex: number;
55
+ }
56
+
57
+ export interface AskAnswer {
58
+ readonly id: string;
59
+ readonly value: string;
60
+ readonly label: string;
61
+ readonly wasCustom: boolean;
62
+ readonly outOfScope: boolean;
63
+ readonly index?: number;
64
+ }
65
+
66
+ export interface AskResult {
67
+ readonly questions: readonly AskQuestion[];
68
+ readonly answers: readonly AskAnswer[];
69
+ readonly cancelled: boolean;
70
+ }
71
+
72
+ export type RenderOption = QuestionOption & {
73
+ readonly isOther?: boolean;
74
+ readonly isOutOfScope?: boolean;
75
+ readonly isRecommended?: boolean;
76
+ };
77
+
78
+ export type ParseAskResult =
79
+ | { readonly ok: true; readonly questions: readonly AskQuestion[] }
80
+ | { readonly ok: false; readonly message: string };
81
+
82
+ export function parseAskParams(value: unknown): ParseAskResult {
83
+ if (!Check(AskParamsSchema, value)) {
84
+ return { ok: false, message: schemaErrorMessage(value) };
85
+ }
86
+
87
+ const seen = new Set<string>();
88
+ for (const question of value.questions) {
89
+ if (seen.has(question.id)) {
90
+ return {
91
+ ok: false,
92
+ message: `Error: Duplicate question id: ${question.id}`,
93
+ };
94
+ }
95
+ seen.add(question.id);
96
+ if (question.recommendationIndex >= question.options.length) {
97
+ return {
98
+ ok: false,
99
+ message: `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`,
100
+ };
101
+ }
102
+ }
103
+
104
+ return { ok: true, questions: normalizeQuestions(value.questions) };
105
+ }
106
+
107
+ export function normalizeQuestions(
108
+ questions: AskParams['questions'],
109
+ ): AskQuestion[] {
110
+ return questions.map((question, index) => {
111
+ const label = question.label;
112
+ return {
113
+ id: question.id,
114
+ label:
115
+ label !== undefined && label.trim().length > 0
116
+ ? label
117
+ : `Q${String(index + 1)}`,
118
+ prompt: question.prompt,
119
+ options: question.options,
120
+ recommendationIndex: question.recommendationIndex,
121
+ };
122
+ });
123
+ }
124
+
125
+ export function renderOptions(question: AskQuestion): RenderOption[] {
126
+ const options: RenderOption[] = question.options.map((option, index) => ({
127
+ ...option,
128
+ isRecommended: index === question.recommendationIndex,
129
+ }));
130
+ options.push({
131
+ value: OUT_OF_SCOPE_VALUE,
132
+ label: OUT_OF_SCOPE_LABEL,
133
+ isOutOfScope: true,
134
+ });
135
+ options.push({
136
+ value: CUSTOM_VALUE,
137
+ label: CUSTOM_LABEL,
138
+ isOther: true,
139
+ });
140
+ return options;
141
+ }
142
+
143
+ export function formatAnswerLines(
144
+ questions: readonly AskQuestion[],
145
+ answers: readonly AskAnswer[],
146
+ ): string[] {
147
+ return answers.map((answer) => {
148
+ const question = questions.find((entry) => entry.id === answer.id);
149
+ const questionLabel = question?.label ?? answer.id;
150
+ if (answer.outOfScope) {
151
+ return `${questionLabel}: Out of scope`;
152
+ }
153
+ if (answer.wasCustom) {
154
+ return `${questionLabel}: user wrote: ${answer.label}`;
155
+ }
156
+ if (answer.index !== undefined) {
157
+ return `${questionLabel}: user selected: ${String(answer.index)}. ${answer.label}`;
158
+ }
159
+ return `${questionLabel}: user selected: ${answer.label}`;
160
+ });
161
+ }
162
+
163
+ function schemaErrorMessage(value: unknown): string {
164
+ const [error] = Errors(AskParamsSchema, value);
165
+ if (error === undefined) {
166
+ return 'Error: Invalid sideroom_ask parameters';
167
+ }
168
+ if (error.keyword === 'minItems' && error.instancePath === '/questions') {
169
+ return 'Error: No questions provided';
170
+ }
171
+ const optionsMatch = /\/questions\/(\d+)\/options$/.exec(error.instancePath);
172
+ if (error.keyword === 'minItems' && optionsMatch !== null) {
173
+ const questionIndex = Number(optionsMatch[1]);
174
+ const questions = asQuestions(value);
175
+ const question = questions[questionIndex];
176
+ const id =
177
+ question !== undefined && typeof question.id === 'string'
178
+ ? question.id
179
+ : String(questionIndex);
180
+ return `Error: Question '${id}' must include at least two options`;
181
+ }
182
+ return `Error: Invalid sideroom_ask parameters`;
183
+ }
184
+
185
+ function asQuestions(value: unknown): readonly Record<string, unknown>[] {
186
+ if (typeof value !== 'object' || value === null) {
187
+ return [];
188
+ }
189
+ if (!('questions' in value) || !Array.isArray(value.questions)) {
190
+ return [];
191
+ }
192
+ return value.questions.filter(
193
+ (question): question is Record<string, unknown> =>
194
+ typeof question === 'object' && question !== null,
195
+ );
196
+ }