gentle-pi 2.7.0 → 3.0.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 (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -0,0 +1,87 @@
1
+ import type { CommandPaletteGroup, CommandPaletteItem } from "./command-palette.ts";
2
+
3
+ // The curated command set for the command palette: an OpenCode-style
4
+ // grouped menu, not a raw listing of every registered extension command.
5
+ // Only command + label live here; the live description and any shortcut
6
+ // hint are attached at build time by buildCommandPaletteGroups, from the
7
+ // actual registration and the shell's configured shortcuts.
8
+
9
+ export interface CommandPaletteCatalogItem {
10
+ command: string;
11
+ label: string;
12
+ }
13
+
14
+ export interface CommandPaletteCatalogGroup {
15
+ title: string;
16
+ items: readonly CommandPaletteCatalogItem[];
17
+ }
18
+
19
+ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
20
+ {
21
+ title: "Configuration",
22
+ items: [
23
+ { command: "gentle:models", label: "Assign models and effort" },
24
+ { command: "gentle:profiles", label: "Agent-model profiles" },
25
+ { command: "gentle:persona", label: "Switch persona" },
26
+ { command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
27
+ { command: "gentle:background-subagents", label: "Background subagents" },
28
+ { command: "gentle:telemetry", label: "Telemetry" },
29
+ { command: "gentle:banner", label: "Startup banner" },
30
+ { command: "gentle:banner-color", label: "Banner color" },
31
+ { command: "gentle:toggle-rose", label: "Toggle banner rose" },
32
+ { command: "gentle:toggle-text-logo", label: "Toggle banner text logo" },
33
+ { command: "gentle:dev-binary", label: "Gentle AI dev binary" },
34
+ ],
35
+ },
36
+ {
37
+ title: "Session",
38
+ items: [
39
+ { command: "gentle:changes", label: "Browse captured changes" },
40
+ { command: "gentle:agents", label: "Subagents" },
41
+ { command: "gentle:usage", label: "Subscription usage" },
42
+ { command: "gentle:review-session-permission", label: "Review session permission" },
43
+ ],
44
+ },
45
+ {
46
+ title: "Diagnostics",
47
+ items: [
48
+ { command: "gentle:status", label: "Gentle AI status" },
49
+ { command: "gentle:doctor", label: "Doctor" },
50
+ ],
51
+ },
52
+ {
53
+ title: "SDD",
54
+ items: [
55
+ { command: "gentle:sdd-preflight", label: "SDD preflight" },
56
+ { command: "gentle-sdd-status", label: "SDD status" },
57
+ { command: "gentle-sdd-continue", label: "SDD continue" },
58
+ { command: "gentle-sdd-init", label: "SDD init" },
59
+ ],
60
+ },
61
+ {
62
+ title: "Skills",
63
+ items: [{ command: "skill-registry:refresh", label: "Refresh skill registry" }],
64
+ },
65
+ ];
66
+
67
+ /**
68
+ * Build the palette's groups for one session: keep only catalog entries
69
+ * whose command is actually registered (so a missing extension never shows
70
+ * a dead row), attach that registration's live description and an optional
71
+ * shortcut hint, and drop any group left with no items. Catalog order is
72
+ * preserved throughout.
73
+ */
74
+ export function buildCommandPaletteGroups(registered: readonly { name: string; description?: string }[], shortcuts: Readonly<Record<string, string | undefined>>): CommandPaletteGroup[] {
75
+ const byName = new Map(registered.map((command) => [command.name, command]));
76
+ const groups: CommandPaletteGroup[] = [];
77
+ for (const group of COMMAND_PALETTE_CATALOG) {
78
+ const items: CommandPaletteItem[] = [];
79
+ for (const entry of group.items) {
80
+ const found = byName.get(entry.command);
81
+ if (!found) continue;
82
+ items.push({ command: entry.command, label: entry.label, description: found.description, shortcut: shortcuts[entry.command] });
83
+ }
84
+ if (items.length > 0) groups.push({ title: group.title, items });
85
+ }
86
+ return groups;
87
+ }
@@ -0,0 +1,346 @@
1
+ import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { stripAnsi } from "./terminal-theme.ts";
3
+
4
+ // Command palette: a pure, curated, grouped overlay component for
5
+ // `/gentle:commands` and its `alt+k` shortcut (see
6
+ // extensions/gentle-shell.ts). It only knows about plain groups of items
7
+ // (command + label, plus an optional description used for ranking and an
8
+ // optional shortcut hint), never about the Pi extension API, so it can be
9
+ // fully exercised without a running agent session. The curated catalog
10
+ // itself lives in lib/command-palette-catalog.ts.
11
+
12
+ export interface CommandPaletteItem {
13
+ command: string;
14
+ label: string;
15
+ description?: string;
16
+ shortcut?: string;
17
+ }
18
+
19
+ export interface CommandPaletteGroup {
20
+ title: string;
21
+ items: CommandPaletteItem[];
22
+ }
23
+
24
+ export type CommandPaletteResult = { type: "run"; name: string } | { type: "close" };
25
+
26
+ export interface CommandPaletteTheme {
27
+ fg(color: string, text: string): string;
28
+ /** Optional: when present, the selected row is drawn as a full-width
29
+ * highlighted bar via bg("selectedBg", ...) instead of a `▸` marker. */
30
+ bg?(color: string, text: string): string;
31
+ }
32
+
33
+ // stripAnsi comes from ./terminal-theme.ts (identical intent: strip ANSI/OSC
34
+ // escape sequences before measuring or comparing text). sanitizeTerminalText
35
+ // stays a local copy on purpose: the shared lib/terminal-theme.ts version
36
+ // deletes control characters, but this palette escapes them into a visible
37
+ // "\xNN" form instead (same regex as the local copy in
38
+ // extensions/gentle-agents.ts), so a stray control byte in a label,
39
+ // shortcut, or description is never silently dropped.
40
+ function sanitizeTerminalText(value: string): string {
41
+ return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (control) => `\\x${control.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
42
+ }
43
+
44
+ function isSubsequence(query: string, text: string): boolean {
45
+ let index = 0;
46
+ for (const char of text) {
47
+ if (index < query.length && char === query[index]) index += 1;
48
+ }
49
+ return index === query.length;
50
+ }
51
+
52
+ function rankItem(item: CommandPaletteItem, lowerQuery: string): number | undefined {
53
+ const label = item.label.toLowerCase();
54
+ if (label.startsWith(lowerQuery)) return 0;
55
+ if (label.includes(lowerQuery)) return 1;
56
+ if (isSubsequence(lowerQuery, label)) return 2;
57
+ if (item.command.toLowerCase().includes(lowerQuery)) return 3;
58
+ if (item.description && item.description.toLowerCase().includes(lowerQuery)) return 4;
59
+ return undefined;
60
+ }
61
+
62
+ /**
63
+ * Rank each group's items against a query: 0 label starts with query, 1
64
+ * label contains, 2 label matches as a subsequence, 3 command name
65
+ * contains, 4 description contains. Matching is case-insensitive and the
66
+ * query is trimmed; ties keep input order within a group (stable sort).
67
+ * Groups are never reordered relative to each other, and a group left with
68
+ * no matching item is dropped entirely. An empty or whitespace-only query
69
+ * returns every group unchanged.
70
+ */
71
+ export function rankPaletteGroups(groups: readonly CommandPaletteGroup[], query: string): CommandPaletteGroup[] {
72
+ const trimmed = query.trim();
73
+ if (!trimmed) return groups.map((group) => ({ title: group.title, items: [...group.items] }));
74
+ const lower = trimmed.toLowerCase();
75
+ const result: CommandPaletteGroup[] = [];
76
+ for (const group of groups) {
77
+ const ranked: { item: CommandPaletteItem; rank: number }[] = [];
78
+ for (const item of group.items) {
79
+ const rank = rankItem(item, lower);
80
+ if (rank !== undefined) ranked.push({ item, rank });
81
+ }
82
+ if (ranked.length === 0) continue;
83
+ ranked.sort((a, b) => a.rank - b.rank);
84
+ result.push({ title: group.title, items: ranked.map((entry) => entry.item) });
85
+ }
86
+ return result;
87
+ }
88
+
89
+ type Tone = "border" | "muted" | "text" | "title" | "accent";
90
+
91
+ const TONE_COLOR: Record<Tone, string> = {
92
+ border: "border",
93
+ muted: "muted",
94
+ text: "text",
95
+ title: "accent",
96
+ accent: "accent",
97
+ };
98
+
99
+ // Two border glyphs ("│ " and " │") plus the padding space on each side.
100
+ const CARD_PADDING = 4;
101
+ const CARD_BORDER_ROWS = 2;
102
+ // Fixed content rows outside the grouped match list: header, query/search,
103
+ // a blank row, a blank row before the footer, and the footer itself.
104
+ const CHROME_ROWS = 5;
105
+ const SCROLL_INDICATOR_ROWS = 2;
106
+ // The card never shrinks below this many total lines, and otherwise fills
107
+ // up to this fraction of the terminal's rows (see render()). There is no
108
+ // fixed row cap here: that belongs to the models panel only.
109
+ const MIN_TOTAL_ROWS = 12;
110
+ const HEIGHT_RATIO = 0.85;
111
+ const DEFAULT_ROWS = 40;
112
+
113
+ type PaletteRow = { kind: "title"; text: string } | { kind: "blank" } | { kind: "item"; item: CommandPaletteItem; itemIndex: number };
114
+
115
+ function buildRows(groups: readonly CommandPaletteGroup[]): PaletteRow[] {
116
+ const rows: PaletteRow[] = [];
117
+ let itemIndex = 0;
118
+ groups.forEach((group, groupIndex) => {
119
+ if (groupIndex > 0) rows.push({ kind: "blank" });
120
+ rows.push({ kind: "title", text: group.title });
121
+ for (const item of group.items) {
122
+ rows.push({ kind: "item", item, itemIndex });
123
+ itemIndex += 1;
124
+ }
125
+ });
126
+ return rows;
127
+ }
128
+
129
+ function countItems(groups: readonly CommandPaletteGroup[]): number {
130
+ return groups.reduce((sum, group) => sum + group.items.length, 0);
131
+ }
132
+
133
+ function itemAt(groups: readonly CommandPaletteGroup[], index: number): CommandPaletteItem | undefined {
134
+ let cursor = 0;
135
+ for (const group of groups) {
136
+ if (index < cursor + group.items.length) return group.items[index - cursor];
137
+ cursor += group.items.length;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ export class CommandPalette {
143
+ private query = "";
144
+ private selected = 0;
145
+ private completed = false;
146
+ private readonly groups: readonly CommandPaletteGroup[];
147
+ private readonly done: (result: CommandPaletteResult) => void;
148
+ private readonly theme: CommandPaletteTheme | undefined;
149
+ private readonly rowsFn: () => number;
150
+
151
+ constructor(groups: readonly CommandPaletteGroup[], done: (result: CommandPaletteResult) => void, theme?: CommandPaletteTheme, rows: () => number = () => DEFAULT_ROWS) {
152
+ this.groups = groups;
153
+ this.done = done;
154
+ this.theme = theme;
155
+ this.rowsFn = rows;
156
+ }
157
+
158
+ invalidate(): void {}
159
+
160
+ handleInput(data: string): void {
161
+ if (this.completed || isKeyRelease(data)) return;
162
+ // A bare line feed ("\n") is both ctrl+j and, on non-Kitty terminals,
163
+ // a synonym for Enter: check the scroll/move keys first so ctrl+j
164
+ // always scrolls instead of running the highlighted command (same
165
+ // ordering as lib/shell-changes-view.ts).
166
+ if (matchesKey(data, "down") || matchesKey(data, "ctrl+j")) {
167
+ this.moveSelection(1);
168
+ return;
169
+ }
170
+ if (matchesKey(data, "up") || matchesKey(data, "ctrl+k")) {
171
+ this.moveSelection(-1);
172
+ return;
173
+ }
174
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
175
+ this.finish({ type: "close" });
176
+ return;
177
+ }
178
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
179
+ const item = itemAt(this.matches(), this.selected);
180
+ if (item) this.finish({ type: "run", name: item.command });
181
+ return;
182
+ }
183
+ if (matchesKey(data, "backspace")) {
184
+ if (this.query.length > 0) this.setQuery(this.query.slice(0, -1));
185
+ return;
186
+ }
187
+ if (matchesKey(data, "ctrl+u")) {
188
+ if (this.query.length > 0) this.setQuery("");
189
+ return;
190
+ }
191
+ if (this.isSingleBmpPrintable(data)) this.setQuery(this.query + data);
192
+ }
193
+
194
+ render(width: number): string[] {
195
+ const innerWidth = Math.max(1, width - CARD_PADDING);
196
+ const maxTotalLines = Math.max(MIN_TOTAL_ROWS, Math.floor(this.rowsFn() * HEIGHT_RATIO));
197
+ return this.renderCard(this.renderBody(innerWidth, maxTotalLines), innerWidth);
198
+ }
199
+
200
+ /**
201
+ * True for a single Basic-Multilingual-Plane printable character.
202
+ * Multi-character input (a paste delivered as one `handleInput` call)
203
+ * and astral characters (surrogate pairs, where `data.length === 2`)
204
+ * are ignored on purpose: the palette only appends one keystroke at a
205
+ * time, and a raw terminal paste is not a single keystroke.
206
+ */
207
+ private isSingleBmpPrintable(data: string): boolean {
208
+ if (data.length !== 1) return false;
209
+ const codePoint = data.codePointAt(0) ?? 0;
210
+ return codePoint >= 0x20 && codePoint !== 0x7f;
211
+ }
212
+
213
+ private setQuery(query: string): void {
214
+ this.query = query;
215
+ this.selected = 0;
216
+ }
217
+
218
+ private moveSelection(delta: number): void {
219
+ const total = countItems(this.matches());
220
+ if (total === 0) return;
221
+ this.selected = Math.max(0, Math.min(total - 1, this.selected + delta));
222
+ }
223
+
224
+ private matches(): CommandPaletteGroup[] {
225
+ return rankPaletteGroups(this.groups, this.query);
226
+ }
227
+
228
+ private finish(result: CommandPaletteResult): void {
229
+ if (this.completed) return;
230
+ this.completed = true;
231
+ this.done(result);
232
+ }
233
+
234
+ private renderBody(width: number, maxTotalLines: number): string[] {
235
+ const lines: string[] = [];
236
+ lines.push(this.renderHeaderRow(width));
237
+ lines.push(this.query.length > 0 ? this.renderLine(`› ${this.query}`, width) : this.renderLine("› Search", width, "muted"));
238
+ lines.push("");
239
+ const groups = this.matches();
240
+ if (groups.length === 0) {
241
+ lines.push(this.renderLine(`No commands match "${this.query}"`, width, "muted"));
242
+ } else {
243
+ const budget = Math.max(1, maxTotalLines - CARD_BORDER_ROWS - CHROME_ROWS);
244
+ lines.push(...this.renderGroups(groups, width, budget));
245
+ }
246
+ lines.push("");
247
+ lines.push(this.renderLine("type to search • ↑/↓ move • enter run • esc close", width, "muted"));
248
+ return lines;
249
+ }
250
+
251
+ private renderHeaderRow(width: number): string {
252
+ const left = this.renderText("Commands", "title");
253
+ const right = this.renderText("esc", "muted");
254
+ return this.justifyToWidth(left, right, width);
255
+ }
256
+
257
+ private renderGroups(groups: CommandPaletteGroup[], width: number, budget: number): string[] {
258
+ const rows = buildRows(groups);
259
+ const selected = Math.max(0, Math.min(countItems(groups) - 1, this.selected));
260
+ if (rows.length <= budget) return rows.map((row) => this.renderRow(row, width, selected));
261
+ const windowSize = Math.max(1, budget - SCROLL_INDICATOR_ROWS);
262
+ const selectedRowIndex = Math.max(0, rows.findIndex((row) => row.kind === "item" && row.itemIndex === selected));
263
+ const start = Math.max(0, Math.min(selectedRowIndex - Math.floor(windowSize / 2), Math.max(0, rows.length - windowSize)));
264
+ const end = Math.min(rows.length, start + windowSize);
265
+ const result: string[] = [];
266
+ if (start > 0) result.push(this.renderLine(` ↑ ${start} more`, width, "muted"));
267
+ for (let i = start; i < end; i++) {
268
+ const row = rows[i];
269
+ if (row) result.push(this.renderRow(row, width, selected));
270
+ }
271
+ if (end < rows.length) result.push(this.renderLine(` ↓ ${rows.length - end} more`, width, "muted"));
272
+ return result;
273
+ }
274
+
275
+ private renderRow(row: PaletteRow, width: number, selected: number): string {
276
+ if (row.kind === "blank") return "";
277
+ if (row.kind === "title") return this.renderLine(row.text, width, "accent");
278
+ return this.renderItemRow(row.item, row.itemIndex === selected, width);
279
+ }
280
+
281
+ private renderItemRow(item: CommandPaletteItem, focused: boolean, width: number): string {
282
+ const canHighlightBg = focused && this.theme?.bg !== undefined;
283
+ const label = sanitizeTerminalText(item.label);
284
+ const shortcut = item.shortcut ? sanitizeTerminalText(item.shortcut) : "";
285
+ if (canHighlightBg) {
286
+ const plainRow = this.justifyToWidth(` ${label}`, shortcut, width);
287
+ return this.theme?.bg?.("selectedBg", plainRow) ?? plainRow;
288
+ }
289
+ const prefix = focused ? this.renderText("▸ ", "accent") : " ";
290
+ const prefixWidth = visibleWidth(stripAnsi(prefix));
291
+ const availableWidth = Math.max(0, width - prefixWidth);
292
+ const labelStyled = this.renderText(label, "text");
293
+ const shortcutStyled = shortcut ? this.renderText(shortcut, "muted") : "";
294
+ return `${prefix}${this.justifyToWidth(labelStyled, shortcutStyled, availableWidth)}`;
295
+ }
296
+
297
+ /** Left-align `left`, right-align `right`, and pad to exactly `width`
298
+ * (display columns) so a caller can wrap the whole result in a
299
+ * full-width background highlight without a ragged tail. */
300
+ private justifyToWidth(left: string, right: string, width: number): string {
301
+ const leftWidth = visibleWidth(stripAnsi(left));
302
+ const rightWidth = visibleWidth(stripAnsi(right));
303
+ const minGap = right ? 1 : 0;
304
+ const gap = Math.max(minGap, width - leftWidth - rightWidth);
305
+ const used = leftWidth + gap + rightWidth;
306
+ const trailingPad = Math.max(0, width - used);
307
+ return `${left}${" ".repeat(gap)}${right}${" ".repeat(trailingPad)}`;
308
+ }
309
+
310
+ private renderCard(lines: string[], innerWidth: number): string[] {
311
+ const horizontal = "─".repeat(innerWidth + 2);
312
+ const border = (text: string) => this.renderText(text, "border");
313
+ return [border(`╭${horizontal}╮`), ...lines.map((content) => `${border("│")} ${this.fitStyledLine(content, innerWidth)} ${border("│")}`), border(`╰${horizontal}╯`)];
314
+ }
315
+
316
+ private fitStyledLine(content: string, width: number): string {
317
+ const visible = visibleWidth(stripAnsi(content));
318
+ if (visible > width) return truncateToWidth(content, Math.max(1, width), "…", true);
319
+ return `${content}${" ".repeat(Math.max(0, width - visible))}`;
320
+ }
321
+
322
+ private renderLine(text = "", width: number, tone?: Tone): string {
323
+ const safe = truncateToWidth(sanitizeTerminalText(text), Math.max(1, width), "…", true);
324
+ return tone ? this.renderText(safe, tone) : safe;
325
+ }
326
+
327
+ private renderText(text: string, tone: Tone): string {
328
+ const safe = sanitizeTerminalText(text);
329
+ if (!this.theme) return safe;
330
+ return this.theme.fg(TONE_COLOR[tone], safe);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Shortcut for `/gentle:commands`. Reads GENTLE_PI_COMMANDS_KEY: unset
336
+ * defaults to "alt+k" (ctrl+k is reserved by Pi's editor for
337
+ * delete-to-line-end, so an extension shortcut on it is skipped); an empty
338
+ * value or "off" (case-insensitive) disables the shortcut; anything else is
339
+ * used as-is (trimmed). Same shape as changesShortcut in
340
+ * extensions/gentle-shell.ts.
341
+ */
342
+ export function commandsKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
343
+ const value = env.GENTLE_PI_COMMANDS_KEY?.trim();
344
+ if (value === undefined) return "alt+k";
345
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
346
+ }
@@ -62,6 +62,11 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
62
62
  this.refreshRows();
63
63
  }
64
64
 
65
+ /** Refresh rendered rows after callers update the existing item records. */
66
+ refreshItems(): void {
67
+ this.refreshRows();
68
+ }
69
+
65
70
  clearHover(): boolean {
66
71
  if (!this.hovered) return false;
67
72
  this.hovered = undefined;
@@ -68,7 +68,6 @@ export const NATIVE_REVIEW_OPERATION = {
68
68
  CAPTURE_UNACHIEVABLE: "review/capture-unachievable",
69
69
  ACKNOWLEDGE_APPROVED: "review/acknowledge-approved",
70
70
  SDD_STATUS: "sdd-status",
71
- SDD_ATTEMPT: "sdd-attempt",
72
71
  SDD_CONTINUE: "sdd-continue",
73
72
  } as const;
74
73
  export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION];
@@ -127,39 +126,6 @@ export interface NativeReviewCli {
127
126
  // delivery or review-transaction authority.
128
127
  sddStatus?(request: NativeSddStatusRequest): Promise<NativeSddStatusV2>;
129
128
  sddContinue?(request: NativeSddStatusRequest): Promise<NativeSddStatusV2>;
130
- sddAttemptAcquire?(request: NativeSddAcquireRequest): Promise<NativeSddAttemptResult>;
131
- sddAttemptSettle?(request: NativeSddSettleRequest): Promise<NativeSddAttemptResult>;
132
- }
133
-
134
- export interface NativeSddAttemptRequest extends Pick<NativeUntrackedSelectionRequest, "untrackedScope" | "expectedUntrackedInventory" | "intendedUntracked"> {
135
- workspaceRoot: string;
136
- changeName: string;
137
- requestId: string;
138
- remediatesEvidenceRevision?: string;
139
- }
140
- export interface NativeSddAcquireRequest extends NativeSddAttemptRequest {
141
- workUnit: string;
142
- evidenceGoal: string;
143
- maxAttempts?: number;
144
- maxChangedLines?: number;
145
- expectedRevision?: string;
146
- token?: string;
147
- }
148
- export const SDD_ATTEMPT_OUTCOME = { PASSED: "passed", FAILED: "failed", INTERRUPTED: "interrupted" } as const;
149
- export interface NativeSddSettleRequest extends NativeSddAttemptRequest {
150
- token: string;
151
- outcome: (typeof SDD_ATTEMPT_OUTCOME)[keyof typeof SDD_ATTEMPT_OUTCOME];
152
- evidenceRevision?: string;
153
- remediationEvidence?: string;
154
- diagnosis: string;
155
- harnessDisposition: "reused" | "invalidated";
156
- cleanupEvidence: string;
157
- processEvidence: string;
158
- }
159
- export interface NativeSddAttemptResult {
160
- state: "proceed" | "blocked" | "complete";
161
- token?: string;
162
- reason?: string;
163
129
  }
164
130
 
165
131
  export const NATIVE_REVIEW_MODE_OPERATION = {
@@ -219,7 +185,7 @@ export interface NativeSddStatusV2 extends Readonly<Record<string, unknown>> {
219
185
  changeRoot: string | null;
220
186
  actionContext: Readonly<Record<string, unknown>> & { mode: "repo-local"; workspaceRoot: string; allowedEditRoots: readonly string[] };
221
187
  dependencies: Readonly<Record<(typeof NATIVE_SDD_DEPENDENCIES)[number], NativeSddDependencyState>>;
222
- phaseInstructions?: Readonly<Record<(typeof NATIVE_SDD_INSTRUCTION_PHASES)[number], readonly string[]>>;
188
+ phaseInstructions?: Readonly<Record<(typeof NATIVE_SDD_INSTRUCTION_PHASES)[number], readonly string[]> & { remediate?: readonly string[] }>;
223
189
  blockedReasons: readonly string[];
224
190
  nextRecommended: string;
225
191
  remediationState?: { required: boolean; complete: boolean; failedEvidenceRevision: string };
@@ -1033,7 +999,7 @@ export class NativeReviewCliError extends Error {
1033
999
  this.launchAttempted = launchAttempted;
1034
1000
  this.mutating = mutating;
1035
1001
  this.mutationOutcome = launchAttempted && mutating ? "unknown" : "none";
1036
- this.nextAction = this.mutationOutcome === "unknown" && operation !== NATIVE_REVIEW_OPERATION.SDD_ATTEMPT ? "review.status" : undefined;
1002
+ this.nextAction = this.mutationOutcome === "unknown" ? "review.status" : undefined;
1037
1003
  this.diagnostics = diagnostics ?? { operation, error_code: code, timed_out: false, output_limit_exceeded: false };
1038
1004
  this.auditRecord = auditRecord;
1039
1005
  }
@@ -1459,7 +1425,7 @@ interface NativeJsonExecution {
1459
1425
  }
1460
1426
 
1461
1427
  const NATIVE_SDD_DEPENDENCIES = ["proposal", "specs", "design", "tasks", "apply", "verify", "archive"] as const;
1462
- const NATIVE_SDD_INSTRUCTION_PHASES = ["apply", "verify", "remediate", "archive"] as const;
1428
+ const NATIVE_SDD_INSTRUCTION_PHASES = ["apply", "verify", "archive"] as const;
1463
1429
  const NATIVE_SDD_NEXT_RECOMMENDATIONS = ["apply", "verify", "remediate", "archive", "archived", "resolve-blockers", "sdd-new", "select-change", "propose", "spec", "design", "tasks"] as const;
1464
1430
  const NATIVE_SDD_DEPENDENCY_STATES = ["blocked", "ready", "all_done"] as const;
1465
1431
 
@@ -1487,7 +1453,13 @@ export function decodeNativeSddStatusV2(value: unknown, request: Pick<NativeSddS
1487
1453
  if (status.phaseInstructions !== undefined) {
1488
1454
  const instructions = object(status.phaseInstructions);
1489
1455
  for (const phase of NATIVE_SDD_INSTRUCTION_PHASES) stringArray(instructions[phase]);
1490
- if (Object.keys(instructions).length !== NATIVE_SDD_INSTRUCTION_PHASES.length) throw new Error("native SDD instructions have an unsupported shape");
1456
+ // Classical SDD no longer emits a remediation phase. Keep the published
1457
+ // producer's optional legacy instructions intact without inventing them
1458
+ // for a newer producer or accepting unknown phase keys.
1459
+ const hasRemediation = Object.hasOwn(instructions, "remediate");
1460
+ if (hasRemediation) stringArray(instructions.remediate);
1461
+ if (Object.keys(instructions).length !== NATIVE_SDD_INSTRUCTION_PHASES.length + Number(hasRemediation)) throw new Error("native SDD instructions have an unsupported shape");
1462
+ if (status.nextRecommended === "remediate" && !hasRemediation) throw new Error("native SDD remediation instructions are missing");
1491
1463
  }
1492
1464
  if (status.nextRecommended === "remediate" || status.remediationState !== undefined) {
1493
1465
  const remediation = object(status.remediationState);
@@ -2136,65 +2108,6 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2136
2108
  return this.invoke(operation, cwd, arguments_, mutating, signal, this.executablePath(operation, mutating), toleratedStderr);
2137
2109
  }
2138
2110
 
2139
- async sddAttemptAcquire(request: NativeSddAcquireRequest): Promise<NativeSddAttemptResult> {
2140
- return this.sddAttempt("acquire", request);
2141
- }
2142
-
2143
- async sddAttemptSettle(request: NativeSddSettleRequest): Promise<NativeSddAttemptResult> {
2144
- return this.sddAttempt("settle", request);
2145
- }
2146
-
2147
- private async sddAttempt(verb: "acquire" | "settle", request: NativeSddAcquireRequest | NativeSddSettleRequest): Promise<NativeSddAttemptResult> {
2148
- const args = ["sdd-attempt", verb, "--cwd", request.workspaceRoot, "--change", request.changeName, "--request-id", request.requestId];
2149
- if (!isAbsolute(request.workspaceRoot) || !isCanonicalProcessString(request.workspaceRoot) || !isCanonicalProcessString(request.changeName) || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(request.requestId)) throw new TypeError("Invalid SDD attempt identity");
2150
- const text = (flag: string, value: string, max: number) => {
2151
- if (!isCanonicalProcessString(value) || /[\r\n]/.test(value) || Buffer.byteLength(value) > max) throw new TypeError(`Invalid ${flag}`);
2152
- args.push(flag, value);
2153
- };
2154
- const revision = (flag: string, value: string | undefined) => {
2155
- if (value === undefined) return;
2156
- if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new TypeError(`Invalid ${flag}`);
2157
- args.push(flag, value);
2158
- };
2159
- if (verb === "acquire") {
2160
- const acquire = request as NativeSddAcquireRequest;
2161
- text("--work-unit", acquire.workUnit, 160);
2162
- text("--evidence-goal", acquire.evidenceGoal, 240);
2163
- for (const [flag, value, max] of [["--max-attempts", acquire.maxAttempts, 100], ["--max-changed-lines", acquire.maxChangedLines, 1_000_000]] as const) {
2164
- if (value === undefined) continue;
2165
- if (!Number.isInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${flag}`);
2166
- args.push(flag, String(value));
2167
- }
2168
- if (acquire.expectedRevision === "") args.push("--expected-revision", "");
2169
- else revision("--expected-revision", acquire.expectedRevision);
2170
- if (acquire.token !== undefined) text("--token", acquire.token, 500);
2171
- } else {
2172
- const settle = request as NativeSddSettleRequest;
2173
- text("--token", settle.token, 500);
2174
- if (!Object.values(SDD_ATTEMPT_OUTCOME).includes(settle.outcome)) throw new TypeError("Invalid outcome");
2175
- args.push("--outcome", settle.outcome);
2176
- if (settle.outcome === "interrupted" ? settle.evidenceRevision !== undefined || settle.remediationEvidence !== undefined : !settle.evidenceRevision && !(settle.outcome === "passed" && settle.remediationEvidence)) throw new TypeError("Invalid terminal evidence");
2177
- revision("--evidence-revision", settle.evidenceRevision);
2178
- text("--diagnosis", settle.diagnosis, 500);
2179
- if (!["reused", "invalidated"].includes(settle.harnessDisposition)) throw new TypeError("Invalid harness disposition");
2180
- args.push("--harness-disposition", settle.harnessDisposition);
2181
- text("--cleanup-evidence", settle.cleanupEvidence, 500);
2182
- text("--process-evidence", settle.processEvidence, 500);
2183
- if (settle.remediationEvidence !== undefined) args.push("--remediation-evidence", settle.remediationEvidence);
2184
- }
2185
- revision("--remediates-evidence-revision", request.remediatesEvidenceRevision);
2186
- args.push(...nativeUntrackedSelectionArguments(nativeUntrackedSelection(request)));
2187
- const operation = NATIVE_REVIEW_OPERATION.SDD_ATTEMPT;
2188
- const { body } = await this.negotiated(operation, request.workspaceRoot, args, true);
2189
- return decode(operation, true, () => {
2190
- const result = body as unknown as NativeSddAttemptResult;
2191
- if (!result || !["proceed", "blocked", "complete"].includes(result.state)) throw new TypeError("Invalid compact state");
2192
- if (verb === "acquire" && result.state === "proceed" && !isCanonicalProcessString(result.token)) throw new TypeError("Missing compact token");
2193
- if (result.reason !== undefined && typeof result.reason !== "string") throw new TypeError("Invalid compact reason");
2194
- return { state: result.state, ...(result.token === undefined ? {} : { token: result.token }), ...(result.reason === undefined ? {} : { reason: result.reason }) };
2195
- });
2196
- }
2197
-
2198
2111
  async sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusV2> {
2199
2112
  return this.sddProjection(request, false);
2200
2113
  }