gentle-pi 3.1.1 → 3.2.1

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 (41) hide show
  1. package/assets/orchestrator-delegation.md +18 -11
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +15 -5
  4. package/docs/readme-reference.md +43 -18
  5. package/docs/review-integration.md +15 -6
  6. package/extensions/gentle-agents.ts +22 -1
  7. package/extensions/gentle-ai.ts +103 -143
  8. package/extensions/gentle-shell.ts +29 -7
  9. package/lib/background-subagents-policy.ts +148 -0
  10. package/lib/model-routing-authority.ts +1 -1
  11. package/lib/native-review-cli.ts +18 -0
  12. package/lib/opaque-pi-reviewer-adapter.ts +130 -10
  13. package/lib/review-candidate-view.ts +27 -6
  14. package/lib/review-host-relay.ts +95 -2
  15. package/lib/shell-bar.ts +63 -9
  16. package/lib/shell-usage.ts +226 -10
  17. package/package.json +2 -1
  18. package/runtime/native-review-cli.mjs +18 -0
  19. package/scripts/gentle-ai-installer.mjs +10 -10
  20. package/scripts/mirror-odd-routing.mjs +242 -0
  21. package/scripts/verify-package-files.mjs +3 -3
  22. package/skills/chained-pr/SKILL.md +2 -1
  23. package/skills/work-unit-commits/SKILL.md +9 -0
  24. package/tests/background-subagents-default-mode.test.ts +105 -0
  25. package/tests/gentle-agents.test.ts +14 -1
  26. package/tests/gentle-ai-binary.test.ts +1 -1
  27. package/tests/gentle-ai-installer.test.ts +47 -47
  28. package/tests/gentle-shell.test.ts +109 -3
  29. package/tests/native-review-capability-contract.test.ts +27 -1
  30. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  31. package/tests/odd-routing-contract.test.ts +101 -2
  32. package/tests/opaque-pi-reviewer-adapter.test.ts +153 -9
  33. package/tests/package-manifest.test.ts +6 -6
  34. package/tests/review-base-ref-hint.test.ts +39 -0
  35. package/tests/review-candidate-view.test.ts +55 -0
  36. package/tests/review-controller-native-routing.test.ts +60 -1
  37. package/tests/review-host-relay.test.ts +83 -14
  38. package/tests/review-relay-transport-agent.test.ts +86 -1
  39. package/tests/shell-bar.test.ts +153 -3
  40. package/tests/shell-usage-view.test.ts +3 -2
  41. package/tests/shell-usage.test.ts +254 -6
@@ -34,6 +34,15 @@ import type {
34
34
  } from "@earendil-works/pi-coding-agent";
35
35
  import { Key, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
36
36
  import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
37
+ import {
38
+ BACKGROUND_SUBAGENTS_FILE,
39
+ BACKGROUND_SUBAGENTS_SCHEMA,
40
+ loadBackgroundSubagentsPolicy,
41
+ parseBackgroundSubagentsPolicyFile,
42
+ resolveBackgroundSubagentsPolicy,
43
+ type BackgroundSubagentsPolicy,
44
+ type BackgroundSubagentsResolution,
45
+ } from "../lib/background-subagents-policy.ts";
37
46
  import {
38
47
  ensureSddPreflight,
39
48
  getSddPreflightPreferences,
@@ -127,6 +136,7 @@ import {
127
136
  reviewHostRelayUnachievableDetail,
128
137
  reviewHostRelayUnachievableReason,
129
138
  reviewProviderRoleVectorSlots,
139
+ resolveReviewHostRelayExtensionPaths,
130
140
  resolveReviewHostRelaySubmission,
131
141
  runReviewHostRelayReviewerGroup,
132
142
  runReviewHostRelaySlot,
@@ -158,7 +168,7 @@ import {
158
168
  } from "../lib/review-snapshot.ts";
159
169
  import { renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts";
160
170
  import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
161
- import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
171
+ import { BASE_REF_ACCEPTED_FORMS, CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
162
172
  import {
163
173
  GentleAiDevBinaryOverrideError,
164
174
  GENTLE_AI_INSTALL_RECOVERY_COMMAND,
@@ -349,9 +359,16 @@ function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number
349
359
 
350
360
  // ---------------------------------------------------------------------------
351
361
  // Background subagents policy — project > global > env > default off
362
+ //
363
+ // The pure resolver (parseBackgroundSubagentsPolicyFile,
364
+ // resolveBackgroundSubagentsPolicy, loadBackgroundSubagentsPolicy, and their
365
+ // types/constants) lives in lib/background-subagents-policy.ts so the
366
+ // runtime side (extensions/gentle-agents.ts) can read the effective policy
367
+ // without importing the pi extension surface. Everything below this point
368
+ // (capability probing, report rendering, the global-file writer) stays here
369
+ // because it is specific to this extension's UI-facing surface.
352
370
  // ---------------------------------------------------------------------------
353
371
 
354
- type BackgroundSubagentsPolicy = "on" | "off";
355
372
  type BackgroundSubagentsCapability = "ready" | "absent";
356
373
 
357
374
  interface BackgroundSubagentsRendering {
@@ -359,142 +376,11 @@ interface BackgroundSubagentsRendering {
359
376
  capability: BackgroundSubagentsCapability;
360
377
  }
361
378
 
362
- /** Which of the four sources decided the effective policy. */
363
- type BackgroundSubagentsSource =
364
- | "project_file"
365
- | "global_file"
366
- | "environment"
367
- | "default";
368
-
369
- interface BackgroundSubagentsResolution {
370
- policy: BackgroundSubagentsPolicy;
371
- source: BackgroundSubagentsSource;
372
- /** The deciding file was present but failed the strict decode. */
373
- malformed: boolean;
374
- projectFile: string;
375
- globalFile: string;
376
- projectFileExists: boolean;
377
- globalFileExists: boolean;
378
- /** The raw env value, reported even when it is unrecognized and inert. */
379
- envValue: string | undefined;
380
- }
381
-
382
- interface LoadBackgroundSubagentsOptions {
383
- /** Override the config home directory (used in tests to avoid touching ~/.pi). */
384
- gentlePiConfigHome?: string;
385
- /** Override the environment lookup (used in tests). */
386
- env?: Record<string, string | undefined>;
387
- }
388
-
389
- const BACKGROUND_SUBAGENTS_SCHEMA = "gentle-pi.background-subagents/v1";
390
- const BACKGROUND_SUBAGENTS_FILE = "background-subagents.json";
391
-
392
379
  const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = {
393
380
  policy: "off",
394
381
  capability: "absent",
395
382
  };
396
383
 
397
- /**
398
- * Strict decode of {"schema":"gentle-pi.background-subagents/v1","policy":"on"|"off"}.
399
- * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
400
- * returns undefined so the caller fails closed to "off".
401
- */
402
- function parseBackgroundSubagentsPolicyFile(
403
- raw: string,
404
- ): BackgroundSubagentsPolicy | undefined {
405
- let parsed: unknown;
406
- try {
407
- parsed = JSON.parse(raw);
408
- } catch {
409
- return undefined;
410
- }
411
- if (!isRecord(parsed)) return undefined;
412
- if (parsed.schema !== BACKGROUND_SUBAGENTS_SCHEMA) return undefined;
413
- if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
414
- if (Object.keys(parsed).length !== 2) return undefined;
415
- return parsed.policy;
416
- }
417
-
418
- /**
419
- * Resolve the background-subagents policy AND the source that decided it.
420
- *
421
- * Resolution order (first hit wins, mirroring loadRuntimeGuardrailsConfig):
422
- * 1. Project file `${cwd}/.pi/gentle-ai/background-subagents.json`
423
- * 2. Global file `${configHome}/background-subagents.json`
424
- * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
425
- * 3. Env var GENTLE_PI_BACKGROUND_SUBAGENTS ("on" | "off")
426
- * 4. Default "off"
427
- *
428
- * A present-but-malformed file fails closed to "off" instead of falling
429
- * through to a lower-priority source, and it stays attributed to that file:
430
- * "off decided by a broken project file" and "off by default" are different
431
- * situations, and only the first one is a mistake to fix.
432
- *
433
- * Four sources with first-hit-wins is exactly the shape that makes an edit
434
- * look like it did nothing, so the deciding source is part of the result
435
- * rather than something a caller has to re-derive.
436
- */
437
- function resolveBackgroundSubagentsPolicy(
438
- cwd: string,
439
- options: LoadBackgroundSubagentsOptions = {},
440
- ): BackgroundSubagentsResolution {
441
- const env = options.env ?? process.env;
442
- const envValue = env.GENTLE_PI_BACKGROUND_SUBAGENTS;
443
- let projectFile = "";
444
- let globalFile = "";
445
- try {
446
- const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome();
447
- projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE);
448
- globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE);
449
- const projectFileExists = existsSync(projectFile);
450
- const globalFileExists = existsSync(globalFile);
451
- const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue };
452
- for (const [source, path, present] of [
453
- ["project_file", projectFile, projectFileExists],
454
- ["global_file", globalFile, globalFileExists],
455
- ] as const) {
456
- if (!present) continue;
457
- let decoded: BackgroundSubagentsPolicy | undefined;
458
- try {
459
- decoded = parseBackgroundSubagentsPolicyFile(readFileSync(path, "utf8"));
460
- } catch {
461
- // Unreadable is indistinguishable from unusable at this layer, and
462
- // both must fail closed on the file that claimed the decision.
463
- decoded = undefined;
464
- }
465
- return decoded === undefined
466
- ? { policy: "off", source, malformed: true, ...locations }
467
- : { policy: decoded, source, malformed: false, ...locations };
468
- }
469
- if (envValue === "on" || envValue === "off") {
470
- return { policy: envValue, source: "environment", malformed: false, ...locations };
471
- }
472
- return { policy: "off", source: "default", malformed: false, ...locations };
473
- } catch {
474
- return {
475
- policy: "off",
476
- source: "default",
477
- malformed: false,
478
- projectFile,
479
- globalFile,
480
- projectFileExists: false,
481
- globalFileExists: false,
482
- envValue,
483
- };
484
- }
485
- }
486
-
487
- /**
488
- * The effective policy alone, for callers that do not report a source.
489
- * It delegates so the loader and the resolver can never disagree.
490
- */
491
- function loadBackgroundSubagentsPolicy(
492
- cwd: string,
493
- options: LoadBackgroundSubagentsOptions = {},
494
- ): BackgroundSubagentsPolicy {
495
- return resolveBackgroundSubagentsPolicy(cwd, options).policy;
496
- }
497
-
498
384
  /** Write the global policy file, creating the config home when needed. */
499
385
  function writeGlobalBackgroundSubagentsPolicy(
500
386
  policy: BackgroundSubagentsPolicy,
@@ -1389,13 +1275,13 @@ Organic Driven Development (ODD) is the predefined workflow of this orchestrator
1389
1275
  3. **Resolve uncertainty.** Recommend optional research only for a named uncertainty; ask one focused user question only for a real unresolved product decision, then stop and wait; use at most one scoped read-only assumption challenge for a high-consequence unproven premise.
1390
1276
  4. **Classify.** The work is substantial when exploration yields two or more meaningful implementation steps, or progress worth recovering after an interruption. Small, understood work stays small and creates no durable task artifacts.
1391
1277
  5. **Track before the first write.** For substantial authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds.
1392
- 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, with the configured TDD mode and applicable checks. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change.
1393
- 7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. Native review applies only at the deliverable boundary and only under the user-owned RDD switch.
1278
+ 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, honoring its mandatory delegation triggers, with the configured TDD mode and applicable checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions.
1279
+ 7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
1394
1280
  Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.
1395
1281
 
1396
1282
  Harness principles:
1397
1283
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
1398
- - Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track substantial work before the first write, implement task by task with proportionate checks, close. SDD is explicitly selected.
1284
+ - Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track substantial work before the first write, implement task by task with proportionate checks, close each task with a work-unit commit, and close. SDD is explicitly selected.
1399
1285
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
1400
1286
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
1401
1287
  - Keep writes single-threaded unless the user explicitly approves parallel write isolation.
@@ -4805,7 +4691,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
4805
4691
  },
4806
4692
  input: {
4807
4693
  type: "string",
4808
- description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
4694
+ description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
4809
4695
  },
4810
4696
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
4811
4697
  inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
@@ -5840,6 +5726,12 @@ function validateNativeStartUntrackedSelection(value: Record<string, unknown>):
5840
5726
  };
5841
5727
  }
5842
5728
 
5729
+ const BASE_REF_REJECTION_REASONS = new Set(["base-ref-unresolvable", "base-ref-ambiguous", "base-ref-moved", "base-ref-invalid"]);
5730
+
5731
+ function baseRefRejectionHint(reason: string): { hint?: string } {
5732
+ return BASE_REF_REJECTION_REASONS.has(reason) ? { hint: BASE_REF_ACCEPTED_FORMS } : {};
5733
+ }
5734
+
5843
5735
  function nativeStartRejection(reason: string, field?: string): Record<string, unknown> {
5844
5736
  return {
5845
5737
  operation: REVIEW_CONTROLLER_OPERATION.START,
@@ -5861,6 +5753,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
5861
5753
  : "native-start-policy-path-invalid",
5862
5754
  reason,
5863
5755
  ...(field === undefined ? {} : { field }),
5756
+ ...baseRefRejectionHint(reason),
5864
5757
  ...nativeStartPreAuthorityRejection(),
5865
5758
  };
5866
5759
  }
@@ -5872,6 +5765,20 @@ function nativeStatusInputRejection(reason: string, field?: string): Record<stri
5872
5765
  outcome: "native-status-input-invalid",
5873
5766
  reason,
5874
5767
  ...(field === undefined ? {} : { field }),
5768
+ ...baseRefRejectionHint(reason),
5769
+ mutation_performed: false,
5770
+ mutation_outcome: "none",
5771
+ };
5772
+ }
5773
+
5774
+ function nativeInspectInputRejection(reason: string, field?: string): Record<string, unknown> {
5775
+ return {
5776
+ operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
5777
+ status: "blocked",
5778
+ outcome: "native-inspect-input-invalid",
5779
+ reason,
5780
+ ...(field === undefined ? {} : { field }),
5781
+ ...baseRefRejectionHint(reason),
5875
5782
  mutation_performed: false,
5876
5783
  mutation_outcome: "none",
5877
5784
  };
@@ -6682,6 +6589,23 @@ function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record<strin
6682
6589
  // refusal reason lives (gentle-pi#524); dropping it hid every admission
6683
6590
  // refusal behind "submission-refused".
6684
6591
  ...(error.stderr.length === 0 ? {} : { stderr: error.stderr }),
6592
+ // gentle-shell#1156: what the reviewer child's own event stream revealed.
6593
+ ...(error.reviewerEvidence === undefined ? {} : { reviewer: error.reviewerEvidence }),
6594
+ };
6595
+ }
6596
+
6597
+ // gentle-shell#1136 / #1158: the only two user-owned launch selections the
6598
+ // relay accepts. The lens's model comes from the agent model routing config
6599
+ // under the lens's agent name; the extension allowlist comes from the
6600
+ // environment. Both are optional, and neither is ever invented here.
6601
+ function reviewHostRelayLaunchSelection(lens: string | undefined, config: AgentModelConfig, environment: NodeJS.ProcessEnv): { reviewerModel?: string; reviewerExtensionPaths?: readonly string[] } {
6602
+ const agentName = lens === undefined || lens.length === 0 ? undefined : lens.startsWith("review-") ? lens : `review-${lens}`;
6603
+ const entry = agentName === undefined ? undefined : config[agentName];
6604
+ const model = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).model === "string" && (entry as AgentRoutingEntry).model!.length > 0 ? (entry as AgentRoutingEntry).model : undefined;
6605
+ const extensionPaths = resolveReviewHostRelayExtensionPaths(environment);
6606
+ return {
6607
+ ...(model === undefined ? {} : { reviewerModel: model }),
6608
+ ...(extensionPaths.length === 0 ? {} : { reviewerExtensionPaths: extensionPaths }),
6685
6609
  };
6686
6610
  }
6687
6611
 
@@ -6793,12 +6717,19 @@ async function executeReviewHostRelayCapture(
6793
6717
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
6794
6718
  );
6795
6719
  }
6796
- const result = await activeReviewHostRelayRunner({
6797
- captureArgumentTokens: slot.captureArgumentTokens,
6798
- targetCwd: cwd,
6799
- submission: slot.submission,
6800
- ...(signal === undefined ? {} : { signal }),
6801
- });
6720
+ const result = await activeReviewHostRelayRunner((() => {
6721
+ // gentle-shell#1136 / #1158: the lens's user-owned reviewer selection and
6722
+ // the extension allowlist ride the request; the relay validates and
6723
+ // refuses broken configurations typed before anything launches.
6724
+ const launch = reviewHostRelayLaunchSelection(slot.lens, readModelConfig(cwd), process.env);
6725
+ return {
6726
+ captureArgumentTokens: slot.captureArgumentTokens,
6727
+ targetCwd: cwd,
6728
+ submission: slot.submission,
6729
+ ...launch,
6730
+ ...(signal === undefined ? {} : { signal }),
6731
+ };
6732
+ })());
6802
6733
  const closure = decodeRelayLastEventClosure(result.submission);
6803
6734
  if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd);
6804
6735
  return {
@@ -7570,6 +7501,7 @@ async function executeReviewCaptureGroupOperation(
7570
7501
  captureArgumentTokens: slot.captureArgumentTokens,
7571
7502
  targetCwd: cwd,
7572
7503
  submission: slot.submission!,
7504
+ ...reviewHostRelayLaunchSelection(slot.lens, readModelConfig(cwd), process.env),
7573
7505
  ...(signal === undefined ? {} : { signal }),
7574
7506
  }));
7575
7507
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -7692,6 +7624,30 @@ async function executeReviewControllerOperation(
7692
7624
  parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT &&
7693
7625
  nativeReviewCli !== null
7694
7626
  ) {
7627
+ const rawInspect = parameters.input === undefined
7628
+ ? undefined
7629
+ : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.INSPECT);
7630
+ const unknownField = rawInspect === undefined
7631
+ ? undefined
7632
+ : Object.keys(rawInspect).find((field) => !["baseRef", "committedOnly"].includes(field));
7633
+ if (unknownField !== undefined) return nativeInspectInputRejection("unknown-field", unknownField);
7634
+ const baseRef = rawInspect?.baseRef;
7635
+ if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeInspectInputRejection("base-ref-invalid");
7636
+ if (baseRef !== undefined && rawInspect?.committedOnly !== true) return nativeInspectInputRejection("committed-only-required");
7637
+ if (rawInspect !== undefined && baseRef === undefined) return nativeInspectInputRejection("committed-only-invalid");
7638
+ let canonicalBaseRef: string | undefined;
7639
+ if (typeof baseRef === "string") {
7640
+ try {
7641
+ canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
7642
+ } catch (error) {
7643
+ if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
7644
+ if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeInspectInputRejection(error.reason);
7645
+ return nativeInspectInputRejection("base-ref-unresolvable");
7646
+ }
7647
+ }
7648
+ const inspectSelector = canonicalBaseRef === undefined
7649
+ ? {}
7650
+ : { baseRef: canonicalBaseRef, committedOnly: true as const };
7695
7651
  // A new inspect supersedes every pre-lineage selection before its first
7696
7652
  // STATUS attempt. A failed or changed-candidate inspect cannot leave an
7697
7653
  // older selection available for a later START.
@@ -7702,6 +7658,7 @@ async function executeReviewControllerOperation(
7702
7658
  nativeReviewCli,
7703
7659
  {
7704
7660
  cwd: defaultCwd,
7661
+ ...inspectSelector,
7705
7662
  ...(signal === undefined ? {} : { signal }),
7706
7663
  },
7707
7664
  retainedUntrackedSelections,
@@ -7777,6 +7734,7 @@ async function executeReviewControllerOperation(
7777
7734
  nativeReviewCli,
7778
7735
  {
7779
7736
  cwd: defaultCwd,
7737
+ ...inspectSelector,
7780
7738
  untrackedScope: parameters.untrackedScope,
7781
7739
  expectedUntrackedInventory: inventory,
7782
7740
  intendedUntracked: selected.intendedUntracked,
@@ -8595,6 +8553,8 @@ export const __testing = {
8595
8553
  loadRuntimeGuardrailsConfig,
8596
8554
  buildGentlePrompt,
8597
8555
  nativeStatusUnsupported,
8556
+ nativeStartRejection,
8557
+ nativeStatusInputRejection,
8598
8558
  executeReviewControllerOperation,
8599
8559
  executeReviewCaptureOperation,
8600
8560
  executeReviewCaptureGroupOperation,
@@ -15,7 +15,7 @@ import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
15
15
  import { agentsViewKey } from "../lib/agents-keys.ts";
16
16
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
17
17
  import { framePromptLines, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
18
- import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
18
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
19
19
  import { UsageView } from "../lib/shell-usage-view.ts";
20
20
  import { sidebarPart } from "../lib/shell-sidebar.ts";
21
21
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
@@ -482,21 +482,43 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
482
482
  }
483
483
  }
484
484
 
485
+ // The NaN Cloud quota endpoint is the one the official dashboard reads with the
486
+ // same API key pi already holds. The key travels in the header only: the request
487
+ // refuses redirects so it cannot be replayed to another origin, asks for no
488
+ // stored copy, and nothing here logs, renders, or persists it.
489
+ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
490
+ if (!apiKey) return undefined;
491
+ try {
492
+ const response = await fetchFn(NAN_QUOTA_URL, {
493
+ redirect: "error",
494
+ cache: "no-store",
495
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "User-Agent": "gentle-pi" },
496
+ });
497
+ if (!response.ok) return undefined;
498
+ const parsed = parseNanQuota(await response.json(), now);
499
+ return parsed.limits.length > 0 ? parsed : undefined;
500
+ } catch {
501
+ return undefined;
502
+ }
503
+ }
504
+
485
505
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
486
506
  installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
487
507
  if (!shellEnabled(env)) return;
488
508
  const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
489
509
  const usage = new UsageStore();
490
510
  let renderHost: ShellRenderHost | undefined;
491
- let usageFetchedAt = 0;
511
+ // The 5-minute rule is per provider: one provider's fetch cannot leave the
512
+ // next one waiting for an interval it never used.
513
+ const usageFetchedAt = new Map<string, number>();
492
514
  const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
493
515
  const provider = ctx.model?.provider;
494
- if (provider !== CODEX_PROVIDER) return;
516
+ if (provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
495
517
  const now = deps.now();
496
- if (!force && now - usageFetchedAt < USAGE_REFRESH_MS) return;
497
- usageFetchedAt = now;
498
- const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER).catch(() => undefined);
499
- const fetched = await fetchCodexUsage(token, deps.fetch, deps.now());
518
+ if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
519
+ usageFetchedAt.set(provider, now);
520
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
521
+ const fetched = provider === NAN_PROVIDER ? await fetchNanUsage(apiKey, deps.fetch, deps.now()) : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
500
522
  if (!fetched) return;
501
523
  usage.record(fetched);
502
524
  renderHost?.invalidateSidebar?.();
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { gentlePiConfigHome } from "./agent-home.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Background subagents policy — project > global > env > default off
7
+ //
8
+ // Pure resolver, extracted from extensions/gentle-ai.ts so the runtime side
9
+ // (extensions/gentle-agents.ts) can read the effective policy without
10
+ // importing the pi extension surface. No pi imports belong in this file.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type BackgroundSubagentsPolicy = "on" | "off";
14
+
15
+ /** Which of the four sources decided the effective policy. */
16
+ export type BackgroundSubagentsSource =
17
+ | "project_file"
18
+ | "global_file"
19
+ | "environment"
20
+ | "default";
21
+
22
+ export interface BackgroundSubagentsResolution {
23
+ policy: BackgroundSubagentsPolicy;
24
+ source: BackgroundSubagentsSource;
25
+ /** The deciding file was present but failed the strict decode. */
26
+ malformed: boolean;
27
+ projectFile: string;
28
+ globalFile: string;
29
+ projectFileExists: boolean;
30
+ globalFileExists: boolean;
31
+ /** The raw env value, reported even when it is unrecognized and inert. */
32
+ envValue: string | undefined;
33
+ }
34
+
35
+ export interface LoadBackgroundSubagentsOptions {
36
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
37
+ gentlePiConfigHome?: string;
38
+ /** Override the environment lookup (used in tests). */
39
+ env?: Record<string, string | undefined>;
40
+ }
41
+
42
+ export const BACKGROUND_SUBAGENTS_SCHEMA = "gentle-pi.background-subagents/v1";
43
+ export const BACKGROUND_SUBAGENTS_FILE = "background-subagents.json";
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ /**
50
+ * Strict decode of {"schema":"gentle-pi.background-subagents/v1","policy":"on"|"off"}.
51
+ * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
52
+ * returns undefined so the caller fails closed to "off".
53
+ */
54
+ export function parseBackgroundSubagentsPolicyFile(
55
+ raw: string,
56
+ ): BackgroundSubagentsPolicy | undefined {
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ if (!isRecord(parsed)) return undefined;
64
+ if (parsed.schema !== BACKGROUND_SUBAGENTS_SCHEMA) return undefined;
65
+ if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
66
+ if (Object.keys(parsed).length !== 2) return undefined;
67
+ return parsed.policy;
68
+ }
69
+
70
+ /**
71
+ * Resolve the background-subagents policy AND the source that decided it.
72
+ *
73
+ * Resolution order (first hit wins, mirroring loadRuntimeGuardrailsConfig):
74
+ * 1. Project file `${cwd}/.pi/gentle-ai/background-subagents.json`
75
+ * 2. Global file `${configHome}/background-subagents.json`
76
+ * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
77
+ * 3. Env var GENTLE_PI_BACKGROUND_SUBAGENTS ("on" | "off")
78
+ * 4. Default "off"
79
+ *
80
+ * A present-but-malformed file fails closed to "off" instead of falling
81
+ * through to a lower-priority source, and it stays attributed to that file:
82
+ * "off decided by a broken project file" and "off by default" are different
83
+ * situations, and only the first one is a mistake to fix.
84
+ *
85
+ * Four sources with first-hit-wins is exactly the shape that makes an edit
86
+ * look like it did nothing, so the deciding source is part of the result
87
+ * rather than something a caller has to re-derive.
88
+ */
89
+ export function resolveBackgroundSubagentsPolicy(
90
+ cwd: string,
91
+ options: LoadBackgroundSubagentsOptions = {},
92
+ ): BackgroundSubagentsResolution {
93
+ const env = options.env ?? process.env;
94
+ const envValue = env.GENTLE_PI_BACKGROUND_SUBAGENTS;
95
+ let projectFile = "";
96
+ let globalFile = "";
97
+ try {
98
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome();
99
+ projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE);
100
+ globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE);
101
+ const projectFileExists = existsSync(projectFile);
102
+ const globalFileExists = existsSync(globalFile);
103
+ const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue };
104
+ for (const [source, path, present] of [
105
+ ["project_file", projectFile, projectFileExists],
106
+ ["global_file", globalFile, globalFileExists],
107
+ ] as const) {
108
+ if (!present) continue;
109
+ let decoded: BackgroundSubagentsPolicy | undefined;
110
+ try {
111
+ decoded = parseBackgroundSubagentsPolicyFile(readFileSync(path, "utf8"));
112
+ } catch {
113
+ // Unreadable is indistinguishable from unusable at this layer, and
114
+ // both must fail closed on the file that claimed the decision.
115
+ decoded = undefined;
116
+ }
117
+ return decoded === undefined
118
+ ? { policy: "off", source, malformed: true, ...locations }
119
+ : { policy: decoded, source, malformed: false, ...locations };
120
+ }
121
+ if (envValue === "on" || envValue === "off") {
122
+ return { policy: envValue, source: "environment", malformed: false, ...locations };
123
+ }
124
+ return { policy: "off", source: "default", malformed: false, ...locations };
125
+ } catch {
126
+ return {
127
+ policy: "off",
128
+ source: "default",
129
+ malformed: false,
130
+ projectFile,
131
+ globalFile,
132
+ projectFileExists: false,
133
+ globalFileExists: false,
134
+ envValue,
135
+ };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The effective policy alone, for callers that do not report a source.
141
+ * It delegates so the loader and the resolver can never disagree.
142
+ */
143
+ export function loadBackgroundSubagentsPolicy(
144
+ cwd: string,
145
+ options: LoadBackgroundSubagentsOptions = {},
146
+ ): BackgroundSubagentsPolicy {
147
+ return resolveBackgroundSubagentsPolicy(cwd, options).policy;
148
+ }
@@ -25,7 +25,7 @@ export type ModelConfigFileResult =
25
25
  | { status: "invalid"; path: string }
26
26
  | { status: "valid"; config: AgentModelConfig };
27
27
 
28
- const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
28
+ export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
29
29
  const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/;
30
30
 
31
31
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -986,6 +986,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
986
986
  // and hint remain dark because neither is proven to reach the negotiated
987
987
  // START path Pi consumes.
988
988
  "3.0.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
989
+ // v3.1.0 changed the ODD orchestrator contract only (gentle-ai #4714).
990
+ // Ground-truthed by diffing contracts/review-integration/v2 and
991
+ // contracts/review-provider-contract between the v3.0.2 and v3.1.0 tags
992
+ // in the gentle-ai source tree: zero bytes changed (provider contract
993
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
994
+ // this row negotiates, so it repeats 3.0.1 exactly. riskEvidence and hint
995
+ // remain dark because neither is proven to reach the negotiated START
996
+ // path Pi consumes.
997
+ "3.1.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
998
+ // v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
999
+ // follow-up). Ground-truthed by diffing contracts/review-integration/v2 and
1000
+ // contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
1001
+ // in the gentle-ai source tree: zero bytes changed (provider contract
1002
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
1003
+ // this row negotiates, so it repeats 3.1.0 exactly. riskEvidence and hint
1004
+ // remain dark because neither is proven to reach the negotiated START
1005
+ // path Pi consumes.
1006
+ "3.2.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
989
1007
  });
990
1008
 
991
1009
  export interface NativeReviewProcessDiagnostics {