@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.
- package/README.md +33 -5
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
- 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/package.json +1 -1
- 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
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
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { type AskParams, parseAskParams } from '../ask/model.ts';
|
|
2
|
+
import {
|
|
3
|
+
parseTodoParams,
|
|
4
|
+
TODO_STATUS,
|
|
5
|
+
type TodoItem,
|
|
6
|
+
type TodoToolParameters,
|
|
7
|
+
} from '../todo/model.ts';
|
|
8
|
+
import {
|
|
9
|
+
JEV_MODEL,
|
|
10
|
+
type JevFileState,
|
|
11
|
+
type JevNoulQuestion,
|
|
12
|
+
type JevRequestBody,
|
|
13
|
+
requestFitsBudget,
|
|
14
|
+
} from './model.ts';
|
|
15
|
+
|
|
16
|
+
const MAX_EVIDENCE_FILES = 4;
|
|
17
|
+
|
|
18
|
+
type ChangedFileEvidence = Pick<JevFileState, 'file' | 'change'>;
|
|
19
|
+
|
|
20
|
+
export interface WorkflowEvidence {
|
|
21
|
+
readonly files: Map<string, ChangedFileEvidence>;
|
|
22
|
+
readonly completed: Map<string, string>;
|
|
23
|
+
partial: boolean;
|
|
24
|
+
checks: 'unknown' | 'passed' | 'failed' | 'stale';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createWorkflowEvidence(): WorkflowEvidence {
|
|
28
|
+
return {
|
|
29
|
+
files: new Map(),
|
|
30
|
+
completed: new Map(),
|
|
31
|
+
partial: false,
|
|
32
|
+
checks: 'unknown',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resetWorkflowEvidence(evidence: WorkflowEvidence): void {
|
|
37
|
+
evidence.files.clear();
|
|
38
|
+
evidence.completed.clear();
|
|
39
|
+
evidence.partial = false;
|
|
40
|
+
evidence.checks = 'unknown';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recordFileEvidence(
|
|
44
|
+
evidence: WorkflowEvidence,
|
|
45
|
+
file: JevFileState,
|
|
46
|
+
): void {
|
|
47
|
+
evidence.checks = 'stale';
|
|
48
|
+
evidence.files.delete(file.file.path);
|
|
49
|
+
evidence.files.set(file.file.path, { file: file.file, change: file.change });
|
|
50
|
+
if (evidence.files.size > MAX_EVIDENCE_FILES) {
|
|
51
|
+
const oldest = evidence.files.keys().next().value;
|
|
52
|
+
if (oldest !== undefined) {
|
|
53
|
+
evidence.files.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
evidence.partial = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function question(instructions: string, violation: string): JevNoulQuestion {
|
|
60
|
+
return {
|
|
61
|
+
type: 'noul',
|
|
62
|
+
instructions,
|
|
63
|
+
criteria: {
|
|
64
|
+
true: violation,
|
|
65
|
+
false:
|
|
66
|
+
'No concrete contradiction is visible, or the supplied evidence is insufficient',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAskReview(input: unknown): JevRequestBody | undefined {
|
|
72
|
+
const parsed = parseAskParams(input as AskParams);
|
|
73
|
+
if (!parsed.ok) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const request: JevRequestBody = {
|
|
77
|
+
model: JEV_MODEL,
|
|
78
|
+
state: { kind: 'questionnaire', questions: parsed.questions },
|
|
79
|
+
questions: {
|
|
80
|
+
'question-options': question(
|
|
81
|
+
'Do the supplied questions contain overlapping single-selection options or bundle independent decisions so no option can express a reasonable combination? Multiple selections may intentionally overlap. Judge only the question text, not unseen requirements.',
|
|
82
|
+
'The wording visibly prevents a clear independent choice',
|
|
83
|
+
),
|
|
84
|
+
'question-recommendation': question(
|
|
85
|
+
'Does a recommended option claim factual certainty or decisive cost/performance figures without support in the supplied question, or contradict its stated constraints? A preference alone need not include proof. Do not assume unseen project evidence is absent; flag only a demonstrably unsupported assertion or contradiction in the wording.',
|
|
86
|
+
'The recommendation visibly contradicts stated constraints or asserts an unsupported decisive fact',
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
return requestFitsBudget(request) ? request : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function completedItems(
|
|
94
|
+
details: unknown,
|
|
95
|
+
): readonly TodoItem[] | undefined {
|
|
96
|
+
if (typeof details !== 'object' || details === null || 'error' in details) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const parsed = parseTodoParams({
|
|
100
|
+
action: 'propose',
|
|
101
|
+
items: 'items' in details ? details.items : undefined,
|
|
102
|
+
} as TodoToolParameters);
|
|
103
|
+
if (!parsed.ok || parsed.params.action !== 'propose') {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return parsed.params.items.filter(
|
|
107
|
+
(item) => item.status === TODO_STATUS.completed,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildBoardReview({
|
|
112
|
+
items,
|
|
113
|
+
evidence,
|
|
114
|
+
changedIds,
|
|
115
|
+
}: {
|
|
116
|
+
readonly items: readonly TodoItem[];
|
|
117
|
+
readonly evidence: WorkflowEvidence;
|
|
118
|
+
readonly changedIds?: ReadonlySet<string>;
|
|
119
|
+
}): JevRequestBody | undefined {
|
|
120
|
+
const newlyCompleted = items.filter(
|
|
121
|
+
(item) =>
|
|
122
|
+
(changedIds === undefined || changedIds.has(item.id)) &&
|
|
123
|
+
evidence.completed.get(item.id) !== item.content,
|
|
124
|
+
);
|
|
125
|
+
evidence.completed.clear();
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
evidence.completed.set(item.id, item.content);
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
newlyCompleted.length === 0 ||
|
|
131
|
+
(evidence.files.size === 0 &&
|
|
132
|
+
evidence.checks !== 'passed' &&
|
|
133
|
+
evidence.checks !== 'failed')
|
|
134
|
+
) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const files: ChangedFileEvidence[] = [];
|
|
138
|
+
const state = {
|
|
139
|
+
kind: 'completion',
|
|
140
|
+
items: newlyCompleted,
|
|
141
|
+
evidence: { files, checks: evidence.checks, partial: evidence.partial },
|
|
142
|
+
};
|
|
143
|
+
const request: JevRequestBody = {
|
|
144
|
+
model: JEV_MODEL,
|
|
145
|
+
state,
|
|
146
|
+
questions: {
|
|
147
|
+
'board-completion': question(
|
|
148
|
+
'Do the newly completed tasks make a concrete claim contradicted by the supplied code changes or check status? Check status concerns only the detected project check in this user turn and becomes stale after a mutation. Missing evidence, unknown/stale checks and partial files do not prove unfinished work. Do not require every task to involve code or tests. Flag only an explicit mismatch, such as claiming checks passed when the observed latest checks failed.',
|
|
149
|
+
'A completed task claims behavior or a check result demonstrably contradicted by visible evidence',
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
for (const file of evidence.files.values()) {
|
|
154
|
+
files.push(file);
|
|
155
|
+
if (!requestFitsBudget(request)) {
|
|
156
|
+
files.pop();
|
|
157
|
+
state.evidence.partial = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return (files.length > 0 ||
|
|
161
|
+
evidence.checks === 'passed' ||
|
|
162
|
+
evidence.checks === 'failed') &&
|
|
163
|
+
requestFitsBudget(request)
|
|
164
|
+
? request
|
|
165
|
+
: undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function completionIds(input: unknown): ReadonlySet<string> {
|
|
169
|
+
const parsed = parseTodoParams(input as TodoToolParameters);
|
|
170
|
+
if (!parsed.ok) {
|
|
171
|
+
return new Set();
|
|
172
|
+
}
|
|
173
|
+
if (parsed.params.action === 'propose') {
|
|
174
|
+
return new Set(
|
|
175
|
+
parsed.params.items
|
|
176
|
+
.filter((item) => item.status === TODO_STATUS.completed)
|
|
177
|
+
.map((item) => item.id),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return new Set(
|
|
181
|
+
parsed.params.patches
|
|
182
|
+
.filter(
|
|
183
|
+
(patch) =>
|
|
184
|
+
patch.status === TODO_STATUS.completed || patch.content !== undefined,
|
|
185
|
+
)
|
|
186
|
+
.map((patch) => patch.id),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
personaToolDetails,
|
|
5
|
-
} from './model.ts';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { PERSONA_SKILL_PATH } from './catalog.ts';
|
|
3
|
+
import { type PersonaToolDetails, personaToolDetails } from './model.ts';
|
|
6
4
|
|
|
7
5
|
export interface PersonaToolResult {
|
|
8
6
|
readonly content: { readonly type: 'text'; readonly text: string }[];
|
|
@@ -11,7 +9,7 @@ export interface PersonaToolResult {
|
|
|
11
9
|
|
|
12
10
|
export function executePersona(): PersonaToolResult {
|
|
13
11
|
return {
|
|
14
|
-
content: [{ type: 'text', text:
|
|
12
|
+
content: [{ type: 'text', text: readFileSync(PERSONA_SKILL_PATH, 'utf8') }],
|
|
15
13
|
details: personaToolDetails(),
|
|
16
14
|
};
|
|
17
15
|
}
|
|
@@ -15,9 +15,10 @@ import { personaStatusLabel } from './model.ts';
|
|
|
15
15
|
import { appendPersonaReminder } from './prompt.ts';
|
|
16
16
|
|
|
17
17
|
export const PERSONA_DESCRIPTION =
|
|
18
|
-
|
|
18
|
+
'Load the complete Sideroom writing guide before a substantial explanation, findings report, implementation summary, or user-facing documentation; also use when asked for clearer, shorter, or less technical wording. Reuse while in context.';
|
|
19
19
|
|
|
20
|
-
export const PERSONA_PROMPT_SNIPPET =
|
|
20
|
+
export const PERSONA_PROMPT_SNIPPET =
|
|
21
|
+
'Load the complete writing guide before substantial user-facing prose.';
|
|
21
22
|
|
|
22
23
|
// Pi loads extensions/*/index.ts through export default.
|
|
23
24
|
export default function registerPersona(pi: ExtensionAPI): void {
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
PERSONA_SKILL_PATH,
|
|
3
|
-
PERSONA_TOOL_NAME,
|
|
4
|
-
PROHIBITIONS,
|
|
5
|
-
type Prohibition,
|
|
6
|
-
TEXT_SCOPE,
|
|
7
|
-
VOICE_RULES,
|
|
8
|
-
} from './catalog.ts';
|
|
1
|
+
import { PERSONA_TOOL_NAME, PROHIBITIONS, VOICE_RULES } from './catalog.ts';
|
|
9
2
|
import type { ProhibitionViolation } from './checks.ts';
|
|
10
3
|
|
|
11
4
|
// Pi narrows these blocks before handing the message over; the shape is
|
|
@@ -45,22 +38,6 @@ export function personaToolDetails(): PersonaToolDetails {
|
|
|
45
38
|
};
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
export function formatPersonaDetail(): string {
|
|
49
|
-
return [
|
|
50
|
-
'Sideroom persona: one always-on voice; no profiles or switching.',
|
|
51
|
-
'',
|
|
52
|
-
'Voice rules',
|
|
53
|
-
...VOICE_RULES.map((rule) => `- ${rule.instruction}`),
|
|
54
|
-
'',
|
|
55
|
-
'Hard prohibitions',
|
|
56
|
-
...PROHIBITIONS.map(
|
|
57
|
-
(prohibition) => `- ${prohibition.rule} (${enforcementOf(prohibition)})`,
|
|
58
|
-
),
|
|
59
|
-
'',
|
|
60
|
-
`Complete guide: ${PERSONA_SKILL_PATH}`,
|
|
61
|
-
].join('\n');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
41
|
export function formatBlockReason(
|
|
65
42
|
path: string,
|
|
66
43
|
violations: readonly ProhibitionViolation[],
|
|
@@ -112,10 +89,3 @@ export function assistantMessageText(content: readonly TextBlock[]): string {
|
|
|
112
89
|
}
|
|
113
90
|
return parts.join('\n');
|
|
114
91
|
}
|
|
115
|
-
|
|
116
|
-
function enforcementOf(prohibition: Prohibition): string {
|
|
117
|
-
if (prohibition.scopes.includes(TEXT_SCOPE.artifact)) {
|
|
118
|
-
return 'blocked on write and edit';
|
|
119
|
-
}
|
|
120
|
-
return 'corrected with a steer';
|
|
121
|
-
}
|
|
@@ -23,12 +23,14 @@ export const PERSONA_EXAMPLES = [
|
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
25
|
export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
|
|
26
|
+
Apply this voice to progress messages, final answers, and user-facing documentation.
|
|
26
27
|
Voice: ${VOICE_SUMMARY}
|
|
27
28
|
Never: ${PROHIBITION_SUMMARY}
|
|
28
29
|
${ENFORCEMENT_SUMMARY}
|
|
29
30
|
Examples:
|
|
30
31
|
${PERSONA_EXAMPLES.join('\n')}
|
|
31
|
-
|
|
32
|
+
Before the first substantial explanation or report, call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}; reuse the complete guide while it remains in context.
|
|
33
|
+
Before sending prose, check: outcome first, necessary terms explained, no repetition or invented labels. Preserve evidence and requested detail.`;
|
|
32
34
|
|
|
33
35
|
export function appendPersonaReminder(systemPrompt: string): string {
|
|
34
36
|
if (systemPrompt.includes(PERSONA_REMINDER_HEADING)) {
|