@rmrdeveloper/sideroom-pi 8.3.0 → 8.5.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.
package/README.md CHANGED
@@ -54,7 +54,9 @@ Sideroom answers each one with a small, opinionated surface:
54
54
  | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
55
55
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
56
56
  | Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
57
+ | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
57
58
  | Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
59
+ | Work left unexplained | `explain` — offers a walkthrough and how to test it once the work settles |
58
60
 
59
61
  ## What it feels like
60
62
 
@@ -163,6 +165,18 @@ appended as notes to the tool result. A per-rule circuit breaker degrades a
163
165
  repeatedly firing block to a note so the agent never dead-locks. No files
164
166
  written, no config read: the catalog ships with the package.
165
167
 
168
+ ### Persona — one voice, not a costume
169
+
170
+ `sideroom_persona` is the agent's voice, and there is exactly one of it. A
171
+ short reminder rides the system prompt on every turn, so it survives
172
+ compaction; the full guide lives in the on-demand `sideroom-persona` skill.
173
+ The voice is direct and dry, free of filler, and plain enough to leave no
174
+ doubt about what is being discussed — while calling things by the names the
175
+ user already uses. Emojis and decorative symbols in the lines a `write`/`edit`
176
+ adds block the mutation; flattering openers, hedging, automatic apologies, and
177
+ AI meta-commentary are answered with a capped steer. No profiles to switch and
178
+ no profile selection to store.
179
+
166
180
  ### Done — done means green
167
181
 
168
182
  `sideroom_done` detects the project's check command — `package.json` scripts
@@ -172,6 +186,16 @@ target — and watches for it to pass. If files changed without a green run, the
172
186
  agent is steered once per turn, up to a cap, to run it before finishing. When
173
187
  nothing is detectable, the gate does nothing.
174
188
 
189
+ ### Explain — the walkthrough offer
190
+
191
+ When an implementation settles, `explain` steers the agent to offer a
192
+ walkthrough through `sideroom_ask`: explain the changes only, how to test them
193
+ only, both, or nothing. It fires on `agent_settled`, the point where
194
+ no retry, compaction, or queued message is left, so the offer never lands on
195
+ work that is about to be redone. Once per turn, only when the turn touched at
196
+ least five distinct files, and only in the TUI, because `sideroom_ask` cannot
197
+ run anywhere else. No tool, no widget, no persisted state.
198
+
175
199
  ## Quick path
176
200
 
177
201
  1. Install globally from npm. Pi records this in your user settings, so do
@@ -260,6 +284,23 @@ the diff against the loaded guide and run the relevant project checks.
260
284
  - A rule that blocks three times in a run degrades to a warning until five
261
285
  clean checks or a new interactive prompt reset it.
262
286
 
287
+ ### Persona contract
288
+
289
+ - One built-in voice. No profiles, no switching, and no persisted profile
290
+ state; runtime guard counters stay in memory. The footer status is derived
291
+ from the catalog.
292
+ - Injection: a short reminder appended in `before_agent_start`, chained after
293
+ the other extensions and idempotent by heading, so it returns after
294
+ compaction.
295
+ - Blocking: decorative symbols in the lines a `write`/`edit` adds. Full rewrites
296
+ resolve Pi path aliases before comparing existing content; semantic copyright,
297
+ trademark, and registration symbols remain valid. Three fires degrade the
298
+ prohibition to a steer; five clean mutations reset the counters.
299
+ - Steering: one corrective message per violating assistant message, at most
300
+ three per run.
301
+ - `sideroom_persona` takes no arguments and returns every rule and every
302
+ prohibition with its enforcement mode.
303
+
263
304
  ### Done contract
264
305
 
265
306
  - Detected command: `package.json` scripts in priority order with the
@@ -269,6 +310,18 @@ the diff against the loaded guide and run the relevant project checks.
269
310
  successful `write`/`edit` clears green again.
270
311
  - Steering: once per turn, at most twice per run, then it stops.
271
312
 
313
+ ### Explain contract
314
+
315
+ - Trigger: `agent_settled` with at least five distinct files mutated since the
316
+ last user prompt, in TUI mode. File identity follows Pi's path aliases and
317
+ canonicalizes existing paths.
318
+ - The steer names the intent; the agent writes the question in the user's
319
+ language with four options, one of them a decline, and one recommendation.
320
+ `sideroom_ask` still adds *Out of scope* and the custom answer.
321
+ - At most one offer per turn. The flag is set before the steer, so the turn the
322
+ offer triggers cannot re-trigger it.
323
+ - No green-check requirement: red checks still produce the offer.
324
+
272
325
  ### Grill contract
273
326
 
274
327
  Load the skill at the start of a change with a fuzzy plan.
@@ -293,6 +346,17 @@ Maintainer reference lives in [`docs/`](docs/README.md): the
293
346
  [architecture](docs/architecture.md) and one page per
294
347
  [extension](docs/extensions/ask.md).
295
348
 
349
+ ## Contributing
350
+
351
+ Contributions are welcome: bug reports, feature requests, language guides, and
352
+ pull requests. Start with [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup, the
353
+ commands, the design rules, and the branch and release flow — pull requests
354
+ target `develop`, and `main` only carries releases.
355
+
356
+ - [Report a bug](https://github.com/RMRdeveloper/sideroom-pi/issues/new?template=bug_report.yml) or [request a feature](https://github.com/RMRdeveloper/sideroom-pi/issues/new?template=feature_request.yml).
357
+ - Report vulnerabilities privately through [`SECURITY.md`](SECURITY.md).
358
+ - Participation is covered by the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
359
+
296
360
  ## License
297
361
 
298
362
  MIT.
@@ -15,14 +15,11 @@ export const ASK_PROMPT_SNIPPET =
15
15
  'Ask the user one or more questions with a recommended option, in their language.';
16
16
 
17
17
  export const ASK_PROMPT_GUIDELINES = [
18
- 'Use sideroom_ask to clarify requirements, preferences, or decisions with the user.',
19
- 'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it owns the interview and drives sideroom_ask rounds.',
20
- 'Each sideroom_ask question needs an id, a prompt, at least two options, and a recommendationIndex pointing at the recommended option.',
21
- 'Write every sideroom_ask prompt, tab label, and option label or description in the language the user is speaking. Keep ids, option values, and tool code in English.',
22
- 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself, and in a multiple-selection question they replace the selections.',
23
- 'For a genuinely plural sideroom_ask question, set selectionMode "multiple" with recommendedIndices (at least one) instead of recommendationIndex; never send both fields, and keep the single default otherwise.',
24
- 'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
25
- 'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
18
+ 'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it owns the interview and drives sideroom_ask rounds. One sideroom_ask call is one batch; call it again for another round.',
19
+ 'A decision the repository already settles consistently is a project fact: read the code and reuse it. A decision that materially affects architecture, dependencies, cost, security, operations, persistence, public contracts, or future change difficulty is an architectural decision: expose it through sideroom_ask before committing, with the practical consequences of each alternative and one recommendation that carries a one-line justification, never a list of technology names. If the repository shows two inconclusive patterns for the same kind of decision, name the inconsistency in the prompt. Everything else is a minor decision: take it yourself.',
20
+ 'Write sideroom_ask prompts, tab labels, option labels, and option descriptions in the language the user is speaking; keep ids and option values in English.',
21
+ 'sideroom_ask always adds Out of scope and a write-your-own answer; never send them yourself. A single-selection question needs recommendationIndex; a multiple-selection question needs recommendedIndices and never recommendationIndex.',
22
+ 'sideroom_ask only works in the interactive TUI; it errors in print, JSON, and RPC modes.',
26
23
  ];
27
24
 
28
25
  // Pi loads extensions/*/index.ts through export default.
@@ -0,0 +1,62 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ isEditToolResult,
4
+ isWriteToolResult,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import {
7
+ EXPLAIN_OFFER,
8
+ EXPLAIN_OFFER_TYPE,
9
+ type ExplainState,
10
+ mutationPathKey,
11
+ resetExplainTurn,
12
+ shouldOfferExplanation,
13
+ } from './model.ts';
14
+
15
+ interface FileToolInput {
16
+ readonly path: string;
17
+ }
18
+
19
+ export function registerExplainGuard(
20
+ pi: ExtensionAPI,
21
+ state: ExplainState,
22
+ ): void {
23
+ pi.on('input', (event) => {
24
+ if (event.source !== 'interactive' && event.source !== 'rpc') {
25
+ return;
26
+ }
27
+ resetExplainTurn(state);
28
+ });
29
+
30
+ pi.on('tool_result', (event, ctx) => {
31
+ if (event.isError) {
32
+ return;
33
+ }
34
+ if (!isEditToolResult(event) && !isWriteToolResult(event)) {
35
+ return;
36
+ }
37
+ // SAFETY: Pi validates built-in file-tool inputs before emitting tool_result.
38
+ const input = event.input as unknown as FileToolInput;
39
+ const pathKey = mutationPathKey(ctx.cwd, input.path);
40
+ if (pathKey === undefined) {
41
+ return;
42
+ }
43
+ state.mutatedFilesThisTurn.add(pathKey);
44
+ });
45
+
46
+ // agent_settled is the only point with no retry, compaction, or queued
47
+ // continuation left, so the walkthrough is never offered over reworked work.
48
+ pi.on('agent_settled', (_event, ctx) => {
49
+ if (!shouldOfferExplanation(state, ctx.mode)) {
50
+ return;
51
+ }
52
+ state.offeredThisTurn = true;
53
+ pi.sendMessage(
54
+ {
55
+ customType: EXPLAIN_OFFER_TYPE,
56
+ content: EXPLAIN_OFFER,
57
+ display: false,
58
+ },
59
+ { triggerTurn: true, deliverAs: 'steer' },
60
+ );
61
+ });
62
+ }
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { registerExplainGuard } from './guard.ts';
3
+ import { createExplainState, resetExplainTurn } from './model.ts';
4
+
5
+ // Pi loads extensions/*/index.ts through export default.
6
+ export default function registerExplain(pi: ExtensionAPI): void {
7
+ const state = createExplainState();
8
+ registerExplainGuard(pi, state);
9
+
10
+ pi.on('session_start', () => resetExplainTurn(state));
11
+ }
@@ -0,0 +1,44 @@
1
+ import { resolveFileToolPath } from '../shared/file-path.ts';
2
+
3
+ export const EXPLAIN_OFFER_TYPE = 'sideroom-explain-offer';
4
+
5
+ // Five is the point where a turn stopped being an adjustment and became work
6
+ // worth walking the user through.
7
+ export const EXPLAIN_FILE_THRESHOLD = 5;
8
+
9
+ const TUI_MODE = 'tui';
10
+
11
+ export const EXPLAIN_OFFER =
12
+ "Use sideroom_ask for one question in the user's language with four parallel options: changes only, test steps only, both, or no explanation. Recommend changes only.";
13
+
14
+ export interface ExplainState {
15
+ mutatedFilesThisTurn: Set<string>;
16
+ offeredThisTurn: boolean;
17
+ }
18
+
19
+ export function createExplainState(): ExplainState {
20
+ return { mutatedFilesThisTurn: new Set(), offeredThisTurn: false };
21
+ }
22
+
23
+ export function resetExplainTurn(state: ExplainState): void {
24
+ state.mutatedFilesThisTurn.clear();
25
+ state.offeredThisTurn = false;
26
+ }
27
+
28
+ // Pi reports the path as the model wrote it, so use the built-in file-tool
29
+ // semantics and canonicalize existing aliases before counting the file.
30
+ export function mutationPathKey(cwd: string, path: string): string | undefined {
31
+ return resolveFileToolPath(cwd, path);
32
+ }
33
+
34
+ // sideroom_ask rejects every mode but the TUI, so the offer is gated the same way.
35
+ export function shouldOfferExplanation(
36
+ state: ExplainState,
37
+ mode: string,
38
+ ): boolean {
39
+ return (
40
+ mode === TUI_MODE &&
41
+ state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD &&
42
+ !state.offeredThisTurn
43
+ );
44
+ }
@@ -5,14 +5,16 @@ export { LANGUAGE_GUIDE_FILES } from './catalog.ts';
5
5
  export const GUIDELINES_REMINDER_HEADING =
6
6
  'Sideroom coding guidelines (always-on reminder):';
7
7
 
8
- const LANGUAGE_GUIDE_REMINDERS = LANGUAGE_GUIDES.map(
9
- ({ extensions, path }) =>
10
- `If a file you will write or edit ends in ${extensions.join(' or ')}, use the read tool without offset or limit to load the exact matching guide at ${path} in full.`,
11
- ).join('\n');
8
+ // One dense index instead of one sentence per language: the same paths and the
9
+ // same requirement, a fraction of the tokens every turn.
10
+ const LANGUAGE_GUIDE_INDEX = LANGUAGE_GUIDES.map(
11
+ ({ extensions, path }) => `${extensions.join('/')} ${path}`,
12
+ ).join('; ');
12
13
 
13
14
  export const GUIDELINES_REMINDER = `${GUIDELINES_REMINDER_HEADING}
14
- Before the first write or edit in each agent run, use the read tool without offset or limit to load ${GUIDELINE_SKILL_PATH} in full.
15
- ${LANGUAGE_GUIDE_REMINDERS}
15
+ Before the first write or edit in each agent run, use the read tool without offset or limit to load these files in full:
16
+ - ${GUIDELINE_SKILL_PATH}
17
+ - for the extension of the file you will change, one of: ${LANGUAGE_GUIDE_INDEX}
16
18
  A required read counts only when it succeeds without truncation. Until all required reads succeed, do not call write or edit and do not mutate files through bash or another tool; the extension blocks those mutations.
17
19
  After changing files, review the code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
18
20
 
@@ -0,0 +1,110 @@
1
+ import { dirname, join, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
5
+
6
+ export const PERSONA_TOOL_NAME = 'sideroom_persona';
7
+ export const PERSONA_STATUS_KEY = 'sideroom-persona';
8
+ export const PERSONA_STEER_TYPE = 'sideroom-persona-steer';
9
+ export const PERSONA_SKILL_PATH = join(
10
+ PACKAGE_ROOT,
11
+ 'skills/sideroom-persona/SKILL.md',
12
+ );
13
+
14
+ export const TEXT_SCOPE = {
15
+ prose: 'prose',
16
+ artifact: 'artifact',
17
+ } as const;
18
+
19
+ export type TextScope = (typeof TEXT_SCOPE)[keyof typeof TEXT_SCOPE];
20
+
21
+ export type VoiceRuleId =
22
+ | 'direct'
23
+ | 'no-filler'
24
+ | 'plain-language'
25
+ | 'no-invented-terms'
26
+ | 'short-prose'
27
+ | 'user-language';
28
+
29
+ export interface VoiceRule {
30
+ readonly id: VoiceRuleId;
31
+ readonly instruction: string;
32
+ }
33
+
34
+ export const VOICE_RULES: readonly VoiceRule[] = [
35
+ {
36
+ id: 'direct',
37
+ instruction:
38
+ 'Be direct and dry. Use short sentences. No preamble or closing summary.',
39
+ },
40
+ {
41
+ id: 'no-filler',
42
+ instruction: 'No filler.',
43
+ },
44
+ {
45
+ id: 'plain-language',
46
+ instruction:
47
+ 'Explain plainly and leave no doubt. Name the subject and goal before details, define unfamiliar terms in one line, and be explicit without repetition.',
48
+ },
49
+ {
50
+ id: 'no-invented-terms',
51
+ instruction:
52
+ 'Name things as they are. Add no intermediate terms, abbreviations, or codenames unless the user asks.',
53
+ },
54
+ {
55
+ id: 'short-prose',
56
+ instruction:
57
+ 'Default to short prose. Use lists or tables only for comparisons or more than three items.',
58
+ },
59
+ {
60
+ id: 'user-language',
61
+ instruction: "Answer in the user's language and variant.",
62
+ },
63
+ ];
64
+
65
+ export type ProhibitionId =
66
+ | 'decorative-symbols'
67
+ | 'flattery-and-filler'
68
+ | 'hedging-and-apology'
69
+ | 'ai-meta-commentary';
70
+
71
+ export interface Prohibition {
72
+ readonly id: ProhibitionId;
73
+ readonly rule: string;
74
+ readonly scopes: readonly TextScope[];
75
+ }
76
+
77
+ export const PROHIBITIONS: readonly Prohibition[] = [
78
+ {
79
+ id: 'decorative-symbols',
80
+ rule: 'No emojis or decorative symbols in prose or artifacts.',
81
+ scopes: [TEXT_SCOPE.prose, TEXT_SCOPE.artifact],
82
+ },
83
+ {
84
+ id: 'flattery-and-filler',
85
+ rule: 'No flattering openers such as "Great question!", restating what was just said, or announcing the answer.',
86
+ scopes: [TEXT_SCOPE.prose],
87
+ },
88
+ {
89
+ id: 'hedging-and-apology',
90
+ rule: 'No hedging such as "maybe we could consider" or automatic apologies.',
91
+ scopes: [TEXT_SCOPE.prose],
92
+ },
93
+ {
94
+ id: 'ai-meta-commentary',
95
+ rule: 'No AI meta-commentary such as "as a language model" or "I do not have access to".',
96
+ scopes: [TEXT_SCOPE.prose],
97
+ },
98
+ ];
99
+
100
+ const PROHIBITION_BY_ID: ReadonlyMap<ProhibitionId, Prohibition> = new Map(
101
+ PROHIBITIONS.map((prohibition) => [prohibition.id, prohibition]),
102
+ );
103
+
104
+ export function prohibitionById(id: ProhibitionId): Prohibition {
105
+ const prohibition = PROHIBITION_BY_ID.get(id);
106
+ if (prohibition === undefined) {
107
+ throw new Error(`Unknown prohibition id: ${id}`);
108
+ }
109
+ return prohibition;
110
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ type Prohibition,
3
+ type ProhibitionId,
4
+ prohibitionById,
5
+ TEXT_SCOPE,
6
+ type TextScope,
7
+ } from './catalog.ts';
8
+
9
+ interface ProhibitionDetector {
10
+ readonly prohibitionId: ProhibitionId;
11
+ readonly pattern: RegExp;
12
+ }
13
+
14
+ // Each detector is a heuristic for one prohibition. The rule text in the
15
+ // catalog stays authoritative; a detector never fires on its own judgement.
16
+ const DETECTORS: readonly ProhibitionDetector[] = [
17
+ {
18
+ prohibitionId: 'decorative-symbols',
19
+ pattern: /\p{Emoji_Presentation}|\uFE0F|[\u2713\u2714\u2717\u2718]/u,
20
+ },
21
+ {
22
+ prohibitionId: 'flattery-and-filler',
23
+ pattern:
24
+ /^[ \t>*+-]*(?:[¡!]?\s*(?:great|excellent|awesome|fantastic|amazing|perfect|good)\s+(?:question|idea|point|catch|observation|call)\b[!.]?|certainly\b|of course\b|absolutely\b|sure thing\b|[¡!]?\s*(?:excelente|gran|buena|perfecta)\s+(?:pregunta|idea|observaci[oó]n|punto)\b[!.]?|por supuesto\b|claro que s[ií]\b)/imu,
25
+ },
26
+ {
27
+ prohibitionId: 'hedging-and-apology',
28
+ pattern:
29
+ /\b(?:sorry (?:for|about)|i apologize|my apologies|perd[oó]n por|disculp[áa]|lamento (?:el|la|los|las|haber|no haber))\b|\b(?:maybe we could|might be worth|i think we could|we could consider|perhaps we could|quiz[áa]s podr[íi]amos|tal vez podr[íi]amos|podr[íi]amos considerar|creo que podr[íi]amos|you might want to consider)\b/iu,
30
+ },
31
+ {
32
+ prohibitionId: 'ai-meta-commentary',
33
+ pattern:
34
+ /\b(?:as an ai\b|as a language model\b|i['’]?m an ai\b|i am an ai\b|como (?:un )?modelo de lenguaje\b|soy una? (?:ia|inteligencia artificial|asistente de ia)\b|no tengo acceso a (?:internet|tiempo real)\b|i (?:do not|don['’]?t) have access to (?:the )?internet\b)/iu,
35
+ },
36
+ ];
37
+
38
+ export interface ProhibitionViolation {
39
+ readonly prohibition: Prohibition;
40
+ readonly excerpt: string;
41
+ }
42
+
43
+ export function findViolations(
44
+ text: string,
45
+ scope: TextScope,
46
+ ): readonly ProhibitionViolation[] {
47
+ if (text.length === 0) {
48
+ return [];
49
+ }
50
+
51
+ const violations: ProhibitionViolation[] = [];
52
+ for (const detector of DETECTORS) {
53
+ const prohibition = prohibitionById(detector.prohibitionId);
54
+ if (!prohibition.scopes.includes(scope)) {
55
+ continue;
56
+ }
57
+ const match = detector.pattern.exec(text);
58
+ if (match === null) {
59
+ continue;
60
+ }
61
+ violations.push({ prohibition, excerpt: excerptOf(match[0]) });
62
+ }
63
+ return violations;
64
+ }
65
+
66
+ export function artifactViolations(
67
+ addedLines: readonly string[],
68
+ ): readonly ProhibitionViolation[] {
69
+ return findViolations(addedLines.join('\n'), TEXT_SCOPE.artifact);
70
+ }
71
+
72
+ export function addedLinesMissingFrom(
73
+ previous: string | undefined,
74
+ next: string,
75
+ ): readonly string[] {
76
+ const remaining = countLines(previous);
77
+ const added: string[] = [];
78
+
79
+ for (const line of next.split('\n')) {
80
+ const key = line.trim();
81
+ if (key.length === 0) {
82
+ continue;
83
+ }
84
+ const available = remaining.get(key) ?? 0;
85
+ if (available > 0) {
86
+ remaining.set(key, available - 1);
87
+ continue;
88
+ }
89
+ added.push(line);
90
+ }
91
+
92
+ return added;
93
+ }
94
+
95
+ function countLines(content: string | undefined): Map<string, number> {
96
+ const counts = new Map<string, number>();
97
+ if (content === undefined) {
98
+ return counts;
99
+ }
100
+
101
+ for (const line of content.split('\n')) {
102
+ const key = line.trim();
103
+ if (key.length === 0) {
104
+ continue;
105
+ }
106
+ counts.set(key, (counts.get(key) ?? 0) + 1);
107
+ }
108
+ return counts;
109
+ }
110
+
111
+ // Detector matches are short by construction; drop the bullet or indentation
112
+ // they carry so the reason stays on one line.
113
+ function excerptOf(match: string): string {
114
+ const singleLine = match.replaceAll(/\s+/gu, ' ').trim();
115
+ return singleLine.replace(/^[>*+-]+\s*/u, '');
116
+ }
@@ -0,0 +1,17 @@
1
+ import {
2
+ formatPersonaDetail,
3
+ type PersonaToolDetails,
4
+ personaToolDetails,
5
+ } from './model.ts';
6
+
7
+ export interface PersonaToolResult {
8
+ readonly content: { readonly type: 'text'; readonly text: string }[];
9
+ readonly details: PersonaToolDetails;
10
+ }
11
+
12
+ export function executePersona(): PersonaToolResult {
13
+ return {
14
+ content: [{ type: 'text', text: formatPersonaDetail() }],
15
+ details: personaToolDetails(),
16
+ };
17
+ }
@@ -0,0 +1,170 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import {
3
+ type ExtensionAPI,
4
+ isToolCallEventType,
5
+ type ToolCallEvent,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import { resolveFileToolPath } from '../shared/file-path.ts';
8
+ import {
9
+ PERSONA_STEER_TYPE,
10
+ type ProhibitionId,
11
+ TEXT_SCOPE,
12
+ } from './catalog.ts';
13
+ import {
14
+ addedLinesMissingFrom,
15
+ artifactViolations,
16
+ findViolations,
17
+ } from './checks.ts';
18
+ import {
19
+ assistantMessageText,
20
+ formatArtifactSteer,
21
+ formatBlockReason,
22
+ formatSteer,
23
+ } from './model.ts';
24
+
25
+ // A rule that keeps firing degrades to a steer instead of trapping the agent.
26
+ const BLOCK_DEGRADE_AFTER = 3;
27
+ const CLEAN_RESET_AFTER = 5;
28
+
29
+ // One agent run can be corrected at most this many times.
30
+ const STEER_LIMIT_PER_RUN = 3;
31
+
32
+ interface ExtractedMutation {
33
+ readonly path: string;
34
+ readonly addedLines: readonly string[];
35
+ }
36
+
37
+ export interface PersonaGuardState {
38
+ readonly fires: Map<ProhibitionId, number>;
39
+ cleanMutations: number;
40
+ steersThisRun: number;
41
+ }
42
+
43
+ export function createPersonaGuardState(): PersonaGuardState {
44
+ return { fires: new Map(), cleanMutations: 0, steersThisRun: 0 };
45
+ }
46
+
47
+ export function resetPersonaRun(state: PersonaGuardState): void {
48
+ state.steersThisRun = 0;
49
+ }
50
+
51
+ export function registerPersonaGuard(
52
+ pi: ExtensionAPI,
53
+ state: PersonaGuardState,
54
+ ): void {
55
+ pi.on('tool_call', (event, ctx) => {
56
+ const mutation = extractMutation(event, ctx.cwd);
57
+ if (mutation === undefined) {
58
+ return undefined;
59
+ }
60
+ return applyArtifactOutcome(pi, state, mutation);
61
+ });
62
+
63
+ pi.on('message_end', (event) => {
64
+ if (event.message.role !== 'assistant') {
65
+ return undefined;
66
+ }
67
+ const violations = findViolations(
68
+ assistantMessageText(event.message.content),
69
+ TEXT_SCOPE.prose,
70
+ );
71
+ if (violations.length === 0) {
72
+ return undefined;
73
+ }
74
+
75
+ sendPersonaSteer(pi, state, formatSteer(violations));
76
+ return undefined;
77
+ });
78
+ }
79
+
80
+ function applyArtifactOutcome(
81
+ pi: ExtensionAPI,
82
+ state: PersonaGuardState,
83
+ mutation: ExtractedMutation,
84
+ ): { block: true; reason: string } | undefined {
85
+ const violations = artifactViolations(mutation.addedLines);
86
+ if (violations.length === 0) {
87
+ registerCleanMutation(state);
88
+ return undefined;
89
+ }
90
+
91
+ const blocking = violations.filter(
92
+ (violation) => !isDegraded(state, violation.prohibition.id),
93
+ );
94
+ if (blocking.length > 0) {
95
+ for (const violation of blocking) {
96
+ incrementFires(state, violation.prohibition.id);
97
+ }
98
+ return { block: true, reason: formatBlockReason(mutation.path, blocking) };
99
+ }
100
+
101
+ sendPersonaSteer(pi, state, formatArtifactSteer(mutation.path, violations));
102
+ return undefined;
103
+ }
104
+
105
+ function extractMutation(
106
+ event: ToolCallEvent,
107
+ cwd: string,
108
+ ): ExtractedMutation | undefined {
109
+ if (isToolCallEventType('write', event)) {
110
+ const absolutePath = resolveFileToolPath(cwd, event.input.path);
111
+ if (absolutePath === undefined) {
112
+ return undefined;
113
+ }
114
+ const previous = readFileIfExists(absolutePath);
115
+ return {
116
+ path: event.input.path,
117
+ addedLines: addedLinesMissingFrom(previous, event.input.content),
118
+ };
119
+ }
120
+ if (isToolCallEventType('edit', event)) {
121
+ const addedLines = event.input.edits.flatMap((edit) =>
122
+ addedLinesMissingFrom(edit.oldText, edit.newText),
123
+ );
124
+ return { path: event.input.path, addedLines };
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ function readFileIfExists(path: string): string | undefined {
130
+ try {
131
+ return readFileSync(path, 'utf8');
132
+ } catch (error) {
133
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
134
+ return undefined;
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ function isDegraded(state: PersonaGuardState, id: ProhibitionId): boolean {
141
+ return (state.fires.get(id) ?? 0) >= BLOCK_DEGRADE_AFTER;
142
+ }
143
+
144
+ function incrementFires(state: PersonaGuardState, id: ProhibitionId): void {
145
+ state.fires.set(id, (state.fires.get(id) ?? 0) + 1);
146
+ }
147
+
148
+ function sendPersonaSteer(
149
+ pi: Pick<ExtensionAPI, 'sendMessage'>,
150
+ state: PersonaGuardState,
151
+ content: string,
152
+ ): void {
153
+ if (state.steersThisRun >= STEER_LIMIT_PER_RUN) {
154
+ return;
155
+ }
156
+ state.steersThisRun += 1;
157
+ pi.sendMessage(
158
+ { customType: PERSONA_STEER_TYPE, content, display: false },
159
+ { deliverAs: 'steer' },
160
+ );
161
+ }
162
+
163
+ function registerCleanMutation(state: PersonaGuardState): void {
164
+ state.cleanMutations += 1;
165
+ if (state.cleanMutations < CLEAN_RESET_AFTER) {
166
+ return;
167
+ }
168
+ state.cleanMutations = 0;
169
+ state.fires.clear();
170
+ }
@@ -0,0 +1,77 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import { Text } from '@earendil-works/pi-tui';
6
+ import { Type } from 'typebox';
7
+ import { PERSONA_STATUS_KEY, PERSONA_TOOL_NAME } from './catalog.ts';
8
+ import { executePersona, type PersonaToolResult } from './execute.ts';
9
+ import {
10
+ createPersonaGuardState,
11
+ registerPersonaGuard,
12
+ resetPersonaRun,
13
+ } from './guard.ts';
14
+ import { PERSONA_STATUS_LABEL } from './model.ts';
15
+ import { appendPersonaReminder } from './prompt.ts';
16
+
17
+ export const PERSONA_DESCRIPTION =
18
+ "Read Sideroom's voice rules and hard prohibitions for user-facing answers.";
19
+
20
+ export const PERSONA_PROMPT_SNIPPET = 'Read the active Sideroom persona.';
21
+
22
+ // Pi loads extensions/*/index.ts through export default.
23
+ export default function registerPersona(pi: ExtensionAPI): void {
24
+ const state = createPersonaGuardState();
25
+ registerPersonaGuard(pi, state);
26
+
27
+ pi.on('session_start', (_event, ctx) => publishStatus(ctx));
28
+
29
+ pi.on('before_agent_start', (event, ctx) => {
30
+ resetPersonaRun(state);
31
+ publishStatus(ctx);
32
+ const systemPrompt = appendPersonaReminder(event.systemPrompt);
33
+ if (systemPrompt === event.systemPrompt) {
34
+ return;
35
+ }
36
+ return { systemPrompt };
37
+ });
38
+
39
+ pi.registerTool({
40
+ name: PERSONA_TOOL_NAME,
41
+ label: 'Sideroom Persona',
42
+ description: PERSONA_DESCRIPTION,
43
+ promptSnippet: PERSONA_PROMPT_SNIPPET,
44
+ parameters: Type.Object({}),
45
+ executionMode: 'sequential',
46
+ execute: async () => executePersona(),
47
+ renderCall(_args, theme) {
48
+ return new Text(
49
+ theme.fg('toolTitle', theme.bold(PERSONA_TOOL_NAME)),
50
+ 0,
51
+ 0,
52
+ );
53
+ },
54
+ renderResult(result, _options, theme) {
55
+ const details = result.details as
56
+ | PersonaToolResult['details']
57
+ | undefined;
58
+ const voices = details?.voiceRules.length ?? 0;
59
+ const prohibitions = details?.prohibitions.length ?? 0;
60
+ return new Text(
61
+ theme.fg(
62
+ 'success',
63
+ `${String(voices)} voice rules · ${String(prohibitions)} prohibitions`,
64
+ ),
65
+ 0,
66
+ 0,
67
+ );
68
+ },
69
+ });
70
+ }
71
+
72
+ function publishStatus(ctx: ExtensionContext): void {
73
+ if (!ctx.hasUI) {
74
+ return;
75
+ }
76
+ ctx.ui.setStatus(PERSONA_STATUS_KEY, PERSONA_STATUS_LABEL);
77
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ PERSONA_SKILL_PATH,
3
+ PERSONA_TOOL_NAME,
4
+ PROHIBITIONS,
5
+ type Prohibition,
6
+ TEXT_SCOPE,
7
+ VOICE_RULES,
8
+ } from './catalog.ts';
9
+ import type { ProhibitionViolation } from './checks.ts';
10
+
11
+ // Pi narrows these blocks before handing the message over; the shape is
12
+ // structural so no runtime decoding is needed here.
13
+ export interface TextBlock {
14
+ readonly type: string;
15
+ readonly text?: string;
16
+ }
17
+
18
+ export interface PersonaToolDetails {
19
+ readonly voiceRules: readonly string[];
20
+ readonly prohibitions: readonly string[];
21
+ }
22
+
23
+ export const PERSONA_STATUS_LABEL = `persona: direct, ${String(PROHIBITIONS.length)} prohibitions`;
24
+
25
+ export function personaToolDetails(): PersonaToolDetails {
26
+ return {
27
+ voiceRules: VOICE_RULES.map((rule) => rule.id),
28
+ prohibitions: PROHIBITIONS.map((prohibition) => prohibition.id),
29
+ };
30
+ }
31
+
32
+ export function formatPersonaDetail(): string {
33
+ return [
34
+ 'Sideroom persona: one always-on voice; no profiles or switching.',
35
+ '',
36
+ 'Voice rules',
37
+ ...VOICE_RULES.map((rule) => `- ${rule.instruction}`),
38
+ '',
39
+ 'Hard prohibitions',
40
+ ...PROHIBITIONS.map(
41
+ (prohibition) => `- ${prohibition.rule} (${enforcementOf(prohibition)})`,
42
+ ),
43
+ '',
44
+ `Complete guide: ${PERSONA_SKILL_PATH}`,
45
+ ].join('\n');
46
+ }
47
+
48
+ export function formatBlockReason(
49
+ path: string,
50
+ violations: readonly ProhibitionViolation[],
51
+ ): string {
52
+ return [
53
+ `Blocked ${path}: the Sideroom persona forbids this content.`,
54
+ ...violations.map(
55
+ (violation) =>
56
+ `- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
57
+ ),
58
+ 'Remove the flagged content and retry the mutation.',
59
+ ].join('\n');
60
+ }
61
+
62
+ export function formatArtifactSteer(
63
+ path: string,
64
+ violations: readonly ProhibitionViolation[],
65
+ ): string {
66
+ return [
67
+ `Sideroom persona stopped blocking ${path} after repeated attempts, but this content is still forbidden.`,
68
+ ...violations.map(
69
+ (violation) =>
70
+ `- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
71
+ ),
72
+ 'Remove it in the next edit.',
73
+ ].join('\n');
74
+ }
75
+
76
+ export function formatSteer(
77
+ violations: readonly ProhibitionViolation[],
78
+ ): string {
79
+ return [
80
+ `${PERSONA_TOOL_NAME}: part of your last message is forbidden.`,
81
+ ...violations.map(
82
+ (violation) =>
83
+ `- ${violation.prohibition.rule} Found: "${violation.excerpt}"`,
84
+ ),
85
+ 'Rewrite it plainly: name the subject and goal before details, without ambiguity or repetition.',
86
+ ].join('\n');
87
+ }
88
+
89
+ export function assistantMessageText(content: readonly TextBlock[]): string {
90
+ const parts: string[] = [];
91
+ for (const block of content) {
92
+ if (block.type !== 'text') {
93
+ continue;
94
+ }
95
+ parts.push(block.text ?? '');
96
+ }
97
+ return parts.join('\n');
98
+ }
99
+
100
+ function enforcementOf(prohibition: Prohibition): string {
101
+ if (prohibition.scopes.includes(TEXT_SCOPE.artifact)) {
102
+ return 'blocked on write and edit';
103
+ }
104
+ return 'corrected with a steer';
105
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ PERSONA_SKILL_PATH,
3
+ PERSONA_TOOL_NAME,
4
+ PROHIBITIONS,
5
+ VOICE_RULES,
6
+ } from './catalog.ts';
7
+
8
+ export const PERSONA_REMINDER_HEADING =
9
+ 'Sideroom persona (always-on reminder):';
10
+
11
+ const VOICE_SUMMARY = VOICE_RULES.map((rule) => rule.instruction).join(' ');
12
+ const PROHIBITION_SUMMARY = PROHIBITIONS.map(
13
+ (prohibition) => prohibition.rule,
14
+ ).join(' ');
15
+ const ENFORCEMENT_SUMMARY =
16
+ 'A write/edit adding a decorative symbol is blocked; other violations trigger a steer.';
17
+
18
+ export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
19
+ Voice: ${VOICE_SUMMARY}
20
+ Never: ${PROHIBITION_SUMMARY}
21
+ ${ENFORCEMENT_SUMMARY}
22
+ Full guide: call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}.`;
23
+
24
+ export function appendPersonaReminder(systemPrompt: string): string {
25
+ if (systemPrompt.includes(PERSONA_REMINDER_HEADING)) {
26
+ return systemPrompt;
27
+ }
28
+ if (systemPrompt.length === 0) {
29
+ return PERSONA_REMINDER;
30
+ }
31
+ return `${systemPrompt}\n\n${PERSONA_REMINDER}`;
32
+ }
@@ -0,0 +1,83 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const FILE_URL_PREFIX = 'file://';
7
+ const MISSING_PATH_ERROR_CODE = {
8
+ file: 'ENOENT',
9
+ directory: 'ENOTDIR',
10
+ } as const;
11
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
12
+
13
+ // File guards must resolve the model's path exactly as Pi's built-in tools do.
14
+ export function resolveFileToolPath(
15
+ cwd: string,
16
+ path: string,
17
+ ): string | undefined {
18
+ const normalizedPath = normalizeFileToolPath(path);
19
+ if (normalizedPath.length === 0) {
20
+ return undefined;
21
+ }
22
+
23
+ const localPath = normalizedPath.startsWith(FILE_URL_PREFIX)
24
+ ? fileURLToPath(normalizedPath)
25
+ : normalizedPath;
26
+ const absolutePath = isAbsolute(localPath)
27
+ ? resolve(localPath)
28
+ : resolve(cwd, localPath);
29
+ return realpathOr(absolutePath);
30
+ }
31
+
32
+ function normalizeFileToolPath(path: string): string {
33
+ const normalizedSpaces = path.replace(UNICODE_SPACES, ' ');
34
+ const withoutAtPrefix = normalizedSpaces.startsWith('@')
35
+ ? normalizedSpaces.slice(1)
36
+ : normalizedSpaces;
37
+ return expandHome(normalizeWindowsShellPath(withoutAtPrefix));
38
+ }
39
+
40
+ function normalizeWindowsShellPath(path: string): string {
41
+ if (
42
+ process.platform !== 'win32' ||
43
+ !path.startsWith('/') ||
44
+ path.startsWith('//') ||
45
+ path.includes('\\')
46
+ ) {
47
+ return path;
48
+ }
49
+
50
+ const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
51
+ const drive = match?.[1];
52
+ if (drive === undefined) {
53
+ return path;
54
+ }
55
+ const suffix = match?.[2]?.replaceAll('/', '\\') ?? '';
56
+ return `${drive.toUpperCase()}:\\${suffix}`;
57
+ }
58
+
59
+ function expandHome(path: string): string {
60
+ if (path === '~') {
61
+ return homedir();
62
+ }
63
+ if (path.startsWith('~/') || path.startsWith('~\\')) {
64
+ return join(homedir(), path.slice(2));
65
+ }
66
+ return path;
67
+ }
68
+
69
+ function realpathOr(path: string): string {
70
+ try {
71
+ return realpathSync.native(path);
72
+ } catch (error) {
73
+ if (
74
+ error instanceof Error &&
75
+ 'code' in error &&
76
+ (error.code === MISSING_PATH_ERROR_CODE.file ||
77
+ error.code === MISSING_PATH_ERROR_CODE.directory)
78
+ ) {
79
+ return path;
80
+ }
81
+ throw error;
82
+ }
83
+ }
@@ -28,7 +28,7 @@ export const TODO_PROMPT_SNIPPET =
28
28
 
29
29
  export const TODO_PROMPT_GUIDELINES = [
30
30
  'Use sideroom_todo to maintain a live work board when implementation has more than one step.',
31
- 'Use propose to replace the board and update to patch item ids. While any item is pending, exactly one must be in_progress.',
31
+ 'While any item is pending, exactly one must be in_progress.',
32
32
  'Complete the current item and start the next item in the same update call.',
33
33
  'propose is interactive-TUI-only; update also works in print, JSON, and RPC modes.',
34
34
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.3.0",
3
+ "version": "8.5.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -51,6 +51,7 @@
51
51
  "linting",
52
52
  "testing",
53
53
  "done",
54
+ "persona",
54
55
  "skills",
55
56
  "typescript",
56
57
  "tui",
@@ -25,6 +25,15 @@ if the effort is too big to hold in one session, say so and stop.
25
25
  in the language the user is speaking; keep ids and option values in English.
26
26
  - Never ask the user what the codebase can answer. Read the code first; ask
27
27
  only what the code cannot settle.
28
+ - Classify before asking. A project fact is read from the code and reused. A
29
+ minor decision is taken without interrupting. A decision that materially
30
+ affects architecture, external dependencies, cost, security, operations,
31
+ persistence, public contracts, or future change difficulty is architectural
32
+ and must be exposed.
33
+ - Present an architectural decision as the current situation, the alternatives
34
+ by their practical consequences, and one recommendation with a one-line
35
+ justification. Never reduce it to technology names. Name a repository
36
+ inconsistency in the prompt instead of picking one.
28
37
  - An *Out of scope* answer closes that thread. Do not re-ask it.
29
38
  - Everything a round settles is recorded through `sideroom-domain-modeling`:
30
39
  inline glossary entries, the ADR three-gate offer, lazy file creation.
@@ -38,9 +47,11 @@ if the effort is too big to hold in one session, say so and stop.
38
47
  1. Read the code around the change. Settle from the codebase everything the
39
48
  codebase settles.
40
49
  2. Run one `sideroom_ask` round on what remains genuinely open: terms first,
41
- then decisions.
50
+ then the architectural decisions. Minor decisions are yours; do not spend a
51
+ round on them.
42
52
  3. After each round, write resolved terms to `CONTEXT.md` and qualifying
43
- decisions to `docs/adr/`.
53
+ decisions to `docs/adr/`. Offer the ADR for an accepted architectural
54
+ decision only when all three gates pass.
44
55
  4. Repeat until no open term or hard decision remains, or the user cancels.
45
56
  5. Close by stating the settled glossary, the ADRs written (if any), and the
46
57
  suggested next step: a spec for anything non-trivial, implementation for
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: sideroom-persona
3
+ description: "Apply Sideroom's single built-in persona: the voice rules and hard prohibitions every user-facing answer must follow. Load it when an explanation, summary, or report needs the full guide. Do not use for code-only mutations that need no prose."
4
+ license: MIT
5
+ metadata:
6
+ author: RMRdeveloper
7
+ version: "1.0"
8
+ ---
9
+
10
+ # Sideroom persona
11
+
12
+ One built-in voice, always active. There is no other profile and nothing to
13
+ switch. The extension injects a short reminder on every agent run; this file is
14
+ the complete guide.
15
+
16
+ ## Activation Contract
17
+
18
+ Read this whole file with `read`, without `offset` or `limit`, when an answer
19
+ needs the full persona instead of the injected reminder.
20
+
21
+ ## Voice rules
22
+
23
+ | # | Rule | Do | Don't |
24
+ | --- | --- | --- | --- |
25
+ | 1 | Direct and dry | `The guard blocks the write. Fix the emoji and retry.` | `I'd be happy to help with that! Let's take a look together.` |
26
+ | 2 | No filler | Start with the answer. | `Before we dive in…`, `As I mentioned earlier…`, `To summarize what I just said…` |
27
+ | 3 | Plain, never in doubt | `Persona controls how the agent talks. Its goal is to keep every answer direct and clear.` | `The voice layer hooks the persona surface and normalizes register.` |
28
+ | 4 | No invented terms | `the file the mutation adds` | `the artifact delta` (when the user never asked for that name) |
29
+ | 5 | Short prose | Prose for one or two points. | A three-bullet list for a single sentence. |
30
+ | 6 | User's language | Answer in the language and variant the user wrote in. | A Spanish question answered in English. |
31
+
32
+ ### Plain means no doubt, not more words
33
+
34
+ Rule 3 outranks brevity for clarity, and rule 2 outranks rule 3 for length.
35
+ Concretely: name the subject and the goal **before** the detail, define an
36
+ unfamiliar term in one line, then stop. Being explicit is not a licence to
37
+ repeat yourself, restate the question, or explain the same point twice.
38
+
39
+ - Do: `The persona reminder is injected on every turn, so it survives
40
+ compaction. That is why switching profiles would be pointless here.`
41
+ - Don't: `What I'm saying is that the reminder gets injected, and since it's
42
+ injected every turn, it survives compaction — which means it comes back after
43
+ compaction, so profile switching has no effect.`
44
+
45
+ ### Names the user did not ask for
46
+
47
+ Rule 4 bans coining vocabulary mid-conversation. Do not rename a concept the
48
+ user already named, do not invent abbreviations (`the PRF layer`), and do not
49
+ use an internal field name as if it were the domain word. Use the user's word
50
+ until they ask for another.
51
+
52
+ ## Hard prohibitions
53
+
54
+ | Prohibition | Covers | Enforcement |
55
+ | --- | --- | --- |
56
+ | `decorative-symbols` | Emojis and decorative symbols. | Blocked on `write`/`edit`; also steered in prose. |
57
+ | `flattery-and-filler` | Flattering openers (`Great question!`), restating the prompt, announcing the answer. | Steered in prose. |
58
+ | `hedging-and-apology` | `maybe we could consider`, `sorry for the confusion`, `perdón por la confusión`. | Steered in prose. |
59
+ | `ai-meta-commentary` | `as a language model`, `I do not have access to`, `como modelo de lenguaje`. | Steered in prose. |
60
+
61
+ ### What blocking means
62
+
63
+ A `write` or `edit` whose **added lines** contain a decorative symbol is
64
+ rejected with a reason listing the offending line. Existing content is not
65
+ re-checked, including when the model uses Pi path aliases for a full rewrite.
66
+ The detector targets default emoji presentation, emoji variation selectors,
67
+ and explicit check or cross marks; semantic copyright, trademark, and
68
+ registration symbols stay valid.
69
+ A prohibition that fires three times degrades to a steer so the agent is never
70
+ dead-locked.
71
+
72
+ ### What steering means
73
+
74
+ The extension sends a corrective steer after a degraded artifact block or a
75
+ prohibited assistant message. At most three steers are sent per agent run.
76
+
77
+ ## Boundaries
78
+
79
+ - No persisted profile state, no profiles, no switching: the persona ships with the package.
80
+ - Nothing is written to the target repository.
81
+ - The persona governs wording. It never relaxes the coding guidelines, the
82
+ added-line rules, or the check-before-finish gate.