@rmrdeveloper/sideroom-pi 8.11.0 → 8.13.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 (64) hide show
  1. package/README.md +49 -5
  2. package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
  3. package/assets/visual-context/browser/INSTALL.md +69 -0
  4. package/assets/visual-context/browser/background.js +578 -0
  5. package/assets/visual-context/browser/content.js +198 -0
  6. package/assets/visual-context/browser/icons/README.md +9 -0
  7. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  10. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  11. package/assets/visual-context/browser/manifest.json +20 -0
  12. package/assets/visual-context/browser/popup.css +19 -0
  13. package/assets/visual-context/browser/popup.html +27 -0
  14. package/assets/visual-context/browser/popup.js +141 -0
  15. package/extensions/done/guard.ts +42 -37
  16. package/extensions/done/index.ts +3 -2
  17. package/extensions/done/model.ts +1 -5
  18. package/extensions/explain/guard.ts +13 -11
  19. package/extensions/explain/model.ts +2 -25
  20. package/extensions/guidelines/review.ts +31 -13
  21. package/extensions/jev/client.ts +22 -1
  22. package/extensions/jev/context.ts +36 -3
  23. package/extensions/jev/guard.ts +113 -34
  24. package/extensions/jev/model.ts +90 -12
  25. package/extensions/jev/workflow.ts +188 -0
  26. package/extensions/persona/execute.ts +4 -6
  27. package/extensions/persona/index.ts +3 -2
  28. package/extensions/persona/model.ts +1 -31
  29. package/extensions/persona/prompt.ts +3 -1
  30. package/extensions/shared/settle.ts +63 -13
  31. package/extensions/visual-context/bridge.ts +240 -0
  32. package/extensions/visual-context/broker.ts +292 -0
  33. package/extensions/visual-context/envelope.ts +34 -0
  34. package/extensions/visual-context/index.ts +328 -0
  35. package/extensions/visual-context/literal.ts +34 -0
  36. package/extensions/visual-context/markup.ts +104 -0
  37. package/extensions/visual-context/match.ts +222 -0
  38. package/extensions/visual-context/model.ts +180 -0
  39. package/extensions/visual-context/pairing.ts +114 -0
  40. package/extensions/visual-context/parse-astro.ts +166 -0
  41. package/extensions/visual-context/parse-html.ts +72 -0
  42. package/extensions/visual-context/parse-jsx.ts +157 -0
  43. package/extensions/visual-context/parse-svelte.ts +128 -0
  44. package/extensions/visual-context/parse-vue.ts +133 -0
  45. package/extensions/visual-context/session.ts +192 -0
  46. package/extensions/visual-context/source.ts +197 -0
  47. package/extensions/visual-context/ui.ts +57 -0
  48. package/package.json +16 -7
  49. package/skills/sideroom-architecture/SKILL.md +5 -3
  50. package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
  51. package/skills/sideroom-codebase-design/SKILL.md +151 -0
  52. package/skills/sideroom-codebase-design/references/growth.md +62 -0
  53. package/skills/sideroom-domain-modeling/SKILL.md +1 -1
  54. package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
  55. package/skills/sideroom-grill/SKILL.md +6 -4
  56. package/skills/sideroom-guidelines/SKILL.md +9 -7
  57. package/skills/sideroom-guidelines/references/languages/go.md +3 -3
  58. package/skills/sideroom-guidelines/references/languages/java.md +3 -3
  59. package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
  60. package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
  61. package/skills/sideroom-guidelines/references/languages/python.md +3 -3
  62. package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
  63. package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
  64. package/skills/sideroom-persona/SKILL.md +15 -4
@@ -13,7 +13,6 @@ export const EXPLAIN_OFFER =
13
13
 
14
14
  export const EXPLAIN_DECISION = {
15
15
  send: 'send',
16
- defer: 'defer',
17
16
  none: 'none',
18
17
  } as const;
19
18
 
@@ -22,24 +21,18 @@ export type ExplainDecision =
22
21
 
23
22
  export interface ExplainState {
24
23
  mutatedFilesThisTurn: Set<string>;
25
- mutatedSinceSettle: boolean;
26
- pendingOffer: boolean;
27
24
  offeredThisTurn: boolean;
28
25
  }
29
26
 
30
27
  export function createExplainState(): ExplainState {
31
28
  return {
32
29
  mutatedFilesThisTurn: new Set(),
33
- mutatedSinceSettle: false,
34
- pendingOffer: false,
35
30
  offeredThisTurn: false,
36
31
  };
37
32
  }
38
33
 
39
34
  export function resetExplainTurn(state: ExplainState): void {
40
35
  state.mutatedFilesThisTurn.clear();
41
- state.mutatedSinceSettle = false;
42
- state.pendingOffer = false;
43
36
  state.offeredThisTurn = false;
44
37
  }
45
38
 
@@ -51,13 +44,9 @@ export function mutationPathKey(cwd: string, path: string): string | undefined {
51
44
 
52
45
  export function recordMutation(state: ExplainState, pathKey: string): void {
53
46
  state.mutatedFilesThisTurn.add(pathKey);
54
- state.mutatedSinceSettle = true;
55
47
  }
56
48
 
57
- // The guidelines review steers at the first settle boundary after a mutation,
58
- // and both handlers run in an extension load order Sideroom does not control.
59
- // Explain holds its steer back one boundary so the review always runs first;
60
- // the review's continuation is what brings the next boundary.
49
+ // The shared settle guard gives review and checks priority over the offer.
61
50
  export function decideOffer(
62
51
  state: ExplainState,
63
52
  mode: string,
@@ -66,15 +55,9 @@ export function decideOffer(
66
55
  mode === TUI_MODE &&
67
56
  !state.offeredThisTurn &&
68
57
  state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD;
69
- if (state.pendingOffer && thresholdReached) {
58
+ if (thresholdReached) {
70
59
  return EXPLAIN_DECISION.send;
71
60
  }
72
- if (
73
- thresholdReached ||
74
- (mode === TUI_MODE && state.mutatedSinceSettle && !state.offeredThisTurn)
75
- ) {
76
- return EXPLAIN_DECISION.defer;
77
- }
78
61
  return EXPLAIN_DECISION.none;
79
62
  }
80
63
 
@@ -84,11 +67,5 @@ export function applyOfferDecision(
84
67
  ): void {
85
68
  if (decision === EXPLAIN_DECISION.send) {
86
69
  state.offeredThisTurn = true;
87
- state.pendingOffer = false;
88
- } else if (decision === EXPLAIN_DECISION.defer) {
89
- state.pendingOffer = true;
90
- } else {
91
- state.pendingOffer = false;
92
70
  }
93
- state.mutatedSinceSettle = false;
94
71
  }
@@ -5,7 +5,8 @@ import {
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
  import { isReviewNote, REVIEW_NOTE_EVENT } from '../shared/review-note.ts';
7
7
  import {
8
- acceptsSettleSteer,
8
+ registerSettleGuard,
9
+ SETTLE_PRIORITY,
9
10
  startsUserTurn,
10
11
  withHiddenSteer,
11
12
  } from '../shared/settle.ts';
@@ -13,10 +14,13 @@ import {
13
14
  export const REVIEW_STEER_TYPE = 'sideroom-guidelines-review';
14
15
 
15
16
  export const REVIEW_STEER =
16
- '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.';
17
+ 'Finish any remaining requested implementation before reviewing. Review the changes this run introduced against the loaded Sideroom language guide: fix only demonstrated violations caused by those changes, preserving all requested requirements and existing behavior. KISS and YAGNI do not justify removing required error handling, compatibility, or tests. Do not refactor unrelated code. Then run the relevant formatter, linter, type checks, and tests. If the guide is no longer in context, re-read it in full first.';
18
+
19
+ export const WORKFLOW_REVIEW_STEER =
20
+ 'Verify the semantic notes below against the visible evidence and the questionnaire or task contract. Correct only demonstrated ambiguity or contradictions; respect user answers and ask again only when clarification is necessary. Missing evidence is not proof of unfinished work. Do not create unrelated implementation work. Run project checks only if code changes.';
17
21
 
18
22
  export const REVIEW_NOTES_STEER =
19
- 'Files changed during the review drew the semantic notes below. Verify each against the loaded Sideroom language guide, fix only real violations, then rerun the relevant checks.';
23
+ 'Additional semantic notes arrived after the review. Verify each against the loaded Sideroom language guide, fix only demonstrated violations introduced by this run without removing required behavior or refactoring unrelated code, then rerun the relevant checks.';
20
24
 
21
25
  export interface ReviewState {
22
26
  mutated: boolean;
@@ -42,21 +46,27 @@ export function resetReviewTurn(state: ReviewState): void {
42
46
  }
43
47
 
44
48
  export function shouldReview(state: ReviewState): boolean {
45
- return state.mutated && !state.steeredThisTurn;
49
+ return (state.mutated || state.notes.length > 0) && !state.steeredThisTurn;
46
50
  }
47
51
 
48
52
  // Notes that arrive after the review fired get one follow-up per turn, so a
49
53
  // fix that draws new notes cannot keep the run going.
50
54
  export function pendingReviewSteer(state: ReviewState): string | undefined {
51
55
  if (shouldReview(state)) {
52
- return withNotes(REVIEW_STEER, state.notes);
56
+ return withNotes(
57
+ state.mutated ? REVIEW_STEER : WORKFLOW_REVIEW_STEER,
58
+ state.notes,
59
+ );
53
60
  }
54
61
  if (
55
62
  state.steeredThisTurn &&
56
63
  !state.notesSteeredThisTurn &&
57
64
  state.notes.length > 0
58
65
  ) {
59
- return withNotes(REVIEW_NOTES_STEER, state.notes);
66
+ return withNotes(
67
+ state.mutated ? REVIEW_NOTES_STEER : WORKFLOW_REVIEW_STEER,
68
+ state.notes,
69
+ );
60
70
  }
61
71
  return undefined;
62
72
  }
@@ -104,12 +114,20 @@ export function registerReviewGuard(
104
114
  // continuation stays inside the same run. agent_settled is notification-only:
105
115
  // a steer sent there starts a second run after the user already saw the
106
116
  // agent stop, and it fires even when the user pressed Escape.
107
- pi.on('agent_before_settle', (event) => {
108
- const steer = pendingReviewSteer(state);
109
- if (steer === undefined || !acceptsSettleSteer(event)) {
110
- return;
111
- }
112
- markReviewSteered(state);
113
- return withHiddenSteer(event, REVIEW_STEER_TYPE, steer);
117
+ registerSettleGuard(pi, {
118
+ priority: SETTLE_PRIORITY.review,
119
+ pending: () => pendingReviewSteer(state) !== undefined,
120
+ handle: (event) => {
121
+ const steer = pendingReviewSteer(state);
122
+ if (steer === undefined) {
123
+ return;
124
+ }
125
+ markReviewSteered(state);
126
+ return withHiddenSteer({
127
+ event,
128
+ customType: REVIEW_STEER_TYPE,
129
+ content: steer,
130
+ });
131
+ },
114
132
  });
115
133
  }
@@ -3,6 +3,7 @@ import {
3
3
  JEV_ENDPOINT,
4
4
  type JevFileState,
5
5
  type JevFinding,
6
+ type JevRequestBody,
6
7
  parseEnvelope,
7
8
  readFindings,
8
9
  } from './model.ts';
@@ -105,7 +106,27 @@ export async function askJev(
105
106
  file: JevFileState,
106
107
  signal: AbortSignal | undefined,
107
108
  ): Promise<JevAttempt> {
108
- const requestBody = buildRequestBody(file);
109
+ return askJevRequest({
110
+ transport,
111
+ apiKey,
112
+ requestBody: buildRequestBody(file),
113
+ signal,
114
+ });
115
+ }
116
+
117
+ interface JevRequestInput {
118
+ readonly transport: JevTransport;
119
+ readonly apiKey: string;
120
+ readonly requestBody: JevRequestBody;
121
+ readonly signal: AbortSignal | undefined;
122
+ }
123
+
124
+ export async function askJevRequest({
125
+ transport,
126
+ apiKey,
127
+ requestBody,
128
+ signal,
129
+ }: JevRequestInput): Promise<JevAttempt> {
109
130
  const body = JSON.stringify(requestBody);
110
131
  let response: JevHttpResponse;
111
132
  try {
@@ -4,6 +4,7 @@ import ignore from 'ignore';
4
4
  import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
5
5
  import {
6
6
  buildFileState,
7
+ isTestPath,
7
8
  type JevFileState,
8
9
  type JevRelatedFile,
9
10
  stateFitsBudget,
@@ -52,19 +53,31 @@ export function buildContextualState(
52
53
  references(candidate.path, candidate.body),
53
54
  path,
54
55
  );
55
- if (!importedByTarget && !consumesTarget) {
56
+ const companionTest = isCompanionTest(path, candidate.path);
57
+ const testedSource = isCompanionTest(candidate.path, path);
58
+ if (
59
+ !importedByTarget &&
60
+ !consumesTarget &&
61
+ !companionTest &&
62
+ !testedSource
63
+ ) {
56
64
  return [];
57
65
  }
58
66
  return [
59
67
  {
60
68
  ...candidate,
61
- relation: importedByTarget ? 'imported' : 'consumer',
69
+ relation: isTestPath(candidate.path)
70
+ ? 'test'
71
+ : importedByTarget || testedSource
72
+ ? 'imported'
73
+ : 'consumer',
62
74
  } as const,
63
75
  ];
64
76
  })
65
77
  .sort((left, right) => {
66
78
  if (left.relation !== right.relation) {
67
- return left.relation === 'imported' ? -1 : 1;
79
+ const priority = { test: 0, consumer: 1, imported: 2 };
80
+ return priority[left.relation] - priority[right.relation];
68
81
  }
69
82
  return compareText(left.path, right.path);
70
83
  });
@@ -325,3 +338,23 @@ function matchesReference(names: ReadonlySet<string>, path: string): boolean {
325
338
  }
326
339
  return false;
327
340
  }
341
+
342
+ // Naming is evidence only for a sibling test, never for a repository-wide basename match.
343
+ function isCompanionTest(source: string, candidate: string): boolean {
344
+ if (
345
+ isTestPath(source) ||
346
+ !isTestPath(candidate) ||
347
+ posix.dirname(source) !== posix.dirname(candidate)
348
+ ) {
349
+ return false;
350
+ }
351
+ const stem = posix.basename(source, extname(source));
352
+ const testStem = posix.basename(candidate, extname(candidate));
353
+ return (
354
+ testStem === `${stem}.test` ||
355
+ testStem === `${stem}.spec` ||
356
+ testStem === `${stem}_test` ||
357
+ testStem === `test_${stem}` ||
358
+ testStem === `${stem}Test`
359
+ );
360
+ }
@@ -6,6 +6,7 @@ import {
6
6
  isWriteToolResult,
7
7
  type ToolCallEvent,
8
8
  } from '@earendil-works/pi-coding-agent';
9
+ import { commandMatches, detectCheckCommand } from '../done/detect.ts';
9
10
  import {
10
11
  projectRelativePath,
11
12
  resolveFileToolPath,
@@ -14,7 +15,7 @@ import { readFileIfExists } from '../shared/read-file.ts';
14
15
  import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
15
16
  import { startsUserTurn } from '../shared/settle.ts';
16
17
  import {
17
- askJev,
18
+ askJevRequest,
18
19
  JEV_FAILURE,
19
20
  type JevFailure,
20
21
  type JevTransport,
@@ -24,12 +25,25 @@ import {
24
25
  type AddedLine,
25
26
  addedLinesForEdit,
26
27
  addedLinesForWrite,
28
+ buildRequestBody,
27
29
  findingKey,
28
30
  formatFindings,
29
31
  isReviewablePath,
30
32
  type JevFinding,
33
+ type JevRequestBody,
31
34
  } from './model.ts';
32
35
 
36
+ import {
37
+ buildAskReview,
38
+ buildBoardReview,
39
+ completedItems,
40
+ completionIds,
41
+ createWorkflowEvidence,
42
+ recordFileEvidence,
43
+ resetWorkflowEvidence,
44
+ type WorkflowEvidence,
45
+ } from './workflow.ts';
46
+
33
47
  export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
34
48
 
35
49
  const MUTATION_KIND = {
@@ -64,6 +78,7 @@ export interface JevGuardState {
64
78
  rateLimitedThisTurn: boolean;
65
79
  lastModel: string | undefined;
66
80
  calls: number;
81
+ readonly evidence: WorkflowEvidence;
67
82
  readonly pending: Map<string, PendingMutation>;
68
83
  readonly notedThisTurn: Set<string>;
69
84
  }
@@ -81,6 +96,7 @@ export function createJevGuardState(): JevGuardState {
81
96
  rateLimitedThisTurn: false,
82
97
  lastModel: undefined,
83
98
  calls: 0,
99
+ evidence: createWorkflowEvidence(),
84
100
  pending: new Map(),
85
101
  notedThisTurn: new Set(),
86
102
  };
@@ -88,6 +104,7 @@ export function createJevGuardState(): JevGuardState {
88
104
 
89
105
  // The F10 screen calls this after the user stores a key or asks to retry.
90
106
  export function resetJevGuard(state: JevGuardState): void {
107
+ resetWorkflowEvidence(state.evidence);
91
108
  state.halted = undefined;
92
109
  state.transientFailures = 0;
93
110
  state.rateLimitedThisTurn = false;
@@ -96,6 +113,7 @@ export function resetJevGuard(state: JevGuardState): void {
96
113
  // Usage and per-turn memory belong to the active session: a new session starts
97
114
  // counting from zero. The breaker stays, because it tracks the key.
98
115
  export function resetJevSession(state: JevGuardState): void {
116
+ resetWorkflowEvidence(state.evidence);
99
117
  state.calls = 0;
100
118
  state.rateLimitedThisTurn = false;
101
119
  state.pending.clear();
@@ -131,6 +149,7 @@ export function registerJevGuard(
131
149
  if (!startsUserTurn(event)) {
132
150
  return;
133
151
  }
152
+ resetWorkflowEvidence(state.evidence);
134
153
  state.notedThisTurn.clear();
135
154
  state.rateLimitedThisTurn = false;
136
155
  });
@@ -145,7 +164,7 @@ export function registerJevGuard(
145
164
  // the previous content is gone. The body is read after, from disk.
146
165
  pi.on('tool_call', (event, ctx) => {
147
166
  const mutation = extractMutation(event, ctx.cwd);
148
- if (mutation === undefined || !isWorthAsking(mutation)) {
167
+ if (mutation === undefined || !isReviewablePath(mutation.relativePath)) {
149
168
  return undefined;
150
169
  }
151
170
  state.pending.set(event.toolCallId, mutation);
@@ -155,48 +174,108 @@ export function registerJevGuard(
155
174
  pi.on('tool_result', async (event, ctx) => {
156
175
  const pending = state.pending.get(event.toolCallId);
157
176
  state.pending.delete(event.toolCallId);
158
- if (pending === undefined || event.isError) {
159
- return undefined;
160
- }
161
- if (!isWriteToolResult(event) && !isEditToolResult(event)) {
162
- return undefined;
163
- }
164
-
165
177
  const apiKey = dependencies.apiKey();
166
- if (apiKey === undefined) {
167
- dependencies.report(JEV_STATUS.noKey, ctx);
178
+ if (
179
+ apiKey === undefined ||
180
+ !shouldCallJev(state) ||
181
+ ctx.signal?.aborted === true
182
+ ) {
183
+ if (!event.isError && pending !== undefined && isWorthAsking(pending)) {
184
+ dependencies.report(
185
+ apiKey === undefined ? JEV_STATUS.noKey : guardStatus(state),
186
+ ctx,
187
+ );
188
+ }
168
189
  return undefined;
169
190
  }
170
- if (!shouldCallJev(state)) {
171
- dependencies.report(guardStatus(state), ctx);
191
+ if (event.isError) {
192
+ if (
193
+ event.toolName === 'bash' &&
194
+ typeof event.input.command === 'string'
195
+ ) {
196
+ const check = detectCheckCommand(ctx.cwd);
197
+ if (check !== undefined && commandMatches(event.input.command, check)) {
198
+ state.evidence.checks = 'failed';
199
+ }
200
+ }
172
201
  return undefined;
173
202
  }
174
-
175
- const body = readFileIfExists(pending.absolutePath);
176
- if (body === undefined) {
203
+ if (event.toolName === 'bash' && typeof event.input.command === 'string') {
204
+ const check = detectCheckCommand(ctx.cwd);
205
+ if (check !== undefined && commandMatches(event.input.command, check)) {
206
+ state.evidence.checks = 'passed';
207
+ }
177
208
  return undefined;
178
209
  }
179
- if (ctx.signal?.aborted === true) {
180
- return undefined;
210
+ let requestBody: JevRequestBody | undefined;
211
+ let subject: string;
212
+ if (event.toolName === 'sideroom_ask') {
213
+ if (
214
+ typeof event.details !== 'object' ||
215
+ event.details === null ||
216
+ !('cancelled' in event.details) ||
217
+ event.details.cancelled !== false
218
+ ) {
219
+ return undefined;
220
+ }
221
+ requestBody = buildAskReview(event.input);
222
+ subject = 'sideroom_ask';
223
+ } else if (event.toolName === 'sideroom_todo') {
224
+ const items = completedItems(event.details);
225
+ if (items === undefined) {
226
+ return undefined;
227
+ }
228
+ requestBody = buildBoardReview({
229
+ items,
230
+ evidence: state.evidence,
231
+ changedIds: completionIds(event.input),
232
+ });
233
+ subject = 'sideroom_todo';
234
+ } else {
235
+ if (
236
+ pending === undefined ||
237
+ (!isWriteToolResult(event) && !isEditToolResult(event))
238
+ ) {
239
+ return undefined;
240
+ }
241
+ subject = pending.relativePath;
242
+ state.evidence.checks = 'stale';
243
+ // Replace cached evidence even when this version no longer fits the budget.
244
+ state.evidence.files.delete(subject);
245
+ if (!isWorthAsking(pending)) {
246
+ state.evidence.partial = true;
247
+ return undefined;
248
+ }
249
+ const body = readFileIfExists(pending.absolutePath);
250
+ if (body === undefined) {
251
+ state.evidence.partial = true;
252
+ return undefined;
253
+ }
254
+ const fileState = buildContextualState(
255
+ ctx.cwd,
256
+ subject,
257
+ body,
258
+ pending.kind,
259
+ pending.lines,
260
+ );
261
+ if (fileState === undefined) {
262
+ state.evidence.partial = true;
263
+ return undefined;
264
+ }
265
+ recordFileEvidence(state.evidence, fileState);
266
+ requestBody = buildRequestBody(fileState);
181
267
  }
182
- const fileState = buildContextualState(
183
- ctx.cwd,
184
- pending.relativePath,
185
- body,
186
- pending.kind,
187
- pending.lines,
188
- );
189
- if (fileState === undefined) {
268
+ if (requestBody === undefined) {
190
269
  return undefined;
191
270
  }
192
271
 
193
272
  state.calls += 1;
194
- const attempt = await askJev(
195
- dependencies.transport,
273
+ const attempt = await askJevRequest({
274
+ transport: dependencies.transport,
196
275
  apiKey,
197
- fileState,
198
- ctx.signal,
199
- );
276
+ requestBody,
277
+ signal: ctx.signal,
278
+ });
200
279
  if (attempt.failure !== undefined) {
201
280
  registerFailure(state, attempt.failure);
202
281
  dependencies.report(guardStatus(state), ctx);
@@ -211,14 +290,14 @@ export function registerJevGuard(
211
290
 
212
291
  const findings = attempt.findings ?? [];
213
292
  const fresh = findings.filter((finding) =>
214
- isUnnoted(state, pending.relativePath, finding),
293
+ isUnnoted(state, subject, finding),
215
294
  );
216
295
  if (fresh.length === 0) {
217
296
  return undefined;
218
297
  }
219
- markNoted(state, pending.relativePath, fresh);
298
+ markNoted(state, subject, fresh);
220
299
  const note: ReviewNote = {
221
- text: formatFindings(pending.relativePath, fresh),
300
+ text: formatFindings(subject, fresh),
222
301
  };
223
302
  pi.events.emit(REVIEW_NOTE_EVENT, note);
224
303
  return undefined;
@@ -147,12 +147,70 @@ export const JEV_RULES: readonly JevRule[] = [
147
147
  },
148
148
  },
149
149
  },
150
+ {
151
+ id: 'testability',
152
+ question: {
153
+ type: NOUL_TYPE,
154
+ instructions:
155
+ 'Do the added lines hardcode clock, network or randomness inside business logic, making deterministic tests difficult? Do not flag infrastructure adapters or entry points merely for using their own APIs.',
156
+ criteria: {
157
+ true: 'Business behavior directly depends on an uncontrollable external dependency with no visible injection point',
158
+ false:
159
+ 'Dependencies are supplied, or the code is the adapter/composition boundary itself',
160
+ },
161
+ },
162
+ },
163
+ {
164
+ id: 'contract-compatibility',
165
+ question: {
166
+ type: NOUL_TYPE,
167
+ instructions:
168
+ 'Do added lines change a function signature, return shape or behavior in a way that demonstrably conflicts with a supplied non-test consumer? Require a concrete mismatch in visible code; do not speculate about unseen callers.',
169
+ criteria: {
170
+ true: 'A supplied consumer calls or interprets the changed contract incompatibly',
171
+ false:
172
+ 'Visible consumers remain compatible, or there is insufficient evidence',
173
+ },
174
+ },
175
+ },
176
+ {
177
+ id: 'test-quality',
178
+ question: {
179
+ type: NOUL_TYPE,
180
+ instructions:
181
+ 'For behavior touched by the added lines, do supplied related tests have a demonstrable blind spot: assertions that cannot detect that behavior failing, or an explicit changed error branch left untested? Use both production and test bodies. Never infer absent repository coverage from missing files or partial context. Do not request tests that mirror implementation or cover unrelated code.',
182
+ criteria: {
183
+ true: 'Visible relevant tests fail to assert the changed observable behavior or a visible changed error case, with concrete evidence',
184
+ false:
185
+ 'Assertions cover the behavior, the change is low-impact, or evidence is incomplete',
186
+ },
187
+ },
188
+ },
189
+ {
190
+ id: 'unnecessary-abstraction',
191
+ question: {
192
+ type: NOUL_TYPE,
193
+ instructions:
194
+ 'Do the added lines introduce a demonstrably redundant layer or combine visibly different domain concepts solely for similar syntax? Require evidence in supplied bodies. A single caller, delegation, injection, compatibility or a clear responsibility is not by itself a violation. Never infer requirements are absent or recommend removing required behavior.',
195
+ criteria: {
196
+ true: 'The added abstraction duplicates an existing role without adding a contract, responsibility or testability, or mixes visibly distinct concepts',
197
+ false:
198
+ 'It serves a present responsibility or contract, or evidence is insufficient',
199
+ },
200
+ },
201
+ },
150
202
  ];
151
203
 
204
+ export function isTestPath(path: string): boolean {
205
+ return /(?:^|\/)(?:__tests__|tests?|specs?)\/|(?:[._-](?:test|spec)|_test)\.[^/]+$|(?:^|\/)test_[^/]+$|Test\.java$/u.test(
206
+ path,
207
+ );
208
+ }
209
+
152
210
  export interface JevRelatedFile {
153
211
  readonly path: string;
154
212
  readonly body: string;
155
- readonly relation: 'imported' | 'consumer';
213
+ readonly relation: 'imported' | 'consumer' | 'test';
156
214
  }
157
215
 
158
216
  export interface JevFileState {
@@ -173,7 +231,7 @@ export interface JevFileState {
173
231
 
174
232
  export interface JevRequestBody {
175
233
  readonly model: string;
176
- readonly state: JevFileState;
234
+ readonly state: unknown;
177
235
  readonly questions: Readonly<Record<string, JevNoulQuestion>>;
178
236
  }
179
237
 
@@ -225,14 +283,17 @@ export function isReviewablePath(path: string): boolean {
225
283
  }
226
284
 
227
285
  export function stateFitsBudget(state: JevFileState): boolean {
228
- const request = buildRequestBody(state);
286
+ return requestFitsBudget(buildRequestBody(state));
287
+ }
288
+
289
+ export function requestFitsBudget(request: JevRequestBody): boolean {
229
290
  const longestQuestion = Object.values(request.questions).reduce(
230
291
  (longest, question) =>
231
292
  Math.max(longest, Buffer.byteLength(JSON.stringify(question))),
232
293
  0,
233
294
  );
234
295
  return (
235
- Buffer.byteLength(JSON.stringify(state)) + longestQuestion <=
296
+ Buffer.byteLength(JSON.stringify(request.state)) + longestQuestion <=
236
297
  MAX_STATE_BYTES &&
237
298
  Buffer.byteLength(JSON.stringify(request)) <= MAX_REQUEST_BYTES
238
299
  );
@@ -243,6 +304,15 @@ export function buildRequestBody(file: JevFileState): JevRequestBody {
243
304
  const hasImportedFile = file.context.files.some(
244
305
  (related) => related.relation === 'imported',
245
306
  );
307
+ const hasConsumer = file.context.files.some(
308
+ (related) => related.relation === 'consumer' && !isTestPath(related.path),
309
+ );
310
+ const hasTest =
311
+ isTestPath(file.file.path) ||
312
+ file.context.files.some((related) => isTestPath(related.path));
313
+ const hasProduction =
314
+ !isTestPath(file.file.path) ||
315
+ file.context.files.some((related) => !isTestPath(related.path));
246
316
  const addedText = file.change.addedLines.join('\n');
247
317
  for (const rule of JEV_RULES) {
248
318
  if (rule.id === 'dependency-direction' && !hasImportedFile) {
@@ -251,6 +321,12 @@ export function buildRequestBody(file: JevFileState): JevRequestBody {
251
321
  if (rule.id === 'comments' && !/(?:\/\/|\/\*|#)/u.test(addedText)) {
252
322
  continue;
253
323
  }
324
+ if (rule.id === 'contract-compatibility' && !hasConsumer) {
325
+ continue;
326
+ }
327
+ if (rule.id === 'test-quality' && (!hasTest || !hasProduction)) {
328
+ continue;
329
+ }
254
330
  questions[rule.id] = rule.question;
255
331
  }
256
332
  return { model: JEV_MODEL, state: file, questions };
@@ -274,15 +350,17 @@ export function readFindings(
274
350
  return [];
275
351
  }
276
352
  const findings: JevFinding[] = [];
277
- for (const rule of JEV_RULES) {
278
- if (!askedRuleIds.has(rule.id)) {
279
- continue;
280
- }
281
- const answer = answers[rule.id];
282
- if (answer === undefined || answer.noul < VIOLATION_PROBABILITY) {
353
+ for (const ruleId of askedRuleIds) {
354
+ const answer = answers[ruleId];
355
+ if (
356
+ answer === undefined ||
357
+ !Number.isFinite(answer.noul) ||
358
+ answer.noul > 1 ||
359
+ answer.noul < VIOLATION_PROBABILITY
360
+ ) {
283
361
  continue;
284
362
  }
285
- findings.push({ ruleId: rule.id, probability: answer.noul });
363
+ findings.push({ ruleId, probability: answer.noul });
286
364
  }
287
365
  return findings;
288
366
  }
@@ -299,7 +377,7 @@ export function formatFindings(
299
377
  `Sideroom Jev review flagged ${path}:`,
300
378
  ...findings.map(
301
379
  (finding) =>
302
- `- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability that the added lines break this rule. Verify against the loaded guide before changing anything.`,
380
+ `- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability of a review concern. Verify the supplied evidence and applicable contract against the loaded guide before changing anything. Missing context is not proof of a violation.`,
303
381
  ),
304
382
  ].join('\n');
305
383
  }