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.
- package/README.md +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
package/lib/shell-usage-view.ts
CHANGED
|
@@ -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"
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/shell-usage.ts
CHANGED
|
@@ -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]:
|
|
122
|
-
[NAN_PROVIDER]:
|
|
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
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
-
|
|
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 && (
|
|
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:
|
|
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
|
-
|
|
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),
|