@rmrdeveloper/sideroom-pi 5.0.1 → 7.0.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 (42) hide show
  1. package/README.md +115 -81
  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 +78 -0
  5. package/extensions/ask/model.ts +196 -0
  6. package/extensions/ask/ui.ts +445 -0
  7. package/extensions/guidelines/index.ts +13 -0
  8. package/extensions/guidelines/prompt.ts +26 -0
  9. package/extensions/modified-files/index.ts +45 -0
  10. package/extensions/modified-files/model.ts +74 -0
  11. package/extensions/modified-files/session.ts +200 -0
  12. package/extensions/modified-files/ui.ts +207 -0
  13. package/extensions/todo/execute.ts +69 -0
  14. package/extensions/todo/guards.ts +107 -0
  15. package/extensions/todo/index.ts +91 -0
  16. package/extensions/todo/model.ts +327 -0
  17. package/extensions/todo/session.ts +166 -0
  18. package/extensions/todo/ui.ts +41 -0
  19. package/package.json +28 -24
  20. package/skills/sideroom-guidelines/SKILL.md +77 -0
  21. package/skills/sideroom-guidelines/references/languages/go.md +27 -0
  22. package/skills/sideroom-guidelines/references/languages/java.md +24 -0
  23. package/skills/sideroom-guidelines/references/languages/php-laravel.md +26 -0
  24. package/skills/sideroom-guidelines/references/languages/python.md +21 -0
  25. package/skills/sideroom-guidelines/references/languages/rust.md +19 -0
  26. package/skills/sideroom-guidelines/references/languages/typescript.md +30 -0
  27. package/dist/pi-extension.js +0 -1175
  28. package/skills/sideroom-critic/SKILL.md +0 -32
  29. package/skills/sideroom-grilling/SKILL.md +0 -81
  30. package/skills/sideroom-spec/SKILL.md +0 -38
  31. package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
  32. package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
  33. package/src/assets/agents/sideroom-code-reviewer.md +0 -30
  34. package/src/assets/agents/sideroom-fixer.md +0 -37
  35. package/src/assets/agents/sideroom-implementer.md +0 -39
  36. package/src/assets/agents/sideroom-planner.md +0 -31
  37. package/src/assets/agents/sideroom-verifier.md +0 -32
  38. package/src/assets/artifacts/guidelines/java.md +0 -211
  39. package/src/assets/artifacts/guidelines/javascript.md +0 -235
  40. package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
  41. package/src/assets/artifacts/guidelines/python.md +0 -227
  42. package/src/assets/artifacts/guidelines/typescript.md +0 -231
@@ -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
+ }
@@ -0,0 +1,445 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ Editor,
4
+ type EditorTheme,
5
+ Key,
6
+ matchesKey,
7
+ visibleWidth,
8
+ wrapTextWithAnsi,
9
+ } from '@earendil-works/pi-tui';
10
+ import {
11
+ type AskAnswer,
12
+ type AskQuestion,
13
+ type AskResult,
14
+ type RenderOption,
15
+ renderOptions,
16
+ } from './model.ts';
17
+
18
+ export async function runAskUi(
19
+ ui: Pick<ExtensionUIContext, 'custom'>,
20
+ questions: readonly AskQuestion[],
21
+ ): Promise<AskResult> {
22
+ const isMulti = questions.length > 1;
23
+ const totalTabs = questions.length + 1;
24
+
25
+ return ui.custom<AskResult>((tui, theme, _keybindings, done) => {
26
+ let currentTab = 0;
27
+ const firstQuestion = questions[0];
28
+ let optionIndex =
29
+ firstQuestion === undefined ? 0 : firstQuestion.recommendationIndex;
30
+ let inputMode = false;
31
+ let inputQuestionId: string | null = null;
32
+ let cachedLines: string[] | undefined;
33
+ const answers = new Map<string, AskAnswer>();
34
+
35
+ const editorTheme: EditorTheme = {
36
+ borderColor: (s) => theme.fg('accent', s),
37
+ selectList: {
38
+ selectedPrefix: (t) => theme.fg('accent', t),
39
+ selectedText: (t) => theme.fg('accent', t),
40
+ description: (t) => theme.fg('muted', t),
41
+ scrollInfo: (t) => theme.fg('dim', t),
42
+ noMatch: (t) => theme.fg('warning', t),
43
+ },
44
+ };
45
+ const editor = new Editor(tui, editorTheme);
46
+
47
+ function refresh(): void {
48
+ cachedLines = undefined;
49
+ tui.requestRender();
50
+ }
51
+
52
+ function submit(cancelled: boolean): void {
53
+ done({
54
+ questions,
55
+ answers: Array.from(answers.values()),
56
+ cancelled,
57
+ });
58
+ }
59
+
60
+ function currentQuestion(): AskQuestion | undefined {
61
+ return questions[currentTab];
62
+ }
63
+
64
+ function currentOptions(): RenderOption[] {
65
+ const question = currentQuestion();
66
+ if (question === undefined) {
67
+ return [];
68
+ }
69
+ return renderOptions(question);
70
+ }
71
+
72
+ function allAnswered(): boolean {
73
+ return questions.every((question) => answers.has(question.id));
74
+ }
75
+
76
+ function focusTab(tab: number): void {
77
+ currentTab = tab;
78
+ const question = questions[tab];
79
+ if (question === undefined) {
80
+ optionIndex = 0;
81
+ refresh();
82
+ return;
83
+ }
84
+ const saved = answers.get(question.id);
85
+ if (saved?.index !== undefined) {
86
+ optionIndex = saved.index - 1;
87
+ } else {
88
+ optionIndex = question.recommendationIndex;
89
+ }
90
+ refresh();
91
+ }
92
+
93
+ function advanceAfterAnswer(): void {
94
+ if (!isMulti) {
95
+ submit(false);
96
+ return;
97
+ }
98
+ if (currentTab < questions.length - 1) {
99
+ focusTab(currentTab + 1);
100
+ return;
101
+ }
102
+ focusTab(questions.length);
103
+ }
104
+
105
+ function saveAnswer(answer: AskAnswer): void {
106
+ answers.set(answer.id, answer);
107
+ }
108
+
109
+ function openCustomEditor(question: AskQuestion): void {
110
+ inputMode = true;
111
+ inputQuestionId = question.id;
112
+ const saved = answers.get(question.id);
113
+ if (saved?.wasCustom === true) {
114
+ editor.setText(saved.label);
115
+ } else {
116
+ editor.setText('');
117
+ }
118
+ refresh();
119
+ }
120
+
121
+ function confirmOption(
122
+ question: AskQuestion,
123
+ opts: readonly RenderOption[],
124
+ index: number,
125
+ ): void {
126
+ const opt = opts[index];
127
+ if (opt === undefined) {
128
+ return;
129
+ }
130
+ if (opt.isOther === true) {
131
+ openCustomEditor(question);
132
+ return;
133
+ }
134
+ if (opt.isOutOfScope === true) {
135
+ saveAnswer({
136
+ id: question.id,
137
+ value: opt.value,
138
+ label: opt.label,
139
+ wasCustom: false,
140
+ outOfScope: true,
141
+ index: index + 1,
142
+ });
143
+ advanceAfterAnswer();
144
+ return;
145
+ }
146
+ const original = question.options[index];
147
+ saveAnswer({
148
+ id: question.id,
149
+ value: original?.value ?? opt.value,
150
+ label: original?.label ?? opt.label,
151
+ wasCustom: false,
152
+ outOfScope: false,
153
+ index: index + 1,
154
+ });
155
+ advanceAfterAnswer();
156
+ }
157
+
158
+ editor.onSubmit = (value) => {
159
+ if (inputQuestionId === null) {
160
+ return;
161
+ }
162
+ const trimmed = value.trim();
163
+ if (trimmed.length === 0) {
164
+ inputMode = false;
165
+ inputQuestionId = null;
166
+ editor.setText('');
167
+ refresh();
168
+ return;
169
+ }
170
+ const question = questions.find((entry) => entry.id === inputQuestionId);
171
+ const displayIndex =
172
+ question === undefined ? 1 : renderOptions(question).length;
173
+ saveAnswer({
174
+ id: inputQuestionId,
175
+ value: trimmed,
176
+ label: trimmed,
177
+ wasCustom: true,
178
+ outOfScope: false,
179
+ index: displayIndex,
180
+ });
181
+ inputMode = false;
182
+ inputQuestionId = null;
183
+ editor.setText('');
184
+ advanceAfterAnswer();
185
+ };
186
+
187
+ function handleInput(data: string): void {
188
+ if (inputMode) {
189
+ if (matchesKey(data, Key.escape)) {
190
+ inputMode = false;
191
+ inputQuestionId = null;
192
+ editor.setText('');
193
+ refresh();
194
+ return;
195
+ }
196
+ editor.handleInput(data);
197
+ refresh();
198
+ return;
199
+ }
200
+
201
+ const question = currentQuestion();
202
+ const opts = currentOptions();
203
+
204
+ if (isMulti) {
205
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
206
+ focusTab((currentTab + 1) % totalTabs);
207
+ return;
208
+ }
209
+ if (matchesKey(data, Key.shift('tab')) || matchesKey(data, Key.left)) {
210
+ focusTab((currentTab - 1 + totalTabs) % totalTabs);
211
+ return;
212
+ }
213
+ }
214
+
215
+ if (currentTab === questions.length) {
216
+ if (matchesKey(data, Key.enter) && allAnswered()) {
217
+ submit(false);
218
+ } else if (matchesKey(data, Key.escape)) {
219
+ submit(true);
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (matchesKey(data, Key.up)) {
225
+ optionIndex = Math.max(0, optionIndex - 1);
226
+ refresh();
227
+ return;
228
+ }
229
+ if (matchesKey(data, Key.down)) {
230
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
231
+ refresh();
232
+ return;
233
+ }
234
+
235
+ const digit = optionIndexFromDigit(data, opts.length);
236
+ if (digit !== undefined && question !== undefined) {
237
+ optionIndex = digit;
238
+ confirmOption(question, opts, digit);
239
+ return;
240
+ }
241
+
242
+ if (matchesKey(data, Key.enter) && question !== undefined) {
243
+ confirmOption(question, opts, optionIndex);
244
+ return;
245
+ }
246
+
247
+ if (matchesKey(data, Key.escape)) {
248
+ submit(true);
249
+ }
250
+ }
251
+
252
+ function render(width: number): string[] {
253
+ if (cachedLines !== undefined) {
254
+ return cachedLines;
255
+ }
256
+
257
+ const lines: string[] = [];
258
+ const renderWidth = Math.max(1, width);
259
+ const question = currentQuestion();
260
+ const opts = currentOptions();
261
+
262
+ function addWrapped(text: string): void {
263
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
264
+ }
265
+
266
+ function addWrappedWithPrefix(prefix: string, text: string): void {
267
+ const prefixWidth = visibleWidth(prefix);
268
+ if (prefixWidth >= renderWidth) {
269
+ addWrapped(prefix + text);
270
+ return;
271
+ }
272
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
273
+ const continuationPrefix = ' '.repeat(prefixWidth);
274
+ for (let index = 0; index < wrapped.length; index += 1) {
275
+ const part = wrapped[index];
276
+ if (part === undefined) {
277
+ continue;
278
+ }
279
+ lines.push(`${index === 0 ? prefix : continuationPrefix}${part}`);
280
+ }
281
+ }
282
+
283
+ lines.push(theme.fg('accent', '─'.repeat(renderWidth)));
284
+
285
+ if (isMulti) {
286
+ const tabs: string[] = [];
287
+ for (let index = 0; index < questions.length; index += 1) {
288
+ const entry = questions[index];
289
+ if (entry === undefined) {
290
+ continue;
291
+ }
292
+ const isActive = index === currentTab;
293
+ const isAnswered = answers.has(entry.id);
294
+ const mark = isAnswered ? '●' : '○';
295
+ const color = isAnswered ? 'success' : 'muted';
296
+ const text = ` ${mark} ${entry.label} `;
297
+ const styled = isActive
298
+ ? theme.bg('selectedBg', theme.fg('text', text))
299
+ : theme.fg(color, text);
300
+ tabs.push(styled);
301
+ }
302
+ const canSubmit = allAnswered();
303
+ const isSubmitTab = currentTab === questions.length;
304
+ const submitText = ' Submit ';
305
+ const submitStyled = isSubmitTab
306
+ ? theme.bg('selectedBg', theme.fg('text', submitText))
307
+ : theme.fg(canSubmit ? 'success' : 'dim', submitText);
308
+ tabs.push(submitStyled);
309
+ addWrappedWithPrefix(' ', tabs.join(' '));
310
+ lines.push('');
311
+ }
312
+
313
+ function renderOptionList(): void {
314
+ for (let index = 0; index < opts.length; index += 1) {
315
+ const opt = opts[index];
316
+ if (opt === undefined) {
317
+ continue;
318
+ }
319
+ if (opt.isOutOfScope === true) {
320
+ lines.push('');
321
+ }
322
+ const selected = index === optionIndex;
323
+ const prefix = selected ? theme.fg('accent', '> ') : ' ';
324
+ const color = selected ? 'accent' : 'text';
325
+ let line = theme.fg(color, `${String(index + 1)}. ${opt.label}`);
326
+ if (opt.isRecommended === true) {
327
+ line = `${line} ${theme.fg('dim', 'recommended')}`;
328
+ }
329
+ addWrappedWithPrefix(prefix, line);
330
+ if (opt.description !== undefined) {
331
+ addWrappedWithPrefix(' ', theme.fg('muted', opt.description));
332
+ }
333
+ }
334
+ }
335
+
336
+ if (inputMode && question !== undefined) {
337
+ addWrappedWithPrefix(
338
+ ' ',
339
+ theme.fg('accent', theme.bold(question.prompt)),
340
+ );
341
+ lines.push('');
342
+ addWrappedWithPrefix(' ', theme.fg('muted', 'Custom answer'));
343
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
344
+ lines.push(` ${line}`);
345
+ }
346
+ lines.push('');
347
+ addWrappedWithPrefix(
348
+ ' ',
349
+ theme.fg('dim', 'Enter save · Esc back to options'),
350
+ );
351
+ } else if (currentTab === questions.length) {
352
+ for (const entry of questions) {
353
+ const answer = answers.get(entry.id);
354
+ const name = theme.fg('muted', `${entry.label}: `);
355
+ if (answer === undefined) {
356
+ addWrappedWithPrefix(
357
+ ' ',
358
+ `${name}${theme.fg('warning', 'not answered')}`,
359
+ );
360
+ continue;
361
+ }
362
+ let kind = '';
363
+ if (answer.outOfScope) {
364
+ kind = 'out of scope · ';
365
+ } else if (answer.wasCustom) {
366
+ kind = 'custom · ';
367
+ }
368
+ addWrappedWithPrefix(
369
+ ' ',
370
+ `${name}${theme.fg('dim', kind)}${theme.fg('text', answer.label)}`,
371
+ );
372
+ }
373
+ lines.push('');
374
+ if (allAnswered()) {
375
+ addWrappedWithPrefix(
376
+ ' ',
377
+ theme.fg('success', 'Enter send answers · Esc cancel'),
378
+ );
379
+ } else {
380
+ const missing = questions
381
+ .filter((entry) => !answers.has(entry.id))
382
+ .map((entry) => entry.label)
383
+ .join(', ');
384
+ addWrappedWithPrefix(
385
+ ' ',
386
+ theme.fg('warning', `Answer ${missing} to continue`),
387
+ );
388
+ }
389
+ } else if (question !== undefined) {
390
+ addWrappedWithPrefix(
391
+ ' ',
392
+ theme.fg('accent', theme.bold(question.prompt)),
393
+ );
394
+ const saved = answers.get(question.id);
395
+ if (saved !== undefined) {
396
+ addWrappedWithPrefix(
397
+ ' ',
398
+ theme.fg('muted', `Current: ${saved.label}`),
399
+ );
400
+ }
401
+ lines.push('');
402
+ renderOptionList();
403
+ }
404
+
405
+ if (!inputMode && currentTab !== questions.length) {
406
+ lines.push('');
407
+ const count = String(opts.length);
408
+ const help = isMulti
409
+ ? `↑↓ or 1–${count} · Enter · Tab next · Esc cancel`
410
+ : `↑↓ or 1–${count} · Enter select · Esc cancel`;
411
+ addWrappedWithPrefix(' ', theme.fg('dim', help));
412
+ }
413
+ lines.push(theme.fg('accent', '─'.repeat(renderWidth)));
414
+
415
+ cachedLines = lines;
416
+ return lines;
417
+ }
418
+
419
+ return {
420
+ render,
421
+ invalidate: () => {
422
+ cachedLines = undefined;
423
+ },
424
+ handleInput,
425
+ };
426
+ });
427
+ }
428
+
429
+ function optionIndexFromDigit(
430
+ data: string,
431
+ optionCount: number,
432
+ ): number | undefined {
433
+ if (data.length !== 1) {
434
+ return undefined;
435
+ }
436
+ const code = data.charCodeAt(0);
437
+ if (code < 49 || code > 57) {
438
+ return undefined;
439
+ }
440
+ const index = code - 49;
441
+ if (index >= optionCount) {
442
+ return undefined;
443
+ }
444
+ return index;
445
+ }
@@ -0,0 +1,13 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { appendGuidelinesReminder } from './prompt.ts';
3
+
4
+ // Pi loads extensions/*/index.ts through export default.
5
+ export default function registerGuidelines(pi: ExtensionAPI): void {
6
+ pi.on('before_agent_start', (event) => {
7
+ const systemPrompt = appendGuidelinesReminder(event.systemPrompt);
8
+ if (systemPrompt === event.systemPrompt) {
9
+ return;
10
+ }
11
+ return { systemPrompt };
12
+ });
13
+ }
@@ -0,0 +1,26 @@
1
+ export const GUIDELINES_REMINDER_HEADING =
2
+ 'Sideroom coding guidelines (always-on reminder):';
3
+
4
+ export const GUIDELINES_REMINDER = `${GUIDELINES_REMINDER_HEADING}
5
+ Before write or edit, read the sideroom-guidelines skill.
6
+ Apply its Do/Don't table. If the path is .java, .php, .ts/.tsx, .py, .go, or .rs, also read that language file under the skill's references/languages/.
7
+ Do not load other language files.`;
8
+
9
+ export const LANGUAGE_DELTA_FILES = [
10
+ 'go.md',
11
+ 'java.md',
12
+ 'php-laravel.md',
13
+ 'python.md',
14
+ 'rust.md',
15
+ 'typescript.md',
16
+ ] as const;
17
+
18
+ export function appendGuidelinesReminder(systemPrompt: string): string {
19
+ if (systemPrompt.includes(GUIDELINES_REMINDER_HEADING)) {
20
+ return systemPrompt;
21
+ }
22
+ if (systemPrompt.length === 0) {
23
+ return GUIDELINES_REMINDER;
24
+ }
25
+ return `${systemPrompt}\n\n${GUIDELINES_REMINDER}`;
26
+ }