@rmrdeveloper/sideroom-pi 8.11.0 → 8.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +49 -5
  2. package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
  3. package/assets/visual-context/browser/INSTALL.md +69 -0
  4. package/assets/visual-context/browser/background.js +578 -0
  5. package/assets/visual-context/browser/content.js +198 -0
  6. package/assets/visual-context/browser/icons/README.md +9 -0
  7. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  10. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  11. package/assets/visual-context/browser/manifest.json +20 -0
  12. package/assets/visual-context/browser/popup.css +19 -0
  13. package/assets/visual-context/browser/popup.html +27 -0
  14. package/assets/visual-context/browser/popup.js +141 -0
  15. package/extensions/done/guard.ts +42 -37
  16. package/extensions/done/index.ts +3 -2
  17. package/extensions/done/model.ts +1 -5
  18. package/extensions/explain/guard.ts +13 -11
  19. package/extensions/explain/model.ts +2 -25
  20. package/extensions/guidelines/review.ts +31 -13
  21. package/extensions/jev/client.ts +22 -1
  22. package/extensions/jev/context.ts +36 -3
  23. package/extensions/jev/guard.ts +113 -34
  24. package/extensions/jev/model.ts +90 -12
  25. package/extensions/jev/workflow.ts +188 -0
  26. package/extensions/persona/execute.ts +4 -6
  27. package/extensions/persona/index.ts +3 -2
  28. package/extensions/persona/model.ts +1 -31
  29. package/extensions/persona/prompt.ts +3 -1
  30. package/extensions/shared/settle.ts +63 -13
  31. package/extensions/visual-context/bridge.ts +240 -0
  32. package/extensions/visual-context/broker.ts +292 -0
  33. package/extensions/visual-context/envelope.ts +34 -0
  34. package/extensions/visual-context/index.ts +328 -0
  35. package/extensions/visual-context/literal.ts +34 -0
  36. package/extensions/visual-context/markup.ts +104 -0
  37. package/extensions/visual-context/match.ts +222 -0
  38. package/extensions/visual-context/model.ts +180 -0
  39. package/extensions/visual-context/pairing.ts +114 -0
  40. package/extensions/visual-context/parse-astro.ts +166 -0
  41. package/extensions/visual-context/parse-html.ts +72 -0
  42. package/extensions/visual-context/parse-jsx.ts +157 -0
  43. package/extensions/visual-context/parse-svelte.ts +128 -0
  44. package/extensions/visual-context/parse-vue.ts +133 -0
  45. package/extensions/visual-context/session.ts +192 -0
  46. package/extensions/visual-context/source.ts +197 -0
  47. package/extensions/visual-context/ui.ts +57 -0
  48. package/package.json +16 -7
  49. package/skills/sideroom-architecture/SKILL.md +5 -3
  50. package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
  51. package/skills/sideroom-codebase-design/SKILL.md +151 -0
  52. package/skills/sideroom-codebase-design/references/growth.md +62 -0
  53. package/skills/sideroom-domain-modeling/SKILL.md +1 -1
  54. package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
  55. package/skills/sideroom-grill/SKILL.md +6 -4
  56. package/skills/sideroom-guidelines/SKILL.md +9 -7
  57. package/skills/sideroom-guidelines/references/languages/go.md +3 -3
  58. package/skills/sideroom-guidelines/references/languages/java.md +3 -3
  59. package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
  60. package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
  61. package/skills/sideroom-guidelines/references/languages/python.md +3 -3
  62. package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
  63. package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
  64. package/skills/sideroom-persona/SKILL.md +15 -4
@@ -0,0 +1,198 @@
1
+ (() => {
2
+ const INSPECTOR_KEY = '__sideroomVisualInspector';
3
+ if (globalThis[INSPECTOR_KEY]) {
4
+ return;
5
+ }
6
+ const MAX_TEXT = 160;
7
+ const MAX_CLASSES = 32;
8
+ const MAX_ANCESTORS = 4;
9
+ const BORDER = '#ffb340';
10
+ let active = false;
11
+ let root;
12
+ let box;
13
+ let caption;
14
+ let exitHint = 'Esc to exit';
15
+
16
+ function clean(text) {
17
+ return text
18
+ .split('')
19
+ .map((character) =>
20
+ character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127
21
+ ? ' '
22
+ : character,
23
+ )
24
+ .join('')
25
+ .replace(/\s+/gu, ' ')
26
+ .trim()
27
+ .slice(0, MAX_TEXT);
28
+ }
29
+
30
+ function describe(element) {
31
+ return {
32
+ tag: element.localName,
33
+ elementId: clean(element.id ?? ''),
34
+ classes: [...element.classList].slice(0, MAX_CLASSES).map(clean),
35
+ };
36
+ }
37
+
38
+ function visibleText(element) {
39
+ if (element.closest('input,textarea,select,[contenteditable]')) {
40
+ return '';
41
+ }
42
+ const clone = element.cloneNode(true);
43
+ for (const child of clone.querySelectorAll(
44
+ 'script,style,input,textarea,select,[contenteditable],[hidden]',
45
+ )) {
46
+ child.remove();
47
+ }
48
+ return clean(clone.textContent ?? '');
49
+ }
50
+
51
+ function selectedElement(event) {
52
+ return event
53
+ .composedPath()
54
+ .find((node) => node instanceof Element && node !== root);
55
+ }
56
+
57
+ function highlight(event) {
58
+ const element = selectedElement(event);
59
+ if (!active || !element || element === document.documentElement) {
60
+ return;
61
+ }
62
+ const bounds = element.getBoundingClientRect();
63
+ Object.assign(box.style, {
64
+ left: `${bounds.left}px`,
65
+ top: `${bounds.top}px`,
66
+ width: `${bounds.width}px`,
67
+ height: `${bounds.height}px`,
68
+ });
69
+ caption.textContent = `${element.localName} · click to attach · ${exitHint}`;
70
+ }
71
+
72
+ function intercept(event) {
73
+ if (active) {
74
+ event.preventDefault();
75
+ event.stopImmediatePropagation();
76
+ }
77
+ }
78
+
79
+ async function select(event) {
80
+ if (!active || !event.isTrusted) {
81
+ return;
82
+ }
83
+ intercept(event);
84
+ const element = selectedElement(event);
85
+ if (!element || element === root) {
86
+ return;
87
+ }
88
+ const ancestors = [];
89
+ let parent = element.parentElement;
90
+ while (parent && ancestors.length < MAX_ANCESTORS) {
91
+ ancestors.push(describe(parent));
92
+ parent = parent.parentElement;
93
+ }
94
+ const selection = {
95
+ id: crypto.randomUUID(),
96
+ page: `${location.origin}${location.pathname}`,
97
+ element: describe(element),
98
+ text: visibleText(element),
99
+ ancestors,
100
+ };
101
+ caption.textContent = 'Sending selection to Pi…';
102
+ try {
103
+ const response = await chrome.runtime.sendMessage({
104
+ action: 'selection',
105
+ selection,
106
+ });
107
+ if (!response.ok) {
108
+ throw new Error(response.message);
109
+ }
110
+ } catch (cause) {
111
+ if (caption) {
112
+ caption.textContent = cause.message;
113
+ }
114
+ }
115
+ }
116
+
117
+ function keydown(event) {
118
+ if (event.key === 'Escape') {
119
+ intercept(event);
120
+ stop();
121
+ chrome.runtime.sendMessage({ action: 'stopped' }).catch((cause) => {
122
+ console.error('Sideroom inspector could not report its exit:', cause);
123
+ });
124
+ }
125
+ }
126
+
127
+ function start(shortcut) {
128
+ stop();
129
+ exitHint = shortcut ? `Esc or ${shortcut} to exit` : 'Esc to exit';
130
+ active = true;
131
+ root = document.createElement('div');
132
+ root.setAttribute('data-sideroom-inspector', '');
133
+ Object.assign(root.style, {
134
+ position: 'fixed',
135
+ inset: '0',
136
+ zIndex: '2147483647',
137
+ pointerEvents: 'none',
138
+ });
139
+ const shadow = root.attachShadow({ mode: 'closed' });
140
+ box = document.createElement('div');
141
+ Object.assign(box.style, {
142
+ position: 'fixed',
143
+ boxSizing: 'border-box',
144
+ border: `2px solid ${BORDER}`,
145
+ background: 'rgba(255,179,64,.08)',
146
+ });
147
+ caption = document.createElement('div');
148
+ caption.setAttribute('role', 'status');
149
+ caption.setAttribute('aria-live', 'polite');
150
+ caption.textContent = `Select an element · ${exitHint}`;
151
+ Object.assign(caption.style, {
152
+ position: 'fixed',
153
+ bottom: '16px',
154
+ left: '16px',
155
+ maxWidth: 'calc(100vw - 32px)',
156
+ padding: '10px 14px',
157
+ background: '#131211',
158
+ color: '#f2ede3',
159
+ border: `1px solid ${BORDER}`,
160
+ font: '13px ui-monospace, monospace',
161
+ });
162
+ shadow.append(box, caption);
163
+ document.documentElement.append(root);
164
+ window.addEventListener('pointermove', highlight, true);
165
+ window.addEventListener('pointerdown', intercept, true);
166
+ window.addEventListener('mousedown', intercept, true);
167
+ window.addEventListener('click', select, true);
168
+ window.addEventListener('keydown', keydown, true);
169
+ window.addEventListener('pagehide', stop);
170
+ }
171
+
172
+ function stop() {
173
+ active = false;
174
+ root?.remove();
175
+ root = undefined;
176
+ box = undefined;
177
+ caption = undefined;
178
+ window.removeEventListener('pointermove', highlight, true);
179
+ window.removeEventListener('pointerdown', intercept, true);
180
+ window.removeEventListener('mousedown', intercept, true);
181
+ window.removeEventListener('click', select, true);
182
+ window.removeEventListener('keydown', keydown, true);
183
+ window.removeEventListener('pagehide', stop);
184
+ }
185
+
186
+ chrome.runtime.onMessage.addListener((message) => {
187
+ if (message.action === 'start') {
188
+ start(message.shortcut);
189
+ }
190
+ if (message.action === 'stop') {
191
+ stop();
192
+ }
193
+ if (message.action === 'receipt' && caption) {
194
+ caption.textContent = message.message;
195
+ }
196
+ });
197
+ globalThis[INSPECTOR_KEY] = { stop };
198
+ })();
@@ -0,0 +1,9 @@
1
+ # Browser icon
2
+
3
+ White `S` on opaque black, rendered with **Archivo Variable**, the text family
4
+ in Sideroom's `DESIGN.md` and `site/src/styles/site.css`. Weight: 700. Source:
5
+ `site/node_modules/@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2`.
6
+
7
+ The PNGs are 16, 32, 48 and 128 pixels. Each canvas uses `#000000`, a `#ffffff`
8
+ glyph at 86% of canvas size, and centers the glyph's actual ink bounds. These
9
+ are rendered font glyphs; the browser does not need the font installed.
@@ -0,0 +1,20 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Sideroom Visual Context",
4
+ "version": "1.1.0",
5
+ "description": "Select a page element and attach local source candidates to your Pi session.",
6
+ "minimum_chrome_version": "116",
7
+ "permissions": ["activeTab", "scripting", "storage", "alarms"],
8
+ "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" },
9
+ "background": { "service_worker": "background.js" },
10
+ "commands": {
11
+ "toggle-inspector": {
12
+ "suggested_key": "Alt+Shift+S",
13
+ "description": "Start or stop selecting elements for Pi"
14
+ }
15
+ },
16
+ "action": { "default_popup": "popup.html", "default_title": "Select an element for Pi", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png" } },
17
+ "content_security_policy": {
18
+ "extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* ws://localhost:*"
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ :root { color-scheme: dark; font-family: "Helvetica Neue", Arial, sans-serif; color: #f2ede3; background: #131211; }
2
+ * { box-sizing: border-box; }
3
+ body { width: 360px; margin: 0; padding: 24px; }
4
+ header { border-top: 2px solid #f2ede3; padding-top: 16px; }
5
+ header img { display: block; }
6
+ details { margin: 16px 0; }
7
+ summary { cursor: pointer; color: #a49d8f; font-size: 13px; }
8
+ h1 { font-size: 30px; line-height: 1.05; letter-spacing: -.035em; margin: 14px 0 18px; }
9
+ .intro, footer { color: #a49d8f; font-size: 13px; line-height: 1.6; }
10
+ label { display: block; margin: 22px 0 8px; font: 11px ui-monospace, monospace; }
11
+ input, select { width: 100%; background: #0b0a09; border: 1px solid #514b42; border-radius: 0; padding: 11px; color: #f2ede3; font: 12px ui-monospace, monospace; }
12
+ button { display: block; width: 100%; border: 1px solid #f2ede3; border-radius: 0; padding: 12px; margin-top: 10px; background: #f2ede3; color: #131211; font: 600 13px "Helvetica Neue", Arial, sans-serif; cursor: pointer; }
13
+ button:hover { background: #fff; }
14
+ button:disabled { opacity: .4; cursor: default; }
15
+ .secondary { background: transparent; border-color: #514b42; color: #a49d8f; }
16
+ .secondary:hover { background: #1b1917; }
17
+ :focus-visible { outline: 2px solid #ffb340; outline-offset: 3px; }
18
+ #status { min-height: 40px; margin: 18px 0; font: 12px/1.6 ui-monospace, monospace; color: #ffb340; overflow-wrap: anywhere; }
19
+ footer { border-top: 1px solid #514b42; margin-top: 22px; padding-top: 14px; font-size: 11px; }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Sideroom — Browser context</title>
7
+ <link rel="stylesheet" href="popup.css" />
8
+ <script src="popup.js" defer></script>
9
+ </head>
10
+ <body>
11
+ <header><img src="icons/icon-48.png" width="32" height="32" alt="Sideroom" /><h1>Point to the code.</h1></header>
12
+ <p class="intro">Select an element in this tab. Its source candidates appear in your Pi session.</p>
13
+ <details id="pairing-details" open><summary>Authorize Windows or WSL</summary><form id="pairing-form">
14
+ <label for="address">Pair once · address from Pi (F7)</label>
15
+ <input id="address" type="text" autocomplete="off" spellcheck="false" placeholder="ws://127.0.0.1:…" required />
16
+ <button id="connect" type="submit">Connect to Pi</button>
17
+ </form></details>
18
+ <p id="status" role="status" aria-live="polite">Checking connection…</p>
19
+ <label for="destination">Send selections to</label>
20
+ <select id="destination" disabled><option value="">Waiting for Pi…</option></select>
21
+ <button id="inspect" type="button" disabled>Select an element</button>
22
+ <p id="shortcut-hint">Escape exits selection. Change the shortcut in your browser's shortcuts page.</p>
23
+ <button id="disconnect" class="secondary" type="button" disabled>Disconnect</button>
24
+ <button id="forget" class="secondary" type="button" disabled>Forget authorizations</button>
25
+ <footer>Click attaches context. Send your instruction in Pi to begin.</footer>
26
+ </body>
27
+ </html>
@@ -0,0 +1,141 @@
1
+ const form = document.querySelector('#pairing-form');
2
+ const address = document.querySelector('#address');
3
+ const status = document.querySelector('#status');
4
+ const inspect = document.querySelector('#inspect');
5
+ const disconnect = document.querySelector('#disconnect');
6
+ const forget = document.querySelector('#forget');
7
+ const destination = document.querySelector('#destination');
8
+ const connect = document.querySelector('#connect');
9
+ // Edge keeps the Chromium shortcut page under its own scheme; every other
10
+ // Chromium browser the companion supports answers on chrome://.
11
+ const SHORTCUT_PAGE = navigator.userAgent.includes('Edg/')
12
+ ? 'edge://extensions/shortcuts'
13
+ : 'chrome://extensions/shortcuts';
14
+ let busy = false;
15
+ let pairingShown = false;
16
+
17
+ function showStatus(receipt) {
18
+ status.textContent = receipt.notice ?? receipt.message;
19
+ if (receipt.connected !== undefined) {
20
+ inspect.disabled = !receipt.connected || !receipt.selectedId;
21
+ disconnect.disabled = !receipt.connected;
22
+ }
23
+ if (receipt.address !== undefined) {
24
+ if (!pairingShown) {
25
+ document.querySelector('#pairing-details').open = !receipt.address;
26
+ pairingShown = true;
27
+ }
28
+ if (document.activeElement !== address) {
29
+ address.value = receipt.address;
30
+ }
31
+ forget.disabled = !receipt.address;
32
+ }
33
+ if (receipt.sessions) {
34
+ destination.replaceChildren();
35
+ const placeholder = document.createElement('option');
36
+ placeholder.value = '';
37
+ placeholder.textContent = receipt.sessions.length
38
+ ? 'Choose a Pi session…'
39
+ : 'Waiting for Pi…';
40
+ destination.append(placeholder);
41
+ for (const session of receipt.sessions) {
42
+ const option = document.createElement('option');
43
+ option.value = session.id;
44
+ option.textContent = `${session.environment} · ${session.name} · ${session.project}`;
45
+ destination.append(option);
46
+ }
47
+ destination.value = receipt.selectedId;
48
+ destination.disabled = !receipt.connected || !receipt.sessions.length;
49
+ }
50
+ }
51
+
52
+ async function request(message) {
53
+ const receipt = await chrome.runtime.sendMessage(message);
54
+ if (!receipt.ok) {
55
+ throw new Error(receipt.message);
56
+ }
57
+ return receipt;
58
+ }
59
+
60
+ form.addEventListener('submit', async (event) => {
61
+ event.preventDefault();
62
+ busy = true;
63
+ connect.disabled = true;
64
+ status.textContent = 'Connecting…';
65
+ try {
66
+ showStatus(await request({ action: 'connect', address: address.value }));
67
+ } catch (cause) {
68
+ showStatus({ message: cause.message, connected: false });
69
+ } finally {
70
+ busy = false;
71
+ connect.disabled = false;
72
+ }
73
+ });
74
+
75
+ destination.addEventListener('change', async () => {
76
+ busy = true;
77
+ try {
78
+ showStatus(await request({ action: 'choose', id: destination.value }));
79
+ } catch (cause) {
80
+ showStatus({ message: cause.message });
81
+ } finally {
82
+ busy = false;
83
+ }
84
+ });
85
+
86
+ inspect.addEventListener('click', async () => {
87
+ try {
88
+ await request({ action: 'inspect' });
89
+ window.close();
90
+ } catch (cause) {
91
+ showStatus({ message: cause.message });
92
+ }
93
+ });
94
+
95
+ for (const button of [disconnect, forget]) {
96
+ button.addEventListener('click', async () => {
97
+ busy = true;
98
+ try {
99
+ showStatus(
100
+ await request({ action: button === forget ? 'forget' : 'disconnect' }),
101
+ );
102
+ if (button === forget) {
103
+ address.value = '';
104
+ document.querySelector('#pairing-details').open = true;
105
+ }
106
+ } catch (cause) {
107
+ showStatus({ message: cause.message });
108
+ } finally {
109
+ busy = false;
110
+ }
111
+ });
112
+ }
113
+
114
+ async function refresh() {
115
+ if (busy) {
116
+ return;
117
+ }
118
+ try {
119
+ showStatus(await request({ action: 'status' }));
120
+ } catch (cause) {
121
+ showStatus({ message: cause.message, connected: false });
122
+ }
123
+ }
124
+ async function showShortcut() {
125
+ const hint = document.querySelector('#shortcut-hint');
126
+ try {
127
+ const commands = await chrome.commands.getAll();
128
+ const shortcut = commands.find(
129
+ (command) => command.name === 'toggle-inspector',
130
+ )?.shortcut;
131
+ hint.textContent = shortcut
132
+ ? `${shortcut} starts or stops selection. Escape also exits. Change it in ${SHORTCUT_PAGE}.`
133
+ : `No selection shortcut is assigned. Set one in ${SHORTCUT_PAGE}. Escape exits selection.`;
134
+ } catch (cause) {
135
+ hint.textContent = `Could not read the shortcut: ${cause.message}. Check ${SHORTCUT_PAGE}.`;
136
+ }
137
+ }
138
+ void showShortcut();
139
+ void refresh();
140
+ const poll = setInterval(refresh, 1_000);
141
+ window.addEventListener('pagehide', () => clearInterval(poll));
@@ -6,6 +6,11 @@ import {
6
6
  isToolCallEventType,
7
7
  isWriteToolResult,
8
8
  } from '@earendil-works/pi-coding-agent';
9
+ import {
10
+ registerSettleGuard,
11
+ SETTLE_PRIORITY,
12
+ withHiddenSteer,
13
+ } from '../shared/settle.ts';
9
14
  import { type CheckCommand, commandMatches } from './detect.ts';
10
15
  import {
11
16
  DONE_GATE_TYPE,
@@ -25,19 +30,22 @@ export type ResolveCommand = (
25
30
 
26
31
  export type ResolveTestSetup = (ctx: Pick<ExtensionContext, 'cwd'>) => boolean;
27
32
 
28
- export function registerDoneGuard(
29
- pi: ExtensionAPI,
30
- state: DoneState,
31
- resolveCommand: ResolveCommand,
32
- resolveTestSetup: ResolveTestSetup,
33
- ): void {
33
+ interface DoneGuardOptions {
34
+ readonly pi: ExtensionAPI;
35
+ readonly state: DoneState;
36
+ readonly resolveCommand: ResolveCommand;
37
+ readonly resolveTestSetup: ResolveTestSetup;
38
+ }
39
+
40
+ export function registerDoneGuard({
41
+ pi,
42
+ state,
43
+ resolveCommand,
44
+ resolveTestSetup,
45
+ }: DoneGuardOptions): void {
34
46
  const checkRuns = new Set<string>();
35
47
  const mutationPaths = new Map<string, string>();
36
48
 
37
- pi.on('turn_start', () => {
38
- state.steeredThisTurn = false;
39
- });
40
-
41
49
  pi.on('tool_call', (event, ctx) => {
42
50
  if (
43
51
  isToolCallEventType('write', event) ||
@@ -72,41 +80,38 @@ export function registerDoneGuard(
72
80
  }
73
81
  if (isBashToolResult(event)) {
74
82
  const wasCheck = checkRuns.delete(event.toolCallId);
75
- if (wasCheck && !event.isError) {
76
- state.checksGreen = true;
83
+ if (wasCheck) {
84
+ state.checksGreen = !event.isError;
77
85
  }
78
86
  }
79
87
  });
80
88
 
81
- pi.on('turn_end', (_event, ctx) => {
82
- const check = resolveCommand(ctx);
83
- if (check !== undefined && shouldSteerDone(state, true)) {
84
- state.steeredThisTurn = true;
85
- state.steerCount += 1;
86
- pi.sendMessage(
87
- {
89
+ registerSettleGuard(pi, {
90
+ priority: SETTLE_PRIORITY.done,
91
+ pending: (ctx) =>
92
+ shouldSteerDone(state, resolveCommand(ctx) !== undefined) ||
93
+ shouldNoticeMissingTest(state, resolveTestSetup(ctx)),
94
+ handle: (event, ctx) => {
95
+ const check = resolveCommand(ctx);
96
+ if (check !== undefined && shouldSteerDone(state, true)) {
97
+ state.steerCount += 1;
98
+ return withHiddenSteer({
99
+ event,
88
100
  customType: DONE_GATE_TYPE,
89
101
  content: formatDoneSteer(check.display),
90
- display: false,
91
- },
92
- { triggerTurn: true, deliverAs: 'steer' },
93
- );
94
- return;
95
- }
96
-
97
- if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
98
- return;
99
- }
100
- state.noticedMissingTest = true;
101
- state.steerCount += 1;
102
- pi.sendMessage(
103
- {
102
+ });
103
+ }
104
+ if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
105
+ return;
106
+ }
107
+ state.noticedMissingTest = true;
108
+ state.steerCount += 1;
109
+ return withHiddenSteer({
110
+ event,
104
111
  customType: MISSING_TEST_NOTICE_TYPE,
105
112
  content: formatMissingTestNotice(),
106
- display: false,
107
- },
108
- { triggerTurn: true, deliverAs: 'steer' },
109
- );
113
+ });
114
+ },
110
115
  });
111
116
  }
112
117
 
@@ -2,6 +2,7 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from '@earendil-works/pi-coding-agent';
5
+ import { startsUserTurn } from '../shared/settle.ts';
5
6
  import {
6
7
  type CheckCommand,
7
8
  detectCheckCommand,
@@ -41,10 +42,10 @@ export default function registerDone(pi: ExtensionAPI): void {
41
42
  return hasTests;
42
43
  };
43
44
 
44
- registerDoneGuard(pi, state, resolveCommand, resolveTestSetup);
45
+ registerDoneGuard({ pi, state, resolveCommand, resolveTestSetup });
45
46
 
46
47
  pi.on('input', (event) => {
47
- if (event.source === 'interactive' || event.source === 'rpc') {
48
+ if (startsUserTurn(event)) {
48
49
  resetDoneRun(state);
49
50
  }
50
51
  });
@@ -1,7 +1,6 @@
1
1
  export interface DoneState {
2
2
  mutated: boolean;
3
3
  checksGreen: boolean;
4
- steeredThisTurn: boolean;
5
4
  steerCount: number;
6
5
  mutatedSource: boolean;
7
6
  mutatedTest: boolean;
@@ -27,7 +26,6 @@ export function createDoneState(): DoneState {
27
26
  return {
28
27
  mutated: false,
29
28
  checksGreen: false,
30
- steeredThisTurn: false,
31
29
  steerCount: 0,
32
30
  mutatedSource: false,
33
31
  mutatedTest: false,
@@ -38,7 +36,6 @@ export function createDoneState(): DoneState {
38
36
  export function resetDoneRun(state: DoneState): void {
39
37
  state.mutated = false;
40
38
  state.checksGreen = false;
41
- state.steeredThisTurn = false;
42
39
  state.steerCount = 0;
43
40
  state.mutatedSource = false;
44
41
  state.mutatedTest = false;
@@ -63,7 +60,6 @@ export function shouldSteerDone(
63
60
  hasCommand &&
64
61
  state.mutated &&
65
62
  !state.checksGreen &&
66
- !state.steeredThisTurn &&
67
63
  state.steerCount < MAX_DONE_STEERS
68
64
  );
69
65
  }
@@ -86,5 +82,5 @@ export function formatDoneSteer(display: string): string {
86
82
  }
87
83
 
88
84
  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.';
85
+ return 'This run changed code files and no test file. Check whether existing tests cover the changed behavior; add or update a test if coverage is missing, then rerun the relevant checks before finishing.';
90
86
  }
@@ -4,7 +4,8 @@ import {
4
4
  isWriteToolResult,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
  import {
7
- acceptsSettleSteer,
7
+ registerSettleGuard,
8
+ SETTLE_PRIORITY,
8
9
  startsUserTurn,
9
10
  withHiddenSteer,
10
11
  } from '../shared/settle.ts';
@@ -54,15 +55,16 @@ export function registerExplainGuard(
54
55
  // agent_before_settle runs after retries and compaction recovery, so the
55
56
  // walkthrough is never offered over reworked work, and its continuation
56
57
  // stays inside the same run instead of starting a second one.
57
- pi.on('agent_before_settle', (event, ctx) => {
58
- if (!acceptsSettleSteer(event)) {
59
- return;
60
- }
61
- const decision = decideOffer(state, ctx.mode);
62
- applyOfferDecision(state, decision);
63
- if (decision !== EXPLAIN_DECISION.send) {
64
- return;
65
- }
66
- return withHiddenSteer(event, EXPLAIN_OFFER_TYPE, EXPLAIN_OFFER);
58
+ registerSettleGuard(pi, {
59
+ priority: SETTLE_PRIORITY.explain,
60
+ pending: (ctx) => decideOffer(state, ctx.mode) === EXPLAIN_DECISION.send,
61
+ handle: (event, ctx) => {
62
+ applyOfferDecision(state, decideOffer(state, ctx.mode));
63
+ return withHiddenSteer({
64
+ event,
65
+ customType: EXPLAIN_OFFER_TYPE,
66
+ content: EXPLAIN_OFFER,
67
+ });
68
+ },
67
69
  });
68
70
  }