@rmrdeveloper/sideroom-pi 8.10.0 → 8.11.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
@@ -55,8 +55,8 @@ Sideroom answers each one with a small, opinionated surface:
55
55
  | Invisible monorepo skills | `monorepo-skills` — trusted child folders join Pi's available skill list |
56
56
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
57
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 |
59
- | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
58
+ | Unchecked judgment rules | `jev` — asks a decision model about guide rules with visible evidence |
59
+ | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of unnecessary technical words |
60
60
  | Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
61
61
  | Work left unexplained | `explain` — offers a walkthrough and how to test it once the work settles |
62
62
 
@@ -140,9 +140,13 @@ project are labelled `external`. A new session starts clean.
140
140
 
141
141
  An on-demand skill for fuzzy plans. It interviews you in `sideroom_ask`
142
142
  rounds — reading the codebase first so it never asks what the code already
143
- says — until you share one understanding. Resolved terms land in
144
- `CONTEXT.md` the moment they resolve; hard decisions land as ADRs under
145
- `docs/adr/`. Everything else stays in the conversation, ready to become a
143
+ says — until you share one understanding. Its recommendations favor choices
144
+ that are easy to maintain and meet known growth needs over the cheapest initial
145
+ build. It names the upfront cost; if a missing fact could change the choice,
146
+ it asks for that fact before recommending a solution. Resolved terms land in
147
+ `CONTEXT.md` the moment they resolve. Only hard-to-reverse, surprising choices
148
+ with a real trade-off qualify for a record under `docs/adr/`; the user is asked
149
+ first. Everything else stays in the conversation, ready to become a
146
150
  spec or an implementation. Vocabulary sharpening during the rounds follows
147
151
  the companion `sideroom-domain-modeling` skill, which also runs on its own
148
152
  whenever the words — not the plan — are the problem. And when there is no
@@ -189,17 +193,18 @@ written, no config read: the catalog ships with the package.
189
193
 
190
194
  ### Jev — judgment where no check reaches
191
195
 
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 when it clears the cutoff the
197
- note travels with the guidelines review at the end of the run instead of
198
- interrupting each edit. READMEs, JSON, lockfiles, deletion-only edits, and files
199
- outside the project are never sent.
200
-
201
- It never blocks and never enters the system prompt. The state is the file and
202
- the added lines and nothing else — no conversation, no session, no neighbours.
196
+ Reading the guide covers the mechanical rules. `jev` makes one optional call
197
+ to TypeSafe's decision model about the changed file and, when available, up to
198
+ four directly related source files. It checks guard clauses, fail fast,
199
+ command/query separation, null handling, immutability, repeated validation,
200
+ responsibility, dependency direction, and comments only when the supplied
201
+ files support the question. An answer above the cutoff travels with the
202
+ end-of-run guidelines review instead of interrupting each edit. READMEs, JSON,
203
+ lockfiles, deletion-only edits, and files outside the project are never sent.
204
+
205
+ It never blocks and never enters the system prompt. No conversation or session
206
+ content leaves the machine. Related source excludes ignored, hidden, sensitive-
207
+ named and symlinked paths; a normal source file can still contain a secret.
203
208
  The footer counts the calls the session made, and `F10` shows the same count
204
209
  beside the key source. Without a key nothing is called, and when Jev runs out of
205
210
  quota, rejects the key,
@@ -214,12 +219,12 @@ without ever writing it to the session.
214
219
  `sideroom_persona` is the agent's voice, and there is exactly one of it. A
215
220
  short reminder rides the system prompt on every turn, so it survives
216
221
  compaction; the full guide lives in the on-demand `sideroom-persona` skill.
217
- The voice is direct and dry, free of filler, and plain enough to leave no
218
- doubt about what is being discussed — while calling things by the names the
219
- user already uses. Emojis and decorative symbols in the lines a `write`/`edit`
220
- adds block the mutation; flattering openers, hedging, automatic apologies, and
221
- AI meta-commentary are answered with a capped steer. No profiles to switch and
222
- no profile selection to store.
222
+ The voice is direct and dry, free of filler. It uses everyday words even when
223
+ you use technical terms. It keeps a technical term only when needed to
224
+ understand or act, and explains it the first time. Emojis and decorative
225
+ symbols in the lines a `write`/`edit` adds block the mutation; flattering
226
+ openers, hedging, automatic apologies, and AI meta-commentary are answered
227
+ with a capped steer. No profiles to switch and no profile selection to store.
223
228
 
224
229
  ### Done — done means green
225
230
 
@@ -359,10 +364,12 @@ itself.
359
364
  arrive after the review fired; nothing blocks and nothing reaches the system
360
365
  prompt.
361
366
  - One request per successful `write`/`edit` of a project file in a supported
362
- language that added lines, with six `noul` questions on one shared state of
363
- the project-relative path, the file body, and the added lines.
364
- - A serialized state over 60k characters is skipped rather than truncated. An
365
- answer at or above `0.8` probability becomes a note.
367
+ language that added lines, with applicable `noul` questions on the complete
368
+ changed file and up to four directly related source files.
369
+ - The official 32k-token state-plus-longest-question and 64k-token request
370
+ limits are observed conservatively using 32,000 and 64,000 UTF-8 bytes.
371
+ Oversized neighbours are omitted whole; if the changed file cannot fit,
372
+ the call is skipped. An answer at or above `0.8` becomes a note.
366
373
  - The request follows the run's abort signal; a cancelled request is not a
367
374
  failure.
368
375
  - Quota (`402`) and a rejected key (`401`, `403`) stop the calls immediately. A
@@ -15,8 +15,9 @@ 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
- 'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it 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: never assume it; follow the sideroom-architecture skill and expose it through sideroom_ask before committing, with the practical consequences of each alternative and one recommendation carrying a one-line justification, never technology names. If the repository shows two inconclusive patterns for the same decision, name the inconsistency in the prompt. Everything else is a minor decision: take it yourself.',
18
+ 'Follow the sideroom-grill skill before non-trivial work without a settled spec. One sideroom_ask call is one batch; call again for another round.',
19
+ 'Read the repository first: reuse a consistent project fact. An architectural decision (boundaries, dependencies, cost, security, operations, persistence, public contracts, future change difficulty) is never settled silently: never assume it. Follow the sideroom-architecture skill; ask through sideroom_ask with practical consequences and a one-line justification for one recommendation. Name inconsistent patterns in the prompt. Take minor decisions yourself.',
20
+ 'Recommend evidence-backed long-term quality: maintainability and growth for known needs over initial speed. State the upfront cost; avoid speculative capacity. If decisive facts or figures are missing, ask for them first through sideroom_ask; recommend gathering evidence, not an unsupported solution.',
20
21
  '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
22
  '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
23
  'sideroom_ask only works in the interactive TUI; it errors in print, JSON, and RPC modes.',
@@ -105,7 +105,8 @@ export async function askJev(
105
105
  file: JevFileState,
106
106
  signal: AbortSignal | undefined,
107
107
  ): Promise<JevAttempt> {
108
- const body = JSON.stringify(buildRequestBody(file));
108
+ const requestBody = buildRequestBody(file);
109
+ const body = JSON.stringify(requestBody);
109
110
  let response: JevHttpResponse;
110
111
  try {
111
112
  response = await transport({ apiKey, body, signal });
@@ -130,7 +131,10 @@ export async function askJev(
130
131
  return { findings: [], model: undefined, failure: undefined };
131
132
  }
132
133
  return {
133
- findings: readFindings(envelope),
134
+ findings: readFindings(
135
+ envelope,
136
+ new Set(Object.keys(requestBody.questions)),
137
+ ),
134
138
  model: envelope.model,
135
139
  failure: undefined,
136
140
  };
@@ -0,0 +1,327 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { extname, join, posix, relative, sep } from 'node:path';
3
+ import ignore from 'ignore';
4
+ import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
5
+ import {
6
+ buildFileState,
7
+ type JevFileState,
8
+ type JevRelatedFile,
9
+ stateFitsBudget,
10
+ } from './model.ts';
11
+
12
+ const MAX_RELATED_FILES = 4;
13
+ const MAX_SCAN_FILES = 1_024;
14
+ const MAX_SCAN_DEPTH = 10;
15
+ const MAX_CANDIDATE_BYTES = 32_000;
16
+ const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'] as const;
17
+ const SENSITIVE_NAME =
18
+ /secret|credential|password|token|private.?key|api.?key|auth.?key|(?:^|[._-])(?:env|private|key)(?:[._-]|$)/iu;
19
+ const SKIP_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage']);
20
+
21
+ interface Candidate {
22
+ readonly path: string;
23
+ readonly body: string;
24
+ }
25
+
26
+ interface ScanResult {
27
+ readonly candidates: readonly Candidate[];
28
+ readonly partial: boolean;
29
+ }
30
+
31
+ // Keep the edited file complete. Other files are included whole or not at all.
32
+ export function buildContextualState(
33
+ cwd: string,
34
+ path: string,
35
+ body: string,
36
+ kind: string,
37
+ lines: Parameters<typeof buildFileState>[3],
38
+ ): JevFileState | undefined {
39
+ const base = buildFileState(path, body, kind, lines);
40
+ if (!stateFitsBudget(base)) {
41
+ return undefined;
42
+ }
43
+
44
+ const scan = scanSourceFiles(cwd, path);
45
+ const imported = references(path, body);
46
+ const related: JevRelatedFile[] = [];
47
+ let partial = scan.partial;
48
+ const matches = scan.candidates
49
+ .flatMap((candidate) => {
50
+ const importedByTarget = matchesReference(imported, candidate.path);
51
+ const consumesTarget = matchesReference(
52
+ references(candidate.path, candidate.body),
53
+ path,
54
+ );
55
+ if (!importedByTarget && !consumesTarget) {
56
+ return [];
57
+ }
58
+ return [
59
+ {
60
+ ...candidate,
61
+ relation: importedByTarget ? 'imported' : 'consumer',
62
+ } as const,
63
+ ];
64
+ })
65
+ .sort((left, right) => {
66
+ if (left.relation !== right.relation) {
67
+ return left.relation === 'imported' ? -1 : 1;
68
+ }
69
+ return compareText(left.path, right.path);
70
+ });
71
+
72
+ for (const candidate of matches) {
73
+ if (related.length >= MAX_RELATED_FILES) {
74
+ partial = true;
75
+ break;
76
+ }
77
+ const next = [...related, candidate];
78
+ if (!stateFitsBudget(buildFileState(path, body, kind, lines, next))) {
79
+ partial = true;
80
+ continue;
81
+ }
82
+ related.push(candidate);
83
+ }
84
+ if (
85
+ [...imported].some(
86
+ (name) =>
87
+ !related.some((file) => matchesReference(new Set([name]), file.path)),
88
+ )
89
+ ) {
90
+ partial = true;
91
+ }
92
+ while (
93
+ related.length > 0 &&
94
+ !stateFitsBudget(buildFileState(path, body, kind, lines, related, partial))
95
+ ) {
96
+ related.pop();
97
+ partial = true;
98
+ }
99
+ const state = buildFileState(path, body, kind, lines, related, partial);
100
+ return stateFitsBudget(state) ? state : undefined;
101
+ }
102
+
103
+ function scanSourceFiles(cwd: string, editedPath: string): ScanResult {
104
+ const root = realpathSync(cwd);
105
+ const candidates: Candidate[] = [];
106
+ let visited = 0;
107
+ let partial = false;
108
+
109
+ function visit(
110
+ directory: string,
111
+ depth: number,
112
+ patterns: readonly string[],
113
+ ): void {
114
+ if (depth > MAX_SCAN_DEPTH || visited >= MAX_SCAN_FILES) {
115
+ partial = true;
116
+ return;
117
+ }
118
+ const localPatterns = [...patterns, ...readIgnorePatterns(directory, root)];
119
+ const matcher = ignore().add(localPatterns);
120
+ const entries = readdirSync(directory, { withFileTypes: true }).sort(
121
+ (a, b) => compareText(a.name, b.name),
122
+ );
123
+ for (const entry of entries) {
124
+ if (visited >= MAX_SCAN_FILES) {
125
+ partial = true;
126
+ return;
127
+ }
128
+ const absolute = join(directory, entry.name);
129
+ const path = relative(root, absolute).split(sep).join('/');
130
+ if (entry.name.startsWith('.') || SENSITIVE_NAME.test(entry.name)) {
131
+ continue;
132
+ }
133
+ if (entry.isDirectory()) {
134
+ if (!SKIP_DIRECTORIES.has(entry.name) && !matcher.ignores(`${path}/`)) {
135
+ visit(absolute, depth + 1, localPatterns);
136
+ }
137
+ continue;
138
+ }
139
+ if (
140
+ !entry.isFile() ||
141
+ matcher.ignores(path) ||
142
+ path === editedPath ||
143
+ languageForPath(path) === LANGUAGE.generic
144
+ ) {
145
+ continue;
146
+ }
147
+ visited += 1;
148
+ if (statSync(absolute).size > MAX_CANDIDATE_BYTES) {
149
+ partial = true;
150
+ continue;
151
+ }
152
+ candidates.push({ path, body: readFileSync(absolute, 'utf8') });
153
+ }
154
+ }
155
+
156
+ visit(root, 0, []);
157
+ return { candidates, partial };
158
+ }
159
+
160
+ function readIgnorePatterns(directory: string, root: string): string[] {
161
+ const prefix = relative(root, directory).split(sep).join('/');
162
+ const rules: string[] = [];
163
+ for (const name of IGNORE_FILES) {
164
+ const file = join(directory, name);
165
+ if (!exists(file)) {
166
+ continue;
167
+ }
168
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) {
169
+ const trimmed = line.trim();
170
+ if (!trimmed || (trimmed.startsWith('#') && !trimmed.startsWith('\\#'))) {
171
+ continue;
172
+ }
173
+ const negative = line.startsWith('!');
174
+ const pattern = (negative ? line.slice(1) : line).replace(/^\//u, '');
175
+ rules.push(
176
+ `${negative ? '!' : ''}${prefix ? `${prefix}/` : ''}${pattern}`,
177
+ );
178
+ }
179
+ }
180
+ return rules;
181
+ }
182
+
183
+ function exists(path: string): boolean {
184
+ try {
185
+ statSync(path);
186
+ return true;
187
+ } catch (error) {
188
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
189
+ return false;
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ function modulePath(path: string): string {
196
+ const extension = extname(path);
197
+ const withoutExtension = extension ? path.slice(0, -extension.length) : path;
198
+ return withoutExtension.endsWith('/index')
199
+ ? withoutExtension.slice(0, -'/index'.length)
200
+ : withoutExtension;
201
+ }
202
+
203
+ function references(path: string, body: string): Set<string> {
204
+ const names = new Set<string>();
205
+ const language = languageForPath(path);
206
+ const code = body.replace(/\/\*[\s\S]*?\*\/|^[ \t]*\/\/[^\n]*$/gmu, '');
207
+ if (language === LANGUAGE.typescript || language === LANGUAGE.javascript) {
208
+ const imports = [
209
+ /^[ \t]*(?:import|export)\s+(?:\{[^}]*\}|[^\n;]+)\s*from\s*['"](\.[^'"]+)['"]/gmu,
210
+ /^[ \t]*import\s*['"](\.[^'"]+)['"]/gmu,
211
+ /^[ \t]*(?:(?:const|let|var)\s+[\w{} ,]+\s*=\s*)?require\s*\(\s*['"](\.[^'"]+)['"]/gmu,
212
+ ];
213
+ for (const pattern of imports) {
214
+ for (const match of code.matchAll(pattern)) {
215
+ const specifier = match[1];
216
+ if (specifier) {
217
+ names.add(
218
+ modulePath(
219
+ posix.normalize(posix.join(posix.dirname(path), specifier)),
220
+ ),
221
+ );
222
+ }
223
+ }
224
+ }
225
+ }
226
+ if (language === LANGUAGE.python) {
227
+ for (const match of code.matchAll(
228
+ /^[ \t]*from\s+(\.+)([\w.]*)\s+import\b/gmu,
229
+ )) {
230
+ const dots = match[1]?.length ?? 0;
231
+ const specifier = match[2]?.replaceAll('.', '/') ?? '';
232
+ const parent = posix.resolve(
233
+ '/',
234
+ posix.dirname(path),
235
+ ...Array(dots - 1).fill('..'),
236
+ );
237
+ names.add(modulePath(posix.join(parent.slice(1), specifier)));
238
+ }
239
+ }
240
+ if (language === LANGUAGE.rust) {
241
+ let sourceRoot = posix.dirname(path);
242
+ if (path.startsWith('src/')) {
243
+ sourceRoot = 'src';
244
+ } else if (path.includes('/src/')) {
245
+ sourceRoot = path.slice(0, path.indexOf('/src/') + '/src'.length);
246
+ }
247
+ for (const match of code.matchAll(
248
+ /^[ \t]*(?:pub\s+)?(?:use|mod)\s+(crate::)?([\w:]+)\s*[;{]/gmu,
249
+ )) {
250
+ const specifier = match[2]?.replaceAll('::', '/');
251
+ if (specifier) {
252
+ const from = match[1] ? sourceRoot : posix.dirname(path);
253
+ names.add(modulePath(posix.join(from, specifier)));
254
+ }
255
+ }
256
+ }
257
+ if (language === LANGUAGE.php) {
258
+ for (const match of code.matchAll(
259
+ /^[ \t]*(?:require|include)(?:_once)?\s*(?:\(?\s*__DIR__\s*\.\s*)?['"](\.[^'"]+|\/[^'"]+)['"]/gmu,
260
+ )) {
261
+ const specifier = match[1];
262
+ if (specifier) {
263
+ names.add(
264
+ modulePath(
265
+ posix.normalize(posix.join(posix.dirname(path), specifier)),
266
+ ),
267
+ );
268
+ }
269
+ }
270
+ }
271
+ if (language === LANGUAGE.java) {
272
+ for (const match of code.matchAll(
273
+ /^[ \t]*import\s+(?:static\s+)?([\w.]+)\s*;/gmu,
274
+ )) {
275
+ const specifier = match[1]?.replaceAll('.', '/');
276
+ if (specifier) {
277
+ names.add(specifier);
278
+ }
279
+ }
280
+ }
281
+ if (language === LANGUAGE.go) {
282
+ for (const declaration of code.matchAll(
283
+ /^[ \t]*import\s*(?:\(([^)]*)\)|"([^"\n]+)")/gmu,
284
+ )) {
285
+ const quoted = declaration[1] ?? `"${declaration[2] ?? ''}"`;
286
+ for (const match of quoted.matchAll(/"([^"\n]+)"/gu)) {
287
+ if (match[1]) {
288
+ names.add(match[1]);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return names;
294
+ }
295
+
296
+ function compareText(left: string, right: string): number {
297
+ if (left < right) {
298
+ return -1;
299
+ }
300
+ if (left > right) {
301
+ return 1;
302
+ }
303
+ return 0;
304
+ }
305
+
306
+ function matchesReference(names: ReadonlySet<string>, path: string): boolean {
307
+ const target = modulePath(path);
308
+ if (names.has(target)) {
309
+ return true;
310
+ }
311
+ for (const name of names) {
312
+ if (path.endsWith('.java') && target.endsWith(`/${name}`)) {
313
+ return true;
314
+ }
315
+ if (path.endsWith('.go')) {
316
+ const packageParts = name.split('/');
317
+ const packageSuffix = packageParts.slice(-2).join('/');
318
+ if (
319
+ packageParts.length >= 2 &&
320
+ posix.dirname(path).endsWith(`/${packageSuffix}`)
321
+ ) {
322
+ return true;
323
+ }
324
+ }
325
+ }
326
+ return false;
327
+ }
@@ -19,16 +19,15 @@ import {
19
19
  type JevFailure,
20
20
  type JevTransport,
21
21
  } from './client.ts';
22
+ import { buildContextualState } from './context.ts';
22
23
  import {
23
24
  type AddedLine,
24
25
  addedLinesForEdit,
25
26
  addedLinesForWrite,
26
- buildFileState,
27
27
  findingKey,
28
28
  formatFindings,
29
29
  isReviewablePath,
30
30
  type JevFinding,
31
- stateFitsBudget,
32
31
  } from './model.ts';
33
32
 
34
33
  export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
@@ -177,13 +176,17 @@ export function registerJevGuard(
177
176
  if (body === undefined) {
178
177
  return undefined;
179
178
  }
180
- const fileState = buildFileState(
179
+ if (ctx.signal?.aborted === true) {
180
+ return undefined;
181
+ }
182
+ const fileState = buildContextualState(
183
+ ctx.cwd,
181
184
  pending.relativePath,
182
185
  body,
183
186
  pending.kind,
184
187
  pending.lines,
185
188
  );
186
- if (!stateFitsBudget(fileState) || ctx.signal?.aborted === true) {
189
+ if (fileState === undefined) {
187
190
  return undefined;
188
191
  }
189
192
 
@@ -269,7 +272,7 @@ function registerSuccess(state: JevGuardState): void {
269
272
  state.transientFailures = 0;
270
273
  }
271
274
 
272
- // A deletion-only edit leaves nothing for the six questions to read, and only
275
+ // A deletion-only edit leaves nothing for the questions to read, and only
273
276
  // the project-relative path of a supported language may leave the machine.
274
277
  function isWorthAsking(mutation: PendingMutation): boolean {
275
278
  return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
@@ -11,12 +11,11 @@ export const JEV_MODEL = 'jev-latest';
11
11
  // noise on every edit.
12
12
  export const VIOLATION_PROBABILITY = 0.8;
13
13
 
14
- // The serialized state must stay well under the two documented budgets (64k
15
- // tokens for the whole request, 32k for the state plus the longest question). A
16
- // new file travels twice, as the body and as its added lines. A state that does
17
- // not fit is skipped, never truncated: half a file reads as a distorted picture
18
- // and Jev answers it with the same confidence.
19
- export const MAX_STATE_CHARACTERS = 60_000;
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
+ export const MAX_STATE_BYTES = 32_000;
18
+ export const MAX_REQUEST_BYTES = 64_000;
20
19
 
21
20
  const NOUL_TYPE = 'noul';
22
21
 
@@ -31,8 +30,7 @@ export interface JevRule {
31
30
  readonly question: JevNoulQuestion;
32
31
  }
33
32
 
34
- // The six guide rules that a single file can answer and that no mechanical
35
- // check covers. Rules 11, 12, 15, 16, 17 and 18 need the module's neighbours.
33
+ // Questions must only use evidence in the file and the selected direct neighbours.
36
34
  export const JEV_RULES: readonly JevRule[] = [
37
35
  {
38
36
  id: 'guard-clauses',
@@ -65,10 +63,11 @@ export const JEV_RULES: readonly JevRule[] = [
65
63
  question: {
66
64
  type: NOUL_TYPE,
67
65
  instructions:
68
- 'In `change.addedLines`, does a function both change state and return a value, or does something that reads like a query have a side effect?',
66
+ 'In `change.addedLines`, does a function return a value while changing a parameter, shared state or an external resource, or does a getter/query cause such a side effect? Ignore changes to local collections created inside the function to build its result.',
69
67
  criteria: {
70
- true: 'A function that returns data also mutates state, or a getter mutates',
71
- false: 'Functions either change state or return data, never both',
68
+ true: 'A function returns data while changing caller-visible state, or a getter/query mutates that state',
69
+ false:
70
+ 'Local accumulation and read-only file access do not change caller-visible state',
72
71
  },
73
72
  },
74
73
  },
@@ -102,15 +101,60 @@ export const JEV_RULES: readonly JevRule[] = [
102
101
  question: {
103
102
  type: NOUL_TYPE,
104
103
  instructions:
105
- 'In `change.addedLines`, is the same required, type or range check repeated after a boundary already owns that input?',
104
+ 'Does `change.addedLines` repeat a required, type or range check that is visibly performed at the input boundary in `file.body` or `context.files`? Do not infer unseen validation.',
106
105
  criteria: {
107
- true: 'A rule already validated at the boundary is checked again deeper in the code',
108
- false: 'Validation happens once, at the boundary that owns the input',
106
+ true: 'The same check is visible both at the boundary and in the added lines',
107
+ false:
108
+ 'The boundary check is not visible, or the added lines do not repeat it',
109
+ },
110
+ },
111
+ },
112
+ {
113
+ id: 'single-responsibility',
114
+ question: {
115
+ type: NOUL_TYPE,
116
+ instructions:
117
+ 'Do the added lines give the changed function or class two unrelated reasons to change, visible in its body? Do not flag a function merely for coordinating focused collaborators.',
118
+ criteria: {
119
+ true: 'The changed unit itself performs two unrelated responsibilities with visible evidence',
120
+ false:
121
+ 'The unit has one responsibility or only coordinates separate focused units',
122
+ },
123
+ },
124
+ },
125
+ {
126
+ id: 'dependency-direction',
127
+ question: {
128
+ type: NOUL_TYPE,
129
+ instructions:
130
+ 'Do the added lines introduce an import from business/domain code to infrastructure, HTTP, or a framework? Use paths and bodies in `context.files` as evidence; never infer a layer from an unavailable file.',
131
+ criteria: {
132
+ true: 'A new inward-to-outward dependency is visible in the import and its related file',
133
+ false:
134
+ 'No such dependency is visible, or the roles of the files are unclear',
135
+ },
136
+ },
137
+ },
138
+ {
139
+ id: 'comments',
140
+ question: {
141
+ type: NOUL_TYPE,
142
+ instructions:
143
+ 'Does a comment in `change.addedLines` merely narrate obvious code instead of explaining non-obvious intent, a trade-off or a hazard?',
144
+ criteria: {
145
+ true: 'A newly added comment repeats what the adjacent code already says',
146
+ false: 'No comment was added, or it explains non-obvious intent',
109
147
  },
110
148
  },
111
149
  },
112
150
  ];
113
151
 
152
+ export interface JevRelatedFile {
153
+ readonly path: string;
154
+ readonly body: string;
155
+ readonly relation: 'imported' | 'consumer';
156
+ }
157
+
114
158
  export interface JevFileState {
115
159
  readonly file: {
116
160
  readonly path: string;
@@ -121,6 +165,10 @@ export interface JevFileState {
121
165
  readonly kind: string;
122
166
  readonly addedLines: readonly string[];
123
167
  };
168
+ readonly context: {
169
+ readonly files: readonly JevRelatedFile[];
170
+ readonly partial: boolean;
171
+ };
124
172
  }
125
173
 
126
174
  export interface JevRequestBody {
@@ -148,10 +196,13 @@ export function buildFileState(
148
196
  body: string,
149
197
  kind: string,
150
198
  lines: readonly AddedLine[],
199
+ relatedFiles: readonly JevRelatedFile[] = [],
200
+ partial = false,
151
201
  ): JevFileState {
152
202
  return {
153
203
  file: { path, language: languageForPath(path), body },
154
204
  change: { kind, addedLines: lines.map((line) => line.text) },
205
+ context: { files: relatedFiles, partial },
155
206
  };
156
207
  }
157
208
 
@@ -168,19 +219,38 @@ export function addedLinesForEdit(
168
219
  return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
169
220
  }
170
221
 
171
- // The six questions are about code structure; a README or a lockfile would
172
- // spend a call on answers that mean nothing.
222
+ // Questions are about code structure; a README or a lockfile has no useful state.
173
223
  export function isReviewablePath(path: string): boolean {
174
224
  return languageForPath(path) !== LANGUAGE.generic;
175
225
  }
176
226
 
177
227
  export function stateFitsBudget(state: JevFileState): boolean {
178
- return JSON.stringify(state).length <= MAX_STATE_CHARACTERS;
228
+ const request = buildRequestBody(state);
229
+ const longestQuestion = Object.values(request.questions).reduce(
230
+ (longest, question) =>
231
+ Math.max(longest, Buffer.byteLength(JSON.stringify(question))),
232
+ 0,
233
+ );
234
+ return (
235
+ Buffer.byteLength(JSON.stringify(state)) + longestQuestion <=
236
+ MAX_STATE_BYTES &&
237
+ Buffer.byteLength(JSON.stringify(request)) <= MAX_REQUEST_BYTES
238
+ );
179
239
  }
180
240
 
181
241
  export function buildRequestBody(file: JevFileState): JevRequestBody {
182
242
  const questions: Record<string, JevNoulQuestion> = {};
243
+ const hasImportedFile = file.context.files.some(
244
+ (related) => related.relation === 'imported',
245
+ );
246
+ const addedText = file.change.addedLines.join('\n');
183
247
  for (const rule of JEV_RULES) {
248
+ if (rule.id === 'dependency-direction' && !hasImportedFile) {
249
+ continue;
250
+ }
251
+ if (rule.id === 'comments' && !/(?:\/\/|\/\*|#)/u.test(addedText)) {
252
+ continue;
253
+ }
184
254
  questions[rule.id] = rule.question;
185
255
  }
186
256
  return { model: JEV_MODEL, state: file, questions };
@@ -197,6 +267,7 @@ export function parseEnvelope(text: string): JevResponseEnvelope | undefined {
197
267
 
198
268
  export function readFindings(
199
269
  envelope: JevResponseEnvelope,
270
+ askedRuleIds: ReadonlySet<string> = new Set(JEV_RULES.map((rule) => rule.id)),
200
271
  ): readonly JevFinding[] {
201
272
  const answers = envelope.answers;
202
273
  if (answers === undefined) {
@@ -204,6 +275,9 @@ export function readFindings(
204
275
  }
205
276
  const findings: JevFinding[] = [];
206
277
  for (const rule of JEV_RULES) {
278
+ if (!askedRuleIds.has(rule.id)) {
279
+ continue;
280
+ }
207
281
  const answer = answers[rule.id];
208
282
  if (answer === undefined || answer.noul < VIOLATION_PROBABILITY) {
209
283
  continue;
@@ -35,7 +35,7 @@ export const VOICE_RULES: readonly VoiceRule[] = [
35
35
  {
36
36
  id: 'plain-language',
37
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".',
38
+ 'Explain in plain words: say what the user needs to know or do before details. Use everyday words even if the user uses technical terms. Keep a technical term only when needed to understand or act; explain it at first use in one short sentence. Do not repeat yourself or chain technical labels.',
39
39
  },
40
40
  {
41
41
  id: 'direct',
@@ -49,7 +49,7 @@ export const VOICE_RULES: readonly VoiceRule[] = [
49
49
  {
50
50
  id: 'no-invented-terms',
51
51
  instruction:
52
- 'Name things as they are. Coin no intermediate term, abbreviation, codename, or technical concept word the user did not ask for.',
52
+ "Do not coin intermediate terms, abbreviations, or codenames. Use the user's terms only when needed to answer; otherwise explain in everyday words.",
53
53
  },
54
54
  {
55
55
  id: 'short-prose',
@@ -18,8 +18,8 @@ const ENFORCEMENT_SUMMARY =
18
18
  // Static Do/Don't pairs. Literals only: no per-turn data, so the reminder
19
19
  // stays byte-identical across turns and the provider prompt cache holds.
20
20
  export const PERSONA_EXAMPLES = [
21
- "Do: Enable the flag to skip cache (enableCache). Don't: The registry resolves the provider through the auth resolver pipeline.",
22
- "Do: This runs when the session closes (session_save). Don't: The lifecycle hook tears down session-scoped resources via the cleanup path.",
21
+ "Do: Your work list stays with this conversation, not in project files. Don't: Session-scoped board state persists outside the repository.",
22
+ "Do: To get a fresh answer, turn off the cache (stored results). Don't: Disable the cache invalidation pipeline.",
23
23
  ] as const;
24
24
 
25
25
  export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.10.0",
3
+ "version": "8.11.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": [
@@ -50,7 +50,14 @@ impact), or for a word conflict — that is `sideroom-domain-modeling`.
50
50
  technology label.
51
51
  - Present the decision as the current situation, the alternatives by their
52
52
  practical consequences, and one recommendation with a one-line justification.
53
- Never reduce it to technology names.
53
+ Prefer the most maintainable option that can meet known growth needs over the
54
+ cheapest initial build; name its upfront cost and the cost of later changes.
55
+ Do not recommend maximum capacity merely for hypothetical growth. Never reduce
56
+ the alternatives to technology names.
57
+ - If a missing figure could change which option is best, ask for it before
58
+ recommending an architecture. The preliminary question still needs a
59
+ justified recommended option (such as obtaining the figure); never invent
60
+ a target or recommend a solution without evidence.
54
61
  - One open architectural dimension blocks implementation. `Out of scope` closes
55
62
  a thread, is recorded, and is not re-asked.
56
63
 
@@ -77,10 +84,10 @@ an explicit `Out of scope` on each one you pick.
77
84
  codebase, and note any inconsistent pattern to name in a prompt.
78
85
  2. List the architectural decisions the change forces. Drop the ones the code
79
86
  already settles.
80
- 3. For each remaining decision, pick the dimensions it touches and run one
81
- `sideroom_ask` round: situation, alternatives by consequence, one
82
- recommendation, each option with a `description`. Force a number or an
83
- explicit `Out of scope`.
87
+ 3. For each remaining decision, pick the dimensions it touches. Ask for any
88
+ decisive missing figures first, then run a `sideroom_ask` round: situation,
89
+ alternatives by consequence, one evidence-backed recommendation, each option
90
+ with a `description`. Force a number or an explicit `Out of scope`.
84
91
  4. Repeat rounds until no architectural dimension is open. Do not implement
85
92
  with one open.
86
93
  5. Record an accepted decision as an ADR under `docs/adr/` only when it clears
@@ -34,6 +34,14 @@ if the effort is too big to hold in one session, say so and stop.
34
34
  by their practical consequences, and one recommendation with a one-line
35
35
  justification. Never reduce it to technology names. Name a repository
36
36
  inconsistency in the prompt instead of picking one.
37
+ - Recommend for sustainable quality: prefer maintainability and room to grow
38
+ for known needs over the cheapest initial build. Explain the upfront cost and
39
+ future change cost; do not mistake speculative maximum capacity for quality.
40
+ Apply this to minor decisions too, without asking the user about them.
41
+ - If a missing fact or figure could change the recommendation, ask for it in
42
+ a preliminary round before recommending a solution. That round still needs
43
+ a defensible recommended option, such as gathering the missing evidence;
44
+ never invent a number or mark an unsupported solution as recommended.
37
45
  - An *Out of scope* answer closes that thread. Do not re-ask it.
38
46
  - Everything a round settles is recorded through `sideroom-domain-modeling`:
39
47
  inline glossary entries, the ADR three-gate offer, lazy file creation.
@@ -47,8 +55,8 @@ if the effort is too big to hold in one session, say so and stop.
47
55
  1. Read the code around the change. Settle from the codebase everything the
48
56
  codebase settles.
49
57
  2. Run one `sideroom_ask` round on what remains genuinely open: terms first,
50
- then the architectural decisions. Minor decisions are yours; do not spend a
51
- round on them.
58
+ then missing facts that could change a recommendation, then the architectural
59
+ decisions. Minor decisions are yours; do not spend a round on them.
52
60
  3. After each round, write resolved terms to `CONTEXT.md` and qualifying
53
61
  decisions to `docs/adr/`. Offer the ADR for an accepted architectural
54
62
  decision only when all three gates pass.
@@ -22,37 +22,39 @@ needs the full persona instead of the injected reminder.
22
22
 
23
23
  | # | Rule | Do | Don't |
24
24
  | --- | --- | --- | --- |
25
- | 1 | Plain, never in doubt | `Enable the flag to skip cache (enableCache).` | `The registry resolves the provider through the auth resolver pipeline.` |
26
- | 2 | 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.` |
25
+ | 1 | Plain, never in doubt | `Your work list stays with this conversation, not in project files.` | `Session-scoped board state persists outside the repository.` |
26
+ | 2 | Direct and dry | `The file was not saved. Remove the symbol and try again.` | `I'd be happy to help with that! Let's take a look together.` |
27
27
  | 3 | No filler | Start with the answer. | `Before we dive in…`, `As I mentioned earlier…`, `To summarize what I just said…` |
28
- | 4 | No invented terms | `the file the mutation adds` | `the artifact delta` (when the user never asked for that name) |
28
+ | 4 | No invented terms | `the file you changed` | `the artifact delta` (when the user never asked for that name) |
29
29
  | 5 | Short prose | Prose for one or two points. | A three-bullet list for a single sentence. |
30
30
  | 6 | User's language | Answer in the language and variant the user wrote in. | A Spanish question answered in English. |
31
31
 
32
32
  ### Plain means everyday words, not more words
33
33
 
34
34
  Rule 1 outranks brevity for clarity, and rule 3 outranks rule 1 for length.
35
- Concretely: name the subject and the goal **before** the detail, use everyday
36
- words instead of concept jargon (`hook`, `pipeline`, `registry`, `resolver`,
37
- `guardrail`, `invariant`) unless the user already used them, define any
38
- necessary term in one line, then stop. Being explicit is not a licence to
39
- repeat yourself, restate the question, or explain the same point twice.
40
-
41
- - Do: `This runs when the session closes. In code: \`session_save\`.`
42
- - Don't: `The lifecycle hook tears down session-scoped resources via the
43
- cleanup path.`
44
- - Do: `The persona reminder is injected on every turn, so it survives
45
- compaction. That is why switching profiles would be pointless here.`
46
- - Don't: `What I'm saying is that the reminder gets injected, and since it's
47
- injected every turn, it survives compaction — which means it comes back after
48
- compaction, so profile switching has no effect.`
35
+ Say what the user needs to know or do before explaining how. Choose words a
36
+ student can understand without knowing the code. Even if the user uses a
37
+ technical term, do not repeat it unless they need it to understand the answer
38
+ or take the next step. If they do, explain it at first use in one short
39
+ sentence. Keep exact commands, file names, and code names when needed to act;
40
+ explain their purpose in everyday words. Plain words must stay accurate.
41
+ Do not repeat the question or explain the same point twice.
42
+
43
+ - User: `Where is the session-scoped work board?`
44
+ - Do: `Your work list stays with this conversation, not in project files.`
45
+ - Don't: `Session-scoped board state persists outside the repository.`
46
+ - Do: `To get a fresh answer, turn off the cache (stored results).`
47
+ - Don't: `Disable the cache invalidation pipeline.`
48
+ - Do: `The answer is saved with this conversation. You can read it again later.`
49
+ - Don't: `The response is persisted to the session, so the session stores the
50
+ response for future access.`
49
51
 
50
52
  ### Names the user did not ask for
51
53
 
52
- Rule 4 bans coining vocabulary mid-conversation. Do not rename a concept the
53
- user already named, do not invent abbreviations (`the PRF layer`), and do not
54
- use an internal field name as if it were the domain word. Use the user's word
55
- until they ask for another.
54
+ Rule 4 bans coining vocabulary mid-conversation. Do not invent abbreviations
55
+ (`the PRF layer`) or use an internal field name as if it were the domain word.
56
+ Replace a user's unnecessary technical term with everyday words, not another
57
+ new label. Keep exact names only when needed to act.
56
58
 
57
59
  ## Hard prohibitions
58
60
 
@@ -90,3 +92,8 @@ At most three steers are sent per agent run.
90
92
  - Nothing is written to the target repository.
91
93
  - The persona governs wording. It never relaxes the coding guidelines, the
92
94
  added-line rules, or the check-before-finish gate.
95
+
96
+ ## References
97
+
98
+ - [Digital.gov: Avoid jargon](https://digital.gov/guides/plain-language/principles/avoid-jargon): keep only necessary specialist words and explain them.
99
+ - [GOV.UK: Use clear language](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/clear-language/): write plainly even for specialists.