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
@@ -152,10 +152,10 @@ test("SDD proposal questions focus on business and PRD gaps", async () => {
152
152
  test("all shipped SDD agents and chains require parent preflight transport", async () => {
153
153
  const agents = [
154
154
  "sdd-init", "sdd-onboard", "sdd-explore", "sdd-research", "sdd-proposal", "sdd-spec", "sdd-design",
155
- "sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-sync", "sdd-archive", "sdd-remediate",
155
+ "sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-remediate",
156
156
  ];
157
157
  const chains = ["sdd-full", "sdd-plan", "sdd-verify"];
158
- assert.equal(agents.length, 14);
158
+ assert.equal(agents.length, 13);
159
159
  assert.equal(chains.length, 3);
160
160
  for (const agent of agents) {
161
161
  const source = await readFile(join(ROOT, "assets", "agents", `${agent}.md`), "utf8");
@@ -221,7 +221,7 @@ test("persistent harness prompt assets do not hardcode Spanish SDD artifact copy
221
221
  assert.deepEqual(failures, []);
222
222
  });
223
223
 
224
- test("SDD assets route completed implementation directly through verify, sync, and archive", async () => {
224
+ test("SDD assets route completed implementation through native archive with optional verification", async () => {
225
225
  const [tasks, apply, status, contract, chain] = await Promise.all([
226
226
  readFile(join(ROOT, "assets/agents/sdd-tasks.md"), "utf8"),
227
227
  readFile(join(ROOT, "assets/agents/sdd-apply.md"), "utf8"),
@@ -231,13 +231,16 @@ test("SDD assets route completed implementation directly through verify, sync, a
231
231
  ]);
232
232
  const assets = [tasks, apply, status, contract, chain].join("\n");
233
233
 
234
- assert.match(tasks, /<!-- sdd-owner: implementation -->/);
235
- assert.match(apply, /next_recommended: "sdd-verify"/);
234
+ assert.match(tasks, /- \[ \] 1\. Implement and verify the behavior\./);
235
+ assert.doesNotMatch(tasks, /<!-- sdd-owner:/);
236
+ assert.match(apply, /fresh native recommendation/);
236
237
  assert.match(status, /gentle-ai\.sdd-status.*v2/i);
237
238
  assert.match(status, /read-only/i);
238
239
  assert.match(contract, /native.*(?:apply|verify).*archive/is);
239
- assert.match(contract, /Manual `sdd-sync` remains its intentional local resolver/);
240
- assert.match(chain, /apply.*verification/is);
240
+ assert.doesNotMatch(contract, /local resolver/);
241
+ assert.match(contract, /verification is optional/);
242
+ assert.match(chain, /apply -> archive/);
243
+ assert.doesNotMatch(chain, /^## sdd-(verify|sync)$/m);
241
244
  assert.doesNotMatch(assets, /<!-- sdd-owner: parent -->/);
242
245
  assert.doesNotMatch(assets, /parent-lifecycle/);
243
246
  assert.doesNotMatch(assets, /approved receipt|bounded review/i);
@@ -0,0 +1,378 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { CommandPalette, commandsKey, rankPaletteGroups, type CommandPaletteGroup, type CommandPaletteItem, type CommandPaletteResult, type CommandPaletteTheme } from "../lib/command-palette.ts";
5
+ import { buildCommandPaletteGroups, COMMAND_PALETTE_CATALOG } from "../lib/command-palette-catalog.ts";
6
+
7
+ // Command palette: a pure, curated, grouped overlay component for
8
+ // `/gentle:commands` and its `alt+k` shortcut. These tests drive ranking,
9
+ // keyboard input, and rendering without any Pi extension API — the
10
+ // component only depends on pi-tui key/width helpers and plain data
11
+ // (groups of items) in, plain strings out.
12
+
13
+ const KEY = {
14
+ DOWN: "\x1b[B",
15
+ UP: "\x1b[A",
16
+ CTRL_J: "\n",
17
+ CTRL_K: "\x0b",
18
+ ENTER: "\r",
19
+ ESCAPE: "\x1b",
20
+ CTRL_C: "\x03",
21
+ BACKSPACE: "\x7f",
22
+ CTRL_U: "\x15",
23
+ } as const;
24
+
25
+ function type(palette: CommandPalette, text: string): void {
26
+ for (const ch of text) palette.handleInput(ch);
27
+ }
28
+
29
+ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPaletteTheme, rows?: () => number): { palette: CommandPalette; results: CommandPaletteResult[] } {
30
+ const results: CommandPaletteResult[] = [];
31
+ const palette = new CommandPalette(groups, (result) => results.push(result), theme, rows);
32
+ return { palette, results };
33
+ }
34
+
35
+ // --- rankPaletteGroups -------------------------------------------------------
36
+
37
+ test("rankPaletteGroups ranks label-prefix, label-contains, label-subsequence, command-contains, then description-contains", () => {
38
+ const group: CommandPaletteGroup = {
39
+ title: "G",
40
+ items: [
41
+ // rank 4: only the description contains "mod".
42
+ { command: "gentle:xyz-tool", label: "xyz", description: "custom mod support" },
43
+ // rank 3: only the command name contains "mod".
44
+ { command: "gentle:mod-tool", label: "xyz-abc" },
45
+ // rank 2: the label matches "mod" only as a subsequence.
46
+ { command: "gentle:whatever3", label: "my-old-doc" },
47
+ // rank 1: the label contains "mod", but does not start with it.
48
+ { command: "gentle:whatever2", label: "gentle models here" },
49
+ // rank 0: the label starts with "mod".
50
+ { command: "gentle:whatever1", label: "Modify a thing" },
51
+ ],
52
+ };
53
+ const ranked = rankPaletteGroups([group], "mod");
54
+ assert.equal(ranked.length, 1);
55
+ assert.deepEqual(
56
+ ranked[0]?.items.map((item) => item.command),
57
+ ["gentle:whatever1", "gentle:whatever2", "gentle:whatever3", "gentle:mod-tool", "gentle:xyz-tool"],
58
+ );
59
+ });
60
+
61
+ test("rankPaletteGroups is case-insensitive and trims the query", () => {
62
+ const group: CommandPaletteGroup = { title: "G", items: [{ command: "a", label: "Alpha Mod" }, { command: "b", label: "beta" }] };
63
+ const lower = rankPaletteGroups([group], "mod").map((g) => g.items.map((i) => i.command));
64
+ assert.deepEqual(rankPaletteGroups([group], "MOD").map((g) => g.items.map((i) => i.command)), lower);
65
+ assert.deepEqual(rankPaletteGroups([group], " mod ").map((g) => g.items.map((i) => i.command)), lower);
66
+ });
67
+
68
+ test("rankPaletteGroups keeps input order within a group for tied ranks", () => {
69
+ const group: CommandPaletteGroup = { title: "G", items: [{ command: "mod-b", label: "mod-b" }, { command: "mod-a", label: "mod-a" }] };
70
+ assert.deepEqual(rankPaletteGroups([group], "mod")[0]?.items.map((i) => i.command), ["mod-b", "mod-a"]);
71
+ });
72
+
73
+ test("rankPaletteGroups returns every group unchanged for an empty or whitespace query", () => {
74
+ const groups: CommandPaletteGroup[] = [
75
+ { title: "G1", items: [{ command: "a", label: "Alpha" }] },
76
+ { title: "G2", items: [{ command: "b", label: "Beta" }] },
77
+ ];
78
+ assert.deepEqual(rankPaletteGroups(groups, ""), groups);
79
+ assert.deepEqual(rankPaletteGroups(groups, " "), groups);
80
+ });
81
+
82
+ test("rankPaletteGroups drops a group with no matching item, and never reorders groups", () => {
83
+ const groups: CommandPaletteGroup[] = [
84
+ { title: "Configuration", items: [{ command: "gentle:models", label: "Assign models and effort" }, { command: "gentle:profiles", label: "Agent-model profiles" }] },
85
+ { title: "Session", items: [{ command: "gentle:usage", label: "Subscription usage" }] },
86
+ { title: "Diagnostics", items: [{ command: "gentle:status", label: "Gentle AI status" }] },
87
+ ];
88
+ const ranked = rankPaletteGroups(groups, "model");
89
+ assert.deepEqual(ranked.map((g) => g.title), ["Configuration"]);
90
+ assert.deepEqual(ranked[0]?.items.map((i) => i.command), ["gentle:models", "gentle:profiles"]);
91
+ });
92
+
93
+ test("rankPaletteGroups returns no groups for a query that matches nothing anywhere", () => {
94
+ const groups: CommandPaletteGroup[] = [{ title: "G", items: [{ command: "a", label: "Alpha", description: "first letter" }] }];
95
+ assert.deepEqual(rankPaletteGroups(groups, "qqqqzzz"), []);
96
+ });
97
+
98
+ // --- input flow --------------------------------------------------------------
99
+
100
+ const GROUPS: CommandPaletteGroup[] = [
101
+ {
102
+ title: "Configuration",
103
+ items: [
104
+ { command: "gentle:models", label: "Assign models and effort" },
105
+ { command: "gentle:profiles", label: "Agent-model profiles" },
106
+ ],
107
+ },
108
+ {
109
+ title: "Session",
110
+ items: [{ command: "gentle:changes", label: "Browse captured changes", shortcut: "alt+g" }],
111
+ },
112
+ ];
113
+
114
+ test("typing filters across groups and resets the selection to the first visible item", () => {
115
+ const { palette, results } = createPalette(GROUPS);
116
+ palette.handleInput(KEY.DOWN);
117
+ palette.handleInput(KEY.DOWN); // selection is now "gentle:changes" (index 2)
118
+ // "s" keeps every item (each label contains "s") in the same relative
119
+ // order, so this only exercises the selection reset: without it, enter
120
+ // would still pick "gentle:changes".
121
+ palette.handleInput("s");
122
+ palette.handleInput(KEY.ENTER);
123
+ assert.deepEqual(results, [{ type: "run", name: "gentle:models" }]);
124
+ });
125
+
126
+ test("backspace removes the last query character", () => {
127
+ const { palette, results } = createPalette(GROUPS);
128
+ type(palette, "changez");
129
+ palette.handleInput(KEY.BACKSPACE);
130
+ palette.handleInput(KEY.ENTER);
131
+ assert.deepEqual(results, [{ type: "run", name: "gentle:changes" }]);
132
+ });
133
+
134
+ test("ctrl+u clears the query", () => {
135
+ const { palette, results } = createPalette(GROUPS);
136
+ type(palette, "changes");
137
+ palette.handleInput(KEY.CTRL_U);
138
+ palette.handleInput(KEY.ENTER);
139
+ assert.deepEqual(results, [{ type: "run", name: "gentle:models" }]);
140
+ });
141
+
142
+ test("up and down move the selection across groups and clamp at the ends", () => {
143
+ const { palette, results } = createPalette(GROUPS);
144
+ palette.handleInput(KEY.UP); // clamps at 0, still "gentle:models"
145
+ palette.handleInput(KEY.DOWN); // gentle:profiles
146
+ palette.handleInput(KEY.DOWN); // gentle:changes (crosses into the Session group)
147
+ palette.handleInput(KEY.DOWN); // clamps at the last item
148
+ palette.handleInput(KEY.ENTER);
149
+ assert.deepEqual(results, [{ type: "run", name: "gentle:changes" }]);
150
+ });
151
+
152
+ test("ctrl+j and ctrl+k also move the selection", () => {
153
+ const { palette, results } = createPalette(GROUPS);
154
+ palette.handleInput(KEY.CTRL_J); // gentle:profiles
155
+ palette.handleInput(KEY.CTRL_K); // gentle:models
156
+ palette.handleInput(KEY.ENTER);
157
+ assert.deepEqual(results, [{ type: "run", name: "gentle:models" }]);
158
+ });
159
+
160
+ test("enter runs the highlighted item and is a no-op with no match", () => {
161
+ const { palette, results } = createPalette(GROUPS);
162
+ type(palette, "nothing-like-this");
163
+ palette.handleInput(KEY.ENTER);
164
+ assert.deepEqual(results, []);
165
+ });
166
+
167
+ test("escape and ctrl+c close without running", () => {
168
+ const escapePalette = createPalette(GROUPS);
169
+ escapePalette.palette.handleInput(KEY.ESCAPE);
170
+ assert.deepEqual(escapePalette.results, [{ type: "close" }]);
171
+
172
+ const ctrlCPalette = createPalette(GROUPS);
173
+ ctrlCPalette.palette.handleInput(KEY.CTRL_C);
174
+ assert.deepEqual(ctrlCPalette.results, [{ type: "close" }]);
175
+ });
176
+
177
+ test("a second input after completion is ignored", () => {
178
+ const { palette, results } = createPalette(GROUPS);
179
+ palette.handleInput(KEY.ESCAPE);
180
+ palette.handleInput(KEY.ENTER);
181
+ palette.handleInput("x");
182
+ assert.deepEqual(results, [{ type: "close" }]);
183
+ });
184
+
185
+ test("a key release event is ignored even when it would otherwise match a binding", () => {
186
+ const { palette, results } = createPalette(GROUPS);
187
+ // ":3B" flags a Kitty protocol key release for the down arrow.
188
+ palette.handleInput("\x1b[1;1:3B");
189
+ palette.handleInput(KEY.ENTER);
190
+ assert.deepEqual(results, [{ type: "run", name: "gentle:models" }]);
191
+ });
192
+
193
+ // --- rendering ---------------------------------------------------------------
194
+
195
+ function render(groups: readonly CommandPaletteGroup[], query: string, width = 60, theme?: CommandPaletteTheme, rows?: () => number): string[] {
196
+ const { palette } = createPalette(groups, theme, rows);
197
+ type(palette, query);
198
+ return palette.render(width);
199
+ }
200
+
201
+ test("render shows the header (Commands / esc), the search hint, and the typed query", () => {
202
+ const lines = render(GROUPS, "");
203
+ const header = lines.find((line) => line.includes("Commands"));
204
+ assert.ok(header, "expected a header line with Commands");
205
+ assert.match(header!, /Commands/);
206
+ assert.match(header!, /esc\s*│?\s*$/);
207
+ assert.ok(lines.some((line) => line.includes("› Search")));
208
+
209
+ const typed = render(GROUPS, "models");
210
+ assert.ok(typed.some((line) => line.includes("› models")));
211
+ });
212
+
213
+ test("render shows group titles and one row per item, with the shortcut right-aligned", () => {
214
+ const lines = render(GROUPS, "").join("\n");
215
+ assert.match(lines, /Configuration/);
216
+ assert.match(lines, /Session/);
217
+ assert.match(lines, /Assign models and effort/);
218
+ assert.match(lines, /Agent-model profiles/);
219
+ const changesLine = render(GROUPS, "").find((line) => line.includes("Browse captured changes"));
220
+ assert.ok(changesLine, "expected a row for Browse captured changes");
221
+ assert.match(changesLine!, /Browse captured changes[\s\S]*alt\+g/);
222
+ });
223
+
224
+ test("render omits the shortcut column for an item without one", () => {
225
+ const lines = render(GROUPS, "").join("\n");
226
+ const modelsLine = render(GROUPS, "")
227
+ .filter((line) => line.includes("Assign models and effort"))
228
+ .join("\n");
229
+ assert.doesNotMatch(modelsLine, /undefined/);
230
+ assert.ok(lines.length > 0);
231
+ });
232
+
233
+ test("render marks the selected row with a highlighted bar when the theme provides bg", () => {
234
+ const bgTheme: CommandPaletteTheme = {
235
+ fg: (_color, text) => text,
236
+ bg: (color, text) => `[bg:${color}]${text}[/bg]`,
237
+ };
238
+ const lines = render(GROUPS, "", 60, bgTheme);
239
+ const selectedLine = lines.find((line) => line.includes("[bg:selectedBg]"));
240
+ assert.ok(selectedLine, "expected a bg-highlighted selected row");
241
+ assert.match(selectedLine!, /Assign models and effort/);
242
+ assert.doesNotMatch(selectedLine!, /▸/);
243
+ });
244
+
245
+ test("render falls back to a ▸ marker when the theme has no bg", () => {
246
+ const fgOnlyTheme: CommandPaletteTheme = { fg: (_color, text) => text };
247
+ const lines = render(GROUPS, "", 60, fgOnlyTheme);
248
+ const selectedLine = lines.find((line) => line.includes("▸"));
249
+ assert.ok(selectedLine, "expected a ▸ marker when bg is unavailable");
250
+ assert.match(selectedLine!, /Assign models and effort/);
251
+ assert.doesNotMatch(lines.join("\n"), /\[bg:/);
252
+ });
253
+
254
+ test("render shows a muted no-match row when nothing matches", () => {
255
+ const lines = render(GROUPS, "does-not-exist").join("\n");
256
+ assert.match(lines, /No commands match "does-not-exist"/);
257
+ });
258
+
259
+ test("render escapes control characters in labels and shortcuts", () => {
260
+ const groups: CommandPaletteGroup[] = [{ title: "G", items: [{ command: "gentle:evil", label: "line1\x07line2", shortcut: "alt+\x07" }] }];
261
+ const lines = render(groups, "").join("\n");
262
+ assert.doesNotMatch(lines, /\x07/);
263
+ assert.match(lines, /\\x07/);
264
+ });
265
+
266
+ test("render footer lists the key bindings", () => {
267
+ const lines = render(GROUPS, "").join("\n");
268
+ assert.match(lines, /type to search • ↑\/↓ move • enter run • esc close/);
269
+ });
270
+
271
+ test("render keeps the right border aligned when a label has wide characters", () => {
272
+ // Both labels are exactly 10 UTF-16 code units, so a length-based pad
273
+ // treats the two rows as equally wide and pads them by the same amount.
274
+ // Their display widths differ (20 columns of CJK vs. 10 ASCII columns),
275
+ // so only a visibleWidth-based pad keeps the right border aligned.
276
+ const groups: CommandPaletteGroup[] = [
277
+ {
278
+ title: "G",
279
+ items: [
280
+ { command: "gentle:emoji", label: "中文中文中文中文中文" },
281
+ { command: "gentle:plain", label: "plain desc" },
282
+ ],
283
+ },
284
+ ];
285
+ const lines = render(groups, "", 60);
286
+ const widths = new Set(lines.map((line) => visibleWidth(line)));
287
+ assert.equal(widths.size, 1, `expected every rendered line to share one visible width, got ${[...widths].join(", ")}`);
288
+ });
289
+
290
+ test("height follows rows() with scroll indicators when 30 items do not fit in 24 rows", () => {
291
+ const groups: CommandPaletteGroup[] = Array.from({ length: 3 }, (_, groupIndex) => ({
292
+ title: `Group ${groupIndex}`,
293
+ items: Array.from({ length: 10 }, (_, itemIndex) => ({ command: `cmd:${groupIndex}-${itemIndex}`, label: `Item ${groupIndex}-${itemIndex}` })),
294
+ }));
295
+ const { palette } = createPalette(groups, undefined, () => 24);
296
+ for (let i = 0; i < 15; i++) palette.handleInput(KEY.DOWN);
297
+ const lines = palette.render(80);
298
+ assert.ok(lines.length <= 20, `expected at most 20 total lines for rows()=24, got ${lines.length}`);
299
+ assert.ok(lines.some((line) => /↑ \d+ more/.test(line)));
300
+ assert.ok(lines.some((line) => /↓ \d+ more/.test(line)));
301
+ });
302
+
303
+ test("a small terminal still renders at least the minimum 12-line card", () => {
304
+ const { palette } = createPalette(GROUPS, undefined, () => 4);
305
+ const lines = palette.render(60);
306
+ assert.ok(lines.length <= 12, `expected at most 12 total lines for a tiny terminal, got ${lines.length}`);
307
+ });
308
+
309
+ // --- commandsKey ---------------------------------------------------------------
310
+
311
+ test("commandsKey defaults to alt+k", () => {
312
+ assert.equal(commandsKey({}), "alt+k");
313
+ });
314
+
315
+ test("commandsKey honors an override", () => {
316
+ assert.equal(commandsKey({ GENTLE_PI_COMMANDS_KEY: "ctrl+p" }), "ctrl+p");
317
+ });
318
+
319
+ test("commandsKey is disabled by an empty value or off (case-insensitive)", () => {
320
+ assert.equal(commandsKey({ GENTLE_PI_COMMANDS_KEY: "" }), undefined);
321
+ assert.equal(commandsKey({ GENTLE_PI_COMMANDS_KEY: "off" }), undefined);
322
+ assert.equal(commandsKey({ GENTLE_PI_COMMANDS_KEY: "OFF" }), undefined);
323
+ });
324
+
325
+ // --- catalog -------------------------------------------------------------------
326
+
327
+ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () => {
328
+ assert.deepEqual(
329
+ COMMAND_PALETTE_CATALOG.map((group) => group.title),
330
+ ["Configuration", "Session", "Diagnostics", "SDD", "Skills"],
331
+ );
332
+ const byTitle = (title: string) => COMMAND_PALETTE_CATALOG.find((group) => group.title === title)?.items.map((item) => item.command);
333
+ assert.deepEqual(byTitle("Configuration"), [
334
+ "gentle:models",
335
+ "gentle:profiles",
336
+ "gentle:persona",
337
+ "gentle:review-mode",
338
+ "gentle:background-subagents",
339
+ "gentle:telemetry",
340
+ "gentle:banner",
341
+ "gentle:banner-color",
342
+ "gentle:toggle-rose",
343
+ "gentle:toggle-text-logo",
344
+ "gentle:dev-binary",
345
+ ]);
346
+ assert.deepEqual(byTitle("Session"), ["gentle:changes", "gentle:agents", "gentle:usage", "gentle:review-session-permission"]);
347
+ assert.deepEqual(byTitle("Diagnostics"), ["gentle:status", "gentle:doctor"]);
348
+ assert.deepEqual(byTitle("SDD"), ["gentle:sdd-preflight", "gentle-sdd-status", "gentle-sdd-continue", "gentle-sdd-init"]);
349
+ assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
350
+ });
351
+
352
+ test("buildCommandPaletteGroups keeps only registered commands, attaches descriptions and shortcuts, drops empty groups, preserves catalog order", () => {
353
+ const registered = [
354
+ { name: "gentle:models", description: "Configure models" },
355
+ { name: "gentle:changes", description: "Browse changes" },
356
+ { name: "gentle:status", description: "Show status" },
357
+ { name: "skill-registry:refresh", description: "Refresh registry" },
358
+ ];
359
+ const shortcuts = { "gentle:changes": "alt+g" };
360
+ const groups = buildCommandPaletteGroups(registered, shortcuts);
361
+ assert.deepEqual(
362
+ groups.map((group) => group.title),
363
+ ["Configuration", "Session", "Diagnostics", "Skills"],
364
+ );
365
+ const configuration = groups.find((group) => group.title === "Configuration");
366
+ assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:models"]);
367
+ assert.equal(configuration?.items[0]?.description, "Configure models");
368
+ assert.equal(configuration?.items[0]?.shortcut, undefined);
369
+ const session = groups.find((group) => group.title === "Session");
370
+ assert.deepEqual(session?.items.map((item) => item.command), ["gentle:changes"]);
371
+ assert.equal(session?.items[0]?.shortcut, "alt+g");
372
+ assert.equal(groups.some((group) => group.title === "SDD"), false);
373
+ });
374
+
375
+ test("buildCommandPaletteGroups drops every group when nothing in the catalog is registered", () => {
376
+ const groups = buildCommandPaletteGroups([{ name: "not-in-catalog", description: "n/a" }], {});
377
+ assert.deepEqual(groups, []);
378
+ });
@@ -93,7 +93,7 @@ test("SDD executor coverage is exhaustive against actual agent files", () => {
93
93
  const expected = [
94
94
  "sdd-apply.md", "sdd-archive.md", "sdd-design.md", "sdd-explore.md",
95
95
  "sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-remediate.md", "sdd-research.md",
96
- "sdd-spec.md", "sdd-status.md", "sdd-sync.md", "sdd-tasks.md",
96
+ "sdd-spec.md", "sdd-status.md", "sdd-tasks.md",
97
97
  "sdd-verify.md",
98
98
  ];
99
99
  assert.deepEqual(actual, expected, "SDD agent set must match the known allowlist");
@@ -231,7 +231,7 @@ test("modified SDD agents are packaged and installed by the existing installer",
231
231
  "assets/agents/sdd-init.md", "assets/agents/sdd-onboard.md",
232
232
  "assets/agents/sdd-proposal.md", "assets/agents/sdd-research.md",
233
233
  "assets/agents/sdd-spec.md", "assets/agents/sdd-status.md",
234
- "assets/agents/sdd-sync.md", "assets/agents/sdd-tasks.md",
234
+ "assets/agents/sdd-tasks.md",
235
235
  "assets/agents/sdd-verify.md",
236
236
  ];
237
237
  for (const path of expected) {
@@ -0,0 +1,108 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ActiveSessionClient, ActiveSessionClientError, ActiveSessionListener, SessionPresenceRegistry } from "../../lib/agents-session-transport.ts";
4
+
5
+ const agentHome = process.env.GENTLE_AGENT_HOME;
6
+ const CONTROL_BYTES = 16_384;
7
+ if (!agentHome) throw new Error("GENTLE_AGENT_HOME is required");
8
+
9
+ await mkdir(join(agentHome, "gentle-agents"), { recursive: true, mode: 0o755 });
10
+ const registry = await SessionPresenceRegistry.create(agentHome);
11
+ let listener;
12
+ let client;
13
+ let blockShutdown = false;
14
+ let blocker;
15
+ let closed = false;
16
+ let input = Buffer.alloc(0);
17
+
18
+ const record = (value) => ({ ...value, presencePath: registry.presencePath(value) });
19
+ const controlLine = (value) => {
20
+ const line = JSON.stringify(value);
21
+ if (Buffer.byteLength(line) > CONTROL_BYTES) throw new Error("control frame exceeds bound");
22
+ return `${line}\n`;
23
+ };
24
+ const emit = (value) => { process.stdout.write(controlLine(value)); };
25
+ const flush = (value) => new Promise((resolveFlush, rejectFlush) => {
26
+ process.stdout.write(controlLine(value), (error) => error ? rejectFlush(error) : resolveFlush());
27
+ });
28
+ const stop = async () => {
29
+ const current = listener?.record;
30
+ client?.close();
31
+ client = undefined;
32
+ if (blocker) clearInterval(blocker);
33
+ blocker = undefined;
34
+ if (listener) await listener.close();
35
+ listener = undefined;
36
+ return current ? record(current) : {};
37
+ };
38
+ const reply = (requestId, ok, result, error) => emit({ requestId, ok, ...(ok ? { result } : { error }) });
39
+ const fatal = () => {
40
+ if (closed) return;
41
+ closed = true;
42
+ input = Buffer.alloc(0);
43
+ process.stdin.off("data", onData);
44
+ void stop().finally(() => process.exit(2));
45
+ };
46
+
47
+ async function handle(line) {
48
+ let text;
49
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(line); }
50
+ catch { fatal(); return; }
51
+ let command;
52
+ try {
53
+ command = JSON.parse(text);
54
+ if (!command || typeof command !== "object" || typeof command.requestId !== "string" || typeof command.operation !== "string") throw new Error("invalid control frame");
55
+ let result;
56
+ if (command.operation === "start") {
57
+ await stop();
58
+ listener = new ActiveSessionListener(registry, command.sessionId, async (notification) => {
59
+ emit({ event: "callback", requestId: notification.id, pid: process.pid, ...notification });
60
+ });
61
+ await listener.start();
62
+ client = new ActiveSessionClient(registry, command.sessionId);
63
+ result = record(listener.record);
64
+ } else if (command.operation === "send") {
65
+ if (!client) throw new Error("listener is not active");
66
+ result = await client.sendNotification(command.recipientSessionId, command.message, { id: command.id });
67
+ } else if (command.operation === "resolve") result = record(await registry.resolve(command.sessionId));
68
+ else if (command.operation === "stop") result = await stop();
69
+ else if (command.operation === "delay") {
70
+ if (!Number.isInteger(command.delayMs) || command.delayMs < 0 || command.delayMs > 2_500) throw new Error("invalid test delay");
71
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, command.delayMs));
72
+ result = { delayed: true };
73
+ } else if (command.operation === "exit") process.exit(23);
74
+ else if (command.operation === "block-shutdown") {
75
+ blockShutdown = true;
76
+ blocker ??= setInterval(() => {}, 1_000);
77
+ result = { armed: true };
78
+ }
79
+ else if (command.operation === "shutdown") {
80
+ if (blockShutdown) return;
81
+ result = await stop();
82
+ await flush({ requestId: command.requestId, ok: true, result });
83
+ process.exit(0);
84
+ } else throw new Error("unknown operation");
85
+ reply(command.requestId, true, result);
86
+ } catch (error) {
87
+ const message = error instanceof ActiveSessionClientError ? error.code : "fixture_error";
88
+ reply(typeof command?.requestId === "string" ? command.requestId : "invalid", false, undefined, message);
89
+ }
90
+ }
91
+
92
+ let queue = Promise.resolve();
93
+ function onData(chunk) {
94
+ let start = 0;
95
+ while (start < chunk.length) {
96
+ const newline = chunk.indexOf(10, start), end = newline < 0 ? chunk.length : newline;
97
+ const fragment = chunk.subarray(start, end);
98
+ if (input.length + fragment.length > CONTROL_BYTES) { fatal(); return; }
99
+ if (newline < 0) { input = Buffer.concat([input, fragment]); return; }
100
+ const line = input.length ? Buffer.concat([input, fragment]) : fragment;
101
+ input = Buffer.alloc(0);
102
+ queue = queue.then(() => handle(line)).catch(fatal);
103
+ start = newline + 1;
104
+ }
105
+ }
106
+
107
+ process.stdin.on("data", onData);
108
+ process.stdin.on("error", fatal);
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: sdd-research
3
+ description: Collect auditable external evidence for a selected SDD research lane.
4
+ tools:
5
+ - read
6
+ - grep
7
+ - find
8
+ - edit
9
+ - write
10
+ - mem_search
11
+ - mem_get_observation
12
+ - mem_save
13
+ ---
14
+
15
+ You are the SDD research executor for Gentle AI.
16
+
17
+ ## Skill Resolution Contract
18
+
19
+ Use your assigned executor/phase skill for this SDD phase. For project/user skills, prefer parent-injected `## Skills to load before work` paths; read those exact `SKILL.md` files before work. Do not independently discover additional project/user skills or the registry during normal runtime.
20
+
21
+ If skill paths are missing, explicit fallback loading is allowed only as degraded self-healing. Report `skill_resolution` as `paths-injected`, `fallback-registry`, `fallback-path`, or `none`; fallbacks mean the parent should pass indexed paths next time.
22
+
23
+ - Run only when the orchestrator selects `sdd-research` and supplies the persisted research intent: the change name, the questions, the requested source classes, and the artifact store. Treat that intent as immutable; if it is absent, return `blocked` with no claims.
24
+ - Evidence grants for this runtime are `documentation=[]; open-web=[]`. Never infer evidence capability from bash, persistence tools, or any inherited tool; persistence tools are not evidence grants. Unsupported or undeclared classes deny admission and emit no claims.
25
+ - Because this runtime declares no evidence grants, retain the selected request, persist a `blocked` outcome with no claims, and stop.
26
+ - Admission denial, partial evidence, invalid sources, or persistence divergence emits no unvalidated claim and blocks proposal readiness.
27
+ - Keep evidence claims separate from non-authoritative product choices; the orchestrator owns product decisions and proposal admission.
28
+ - Do NOT launch child subagents. Parent/orchestrator owns delegation.
29
+ - Persist the research and pre-proposal artifacts per the Memory Contract below; never claim persistence you did not perform.
30
+ - Keep output concise and return the SDD result contract.
31
+ ## Memory Contract
32
+
33
+ Read any input artifacts directly from the active backend before doing the phase work; do not wait for the parent to inline them. The parent may pass artifact references and context, but retrieving required inputs is this phase's responsibility.
34
+
35
+ Inputs to read (`engram`/`both`: use the injected Engram memory read tools for the topic key, then fetch the full observation; `openspec`: read the file under `openspec/changes/{change}/`):
36
+ - Exploration (when it exists): `sdd/{change}/explore` (openspec: the exploration file under `openspec/changes/{change}/`).
37
+
38
+ Persist this phase's artifact to the active backend before returning (mandatory):
39
+ - `engram`/`both`: call the injected Engram save tool with title and `topic_key` `"sdd/{change}/research"`, `type: "architecture"`, `project` from context, and `capture_prompt: false` when the tool schema supports it (omit the field if an older schema rejects it).
40
+ - `openspec`: write/update `openspec/changes/{change}/research.md`.
41
+ - `none`: return the research record inline.
42
+
43
+ The research artifact uses schema `gentle-ai.sdd-research/v1`: a positive `revision`, an explicit `done | partial | blocked` outcome, the questions, admission and the observed exact grants, sources, and validated claims where each claim maps to source IDs. For this runtime the outcome is `blocked` with an admission denial and no claims.
44
+
45
+ Also update the pre-proposal state (`engram`/`both`: topic `"sdd/{change}/preproposal"`; same save conventions) using schema `gentle-ai.sdd-preproposal/v1`: a positive `revision`, the exploration reference, the research request and classes, the admission outcome, evidence references, product decisions (`pending | confirmed`), and `proposal_ready`.
46
+
47
+ Hybrid (`both`) persistence means identical bytes in both stores. On hybrid mismatch or a one-sided write failure, never prefer one store: recover from the retained intent, not from a surviving store, and keep proposal readiness false for recovery.
48
+
49
+ Never claim persistence you did not perform.
50
+
51
+
52
+ ## Key Learnings Closing
53
+
54
+ Close your final report text with a `## Key Learnings` block (no trailing colon). Use 1–5 numbered items, each a standalone factual sentence of at least 20 characters and at least 4 words. This applies to final report text only — not intermediate tool output or saved artifact content. The Engram memory provider automatically extracts and persists these items as passive capture; you do not parse the block or invoke passive-capture tools yourself. Omit the block when there is genuinely no reusable learning; no filler or speculation. This closing block is separate from explicit `mem_save` artifact/decision persistence.