@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.
- package/README.md +49 -5
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
- package/assets/visual-context/browser/INSTALL.md +69 -0
- package/assets/visual-context/browser/background.js +578 -0
- package/assets/visual-context/browser/content.js +198 -0
- package/assets/visual-context/browser/icons/README.md +9 -0
- package/assets/visual-context/browser/icons/icon-128.png +0 -0
- package/assets/visual-context/browser/icons/icon-16.png +0 -0
- package/assets/visual-context/browser/icons/icon-32.png +0 -0
- package/assets/visual-context/browser/icons/icon-48.png +0 -0
- package/assets/visual-context/browser/manifest.json +20 -0
- package/assets/visual-context/browser/popup.css +19 -0
- package/assets/visual-context/browser/popup.html +27 -0
- package/assets/visual-context/browser/popup.js +141 -0
- package/extensions/done/guard.ts +42 -37
- package/extensions/done/index.ts +3 -2
- package/extensions/done/model.ts +1 -5
- package/extensions/explain/guard.ts +13 -11
- package/extensions/explain/model.ts +2 -25
- package/extensions/guidelines/review.ts +31 -13
- package/extensions/jev/client.ts +22 -1
- package/extensions/jev/context.ts +36 -3
- package/extensions/jev/guard.ts +113 -34
- package/extensions/jev/model.ts +90 -12
- package/extensions/jev/workflow.ts +188 -0
- package/extensions/persona/execute.ts +4 -6
- package/extensions/persona/index.ts +3 -2
- package/extensions/persona/model.ts +1 -31
- package/extensions/persona/prompt.ts +3 -1
- package/extensions/shared/settle.ts +63 -13
- package/extensions/visual-context/bridge.ts +240 -0
- package/extensions/visual-context/broker.ts +292 -0
- package/extensions/visual-context/envelope.ts +34 -0
- package/extensions/visual-context/index.ts +328 -0
- package/extensions/visual-context/literal.ts +34 -0
- package/extensions/visual-context/markup.ts +104 -0
- package/extensions/visual-context/match.ts +222 -0
- package/extensions/visual-context/model.ts +180 -0
- package/extensions/visual-context/pairing.ts +114 -0
- package/extensions/visual-context/parse-astro.ts +166 -0
- package/extensions/visual-context/parse-html.ts +72 -0
- package/extensions/visual-context/parse-jsx.ts +157 -0
- package/extensions/visual-context/parse-svelte.ts +128 -0
- package/extensions/visual-context/parse-vue.ts +133 -0
- package/extensions/visual-context/session.ts +192 -0
- package/extensions/visual-context/source.ts +197 -0
- package/extensions/visual-context/ui.ts +57 -0
- package/package.json +16 -7
- package/skills/sideroom-architecture/SKILL.md +5 -3
- package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
- package/skills/sideroom-codebase-design/SKILL.md +151 -0
- package/skills/sideroom-codebase-design/references/growth.md +62 -0
- package/skills/sideroom-domain-modeling/SKILL.md +1 -1
- package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
- package/skills/sideroom-grill/SKILL.md +6 -4
- package/skills/sideroom-guidelines/SKILL.md +9 -7
- package/skills/sideroom-guidelines/references/languages/go.md +3 -3
- package/skills/sideroom-guidelines/references/languages/java.md +3 -3
- package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
- package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
- package/skills/sideroom-guidelines/references/languages/python.md +3 -3
- package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
- package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
- 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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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(
|
|
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(
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
}
|
package/extensions/jev/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/jev/guard.ts
CHANGED
|
@@ -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
|
-
|
|
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 || !
|
|
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 (
|
|
167
|
-
|
|
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 (
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
dependencies.transport,
|
|
273
|
+
const attempt = await askJevRequest({
|
|
274
|
+
transport: dependencies.transport,
|
|
196
275
|
apiKey,
|
|
197
|
-
|
|
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,
|
|
293
|
+
isUnnoted(state, subject, finding),
|
|
215
294
|
);
|
|
216
295
|
if (fresh.length === 0) {
|
|
217
296
|
return undefined;
|
|
218
297
|
}
|
|
219
|
-
markNoted(state,
|
|
298
|
+
markNoted(state, subject, fresh);
|
|
220
299
|
const note: ReviewNote = {
|
|
221
|
-
text: formatFindings(
|
|
300
|
+
text: formatFindings(subject, fresh),
|
|
222
301
|
};
|
|
223
302
|
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
224
303
|
return undefined;
|
package/extensions/jev/model.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
|
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
|
}
|