@rmrdeveloper/sideroom-pi 8.7.0 → 8.9.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.
@@ -0,0 +1,281 @@
1
+ import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ type TUI,
7
+ truncateToWidth,
8
+ } from '@earendil-works/pi-tui';
9
+ import { JEV_STATUS, type JevStatus } from './guard.ts';
10
+ import { JEV_KEY_ENV_VAR, type JevKeySource } from './key.ts';
11
+
12
+ export const JEV_STATUS_KEY = 'sideroom-jev';
13
+ export const JEV_SHORTCUT = 'f10';
14
+
15
+ const MASK_CHARACTER = '•';
16
+ const CLEAR_KEY = Key.ctrl('u');
17
+
18
+ // Pi wraps every paste in bracketed-paste markers before it reaches a component,
19
+ // so the leading escape byte made the whole chunk look like a control sequence
20
+ // and nothing landed in the field.
21
+ const PASTE_START = '\x1b[200~';
22
+ const PASTE_END = '\x1b[201~';
23
+
24
+ // A pasted line usually carries a trailing newline, and no key holds
25
+ // whitespace, so everything unprintable is dropped.
26
+ export const MAX_KEY_LENGTH = 512;
27
+
28
+ const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
29
+ [JEV_STATUS.ready]: 'jev: ready',
30
+ [JEV_STATUS.noKey]: 'jev: no key (F10)',
31
+ [JEV_STATUS.quota]: 'jev: out of quota (F10)',
32
+ [JEV_STATUS.auth]: 'jev: key rejected (F10)',
33
+ [JEV_STATUS.unavailable]: 'jev: unreachable',
34
+ };
35
+
36
+ export interface JevKeyActions {
37
+ readonly store: (apiKey: string) => void;
38
+ readonly clear: () => void;
39
+ readonly retry: () => void;
40
+ readonly origin: () => JevKeySource['origin'] | undefined;
41
+ readonly usage: () => number;
42
+ readonly refresh: (ctx: ExtensionContext) => void;
43
+ }
44
+
45
+ export interface JevKeyController {
46
+ open(ctx: ExtensionContext): void;
47
+ }
48
+
49
+ export function statusLabel(
50
+ status: JevStatus,
51
+ model?: string,
52
+ calls = 0,
53
+ ): string {
54
+ const base =
55
+ status === JEV_STATUS.ready && model !== undefined
56
+ ? `jev: ready (${model})`
57
+ : STATUS_LABEL[status];
58
+ if (calls === 0) {
59
+ return base;
60
+ }
61
+ return `${base} · ${String(calls)} call${calls === 1 ? '' : 's'}`;
62
+ }
63
+
64
+ export function createJevKeyController(
65
+ actions: JevKeyActions,
66
+ ): JevKeyController {
67
+ let opening = false;
68
+
69
+ return {
70
+ open(ctx) {
71
+ if (opening) {
72
+ return;
73
+ }
74
+ if (ctx.mode !== 'tui') {
75
+ ctx.ui.notify(
76
+ `Set ${JEV_KEY_ENV_VAR} in the environment: capturing a key needs the terminal.`,
77
+ 'warning',
78
+ );
79
+ return;
80
+ }
81
+ opening = true;
82
+ void ctx.ui
83
+ .custom<void>(
84
+ (tui, theme, _keybindings, done) =>
85
+ new JevKeyOverlay(tui, theme, done, actions, ctx),
86
+ {
87
+ overlay: true,
88
+ overlayOptions: {
89
+ anchor: 'center',
90
+ width: '70%',
91
+ minWidth: 44,
92
+ maxHeight: '60%',
93
+ visible: (terminalWidth) => terminalWidth >= 60,
94
+ },
95
+ },
96
+ )
97
+ .finally(() => {
98
+ opening = false;
99
+ });
100
+ },
101
+ };
102
+ }
103
+
104
+ // The packaged Input component has no masking, and a key must never render, so
105
+ // this draws its own single masked field.
106
+ export class JevKeyOverlay implements Component {
107
+ private entry = '';
108
+ private readonly tui: TUI;
109
+ private readonly theme: Theme;
110
+ private readonly close: () => void;
111
+ private readonly actions: JevKeyActions;
112
+ private readonly ctx: ExtensionContext;
113
+
114
+ constructor(
115
+ tui: TUI,
116
+ theme: Theme,
117
+ close: () => void,
118
+ actions: JevKeyActions,
119
+ ctx: ExtensionContext,
120
+ ) {
121
+ this.tui = tui;
122
+ this.theme = theme;
123
+ this.close = close;
124
+ this.actions = actions;
125
+ this.ctx = ctx;
126
+ }
127
+
128
+ handleInput(keyData: string): void {
129
+ const pasted = readPastedKey(keyData);
130
+ if (pasted !== undefined) {
131
+ this.append(pasted);
132
+ return;
133
+ }
134
+ if (matchesKey(keyData, Key.escape)) {
135
+ this.close();
136
+ return;
137
+ }
138
+ if (matchesKey(keyData, Key.enter)) {
139
+ this.save();
140
+ return;
141
+ }
142
+ if (matchesKey(keyData, Key.backspace)) {
143
+ this.entry = this.entry.slice(0, -1);
144
+ this.tui.requestRender();
145
+ return;
146
+ }
147
+ if (matchesKey(keyData, CLEAR_KEY)) {
148
+ this.clearStored();
149
+ return;
150
+ }
151
+ if (isTypedCharacter(keyData)) {
152
+ this.append(keyData);
153
+ }
154
+ }
155
+
156
+ private append(text: string): void {
157
+ if (text.length === 0) {
158
+ return;
159
+ }
160
+ this.entry = `${this.entry}${text}`.slice(0, MAX_KEY_LENGTH);
161
+ this.tui.requestRender();
162
+ }
163
+
164
+ render(width: number): string[] {
165
+ const innerWidth = Math.max(1, width - 2);
166
+ const border = (text: string) => this.theme.fg('border', text);
167
+ const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
168
+ const row = (text: string) => `${border('│')}${line(text)}${border('│')}`;
169
+
170
+ const lines = [
171
+ border(`╭${'─'.repeat(innerWidth)}╮`),
172
+ row(this.theme.fg('accent', this.theme.bold(' Jev API key '))),
173
+ border(`├${'─'.repeat(innerWidth)}┤`),
174
+ row(` ${MASK_CHARACTER.repeat(this.entry.length)}`),
175
+ row(this.theme.fg('dim', ` source: ${this.originLabel()}`)),
176
+ row(
177
+ this.theme.fg(
178
+ 'dim',
179
+ ` calls this session: ${String(this.actions.usage())}`,
180
+ ),
181
+ ),
182
+ border(`├${'─'.repeat(innerWidth)}┤`),
183
+ row(
184
+ this.theme.fg(
185
+ 'dim',
186
+ ' The key is stored in sideroom.json, never in the session.',
187
+ ),
188
+ ),
189
+ row(
190
+ this.theme.fg(
191
+ 'dim',
192
+ ` ${JEV_KEY_ENV_VAR} wins over the file when both are set.`,
193
+ ),
194
+ ),
195
+ border(`├${'─'.repeat(innerWidth)}┤`),
196
+ row(this.theme.fg('dim', ' Enter save · Ctrl+U clear · Esc close')),
197
+ border(`╰${'─'.repeat(innerWidth)}╯`),
198
+ ];
199
+ return lines;
200
+ }
201
+
202
+ invalidate(): void {}
203
+
204
+ private save(): void {
205
+ const apiKey = this.entry.trim();
206
+ if (apiKey.length === 0) {
207
+ return;
208
+ }
209
+ this.actions.store(apiKey);
210
+ this.finish('Jev API key stored.');
211
+ }
212
+
213
+ private clearStored(): void {
214
+ this.entry = '';
215
+ this.actions.clear();
216
+ this.finish('Jev API key cleared.');
217
+ }
218
+
219
+ private finish(message: string): void {
220
+ this.actions.retry();
221
+ this.actions.refresh(this.ctx);
222
+ this.ctx.ui.notify(message, 'info');
223
+ this.close();
224
+ }
225
+
226
+ private originLabel(): string {
227
+ const origin = this.actions.origin();
228
+ if (origin === 'environment') {
229
+ return `from ${JEV_KEY_ENV_VAR}`;
230
+ }
231
+ if (origin === 'config') {
232
+ return 'stored in sideroom.json';
233
+ }
234
+ return 'not configured';
235
+ }
236
+ }
237
+
238
+ // Pi re-wraps paste content as `ESC[200~ ... ESC[201~`, so the payload is read
239
+ // between the markers. A paste split across chunks still contributes whatever
240
+ // arrived after the opening marker.
241
+ export function readPastedKey(keyData: string): string | undefined {
242
+ const start = keyData.indexOf(PASTE_START);
243
+ if (start === -1) {
244
+ return undefined;
245
+ }
246
+ const from = start + PASTE_START.length;
247
+ const end = keyData.indexOf(PASTE_END, from);
248
+ const body = end === -1 ? keyData.slice(from) : keyData.slice(from, end);
249
+ return printableOnly(body);
250
+ }
251
+
252
+ // C0 control bytes and DEL are the only boundaries that matter here, and they
253
+ // live in one place so the two input paths cannot drift apart.
254
+ function isControlCode(code: number): boolean {
255
+ return code < 0x20 || code === 0x7f;
256
+ }
257
+
258
+ // Paste drops the space as well: no key holds one, and a pasted line carries a
259
+ // trailing newline that would otherwise sit right before the closing marker.
260
+ function printableOnly(text: string): string {
261
+ let printable = '';
262
+ for (const character of text) {
263
+ const code = character.codePointAt(0) ?? 0;
264
+ if (!isControlCode(code) && character !== ' ') {
265
+ printable += character;
266
+ }
267
+ }
268
+ return printable;
269
+ }
270
+
271
+ function isTypedCharacter(keyData: string): boolean {
272
+ if (keyData.length === 0) {
273
+ return false;
274
+ }
275
+ for (const character of keyData) {
276
+ if (isControlCode(character.codePointAt(0) ?? 0)) {
277
+ return false;
278
+ }
279
+ }
280
+ return true;
281
+ }
@@ -0,0 +1,9 @@
1
+ const END_OF_OPTIONS = '--';
2
+ const NO_SKILLS_FLAGS = new Set(['--no-skills', '-ns']);
3
+
4
+ export function areSkillsDisabled(argv: readonly string[]): boolean {
5
+ const endOfOptions = argv.indexOf(END_OF_OPTIONS);
6
+ const optionArguments =
7
+ endOfOptions === -1 ? argv : argv.slice(0, endOfOptions);
8
+ return optionArguments.some((argument) => NO_SKILLS_FLAGS.has(argument));
9
+ }
@@ -0,0 +1,36 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { areSkillsDisabled } from './flags.ts';
3
+ import { appendMonorepoSkillsNote } from './prompt.ts';
4
+ import { findMonorepoSkillPaths } from './scan.ts';
5
+
6
+ // Pi loads extensions/*/index.ts through export default.
7
+ export default function registerMonorepoSkills(pi: ExtensionAPI): void {
8
+ let hasMonorepoSkills = false;
9
+
10
+ pi.on('resources_discover', (event, ctx) => {
11
+ hasMonorepoSkills = false;
12
+ if (!ctx.isProjectTrusted() || areSkillsDisabled(process.argv)) {
13
+ return;
14
+ }
15
+
16
+ const skillPaths = findMonorepoSkillPaths(event.cwd);
17
+ if (skillPaths.length === 0) {
18
+ return;
19
+ }
20
+
21
+ hasMonorepoSkills = true;
22
+ return { skillPaths };
23
+ });
24
+
25
+ pi.on('before_agent_start', (event) => {
26
+ if (!hasMonorepoSkills) {
27
+ return;
28
+ }
29
+
30
+ const systemPrompt = appendMonorepoSkillsNote(event.systemPrompt);
31
+ if (systemPrompt === event.systemPrompt) {
32
+ return;
33
+ }
34
+ return { systemPrompt };
35
+ });
36
+ }
@@ -0,0 +1,14 @@
1
+ export const MONOREPO_SKILLS_NOTE_HEADING = 'Sideroom monorepo skills:';
2
+
3
+ export const MONOREPO_SKILLS_NOTE = `${MONOREPO_SKILLS_NOTE_HEADING}
4
+ Some available skills come from child folders of this repository. When skills cover the same work, prefer the skill whose location is inside the folder you are editing.`;
5
+
6
+ export function appendMonorepoSkillsNote(systemPrompt: string): string {
7
+ if (systemPrompt.includes(MONOREPO_SKILLS_NOTE_HEADING)) {
8
+ return systemPrompt;
9
+ }
10
+ if (systemPrompt.length === 0) {
11
+ return MONOREPO_SKILLS_NOTE;
12
+ }
13
+ return `${systemPrompt}\n\n${MONOREPO_SKILLS_NOTE}`;
14
+ }
@@ -0,0 +1,225 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join, relative, resolve, sep } from 'node:path';
3
+ import ignore from 'ignore';
4
+
5
+ const MAX_CHILD_DEPTH = 3;
6
+ const IGNORE_FILE_NAMES = ['.gitignore', '.ignore', '.fdignore'] as const;
7
+ const NODE_MODULES_DIRECTORY = 'node_modules';
8
+ const PI_DIRECTORY = '.pi';
9
+ const AGENTS_DIRECTORY = '.agents';
10
+ const SKILLS_DIRECTORY = 'skills';
11
+ const SKILL_FILE_NAME = 'SKILL.md';
12
+ const MARKDOWN_EXTENSION = '.md';
13
+
14
+ export function findMonorepoSkillPaths(cwd: string): string[] {
15
+ const rootDirectory = resolve(cwd);
16
+ const childDirectories = collectChildDirectories(
17
+ rootDirectory,
18
+ rootDirectory,
19
+ 0,
20
+ [],
21
+ );
22
+ childDirectories.sort((left, right) =>
23
+ compareText(relative(rootDirectory, left), relative(rootDirectory, right)),
24
+ );
25
+
26
+ const skillPaths: string[] = [];
27
+ for (const childDirectory of childDirectories) {
28
+ const piSkillsDirectory = join(
29
+ childDirectory,
30
+ PI_DIRECTORY,
31
+ SKILLS_DIRECTORY,
32
+ );
33
+ if (isDirectory(piSkillsDirectory)) {
34
+ skillPaths.push(piSkillsDirectory);
35
+ }
36
+
37
+ const agentsSkillsDirectory = join(
38
+ childDirectory,
39
+ AGENTS_DIRECTORY,
40
+ SKILLS_DIRECTORY,
41
+ );
42
+ if (isDirectory(agentsSkillsDirectory)) {
43
+ skillPaths.push(...collectAgentsSkillPaths(agentsSkillsDirectory));
44
+ }
45
+ }
46
+ return skillPaths;
47
+ }
48
+
49
+ function collectChildDirectories(
50
+ directory: string,
51
+ rootDirectory: string,
52
+ depth: number,
53
+ inheritedIgnorePatterns: readonly string[],
54
+ ): string[] {
55
+ if (depth >= MAX_CHILD_DEPTH) {
56
+ return [];
57
+ }
58
+
59
+ const ignorePatterns = [
60
+ ...inheritedIgnorePatterns,
61
+ ...readIgnoreRules(directory, rootDirectory),
62
+ ];
63
+ const ignoreMatcher = ignore().add(ignorePatterns);
64
+ const childDirectories: string[] = [];
65
+ for (const entry of readDirectoryEntries(directory)) {
66
+ if (!entry.isDirectory() || isTraversalDirectory(entry.name)) {
67
+ continue;
68
+ }
69
+
70
+ const childDirectory = join(directory, entry.name);
71
+ const relativeChild = toPosixPath(relative(rootDirectory, childDirectory));
72
+ if (ignoreMatcher.ignores(`${relativeChild}/`)) {
73
+ continue;
74
+ }
75
+
76
+ childDirectories.push(
77
+ childDirectory,
78
+ ...collectChildDirectories(
79
+ childDirectory,
80
+ rootDirectory,
81
+ depth + 1,
82
+ ignorePatterns,
83
+ ),
84
+ );
85
+ }
86
+ return childDirectories;
87
+ }
88
+
89
+ function collectAgentsSkillPaths(skillsDirectory: string): string[] {
90
+ return collectAgentsSkillPathsFromDirectory(
91
+ skillsDirectory,
92
+ skillsDirectory,
93
+ [],
94
+ );
95
+ }
96
+
97
+ function collectAgentsSkillPathsFromDirectory(
98
+ directory: string,
99
+ rootDirectory: string,
100
+ inheritedIgnorePatterns: readonly string[],
101
+ ): string[] {
102
+ const ignorePatterns = [
103
+ ...inheritedIgnorePatterns,
104
+ ...readIgnoreRules(directory, rootDirectory),
105
+ ];
106
+ const ignoreMatcher = ignore().add(ignorePatterns);
107
+ const entries = readDirectoryEntries(directory);
108
+ const declaredSkill = entries.find((entry) => {
109
+ if (entry.name !== SKILL_FILE_NAME || !entry.isFile()) {
110
+ return false;
111
+ }
112
+ const relativeSkill = toPosixPath(
113
+ relative(rootDirectory, join(directory, entry.name)),
114
+ );
115
+ return !ignoreMatcher.ignores(relativeSkill);
116
+ });
117
+ if (declaredSkill) {
118
+ return [join(directory, declaredSkill.name)];
119
+ }
120
+
121
+ const skillPaths: string[] = [];
122
+ for (const entry of entries) {
123
+ if (isTraversalDirectory(entry.name)) {
124
+ continue;
125
+ }
126
+
127
+ const entryPath = join(directory, entry.name);
128
+ const relativeEntry = toPosixPath(relative(rootDirectory, entryPath));
129
+ if (entry.isFile()) {
130
+ if (
131
+ directory !== rootDirectory &&
132
+ entry.name.endsWith(MARKDOWN_EXTENSION) &&
133
+ !ignoreMatcher.ignores(relativeEntry)
134
+ ) {
135
+ skillPaths.push(entryPath);
136
+ }
137
+ continue;
138
+ }
139
+ if (!entry.isDirectory() || ignoreMatcher.ignores(`${relativeEntry}/`)) {
140
+ continue;
141
+ }
142
+ skillPaths.push(
143
+ ...collectAgentsSkillPathsFromDirectory(
144
+ entryPath,
145
+ rootDirectory,
146
+ ignorePatterns,
147
+ ),
148
+ );
149
+ }
150
+ return skillPaths;
151
+ }
152
+
153
+ function readIgnoreRules(directory: string, rootDirectory: string): string[] {
154
+ const relativeDirectory = relative(rootDirectory, directory);
155
+ const prefix = relativeDirectory ? `${toPosixPath(relativeDirectory)}/` : '';
156
+ const ignorePatterns: string[] = [];
157
+
158
+ for (const fileName of IGNORE_FILE_NAMES) {
159
+ const ignorePath = join(directory, fileName);
160
+ if (!existsSync(ignorePath)) {
161
+ continue;
162
+ }
163
+
164
+ ignorePatterns.push(
165
+ ...readFileSync(ignorePath, 'utf8')
166
+ .split(/\r?\n/)
167
+ .map((line) => prefixIgnorePattern(line, prefix))
168
+ .filter((pattern): pattern is string => pattern !== undefined),
169
+ );
170
+ }
171
+ return ignorePatterns;
172
+ }
173
+
174
+ function prefixIgnorePattern(line: string, prefix: string): string | undefined {
175
+ const trimmed = line.trim();
176
+ if (trimmed.length === 0) {
177
+ return undefined;
178
+ }
179
+ if (trimmed.startsWith('#') && !trimmed.startsWith('\\#')) {
180
+ return undefined;
181
+ }
182
+
183
+ let pattern = line;
184
+ let negated = false;
185
+ if (pattern.startsWith('!')) {
186
+ negated = true;
187
+ pattern = pattern.slice(1);
188
+ } else if (pattern.startsWith('\\!')) {
189
+ pattern = pattern.slice(1);
190
+ }
191
+ if (pattern.startsWith('/')) {
192
+ pattern = pattern.slice(1);
193
+ }
194
+
195
+ const prefixed = prefix ? `${prefix}${pattern}` : pattern;
196
+ return negated ? `!${prefixed}` : prefixed;
197
+ }
198
+
199
+ function readDirectoryEntries(directory: string) {
200
+ return readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
201
+ compareText(left.name, right.name),
202
+ );
203
+ }
204
+
205
+ function isDirectory(path: string): boolean {
206
+ return existsSync(path) && lstatSync(path).isDirectory();
207
+ }
208
+
209
+ function isTraversalDirectory(name: string): boolean {
210
+ return name.startsWith('.') || name === NODE_MODULES_DIRECTORY;
211
+ }
212
+
213
+ function toPosixPath(path: string): string {
214
+ return path.split(sep).join('/');
215
+ }
216
+
217
+ function compareText(left: string, right: string): number {
218
+ if (left < right) {
219
+ return -1;
220
+ }
221
+ if (left > right) {
222
+ return 1;
223
+ }
224
+ return 0;
225
+ }
@@ -32,29 +32,29 @@ export interface VoiceRule {
32
32
  }
33
33
 
34
34
  export const VOICE_RULES: readonly VoiceRule[] = [
35
+ {
36
+ id: 'plain-language',
37
+ instruction:
38
+ 'Explain in plain words: name the subject and goal before the detail, prefer everyday terms over concept jargon (hook, pipeline, registry, resolver, guardrail, invariant) unless the user used them, define any necessary term in one line, and be explicit without repetition. Never write tangled chains such as "the registry resolves the provider through the auth resolver".',
39
+ },
35
40
  {
36
41
  id: 'direct',
37
42
  instruction:
38
- 'Be direct and dry. Use short sentences. No preamble or closing summary.',
43
+ 'Be direct and dry. Short sentences. No preamble or closing summary.',
39
44
  },
40
45
  {
41
46
  id: 'no-filler',
42
47
  instruction: 'No filler.',
43
48
  },
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
49
  {
50
50
  id: 'no-invented-terms',
51
51
  instruction:
52
- 'Name things as they are. Add no intermediate terms, abbreviations, or codenames unless the user asks.',
52
+ 'Name things as they are. Coin no intermediate term, abbreviation, codename, or technical concept word the user did not ask for.',
53
53
  },
54
54
  {
55
55
  id: 'short-prose',
56
56
  instruction:
57
- 'Default to short prose. Use lists or tables only for comparisons or more than three items.',
57
+ 'Default to short prose. Lists or tables only to compare options or list more than three items.',
58
58
  },
59
59
  {
60
60
  id: 'user-language',
@@ -1,10 +1,11 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import {
3
2
  type ExtensionAPI,
3
+ type ExtensionContext,
4
4
  isToolCallEventType,
5
5
  type ToolCallEvent,
6
6
  } from '@earendil-works/pi-coding-agent';
7
7
  import { resolveFileToolPath } from '../shared/file-path.ts';
8
+ import { readFileIfExists } from '../shared/read-file.ts';
8
9
  import {
9
10
  PERSONA_STEER_TYPE,
10
11
  type ProhibitionId,
@@ -38,29 +39,41 @@ export interface PersonaGuardState {
38
39
  readonly fires: Map<ProhibitionId, number>;
39
40
  cleanMutations: number;
40
41
  steersThisRun: number;
42
+ blocksThisRun: number;
41
43
  }
42
44
 
43
45
  export function createPersonaGuardState(): PersonaGuardState {
44
- return { fires: new Map(), cleanMutations: 0, steersThisRun: 0 };
46
+ return {
47
+ fires: new Map(),
48
+ cleanMutations: 0,
49
+ steersThisRun: 0,
50
+ blocksThisRun: 0,
51
+ };
45
52
  }
46
53
 
47
54
  export function resetPersonaRun(state: PersonaGuardState): void {
48
55
  state.steersThisRun = 0;
56
+ state.blocksThisRun = 0;
49
57
  }
50
58
 
51
59
  export function registerPersonaGuard(
52
60
  pi: ExtensionAPI,
53
61
  state: PersonaGuardState,
62
+ report: (ctx: ExtensionContext) => void,
54
63
  ): void {
55
64
  pi.on('tool_call', (event, ctx) => {
56
65
  const mutation = extractMutation(event, ctx.cwd);
57
66
  if (mutation === undefined) {
58
67
  return undefined;
59
68
  }
60
- return applyArtifactOutcome(pi, state, mutation);
69
+ const blockDecision = applyArtifactOutcome(pi, state, mutation);
70
+ if (blockDecision !== undefined) {
71
+ report(ctx);
72
+ }
73
+ return blockDecision;
61
74
  });
62
75
 
63
- pi.on('message_end', (event) => {
76
+ pi.on('message_end', (event, ctx) => {
64
77
  if (event.message.role !== 'assistant') {
65
78
  return undefined;
66
79
  }
@@ -73,6 +86,7 @@ export function registerPersonaGuard(
73
86
  }
74
87
 
75
88
  sendPersonaSteer(pi, state, formatSteer(violations));
89
+ report(ctx);
76
90
  return undefined;
77
91
  });
78
92
  }
@@ -92,6 +106,7 @@ function applyArtifactOutcome(
92
106
  (violation) => !isDegraded(state, violation.prohibition.id),
93
107
  );
94
108
  if (blocking.length > 0) {
109
+ state.blocksThisRun += 1;
95
110
  for (const violation of blocking) {
96
111
  incrementFires(state, violation.prohibition.id);
97
112
  }
@@ -126,17 +141,6 @@ function extractMutation(
126
141
  return undefined;
127
142
  }
128
143
 
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
144
  function isDegraded(state: PersonaGuardState, id: ProhibitionId): boolean {
141
145
  return (state.fires.get(id) ?? 0) >= BLOCK_DEGRADE_AFTER;
142
146
  }
@@ -156,7 +160,7 @@ function sendPersonaSteer(
156
160
  state.steersThisRun += 1;
157
161
  pi.sendMessage(
158
162
  { customType: PERSONA_STEER_TYPE, content, display: false },
159
- { deliverAs: 'steer' },
163
+ { triggerTurn: true, deliverAs: 'steer' },
160
164
  );
161
165
  }
162
166