@rmrdeveloper/sideroom-pi 8.11.0 → 8.12.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 (35) hide show
  1. package/README.md +33 -5
  2. package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
  3. package/extensions/done/guard.ts +42 -37
  4. package/extensions/done/index.ts +3 -2
  5. package/extensions/done/model.ts +1 -5
  6. package/extensions/explain/guard.ts +13 -11
  7. package/extensions/explain/model.ts +2 -25
  8. package/extensions/guidelines/review.ts +31 -13
  9. package/extensions/jev/client.ts +22 -1
  10. package/extensions/jev/context.ts +36 -3
  11. package/extensions/jev/guard.ts +113 -34
  12. package/extensions/jev/model.ts +90 -12
  13. package/extensions/jev/workflow.ts +188 -0
  14. package/extensions/persona/execute.ts +4 -6
  15. package/extensions/persona/index.ts +3 -2
  16. package/extensions/persona/model.ts +1 -31
  17. package/extensions/persona/prompt.ts +3 -1
  18. package/extensions/shared/settle.ts +63 -13
  19. package/package.json +1 -1
  20. package/skills/sideroom-architecture/SKILL.md +5 -3
  21. package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
  22. package/skills/sideroom-codebase-design/SKILL.md +151 -0
  23. package/skills/sideroom-codebase-design/references/growth.md +62 -0
  24. package/skills/sideroom-domain-modeling/SKILL.md +1 -1
  25. package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
  26. package/skills/sideroom-grill/SKILL.md +6 -4
  27. package/skills/sideroom-guidelines/SKILL.md +9 -7
  28. package/skills/sideroom-guidelines/references/languages/go.md +3 -3
  29. package/skills/sideroom-guidelines/references/languages/java.md +3 -3
  30. package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
  31. package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
  32. package/skills/sideroom-guidelines/references/languages/python.md +3 -3
  33. package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
  34. package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
  35. package/skills/sideroom-persona/SKILL.md +15 -4
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Sideroom Pi — the side room your agent was missing
2
2
 
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/logo.png" alt="The Sideroom Pi mark" width="640">
5
+ </p>
6
+
3
7
  [![npm version](https://img.shields.io/npm/v/@rmrdeveloper/sideroom-pi?label=npm&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
4
8
  [![npm monthly downloads](https://img.shields.io/npm/dm/@rmrdeveloper/sideroom-pi?label=downloads&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
5
9
  [![npm license](https://img.shields.io/npm/l/@rmrdeveloper/sideroom-pi?label=license)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
@@ -54,6 +58,7 @@ Sideroom answers each one with a small, opinionated surface:
54
58
  | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill + an end-of-turn review steer |
55
59
  | Invisible monorepo skills | `monorepo-skills` — trusted child folders join Pi's available skill list |
56
60
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
61
+ | Costly growth and refactors | `sideroom-codebase-design` — evidenced improvements to module design, tests, and long-term scaling |
57
62
  | Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
58
63
  | Unchecked judgment rules | `jev` — asks a decision model about guide rules with visible evidence |
59
64
  | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of unnecessary technical words |
@@ -154,6 +159,18 @@ one to interview at all, `sideroom-domain-scaffold` reads the central
154
159
  domain code as source of truth, builds or completes `CONTEXT.md` on its
155
160
  own, and grills the conflicting terms once, at the end.
156
161
 
162
+ ### Codebase design — improve how the code grows
163
+
164
+ `sideroom-codebase-design` reviews existing code, identifies where changes or
165
+ growth create friction, and designs improvements with small interfaces and
166
+ clear responsibilities. It considers growing features, teams, traffic, and
167
+ data, using known needs and evidence rather than hypothetical infrastructure.
168
+ Each recommendation explains the problem, benefit, cost, confidence, migration,
169
+ and verification in text. It produces no diagrams or HTML reports. A review
170
+ stays a review; when implementation is requested, it follows the authorized
171
+ scope and preserves behavior and compatibility. See
172
+ [skill activation](docs/skills.md) for routing and examples.
173
+
157
174
  ### Guidelines — a gate, not a novel
158
175
 
159
176
  A short system-prompt contract points at the packaged
@@ -197,13 +214,18 @@ Reading the guide covers the mechanical rules. `jev` makes one optional call
197
214
  to TypeSafe's decision model about the changed file and, when available, up to
198
215
  four directly related source files. It checks guard clauses, fail fast,
199
216
  command/query separation, null handling, immutability, repeated validation,
200
- responsibility, dependency direction, and comments only when the supplied
217
+ responsibility, dependency direction, comments, testability, redundant
218
+ abstractions, consumer compatibility and test quality only when the supplied
201
219
  files support the question. An answer above the cutoff travels with the
202
220
  end-of-run guidelines review instead of interrupting each edit. READMEs, JSON,
203
221
  lockfiles, deletion-only edits, and files outside the project are never sent.
204
222
 
205
223
  It never blocks and never enters the system prompt. No conversation or session
206
- content leaves the machine. Related source excludes ignored, hidden, sensitive-
224
+ history leaves the machine. Successful questionnaires also send their question
225
+ wording and options, without user answers. Newly completed tasks may send their
226
+ descriptions, up to four observed changed files and a check status, without
227
+ commands or logs. These additional calls review explicit contradictions and
228
+ ambiguous choices. Related source excludes ignored, hidden, sensitive-
207
229
  named and symlinked paths; a normal source file can still contain a secret.
208
230
  The footer counts the calls the session made, and `F10` shows the same count
209
231
  beside the key source. Without a key nothing is called, and when Jev runs out of
@@ -365,7 +387,12 @@ itself.
365
387
  prompt.
366
388
  - One request per successful `write`/`edit` of a project file in a supported
367
389
  language that added lines, with applicable `noul` questions on the complete
368
- changed file and up to four directly related source files.
390
+ changed file and up to four directly related source files, prioritizing tests
391
+ and production consumers before imports.
392
+ - One additional request per successful, non-cancelled questionnaire and per
393
+ eligible completion update. Only validated question wording or targeted
394
+ completion descriptions and bounded observed evidence travel; no answers,
395
+ conversation history or command output. All requests share the same limits.
369
396
  - The official 32k-token state-plus-longest-question and 64k-token request
370
397
  limits are observed conservatively using 32,000 and 64,000 UTF-8 bytes.
371
398
  Oversized neighbours are omitted whole; if the changed file cannot fit,
@@ -433,8 +460,9 @@ and zero ADRs is working as designed.
433
460
 
434
461
  ### Coexisting with other grilling skills
435
462
 
436
- Pi routes skills by description, not by name, so several grilling skills can
437
- be installed at once. `sideroom-grill` claims non-trivial plans before
463
+ See [skill activation](docs/skills.md) for selection boundaries and discovery
464
+ diagnostics. Pi routes skills by description, not by name, so several grilling
465
+ skills can be installed at once. `sideroom-grill` claims non-trivial plans before
438
466
  implementation and states precedence over other grilling, interview, and spec
439
467
  skills. If a third-party skill still wins, remove it from routing by adding
440
468
  `disable-model-invocation: true` to its frontmatter, or exclude its path with
@@ -181,7 +181,7 @@ async function createUser(data) {
181
181
  ```
182
182
 
183
183
  ### 12. DRY (real duplication only)
184
- RULE: Extract when at least 2 of 3 hold: (a) the logic appears 3+ times, (b) a future rule change would need to touch all occurrences at once, (c) the copies represent the same domain concept. Do not extract on the first or second occurrence, and do not extract look-alike code that belongs to different concepts (a user's email validation vs a vendor's) — they will diverge.
184
+ RULE: Extract when at least 2 of 3 hold: (a) the logic appears 3+ times, (b) a future rule change would need to touch all occurrences at once, (c) the copies represent the same domain concept. Do not extract solely to anticipate reuse on the first or second occurrence, and do not extract look-alike code that belongs to different concepts (a user's email validation vs a vendor's) — they will diverge. Extraction for a present responsibility, clear contract, or testability is valid even with one caller.
185
185
  ```ts
186
186
  // bad — extracted after one real use
187
187
  function formatName(x: string) { return x.trim().toUpperCase(); }
@@ -190,7 +190,7 @@ function normalizeTicketStatus(status: string): TicketStatus { /* ... */ }
190
190
  ```
191
191
 
192
192
  ### 13. KISS
193
- RULE: Ship the simplest solution for the current, real requirement. No configurability, hooks, or layers added "just in case."
193
+ RULE: Choose the simplest design that fully satisfies the current requirements and preserves required behavior. Avoid configurability, hooks, or layers without a demonstrated need; do not remove necessary structure just to reduce code size.
194
194
  ```js
195
195
  // bad
196
196
  function formatPrice(value, { currency = 'USD', locale = 'en-US', showSymbol = true } = {}) { /* ... */ }
@@ -199,7 +199,7 @@ function formatPrice(value) { return `$${value.toFixed(2)}`; }
199
199
  ```
200
200
 
201
201
  ### 14. YAGNI
202
- RULE: Don't add fields, params, or branches for a use case that hasn't arrived. (Differs from KISS: KISS simplifies the chosen solution; YAGNI stops you building the unrequested one.)
202
+ RULE: Avoid capabilities for hypothetical future use cases without evidence. User requirements, established project contracts, required error handling, compatibility, and meaningful tests are current needs, not speculation. Never use YAGNI to omit them or remove existing supported behavior. Apply it to the proposed change, not as a reason to refactor unrelated code.
203
203
  ```js
204
204
  // bad
205
205
  function createInvoice(order, { supportsRecurring = false, supportsMultiCurrency = false } = {}) { /* ... */ }
@@ -6,6 +6,11 @@ import {
6
6
  isToolCallEventType,
7
7
  isWriteToolResult,
8
8
  } from '@earendil-works/pi-coding-agent';
9
+ import {
10
+ registerSettleGuard,
11
+ SETTLE_PRIORITY,
12
+ withHiddenSteer,
13
+ } from '../shared/settle.ts';
9
14
  import { type CheckCommand, commandMatches } from './detect.ts';
10
15
  import {
11
16
  DONE_GATE_TYPE,
@@ -25,19 +30,22 @@ export type ResolveCommand = (
25
30
 
26
31
  export type ResolveTestSetup = (ctx: Pick<ExtensionContext, 'cwd'>) => boolean;
27
32
 
28
- export function registerDoneGuard(
29
- pi: ExtensionAPI,
30
- state: DoneState,
31
- resolveCommand: ResolveCommand,
32
- resolveTestSetup: ResolveTestSetup,
33
- ): void {
33
+ interface DoneGuardOptions {
34
+ readonly pi: ExtensionAPI;
35
+ readonly state: DoneState;
36
+ readonly resolveCommand: ResolveCommand;
37
+ readonly resolveTestSetup: ResolveTestSetup;
38
+ }
39
+
40
+ export function registerDoneGuard({
41
+ pi,
42
+ state,
43
+ resolveCommand,
44
+ resolveTestSetup,
45
+ }: DoneGuardOptions): void {
34
46
  const checkRuns = new Set<string>();
35
47
  const mutationPaths = new Map<string, string>();
36
48
 
37
- pi.on('turn_start', () => {
38
- state.steeredThisTurn = false;
39
- });
40
-
41
49
  pi.on('tool_call', (event, ctx) => {
42
50
  if (
43
51
  isToolCallEventType('write', event) ||
@@ -72,41 +80,38 @@ export function registerDoneGuard(
72
80
  }
73
81
  if (isBashToolResult(event)) {
74
82
  const wasCheck = checkRuns.delete(event.toolCallId);
75
- if (wasCheck && !event.isError) {
76
- state.checksGreen = true;
83
+ if (wasCheck) {
84
+ state.checksGreen = !event.isError;
77
85
  }
78
86
  }
79
87
  });
80
88
 
81
- pi.on('turn_end', (_event, ctx) => {
82
- const check = resolveCommand(ctx);
83
- if (check !== undefined && shouldSteerDone(state, true)) {
84
- state.steeredThisTurn = true;
85
- state.steerCount += 1;
86
- pi.sendMessage(
87
- {
89
+ registerSettleGuard(pi, {
90
+ priority: SETTLE_PRIORITY.done,
91
+ pending: (ctx) =>
92
+ shouldSteerDone(state, resolveCommand(ctx) !== undefined) ||
93
+ shouldNoticeMissingTest(state, resolveTestSetup(ctx)),
94
+ handle: (event, ctx) => {
95
+ const check = resolveCommand(ctx);
96
+ if (check !== undefined && shouldSteerDone(state, true)) {
97
+ state.steerCount += 1;
98
+ return withHiddenSteer({
99
+ event,
88
100
  customType: DONE_GATE_TYPE,
89
101
  content: formatDoneSteer(check.display),
90
- display: false,
91
- },
92
- { triggerTurn: true, deliverAs: 'steer' },
93
- );
94
- return;
95
- }
96
-
97
- if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
98
- return;
99
- }
100
- state.noticedMissingTest = true;
101
- state.steerCount += 1;
102
- pi.sendMessage(
103
- {
102
+ });
103
+ }
104
+ if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
105
+ return;
106
+ }
107
+ state.noticedMissingTest = true;
108
+ state.steerCount += 1;
109
+ return withHiddenSteer({
110
+ event,
104
111
  customType: MISSING_TEST_NOTICE_TYPE,
105
112
  content: formatMissingTestNotice(),
106
- display: false,
107
- },
108
- { triggerTurn: true, deliverAs: 'steer' },
109
- );
113
+ });
114
+ },
110
115
  });
111
116
  }
112
117
 
@@ -2,6 +2,7 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from '@earendil-works/pi-coding-agent';
5
+ import { startsUserTurn } from '../shared/settle.ts';
5
6
  import {
6
7
  type CheckCommand,
7
8
  detectCheckCommand,
@@ -41,10 +42,10 @@ export default function registerDone(pi: ExtensionAPI): void {
41
42
  return hasTests;
42
43
  };
43
44
 
44
- registerDoneGuard(pi, state, resolveCommand, resolveTestSetup);
45
+ registerDoneGuard({ pi, state, resolveCommand, resolveTestSetup });
45
46
 
46
47
  pi.on('input', (event) => {
47
- if (event.source === 'interactive' || event.source === 'rpc') {
48
+ if (startsUserTurn(event)) {
48
49
  resetDoneRun(state);
49
50
  }
50
51
  });
@@ -1,7 +1,6 @@
1
1
  export interface DoneState {
2
2
  mutated: boolean;
3
3
  checksGreen: boolean;
4
- steeredThisTurn: boolean;
5
4
  steerCount: number;
6
5
  mutatedSource: boolean;
7
6
  mutatedTest: boolean;
@@ -27,7 +26,6 @@ export function createDoneState(): DoneState {
27
26
  return {
28
27
  mutated: false,
29
28
  checksGreen: false,
30
- steeredThisTurn: false,
31
29
  steerCount: 0,
32
30
  mutatedSource: false,
33
31
  mutatedTest: false,
@@ -38,7 +36,6 @@ export function createDoneState(): DoneState {
38
36
  export function resetDoneRun(state: DoneState): void {
39
37
  state.mutated = false;
40
38
  state.checksGreen = false;
41
- state.steeredThisTurn = false;
42
39
  state.steerCount = 0;
43
40
  state.mutatedSource = false;
44
41
  state.mutatedTest = false;
@@ -63,7 +60,6 @@ export function shouldSteerDone(
63
60
  hasCommand &&
64
61
  state.mutated &&
65
62
  !state.checksGreen &&
66
- !state.steeredThisTurn &&
67
63
  state.steerCount < MAX_DONE_STEERS
68
64
  );
69
65
  }
@@ -86,5 +82,5 @@ export function formatDoneSteer(display: string): string {
86
82
  }
87
83
 
88
84
  export function formatMissingTestNotice(): string {
89
- return 'This run changed code files and no test file. Add or update the test that covers the change before finishing.';
85
+ return 'This run changed code files and no test file. Check whether existing tests cover the changed behavior; add or update a test if coverage is missing, then rerun the relevant checks before finishing.';
90
86
  }
@@ -4,7 +4,8 @@ import {
4
4
  isWriteToolResult,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
  import {
7
- acceptsSettleSteer,
7
+ registerSettleGuard,
8
+ SETTLE_PRIORITY,
8
9
  startsUserTurn,
9
10
  withHiddenSteer,
10
11
  } from '../shared/settle.ts';
@@ -54,15 +55,16 @@ export function registerExplainGuard(
54
55
  // agent_before_settle runs after retries and compaction recovery, so the
55
56
  // walkthrough is never offered over reworked work, and its continuation
56
57
  // stays inside the same run instead of starting a second one.
57
- pi.on('agent_before_settle', (event, ctx) => {
58
- if (!acceptsSettleSteer(event)) {
59
- return;
60
- }
61
- const decision = decideOffer(state, ctx.mode);
62
- applyOfferDecision(state, decision);
63
- if (decision !== EXPLAIN_DECISION.send) {
64
- return;
65
- }
66
- return withHiddenSteer(event, EXPLAIN_OFFER_TYPE, EXPLAIN_OFFER);
58
+ registerSettleGuard(pi, {
59
+ priority: SETTLE_PRIORITY.explain,
60
+ pending: (ctx) => decideOffer(state, ctx.mode) === EXPLAIN_DECISION.send,
61
+ handle: (event, ctx) => {
62
+ applyOfferDecision(state, decideOffer(state, ctx.mode));
63
+ return withHiddenSteer({
64
+ event,
65
+ customType: EXPLAIN_OFFER_TYPE,
66
+ content: EXPLAIN_OFFER,
67
+ });
68
+ },
67
69
  });
68
70
  }
@@ -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
+ }