gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,349 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
5
+ import type { ExecFileAdapter, ExecFileResult } from "../lib/native-review-cli.ts";
6
+ import {
7
+ decodeTelemetryTriggerDecision,
8
+ shouldTriggerTelemetry,
9
+ spawnTelemetryTrigger,
10
+ TELEMETRY_TRIGGER_CONTRACT,
11
+ TELEMETRY_TRIGGER_KILL_TIMEOUT_MS,
12
+ type TelemetryTriggerChildLike,
13
+ type TelemetryTriggerSpawn,
14
+ type TelemetryTriggerSpawnOptions,
15
+ } from "../lib/telemetry-trigger.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // gentle-pi#677: gentle-ai owns telemetry end to end (gentle-ai#4309); Pi only
19
+ // nudges the local binary once per process. These tests cover the pure
20
+ // predicate/spawn/decode core in lib/telemetry-trigger.ts and the two ways
21
+ // extensions/gentle-ai.ts wires it in: the once-per-process activation nudge
22
+ // and the foreground /gentle:telemetry slash command relay.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface FakeSpawnRecord {
26
+ command: string;
27
+ args: string[];
28
+ options: TelemetryTriggerSpawnOptions;
29
+ }
30
+
31
+ function fakeSpawn(): { spawn: TelemetryTriggerSpawn; calls: FakeSpawnRecord[]; killCalls: number } {
32
+ const calls: FakeSpawnRecord[] = [];
33
+ const state = { killCalls: 0 };
34
+ const spawn: TelemetryTriggerSpawn = (command, args, options) => {
35
+ calls.push({ command, args: [...args], options });
36
+ const child: TelemetryTriggerChildLike = {
37
+ unref() {
38
+ return undefined;
39
+ },
40
+ kill() {
41
+ state.killCalls += 1;
42
+ return true;
43
+ },
44
+ on() {
45
+ return undefined;
46
+ },
47
+ };
48
+ return child;
49
+ };
50
+ return {
51
+ spawn,
52
+ calls,
53
+ get killCalls() {
54
+ return state.killCalls;
55
+ },
56
+ };
57
+ }
58
+
59
+ test("shouldTriggerTelemetry: pure predicate table", () => {
60
+ const cases: Array<[Record<string, string | undefined>, boolean]> = [
61
+ [{}, true],
62
+ [{ DO_NOT_TRACK: "1" }, false],
63
+ [{ DO_NOT_TRACK: "0" }, true],
64
+ [{ GENTLE_AI_TELEMETRY: "0" }, false],
65
+ [{ GENTLE_AI_TELEMETRY: "1" }, true],
66
+ [{ CI: "true" }, false],
67
+ [{ CI: "false" }, true],
68
+ [{ CI: undefined }, true],
69
+ [{ DO_NOT_TRACK: "1", GENTLE_AI_TELEMETRY: "0", CI: "true" }, false],
70
+ ];
71
+ for (const [env, expected] of cases) {
72
+ assert.equal(shouldTriggerTelemetry(env), expected, JSON.stringify(env));
73
+ }
74
+ });
75
+
76
+ test("spawnTelemetryTrigger: launches the expected argv, detached and stdio-ignored", () => {
77
+ const fake = fakeSpawn();
78
+ const result = spawnTelemetryTrigger({
79
+ executable: "/opt/gentle-ai/gentle-ai",
80
+ cwd: "/work/project",
81
+ env: {},
82
+ spawn: fake.spawn,
83
+ });
84
+ assert.deepEqual(result, { spawned: true, reason: "spawned" });
85
+ assert.equal(fake.calls.length, 1);
86
+ const [call] = fake.calls;
87
+ assert.equal(call.command, "/opt/gentle-ai/gentle-ai");
88
+ assert.deepEqual(call.args, ["telemetry", "trigger", "--json"]);
89
+ assert.equal(call.options.cwd, "/work/project");
90
+ assert.equal(call.options.detached, true);
91
+ assert.equal(call.options.stdio, "ignore");
92
+ });
93
+
94
+ test("spawnTelemetryTrigger: never spawns under each kill switch", () => {
95
+ const switches: Array<[Record<string, string | undefined>, string]> = [
96
+ [{ DO_NOT_TRACK: "1" }, "do-not-track"],
97
+ [{ GENTLE_AI_TELEMETRY: "0" }, "telemetry-disabled"],
98
+ [{ CI: "true" }, "ci"],
99
+ ];
100
+ for (const [env, reason] of switches) {
101
+ const fake = fakeSpawn();
102
+ const result = spawnTelemetryTrigger({ executable: "gentle-ai", cwd: "/work", env, spawn: fake.spawn });
103
+ assert.deepEqual(result, { spawned: false, reason }, JSON.stringify(env));
104
+ assert.equal(fake.calls.length, 0, JSON.stringify(env));
105
+ }
106
+ });
107
+
108
+ test("spawnTelemetryTrigger: a throwing spawn is swallowed, never thrown", () => {
109
+ const throwingSpawn: TelemetryTriggerSpawn = () => {
110
+ throw new Error("spawn EAGAIN");
111
+ };
112
+ const result = spawnTelemetryTrigger({ executable: "gentle-ai", cwd: "/work", env: {}, spawn: throwingSpawn });
113
+ assert.deepEqual(result, { spawned: false, reason: "spawn-error" });
114
+ });
115
+
116
+ test("spawnTelemetryTrigger: the 3 s kill timer fires exactly once and is unref'd", (t) => {
117
+ t.mock.timers.enable({ apis: ["setTimeout"] });
118
+ const fake = fakeSpawn();
119
+ const result = spawnTelemetryTrigger({ executable: "gentle-ai", cwd: "/work", env: {}, spawn: fake.spawn });
120
+ assert.equal(result.spawned, true);
121
+ assert.equal(fake.killCalls, 0);
122
+ t.mock.timers.tick(TELEMETRY_TRIGGER_KILL_TIMEOUT_MS);
123
+ assert.equal(fake.killCalls, 1);
124
+ t.mock.timers.tick(TELEMETRY_TRIGGER_KILL_TIMEOUT_MS);
125
+ assert.equal(fake.killCalls, 1, "the timer must not fire a second time");
126
+ });
127
+
128
+ test("decodeTelemetryTriggerDecision: decodes a well-formed v1 payload", () => {
129
+ const stdout = JSON.stringify({ schema: TELEMETRY_TRIGGER_CONTRACT, decision: "sent_heartbeat", source: "local-state" });
130
+ assert.deepEqual(decodeTelemetryTriggerDecision(stdout), {
131
+ schema: TELEMETRY_TRIGGER_CONTRACT,
132
+ decision: "sent_heartbeat",
133
+ source: "local-state",
134
+ });
135
+ });
136
+
137
+ test("decodeTelemetryTriggerDecision: every documented decision round-trips", () => {
138
+ for (const decision of ["enrolled", "sent_install", "sent_heartbeat", "rate_limited", "backoff", "disabled"]) {
139
+ const stdout = JSON.stringify({ schema: TELEMETRY_TRIGGER_CONTRACT, decision, source: "env" });
140
+ assert.equal(decodeTelemetryTriggerDecision(stdout)?.decision, decision);
141
+ }
142
+ });
143
+
144
+ test("decodeTelemetryTriggerDecision: an old binary's output decodes as nothing to do", () => {
145
+ assert.equal(decodeTelemetryTriggerDecision("unknown telemetry command\n"), undefined);
146
+ assert.equal(decodeTelemetryTriggerDecision(""), undefined);
147
+ });
148
+
149
+ test("decodeTelemetryTriggerDecision: rejects a mismatched schema, unknown decision, or missing source", () => {
150
+ assert.equal(decodeTelemetryTriggerDecision(JSON.stringify({ schema: "other/v1", decision: "enrolled", source: "cli" })), undefined);
151
+ assert.equal(decodeTelemetryTriggerDecision(JSON.stringify({ schema: TELEMETRY_TRIGGER_CONTRACT, decision: "unheard-of", source: "cli" })), undefined);
152
+ assert.equal(decodeTelemetryTriggerDecision(JSON.stringify({ schema: TELEMETRY_TRIGGER_CONTRACT, decision: "enrolled" })), undefined);
153
+ assert.equal(decodeTelemetryTriggerDecision("[]"), undefined);
154
+ assert.equal(decodeTelemetryTriggerDecision("null"), undefined);
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Extension wiring: the once-per-process activation nudge.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function buildExtensionHarness(overrides: Parameters<typeof createGentleAiExtension>[0] = {}) {
162
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<unknown>>();
163
+ const commands = new Map<string, { handler: (args: string, ctx: ExtensionContext) => Promise<void> }>();
164
+ const pi = {
165
+ on(name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<unknown>) {
166
+ handlers.set(name, handler);
167
+ },
168
+ registerCommand(name: string, command: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) {
169
+ commands.set(name, command);
170
+ },
171
+ registerTool() {},
172
+ events: { emit() {} },
173
+ } as unknown as ExtensionAPI;
174
+ createGentleAiExtension({ nativeReviewCli: null, ...overrides })(pi);
175
+ return { handlers, commands };
176
+ }
177
+
178
+ function fakeContext(cwd: string, notifications: Array<{ message: string; severity: string }>): ExtensionContext {
179
+ return {
180
+ cwd,
181
+ hasUI: true,
182
+ ui: {
183
+ notify(message: string, severity: string) {
184
+ notifications.push({ message, severity });
185
+ },
186
+ },
187
+ } as unknown as ExtensionContext;
188
+ }
189
+
190
+ test("activation: spawns the telemetry trigger exactly once for a primary session", async (t) => {
191
+ t.after(() => __testing.resetTelemetryTriggerGuardForTesting());
192
+ __testing.resetTelemetryTriggerGuardForTesting();
193
+ const fake = fakeSpawn();
194
+ const { handlers } = buildExtensionHarness({
195
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
196
+ telemetryTriggerSpawn: fake.spawn,
197
+ // Pin the environment: the ambient shell or CI may carry a kill switch
198
+ // (CI=true, DO_NOT_TRACK=1, GENTLE_AI_TELEMETRY=0) that would suppress the spawn.
199
+ processEnv: { PATH: "/bin" },
200
+ });
201
+ const beforeAgentStart = handlers.get("before_agent_start");
202
+ assert.equal(typeof beforeAgentStart, "function");
203
+ const notifications: Array<{ message: string; severity: string }> = [];
204
+ const ctx = fakeContext("/work/project", notifications);
205
+
206
+ // A primary-session event carries no agent name at all.
207
+ await beforeAgentStart!({ systemPrompt: "" }, ctx);
208
+ await beforeAgentStart!({ systemPrompt: "" }, ctx);
209
+
210
+ assert.equal(fake.calls.length, 1, "the trigger must be attempted at most once per process");
211
+ const [call] = fake.calls;
212
+ assert.deepEqual(call.args, ["telemetry", "trigger", "--json"]);
213
+ assert.equal(call.options.cwd, "/work/project");
214
+ assert.equal(call.options.detached, true);
215
+ assert.equal(call.options.stdio, "ignore");
216
+ });
217
+
218
+ test("activation: never spawns for a named or SDD agent event", async (t) => {
219
+ t.after(() => __testing.resetTelemetryTriggerGuardForTesting());
220
+ __testing.resetTelemetryTriggerGuardForTesting();
221
+ const fake = fakeSpawn();
222
+ const { handlers } = buildExtensionHarness({
223
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
224
+ telemetryTriggerSpawn: fake.spawn,
225
+ });
226
+ const beforeAgentStart = handlers.get("before_agent_start");
227
+ const notifications: Array<{ message: string; severity: string }> = [];
228
+ const ctx = fakeContext("/work/project", notifications);
229
+
230
+ await beforeAgentStart!({ agentName: "review-risk", systemPrompt: "" }, ctx);
231
+ await beforeAgentStart!({ systemPrompt: "SDD apply executor" }, ctx);
232
+
233
+ assert.equal(fake.calls.length, 0, "named/SDD agents must never trigger the nudge");
234
+ });
235
+
236
+ test("activation: a missing binary or spawn error never affects activation", async (t) => {
237
+ t.after(() => __testing.resetTelemetryTriggerGuardForTesting());
238
+ __testing.resetTelemetryTriggerGuardForTesting();
239
+ const { handlers } = buildExtensionHarness({
240
+ resolveTelemetryTriggerBinary: () => {
241
+ throw new Error("package-local-binary-missing: not installed");
242
+ },
243
+ });
244
+ const beforeAgentStart = handlers.get("before_agent_start");
245
+ const notifications: Array<{ message: string; severity: string }> = [];
246
+ const ctx = fakeContext("/work/project", notifications);
247
+
248
+ // Must resolve cleanly and produce the ordinary orchestrator prompt fields,
249
+ // never throw or notify about the missing binary.
250
+ const outcome = await beforeAgentStart!({ systemPrompt: "base" }, ctx);
251
+ assert.equal(typeof outcome, "object");
252
+ assert.equal(notifications.length, 0);
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Extension wiring: the foreground /gentle:telemetry slash command.
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function fakeAdapter(result: ExecFileResult): { adapter: ExecFileAdapter; requests: Array<{ file: string; arguments: readonly string[] }> } {
260
+ const requests: Array<{ file: string; arguments: readonly string[] }> = [];
261
+ const adapter: ExecFileAdapter = async (request) => {
262
+ requests.push({ file: request.file, arguments: request.arguments });
263
+ return result;
264
+ };
265
+ return { adapter, requests };
266
+ }
267
+
268
+ test("/gentle:telemetry relays a status --json payload", async () => {
269
+ const payload = { schema: "gentle-ai.telemetry-status/v1", enabled: true, source: "default" };
270
+ const { adapter, requests } = fakeAdapter({
271
+ stdout: `${JSON.stringify(payload)}\n`,
272
+ stderr: "",
273
+ exitCode: 0,
274
+ signal: null,
275
+ timedOut: false,
276
+ outputLimitExceeded: false,
277
+ });
278
+ const { commands } = buildExtensionHarness({
279
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
280
+ telemetryExecFileAdapter: adapter,
281
+ });
282
+ const command = commands.get("gentle:telemetry");
283
+ assert.ok(command, "gentle:telemetry must be registered");
284
+ const notifications: Array<{ message: string; severity: string }> = [];
285
+ await command!.handler("", fakeContext("/work/project", notifications));
286
+
287
+ assert.equal(requests.length, 1);
288
+ assert.deepEqual(requests[0].arguments, ["telemetry", "status", "--json"]);
289
+ assert.equal(notifications.length, 1);
290
+ assert.equal(notifications[0].severity, "info");
291
+ assert.deepEqual(JSON.parse(notifications[0].message), payload);
292
+ });
293
+
294
+ test("/gentle:telemetry disable prints a one-line confirmation", async () => {
295
+ const { adapter } = fakeAdapter({
296
+ stdout: `${JSON.stringify({ schema: "gentle-ai.telemetry-status/v1", enabled: false, source: "user" })}\n`,
297
+ stderr: "",
298
+ exitCode: 0,
299
+ signal: null,
300
+ timedOut: false,
301
+ outputLimitExceeded: false,
302
+ });
303
+ const { commands } = buildExtensionHarness({
304
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
305
+ telemetryExecFileAdapter: adapter,
306
+ });
307
+ const command = commands.get("gentle:telemetry");
308
+ const notifications: Array<{ message: string; severity: string }> = [];
309
+ await command!.handler("disable", fakeContext("/work/project", notifications));
310
+
311
+ assert.deepEqual(notifications, [{ message: "Gentle AI telemetry disabled.", severity: "info" }]);
312
+ });
313
+
314
+ test("/gentle:telemetry relays a typed non-zero failure", async () => {
315
+ const { adapter } = fakeAdapter({
316
+ stdout: "",
317
+ stderr: "unknown telemetry command\n",
318
+ exitCode: 1,
319
+ signal: null,
320
+ timedOut: false,
321
+ outputLimitExceeded: false,
322
+ });
323
+ const { commands } = buildExtensionHarness({
324
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
325
+ telemetryExecFileAdapter: adapter,
326
+ });
327
+ const command = commands.get("gentle:telemetry");
328
+ const notifications: Array<{ message: string; severity: string }> = [];
329
+ await command!.handler("preview", fakeContext("/work/project", notifications));
330
+
331
+ assert.equal(notifications.length, 1);
332
+ assert.equal(notifications[0].severity, "error");
333
+ assert.match(notifications[0].message, /unknown telemetry command/);
334
+ });
335
+
336
+ test("/gentle:telemetry rejects an unknown sub-action without calling the binary", async () => {
337
+ const { adapter, requests } = fakeAdapter({ stdout: "{}", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false });
338
+ const { commands } = buildExtensionHarness({
339
+ resolveTelemetryTriggerBinary: () => "/opt/gentle-ai/gentle-ai",
340
+ telemetryExecFileAdapter: adapter,
341
+ });
342
+ const command = commands.get("gentle:telemetry");
343
+ const notifications: Array<{ message: string; severity: string }> = [];
344
+ await command!.handler("frobnicate", fakeContext("/work/project", notifications));
345
+
346
+ assert.equal(requests.length, 0);
347
+ assert.equal(notifications.length, 1);
348
+ assert.equal(notifications[0].severity, "warning");
349
+ });
@@ -15,18 +15,18 @@ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
15
15
  // The guard reads the `## Allowed edit surfaces` section out of the delegated
16
16
  // `subagent_run` prompt. A delegated task is a full prompt: the surfaces list
17
17
  // is followed by deeper headings (`### Validation`, `#### Return`) and by
18
- // ordinary prose. The section must end where the list ends, so that following
19
- // content is never parsed as a surface entry and never turned into a false
20
- // rejection. A `context` value carrying only the heading and its lines had
21
- // nothing following it, which is why the same surfaces were accepted there and
22
- // rejected in `task`.
18
+ // ordinary prose. The section remains fail-closed through ordinary prose until
19
+ // the next canonical Markdown heading, so following content is always parsed as
20
+ // a surface entry rather than silently discarded. A `context` value carrying
21
+ // only the heading and its lines had nothing following it, which is why the same
22
+ // surfaces were accepted there and rejected in `task`.
23
23
  //
24
24
  // Every case below runs through the real `tool_call` hook with the exact
25
25
  // `subagent_run` input shape, because that is the boundary that rejected.
26
26
  // ---------------------------------------------------------------------------
27
27
 
28
28
  const REJECTION =
29
- "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
29
+ "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. Every non-empty line belongs to the section until the next canonical Markdown heading and must be a valid surface entry. Paths containing whitespace require whole-entry backticks; begin explanatory prose under the next Markdown heading. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs.";
30
30
 
31
31
  type ToolCallHandler = (
32
32
  event: { toolName: string; input: unknown },
@@ -89,8 +89,8 @@ test("task-scoped surfaces are accepted ahead of a deeper heading", async () =>
89
89
  }, "a delegated task ends its surfaces list at the next heading of any level");
90
90
  });
91
91
 
92
- test("task-scoped surfaces are accepted ahead of trailing prose", async () => {
93
- await assertAccepted({
92
+ test("task-scoped surfaces reject trailing prose before the next heading", async () => {
93
+ await assertRejected({
94
94
  agent: "gentle-ai-worker",
95
95
  mode: "task",
96
96
  task: [
@@ -99,7 +99,86 @@ test("task-scoped surfaces are accepted ahead of trailing prose", async () => {
99
99
  "",
100
100
  "Then run the focused test file and report the outcome.",
101
101
  ].join("\n"),
102
- }, "prose after the list is not a surface entry");
102
+ }, "trailing prose must begin under a following Markdown heading");
103
+ });
104
+
105
+ test("canonical headings close the surface section with up to three ASCII spaces", async () => {
106
+ for (const indentation of ["", " ", " ", " "]) {
107
+ await assertAccepted({
108
+ agent: "gentle-ai-worker",
109
+ mode: "task",
110
+ task: [
111
+ "## Allowed edit surfaces",
112
+ "- `lib/sdd-status.ts`",
113
+ `${indentation}### Validation`,
114
+ "node --test",
115
+ ].join("\n"),
116
+ }, `${JSON.stringify(indentation)} indentation closes the section`);
117
+ }
118
+ });
119
+
120
+ test("canonical empty headings with trailing spaces close the surface section", async () => {
121
+ for (const indentation of ["", " "]) {
122
+ await assertAccepted({
123
+ agent: "gentle-ai-worker",
124
+ mode: "task",
125
+ task: [
126
+ "## Allowed edit surfaces",
127
+ "- `lib/sdd-status.ts`",
128
+ `${indentation}### `,
129
+ "Ordinary prose after an empty heading is not a surface entry.",
130
+ ].join("\n"),
131
+ }, `${JSON.stringify(indentation)} indentation closes at an empty heading`);
132
+ }
133
+ });
134
+
135
+ test("pseudo-headings remain inside the surface section and reject dangerous paths", async () => {
136
+ for (const [label, heading] of [
137
+ ["four-space indentation", " ### Validation"],
138
+ ["seven hashes", "####### Validation"],
139
+ ["bare marker", "###"],
140
+ ["missing heading separator", "###Validation"],
141
+ ["tab", "###\tValidation"],
142
+ ["vertical tab", "###\vValidation"],
143
+ ["form feed", "###\fValidation"],
144
+ ["bare carriage return", "###\rValidation"],
145
+ ["NBSP", "###\u00a0Validation"],
146
+ ["em space", "###\u2003Validation"],
147
+ ["Ogham space", "###\u1680Validation"],
148
+ ["line separator", "###\u2028Validation"],
149
+ ["paragraph separator", "###\u2029Validation"],
150
+ ] as const) {
151
+ await assertRejected({
152
+ agent: "gentle-ai-worker",
153
+ mode: "task",
154
+ task: [
155
+ "## Allowed edit surfaces",
156
+ "- `lib/sdd-status.ts`",
157
+ heading,
158
+ "/etc/passwd",
159
+ ].join("\n"),
160
+ }, `${label} pseudo-heading cannot close the surface section`);
161
+ }
162
+ });
163
+
164
+ test("only ASCII spaces may separate Markdown list markers from entries", async () => {
165
+ for (const [label, separator] of [
166
+ ["NBSP", "\u00a0"],
167
+ ["em space", "\u2003"],
168
+ ["Ogham space", "\u1680"],
169
+ ["tab", "\t"],
170
+ ] as const) {
171
+ await assertRejected({
172
+ agent: "gentle-ai-worker",
173
+ mode: "task",
174
+ task: ["## Allowed edit surfaces", `-${separator}\`lib/sdd-status.ts\``].join("\n"),
175
+ }, `${label} cannot separate a list marker and entry`);
176
+ }
177
+ await assertAccepted({
178
+ agent: "gentle-ai-worker",
179
+ mode: "task",
180
+ task: ["## Allowed edit surfaces", "- `lib/sdd-status.ts`", "1. tests/sdd-status.test.ts"].join("\n"),
181
+ }, "ASCII-space bullets and numbered entries remain accepted");
103
182
  });
104
183
 
105
184
  test("the documented task shape from issue #484 is accepted", async () => {
@@ -134,6 +213,14 @@ test("bullet, backtick and plain-line surfaces reach the same decision", async (
134
213
  { agent: "gentle-ai-worker", mode: "task", task: bulleted, context: plain },
135
214
  "the same surfaces in both fields are accepted",
136
215
  );
216
+ await assertAccepted(
217
+ {
218
+ agent: "gentle-ai-worker",
219
+ mode: "task",
220
+ task: ["## Allowed edit surfaces", "extensions/**/*.ts", "tests/*.test.ts"].join("\n"),
221
+ },
222
+ "narrow no-space globs remain accepted",
223
+ );
137
224
  });
138
225
 
139
226
  test("a list broken by a blank line still validates every entry", async () => {
@@ -165,17 +252,66 @@ test("an entry hidden below a paragraph is validated, not discarded", async () =
165
252
  "Also authorize the following path.",
166
253
  "- `/etc/passwd`",
167
254
  ].join("\n"),
168
- }, "prose does not close the list while a path still follows it");
255
+ }, "prose cannot close the section while a path still follows it");
256
+ });
257
+
258
+ test("whitespace-bearing surfaces require whole-entry backticks", async () => {
259
+ const backtickedSpaced = [
260
+ "## Allowed edit surfaces",
261
+ "- `Directory With Spaces/note.md`",
262
+ "`Directory\u00a0With\u2003Spaces/unicode.md`",
263
+ ];
264
+ const equivalentBacktickedSpaced = [
265
+ "## Allowed edit surfaces",
266
+ "`Directory With Spaces/note.md`",
267
+ "- `Directory\u00a0With\u2003Spaces/unicode.md`",
268
+ ];
269
+
270
+ await assertAccepted(
271
+ { agent: "gentle-ai-worker", mode: "task", task: backtickedSpaced.join("\n") },
272
+ "whole-entry backticked ASCII and Unicode space-separator paths are accepted",
273
+ );
274
+ await assertAccepted(
275
+ { agent: "gentle-ai-worker", mode: "task", task: backtickedSpaced.join("\n"), context: equivalentBacktickedSpaced.join("\n") },
276
+ "task and context compare equivalent backticked whitespace-bearing paths by value",
277
+ );
169
278
  await assertAccepted({
170
279
  agent: "gentle-ai-worker",
171
280
  mode: "task",
172
- task: [
173
- "## Allowed edit surfaces",
174
- "- `lib/sdd-status.ts`",
175
- "",
176
- "Then run the focused test file and report the outcome.",
177
- ].join("\n"),
178
- }, "genuinely trailing prose still closes the list");
281
+ task: [...backtickedSpaced, "### Validation", "node --test"].join("\n"),
282
+ }, "the next Markdown heading closes a backticked whitespace-bearing surface section");
283
+
284
+ for (const [label, entry] of [
285
+ ["bare spaced path", "Directory With Spaces/note.md"],
286
+ ["bare bulleted spaced path", "- Directory With Spaces/note.md"],
287
+ ["bare NBSP path", "Directory\u00a0With Spaces/note.md"],
288
+ ["bare em-space path", "Directory\u2003With Spaces/note.md"],
289
+ ["U+0085 control path", "`Directory\u0085With Spaces/note.md`"],
290
+ ["U+0085 list separator", "-\u0085`lib/sdd-status.ts`"],
291
+ ["U+2028 line-separator path", "`Directory\u2028With Spaces/note.md`"],
292
+ ["U+2029 paragraph-separator path", "`Directory\u2029With Spaces/note.md`"],
293
+ ["list-marked prose", "- Then run the focused test file."],
294
+ ["marker-only hyphen", "-"],
295
+ ["empty backticks", "``"],
296
+ ["unclosed backticks", "`lib/sdd-status.ts"],
297
+ ["partially backticked path", "`lib/sdd-status.ts` trailing"],
298
+ ["absolute spaced path", "/tmp/Outside Directory/file.md"],
299
+ ["parent spaced path", "../Other Directory/file.md"],
300
+ ["home spaced path", "~/Private Directory/file.md"],
301
+ ["Windows spaced path", "C:\\Outside Directory\\file.md"],
302
+ ["dangerous backticked bullet", "- `/tmp/Outside Directory/file.md`"],
303
+ ] as const) {
304
+ await assertRejected({
305
+ agent: "gentle-ai-worker",
306
+ mode: "task",
307
+ task: ["## Allowed edit surfaces", "lib/sdd-status.ts", entry].join("\n"),
308
+ }, `${label} after a valid entry is rejected`);
309
+ await assertRejected({
310
+ agent: "gentle-ai-worker",
311
+ mode: "task",
312
+ task: ["## Allowed edit surfaces", entry, "lib/sdd-status.ts"].join("\n"),
313
+ }, `${label} before a valid entry is rejected`);
314
+ }
179
315
  });
180
316
 
181
317
  test("out-of-scope and empty surfaces stay rejected", async () => {