@rmrdeveloper/sideroom-pi 8.5.0 → 8.7.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 +1 -1
- package/extensions/ask/index.ts +2 -2
- 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/rules/catalog.ts +9 -1
- package/extensions/rules/checks.ts +155 -0
- package/extensions/rules/lexer.ts +32 -11
- package/extensions/todo/index.ts +1 -1
- package/extensions/todo/session.ts +57 -8
- package/package.json +4 -2
- package/skills/sideroom-architecture/SKILL.md +104 -0
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
|
|
package/extensions/ask/index.ts
CHANGED
|
@@ -15,8 +15,8 @@ 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
|
-
'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it
|
|
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
|
|
18
|
+
'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it 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: never assume it; follow the sideroom-architecture skill and expose it through sideroom_ask before committing, with the practical consequences of each alternative and one recommendation carrying a one-line justification, never technology names. If the repository shows two inconclusive patterns for the same decision, name the inconsistency in the prompt. Everything else is a minor decision: take it yourself.',
|
|
20
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
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
22
|
'sideroom_ask only works in the interactive TUI; it errors in print, JSON, and RPC modes.',
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/extensions/todo/index.ts
CHANGED
|
@@ -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.7.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"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sideroom-architecture
|
|
3
|
+
description: "Surface the architectural decisions a change forces so they are asked, not silently assumed. Use when a feature or change touches module boundaries, data ownership, integration contracts, failure behavior, quality attributes, operations, cost, lock-in, or reversibility, or when the user says the architecture is not settled. Do not use for a trivial or reversible change, a pure glossary lookup, or an already-closed spec."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: RMRdeveloper
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Sideroom architecture
|
|
11
|
+
|
|
12
|
+
## Purpose
|
|
13
|
+
|
|
14
|
+
An LLM's default failure is to assume the architecture and start typing. This
|
|
15
|
+
skill forces the opposite: name the architectural decisions a change forces and
|
|
16
|
+
ask the ones that matter, instead of picking them silently. It does not turn
|
|
17
|
+
every change into an interrogation.
|
|
18
|
+
|
|
19
|
+
## Activation Contract
|
|
20
|
+
|
|
21
|
+
Load this skill when a change is about to commit an architectural decision:
|
|
22
|
+
something that materially affects module boundaries, data ownership,
|
|
23
|
+
integration contracts, failure behavior, quality attributes (latency,
|
|
24
|
+
throughput, availability), operations, cost, lock-in, or the difficulty of
|
|
25
|
+
changing course later. It runs under `sideroom-grill` for the architectural
|
|
26
|
+
part of an interview, and on its own when only the architecture is open.
|
|
27
|
+
|
|
28
|
+
Do not load it for a minor decision (reversible, no boundary or contract
|
|
29
|
+
impact), or for a word conflict — that is `sideroom-domain-modeling`.
|
|
30
|
+
|
|
31
|
+
## Hard Rules
|
|
32
|
+
|
|
33
|
+
- Assume nothing architectural. When a change forces an architectural decision
|
|
34
|
+
the code does not already settle, expose it through `sideroom_ask` before
|
|
35
|
+
writing the code. Silent selection of an architectural default is the failure
|
|
36
|
+
this skill exists to prevent.
|
|
37
|
+
- Pick the dimensions this decision actually touches; do not march the whole
|
|
38
|
+
catalog. Two sharp questions beat nine shallow ones. A dimension the change
|
|
39
|
+
does not touch is not asked.
|
|
40
|
+
- Read the code first. A decision the repository already settles consistently
|
|
41
|
+
is a project fact: reuse it, never ask it. When the repository shows two
|
|
42
|
+
inconclusive patterns for the same kind of decision, name that inconsistency
|
|
43
|
+
in the prompt instead of picking one.
|
|
44
|
+
- Quantify or decline. An architectural dimension does not close without a
|
|
45
|
+
number, a range, or an explicit `Out of scope` from the user. A decision
|
|
46
|
+
without a figure is a preference in disguise.
|
|
47
|
+
- Every option of an architectural question carries a `description`: its
|
|
48
|
+
practical consequence, its cost, and the obligation it imposes. The label
|
|
49
|
+
names the choice; the description carries the trade-off. Never a one-word
|
|
50
|
+
technology label.
|
|
51
|
+
- Present the decision as the current situation, the alternatives by their
|
|
52
|
+
practical consequences, and one recommendation with a one-line justification.
|
|
53
|
+
Never reduce it to technology names.
|
|
54
|
+
- One open architectural dimension blocks implementation. `Out of scope` closes
|
|
55
|
+
a thread, is recorded, and is not re-asked.
|
|
56
|
+
|
|
57
|
+
## Dimension catalog
|
|
58
|
+
|
|
59
|
+
Reference, not a checklist. Pick only what the change touches; force a number or
|
|
60
|
+
an explicit `Out of scope` on each one you pick.
|
|
61
|
+
|
|
62
|
+
| Dimension | The question it forces |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| Boundaries | Which modules are in, which are out, which dependency direction is forbidden |
|
|
65
|
+
| Data | What is the source of truth, who owns it, how the schema evolves and migrates |
|
|
66
|
+
| Integration | Synchronous or asynchronous, backward compatibility, version window |
|
|
67
|
+
| Quality attributes | Expected latency and volume, availability target — in figures |
|
|
68
|
+
| Failure | What degrades, what retries, what must be idempotent |
|
|
69
|
+
| Operations | How it deploys, how it rolls back, which signal warns it is going wrong |
|
|
70
|
+
| Cost and lock-in | Operating cost, the cost of leaving the provider or dependency |
|
|
71
|
+
| Ownership | Who maintains it, who responds when it breaks |
|
|
72
|
+
| Reversibility | What reopens the decision, and how expensive undoing it is |
|
|
73
|
+
|
|
74
|
+
## Execution Steps
|
|
75
|
+
|
|
76
|
+
1. Read the code around the change. Settle every project fact from the
|
|
77
|
+
codebase, and note any inconsistent pattern to name in a prompt.
|
|
78
|
+
2. List the architectural decisions the change forces. Drop the ones the code
|
|
79
|
+
already settles.
|
|
80
|
+
3. For each remaining decision, pick the dimensions it touches and run one
|
|
81
|
+
`sideroom_ask` round: situation, alternatives by consequence, one
|
|
82
|
+
recommendation, each option with a `description`. Force a number or an
|
|
83
|
+
explicit `Out of scope`.
|
|
84
|
+
4. Repeat rounds until no architectural dimension is open. Do not implement
|
|
85
|
+
with one open.
|
|
86
|
+
5. Record an accepted decision as an ADR under `docs/adr/` only when it clears
|
|
87
|
+
the three `sideroom-domain-modeling` gates: hard to reverse, surprising
|
|
88
|
+
without context, the result of a real trade-off. The ADR names the rejected
|
|
89
|
+
alternatives and what reopens the decision.
|
|
90
|
+
|
|
91
|
+
## Output Contract
|
|
92
|
+
|
|
93
|
+
Before the first round: a one-line statement of what is built, what it touches,
|
|
94
|
+
what is out. After the rounds: the architectural decisions closed with their
|
|
95
|
+
figures, the dimensions marked out of scope, and any ADR written with its
|
|
96
|
+
rejected alternatives and its revision trigger. No architectural default
|
|
97
|
+
reaches the code without having been asked or read from the repository.
|
|
98
|
+
|
|
99
|
+
## References
|
|
100
|
+
|
|
101
|
+
- Driven by `sideroom-grill` for the architectural part of an interview; that
|
|
102
|
+
skill owns the interview, this one owns the architectural interrogation.
|
|
103
|
+
- Hands qualifying decisions to `sideroom-domain-modeling` for the ADR
|
|
104
|
+
three-gate offer and records shared vocabulary through it.
|