gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { TASK_STATUS, type TaskRecord } from "../lib/agents-protocol.ts";
5
+ import { formatElapsed, renderAgentsCard, widgetExpiryMs, widgetRows, widgetTasks } from "../lib/agents-widget.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ // Gentle Agents widget: the card above the editor that shows what the
9
+ // subagents are doing, drawn from task records only (never from threads).
10
+ // Layout: glyph, agent, task summary (wrapped), then model · tokens · cost · time.
11
+
12
+ const plainTheme = { fg: (_color: string, text: string) => text };
13
+
14
+ function task(overrides: Partial<TaskRecord>): TaskRecord {
15
+ return { id: "t", agent: "sdd-explore", mode: "task", prompt: "map footer data sources", label: "map footer data sources", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "anthropic/claude-sonnet-5", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "grep", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 34_000, cost: 0.27, ...overrides };
16
+ }
17
+
18
+ test("formatElapsed renders seconds, minutes, and hours compactly", () => {
19
+ assert.equal(formatElapsed(4_000), "4s");
20
+ assert.equal(formatElapsed(65_000), "1m05s");
21
+ assert.equal(formatElapsed(3_720_000), "1h02m");
22
+ assert.equal(formatElapsed(-5), "0s");
23
+ });
24
+
25
+ test("widgetTasks keeps active tasks in start order and only recently finished ones", () => {
26
+ const tasks = [
27
+ task({ id: "old-done", status: TASK_STATUS.COMPLETED, endedAt: 10_000 }),
28
+ task({ id: "new-done", status: TASK_STATUS.FAILED, endedAt: 95_000 }),
29
+ task({ id: "queued", status: TASK_STATUS.QUEUED, createdAt: 3000, startedAt: null }),
30
+ task({ id: "running", createdAt: 2000, startedAt: 2000 }),
31
+ ];
32
+ assert.deepEqual(widgetTasks(tasks, 100_000).map((entry) => entry.id), ["new-done", "running", "queued"]);
33
+ assert.deepEqual(widgetTasks([], 100_000), []);
34
+ });
35
+
36
+ test("widgetExpiryMs says how long until the next finished row leaves the card", () => {
37
+ const running = task({ id: "running", createdAt: 2000, startedAt: 2000 });
38
+ const done = task({ id: "done", status: TASK_STATUS.COMPLETED, endedAt: 95_000 });
39
+ const later = task({ id: "later", status: TASK_STATUS.COMPLETED, endedAt: 99_000 });
40
+ assert.equal(widgetExpiryMs([running, done, later], 100_000), 55_000, "the oldest shown row expires first");
41
+ assert.equal(widgetExpiryMs([done], 155_000), undefined, "a row past its minute is already gone");
42
+ assert.equal(widgetExpiryMs([running], 100_000), undefined, "active rows never expire");
43
+ assert.equal(widgetExpiryMs([], 100_000), undefined);
44
+ });
45
+
46
+ test("renderAgentsCard draws columns for agent, task, and model · tokens · cost · time, with the batch time in the rule", () => {
47
+ const tasks = [
48
+ task({ id: "a", status: TASK_STATUS.COMPLETED, startedAt: 1000, endedAt: 26_000 }),
49
+ task({ id: "b", agent: "sdd-apply", label: "write gentle-shell footer", startedAt: 44_000, tokens: 12_000, cost: 0.09 }),
50
+ ];
51
+ const lines = renderAgentsCard(tasks, plainTheme, 84, 85_000, { collapsed: false });
52
+ for (const line of lines) assert.equal(visibleWidth(line), 84, `"${stripAnsi(line)}" is not 84 wide`);
53
+ const plain = lines.map(stripAnsi);
54
+ assert.match(plain[0], /^╭─ ❀ Agents · 1 active · 1 done ─+ 1m24s ╮$/);
55
+ assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.27 · 25s │$/);
56
+ assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.09 · 41s │$/);
57
+ assert.match(plain[3], /^╰─+╯$/);
58
+ assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
59
+ });
60
+
61
+ test("renderAgentsCard renders singleton elapsed time only on its task row", () => {
62
+ const lines = renderAgentsCard([task({})], plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
63
+ assert.equal(lines.join("\n").match(/4s/g)?.length, 1);
64
+ assert.match(lines[1], /4s/);
65
+ });
66
+
67
+ test("renderAgentsCard keeps every task on one line, clipping long labels, and drops the task column when the card is narrow", () => {
68
+ const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
69
+ const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
70
+ assert.equal(wide.length, 3);
71
+ assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
72
+ const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
73
+ assert.equal(narrow.length, 3);
74
+ assert.match(narrow[1], /^│ ◐ sdd-explore +claude-sonnet-5 +│$/);
75
+ });
76
+
77
+ test("renderAgentsCard shows questions and failures in place of the task, and collapses to the first row", () => {
78
+ const tasks = [
79
+ task({ id: "a", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", tokens: 0, cost: 0 }),
80
+ task({ id: "b", status: TASK_STATUS.FAILED, endedAt: 2000, error: "pi exited with code 1", lastStep: "pi exited with code 1" }),
81
+ task({ id: "c", status: TASK_STATUS.QUEUED, createdAt: 1500, startedAt: null, tokens: 0, cost: 0 }),
82
+ ];
83
+ const plain = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: false }).map(stripAnsi);
84
+ assert.match(plain[0], /^╭─ ❀ Agents · 1 waiting · 1 queued · 1 failed ─+ 2s ╮$/);
85
+ assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · 2s │$/);
86
+ assert.match(plain[2], /^│ ✗ sdd-explore pi exited with code 1 +claude-sonnet-5 · 34k · \$0\.27 · 1s │$/);
87
+ assert.match(plain[3], /^│ ○ sdd-explore map footer data sources +queued │$/);
88
+ const collapsed = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: true, collapseKey: "ctrl+shift+a" }).map(stripAnsi);
89
+ assert.equal(collapsed.length, 3);
90
+ assert.match(collapsed[0], /ctrl\+shift\+a expand ╮$/);
91
+ assert.match(collapsed[1], /^│ \? sdd-explore asked: Delete\?/);
92
+ });
93
+
94
+ test("agent model and effort outrank usage at sidebar widths without inventing unknown values", () => {
95
+ for (const width of [32, 44, 60, 100]) {
96
+ const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
97
+ assert.equal(lines.length, 3);
98
+ assert.match(lines[1], /worker/);
99
+ assert.match(lines[1], /gpt-5 · high/);
100
+ for (const line of lines) assert.equal(visibleWidth(line), width);
101
+ if (width <= 44) assert.doesNotMatch(lines[1], /34k|\$0\.27|4s/);
102
+ }
103
+ for (const width of [0, 1, 2, 3, 4, 8, 16, 24]) {
104
+ const lines = renderAgentsCard([task({ agent: "界worker", thinking: "xhigh" })], plainTheme, width, 5000, { collapsed: false });
105
+ assert.ok(lines.length <= 4, "narrow metadata gets at most one dedicated row");
106
+ for (const line of lines) assert.equal(visibleWidth(line), width);
107
+ }
108
+ const sidebar = renderAgentsCard([task({ agent: "gentle-ai-worker", model: "openai/gpt-5.6", thinking: "high" })], plainTheme, 32, 5000, { collapsed: false });
109
+ assert.match(sidebar.join("\n"), /gentle-ai-worker/);
110
+ assert.match(sidebar.join("\n"), /gpt-5\.6 · high/);
111
+ for (const line of sidebar) assert.equal(visibleWidth(line), 32);
112
+ const unknown = renderAgentsCard([task({ model: "default", thinking: undefined })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
113
+ assert.doesNotMatch(unknown, /default|undefined|high|off/);
114
+ const off = renderAgentsCard([task({ thinking: "off" })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
115
+ assert.match(off, /claude-sonnet-5 · off/);
116
+ });
117
+
118
+ test("widgetRows caps the card at a quarter of the terminal, between three and eight rows", () => {
119
+ assert.equal(widgetRows(40), 8, "tall terminals still stop at eight rows");
120
+ assert.equal(widgetRows(24), 6);
121
+ assert.equal(widgetRows(10), 3, "short terminals keep three rows");
122
+ assert.equal(widgetRows(undefined), 8, "without a terminal the widest default applies");
123
+ });
124
+
125
+ test("renderAgentsCard caps the rows at maxRows, keeps active tasks ahead of finished ones, and says how many are hidden", () => {
126
+ const tasks = [
127
+ task({ id: "done", status: TASK_STATUS.COMPLETED, startedAt: 500, endedAt: 2000 }),
128
+ ...Array.from({ length: 6 }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, createdAt: 1000 + index, startedAt: 1000 + index })),
129
+ ];
130
+ const plain = renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" }).map(stripAnsi);
131
+ assert.equal(plain.length, 6, "frame plus four rows");
132
+ assert.match(plain[0], /6 active · 1 done/, "the title still counts every shown task");
133
+ assert.match(plain[1], /◐ sdd-explore job 0/);
134
+ assert.match(plain[3], /◐ sdd-explore job 2/);
135
+ assert.match(plain[4], /^│ … 4 more · alt\+a to view +│$/, "the finished row gives way to running ones");
136
+ assert.equal(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false }).length, 9, "without a cap every row shows");
137
+ assert.equal(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 7 }).length, 9, "at the cap no row is hidden");
138
+ assert.match(renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false, maxRows: 4 }).map(stripAnsi)[4], /^│ … 4 more +│$/, "no view key, no hint");
139
+ const waiting = [...tasks, task({ id: "ask", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", createdAt: 4000, startedAt: 4000 })];
140
+ assert.match(renderAgentsCard(waiting, plainTheme, 80, 5000, { collapsed: false, maxRows: 2 }).map(stripAnsi)[1], /^│ \? sdd-explore asked: Delete\?/, "a question is never hidden");
141
+ });
@@ -149,6 +149,27 @@ test("SDD proposal questions focus on business and PRD gaps", async () => {
149
149
  );
150
150
  });
151
151
 
152
+ test("all shipped SDD agents and chains require parent preflight transport", async () => {
153
+ const agents = [
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",
156
+ ];
157
+ const chains = ["sdd-full", "sdd-plan", "sdd-verify"];
158
+ assert.equal(agents.length, 14);
159
+ assert.equal(chains.length, 3);
160
+ for (const agent of agents) {
161
+ const source = await readFile(join(ROOT, "assets", "agents", `${agent}.md`), "utf8");
162
+ assert.match(source, /## Parent Preflight Transport/);
163
+ assert.match(source, /never confirms or persists SDD choices/);
164
+ assert.doesNotMatch(source, /(?:infer|synthesize) (?:a )?(?:preflight )?(?:confirmation|defaults?) yourself/i);
165
+ }
166
+ for (const chain of chains) {
167
+ const source = await readFile(join(ROOT, "assets", "chains", `${chain}.chain.md`), "utf8");
168
+ assert.match(source, /## Parent preflight transport guard/);
169
+ assert.match(source, /Missing or malformed transport blocks the chain before its first phase/);
170
+ }
171
+ });
172
+
152
173
  test("SDD chain assets distinguish interactive gates from auto execution", async () => {
153
174
  const planChain = await readFile(join(ROOT, "assets/chains/sdd-plan.chain.md"), "utf8");
154
175
  const fullChain = await readFile(join(ROOT, "assets/chains/sdd-full.chain.md"), "utf8");
@@ -212,8 +233,10 @@ test("SDD assets route completed implementation directly through verify, sync, a
212
233
 
213
234
  assert.match(tasks, /<!-- sdd-owner: implementation -->/);
214
235
  assert.match(apply, /next_recommended: "sdd-verify"/);
215
- assert.match(status, /verify.*ready/i);
216
- assert.match(contract, /apply.*verify.*sync.*archive/is);
236
+ assert.match(status, /gentle-ai\.sdd-status.*v2/i);
237
+ assert.match(status, /read-only/i);
238
+ assert.match(contract, /native.*(?:apply|verify).*archive/is);
239
+ assert.match(contract, /Manual `sdd-sync` remains its intentional local resolver/);
217
240
  assert.match(chain, /apply.*verification/is);
218
241
  assert.doesNotMatch(assets, /<!-- sdd-owner: parent -->/);
219
242
  assert.doesNotMatch(assets, /parent-lifecycle/);
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { getKeybindings, KeybindingsManager, setKeybindings, stripTerminalSequences, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
3
4
  import askUserChoice from "../extensions/ask-user-choice.ts";
4
5
 
5
6
  interface ChoiceResult {
@@ -16,6 +17,7 @@ interface ChoiceParameters {
16
17
  additionalProperties?: boolean;
17
18
  properties?: {
18
19
  question?: unknown;
20
+ allowCustomResponse?: unknown;
19
21
  options?: {
20
22
  minItems?: number;
21
23
  maxItems?: number;
@@ -25,7 +27,10 @@ interface ChoiceParameters {
25
27
  }
26
28
 
27
29
  interface ChoiceTool {
30
+ renderShell?: string;
28
31
  name: string;
32
+ description?: string;
33
+ promptGuidelines?: string[];
29
34
  parameters: ChoiceParameters;
30
35
  execute: (...args: unknown[]) => Promise<ChoiceResult>;
31
36
  }
@@ -35,6 +40,35 @@ interface ChoiceLifecycleEvent {
35
40
  data: { active: boolean };
36
41
  }
37
42
 
43
+ interface NativeChoiceComponent {
44
+ render(width: number): string[];
45
+ handleInput(data: string): void;
46
+ handleMouse?: (event: Record<string, unknown>) => NativeChoiceMouseResult | undefined;
47
+ }
48
+
49
+ interface NativeChoiceMouseResult {
50
+ handled?: boolean;
51
+ focus?: boolean;
52
+ render?: boolean;
53
+ }
54
+
55
+ interface ChoiceTui {
56
+ requestRender(): void;
57
+ }
58
+
59
+ interface ChoiceTheme {
60
+ fg(color: string, text: string): string;
61
+ bg(color: string, text: string): string;
62
+ bold(text: string): string;
63
+ }
64
+
65
+ type ChoiceCustomFactory = (
66
+ tui: ChoiceTui,
67
+ theme: ChoiceTheme,
68
+ keybindings: unknown,
69
+ done: (value: unknown) => void,
70
+ ) => NativeChoiceComponent;
71
+
38
72
  type BeforeAgentStart = (event: unknown, ctx: { mode: string }) => void | Promise<void>;
39
73
 
40
74
  function registerChoiceTool(
@@ -103,11 +137,11 @@ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
103
137
  return {
104
138
  mode: "tui",
105
139
  ui: {
106
- custom: async (factory: (tui: { requestRender(): void }, theme: { fg(_color: string, text: string): string; bold(text: string): string }, keybindings: unknown, done: (value: unknown) => void) => { render(width: number): string[]; handleInput(data: string): void }) => {
140
+ custom: async (factory: ChoiceCustomFactory) => {
107
141
  let result: unknown;
108
142
  const component = factory(
109
143
  { requestRender() {} },
110
- { fg: (_color, text) => text, bold: (text) => text },
144
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
111
145
  {},
112
146
  (value) => {
113
147
  result = value;
@@ -121,6 +155,10 @@ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
121
155
  };
122
156
  }
123
157
 
158
+ test("ask_user_choice transcript opts out of Pi's painted tool shell", () => {
159
+ assert.equal(registerChoiceTool().tool.renderShell, "self");
160
+ });
161
+
124
162
  test("ask_user_choice registers without runtime actions or overriding the open questionnaire", () => {
125
163
  const registration = registerChoiceTool(["read", "ask_user_question"]);
126
164
 
@@ -129,20 +167,300 @@ test("ask_user_choice registers without runtime actions or overriding the open q
129
167
  assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
130
168
  });
131
169
 
132
- test("ask_user_choice exposes a strict closed single-select schema", () => {
170
+ test("ask_user_choice keeps closed mode by default and makes custom mode opt-in", () => {
133
171
  const { tool } = registerChoiceTool();
134
172
  const optionsSchema = tool.parameters.properties?.options;
135
173
  const optionSchema = optionsSchema?.items;
136
174
 
137
175
  assert.equal(tool.name, "ask_user_choice");
138
176
  assert.equal(tool.parameters.additionalProperties, false);
139
- assert.deepEqual(Object.keys(tool.parameters.properties ?? {}).sort(), ["options", "question"]);
177
+ assert.deepEqual(Object.keys(tool.parameters.properties ?? {}).sort(), ["allowCustomResponse", "options", "question"]);
178
+ assert.equal(tool.parameters.properties?.allowCustomResponse !== undefined, true);
179
+ assert.match(tool.description ?? "", /allowCustomResponse/);
180
+ assert.match((tool.promptGuidelines ?? []).join(" "), /Never enable allowCustomResponse.*provider-owned consent prompts.*maintenance authorizations.*exact opaque token/);
140
181
  assert.equal(optionsSchema?.minItems, 2);
141
182
  assert.equal(optionsSchema?.maxItems, 4);
142
183
  assert.equal(optionSchema?.additionalProperties, false);
143
184
  assert.deepEqual(Object.keys(optionSchema?.properties ?? {}).sort(), ["description", "label", "value"]);
144
185
  });
145
186
 
187
+ test("ask_user_choice hover preserves the keyboard-selected opaque value", async () => {
188
+ const { tool } = registerChoiceTool();
189
+ let completionCalls = 0;
190
+ const result = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, {
191
+ mode: "tui",
192
+ ui: { custom: async (factory: ChoiceCustomFactory) => {
193
+ let completed: unknown;
194
+ const component = factory({ requestRender() {} }, {
195
+ fg: (_color, text) => `\u001b[38;5;39m${text}\u001b[39m`,
196
+ bg: (_color, text) => `\u001b[48;5;236m${text}\u001b[49m`, bold: (text) => text,
197
+ }, {}, (value) => { completionCalls++; completed = value; });
198
+ const lines = component.render(80);
199
+ const first = lines.findIndex((line) => stripTerminalSequences(line).includes(options[0]!.label));
200
+ const second = lines.findIndex((line) => stripTerminalSequences(line).includes(options[1]!.label));
201
+ assert.ok(first >= 0 && second >= 0);
202
+ const hover = {
203
+ type: "move", button: "none", x: 0, y: second,
204
+ screenX: 0, screenY: second, width: 80, height: lines.length,
205
+ shift: false, alt: false, ctrl: false,
206
+ };
207
+ assert.equal(component.handleMouse?.(hover)?.render, true);
208
+ assert.equal(completed, undefined, "hover never submits");
209
+ const hovered = component.render(80);
210
+ assert.ok(hovered[second]?.includes("\u001b[48;5;236m"), "only the second option is hovered");
211
+ assert.ok(
212
+ hovered[first]?.includes("\u001b[38;5;39m") && !hovered[first]?.includes("\u001b[48;5;236m"),
213
+ "hover does not change keyboard selection",
214
+ );
215
+ component.handleInput("\r");
216
+ component.handleInput("\r");
217
+ return completed;
218
+ } },
219
+ });
220
+ assert.deepEqual(result.details.selection, { value: options[0]!.value, label: options[0]!.label, index: 1 });
221
+ assert.equal(completionCalls, 1);
222
+ });
223
+
224
+ test("ask_user_choice retains native rendered hit testing for mouse selection", async () => {
225
+ const longOptions = [
226
+ {
227
+ label: "Authorize the observed baseline hash after independent verification",
228
+ description: "Accept the observed baseline after a long description that verifies layout offsets.",
229
+ value: "authorize_observed_hash",
230
+ },
231
+ {
232
+ label: "Preserve the originally requested hash without changing the envelope",
233
+ description: "Keep the original opaque answer token after the user confirms the requested value.",
234
+ value: "preserve_requested_hash",
235
+ },
236
+ ];
237
+ const { tool } = registerChoiceTool();
238
+ let completionCalls = 0;
239
+ const result = await tool.execute(
240
+ "call",
241
+ {
242
+ question: "A deliberately long question verifies that the rendered header does not shift native option hit bounds.",
243
+ options: longOptions,
244
+ },
245
+ new AbortController().signal,
246
+ undefined,
247
+ {
248
+ mode: "tui",
249
+ ui: {
250
+ custom: async (factory: ChoiceCustomFactory) => {
251
+ let completed: unknown;
252
+ const component = factory(
253
+ { requestRender() {} },
254
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
255
+ {},
256
+ (value) => {
257
+ completionCalls++;
258
+ completed = value;
259
+ },
260
+ ) as NativeChoiceComponent;
261
+ const narrow = component.render(24);
262
+ assert.ok(narrow.length > 0, "the real component renders before a narrow-to-wide resize");
263
+ const wide = component.render(100);
264
+ const secondRow = wide.findIndex((line) => line.includes(longOptions[1]!.label.slice(0, 24)));
265
+ assert.ok(secondRow >= 0, "the test locates the actual rendered option row");
266
+ const event = (type: string, button: string, y: number, wheelDelta?: number) => ({
267
+ type,
268
+ button,
269
+ x: 1,
270
+ y,
271
+ screenX: 1,
272
+ screenY: y,
273
+ width: 100,
274
+ height: wide.length,
275
+ shift: false,
276
+ alt: false,
277
+ ctrl: false,
278
+ wheelDelta,
279
+ });
280
+ assert.equal(typeof component.handleMouse, "function", "native mouse dispatch must survive the custom UI adapter");
281
+ const wheel = component.handleMouse?.(event("wheel", "none", secondRow, 1));
282
+ assert.equal(wheel?.handled, true);
283
+ assert.equal(wheel?.render, true);
284
+ assert.equal(completed, undefined, "wheel changes focus without answering");
285
+ assert.equal(component.handleMouse?.(event("release", "left", secondRow)), undefined);
286
+ assert.equal(component.handleMouse?.(event("click", "right", secondRow)), undefined);
287
+ assert.equal(component.handleMouse?.(event("click", "left", wide.length)), undefined);
288
+ assert.equal(component.handleMouse?.({ type: "click" }), undefined);
289
+ const press = component.handleMouse?.(event("press", "left", secondRow));
290
+ assert.equal(press?.handled, true);
291
+ assert.equal(press?.focus, true);
292
+ assert.equal(completed, undefined, "press focuses and selects but never answers");
293
+ component.handleMouse?.(event("click", "left", secondRow));
294
+ component.handleMouse?.(event("click", "left", secondRow));
295
+ component.handleInput("\r");
296
+ return completed;
297
+ },
298
+ },
299
+ },
300
+ );
301
+ assert.deepEqual(result.details.selection, {
302
+ value: "preserve_requested_hash",
303
+ label: longOptions[1]!.label,
304
+ index: 2,
305
+ });
306
+ assert.equal(completionCalls, 1, "late keyboard input cannot complete the choice twice");
307
+ });
308
+
309
+ test("ask_user_choice supports an opt-in custom response from keyboard navigation", async () => {
310
+ const { tool } = registerChoiceTool();
311
+ const rendered: string[] = [];
312
+ const response = " exactly as typed ";
313
+ const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
314
+ mode: "tui",
315
+ ui: {
316
+ custom: async (factory: ChoiceCustomFactory) => {
317
+ let completed: unknown;
318
+ const component = factory(
319
+ { requestRender() {} },
320
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
321
+ {},
322
+ (value) => { completed = value; },
323
+ );
324
+ rendered.push(component.render(80).join("\n"));
325
+ component.handleInput("\x1b[B");
326
+ component.handleInput("\x1b[B");
327
+ component.handleInput("\r");
328
+ rendered.push(component.render(80).join("\n"));
329
+ component.handleInput(response);
330
+ component.handleInput("\r");
331
+ return completed;
332
+ },
333
+ },
334
+ });
335
+ assert.match(rendered[0] ?? "", /Other…/);
336
+ assert.match(rendered[1] ?? "", /Custom response/);
337
+ assert.deepEqual(result.details, { question: "Explain?", options, customResponse: response });
338
+ assert.equal(result.content[0]?.text, `User responded: ${response}`);
339
+ });
340
+
341
+ test("ask_user_choice uses native input decoding and supplied custom editor keybindings", async () => {
342
+ const { tool } = registerChoiceTool();
343
+ const configured = new KeybindingsManager(TUI_KEYBINDINGS, {
344
+ "tui.select.down": "ctrl+n",
345
+ "tui.select.confirm": "ctrl+o",
346
+ "tui.select.cancel": "ctrl+x",
347
+ "tui.input.submit": "ctrl+s",
348
+ "tui.editor.deleteCharBackward": "ctrl+h",
349
+ });
350
+ const previous = getKeybindings();
351
+ setKeybindings(configured);
352
+ try {
353
+ const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
354
+ mode: "tui",
355
+ ui: { custom: async (factory: ChoiceCustomFactory) => {
356
+ let completed: unknown;
357
+ const component = factory(
358
+ { requestRender() {} },
359
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
360
+ configured, (value) => { completed = value; },
361
+ );
362
+ component.handleInput("\x0e");
363
+ component.handleInput("\x0e");
364
+ component.handleInput("\x0f");
365
+ component.handleInput("\x1b[97u");
366
+ component.handleInput("\x1b[98u");
367
+ component.handleInput("\x08");
368
+ component.handleInput("\x18");
369
+ assert.match(component.render(80).join("\n"), /Other…/);
370
+ component.handleInput("\x0f");
371
+ component.handleInput("\x13");
372
+ return completed;
373
+ } },
374
+ });
375
+ assert.deepEqual(result.details, { question: "Explain?", options, customResponse: "a" });
376
+ } finally {
377
+ setKeybindings(previous);
378
+ }
379
+ });
380
+
381
+ test("ask_user_choice activates an opt-in custom response from the Other row pointer target", async () => {
382
+ const { tool } = registerChoiceTool();
383
+ const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
384
+ mode: "tui",
385
+ ui: {
386
+ custom: async (factory: ChoiceCustomFactory) => {
387
+ let completed: unknown;
388
+ const component = factory(
389
+ { requestRender() {} },
390
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
391
+ {},
392
+ (value) => { completed = value; },
393
+ );
394
+ const lines = component.render(80);
395
+ const otherRow = lines.findIndex((line) => stripTerminalSequences(line).includes("Other…"));
396
+ assert.ok(otherRow >= 0);
397
+ const event = (type: string) => ({
398
+ type, button: "left", x: 0, y: otherRow, screenX: 0, screenY: otherRow,
399
+ width: 80, height: lines.length, shift: false, alt: false, ctrl: false,
400
+ });
401
+ assert.equal(component.handleMouse?.(event("press"))?.focus, true);
402
+ component.handleMouse?.(event("click"));
403
+ component.handleInput("pointer response");
404
+ component.handleInput("\r");
405
+ return completed;
406
+ },
407
+ },
408
+ });
409
+ assert.deepEqual(result.details, { question: "Explain?", options, customResponse: "pointer response" });
410
+ });
411
+
412
+ test("ask_user_choice rejects empty custom responses and escapes back to list cancellation", async () => {
413
+ const { tool } = registerChoiceTool();
414
+ const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
415
+ mode: "tui",
416
+ ui: {
417
+ custom: async (factory: ChoiceCustomFactory) => {
418
+ let completed: unknown;
419
+ const choice = factory(
420
+ { requestRender() {} },
421
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
422
+ {},
423
+ (value) => { completed = value; },
424
+ );
425
+ choice.handleInput("\x1b[B");
426
+ choice.handleInput("\x1b[B");
427
+ choice.handleInput("\r");
428
+ assert.match(choice.render(80).join("\n"), /Custom response/);
429
+ choice.handleInput(" ");
430
+ choice.handleInput("\r");
431
+ assert.equal(completed, undefined, "whitespace-only submission stays in the editor");
432
+ assert.match(choice.render(80).join("\n"), /Custom response/);
433
+ choice.handleInput("\x1b");
434
+ assert.match(choice.render(80).join("\n"), /Other…/);
435
+ choice.handleInput("\x1b");
436
+ return completed;
437
+ },
438
+ },
439
+ });
440
+ assert.deepEqual(result.details, { question: "Explain?", options, cancelled: true });
441
+ });
442
+
443
+ test("ask_user_choice renders Other only when custom response is explicitly enabled", async () => {
444
+ const { tool } = registerChoiceTool();
445
+ for (const allowCustomResponse of [undefined, false, true]) {
446
+ let rendered = "";
447
+ await tool.execute("call", { question: "Proceed?", options, ...(allowCustomResponse === undefined ? {} : { allowCustomResponse }) }, new AbortController().signal, undefined, {
448
+ mode: "tui",
449
+ ui: { custom: async (factory: ChoiceCustomFactory) => {
450
+ const component = factory(
451
+ { requestRender() {} },
452
+ { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
453
+ {}, () => {},
454
+ );
455
+ rendered = component.render(80).join("\n");
456
+ component.handleInput("\x1b");
457
+ return undefined;
458
+ } },
459
+ });
460
+ assert.equal(rendered.includes("Other…"), allowCustomResponse === true);
461
+ }
462
+ });
463
+
146
464
  test("ask_user_choice handles a closed Kilo hash decision with an opaque envelope value", async () => {
147
465
  const { tool } = registerChoiceTool(["read"]);
148
466
  const rendered = { value: "" };
@@ -185,7 +503,9 @@ test("ask_user_choice emits a private balanced lifecycle around selection and ca
185
503
  },
186
504
  },
187
505
  );
188
- assert.equal(selected.details.selection?.value, "preserve_requested_hash");
506
+ const selection = selected.details.selection;
507
+ assert.ok(selection !== null && typeof selection === "object" && "value" in selection);
508
+ assert.equal(selection.value, "preserve_requested_hash");
189
509
  assert.deepEqual(sequence, ["active", "custom", "inactive"]);
190
510
  assert.deepEqual(selectedRegistration.emittedEvents(), [
191
511
  { channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },