gentle-pi 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. 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() });
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ INTERACTIVE_HOST_ENV,
5
+ isInteractiveMode,
6
+ isInteractiveRpcHost,
7
+ withoutInteractiveHost,
8
+ } from "../lib/rpc-host.ts";
9
+
10
+ // Interactive host signal: the desktop app sets GENTLE_SHELL_INTERACTIVE_HOST=1
11
+ // on the pi process it spawns directly (`--mode rpc`). Subagent children
12
+ // spawned by lib/agents-runner.ts must never see it, so rpc without the
13
+ // variable stays byte-identical to today's headless behaviour.
14
+
15
+ test("isInteractiveRpcHost is true only for rpc mode with the variable set to \"1\"", () => {
16
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
17
+ });
18
+
19
+ test("isInteractiveRpcHost is false for rpc mode without the variable", () => {
20
+ assert.equal(isInteractiveRpcHost("rpc", {}), false);
21
+ });
22
+
23
+ test("isInteractiveRpcHost is false for rpc mode when the variable is not exactly \"1\"", () => {
24
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "true" }), false);
25
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "0" }), false);
26
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "" }), false);
27
+ });
28
+
29
+ test("isInteractiveRpcHost is false for every non-rpc mode even with the variable set", () => {
30
+ for (const mode of ["tui", "print", "json"]) {
31
+ assert.equal(isInteractiveRpcHost(mode, { [INTERACTIVE_HOST_ENV]: "1" }), false, `mode ${mode}`);
32
+ }
33
+ });
34
+
35
+ test("isInteractiveRpcHost defaults to process.env when no env is supplied", (t) => {
36
+ const previous = process.env[INTERACTIVE_HOST_ENV];
37
+ process.env[INTERACTIVE_HOST_ENV] = "1";
38
+ t.after(() => {
39
+ if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
40
+ else process.env[INTERACTIVE_HOST_ENV] = previous;
41
+ });
42
+
43
+ assert.equal(isInteractiveRpcHost("rpc"), true);
44
+ });
45
+
46
+ test("isInteractiveMode is true for tui regardless of the variable", () => {
47
+ assert.equal(isInteractiveMode("tui", {}), true);
48
+ assert.equal(isInteractiveMode("tui", { [INTERACTIVE_HOST_ENV]: "1" }), true);
49
+ });
50
+
51
+ test("isInteractiveMode is true for an interactive rpc host", () => {
52
+ assert.equal(isInteractiveMode("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
53
+ });
54
+
55
+ test("isInteractiveMode is false for rpc without the variable, and for print/json", () => {
56
+ assert.equal(isInteractiveMode("rpc", {}), false);
57
+ assert.equal(isInteractiveMode("print", { [INTERACTIVE_HOST_ENV]: "1" }), false);
58
+ assert.equal(isInteractiveMode("json", { [INTERACTIVE_HOST_ENV]: "1" }), false);
59
+ });
60
+
61
+ test("withoutInteractiveHost returns a copy without the variable, preserving the rest", () => {
62
+ const source = { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" };
63
+
64
+ const result = withoutInteractiveHost(source);
65
+
66
+ assert.deepEqual(result, { PATH: "/bin", KEEP: "yes" });
67
+ assert.deepEqual(source, { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" }, "the source env is not mutated");
68
+ });
69
+
70
+ test("withoutInteractiveHost is a no-op copy when the variable is absent", () => {
71
+ const source = { PATH: "/bin" };
72
+
73
+ const result = withoutInteractiveHost(source);
74
+
75
+ assert.deepEqual(result, { PATH: "/bin" });
76
+ assert.notEqual(result, source, "a fresh object is returned, not the same reference");
77
+ });
@@ -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
+ });