@rmrdeveloper/sideroom-pi 8.4.0 → 8.6.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 +17 -14
- package/extensions/ask/index.ts +5 -8
- package/extensions/done/detect.ts +24 -0
- package/extensions/done/guard.ts +54 -7
- package/extensions/done/index.ts +23 -3
- package/extensions/done/model.ts +47 -0
- package/extensions/explain/guard.ts +16 -5
- package/extensions/explain/index.ts +2 -2
- package/extensions/explain/model.ts +24 -8
- package/extensions/guidelines/prompt.ts +8 -6
- package/extensions/persona/checks.ts +1 -1
- package/extensions/persona/guard.ts +7 -7
- package/extensions/rules/catalog.ts +9 -1
- package/extensions/rules/checks.ts +155 -0
- package/extensions/rules/lexer.ts +32 -11
- package/extensions/shared/file-path.ts +83 -0
- package/extensions/todo/index.ts +2 -2
- package/extensions/todo/session.ts +57 -8
- package/package.json +4 -2
- package/skills/sideroom-grill/SKILL.md +13 -2
- package/skills/sideroom-persona/SKILL.md +6 -3
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ chat. It never creates a repository task file or a `/todos` command.
|
|
|
121
121
|
- Widget: at most five rows — open items before resolved ones, the
|
|
122
122
|
`in_progress` row always visible — plus a `…+N more · F9: view all` hint.
|
|
123
123
|
`F9` opens the full board as a read-only overlay, so the widget stays small
|
|
124
|
-
without hiding work from you; the agent still gets every item in its
|
|
124
|
+
without hiding work from you; the agent still gets every item in its context.
|
|
125
125
|
|
|
126
126
|
### Edited files — proof, not promises
|
|
127
127
|
|
|
@@ -189,12 +189,12 @@ nothing is detectable, the gate does nothing.
|
|
|
189
189
|
### Explain — the walkthrough offer
|
|
190
190
|
|
|
191
191
|
When an implementation settles, `explain` steers the agent to offer a
|
|
192
|
-
walkthrough through `sideroom_ask`: explain the changes
|
|
193
|
-
only
|
|
192
|
+
walkthrough through `sideroom_ask`: explain the changes only, how to test them
|
|
193
|
+
only, both, or nothing. It fires on `agent_settled`, the point where
|
|
194
194
|
no retry, compaction, or queued message is left, so the offer never lands on
|
|
195
|
-
work that is about to be redone. Once per
|
|
196
|
-
|
|
197
|
-
else. No tool, no widget, no persisted state.
|
|
195
|
+
work that is about to be redone. Once per turn, only when the turn touched at
|
|
196
|
+
least five distinct files, and only in the TUI, because `sideroom_ask` cannot
|
|
197
|
+
run anywhere else. No tool, no widget, no persisted state.
|
|
198
198
|
|
|
199
199
|
## Quick path
|
|
200
200
|
|
|
@@ -292,8 +292,10 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
292
292
|
- Injection: a short reminder appended in `before_agent_start`, chained after
|
|
293
293
|
the other extensions and idempotent by heading, so it returns after
|
|
294
294
|
compaction.
|
|
295
|
-
- Blocking: decorative symbols in the lines a `write`/`edit` adds.
|
|
296
|
-
|
|
295
|
+
- Blocking: decorative symbols in the lines a `write`/`edit` adds. Full rewrites
|
|
296
|
+
resolve Pi path aliases before comparing existing content; semantic copyright,
|
|
297
|
+
trademark, and registration symbols remain valid. Three fires degrade the
|
|
298
|
+
prohibition to a steer; five clean mutations reset the counters.
|
|
297
299
|
- Steering: one corrective message per violating assistant message, at most
|
|
298
300
|
three per run.
|
|
299
301
|
- `sideroom_persona` takes no arguments and returns every rule and every
|
|
@@ -310,13 +312,14 @@ the diff against the loaded guide and run the relevant project checks.
|
|
|
310
312
|
|
|
311
313
|
### Explain contract
|
|
312
314
|
|
|
313
|
-
- Trigger: `agent_settled` with at least
|
|
314
|
-
last user prompt, in TUI mode.
|
|
315
|
+
- Trigger: `agent_settled` with at least five distinct files mutated since the
|
|
316
|
+
last user prompt, in TUI mode. File identity follows Pi's path aliases and
|
|
317
|
+
canonicalizes existing paths.
|
|
315
318
|
- The steer names the intent; the agent writes the question in the user's
|
|
316
|
-
language with
|
|
317
|
-
*Out of scope* and the custom answer.
|
|
318
|
-
- At most one offer per
|
|
319
|
-
|
|
319
|
+
language with four options, one of them a decline, and one recommendation.
|
|
320
|
+
`sideroom_ask` still adds *Out of scope* and the custom answer.
|
|
321
|
+
- At most one offer per turn. The flag is set before the steer, so the turn the
|
|
322
|
+
offer triggers cannot re-trigger it.
|
|
320
323
|
- No green-check requirement: red checks still produce the offer.
|
|
321
324
|
|
|
322
325
|
### Grill contract
|
package/extensions/ask/index.ts
CHANGED
|
@@ -15,14 +15,11 @@ export const ASK_PROMPT_SNIPPET =
|
|
|
15
15
|
'Ask the user one or more questions with a recommended option, in their language.';
|
|
16
16
|
|
|
17
17
|
export const ASK_PROMPT_GUIDELINES = [
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'sideroom_ask
|
|
23
|
-
'For a genuinely plural sideroom_ask question, set selectionMode "multiple" with recommendedIndices (at least one) instead of recommendationIndex; never send both fields, and keep the single default otherwise.',
|
|
24
|
-
'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
|
|
25
|
-
'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
|
|
18
|
+
'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it owns the interview and drives sideroom_ask rounds. One sideroom_ask call is one batch; call it again for another round.',
|
|
19
|
+
'A decision the repository already settles consistently is a project fact: read the code and reuse it. A decision that materially affects architecture, dependencies, cost, security, operations, persistence, public contracts, or future change difficulty is an architectural decision: expose it through sideroom_ask before committing, with the practical consequences of each alternative and one recommendation that carries a one-line justification, never a list of technology names. If the repository shows two inconclusive patterns for the same kind of decision, name the inconsistency in the prompt. Everything else is a minor decision: take it yourself.',
|
|
20
|
+
'Write sideroom_ask prompts, tab labels, option labels, and option descriptions in the language the user is speaking; keep ids and option values in English.',
|
|
21
|
+
'sideroom_ask always adds Out of scope and a write-your-own answer; never send them yourself. A single-selection question needs recommendationIndex; a multiple-selection question needs recommendedIndices and never recommendationIndex.',
|
|
22
|
+
'sideroom_ask only works in the interactive TUI; it errors in print, JSON, and RPC modes.',
|
|
26
23
|
];
|
|
27
24
|
|
|
28
25
|
// Pi loads extensions/*/index.ts through export default.
|
|
@@ -157,6 +157,30 @@ function shellOperatorAt(
|
|
|
157
157
|
return undefined;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
export function hasTestSetup(cwd: string): boolean {
|
|
161
|
+
if (hasPackageTestScript(cwd)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
existsSync(join(cwd, 'pyproject.toml')) ||
|
|
166
|
+
existsSync(join(cwd, 'setup.py'))
|
|
167
|
+
) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (existsSync(join(cwd, 'go.mod'))) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return existsSync(join(cwd, 'Cargo.toml'));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function hasPackageTestScript(cwd: string): boolean {
|
|
177
|
+
const packagePath = join(cwd, 'package.json');
|
|
178
|
+
if (!existsSync(packagePath)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return typeof readScripts(packagePath).test === 'string';
|
|
182
|
+
}
|
|
183
|
+
|
|
160
184
|
function detectPackageCommand(cwd: string): CheckCommand | undefined {
|
|
161
185
|
const packagePath = join(cwd, 'package.json');
|
|
162
186
|
if (!existsSync(packagePath)) {
|
package/extensions/done/guard.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
DONE_GATE_TYPE,
|
|
12
12
|
type DoneState,
|
|
13
13
|
formatDoneSteer,
|
|
14
|
+
formatMissingTestNotice,
|
|
15
|
+
isSourcePath,
|
|
16
|
+
isTestPath,
|
|
17
|
+
MISSING_TEST_NOTICE_TYPE,
|
|
18
|
+
shouldNoticeMissingTest,
|
|
14
19
|
shouldSteerDone,
|
|
15
20
|
} from './model.ts';
|
|
16
21
|
|
|
@@ -18,18 +23,29 @@ export type ResolveCommand = (
|
|
|
18
23
|
ctx: Pick<ExtensionContext, 'cwd'>,
|
|
19
24
|
) => CheckCommand | undefined;
|
|
20
25
|
|
|
26
|
+
export type ResolveTestSetup = (ctx: Pick<ExtensionContext, 'cwd'>) => boolean;
|
|
27
|
+
|
|
21
28
|
export function registerDoneGuard(
|
|
22
29
|
pi: ExtensionAPI,
|
|
23
30
|
state: DoneState,
|
|
24
31
|
resolveCommand: ResolveCommand,
|
|
32
|
+
resolveTestSetup: ResolveTestSetup,
|
|
25
33
|
): void {
|
|
26
34
|
const checkRuns = new Set<string>();
|
|
35
|
+
const mutationPaths = new Map<string, string>();
|
|
27
36
|
|
|
28
37
|
pi.on('turn_start', () => {
|
|
29
38
|
state.steeredThisTurn = false;
|
|
30
39
|
});
|
|
31
40
|
|
|
32
41
|
pi.on('tool_call', (event, ctx) => {
|
|
42
|
+
if (
|
|
43
|
+
isToolCallEventType('write', event) ||
|
|
44
|
+
isToolCallEventType('edit', event)
|
|
45
|
+
) {
|
|
46
|
+
mutationPaths.set(event.toolCallId, event.input.path);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
33
49
|
if (!isToolCallEventType('bash', event)) {
|
|
34
50
|
return;
|
|
35
51
|
}
|
|
@@ -44,10 +60,14 @@ export function registerDoneGuard(
|
|
|
44
60
|
|
|
45
61
|
pi.on('tool_result', (event) => {
|
|
46
62
|
if (isWriteToolResult(event) || isEditToolResult(event)) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
const path = mutationPaths.get(event.toolCallId);
|
|
64
|
+
mutationPaths.delete(event.toolCallId);
|
|
65
|
+
if (event.isError) {
|
|
66
|
+
return;
|
|
50
67
|
}
|
|
68
|
+
state.mutated = true;
|
|
69
|
+
state.checksGreen = false;
|
|
70
|
+
recordMutationPath(state, path);
|
|
51
71
|
return;
|
|
52
72
|
}
|
|
53
73
|
if (isBashToolResult(event)) {
|
|
@@ -60,18 +80,45 @@ export function registerDoneGuard(
|
|
|
60
80
|
|
|
61
81
|
pi.on('turn_end', (_event, ctx) => {
|
|
62
82
|
const check = resolveCommand(ctx);
|
|
63
|
-
if (check
|
|
83
|
+
if (check !== undefined && shouldSteerDone(state, true)) {
|
|
84
|
+
state.steeredThisTurn = true;
|
|
85
|
+
state.steerCount += 1;
|
|
86
|
+
pi.sendMessage(
|
|
87
|
+
{
|
|
88
|
+
customType: DONE_GATE_TYPE,
|
|
89
|
+
content: formatDoneSteer(check.display),
|
|
90
|
+
display: false,
|
|
91
|
+
},
|
|
92
|
+
{ triggerTurn: true, deliverAs: 'steer' },
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
|
|
64
98
|
return;
|
|
65
99
|
}
|
|
66
|
-
state.
|
|
100
|
+
state.noticedMissingTest = true;
|
|
67
101
|
state.steerCount += 1;
|
|
68
102
|
pi.sendMessage(
|
|
69
103
|
{
|
|
70
|
-
customType:
|
|
71
|
-
content:
|
|
104
|
+
customType: MISSING_TEST_NOTICE_TYPE,
|
|
105
|
+
content: formatMissingTestNotice(),
|
|
72
106
|
display: false,
|
|
73
107
|
},
|
|
74
108
|
{ triggerTurn: true, deliverAs: 'steer' },
|
|
75
109
|
);
|
|
76
110
|
});
|
|
77
111
|
}
|
|
112
|
+
|
|
113
|
+
function recordMutationPath(state: DoneState, path: string | undefined): void {
|
|
114
|
+
if (path === undefined) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (isTestPath(path)) {
|
|
118
|
+
state.mutatedTest = true;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (isSourcePath(path)) {
|
|
122
|
+
state.mutatedSource = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/extensions/done/index.ts
CHANGED
|
@@ -2,14 +2,23 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
type CheckCommand,
|
|
7
|
+
detectCheckCommand,
|
|
8
|
+
hasTestSetup,
|
|
9
|
+
} from './detect.ts';
|
|
10
|
+
import {
|
|
11
|
+
type ResolveCommand,
|
|
12
|
+
type ResolveTestSetup,
|
|
13
|
+
registerDoneGuard,
|
|
14
|
+
} from './guard.ts';
|
|
7
15
|
import { createDoneState, resetDoneRun } from './model.ts';
|
|
8
16
|
|
|
9
17
|
// Pi loads extensions/*/index.ts through export default.
|
|
10
18
|
export default function registerDone(pi: ExtensionAPI): void {
|
|
11
19
|
const state = createDoneState();
|
|
12
20
|
const cache = new Map<string, CheckCommand | undefined>();
|
|
21
|
+
const testCache = new Map<string, boolean>();
|
|
13
22
|
const resolveCommand: ResolveCommand = (
|
|
14
23
|
ctx: Pick<ExtensionContext, 'cwd'>,
|
|
15
24
|
) => {
|
|
@@ -20,8 +29,19 @@ export default function registerDone(pi: ExtensionAPI): void {
|
|
|
20
29
|
cache.set(ctx.cwd, command);
|
|
21
30
|
return command;
|
|
22
31
|
};
|
|
32
|
+
const resolveTestSetup: ResolveTestSetup = (
|
|
33
|
+
ctx: Pick<ExtensionContext, 'cwd'>,
|
|
34
|
+
) => {
|
|
35
|
+
const cached = testCache.get(ctx.cwd);
|
|
36
|
+
if (cached !== undefined) {
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
const hasTests = hasTestSetup(ctx.cwd);
|
|
40
|
+
testCache.set(ctx.cwd, hasTests);
|
|
41
|
+
return hasTests;
|
|
42
|
+
};
|
|
23
43
|
|
|
24
|
-
registerDoneGuard(pi, state, resolveCommand);
|
|
44
|
+
registerDoneGuard(pi, state, resolveCommand, resolveTestSetup);
|
|
25
45
|
|
|
26
46
|
pi.on('input', (event) => {
|
|
27
47
|
if (event.source === 'interactive' || event.source === 'rpc') {
|
package/extensions/done/model.ts
CHANGED
|
@@ -3,18 +3,35 @@ export interface DoneState {
|
|
|
3
3
|
checksGreen: boolean;
|
|
4
4
|
steeredThisTurn: boolean;
|
|
5
5
|
steerCount: number;
|
|
6
|
+
mutatedSource: boolean;
|
|
7
|
+
mutatedTest: boolean;
|
|
8
|
+
noticedMissingTest: boolean;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
export const MAX_DONE_STEERS = 2;
|
|
9
12
|
|
|
10
13
|
export const DONE_GATE_TYPE = 'sideroom-done-gate';
|
|
11
14
|
|
|
15
|
+
export const MISSING_TEST_NOTICE_TYPE = 'sideroom-missing-test-notice';
|
|
16
|
+
|
|
17
|
+
const TEST_DIRECTORY = /(?:^|\/)(?:__tests__|tests?|specs?)(?:\/|$)/;
|
|
18
|
+
|
|
19
|
+
// Test paths are recognised by name only: the notice never claims the right
|
|
20
|
+
// test exists, so a missed convention only costs a nudge, never a false pass.
|
|
21
|
+
const TEST_FILE =
|
|
22
|
+
/(?:^|\/)(?:test_[\w.-]*\.py|[\w.-]*[._](?:test|spec)\.[\w.-]+|[\w.-]*(?:Test|Tests)\.java)$/;
|
|
23
|
+
|
|
24
|
+
const SOURCE_FILE = /\.(?:[cm]?js|jsx|ts|tsx|go|java|php|py|rs)$/i;
|
|
25
|
+
|
|
12
26
|
export function createDoneState(): DoneState {
|
|
13
27
|
return {
|
|
14
28
|
mutated: false,
|
|
15
29
|
checksGreen: false,
|
|
16
30
|
steeredThisTurn: false,
|
|
17
31
|
steerCount: 0,
|
|
32
|
+
mutatedSource: false,
|
|
33
|
+
mutatedTest: false,
|
|
34
|
+
noticedMissingTest: false,
|
|
18
35
|
};
|
|
19
36
|
}
|
|
20
37
|
|
|
@@ -23,6 +40,19 @@ export function resetDoneRun(state: DoneState): void {
|
|
|
23
40
|
state.checksGreen = false;
|
|
24
41
|
state.steeredThisTurn = false;
|
|
25
42
|
state.steerCount = 0;
|
|
43
|
+
state.mutatedSource = false;
|
|
44
|
+
state.mutatedTest = false;
|
|
45
|
+
state.noticedMissingTest = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isTestPath(path: string): boolean {
|
|
49
|
+
const normalized = path.replaceAll('\\', '/');
|
|
50
|
+
return TEST_DIRECTORY.test(normalized) || TEST_FILE.test(normalized);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isSourcePath(path: string): boolean {
|
|
54
|
+
const normalized = path.replaceAll('\\', '/');
|
|
55
|
+
return SOURCE_FILE.test(normalized);
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
export function shouldSteerDone(
|
|
@@ -38,6 +68,23 @@ export function shouldSteerDone(
|
|
|
38
68
|
);
|
|
39
69
|
}
|
|
40
70
|
|
|
71
|
+
export function shouldNoticeMissingTest(
|
|
72
|
+
state: DoneState,
|
|
73
|
+
hasTests: boolean,
|
|
74
|
+
): boolean {
|
|
75
|
+
return (
|
|
76
|
+
hasTests &&
|
|
77
|
+
state.mutatedSource &&
|
|
78
|
+
!state.mutatedTest &&
|
|
79
|
+
!state.noticedMissingTest &&
|
|
80
|
+
state.steerCount < MAX_DONE_STEERS
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
export function formatDoneSteer(display: string): string {
|
|
42
85
|
return `Run \`${display}\` and make it pass before finishing.`;
|
|
43
86
|
}
|
|
87
|
+
|
|
88
|
+
export function formatMissingTestNotice(): string {
|
|
89
|
+
return 'This run changed code files and no test file. Add or update the test that covers the change before finishing.';
|
|
90
|
+
}
|
|
@@ -7,10 +7,15 @@ import {
|
|
|
7
7
|
EXPLAIN_OFFER,
|
|
8
8
|
EXPLAIN_OFFER_TYPE,
|
|
9
9
|
type ExplainState,
|
|
10
|
-
|
|
10
|
+
mutationPathKey,
|
|
11
|
+
resetExplainTurn,
|
|
11
12
|
shouldOfferExplanation,
|
|
12
13
|
} from './model.ts';
|
|
13
14
|
|
|
15
|
+
interface FileToolInput {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export function registerExplainGuard(
|
|
15
20
|
pi: ExtensionAPI,
|
|
16
21
|
state: ExplainState,
|
|
@@ -19,17 +24,23 @@ export function registerExplainGuard(
|
|
|
19
24
|
if (event.source !== 'interactive' && event.source !== 'rpc') {
|
|
20
25
|
return;
|
|
21
26
|
}
|
|
22
|
-
|
|
27
|
+
resetExplainTurn(state);
|
|
23
28
|
});
|
|
24
29
|
|
|
25
|
-
pi.on('tool_result', (event) => {
|
|
30
|
+
pi.on('tool_result', (event, ctx) => {
|
|
26
31
|
if (event.isError) {
|
|
27
32
|
return;
|
|
28
33
|
}
|
|
29
34
|
if (!isEditToolResult(event) && !isWriteToolResult(event)) {
|
|
30
35
|
return;
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
// SAFETY: Pi validates built-in file-tool inputs before emitting tool_result.
|
|
38
|
+
const input = event.input as unknown as FileToolInput;
|
|
39
|
+
const pathKey = mutationPathKey(ctx.cwd, input.path);
|
|
40
|
+
if (pathKey === undefined) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
state.mutatedFilesThisTurn.add(pathKey);
|
|
33
44
|
});
|
|
34
45
|
|
|
35
46
|
// agent_settled is the only point with no retry, compaction, or queued
|
|
@@ -38,7 +49,7 @@ export function registerExplainGuard(
|
|
|
38
49
|
if (!shouldOfferExplanation(state, ctx.mode)) {
|
|
39
50
|
return;
|
|
40
51
|
}
|
|
41
|
-
state.
|
|
52
|
+
state.offeredThisTurn = true;
|
|
42
53
|
pi.sendMessage(
|
|
43
54
|
{
|
|
44
55
|
customType: EXPLAIN_OFFER_TYPE,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { registerExplainGuard } from './guard.ts';
|
|
3
|
-
import { createExplainState,
|
|
3
|
+
import { createExplainState, resetExplainTurn } from './model.ts';
|
|
4
4
|
|
|
5
5
|
// Pi loads extensions/*/index.ts through export default.
|
|
6
6
|
export default function registerExplain(pi: ExtensionAPI): void {
|
|
7
7
|
const state = createExplainState();
|
|
8
8
|
registerExplainGuard(pi, state);
|
|
9
9
|
|
|
10
|
-
pi.on('session_start', () =>
|
|
10
|
+
pi.on('session_start', () => resetExplainTurn(state));
|
|
11
11
|
}
|
|
@@ -1,22 +1,34 @@
|
|
|
1
|
+
import { resolveFileToolPath } from '../shared/file-path.ts';
|
|
2
|
+
|
|
1
3
|
export const EXPLAIN_OFFER_TYPE = 'sideroom-explain-offer';
|
|
2
4
|
|
|
5
|
+
// Five is the point where a turn stopped being an adjustment and became work
|
|
6
|
+
// worth walking the user through.
|
|
7
|
+
export const EXPLAIN_FILE_THRESHOLD = 5;
|
|
8
|
+
|
|
3
9
|
const TUI_MODE = 'tui';
|
|
4
10
|
|
|
5
11
|
export const EXPLAIN_OFFER =
|
|
6
|
-
"Use sideroom_ask for one question in the user's language with
|
|
12
|
+
"Use sideroom_ask for one question in the user's language with four parallel options: changes only, test steps only, both, or no explanation. Recommend changes only.";
|
|
7
13
|
|
|
8
14
|
export interface ExplainState {
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
mutatedFilesThisTurn: Set<string>;
|
|
16
|
+
offeredThisTurn: boolean;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export function createExplainState(): ExplainState {
|
|
14
|
-
return {
|
|
20
|
+
return { mutatedFilesThisTurn: new Set(), offeredThisTurn: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resetExplainTurn(state: ExplainState): void {
|
|
24
|
+
state.mutatedFilesThisTurn.clear();
|
|
25
|
+
state.offeredThisTurn = false;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
// Pi reports the path as the model wrote it, so use the built-in file-tool
|
|
29
|
+
// semantics and canonicalize existing aliases before counting the file.
|
|
30
|
+
export function mutationPathKey(cwd: string, path: string): string | undefined {
|
|
31
|
+
return resolveFileToolPath(cwd, path);
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
// sideroom_ask rejects every mode but the TUI, so the offer is gated the same way.
|
|
@@ -24,5 +36,9 @@ export function shouldOfferExplanation(
|
|
|
24
36
|
state: ExplainState,
|
|
25
37
|
mode: string,
|
|
26
38
|
): boolean {
|
|
27
|
-
return
|
|
39
|
+
return (
|
|
40
|
+
mode === TUI_MODE &&
|
|
41
|
+
state.mutatedFilesThisTurn.size >= EXPLAIN_FILE_THRESHOLD &&
|
|
42
|
+
!state.offeredThisTurn
|
|
43
|
+
);
|
|
28
44
|
}
|
|
@@ -5,14 +5,16 @@ export { LANGUAGE_GUIDE_FILES } from './catalog.ts';
|
|
|
5
5
|
export const GUIDELINES_REMINDER_HEADING =
|
|
6
6
|
'Sideroom coding guidelines (always-on reminder):';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
).join('
|
|
8
|
+
// One dense index instead of one sentence per language: the same paths and the
|
|
9
|
+
// same requirement, a fraction of the tokens every turn.
|
|
10
|
+
const LANGUAGE_GUIDE_INDEX = LANGUAGE_GUIDES.map(
|
|
11
|
+
({ extensions, path }) => `${extensions.join('/')} ${path}`,
|
|
12
|
+
).join('; ');
|
|
12
13
|
|
|
13
14
|
export const GUIDELINES_REMINDER = `${GUIDELINES_REMINDER_HEADING}
|
|
14
|
-
Before the first write or edit in each agent run, use the read tool without offset or limit to load
|
|
15
|
-
${
|
|
15
|
+
Before the first write or edit in each agent run, use the read tool without offset or limit to load these files in full:
|
|
16
|
+
- ${GUIDELINE_SKILL_PATH}
|
|
17
|
+
- for the extension of the file you will change, one of: ${LANGUAGE_GUIDE_INDEX}
|
|
16
18
|
A required read counts only when it succeeds without truncation. Until all required reads succeed, do not call write or edit and do not mutate files through bash or another tool; the extension blocks those mutations.
|
|
17
19
|
After changing files, review the code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
|
|
18
20
|
|
|
@@ -16,7 +16,7 @@ interface ProhibitionDetector {
|
|
|
16
16
|
const DETECTORS: readonly ProhibitionDetector[] = [
|
|
17
17
|
{
|
|
18
18
|
prohibitionId: 'decorative-symbols',
|
|
19
|
-
pattern: /\p{
|
|
19
|
+
pattern: /\p{Emoji_Presentation}|\uFE0F|[\u2713\u2714\u2717\u2718]/u,
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
prohibitionId: 'flattery-and-filler',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
2
|
import {
|
|
4
3
|
type ExtensionAPI,
|
|
5
4
|
isToolCallEventType,
|
|
6
5
|
type ToolCallEvent,
|
|
7
6
|
} from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import { resolveFileToolPath } from '../shared/file-path.ts';
|
|
8
8
|
import {
|
|
9
9
|
PERSONA_STEER_TYPE,
|
|
10
10
|
type ProhibitionId,
|
|
@@ -107,7 +107,11 @@ function extractMutation(
|
|
|
107
107
|
cwd: string,
|
|
108
108
|
): ExtractedMutation | undefined {
|
|
109
109
|
if (isToolCallEventType('write', event)) {
|
|
110
|
-
const
|
|
110
|
+
const absolutePath = resolveFileToolPath(cwd, event.input.path);
|
|
111
|
+
if (absolutePath === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const previous = readFileIfExists(absolutePath);
|
|
111
115
|
return {
|
|
112
116
|
path: event.input.path,
|
|
113
117
|
addedLines: addedLinesMissingFrom(previous, event.input.content),
|
|
@@ -126,17 +130,13 @@ function readFileIfExists(path: string): string | undefined {
|
|
|
126
130
|
try {
|
|
127
131
|
return readFileSync(path, 'utf8');
|
|
128
132
|
} catch (error) {
|
|
129
|
-
if (
|
|
133
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
130
134
|
return undefined;
|
|
131
135
|
}
|
|
132
136
|
throw error;
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
function isMissingFile(error: unknown): boolean {
|
|
137
|
-
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
138
|
-
}
|
|
139
|
-
|
|
140
140
|
function isDegraded(state: PersonaGuardState, id: ProhibitionId): boolean {
|
|
141
141
|
return (state.fires.get(id) ?? 0) >= BLOCK_DEGRADE_AFTER;
|
|
142
142
|
}
|
|
@@ -18,19 +18,23 @@ export type LanguageId = (typeof LANGUAGE)[keyof typeof LANGUAGE];
|
|
|
18
18
|
|
|
19
19
|
export type RuleId =
|
|
20
20
|
| 'braced-conditionals'
|
|
21
|
+
| 'explicit-any'
|
|
21
22
|
| 'explicit-error-handling'
|
|
22
23
|
| 'clear-names'
|
|
23
24
|
| 'comments'
|
|
24
|
-
| 'debug-artifacts'
|
|
25
|
+
| 'debug-artifacts'
|
|
26
|
+
| 'suppressed-type-errors';
|
|
25
27
|
|
|
26
28
|
export type RuleSeverity = (typeof SEVERITY)[keyof typeof SEVERITY];
|
|
27
29
|
|
|
28
30
|
export const RULE_SEVERITY: Readonly<Record<RuleId, RuleSeverity>> = {
|
|
29
31
|
'braced-conditionals': SEVERITY.block,
|
|
32
|
+
'explicit-any': SEVERITY.block,
|
|
30
33
|
'explicit-error-handling': SEVERITY.block,
|
|
31
34
|
'clear-names': SEVERITY.warn,
|
|
32
35
|
comments: SEVERITY.warn,
|
|
33
36
|
'debug-artifacts': SEVERITY.warn,
|
|
37
|
+
'suppressed-type-errors': SEVERITY.warn,
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
export function languageForPath(path: string): LanguageId {
|
|
@@ -69,3 +73,7 @@ export function languageForPath(path: string): LanguageId {
|
|
|
69
73
|
export function supportsBracedConditionals(language: LanguageId): boolean {
|
|
70
74
|
return language !== LANGUAGE.python && language !== LANGUAGE.generic;
|
|
71
75
|
}
|
|
76
|
+
|
|
77
|
+
export function supportsExplicitAny(language: LanguageId): boolean {
|
|
78
|
+
return language === LANGUAGE.typescript;
|
|
79
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
RULE_SEVERITY,
|
|
5
5
|
type RuleId,
|
|
6
6
|
supportsBracedConditionals,
|
|
7
|
+
supportsExplicitAny,
|
|
7
8
|
} from './catalog.ts';
|
|
8
9
|
import { maskNonCode } from './lexer.ts';
|
|
9
10
|
import type { AddedLine, RuleViolation } from './model.ts';
|
|
@@ -38,6 +39,29 @@ const DEBUG_PATTERNS: readonly RegExp[] = [
|
|
|
38
39
|
/\bdbg!\s*\(/,
|
|
39
40
|
];
|
|
40
41
|
|
|
42
|
+
// Keep these patterns on syntax that can only introduce a type. Value-level
|
|
43
|
+
// commas, assignments, and operators may legally surround an identifier named
|
|
44
|
+
// `any`, so they are deliberately absent.
|
|
45
|
+
const EXPLICIT_ANY_PATTERNS: readonly RegExp[] = [
|
|
46
|
+
/\bas\s+any\b/,
|
|
47
|
+
/\b(?:keyof|satisfies)\s+any\b/,
|
|
48
|
+
/\bany\s*\[\s*\]/,
|
|
49
|
+
/\b[$\w]+<[^<>\n]*\bany\b[^<>\n]*>(?!\s*[$\w])/,
|
|
50
|
+
/\binterface\s+[$\w]+(?:\s*<[^;{\n]*>)?\s*\{[^}\n]*:\s*[^;}\n]*\bany\b/,
|
|
51
|
+
/\b(?:const|let|var)\s+[$\w]+\s*:\s*(?:(?:=>)|[^;=\n])*\bany\b/,
|
|
52
|
+
/(?:^|[(,])\s*(?:\.\.\.)?[$\w]+\??\s*:\s*(?:(?:=>)|[^;=\n])*\bany\b/,
|
|
53
|
+
/\)\s*:\s*(?:(?:=>)|[^;={\n])*\bany\b/,
|
|
54
|
+
/^\s*(?:(?:public|private|protected|readonly|static|declare|abstract)\s+)*[$\w]+[?!]?\s*:\s*(?:(?:=>)|[^;,=\n])*\bany\b[^;=\n]*(?:;|=)/,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// A suppression is a comment whose first token is the directive; prose that
|
|
58
|
+
// mentions the directive, or a string that quotes it, is not one.
|
|
59
|
+
const SUPPRESSED_TYPE_ERRORS =
|
|
60
|
+
/(?:^|\s)(?:\/\/|\/\*)\s*@ts-(?:ignore|nocheck)\b/;
|
|
61
|
+
|
|
62
|
+
const TYPE_ALIAS = /\btype\s+[$\w]+(?:\s*<[^;=]*>)?\s*=/g;
|
|
63
|
+
const ANY_TOKEN = /\bany\b/g;
|
|
64
|
+
|
|
41
65
|
const FUNCTION_PARAMS: readonly RegExp[] = [
|
|
42
66
|
/\bfunction\s+\w*\s*\(([^()]*)\)/g,
|
|
43
67
|
/\bdef\s+\w+\s*\(([^()]*)\)/g,
|
|
@@ -54,10 +78,17 @@ export function evaluateAddedLines(
|
|
|
54
78
|
const code = maskNonCode(joined, language);
|
|
55
79
|
const codeLines = code.split('\n');
|
|
56
80
|
const usesBracedConditionals = supportsBracedConditionals(language);
|
|
81
|
+
const usesExplicitAny = supportsExplicitAny(language);
|
|
82
|
+
const commentLines = usesExplicitAny
|
|
83
|
+
? maskNonCode(joined, language, { keepComments: true }).split('\n')
|
|
84
|
+
: [];
|
|
57
85
|
|
|
58
86
|
if (usesBracedConditionals) {
|
|
59
87
|
collectBracedConditionals(code, lines, violations);
|
|
60
88
|
}
|
|
89
|
+
if (usesExplicitAny) {
|
|
90
|
+
collectTypeAliasAny(code, lines, violations);
|
|
91
|
+
}
|
|
61
92
|
|
|
62
93
|
for (let index = 0; index < lines.length; index += 1) {
|
|
63
94
|
const added = lines[index];
|
|
@@ -71,6 +102,13 @@ export function evaluateAddedLines(
|
|
|
71
102
|
if (language === LANGUAGE.python) {
|
|
72
103
|
collectPythonSuite(codeLine, violations);
|
|
73
104
|
}
|
|
105
|
+
if (usesExplicitAny) {
|
|
106
|
+
collectExplicitAny(codeLine, violations);
|
|
107
|
+
collectSuppressedTypeErrors(
|
|
108
|
+
{ line: added.line, text: commentLines[index] ?? '' },
|
|
109
|
+
violations,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
74
112
|
collectClearNames(codeLine, violations);
|
|
75
113
|
collectComments(added, violations);
|
|
76
114
|
collectDebugArtifacts(codeLine, violations);
|
|
@@ -274,6 +312,123 @@ function collectDebugArtifacts(
|
|
|
274
312
|
}
|
|
275
313
|
}
|
|
276
314
|
|
|
315
|
+
function collectExplicitAny(
|
|
316
|
+
codeLine: AddedLine,
|
|
317
|
+
violations: RuleViolation[],
|
|
318
|
+
): void {
|
|
319
|
+
const typeCode = codeLine.text
|
|
320
|
+
.replaceAll(/\btypeof\s+any\b/g, (match) => ' '.repeat(match.length))
|
|
321
|
+
.replaceAll(/^\s*(?:import|export)\s*\{[^}]*\}/g, (match) =>
|
|
322
|
+
' '.repeat(match.length),
|
|
323
|
+
);
|
|
324
|
+
if (EXPLICIT_ANY_PATTERNS.some((pattern) => pattern.test(typeCode))) {
|
|
325
|
+
violations.push(
|
|
326
|
+
violation(
|
|
327
|
+
'explicit-any',
|
|
328
|
+
"[explicit-any] Replace 'any' with the type the value actually has; use 'unknown' only when it must be narrowed first.",
|
|
329
|
+
codeLine.line,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function collectTypeAliasAny(
|
|
336
|
+
code: string,
|
|
337
|
+
lines: readonly AddedLine[],
|
|
338
|
+
violations: RuleViolation[],
|
|
339
|
+
): void {
|
|
340
|
+
const aliases = code.matchAll(
|
|
341
|
+
new RegExp(TYPE_ALIAS.source, TYPE_ALIAS.flags),
|
|
342
|
+
);
|
|
343
|
+
for (const alias of aliases) {
|
|
344
|
+
const bodyOffset = (alias.index ?? 0) + alias[0].length;
|
|
345
|
+
const endOffset = typeAliasEndOffset(code, bodyOffset);
|
|
346
|
+
const body = code.slice(bodyOffset, endOffset);
|
|
347
|
+
for (const anyMatch of body.matchAll(
|
|
348
|
+
new RegExp(ANY_TOKEN.source, ANY_TOKEN.flags),
|
|
349
|
+
)) {
|
|
350
|
+
const anyOffset = bodyOffset + (anyMatch.index ?? 0);
|
|
351
|
+
if (isValueLevelAnyInType(code, anyOffset)) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
violations.push(
|
|
355
|
+
violation(
|
|
356
|
+
'explicit-any',
|
|
357
|
+
"[explicit-any] Replace 'any' with the type the value actually has; use 'unknown' only when it must be narrowed first.",
|
|
358
|
+
lineForOffset(code, lines, anyOffset),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function typeAliasEndOffset(code: string, bodyOffset: number): number {
|
|
366
|
+
let braceDepth = 0;
|
|
367
|
+
let bracketDepth = 0;
|
|
368
|
+
let parenthesisDepth = 0;
|
|
369
|
+
for (let index = bodyOffset; index < code.length; index += 1) {
|
|
370
|
+
const character = code[index];
|
|
371
|
+
if (character === '{') {
|
|
372
|
+
braceDepth += 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (character === '}') {
|
|
376
|
+
braceDepth -= 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (character === '[') {
|
|
380
|
+
bracketDepth += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (character === ']') {
|
|
384
|
+
bracketDepth -= 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (character === '(') {
|
|
388
|
+
parenthesisDepth += 1;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (character === ')') {
|
|
392
|
+
parenthesisDepth -= 1;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (
|
|
396
|
+
character === ';' &&
|
|
397
|
+
braceDepth === 0 &&
|
|
398
|
+
bracketDepth === 0 &&
|
|
399
|
+
parenthesisDepth === 0
|
|
400
|
+
) {
|
|
401
|
+
return index;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return code.length;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isValueLevelAnyInType(code: string, anyOffset: number): boolean {
|
|
408
|
+
const prefix = code.slice(0, anyOffset);
|
|
409
|
+
if (/\btypeof\s*$/.test(prefix) || /\.\s*$/.test(prefix)) {
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
const suffix = code.slice(anyOffset + 'any'.length);
|
|
413
|
+
return /^\s*\??\s*(?::|\()/.test(suffix);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function collectSuppressedTypeErrors(
|
|
417
|
+
commentLine: AddedLine,
|
|
418
|
+
violations: RuleViolation[],
|
|
419
|
+
): void {
|
|
420
|
+
if (!SUPPRESSED_TYPE_ERRORS.test(commentLine.text)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
violations.push(
|
|
424
|
+
violation(
|
|
425
|
+
'suppressed-type-errors',
|
|
426
|
+
"[suppressed-type-errors] Replace '@ts-ignore' or '@ts-nocheck' with '@ts-expect-error' and an explanation, or fix the type.",
|
|
427
|
+
commentLine.line,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
277
432
|
function closingParenthesisOffset(
|
|
278
433
|
code: string,
|
|
279
434
|
openingOffset: number,
|
|
@@ -9,8 +9,18 @@ const LEXICAL_MODE = {
|
|
|
9
9
|
|
|
10
10
|
type LexicalMode = (typeof LEXICAL_MODE)[keyof typeof LEXICAL_MODE];
|
|
11
11
|
|
|
12
|
-
export
|
|
12
|
+
export interface MaskOptions {
|
|
13
|
+
/** Keep comment text so a caller can read the directives inside it. */
|
|
14
|
+
readonly keepComments?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function maskNonCode(
|
|
18
|
+
source: string,
|
|
19
|
+
language: LanguageId,
|
|
20
|
+
options: MaskOptions = {},
|
|
21
|
+
): string {
|
|
13
22
|
const masked = [...source];
|
|
23
|
+
const keepComments = options.keepComments === true;
|
|
14
24
|
let mode: LexicalMode = LEXICAL_MODE.code;
|
|
15
25
|
let delimiter = '';
|
|
16
26
|
|
|
@@ -22,20 +32,20 @@ export function maskNonCode(source: string, language: LanguageId): string {
|
|
|
22
32
|
mode = LEXICAL_MODE.code;
|
|
23
33
|
continue;
|
|
24
34
|
}
|
|
25
|
-
|
|
35
|
+
maskComment(masked, index, keepComments);
|
|
26
36
|
continue;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
if (mode === LEXICAL_MODE.blockComment) {
|
|
30
40
|
const closesComment = character === '*' && source[index + 1] === '/';
|
|
31
41
|
if (closesComment) {
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
maskComment(masked, index, keepComments);
|
|
43
|
+
maskComment(masked, index + 1, keepComments);
|
|
34
44
|
index += 1;
|
|
35
45
|
mode = LEXICAL_MODE.code;
|
|
36
46
|
continue;
|
|
37
47
|
}
|
|
38
|
-
|
|
48
|
+
maskComment(masked, index, keepComments);
|
|
39
49
|
continue;
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -58,7 +68,7 @@ export function maskNonCode(source: string, language: LanguageId): string {
|
|
|
58
68
|
mode = LEXICAL_MODE.code;
|
|
59
69
|
continue;
|
|
60
70
|
}
|
|
61
|
-
if (delimiter
|
|
71
|
+
if (delimiter !== '`' && character === '\n') {
|
|
62
72
|
mode = LEXICAL_MODE.code;
|
|
63
73
|
continue;
|
|
64
74
|
}
|
|
@@ -68,8 +78,8 @@ export function maskNonCode(source: string, language: LanguageId): string {
|
|
|
68
78
|
|
|
69
79
|
const startsLineComment = character === '/' && source[index + 1] === '/';
|
|
70
80
|
if (startsLineComment) {
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
maskComment(masked, index, keepComments);
|
|
82
|
+
maskComment(masked, index + 1, keepComments);
|
|
73
83
|
index += 1;
|
|
74
84
|
mode = LEXICAL_MODE.lineComment;
|
|
75
85
|
continue;
|
|
@@ -77,8 +87,8 @@ export function maskNonCode(source: string, language: LanguageId): string {
|
|
|
77
87
|
|
|
78
88
|
const startsBlockComment = character === '/' && source[index + 1] === '*';
|
|
79
89
|
if (startsBlockComment) {
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
maskComment(masked, index, keepComments);
|
|
91
|
+
maskComment(masked, index + 1, keepComments);
|
|
82
92
|
index += 1;
|
|
83
93
|
mode = LEXICAL_MODE.blockComment;
|
|
84
94
|
continue;
|
|
@@ -87,7 +97,7 @@ export function maskNonCode(source: string, language: LanguageId): string {
|
|
|
87
97
|
const startsHashComment =
|
|
88
98
|
character === '#' && supportsHashComments(language);
|
|
89
99
|
if (startsHashComment) {
|
|
90
|
-
|
|
100
|
+
maskComment(masked, index, keepComments);
|
|
91
101
|
mode = LEXICAL_MODE.lineComment;
|
|
92
102
|
continue;
|
|
93
103
|
}
|
|
@@ -134,3 +144,14 @@ function maskCharacter(masked: string[], index: number): void {
|
|
|
134
144
|
masked[index] = ' ';
|
|
135
145
|
}
|
|
136
146
|
}
|
|
147
|
+
|
|
148
|
+
function maskComment(
|
|
149
|
+
masked: string[],
|
|
150
|
+
index: number,
|
|
151
|
+
keepComments: boolean,
|
|
152
|
+
): void {
|
|
153
|
+
if (keepComments) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
maskCharacter(masked, index);
|
|
157
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const FILE_URL_PREFIX = 'file://';
|
|
7
|
+
const MISSING_PATH_ERROR_CODE = {
|
|
8
|
+
file: 'ENOENT',
|
|
9
|
+
directory: 'ENOTDIR',
|
|
10
|
+
} as const;
|
|
11
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
12
|
+
|
|
13
|
+
// File guards must resolve the model's path exactly as Pi's built-in tools do.
|
|
14
|
+
export function resolveFileToolPath(
|
|
15
|
+
cwd: string,
|
|
16
|
+
path: string,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
const normalizedPath = normalizeFileToolPath(path);
|
|
19
|
+
if (normalizedPath.length === 0) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const localPath = normalizedPath.startsWith(FILE_URL_PREFIX)
|
|
24
|
+
? fileURLToPath(normalizedPath)
|
|
25
|
+
: normalizedPath;
|
|
26
|
+
const absolutePath = isAbsolute(localPath)
|
|
27
|
+
? resolve(localPath)
|
|
28
|
+
: resolve(cwd, localPath);
|
|
29
|
+
return realpathOr(absolutePath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeFileToolPath(path: string): string {
|
|
33
|
+
const normalizedSpaces = path.replace(UNICODE_SPACES, ' ');
|
|
34
|
+
const withoutAtPrefix = normalizedSpaces.startsWith('@')
|
|
35
|
+
? normalizedSpaces.slice(1)
|
|
36
|
+
: normalizedSpaces;
|
|
37
|
+
return expandHome(normalizeWindowsShellPath(withoutAtPrefix));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeWindowsShellPath(path: string): string {
|
|
41
|
+
if (
|
|
42
|
+
process.platform !== 'win32' ||
|
|
43
|
+
!path.startsWith('/') ||
|
|
44
|
+
path.startsWith('//') ||
|
|
45
|
+
path.includes('\\')
|
|
46
|
+
) {
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const match = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
51
|
+
const drive = match?.[1];
|
|
52
|
+
if (drive === undefined) {
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
const suffix = match?.[2]?.replaceAll('/', '\\') ?? '';
|
|
56
|
+
return `${drive.toUpperCase()}:\\${suffix}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expandHome(path: string): string {
|
|
60
|
+
if (path === '~') {
|
|
61
|
+
return homedir();
|
|
62
|
+
}
|
|
63
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) {
|
|
64
|
+
return join(homedir(), path.slice(2));
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function realpathOr(path: string): string {
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync.native(path);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (
|
|
74
|
+
error instanceof Error &&
|
|
75
|
+
'code' in error &&
|
|
76
|
+
(error.code === MISSING_PATH_ERROR_CODE.file ||
|
|
77
|
+
error.code === MISSING_PATH_ERROR_CODE.directory)
|
|
78
|
+
) {
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/extensions/todo/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ export const TODO_PROMPT_SNIPPET =
|
|
|
28
28
|
|
|
29
29
|
export const TODO_PROMPT_GUIDELINES = [
|
|
30
30
|
'Use sideroom_todo to maintain a live work board when implementation has more than one step.',
|
|
31
|
-
'
|
|
31
|
+
'While any item is pending, exactly one must be in_progress.',
|
|
32
32
|
'Complete the current item and start the next item in the same update call.',
|
|
33
33
|
'propose is interactive-TUI-only; update also works in print, JSON, and RPC modes.',
|
|
34
34
|
];
|
|
@@ -50,7 +50,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
50
50
|
notifyWidgetRefresh(ctx);
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
registerSessionRefreshEvents(pi, restoreAndRefresh);
|
|
53
|
+
registerSessionRefreshEvents(pi, store, restoreAndRefresh);
|
|
54
54
|
registerGuardEvents(pi, store, restore);
|
|
55
55
|
registerBoardContext(pi, store, restoreAndRefresh);
|
|
56
56
|
registerTodoTool(pi, store, notifyWidgetRefresh);
|
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
import { renderTodoWidget } from './ui.ts';
|
|
14
14
|
|
|
15
15
|
export const SNAPSHOT_TYPE = 'sideroom-todo';
|
|
16
|
+
export const BOARD_MESSAGE_TYPE = 'sideroom-todo-board';
|
|
16
17
|
export const WIDGET_KEY = 'sideroom-todo';
|
|
17
18
|
export const TODO_WIDGET_REFRESH_EVENT = 'sideroom:todo-widget-refreshed';
|
|
18
19
|
|
|
19
20
|
export interface TodoStore {
|
|
20
21
|
items: readonly TodoItem[];
|
|
22
|
+
lastSentBlock?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export type BoardLifecycle = (ctx: ExtensionContext) => void;
|
|
@@ -89,11 +91,29 @@ export function commitBoard(
|
|
|
89
91
|
|
|
90
92
|
export function registerSessionRefreshEvents(
|
|
91
93
|
pi: ExtensionAPI,
|
|
94
|
+
store: TodoStore,
|
|
92
95
|
restoreAndRefresh: BoardLifecycle,
|
|
93
96
|
): void {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
// A restore can drop the sent block from the model's context, so an unchanged
|
|
98
|
+
// board still has to be announced again.
|
|
99
|
+
const restoreSession = (ctx: ExtensionContext) => {
|
|
100
|
+
restoreAndRefresh(ctx);
|
|
101
|
+
forgetSentBoardBlock(store);
|
|
102
|
+
};
|
|
103
|
+
pi.on('session_start', (_event, ctx) => restoreSession(ctx));
|
|
104
|
+
pi.on('session_tree', (_event, ctx) => restoreSession(ctx));
|
|
105
|
+
pi.on('session_compact', (event, ctx) => {
|
|
106
|
+
restoreSession(ctx);
|
|
107
|
+
if (!event.willRetry) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const block = pendingBoardBlock(store);
|
|
111
|
+
if (block === undefined) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
store.lastSentBlock = block;
|
|
115
|
+
pi.sendMessage(boardMessage(block), { deliverAs: 'steer' });
|
|
116
|
+
});
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
export function registerBoardContext(
|
|
@@ -101,17 +121,46 @@ export function registerBoardContext(
|
|
|
101
121
|
store: TodoStore,
|
|
102
122
|
restoreAndRefresh: BoardLifecycle,
|
|
103
123
|
): void {
|
|
104
|
-
pi.on('before_agent_start', (
|
|
124
|
+
pi.on('before_agent_start', (_event, ctx) => {
|
|
105
125
|
restoreAndRefresh(ctx);
|
|
106
|
-
|
|
126
|
+
const block = pendingBoardBlock(store);
|
|
127
|
+
if (block === undefined) {
|
|
107
128
|
return;
|
|
108
129
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
};
|
|
130
|
+
store.lastSentBlock = block;
|
|
131
|
+
return { message: boardMessage(block) };
|
|
112
132
|
});
|
|
113
133
|
}
|
|
114
134
|
|
|
135
|
+
// The block rides a session message instead of the system prompt: a volatile
|
|
136
|
+
// system prompt invalidates the cached prefix of the whole request.
|
|
137
|
+
function pendingBoardBlock(store: TodoStore): string | undefined {
|
|
138
|
+
if (store.items.length === 0) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const block = formatBoardBlock(store.items);
|
|
142
|
+
if (block === store.lastSentBlock) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
return block;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function forgetSentBoardBlock(store: TodoStore): void {
|
|
149
|
+
store.lastSentBlock = undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function boardMessage(block: string): {
|
|
153
|
+
customType: typeof BOARD_MESSAGE_TYPE;
|
|
154
|
+
content: string;
|
|
155
|
+
display: false;
|
|
156
|
+
} {
|
|
157
|
+
return {
|
|
158
|
+
customType: BOARD_MESSAGE_TYPE,
|
|
159
|
+
content: block,
|
|
160
|
+
display: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
115
164
|
function findSnapshotItems(
|
|
116
165
|
branch: SessionBranch,
|
|
117
166
|
): readonly TodoItem[] | undefined {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"format": "biome format --write extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
|
|
23
23
|
"format:check": "biome format extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
|
|
24
24
|
"preview": "node scripts/render-preview.mjs",
|
|
25
|
+
"cache:report": "node scripts/cache-report.mjs",
|
|
25
26
|
"check:if-blocks": "node scripts/check-if-blocks.mjs",
|
|
26
27
|
"check": "npm run types && npm run check:if-blocks && biome check extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json && npm test"
|
|
27
28
|
},
|
|
@@ -69,7 +70,8 @@
|
|
|
69
70
|
},
|
|
70
71
|
"pi": {
|
|
71
72
|
"extensions": [
|
|
72
|
-
"./extensions"
|
|
73
|
+
"./extensions",
|
|
74
|
+
"!extensions/*.test.ts"
|
|
73
75
|
],
|
|
74
76
|
"skills": [
|
|
75
77
|
"./skills"
|
|
@@ -25,6 +25,15 @@ if the effort is too big to hold in one session, say so and stop.
|
|
|
25
25
|
in the language the user is speaking; keep ids and option values in English.
|
|
26
26
|
- Never ask the user what the codebase can answer. Read the code first; ask
|
|
27
27
|
only what the code cannot settle.
|
|
28
|
+
- Classify before asking. A project fact is read from the code and reused. A
|
|
29
|
+
minor decision is taken without interrupting. A decision that materially
|
|
30
|
+
affects architecture, external dependencies, cost, security, operations,
|
|
31
|
+
persistence, public contracts, or future change difficulty is architectural
|
|
32
|
+
and must be exposed.
|
|
33
|
+
- Present an architectural decision as the current situation, the alternatives
|
|
34
|
+
by their practical consequences, and one recommendation with a one-line
|
|
35
|
+
justification. Never reduce it to technology names. Name a repository
|
|
36
|
+
inconsistency in the prompt instead of picking one.
|
|
28
37
|
- An *Out of scope* answer closes that thread. Do not re-ask it.
|
|
29
38
|
- Everything a round settles is recorded through `sideroom-domain-modeling`:
|
|
30
39
|
inline glossary entries, the ADR three-gate offer, lazy file creation.
|
|
@@ -38,9 +47,11 @@ if the effort is too big to hold in one session, say so and stop.
|
|
|
38
47
|
1. Read the code around the change. Settle from the codebase everything the
|
|
39
48
|
codebase settles.
|
|
40
49
|
2. Run one `sideroom_ask` round on what remains genuinely open: terms first,
|
|
41
|
-
then decisions.
|
|
50
|
+
then the architectural decisions. Minor decisions are yours; do not spend a
|
|
51
|
+
round on them.
|
|
42
52
|
3. After each round, write resolved terms to `CONTEXT.md` and qualifying
|
|
43
|
-
decisions to `docs/adr/`.
|
|
53
|
+
decisions to `docs/adr/`. Offer the ADR for an accepted architectural
|
|
54
|
+
decision only when all three gates pass.
|
|
44
55
|
4. Repeat until no open term or hard decision remains, or the user cancels.
|
|
45
56
|
5. Close by stating the settled glossary, the ADRs written (if any), and the
|
|
46
57
|
suggested next step: a spec for anything non-trivial, implementation for
|
|
@@ -62,9 +62,12 @@ until they ask for another.
|
|
|
62
62
|
|
|
63
63
|
A `write` or `edit` whose **added lines** contain a decorative symbol is
|
|
64
64
|
rejected with a reason listing the offending line. Existing content is not
|
|
65
|
-
re-checked,
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
re-checked, including when the model uses Pi path aliases for a full rewrite.
|
|
66
|
+
The detector targets default emoji presentation, emoji variation selectors,
|
|
67
|
+
and explicit check or cross marks; semantic copyright, trademark, and
|
|
68
|
+
registration symbols stay valid.
|
|
69
|
+
A prohibition that fires three times degrades to a steer so the agent is never
|
|
70
|
+
dead-locked.
|
|
68
71
|
|
|
69
72
|
### What steering means
|
|
70
73
|
|