gentle-pi 3.2.0 → 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.
@@ -11,6 +11,11 @@ export interface UsageWindow {
11
11
  usedPercent: number;
12
12
  windowSeconds: number;
13
13
  resetAt: number | null;
14
+ // Raw allowance numbers, kept only by providers that report them (NaN).
15
+ // Aggregates are weighted by budget, so averaging percentages is never
16
+ // needed; nothing renders these fields directly.
17
+ used?: number;
18
+ budget?: number;
14
19
  }
15
20
 
16
21
  export interface UsageLimit {
@@ -54,8 +59,27 @@ interface RawCodexUsage {
54
59
  additional_rate_limits?: RawAdditionalLimit[] | null;
55
60
  }
56
61
 
62
+ interface RawNanModel {
63
+ model?: unknown;
64
+ cap?: unknown;
65
+ fullCap?: unknown;
66
+ tokensUsed?: unknown;
67
+ periodEnd?: unknown;
68
+ windowHours?: unknown;
69
+ windowTokens?: unknown;
70
+ fullWindowTokens?: unknown;
71
+ windowTokensUsed?: unknown;
72
+ windowResetsAt?: unknown;
73
+ }
74
+
75
+ interface RawNanQuota {
76
+ models?: unknown;
77
+ periodEnd?: unknown;
78
+ }
79
+
57
80
  export const CODEX_PROVIDER = "openai-codex";
58
81
  export const ANTHROPIC_PROVIDER = "anthropic";
82
+ export const NAN_PROVIDER = "nan";
59
83
  const ANTHROPIC_MAIN_LIMIT = "claude";
60
84
  const ANTHROPIC_PREFIX = "anthropic-ratelimit-unified-";
61
85
  const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
@@ -63,6 +87,17 @@ const ANTHROPIC_WINDOWS: ReadonlyArray<[key: string, seconds: number]> = [
63
87
  ["7d", 604_800],
64
88
  ];
65
89
  export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
90
+ // The NaN Cloud dashboard backend; not part of NaN's published OpenAPI, so the
91
+ // fetch that uses it is fixed-origin, redirect-refusing, and schema-validated.
92
+ export const NAN_QUOTA_URL = "https://cloud-api.nan.builders/api/usage/quota";
93
+ // The model's own allowance for the billing period carries no label: the model
94
+ // id names it in the bar, and the reset text says what the window is in the
95
+ // panel. Only a sub-window on top of it (a rolling `4h`) needs a name.
96
+ const NAN_PERIOD_LABEL = "";
97
+ // The dashboard's own published fallbacks for a model that reports rolling
98
+ // numbers without naming its budget.
99
+ const NAN_DEFAULT_WINDOW_TOKENS = 400_000_000;
100
+ const NAN_DEFAULT_WINDOW_HOURS = 4;
66
101
  const CODEX_MAIN_LIMIT = "codex";
67
102
  const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
68
103
  const HEADER_PREFIX = "x-codex-";
@@ -81,9 +116,10 @@ const ROLE = {
81
116
  SEPARATOR: "muted",
82
117
  } as const;
83
118
  export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
84
- export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER];
119
+ export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER, NAN_PROVIDER];
85
120
  const PENDING_NOTE: Record<string, string> = {
86
121
  [CODEX_PROVIDER]: "no usage yet · r to fetch",
122
+ [NAN_PROVIDER]: "no usage yet · r to fetch",
87
123
  [ANTHROPIC_PROVIDER]: "usage arrives with the first response",
88
124
  };
89
125
  const UNSUPPORTED_NOTE = "no subscription usage for this provider";
@@ -174,6 +210,178 @@ export function parseUsageHeaders(headers: Record<string, string>, now: number):
174
210
  return parseCodexHeaders(headers, now) ?? parseAnthropicHeaders(headers, now);
175
211
  }
176
212
 
213
+ // NaN Cloud reports one allowance per model for the billing period, plus the
214
+ // rolling window the model applies on top of it. Percentages follow the
215
+ // dashboard exactly: tokens used over the period allowance, and window tokens
216
+ // over the full window budget. Every field is optional, because this payload
217
+ // lives outside NaN's published contract.
218
+ function quotaTimestamp(value: unknown): number | null {
219
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value * 1000;
220
+ if (typeof value !== "string") return null;
221
+ const parsed = Date.parse(value);
222
+ return Number.isNaN(parsed) ? null : parsed;
223
+ }
224
+
225
+ function quotaNumber(value: unknown, positive: boolean): number | undefined {
226
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
227
+ return positive ? (value > 0 ? value : undefined) : value >= 0 ? value : undefined;
228
+ }
229
+
230
+ function nanRollingWindow(raw: RawNanModel): UsageWindow | undefined {
231
+ const used = quotaNumber(raw.windowTokensUsed, false);
232
+ if (used === undefined) return undefined;
233
+ const budget = quotaNumber(raw.fullWindowTokens, true) ?? quotaNumber(raw.windowTokens, true) ?? NAN_DEFAULT_WINDOW_TOKENS;
234
+ const hours = quotaNumber(raw.windowHours, true) ?? NAN_DEFAULT_WINDOW_HOURS;
235
+ return { label: windowLabel(hours * HOUR), usedPercent: (used / budget) * 100, windowSeconds: hours * HOUR, resetAt: quotaTimestamp(raw.windowResetsAt) };
236
+ }
237
+
238
+ // The allowance the dashboard divides by is the full-period cap, because `cap`
239
+ // is the allowance of the period in progress and comes back prorated on a first
240
+ // period. A model that reports neither figure reports no allowance at all, which
241
+ // is a state the surfaces already know how to draw nothing for.
242
+ function nanEffectiveAllowance(raw: RawNanModel): number | undefined {
243
+ return quotaNumber(raw.fullCap, true) ?? quotaNumber(raw.cap, true);
244
+ }
245
+
246
+ // One allowance per metered model, weighted by that model's own cap. The raw
247
+ // numbers travel with the period window so the bar and the panel can aggregate
248
+ // without ever averaging percentages.
249
+ function nanPeriodWindow(tokensUsed: number, cap: number, resetAt: number | null, now: number): UsageWindow {
250
+ return {
251
+ label: NAN_PERIOD_LABEL,
252
+ usedPercent: (tokensUsed / cap) * 100,
253
+ windowSeconds: resetAt === null ? 0 : Math.max(0, Math.round((resetAt - now) / 1000)),
254
+ resetAt,
255
+ used: tokensUsed,
256
+ budget: cap,
257
+ };
258
+ }
259
+
260
+ export function parseNanQuota(payload: unknown, now: number): ProviderUsage {
261
+ const raw = (payload ?? {}) as RawNanQuota;
262
+ const fallbackResetAt = quotaTimestamp(raw.periodEnd);
263
+ const limits: UsageLimit[] = [];
264
+ if (Array.isArray(raw.models)) {
265
+ for (const entry of raw.models) {
266
+ if (!entry || typeof entry !== "object") continue;
267
+ const model = entry as RawNanModel;
268
+ if (typeof model.model !== "string" || model.model.length === 0) continue;
269
+ const allowance = nanEffectiveAllowance(model);
270
+ // A model that reports no allowance is not drift — the dashboard draws
271
+ // nothing for it either, and the live payload carries such entries. A metered
272
+ // allowance whose usage cannot be read is drift: a partial snapshot would
273
+ // understate every aggregate it feeds, so the read fails whole and the last
274
+ // valid snapshot survives instead.
275
+ if (allowance === undefined) continue;
276
+ const tokensUsed = quotaNumber(model.tokensUsed, false);
277
+ if (tokensUsed === undefined) return { provider: NAN_PROVIDER, plan: undefined, limits: [], fetchedAt: now };
278
+ const resetAt = quotaTimestamp(model.periodEnd) ?? fallbackResetAt;
279
+ const windows: UsageWindow[] = [nanPeriodWindow(tokensUsed, allowance, resetAt, now)];
280
+ const rolling = nanRollingWindow(model);
281
+ if (rolling) windows.push(rolling);
282
+ limits.push({ name: model.model, windows, limitReached: tokensUsed >= allowance });
283
+ }
284
+ }
285
+ return { provider: NAN_PROVIDER, plan: undefined, limits, fetchedAt: now };
286
+ }
287
+
288
+ // Aggregation. NaN reports one allowance per metered model and the payload
289
+ // order is the server's business, so surfaces pick by meaning, not by position.
290
+ function rawAllowance(limit: UsageLimit): UsageWindow | undefined {
291
+ const [first] = limit.windows;
292
+ if (!first || first.used === undefined || first.budget === undefined) return undefined;
293
+ return first.budget > 0 ? first : undefined;
294
+ }
295
+
296
+ // The leading alphabetic run of a model id: glm5.3-flash and glm5.2 are both
297
+ // "glm". Derived from the id the payload reports, never from a vendor list.
298
+ export function modelFamily(modelId: string): string {
299
+ return (/^[a-z]+/i.exec(modelId)?.[0] ?? modelId).toLowerCase();
300
+ }
301
+
302
+ // Only NaN carries raw allowance numbers, so this one gate is what keeps Codex
303
+ // and Anthropic on exactly the rows and the meter they had before. One metered
304
+ // model is still a payload that carries them: the gate answers "does this
305
+ // provider report allowances", never "are there enough rows to sort", because
306
+ // a single allowance read as "no allowances" sent the bar back to whichever
307
+ // model the payload listed first.
308
+ export function allowanceGroupsSupported(limits: readonly UsageLimit[]): boolean {
309
+ return limits.length > 0 && limits.every((limit) => rawAllowance(limit) !== undefined);
310
+ }
311
+
312
+ function percentOf(limit: UsageLimit): number {
313
+ return limit.windows[0]?.usedPercent ?? 0;
314
+ }
315
+
316
+ const GROUP_SUFFIX = " total";
317
+
318
+ // A group is an allowance share, never an average of shares: Σused / Σbudget.
319
+ // It carries no reset, because its members close their own billing period on
320
+ // their own date, and a single reset would be a lie.
321
+ function allowanceTotal(name: string, limits: readonly UsageLimit[]): UsageLimit | undefined {
322
+ const windows = limits.map(rawAllowance).filter((window): window is UsageWindow => window !== undefined);
323
+ if (windows.length === 0 || windows.length !== limits.length) return undefined;
324
+ const used = windows.reduce((total, window) => total + (window.used ?? 0), 0);
325
+ const budget = windows.reduce((total, window) => total + (window.budget ?? 0), 0);
326
+ if (budget <= 0) return undefined;
327
+ return {
328
+ name,
329
+ windows: [{ label: NAN_PERIOD_LABEL, usedPercent: (used / budget) * 100, windowSeconds: 0, resetAt: null }],
330
+ limitReached: limits.some((limit) => limit.limitReached),
331
+ };
332
+ }
333
+
334
+ // What the grouping is for now that the totals are gone: the order. A family
335
+ // stays together, families sort by what they consume and the members inside one
336
+ // follow the same rule, most used first. The account and family totals are the
337
+ // bar's fallback ladder only — they are never rows, because a total nobody can
338
+ // act on only costs space. Every row is a limit block, so nothing here
339
+ // introduces a shape the other providers do not already use.
340
+ export function groupUsageLimits(limits: readonly UsageLimit[]): UsageLimit[] {
341
+ if (!allowanceGroupsSupported(limits)) return [...limits];
342
+ const order: string[] = [];
343
+ const members = new Map<string, UsageLimit[]>();
344
+ for (const limit of limits) {
345
+ const family = modelFamily(limit.name);
346
+ if (!members.has(family)) {
347
+ members.set(family, []);
348
+ order.push(family);
349
+ }
350
+ members.get(family)?.push(limit);
351
+ }
352
+ return order
353
+ .map((family) => {
354
+ const sorted = [...(members.get(family) ?? [])].sort((a, b) => percentOf(b) - percentOf(a));
355
+ const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, sorted);
356
+ return { percent: total?.windows[0]?.usedPercent ?? percentOf(sorted[0]), sorted };
357
+ })
358
+ .sort((a, b) => b.percent - a.percent)
359
+ .flatMap((family) => family.sorted);
360
+ }
361
+
362
+ // The bar follows the model the session is using: exact allowance, then its
363
+ // family, then the account total, then the first limit (which is what every
364
+ // provider without raw numbers keeps using, and what a missing model keeps).
365
+ export function selectUsageLimit(usage: ProviderUsage, activeModelId?: string): UsageLimit | undefined {
366
+ if (!activeModelId) return usage.limits[0];
367
+ const exact = usage.limits.find((limit) => limit.name === activeModelId);
368
+ if (exact) return exact;
369
+ if (allowanceGroupsSupported(usage.limits)) {
370
+ const family = modelFamily(activeModelId);
371
+ const members = usage.limits.filter((limit) => modelFamily(limit.name) === family);
372
+ // The family rung is about the name of the meter, not about printing a row,
373
+ // so a single member counts: its family is a closer statement of what the
374
+ // session is drawing from than the whole account.
375
+ if (members.length > 0) {
376
+ const total = allowanceTotal(`${family}${GROUP_SUFFIX}`, members);
377
+ if (total) return total;
378
+ }
379
+ const account = allowanceTotal(`${usage.provider}${GROUP_SUFFIX}`, usage.limits);
380
+ if (account) return account;
381
+ }
382
+ return usage.limits[0];
383
+ }
384
+
177
385
  export function accountIdFromToken(token: string): string | undefined {
178
386
  const parts = token.split(".");
179
387
  if (parts.length !== 3) return undefined;
@@ -190,11 +398,12 @@ function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
190
398
  return paintGauge(percent, theme, cells);
191
399
  }
192
400
 
193
- export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme): string | undefined {
194
- const main = usage.limits[0];
401
+ export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string): string | undefined {
402
+ const main = selectUsageLimit(usage, activeModelId);
195
403
  const [first, ...rest] = main?.windows ?? [];
196
404
  if (!first) return undefined;
197
- const head = `${theme.fg(ROLE.LABEL, main.name)} ${theme.fg(ROLE.LABEL, first.label)} ${paintMeter(first.usedPercent, 8, theme)} ${theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)}`;
405
+ // An unlabeled window prints as the name, the meter and the percentage.
406
+ const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
198
407
  const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
199
408
  return [head, ...tail].join(" ");
200
409
  }
@@ -218,12 +427,19 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
218
427
  const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
219
428
  const plan = usage.plan ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.PLAN, usage.plan)}` : "";
220
429
  lines.push(`${mark}${theme.fg(ROLE.PROVIDER, usage.provider)}${plan} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, updatedAgo(usage.fetchedAt, now))}`);
221
- for (const limit of usage.limits) {
222
- lines.push(` ${theme.fg(ROLE.LIMIT, limit.name)}`);
223
- for (const window of limit.windows) {
224
- const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
225
- lines.push(` ${theme.fg(ROLE.LABEL, window.label.padEnd(5))} ${paintMeter(window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)} ${theme.fg(ROLE.RESET, formatReset(window.resetAt, now))}`);
226
- }
430
+ // One row per window: the limit name, its meter, its percentage and the reset
431
+ // that window reports, all on one line. A window without its own label (the
432
+ // model's allowance) is named by its limit alone, and one without a reset ends
433
+ // at its percentage, never on a dangling separator.
434
+ const rows = groupUsageLimits(usage.limits).flatMap((limit) =>
435
+ limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
436
+ );
437
+ const nameWidth = rows.reduce((widest, row) => Math.max(widest, row.name.length), 0);
438
+ for (const row of rows) {
439
+ const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
440
+ const reset = formatReset(row.window.resetAt, now);
441
+ const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
442
+ lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
227
443
  }
228
444
  }
229
445
  return lines.map((line) => truncateToWidth(line, width, "…"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
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",
@@ -38,6 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "check:provider-contract": "node scripts/check-provider-contract.mjs",
41
+ "mirror:odd-routing": "node scripts/mirror-odd-routing.mjs",
41
42
  "postinstall": "node scripts/install-gentle-ai.mjs",
42
43
  "test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
43
44
  "test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
@@ -996,6 +996,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
996
996
  // remain dark because neither is proven to reach the negotiated START
997
997
  // path Pi consumes.
998
998
  "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 }),
999
+ // v3.2.1 changed the ODD orchestrator contract only (gentle-ai #4714
1000
+ // follow-up). Ground-truthed by diffing contracts/review-integration/v2 and
1001
+ // contracts/review-provider-contract between the v3.1.0 and v3.2.1 tags
1002
+ // in the gentle-ai source tree: zero bytes changed (provider contract
1003
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
1004
+ // this row negotiates, so it repeats 3.1.0 exactly. riskEvidence and hint
1005
+ // remain dark because neither is proven to reach the negotiated START
1006
+ // path Pi consumes.
1007
+ "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 }),
999
1008
  });
1000
1009
 
1001
1010
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "3.1.0";
39
+ export const INSTALLER_VERSION = "3.2.1";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v3";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.1.0`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.2.1`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit cfc415ce, the published v3.1.0 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:CrZlui5N8/RSmxvn/7usKe6q1EvRt7y6dyfEwjstmJw=";
50
+ // tag resolves to commit e7729359, the published v3.2.1 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:0QFo0ERv8/3lgTepEG0E/P7yD9qXXCx2M7ppQviVHMo=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v3.1.0 digests are pinned from the published release:
70
+ // still holds it. The v3.2.1 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v3.1.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v3.2.1 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_3.1.0_darwin_amd64.tar.gz", "613f0e11adeebb421daae4c68cb9f207f55c559a70595549ff25f988559226e4", "98340df0102825072431a2c0373ea4d9db1bacafced234f51fb58661ed3d731f", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_3.1.0_darwin_arm64.tar.gz", "bfcbf8df2682fcf1535b26c604e8dbb445df0ca00a651de204fdc2d013dfe472", "3cdc9689ea0d71186b896341b4181e2df13a82b64d236a26a3273171150d802f", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_3.1.0_linux_amd64.tar.gz", "dc55c44a2eb46212a38eca0dfd4d778481ec37e765f40d5a0752d03c28e1ee49", "70e335d25809a0d358c12f48b2f0d1da00741e725584ceeb8c1318c60d0a6e9e", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_3.1.0_linux_arm64.tar.gz", "3a89d5f5a549004cc2b01949014ed59f9c28e1ff0c9958531bb539504286e407", "6cf9f20fc390b13e2b9b427ca1c2df404d1bbb9248201f9a592c7f0a37ef5416", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_3.2.1_darwin_amd64.tar.gz", "1696a4852435920ebae351a5173adead6da17db6762359dcb12dc3971d983c3f", "ed7d18b7446c63f265a827a736ed4465ec562cc3c07c470743d3740fb4d2f4a9", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_3.2.1_darwin_arm64.tar.gz", "e3a991a03c6fb6373f6a254b808c3fabf36330634d93704c9d9ba365467fc6f8", "2ae66cfa11f8ab93ea46c20e79bd191dedde40a4898f0b312330a0790f802ca3", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_3.2.1_linux_amd64.tar.gz", "e2e3171377c040c27a93ae66b4adf824626f23cacd398b3b652af0d87ff65807", "3464525775b60cad6358a589c298a2d4098466bc0a5ae5f0d1e578ab10134659", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_3.2.1_linux_arm64.tar.gz", "342c7fc60cf062730c8f593f09269f75485b3b1fa2907b2b188eafded1a0e222", "d2c4d8ebfdc681ceece254d0d12cd04dd7af3b435c945e42623e3475981c2f8f", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // ODD routing drift ratchet — canonical fixture regeneration.
3
+ //
4
+ // gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
5
+ // `internal/components/agentguidance/routing.go` (RenderRouting). There is no
6
+ // automated sync, so a canonical change leaves the pi mirror stale with nothing
7
+ // catching it. This script renders the canonical block from a LOCAL gentle-ai
8
+ // checkout and vendors it into `fixtures/odd-routing-canonical.md` with managed
9
+ // provenance, where `tests/odd-routing-canonical-ratchet.test.ts` turns drift
10
+ // into a failing test.
11
+ //
12
+ // This script NEVER fetches anything from the network (GOPROXY=off), NEVER
13
+ // modifies the gentle-ai checkout beyond a transient temporary Go entrypoint
14
+ // that it always deletes, and NEVER touches the installer release pin
15
+ // (scripts/gentle-ai-installer.mjs INSTALLER_VERSION). Regeneration writes ONLY
16
+ // the fixture; it never auto-rewrites the pi mirror assets.
17
+ //
18
+ // Usage:
19
+ // node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+
27
+ export const ODD_ROUTING_FIXTURE_RELATIVE = "fixtures/odd-routing-canonical.md";
28
+ export const ODD_ROUTING_SOURCE_REPO = "https://github.com/Gentleman-Programming/gentle-ai";
29
+ export const ODD_ROUTING_SOURCE_PATH = "internal/components/agentguidance/routing.go";
30
+ export const ODD_ROUTING_HEADER_OPEN = "<!-- gentle-pi:managed-odd-routing-canonical\n";
31
+ export const ODD_ROUTING_HEADER_CLOSE = "\n-->\n";
32
+ export const ODD_ROUTING_TEMP_DIR = "tmp-odd-routing-dump";
33
+ export const ODD_ROUTING_BLOCK_PREFIX = "## Implementation Routing";
34
+
35
+ const GO_ENTRYPOINT = `package main
36
+
37
+ import (
38
+ "fmt"
39
+ "os"
40
+
41
+ "github.com/gentleman-programming/gentle-ai/v3/internal/components/agentguidance"
42
+ "github.com/gentleman-programming/gentle-ai/v3/internal/model"
43
+ )
44
+
45
+ func main() {
46
+ rendered, err := agentguidance.RenderRouting(model.AgentPi)
47
+ if err != nil {
48
+ fmt.Fprintln(os.Stderr, err)
49
+ os.Exit(1)
50
+ }
51
+ fmt.Print(rendered)
52
+ }
53
+ `;
54
+
55
+ export function sha256Hex(value) {
56
+ return createHash("sha256").update(value).digest("hex");
57
+ }
58
+
59
+ export function parseOddRoutingFixture(raw) {
60
+ if (!raw.startsWith(ODD_ROUTING_HEADER_OPEN)) {
61
+ throw new Error(`fixture does not start with the managed header ${JSON.stringify(ODD_ROUTING_HEADER_OPEN)}`);
62
+ }
63
+ const headerEnd = raw.indexOf(ODD_ROUTING_HEADER_CLOSE, ODD_ROUTING_HEADER_OPEN.length);
64
+ if (headerEnd === -1) {
65
+ throw new Error(`fixture managed header is not terminated by ${JSON.stringify(ODD_ROUTING_HEADER_CLOSE)}`);
66
+ }
67
+ const headerText = raw.slice(ODD_ROUTING_HEADER_OPEN.length, headerEnd);
68
+ const body = raw.slice(headerEnd + ODD_ROUTING_HEADER_CLOSE.length);
69
+ const header = {};
70
+ for (const line of headerText.split("\n")) {
71
+ const separator = line.indexOf(": ");
72
+ if (separator === -1) continue;
73
+ header[line.slice(0, separator)] = line.slice(separator + 2);
74
+ }
75
+ return { header, body };
76
+ }
77
+
78
+ export function renderOddRoutingFixture(block, { sourceCommit, generatedAt }) {
79
+ const body = block.endsWith("\n") ? block : `${block}\n`;
80
+ const header = [
81
+ ODD_ROUTING_HEADER_OPEN.trimEnd(),
82
+ `source_repo: ${ODD_ROUTING_SOURCE_REPO}`,
83
+ `source_path: ${ODD_ROUTING_SOURCE_PATH}`,
84
+ `source_commit: ${sourceCommit}`,
85
+ `generated_at: ${generatedAt}`,
86
+ `block_sha256: ${sha256Hex(body)}`,
87
+ "-->",
88
+ ].join("\n");
89
+ return `${header}\n${body}`;
90
+ }
91
+
92
+ // `git status --porcelain --untracked-files=no` emits one `XY PATH` line per
93
+ // modified tracked file. Extract just the repository-relative path so the
94
+ // fail-closed error can name what would make provenance unverifiable.
95
+ export function parsePorcelainPaths(porcelainOutput) {
96
+ return porcelainOutput
97
+ .split("\n")
98
+ .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
99
+ .filter((line) => line.length > 0)
100
+ .map((line) => line.slice(3).trim())
101
+ .filter((path) => path.length > 0);
102
+ }
103
+
104
+ // Fail closed when the checkout carries uncommitted changes to tracked files.
105
+ // The fixture header asserts the rendered block came from the committed tree;
106
+ // rendering a dirty tree under that claim would make the provenance a lie.
107
+ // Untracked files never reach this guard because the git call excludes them.
108
+ export function assertCleanGentleAiCheckout(porcelainOutput) {
109
+ const dirtyPaths = parsePorcelainPaths(porcelainOutput);
110
+ if (dirtyPaths.length > 0) {
111
+ throw new Error(
112
+ `gentle-ai checkout is dirty; provenance would be unverifiable: ${dirtyPaths.join(", ")}. Commit or stash first.`,
113
+ );
114
+ }
115
+ }
116
+
117
+ function parseArguments(argv) {
118
+ const values = { gentleAi: undefined };
119
+ for (let index = 0; index < argv.length; index += 2) {
120
+ const flag = argv[index];
121
+ const value = argv[index + 1];
122
+ if (flag !== "--gentle-ai" || value === undefined) {
123
+ throw new Error("usage: node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]");
124
+ }
125
+ if (values.gentleAi !== undefined) throw new Error("duplicate --gentle-ai argument");
126
+ values.gentleAi = value;
127
+ }
128
+ return values;
129
+ }
130
+
131
+ function requireGentleAiCheckout(gentleAiRoot) {
132
+ if (!existsSync(gentleAiRoot)) {
133
+ throw new Error(
134
+ `no gentle-ai checkout at ${gentleAiRoot}; pass --gentle-ai <path> or clone ${ODD_ROUTING_SOURCE_REPO} as a sibling of gentle-pi`,
135
+ );
136
+ }
137
+ const routingPath = join(gentleAiRoot, ...ODD_ROUTING_SOURCE_PATH.split("/"));
138
+ if (!existsSync(routingPath)) {
139
+ throw new Error(`not a gentle-ai checkout: missing ${routingPath}`);
140
+ }
141
+ }
142
+
143
+ export function renderCanonicalRouting(gentleAiRoot) {
144
+ const tempRoot = join(gentleAiRoot, ODD_ROUTING_TEMP_DIR);
145
+ // Clear any stale temp entrypoint from a previous crash so the render is clean.
146
+ rmSync(tempRoot, { recursive: true, force: true });
147
+ mkdirSync(tempRoot, { recursive: true });
148
+ try {
149
+ writeFileSync(join(tempRoot, "main.go"), GO_ENTRYPOINT, { encoding: "utf8" });
150
+ const rendered = execFileSync("go", ["run", `./${ODD_ROUTING_TEMP_DIR}`], {
151
+ cwd: gentleAiRoot,
152
+ encoding: "utf8",
153
+ // GOPROXY=off fails closed instead of downloading: this script must
154
+ // never touch the network.
155
+ env: { ...process.env, GOPROXY: "off" },
156
+ stdio: ["ignore", "pipe", "inherit"],
157
+ });
158
+ if (!rendered.startsWith(ODD_ROUTING_BLOCK_PREFIX)) {
159
+ throw new Error(
160
+ `RenderRouting(model.AgentPi) did not return the expected routing block (starts with ${JSON.stringify(ODD_ROUTING_BLOCK_PREFIX)})`,
161
+ );
162
+ }
163
+ return rendered;
164
+ } finally {
165
+ rmSync(tempRoot, { recursive: true, force: true });
166
+ }
167
+ }
168
+
169
+ // Resolve fixture provenance from the committed gentle-ai tree. `generatedAt`
170
+ // is the committer date, not wall-clock time, so rendering the same commit
171
+ // twice yields byte-identical fixture files. `execGit` is injectable for tests.
172
+ export function resolveOddRoutingProvenance(gentleAiRoot, execGit) {
173
+ const git = execGit ?? ((args) => execFileSync("git", args, { cwd: gentleAiRoot, encoding: "utf8" }));
174
+ assertCleanGentleAiCheckout(git(["status", "--porcelain", "--untracked-files=no"]));
175
+ return {
176
+ sourceCommit: git(["rev-parse", "HEAD"]).trim(),
177
+ generatedAt: git(["show", "-s", "--format=%cI", "HEAD"]).trim(),
178
+ };
179
+ }
180
+
181
+ export function mirrorOddRouting(packageRoot, gentleAiRoot) {
182
+ requireGentleAiCheckout(gentleAiRoot);
183
+ // Resolve (and fail closed on dirty) provenance BEFORE rendering: the
184
+ // transient Go entrypoint must not run against a tree whose commit hash the
185
+ // fixture would misrepresent.
186
+ const provenance = resolveOddRoutingProvenance(gentleAiRoot);
187
+ const block = renderCanonicalRouting(gentleAiRoot);
188
+ const contents = renderOddRoutingFixture(block, provenance);
189
+
190
+ const fixturePath = join(resolve(packageRoot), ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
191
+ const previous = readPreviousFixture(fixturePath);
192
+ mkdirSync(dirname(fixturePath), { recursive: true });
193
+
194
+ // Atomic overwrite: write a sibling temp file, then rename it into place.
195
+ const stagingPath = `${fixturePath}.tmp`;
196
+ try {
197
+ writeFileSync(stagingPath, contents, { encoding: "utf8" });
198
+ renameSync(stagingPath, fixturePath);
199
+ } finally {
200
+ rmSync(stagingPath, { force: true });
201
+ }
202
+
203
+ const next = parseOddRoutingFixture(contents);
204
+ return {
205
+ fixturePath,
206
+ sourceCommit: next.header.source_commit,
207
+ blockSha256: next.header.block_sha256,
208
+ changed: previous === undefined || previous.body !== next.body || previous.header.source_commit !== next.header.source_commit,
209
+ previous,
210
+ };
211
+ }
212
+
213
+ function readPreviousFixture(fixturePath) {
214
+ if (!existsSync(fixturePath)) return undefined;
215
+ const raw = readFileSync(fixturePath, "utf8");
216
+ // Verify the existing fixture before clobbering it: an unparseable file is a
217
+ // signal to inspect, not to silently overwrite.
218
+ const parsed = parseOddRoutingFixture(raw);
219
+ const recorded = parsed.header.block_sha256;
220
+ const actual = sha256Hex(parsed.body);
221
+ if (recorded !== undefined && recorded !== actual) {
222
+ console.warn(`warning: existing fixture body does not match its recorded digest (${recorded}); regenerating from canonical`);
223
+ }
224
+ return parsed;
225
+ }
226
+
227
+ async function main() {
228
+ const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
229
+ const { gentleAi } = parseArguments(process.argv.slice(2));
230
+ const gentleAiRoot = resolve(gentleAi ?? join(packageRoot, "..", "gentle-ai"));
231
+ const result = mirrorOddRouting(packageRoot, gentleAiRoot);
232
+ console.log(`${result.changed ? "Wrote" : "Refreshed"} ${result.fixturePath}`);
233
+ console.log(`source commit ${result.sourceCommit}; block sha256 ${result.blockSha256}`);
234
+ if (result.previous !== undefined) {
235
+ console.log(`previous commit ${result.previous.header.source_commit}; previous block sha256 ${result.previous.header.block_sha256}`);
236
+ }
237
+ }
238
+
239
+ const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
240
+ if (isMainModule) {
241
+ await main();
242
+ }
@@ -181,7 +181,7 @@ const contractHashes = {
181
181
  "contracts/review-integration/v2/schemas/start.schema.json": "2991e3fcca672d9257d61b6a336fb34e58b15a8e03f8a09a7adf892cae6a8085",
182
182
  "contracts/review-integration/v2/schemas/status.schema.json": "c4dcc736cfc6300560a3c4262d2d982368529d5c49d58d499552a3b0beef9212",
183
183
  "contracts/telemetry/runtime-aggregate-v1.schema.json": "eb0f2993d9271f55cb42eca343e6fbb601a733fb90bd40daeebc92ee60ae1ba9",
184
- "docs/review-integration.md": "95a3df92785bc4d9f3b99e702aaf817ae0440bd16c83218d2c3f2aca67c280fb",
184
+ "docs/review-integration.md": "9868cd52ae8f15cc2e6a6757014d5b12ff0904e06725d1a4cf29e9bcc3a7bae2",
185
185
  };
186
186
 
187
187
  requiredPaths.push(...Object.keys(contractHashes));
@@ -339,7 +339,7 @@ async function main() {
339
339
  });
340
340
 
341
341
  if (driftedContracts.length > 0) {
342
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.1.0 runtime's vendored Gentle AI contract artifacts:");
342
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.2.1 runtime's vendored Gentle AI contract artifacts:");
343
343
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
344
344
  process.exit(1);
345
345
  }
@@ -384,7 +384,7 @@ async function main() {
384
384
  process.exit(1);
385
385
  }
386
386
 
387
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.1.0 runtime).`);
387
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.2.1 runtime).`);
388
388
  }
389
389
 
390
390
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
97
97
  method: "go-sumdb-source-build",
98
98
  package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
99
99
  module: "github.com/gentleman-programming/gentle-ai/v3",
100
- tag: "v3.1.0",
100
+ tag: "v3.2.1",
101
101
  architecture: process.arch === "x64" ? "x64" : "arm64",
102
102
  binarySha256: createHash("sha256").update(binary).digest("hex"),
103
103
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,