@rmrdeveloper/sideroom-pi 9.0.1 → 9.1.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 (62) hide show
  1. package/README.md +11 -15
  2. package/assets/visual-context/browser/background.js +2 -2
  3. package/assets/visual-context/browser/popup.js +1 -2
  4. package/extensions/ask/index.ts +0 -1
  5. package/extensions/ask/model.ts +1 -1
  6. package/extensions/ask/selection.ts +0 -4
  7. package/extensions/done/guard.ts +20 -0
  8. package/extensions/done/index.ts +0 -1
  9. package/extensions/done/model.ts +1 -3
  10. package/extensions/explain/guard.ts +11 -21
  11. package/extensions/explain/index.ts +0 -1
  12. package/extensions/explain/model.ts +0 -5
  13. package/extensions/guidelines/execute.ts +16 -2
  14. package/extensions/guidelines/guard.ts +1 -2
  15. package/extensions/guidelines/index.ts +7 -7
  16. package/extensions/guidelines/prompt.ts +0 -2
  17. package/extensions/guidelines/review.ts +9 -10
  18. package/extensions/jev/client.ts +0 -7
  19. package/extensions/jev/context.ts +0 -2
  20. package/extensions/jev/guard.ts +78 -44
  21. package/extensions/jev/index.ts +0 -3
  22. package/extensions/jev/key.ts +0 -3
  23. package/extensions/jev/model.ts +0 -8
  24. package/extensions/jev/queue.ts +125 -0
  25. package/extensions/jev/ui.ts +0 -14
  26. package/extensions/modified-files/index.ts +0 -1
  27. package/extensions/monorepo-skills/index.ts +0 -1
  28. package/extensions/persona/checks.ts +0 -4
  29. package/extensions/persona/guard.ts +45 -6
  30. package/extensions/persona/index.ts +4 -2
  31. package/extensions/persona/model.ts +0 -2
  32. package/extensions/persona/prompt.ts +0 -2
  33. package/extensions/rules/checks.ts +0 -5
  34. package/extensions/rules/index.ts +0 -1
  35. package/extensions/rules/lexer.ts +1 -1
  36. package/extensions/shared/check-status.ts +35 -0
  37. package/extensions/shared/file-path.ts +0 -3
  38. package/extensions/shared/read-file.ts +0 -2
  39. package/extensions/shared/settle.ts +78 -13
  40. package/extensions/todo/guards.ts +40 -84
  41. package/extensions/todo/index.ts +1 -2
  42. package/extensions/todo/model.ts +6 -48
  43. package/extensions/todo/session.ts +1 -4
  44. package/extensions/visual-context/broker.ts +10 -10
  45. package/extensions/visual-context/configure.ts +4 -4
  46. package/extensions/visual-context/files.ts +0 -3
  47. package/extensions/visual-context/index.ts +8 -9
  48. package/extensions/visual-context/literal.ts +0 -1
  49. package/extensions/visual-context/markup.ts +3 -5
  50. package/extensions/visual-context/model.ts +2 -2
  51. package/extensions/visual-context/pairing.ts +1 -2
  52. package/extensions/visual-context/parse-astro.ts +9 -12
  53. package/extensions/visual-context/parse-jsx.ts +2 -2
  54. package/extensions/visual-context/parse-vue.ts +1 -1
  55. package/extensions/visual-context/setup.ts +8 -8
  56. package/extensions/visual-context/source.ts +0 -3
  57. package/package.json +14 -8
  58. package/skills/sideroom-guidelines/SKILL.md +1 -1
  59. package/skills/sideroom-persona/SKILL.md +8 -6
  60. package/skills/sideroom-user-stories/SKILL.md +78 -0
  61. package/skills/sideroom-user-stories/references/review.md +84 -0
  62. package/skills/sideroom-user-stories/references/templates.md +131 -0
@@ -13,7 +13,8 @@ import {
13
13
  } from '../shared/file-path.ts';
14
14
  import { readFileIfExists } from '../shared/read-file.ts';
15
15
  import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
16
- import { startsUserTurn } from '../shared/settle.ts';
16
+ import { registerSettleWait, startsUserTurn } from '../shared/settle.ts';
17
+ import { JevReviewQueue } from './queue.ts';
17
18
  import {
18
19
  askJevRequest,
19
20
  JEV_FAILURE,
@@ -78,6 +79,7 @@ export interface JevGuardState {
78
79
  rateLimitedThisTurn: boolean;
79
80
  lastModel: string | undefined;
80
81
  calls: number;
82
+ readonly queue: JevReviewQueue;
81
83
  readonly evidence: WorkflowEvidence;
82
84
  readonly pending: Map<string, PendingMutation>;
83
85
  readonly notedThisTurn: Set<string>;
@@ -96,23 +98,23 @@ export function createJevGuardState(): JevGuardState {
96
98
  rateLimitedThisTurn: false,
97
99
  lastModel: undefined,
98
100
  calls: 0,
101
+ queue: new JevReviewQueue(),
99
102
  evidence: createWorkflowEvidence(),
100
103
  pending: new Map(),
101
104
  notedThisTurn: new Set(),
102
105
  };
103
106
  }
104
107
 
105
- // The F10 screen calls this after the user stores a key or asks to retry.
106
108
  export function resetJevGuard(state: JevGuardState): void {
109
+ state.queue.cancel();
107
110
  resetWorkflowEvidence(state.evidence);
108
111
  state.halted = undefined;
109
112
  state.transientFailures = 0;
110
113
  state.rateLimitedThisTurn = false;
111
114
  }
112
115
 
113
- // Usage and per-turn memory belong to the active session: a new session starts
114
- // counting from zero. The breaker stays, because it tracks the key.
115
116
  export function resetJevSession(state: JevGuardState): void {
117
+ state.queue.cancel();
116
118
  resetWorkflowEvidence(state.evidence);
117
119
  state.calls = 0;
118
120
  state.rateLimitedThisTurn = false;
@@ -145,23 +147,23 @@ export function registerJevGuard(
145
147
  state: JevGuardState,
146
148
  dependencies: JevGuardDependencies,
147
149
  ): void {
150
+ registerSettleWait(pi, () => state.queue.drain());
151
+ pi.on('session_shutdown', () => state.queue.cancel());
152
+
148
153
  pi.on('input', (event) => {
149
154
  if (!startsUserTurn(event)) {
150
155
  return;
151
156
  }
157
+ state.queue.cancel();
152
158
  resetWorkflowEvidence(state.evidence);
153
159
  state.notedThisTurn.clear();
154
160
  state.rateLimitedThisTurn = false;
155
161
  });
156
162
 
157
- // A call another extension blocks in tool_call never reaches tool_result,
158
- // and every result of a turn lands before its turn_end.
159
163
  pi.on('turn_end', () => {
160
164
  state.pending.clear();
161
165
  });
162
166
 
163
- // The added lines are read before the mutation lands, because after a write
164
- // the previous content is gone. The body is read after, from disk.
165
167
  pi.on('tool_call', (event, ctx) => {
166
168
  const mutation = extractMutation(event, ctx.cwd);
167
169
  if (mutation === undefined || !isReviewablePath(mutation.relativePath)) {
@@ -171,9 +173,13 @@ export function registerJevGuard(
171
173
  return undefined;
172
174
  });
173
175
 
174
- pi.on('tool_result', async (event, ctx) => {
176
+ pi.on('tool_result', (event, ctx) => {
175
177
  const pending = state.pending.get(event.toolCallId);
176
178
  state.pending.delete(event.toolCallId);
179
+ if (!event.isError && pending !== undefined) {
180
+ state.queue.invalidate(pending.relativePath);
181
+ state.queue.invalidate('sideroom_todo');
182
+ }
177
183
  const apiKey = dependencies.apiKey();
178
184
  if (
179
185
  apiKey === undefined ||
@@ -196,6 +202,7 @@ export function registerJevGuard(
196
202
  const check = detectCheckCommand(ctx.cwd);
197
203
  if (check !== undefined && commandMatches(event.input.command, check)) {
198
204
  state.evidence.checks = 'failed';
205
+ state.queue.invalidate('sideroom_todo');
199
206
  }
200
207
  }
201
208
  return undefined;
@@ -204,11 +211,13 @@ export function registerJevGuard(
204
211
  const check = detectCheckCommand(ctx.cwd);
205
212
  if (check !== undefined && commandMatches(event.input.command, check)) {
206
213
  state.evidence.checks = 'passed';
214
+ state.queue.invalidate('sideroom_todo');
207
215
  }
208
216
  return undefined;
209
217
  }
210
218
  let requestBody: JevRequestBody | undefined;
211
219
  let subject: string;
220
+ let sourcePaths: readonly string[] = [];
212
221
  if (event.toolName === 'sideroom_ask') {
213
222
  if (
214
223
  typeof event.details !== 'object' ||
@@ -231,6 +240,7 @@ export function registerJevGuard(
231
240
  changedIds: completionIds(event.input),
232
241
  });
233
242
  subject = 'sideroom_todo';
243
+ sourcePaths = [...state.evidence.files.keys()];
234
244
  } else {
235
245
  if (
236
246
  pending === undefined ||
@@ -240,7 +250,7 @@ export function registerJevGuard(
240
250
  }
241
251
  subject = pending.relativePath;
242
252
  state.evidence.checks = 'stale';
243
- // Replace cached evidence even when this version no longer fits the budget.
253
+ const previous = state.evidence.files.get(subject);
244
254
  state.evidence.files.delete(subject);
245
255
  if (!isWorthAsking(pending)) {
246
256
  state.evidence.partial = true;
@@ -256,7 +266,7 @@ export function registerJevGuard(
256
266
  subject,
257
267
  body,
258
268
  pending.kind,
259
- pending.lines,
269
+ mergeReviewLines({ previous: previous?.change.addedLines ?? [], added: pending.lines, body }),
260
270
  );
261
271
  if (fileState === undefined) {
262
272
  state.evidence.partial = true;
@@ -264,46 +274,74 @@ export function registerJevGuard(
264
274
  }
265
275
  recordFileEvidence(state.evidence, fileState);
266
276
  requestBody = buildRequestBody(fileState);
277
+ sourcePaths = [subject, ...fileState.context.files.map((file) => file.path)];
267
278
  }
268
279
  if (requestBody === undefined) {
269
280
  return undefined;
270
281
  }
271
282
 
272
- state.calls += 1;
273
- const attempt = await askJevRequest({
274
- transport: dependencies.transport,
275
- apiKey,
276
- requestBody,
283
+ const body = requestBody;
284
+ return state.queue.enqueue({
285
+ key: subject,
286
+ dependencies: sourcePaths,
277
287
  signal: ctx.signal,
278
- });
279
- if (attempt.failure !== undefined) {
280
- registerFailure(state, attempt.failure);
281
- dependencies.report(guardStatus(state), ctx);
282
- return undefined;
283
- }
288
+ run: async (signal) => {
289
+ if (!shouldCallJev(state) || dependencies.apiKey() !== apiKey || signal.aborted) {
290
+ return;
291
+ }
292
+ state.calls += 1;
293
+ const attempt = await askJevRequest({
294
+ transport: dependencies.transport,
295
+ apiKey,
296
+ requestBody: body,
297
+ signal,
298
+ });
299
+ if (signal.aborted || dependencies.apiKey() !== apiKey) {
300
+ return;
301
+ }
302
+ if (attempt.failure !== undefined) {
303
+ registerFailure(state, attempt.failure);
304
+ dependencies.report(guardStatus(state), ctx);
305
+ return undefined;
306
+ }
284
307
 
285
- registerSuccess(state);
286
- if (attempt.model !== undefined) {
287
- state.lastModel = attempt.model;
288
- }
289
- dependencies.report(JEV_STATUS.ready, ctx);
308
+ registerSuccess(state);
309
+ if (attempt.model !== undefined) {
310
+ state.lastModel = attempt.model;
311
+ }
312
+ dependencies.report(JEV_STATUS.ready, ctx);
290
313
 
291
- const findings = attempt.findings ?? [];
292
- const fresh = findings.filter((finding) =>
293
- isUnnoted(state, subject, finding),
294
- );
295
- if (fresh.length === 0) {
296
- return undefined;
297
- }
298
- markNoted(state, subject, fresh);
299
- const note: ReviewNote = {
300
- text: formatFindings(subject, fresh),
301
- };
302
- pi.events.emit(REVIEW_NOTE_EVENT, note);
303
- return undefined;
314
+ const findings = attempt.findings ?? [];
315
+ const fresh = findings.filter((finding) =>
316
+ isUnnoted(state, subject, finding),
317
+ );
318
+ if (fresh.length === 0) {
319
+ return undefined;
320
+ }
321
+ markNoted(state, subject, fresh);
322
+ const note: ReviewNote = {
323
+ text: formatFindings(subject, fresh),
324
+ };
325
+ pi.events.emit(REVIEW_NOTE_EVENT, note);
326
+ return undefined;
327
+ },
328
+ });
304
329
  });
305
330
  }
306
331
 
332
+ interface ReviewLinesInput {
333
+ readonly previous: readonly string[];
334
+ readonly added: readonly AddedLine[];
335
+ readonly body: string;
336
+ }
337
+
338
+ function mergeReviewLines({ previous, added, body }: ReviewLinesInput): readonly AddedLine[] {
339
+ const changed = new Set([...previous, ...added.map((line) => line.text)].map((text) => text.trim()));
340
+ return body.split('\n').flatMap((text, index) =>
341
+ changed.has(text.trim()) ? [{ line: index + 1, text }] : [],
342
+ );
343
+ }
344
+
307
345
  function isUnnoted(
308
346
  state: JevGuardState,
309
347
  path: string,
@@ -322,8 +360,6 @@ function markNoted(
322
360
  }
323
361
  }
324
362
 
325
- // A user abort says nothing about the service, and a rate limit clears on its
326
- // own, so neither may count toward switching Jev off.
327
363
  function registerFailure(state: JevGuardState, failure: JevFailure): void {
328
364
  if (failure === JEV_FAILURE.cancelled) {
329
365
  return;
@@ -351,8 +387,6 @@ function registerSuccess(state: JevGuardState): void {
351
387
  state.transientFailures = 0;
352
388
  }
353
389
 
354
- // A deletion-only edit leaves nothing for the questions to read, and only
355
- // the project-relative path of a supported language may leave the machine.
356
390
  function isWorthAsking(mutation: PendingMutation): boolean {
357
391
  return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
358
392
  }
@@ -26,13 +26,10 @@ import {
26
26
  statusLabel,
27
27
  } from './ui.ts';
28
28
 
29
- // Pi loads extensions/*/index.ts through export default.
30
29
  export default function registerJev(pi: ExtensionAPI): void {
31
30
  const configPath = configPathFor(getAgentDir());
32
31
  const guardState = createJevGuardState();
33
32
 
34
- // The key is read on each use instead of cached at load time, so a key stored
35
- // during a running session takes effect without a restart.
36
33
  const readKey = () => resolveApiKey(process.env, configPath);
37
34
 
38
35
  const currentStatus = (): JevStatus => {
@@ -17,9 +17,6 @@ export function configPathFor(agentDirectory: string): string {
17
17
  return join(agentDirectory, SIDEROOM_CONFIG_FILE);
18
18
  }
19
19
 
20
- // The environment wins so containers and CI work with no file at all. A missing
21
- // or malformed config means "no key"; anything else about the file is a real
22
- // problem and surfaces instead of hiding.
23
20
  export function resolveApiKey(
24
21
  environment: Readonly<Record<string, string | undefined>>,
25
22
  configPath: string,
@@ -6,14 +6,8 @@ export type { AddedLine } from '../rules/model.ts';
6
6
  export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
7
7
  export const JEV_MODEL = 'jev-latest';
8
8
 
9
- // A noul answer carries a probability and no confidence, so this cutoff is the
10
- // only brake. Below it Jev is saying it has nothing to go on, which would be
11
- // noise on every edit.
12
9
  export const VIOLATION_PROBABILITY = 0.8;
13
10
 
14
- // TypeSafe documents 32k tokens for state plus the longest question and 64k
15
- // for the full request. JSON UTF-8 bytes are a deliberately conservative
16
- // upper bound; no third-party tokenizer can promise the model's exact count.
17
11
  export const MAX_STATE_BYTES = 32_000;
18
12
  export const MAX_REQUEST_BYTES = 64_000;
19
13
 
@@ -30,7 +24,6 @@ export interface JevRule {
30
24
  readonly question: JevNoulQuestion;
31
25
  }
32
26
 
33
- // Questions must only use evidence in the file and the selected direct neighbours.
34
27
  export const JEV_RULES: readonly JevRule[] = [
35
28
  {
36
29
  id: 'guard-clauses',
@@ -290,7 +283,6 @@ export function addedLinesForEdit(
290
283
  return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
291
284
  }
292
285
 
293
- // Questions are about code structure; a README or a lockfile has no useful state.
294
286
  export function isReviewablePath(path: string): boolean {
295
287
  return languageForPath(path) !== LANGUAGE.generic;
296
288
  }
@@ -0,0 +1,125 @@
1
+ const MAX_QUEUED_REVIEWS = 16;
2
+
3
+ interface ReviewJob {
4
+ readonly key: string;
5
+ readonly dependencies?: readonly string[];
6
+ readonly signal?: AbortSignal;
7
+ readonly run: (signal: AbortSignal) => Promise<void>;
8
+ }
9
+
10
+ interface QueuedReview extends ReviewJob {
11
+ readonly controller: AbortController;
12
+ readonly signal: AbortSignal;
13
+ }
14
+
15
+ export class JevReviewQueue {
16
+ private readonly queued = new Map<string, QueuedReview>();
17
+ private active: QueuedReview | undefined;
18
+ private running: Promise<void> | undefined;
19
+ private failures: unknown[] = [];
20
+ private readonly waiting = new Set<QueuedReview>();
21
+
22
+ enqueue(job: ReviewJob): Promise<void> | undefined {
23
+ this.invalidate(job.key);
24
+ const controller = new AbortController();
25
+ const signal = job.signal === undefined
26
+ ? controller.signal
27
+ : AbortSignal.any([controller.signal, job.signal]);
28
+ const review = { ...job, controller, signal };
29
+ if (this.queued.size >= MAX_QUEUED_REVIEWS) {
30
+ this.waiting.add(review);
31
+ return this.enqueueWhenReady(review);
32
+ }
33
+ this.queued.set(job.key, review);
34
+ this.pump();
35
+ return undefined;
36
+ }
37
+
38
+ private async enqueueWhenReady(job: QueuedReview): Promise<void> {
39
+ try {
40
+ while (this.queued.size >= MAX_QUEUED_REVIEWS && this.running !== undefined && !job.signal.aborted) {
41
+ await this.running;
42
+ }
43
+ if (!job.signal.aborted) {
44
+ this.queued.set(job.key, job);
45
+ this.pump();
46
+ }
47
+ } finally {
48
+ this.waiting.delete(job);
49
+ }
50
+ }
51
+
52
+ invalidate(path: string): void {
53
+ for (const [key, job] of this.queued) {
54
+ if (key === path || job.dependencies?.includes(path)) {
55
+ job.controller.abort();
56
+ this.queued.delete(key);
57
+ }
58
+ }
59
+ for (const job of this.waiting) {
60
+ if (job.key === path || job.dependencies?.includes(path)) {
61
+ job.controller.abort();
62
+ }
63
+ }
64
+ if (this.active?.key === path || this.active?.dependencies?.includes(path)) {
65
+ this.active.controller.abort();
66
+ }
67
+ }
68
+
69
+ cancel(): void {
70
+ this.active?.controller.abort();
71
+ for (const job of this.queued.values()) {
72
+ job.controller.abort();
73
+ }
74
+ this.queued.clear();
75
+ for (const job of this.waiting) {
76
+ job.controller.abort();
77
+ }
78
+ this.waiting.clear();
79
+ this.failures = [];
80
+ }
81
+
82
+ async drain(): Promise<void> {
83
+ while (this.running !== undefined || this.waiting.size > 0) {
84
+ await this.running;
85
+ }
86
+ if (this.failures.length > 0) {
87
+ const failures = this.failures;
88
+ this.failures = [];
89
+ throw new AggregateError(failures, 'Jev background review failed');
90
+ }
91
+ }
92
+
93
+ private pump(): void {
94
+ if (this.running !== undefined) {
95
+ return;
96
+ }
97
+ const next = this.queued.entries().next().value;
98
+ if (next === undefined) {
99
+ return;
100
+ }
101
+ const [key, job] = next;
102
+ this.queued.delete(key);
103
+ if (job.signal.aborted) {
104
+ this.pump();
105
+ return;
106
+ }
107
+ this.active = job;
108
+ this.running = Promise.resolve()
109
+ .then(() => {
110
+ if (!job.signal.aborted) {
111
+ return job.run(job.signal);
112
+ }
113
+ })
114
+ .catch((error: unknown) => {
115
+ if (!job.signal.aborted) {
116
+ this.failures.push(error);
117
+ }
118
+ })
119
+ .finally(() => {
120
+ this.active = undefined;
121
+ this.running = undefined;
122
+ this.pump();
123
+ });
124
+ }
125
+ }
@@ -15,14 +15,9 @@ export const JEV_SHORTCUT = 'f10';
15
15
  const MASK_CHARACTER = '•';
16
16
  const CLEAR_KEY = Key.ctrl('u');
17
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
18
  const PASTE_START = '\x1b[200~';
22
19
  const PASTE_END = '\x1b[201~';
23
20
 
24
- // A pasted line usually carries a trailing newline, and no key holds
25
- // whitespace, so everything unprintable is dropped.
26
21
  export const MAX_KEY_LENGTH = 512;
27
22
 
28
23
  const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
@@ -102,8 +97,6 @@ export function createJevKeyController(
102
97
  };
103
98
  }
104
99
 
105
- // The packaged Input component has no masking, and a key must never render, so
106
- // this draws its own single masked field.
107
100
  export class JevKeyOverlay implements Component {
108
101
  private entry = '';
109
102
  private readonly tui: TUI;
@@ -236,9 +229,6 @@ export class JevKeyOverlay implements Component {
236
229
  }
237
230
  }
238
231
 
239
- // Pi re-wraps paste content as `ESC[200~ ... ESC[201~`, so the payload is read
240
- // between the markers. A paste split across chunks still contributes whatever
241
- // arrived after the opening marker.
242
232
  export function readPastedKey(keyData: string): string | undefined {
243
233
  const start = keyData.indexOf(PASTE_START);
244
234
  if (start === -1) {
@@ -250,14 +240,10 @@ export function readPastedKey(keyData: string): string | undefined {
250
240
  return printableOnly(body);
251
241
  }
252
242
 
253
- // C0 control bytes and DEL are the only boundaries that matter here, and they
254
- // live in one place so the two input paths cannot drift apart.
255
243
  function isControlCode(code: number): boolean {
256
244
  return code < 0x20 || code === 0x7f;
257
245
  }
258
246
 
259
- // Paste drops the space as well: no key holds one, and a pasted line carries a
260
- // trailing newline that would otherwise sit right before the closing marker.
261
247
  function printableOnly(text: string): string {
262
248
  let printable = '';
263
249
  for (const character of text) {
@@ -13,7 +13,6 @@ import { createModifiedFilesOverlayController } from './ui.ts';
13
13
 
14
14
  const SHORTCUT = 'f8';
15
15
 
16
- // Pi loads extensions/*/index.ts through export default.
17
16
  export default function registerModifiedFiles(pi: ExtensionAPI): void {
18
17
  const store = createModifiedFilesStore();
19
18
  const overlay = createModifiedFilesOverlayController(
@@ -3,7 +3,6 @@ import { areSkillsDisabled } from './flags.ts';
3
3
  import { appendMonorepoSkillsNote } from './prompt.ts';
4
4
  import { findMonorepoSkillPaths } from './scan.ts';
5
5
 
6
- // Pi loads extensions/*/index.ts through export default.
7
6
  export default function registerMonorepoSkills(pi: ExtensionAPI): void {
8
7
  let hasMonorepoSkills = false;
9
8
 
@@ -11,8 +11,6 @@ interface ProhibitionDetector {
11
11
  readonly pattern: RegExp;
12
12
  }
13
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
14
  const DETECTORS: readonly ProhibitionDetector[] = [
17
15
  {
18
16
  prohibitionId: 'decorative-symbols',
@@ -108,8 +106,6 @@ function countLines(content: string | undefined): Map<string, number> {
108
106
  return counts;
109
107
  }
110
108
 
111
- // Detector matches are short by construction; drop the bullet or indentation
112
- // they carry so the reason stays on one line.
113
109
  function excerptOf(match: string): string {
114
110
  const singleLine = match.replaceAll(/\s+/gu, ' ').trim();
115
111
  return singleLine.replace(/^[>*+-]+\s*/u, '');
@@ -6,6 +6,12 @@ import {
6
6
  } from '@earendil-works/pi-coding-agent';
7
7
  import { resolveFileToolPath } from '../shared/file-path.ts';
8
8
  import { readFileIfExists } from '../shared/read-file.ts';
9
+ import {
10
+ registerSettleGuard,
11
+ registerSettleSupplement,
12
+ SETTLE_PRIORITY,
13
+ withHiddenSteer,
14
+ } from '../shared/settle.ts';
9
15
  import {
10
16
  PERSONA_STEER_TYPE,
11
17
  type ProhibitionId,
@@ -23,11 +29,9 @@ import {
23
29
  formatSteer,
24
30
  } from './model.ts';
25
31
 
26
- // A rule that keeps firing degrades to a steer instead of trapping the agent.
27
32
  const BLOCK_DEGRADE_AFTER = 3;
28
33
  const CLEAN_RESET_AFTER = 5;
29
34
 
30
- // One agent run can be corrected at most this many times.
31
35
  const STEER_LIMIT_PER_RUN = 3;
32
36
 
33
37
  interface ExtractedMutation {
@@ -40,6 +44,7 @@ export interface PersonaGuardState {
40
44
  cleanMutations: number;
41
45
  steersThisRun: number;
42
46
  blocksThisRun: number;
47
+ pendingProse: string | undefined;
43
48
  }
44
49
 
45
50
  export function createPersonaGuardState(): PersonaGuardState {
@@ -48,12 +53,14 @@ export function createPersonaGuardState(): PersonaGuardState {
48
53
  cleanMutations: 0,
49
54
  steersThisRun: 0,
50
55
  blocksThisRun: 0,
56
+ pendingProse: undefined,
51
57
  };
52
58
  }
53
59
 
54
60
  export function resetPersonaRun(state: PersonaGuardState): void {
55
61
  state.steersThisRun = 0;
56
62
  state.blocksThisRun = 0;
63
+ state.pendingProse = undefined;
57
64
  }
58
65
 
59
66
  export function registerPersonaGuard(
@@ -73,10 +80,14 @@ export function registerPersonaGuard(
73
80
  return blockDecision;
74
81
  });
75
82
 
76
- pi.on('message_end', (event, ctx) => {
83
+ pi.on('message_end', (event) => {
77
84
  if (event.message.role !== 'assistant') {
78
85
  return undefined;
79
86
  }
87
+ state.pendingProse = undefined;
88
+ if (event.message.stopReason !== 'stop') {
89
+ return undefined;
90
+ }
80
91
  const violations = findViolations(
81
92
  assistantMessageText(event.message.content),
82
93
  TEXT_SCOPE.prose,
@@ -85,10 +96,38 @@ export function registerPersonaGuard(
85
96
  return undefined;
86
97
  }
87
98
 
88
- sendPersonaSteer(pi, state, formatSteer(violations));
89
- report(ctx);
99
+ state.pendingProse = formatSteer(violations);
90
100
  return undefined;
91
101
  });
102
+
103
+ const pending = () =>
104
+ state.pendingProse !== undefined &&
105
+ state.steersThisRun < STEER_LIMIT_PER_RUN;
106
+ const prepare = (ctx: ExtensionContext) => {
107
+ if (
108
+ state.pendingProse === undefined ||
109
+ state.steersThisRun >= STEER_LIMIT_PER_RUN
110
+ ) {
111
+ return undefined;
112
+ }
113
+ const content = state.pendingProse;
114
+ state.pendingProse = undefined;
115
+ state.steersThisRun += 1;
116
+ report(ctx);
117
+ return { customType: PERSONA_STEER_TYPE, content };
118
+ };
119
+ registerSettleSupplement(pi, prepare);
120
+ registerSettleGuard(pi, {
121
+ priority: SETTLE_PRIORITY.persona,
122
+ pending,
123
+ handle: (event, ctx) => {
124
+ const message = prepare(ctx);
125
+ if (message === undefined) {
126
+ return;
127
+ }
128
+ return withHiddenSteer({ event, ...message });
129
+ },
130
+ });
92
131
  }
93
132
 
94
133
  function applyArtifactOutcome(
@@ -160,7 +199,7 @@ function sendPersonaSteer(
160
199
  state.steersThisRun += 1;
161
200
  pi.sendMessage(
162
201
  { customType: PERSONA_STEER_TYPE, content, display: false },
163
- { triggerTurn: true, deliverAs: 'steer' },
202
+ { triggerTurn: false, deliverAs: 'steer' },
164
203
  );
165
204
  }
166
205
 
@@ -20,7 +20,6 @@ export const PERSONA_DESCRIPTION =
20
20
  export const PERSONA_PROMPT_SNIPPET =
21
21
  'Load the complete writing guide before substantial user-facing prose.';
22
22
 
23
- // Pi loads extensions/*/index.ts through export default.
24
23
  export default function registerPersona(pi: ExtensionAPI): void {
25
24
  const state = createPersonaGuardState();
26
25
  const publishStatus = (ctx: ExtensionContext): void => {
@@ -34,7 +33,10 @@ export default function registerPersona(pi: ExtensionAPI): void {
34
33
  };
35
34
  registerPersonaGuard(pi, state, publishStatus);
36
35
 
37
- pi.on('session_start', (_event, ctx) => publishStatus(ctx));
36
+ pi.on('session_start', (_event, ctx) => {
37
+ resetPersonaRun(state);
38
+ publishStatus(ctx);
39
+ });
38
40
 
39
41
  pi.on('before_agent_start', (event, ctx) => {
40
42
  resetPersonaRun(state);
@@ -1,8 +1,6 @@
1
1
  import { PERSONA_TOOL_NAME, PROHIBITIONS, VOICE_RULES } from './catalog.ts';
2
2
  import type { ProhibitionViolation } from './checks.ts';
3
3
 
4
- // Pi narrows these blocks before handing the message over; the shape is
5
- // structural so no runtime decoding is needed here.
6
4
  export interface TextBlock {
7
5
  readonly type: string;
8
6
  readonly text?: string;
@@ -15,8 +15,6 @@ const PROHIBITION_SUMMARY = PROHIBITIONS.map(
15
15
  const ENFORCEMENT_SUMMARY =
16
16
  'A write/edit adding a decorative symbol is blocked; other violations trigger a steer.';
17
17
 
18
- // Static Do/Don't pairs. Literals only: no per-turn data, so the reminder
19
- // stays byte-identical across turns and the provider prompt cache holds.
20
18
  export const PERSONA_EXAMPLES = [
21
19
  "Do: Your work list stays with this conversation, not in project files. Don't: Session-scoped board state persists outside the repository.",
22
20
  "Do: To get a fresh answer, turn off the cache (stored results). Don't: Disable the cache invalidation pipeline.",