@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.
package/README.md CHANGED
@@ -51,9 +51,11 @@ Sideroom answers each one with a small, opinionated surface:
51
51
  | Silent guessing | `sideroom_ask` — questions with an opinion, one batch at a time |
52
52
  | Invisible work | `sideroom_todo` — a live board above the editor, always one step in focus |
53
53
  | Repo pollution | Session-branch state — the board and history die with the session, never with a commit |
54
- | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
54
+ | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill + an end-of-turn review steer |
55
+ | Invisible monorepo skills | `monorepo-skills` — trusted child folders join Pi's available skill list |
55
56
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
56
57
  | Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
58
+ | Unchecked judgment rules | `jev` — asks a decision model about the six rules no check can decide |
57
59
  | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
58
60
  | Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
59
61
  | Work left unexplained | `explain` — offers a walkthrough and how to test it once the work settles |
@@ -86,6 +88,10 @@ wall of tool calls, it:
86
88
  blocked. The guides cover guard clauses, braced conditionals, fail-fast
87
89
  errors, focused units, and all 19 canonical rules without dumping them into
88
90
  the system prompt.
91
+ - **Finds the skills below the working directory.** In a trusted monorepo, Pi
92
+ normally ignores project skills stored under child folders. Sideroom scans
93
+ three levels down, adds their `.pi/skills` and `.agents/skills`, and leaves
94
+ duplicate-name warnings to Pi.
89
95
  - **Cannot sneak sloppy lines past the gate.** Each write and edit is checked
90
96
  against the mechanical rules on the added lines only: braceless
91
97
  conditionals and swallowed errors block the mutation; vague names, stale
@@ -152,7 +158,23 @@ full read of the packaged skill and, for supported targets, the one complete
152
158
  language guide under `skills/sideroom-guidelines/references/languages/`.
153
159
  Each guide mirrors all 19 rules in the canonical seed with idiomatic examples.
154
160
  The seed, `assets/artifacts/GUIDELINES_TEMPLATE.md`, is never pasted into the
155
- system prompt.
161
+ system prompt. After any successful mutation, one hidden review steer fires when
162
+ the turn settles and asks the agent to re-check every changed file against the
163
+ loaded guide and run the relevant formatter, linter, type checks, and tests —
164
+ so the contract is applied, not just read.
165
+
166
+ ### Monorepo skills — child skills are still project skills
167
+
168
+ Pi discovers `.pi/skills` only from its working directory and
169
+ `.agents/skills` from that directory and its ancestors. `monorepo-skills`
170
+ closes the downward gap: in a trusted project it scans at most three child
171
+ levels, respects `.gitignore`, `.ignore`, and `.fdignore`, and contributes the
172
+ same skill locations without writing project configuration. Skill bodies stay
173
+ on demand; only names, descriptions, and locations enter the system prompt.
174
+
175
+ Duplicate names keep Pi's precedence and diagnostics. Root and global skills
176
+ load before extension paths, so use `pi config -l` to disable one that shadows
177
+ a needed child-folder skill.
156
178
 
157
179
  ### Rules — enforcement, not advice
158
180
 
@@ -165,6 +187,24 @@ appended as notes to the tool result. A per-rule circuit breaker degrades a
165
187
  repeatedly firing block to a note so the agent never dead-locks. No files
166
188
  written, no config read: the catalog ships with the package.
167
189
 
190
+ ### Jev — judgment where no check reaches
191
+
192
+ Reading the guide covers the mechanical rules. `jev` covers the other kind: one
193
+ optional call to TypeSafe's Jev decision model about the six guide rules a
194
+ single file can answer (guard clauses, fail fast, command/query separation,
195
+ null handling, immutability, validate once) on the file a `write` or `edit` just
196
+ changed. The answer arrives as a probability, and a note is appended to the tool
197
+ result when it clears the cutoff.
198
+
199
+ It never blocks and never enters the system prompt. The state is the file and
200
+ the added lines and nothing else — no conversation, no session, no neighbours.
201
+ The footer counts the calls the session made, and `F10` shows the same count
202
+ beside the key source. Without a key nothing is called, and when Jev runs out of
203
+ quota, rejects the key,
204
+ or stops answering, the extension goes quiet without touching the turn. The key
205
+ lives in `TYPESAFE_API_KEY` or in your agent directory, and `F10` captures it
206
+ without ever writing it to the session.
207
+
168
208
  ### Persona — one voice, not a costume
169
209
 
170
210
  `sideroom_persona` is the agent's voice, and there is exactly one of it. A
@@ -192,9 +232,11 @@ When an implementation settles, `explain` steers the agent to offer a
192
232
  walkthrough through `sideroom_ask`: explain the changes only, how to test them
193
233
  only, both, or nothing. It fires on `agent_settled`, the point where
194
234
  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.
235
+ work that is about to be redone. It waits one settle so the guidelines review
236
+ runs first; both fire on `agent_settled`, and Pi would otherwise order them by
237
+ extension load. Once per turn, only when the turn touched at least five distinct
238
+ files, and only in the TUI, because `sideroom_ask` cannot run anywhere else. No
239
+ tool, no widget, no persisted state.
198
240
 
199
241
  ## Quick path
200
242
 
@@ -269,8 +311,25 @@ also fully read the packaged guide matching the target path. Reads with
269
311
  `offset` or `limit`, failed reads, and same-named files elsewhere do not count.
270
312
  The extension blocks the mutation until the required reads succeed; unsupported
271
313
  languages use the shared table. Do not route around
272
- the gate through Bash or another file-mutation path. Before finishing, review
273
- the diff against the loaded guide and run the relevant project checks.
314
+ the gate through Bash or another file-mutation path. After any successful
315
+ `write`/`edit`, one hidden review steer fires on `agent_settled` (at most once
316
+ per turn) asking the agent to re-check every changed file against the loaded
317
+ guide and run the relevant project checks. Interactive input clears the steer
318
+ flags; `before_agent_start` does not, so a steer continuation never re-triggers
319
+ itself.
320
+
321
+ ### Monorepo-skills contract
322
+
323
+ - Trigger: `resources_discover` after project trust resolves, in every mode.
324
+ - Scope: `.pi/skills` and `.agents/skills` in child folders at most three
325
+ levels below Pi's working directory.
326
+ - Traversal: ignore-aware, no directory symlinks, deterministic child-folder
327
+ order, `.pi/skills` before `.agents/skills` inside one folder.
328
+ - Safety: untrusted projects and CLI `--no-skills`/`-ns` contribute no paths.
329
+ - Collisions: Pi keeps the first name and reports skipped paths. Child-folder
330
+ skills cannot override root or user skills added earlier.
331
+ - Prompt: one static, idempotent note prefers the skill whose location matches
332
+ the folder being edited.
274
333
 
275
334
  ### Rules contract
276
335
 
@@ -284,11 +343,30 @@ the diff against the loaded guide and run the relevant project checks.
284
343
  - A rule that blocks three times in a run degrades to a warning until five
285
344
  clean checks or a new interactive prompt reset it.
286
345
 
346
+ ### Jev contract
347
+
348
+ - Off without a key: no request, and behavior identical to a package without
349
+ this extension.
350
+ - Warn only. A finding is text appended to the tool result; nothing blocks and
351
+ nothing reaches the system prompt.
352
+ - One request per successful `write`/`edit`, with six `noul` questions on one
353
+ shared state of the file body and the added lines.
354
+ - A body over 60k characters is skipped rather than truncated. An answer at or
355
+ above `0.8` probability becomes a note.
356
+ - Quota (`402`) and a rejected key (`401`, `403`) stop the calls immediately;
357
+ network errors, `429` and `5xx` stop after three consecutive failures. A
358
+ success rearms the breaker, and `F10` rearms it by hand.
359
+ - Every request increments a session call counter, shown in the footer and on
360
+ `F10`; it resets on `session_start` and is never persisted.
361
+ - The key comes from `TYPESAFE_API_KEY` or from
362
+ `getAgentDir()/sideroom.json` at mode `0600`, and never enters the session.
363
+
287
364
  ### Persona contract
288
365
 
289
366
  - 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.
367
+ state; runtime guard counters stay in memory. The footer shows the run's block
368
+ and steer counts (`persona: direct · 2 blocks · 1 steer`) and drops back to
369
+ `persona: direct` on the next run.
292
370
  - Injection: a short reminder appended in `before_agent_start`, chained after
293
371
  the other extensions and idempotent by heading, so it returns after
294
372
  compaction.
@@ -315,6 +393,9 @@ the diff against the loaded guide and run the relevant project checks.
315
393
  - Trigger: `agent_settled` with at least five distinct files mutated since the
316
394
  last user prompt, in TUI mode. File identity follows Pi's path aliases and
317
395
  canonicalizes existing paths.
396
+ - One settle of delay: the guidelines review steers on the same event, and Pi
397
+ runs both deferred steers in extension load order, so `explain` offers at the
398
+ next settle.
318
399
  - The steer names the intent; the agent writes the question in the user's
319
400
  language with four options, one of them a decline, and one recommendation.
320
401
  `sideroom_ask` still adds *Out of scope* and the custom answer.
@@ -338,7 +419,9 @@ skills. If a third-party skill still wins, remove it from routing by adding
338
419
  `disable-model-invocation: true` to its frontmatter, or exclude its path with
339
420
  `-path` in `settings.skills`. A literal name collision keeps the first skill
340
421
  found: project `.pi/skills`, then project `.agents/skills`, then
341
- `~/.pi/agent/skills`, then `~/.agents/skills`, then packages.
422
+ `~/.pi/agent/skills`, then `~/.agents/skills`, then packages. Child-folder
423
+ paths contributed by `monorepo-skills` come later and cannot override any of
424
+ them.
342
425
 
343
426
  ## Documentation
344
427
 
@@ -4,12 +4,15 @@ import {
4
4
  isWriteToolResult,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
  import {
7
+ applyOfferDecision,
8
+ decideOffer,
9
+ EXPLAIN_DECISION,
7
10
  EXPLAIN_OFFER,
8
11
  EXPLAIN_OFFER_TYPE,
9
12
  type ExplainState,
10
13
  mutationPathKey,
14
+ recordMutation,
11
15
  resetExplainTurn,
12
- shouldOfferExplanation,
13
16
  } from './model.ts';
14
17
 
15
18
  interface FileToolInput {
@@ -40,16 +43,17 @@ export function registerExplainGuard(
40
43
  if (pathKey === undefined) {
41
44
  return;
42
45
  }
43
- state.mutatedFilesThisTurn.add(pathKey);
46
+ recordMutation(state, pathKey);
44
47
  });
45
48
 
46
49
  // agent_settled is the only point with no retry, compaction, or queued
47
50
  // continuation left, so the walkthrough is never offered over reworked work.
48
51
  pi.on('agent_settled', (_event, ctx) => {
49
- if (!shouldOfferExplanation(state, ctx.mode)) {
52
+ const decision = decideOffer(state, ctx.mode);
53
+ applyOfferDecision(state, decision);
54
+ if (decision !== EXPLAIN_DECISION.send) {
50
55
  return;
51
56
  }
52
- state.offeredThisTurn = true;
53
57
  pi.sendMessage(
54
58
  {
55
59
  customType: EXPLAIN_OFFER_TYPE,
@@ -11,17 +11,35 @@ const TUI_MODE = 'tui';
11
11
  export const EXPLAIN_OFFER =
12
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
13
 
14
+ export const EXPLAIN_DECISION = {
15
+ send: 'send',
16
+ defer: 'defer',
17
+ none: 'none',
18
+ } as const;
19
+
20
+ export type ExplainDecision =
21
+ (typeof EXPLAIN_DECISION)[keyof typeof EXPLAIN_DECISION];
22
+
14
23
  export interface ExplainState {
15
24
  mutatedFilesThisTurn: Set<string>;
25
+ mutatedSinceSettle: boolean;
26
+ pendingOffer: boolean;
16
27
  offeredThisTurn: boolean;
17
28
  }
18
29
 
19
30
  export function createExplainState(): ExplainState {
20
- return { mutatedFilesThisTurn: new Set(), offeredThisTurn: false };
31
+ return {
32
+ mutatedFilesThisTurn: new Set(),
33
+ mutatedSinceSettle: false,
34
+ pendingOffer: false,
35
+ offeredThisTurn: false,
36
+ };
21
37
  }
22
38
 
23
39
  export function resetExplainTurn(state: ExplainState): void {
24
40
  state.mutatedFilesThisTurn.clear();
41
+ state.mutatedSinceSettle = false;
42
+ state.pendingOffer = false;
25
43
  state.offeredThisTurn = false;
26
44
  }
27
45
 
@@ -31,14 +49,45 @@ export function mutationPathKey(cwd: string, path: string): string | undefined {
31
49
  return resolveFileToolPath(cwd, path);
32
50
  }
33
51
 
34
- // sideroom_ask rejects every mode but the TUI, so the offer is gated the same way.
35
- export function shouldOfferExplanation(
52
+ export function recordMutation(state: ExplainState, pathKey: string): void {
53
+ state.mutatedFilesThisTurn.add(pathKey);
54
+ state.mutatedSinceSettle = true;
55
+ }
56
+
57
+ // The guidelines review steers at the first settle after a mutation, and
58
+ // agent_settled runs both deferred steers in extension load order. Explain
59
+ // holds its own steer back one settle so the review always runs first.
60
+ export function decideOffer(
36
61
  state: ExplainState,
37
62
  mode: string,
38
- ): boolean {
39
- return (
63
+ ): ExplainDecision {
64
+ const thresholdReached =
40
65
  mode === TUI_MODE &&
41
- state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD &&
42
- !state.offeredThisTurn
43
- );
66
+ !state.offeredThisTurn &&
67
+ state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD;
68
+ if (state.pendingOffer && thresholdReached) {
69
+ return EXPLAIN_DECISION.send;
70
+ }
71
+ if (
72
+ thresholdReached ||
73
+ (mode === TUI_MODE && state.mutatedSinceSettle && !state.offeredThisTurn)
74
+ ) {
75
+ return EXPLAIN_DECISION.defer;
76
+ }
77
+ return EXPLAIN_DECISION.none;
78
+ }
79
+
80
+ export function applyOfferDecision(
81
+ state: ExplainState,
82
+ decision: ExplainDecision,
83
+ ): void {
84
+ if (decision === EXPLAIN_DECISION.send) {
85
+ state.offeredThisTurn = true;
86
+ state.pendingOffer = false;
87
+ } else if (decision === EXPLAIN_DECISION.defer) {
88
+ state.pendingOffer = true;
89
+ } else {
90
+ state.pendingOffer = false;
91
+ }
92
+ state.mutatedSinceSettle = false;
44
93
  }
@@ -5,12 +5,24 @@ import {
5
5
  resetGuidelineReadState,
6
6
  } from './guard.ts';
7
7
  import { appendGuidelinesReminder } from './prompt.ts';
8
+ import {
9
+ createReviewState,
10
+ registerReviewGuard,
11
+ resetReviewTurn,
12
+ } from './review.ts';
8
13
 
9
14
  // Pi loads extensions/*/index.ts through export default.
10
15
  export default function registerGuidelines(pi: ExtensionAPI): void {
11
16
  const readState = createGuidelineReadState();
12
17
  registerGuidelineGuard(pi, readState);
13
18
 
19
+ const reviewState = createReviewState();
20
+ registerReviewGuard(pi, reviewState);
21
+ pi.on('session_start', () => {
22
+ resetGuidelineReadState(readState);
23
+ resetReviewTurn(reviewState);
24
+ });
25
+
14
26
  pi.on('before_agent_start', (event) => {
15
27
  resetGuidelineReadState(readState);
16
28
  const systemPrompt = appendGuidelinesReminder(event.systemPrompt);
@@ -15,8 +15,7 @@ export const GUIDELINES_REMINDER = `${GUIDELINES_REMINDER_HEADING}
15
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
16
  - ${GUIDELINE_SKILL_PATH}
17
17
  - for the extension of the file you will change, one of: ${LANGUAGE_GUIDE_INDEX}
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.
19
- After changing files, review the code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
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.`;
20
19
 
21
20
  export function appendGuidelinesReminder(systemPrompt: string): string {
22
21
  if (systemPrompt.includes(GUIDELINES_REMINDER_HEADING)) {
@@ -0,0 +1,70 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ isEditToolResult,
4
+ isWriteToolResult,
5
+ } from '@earendil-works/pi-coding-agent';
6
+
7
+ export const REVIEW_STEER_TYPE = 'sideroom-guidelines-review';
8
+
9
+ export const REVIEW_STEER =
10
+ 'Review every file this run changed against the loaded Sideroom language guide: fix any mechanical, structural, or architectural rule violations in those files, then run the relevant formatter, linter, type checks, and tests. If the guide is no longer in context, re-read it in full first.';
11
+
12
+ export interface ReviewState {
13
+ mutated: boolean;
14
+ steeredThisTurn: boolean;
15
+ }
16
+
17
+ export function createReviewState(): ReviewState {
18
+ return { mutated: false, steeredThisTurn: false };
19
+ }
20
+
21
+ export function resetReviewTurn(state: ReviewState): void {
22
+ state.mutated = false;
23
+ state.steeredThisTurn = false;
24
+ }
25
+
26
+ export function shouldReview(state: ReviewState): boolean {
27
+ return state.mutated && !state.steeredThisTurn;
28
+ }
29
+
30
+ export function registerReviewGuard(
31
+ pi: ExtensionAPI,
32
+ state: ReviewState,
33
+ ): void {
34
+ pi.on('input', (event) => {
35
+ if (event.source !== 'interactive' && event.source !== 'rpc') {
36
+ return;
37
+ }
38
+ resetReviewTurn(state);
39
+ });
40
+
41
+ pi.on('tool_result', (event) => {
42
+ if (event.isError) {
43
+ return;
44
+ }
45
+ if (!isEditToolResult(event) && !isWriteToolResult(event)) {
46
+ return;
47
+ }
48
+ state.mutated = true;
49
+ });
50
+
51
+ // agent_settled is the only point with no retry, compaction, or queued
52
+ // continuation left, so the review never lands on work a retry is about to
53
+ // redo. Mark the flag before sending so the continuation this steer starts
54
+ // cannot re-trigger it; sendMessage is intentionally not awaited, matching
55
+ // explain, so the settle handler returns immediately.
56
+ pi.on('agent_settled', () => {
57
+ if (!shouldReview(state)) {
58
+ return;
59
+ }
60
+ state.steeredThisTurn = true;
61
+ pi.sendMessage(
62
+ {
63
+ customType: REVIEW_STEER_TYPE,
64
+ content: REVIEW_STEER,
65
+ display: false,
66
+ },
67
+ { triggerTurn: true, deliverAs: 'steer' },
68
+ );
69
+ });
70
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ buildRequestBody,
3
+ JEV_ENDPOINT,
4
+ type JevFileState,
5
+ type JevFinding,
6
+ parseEnvelope,
7
+ readFindings,
8
+ } from './model.ts';
9
+
10
+ export const JEV_REQUEST_TIMEOUT_MS = 3_000;
11
+
12
+ export const JEV_FAILURE = {
13
+ quota: 'quota',
14
+ auth: 'auth',
15
+ transient: 'transient',
16
+ } as const;
17
+
18
+ export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
19
+
20
+ export interface JevHttpRequest {
21
+ readonly apiKey: string;
22
+ readonly body: string;
23
+ }
24
+
25
+ export interface JevHttpResponse {
26
+ readonly status: number;
27
+ readonly text: string;
28
+ }
29
+
30
+ export type JevTransport = (
31
+ request: JevHttpRequest,
32
+ ) => Promise<JevHttpResponse>;
33
+
34
+ export interface JevAttempt {
35
+ readonly findings: readonly JevFinding[] | undefined;
36
+ readonly model: string | undefined;
37
+ readonly failure: JevFailure | undefined;
38
+ }
39
+
40
+ // The endpoint is a package constant, never a value from the environment or the
41
+ // session, so the outbound URL cannot be steered by the repository being edited.
42
+ export function createFetchTransport(
43
+ timeoutMs: number = JEV_REQUEST_TIMEOUT_MS,
44
+ ): JevTransport {
45
+ return async (request) => {
46
+ const response = await fetch(JEV_ENDPOINT, {
47
+ method: 'POST',
48
+ headers: {
49
+ Authorization: `Bearer ${request.apiKey}`,
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ body: request.body,
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ });
55
+ return { status: response.status, text: await response.text() };
56
+ };
57
+ }
58
+
59
+ export function classifyStatus(status: number): JevFailure | undefined {
60
+ if (status === 401 || status === 403) {
61
+ return JEV_FAILURE.auth;
62
+ }
63
+ if (status === 402) {
64
+ return JEV_FAILURE.quota;
65
+ }
66
+ if (status >= 400) {
67
+ return JEV_FAILURE.transient;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ // A Jev failure is a missing note, never an error: the mutation already
73
+ // succeeded and the mechanical rules already ran. So this returns a value
74
+ // instead of throwing, and the caller only decides whether to keep calling.
75
+ export async function askJev(
76
+ transport: JevTransport,
77
+ apiKey: string,
78
+ file: JevFileState,
79
+ ): Promise<JevAttempt> {
80
+ const body = JSON.stringify(buildRequestBody(file));
81
+ let response: JevHttpResponse;
82
+ try {
83
+ response = await transport({ apiKey, body });
84
+ } catch {
85
+ return {
86
+ findings: undefined,
87
+ model: undefined,
88
+ failure: JEV_FAILURE.transient,
89
+ };
90
+ }
91
+
92
+ const failure = classifyStatus(response.status);
93
+ if (failure !== undefined) {
94
+ return { findings: undefined, model: undefined, failure };
95
+ }
96
+
97
+ const envelope = parseEnvelope(response.text);
98
+ if (envelope === undefined) {
99
+ return { findings: [], model: undefined, failure: undefined };
100
+ }
101
+ return {
102
+ findings: readFindings(envelope),
103
+ model: envelope.model,
104
+ failure: undefined,
105
+ };
106
+ }