gentle-pi 3.3.0 → 3.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 (69) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +26 -2
  10. package/docs/readme-reference.md +32 -6
  11. package/extensions/ask-user-question.ts +210 -0
  12. package/extensions/gentle-agents.ts +8 -1
  13. package/extensions/gentle-ai.ts +59 -18
  14. package/extensions/gentle-shell.ts +99 -10
  15. package/extensions/quiet-tools.ts +28 -5
  16. package/extensions/startup-banner.ts +25 -10
  17. package/lib/animation-policy.ts +52 -0
  18. package/lib/background-cache-warming.ts +38 -0
  19. package/lib/command-palette-catalog.ts +1 -0
  20. package/lib/inprocess-reviewer.ts +38 -1
  21. package/lib/native-review-cli.ts +36 -10
  22. package/lib/questionnaire/questionnaire-view.ts +603 -0
  23. package/lib/questionnaire/schema.ts +82 -0
  24. package/lib/questionnaire/validate.ts +141 -0
  25. package/lib/review-candidate-view-owner.ts +20 -5
  26. package/lib/review-candidate-view.ts +9 -2
  27. package/lib/review-host-relay.ts +10 -0
  28. package/lib/review-integration-v2.ts +4 -1
  29. package/lib/shell-bar.ts +13 -0
  30. package/lib/shell-sidebar-layout.ts +10 -4
  31. package/lib/shell-usage-view.ts +5 -2
  32. package/lib/shell-usage.ts +120 -6
  33. package/package.json +1 -1
  34. package/runtime/native-review-cli.mjs +35 -9
  35. package/runtime/review-integration-v2.mjs +4 -1
  36. package/scripts/gentle-ai-installer.mjs +10 -10
  37. package/scripts/verify-package-files.mjs +2 -3
  38. package/tests/animation-policy.test.ts +42 -0
  39. package/tests/ask-user-question.test.ts +435 -0
  40. package/tests/background-cache-warming.test.ts +60 -0
  41. package/tests/background-subagents.test.ts +68 -0
  42. package/tests/command-palette.test.ts +9 -0
  43. package/tests/gentle-agents.test.ts +71 -2
  44. package/tests/gentle-ai-binary.test.ts +1 -1
  45. package/tests/gentle-ai-installer.test.ts +47 -47
  46. package/tests/gentle-ai.test.ts +56 -4
  47. package/tests/gentle-shell.test.ts +355 -2
  48. package/tests/inprocess-reviewer.test.ts +92 -0
  49. package/tests/native-review-capability-contract.test.ts +16 -1
  50. package/tests/native-review-parity.test.ts +19 -0
  51. package/tests/package-manifest.test.ts +6 -17
  52. package/tests/questionnaire-schema.test.ts +274 -0
  53. package/tests/questionnaire-view.test.ts +446 -0
  54. package/tests/rdd-status-line.test.ts +21 -4
  55. package/tests/review-candidate-owner-retry.test.ts +63 -0
  56. package/tests/review-candidate-view.test.ts +15 -0
  57. package/tests/review-controller-native-routing.test.ts +86 -0
  58. package/tests/review-host-relay.test.ts +21 -0
  59. package/tests/review-integration-v2.test.ts +30 -0
  60. package/tests/review-ledger-contract.test.ts +1 -2
  61. package/tests/review-relay-transport-agent.test.ts +107 -2
  62. package/tests/review-risk-assessment.test.ts +104 -0
  63. package/tests/shell-bar.test.ts +8 -0
  64. package/tests/shell-sidebar-layout.test.ts +60 -5
  65. package/tests/shell-usage.test.ts +129 -0
  66. package/tests/skill-collision-prefixes.test.ts +1 -1
  67. package/tests/startup-banner.test.ts +93 -2
  68. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  69. package/skills/release/SKILL.md +0 -137
@@ -556,6 +556,27 @@ test("the relay forwards the caller-owned selection and thinking level to the co
556
556
  assert.equal(calls[0]!.routingKey, "review-risk");
557
557
  });
558
558
 
559
+ // The in-process reviewer completion is an extension side-call that bypasses
560
+ // pi's main agent loop, so the relay must forward the caller's live session id
561
+ // into the completion request; the completion itself turns it into the
562
+ // OpenCode attribution headers (lib/inprocess-reviewer.ts). A absent session
563
+ // id forwards nothing and is never an error.
564
+ test("the relay forwards the caller's reviewer session id into the completion request", async (t) => {
565
+ const fixture = harness(t);
566
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
567
+ await runRelay(fixture, { reviewerSessionId: "ses-live-1" }, runReviewer);
568
+ assert.equal(calls.length, 1);
569
+ assert.equal(calls[0]!.sessionId, "ses-live-1");
570
+ });
571
+
572
+ test("a relay request with no reviewer session id adds no session id to the completion request", async (t) => {
573
+ const fixture = harness(t);
574
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
575
+ await runRelay(fixture, {}, runReviewer);
576
+ assert.equal(calls.length, 1);
577
+ assert.equal(calls[0]!.sessionId, undefined);
578
+ });
579
+
559
580
  test("a missing model registry is refused typed before materialize ever runs", async (t) => {
560
581
  const fixture = harness(t);
561
582
  const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
@@ -14,6 +14,7 @@ import {
14
14
  decodeReviewProjectionV1,
15
15
  decodeReviewRepairV2,
16
16
  decodeReviewStartV3,
17
+ decodeReviewStartV4,
17
18
  decodeReviewStatusV3,
18
19
  } from "../lib/review-integration-v2.ts";
19
20
 
@@ -349,6 +350,35 @@ test("capabilities gates and projections still enforce the required floor", () =
349
350
  assert.throws(() => decode(missingProjection), /projections/);
350
351
  });
351
352
 
353
+ test("START/v4 preserves provider-generated manifest evidence while retaining exact omitempty validation", () => {
354
+ const source = fixture<JsonObject>("start.fixture.json");
355
+ source.schema = "gentle-ai.review-integration.start/v4";
356
+ source.action = "closed";
357
+ source.lenses_required = false;
358
+ source.state = "approved";
359
+ source.selected_lenses = [];
360
+ delete source.repository_context;
361
+ const entry = (source.changed_path_manifest as JsonObject[])[0];
362
+ entry.generated = true;
363
+
364
+ const decoded = decodeReviewStartV4(source);
365
+ assert.equal(decoded.changedPathManifest?.[0]?.generated, true);
366
+
367
+ const omitted = clone(source);
368
+ delete (omitted.changed_path_manifest as JsonObject[])[0].generated;
369
+ assert.doesNotThrow(() => decodeReviewStartV4(omitted));
370
+
371
+ for (const generated of [false, "true"] as const) {
372
+ const invalid = clone(source);
373
+ ((invalid.changed_path_manifest as JsonObject[])[0]).generated = generated;
374
+ assert.throws(() => decodeReviewStartV4(invalid), /generated/);
375
+ }
376
+
377
+ const unknown = clone(source);
378
+ ((unknown.changed_path_manifest as JsonObject[])[0]).unrelated = true;
379
+ assert.throws(() => decodeReviewStartV4(unknown), /not allowed/);
380
+ });
381
+
352
382
  test("START independently binds base/candidate tree and the target-mode overlay pair", () => {
353
383
  const source = fixture<JsonObject>("start.fixture.json");
354
384
  const decoded = decodeReviewStartV3(source);
@@ -22,7 +22,6 @@ const README = "README.md";
22
22
  const TECHNICAL_REFERENCE = "docs/readme-reference.md";
23
23
  const CHAIN = "assets/chains/4r-review.chain.md";
24
24
  const SDD_WORKFLOW = "assets/sdd-orchestrator-workflow.md";
25
- const RELEASE_SKILL = "skills/release/SKILL.md";
26
25
  const WORKER = "assets/agents/gentle-ai-worker.md";
27
26
  const CANONICAL_LIFECYCLE_SPECS = [
28
27
  "openspec/specs/review-orchestration/spec.md",
@@ -400,7 +399,7 @@ test("technical reference documents the dynamic runtime authority boundary witho
400
399
  });
401
400
 
402
401
  test("managed contracts retain no fresh lifecycle review directive", () => {
403
- const managed = union([...ORCHESTRATOR, SDD_WORKFLOW, RELEASE_SKILL, WORKER, GENTLE_SKILL, README]);
402
+ const managed = union([...ORCHESTRATOR, SDD_WORKFLOW, WORKER, GENTLE_SKILL, README]);
404
403
  for (const obsolete of [
405
404
  "A fresh review still follows delegated implementation.",
406
405
  "run a fresh-context review lens unless",
@@ -8,7 +8,7 @@ import { __testing } from "../extensions/gentle-ai.ts";
8
8
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
9
9
  import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
10
10
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
11
- import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
+ import type { ReviewArtifactSubjectV2, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
12
12
  import type { ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
13
13
 
14
14
  // Third field failure on the recovered-lineage defect (2026-08-16, gentle-pi
@@ -149,10 +149,11 @@ function transportAwareNative(options: { refusalCode?: string } = {}): {
149
149
  return { native, agents };
150
150
  }
151
151
 
152
- async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
152
+ async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }, reviewerSessionId?: string): Promise<Record<string, unknown>> {
153
153
  return await __testing.executeReviewCaptureOperation(
154
154
  { lineageId, collectBinding: JSON.stringify(collectInput(lineageId, true)), ...input },
155
155
  cwd, native, undefined, new CandidateViewRegistry(),
156
+ undefined, undefined, undefined, reviewerSessionId,
156
157
  ) as Record<string, unknown>;
157
158
  }
158
159
 
@@ -217,6 +218,110 @@ test("capture forwards the lens's user-owned selection and thinking level to the
217
218
  assert.equal(relayed[0]!.routingKey, "review-reliability");
218
219
  });
219
220
 
221
+ // The live session id threads from the extension context through both
222
+ // request builders, so an OpenCode-routed reviewer model carries its
223
+ // attribution headers (a side-call bypasses pi's main agent loop, where pi
224
+ // adds them itself). A absent session id adds nothing and is never an error.
225
+ test("capture threads the caller's live session id into the relay request", async (t) => {
226
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
227
+ const cwd = repository(t);
228
+ const { native } = transportAwareNative();
229
+ const relayed: ReviewHostRelayRequest[] = [];
230
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
231
+ relayed.push(request);
232
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
233
+ });
234
+
235
+ await runCapture(cwd, native, "session-lineage", { reviewerRunAcknowledged: true }, "ses-live-1");
236
+ assert.equal(relayed.length, 1);
237
+ assert.equal(relayed[0]!.reviewerSessionId, "ses-live-1");
238
+ });
239
+
240
+ test("capture with no live session id adds no reviewer session id to the relay request", async (t) => {
241
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
242
+ const cwd = repository(t);
243
+ const { native } = transportAwareNative();
244
+ const relayed: ReviewHostRelayRequest[] = [];
245
+ __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
246
+ relayed.push(request);
247
+ return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
248
+ });
249
+
250
+ await runCapture(cwd, native, "no-session-lineage");
251
+ assert.equal(relayed.length, 1);
252
+ assert.equal(relayed[0]!.reviewerSessionId, undefined);
253
+ });
254
+
255
+ function groupInput(lineageId: string, lens: string, order: number): ReviewCollectInputV3 {
256
+ const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
257
+ const base = collectInput(lineageId, true);
258
+ const arguments_ = base.arguments.map((argument) => {
259
+ if (argument.name === "lens") return { ...argument, value: lens, token: `--lens=${lens}` };
260
+ if (argument.name === "order") return { ...argument, value: String(order), token: `--order=${order}` };
261
+ if (argument.name === "subject-hash") return { ...argument, value: subjectHash, token: `--subject-hash=${subjectHash}` };
262
+ return argument;
263
+ });
264
+ return { ...base, arguments: arguments_, artifactSubject: { ...base.artifactSubject, subjectHash, lens: lens as ReviewArtifactSubjectV2["lens"], selectedOrder: order } };
265
+ }
266
+
267
+ function groupStatus(lineageId: string, inputs: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
268
+ const status = recoveredStatus(lineageId, false) as ReviewStatusV3 & { repositoryContext?: Record<string, unknown>; nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
269
+ // The reviewer group binds one repository context per slot; reuse the same
270
+ // opaque handle shape the finalizeStatus fixtures in the routing tests use.
271
+ status.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA };
272
+ status.nextTransition = { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } };
273
+ return status;
274
+ }
275
+
276
+ function queueNative(statuses: readonly ReviewStatusV3[]): { native: NativeReviewCli; agents: Array<string | undefined> } {
277
+ const agents: Array<string | undefined> = [];
278
+ const queue = [...statuses];
279
+ const native = {
280
+ targetStatus: async (request: { agent?: string }) => {
281
+ agents.push(request.agent);
282
+ const next = queue.shift();
283
+ if (next === undefined) throw new Error("status queue exhausted");
284
+ return next;
285
+ },
286
+ } as unknown as NativeReviewCli;
287
+ return { native, agents };
288
+ }
289
+
290
+ function prepared(request: ReviewHostRelayRequest) {
291
+ return { request, promptByteLength: 64, resultByteLength: 32 };
292
+ }
293
+
294
+ test("capture group threads the caller's live session id into every relay request", async (t) => {
295
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
296
+ const cwd = repository(t);
297
+ const lineageId = "group-session-lineage";
298
+ const inputs = [groupInput(lineageId, "review-risk", 0), groupInput(lineageId, "review-resilience", 1)];
299
+ // The final reconciliation STATUS must no longer offer the submitted
300
+ // reviewer suffix; a STATUS without a collect transition reconciles the
301
+ // drained group instead of replaying the submitted set.
302
+ const finalStatus = groupStatus(lineageId, []) as ReviewStatusV3 & { nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
303
+ delete finalStatus.nextTransition;
304
+ const { native } = queueNative([groupStatus(lineageId, inputs), groupStatus(lineageId, inputs), groupStatus(lineageId, inputs.slice(1)), finalStatus]);
305
+ const relayed: ReviewHostRelayRequest[] = [];
306
+ __testing.setReviewHostRelayGroupRunnersForTesting(
307
+ async (requests: readonly ReviewHostRelayRequest[]) => {
308
+ relayed.push(...requests);
309
+ return requests.map(prepared);
310
+ },
311
+ async (result: { promptByteLength: number; resultByteLength: number }) => ({ promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }),
312
+ );
313
+
314
+ const result = await __testing.executeReviewCaptureGroupOperation(
315
+ { lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true },
316
+ cwd, native, undefined, new CandidateViewRegistry(),
317
+ undefined, undefined, undefined, "ses-live-1",
318
+ ) as Record<string, unknown>;
319
+
320
+ assert.equal(relayed.length, 2);
321
+ assert.ok(relayed.every((request) => request.reviewerSessionId === "ses-live-1"), "every grouped reviewer request must carry the live session id");
322
+ assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
323
+ });
324
+
220
325
  // gentle-pi#311 P2 decision (flagged for confirmation): the in-process path
221
326
  // has no ambient default model. A routing entry with no configured model used
222
327
  // to launch the child selection-free (inheriting pi's own default); the real
@@ -9,6 +9,7 @@ import {
9
9
  NativeReviewCliV216,
10
10
  type ExecFileAdapter,
11
11
  type NativeReviewCli,
12
+ type NativeReviewAssessRequest,
12
13
  } from "../lib/native-review-cli.ts";
13
14
  import {
14
15
  decodeReviewAssessmentV1,
@@ -573,6 +574,109 @@ function nativeClient(adapter: ExecFileAdapter): NativeReviewCliV216 {
573
574
  return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
574
575
  }
575
576
 
577
+ test("gentle_review assess: stderr-only refusal preserves safe actionable diagnostics and fails closed", async () => {
578
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked scope declaration required; use --untracked-scope=exclude or select; token=hidden-secret", exitCode: 1 }]);
579
+ const client = nativeClient(queue.adapter);
580
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
581
+ const result = await tool.execute("refusal", { operation: "assess" }, undefined, undefined, ctx);
582
+ const details = result.details as { risk: string; reasons: { code: string; detail: string }[]; plan: { independentVerifier: boolean } };
583
+ assert.equal(details.risk, "unassessable");
584
+ assert.equal(details.plan.independentVerifier, true);
585
+ assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
586
+ assert.match(details.reasons[0].detail, /untracked scope declaration required/);
587
+ assert.match(details.reasons[0].detail, /--untracked-scope=exclude/);
588
+ assert.doesNotMatch(JSON.stringify(result), /hidden-secret/);
589
+ });
590
+
591
+ test("gentle_review assess: diagnostic projection is bounded and redacts paths and environment assignments", async () => {
592
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked declaration required /private/project/file C:\\private\\project\\file PRIVATE_PROJECT=hidden-project token=hidden-token " + "x".repeat(6000), exitCode: 1 }]);
593
+ const client = nativeClient(queue.adapter);
594
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
595
+ const result = await tool.execute("private-refusal", { operation: "assess" }, undefined, undefined, ctx);
596
+ const serialized = JSON.stringify(result);
597
+ const detail = (result.details as { reasons: { detail: string }[] }).reasons[0].detail;
598
+ assert.match(detail, /untracked declaration required/);
599
+ assert.ok(detail.length < 4300);
600
+ assert.doesNotMatch(serialized, /hidden-project|hidden-token|private.project|\/package\//);
601
+ });
602
+
603
+ for (const [shape, sensitive] of [
604
+ ["lowercase environment", "private_project=hidden-project"],
605
+ ["quoted environment", 'private_project="hidden project value"'],
606
+ ["credential argument", "--password hidden-credential"],
607
+ ["quoted credential argument", '--api-key "hidden credential value"'],
608
+ ["POSIX path with spaces", "/private/hidden project/hidden file.ts"],
609
+ ["quoted POSIX path", '"/private/hidden project/hidden file.ts"'],
610
+ ["Windows path with spaces", String.raw`C:\private\hidden project\hidden file.ts`],
611
+ ["quoted Windows path", String.raw`"C:\private\hidden project\hidden file.ts"`],
612
+ ]) {
613
+ test(`assess diagnostic redacts ${shape} without losing provider guidance`, async () => {
614
+ const queue = queuedAdapter([{ stdout: "", stderr: `${sensitive}\nuntracked scope declaration required; use --untracked-scope=exclude or select`, exitCode: 1 }]);
615
+ const client = nativeClient(queue.adapter);
616
+ const result = await reviewControllerTool({ assess: client.assess.bind(client) }).execute("private-shape", { operation: "assess" }, undefined, undefined, ctx);
617
+ const details = result.details as { risk: string; reasons: { code: string; detail: string }[] };
618
+ assert.equal(details.risk, "unassessable");
619
+ assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
620
+ assert.doesNotMatch(JSON.stringify(result), /hidden|private_project| project|file\.ts|\/private\//);
621
+ assert.match(details.reasons[0].detail, /untracked scope declaration required; use --untracked-scope=exclude or select/);
622
+ assert.ok(details.reasons[0].detail.length < 4300);
623
+ });
624
+ }
625
+
626
+ for (const selection of [
627
+ { untrackedScope: "exclude", expectedUntrackedInventory: "inventory-v1" },
628
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory-v1", intendedUntracked: ["new/file.ts", "notes.md"] },
629
+ ] as const) {
630
+ test(`native and facade assess forward explicit ${selection.untrackedScope} without choosing scope`, async () => {
631
+ const queue = queuedAdapter([{ stdout: JSON.stringify(validEnvelope()) }, { stdout: JSON.stringify(validEnvelope()) }]);
632
+ const client = nativeClient(queue.adapter);
633
+ await client.assess({ cwd: process.cwd(), ...selection });
634
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
635
+ const result = await tool.execute("selected", { operation: "assess", input: JSON.stringify({ ...selection, baseRef: "origin/main", committedOnly: true }) }, undefined, undefined, ctx);
636
+ assert.equal((result.details as { risk: string }).risk, "medium");
637
+ const flags = [`--untracked-scope=${selection.untrackedScope}`, "--expected-untracked-inventory=inventory-v1", ...(selection.untrackedScope === "select" ? selection.intendedUntracked.map((path) => `--intended-untracked=${path}`) : [])];
638
+ assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), ...flags, "--json"]);
639
+ assert.deepEqual(queue.calls[1].arguments, ["review", "assess", "--cwd", process.cwd(), "--base-ref", "origin/main", "--committed-only", ...flags, "--json"]);
640
+ });
641
+ }
642
+
643
+ test("native and facade assess reject invalid declarations before launching", async () => {
644
+ const invalid = [
645
+ { untrackedScope: "exclude" },
646
+ { expectedUntrackedInventory: "inventory" },
647
+ { intendedUntracked: ["file.ts"] },
648
+ { untrackedScope: "all", expectedUntrackedInventory: "inventory" },
649
+ { untrackedScope: "exclude", expectedUntrackedInventory: "inventory", intendedUntracked: ["file.ts"] },
650
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory" },
651
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [] },
652
+ ...[null, 42, "", " inventory", "inventory\n", "inv\u0000entory"].map((expectedUntrackedInventory) => ({ untrackedScope: "exclude", expectedUntrackedInventory })),
653
+ ...["/absolute", "C:\\absolute", "dir\\file", ".", "..", "../file", "dir/../file", "dir/./file", "dir//file", "dir/", " file", "file\n"].map((path) => ({ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [path] })),
654
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: ["file", "file"] },
655
+ { untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: "file" },
656
+ ];
657
+ for (const selection of invalid) {
658
+ const queue = queuedAdapter([]);
659
+ const client = nativeClient(queue.adapter);
660
+ await assert.rejects(() => client.assess({ cwd: process.cwd(), ...selection } as NativeReviewAssessRequest), TypeError);
661
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
662
+ await assert.rejects(() => tool.execute("invalid", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx));
663
+ assert.equal(queue.calls.length, 0);
664
+ }
665
+ });
666
+
667
+ test("facade assess leaves absent declarations to native and preserves stale-inventory refusal", async () => {
668
+ for (const selection of [{}, { untrackedScope: "exclude", expectedUntrackedInventory: "stale-inventory" }]) {
669
+ const queue = queuedAdapter([{ stdout: "", stderr: "untracked inventory changed; inspect and declare the current inventory", exitCode: 1 }]);
670
+ const client = nativeClient(queue.adapter);
671
+ const tool = reviewControllerTool({ assess: client.assess.bind(client) });
672
+ const result = await tool.execute("stale", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx);
673
+ const details = result.details as { risk: string; reasons: { detail: string }[] };
674
+ assert.equal(details.risk, "unassessable");
675
+ assert.match(details.reasons[0].detail, /inventory changed/);
676
+ if (!("untrackedScope" in selection)) assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), "--json"]);
677
+ }
678
+ });
679
+
576
680
  test("native assess: decodes a well-formed envelope and sends the exact plain-versioned argv", async () => {
577
681
  const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "medium", reasons: [], changed_paths: 1, changed_lines: 2, candidate: { kind: "current-changes" } }) }]);
578
682
  const result = await nativeClient(queue.adapter).assess!({ cwd: process.cwd() });
@@ -9,6 +9,7 @@ import {
9
9
  renderGauge,
10
10
  renderShellBar,
11
11
  renderShellHeaderBar,
12
+ renderShellHeaderRule,
12
13
  renderShellSidebarBar,
13
14
  shellEnabled,
14
15
  type ShellBarModel,
@@ -427,3 +428,10 @@ test("renderShellHeaderBar's usage span always points at the usage text, not ctx
427
428
  assert.equal(usageSpan.end, visibleWidth(text), "the usage segment always ends at the right edge");
428
429
  }
429
430
  });
431
+
432
+ test("renderShellHeaderRule paints one full-width line in the editor frame color", () => {
433
+ assert.equal(renderShellHeaderRule(taggedTheme, 4), "<border>────</border>", "the rule uses the editor frame's border role");
434
+ assert.equal(renderShellHeaderRule(plainTheme, 12), "─".repeat(12), "the rule spans the full width");
435
+ assert.equal(renderShellHeaderRule(plainTheme, 0), "");
436
+ assert.equal(renderShellHeaderRule(plainTheme, -3), "", "negative widths clamp to an empty rule");
437
+ });
@@ -23,7 +23,7 @@ function rail(f: ReturnType<typeof fixture>): ScrollView {
23
23
  assert.equal(node.type, "hstack");
24
24
  return node.entries[1].component;
25
25
  }
26
- type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number; grow: number; shrink: number; minSize: number }[] };
26
+ type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number | string; grow: number; shrink: number; minSize: number }[] };
27
27
  // Finds the [left, scroll] hstack regardless of whether it is returned
28
28
  // directly (no active header) or nested one level under the header vstack.
29
29
  function hstackOf(f: ReturnType<typeof fixture>): HstackNode {
@@ -130,7 +130,7 @@ test("wheel scrolls the rail and is consumed at both boundaries and blank space"
130
130
  f.host.terminal.columns = 160;
131
131
  const restored = rail(f);
132
132
  assert.deepEqual(f.bottom.render(80), []);
133
- const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number; grow: number; shrink: number; minSize: number }[] };
133
+ const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number | string; grow: number; shrink: number; minSize: number }[] };
134
134
  assert.equal(layout.gap, 3);
135
135
  assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
136
136
  { basis: 0, grow: 1, shrink: 1, minSize: 1 },
@@ -188,11 +188,13 @@ test("rail dispatches a clipped, scroll-translated left click to only the matchi
188
188
  const hit = scroll.handleMouse(mouse("click", "left", headerY));
189
189
  assert.equal(hit?.handled, true);
190
190
  assert.equal(clicks, 1);
191
- assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 47, height: 2 });
191
+ // With a transient scrollbar the rail no longer reserves a column, so
192
+ // clipped parts are one column wider than in the always-on geometry.
193
+ assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 48, height: 2 });
192
194
 
193
195
  const gapY = headerY - 1;
194
196
  assert.equal(scroll.handleMouse(mouse("click", "left", gapY)), undefined, "section gaps do not hit a neighbor");
195
- assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 48 }), undefined, "padding outside the clipped part is inert");
197
+ assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 49 }), undefined, "padding outside the clipped part is inert");
196
198
  invalidateSidebar(f.tui);
197
199
  assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "stale geometry is inert");
198
200
  f.host.terminal.columns = 139;
@@ -586,8 +588,10 @@ test("an active header wraps the hstack in a vstack and removes the banner from
586
588
  assert.equal(node.type, "vstack");
587
589
  assert.equal(node.gap, 0);
588
590
  assert.equal(node.align, "stretch");
591
+ // basis "auto": pi-tui measures the header leaf from its rendered lines
592
+ // (one line: the status bar; with the rule row: two) instead of hard-pinning the row.
589
593
  assert.deepEqual(node.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
590
- { basis: 1, grow: 0, shrink: 0, minSize: 1 },
594
+ { basis: "auto", grow: 0, shrink: 0, minSize: 1 },
591
595
  { basis: 0, grow: 1, shrink: 1, minSize: 1 },
592
596
  ]);
593
597
  const header = node.entries[0].component as { render(width: number): string[] };
@@ -608,6 +612,31 @@ test("an active header wraps the hstack in a vstack and removes the banner from
608
612
  assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
609
613
  });
610
614
 
615
+ test("the header leaf carries the rule row: a two-line header renders both lines at full width", (t) => {
616
+ const f = fixture();
617
+ sidebarHeader(f.tui, {
618
+ render: (width: number) => [`HEADER ${width}`, `RULE ${width}`],
619
+ invalidate() {},
620
+ });
621
+ t.after(installSidebar(f.tui, theme));
622
+
623
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
624
+ assert.equal(node.type, "vstack", "a two-line header still wraps the hstack in a vstack");
625
+ const header = node.entries[0]!.component as { render(width: number): string[] };
626
+ assert.deepEqual(header.render(0), ["HEADER 138", "RULE 138"], "both the status line and the rule row reach the layout, rendered at the terminal width minus the rail's right inset");
627
+ });
628
+
629
+ test("below the sidebar breakpoint a rule-like header paints no rule row", (t) => {
630
+ const f = fixture("fullscreen", 139);
631
+ sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`, `RULE ${width}`], invalidate() {} });
632
+ t.after(installSidebar(f.tui, theme));
633
+
634
+ // Below SIDEBAR_BREAKPOINT prepare() declines, so the replacement hands
635
+ // back the native root's own layout node: neither the header wrapper nor
636
+ // a rule row ever paints.
637
+ assert.deepEqual(f.root[NODE](), f.original(), "below the breakpoint the rule is removed with the whole sidebar");
638
+ });
639
+
611
640
  test("without a registered header the rail keeps the banner and the plain hstack", (t) => {
612
641
  const f = fixture();
613
642
  t.after(installSidebar(f.tui, theme));
@@ -707,3 +736,29 @@ test("the header component's handleMouse is a harmless no-op when no header part
707
736
  // backward-compatible fallback), so there is no header leaf to click at all.
708
737
  assert.equal(node.type, "hstack");
709
738
  });
739
+
740
+ test("rail scrollbar is transient: hidden by default, visible only while scrolling overflow", (t) => {
741
+ const f = fixture();
742
+ t.mock.timers.enable({ apis: ["setTimeout"] });
743
+ t.after(installSidebar(f.tui, theme));
744
+ const scroll = rail(f);
745
+ // Regression guard: the rail scroll view used to hardcode scrollbar
746
+ // "always", re-slicing its column on every render pass even when idle.
747
+ assert.equal(scroll.scrollbar, "auto");
748
+ sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 60 }, (_, i) => `todo row ${i}`), invalidate() {} });
749
+ renderLayoutFrame(f.root, 140, 20, () => {});
750
+ assert.equal(scroll.isScrollbarVisible, false, "no scroll yet: hidden even with overflow");
751
+ scroll.scrollBy(1);
752
+ assert.equal(scroll.isScrollbarVisible, true, "rail scroll reveals the transient scrollbar");
753
+ t.mock.timers.tick(1000);
754
+ assert.equal(scroll.isScrollbarVisible, false, "hidden again after the transient window");
755
+ });
756
+
757
+ test("rail scrollbar stays hidden when the rail does not overflow", (t) => {
758
+ const f = fixture();
759
+ t.after(installSidebar(f.tui, theme));
760
+ const scroll = rail(f);
761
+ renderLayoutFrame(f.root, 140, 20, () => {});
762
+ scroll.scrollBy(1);
763
+ assert.equal(scroll.isScrollbarVisible, false);
764
+ });
@@ -7,15 +7,20 @@ import {
7
7
  parseAnthropicHeaders,
8
8
  parseCodexHeaders,
9
9
  parseNanQuota,
10
+ parseProviderUsage,
10
11
  parseUsageHeaders,
11
12
  parseCodexUsage,
13
+ parseUsageSource,
12
14
  providerNote,
13
15
  renderUsageBar,
14
16
  renderUsagePanel,
15
17
  SUPPORTED_USAGE_PROVIDERS,
18
+ UsageSourceRegistry,
19
+ USAGE_SOURCE_SCHEMA,
16
20
  UsageStore,
17
21
  windowLabel,
18
22
  type ProviderUsage,
23
+ type UsageSource,
19
24
  type UsageWindow,
20
25
  } from "../lib/shell-usage.ts";
21
26
 
@@ -243,6 +248,130 @@ test("nan is a supported usage provider with its own pending note", () => {
243
248
  assert.deepEqual(renderUsagePanel([], plainTheme, 100, NOW, { provider: "nan" }), ["✿ nan · no usage yet · r to fetch"]);
244
249
  });
245
250
 
251
+ // A generic hook: any extension can register a usage source for its own
252
+ // provider at runtime, without gentle-shell knowing anything about it.
253
+
254
+ test("parseUsageSource validates the registration payload and ignores anything malformed", () => {
255
+ const fetchFn = async () => undefined;
256
+ const valid = parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: fetchFn });
257
+ assert.equal(valid?.schema, USAGE_SOURCE_SCHEMA);
258
+ assert.equal(valid?.provider, "acme-cloud");
259
+ assert.equal(valid?.fetch, fetchFn);
260
+ assert.equal(valid?.pendingNote, undefined);
261
+
262
+ const withNote = parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: "warming up", fetch: fetchFn });
263
+ assert.equal(withNote?.pendingNote, "warming up");
264
+
265
+ assert.equal(parseUsageSource({ schema: "gentle-pi.usage-source/v0", provider: "acme-cloud", fetch: fetchFn }), undefined, "wrong schema");
266
+ assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "", fetch: fetchFn }), undefined, "empty provider");
267
+ assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme cloud", fetch: fetchFn }), undefined, "unsafe provider id");
268
+ assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: "nope" }), undefined, "fetch not a function");
269
+ assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: 7, fetch: fetchFn }), undefined, "pendingNote must be a string when present");
270
+ assert.equal(parseUsageSource(null), undefined);
271
+ assert.equal(parseUsageSource(undefined), undefined);
272
+ assert.equal(parseUsageSource("acme-cloud"), undefined);
273
+ assert.equal(parseUsageSource({}), undefined);
274
+ });
275
+
276
+ test("UsageSourceRegistry replaces a provider's source on re-registration", () => {
277
+ const registry = new UsageSourceRegistry();
278
+ assert.equal(registry.has("acme-cloud"), false);
279
+ assert.equal(registry.get("acme-cloud"), undefined);
280
+ assert.equal(registry.note("acme-cloud"), undefined);
281
+
282
+ const first: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: async () => undefined };
283
+ registry.register(first);
284
+ assert.equal(registry.has("acme-cloud"), true);
285
+ assert.equal(registry.get("acme-cloud"), first);
286
+ assert.equal(registry.note("acme-cloud"), "no usage yet · r to fetch", "default note when the source sets none");
287
+
288
+ const second: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: "still warming up", fetch: async () => undefined };
289
+ registry.register(second);
290
+ assert.equal(registry.get("acme-cloud"), second, "re-registration replaces, never accumulates");
291
+ assert.equal(registry.note("acme-cloud"), "still warming up");
292
+ });
293
+
294
+ test("a registered provider is supported without touching the built-in note map", () => {
295
+ const registry = new UsageSourceRegistry();
296
+ assert.equal(providerNote("acme-cloud"), "no subscription usage for this provider");
297
+ assert.equal(providerNote("acme-cloud", registry), "no subscription usage for this provider", "an empty registry changes nothing");
298
+ registry.register({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: async () => undefined });
299
+ assert.equal(providerNote("acme-cloud", registry), "no usage yet · r to fetch");
300
+ assert.equal(providerNote("nan", registry), "no usage yet · r to fetch", "the static built-ins are unaffected");
301
+ assert.deepEqual(renderUsagePanel([], plainTheme, 100, NOW, { provider: "acme-cloud" }, registry), ["✿ acme-cloud · no usage yet · r to fetch"]);
302
+ });
303
+
304
+ // A registered source's resolved value crosses the same trust boundary a
305
+ // parsed HTTP payload does. parseProviderUsage validates it exactly like
306
+ // one, and the object gentle-shell records is always freshly built, never
307
+ // the caller's own reference.
308
+
309
+ const VALID_SOURCE_USAGE = {
310
+ provider: "acme-cloud",
311
+ plan: "Acme · 42 credits",
312
+ fetchedAt: 100,
313
+ limits: [
314
+ {
315
+ name: "acme-cloud",
316
+ limitReached: false,
317
+ windows: [
318
+ { label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null },
319
+ { label: "day", usedPercent: 12, windowSeconds: 86_400, resetAt: 200, used: 5, budget: 40 },
320
+ ],
321
+ },
322
+ ],
323
+ };
324
+
325
+ test("parseProviderUsage accepts a matching shape and copies it defensively", () => {
326
+ const source = structuredClone(VALID_SOURCE_USAGE);
327
+ const parsed = parseProviderUsage(source, "acme-cloud");
328
+ assert.deepEqual(parsed, source);
329
+ assert.notEqual(parsed, source, "the recorded object must never be the caller's own reference");
330
+ assert.notEqual(parsed?.limits, source.limits);
331
+ assert.notEqual(parsed?.limits[0], source.limits[0]);
332
+ assert.notEqual(parsed?.limits[0].windows, source.limits[0].windows);
333
+ assert.notEqual(parsed?.limits[0].windows[0], source.limits[0].windows[0]);
334
+
335
+ // Mutating the caller's own object after the fact must never reach the copy.
336
+ source.limits[0].windows[0].usedPercent = 999;
337
+ assert.equal(parsed?.limits[0].windows[0].usedPercent, 40);
338
+ });
339
+
340
+ test("parseProviderUsage rejects a resolution for a different provider", () => {
341
+ assert.equal(parseProviderUsage(structuredClone(VALID_SOURCE_USAGE), "other-provider"), undefined);
342
+ });
343
+
344
+ test("parseProviderUsage rejects a missing or malformed limits array", () => {
345
+ assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0 }, "acme-cloud"), undefined, "limits missing entirely");
346
+ assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: "nope" }, "acme-cloud"), undefined, "limits not an array");
347
+ assert.equal(
348
+ parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [{ name: "x", limitReached: "nope", windows: [] }] }, "acme-cloud"),
349
+ undefined,
350
+ "limitReached must be a boolean",
351
+ );
352
+ });
353
+
354
+ test("parseProviderUsage rejects a non-numeric usedPercent inside a window", () => {
355
+ const usage = structuredClone(VALID_SOURCE_USAGE);
356
+ (usage.limits[0].windows[0] as unknown as { usedPercent: unknown }).usedPercent = "40";
357
+ assert.equal(parseProviderUsage(usage, "acme-cloud"), undefined);
358
+ });
359
+
360
+ test("parseProviderUsage rejects a limit whose windows is not an array", () => {
361
+ const usage = structuredClone(VALID_SOURCE_USAGE);
362
+ (usage.limits[0] as unknown as { windows: unknown }).windows = "nope";
363
+ assert.equal(parseProviderUsage(usage, "acme-cloud"), undefined);
364
+ });
365
+
366
+ test("parseProviderUsage rejects malformed inputs outright", () => {
367
+ assert.equal(parseProviderUsage(undefined, "acme-cloud"), undefined);
368
+ assert.equal(parseProviderUsage(null, "acme-cloud"), undefined);
369
+ assert.equal(parseProviderUsage("acme-cloud", "acme-cloud"), undefined);
370
+ assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: 7, fetchedAt: 0, limits: [] }, "acme-cloud"), undefined, "plan must be a string when present");
371
+ assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: Number.POSITIVE_INFINITY, limits: [] }, "acme-cloud"), undefined, "fetchedAt must be finite");
372
+ assert.deepEqual(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [] }, "acme-cloud"), { provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [] });
373
+ });
374
+
246
375
  test("renderUsagePanel lists each NaN model allowance with its reset on the same row", () => {
247
376
  const usage = parseNanQuota(NAN_QUOTA, NOW);
248
377
  const lines = renderUsagePanel([usage], plainTheme, 80, NOW, { provider: "nan" });
@@ -25,7 +25,7 @@ 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", "release"];
28
+ const UNPREFIXED_DIRS = ["gentle-ai"];
29
29
 
30
30
  for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
31
31
  test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {