gentle-pi 3.2.1 → 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 (108) 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 +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  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 +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,5 +1,6 @@
1
- import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { renderUsagePanel, type ActiveProvider, type UsageStore, type UsageTheme } from "./shell-usage.ts";
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import { renderUsagePanel, type ActiveProvider, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
3
+ import { paintHoverable } from "./shell-hover.ts";
3
4
 
4
5
  // Gentle Shell subscriptions overlay: a framed panel over the usage store.
5
6
  // It reads the store on every render, so a refresh only needs to record.
@@ -8,6 +9,9 @@ export interface UsageViewDeps {
8
9
  theme: UsageTheme;
9
10
  now(): number;
10
11
  active(): ActiveProvider | undefined;
12
+ // Optional: lets the panel resolve the pending note of a provider whose
13
+ // usage source was registered at runtime instead of built in.
14
+ registry?(): UsageSourceRegistry | undefined;
11
15
  onRefresh(): Promise<void>;
12
16
  onClose(): void;
13
17
  requestRender(): void;
@@ -33,10 +37,32 @@ function fit(text: string, width: number): string {
33
37
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
34
38
  }
35
39
 
40
+ // Column offset of the footer's hint text within the rendered line: the
41
+ // frame draws "│ " before the fitted content starts.
42
+ const HINT_CONTENT_OFFSET = 2;
43
+ const HINT_GAP = " ";
44
+
45
+ type HintAction = "refresh" | "close";
46
+
47
+ interface HintSpan {
48
+ start: number;
49
+ end: number;
50
+ action: HintAction;
51
+ }
52
+
53
+ interface PointerLayout {
54
+ width: number;
55
+ height: number;
56
+ row: number;
57
+ spans: HintSpan[];
58
+ }
59
+
36
60
  export class UsageView {
37
61
  private readonly store: UsageStore;
38
62
  private readonly deps: UsageViewDeps;
39
63
  private refreshing = false;
64
+ private pointer: PointerLayout | undefined;
65
+ private hoveredHint: HintAction | undefined;
40
66
 
41
67
  constructor(store: UsageStore, deps: UsageViewDeps) {
42
68
  this.store = store;
@@ -48,13 +74,25 @@ export class UsageView {
48
74
  this.deps.onClose();
49
75
  return;
50
76
  }
51
- if (data === "r" && !this.refreshing) {
52
- this.refreshing = true;
77
+ if (data === "r") this.refresh();
78
+ }
79
+
80
+ // A failing usage fetch is the store's problem to report (its rows already
81
+ // carry the last error); the panel only clears its "refreshing" state. The
82
+ // rejection must never leave this method: an unhandled rejection is fatal
83
+ // to the whole shell on current Node.
84
+ private refresh(): void {
85
+ if (this.refreshing) return;
86
+ this.refreshing = true;
87
+ this.deps.requestRender();
88
+ const settle = () => {
89
+ this.refreshing = false;
53
90
  this.deps.requestRender();
54
- void this.deps.onRefresh().finally(() => {
55
- this.refreshing = false;
56
- this.deps.requestRender();
57
- });
91
+ };
92
+ try {
93
+ this.deps.onRefresh().then(settle, settle);
94
+ } catch {
95
+ settle();
58
96
  }
59
97
  }
60
98
 
@@ -63,14 +101,67 @@ export class UsageView {
63
101
  const inner = width - 2;
64
102
  const title = this.refreshing ? REFRESHING : TITLE;
65
103
  const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
66
- const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active()).map(
104
+ const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.()).map(
67
105
  (line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
68
106
  );
69
- const keys = KEYS.map(([key, label]) => `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`).join(" ");
107
+ const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
108
+ // The hovered hint paints entirely in the shared hover role (key and
109
+ // label together, one color) instead of its ordinary two-role split --
110
+ // the same treatment every other clickable surface uses.
111
+ const keys = hints
112
+ .map(({ key, label, action }) =>
113
+ this.hoveredHint === action ? paintHoverable(theme, `${key} ${label}`, true) : `${theme.fg(KEY_ROLE, key)} ${theme.fg(KEY_TEXT_ROLE, label)}`,
114
+ )
115
+ .join(HINT_GAP);
70
116
  const keysLine = `${theme.fg(FRAME_ROLE, "│")} ${fit(keys, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`;
71
117
  const bottom = theme.fg(FRAME_ROLE, `╰${rule(inner)}╯`);
72
- return [top, ...body, keysLine, bottom];
118
+ const lines = [top, ...body, keysLine, bottom];
119
+ this.pointer = this.hintLayout(width, lines.length, body.length + 1, hints, inner - 2);
120
+ return lines;
73
121
  }
74
122
 
75
- invalidate(): void {}
123
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
124
+ if (event.type === "move" && event.button === "none") {
125
+ const layout = this.pointer;
126
+ const action = layout && event.width === layout.width && event.height === layout.height && event.y === layout.row
127
+ ? layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end)?.action
128
+ : undefined;
129
+ if (action === this.hoveredHint) return action ? { handled: true } : undefined;
130
+ this.hoveredHint = action;
131
+ return { handled: true, render: true };
132
+ }
133
+ if (event.type !== "click" || event.button !== "left") return undefined;
134
+ const layout = this.pointer;
135
+ if (!layout || event.width !== layout.width || event.height !== layout.height || event.y !== layout.row) return undefined;
136
+ const span = layout.spans.find((candidate) => event.x >= candidate.start && event.x < candidate.end);
137
+ if (!span) return undefined;
138
+ if (span.action === "close") {
139
+ this.deps.onClose();
140
+ return { handled: true, render: true };
141
+ }
142
+ this.refresh();
143
+ return { handled: true, render: true };
144
+ }
145
+
146
+ invalidate(): void {
147
+ this.pointer = undefined;
148
+ this.hoveredHint = undefined;
149
+ }
150
+
151
+ // Spans are only registered when the hints text fits without truncation:
152
+ // past that point `fit` clips it with an ellipsis and per-hint columns no
153
+ // longer line up with the plain "key label" text used here.
154
+ private hintLayout(width: number, height: number, row: number, hints: Array<{ text: string; action: HintAction }>, contentWidth: number): PointerLayout | undefined {
155
+ const plainWidth = hints.reduce((total, hint) => total + hint.text.length, 0) + HINT_GAP.length * Math.max(0, hints.length - 1);
156
+ if (plainWidth > contentWidth) return undefined;
157
+ const spans: HintSpan[] = [];
158
+ let cursor = HINT_CONTENT_OFFSET;
159
+ for (const hint of hints) {
160
+ const start = cursor;
161
+ const end = start + hint.text.length;
162
+ spans.push({ start, end, action: hint.action });
163
+ cursor = end + HINT_GAP.length;
164
+ }
165
+ return { width, height, row, spans };
166
+ }
76
167
  }
@@ -117,9 +117,10 @@ const ROLE = {
117
117
  } as const;
118
118
  export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
119
119
  export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER, NAN_PROVIDER];
120
+ const DEFAULT_PENDING_NOTE = "no usage yet · r to fetch";
120
121
  const PENDING_NOTE: Record<string, string> = {
121
- [CODEX_PROVIDER]: "no usage yet · r to fetch",
122
- [NAN_PROVIDER]: "no usage yet · r to fetch",
122
+ [CODEX_PROVIDER]: DEFAULT_PENDING_NOTE,
123
+ [NAN_PROVIDER]: DEFAULT_PENDING_NOTE,
123
124
  [ANTHROPIC_PROVIDER]: "usage arrives with the first response",
124
125
  };
125
126
  const UNSUPPORTED_NOTE = "no subscription usage for this provider";
@@ -129,8 +130,121 @@ export interface ActiveProvider {
129
130
  provider: string;
130
131
  }
131
132
 
132
- export function providerNote(provider: string): string {
133
- return PENDING_NOTE[provider] ?? UNSUPPORTED_NOTE;
133
+ // A generic hook so an extension holding its own provider (its own API token,
134
+ // its own usage endpoint) can plug a usage source into the shell without the
135
+ // shell ever knowing that provider's name. Emitted on `pi.events` as payload
136
+ // under `USAGE_SOURCE_EVENT`; gentle-shell validates the shape below and
137
+ // ignores anything else, so a malformed or foreign event never reaches a
138
+ // fetch call. Re-registration for the same provider replaces the previous
139
+ // source, so a second `session_start` emitting the same payload is a no-op
140
+ // in effect, not an accumulation.
141
+ export const USAGE_SOURCE_EVENT = "gentle-pi:usage-source/v1";
142
+ export const USAGE_SOURCE_SCHEMA = "gentle-pi.usage-source/v1";
143
+ const USAGE_SOURCE_PROVIDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
144
+
145
+ export interface UsageSource {
146
+ schema: typeof USAGE_SOURCE_SCHEMA;
147
+ provider: string;
148
+ pendingNote?: string;
149
+ fetch(apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined>;
150
+ }
151
+
152
+ // Pure and defensive: the payload crosses an event bus from another
153
+ // extension, so nothing here is trusted until every field is checked. Any
154
+ // mismatch returns undefined rather than throwing, exactly like the other
155
+ // payload parsers in this file.
156
+ export function parseUsageSource(value: unknown): UsageSource | undefined {
157
+ if (!value || typeof value !== "object") return undefined;
158
+ const raw = value as Record<string, unknown>;
159
+ if (raw.schema !== USAGE_SOURCE_SCHEMA) return undefined;
160
+ if (typeof raw.provider !== "string" || !USAGE_SOURCE_PROVIDER_PATTERN.test(raw.provider)) return undefined;
161
+ if (typeof raw.fetch !== "function") return undefined;
162
+ if (raw.pendingNote !== undefined && typeof raw.pendingNote !== "string") return undefined;
163
+ const source: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: raw.provider, fetch: raw.fetch as UsageSource["fetch"] };
164
+ if (typeof raw.pendingNote === "string") source.pendingNote = raw.pendingNote;
165
+ return source;
166
+ }
167
+
168
+ // One source per provider, most recent registration wins. Nothing here fetches
169
+ // or touches the network; it only remembers who to ask.
170
+ export class UsageSourceRegistry {
171
+ private readonly sources = new Map<string, UsageSource>();
172
+
173
+ register(source: UsageSource): void {
174
+ this.sources.set(source.provider, source);
175
+ }
176
+
177
+ get(provider: string): UsageSource | undefined {
178
+ return this.sources.get(provider);
179
+ }
180
+
181
+ has(provider: string): boolean {
182
+ return this.sources.has(provider);
183
+ }
184
+
185
+ note(provider: string): string | undefined {
186
+ const source = this.sources.get(provider);
187
+ return source ? (source.pendingNote ?? DEFAULT_PENDING_NOTE) : undefined;
188
+ }
189
+ }
190
+
191
+ export function providerNote(provider: string, registry?: UsageSourceRegistry): string {
192
+ return PENDING_NOTE[provider] ?? registry?.note(provider) ?? UNSUPPORTED_NOTE;
193
+ }
194
+
195
+ function isFiniteNumber(value: unknown): value is number {
196
+ return typeof value === "number" && Number.isFinite(value);
197
+ }
198
+
199
+ function parseSourceUsageWindow(value: unknown): UsageWindow | undefined {
200
+ if (!value || typeof value !== "object") return undefined;
201
+ const raw = value as Record<string, unknown>;
202
+ if (typeof raw.label !== "string") return undefined;
203
+ if (!isFiniteNumber(raw.usedPercent)) return undefined;
204
+ if (!isFiniteNumber(raw.windowSeconds)) return undefined;
205
+ if (raw.resetAt !== null && !isFiniteNumber(raw.resetAt)) return undefined;
206
+ if (raw.used !== undefined && !isFiniteNumber(raw.used)) return undefined;
207
+ if (raw.budget !== undefined && !isFiniteNumber(raw.budget)) return undefined;
208
+ const window: UsageWindow = { label: raw.label, usedPercent: raw.usedPercent, windowSeconds: raw.windowSeconds, resetAt: raw.resetAt as number | null };
209
+ if (raw.used !== undefined) window.used = raw.used as number;
210
+ if (raw.budget !== undefined) window.budget = raw.budget as number;
211
+ return window;
212
+ }
213
+
214
+ function parseSourceUsageLimit(value: unknown): UsageLimit | undefined {
215
+ if (!value || typeof value !== "object") return undefined;
216
+ const raw = value as Record<string, unknown>;
217
+ if (typeof raw.name !== "string") return undefined;
218
+ if (typeof raw.limitReached !== "boolean") return undefined;
219
+ if (!Array.isArray(raw.windows)) return undefined;
220
+ const windows: UsageWindow[] = [];
221
+ for (const entry of raw.windows) {
222
+ const window = parseSourceUsageWindow(entry);
223
+ if (!window) return undefined;
224
+ windows.push(window);
225
+ }
226
+ return { name: raw.name, limitReached: raw.limitReached, windows };
227
+ }
228
+
229
+ // A registered source's resolved value crosses the same trust boundary a
230
+ // parsed HTTP payload does: it is foreign code's own object, so it is
231
+ // validated field by field and never recorded by reference. Every accepted
232
+ // shape is rebuilt from scratch, so a source mutating its own object after
233
+ // returning it can never reach a snapshot gentle-shell already recorded.
234
+ export function parseProviderUsage(value: unknown, expectedProvider: string): ProviderUsage | undefined {
235
+ if (!value || typeof value !== "object") return undefined;
236
+ const raw = value as Record<string, unknown>;
237
+ if (raw.provider !== expectedProvider) return undefined;
238
+ if (raw.plan !== undefined && typeof raw.plan !== "string") return undefined;
239
+ if (!isFiniteNumber(raw.fetchedAt)) return undefined;
240
+ if (!Array.isArray(raw.limits)) return undefined;
241
+ const limits: UsageLimit[] = [];
242
+ for (const entry of raw.limits) {
243
+ const limit = parseSourceUsageLimit(entry);
244
+ if (!limit) return undefined;
245
+ limits.push(limit);
246
+ }
247
+ return { provider: raw.provider, plan: typeof raw.plan === "string" ? raw.plan : undefined, limits, fetchedAt: raw.fetchedAt };
134
248
  }
135
249
 
136
250
  export function windowLabel(seconds: number): string {
@@ -415,13 +529,13 @@ function updatedAgo(fetchedAt: number, now: number): string {
415
529
 
416
530
  // The active provider comes first, marked with the petal, and explains
417
531
  // itself when it has no data yet. Other providers seen this session follow.
418
- export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider): string[] {
532
+ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry): string[] {
419
533
  const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
420
534
  const others = usages.filter((usage) => usage !== activeUsage);
421
535
  if (!active && usages.length === 0) return [truncateToWidth(USAGE_EMPTY_MESSAGE, width, "…")];
422
536
  const lines: string[] = [];
423
537
  if (active && !activeUsage) {
424
- lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider))}`);
538
+ lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider, registry))}`);
425
539
  }
426
540
  for (const usage of [...(activeUsage ? [activeUsage] : []), ...others]) {
427
541
  const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.2.1",
3
+ "version": "3.4.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -706,7 +706,11 @@ function isNativeUntrackedPath(value ) {
706
706
  && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
707
707
  }
708
708
 
709
- function nativeUntrackedSelection(request ) {
709
+ export function nativeUntrackedSelection(request
710
+
711
+
712
+
713
+ ) {
710
714
  const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
711
715
  const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
712
716
  if (!declared) return {};
@@ -717,16 +721,19 @@ function nativeUntrackedSelection(request )
717
721
  ) {
718
722
  throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
719
723
  }
720
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
724
+ // The guard above establishes the array and element types for both typed
725
+ // native requests and untyped facade input.
726
+ const paths = intendedUntracked ;
727
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
721
728
  throw new TypeError("Native exclude untracked selection cannot include paths");
722
729
  }
723
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
730
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
724
731
  throw new TypeError("Native select untracked selection requires at least one path");
725
732
  }
726
733
  return {
727
734
  untrackedScope,
728
735
  expectedUntrackedInventory,
729
- intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
736
+ intendedUntracked: paths === undefined ? undefined : [...paths],
730
737
  };
731
738
  }
732
739
 
@@ -1005,6 +1012,27 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1005
1012
  // remain dark because neither is proven to reach the negotiated START
1006
1013
  // path Pi consumes.
1007
1014
  "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 }),
1015
+ // v3.4.0 (gentle-pi never pinned the intervening v3.3.0 tag, so it gets no
1016
+ // row here) added capabilities/v2.6 and status/v8-v9, and extended
1017
+ // `review assess` with review_due/review_due_reason/next_transition
1018
+ // (gentle-ai #4714 follow-up). Ground-truthed by diffing
1019
+ // contracts/review-integration/v2 and contracts/review-provider-contract
1020
+ // between the v3.2.1 and v3.4.0 tags in the gentle-ai source tree: the
1021
+ // provider contract stays byte-identical at 1.2.0, and every
1022
+ // review-integration/v2 change is an additive superset (new optional
1023
+ // schema/fields) that decodeReviewStatusV3 and the capabilities
1024
+ // negotiator already accept without touching the closed START/STATUS
1025
+ // fields this row negotiates, so it repeats 3.2.1 exactly. riskEvidence
1026
+ // and hint remain dark because neither is proven to reach the negotiated
1027
+ // START path Pi consumes.
1028
+ "3.4.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 }),
1029
+ // v3.5.0 repeats 3.4.0: the published provider contract bundle is
1030
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1031
+ // with only build-identity differences, and no review-integration schema
1032
+ // changed. The v2 preflight failure identity fix does not change the
1033
+ // closed START/STATUS fields this row negotiates. riskEvidence and hint
1034
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1035
+ "3.5.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 }),
1008
1036
  });
1009
1037
 
1010
1038
 
@@ -1089,8 +1117,19 @@ function decodeSelectedLenses(value , riskLevel , lensesRequired
1089
1117
  function enumString(value , allowed ) { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
1090
1118
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
1091
1119
 
1092
- function sanitizeNativeDiagnosticText(value , limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT) {
1093
- const normalized = value
1120
+ function sanitizeNativeDiagnosticText(value , limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation ) {
1121
+ // ASSESS diagnostics are projected into a public verification plan. Retain
1122
+ // native guidance, not local paths or environment assignment values.
1123
+ const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
1124
+ ? value
1125
+ .replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
1126
+ .replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
1127
+ // Quoted paths have a clear boundary. For an unquoted path, the
1128
+ // remaining line is ambiguous (spaces may belong to the filename).
1129
+ // Redact that suffix rather than leak trailing path components.
1130
+ .replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
1131
+ : value;
1132
+ const normalized = input
1094
1133
  .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
1095
1134
  .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
1096
1135
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
@@ -1124,7 +1163,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value )
1124
1163
  timed_out: booleanValue(raw.timed_out),
1125
1164
  output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1126
1165
  ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint }),
1127
- ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1166
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
1128
1167
  };
1129
1168
  } catch { return undefined; }
1130
1169
  }
@@ -1141,7 +1180,7 @@ function nativeProcessDiagnostics(operation , code
1141
1180
  ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1142
1181
  ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1143
1182
  : {}),
1144
- ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
1183
+ ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
1145
1184
  };
1146
1185
  }
1147
1186
 
@@ -2522,6 +2561,7 @@ export class NativeReviewCliV216 {
2522
2561
  // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2523
2562
  // closed to `high`.
2524
2563
  async assess(request ) {
2564
+ const selection = nativeUntrackedSelection(request);
2525
2565
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2526
2566
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2527
2567
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
@@ -2529,7 +2569,7 @@ export class NativeReviewCliV216 {
2529
2569
  const execution = await this.invoke(
2530
2570
  NATIVE_REVIEW_OPERATION.ASSESS,
2531
2571
  cwd,
2532
- ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2572
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
2533
2573
  false,
2534
2574
  request.signal,
2535
2575
  this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),