gentle-pi 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -15,6 +15,7 @@ import {
15
15
  foreignRootBranch,
16
16
  parseWorktrees,
17
17
  changesSummary,
18
+ changesDigest,
18
19
  emptyChanges,
19
20
  parseNumstat,
20
21
  parsePorcelain,
@@ -101,6 +102,36 @@ test("changesModel sorts files by path and totals the counts", () => {
101
102
  assert.equal(model.deleted, 2);
102
103
  });
103
104
 
105
+ // The footer Status memo reads the changes digest on every keystroke, so the
106
+ // model stores it once per build and reading it never walks the file list.
107
+ // The digest hashes every per-file tuple the renderer consumes (path, status,
108
+ // counts, diffRevision, countsUnavailable); a 64-bit collision could only
109
+ // skip one refresh until the next change moves it again -- never render a
110
+ // wrong picture.
111
+ test("the changes digest is stored on the model and stays equal while nothing moves", () => {
112
+ const files = [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)];
113
+ const model = changesModel([...files]);
114
+ assert.equal(Object.hasOwn(model, "digest"), true, "the digest is a stored field, not recomputed per read");
115
+ assert.equal(model.digest, changesDigest(model), "the stored digest matches an independently computed one");
116
+ assert.equal(changesModel([...files]).digest, model.digest, "an unchanged model rebuilds to the same digest");
117
+ assert.equal(emptyChanges().digest, emptyChanges().digest, "the empty model carries a stable digest");
118
+ });
119
+
120
+ test("the changes digest moves with every per-file detail the renderer consumes", () => {
121
+ const base = changesModel([file("lib/a.ts", 4, 2)]).digest;
122
+ const digestFor = (files: ChangedFile[]) => changesModel(files).digest!;
123
+ assert.notEqual(digestFor([file("lib/a.ts", 4, 2, CHANGE_STATUS.RENAMED)]), base, "a status change moves the digest even at equal counts");
124
+ assert.notEqual(digestFor([file("lib/a.ts", 5, 2)]), base, "an added-count change moves the digest");
125
+ assert.notEqual(digestFor([file("lib/a.ts", 4, 3)]), base, "a deleted-count change moves the digest");
126
+ assert.notEqual(digestFor([file("lib/b.ts", 4, 2)]), base, "a path change moves the digest");
127
+ const revised = file("lib/a.ts", 4, 2);
128
+ revised.diffRevision = "abc123";
129
+ assert.notEqual(digestFor([revised]), base, "a diffRevision change moves the digest");
130
+ const partial = file("lib/a.ts", 4, 2);
131
+ partial.countsUnavailable = "timeout";
132
+ assert.notEqual(digestFor([partial]), base, "countsUnavailable moves the digest");
133
+ });
134
+
104
135
  test("changesSummary and the widget describe the session at a glance", () => {
105
136
  const model = changesModel([file("extensions/gentle-shell.ts", 31, 0, CHANGE_STATUS.ADDED), file("lib/shell-bar.ts", 9, 7), file("tests/x.test.ts", 2, 0)]);
106
137
  assert.equal(changesSummary(model), "3 files · +42 −7");
@@ -121,6 +152,14 @@ test("renderChangesWidget colors counts by direction and yields nothing when cle
121
152
  assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
122
153
  });
123
154
 
155
+ // The unborn state (no commits yet) is silence in the idle bar: the widget
156
+ // renders only tracked files, and /gentle:changes explains the state on demand.
157
+ test("renderChangesWidget stays silent when nothing is tracked", () => {
158
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
159
+ assert.deepEqual(renderChangesWidget(emptyChanges(), taggedTheme, 400), []);
160
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 12), []);
161
+ });
162
+
124
163
  test("renderChangesWidget drops the file list before truncating on narrow terminals", () => {
125
164
  const model = changesModel([file("a/very/long/path/one.ts", 1, 0), file("a/very/long/path/two.ts", 1, 0)]);
126
165
  const [line] = renderChangesWidget(model, plainTheme, 40);
@@ -132,6 +171,67 @@ test("renderChangesWidget drops the file list before truncating on narrow termin
132
171
  assert.match(tiny, /^✎ 2 files/);
133
172
  });
134
173
 
174
+ // Repeated frames hit the widget from two surfaces (the sidebar part and the
175
+ // below-input header row), but its paint depends only on what the model digest
176
+ // already covers plus the width and the theme. A counting theme is the seam: a
177
+ // cache hit paints nothing, so theme.fg stays silent instead of walking files.
178
+ function countingTheme() {
179
+ let calls = 0;
180
+ return {
181
+ get fgCalls() {
182
+ return calls;
183
+ },
184
+ fg(_color: string, text: string) {
185
+ calls += 1;
186
+ return text;
187
+ },
188
+ };
189
+ }
190
+
191
+ test("renderChangesWidget memoizes a frame per digest, width and theme so an unchanged model repaints nothing", () => {
192
+ const model = changesModel([file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
193
+ const theme = countingTheme();
194
+ const first = renderChangesWidget(model, theme, 120);
195
+ assert.ok(theme.fgCalls > 0, "the first render paints");
196
+ const afterFirst = theme.fgCalls;
197
+ const second = renderChangesWidget(model, theme, 120);
198
+ assert.deepEqual(second, first, "an unchanged model, width and theme return the same rows");
199
+ assert.equal(theme.fgCalls, afterFirst, "a memo hit repaints nothing: no per-file iteration");
200
+ });
201
+
202
+ test("renderChangesWidget re-renders when the digest moves", () => {
203
+ const theme = countingTheme();
204
+ const before = changesModel([file("lib/a.ts", 4, 2)]);
205
+ renderChangesWidget(before, theme, 120);
206
+ const afterFirst = theme.fgCalls;
207
+ const moved = changesModel([file("lib/a.ts", 5, 2)]);
208
+ assert.notEqual(moved.digest, before.digest, "a count change moves the digest");
209
+ const lines = renderChangesWidget(moved, theme, 120);
210
+ assert.ok(theme.fgCalls > afterFirst, "a digest change re-renders");
211
+ assert.match(lines.join("\n"), /\+5/, "the re-render shows the new totals");
212
+ });
213
+
214
+ test("renderChangesWidget re-renders when the width or the theme changes", () => {
215
+ const model = changesModel([file("lib/a.ts", 4, 2)]);
216
+ const theme = countingTheme();
217
+ const wide = renderChangesWidget(model, theme, 400);
218
+ const afterWide = theme.fgCalls;
219
+ renderChangesWidget(model, theme, 40);
220
+ assert.ok(theme.fgCalls > afterWide, "a width change re-renders");
221
+ const otherTheme = countingTheme();
222
+ assert.deepEqual(renderChangesWidget(model, otherTheme, 400), wide, "a new theme object repaints the same wide frame");
223
+ assert.ok(otherTheme.fgCalls > 0, "the new theme did the painting");
224
+ });
225
+
226
+ test("renderChangesWidget never caches a digest-less model", () => {
227
+ const model = { files: [file("lib/a.ts", 4, 2)], added: 4, deleted: 2 };
228
+ const theme = countingTheme();
229
+ renderChangesWidget(model, theme, 120);
230
+ const afterFirst = theme.fgCalls;
231
+ renderChangesWidget(model, theme, 120);
232
+ assert.ok(theme.fgCalls > afterFirst, "a hand-built model without a digest re-renders every call");
233
+ });
234
+
135
235
  function fakeGit(numstats: string[], porcelains: string[], code = 0) {
136
236
  const calls: string[][] = [];
137
237
  let round = 0;
@@ -336,7 +436,6 @@ test("foreignRootBranch still reports a genuine detached HEAD as undefined, not
336
436
  test("a root missing from worktree list resolves its own branch instead of defaulting to detached", async (t) => {
337
437
  const scenarios: Array<{ name: string; symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number }; expected: string | undefined }> = [
338
438
  { name: "a normal branch on a foreign clone", symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: "feature" },
339
- { name: "an unborn branch (no commits yet)", symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 }, expected: "no commits yet" },
340
439
  { name: "a genuinely detached HEAD", symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: undefined },
341
440
  ];
342
441
  for (const scenario of scenarios) {
@@ -358,9 +457,57 @@ test("a root missing from worktree list resolves its own branch instead of defau
358
457
  assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", scenario.expected]]);
359
458
  assert.ok(calls.some((args) => args[0] === "symbolic-ref"), "a root absent from worktree list must be asked directly");
360
459
  });
460
+ // An unborn foreign root is a holder before it is a branch question: with
461
+ // no commits there is no baseline to diff against, so it is skipped --
462
+ // neither dirty nor failed -- before any branch lookup happens.
463
+ await t.test("an unborn foreign root is skipped as a holder before the branch ask", async () => {
464
+ const calls: string[][] = [];
465
+ const tracker = new WorktreeChangesTracker(
466
+ async () => ({ stdout: "", code: 0 }),
467
+ () => async (args) => {
468
+ calls.push(args);
469
+ if (args[0] === "rev-parse") return { stdout: "", code: 1 };
470
+ return { stdout: "", code: 0 };
471
+ },
472
+ undefined,
473
+ () => ["/foreign"],
474
+ );
475
+ await tracker.start();
476
+ assert.deepEqual(tracker.worktrees, []);
477
+ assert.deepEqual(tracker.unbornRoots, ["/foreign"]);
478
+ assert.deepEqual(tracker.failedRoots, []);
479
+ assert.ok(!calls.some((args) => args[0] === "symbolic-ref"), "a skipped holder is never asked for a branch");
480
+ });
361
481
  }
362
482
  });
363
483
 
484
+ // A worktree-holder repository (no commits, only linked worktrees inside) has
485
+ // no baseline: `git diff HEAD` is meaningless and every file reports
486
+ // untracked, so scanning it floods Changes with thousands of phantom files.
487
+ // It is skipped as neither dirty nor failed, and its first commit starts
488
+ // tracking it on the next scan.
489
+ test("a root with no commits is skipped as a holder, neither dirty nor failed, and recovers on its first commit", async () => {
490
+ let holderHasCommits = false;
491
+ const tracker = new WorktreeChangesTracker(
492
+ async () => ({ stdout: "worktree /real\0branch refs/heads/main\0\0", code: 0 }),
493
+ (root) => async (args) => {
494
+ if (args[0] === "rev-parse") return { stdout: "", code: root === "/holder" && !holderHasCommits ? 1 : 0 };
495
+ return { stdout: args[0] === "status" && (root === "/real" || (root === "/holder" && holderHasCommits)) ? "?? a.ts\0" : "", code: 0 };
496
+ },
497
+ undefined,
498
+ () => ["/holder", "/real"],
499
+ );
500
+ await tracker.start();
501
+ assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/real"], "an unborn root is skipped, not scanned");
502
+ assert.deepEqual(tracker.unbornRoots, ["/holder"]);
503
+ assert.deepEqual(tracker.failedRoots, [], "no commits is not a Git failure");
504
+ assert.equal(tracker.scannedRootCount, 2);
505
+ holderHasCommits = true;
506
+ await tracker.refresh();
507
+ assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/holder", "/real"], "the first commit starts tracking");
508
+ assert.deepEqual(tracker.unbornRoots, []);
509
+ });
510
+
364
511
  // The direct per-root ask (for a root missing from worktree list) can fail on
365
512
  // its own -- root gone, git unavailable -- independently of the change scan
366
513
  // that already found real files. That failure must label the root
@@ -8,15 +8,15 @@ import {
8
8
  PROMPT_STATE,
9
9
  petalGlyph,
10
10
  petalTone,
11
- SHELL_PULSE_MS,
11
+ SHELL_PULSE_WATCHDOG_MS,
12
12
  SHELL_SCANNER_STEPS,
13
13
  withPromptHint,
14
14
  type PromptFrameOptions,
15
15
  } from "../lib/shell-prompt.ts";
16
16
 
17
- test("scanner reflects a symmetric light wave across working text at Pi's original cadence", () => {
17
+ test("scanner reflects a symmetric light wave across working text at the watchdog cadence", () => {
18
18
  const fg = (role: string, char: string) => `<${role}>${char}</${role}>`;
19
- assert.equal(SHELL_PULSE_MS, 80);
19
+ assert.equal(SHELL_PULSE_WATCHDOG_MS, 1000);
20
20
  assert.equal(SHELL_SCANNER_STEPS, 15);
21
21
  for (const word of ["working…", "Thinking…"]) {
22
22
  for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
@@ -1,7 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { buildShellBarModel, type ShellFooterData } from "../extensions/gentle-shell.ts";
4
+ import { buildShellBarModel, createFooterStatusMemo, type ShellFooterData } from "../extensions/gentle-shell.ts";
5
+ import { CHANGE_STATUS } from "../lib/shell-changes.ts";
6
+ import type { RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
7
+ import type { ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
8
+ import type { ProviderUsage } from "../lib/shell-usage.ts";
5
9
  import { createQuietToolRenderer } from "../extensions/quiet-tools.ts";
6
10
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
11
 
@@ -127,3 +131,132 @@ test("image renderers keep their live geometry instead of caching opaque compone
127
131
  { expanded: true, isPartial: false }, theme as never, {} as never);
128
132
  assert.notDeepEqual(card.render(80), card.render(80));
129
133
  });
134
+
135
+ function footerMemoFixture() {
136
+ let reads = 0;
137
+ let projections = 0;
138
+ let leaf = "leaf-1";
139
+ const entries = [{ type: "message", message: { role: "assistant", usage: { cost: { total: 1 } } } }];
140
+ // One stable model object, replaced only by setModelId: real hosts keep the
141
+ // model identity stable, and sessionStatsKey compares it by identity.
142
+ let model: { id: string; contextWindow: number } = { id: "model", contextWindow: 1000 };
143
+ const ctx = {
144
+ get model() { return model; },
145
+ getContextUsage() { projections++; return { percent: 10, contextWindow: 1000 }; },
146
+ modelRegistry: { isUsingOAuth: () => false },
147
+ sessionManager: {
148
+ getEntries() { reads++; return [...entries]; },
149
+ getEntryCount: () => entries.length,
150
+ getSessionId: () => "session-1",
151
+ getLeafId: () => leaf,
152
+ getCwd: () => "/repo",
153
+ getSessionName: () => "session",
154
+ },
155
+ };
156
+ let branch: string | null = "main";
157
+ let statuses = new Map<string, string>();
158
+ let usage: ProviderUsage | undefined;
159
+ let added = 0;
160
+ let deleted = 0;
161
+ let unbornRoots = 0;
162
+ let review: ReviewSidebarSnapshot | undefined;
163
+ let jobs: RunningJobDisplay[] | undefined;
164
+ let settings: unknown = { statusPlacement: "auto" };
165
+ let style: unknown = "comfortable";
166
+ const footerData = { getGitBranch: () => branch, getExtensionStatuses: () => statuses } as unknown as ShellFooterData;
167
+ let profile: string | undefined = "team";
168
+ const memo = createFooterStatusMemo({
169
+ pi, ctx: ctx as unknown as ExtensionContext, footerData,
170
+ usage: () => usage,
171
+ profile: () => profile,
172
+ jobs: () => jobs,
173
+ review: () => review,
174
+ tracker: () => ({ model: { files: [{ path: "a.ts", status: CHANGE_STATUS.MODIFIED, added, deleted }], added, deleted }, unbornRoots: Array.from({ length: unbornRoots }, () => "root") }),
175
+ visualSettings: () => settings,
176
+ cardStyle: () => style,
177
+ });
178
+ return {
179
+ ctx: ctx as unknown as ExtensionContext & { sessionManager: { getLeafId?: () => string } },
180
+ memo,
181
+ counts: () => [reads, projections],
182
+ setLeaf: (value: string) => { leaf = value; },
183
+ spend: () => { entries[0]!.message.usage.cost.total += 1; },
184
+ setModelId: (value: string) => { model = { ...model, id: value }; },
185
+ setProfile: (value: string | undefined) => { profile = value; },
186
+ setBranch: (value: string | null) => { branch = value; },
187
+ setStatuses: (value: Map<string, string>) => { statuses = value; },
188
+ setUsage: (value: ProviderUsage | undefined) => { usage = value; },
189
+ setChangeCounts: (nextAdded: number, nextDeleted: number) => { added = nextAdded; deleted = nextDeleted; },
190
+ setUnbornRoots: (value: number) => { unbornRoots = value; },
191
+ setReview: (value: ReviewSidebarSnapshot | undefined) => { review = value; },
192
+ setJobs: (value: RunningJobDisplay[] | undefined) => { jobs = value; },
193
+ setSettings: (value: unknown) => { settings = value; },
194
+ setStyle: (value: unknown) => { style = value; },
195
+ };
196
+ }
197
+
198
+ test("the footer status memo reuses one model while live inputs hold still", () => {
199
+ const f = footerMemoFixture();
200
+ const first = f.memo.model();
201
+ assert.equal(first.costTotal, 1);
202
+ assert.deepEqual(first.changes, { files: 1, added: 0, deleted: 0 });
203
+ for (let frame = 0; frame < 10; frame++) assert.equal(f.memo.model(), first, "stable inputs reuse the built model");
204
+ assert.deepEqual(f.counts(), [1, 1], "warm frames never rescan the session");
205
+ });
206
+
207
+ test("the footer status digest key notices every live input the model reads", () => {
208
+ const f = footerMemoFixture();
209
+ const before = f.memo.key();
210
+ assert.equal(f.memo.key(), before, "the key is deterministic while nothing moves");
211
+ const mutations: Array<[string, () => void]> = [
212
+ ["branch", () => f.setBranch("feature")],
213
+ ["profile", () => f.setProfile("other (local)")],
214
+ ["statuses", () => f.setStatuses(new Map([["jobs", "1 running"]]))],
215
+ ["usage", () => f.setUsage({ provider: "p", plan: undefined, limits: [], fetchedAt: 1 })],
216
+ ["change counts", () => f.setChangeCounts(2, 1)],
217
+ ["unborn roots", () => f.setUnbornRoots(1)],
218
+ ["review", () => f.setReview({ state: "open", scope: undefined } as unknown as ReviewSidebarSnapshot)],
219
+ ["jobs", () => f.setJobs([{ id: "j", label: "l", startedAt: 0 }])],
220
+ ["visual settings", () => f.setSettings({ statusPlacement: "bottom" })],
221
+ ["card style", () => f.setStyle("compact")],
222
+ ["model", () => f.setModelId("new-model")],
223
+ ["cwd", () => (f.ctx.sessionManager as { getCwd(): string }).getCwd = () => "/other"],
224
+ ["session name", () => (f.ctx.sessionManager as { getSessionName(): string }).getSessionName = () => "renamed"],
225
+ ["leaf", () => f.setLeaf("leaf-2")],
226
+ ];
227
+ let previous = before;
228
+ for (const [name, mutate] of mutations) {
229
+ mutate();
230
+ const current = f.memo.key();
231
+ assert.notEqual(current, previous, `a moved ${name} changes the digest key`);
232
+ previous = current;
233
+ }
234
+ // A branch or profile move rebuilds the model without rescanning statistics:
235
+ // the model object identity stays stable across those rebuilds.
236
+ const settled = f.memo.model();
237
+ const [reads, projections] = f.counts();
238
+ f.setBranch("release");
239
+ assert.notEqual(f.memo.model(), settled);
240
+ assert.deepEqual(f.counts(), [reads, projections]);
241
+ });
242
+
243
+ test("a moved leaf rebuilds the footer model with fresh statistics", () => {
244
+ const f = footerMemoFixture();
245
+ const first = f.memo.model();
246
+ f.setLeaf("leaf-2");
247
+ const second = f.memo.model();
248
+ assert.notEqual(second, first);
249
+ assert.deepEqual(f.counts(), [2, 2]);
250
+ assert.equal(f.memo.model(), second, "the fresh model is memoized again");
251
+ });
252
+
253
+ test("hosts without leaf identity stay honest through the digest key's values", () => {
254
+ const f = footerMemoFixture();
255
+ assert.equal(f.memo.key(), f.memo.key(), "the key stays deterministic on uncached hosts");
256
+ Reflect.deleteProperty(f.ctx.sessionManager, "getLeafId");
257
+ const first = f.memo.model();
258
+ f.spend();
259
+ const second = f.memo.model();
260
+ assert.notEqual(second, first, "a changed cost value rebuilds the model without leaf identity");
261
+ assert.equal(second.costTotal, 2);
262
+ });
@@ -589,6 +589,56 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
589
589
  assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
590
590
  });
591
591
 
592
+ test("a theme switch repaints rail sections whose digest did not change", (t) => {
593
+ const f = fixture();
594
+ // Pi hands extensions a live theme proxy and announces a switch only through
595
+ // ui.invalidate(), which never reaches the rail inside the layout tree.
596
+ let code = 31;
597
+ const live = { fg: (_color: string, text: string) => `\x1b[${code}m${text}\x1b[39m`, bold: (text: string) => text };
598
+ for (const key of ["footer", "agents", "todo"]) {
599
+ sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
600
+ digest: () => "stable",
601
+ render: () => [live.fg("accent", `${key} card`)],
602
+ invalidate() {},
603
+ });
604
+ }
605
+ t.after(installSidebar(f.tui, live));
606
+
607
+ const before = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
608
+ for (const key of ["footer", "agents", "todo"]) assert.ok(before.includes(`\x1b[31m${key} card`), `${key} paints with the first theme`);
609
+
610
+ code = 32;
611
+ const after = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
612
+ for (const key of ["footer", "agents", "todo"]) {
613
+ assert.ok(after.includes(`\x1b[32m${key} card`), `${key} repaints with the new theme`);
614
+ assert.ok(!after.includes(`\x1b[31m${key} card`), `${key} drops the old theme`);
615
+ }
616
+ });
617
+
618
+ test("structural cache misses skip the whole-rail digest pass", (t) => {
619
+ const f = fixture();
620
+ let digests = 0;
621
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
622
+ digest: () => { digests++; return "stable"; },
623
+ render: () => [`Todo ${digests}`],
624
+ invalidate() {},
625
+ });
626
+ t.after(installSidebar(f.tui, theme));
627
+
628
+ // Every frame evaluates the digest exactly once: the whole-rail comparison
629
+ // reuses it for the section cache instead of calling each digest twice per
630
+ // prepare (issue #1681).
631
+ renderLayoutFrame(f.root, 140, 20, () => {});
632
+ const prepared = digests;
633
+ assert.equal(prepared, 1, "preparing the rail evaluates the digest once");
634
+ renderLayoutFrame(f.root, 140, 20, () => {});
635
+ assert.equal(digests, prepared + 1, "a cache hit compares the digest once");
636
+ renderLayoutFrame(f.root, 141, 20, () => {});
637
+ assert.equal(digests, prepared + 2, "a structural miss re-prepares with a single digest pass");
638
+ renderLayoutFrame(f.root, 140, 20, () => {});
639
+ assert.equal(digests, prepared + 3, "returning to the cached structure stays on a single digest pass");
640
+ });
641
+
592
642
  test("reuses final sidebar presentation until a relevant invalidation", (t) => {
593
643
  const f = fixture();
594
644
  sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
@@ -36,7 +36,7 @@ for (const [name, text] of Object.entries({ registeredDescription: background.de
36
36
  test("inline still means the same parent, and the public background API stays asynchronous", () => {
37
37
  assert.ok(core.includes("Small path: inline (parent; `bash_background` policy);"));
38
38
  assert.ok(guide.includes("Inline means the same parent, not necessarily synchronous bash"));
39
- assert.ok(core.includes("run the focused test and the suite inline, once each"));
39
+ assert.ok(core.includes("run the focused test and the suite inline in one command"));
40
40
  assert.match(background.description, /return a job id at once/);
41
41
  assert.match(background.description, /notified once, automatically/);
42
42
  assert.match(background.description, /do not poll, sleep, or delegate a subagent to wait/);
@@ -25,7 +25,9 @@ const PREFIXED_NAMES: Record<string, string> = {
25
25
  "work-unit-commits": "gentle-ai-work-unit-commits",
26
26
  };
27
27
 
28
- const UNPREFIXED_DIRS = ["gentle-ai"];
28
+ // gentle-pi-owned skills that gentle-ai does not install keep their own
29
+ // gentle- namespace, so they need no gentle-ai- prefix to avoid a collision.
30
+ const UNPREFIXED_DIRS = ["gentle-ai", "design"];
29
31
 
30
32
  for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
31
33
  test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
@@ -16,7 +16,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
16
16
  import assert from "node:assert/strict";
17
17
  import fs from "node:fs";
18
18
  import { syncBuiltinESMExports } from "node:module";
19
- import { join } from "node:path";
19
+ import { join, sep } from "node:path";
20
20
  const root = ${JSON.stringify(root)};
21
21
  const cwd = join(root, "workspace");
22
22
  const home = join(root, "home");
@@ -52,6 +52,8 @@ for (const code of ["ENOENT", "EMFILE"]) {
52
52
  });
53
53
  const ctx = { cwd, hasUI: true, ui: { notify: (message, level) => notices.push({ message, level }) } };
54
54
  const waitFor = async (predicate) => {
55
+ // Must stay well below the parent's spawnSync timeout so a missed
56
+ // outcome fails with this assertion instead of an opaque ETIMEDOUT.
55
57
  const deadline = Date.now() + 5000;
56
58
  while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
57
59
  assert.ok(predicate(), "timed out waiting for watcher outcome");
@@ -66,18 +68,28 @@ for (const code of ["ENOENT", "EMFILE"]) {
66
68
  if (code === "ENOENT" && process.platform !== "darwin" && process.platform !== "win32") {
67
69
  // Force the transient missing-directory race at Node's real recursive
68
70
  // readdir boundary, after watcher creation, not in our refresh callback.
71
+ // Older Node rescans the parent folder; Node 24.22+ resolves new files
72
+ // via lstat and only readdirs a newly added directory, so trigger with
73
+ // a directory and fail whichever readdir lands under assets.
69
74
  const nativeReaddir = fs.readdirSync;
70
75
  let reachedRescan = false;
71
76
  fs.readdirSync = function (path, options) {
72
- if (path === assets) {
77
+ if (typeof path === "string" && (path === assets || path.startsWith(assets + sep))) {
73
78
  fs.readdirSync = nativeReaddir;
74
79
  reachedRescan = true;
75
80
  throw Object.assign(new Error("ENOENT: transient skill assets disappeared"), { code, syscall: "scandir", path });
76
81
  }
77
82
  return nativeReaddir(path, options);
78
83
  };
79
- fs.writeFileSync(join(assets, "trigger.txt"), "trigger directory rescan");
80
- await waitFor(() => reachedRescan);
84
+ fs.mkdirSync(join(assets, "trigger"));
85
+ // The rescan race needs live recursive-watch events. Where the OS
86
+ // cannot deliver them (an exhausted inotify budget, some WSL2
87
+ // setups), waiting the full waitFor budget is pointless: settle
88
+ // briefly for the natural race, then exercise the public error
89
+ // listener directly, as the manual fallback below always did.
90
+ const raceDeadline = Date.now() + 2000;
91
+ while (!reachedRescan && Date.now() < raceDeadline) await new Promise((resolve) => setTimeout(resolve, 25));
92
+ fs.readdirSync = nativeReaddir;
81
93
  // Newer Node versions swallow rescan ENOENT themselves. Still
82
94
  // exercise our public error listener without requiring Node's bug.
83
95
  if (!notices.some(({ message, level }) => level === "warning" && message.includes(code))) {
@@ -112,7 +124,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
112
124
  timeout: 15_000,
113
125
  env: { ...process.env, GIT_CEILING_DIRECTORIES: root },
114
126
  });
115
- assert.equal(child.error, undefined, child.error?.message);
127
+ assert.equal(child.error, undefined, `${child.error?.message}\n${child.stderr}`);
116
128
  assert.equal(child.status, 0, child.stderr);
117
129
  assert.match(child.stdout, new RegExp(`SURVIVED ${code}`));
118
130
  });
@@ -120,28 +120,61 @@ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", async
120
120
  );
121
121
  });
122
122
 
123
- test("findSkillFiles scans one skill directory level only", async () => {
123
+ test("findSkillFiles scans flat skills and category-nested skills without descending further", async () => {
124
124
  const root = join(tmpdir(), `gentle-pi-shallow-${Date.now()}`);
125
125
  const skillPath = join(root, "docs", "SKILL.md");
126
126
  const nestedSkillPath = join(root, "fixtures", "nested", "SKILL.md");
127
+ const deeperSkillPath = join(root, "fixtures", "nested", "deeper", "SKILL.md");
127
128
  mkdirSync(dirname(skillPath), { recursive: true });
128
129
  mkdirSync(dirname(nestedSkillPath), { recursive: true });
130
+ mkdirSync(dirname(deeperSkillPath), { recursive: true });
129
131
  writeFileSync(skillPath, "---\nname: docs\ndescription: Docs.\n---\n");
130
132
  writeFileSync(nestedSkillPath, "---\nname: nested\ndescription: Nested fixture.\n---\n");
133
+ writeFileSync(deeperSkillPath, "---\nname: deeper\ndescription: Deeper fixture.\n---\n");
131
134
 
132
- assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
135
+ assert.deepEqual(await __testing.findSkillFiles(root), [skillPath, nestedSkillPath]);
133
136
  });
134
137
 
135
- test("findSkillFiles follows symlinked skill directories", async (t) => {
138
+ test("findSkillFiles keeps direct skills ahead of alphabetically earlier nested collisions", async () => {
139
+ const cwd = join(tmpdir(), `gentle-pi-precedence-${Date.now()}`);
140
+ const directSkillPath = join(cwd, "skills", "zeta", "SKILL.md");
141
+ const nestedSkillPath = join(cwd, "skills", "alpha", "duplicate", "SKILL.md");
142
+ mkdirSync(dirname(directSkillPath), { recursive: true });
143
+ mkdirSync(dirname(nestedSkillPath), { recursive: true });
144
+ writeFileSync(directSkillPath, "---\nname: duplicate\ndescription: Direct skill.\n---\n");
145
+ writeFileSync(nestedSkillPath, "---\nname: duplicate\ndescription: Nested skill.\n---\n");
146
+
147
+ await __testing.regenerateRegistry(cwd, true);
148
+ const registry = readFileSync(join(cwd, ".atl", "skill-registry.md"), "utf8");
149
+ assert.match(registry, new RegExp(directSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
150
+ assert.doesNotMatch(registry, new RegExp(nestedSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
151
+ });
152
+
153
+ test("findSkillFiles does not descend into excluded categories", async () => {
154
+ const root = join(tmpdir(), `gentle-pi-excluded-category-${Date.now()}`);
155
+ for (const category of ["_shared", "skill-registry", "sdd-fixtures"]) {
156
+ const nestedSkillPath = join(root, category, "hidden", "SKILL.md");
157
+ mkdirSync(dirname(nestedSkillPath), { recursive: true });
158
+ writeFileSync(nestedSkillPath, "---\nname: hidden\ndescription: Hidden skill.\n---\n");
159
+ }
160
+
161
+ assert.deepEqual(await __testing.findSkillFiles(root), []);
162
+ });
163
+
164
+ test("findSkillFiles follows direct symlinked skills but not symlinked categories", async (t) => {
136
165
  const root = join(tmpdir(), `gentle-pi-symlink-root-${Date.now()}`);
137
166
  const realSkillDir = join(tmpdir(), `gentle-pi-symlink-target-${Date.now()}`);
167
+ const categoryTarget = join(tmpdir(), `gentle-pi-symlink-category-${Date.now()}`);
138
168
  const linkedSkillDir = join(root, "linked");
139
169
  const skillPath = join(linkedSkillDir, "SKILL.md");
140
170
  mkdirSync(root, { recursive: true });
141
171
  mkdirSync(realSkillDir, { recursive: true });
172
+ mkdirSync(join(categoryTarget, "nested"), { recursive: true });
142
173
  writeFileSync(join(realSkillDir, "SKILL.md"), "---\nname: linked\ndescription: Linked skill.\n---\n");
174
+ writeFileSync(join(categoryTarget, "nested", "SKILL.md"), "---\nname: nested\ndescription: Nested skill.\n---\n");
143
175
  try {
144
176
  symlinkSync(realSkillDir, linkedSkillDir, "dir");
177
+ symlinkSync(categoryTarget, join(root, "category"), "dir");
145
178
  } catch (error) {
146
179
  t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`);
147
180
  return;
@@ -150,6 +183,12 @@ test("findSkillFiles follows symlinked skill directories", async (t) => {
150
183
  assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
151
184
  });
152
185
 
186
+ test("only missing skill-file errors allow category scanning", () => {
187
+ assert.equal(__testing.isMissingSkillFileError({ code: "ENOENT" }), true);
188
+ assert.equal(__testing.isMissingSkillFileError({ code: "ENOTDIR" }), true);
189
+ assert.equal(__testing.isMissingSkillFileError({ code: "EACCES" }), false);
190
+ });
191
+
153
192
  test("skill registry watchers close on shutdown", async () => {
154
193
  const root = join(tmpdir(), `gentle-pi-watchers-${Date.now()}`);
155
194
  const skillPath = join(root, "skills", "docs", "SKILL.md");
@@ -453,6 +492,11 @@ test("registered watcher protects registry tracked after a cache hit", async (t)
453
492
  while (runtime.notices.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
454
493
  assert.equal(readFileSync(fixture.registry, "utf8"), before);
455
494
  assert.ok(runtime.notices.some(({ message, level }) => level === "warning" && message.includes("/skill-registry:refresh")));
495
+ // The poll re-asks the same tracked-target state every interval: the
496
+ // warning stays a one-shot per unchanged state instead of repeating.
497
+ runtime.notices.length = 0;
498
+ await new Promise((resolve) => setTimeout(resolve, 3000));
499
+ assert.deepEqual(runtime.notices, []);
456
500
  assert.ok(!runtime.notices.some(({ message }) => message.includes("refreshed")));
457
501
  });
458
502
 
@@ -61,7 +61,7 @@ test("AC2: counts never classify, and the step-count classifier is gone", () =>
61
61
  test("AC3: small work runs its checks inline and keeps test-first", () => {
62
62
  const size = sectionOf(core, "## Task Size");
63
63
  for (const clause of [
64
- "run the focused test and the suite inline, once each",
64
+ "run the focused test and the suite inline in one command",
65
65
  "observe RED inline before the fix",
66
66
  "No explore, worker, or verifier",
67
67
  "no feature document, mirror, or commits unless the user asks",
@@ -128,7 +128,7 @@ test("T33: the Close step checks shared code before Risk: none", () => {
128
128
  // command that shares the changed code, old code against new, caught it in
129
129
  // 2 of 2. Inline work and the worker carry the same check.
130
130
  // S15: the differential check runs in scratch and is never committed as a test (blind round 4 found tests pinning a git commit).
131
- const DIFFERENTIAL = "Before finishing, run a differential check: for each existing command that shares the code you changed, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a `mktemp -d` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test.";
131
+ const DIFFERENTIAL = "Only when your diff changes shared code (options, parsers, helpers) that an existing command the request did not name also uses, run one differential check before finishing, as a single script: for each such command, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a `mktemp -d` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test.";
132
132
  test("C8: inline test-first and the worker PRESERVE step run the differential check", () => {
133
133
  for (const persona of ["gentleman", "neutral"] as const) {
134
134
  const principle = __testing.buildGentlePrompt(persona).split("\n").find((line) => line.includes("use test-first by default"));
@@ -47,6 +47,23 @@ test("S3: a Risk line naming a Task Size high-risk item admits the verifier", (t
47
47
  assert.equal(rejectUnriskedVerifierLaunch(viaContext, { root, contextUsage: quiet }), undefined, "the context carries the line");
48
48
  });
49
49
 
50
+ // gentle-shell-bench model-economy L9: a DeepSeek verifier launch was refused twice because its valid declaration
51
+ // opened a sentence mid-paragraph ("... on top of commit ebf3f35). Risk: item 1 (...)"), not a line.
52
+ test("S3: a Risk declaration that opens a sentence admits the verifier", (t) => {
53
+ const root = workspace(false);
54
+ t.after(() => rmSync(root, { recursive: true, force: true }));
55
+ for (const task of [
56
+ "Independently verify an ODD change on top of commit ebf3f35. Risk: item 1 (schema migration of stored data).",
57
+ "Verify the parser change! Risk: item 3 (shared parser)",
58
+ "Is the guard still sound? Risk: item 2",
59
+ ]) {
60
+ assert.equal(rejectUnriskedVerifierLaunch({ agent: "gentle-ai-verify", task }, { root, contextUsage: quiet }), undefined, task);
61
+ }
62
+ for (const task of ["Verify it. Risk: none (local rename)", "Verify it. Do not write Risk: item 3 here.", "Verify it.Risk: item 3"]) {
63
+ assert.ok(rejectUnriskedVerifierLaunch({ agent: "gentle-ai-verify", task }, { root, contextUsage: quiet })?.block, task);
64
+ }
65
+ });
66
+
50
67
  test("S3: an item outside 1-6, a Risk mention inside prose, or none first is rejected", (t) => {
51
68
  const root = workspace(false);
52
69
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -128,7 +145,7 @@ test("S3/S5: the orchestrator tool_call hook gates only primary verifier launche
128
145
  test("S6: the Verification rule and the reference docs point at the runtime admission", () => {
129
146
  const read = (path: string) => readFileSync(join(import.meta.dirname, "..", path), "utf8");
130
147
  assert.ok(read("assets/orchestrator.md").includes("independent `gentle-ai-verify`; else inline; runtime admission, gates/overengineering"), "the core rule names the runtime check");
131
- assert.ok(read("assets/orchestrator-verification.md").includes("(the runtime admits its launch on a `Risk: item N` line, a feature-document task line, or past the context backstop)"), "the normative rule names the admitted line");
148
+ assert.ok(read("assets/orchestrator-verification.md").includes("(runtime admits its launch on a `Risk: item N` line or sentence, a feature-document task line, or past the context backstop)"), "the normative rule names the admitted line");
132
149
  const docs = read("docs/readme-reference.md");
133
150
  const writer = docs.indexOf("A second admission check stops small-task delegation");
134
151
  const verifier = docs.indexOf("A third admission check keeps the independent verifier for high-risk changes");