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.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- 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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
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 = "
|
|
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("(
|
|
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");
|