gentle-pi 2.5.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -58,6 +58,12 @@ test("renderAgentsCard draws columns for agent, task, and model · tokens · cos
58
58
  assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
59
59
  });
60
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
+
61
67
  test("renderAgentsCard keeps every task on one line, clipping long labels, and drops the task column when the card is narrow", () => {
62
68
  const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
63
69
  const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
@@ -65,7 +71,7 @@ test("renderAgentsCard keeps every task on one line, clipping long labels, and d
65
71
  assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
66
72
  const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
67
73
  assert.equal(narrow.length, 3);
68
- assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.27 · 4s │$/);
74
+ assert.match(narrow[1], /^│ ◐ sdd-explore +claude-sonnet-5 +│$/);
69
75
  });
70
76
 
71
77
  test("renderAgentsCard shows questions and failures in place of the task, and collapses to the first row", () => {
@@ -85,6 +91,30 @@ test("renderAgentsCard shows questions and failures in place of the task, and co
85
91
  assert.match(collapsed[1], /^│ \? sdd-explore asked: Delete\?/);
86
92
  });
87
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
+
88
118
  test("widgetRows caps the card at a quarter of the terminal, between three and eight rows", () => {
89
119
  assert.equal(widgetRows(40), 8, "tall terminals still stop at eight rows");
90
120
  assert.equal(widgetRows(24), 6);
@@ -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,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+ import { getKeybindings, KeybindingsManager, setKeybindings, stripTerminalSequences, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
4
4
  import askUserChoice from "../extensions/ask-user-choice.ts";
5
5
 
6
6
  interface ChoiceResult {
@@ -17,6 +17,7 @@ interface ChoiceParameters {
17
17
  additionalProperties?: boolean;
18
18
  properties?: {
19
19
  question?: unknown;
20
+ allowCustomResponse?: unknown;
20
21
  options?: {
21
22
  minItems?: number;
22
23
  maxItems?: number;
@@ -26,7 +27,10 @@ interface ChoiceParameters {
26
27
  }
27
28
 
28
29
  interface ChoiceTool {
30
+ renderShell?: string;
29
31
  name: string;
32
+ description?: string;
33
+ promptGuidelines?: string[];
30
34
  parameters: ChoiceParameters;
31
35
  execute: (...args: unknown[]) => Promise<ChoiceResult>;
32
36
  }
@@ -151,6 +155,10 @@ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
151
155
  };
152
156
  }
153
157
 
158
+ test("ask_user_choice transcript opts out of Pi's painted tool shell", () => {
159
+ assert.equal(registerChoiceTool().tool.renderShell, "self");
160
+ });
161
+
154
162
  test("ask_user_choice registers without runtime actions or overriding the open questionnaire", () => {
155
163
  const registration = registerChoiceTool(["read", "ask_user_question"]);
156
164
 
@@ -159,14 +167,17 @@ test("ask_user_choice registers without runtime actions or overriding the open q
159
167
  assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
160
168
  });
161
169
 
162
- 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", () => {
163
171
  const { tool } = registerChoiceTool();
164
172
  const optionsSchema = tool.parameters.properties?.options;
165
173
  const optionSchema = optionsSchema?.items;
166
174
 
167
175
  assert.equal(tool.name, "ask_user_choice");
168
176
  assert.equal(tool.parameters.additionalProperties, false);
169
- 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/);
170
181
  assert.equal(optionsSchema?.minItems, 2);
171
182
  assert.equal(optionsSchema?.maxItems, 4);
172
183
  assert.equal(optionSchema?.additionalProperties, false);
@@ -295,6 +306,161 @@ test("ask_user_choice retains native rendered hit testing for mouse selection",
295
306
  assert.equal(completionCalls, 1, "late keyboard input cannot complete the choice twice");
296
307
  });
297
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
+
298
464
  test("ask_user_choice handles a closed Kilo hash decision with an opaque envelope value", async () => {
299
465
  const { tool } = registerChoiceTool(["read"]);
300
466
  const rendered = { value: "" };
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ async function proveLazyDiscovery(): Promise<void> {
10
+ const {
11
+ createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices,
12
+ ModelRuntime, SessionManager, SettingsManager,
13
+ } = await import("@earendil-works/pi-coding-agent");
14
+ const cwd = process.cwd();
15
+ const agentDir = process.env.PI_CODING_AGENT_DIR!;
16
+ const shim = join(cwd, "extensions.ts");
17
+ const source = (name: string) => JSON.stringify(new URL(`../extensions/${name}.ts`, import.meta.url).href);
18
+ writeFileSync(shim, `
19
+ import { createGentleAiExtension } from ${source("gentle-ai")};
20
+ import gentleAgents from ${source("gentle-agents")};
21
+ import sddInit from ${source("sdd-init")};
22
+ export default function (pi) {
23
+ createGentleAiExtension({ nativeReviewCli: null, candidateViews: null, processEnv: {} })(pi);
24
+ gentleAgents(pi);
25
+ sddInit(pi);
26
+ }
27
+ `);
28
+ const modelRuntime = await ModelRuntime.create({
29
+ authPath: join(agentDir, "empty-auth.json"),
30
+ modelsPath: null,
31
+ modelsStorePath: join(agentDir, "models-store.json"),
32
+ allowModelNetwork: false,
33
+ });
34
+ const runtime = await createAgentSessionRuntime(async ({ cwd, sessionManager, sessionStartEvent }) => {
35
+ const services = await createAgentSessionServices({
36
+ cwd, agentDir, modelRuntime,
37
+ settingsManager: SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }),
38
+ resourceLoaderOptions: {
39
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
40
+ additionalExtensionPaths: [shim],
41
+ },
42
+ });
43
+ assert.deepEqual(services.resourceLoader.getExtensions().errors, []);
44
+ assert.deepEqual(services.diagnostics, []);
45
+ return {
46
+ ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, tools: ["subagent_list_agents"] })),
47
+ services, diagnostics: services.diagnostics,
48
+ };
49
+ }, { cwd, agentDir, sessionManager: SessionManager.inMemory(cwd) });
50
+ try {
51
+ await runtime.session.bindExtensions({ mode: "print" });
52
+ const session = runtime.session;
53
+ const list = session.agent.state.tools.find(tool => tool.name === "subagent_list_agents");
54
+ assert.ok(list, "actual SDK must expose the wrapped discovery tool");
55
+ const names = async () => {
56
+ const result = await list.execute("asset-discovery", {});
57
+ return result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
58
+ };
59
+ const before = await names();
60
+ assert.match(before, /- gentle-ai-explore \(global\)/);
61
+ assert.match(before, /- review-risk \(global\)/);
62
+ assert.doesNotMatch(before, /- sdd-/, "fresh startup must not install SDD definitions");
63
+ assert.equal(existsSync(join(agentDir, "chains", "sdd-full.chain.md")), false);
64
+ assert.equal(existsSync(join(agentDir, "gentle-ai", "support")), false);
65
+ await session.prompt("/gentle:install-sdd");
66
+ assert.strictEqual(runtime.session, session);
67
+ const after = await names();
68
+ for (const name of ["gentle-ai-explore", "review-risk", "sdd-init", "sdd-apply"]) {
69
+ assert.ok(after.includes(`- ${name} (global)`), `same-session discovery must include ${name}`);
70
+ }
71
+ for (const file of ["sdd-status-contract.md", "strict-tdd.md", "strict-tdd-verify.md"]) {
72
+ assert.ok(existsSync(join(agentDir, "gentle-ai", "support", file)), `missing support: ${file}`);
73
+ }
74
+ assert.ok(existsSync(join(agentDir, "chains", "sdd-full.chain.md")));
75
+ assert.equal(session.messages.length, 0, "slash activation must not start a model turn");
76
+ console.log("SDK discovery: delegation/review only -> /gentle:install-sdd -> same-session sdd-init/sdd-apply and support");
77
+ } finally {
78
+ await runtime.dispose();
79
+ }
80
+ }
81
+
82
+ if (process.env.GENTLE_PI_ASSET_PROOF_CHILD === "1") {
83
+ await proveLazyDiscovery();
84
+ } else {
85
+ test("actual SDK discovers SDD only after explicit activation in the same session", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-assets-sdk-"));
87
+ try {
88
+ const home = join(root, "home");
89
+ const cwd = join(root, "project");
90
+ const agentDir = join(home, ".pi", "agent");
91
+ for (const path of [cwd, agentDir]) mkdirSync(path, { recursive: true });
92
+ const result = spawnSync(process.execPath, ["--experimental-strip-types", fileURLToPath(import.meta.url)], {
93
+ cwd, encoding: "utf8", timeout: 30_000,
94
+ env: {
95
+ PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, HOME: home, TMPDIR: root,
96
+ PI_CODING_AGENT_DIR: agentDir, GENTLE_PI_AGENT_HOME: agentDir,
97
+ GENTLE_PI_CONFIG_HOME: join(home, "config"), XDG_CONFIG_HOME: join(home, ".config"),
98
+ PI_OFFLINE: "1", GENTLE_PI_ASSET_PROOF_CHILD: "1",
99
+ },
100
+ });
101
+ assert.ifError(result.error);
102
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
103
+ assert.match(result.stdout, /SDK discovery: delegation\/review only/);
104
+ } finally {
105
+ rmSync(root, { recursive: true, force: true });
106
+ }
107
+ });
108
+ }
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
6
  import { __testing } from "../extensions/gentle-ai.ts";
7
7
 
8
- const { classifyGuardedCommand } = __testing;
8
+ const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle } = __testing;
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
11
  // Helpers
@@ -129,6 +129,121 @@ test("classifyGuardedCommand: git push plain allowed when autonomousMode=true an
129
129
  assert.equal(result, "allow");
130
130
  });
131
131
 
132
+ test("classifyGuardedCommand: configured push allow is remote-agnostic and unchanged", () => {
133
+ const config = { autonomousMode: true, guardedCommands: { gitPush: "allow" as const } };
134
+ for (const command of [
135
+ "git push origin feature/test",
136
+ "git push upstream feature/test",
137
+ "git push fork feature/test",
138
+ "git push",
139
+ "git push feature/test && git status",
140
+ "echo done || git push origin feature/test",
141
+ ]) {
142
+ assert.equal(classifyGuardedCommand(command, config), "allow", command);
143
+ }
144
+ assert.equal(
145
+ classifyGuardedCommand("git push origin feature/test", { autonomousMode: true, guardedCommands: { gitPush: "block" } }),
146
+ "block",
147
+ );
148
+ for (const command of [
149
+ "git push -f origin feature/test",
150
+ "git push --force origin main",
151
+ "git push --force-with-lease origin feature/test",
152
+ ]) {
153
+ assert.equal(classifyGuardedCommand(command, config), "block", command);
154
+ }
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Guarded command preview — presentation-only centering
159
+ // ---------------------------------------------------------------------------
160
+
161
+ test("guardedCommandPreview: long late matched action stays visible in preview", () => {
162
+ const prefix = "noise ".repeat(80);
163
+ const command = `${prefix}git push origin feature/test`;
164
+ // Contract: the caller owns the trigger index. confirmCommand hands over
165
+ // evaluation.triggerIndex, so the preview always centers on the matched
166
+ // action supplied by the evaluator.
167
+ const triggerIndex = command.indexOf("git push");
168
+ const preview = guardedCommandPreview(command, triggerIndex);
169
+ assert.match(preview, /git push origin feature\/test/);
170
+ assert.ok(preview.startsWith("…"), "preview elides leading context near a late match");
171
+ });
172
+
173
+ test("guardedCommandPreview: late non-push action visible, elided, and width-bounded", () => {
174
+ const prefix = "noise ".repeat(80);
175
+ const command = `${prefix}npm publish --tag beta`;
176
+ const triggerIndex = command.indexOf("npm publish");
177
+ const preview = guardedCommandPreview(command, triggerIndex);
178
+ assert.match(preview, /npm publish --tag beta/);
179
+ assert.ok(preview.startsWith("…"), "preview elides leading context near a late match");
180
+ // Bounded visible width: 1 leading ellipsis + up to 179 content chars (budget 180).
181
+ assert.ok(preview.length <= 180, `preview exceeds bounded width: ${preview.length}`);
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Guarded command title — presentation-only, action-specific
186
+ // ---------------------------------------------------------------------------
187
+
188
+ test("guardedCommandTitle: exact action-specific titles per guarded key", () => {
189
+ assert.equal(guardedCommandTitle("gitPush"), "Allow guarded git push?");
190
+ assert.equal(guardedCommandTitle("gitRebase"), "Allow guarded git rebase?");
191
+ assert.equal(
192
+ guardedCommandTitle("gitBranchDeleteForce"),
193
+ "Allow guarded forced git branch deletion?",
194
+ );
195
+ assert.equal(guardedCommandTitle("npmPublish"), "Allow guarded npm publish?");
196
+ assert.equal(guardedCommandTitle("piRemove"), "Allow guarded pi remove?");
197
+ });
198
+
199
+ test("guardedCommandTitle: unkeyed guarded command falls back to generic title", () => {
200
+ assert.equal(guardedCommandTitle(undefined), "Allow guarded command?");
201
+ });
202
+
203
+ test("evaluateGuardedCommand: collects guarded matches in command order and applies full-command precedence", () => {
204
+ const command = "npm publish && git push origin main && git rebase main";
205
+ const evaluation = evaluateGuardedCommand(command, {
206
+ autonomousMode: true,
207
+ guardedCommands: { gitPush: "allow", gitRebase: "confirm", npmPublish: "block" },
208
+ });
209
+ assert.deepEqual(evaluation.matches.map((match) => [match.key, match.action]), [
210
+ ["npmPublish", "block"],
211
+ ["gitPush", "allow"],
212
+ ["gitRebase", "confirm"],
213
+ ]);
214
+ assert.deepEqual(evaluation.matches.map((match) => command.slice(match.triggerIndex).split(/\s/, 1)[0]), ["publish", "push", "rebase"]);
215
+ assert.equal(evaluation.action, "block");
216
+
217
+ assert.equal(evaluateGuardedCommand("git rebase main && git push --force origin main", {
218
+ autonomousMode: true,
219
+ guardedCommands: { gitRebase: "allow", gitPush: "allow" },
220
+ }).action, "block", "a later hard deny overrides an earlier allowed action");
221
+ });
222
+
223
+ test("evaluateGuardedCommand: git -C stores the push token offset for bounded previews", () => {
224
+ const command = `git -C /${"very-long-path/".repeat(30)} push origin main`;
225
+ const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
226
+ assert.equal(command.slice(evaluation.triggerIndex, evaluation.triggerIndex + 4), "push");
227
+ assert.match(guardedCommandPreview(command, evaluation.triggerIndex), /push origin main/);
228
+ });
229
+
230
+ test("evaluateGuardedCommand: git -C path containing push still offsets the push action", () => {
231
+ const command = `git -C /${"push-path/".repeat(30)} push origin main`;
232
+ const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
233
+ assert.equal(evaluation.triggerIndex, command.lastIndexOf("push"));
234
+ assert.match(guardedCommandPreview(command, evaluation.triggerIndex), /push origin main/);
235
+ });
236
+
237
+ test("evaluateGuardedCommand: repeated forced branch deletions remain separate matches", () => {
238
+ const command = "git branch --delete --force old && git branch --delete --force older";
239
+ const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
240
+ assert.deepEqual(evaluation.matches.map((match) => match.key), ["gitBranchDeleteForce", "gitBranchDeleteForce"]);
241
+ assert.equal(
242
+ guardedCommandTitle(evaluation.key, evaluation.matches),
243
+ "Allow guarded actions: forced git branch deletion; forced git branch deletion?",
244
+ );
245
+ });
246
+
132
247
  test("classifyGuardedCommand: git push plain still confirm when autonomousMode=false even with gitPush=allow in config", () => {
133
248
  const result = classifyGuardedCommand("git push origin feature/test", {
134
249
  autonomousMode: false,
@@ -191,7 +191,7 @@ test("CodeGraph tool rejects incomplete or oversized query requests before runni
191
191
  });
192
192
 
193
193
  test("CodeGraph tool registration exposes a single constrained custom tool", () => {
194
- const tools: Array<{ name: string; parameters: Record<string, unknown> }> = [];
194
+ const tools: Array<{ name: string; renderShell?: string; parameters: Record<string, unknown> }> = [];
195
195
  const pi = {
196
196
  registerTool(tool: { name: string; parameters: Record<string, unknown> }) {
197
197
  tools.push(tool);
@@ -202,6 +202,7 @@ test("CodeGraph tool registration exposes a single constrained custom tool", ()
202
202
 
203
203
  assert.equal(tools.length, 1);
204
204
  assert.equal(tools[0]?.name, "codegraph");
205
+ assert.equal(tools[0]?.renderShell, "self", "CodeGraph must not inherit Pi's painted Box");
205
206
  assert.deepEqual(tools[0]?.parameters, {
206
207
  type: "object",
207
208
  additionalProperties: false,
@@ -92,7 +92,7 @@ test("SDD executor coverage is exhaustive against actual agent files", () => {
92
92
  // the allowlist matches reality so coverage cannot silently drift.
93
93
  const expected = [
94
94
  "sdd-apply.md", "sdd-archive.md", "sdd-design.md", "sdd-explore.md",
95
- "sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-research.md",
95
+ "sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-remediate.md", "sdd-research.md",
96
96
  "sdd-spec.md", "sdd-status.md", "sdd-sync.md", "sdd-tasks.md",
97
97
  "sdd-verify.md",
98
98
  ];
@@ -306,6 +306,8 @@ test("dev-binary: v7 intended-untracked selection decodes both selected statuses
306
306
  assert.equal("lineage_created" in result, false, "reconciled unknown mutation must not claim lineage creation");
307
307
  assert.equal("mutation_performed" in result, false, "reconciled unknown mutation must not claim a mutation result");
308
308
  assert.equal(result.mutation_outcome, "unknown", "the controller cannot prove mutation status after an intercepted launch");
309
+ assert.match(String(result.required_status_action), /Run target-scoped review\.status/, "ambiguous START must retain its executable-safe STATUS instruction");
310
+ assert.equal(result.next_action, "start", "the reconciled provider action remains visible without authoring a replay");
309
311
  const after = await native.targetStatus({ cwd, agent: "pi" });
310
312
  assert.equal(after.authority, undefined, "post-intercept STATUS must not expose authority");
311
313
  });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import test from "node:test";
5
+
6
+ // Reuse Pi's existing YAML dependency without adding a package dependency.
7
+ const piRequire = createRequire(import.meta.resolve("@earendil-works/pi-coding-agent"));
8
+ const { parseDocument } = piRequire("yaml");
9
+ const formUrl = new URL("../.github/ISSUE_TEMPLATE/feature_request.yml", import.meta.url);
10
+
11
+ function readForm() {
12
+ const document = parseDocument(readFileSync(formUrl, "utf8"), { uniqueKeys: true });
13
+ assert.deepEqual(document.errors, [], "the issue form must be valid, unambiguous YAML");
14
+ return document.toJS();
15
+ }
16
+
17
+ test("feature request form has supported structure and only intake labels", () => {
18
+ const form = readForm();
19
+ assert.deepEqual(Object.keys(form).sort(), ["body", "description", "labels", "name"]);
20
+ assert.equal(form.name, "Feature Request");
21
+ assert.equal(typeof form.description, "string");
22
+ assert.ok(form.description.trim().length > 0);
23
+ // Exact allowlist also rejects approval, exception, and other privileged labels.
24
+ assert.deepEqual(form.labels, ["enhancement", "status:needs-review"]);
25
+ assert.ok(Array.isArray(form.body));
26
+ assert.deepEqual(form.body.map((field) => field.id), [
27
+ "preflight", "problem", "outcome", "alternatives", "context",
28
+ ]);
29
+ for (const field of form.body) {
30
+ assert.ok(["checkboxes", "textarea"].includes(field.type));
31
+ assert.match(field.id, /^[a-zA-Z0-9_-]+$/);
32
+ assert.equal(typeof field.attributes.label, "string");
33
+ assert.ok(field.attributes.label.trim().length > 0);
34
+ assert.deepEqual(Object.keys(field).sort(), field.type === "checkboxes"
35
+ ? ["attributes", "id", "type"]
36
+ : ["attributes", "id", "type", "validations"]);
37
+ }
38
+ });
39
+
40
+ test("duplicate and privacy affirmations are individually required", () => {
41
+ const [preflight] = readForm().body;
42
+ assert.equal(preflight.type, "checkboxes");
43
+ assert.deepEqual(preflight.attributes.options, [
44
+ {
45
+ label: "I searched open and closed issues and did not find a request for this feature.",
46
+ required: true,
47
+ },
48
+ {
49
+ label: "I reviewed this request and removed credentials, tokens, private paths, hostnames, and other sensitive data.",
50
+ required: true,
51
+ },
52
+ ]);
53
+ });
54
+
55
+ test("feature questions require problem and outcome but keep alternatives and context optional", () => {
56
+ const questions = readForm().body.slice(1);
57
+ assert.deepEqual(questions.map((field) => ({
58
+ type: field.type,
59
+ label: field.attributes.label,
60
+ validations: field.validations,
61
+ })), [
62
+ { type: "textarea", label: "Problem or opportunity", validations: { required: true } },
63
+ { type: "textarea", label: "Proposed outcome", validations: { required: true } },
64
+ { type: "textarea", label: "Alternatives considered", validations: { required: false } },
65
+ { type: "textarea", label: "Additional context", validations: { required: false } },
66
+ ]);
67
+ });
@@ -0,0 +1,5 @@
1
+ process.on("message", (message) => {
2
+ if (message?.kind !== "ack") return;
3
+ process.stdout.write(`${JSON.stringify({ accepted: message.accepted === true })}\n`, () => process.exit(0));
4
+ });
5
+ process.send({ id: "n1", kind: "notification", message: "fixture ready" });