gentle-pi 4.3.0 → 4.4.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 (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,1313 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { request as httpRequest } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import gentleDesign, { DESIGN_COMMAND_NAME } from "../extensions/gentle-design.ts";
10
+ import { formatDesignComment, formatDesignCommentBatch, startDesignCanvasServer, type DesignCanvasServer, type DesignCanvasServerOptions, type DesignComment } from "../lib/design-canvas-server.ts";
11
+ import type { OpenInBrowserResult } from "../lib/design-open-browser.ts";
12
+ import { __testing } from "../extensions/skill-registry.ts";
13
+
14
+ // /gentle:design wiring: a brief starts one design run through the
15
+ // gentle-design skill; open/no arguments show the canvas for a design in the
16
+ // session cwd; stop and session shutdown close it; canvas comments reach the
17
+ // session; sync runs the skill's design-system mode; implement hands an
18
+ // artboard off to code.
19
+
20
+ const SKILL_PATH = fileURLToPath(new URL("../skills/design/SKILL.md", import.meta.url));
21
+
22
+ type Registration = { description: string; handler(args: string, ctx: ExtensionContext): Promise<void> };
23
+ type Sent = { content: unknown; options: unknown };
24
+ type ToolResult = { content: Array<{ type: string; text: string }>; details?: unknown };
25
+ type Tool = { name: string; description: string; execute(id: string, params: unknown, signal: unknown, onUpdate: unknown, ctx: unknown): Promise<ToolResult> };
26
+
27
+ function fakePi(skillLoaded = true) {
28
+ const commands = new Map<string, Registration>();
29
+ const tools = new Map<string, Tool>();
30
+ const handlers = new Map<string, Array<(...args: unknown[]) => unknown>>();
31
+ const sent: Sent[] = [];
32
+ const pi = {
33
+ registerCommand: (name: string, registration: Registration) => commands.set(name, registration),
34
+ registerTool: (tool: Tool) => tools.set(tool.name, tool),
35
+ getCommands: () => (skillLoaded ? [{ name: "skill:gentle-design", source: "skill" }] : []),
36
+ sendUserMessage: (content: unknown, options?: unknown) => sent.push({ content, options }),
37
+ on: (event: string, handler: (...args: unknown[]) => unknown) => {
38
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
39
+ return () => {};
40
+ },
41
+ } as unknown as ExtensionAPI;
42
+ const emit = async (event: string, payload: unknown) => {
43
+ for (const handler of handlers.get(event) ?? []) await handler(payload, {});
44
+ };
45
+ return { pi, sent, emit, command: () => commands.get(DESIGN_COMMAND_NAME)!, tool: (name: string) => tools.get(name) };
46
+ }
47
+
48
+ function fakeContext(cwd: string, idle = true) {
49
+ const notices: Array<{ message: string; level?: string }> = [];
50
+ const state = { idle };
51
+ const ctx = {
52
+ hasUI: true,
53
+ cwd,
54
+ isIdle: () => state.idle,
55
+ sessionManager: { getCwd: () => cwd },
56
+ ui: { notify: (message: string, level?: string) => notices.push({ message, level }) },
57
+ } as unknown as ExtensionContext;
58
+ return { ctx, notices, state };
59
+ }
60
+
61
+ const scratchDirs: string[] = [];
62
+ test.after(() => {
63
+ for (const dir of scratchDirs) rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ function scratchCwd(): string {
67
+ const dir = mkdtempSync(join(tmpdir(), "gentle-design-test-"));
68
+ scratchDirs.push(dir);
69
+ return dir;
70
+ }
71
+
72
+ function writeDesign(cwd: string, slug: string, title: string, mtimeSeconds?: number) {
73
+ mkdirSync(join(cwd, "design", slug), { recursive: true });
74
+ const manifest = join(cwd, "design", slug, "design.json");
75
+ writeFileSync(manifest, JSON.stringify({ title, brief: "b", artboards: [{ file: "01-a.html", title: "A", tradeoff: "t", width: 800, height: 600 }] }));
76
+ writeFileSync(join(cwd, "design", slug, "01-a.html"), "<h1>A</h1>");
77
+ if (mtimeSeconds !== undefined) utimesSync(manifest, mtimeSeconds, mtimeSeconds);
78
+ }
79
+
80
+ const unexpectedServer = async (): Promise<DesignCanvasServer> => { throw new Error("no canvas server expected"); };
81
+ const unexpectedBrowser = async (): Promise<OpenInBrowserResult> => { throw new Error("no browser expected"); };
82
+
83
+ /** Fake canvas server factory: records starts, closes, shows and the comment callback; `pages.connected` is its open canvas page count. */
84
+ function fakeCanvas(opened: OpenInBrowserResult = { opened: true }) {
85
+ const starts: DesignCanvasServerOptions[] = [];
86
+ const opens: string[] = [];
87
+ const shows: Array<string | null> = [];
88
+ const pages = { connected: 0 };
89
+ let closes = 0;
90
+ const startServer = async (options: DesignCanvasServerOptions): Promise<DesignCanvasServer> => {
91
+ starts.push(options);
92
+ const base = "http://127.0.0.1:4100/tok/";
93
+ return {
94
+ port: 4100,
95
+ token: "tok",
96
+ url: options.initialSlug ? `${base}d/${options.initialSlug}/` : base,
97
+ close: async () => { closes++; },
98
+ connectedClients: () => pages.connected,
99
+ show: (slug) => { shows.push(slug); return pages.connected; },
100
+ };
101
+ };
102
+ const openBrowser = async (url: string) => { opens.push(url); return opened; };
103
+ return { startServer, openBrowser, starts, opens, shows, pages, closes: () => closes };
104
+ }
105
+
106
+ test("registers /gentle:design with help text naming the brief form and every subcommand", () => {
107
+ const { pi, command } = fakePi();
108
+ gentleDesign(pi);
109
+ assert.equal(DESIGN_COMMAND_NAME, "gentle:design");
110
+ const description = command().description;
111
+ for (const word of ["<brief>", "open", "stop", "sync", "implement"]) assert.ok(description.includes(word), `help names ${word}`);
112
+ });
113
+
114
+ test("a brief sends exactly one message that runs the gentle-design skill with that brief", async () => {
115
+ const { pi, sent, command } = fakePi();
116
+ gentleDesign(pi);
117
+ const { ctx, notices } = fakeContext(scratchCwd());
118
+ await command().handler(" a pricing page for a note-taking app ", ctx);
119
+ assert.equal(sent.length, 1);
120
+ assert.equal(sent[0].content, "/skill:gentle-design a pricing page for a note-taking app");
121
+ assert.deepEqual(sent[0].options, { expandPromptTemplates: true });
122
+ assert.deepEqual(notices, []);
123
+ });
124
+
125
+ test("without the skill loaded, the message points the model at the bundled skill file", async () => {
126
+ const { pi, sent, command } = fakePi(false);
127
+ gentleDesign(pi);
128
+ await command().handler("a login screen", fakeContext(scratchCwd()).ctx);
129
+ assert.equal(sent.length, 1);
130
+ const content = String(sent[0].content);
131
+ assert.ok(!content.startsWith("/"), "a plain message, not a slash command");
132
+ assert.ok(content.includes(SKILL_PATH), "names the skill file by absolute path");
133
+ assert.ok(content.includes("a login screen"));
134
+ });
135
+
136
+ test("while the agent is busy the design run is queued as a follow-up", async () => {
137
+ const { pi, sent, command } = fakePi();
138
+ gentleDesign(pi);
139
+ await command().handler("a dashboard", fakeContext(scratchCwd(), false).ctx);
140
+ assert.equal(sent.length, 1);
141
+ assert.deepEqual(sent[0].options, { expandPromptTemplates: true, deliverAs: "followUp" });
142
+ });
143
+
144
+ test("no arguments and no designs prints usage only and sends nothing", async () => {
145
+ const { pi, sent, command } = fakePi();
146
+ gentleDesign(pi);
147
+ const { ctx, notices } = fakeContext(scratchCwd());
148
+ await command().handler("", ctx);
149
+ assert.equal(sent.length, 0);
150
+ assert.equal(notices.length, 1);
151
+ assert.match(notices[0].message, /\/gentle:design <brief>/);
152
+ assert.doesNotMatch(notices[0].message, /Designs in this project/);
153
+ });
154
+
155
+ test("a whitespace-only brief behaves like no arguments", async () => {
156
+ const { pi, sent, command } = fakePi();
157
+ gentleDesign(pi);
158
+ const { ctx, notices } = fakeContext(scratchCwd());
159
+ await command().handler(" \t\n ", ctx);
160
+ assert.equal(sent.length, 0);
161
+ assert.equal(notices.length, 1);
162
+ assert.match(notices[0].message, /\/gentle:design <brief>/);
163
+ });
164
+
165
+ test("open starts one canvas server on design/, opens the slug URL and notifies it; a second open reuses it", async () => {
166
+ const cwd = scratchCwd();
167
+ writeDesign(cwd, "pricing-page", "Pricing page");
168
+ writeDesign(cwd, "login", "Login screen");
169
+ const { pi, sent, command } = fakePi();
170
+ const canvas = fakeCanvas();
171
+ gentleDesign(pi, canvas);
172
+ const { ctx, notices } = fakeContext(cwd);
173
+ await command().handler("open pricing-page", ctx);
174
+ assert.equal(canvas.starts.length, 1);
175
+ assert.equal(canvas.starts[0].designRoot, join(cwd, "design"));
176
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/d/pricing-page/"]);
177
+ assert.deepEqual(notices, [{ message: "Design canvas: http://127.0.0.1:4100/tok/d/pricing-page/", level: "info" }]);
178
+ await command().handler("open login", ctx);
179
+ assert.equal(canvas.starts.length, 1, "the running server is reused");
180
+ assert.deepEqual(canvas.opens.at(-1), "http://127.0.0.1:4100/tok/d/login/");
181
+ assert.equal(notices.at(-1)!.message, "Design canvas: http://127.0.0.1:4100/tok/d/login/");
182
+ assert.equal(sent.length, 0);
183
+ });
184
+
185
+ // S14: while a canvas page is connected, open refreshes it instead of opening another tab.
186
+
187
+ test("open refreshes the connected canvas page instead of opening the browser again", async () => {
188
+ const cwd = scratchCwd();
189
+ writeDesign(cwd, "pricing-page", "Pricing page", 2000);
190
+ writeDesign(cwd, "login", "Login screen", 1000);
191
+ const { pi, command } = fakePi();
192
+ const canvas = fakeCanvas();
193
+ gentleDesign(pi, canvas);
194
+ const { ctx, notices } = fakeContext(cwd);
195
+ await command().handler("open login", ctx);
196
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/d/login/"], "the first open spawns the browser");
197
+ assert.deepEqual(canvas.shows, []);
198
+ canvas.pages.connected = 1;
199
+ await command().handler("open login", ctx);
200
+ assert.equal(canvas.opens.length, 1, "no new tab while a page is connected");
201
+ assert.deepEqual(canvas.shows, ["login"]);
202
+ assert.deepEqual(notices.at(-1), { message: "Design canvas refreshed: http://127.0.0.1:4100/tok/d/login/", level: "info" });
203
+ await command().handler("", ctx);
204
+ assert.deepEqual(canvas.shows, ["login", "pricing-page"], "no-args open switches the same page to the latest design");
205
+ assert.equal(notices.at(-1)!.message, "Design canvas refreshed: http://127.0.0.1:4100/tok/d/pricing-page/");
206
+ canvas.pages.connected = 0;
207
+ await command().handler("open login", ctx);
208
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/d/login/", "http://127.0.0.1:4100/tok/d/login/"], "a closed page lets open spawn the browser again");
209
+ assert.equal(canvas.shows.length, 2);
210
+ assert.equal(notices.at(-1)!.message, "Design canvas: http://127.0.0.1:4100/tok/d/login/");
211
+ assert.equal(canvas.starts.length, 1);
212
+ });
213
+
214
+ test("open on a server it had to start always opens the browser, even after a stop", async () => {
215
+ const cwd = scratchCwd();
216
+ writeDesign(cwd, "login", "Login screen");
217
+ const { pi, command } = fakePi();
218
+ const canvas = fakeCanvas();
219
+ gentleDesign(pi, canvas);
220
+ const { ctx } = fakeContext(cwd);
221
+ canvas.pages.connected = 1;
222
+ await command().handler("open login", ctx);
223
+ assert.equal(canvas.opens.length, 1, "a new server has no page of its own yet");
224
+ await command().handler("stop", ctx);
225
+ await command().handler("open login", ctx);
226
+ assert.equal(canvas.starts.length, 2);
227
+ assert.equal(canvas.opens.length, 2);
228
+ assert.deepEqual(canvas.shows, []);
229
+ });
230
+
231
+ test("with only the system board, a refresh shows the system-only view", async () => {
232
+ const cwd = scratchCwd();
233
+ mkdirSync(join(cwd, "design"), { recursive: true });
234
+ writeFileSync(join(cwd, "design", "system.html"), "<h1>Tokens</h1>");
235
+ const { pi, command } = fakePi();
236
+ const canvas = fakeCanvas();
237
+ gentleDesign(pi, canvas);
238
+ const { ctx, notices } = fakeContext(cwd);
239
+ await command().handler("open", ctx);
240
+ canvas.pages.connected = 2;
241
+ await command().handler("open", ctx);
242
+ assert.equal(canvas.opens.length, 1);
243
+ assert.deepEqual(canvas.shows, [null]);
244
+ assert.equal(notices.at(-1)!.message, "Design canvas refreshed: http://127.0.0.1:4100/tok/");
245
+ });
246
+
247
+ test("when the browser cannot open, the URL is still printed with a manual hint", async () => {
248
+ const cwd = scratchCwd();
249
+ writeDesign(cwd, "login", "Login screen");
250
+ const { pi, command } = fakePi();
251
+ const canvas = fakeCanvas({ opened: false, error: "spawn xdg-open ENOENT" });
252
+ gentleDesign(pi, canvas);
253
+ const { ctx, notices } = fakeContext(cwd);
254
+ await command().handler("open login", ctx);
255
+ assert.equal(notices.length, 1);
256
+ assert.equal(notices[0].level, "warning");
257
+ assert.equal(notices[0].message, "Design canvas: http://127.0.0.1:4100/tok/d/login/\nThe browser could not be opened (spawn xdg-open ENOENT). Open the URL manually.");
258
+ });
259
+
260
+ test("an unknown or invalid slug warns with the available slugs and starts nothing", async () => {
261
+ const cwd = scratchCwd();
262
+ writeDesign(cwd, "pricing-page", "Pricing page", 2000);
263
+ writeDesign(cwd, "login", "Login screen", 1000);
264
+ mkdirSync(join(cwd, "design", "no-manifest"), { recursive: true });
265
+ mkdirSync(join(cwd, "design", "broken"), { recursive: true });
266
+ writeFileSync(join(cwd, "design", "broken", "design.json"), "{not json");
267
+ utimesSync(join(cwd, "design", "broken", "design.json"), 500, 500);
268
+ for (const slug of ["missing", "../login", "Login", "no-manifest", "a b"]) {
269
+ const { pi, command } = fakePi();
270
+ const canvas = fakeCanvas();
271
+ gentleDesign(pi, canvas);
272
+ const { ctx, notices } = fakeContext(cwd);
273
+ await command().handler(`open ${slug}`, ctx);
274
+ assert.equal(canvas.starts.length, 0, slug);
275
+ assert.deepEqual(canvas.opens, [], slug);
276
+ assert.equal(notices.length, 1, slug);
277
+ assert.equal(notices[0].level, "warning", slug);
278
+ assert.equal(notices[0].message, `No design "${slug}" in design/. Available: pricing-page, login, broken.`, slug);
279
+ }
280
+ });
281
+
282
+ test("open without designs warns with usage and starts nothing", async () => {
283
+ const { pi, command } = fakePi();
284
+ const canvas = fakeCanvas();
285
+ gentleDesign(pi, canvas);
286
+ const { ctx, notices } = fakeContext(scratchCwd());
287
+ await command().handler("open", ctx);
288
+ assert.equal(canvas.starts.length, 0);
289
+ assert.equal(notices.length, 1);
290
+ assert.equal(notices[0].level, "warning");
291
+ assert.match(notices[0].message, /No designs in design\/ yet/);
292
+ assert.match(notices[0].message, /\/gentle:design <brief>/);
293
+ });
294
+
295
+ test("open <slug> without designs keeps the same no-designs warning as open", async () => {
296
+ const { pi, command } = fakePi();
297
+ const canvas = fakeCanvas();
298
+ gentleDesign(pi, canvas);
299
+ const cwd = scratchCwd();
300
+ const bare = fakeContext(cwd);
301
+ await command().handler("open", bare.ctx);
302
+ const named = fakeContext(cwd);
303
+ await command().handler("open foo", named.ctx);
304
+ assert.equal(canvas.starts.length, 0);
305
+ assert.deepEqual(named.notices, bare.notices);
306
+ });
307
+
308
+ test("no arguments with designs opens the most recent design by design.json mtime", async () => {
309
+ const cwd = scratchCwd();
310
+ writeDesign(cwd, "alpha", "Alpha", 1000);
311
+ writeDesign(cwd, "zeta", "Zeta", 3000);
312
+ writeDesign(cwd, "beta", "Beta", 2000);
313
+ for (const args of ["", "open"]) {
314
+ const { pi, sent, command } = fakePi();
315
+ const canvas = fakeCanvas();
316
+ gentleDesign(pi, canvas);
317
+ const { ctx, notices } = fakeContext(cwd);
318
+ await command().handler(args, ctx);
319
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/d/zeta/"], JSON.stringify(args));
320
+ assert.equal(notices.length, 1);
321
+ assert.equal(notices[0].message, "Design canvas: http://127.0.0.1:4100/tok/d/zeta/");
322
+ assert.equal(sent.length, 0);
323
+ }
324
+ });
325
+
326
+ test("stop closes the running canvas, and reports when none is running", async () => {
327
+ const cwd = scratchCwd();
328
+ writeDesign(cwd, "login", "Login screen");
329
+ const { pi, command } = fakePi();
330
+ const canvas = fakeCanvas();
331
+ gentleDesign(pi, canvas);
332
+ const { ctx, notices } = fakeContext(cwd);
333
+ await command().handler("stop", ctx);
334
+ assert.deepEqual(notices.at(-1), { message: "No design canvas is running.", level: "info" });
335
+ await command().handler("open login", ctx);
336
+ await command().handler("stop", ctx);
337
+ assert.equal(canvas.closes(), 1);
338
+ assert.deepEqual(notices.at(-1), { message: "Design canvas stopped.", level: "info" });
339
+ await command().handler("stop", ctx);
340
+ assert.equal(notices.at(-1)!.message, "No design canvas is running.");
341
+ await command().handler("open login", ctx);
342
+ assert.equal(canvas.starts.length, 2, "open after stop starts a fresh server");
343
+ });
344
+
345
+ test("session shutdown closes the canvas server", async () => {
346
+ const cwd = scratchCwd();
347
+ writeDesign(cwd, "login", "Login screen");
348
+ const { pi, emit, command } = fakePi();
349
+ const canvas = fakeCanvas();
350
+ gentleDesign(pi, canvas);
351
+ await emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
352
+ assert.equal(canvas.closes(), 0, "nothing to close yet");
353
+ await command().handler("open login", fakeContext(cwd).ctx);
354
+ await emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
355
+ assert.equal(canvas.closes(), 1);
356
+ });
357
+
358
+ test("a canvas comment reaches the session, as a follow-up while the agent is busy", async () => {
359
+ const cwd = scratchCwd();
360
+ writeDesign(cwd, "login", "Login screen");
361
+ const { pi, sent, command } = fakePi();
362
+ const canvas = fakeCanvas();
363
+ gentleDesign(pi, canvas);
364
+ const { ctx, state } = fakeContext(cwd);
365
+ await command().handler("open login", ctx);
366
+ const comment: DesignComment = { id: "abc123", slug: "login", file: "01-a.html", selector: "body > h1", excerpt: "A", comment: "Bigger" };
367
+ await canvas.starts[0].onComment(comment);
368
+ assert.deepEqual(sent.at(-1), { content: formatDesignComment(comment), options: undefined });
369
+ state.idle = false;
370
+ await canvas.starts[0].onComment(comment);
371
+ assert.deepEqual(sent.at(-1), { content: formatDesignComment(comment), options: { deliverAs: "followUp" } });
372
+ assert.equal(sent.length, 2);
373
+ const batch = [comment, { ...comment, id: "def456", comment: "Smaller" }];
374
+ await canvas.starts[0].onComments!(batch);
375
+ assert.deepEqual(sent.at(-1), { content: formatDesignCommentBatch(batch), options: { deliverAs: "followUp" } });
376
+ assert.equal(sent.length, 3, "a batch is one message");
377
+ });
378
+
379
+ test("integration: a comment POSTed to the real canvas server sends one session message", async (t) => {
380
+ const cwd = scratchCwd();
381
+ writeDesign(cwd, "login", "Login screen");
382
+ const { pi, sent, emit, command } = fakePi();
383
+ const opens: string[] = [];
384
+ gentleDesign(pi, { openBrowser: async (url) => { opens.push(url); return { opened: true }; } });
385
+ t.after(() => emit("session_shutdown", { type: "session_shutdown", reason: "quit" }));
386
+ await command().handler("open login", fakeContext(cwd).ctx);
387
+ assert.equal(opens.length, 1);
388
+ const url = new URL(opens[0]);
389
+ assert.equal(url.hostname, "127.0.0.1");
390
+ const token = url.pathname.split("/")[1];
391
+ const body = JSON.stringify({ slug: "login", file: "01-a.html", selector: "body > h1", excerpt: " A ", comment: "Make it red" });
392
+ const status = await new Promise<number>((resolve, reject) => {
393
+ const req = httpRequest({ host: "127.0.0.1", port: url.port, path: `/${token}/api/comments`, method: "POST", headers: { Origin: url.origin, "Content-Type": "application/json" } }, (res) => {
394
+ res.resume();
395
+ res.on("end", () => resolve(res.statusCode ?? 0));
396
+ });
397
+ req.on("error", reject);
398
+ req.end(body);
399
+ });
400
+ assert.equal(status, 202);
401
+ const stored = JSON.parse(readFileSync(join(cwd, "design", "login", "comments.json"), "utf8")).comments;
402
+ assert.equal(stored.length, 1);
403
+ assert.notEqual(stored[0].sentAt, null);
404
+ assert.deepEqual(sent, [{ content: formatDesignComment({ id: stored[0].id, slug: "login", file: "01-a.html", selector: "body > h1", excerpt: "A", comment: "Make it red" }), options: undefined }]);
405
+ });
406
+
407
+ test("a canvas comment with a screenshot reaches the session as text and an attached image", async () => {
408
+ const cwd = scratchCwd();
409
+ writeDesign(cwd, "login", "Login screen");
410
+ const { pi, sent, command } = fakePi();
411
+ const canvas = fakeCanvas();
412
+ gentleDesign(pi, canvas);
413
+ await command().handler("open login", fakeContext(cwd).ctx);
414
+ const comment: DesignComment = { id: "abc123", slug: "login", file: "01-a.html", selector: "body > h1", excerpt: "A", comment: "It flickers here", screenshot: { path: "design/login/.screenshots/abc123.png", mimeType: "image/png", data: "iVBORw0KGgo=" } };
415
+ await canvas.starts[0].onComment(comment);
416
+ assert.deepEqual(sent.at(-1), { content: [{ type: "text", text: formatDesignComment(comment) }, { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }], options: undefined });
417
+ await canvas.starts[0].onComments!([comment, { ...comment, id: "def456", screenshot: undefined }]);
418
+ const batch = sent.at(-1)!.content as Array<{ type: string }>;
419
+ assert.deepEqual(batch.map((part) => part.type), ["text", "image"], "a batch is one message with its screenshots attached");
420
+ });
421
+
422
+ test("integration: a comment POSTed with a screenshot reaches the session with the image", async (t) => {
423
+ const cwd = scratchCwd();
424
+ writeDesign(cwd, "login", "Login screen");
425
+ const { pi, sent, emit, command } = fakePi();
426
+ const opens: string[] = [];
427
+ gentleDesign(pi, { openBrowser: async (url) => { opens.push(url); return { opened: true }; } });
428
+ t.after(() => emit("session_shutdown", { type: "session_shutdown", reason: "quit" }));
429
+ await command().handler("open login", fakeContext(cwd).ctx);
430
+ const url = new URL(opens[0]);
431
+ const token = url.pathname.split("/")[1];
432
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]).toString("base64");
433
+ const body = JSON.stringify({ slug: "login", file: "01-a.html", selector: "body > h1", excerpt: "A", comment: "Mid-transition", screenshot: { mimeType: "image/png", data: png } });
434
+ const status = await new Promise<number>((resolve, reject) => {
435
+ const req = httpRequest({ host: "127.0.0.1", port: url.port, path: `/${token}/api/comments`, method: "POST", headers: { Origin: url.origin, "Content-Type": "application/json" } }, (res) => {
436
+ res.resume();
437
+ res.on("end", () => resolve(res.statusCode ?? 0));
438
+ });
439
+ req.on("error", reject);
440
+ req.end(body);
441
+ });
442
+ assert.equal(status, 202);
443
+ const [stored] = JSON.parse(readFileSync(join(cwd, "design", "login", "comments.json"), "utf8")).comments;
444
+ assert.equal(stored.screenshot, `.screenshots/${stored.id}.png`);
445
+ const path = `design/login/.screenshots/${stored.id}.png`;
446
+ const text = formatDesignComment({ id: stored.id, slug: "login", file: "01-a.html", selector: "body > h1", excerpt: "A", comment: "Mid-transition", screenshot: { path, mimeType: "image/png", data: png } });
447
+ assert.deepEqual(sent, [{ content: [{ type: "text", text }, { type: "image", data: png, mimeType: "image/png" }], options: undefined }]);
448
+ });
449
+
450
+ function writeLinkedDesign(cwd: string, slug: string, feature?: unknown) {
451
+ mkdirSync(join(cwd, "design", slug), { recursive: true });
452
+ writeFileSync(join(cwd, "design", slug, "design.json"), JSON.stringify({
453
+ title: "Login screen",
454
+ brief: "b",
455
+ ...(feature === undefined ? {} : { feature }),
456
+ artboards: [
457
+ { file: "01-split.html", title: "Split layout", tradeoff: "Calm; less room for the form", width: 1440, height: 900 },
458
+ { file: "02-card.html", title: "Centered card", tradeoff: "Focused; generic", width: 1440, height: 900 },
459
+ ],
460
+ }));
461
+ writeFileSync(join(cwd, "design", slug, "01-split.html"), "<h1>Split</h1>");
462
+ writeFileSync(join(cwd, "design", slug, "02-card.html"), "<h1>Card</h1>");
463
+ }
464
+
465
+ test("implement sends one handoff message with the artboard, tokens, open comments and feature link", async () => {
466
+ const cwd = scratchCwd();
467
+ writeLinkedDesign(cwd, "login", "odd/tasks/login-page.md");
468
+ writeFileSync(join(cwd, "design", "system.md"), "# Tokens");
469
+ seedComments(cwd, "login", [
470
+ { id: "open01", file: "01-split.html", comment: "Make the headline shorter" },
471
+ { id: "done01", file: "01-split.html", status: "resolved" },
472
+ { id: "other1", file: "02-card.html" },
473
+ ]);
474
+ const { pi, sent, command } = fakePi();
475
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
476
+ const { ctx, notices } = fakeContext(cwd);
477
+ await command().handler("implement login/01-split.html", ctx);
478
+ assert.deepEqual(notices, []);
479
+ assert.equal(sent.length, 1);
480
+ assert.equal(sent[0].options, undefined);
481
+ const content = String(sent[0].content);
482
+ for (const part of ["design/login/01-split.html", "Split layout", "Calm; less room for the form", "real stack", "visual spec", "existing components", "verbatim", "design/system.md", "open01", "body > h1", "\"A\"", "Make the headline shorter", "gentle_design_comments", "track this implementation in odd/tasks/login-page.md", "This handoff itself commits nothing.", 'gentle_design tool: action "handoff", target "login/01-split.html"', 'status "in_progress" when you start', 'status "implemented" when it is done', "previewUrl"]) {
483
+ assert.ok(content.includes(part), `the message names ${part}`);
484
+ }
485
+ for (const absent of ["done01", "other1", "ODD rules as usual"]) assert.ok(!content.includes(absent), `the message omits ${absent}`);
486
+ });
487
+
488
+ test("implement without system.md, comments or a valid feature falls back to the ODD rules", async () => {
489
+ const cwd = scratchCwd();
490
+ writeLinkedDesign(cwd, "login", "../outside.md");
491
+ const { pi, sent, command } = fakePi();
492
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
493
+ await command().handler("implement login/02-card.html", fakeContext(cwd).ctx);
494
+ assert.equal(sent.length, 1);
495
+ const content = String(sent[0].content);
496
+ assert.ok(content.includes("design/login/02-card.html"));
497
+ assert.ok(content.includes("size it under the project's ODD rules as usual"));
498
+ assert.ok(content.includes("This handoff itself commits nothing."));
499
+ for (const absent of ["design/system.md", "track this implementation", "outside.md", "gentle_design_comments"]) assert.ok(!content.includes(absent), `the message omits ${absent}`);
500
+ });
501
+
502
+ test("implement reports an invalid feature link instead of dropping it silently", async () => {
503
+ const cwd = scratchCwd();
504
+ writeLinkedDesign(cwd, "login", "../outside.md");
505
+ const { pi, sent, command } = fakePi();
506
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
507
+ const { ctx, notices } = fakeContext(cwd);
508
+ await command().handler("implement login/02-card.html", ctx);
509
+ assert.equal(sent.length, 1);
510
+ assert.deepEqual(notices, [{ message: "design/login/design.json: feature must be a relative odd/tasks/<name>.md path; it was ignored", level: "warning" }]);
511
+ });
512
+
513
+ test("implement with a valid feature link reports nothing", async () => {
514
+ const cwd = scratchCwd();
515
+ writeLinkedDesign(cwd, "login");
516
+ const { pi, sent, command } = fakePi();
517
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
518
+ const { ctx, notices } = fakeContext(cwd);
519
+ await command().handler("implement login/01-split.html", ctx);
520
+ assert.equal(sent.length, 1);
521
+ assert.deepEqual(notices, []);
522
+ });
523
+
524
+ test("implement of an unknown design or artboard warns with the valid targets and sends nothing", async () => {
525
+ const cwd = scratchCwd();
526
+ writeLinkedDesign(cwd, "login");
527
+ for (const args of ["implement", "implement login", "implement missing/01-split.html", "implement login/99-none.html", "implement login/../x.html"]) {
528
+ const { pi, sent, command } = fakePi();
529
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
530
+ const { ctx, notices } = fakeContext(cwd);
531
+ await command().handler(args, ctx);
532
+ assert.equal(sent.length, 0, args);
533
+ assert.equal(notices.length, 1, args);
534
+ assert.equal(notices[0].level, "warning", args);
535
+ assert.match(notices[0].message, /login\/01-split\.html, login\/02-card\.html/, args);
536
+ }
537
+ const { pi, sent, command } = fakePi();
538
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
539
+ const { ctx, notices } = fakeContext(scratchCwd());
540
+ await command().handler("implement login/01-split.html", ctx);
541
+ assert.equal(sent.length, 0);
542
+ assert.match(notices[0].message, /No designs in design\/ yet/);
543
+ });
544
+
545
+ test("implement is queued as a follow-up while the agent is busy", async () => {
546
+ const cwd = scratchCwd();
547
+ writeLinkedDesign(cwd, "login");
548
+ const { pi, sent, command } = fakePi();
549
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
550
+ await command().handler("implement login/01-split.html", fakeContext(cwd, false).ctx);
551
+ assert.equal(sent.length, 1);
552
+ assert.deepEqual(sent[0].options, { deliverAs: "followUp" });
553
+ });
554
+
555
+ test("integration: the canvas Implement route sends the same text as the command", async (t) => {
556
+ const cwd = scratchCwd();
557
+ writeLinkedDesign(cwd, "login", "odd/tasks/login-page.md");
558
+ writeFileSync(join(cwd, "design", "system.md"), "# Tokens");
559
+ seedComments(cwd, "login", [{ id: "open01", file: "01-split.html" }]);
560
+ const { pi, sent, emit, command } = fakePi();
561
+ const opens: string[] = [];
562
+ gentleDesign(pi, { openBrowser: async (url) => { opens.push(url); return { opened: true }; } });
563
+ t.after(() => emit("session_shutdown", { type: "session_shutdown", reason: "quit" }));
564
+ const { ctx } = fakeContext(cwd);
565
+ await command().handler("implement login/01-split.html", ctx);
566
+ await command().handler("open login", ctx);
567
+ const url = new URL(opens[0]);
568
+ const token = url.pathname.split("/")[1];
569
+ const status = await new Promise<number>((resolve, reject) => {
570
+ const req = httpRequest({ host: "127.0.0.1", port: url.port, path: `/${token}/api/designs/login/implement`, method: "POST", headers: { Origin: url.origin, "Content-Type": "application/json" } }, (res) => {
571
+ res.resume();
572
+ res.on("end", () => resolve(res.statusCode ?? 0));
573
+ });
574
+ req.on("error", reject);
575
+ req.end(JSON.stringify({ file: "01-split.html" }));
576
+ });
577
+ assert.equal(status, 202);
578
+ assert.equal(sent.length, 2);
579
+ assert.equal(sent[1].content, sent[0].content);
580
+ assert.ok(String(sent[0].content).includes("open01"));
581
+ });
582
+
583
+ test("with only design/system.html, no arguments and open start the canvas on the system board", async () => {
584
+ const cwd = scratchCwd();
585
+ mkdirSync(join(cwd, "design"), { recursive: true });
586
+ writeFileSync(join(cwd, "design", "system.html"), "<h1>Tokens</h1>");
587
+ for (const args of ["", "open"]) {
588
+ const { pi, sent, command } = fakePi();
589
+ const canvas = fakeCanvas();
590
+ gentleDesign(pi, canvas);
591
+ const { ctx, notices } = fakeContext(cwd);
592
+ await command().handler(args, ctx);
593
+ assert.equal(canvas.starts.length, 1, JSON.stringify(args));
594
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/"], JSON.stringify(args));
595
+ assert.deepEqual(notices, [{ message: "Design canvas: http://127.0.0.1:4100/tok/", level: "info" }]);
596
+ assert.equal(sent.length, 0);
597
+ }
598
+ const { pi, command } = fakePi();
599
+ const canvas = fakeCanvas();
600
+ gentleDesign(pi, canvas);
601
+ const { ctx, notices } = fakeContext(cwd);
602
+ await command().handler("open login", ctx);
603
+ assert.equal(canvas.starts.length, 0, "a named design still has to exist");
604
+ assert.equal(notices[0].level, "warning");
605
+ });
606
+
607
+ test("the skill documents the feature field and the ODD link rule", () => {
608
+ const source = readFileSync(SKILL_PATH, "utf8");
609
+ assert.ok(source.includes("\"feature\": \"odd/tasks/"), "the manifest example carries a feature link");
610
+ const start = source.indexOf("ODD link");
611
+ assert.ok(start !== -1, "SKILL.md has an ODD link rule");
612
+ for (const part of ["odd/tasks/", "## Log", "standalone"]) assert.ok(source.includes(part), `SKILL.md mentions ${part}`);
613
+ });
614
+
615
+ function toolText(result: ToolResult): string {
616
+ return result.content.map((part) => part.text).join("\n");
617
+ }
618
+
619
+ function seedComments(cwd: string, slug: string, comments: Array<{ id: string; file?: string; status?: string; comment?: string }>) {
620
+ const entries = comments.map((entry) => ({
621
+ id: entry.id,
622
+ file: entry.file ?? "01-a.html",
623
+ selector: "body > h1",
624
+ excerpt: "A",
625
+ comment: entry.comment ?? `comment ${entry.id}`,
626
+ createdAt: "2026-10-26T10:00:00.000Z",
627
+ status: entry.status ?? "open",
628
+ sentAt: null,
629
+ resolvedAt: entry.status === "resolved" ? "2026-10-26T11:00:00.000Z" : null,
630
+ }));
631
+ writeFileSync(join(cwd, "design", slug, "comments.json"), JSON.stringify({ version: 1, comments: entries }));
632
+ }
633
+
634
+ test("the gentle_design_comments tool lists open comments, by design and artboard", async () => {
635
+ const cwd = scratchCwd();
636
+ // Designs are listed most recent first.
637
+ writeDesign(cwd, "login", "Login screen", 2000);
638
+ writeDesign(cwd, "pricing", "Pricing", 1000);
639
+ seedComments(cwd, "login", [{ id: "aaa111", comment: "Bigger title" }, { id: "bbb222", status: "resolved" }, { id: "ccc333", file: "02-b.html" }]);
640
+ seedComments(cwd, "pricing", [{ id: "ddd444", comment: "Cheaper" }]);
641
+ const { pi, tool } = fakePi();
642
+ gentleDesign(pi);
643
+ const comments = tool("gentle_design_comments");
644
+ assert.ok(comments, "the tool is registered");
645
+ assert.match(comments.description, /comments\.json/);
646
+ const ctx = fakeContext(cwd).ctx;
647
+ const all = await comments.execute("1", { action: "list" }, undefined, undefined, ctx);
648
+ const text = toolText(all);
649
+ for (const id of ["aaa111", "ccc333", "ddd444"]) assert.ok(text.includes(id), id);
650
+ assert.ok(!text.includes("bbb222"), "resolved comments are not listed");
651
+ assert.ok(text.includes("design/login/01-a.html") && text.includes("Bigger title"));
652
+ assert.deepEqual((all.details as { comments: Array<{ id: string }> }).comments.map((entry) => entry.id), ["aaa111", "ccc333", "ddd444"]);
653
+ const one = await comments.execute("2", { action: "list", slug: "login", file: "02-b.html" }, undefined, undefined, ctx);
654
+ assert.deepEqual((one.details as { comments: Array<{ id: string }> }).comments.map((entry) => entry.id), ["ccc333"]);
655
+ const none = await comments.execute("3", { action: "list", slug: "pricing", file: "09-z.html" }, undefined, undefined, ctx);
656
+ assert.match(toolText(none), /No open comments/);
657
+ });
658
+
659
+ test("the gentle_design_comments tool names a comment's screenshot for the read tool", async () => {
660
+ const cwd = scratchCwd();
661
+ writeDesign(cwd, "login", "Login screen");
662
+ seedComments(cwd, "login", [{ id: "aaa111" }, { id: "bbb222" }]);
663
+ const path = join(cwd, "design", "login", "comments.json");
664
+ const document = JSON.parse(readFileSync(path, "utf8"));
665
+ document.comments[0].screenshot = ".screenshots/aaa111.png";
666
+ writeFileSync(path, JSON.stringify(document));
667
+ const { pi, tool } = fakePi();
668
+ gentleDesign(pi);
669
+ const text = toolText(await tool("gentle_design_comments")!.execute("1", { action: "list", slug: "login" }, undefined, undefined, fakeContext(cwd).ctx));
670
+ assert.match(text, /\[aaa111\][^]*\n Screenshot: design\/login\/\.screenshots\/aaa111\.png \(open it with the read tool\)\n- \[bbb222\]/);
671
+ assert.equal(text.match(/Screenshot:/g)?.length, 1);
672
+ });
673
+
674
+ test("the gentle_design_comments tool resolves comments by id and writes comments.json", async () => {
675
+ const cwd = scratchCwd();
676
+ writeDesign(cwd, "login", "Login screen");
677
+ seedComments(cwd, "login", [{ id: "aaa111" }, { id: "bbb222" }]);
678
+ const { pi, tool } = fakePi();
679
+ gentleDesign(pi);
680
+ const ctx = fakeContext(cwd).ctx;
681
+ const result = await tool("gentle_design_comments")!.execute("1", { action: "resolve", slug: "login", ids: ["aaa111", "zzz999"] }, undefined, undefined, ctx);
682
+ assert.match(toolText(result), /Resolved 1 comment/);
683
+ assert.match(toolText(result), /zzz999/);
684
+ const stored = JSON.parse(readFileSync(join(cwd, "design", "login", "comments.json"), "utf8")).comments;
685
+ assert.equal(stored[0].status, "resolved");
686
+ assert.notEqual(stored[0].resolvedAt, null);
687
+ assert.equal(stored[1].status, "open");
688
+ await assert.rejects(tool("gentle_design_comments")!.execute("2", { action: "resolve", slug: "login", ids: [] }, undefined, undefined, ctx), /ids/);
689
+ await assert.rejects(tool("gentle_design_comments")!.execute("3", { action: "resolve", ids: ["aaa111"] }, undefined, undefined, ctx), /slug/);
690
+ await assert.rejects(tool("gentle_design_comments")!.execute("4", { action: "resolve", slug: "missing", ids: ["aaa111"] }, undefined, undefined, ctx), /missing/);
691
+ writeFileSync(join(cwd, "design", "login", "comments.json"), "{ broken");
692
+ await assert.rejects(tool("gentle_design_comments")!.execute("5", { action: "list", slug: "login" }, undefined, undefined, ctx), /comments\.json/);
693
+ assert.equal(readFileSync(join(cwd, "design", "login", "comments.json"), "utf8"), "{ broken");
694
+ });
695
+
696
+ // Advisory 8: the command and the canvas server list designs with one containment rule.
697
+ test("a symlinked design inside design/ is listed by the command and the server; one escaping it by neither", async (t) => {
698
+ const cwd = scratchCwd();
699
+ const outside = scratchCwd();
700
+ writeDesign(cwd, "real", "Real design", 3000);
701
+ // The link target lives inside design/ under a non-slug name, so only the link is a design.
702
+ mkdirSync(join(cwd, "design", "_shelf", "kept"), { recursive: true });
703
+ writeFileSync(join(cwd, "design", "_shelf", "kept", "design.json"), JSON.stringify({ title: "Linked", artboards: [{ file: "01-a.html", title: "A", width: 800, height: 600 }] }));
704
+ writeFileSync(join(cwd, "design", "_shelf", "kept", "01-a.html"), "<h1>A</h1>");
705
+ symlinkSync(join(cwd, "design", "_shelf", "kept"), join(cwd, "design", "linked"));
706
+ writeDesign(outside, "away", "Away");
707
+ symlinkSync(join(outside, "design", "away"), join(cwd, "design", "escaped"));
708
+ const { pi, command } = fakePi();
709
+ const canvas = fakeCanvas();
710
+ gentleDesign(pi, canvas);
711
+ const { ctx, notices } = fakeContext(cwd);
712
+ await command().handler("open linked", ctx);
713
+ assert.deepEqual(canvas.opens, ["http://127.0.0.1:4100/tok/d/linked/"]);
714
+ await command().handler("open escaped", ctx);
715
+ assert.equal(canvas.opens.length, 1);
716
+ assert.equal(notices.at(-1)!.level, "warning");
717
+ assert.match(notices.at(-1)!.message, /Available: /);
718
+ const available = notices.at(-1)!.message.replace(/^.*Available: /s, "").replace(/\.$/, "").split(", ").sort();
719
+ assert.deepEqual(available, ["linked", "real"]);
720
+ const server = await startDesignCanvasServer({ designRoot: join(cwd, "design"), onComment: () => {}, randomToken: () => "listing-token-0123456789" });
721
+ t.after(() => server.close());
722
+ const listed = await new Promise<string>((resolve, reject) => {
723
+ httpRequest({ host: "127.0.0.1", port: server.port, path: "/listing-token-0123456789/api/designs" }, (res) => {
724
+ let body = "";
725
+ res.setEncoding("utf8");
726
+ res.on("data", (chunk: string) => { body += chunk; });
727
+ res.on("end", () => resolve(body));
728
+ }).on("error", reject).end();
729
+ });
730
+ assert.deepEqual(JSON.parse(listed).designs.map((design: { slug: string }) => design.slug).sort(), available);
731
+ });
732
+
733
+ test("sync sends exactly one message that runs the gentle-design skill in sync mode", async () => {
734
+ const { pi, sent, command } = fakePi();
735
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
736
+ const { ctx, notices } = fakeContext(scratchCwd());
737
+ await command().handler("sync", ctx);
738
+ assert.equal(sent.length, 1);
739
+ const content = String(sent[0].content);
740
+ assert.ok(content.startsWith("/skill:gentle-design sync "), content);
741
+ for (const part of ["design/system.md", "design/system.html"]) assert.ok(content.includes(part), `names ${part}`);
742
+ assert.deepEqual(sent[0].options, { expandPromptTemplates: true });
743
+ assert.deepEqual(notices, []);
744
+ });
745
+
746
+ test("sync with extra words warns and sends nothing", async () => {
747
+ const { pi, sent, command } = fakePi();
748
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
749
+ const { ctx, notices } = fakeContext(scratchCwd());
750
+ await command().handler("sync only the buttons", ctx);
751
+ assert.equal(sent.length, 0);
752
+ assert.deepEqual(notices, [{ message: "/gentle:design sync takes no arguments.", level: "warning" }]);
753
+ });
754
+
755
+ test("sync is queued as a follow-up while the agent is busy", async () => {
756
+ const { pi, sent, command } = fakePi();
757
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
758
+ await command().handler("sync", fakeContext(scratchCwd(), false).ctx);
759
+ assert.equal(sent.length, 1);
760
+ assert.deepEqual(sent[0].options, { expandPromptTemplates: true, deliverAs: "followUp" });
761
+ });
762
+
763
+ test("without the skill loaded, sync points the model at the bundled skill file", async () => {
764
+ const { pi, sent, command } = fakePi(false);
765
+ gentleDesign(pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
766
+ await command().handler("sync", fakeContext(scratchCwd()).ctx);
767
+ assert.equal(sent.length, 1);
768
+ const content = String(sent[0].content);
769
+ assert.ok(!content.startsWith("/"), "a plain message, not a slash command");
770
+ assert.ok(content.includes(SKILL_PATH), "names the skill file by absolute path");
771
+ assert.ok(content.includes("Design system sync"), "names the skill section to follow");
772
+ assert.ok(content.includes("design/system.md"));
773
+ });
774
+
775
+ test("the skill's Design system sync section names both files and the self-check rule", () => {
776
+ const source = readFileSync(SKILL_PATH, "utf8");
777
+ const start = source.indexOf("## Design system sync");
778
+ assert.ok(start !== -1, "SKILL.md has a Design system sync section");
779
+ const section = source.slice(start, source.indexOf("\n## ", start + 1));
780
+ for (const part of ["design/system.md", "design/system.html", "1440", "Self-check", "never edit"]) {
781
+ assert.ok(section.includes(part), `the section mentions ${part}`);
782
+ }
783
+ });
784
+
785
+ test("skills/design/SKILL.md is named gentle-design and documents the manifest fields", () => {
786
+ const source = readFileSync(SKILL_PATH, "utf8");
787
+ assert.equal(__testing.parseFrontmatter(source).name, "gentle-design");
788
+ for (const field of ["design.json", "\"title\"", "\"brief\"", "\"artboards\"", "\"file\"", "\"tradeoff\"", "\"width\"", "\"height\"", "design/system.md", "Copy as prompt"]) {
789
+ assert.ok(source.includes(field), `SKILL.md mentions ${field}`);
790
+ }
791
+ });
792
+
793
+ // S11: the gentle_design tool reaches every /gentle:design action without the
794
+ // command, and a system-prompt hint makes the gentle-design flow the default.
795
+
796
+ function designTool(fake: ReturnType<typeof fakePi>) {
797
+ const tool = fake.tool("gentle_design");
798
+ assert.ok(tool, "the gentle_design tool is registered");
799
+ return (params: unknown, ctx: ExtensionContext) => tool.execute("call", params, undefined, undefined, ctx);
800
+ }
801
+
802
+ test("the gentle_design tool description says when to use it and leaves briefs and sync to the skill", () => {
803
+ const fake = fakePi();
804
+ gentleDesign(fake.pi);
805
+ const description = fake.tool("gentle_design")!.description;
806
+ for (const part of ["list", "open", "stop", "implement", "canvas", "<slug>/<artboard>", "gentle-design skill", "sync"]) assert.ok(description.includes(part), `the description names ${part}`);
807
+ });
808
+
809
+ test("gentle_design list names each design with its artboards, the system board and the canvas state", async () => {
810
+ const cwd = scratchCwd();
811
+ writeLinkedDesign(cwd, "login");
812
+ writeDesign(cwd, "pricing", "Pricing", 1000);
813
+ utimesSync(join(cwd, "design", "login", "design.json"), 2000, 2000);
814
+ writeFileSync(join(cwd, "design", "system.html"), "<h1>Tokens</h1>");
815
+ const fake = fakePi();
816
+ const canvas = fakeCanvas();
817
+ gentleDesign(fake.pi, canvas);
818
+ const run = designTool(fake);
819
+ const { ctx } = fakeContext(cwd);
820
+ const result = await run({ action: "list" }, ctx);
821
+ assert.equal(toolText(result), [
822
+ "Designs in design/ (2):",
823
+ "- login: Login screen",
824
+ " - 01-split.html: Split layout",
825
+ " - 02-card.html: Centered card",
826
+ "- pricing: Pricing",
827
+ " - 01-a.html: A",
828
+ "System board: design/system.html (shown first on the canvas).",
829
+ "Design sources: none detected.",
830
+ "Design system: no design/system.md.",
831
+ "Canvas: not running.",
832
+ ].join("\n"));
833
+ assert.deepEqual(result.details, {
834
+ designs: [
835
+ { slug: "login", title: "Login screen", artboards: [{ file: "01-split.html", title: "Split layout" }, { file: "02-card.html", title: "Centered card" }] },
836
+ { slug: "pricing", title: "Pricing", artboards: [{ file: "01-a.html", title: "A" }] },
837
+ ],
838
+ systemBoard: true,
839
+ designSources: { paths: [], total: 0 },
840
+ system: NO_SYSTEM,
841
+ canvas: { running: false },
842
+ });
843
+ assert.equal(canvas.starts.length, 0, "list starts nothing");
844
+ await run({ action: "open", slug: "pricing" }, ctx);
845
+ const running = await run({ action: "list" }, ctx);
846
+ assert.ok(toolText(running).endsWith("Canvas: running at http://127.0.0.1:4100/tok/d/pricing/"), toolText(running));
847
+ assert.deepEqual((running.details as { canvas: unknown }).canvas, { running: true, url: "http://127.0.0.1:4100/tok/d/pricing/" });
848
+ await run({ action: "stop" }, ctx);
849
+ assert.deepEqual(((await run({ action: "list" }, ctx)).details as { canvas: unknown }).canvas, { running: false });
850
+ });
851
+
852
+ test("gentle_design list with no designs, and with only the system board", async () => {
853
+ const fake = fakePi();
854
+ gentleDesign(fake.pi, fakeCanvas());
855
+ const run = designTool(fake);
856
+ const cwd = scratchCwd();
857
+ const empty = await run({ action: "list" }, fakeContext(cwd).ctx);
858
+ assert.equal(toolText(empty), "No designs in design/ yet.\nNo system board (design/system.html).\nDesign sources: none detected.\nDesign system: no design/system.md.\nCanvas: not running.");
859
+ assert.deepEqual(empty.details, { designs: [], systemBoard: false, designSources: { paths: [], total: 0 }, system: NO_SYSTEM, canvas: { running: false } });
860
+ mkdirSync(join(cwd, "design"), { recursive: true });
861
+ writeFileSync(join(cwd, "design", "system.html"), "<h1>Tokens</h1>");
862
+ const system = await run({ action: "list" }, fakeContext(cwd).ctx);
863
+ assert.equal(toolText(system), "No designs in design/ yet.\nSystem board: design/system.html (shown first on the canvas).\nDesign sources: none detected.\nDesign system: no design/system.md.\nCanvas: not running.");
864
+ assert.deepEqual(system.details, { designs: [], systemBoard: true, designSources: { paths: [], total: 0 }, system: NO_SYSTEM, canvas: { running: false } });
865
+ });
866
+
867
+ // S13: list reports the project's design sources and whether design/system.md
868
+ // is missing or older than them, so the skill can sync first or warn.
869
+
870
+ const NO_SYSTEM = { exists: false, file: "design/system.md", staleSources: [] };
871
+
872
+ function listDetails(result: ToolResult) {
873
+ return result.details as { designSources: { paths: string[]; total: number }; system: { exists: boolean; file: string; staleSources: string[] } };
874
+ }
875
+
876
+ test("gentle_design list asks for a sync when the project has design sources but no design/system.md", async () => {
877
+ const cwd = scratchCwd();
878
+ writeFileSync(join(cwd, "tailwind.config.js"), "module.exports = {}");
879
+ mkdirSync(join(cwd, "src"), { recursive: true });
880
+ writeFileSync(join(cwd, "src", "globals.css"), ":root { --brand: #0a84ff; }");
881
+ writeFileSync(join(cwd, "src", "plain.css"), "body { margin: 0; }");
882
+ const fake = fakePi();
883
+ gentleDesign(fake.pi, fakeCanvas());
884
+ const result = await designTool(fake)({ action: "list" }, fakeContext(cwd).ctx);
885
+ assert.equal(toolText(result), [
886
+ "No designs in design/ yet.",
887
+ "No system board (design/system.html).",
888
+ "Design sources (2): src/globals.css, tailwind.config.js",
889
+ "Design system: no design/system.md. Run the skill's Design system sync before designing.",
890
+ "Canvas: not running.",
891
+ ].join("\n"));
892
+ assert.deepEqual(listDetails(result).designSources, { paths: ["src/globals.css", "tailwind.config.js"], total: 2 });
893
+ assert.deepEqual(listDetails(result).system, NO_SYSTEM);
894
+ });
895
+
896
+ test("gentle_design list names the sources changed after design/system.md, and none when it is current", async () => {
897
+ const cwd = scratchCwd();
898
+ writeFileSync(join(cwd, "tailwind.config.js"), "module.exports = {}");
899
+ writeFileSync(join(cwd, "tokens.json"), "{}");
900
+ mkdirSync(join(cwd, "design"), { recursive: true });
901
+ writeFileSync(join(cwd, "design", "system.md"), "# Tokens");
902
+ utimesSync(join(cwd, "design", "system.md"), 2000, 2000);
903
+ utimesSync(join(cwd, "tailwind.config.js"), 1000, 1000);
904
+ utimesSync(join(cwd, "tokens.json"), 3000, 3000);
905
+ const fake = fakePi();
906
+ gentleDesign(fake.pi, fakeCanvas());
907
+ const run = designTool(fake);
908
+ const stale = await run({ action: "list" }, fakeContext(cwd).ctx);
909
+ assert.ok(toolText(stale).includes("\nDesign system: design/system.md is older than 1 source changed after it: tokens.json. Say its tokens may be outdated and offer to sync again before designing.\n"), toolText(stale));
910
+ assert.deepEqual(listDetails(stale).system, { exists: true, file: "design/system.md", staleSources: ["tokens.json"] });
911
+ utimesSync(join(cwd, "tokens.json"), 1000, 1000);
912
+ const current = await run({ action: "list" }, fakeContext(cwd).ctx);
913
+ assert.ok(toolText(current).includes("\nDesign system: design/system.md is up to date.\n"), toolText(current));
914
+ assert.deepEqual(listDetails(current).system, { exists: true, file: "design/system.md", staleSources: [] });
915
+ });
916
+
917
+ test("gentle_design list caps the design sources it names at 20, with the total", async () => {
918
+ const cwd = scratchCwd();
919
+ for (let index = 0; index < 23; index++) writeFileSync(join(cwd, `t${String(index).padStart(2, "0")}-tokens.json`), "{}");
920
+ const fake = fakePi();
921
+ gentleDesign(fake.pi, fakeCanvas());
922
+ const result = await designTool(fake)({ action: "list" }, fakeContext(cwd).ctx);
923
+ const { paths, total } = listDetails(result).designSources;
924
+ assert.equal(total, 23);
925
+ assert.equal(paths.length, 20);
926
+ assert.equal(paths[19], "t19-tokens.json");
927
+ assert.ok(toolText(result).includes("Design sources (23): t00-tokens.json, "), toolText(result));
928
+ assert.ok(toolText(result).includes("t19-tokens.json, … 3 more\n"), toolText(result));
929
+ });
930
+
931
+ test("the gentle_design tool description says to call list before designing", () => {
932
+ const fake = fakePi();
933
+ gentleDesign(fake.pi);
934
+ assert.match(fake.tool("gentle_design")!.description, /Call list before designing/);
935
+ });
936
+
937
+ test("gentle_design open starts the canvas once, returns its URL, and /gentle:design open reuses that server", async () => {
938
+ const cwd = scratchCwd();
939
+ writeDesign(cwd, "pricing-page", "Pricing page", 2000);
940
+ writeDesign(cwd, "login", "Login screen", 1000);
941
+ const fake = fakePi();
942
+ const canvas = fakeCanvas();
943
+ gentleDesign(fake.pi, canvas);
944
+ const run = designTool(fake);
945
+ const { ctx, notices } = fakeContext(cwd);
946
+ const opened = await run({ action: "open", slug: "login" }, ctx);
947
+ assert.equal(toolText(opened), "Design canvas: http://127.0.0.1:4100/tok/d/login/");
948
+ assert.deepEqual(opened.details, { url: "http://127.0.0.1:4100/tok/d/login/", opened: true });
949
+ assert.equal(canvas.starts.length, 1);
950
+ const latest = await run({ action: "open" }, ctx);
951
+ assert.deepEqual(latest.details, { url: "http://127.0.0.1:4100/tok/d/pricing-page/", opened: true }, "no slug opens the most recent design");
952
+ await fake.command().handler("open login", ctx);
953
+ assert.equal(canvas.starts.length, 1, "the command reuses the tool's server");
954
+ assert.deepEqual(notices, [{ message: toolText(opened), level: "info" }], "the command shows the same notice text");
955
+ await fake.command().handler("stop", ctx);
956
+ assert.equal(canvas.closes(), 1);
957
+ assert.equal(fake.sent.length, 0);
958
+ });
959
+
960
+ test("gentle_design open refreshes a connected canvas page, and opens the browser once none is connected", async () => {
961
+ const cwd = scratchCwd();
962
+ writeDesign(cwd, "pricing-page", "Pricing page", 2000);
963
+ writeDesign(cwd, "login", "Login screen", 1000);
964
+ const fake = fakePi();
965
+ const canvas = fakeCanvas();
966
+ gentleDesign(fake.pi, canvas);
967
+ const run = designTool(fake);
968
+ const { ctx } = fakeContext(cwd);
969
+ await run({ action: "open", slug: "login" }, ctx);
970
+ assert.equal(canvas.opens.length, 1);
971
+ canvas.pages.connected = 1;
972
+ const refreshed = await run({ action: "open", slug: "pricing-page" }, ctx);
973
+ assert.equal(toolText(refreshed), "Design canvas refreshed: http://127.0.0.1:4100/tok/d/pricing-page/");
974
+ assert.deepEqual(refreshed.details, { url: "http://127.0.0.1:4100/tok/d/pricing-page/", opened: true, refreshed: true });
975
+ assert.equal(canvas.opens.length, 1, "the tool opens no new tab either");
976
+ assert.deepEqual(canvas.shows, ["pricing-page"]);
977
+ canvas.pages.connected = 0;
978
+ const reopened = await run({ action: "open", slug: "login" }, ctx);
979
+ assert.deepEqual(reopened.details, { url: "http://127.0.0.1:4100/tok/d/login/", opened: true });
980
+ assert.equal(canvas.opens.length, 2);
981
+ });
982
+
983
+ test("gentle_design open reports the manual URL when the browser cannot open", async () => {
984
+ const cwd = scratchCwd();
985
+ writeDesign(cwd, "login", "Login screen");
986
+ const fake = fakePi();
987
+ gentleDesign(fake.pi, fakeCanvas({ opened: false, error: "spawn xdg-open ENOENT" }));
988
+ const result = await designTool(fake)({ action: "open", slug: "login" }, fakeContext(cwd).ctx);
989
+ assert.equal(toolText(result), "Design canvas: http://127.0.0.1:4100/tok/d/login/\nThe browser could not be opened (spawn xdg-open ENOENT). Open the URL manually.");
990
+ assert.deepEqual(result.details, { url: "http://127.0.0.1:4100/tok/d/login/", opened: false });
991
+ });
992
+
993
+ test("gentle_design open of an unknown slug fails naming the valid slugs and starts nothing", async () => {
994
+ const cwd = scratchCwd();
995
+ writeDesign(cwd, "pricing-page", "Pricing page", 2000);
996
+ writeDesign(cwd, "login", "Login screen", 1000);
997
+ const fake = fakePi();
998
+ const canvas = fakeCanvas();
999
+ gentleDesign(fake.pi, canvas);
1000
+ await assert.rejects(designTool(fake)({ action: "open", slug: "missing" }, fakeContext(cwd).ctx), { message: 'No design "missing" in design/. Available: pricing-page, login.' });
1001
+ await assert.rejects(designTool(fake)({ action: "open" }, fakeContext(scratchCwd()).ctx), /No designs in design\/ yet/);
1002
+ assert.equal(canvas.starts.length, 0);
1003
+ });
1004
+
1005
+ test("gentle_design stop closes the canvas, and reports when none is running", async () => {
1006
+ const cwd = scratchCwd();
1007
+ writeDesign(cwd, "login", "Login screen");
1008
+ const fake = fakePi();
1009
+ const canvas = fakeCanvas();
1010
+ gentleDesign(fake.pi, canvas);
1011
+ const run = designTool(fake);
1012
+ const { ctx } = fakeContext(cwd);
1013
+ assert.equal(toolText(await run({ action: "stop" }, ctx)), "No design canvas is running.");
1014
+ await fake.command().handler("open login", ctx);
1015
+ const stopped = await run({ action: "stop" }, ctx);
1016
+ assert.equal(toolText(stopped), "Design canvas stopped.");
1017
+ assert.deepEqual(stopped.details, { stopped: true });
1018
+ assert.equal(canvas.closes(), 1);
1019
+ assert.deepEqual((await run({ action: "stop" }, ctx)).details, { stopped: false });
1020
+ });
1021
+
1022
+ test("gentle_design implement returns the command's handoff text and sends no message", async () => {
1023
+ const cwd = scratchCwd();
1024
+ writeLinkedDesign(cwd, "login", "odd/tasks/login-page.md");
1025
+ writeFileSync(join(cwd, "design", "system.md"), "# Tokens");
1026
+ seedComments(cwd, "login", [{ id: "open01", file: "01-split.html" }]);
1027
+ const fake = fakePi();
1028
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1029
+ const { ctx } = fakeContext(cwd);
1030
+ const result = await designTool(fake)({ action: "implement", target: "login/01-split.html" }, ctx);
1031
+ assert.equal(fake.sent.length, 0, "the tool sends no user message");
1032
+ assert.equal(result.content.length, 1);
1033
+ await fake.command().handler("implement login/01-split.html", ctx);
1034
+ assert.equal(fake.sent.length, 1);
1035
+ assert.equal(result.content[0].text, fake.sent[0].content, "byte-identical to the command's message");
1036
+ assert.deepEqual(result.details, { target: "login/01-split.html", warnings: [] });
1037
+ });
1038
+
1039
+ test("gentle_design implement fails on an unknown target and reports an invalid feature link", async () => {
1040
+ const cwd = scratchCwd();
1041
+ writeLinkedDesign(cwd, "login", "../outside.md");
1042
+ const fake = fakePi();
1043
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1044
+ const run = designTool(fake);
1045
+ const { ctx } = fakeContext(cwd);
1046
+ for (const target of ["login/99-none.html", "login", ""]) {
1047
+ await assert.rejects(run({ action: "implement", target }, ctx), /Available: login\/01-split\.html, login\/02-card\.html/, target);
1048
+ }
1049
+ await assert.rejects(run({ action: "implement" }, ctx), /<slug>\/<artboard>/);
1050
+ const result = await run({ action: "implement", target: "login/02-card.html" }, ctx);
1051
+ const warning = "design/login/design.json: feature must be a relative odd/tasks/<name>.md path; it was ignored";
1052
+ assert.ok(result.content[0].text.includes("size it under the project's ODD rules as usual"));
1053
+ assert.equal(result.content[1].text, `Warning: ${warning}`);
1054
+ assert.deepEqual(result.details, { target: "login/02-card.html", warnings: [warning] });
1055
+ assert.equal(fake.sent.length, 0);
1056
+ });
1057
+
1058
+ const DESIGN_HINT = [
1059
+ "Gentle Design: any request for a UI design, mockup, screen, landing page or visual directions uses the gentle-design skill by default; read the skill first when it is not loaded.",
1060
+ 'Before designing, call the gentle_design tool (action "list"); when the project has design sources but no design/system.md, run the skill\'s design system sync first.',
1061
+ 'After writing or changing a design, open or refresh it on the canvas with the gentle_design tool (action "open" with the slug), unless the user said not to.',
1062
+ "Designs stay under design/<slug>/.",
1063
+ ].join("\n");
1064
+
1065
+ async function promptAfterStart(fake: ReturnType<typeof fakePi>, skills: Array<{ name: string }> = []) {
1066
+ const options = { appendSystemPrompt: "", skills };
1067
+ await fake.emit("before_agent_start", { type: "before_agent_start", messageType: "user", prompt: "a login screen", systemPrompt: "", systemPromptOptions: options });
1068
+ return options.appendSystemPrompt;
1069
+ }
1070
+
1071
+ test("the design hint is appended to the system prompt while the gentle-design skill is available", async () => {
1072
+ const loaded = fakePi(true);
1073
+ gentleDesign(loaded.pi, { env: {} });
1074
+ assert.equal(await promptAfterStart(loaded), DESIGN_HINT);
1075
+ const listed = fakePi(false);
1076
+ gentleDesign(listed.pi, { env: {} });
1077
+ assert.equal(await promptAfterStart(listed, [{ name: "gentle-design" }]), DESIGN_HINT, "a skill in the prompt's skill list counts as available");
1078
+ const options = { appendSystemPrompt: "Other block", skills: [] };
1079
+ await loaded.emit("before_agent_start", { type: "before_agent_start", systemPromptOptions: options });
1080
+ await loaded.emit("before_agent_start", { type: "before_agent_start", systemPromptOptions: options });
1081
+ assert.equal(options.appendSystemPrompt, `Other block\n\n${DESIGN_HINT}`, "appended once");
1082
+ });
1083
+
1084
+ test("the design hint is absent without the skill and in child sessions", async () => {
1085
+ const missing = fakePi(false);
1086
+ gentleDesign(missing.pi, { env: {} });
1087
+ assert.equal(await promptAfterStart(missing, [{ name: "gentle-ai" }]), "");
1088
+ const child = fakePi(true);
1089
+ gentleDesign(child.pi, { env: { GENTLE_PI_AGENTS_CHILD: "1" } });
1090
+ assert.equal(await promptAfterStart(child, [{ name: "gentle-design" }]), "");
1091
+ });
1092
+
1093
+ test("the skill triggers on natural-language design requests and opens the canvas after writing", () => {
1094
+ const source = readFileSync(SKILL_PATH, "utf8");
1095
+ const description = __testing.parseFrontmatter(source).description ?? "";
1096
+ for (const trigger of ["design", "mockup", "landing page", "UI directions"]) assert.ok(description.includes(trigger), `the description triggers on ${trigger}`);
1097
+ assert.ok(description.length <= 250, `description stays short (${description.length})`);
1098
+ const steps = source.slice(source.indexOf("## Execution Steps"), source.indexOf("\n## ", source.indexOf("## Execution Steps") + 1));
1099
+ assert.match(steps, /`gentle_design`[^\n]*"open"[^\n]*slug[^\n]*unless the user/);
1100
+ assert.match(source, /`\/gentle:design`[^\n]*optional shortcuts/);
1101
+ });
1102
+
1103
+ test("the skill's Before designing gate lists first, syncs automatically, and offers a re-sync when stale", () => {
1104
+ const source = readFileSync(SKILL_PATH, "utf8");
1105
+ const start = source.indexOf("## Before designing");
1106
+ assert.ok(start !== -1, "SKILL.md has a Before designing section");
1107
+ assert.ok(start < source.indexOf("## Execution Steps"), "the gate comes before the steps");
1108
+ const section = source.slice(start, source.indexOf("\n## ", start + 1));
1109
+ for (const part of ["`gentle_design`", "`list`", "designSources", "Design system sync", "synced", "design freely", "staleSources", "offer to sync again", "outdated"]) {
1110
+ assert.ok(section.includes(part), `the gate mentions ${part}`);
1111
+ }
1112
+ const output = source.slice(source.indexOf("## Output Contract"), source.indexOf("\n## ", source.indexOf("## Output Contract") + 1));
1113
+ assert.match(output, /automatic sync/);
1114
+ });
1115
+
1116
+ // S15: handoff state in design/<slug>/handoffs.json, the chosen artboard, and the tool's handoff action.
1117
+
1118
+ const handoffsFile = (cwd: string, slug = "login") => JSON.parse(readFileSync(join(cwd, "design", slug, "handoffs.json"), "utf8"));
1119
+
1120
+ test("the handoff message tells the agent to report in_progress and implemented with a previewUrl", async () => {
1121
+ const cwd = scratchCwd();
1122
+ writeLinkedDesign(cwd, "login");
1123
+ const fake = fakePi();
1124
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1125
+ const result = await designTool(fake)({ action: "implement", target: "login/02-card.html" }, fakeContext(cwd).ctx);
1126
+ const text = result.content[0].text;
1127
+ assert.ok(text.includes('Report progress with the gentle_design tool: action "handoff", target "login/02-card.html", status "in_progress" when you start; status "implemented" when it is done, with previewUrl set to the running app\'s loopback URL (localhost, 127.0.0.1 or [::1]) when one is running, so the canvas can compare it with the artboard.'), text);
1128
+ assert.ok(text.indexOf("Report progress") < text.indexOf("This handoff itself commits nothing."));
1129
+ });
1130
+
1131
+ test("the command records the handoff as chosen before sending it, queued while the agent is busy", async () => {
1132
+ const cwd = scratchCwd();
1133
+ writeLinkedDesign(cwd, "login", "odd/tasks/login-page.md");
1134
+ const fake = fakePi();
1135
+ const seen: unknown[] = [];
1136
+ const send = fake.pi.sendUserMessage;
1137
+ (fake.pi as { sendUserMessage: unknown }).sendUserMessage = (content: unknown, options?: unknown) => {
1138
+ seen.push(handoffsFile(cwd));
1139
+ return (send as (content: unknown, options?: unknown) => void)(content, options);
1140
+ };
1141
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1142
+ await fake.command().handler("implement login/01-split.html", fakeContext(cwd, false).ctx);
1143
+ assert.equal(fake.sent.length, 1);
1144
+ assert.equal((seen[0] as { chosen: string }).chosen, "01-split.html", "recorded before the message is sent");
1145
+ const busy = handoffsFile(cwd);
1146
+ assert.equal(busy.chosen, "01-split.html");
1147
+ assert.deepEqual({ ...busy.handoffs["01-split.html"], sentAt: "t" }, { status: "sent", queued: true, sentAt: "t", startedAt: null, implementedAt: null, feature: "odd/tasks/login-page.md" });
1148
+
1149
+ await fake.command().handler("implement login/02-card.html", fakeContext(cwd, true).ctx);
1150
+ const idle = handoffsFile(cwd);
1151
+ assert.equal(idle.chosen, "02-card.html", "a new handoff moves chosen");
1152
+ assert.equal(idle.handoffs["02-card.html"].queued, false);
1153
+ });
1154
+
1155
+ test("the command refuses a handoff when handoffs.json is invalid, and never overwrites it", async () => {
1156
+ const cwd = scratchCwd();
1157
+ writeLinkedDesign(cwd, "login");
1158
+ writeFileSync(join(cwd, "design", "login", "handoffs.json"), "{ broken");
1159
+ const fake = fakePi();
1160
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1161
+ const { ctx, notices } = fakeContext(cwd);
1162
+ await fake.command().handler("implement login/01-split.html", ctx);
1163
+ assert.equal(fake.sent.length, 0);
1164
+ assert.equal(notices.length, 1);
1165
+ assert.equal(notices[0].level, "warning");
1166
+ assert.match(notices[0].message, /design\/login\/handoffs\.json cannot be read: .*never overwritten/);
1167
+ assert.equal(readFileSync(join(cwd, "design", "login", "handoffs.json"), "utf8"), "{ broken");
1168
+ await assert.rejects(designTool(fake)({ action: "implement", target: "login/01-split.html" }, ctx), /handoffs\.json cannot be read/);
1169
+ });
1170
+
1171
+ test("gentle_design implement records the handoff as sent and chosen", async () => {
1172
+ const cwd = scratchCwd();
1173
+ writeLinkedDesign(cwd, "login");
1174
+ const fake = fakePi();
1175
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1176
+ await designTool(fake)({ action: "implement", target: "login/02-card.html" }, fakeContext(cwd).ctx);
1177
+ const file = handoffsFile(cwd);
1178
+ assert.equal(file.chosen, "02-card.html");
1179
+ assert.equal(file.handoffs["02-card.html"].status, "sent");
1180
+ assert.equal(file.handoffs["02-card.html"].queued, false, "the model follows it in this run");
1181
+ });
1182
+
1183
+ test("gentle_design handoff moves an artboard to in_progress, then implemented with a previewUrl", async () => {
1184
+ const cwd = scratchCwd();
1185
+ writeLinkedDesign(cwd, "login", "odd/tasks/login-page.md");
1186
+ const fake = fakePi();
1187
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1188
+ const run = designTool(fake);
1189
+ const { ctx } = fakeContext(cwd);
1190
+ await run({ action: "implement", target: "login/01-split.html" }, ctx);
1191
+ const started = await run({ action: "handoff", target: "login/01-split.html", status: "in_progress", note: "Header first" }, ctx);
1192
+ assert.equal(toolText(started), "Handoff of design/login/01-split.html: in progress. It is the chosen artboard of design/login.");
1193
+ assert.equal(handoffsFile(cwd).handoffs["01-split.html"].status, "in_progress");
1194
+ assert.equal(handoffsFile(cwd).handoffs["01-split.html"].note, "Header first");
1195
+ const done = await run({ action: "handoff", target: "login/01-split.html", status: "implemented", previewUrl: "http://localhost:5173/login" }, ctx);
1196
+ assert.equal(toolText(done), "Handoff of design/login/01-split.html: implemented. It is the chosen artboard of design/login.\nPreview: http://localhost:5173/login (the canvas offers Compare).");
1197
+ const entry = handoffsFile(cwd).handoffs["01-split.html"];
1198
+ assert.equal(entry.status, "implemented");
1199
+ assert.equal(entry.previewUrl, "http://localhost:5173/login");
1200
+ assert.equal(entry.feature, "odd/tasks/login-page.md", "the handoff keeps its feature link");
1201
+ assert.deepEqual(done.details, { target: "login/01-split.html", chosen: "01-split.html", handoff: entry });
1202
+ assert.equal(fake.sent.length, 0, "the tool sends no user message");
1203
+ });
1204
+
1205
+ test("gentle_design handoff refuses an invalid previewUrl, an unknown target and a bad status, writing nothing", async () => {
1206
+ const cwd = scratchCwd();
1207
+ writeLinkedDesign(cwd, "login");
1208
+ const fake = fakePi();
1209
+ gentleDesign(fake.pi, { startServer: unexpectedServer, openBrowser: unexpectedBrowser });
1210
+ const run = designTool(fake);
1211
+ const { ctx } = fakeContext(cwd);
1212
+ for (const previewUrl of ["http://example.com/", "http://localhost.evil.test:3000/", "javascript:alert(1)", "http://user:pw@localhost:3000/", "file:///etc/passwd"]) {
1213
+ await assert.rejects(run({ action: "handoff", target: "login/01-split.html", status: "implemented", previewUrl }, ctx), /previewUrl/, previewUrl);
1214
+ }
1215
+ for (const target of ["login/99-none.html", "missing/01-split.html", "login", ""]) {
1216
+ await assert.rejects(run({ action: "handoff", target, status: "in_progress" }, ctx), /Available: login\/01-split\.html, login\/02-card\.html/, target);
1217
+ }
1218
+ await assert.rejects(run({ action: "handoff", target: "login/01-split.html" }, ctx), /status "in_progress" or "implemented"/);
1219
+ await assert.rejects(run({ action: "handoff", target: "login/01-split.html", status: "sent" }, ctx), /status "in_progress" or "implemented"/);
1220
+ assert.throws(() => readFileSync(join(cwd, "design", "login", "handoffs.json")), /ENOENT/);
1221
+ });
1222
+
1223
+ test("gentle_design list reports each design's chosen artboard and its handoff states", async () => {
1224
+ const cwd = scratchCwd();
1225
+ writeLinkedDesign(cwd, "login");
1226
+ writeDesign(cwd, "pricing", "Pricing", 1000);
1227
+ utimesSync(join(cwd, "design", "login", "design.json"), 2000, 2000);
1228
+ const fake = fakePi();
1229
+ gentleDesign(fake.pi, fakeCanvas());
1230
+ const run = designTool(fake);
1231
+ const { ctx } = fakeContext(cwd);
1232
+ await fake.command().handler("implement login/02-card.html", fakeContext(cwd, false).ctx);
1233
+ await fake.command().handler("implement login/01-split.html", ctx);
1234
+ await run({ action: "handoff", target: "login/01-split.html", status: "implemented", previewUrl: "http://127.0.0.1:3000/" }, ctx);
1235
+ const result = await run({ action: "list" }, ctx);
1236
+ const text = toolText(result);
1237
+ assert.ok(text.startsWith([
1238
+ "Designs in design/ (2):",
1239
+ "- login: Login screen",
1240
+ " Chosen: 01-split.html (the other artboards are alternatives)",
1241
+ " - 01-split.html: Split layout (handoff implemented; preview http://127.0.0.1:3000/)",
1242
+ " - 02-card.html: Centered card (handoff sent, queued)",
1243
+ "- pricing: Pricing",
1244
+ " - 01-a.html: A",
1245
+ "",
1246
+ ].join("\n")), text);
1247
+ const [login, pricing] = (result.details as { designs: Array<Record<string, unknown>> }).designs;
1248
+ assert.equal(login.chosen, "01-split.html");
1249
+ const states = login.handoffs as Record<string, { status: string; queued: boolean; previewUrl?: string }>;
1250
+ assert.deepEqual(Object.keys(states).sort(), ["01-split.html", "02-card.html"]);
1251
+ assert.equal(states["01-split.html"].status, "implemented");
1252
+ assert.equal(states["01-split.html"].previewUrl, "http://127.0.0.1:3000/");
1253
+ assert.equal(states["02-card.html"].queued, true);
1254
+ assert.equal("chosen" in pricing, false, "a design with no handoffs keeps the earlier details");
1255
+ assert.equal("handoffs" in pricing, false);
1256
+ });
1257
+
1258
+ test("gentle_design list reports an unreadable handoffs.json without hiding the design", async () => {
1259
+ const cwd = scratchCwd();
1260
+ writeLinkedDesign(cwd, "login");
1261
+ writeFileSync(join(cwd, "design", "login", "handoffs.json"), "[]");
1262
+ const fake = fakePi();
1263
+ gentleDesign(fake.pi, fakeCanvas());
1264
+ const result = await designTool(fake)({ action: "list" }, fakeContext(cwd).ctx);
1265
+ assert.ok(toolText(result).includes(" handoffs.json cannot be read: handoffs.json must be a JSON object"), toolText(result));
1266
+ assert.equal((result.details as { designs: Array<{ artboards: unknown[] }> }).designs[0].artboards.length, 2);
1267
+ });
1268
+
1269
+ test("integration: the canvas Implement route records the handoff and answers queued from the session state", async (t) => {
1270
+ const cwd = scratchCwd();
1271
+ writeLinkedDesign(cwd, "login");
1272
+ const { pi, sent, emit, command } = fakePi();
1273
+ const opens: string[] = [];
1274
+ gentleDesign(pi, { openBrowser: async (url) => { opens.push(url); return { opened: true }; } });
1275
+ t.after(() => emit("session_shutdown", { type: "session_shutdown", reason: "quit" }));
1276
+ const session = fakeContext(cwd, false);
1277
+ await command().handler("open login", session.ctx);
1278
+ const url = new URL(opens[0]);
1279
+ const token = url.pathname.split("/")[1];
1280
+ const post = (file: string) => new Promise<{ status: number; body: string }>((resolve, reject) => {
1281
+ const req = httpRequest({ host: "127.0.0.1", port: url.port, path: `/${token}/api/designs/login/implement`, method: "POST", headers: { Origin: url.origin, "Content-Type": "application/json" } }, (res) => {
1282
+ let body = "";
1283
+ res.setEncoding("utf8");
1284
+ res.on("data", (chunk: string) => { body += chunk; });
1285
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
1286
+ });
1287
+ req.on("error", reject);
1288
+ req.end(JSON.stringify({ file }));
1289
+ });
1290
+ const busy = await post("01-split.html");
1291
+ assert.equal(busy.status, 202);
1292
+ assert.equal(JSON.parse(busy.body).queued, true);
1293
+ assert.deepEqual(sent[0].options, { deliverAs: "followUp" });
1294
+ assert.equal(handoffsFile(cwd).handoffs["01-split.html"].queued, true);
1295
+ session.state.idle = true;
1296
+ const idle = await post("02-card.html");
1297
+ assert.equal(JSON.parse(idle.body).queued, false);
1298
+ assert.equal(sent[1].options, undefined);
1299
+ assert.equal(handoffsFile(cwd).chosen, "02-card.html");
1300
+ });
1301
+
1302
+ test("the gentle_design tool schema and description name the handoff action", () => {
1303
+ const fake = fakePi();
1304
+ gentleDesign(fake.pi);
1305
+ const tool = fake.tool("gentle_design") as unknown as { description: string; parameters: { properties: Record<string, unknown> } };
1306
+ for (const part of ["handoff", "in_progress", "implemented", "previewUrl"]) assert.ok(tool.description.includes(part), `the description names ${part}`);
1307
+ for (const key of ["status", "note", "previewUrl"]) assert.ok(key in tool.parameters.properties, `the schema has ${key}`);
1308
+ });
1309
+
1310
+ test("the skill tells the agent to report handoff progress and to name the chosen artboard in the ODD Log", () => {
1311
+ const source = readFileSync(SKILL_PATH, "utf8");
1312
+ for (const part of ['action "handoff"', '"in_progress"', '"implemented"', "previewUrl", "chosen artboard"]) assert.ok(source.includes(part), `SKILL.md mentions ${part}`);
1313
+ });