@rmrdeveloper/sideroom-pi 8.10.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.
- package/README.md +63 -28
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
- package/extensions/ask/index.ts +3 -2
- 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 +27 -2
- package/extensions/jev/context.ts +360 -0
- package/extensions/jev/guard.ts +116 -34
- package/extensions/jev/model.ts +175 -23
- package/extensions/jev/workflow.ts +188 -0
- package/extensions/persona/catalog.ts +2 -2
- 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 +5 -3
- package/extensions/shared/settle.ts +63 -13
- package/package.json +1 -1
- package/skills/sideroom-architecture/SKILL.md +17 -8
- 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 +16 -6
- 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 +43 -25
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
isTestPath,
|
|
8
|
+
type JevFileState,
|
|
9
|
+
type JevRelatedFile,
|
|
10
|
+
stateFitsBudget,
|
|
11
|
+
} from './model.ts';
|
|
12
|
+
|
|
13
|
+
const MAX_RELATED_FILES = 4;
|
|
14
|
+
const MAX_SCAN_FILES = 1_024;
|
|
15
|
+
const MAX_SCAN_DEPTH = 10;
|
|
16
|
+
const MAX_CANDIDATE_BYTES = 32_000;
|
|
17
|
+
const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'] as const;
|
|
18
|
+
const SENSITIVE_NAME =
|
|
19
|
+
/secret|credential|password|token|private.?key|api.?key|auth.?key|(?:^|[._-])(?:env|private|key)(?:[._-]|$)/iu;
|
|
20
|
+
const SKIP_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage']);
|
|
21
|
+
|
|
22
|
+
interface Candidate {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly body: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ScanResult {
|
|
28
|
+
readonly candidates: readonly Candidate[];
|
|
29
|
+
readonly partial: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Keep the edited file complete. Other files are included whole or not at all.
|
|
33
|
+
export function buildContextualState(
|
|
34
|
+
cwd: string,
|
|
35
|
+
path: string,
|
|
36
|
+
body: string,
|
|
37
|
+
kind: string,
|
|
38
|
+
lines: Parameters<typeof buildFileState>[3],
|
|
39
|
+
): JevFileState | undefined {
|
|
40
|
+
const base = buildFileState(path, body, kind, lines);
|
|
41
|
+
if (!stateFitsBudget(base)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scan = scanSourceFiles(cwd, path);
|
|
46
|
+
const imported = references(path, body);
|
|
47
|
+
const related: JevRelatedFile[] = [];
|
|
48
|
+
let partial = scan.partial;
|
|
49
|
+
const matches = scan.candidates
|
|
50
|
+
.flatMap((candidate) => {
|
|
51
|
+
const importedByTarget = matchesReference(imported, candidate.path);
|
|
52
|
+
const consumesTarget = matchesReference(
|
|
53
|
+
references(candidate.path, candidate.body),
|
|
54
|
+
path,
|
|
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
|
+
) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
...candidate,
|
|
69
|
+
relation: isTestPath(candidate.path)
|
|
70
|
+
? 'test'
|
|
71
|
+
: importedByTarget || testedSource
|
|
72
|
+
? 'imported'
|
|
73
|
+
: 'consumer',
|
|
74
|
+
} as const,
|
|
75
|
+
];
|
|
76
|
+
})
|
|
77
|
+
.sort((left, right) => {
|
|
78
|
+
if (left.relation !== right.relation) {
|
|
79
|
+
const priority = { test: 0, consumer: 1, imported: 2 };
|
|
80
|
+
return priority[left.relation] - priority[right.relation];
|
|
81
|
+
}
|
|
82
|
+
return compareText(left.path, right.path);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const candidate of matches) {
|
|
86
|
+
if (related.length >= MAX_RELATED_FILES) {
|
|
87
|
+
partial = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const next = [...related, candidate];
|
|
91
|
+
if (!stateFitsBudget(buildFileState(path, body, kind, lines, next))) {
|
|
92
|
+
partial = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
related.push(candidate);
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
[...imported].some(
|
|
99
|
+
(name) =>
|
|
100
|
+
!related.some((file) => matchesReference(new Set([name]), file.path)),
|
|
101
|
+
)
|
|
102
|
+
) {
|
|
103
|
+
partial = true;
|
|
104
|
+
}
|
|
105
|
+
while (
|
|
106
|
+
related.length > 0 &&
|
|
107
|
+
!stateFitsBudget(buildFileState(path, body, kind, lines, related, partial))
|
|
108
|
+
) {
|
|
109
|
+
related.pop();
|
|
110
|
+
partial = true;
|
|
111
|
+
}
|
|
112
|
+
const state = buildFileState(path, body, kind, lines, related, partial);
|
|
113
|
+
return stateFitsBudget(state) ? state : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scanSourceFiles(cwd: string, editedPath: string): ScanResult {
|
|
117
|
+
const root = realpathSync(cwd);
|
|
118
|
+
const candidates: Candidate[] = [];
|
|
119
|
+
let visited = 0;
|
|
120
|
+
let partial = false;
|
|
121
|
+
|
|
122
|
+
function visit(
|
|
123
|
+
directory: string,
|
|
124
|
+
depth: number,
|
|
125
|
+
patterns: readonly string[],
|
|
126
|
+
): void {
|
|
127
|
+
if (depth > MAX_SCAN_DEPTH || visited >= MAX_SCAN_FILES) {
|
|
128
|
+
partial = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const localPatterns = [...patterns, ...readIgnorePatterns(directory, root)];
|
|
132
|
+
const matcher = ignore().add(localPatterns);
|
|
133
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort(
|
|
134
|
+
(a, b) => compareText(a.name, b.name),
|
|
135
|
+
);
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (visited >= MAX_SCAN_FILES) {
|
|
138
|
+
partial = true;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const absolute = join(directory, entry.name);
|
|
142
|
+
const path = relative(root, absolute).split(sep).join('/');
|
|
143
|
+
if (entry.name.startsWith('.') || SENSITIVE_NAME.test(entry.name)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
if (!SKIP_DIRECTORIES.has(entry.name) && !matcher.ignores(`${path}/`)) {
|
|
148
|
+
visit(absolute, depth + 1, localPatterns);
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
!entry.isFile() ||
|
|
154
|
+
matcher.ignores(path) ||
|
|
155
|
+
path === editedPath ||
|
|
156
|
+
languageForPath(path) === LANGUAGE.generic
|
|
157
|
+
) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
visited += 1;
|
|
161
|
+
if (statSync(absolute).size > MAX_CANDIDATE_BYTES) {
|
|
162
|
+
partial = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
candidates.push({ path, body: readFileSync(absolute, 'utf8') });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
visit(root, 0, []);
|
|
170
|
+
return { candidates, partial };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readIgnorePatterns(directory: string, root: string): string[] {
|
|
174
|
+
const prefix = relative(root, directory).split(sep).join('/');
|
|
175
|
+
const rules: string[] = [];
|
|
176
|
+
for (const name of IGNORE_FILES) {
|
|
177
|
+
const file = join(directory, name);
|
|
178
|
+
if (!exists(file)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (!trimmed || (trimmed.startsWith('#') && !trimmed.startsWith('\\#'))) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const negative = line.startsWith('!');
|
|
187
|
+
const pattern = (negative ? line.slice(1) : line).replace(/^\//u, '');
|
|
188
|
+
rules.push(
|
|
189
|
+
`${negative ? '!' : ''}${prefix ? `${prefix}/` : ''}${pattern}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return rules;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function exists(path: string): boolean {
|
|
197
|
+
try {
|
|
198
|
+
statSync(path);
|
|
199
|
+
return true;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function modulePath(path: string): string {
|
|
209
|
+
const extension = extname(path);
|
|
210
|
+
const withoutExtension = extension ? path.slice(0, -extension.length) : path;
|
|
211
|
+
return withoutExtension.endsWith('/index')
|
|
212
|
+
? withoutExtension.slice(0, -'/index'.length)
|
|
213
|
+
: withoutExtension;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function references(path: string, body: string): Set<string> {
|
|
217
|
+
const names = new Set<string>();
|
|
218
|
+
const language = languageForPath(path);
|
|
219
|
+
const code = body.replace(/\/\*[\s\S]*?\*\/|^[ \t]*\/\/[^\n]*$/gmu, '');
|
|
220
|
+
if (language === LANGUAGE.typescript || language === LANGUAGE.javascript) {
|
|
221
|
+
const imports = [
|
|
222
|
+
/^[ \t]*(?:import|export)\s+(?:\{[^}]*\}|[^\n;]+)\s*from\s*['"](\.[^'"]+)['"]/gmu,
|
|
223
|
+
/^[ \t]*import\s*['"](\.[^'"]+)['"]/gmu,
|
|
224
|
+
/^[ \t]*(?:(?:const|let|var)\s+[\w{} ,]+\s*=\s*)?require\s*\(\s*['"](\.[^'"]+)['"]/gmu,
|
|
225
|
+
];
|
|
226
|
+
for (const pattern of imports) {
|
|
227
|
+
for (const match of code.matchAll(pattern)) {
|
|
228
|
+
const specifier = match[1];
|
|
229
|
+
if (specifier) {
|
|
230
|
+
names.add(
|
|
231
|
+
modulePath(
|
|
232
|
+
posix.normalize(posix.join(posix.dirname(path), specifier)),
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (language === LANGUAGE.python) {
|
|
240
|
+
for (const match of code.matchAll(
|
|
241
|
+
/^[ \t]*from\s+(\.+)([\w.]*)\s+import\b/gmu,
|
|
242
|
+
)) {
|
|
243
|
+
const dots = match[1]?.length ?? 0;
|
|
244
|
+
const specifier = match[2]?.replaceAll('.', '/') ?? '';
|
|
245
|
+
const parent = posix.resolve(
|
|
246
|
+
'/',
|
|
247
|
+
posix.dirname(path),
|
|
248
|
+
...Array(dots - 1).fill('..'),
|
|
249
|
+
);
|
|
250
|
+
names.add(modulePath(posix.join(parent.slice(1), specifier)));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (language === LANGUAGE.rust) {
|
|
254
|
+
let sourceRoot = posix.dirname(path);
|
|
255
|
+
if (path.startsWith('src/')) {
|
|
256
|
+
sourceRoot = 'src';
|
|
257
|
+
} else if (path.includes('/src/')) {
|
|
258
|
+
sourceRoot = path.slice(0, path.indexOf('/src/') + '/src'.length);
|
|
259
|
+
}
|
|
260
|
+
for (const match of code.matchAll(
|
|
261
|
+
/^[ \t]*(?:pub\s+)?(?:use|mod)\s+(crate::)?([\w:]+)\s*[;{]/gmu,
|
|
262
|
+
)) {
|
|
263
|
+
const specifier = match[2]?.replaceAll('::', '/');
|
|
264
|
+
if (specifier) {
|
|
265
|
+
const from = match[1] ? sourceRoot : posix.dirname(path);
|
|
266
|
+
names.add(modulePath(posix.join(from, specifier)));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (language === LANGUAGE.php) {
|
|
271
|
+
for (const match of code.matchAll(
|
|
272
|
+
/^[ \t]*(?:require|include)(?:_once)?\s*(?:\(?\s*__DIR__\s*\.\s*)?['"](\.[^'"]+|\/[^'"]+)['"]/gmu,
|
|
273
|
+
)) {
|
|
274
|
+
const specifier = match[1];
|
|
275
|
+
if (specifier) {
|
|
276
|
+
names.add(
|
|
277
|
+
modulePath(
|
|
278
|
+
posix.normalize(posix.join(posix.dirname(path), specifier)),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (language === LANGUAGE.java) {
|
|
285
|
+
for (const match of code.matchAll(
|
|
286
|
+
/^[ \t]*import\s+(?:static\s+)?([\w.]+)\s*;/gmu,
|
|
287
|
+
)) {
|
|
288
|
+
const specifier = match[1]?.replaceAll('.', '/');
|
|
289
|
+
if (specifier) {
|
|
290
|
+
names.add(specifier);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (language === LANGUAGE.go) {
|
|
295
|
+
for (const declaration of code.matchAll(
|
|
296
|
+
/^[ \t]*import\s*(?:\(([^)]*)\)|"([^"\n]+)")/gmu,
|
|
297
|
+
)) {
|
|
298
|
+
const quoted = declaration[1] ?? `"${declaration[2] ?? ''}"`;
|
|
299
|
+
for (const match of quoted.matchAll(/"([^"\n]+)"/gu)) {
|
|
300
|
+
if (match[1]) {
|
|
301
|
+
names.add(match[1]);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return names;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function compareText(left: string, right: string): number {
|
|
310
|
+
if (left < right) {
|
|
311
|
+
return -1;
|
|
312
|
+
}
|
|
313
|
+
if (left > right) {
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function matchesReference(names: ReadonlySet<string>, path: string): boolean {
|
|
320
|
+
const target = modulePath(path);
|
|
321
|
+
if (names.has(target)) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
for (const name of names) {
|
|
325
|
+
if (path.endsWith('.java') && target.endsWith(`/${name}`)) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (path.endsWith('.go')) {
|
|
329
|
+
const packageParts = name.split('/');
|
|
330
|
+
const packageSuffix = packageParts.slice(-2).join('/');
|
|
331
|
+
if (
|
|
332
|
+
packageParts.length >= 2 &&
|
|
333
|
+
posix.dirname(path).endsWith(`/${packageSuffix}`)
|
|
334
|
+
) {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
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,23 +15,35 @@ 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,
|
|
21
22
|
} from './client.ts';
|
|
23
|
+
import { buildContextualState } from './context.ts';
|
|
22
24
|
import {
|
|
23
25
|
type AddedLine,
|
|
24
26
|
addedLinesForEdit,
|
|
25
27
|
addedLinesForWrite,
|
|
26
|
-
|
|
28
|
+
buildRequestBody,
|
|
27
29
|
findingKey,
|
|
28
30
|
formatFindings,
|
|
29
31
|
isReviewablePath,
|
|
30
32
|
type JevFinding,
|
|
31
|
-
|
|
33
|
+
type JevRequestBody,
|
|
32
34
|
} from './model.ts';
|
|
33
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
|
+
|
|
34
47
|
export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
|
|
35
48
|
|
|
36
49
|
const MUTATION_KIND = {
|
|
@@ -65,6 +78,7 @@ export interface JevGuardState {
|
|
|
65
78
|
rateLimitedThisTurn: boolean;
|
|
66
79
|
lastModel: string | undefined;
|
|
67
80
|
calls: number;
|
|
81
|
+
readonly evidence: WorkflowEvidence;
|
|
68
82
|
readonly pending: Map<string, PendingMutation>;
|
|
69
83
|
readonly notedThisTurn: Set<string>;
|
|
70
84
|
}
|
|
@@ -82,6 +96,7 @@ export function createJevGuardState(): JevGuardState {
|
|
|
82
96
|
rateLimitedThisTurn: false,
|
|
83
97
|
lastModel: undefined,
|
|
84
98
|
calls: 0,
|
|
99
|
+
evidence: createWorkflowEvidence(),
|
|
85
100
|
pending: new Map(),
|
|
86
101
|
notedThisTurn: new Set(),
|
|
87
102
|
};
|
|
@@ -89,6 +104,7 @@ export function createJevGuardState(): JevGuardState {
|
|
|
89
104
|
|
|
90
105
|
// The F10 screen calls this after the user stores a key or asks to retry.
|
|
91
106
|
export function resetJevGuard(state: JevGuardState): void {
|
|
107
|
+
resetWorkflowEvidence(state.evidence);
|
|
92
108
|
state.halted = undefined;
|
|
93
109
|
state.transientFailures = 0;
|
|
94
110
|
state.rateLimitedThisTurn = false;
|
|
@@ -97,6 +113,7 @@ export function resetJevGuard(state: JevGuardState): void {
|
|
|
97
113
|
// Usage and per-turn memory belong to the active session: a new session starts
|
|
98
114
|
// counting from zero. The breaker stays, because it tracks the key.
|
|
99
115
|
export function resetJevSession(state: JevGuardState): void {
|
|
116
|
+
resetWorkflowEvidence(state.evidence);
|
|
100
117
|
state.calls = 0;
|
|
101
118
|
state.rateLimitedThisTurn = false;
|
|
102
119
|
state.pending.clear();
|
|
@@ -132,6 +149,7 @@ export function registerJevGuard(
|
|
|
132
149
|
if (!startsUserTurn(event)) {
|
|
133
150
|
return;
|
|
134
151
|
}
|
|
152
|
+
resetWorkflowEvidence(state.evidence);
|
|
135
153
|
state.notedThisTurn.clear();
|
|
136
154
|
state.rateLimitedThisTurn = false;
|
|
137
155
|
});
|
|
@@ -146,7 +164,7 @@ export function registerJevGuard(
|
|
|
146
164
|
// the previous content is gone. The body is read after, from disk.
|
|
147
165
|
pi.on('tool_call', (event, ctx) => {
|
|
148
166
|
const mutation = extractMutation(event, ctx.cwd);
|
|
149
|
-
if (mutation === undefined || !
|
|
167
|
+
if (mutation === undefined || !isReviewablePath(mutation.relativePath)) {
|
|
150
168
|
return undefined;
|
|
151
169
|
}
|
|
152
170
|
state.pending.set(event.toolCallId, mutation);
|
|
@@ -156,44 +174,108 @@ export function registerJevGuard(
|
|
|
156
174
|
pi.on('tool_result', async (event, ctx) => {
|
|
157
175
|
const pending = state.pending.get(event.toolCallId);
|
|
158
176
|
state.pending.delete(event.toolCallId);
|
|
159
|
-
if (pending === undefined || event.isError) {
|
|
160
|
-
return undefined;
|
|
161
|
-
}
|
|
162
|
-
if (!isWriteToolResult(event) && !isEditToolResult(event)) {
|
|
163
|
-
return undefined;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
177
|
const apiKey = dependencies.apiKey();
|
|
167
|
-
if (
|
|
168
|
-
|
|
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
|
+
}
|
|
169
189
|
return undefined;
|
|
170
190
|
}
|
|
171
|
-
if (
|
|
172
|
-
|
|
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
|
+
}
|
|
173
201
|
return undefined;
|
|
174
202
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
178
208
|
return undefined;
|
|
179
209
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
267
|
+
}
|
|
268
|
+
if (requestBody === undefined) {
|
|
187
269
|
return undefined;
|
|
188
270
|
}
|
|
189
271
|
|
|
190
272
|
state.calls += 1;
|
|
191
|
-
const attempt = await
|
|
192
|
-
dependencies.transport,
|
|
273
|
+
const attempt = await askJevRequest({
|
|
274
|
+
transport: dependencies.transport,
|
|
193
275
|
apiKey,
|
|
194
|
-
|
|
195
|
-
ctx.signal,
|
|
196
|
-
);
|
|
276
|
+
requestBody,
|
|
277
|
+
signal: ctx.signal,
|
|
278
|
+
});
|
|
197
279
|
if (attempt.failure !== undefined) {
|
|
198
280
|
registerFailure(state, attempt.failure);
|
|
199
281
|
dependencies.report(guardStatus(state), ctx);
|
|
@@ -208,14 +290,14 @@ export function registerJevGuard(
|
|
|
208
290
|
|
|
209
291
|
const findings = attempt.findings ?? [];
|
|
210
292
|
const fresh = findings.filter((finding) =>
|
|
211
|
-
isUnnoted(state,
|
|
293
|
+
isUnnoted(state, subject, finding),
|
|
212
294
|
);
|
|
213
295
|
if (fresh.length === 0) {
|
|
214
296
|
return undefined;
|
|
215
297
|
}
|
|
216
|
-
markNoted(state,
|
|
298
|
+
markNoted(state, subject, fresh);
|
|
217
299
|
const note: ReviewNote = {
|
|
218
|
-
text: formatFindings(
|
|
300
|
+
text: formatFindings(subject, fresh),
|
|
219
301
|
};
|
|
220
302
|
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
221
303
|
return undefined;
|
|
@@ -269,7 +351,7 @@ function registerSuccess(state: JevGuardState): void {
|
|
|
269
351
|
state.transientFailures = 0;
|
|
270
352
|
}
|
|
271
353
|
|
|
272
|
-
// A deletion-only edit leaves nothing for the
|
|
354
|
+
// A deletion-only edit leaves nothing for the questions to read, and only
|
|
273
355
|
// the project-relative path of a supported language may leave the machine.
|
|
274
356
|
function isWorthAsking(mutation: PendingMutation): boolean {
|
|
275
357
|
return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
|