@rmrdeveloper/sideroom-pi 8.0.1 → 8.1.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 +58 -2
- package/extensions/done/detect.ts +128 -0
- package/extensions/done/guard.ts +77 -0
- package/extensions/done/index.ts +35 -0
- package/extensions/done/model.ts +43 -0
- package/extensions/guidelines/catalog.ts +47 -0
- package/extensions/guidelines/guard.ts +72 -39
- package/extensions/guidelines/index.ts +6 -0
- package/extensions/guidelines/prompt.ts +13 -13
- package/extensions/rules/catalog.ts +71 -0
- package/extensions/rules/checks.ts +244 -0
- package/extensions/rules/guard.ts +197 -0
- package/extensions/rules/index.ts +22 -0
- package/extensions/rules/model.ts +69 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
> Coding agents fail in predictable ways: they guess instead of asking, they
|
|
8
8
|
> bury progress in chat scroll, they litter your repo with `TODO.md` files,
|
|
9
|
-
>
|
|
9
|
+
> every session writes code in a slightly different style, and they declare
|
|
10
|
+
> victory while the project checks are still red.
|
|
10
11
|
>
|
|
11
|
-
> **Sideroom fixes all
|
|
12
|
+
> **Sideroom fixes all of it — without touching your repository.**
|
|
12
13
|
|
|
13
14
|
Sideroom Pi is a global [Pi package](https://pi.dev/docs/latest/packages)
|
|
14
15
|
that gives the parent agent a *side room* next to your code: a place to ask
|
|
@@ -29,6 +30,10 @@ Every long agent session drifts toward the same failure modes:
|
|
|
29
30
|
and half-abandoned plans committed next to real code.
|
|
30
31
|
4. **Style drift.** Each session reinvents conventions: nesting depth, error
|
|
31
32
|
handling, naming, validation. Review becomes cleanup.
|
|
33
|
+
5. **Unapplied guidelines.** The contract is read, then ignored: a braceless
|
|
34
|
+
`if`, a swallowed error, `console.log`, and `TODO` land in the diff anyway.
|
|
35
|
+
6. **Premature completion.** The agent announces it is done while the
|
|
36
|
+
formatter, linter, type checks, or tests were never run.
|
|
32
37
|
|
|
33
38
|
Sideroom answers each one with a small, opinionated surface:
|
|
34
39
|
|
|
@@ -39,6 +44,8 @@ Sideroom answers each one with a small, opinionated surface:
|
|
|
39
44
|
| Repo pollution | Session-branch state — the board and history die with the session, never with a commit |
|
|
40
45
|
| Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
|
|
41
46
|
| Vague plans | `sideroom-grill` — an interview that settles the words before the work |
|
|
47
|
+
| Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
|
|
48
|
+
| Premature completion | `sideroom_done` — steers back to the project's check command before finishing |
|
|
42
49
|
|
|
43
50
|
## What it feels like
|
|
44
51
|
|
|
@@ -65,6 +72,14 @@ wall of tool calls, it:
|
|
|
65
72
|
blocked. The guides cover guard clauses, braced conditionals, fail-fast
|
|
66
73
|
errors, focused units, and all 19 canonical rules without dumping them into
|
|
67
74
|
the system prompt.
|
|
75
|
+
- **Cannot sneak sloppy lines past the gate.** Each write and edit is checked
|
|
76
|
+
against the mechanical rules on the added lines only: braceless
|
|
77
|
+
conditionals and swallowed errors block the mutation; vague names, stale
|
|
78
|
+
`TODO`s, commented-out code, and debug output are appended as notes to the
|
|
79
|
+
result. A rule that keeps firing degrades instead of trapping the agent.
|
|
80
|
+
- **Does not call it done in red.** When files changed and the project's own
|
|
81
|
+
check command has not passed, the agent is steered back to run it before
|
|
82
|
+
finishing. If no check command is detectable, the gate stays out of the way.
|
|
68
83
|
|
|
69
84
|
## What's inside
|
|
70
85
|
|
|
@@ -119,6 +134,26 @@ Each guide mirrors all 19 rules in the canonical seed with idiomatic examples.
|
|
|
119
134
|
The seed, `assets/artifacts/GUIDELINES_TEMPLATE.md`, is never pasted into the
|
|
120
135
|
system prompt.
|
|
121
136
|
|
|
137
|
+
### Rules — enforcement, not advice
|
|
138
|
+
|
|
139
|
+
The guidelines gate makes the agent read the rules; `sideroom_rules` makes it
|
|
140
|
+
follow them. On every `write`/`edit`, only the added lines are checked against
|
|
141
|
+
the mechanical rules from the canonical seed. Braceless conditionals and
|
|
142
|
+
swallowed errors block the mutation with an actionable reason; banned
|
|
143
|
+
identifiers, stale comments, commented-out code, and debug artifacts are
|
|
144
|
+
appended as notes to the tool result. A per-rule circuit breaker degrades a
|
|
145
|
+
repeatedly firing block to a note so the agent never dead-locks. No files
|
|
146
|
+
written, no config read: the catalog ships with the package.
|
|
147
|
+
|
|
148
|
+
### Done — done means green
|
|
149
|
+
|
|
150
|
+
`sideroom_done` detects the project's check command — `package.json` scripts
|
|
151
|
+
(`check` → `test` → `lint` → `typecheck` → `types`) with the right
|
|
152
|
+
package manager, `pytest`, `go test ./...`, `cargo test`, or a `make check`
|
|
153
|
+
target — and watches for it to pass. If files changed without a green run, the
|
|
154
|
+
agent is steered once per turn, up to a cap, to run it before finishing. When
|
|
155
|
+
nothing is detectable, the gate does nothing.
|
|
156
|
+
|
|
122
157
|
## Quick path
|
|
123
158
|
|
|
124
159
|
1. Install globally from npm. Pi records this in your user settings, so do
|
|
@@ -187,6 +222,27 @@ languages use the shared table. Do not route around
|
|
|
187
222
|
the gate through Bash or another file-mutation path. Before finishing, review
|
|
188
223
|
the diff against the loaded guide and run the relevant project checks.
|
|
189
224
|
|
|
225
|
+
### Rules contract
|
|
226
|
+
|
|
227
|
+
- Trigger: every `write`/`edit` call, in every mode.
|
|
228
|
+
- Scope: only lines added by the call. For `edit`, each `oldText → newText`
|
|
229
|
+
pair; for `write`, the new content against the existing file.
|
|
230
|
+
- Blocking rules: braced conditionals (rule 1) and explicit error handling
|
|
231
|
+
(rule 5). Warning rules: clear names (6), comments (19), and debug artifacts.
|
|
232
|
+
- Blocked calls return the rule id, the reason, and the fix. Warnings are
|
|
233
|
+
appended to the tool result.
|
|
234
|
+
- A rule that blocks three times in a run degrades to a warning until five
|
|
235
|
+
clean checks or a new interactive prompt reset it.
|
|
236
|
+
|
|
237
|
+
### Done contract
|
|
238
|
+
|
|
239
|
+
- Detected command: `package.json` scripts in priority order with the
|
|
240
|
+
lockfile's package manager, then `pytest -q` for Python, `go test ./...`,
|
|
241
|
+
`cargo test`, or `make check`. Detection is cached per working directory.
|
|
242
|
+
- Green means that command ran with a zero exit in this run. A later
|
|
243
|
+
successful `write`/`edit` clears green again.
|
|
244
|
+
- Steering: once per turn, at most twice per run, then it stops.
|
|
245
|
+
|
|
190
246
|
### Grill contract
|
|
191
247
|
|
|
192
248
|
Load the skill at the start of a change with a fuzzy plan.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface CheckCommand {
|
|
5
|
+
readonly executable: string;
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
readonly display: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const PACKAGE_MANAGER = {
|
|
11
|
+
npm: 'npm',
|
|
12
|
+
pnpm: 'pnpm',
|
|
13
|
+
yarn: 'yarn',
|
|
14
|
+
bun: 'bun',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
type PackageManager = (typeof PACKAGE_MANAGER)[keyof typeof PACKAGE_MANAGER];
|
|
18
|
+
|
|
19
|
+
const SCRIPT_PRIORITY = [
|
|
20
|
+
'check',
|
|
21
|
+
'test',
|
|
22
|
+
'lint',
|
|
23
|
+
'typecheck',
|
|
24
|
+
'types',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export function detectCheckCommand(cwd: string): CheckCommand | undefined {
|
|
28
|
+
const packageCommand = detectPackageCommand(cwd);
|
|
29
|
+
if (packageCommand !== undefined) {
|
|
30
|
+
return packageCommand;
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
existsSync(join(cwd, 'pyproject.toml')) ||
|
|
34
|
+
existsSync(join(cwd, 'setup.py'))
|
|
35
|
+
) {
|
|
36
|
+
return command('pytest', ['-q']);
|
|
37
|
+
}
|
|
38
|
+
if (existsSync(join(cwd, 'go.mod'))) {
|
|
39
|
+
return command('go', ['test', './...']);
|
|
40
|
+
}
|
|
41
|
+
if (existsSync(join(cwd, 'Cargo.toml'))) {
|
|
42
|
+
return command('cargo', ['test']);
|
|
43
|
+
}
|
|
44
|
+
if (hasMakeCheck(cwd)) {
|
|
45
|
+
return command('make', ['check']);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function commandMatches(text: string, check: CheckCommand): boolean {
|
|
51
|
+
const normalized = collapseWhitespace(text.trim());
|
|
52
|
+
const target = collapseWhitespace(check.display);
|
|
53
|
+
if (normalized === target) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return [' ', '&', '|', ';'].some((separator) =>
|
|
57
|
+
normalized.startsWith(`${target}${separator}`),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function detectPackageCommand(cwd: string): CheckCommand | undefined {
|
|
62
|
+
const packagePath = join(cwd, 'package.json');
|
|
63
|
+
if (!existsSync(packagePath)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const scripts = readScripts(packagePath);
|
|
68
|
+
const script = SCRIPT_PRIORITY.find(
|
|
69
|
+
(name) => typeof scripts[name] === 'string',
|
|
70
|
+
);
|
|
71
|
+
if (script === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manager = packageManager(cwd);
|
|
76
|
+
if (manager === PACKAGE_MANAGER.yarn) {
|
|
77
|
+
return command(PACKAGE_MANAGER.yarn, [script]);
|
|
78
|
+
}
|
|
79
|
+
return command(manager, ['run', script]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readScripts(path: string): Record<string, unknown> {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
85
|
+
scripts?: Record<string, unknown>;
|
|
86
|
+
};
|
|
87
|
+
return parsed.scripts ?? {};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`Failed to read scripts from ${path}`, { cause: error });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageManager(cwd: string): PackageManager {
|
|
94
|
+
if (existsSync(join(cwd, 'pnpm-lock.yaml'))) {
|
|
95
|
+
return PACKAGE_MANAGER.pnpm;
|
|
96
|
+
}
|
|
97
|
+
if (existsSync(join(cwd, 'yarn.lock'))) {
|
|
98
|
+
return PACKAGE_MANAGER.yarn;
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) {
|
|
101
|
+
return PACKAGE_MANAGER.bun;
|
|
102
|
+
}
|
|
103
|
+
return PACKAGE_MANAGER.npm;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasMakeCheck(cwd: string): boolean {
|
|
107
|
+
const path = join(cwd, 'Makefile');
|
|
108
|
+
if (!existsSync(path)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return /^check:/m.test(readFileSync(path, 'utf8'));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`Failed to read ${path}`, { cause: error });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function command(executable: string, args: readonly string[]): CheckCommand {
|
|
119
|
+
return {
|
|
120
|
+
executable,
|
|
121
|
+
args,
|
|
122
|
+
display: [executable, ...args].join(' '),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collapseWhitespace(value: string): string {
|
|
127
|
+
return value.replaceAll(/\s+/g, ' ');
|
|
128
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isBashToolResult,
|
|
5
|
+
isEditToolResult,
|
|
6
|
+
isToolCallEventType,
|
|
7
|
+
isWriteToolResult,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { type CheckCommand, commandMatches } from './detect.ts';
|
|
10
|
+
import {
|
|
11
|
+
DONE_GATE_TYPE,
|
|
12
|
+
type DoneState,
|
|
13
|
+
formatDoneSteer,
|
|
14
|
+
shouldSteerDone,
|
|
15
|
+
} from './model.ts';
|
|
16
|
+
|
|
17
|
+
export type ResolveCommand = (
|
|
18
|
+
ctx: Pick<ExtensionContext, 'cwd'>,
|
|
19
|
+
) => CheckCommand | undefined;
|
|
20
|
+
|
|
21
|
+
export function registerDoneGuard(
|
|
22
|
+
pi: ExtensionAPI,
|
|
23
|
+
state: DoneState,
|
|
24
|
+
resolveCommand: ResolveCommand,
|
|
25
|
+
): void {
|
|
26
|
+
const checkRuns = new Set<string>();
|
|
27
|
+
|
|
28
|
+
pi.on('turn_start', () => {
|
|
29
|
+
state.steeredThisTurn = false;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on('tool_call', (event, ctx) => {
|
|
33
|
+
if (!isToolCallEventType('bash', event)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const check = resolveCommand(ctx);
|
|
37
|
+
if (check === undefined) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (commandMatches(event.input.command, check)) {
|
|
41
|
+
checkRuns.add(event.toolCallId);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
pi.on('tool_result', (event) => {
|
|
46
|
+
if (isWriteToolResult(event) || isEditToolResult(event)) {
|
|
47
|
+
if (!event.isError) {
|
|
48
|
+
state.mutated = true;
|
|
49
|
+
state.checksGreen = false;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (isBashToolResult(event)) {
|
|
54
|
+
const wasCheck = checkRuns.delete(event.toolCallId);
|
|
55
|
+
if (wasCheck && !event.isError) {
|
|
56
|
+
state.checksGreen = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.on('turn_end', (_event, ctx) => {
|
|
62
|
+
const check = resolveCommand(ctx);
|
|
63
|
+
if (check === undefined || !shouldSteerDone(state, true)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
state.steeredThisTurn = true;
|
|
67
|
+
state.steerCount += 1;
|
|
68
|
+
pi.sendMessage(
|
|
69
|
+
{
|
|
70
|
+
customType: DONE_GATE_TYPE,
|
|
71
|
+
content: formatDoneSteer(check.display),
|
|
72
|
+
display: false,
|
|
73
|
+
},
|
|
74
|
+
{ triggerTurn: true, deliverAs: 'steer' },
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { type CheckCommand, detectCheckCommand } from './detect.ts';
|
|
6
|
+
import { type ResolveCommand, registerDoneGuard } from './guard.ts';
|
|
7
|
+
import { createDoneState, resetDoneRun } from './model.ts';
|
|
8
|
+
|
|
9
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
10
|
+
export default function registerDone(pi: ExtensionAPI): void {
|
|
11
|
+
const state = createDoneState();
|
|
12
|
+
const cache = new Map<string, CheckCommand | undefined>();
|
|
13
|
+
const resolveCommand: ResolveCommand = (
|
|
14
|
+
ctx: Pick<ExtensionContext, 'cwd'>,
|
|
15
|
+
) => {
|
|
16
|
+
if (cache.has(ctx.cwd)) {
|
|
17
|
+
return cache.get(ctx.cwd);
|
|
18
|
+
}
|
|
19
|
+
const command = detectCheckCommand(ctx.cwd);
|
|
20
|
+
cache.set(ctx.cwd, command);
|
|
21
|
+
return command;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
registerDoneGuard(pi, state, resolveCommand);
|
|
25
|
+
|
|
26
|
+
pi.on('input', (event) => {
|
|
27
|
+
if (event.source === 'interactive' || event.source === 'rpc') {
|
|
28
|
+
resetDoneRun(state);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on('session_start', () => {
|
|
33
|
+
resetDoneRun(state);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface DoneState {
|
|
2
|
+
mutated: boolean;
|
|
3
|
+
checksGreen: boolean;
|
|
4
|
+
steeredThisTurn: boolean;
|
|
5
|
+
steerCount: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const MAX_DONE_STEERS = 2;
|
|
9
|
+
|
|
10
|
+
export const DONE_GATE_TYPE = 'sideroom-done-gate';
|
|
11
|
+
|
|
12
|
+
export function createDoneState(): DoneState {
|
|
13
|
+
return {
|
|
14
|
+
mutated: false,
|
|
15
|
+
checksGreen: false,
|
|
16
|
+
steeredThisTurn: false,
|
|
17
|
+
steerCount: 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resetDoneRun(state: DoneState): void {
|
|
22
|
+
state.mutated = false;
|
|
23
|
+
state.checksGreen = false;
|
|
24
|
+
state.steeredThisTurn = false;
|
|
25
|
+
state.steerCount = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function shouldSteerDone(
|
|
29
|
+
state: DoneState,
|
|
30
|
+
hasCommand: boolean,
|
|
31
|
+
): boolean {
|
|
32
|
+
return (
|
|
33
|
+
hasCommand &&
|
|
34
|
+
state.mutated &&
|
|
35
|
+
!state.checksGreen &&
|
|
36
|
+
!state.steeredThisTurn &&
|
|
37
|
+
state.steerCount < MAX_DONE_STEERS
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatDoneSteer(display: string): string {
|
|
42
|
+
return `Run \`${display}\` and make it pass before finishing.`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
5
|
+
const LANGUAGE_GUIDE_DIRECTORY = join(
|
|
6
|
+
PACKAGE_ROOT,
|
|
7
|
+
'skills/sideroom-guidelines/references/languages',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const GUIDELINE_SKILL_PATH = join(
|
|
11
|
+
PACKAGE_ROOT,
|
|
12
|
+
'skills/sideroom-guidelines/SKILL.md',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export interface LanguageGuide {
|
|
16
|
+
readonly extensions: readonly string[];
|
|
17
|
+
readonly fileName: string;
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const LANGUAGE_GUIDES: readonly LanguageGuide[] = [
|
|
22
|
+
languageGuide('go.md', ['.go']),
|
|
23
|
+
languageGuide('java.md', ['.java']),
|
|
24
|
+
languageGuide('php-laravel.md', ['.php']),
|
|
25
|
+
languageGuide('python.md', ['.py']),
|
|
26
|
+
languageGuide('rust.md', ['.rs']),
|
|
27
|
+
languageGuide('typescript.md', ['.ts', '.tsx']),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const LANGUAGE_GUIDE_FILES: readonly string[] = LANGUAGE_GUIDES.map(
|
|
31
|
+
({ fileName }) => fileName,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const LANGUAGE_TARGET_LABELS: readonly string[] = LANGUAGE_GUIDES.map(
|
|
35
|
+
({ extensions }) => extensions.join('/'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function languageGuide(
|
|
39
|
+
fileName: string,
|
|
40
|
+
extensions: readonly string[],
|
|
41
|
+
): LanguageGuide {
|
|
42
|
+
return {
|
|
43
|
+
extensions,
|
|
44
|
+
fileName,
|
|
45
|
+
path: join(LANGUAGE_GUIDE_DIRECTORY, fileName),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -1,29 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
type ExtensionAPI,
|
|
5
6
|
isReadToolResult,
|
|
6
7
|
isToolCallEventType,
|
|
7
8
|
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { GUIDELINE_SKILL_PATH, LANGUAGE_GUIDES } from './catalog.ts';
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
export const GUIDELINE_SKILL_PATH = join(
|
|
11
|
-
PACKAGE_ROOT,
|
|
12
|
-
'skills/sideroom-guidelines/SKILL.md',
|
|
13
|
-
);
|
|
14
|
-
const LANGUAGE_GUIDE_DIRECTORY = join(
|
|
15
|
-
PACKAGE_ROOT,
|
|
16
|
-
'skills/sideroom-guidelines/references/languages',
|
|
17
|
-
);
|
|
18
|
-
const LANGUAGE_GUIDE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
19
|
-
'.go': join(LANGUAGE_GUIDE_DIRECTORY, 'go.md'),
|
|
20
|
-
'.java': join(LANGUAGE_GUIDE_DIRECTORY, 'java.md'),
|
|
21
|
-
'.php': join(LANGUAGE_GUIDE_DIRECTORY, 'php-laravel.md'),
|
|
22
|
-
'.py': join(LANGUAGE_GUIDE_DIRECTORY, 'python.md'),
|
|
23
|
-
'.rs': join(LANGUAGE_GUIDE_DIRECTORY, 'rust.md'),
|
|
24
|
-
'.ts': join(LANGUAGE_GUIDE_DIRECTORY, 'typescript.md'),
|
|
25
|
-
'.tsx': join(LANGUAGE_GUIDE_DIRECTORY, 'typescript.md'),
|
|
26
|
-
};
|
|
11
|
+
export { GUIDELINE_SKILL_PATH } from './catalog.ts';
|
|
27
12
|
|
|
28
13
|
interface ReadInput {
|
|
29
14
|
readonly path: string;
|
|
@@ -49,17 +34,21 @@ export function registerGuidelineGuard(
|
|
|
49
34
|
pi: ExtensionAPI,
|
|
50
35
|
state: GuidelineReadState,
|
|
51
36
|
): void {
|
|
52
|
-
pi.on('tool_result', (event) => {
|
|
37
|
+
pi.on('tool_result', (event, ctx) => {
|
|
53
38
|
if (!isReadToolResult(event) || event.isError) {
|
|
54
39
|
return;
|
|
55
40
|
}
|
|
56
41
|
|
|
57
42
|
// SAFETY: Pi validates built-in read arguments before emitting tool_result.
|
|
58
43
|
const input = event.input as unknown as ReadInput;
|
|
59
|
-
if (
|
|
44
|
+
if (
|
|
45
|
+
input.offset !== undefined ||
|
|
46
|
+
input.limit !== undefined ||
|
|
47
|
+
event.details?.truncation?.truncated === true
|
|
48
|
+
) {
|
|
60
49
|
return;
|
|
61
50
|
}
|
|
62
|
-
recordGuidelineRead(state, input.path);
|
|
51
|
+
recordGuidelineRead(state, input.path, ctx.cwd);
|
|
63
52
|
});
|
|
64
53
|
|
|
65
54
|
pi.on('tool_call', (event) => {
|
|
@@ -70,7 +59,11 @@ export function registerGuidelineGuard(
|
|
|
70
59
|
return;
|
|
71
60
|
}
|
|
72
61
|
|
|
73
|
-
const reason = mutationBlockReason(
|
|
62
|
+
const reason = mutationBlockReason(
|
|
63
|
+
state,
|
|
64
|
+
event.input.path,
|
|
65
|
+
pi.getActiveTools().includes('read'),
|
|
66
|
+
);
|
|
74
67
|
if (reason === undefined) {
|
|
75
68
|
return;
|
|
76
69
|
}
|
|
@@ -81,20 +74,21 @@ export function registerGuidelineGuard(
|
|
|
81
74
|
export function recordGuidelineRead(
|
|
82
75
|
state: GuidelineReadState,
|
|
83
76
|
path: string | undefined,
|
|
77
|
+
cwd: string = process.cwd(),
|
|
84
78
|
): void {
|
|
85
79
|
if (path === undefined) {
|
|
86
80
|
return;
|
|
87
81
|
}
|
|
88
82
|
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
83
|
+
const canonicalPath = canonicalizePath(path, cwd);
|
|
84
|
+
if (canonicalPath === canonicalCatalogPath(GUIDELINE_SKILL_PATH)) {
|
|
91
85
|
state.skillRead = true;
|
|
92
86
|
return;
|
|
93
87
|
}
|
|
94
88
|
|
|
95
|
-
for (const
|
|
96
|
-
if (
|
|
97
|
-
state.languageGuidesRead.add(
|
|
89
|
+
for (const guide of LANGUAGE_GUIDES) {
|
|
90
|
+
if (canonicalPath === canonicalCatalogPath(guide.path)) {
|
|
91
|
+
state.languageGuidesRead.add(guide.path);
|
|
98
92
|
return;
|
|
99
93
|
}
|
|
100
94
|
}
|
|
@@ -103,6 +97,7 @@ export function recordGuidelineRead(
|
|
|
103
97
|
export function mutationBlockReason(
|
|
104
98
|
state: GuidelineReadState,
|
|
105
99
|
targetPath: string,
|
|
100
|
+
readToolAvailable = true,
|
|
106
101
|
): string | undefined {
|
|
107
102
|
const requiredPaths: string[] = [];
|
|
108
103
|
if (!state.skillRead) {
|
|
@@ -117,20 +112,58 @@ export function mutationBlockReason(
|
|
|
117
112
|
if (requiredPaths.length === 0) {
|
|
118
113
|
return undefined;
|
|
119
114
|
}
|
|
115
|
+
if (!readToolAvailable) {
|
|
116
|
+
return `Blocked ${targetPath}: the read tool is inactive, so the guidelines gate cannot be satisfied. Enable read, then read ${requiredPaths.join(' and ')} before retrying the mutation.`;
|
|
117
|
+
}
|
|
120
118
|
|
|
121
|
-
return `Blocked ${targetPath}: read ${requiredPaths.join(' and ')} with the read tool in this agent run, then retry the
|
|
119
|
+
return `Blocked ${targetPath}: read ${requiredPaths.join(' and ')} with the read tool in this agent run, then retry the mutation.`;
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
function languageGuideFor(path: string): string | undefined {
|
|
125
|
-
const normalizedPath =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
const normalizedPath = stripLeadingAt(path)
|
|
124
|
+
.replaceAll('\\', '/')
|
|
125
|
+
.toLowerCase();
|
|
126
|
+
return LANGUAGE_GUIDES.find(({ extensions }) =>
|
|
127
|
+
extensions.some((extension) => normalizedPath.endsWith(extension)),
|
|
128
|
+
)?.path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const canonicalCatalogPaths = new Map<string, string>();
|
|
132
|
+
|
|
133
|
+
function canonicalCatalogPath(path: string): string {
|
|
134
|
+
const cached = canonicalCatalogPaths.get(path);
|
|
135
|
+
if (cached !== undefined) {
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
const canonical = realpathOr(path);
|
|
139
|
+
canonicalCatalogPaths.set(path, canonical);
|
|
140
|
+
return canonical;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function canonicalizePath(path: string, cwd: string): string {
|
|
144
|
+
const expanded = expandHome(stripLeadingAt(path));
|
|
145
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
|
146
|
+
return realpathOr(normalize(absolute));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function realpathOr(path: string): string {
|
|
150
|
+
try {
|
|
151
|
+
return realpathSync.native(path);
|
|
152
|
+
} catch {
|
|
153
|
+
return path;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function expandHome(path: string): string {
|
|
158
|
+
if (path === '~') {
|
|
159
|
+
return homedir();
|
|
160
|
+
}
|
|
161
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) {
|
|
162
|
+
return join(homedir(), path.slice(2));
|
|
163
|
+
}
|
|
164
|
+
return path;
|
|
132
165
|
}
|
|
133
166
|
|
|
134
|
-
function
|
|
135
|
-
return path.
|
|
167
|
+
function stripLeadingAt(path: string): string {
|
|
168
|
+
return path.startsWith('@') ? path.slice(1) : path;
|
|
136
169
|
}
|
|
@@ -19,4 +19,10 @@ export default function registerGuidelines(pi: ExtensionAPI): void {
|
|
|
19
19
|
}
|
|
20
20
|
return { systemPrompt };
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
// Compaction may summarize the loaded guides out of context mid-run; force
|
|
24
|
+
// a fresh read instead of trusting a now-stale in-memory flag.
|
|
25
|
+
pi.on('session_compact', () => {
|
|
26
|
+
resetGuidelineReadState(readState);
|
|
27
|
+
});
|
|
22
28
|
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
+
import { GUIDELINE_SKILL_PATH, LANGUAGE_GUIDES } from './catalog.ts';
|
|
2
|
+
|
|
3
|
+
export { LANGUAGE_GUIDE_FILES } from './catalog.ts';
|
|
4
|
+
|
|
1
5
|
export const GUIDELINES_REMINDER_HEADING =
|
|
2
6
|
'Sideroom coding guidelines (always-on reminder):';
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Before finishing, review changed code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
|
|
8
|
+
const LANGUAGE_GUIDE_REMINDERS = LANGUAGE_GUIDES.map(
|
|
9
|
+
({ extensions, path }) =>
|
|
10
|
+
`If a file you will write or edit ends in ${extensions.join(' or ')}, use the read tool without offset or limit to load the exact matching guide at ${path} in full.`,
|
|
11
|
+
).join('\n');
|
|
9
12
|
|
|
10
|
-
export const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'rust.md',
|
|
16
|
-
'typescript.md',
|
|
17
|
-
] as const;
|
|
13
|
+
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 ${GUIDELINE_SKILL_PATH} in full.
|
|
15
|
+
${LANGUAGE_GUIDE_REMINDERS}
|
|
16
|
+
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
|
+
After changing files, review the code against the loaded guide and run the relevant formatter, linter, type checks, and tests.`;
|
|
18
18
|
|
|
19
19
|
export function appendGuidelinesReminder(systemPrompt: string): string {
|
|
20
20
|
if (systemPrompt.includes(GUIDELINES_REMINDER_HEADING)) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const LANGUAGE = {
|
|
2
|
+
typescript: 'typescript',
|
|
3
|
+
javascript: 'javascript',
|
|
4
|
+
go: 'go',
|
|
5
|
+
java: 'java',
|
|
6
|
+
php: 'php',
|
|
7
|
+
python: 'python',
|
|
8
|
+
rust: 'rust',
|
|
9
|
+
generic: 'generic',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const SEVERITY = {
|
|
13
|
+
block: 'block',
|
|
14
|
+
warn: 'warn',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type LanguageId = (typeof LANGUAGE)[keyof typeof LANGUAGE];
|
|
18
|
+
|
|
19
|
+
export type RuleId =
|
|
20
|
+
| 'braced-conditionals'
|
|
21
|
+
| 'explicit-error-handling'
|
|
22
|
+
| 'clear-names'
|
|
23
|
+
| 'comments'
|
|
24
|
+
| 'debug-artifacts';
|
|
25
|
+
|
|
26
|
+
export type RuleSeverity = (typeof SEVERITY)[keyof typeof SEVERITY];
|
|
27
|
+
|
|
28
|
+
export const RULE_SEVERITY: Readonly<Record<RuleId, RuleSeverity>> = {
|
|
29
|
+
'braced-conditionals': SEVERITY.block,
|
|
30
|
+
'explicit-error-handling': SEVERITY.block,
|
|
31
|
+
'clear-names': SEVERITY.warn,
|
|
32
|
+
comments: SEVERITY.warn,
|
|
33
|
+
'debug-artifacts': SEVERITY.warn,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function languageForPath(path: string): LanguageId {
|
|
37
|
+
const normalized = path.replaceAll('\\', '/').toLowerCase();
|
|
38
|
+
const separator = normalized.lastIndexOf('/');
|
|
39
|
+
const fileName = normalized.slice(separator + 1);
|
|
40
|
+
if (fileName.endsWith('.ts') || fileName.endsWith('.tsx')) {
|
|
41
|
+
return LANGUAGE.typescript;
|
|
42
|
+
}
|
|
43
|
+
if (
|
|
44
|
+
fileName.endsWith('.js') ||
|
|
45
|
+
fileName.endsWith('.jsx') ||
|
|
46
|
+
fileName.endsWith('.mjs') ||
|
|
47
|
+
fileName.endsWith('.cjs')
|
|
48
|
+
) {
|
|
49
|
+
return LANGUAGE.javascript;
|
|
50
|
+
}
|
|
51
|
+
if (fileName.endsWith('.go')) {
|
|
52
|
+
return LANGUAGE.go;
|
|
53
|
+
}
|
|
54
|
+
if (fileName.endsWith('.java')) {
|
|
55
|
+
return LANGUAGE.java;
|
|
56
|
+
}
|
|
57
|
+
if (fileName.endsWith('.php')) {
|
|
58
|
+
return LANGUAGE.php;
|
|
59
|
+
}
|
|
60
|
+
if (fileName.endsWith('.py')) {
|
|
61
|
+
return LANGUAGE.python;
|
|
62
|
+
}
|
|
63
|
+
if (fileName.endsWith('.rs')) {
|
|
64
|
+
return LANGUAGE.rust;
|
|
65
|
+
}
|
|
66
|
+
return LANGUAGE.generic;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function supportsBracedConditionals(language: LanguageId): boolean {
|
|
70
|
+
return language !== LANGUAGE.python && language !== LANGUAGE.generic;
|
|
71
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LANGUAGE,
|
|
3
|
+
type LanguageId,
|
|
4
|
+
RULE_SEVERITY,
|
|
5
|
+
type RuleId,
|
|
6
|
+
supportsBracedConditionals,
|
|
7
|
+
} from './catalog.ts';
|
|
8
|
+
import type { AddedLine, RuleViolation } from './model.ts';
|
|
9
|
+
|
|
10
|
+
const BANNED_ALTERNATION = 'data|info|temp|tmp|result|obj|val|x';
|
|
11
|
+
|
|
12
|
+
const BRACED_IF = /\b(?:if|else\s+if|for|while)\s*\(.*\)\s*(?!\{)\S/;
|
|
13
|
+
const BRACED_ELSE = /\belse\s+(?!if\b|\{)\S/;
|
|
14
|
+
const PYTHON_INLINE_SUITE =
|
|
15
|
+
/^\s*(?:if|elif|else|for|while|with|try|except|finally|def|class)\b.*:\s*(?:return|pass|raise|break|continue|yield|assert|del|import|from|global|nonlocal|print|await|lambda)\b/;
|
|
16
|
+
|
|
17
|
+
const EMPTY_CATCH =
|
|
18
|
+
/\bcatch\s*(?:\([^)]*\))?\s*\{\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)?\}/g;
|
|
19
|
+
const SWALLOW_RETURN =
|
|
20
|
+
/\bcatch\s*(?:\([^)]*\))?\s*\{\s*return\s+(?:null|undefined|void\s+0)\s*;?\s*\}/g;
|
|
21
|
+
const PYTHON_SWALLOW =
|
|
22
|
+
/^[ \t]*except\b[^:\n]*:[ \t]*(?:pass|\.\.\.)[ \t]*(?:#[^\n]*)?$/gm;
|
|
23
|
+
const PYTHON_SWALLOW_BLOCK =
|
|
24
|
+
/^[ \t]*except\b[^:\n]*:[ \t]*\n[ \t]*(?:pass|\.\.\.)[ \t]*(?:#[^\n]*)?$/gm;
|
|
25
|
+
|
|
26
|
+
const TODO_COMMENT = /(?:\/\/|#|\/\*)[^\n]*\b(?:TODO|FIXME|XXX|HACK)\b/;
|
|
27
|
+
const COMMENTED_CODE =
|
|
28
|
+
/^\s*(?:\/\/|#)\s*(?:if|for|while|return|const|let|var|function|class|def|import|from|print|console|await|async)\b/;
|
|
29
|
+
const CODE_LIKE_COMMENT = /^\s*(?:\/\/|#).*[;{}]\s*$/;
|
|
30
|
+
|
|
31
|
+
const DEBUG_PATTERNS: readonly RegExp[] = [
|
|
32
|
+
/\bconsole\.(?:log|debug)\s*\(/,
|
|
33
|
+
/\bdebugger\b/,
|
|
34
|
+
/^\s*print\s*\(/,
|
|
35
|
+
/\bvar_dump\s*\(/,
|
|
36
|
+
/\bdd\s*\(/,
|
|
37
|
+
/\bdump\s*\(/,
|
|
38
|
+
/\bdbg!\s*\(/,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const FUNCTION_PARAMS: readonly RegExp[] = [
|
|
42
|
+
/\bfunction\s+\w*\s*\(([^()]*)\)/g,
|
|
43
|
+
/\bdef\s+\w+\s*\(([^()]*)\)/g,
|
|
44
|
+
/\bfn\s+\w+\s*\(([^()]*)\)/g,
|
|
45
|
+
/\(([^()]*)\)\s*=>/g,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function evaluateAddedLines(
|
|
49
|
+
language: LanguageId,
|
|
50
|
+
lines: readonly AddedLine[],
|
|
51
|
+
): RuleViolation[] {
|
|
52
|
+
const violations: RuleViolation[] = [];
|
|
53
|
+
|
|
54
|
+
for (const added of lines) {
|
|
55
|
+
if (supportsBracedConditionals(language)) {
|
|
56
|
+
collectBracedConditionals(added, violations);
|
|
57
|
+
} else if (language === LANGUAGE.python) {
|
|
58
|
+
collectPythonSuite(added, violations);
|
|
59
|
+
}
|
|
60
|
+
collectClearNames(added, violations);
|
|
61
|
+
collectComments(added, violations);
|
|
62
|
+
collectDebugArtifacts(added, violations);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
collectErrorHandling(language, lines, violations);
|
|
66
|
+
return dedupe(violations);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function violation(
|
|
70
|
+
ruleId: RuleId,
|
|
71
|
+
message: string,
|
|
72
|
+
line: number,
|
|
73
|
+
): RuleViolation {
|
|
74
|
+
return { ruleId, severity: RULE_SEVERITY[ruleId], message, line };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectBracedConditionals(
|
|
78
|
+
added: AddedLine,
|
|
79
|
+
violations: RuleViolation[],
|
|
80
|
+
): void {
|
|
81
|
+
if (BRACED_IF.test(added.text) || BRACED_ELSE.test(added.text)) {
|
|
82
|
+
violations.push(
|
|
83
|
+
violation(
|
|
84
|
+
'braced-conditionals',
|
|
85
|
+
'[braced-conditionals] Wrap the conditional body in braces.',
|
|
86
|
+
added.line,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectPythonSuite(
|
|
93
|
+
added: AddedLine,
|
|
94
|
+
violations: RuleViolation[],
|
|
95
|
+
): void {
|
|
96
|
+
if (PYTHON_INLINE_SUITE.test(added.text)) {
|
|
97
|
+
violations.push(
|
|
98
|
+
violation(
|
|
99
|
+
'braced-conditionals',
|
|
100
|
+
'[braced-conditionals] Put the suite on its own indented line.',
|
|
101
|
+
added.line,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectErrorHandling(
|
|
108
|
+
language: LanguageId,
|
|
109
|
+
lines: readonly AddedLine[],
|
|
110
|
+
violations: RuleViolation[],
|
|
111
|
+
): void {
|
|
112
|
+
const joined = lines.map((added) => added.text).join('\n');
|
|
113
|
+
if (language === LANGUAGE.python) {
|
|
114
|
+
collectPattern(PYTHON_SWALLOW, joined, lines, violations);
|
|
115
|
+
collectPattern(PYTHON_SWALLOW_BLOCK, joined, lines, violations);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (language === LANGUAGE.generic) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
collectPattern(EMPTY_CATCH, joined, lines, violations);
|
|
122
|
+
collectPattern(SWALLOW_RETURN, joined, lines, violations);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collectPattern(
|
|
126
|
+
pattern: RegExp,
|
|
127
|
+
joined: string,
|
|
128
|
+
lines: readonly AddedLine[],
|
|
129
|
+
violations: RuleViolation[],
|
|
130
|
+
): void {
|
|
131
|
+
const matcher = new RegExp(pattern.source, pattern.flags);
|
|
132
|
+
for (const match of joined.matchAll(matcher)) {
|
|
133
|
+
const offset = match.index ?? 0;
|
|
134
|
+
violations.push(
|
|
135
|
+
violation(
|
|
136
|
+
'explicit-error-handling',
|
|
137
|
+
'[explicit-error-handling] Do not swallow errors; rethrow with context or let them propagate.',
|
|
138
|
+
lineForOffset(joined, lines, offset),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function collectClearNames(
|
|
145
|
+
added: AddedLine,
|
|
146
|
+
violations: RuleViolation[],
|
|
147
|
+
): void {
|
|
148
|
+
const declaration = new RegExp(
|
|
149
|
+
`\\b(?:const|let|var|val|auto|final)\\s+(${BANNED_ALTERNATION})\\b`,
|
|
150
|
+
);
|
|
151
|
+
const assignment = new RegExp(`^\\s*(${BANNED_ALTERNATION})\\s*(?::=|=)`);
|
|
152
|
+
const match = declaration.exec(added.text) ?? assignment.exec(added.text);
|
|
153
|
+
if (match !== null) {
|
|
154
|
+
pushClearName(added.line, match[1], violations);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const pattern of FUNCTION_PARAMS) {
|
|
159
|
+
const params = new RegExp(pattern.source, pattern.flags);
|
|
160
|
+
for (const paramMatch of added.text.matchAll(params)) {
|
|
161
|
+
const banned = new RegExp(`\\b(${BANNED_ALTERNATION})\\b`).exec(
|
|
162
|
+
paramMatch[1] ?? '',
|
|
163
|
+
);
|
|
164
|
+
if (banned !== null) {
|
|
165
|
+
pushClearName(added.line, banned[1], violations);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pushClearName(
|
|
173
|
+
line: number,
|
|
174
|
+
name: string | undefined,
|
|
175
|
+
violations: RuleViolation[],
|
|
176
|
+
): void {
|
|
177
|
+
const label = name ?? 'identifier';
|
|
178
|
+
violations.push(
|
|
179
|
+
violation(
|
|
180
|
+
'clear-names',
|
|
181
|
+
`[clear-names] Rename '${label}' to a name that reveals its role.`,
|
|
182
|
+
line,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function collectComments(added: AddedLine, violations: RuleViolation[]): void {
|
|
188
|
+
if (
|
|
189
|
+
TODO_COMMENT.test(added.text) ||
|
|
190
|
+
COMMENTED_CODE.test(added.text) ||
|
|
191
|
+
CODE_LIKE_COMMENT.test(added.text)
|
|
192
|
+
) {
|
|
193
|
+
violations.push(
|
|
194
|
+
violation(
|
|
195
|
+
'comments',
|
|
196
|
+
'[comments] Remove stale TODO/FIXME or commented-out code; keep only non-obvious intent.',
|
|
197
|
+
added.line,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectDebugArtifacts(
|
|
204
|
+
added: AddedLine,
|
|
205
|
+
violations: RuleViolation[],
|
|
206
|
+
): void {
|
|
207
|
+
if (DEBUG_PATTERNS.some((pattern) => pattern.test(added.text))) {
|
|
208
|
+
violations.push(
|
|
209
|
+
violation(
|
|
210
|
+
'debug-artifacts',
|
|
211
|
+
'[debug-artifacts] Remove debug output before finishing.',
|
|
212
|
+
added.line,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function lineForOffset(
|
|
219
|
+
joined: string,
|
|
220
|
+
lines: readonly AddedLine[],
|
|
221
|
+
offset: number,
|
|
222
|
+
): number {
|
|
223
|
+
let lineIndex = 0;
|
|
224
|
+
for (let index = 0; index < offset; index += 1) {
|
|
225
|
+
if (joined.charCodeAt(index) === 10) {
|
|
226
|
+
lineIndex += 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return lines[lineIndex]?.line ?? lines.at(-1)?.line ?? 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function dedupe(violations: readonly RuleViolation[]): RuleViolation[] {
|
|
233
|
+
const seen = new Set<string>();
|
|
234
|
+
const deduped: RuleViolation[] = [];
|
|
235
|
+
for (const item of violations) {
|
|
236
|
+
const key = `${item.ruleId}:${String(item.line)}:${item.message}`;
|
|
237
|
+
if (seen.has(key)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
seen.add(key);
|
|
241
|
+
deduped.push(item);
|
|
242
|
+
}
|
|
243
|
+
return deduped;
|
|
244
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
isEditToolResult,
|
|
6
|
+
isToolCallEventType,
|
|
7
|
+
isWriteToolResult,
|
|
8
|
+
type ToolCallEvent,
|
|
9
|
+
} from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import { languageForPath, type RuleId, SEVERITY } from './catalog.ts';
|
|
11
|
+
import { evaluateAddedLines } from './checks.ts';
|
|
12
|
+
import {
|
|
13
|
+
type AddedLine,
|
|
14
|
+
addedLines,
|
|
15
|
+
formatBlockReason,
|
|
16
|
+
formatWarnings,
|
|
17
|
+
type RuleViolation,
|
|
18
|
+
} from './model.ts';
|
|
19
|
+
|
|
20
|
+
const BLOCK_DEGRADE_AFTER = 3;
|
|
21
|
+
const CLEAN_RESET_AFTER = 5;
|
|
22
|
+
|
|
23
|
+
interface PendingWarnings {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly violations: readonly RuleViolation[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RulesState {
|
|
29
|
+
readonly fires: Map<RuleId, number>;
|
|
30
|
+
cleanChecks: number;
|
|
31
|
+
readonly pendingWarnings: Map<string, PendingWarnings>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExtractedMutation {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly language: ReturnType<typeof languageForPath>;
|
|
37
|
+
readonly lines: readonly AddedLine[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRulesState(): RulesState {
|
|
41
|
+
return { fires: new Map(), cleanChecks: 0, pendingWarnings: new Map() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resetRulesState(state: RulesState): void {
|
|
45
|
+
state.fires.clear();
|
|
46
|
+
state.cleanChecks = 0;
|
|
47
|
+
state.pendingWarnings.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerRulesGuard(pi: ExtensionAPI, state: RulesState): void {
|
|
51
|
+
pi.on('tool_call', (event, ctx) => {
|
|
52
|
+
const mutation = extractMutation(event, ctx.cwd);
|
|
53
|
+
if (mutation === undefined) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const violations = evaluateAddedLines(mutation.language, mutation.lines);
|
|
57
|
+
return applyRulesOutcome(
|
|
58
|
+
state,
|
|
59
|
+
event.toolCallId,
|
|
60
|
+
mutation.path,
|
|
61
|
+
decideRules(state, violations),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on('tool_result', (event) => {
|
|
66
|
+
if (!isWriteToolResult(event) && !isEditToolResult(event)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const pending = state.pendingWarnings.get(event.toolCallId);
|
|
70
|
+
state.pendingWarnings.delete(event.toolCallId);
|
|
71
|
+
if (pending === undefined || event.isError) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
...event.content,
|
|
77
|
+
{
|
|
78
|
+
type: 'text' as const,
|
|
79
|
+
text: formatWarnings(pending.path, pending.violations),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RulesDecision {
|
|
87
|
+
readonly blocking: readonly RuleViolation[];
|
|
88
|
+
readonly warnings: readonly RuleViolation[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decideRules(
|
|
92
|
+
state: RulesState,
|
|
93
|
+
violations: readonly RuleViolation[],
|
|
94
|
+
): RulesDecision {
|
|
95
|
+
return {
|
|
96
|
+
blocking: violations.filter(
|
|
97
|
+
(item) =>
|
|
98
|
+
item.severity === SEVERITY.block && !isDegraded(state, item.ruleId),
|
|
99
|
+
),
|
|
100
|
+
warnings: violations.filter(
|
|
101
|
+
(item) =>
|
|
102
|
+
item.severity === SEVERITY.warn || isDegraded(state, item.ruleId),
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applyRulesOutcome(
|
|
108
|
+
state: RulesState,
|
|
109
|
+
toolCallId: string,
|
|
110
|
+
path: string,
|
|
111
|
+
decision: RulesDecision,
|
|
112
|
+
): { block: true; reason: string } | undefined {
|
|
113
|
+
if (decision.blocking.length > 0) {
|
|
114
|
+
for (const item of decision.blocking) {
|
|
115
|
+
incrementFires(state, item.ruleId);
|
|
116
|
+
}
|
|
117
|
+
return { block: true, reason: formatBlockReason(path, decision.blocking) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
registerCleanCheck(state);
|
|
121
|
+
if (decision.warnings.length > 0) {
|
|
122
|
+
state.pendingWarnings.set(toolCallId, {
|
|
123
|
+
path,
|
|
124
|
+
violations: decision.warnings,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractMutation(
|
|
131
|
+
event: ToolCallEvent,
|
|
132
|
+
cwd: string,
|
|
133
|
+
): ExtractedMutation | undefined {
|
|
134
|
+
if (isToolCallEventType('write', event)) {
|
|
135
|
+
return extractWrite(event.input.path, event.input.content, cwd);
|
|
136
|
+
}
|
|
137
|
+
if (isToolCallEventType('edit', event)) {
|
|
138
|
+
return extractEdit(event.input.path, event.input.edits);
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function extractWrite(
|
|
144
|
+
path: string,
|
|
145
|
+
content: string,
|
|
146
|
+
cwd: string,
|
|
147
|
+
): ExtractedMutation {
|
|
148
|
+
const previous = readFileIfExists(resolve(cwd, path));
|
|
149
|
+
return {
|
|
150
|
+
path,
|
|
151
|
+
language: languageForPath(path),
|
|
152
|
+
lines: addedLines(previous, content),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractEdit(
|
|
157
|
+
path: string,
|
|
158
|
+
edits: readonly { readonly oldText: string; readonly newText: string }[],
|
|
159
|
+
): ExtractedMutation {
|
|
160
|
+
const lines: AddedLine[] = [];
|
|
161
|
+
for (const edit of edits) {
|
|
162
|
+
lines.push(...addedLines(edit.oldText, edit.newText));
|
|
163
|
+
}
|
|
164
|
+
return { path, language: languageForPath(path), lines };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readFileIfExists(path: string): string | undefined {
|
|
168
|
+
try {
|
|
169
|
+
return readFileSync(path, 'utf8');
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (isMissingFile(error)) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isMissingFile(error: unknown): boolean {
|
|
179
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isDegraded(state: RulesState, ruleId: RuleId): boolean {
|
|
183
|
+
return (state.fires.get(ruleId) ?? 0) >= BLOCK_DEGRADE_AFTER;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function incrementFires(state: RulesState, ruleId: RuleId): void {
|
|
187
|
+
state.fires.set(ruleId, (state.fires.get(ruleId) ?? 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function registerCleanCheck(state: RulesState): void {
|
|
191
|
+
state.cleanChecks += 1;
|
|
192
|
+
if (state.cleanChecks < CLEAN_RESET_AFTER) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
state.cleanChecks = 0;
|
|
196
|
+
state.fires.clear();
|
|
197
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
createRulesState,
|
|
4
|
+
registerRulesGuard,
|
|
5
|
+
resetRulesState,
|
|
6
|
+
} from './guard.ts';
|
|
7
|
+
|
|
8
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
9
|
+
export default function registerRules(pi: ExtensionAPI): void {
|
|
10
|
+
const state = createRulesState();
|
|
11
|
+
registerRulesGuard(pi, state);
|
|
12
|
+
|
|
13
|
+
pi.on('input', (event) => {
|
|
14
|
+
if (event.source === 'interactive' || event.source === 'rpc') {
|
|
15
|
+
resetRulesState(state);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
pi.on('session_compact', () => {
|
|
20
|
+
resetRulesState(state);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { RuleId, RuleSeverity } from './catalog.ts';
|
|
2
|
+
|
|
3
|
+
export interface AddedLine {
|
|
4
|
+
readonly line: number;
|
|
5
|
+
readonly text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RuleViolation {
|
|
9
|
+
readonly ruleId: RuleId;
|
|
10
|
+
readonly severity: RuleSeverity;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly line: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function addedLines(
|
|
16
|
+
previous: string | undefined,
|
|
17
|
+
next: string,
|
|
18
|
+
): AddedLine[] {
|
|
19
|
+
const nextLines = next.split('\n');
|
|
20
|
+
const added: AddedLine[] = [];
|
|
21
|
+
const remaining = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
if (previous !== undefined) {
|
|
24
|
+
for (const text of previous.split('\n')) {
|
|
25
|
+
const key = text.trim();
|
|
26
|
+
if (key.length === 0) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
remaining.set(key, (remaining.get(key) ?? 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (let index = 0; index < nextLines.length; index += 1) {
|
|
34
|
+
const text = nextLines[index] ?? '';
|
|
35
|
+
const key = text.trim();
|
|
36
|
+
if (key.length === 0) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const count = remaining.get(key) ?? 0;
|
|
40
|
+
if (count > 0) {
|
|
41
|
+
remaining.set(key, count - 1);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
added.push({ line: index + 1, text });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return added;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatBlockReason(
|
|
51
|
+
path: string,
|
|
52
|
+
violations: readonly RuleViolation[],
|
|
53
|
+
): string {
|
|
54
|
+
return [
|
|
55
|
+
`Blocked ${path}: sideroom rules violated.`,
|
|
56
|
+
...violations.map((item) => `- ${item.message}`),
|
|
57
|
+
'Fix the flagged lines and retry the mutation.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatWarnings(
|
|
62
|
+
path: string,
|
|
63
|
+
violations: readonly RuleViolation[],
|
|
64
|
+
): string {
|
|
65
|
+
return [
|
|
66
|
+
`Sideroom rules flagged ${path}:`,
|
|
67
|
+
...violations.map((item) => `- ${item.message}`),
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmrdeveloper/sideroom-pi",
|
|
3
|
-
"version": "8.0
|
|
4
|
-
"description": "A global Pi package with a questionnaire, work board, edited-file view,
|
|
3
|
+
"version": "8.1.0",
|
|
4
|
+
"description": "A global Pi package with a questionnaire, work board, edited-file view, coding guidelines, rule enforcement, and a finish gate for parent agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"extensions",
|