@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
@@ -6,6 +6,7 @@ import {
6
6
  isWriteToolResult,
7
7
  type ToolCallEvent,
8
8
  } from '@earendil-works/pi-coding-agent';
9
+ import { commandMatches, detectCheckCommand } from '../done/detect.ts';
9
10
  import {
10
11
  projectRelativePath,
11
12
  resolveFileToolPath,
@@ -14,7 +15,7 @@ import { readFileIfExists } from '../shared/read-file.ts';
14
15
  import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
15
16
  import { startsUserTurn } from '../shared/settle.ts';
16
17
  import {
17
- askJev,
18
+ askJevRequest,
18
19
  JEV_FAILURE,
19
20
  type JevFailure,
20
21
  type JevTransport,
@@ -24,12 +25,25 @@ import {
24
25
  type AddedLine,
25
26
  addedLinesForEdit,
26
27
  addedLinesForWrite,
28
+ buildRequestBody,
27
29
  findingKey,
28
30
  formatFindings,
29
31
  isReviewablePath,
30
32
  type JevFinding,
33
+ type JevRequestBody,
31
34
  } from './model.ts';
32
35
 
36
+ import {
37
+ buildAskReview,
38
+ buildBoardReview,
39
+ completedItems,
40
+ completionIds,
41
+ createWorkflowEvidence,
42
+ recordFileEvidence,
43
+ resetWorkflowEvidence,
44
+ type WorkflowEvidence,
45
+ } from './workflow.ts';
46
+
33
47
  export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
34
48
 
35
49
  const MUTATION_KIND = {
@@ -64,6 +78,7 @@ export interface JevGuardState {
64
78
  rateLimitedThisTurn: boolean;
65
79
  lastModel: string | undefined;
66
80
  calls: number;
81
+ readonly evidence: WorkflowEvidence;
67
82
  readonly pending: Map<string, PendingMutation>;
68
83
  readonly notedThisTurn: Set<string>;
69
84
  }
@@ -81,6 +96,7 @@ export function createJevGuardState(): JevGuardState {
81
96
  rateLimitedThisTurn: false,
82
97
  lastModel: undefined,
83
98
  calls: 0,
99
+ evidence: createWorkflowEvidence(),
84
100
  pending: new Map(),
85
101
  notedThisTurn: new Set(),
86
102
  };
@@ -88,6 +104,7 @@ export function createJevGuardState(): JevGuardState {
88
104
 
89
105
  // The F10 screen calls this after the user stores a key or asks to retry.
90
106
  export function resetJevGuard(state: JevGuardState): void {
107
+ resetWorkflowEvidence(state.evidence);
91
108
  state.halted = undefined;
92
109
  state.transientFailures = 0;
93
110
  state.rateLimitedThisTurn = false;
@@ -96,6 +113,7 @@ export function resetJevGuard(state: JevGuardState): void {
96
113
  // Usage and per-turn memory belong to the active session: a new session starts
97
114
  // counting from zero. The breaker stays, because it tracks the key.
98
115
  export function resetJevSession(state: JevGuardState): void {
116
+ resetWorkflowEvidence(state.evidence);
99
117
  state.calls = 0;
100
118
  state.rateLimitedThisTurn = false;
101
119
  state.pending.clear();
@@ -131,6 +149,7 @@ export function registerJevGuard(
131
149
  if (!startsUserTurn(event)) {
132
150
  return;
133
151
  }
152
+ resetWorkflowEvidence(state.evidence);
134
153
  state.notedThisTurn.clear();
135
154
  state.rateLimitedThisTurn = false;
136
155
  });
@@ -145,7 +164,7 @@ export function registerJevGuard(
145
164
  // the previous content is gone. The body is read after, from disk.
146
165
  pi.on('tool_call', (event, ctx) => {
147
166
  const mutation = extractMutation(event, ctx.cwd);
148
- if (mutation === undefined || !isWorthAsking(mutation)) {
167
+ if (mutation === undefined || !isReviewablePath(mutation.relativePath)) {
149
168
  return undefined;
150
169
  }
151
170
  state.pending.set(event.toolCallId, mutation);
@@ -155,48 +174,108 @@ export function registerJevGuard(
155
174
  pi.on('tool_result', async (event, ctx) => {
156
175
  const pending = state.pending.get(event.toolCallId);
157
176
  state.pending.delete(event.toolCallId);
158
- if (pending === undefined || event.isError) {
159
- return undefined;
160
- }
161
- if (!isWriteToolResult(event) && !isEditToolResult(event)) {
162
- return undefined;
163
- }
164
-
165
177
  const apiKey = dependencies.apiKey();
166
- if (apiKey === undefined) {
167
- dependencies.report(JEV_STATUS.noKey, ctx);
178
+ if (
179
+ apiKey === undefined ||
180
+ !shouldCallJev(state) ||
181
+ ctx.signal?.aborted === true
182
+ ) {
183
+ if (!event.isError && pending !== undefined && isWorthAsking(pending)) {
184
+ dependencies.report(
185
+ apiKey === undefined ? JEV_STATUS.noKey : guardStatus(state),
186
+ ctx,
187
+ );
188
+ }
168
189
  return undefined;
169
190
  }
170
- if (!shouldCallJev(state)) {
171
- dependencies.report(guardStatus(state), ctx);
191
+ if (event.isError) {
192
+ if (
193
+ event.toolName === 'bash' &&
194
+ typeof event.input.command === 'string'
195
+ ) {
196
+ const check = detectCheckCommand(ctx.cwd);
197
+ if (check !== undefined && commandMatches(event.input.command, check)) {
198
+ state.evidence.checks = 'failed';
199
+ }
200
+ }
172
201
  return undefined;
173
202
  }
174
-
175
- const body = readFileIfExists(pending.absolutePath);
176
- if (body === undefined) {
203
+ if (event.toolName === 'bash' && typeof event.input.command === 'string') {
204
+ const check = detectCheckCommand(ctx.cwd);
205
+ if (check !== undefined && commandMatches(event.input.command, check)) {
206
+ state.evidence.checks = 'passed';
207
+ }
177
208
  return undefined;
178
209
  }
179
- if (ctx.signal?.aborted === true) {
180
- return undefined;
210
+ let requestBody: JevRequestBody | undefined;
211
+ let subject: string;
212
+ if (event.toolName === 'sideroom_ask') {
213
+ if (
214
+ typeof event.details !== 'object' ||
215
+ event.details === null ||
216
+ !('cancelled' in event.details) ||
217
+ event.details.cancelled !== false
218
+ ) {
219
+ return undefined;
220
+ }
221
+ requestBody = buildAskReview(event.input);
222
+ subject = 'sideroom_ask';
223
+ } else if (event.toolName === 'sideroom_todo') {
224
+ const items = completedItems(event.details);
225
+ if (items === undefined) {
226
+ return undefined;
227
+ }
228
+ requestBody = buildBoardReview({
229
+ items,
230
+ evidence: state.evidence,
231
+ changedIds: completionIds(event.input),
232
+ });
233
+ subject = 'sideroom_todo';
234
+ } else {
235
+ if (
236
+ pending === undefined ||
237
+ (!isWriteToolResult(event) && !isEditToolResult(event))
238
+ ) {
239
+ return undefined;
240
+ }
241
+ subject = pending.relativePath;
242
+ state.evidence.checks = 'stale';
243
+ // Replace cached evidence even when this version no longer fits the budget.
244
+ state.evidence.files.delete(subject);
245
+ if (!isWorthAsking(pending)) {
246
+ state.evidence.partial = true;
247
+ return undefined;
248
+ }
249
+ const body = readFileIfExists(pending.absolutePath);
250
+ if (body === undefined) {
251
+ state.evidence.partial = true;
252
+ return undefined;
253
+ }
254
+ const fileState = buildContextualState(
255
+ ctx.cwd,
256
+ subject,
257
+ body,
258
+ pending.kind,
259
+ pending.lines,
260
+ );
261
+ if (fileState === undefined) {
262
+ state.evidence.partial = true;
263
+ return undefined;
264
+ }
265
+ recordFileEvidence(state.evidence, fileState);
266
+ requestBody = buildRequestBody(fileState);
181
267
  }
182
- const fileState = buildContextualState(
183
- ctx.cwd,
184
- pending.relativePath,
185
- body,
186
- pending.kind,
187
- pending.lines,
188
- );
189
- if (fileState === undefined) {
268
+ if (requestBody === undefined) {
190
269
  return undefined;
191
270
  }
192
271
 
193
272
  state.calls += 1;
194
- const attempt = await askJev(
195
- dependencies.transport,
273
+ const attempt = await askJevRequest({
274
+ transport: dependencies.transport,
196
275
  apiKey,
197
- fileState,
198
- ctx.signal,
199
- );
276
+ requestBody,
277
+ signal: ctx.signal,
278
+ });
200
279
  if (attempt.failure !== undefined) {
201
280
  registerFailure(state, attempt.failure);
202
281
  dependencies.report(guardStatus(state), ctx);
@@ -211,14 +290,14 @@ export function registerJevGuard(
211
290
 
212
291
  const findings = attempt.findings ?? [];
213
292
  const fresh = findings.filter((finding) =>
214
- isUnnoted(state, pending.relativePath, finding),
293
+ isUnnoted(state, subject, finding),
215
294
  );
216
295
  if (fresh.length === 0) {
217
296
  return undefined;
218
297
  }
219
- markNoted(state, pending.relativePath, fresh);
298
+ markNoted(state, subject, fresh);
220
299
  const note: ReviewNote = {
221
- text: formatFindings(pending.relativePath, fresh),
300
+ text: formatFindings(subject, fresh),
222
301
  };
223
302
  pi.events.emit(REVIEW_NOTE_EVENT, note);
224
303
  return undefined;
@@ -147,12 +147,70 @@ export const JEV_RULES: readonly JevRule[] = [
147
147
  },
148
148
  },
149
149
  },
150
+ {
151
+ id: 'testability',
152
+ question: {
153
+ type: NOUL_TYPE,
154
+ instructions:
155
+ 'Do the added lines hardcode clock, network or randomness inside business logic, making deterministic tests difficult? Do not flag infrastructure adapters or entry points merely for using their own APIs.',
156
+ criteria: {
157
+ true: 'Business behavior directly depends on an uncontrollable external dependency with no visible injection point',
158
+ false:
159
+ 'Dependencies are supplied, or the code is the adapter/composition boundary itself',
160
+ },
161
+ },
162
+ },
163
+ {
164
+ id: 'contract-compatibility',
165
+ question: {
166
+ type: NOUL_TYPE,
167
+ instructions:
168
+ 'Do added lines change a function signature, return shape or behavior in a way that demonstrably conflicts with a supplied non-test consumer? Require a concrete mismatch in visible code; do not speculate about unseen callers.',
169
+ criteria: {
170
+ true: 'A supplied consumer calls or interprets the changed contract incompatibly',
171
+ false:
172
+ 'Visible consumers remain compatible, or there is insufficient evidence',
173
+ },
174
+ },
175
+ },
176
+ {
177
+ id: 'test-quality',
178
+ question: {
179
+ type: NOUL_TYPE,
180
+ instructions:
181
+ 'For behavior touched by the added lines, do supplied related tests have a demonstrable blind spot: assertions that cannot detect that behavior failing, or an explicit changed error branch left untested? Use both production and test bodies. Never infer absent repository coverage from missing files or partial context. Do not request tests that mirror implementation or cover unrelated code.',
182
+ criteria: {
183
+ true: 'Visible relevant tests fail to assert the changed observable behavior or a visible changed error case, with concrete evidence',
184
+ false:
185
+ 'Assertions cover the behavior, the change is low-impact, or evidence is incomplete',
186
+ },
187
+ },
188
+ },
189
+ {
190
+ id: 'unnecessary-abstraction',
191
+ question: {
192
+ type: NOUL_TYPE,
193
+ instructions:
194
+ 'Do the added lines introduce a demonstrably redundant layer or combine visibly different domain concepts solely for similar syntax? Require evidence in supplied bodies. A single caller, delegation, injection, compatibility or a clear responsibility is not by itself a violation. Never infer requirements are absent or recommend removing required behavior.',
195
+ criteria: {
196
+ true: 'The added abstraction duplicates an existing role without adding a contract, responsibility or testability, or mixes visibly distinct concepts',
197
+ false:
198
+ 'It serves a present responsibility or contract, or evidence is insufficient',
199
+ },
200
+ },
201
+ },
150
202
  ];
151
203
 
204
+ export function isTestPath(path: string): boolean {
205
+ return /(?:^|\/)(?:__tests__|tests?|specs?)\/|(?:[._-](?:test|spec)|_test)\.[^/]+$|(?:^|\/)test_[^/]+$|Test\.java$/u.test(
206
+ path,
207
+ );
208
+ }
209
+
152
210
  export interface JevRelatedFile {
153
211
  readonly path: string;
154
212
  readonly body: string;
155
- readonly relation: 'imported' | 'consumer';
213
+ readonly relation: 'imported' | 'consumer' | 'test';
156
214
  }
157
215
 
158
216
  export interface JevFileState {
@@ -173,7 +231,7 @@ export interface JevFileState {
173
231
 
174
232
  export interface JevRequestBody {
175
233
  readonly model: string;
176
- readonly state: JevFileState;
234
+ readonly state: unknown;
177
235
  readonly questions: Readonly<Record<string, JevNoulQuestion>>;
178
236
  }
179
237
 
@@ -225,14 +283,17 @@ export function isReviewablePath(path: string): boolean {
225
283
  }
226
284
 
227
285
  export function stateFitsBudget(state: JevFileState): boolean {
228
- const request = buildRequestBody(state);
286
+ return requestFitsBudget(buildRequestBody(state));
287
+ }
288
+
289
+ export function requestFitsBudget(request: JevRequestBody): boolean {
229
290
  const longestQuestion = Object.values(request.questions).reduce(
230
291
  (longest, question) =>
231
292
  Math.max(longest, Buffer.byteLength(JSON.stringify(question))),
232
293
  0,
233
294
  );
234
295
  return (
235
- Buffer.byteLength(JSON.stringify(state)) + longestQuestion <=
296
+ Buffer.byteLength(JSON.stringify(request.state)) + longestQuestion <=
236
297
  MAX_STATE_BYTES &&
237
298
  Buffer.byteLength(JSON.stringify(request)) <= MAX_REQUEST_BYTES
238
299
  );
@@ -243,6 +304,15 @@ export function buildRequestBody(file: JevFileState): JevRequestBody {
243
304
  const hasImportedFile = file.context.files.some(
244
305
  (related) => related.relation === 'imported',
245
306
  );
307
+ const hasConsumer = file.context.files.some(
308
+ (related) => related.relation === 'consumer' && !isTestPath(related.path),
309
+ );
310
+ const hasTest =
311
+ isTestPath(file.file.path) ||
312
+ file.context.files.some((related) => isTestPath(related.path));
313
+ const hasProduction =
314
+ !isTestPath(file.file.path) ||
315
+ file.context.files.some((related) => !isTestPath(related.path));
246
316
  const addedText = file.change.addedLines.join('\n');
247
317
  for (const rule of JEV_RULES) {
248
318
  if (rule.id === 'dependency-direction' && !hasImportedFile) {
@@ -251,6 +321,12 @@ export function buildRequestBody(file: JevFileState): JevRequestBody {
251
321
  if (rule.id === 'comments' && !/(?:\/\/|\/\*|#)/u.test(addedText)) {
252
322
  continue;
253
323
  }
324
+ if (rule.id === 'contract-compatibility' && !hasConsumer) {
325
+ continue;
326
+ }
327
+ if (rule.id === 'test-quality' && (!hasTest || !hasProduction)) {
328
+ continue;
329
+ }
254
330
  questions[rule.id] = rule.question;
255
331
  }
256
332
  return { model: JEV_MODEL, state: file, questions };
@@ -274,15 +350,17 @@ export function readFindings(
274
350
  return [];
275
351
  }
276
352
  const findings: JevFinding[] = [];
277
- for (const rule of JEV_RULES) {
278
- if (!askedRuleIds.has(rule.id)) {
279
- continue;
280
- }
281
- const answer = answers[rule.id];
282
- if (answer === undefined || answer.noul < VIOLATION_PROBABILITY) {
353
+ for (const ruleId of askedRuleIds) {
354
+ const answer = answers[ruleId];
355
+ if (
356
+ answer === undefined ||
357
+ !Number.isFinite(answer.noul) ||
358
+ answer.noul > 1 ||
359
+ answer.noul < VIOLATION_PROBABILITY
360
+ ) {
283
361
  continue;
284
362
  }
285
- findings.push({ ruleId: rule.id, probability: answer.noul });
363
+ findings.push({ ruleId, probability: answer.noul });
286
364
  }
287
365
  return findings;
288
366
  }
@@ -299,7 +377,7 @@ export function formatFindings(
299
377
  `Sideroom Jev review flagged ${path}:`,
300
378
  ...findings.map(
301
379
  (finding) =>
302
- `- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability that the added lines break this rule. Verify against the loaded guide before changing anything.`,
380
+ `- [${finding.ruleId}] Jev scored ${finding.probability.toFixed(2)} probability of a review concern. Verify the supplied evidence and applicable contract against the loaded guide before changing anything. Missing context is not proof of a violation.`,
303
381
  ),
304
382
  ].join('\n');
305
383
  }
@@ -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
- formatPersonaDetail,
3
- type PersonaToolDetails,
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: formatPersonaDetail() }],
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
- "Read Sideroom's voice rules and hard prohibitions for user-facing answers.";
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 = 'Read the active Sideroom persona.';
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
- Full guide: call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}.`;
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)) {