pi-oracle 0.7.12 → 0.7.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.13 - 2026-06-15
6
+
7
+ ### Added
8
+ - added a release-blocking ChatGPT preset proof gate (`npm run release:proof:chatgpt-presets`) so publishing requires fresh loaded-extension evidence for every canonical ChatGPT preset
9
+
10
+ ### Fixed
11
+ - fixed compact ChatGPT Intelligence menu handling so selected thinking tiers that close back to `Medium`, `High`, or `Extra High` composer pills are accepted only after an intentional matching menu click instead of falling through to the removed legacy effort dropdown
12
+ - fixed `instant_auto_switch` under the compact ChatGPT UI, where the legacy auto-switch control is absent after selecting the compact `Instant` tier
13
+ - made ChatGPT model-configuration opening tolerate slower compact-UI hydration before reporting UI drift
14
+ - stabilized archive creation when the compression subprocess exits before tar, so the worker terminates upstream tar immediately instead of waiting for the archive timeout
15
+ - surfaced provider rate-limit/outage modals explicitly during ChatGPT model setup, upload, send, and response waits instead of reporting generic UI drift
16
+
5
17
  ## 0.7.12 - 2026-06-15
6
18
 
7
19
  ### Changed
package/README.md CHANGED
@@ -382,7 +382,7 @@ npm test
382
382
  npm run verify:oracle
383
383
  ```
384
384
 
385
- `npm publish` is guarded by `prepublishOnly`, which runs `npm run release:check`. That release gate requires doctor-first macOS, Ubuntu, and Windows native Crabbox evidence. The required Crabbox runtime suite uses packed-install proof, not source-tree `pi -e` loading.
385
+ `npm publish` is guarded by `prepublishOnly`, which runs `npm run release:check`. That release gate now blocks unless fresh live ChatGPT preset proof exists for every canonical preset, then requires doctor-first macOS, Ubuntu, and Windows native Crabbox evidence. The required Crabbox runtime suite uses packed-install proof, not source-tree `pi -e` loading.
386
386
 
387
387
  Use the narrowest validation workflow that proves the change:
388
388
 
@@ -391,6 +391,7 @@ Use the narrowest validation workflow that proves the change:
391
391
  | Everyday local iteration | `npm run verify:oracle` |
392
392
  | Platform-sensitive changes | `npm run smoke:platform:doctor`, then a focused `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` |
393
393
  | Platform matrix proof | `npm run smoke:platform:all` |
394
+ | ChatGPT preset release proof | `npm run release:proof:chatgpt-presets` |
394
395
  | Publish/release gate | `npm run release:check` |
395
396
 
396
397
  For macOS, Ubuntu, and Windows native package/build plus packed runtime validation, use [`docs/platform-smoke.md`](docs/platform-smoke.md). The full release gate is:
@@ -399,9 +400,19 @@ For macOS, Ubuntu, and Windows native package/build plus packed runtime validati
399
400
  npm run release:check
400
401
  ```
401
402
 
403
+ Before a release, run live jobs through the loaded extension for every ChatGPT preset in `ORACLE_SUBMIT_PRESETS`. Each prompt must make the saved response contain exact markers `PRESET <preset> OK` and `PACKAGE pi-oracle`. After every job has completed, save the job ids/job directories in `.artifacts/chatgpt-preset-proof/latest.json`; `validatedAt` must be later than the completed jobs. Start from the checked, intentionally non-valid template:
404
+
405
+ ```bash
406
+ mkdir -p .artifacts/chatgpt-preset-proof
407
+ node scripts/oracle-chatgpt-preset-proof.mjs template > .artifacts/chatgpt-preset-proof/latest.json
408
+ npm run release:proof:chatgpt-presets
409
+ ```
410
+
411
+ The proof checker is intentionally part of `release:check`; it fails if the proof is missing, stale, tied to a different package version/git head, references jobs that completed before the current commit, or lacks actual persisted ChatGPT `.tar.zst` job state and response text for any canonical preset.
412
+
402
413
  The real runtime suite defaults to deterministic installed-tool execution so platform proof stays bounded. Provider/model defaults remain `zai/glm-5.2` for doctor/config and for optional model-agent debugging; override with `PI_ORACLE_REAL_TEST_PROVIDER` and `PI_ORACLE_REAL_TEST_MODEL` when needed. For inner-loop source loading only, use `npm run smoke:real:source`; it is not release proof. Set `PI_ORACLE_REAL_TEST_MODEL_AGENT=1` only when debugging the slower model-agent path. The optional second real-agent negative symlink check is opt-in via `PI_ORACLE_REAL_TEST_NEGATIVE_SYMLINK=1`; `npm run sanity:oracle` covers archive/symlink rejection by default without adding another model-agent turn to the platform release gate.
403
414
 
404
- For manual end-to-end local-extension smoke testing, use [`docs/ORACLE_ISOLATED_PI_VALIDATION.md`](docs/ORACLE_ISOLATED_PI_VALIDATION.md). That workflow launches isolated `pi` coding-agent sessions against this checkout and uses `instant` or `thinking_light`, as required by the project validation policy.
415
+ For manual end-to-end local-extension smoke testing, use [`docs/ORACLE_ISOLATED_PI_VALIDATION.md`](docs/ORACLE_ISOLATED_PI_VALIDATION.md). Ordinary pre-commit smoke runs can still use `instant` or `thinking_light`, but release proof must cover every canonical ChatGPT preset through the loaded extension.
405
416
 
406
417
  ## Project map
407
418
 
@@ -608,7 +608,7 @@ Live-validated after the concurrency redesign:
608
608
 
609
609
  Still to verify live after this pivot:
610
610
 
611
- - model-selection verification against the current ChatGPT UI under additional real-world variation
611
+ - full ChatGPT preset release matrix evidence must be refreshed before any release; `npm run release:proof:chatgpt-presets` blocks release without one completed loaded-extension ChatGPT job for every canonical preset
612
612
  - optional richer terminal semantics for partial artifact failure (`complete_with_artifact_errors`) in more live scenarios
613
613
 
614
614
  ## Production readiness criteria
@@ -629,7 +629,7 @@ This architecture is now live-validated for the core release path:
629
629
  ### Current readiness summary
630
630
 
631
631
  Current release blockers for the validated scope:
632
- - none currently known
632
+ - release is blocked until fresh loaded-extension ChatGPT preset proof passes `npm run release:proof:chatgpt-presets` for every canonical `ORACLE_SUBMIT_PRESETS` id
633
633
 
634
634
  Remaining non-blocking hardening work:
635
635
  - broaden live proof of the new lifecycle/state-machine model across more degraded paths
@@ -653,4 +653,4 @@ Recent proof points:
653
653
  - repo-owned sanity harness: `npm run sanity:oracle`
654
654
  - real installed-extension smoke source of truth: `scripts/oracle-real-smoke.mjs`; required release proof runs packed-install mode (`npm run smoke:real:packed`) and executes installed-package `oracle_submit` deterministically, with optional slower model-agent debugging via `PI_ORACLE_REAL_TEST_MODEL_AGENT=1`; source mode (`npm run smoke:real:source`) is inner-loop/debug only
655
655
  - macOS, Ubuntu, and Windows native package/build/runtime smoke source of truth: `docs/platform-smoke.md`; use `npm run verify:oracle` for everyday local iteration, `npm run smoke:platform:doctor` plus a focused target/suite run for platform-sensitive changes, `npm run smoke:platform:all` for doctor-first platform matrix evidence, and `npm run release:check` for the full local-plus-platform release gate
656
- - release gate: `npm run release:check`, also used by `prepublishOnly`, combines static verification and all required Crabbox platform smokes
656
+ - release gate: `npm run release:check`, also used by `prepublishOnly`, combines static verification, fresh loaded-extension ChatGPT preset proof via `npm run release:proof:chatgpt-presets`, and all required Crabbox platform smokes
@@ -35,15 +35,34 @@ Do not add `https://github.com/fitchmultz/pi-oracle` to this repository's `.pi/s
35
35
 
36
36
  `oracle_submit` now preflights missing, unreadable, or unverified auth seed profiles before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, use `oracle_preflight` for the blocker path or create a test seed only in a purpose-built fixture that includes the `.oracle-seed-generation` marker.
37
37
 
38
- ## Preset requirement
38
+ ## Preset requirements
39
39
 
40
- Use either:
40
+ For ordinary pre-commit isolated smoke tests, use either:
41
41
 
42
42
  - `instant`
43
43
  - `thinking_light`
44
44
 
45
45
  The examples below use `instant` because it is the fastest smoke-test preset.
46
46
 
47
+ For any release, and for any change that touches ChatGPT model selection, run live loaded-extension jobs for every canonical ChatGPT preset from `ORACLE_SUBMIT_PRESETS`:
48
+
49
+ - `pro_standard`
50
+ - `pro_extended`
51
+ - `thinking_light`
52
+ - `thinking_standard`
53
+ - `thinking_extended`
54
+ - `thinking_heavy`
55
+ - `instant`
56
+ - `instant_auto_switch`
57
+
58
+ Use prompts that make each saved response contain exact markers `PRESET <preset> OK` and `PACKAGE pi-oracle`. Save the completed job ids/job directories in `.artifacts/chatgpt-preset-proof/latest.json` only after every job completes; `validatedAt` must be later than those completed jobs. The checker reads the actual persisted `job.json`, worker log, and response files. Then run:
59
+
60
+ ```bash
61
+ npm run release:proof:chatgpt-presets
62
+ ```
63
+
64
+ `npm run release:check` runs that proof gate before release. This is intentional: publishing is blocked until every ChatGPT preset has fresh loaded-extension evidence.
65
+
47
66
  ## Prerequisites
48
67
 
49
68
  - `pi` installed locally
@@ -49,7 +49,8 @@ Use the narrowest workflow that proves the change. Do not run the full platform
49
49
  | Everyday local iteration | `npm run verify:oracle` | Syntax, bundle, platform-smoke invariants, type checks, oracle sanity, and package dry-run pass locally. |
50
50
  | Platform-sensitive change | `npm run smoke:platform:doctor`, then `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` | Target setup is ready and the affected platform/suite works without paying for unrelated targets. |
51
51
  | Platform matrix proof | `npm run smoke:platform:all` | Doctor-first packed-install proof passes on every required target and suite. |
52
- | Publish/release gate | `npm run release:check` | Local verification (`verify:oracle`) passes, then the doctor-first platform matrix passes. |
52
+ | ChatGPT preset release proof | `npm run release:proof:chatgpt-presets` | Fresh loaded-extension proof exists for every canonical ChatGPT preset. |
53
+ | Publish/release gate | `npm run release:check` | Local verification (`verify:oracle`) passes, fresh ChatGPT preset proof exists, then the doctor-first platform matrix passes. |
53
54
 
54
55
  Platform-sensitive changes include archive behavior, process cleanup, runtime/browser profile handling, package metadata, Crabbox harness code, or anything that may differ across macOS/Linux/Windows.
55
56
 
@@ -77,7 +78,7 @@ Full release gate:
77
78
  npm run release:check
78
79
  ```
79
80
 
80
- `release:check` runs `verify:oracle` before `smoke:platform:all`, matching the Crabbox doctor-first release order: cheap harness checks, doctor, full matrix, then artifact review. `prepublishOnly` runs `npm run release:check`.
81
+ `release:check` runs `verify:oracle`, then `release:proof:chatgpt-presets`, then `smoke:platform:all`, matching the release order: cheap harness checks, fresh live ChatGPT preset proof, doctor, full matrix, then artifact review. `prepublishOnly` runs `npm run release:check`.
81
82
 
82
83
  ## What `platform-build` proves
83
84
 
@@ -578,11 +578,13 @@ async function writeNonWindowsTarArchiveFile(
578
578
  (code) => {
579
579
  targetCode = code;
580
580
  targetDone = true;
581
+ if (code !== 0 && tarCode === undefined) terminateChildren();
581
582
  finish();
582
583
  },
583
584
  (error) => {
584
585
  targetError = error instanceof Error ? error : new Error(String(error));
585
586
  targetDone = true;
587
+ if (tarCode === undefined) terminateChildren();
586
588
  finish();
587
589
  },
588
590
  );
@@ -4,9 +4,11 @@
4
4
  // Usage: Imported by oracle commands, tools, queue logic, poller flows, and runtime cleanup/reconciliation paths.
5
5
  // Invariants/Assumptions: Job mutations happen under per-job locks, worker identity checks defend against PID reuse, and persisted jobs remain the source of truth.
6
6
  import { createHash, randomUUID } from "node:crypto";
7
+ import { execFileSync } from "node:child_process";
7
8
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
8
9
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
9
10
  import { isAbsolute, join, relative as relativePath, resolve, sep } from "node:path";
11
+ import { fileURLToPath } from "node:url";
10
12
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
13
  import {
12
14
  ACTIVE_ORACLE_JOB_STATUSES,
@@ -117,6 +119,14 @@ export interface OracleArtifactRecord {
117
119
  matchesUploadedArchive?: boolean;
118
120
  }
119
121
 
122
+ export interface OracleExtensionProvenance {
123
+ schemaVersion: 1;
124
+ packageName: string;
125
+ packageVersion: string;
126
+ sourcePath: string;
127
+ gitHead?: string;
128
+ }
129
+
120
130
  export interface OracleJob {
121
131
  id: string;
122
132
  status: OracleJobStatus;
@@ -135,6 +145,7 @@ export interface OracleJob {
135
145
  originSessionFile?: string;
136
146
  requestSource: "command" | "tool";
137
147
  selection: OracleResolvedSelection;
148
+ extensionProvenance?: OracleExtensionProvenance;
138
149
  followUpToJobId?: string;
139
150
  chatUrl?: string;
140
151
  conversationId?: string;
@@ -452,8 +463,8 @@ export async function cleanupJobResources(
452
463
 
453
464
  function getCleanupRetentionMs(job: OracleJob): { complete: number; failed: number } {
454
465
  return {
455
- complete: job.config.cleanup?.completeJobRetentionMs ?? ORACLE_COMPLETE_JOB_RETENTION_MS,
456
- failed: job.config.cleanup?.failedJobRetentionMs ?? ORACLE_FAILED_JOB_RETENTION_MS,
466
+ complete: job.config?.cleanup?.completeJobRetentionMs ?? ORACLE_COMPLETE_JOB_RETENTION_MS,
467
+ failed: job.config?.cleanup?.failedJobRetentionMs ?? ORACLE_FAILED_JOB_RETENTION_MS,
457
468
  };
458
469
  }
459
470
 
@@ -899,6 +910,39 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
899
910
  });
900
911
  }
901
912
 
913
+ function readExtensionProvenance(cwd: string): OracleExtensionProvenance {
914
+ const sourcePath = resolve(fileURLToPath(new URL("../../../", import.meta.url)));
915
+ let packageName = "pi-oracle";
916
+ let packageVersion = "unknown";
917
+ try {
918
+ const packageJson = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf8")) as { name?: string; version?: string };
919
+ packageName = packageJson.name || packageName;
920
+ packageVersion = packageJson.version || packageVersion;
921
+ } catch {
922
+ // Keep provenance present even when package metadata is unavailable in an
923
+ // unusual loader; release proof rejects unknown versions.
924
+ }
925
+
926
+ let gitHead: string | undefined;
927
+ try {
928
+ gitHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: sourcePath, encoding: "utf8" }).trim();
929
+ } catch {
930
+ try {
931
+ gitHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
932
+ } catch {
933
+ gitHead = undefined;
934
+ }
935
+ }
936
+
937
+ return {
938
+ schemaVersion: 1,
939
+ packageName,
940
+ packageVersion,
941
+ sourcePath,
942
+ gitHead,
943
+ };
944
+ }
945
+
902
946
  export async function createJob(
903
947
  id: string,
904
948
  input: OracleSubmitInput,
@@ -946,6 +990,7 @@ export async function createJob(
946
990
  originSessionFile: sessionFile,
947
991
  requestSource: input.requestSource,
948
992
  selection: input.selection,
993
+ extensionProvenance: readExtensionProvenance(cwd),
949
994
  followUpToJobId: input.followUpToJobId,
950
995
  chatUrl: input.chatUrl,
951
996
  conversationId,
@@ -13,10 +13,12 @@ export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: st
13
13
  export declare function stripChatGptResponseChrome(value: string | undefined): string;
14
14
  export declare function matchesModelFamilyLabel(label: string | undefined, family: OracleUiModelFamily): boolean;
15
15
  export declare function matchesRequestedModelControlLabel(label: string | undefined, selection: OracleUiSelection): boolean;
16
+ export declare function matchesCompactIntelligenceControlLabel(label: string | undefined): boolean;
16
17
  export declare function matchesCompactIntelligenceOpenerLabel(label: string | undefined): boolean;
17
18
  export declare function requestedEffortLabel(selection: OracleUiSelection): string | undefined;
18
19
  export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
19
20
  export declare function thinkingChipVisible(snapshot: string): boolean;
21
+ export declare function snapshotHasClosedCompactSelection(snapshot: string, selection: OracleUiSelection): boolean;
20
22
  export declare function snapshotHasModelConfigurationUi(snapshot: string): boolean;
21
23
  export declare function snapshotHasUsableComposerControls(snapshot: string): boolean;
22
24
  export declare function snapshotHasModelOpener(snapshot: string): boolean;
@@ -248,9 +248,29 @@ function hasLegacyEffortCombobox(entries) {
248
248
  });
249
249
  }
250
250
 
251
- function compactSelectionFromEntry(entry, _entries, _options = {}) {
252
- if (entry.disabled || !COMPACT_INTELLIGENCE_CONTROL_KINDS.has(entry.kind || "")) return undefined;
253
- return parseCompactIntelligenceSelection(entry.label);
251
+ function compactSelectionFromEntry(entry, _entries, options = {}) {
252
+ if (entry.disabled) return undefined;
253
+ const kind = entry.kind || "";
254
+ if (COMPACT_INTELLIGENCE_CONTROL_KINDS.has(kind)) return parseCompactIntelligenceSelection(entry.label);
255
+ if (options.allowClosedButtons && kind === "button" && !/\bexpanded=true\b/.test(String(entry.line || ""))) {
256
+ return parseCompactIntelligenceSelection(entry.label);
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ export function matchesCompactIntelligenceControlLabel(label) {
262
+ return Boolean(parseCompactIntelligenceSelection(label));
263
+ }
264
+
265
+ export function snapshotHasClosedCompactSelection(snapshot, selection) {
266
+ /** @type {SnapshotEntry[]} */
267
+ const entries = parseSnapshotEntries(snapshot);
268
+ if (hasRemovableComposerModelChip(entries) || hasLegacyEffortCombobox(entries) || hasCompactIntelligenceMenuContext(entries)) return false;
269
+ return entries.some((entry) => {
270
+ if (entry.kind !== "button" || entry.disabled) return false;
271
+ const compactSelection = compactSelectionFromEntry(entry, entries, { allowClosedButtons: true });
272
+ return compactSelectionMatchesRequestedInSnapshot(snapshot, selection, compactSelection);
273
+ });
254
274
  }
255
275
 
256
276
  function compactSelectionMatchesRequested(selection, compactSelection) {
@@ -23,12 +23,14 @@ import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABE
23
23
  import {
24
24
  buildAllowedChatGptOrigins,
25
25
  deriveAssistantCompletionSignature,
26
+ matchesCompactIntelligenceControlLabel,
26
27
  matchesCompactIntelligenceOpenerLabel,
27
28
  matchesModelFamilyLabel,
28
29
  matchesRequestedModelControlLabel,
29
30
  requestedEffortLabel,
30
31
  effortSelectionVisible,
31
32
  snapshotCanSafelySkipModelConfiguration,
33
+ snapshotHasClosedCompactSelection,
32
34
  snapshotHasModelConfigurationUi,
33
35
  snapshotHasModelOpener,
34
36
  snapshotHasUsableComposerControls,
@@ -78,6 +80,7 @@ const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
78
80
  const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
79
81
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
80
82
  const PROFILE_CLONE_TIMEOUT_MS = 120_000;
83
+ const MODEL_CONFIGURATION_OPEN_TIMEOUT_MS = 45_000;
81
84
  const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
82
85
  const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
83
86
  const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
@@ -1091,15 +1094,9 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
1091
1094
  return { state: "challenge_blocking", message: "ChatGPT is showing a challenge/verification page" };
1092
1095
  }
1093
1096
 
1094
- const outagePatterns = [
1095
- /something went wrong/i,
1096
- /a network error occurred/i,
1097
- /an error occurred while connecting to the websocket/i,
1098
- /try again later/i,
1099
- /rate limit/i,
1100
- ];
1101
- if (outagePatterns.some((pattern) => pattern.test(text))) {
1102
- return { state: "transient_outage_error", message: "ChatGPT is showing a transient outage/error page" };
1097
+ const outageText = detectProviderTransientErrorText(text);
1098
+ if (outageText) {
1099
+ return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/rate-limit page: ${outageText}` };
1103
1100
  }
1104
1101
 
1105
1102
  const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
@@ -1162,8 +1159,9 @@ function classifyGrokPage({ url, snapshot, body }) {
1162
1159
  if (/captcha|cloudflare|verify you are human|unusual activity|suspicious activity/i.test(text)) {
1163
1160
  return { state: "challenge_blocking", message: "Grok is showing a challenge/verification page" };
1164
1161
  }
1165
- if (/something went wrong|network error|try again later|rate limit/i.test(text)) {
1166
- return { state: "transient_outage_error", message: "Grok is showing a transient outage/error page" };
1162
+ const outageText = detectProviderTransientErrorText(text);
1163
+ if (outageText) {
1164
+ return { state: "transient_outage_error", message: `Grok is showing a transient outage/rate-limit page: ${outageText}` };
1167
1165
  }
1168
1166
  const onGrokOrigin = typeof url === "string" && url.startsWith("https://grok.com");
1169
1167
  if (onGrokOrigin && hasGrokLoginCta(text)) {
@@ -1250,6 +1248,42 @@ function detectUploadErrorText(text) {
1250
1248
  return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
1251
1249
  }
1252
1250
 
1251
+ function detectProviderTransientErrorText(text) {
1252
+ const patterns = [
1253
+ "Too many requests",
1254
+ "rate limit",
1255
+ "try again later",
1256
+ "Something went wrong",
1257
+ "A network error occurred",
1258
+ "An error occurred while connecting to the websocket",
1259
+ ];
1260
+ return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
1261
+ }
1262
+
1263
+ function detectProviderVisibleBlockerText(text) {
1264
+ const patterns = [
1265
+ "Too many requests",
1266
+ "rate limit",
1267
+ ];
1268
+ return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
1269
+ }
1270
+
1271
+ function formatProviderTransientErrorMessage(job, errorText, context) {
1272
+ const providerLabel = isGrokJob(job) ? "Grok" : "ChatGPT";
1273
+ return `${providerLabel} is showing a transient outage/rate-limit page${context ? ` while ${context}` : ""}: ${errorText}`;
1274
+ }
1275
+
1276
+ function providerTransientErrorMessage(job, text, context) {
1277
+ const errorText = detectProviderVisibleBlockerText(text);
1278
+ if (!errorText) return "";
1279
+ return formatProviderTransientErrorMessage(job, errorText, context);
1280
+ }
1281
+
1282
+ function throwIfProviderTransientError(job, text, context) {
1283
+ const message = providerTransientErrorMessage(job, text, context);
1284
+ if (message) throw new Error(message);
1285
+ }
1286
+
1253
1287
  function detectResponseFailureText(text) {
1254
1288
  const patterns = [
1255
1289
  "Message delivery timed out",
@@ -1289,6 +1323,7 @@ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
1289
1323
  while (Date.now() < timeoutAt) {
1290
1324
  await heartbeat();
1291
1325
  const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
1326
+ throwIfProviderTransientError(job, snapshot, "uploading the archive");
1292
1327
 
1293
1328
  const errorText = detectUploadErrorText(`${snapshot}\n${body}`);
1294
1329
  if (errorText) {
@@ -1323,6 +1358,7 @@ async function waitForSendReady(job) {
1323
1358
  await heartbeat();
1324
1359
  const snapshot = await snapshotText(job);
1325
1360
  const body = await pageText(job).catch(() => "");
1361
+ throwIfProviderTransientError(job, snapshot, "waiting for send readiness");
1326
1362
  const errorText = detectUploadErrorText(`${snapshot}\n${body}`);
1327
1363
  if (errorText) {
1328
1364
  throw new Error(`Upload error detected: ${errorText}`);
@@ -1366,6 +1402,7 @@ async function sendAcceptanceState(job, baselineAssistantCount) {
1366
1402
  urlKnown: urlResult.ok,
1367
1403
  assistantCount: Math.max(baselineAssistantCount, messages.length),
1368
1404
  stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming"),
1405
+ transientErrorText: detectProviderVisibleBlockerText(snapshot) || "",
1369
1406
  };
1370
1407
  }
1371
1408
 
@@ -1386,6 +1423,7 @@ async function waitForSendAccepted(job, beforeSend, options = {}) {
1386
1423
  while (Date.now() < timeoutAt) {
1387
1424
  await heartbeat();
1388
1425
  const afterSend = await sendAcceptanceState(job, beforeSend.assistantCount || 0);
1426
+ if (afterSend.transientErrorText) throw new Error(formatProviderTransientErrorMessage(job, afterSend.transientErrorText, "waiting for send acceptance"));
1389
1427
  if (providerSendAccepted(beforeSend, afterSend)) return true;
1390
1428
  await sleep(500);
1391
1429
  }
@@ -1420,12 +1458,13 @@ async function dismissProFeedbackModal(job, snapshot) {
1420
1458
  }
1421
1459
 
1422
1460
  async function openModelConfiguration(job) {
1423
- const timeoutAt = Date.now() + 15_000;
1461
+ const timeoutAt = Date.now() + MODEL_CONFIGURATION_OPEN_TIMEOUT_MS;
1424
1462
  let lastSnapshot = "";
1425
1463
 
1426
1464
  while (Date.now() < timeoutAt) {
1427
1465
  const initialSnapshot = await snapshotText(job);
1428
1466
  lastSnapshot = initialSnapshot;
1467
+ throwIfProviderTransientError(job, initialSnapshot, "opening model configuration");
1429
1468
  if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
1430
1469
  if (await dismissProFeedbackModal(job, initialSnapshot)) continue;
1431
1470
 
@@ -1438,6 +1477,7 @@ async function openModelConfiguration(job) {
1438
1477
  await agentBrowser(job, "wait", "800");
1439
1478
  const after = await snapshotText(job);
1440
1479
  lastSnapshot = after;
1480
+ throwIfProviderTransientError(job, after, "opening model configuration");
1441
1481
  if (snapshotHasModelConfigurationUi(after)) return after;
1442
1482
  if (canUseOpenModelMenuForSelection(after, job.selection)) return after;
1443
1483
 
@@ -1451,6 +1491,7 @@ async function openModelConfiguration(job) {
1451
1491
  await agentBrowser(job, "wait", "1200");
1452
1492
  const postConfigure = await snapshotText(job);
1453
1493
  lastSnapshot = postConfigure;
1494
+ throwIfProviderTransientError(job, postConfigure, "opening model configuration");
1454
1495
  if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
1455
1496
  if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
1456
1497
  }
@@ -1544,22 +1585,28 @@ async function configureModel(job) {
1544
1585
  throw new Error(`Could not find model family control for ${job.selection.modelFamily}`);
1545
1586
  }
1546
1587
 
1588
+ let compactSelectionVerifiedAfterClick = false;
1547
1589
  if (!alreadyConfiguredInUi && !familyAlreadySelectedInUi && familyEntry) {
1590
+ const clickedCompactControl = matchesCompactIntelligenceControlLabel(familyEntry.label);
1548
1591
  await clickRef(job, familyEntry.ref);
1549
1592
  await agentBrowser(job, "wait", "800");
1550
1593
  familySnapshot = await snapshotText(job);
1551
1594
  verificationSnapshot = familySnapshot;
1595
+ compactSelectionVerifiedAfterClick = clickedCompactControl && snapshotHasClosedCompactSelection(familySnapshot, job.selection);
1596
+ if (compactSelectionVerifiedAfterClick) {
1597
+ await log(`Verified compact ChatGPT selection after menu close for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1598
+ }
1552
1599
  const postClickControlOptions = {
1553
1600
  ignoreCompactTierButtons: snapshotHasCompactIntelligenceMenuControls(familySnapshot),
1554
1601
  ignoreCompactOnlyButtons: snapshotHasLegacyEffortCombobox(familySnapshot),
1555
1602
  };
1556
1603
  familyEntry = findEntry(familySnapshot, (candidate) => matchesRequestedModelControl(candidate, job.selection, postClickControlOptions));
1557
- if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1604
+ if (!compactSelectionVerifiedAfterClick && !familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1558
1605
  throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
1559
1606
  }
1560
1607
  }
1561
1608
 
1562
- if (job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") {
1609
+ if ((job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") && !compactSelectionVerifiedAfterClick) {
1563
1610
  const effortLabel = requestedEffortLabel(job.selection);
1564
1611
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1565
1612
  const opened = await openEffortDropdown(job);
@@ -1589,7 +1636,8 @@ async function configureModel(job) {
1589
1636
  if (job.selection.modelFamily === "instant") {
1590
1637
  const desiredAutoSwitchState = job.selection.autoSwitchToThinking === true;
1591
1638
  const currentAutoSwitchState = autoSwitchToThinkingSelectionVisible(familySnapshot);
1592
- const compactInstantAlreadyVerified = desiredAutoSwitchState && currentAutoSwitchState === undefined && snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1639
+ const compactInstantAlreadyVerified = compactSelectionVerifiedAfterClick
1640
+ || (desiredAutoSwitchState && currentAutoSwitchState === undefined && snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection));
1593
1641
  if (!compactInstantAlreadyVerified && currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1594
1642
  await clickAutoSwitchToThinkingControl(job);
1595
1643
  await agentBrowser(job, "wait", "400");
@@ -1598,7 +1646,7 @@ async function configureModel(job) {
1598
1646
  }
1599
1647
  }
1600
1648
 
1601
- const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job.selection);
1649
+ const stronglyVerified = compactSelectionVerifiedAfterClick || snapshotStronglyMatchesRequestedModel(verificationSnapshot, job.selection);
1602
1650
  if (!stronglyVerified) {
1603
1651
  throw new Error(`Could not verify requested model settings in configuration UI for ${job.selection.modelFamily}`);
1604
1652
  }
@@ -1793,6 +1841,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1793
1841
  const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
1794
1842
  const hasRetryButton = snapshot.includes('button "Retry"');
1795
1843
  const copyResponseCount = isGrokJob(job) ? (snapshot.match(/button "Copy"/g) || []).length : (snapshot.match(/Copy response/g) || []).length;
1844
+ throwIfProviderTransientError(job, snapshot, "waiting for response completion");
1796
1845
  const responseFailureText = detectResponseFailureText(`${snapshot}\n${body}`);
1797
1846
  const messages = await assistantMessages(job);
1798
1847
  const targetMessage = messages[baselineAssistantCount];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@
36
36
  "platform-smoke.config.mjs",
37
37
  "scripts/platform-smoke.mjs",
38
38
  "scripts/platform-smoke",
39
- "scripts/oracle-real-smoke.mjs"
39
+ "scripts/oracle-real-smoke.mjs",
40
+ "scripts/oracle-chatgpt-preset-proof.mjs"
40
41
  ],
41
42
  "pi": {
42
43
  "extensions": [
@@ -49,7 +50,7 @@
49
50
  "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
50
51
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
51
52
  "pack:check": "npm pack --dry-run",
52
- "verify:oracle": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
53
+ "verify:oracle": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
53
54
  "test": "npm run verify:oracle",
54
55
  "prepublishOnly": "npm run release:check",
55
56
  "check:platform-smoke": "node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/assertions.mjs && node --check scripts/platform-smoke/artifacts.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/targets.mjs && node scripts/platform-smoke/invariants.mjs",
@@ -61,12 +62,14 @@
61
62
  "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
62
63
  "smoke:real": "npm run smoke:real:packed",
63
64
  "smoke:real:doctor": "node scripts/oracle-real-smoke.mjs doctor",
64
- "release:check": "npm run verify:oracle && npm run smoke:platform:all",
65
+ "release:check": "npm run verify:oracle && npm run release:proof:chatgpt-presets && npm run smoke:platform:all",
65
66
  "check:oracle-real-smoke": "node --check scripts/oracle-real-smoke.mjs",
67
+ "check:oracle-release-proof": "node --check scripts/oracle-chatgpt-preset-proof.mjs",
68
+ "release:proof:chatgpt-presets": "node scripts/oracle-chatgpt-preset-proof.mjs check",
66
69
  "smoke:real:packed": "node scripts/oracle-real-smoke.mjs run --mode packed",
67
70
  "smoke:real:source": "node scripts/oracle-real-smoke.mjs run --mode source",
68
71
  "sanity:oracle:platform": "node scripts/oracle-sanity-runner.mjs --mode platform",
69
- "verify:oracle:platform": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run sanity:oracle:platform && npm run pack:check"
72
+ "verify:oracle:platform": "npm run check:oracle-extension && npm run check:platform-smoke && npm run check:oracle-real-smoke && npm run check:oracle-release-proof && npm run sanity:oracle:platform && npm run pack:check"
70
73
  },
71
74
  "dependencies": {
72
75
  "@steipete/sweet-cookie": "^0.3.0"
@@ -23,7 +23,7 @@ export default {
23
23
  commands: ["npm run smoke:platform:all"],
24
24
  },
25
25
  release: {
26
- description: "Full release gate: local verification plus the doctor-first platform matrix.",
26
+ description: "Full release gate: local verification, fresh ChatGPT preset proof, plus the doctor-first platform matrix.",
27
27
  commands: ["npm run release:check"],
28
28
  },
29
29
  },
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ // Purpose: Release-blocking proof gate for live ChatGPT preset selection.
3
+ // Responsibilities: Validate that a fresh manual/live oracle job matrix covered every canonical ChatGPT preset before publish.
4
+ // Scope: Maintainer release safety only; the script does not submit jobs or touch provider accounts.
5
+ // Usage: npm run release:proof:chatgpt-presets, or `node scripts/oracle-chatgpt-preset-proof.mjs template`.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
13
+ const REPO_ROOT = resolve(SCRIPT_DIR, "..");
14
+ const DEFAULT_PROOF_PATH = ".artifacts/chatgpt-preset-proof/latest.json";
15
+ const PROOF_PATH_ENV = "PI_ORACLE_CHATGPT_PRESET_PROOF";
16
+ const JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
17
+ const MAX_AGE_HOURS_ENV = "PI_ORACLE_CHATGPT_PRESET_PROOF_MAX_AGE_HOURS";
18
+ const DEFAULT_MAX_AGE_HOURS = 72;
19
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
21
+
22
+ function usage() {
23
+ console.log(`Usage: node scripts/oracle-chatgpt-preset-proof.mjs <check|template>
24
+
25
+ Commands:
26
+ check Validate release-blocking live ChatGPT preset proof. Default.
27
+ template Print a non-valid proof-file template for the current package version/git head.
28
+
29
+ Environment:
30
+ ${PROOF_PATH_ENV} Proof JSON path (default: ${DEFAULT_PROOF_PATH})
31
+ ${JOBS_DIR_ENV} Oracle jobs root for job lookup (default also checks /tmp)
32
+ ${MAX_AGE_HOURS_ENV} Freshness window in hours (default: ${DEFAULT_MAX_AGE_HOURS})
33
+
34
+ Proof file contract:
35
+ The proof must reference live oracle job state produced by the loaded extension
36
+ after the current git HEAD. It must include one completed ChatGPT job per
37
+ canonical ORACLE_SUBMIT_PRESETS id. Shape-only proof is rejected.
38
+ `);
39
+ }
40
+
41
+ function fail(message) {
42
+ console.error(message);
43
+ process.exitCode = 1;
44
+ }
45
+
46
+ function readJson(path) {
47
+ return JSON.parse(readFileSync(path, "utf8"));
48
+ }
49
+
50
+ function git(args) {
51
+ return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" }).trim();
52
+ }
53
+
54
+ function packageMetadata() {
55
+ const pkg = readJson(resolve(REPO_ROOT, "package.json"));
56
+ return { name: pkg.name, version: pkg.version };
57
+ }
58
+
59
+ function currentGitHead() {
60
+ return git(["rev-parse", "HEAD"]);
61
+ }
62
+
63
+ function currentGitHeadCommittedAt() {
64
+ return git(["show", "-s", "--format=%cI", "HEAD"]);
65
+ }
66
+
67
+ function currentGitStatus() {
68
+ return git(["status", "--short"]);
69
+ }
70
+
71
+ function canonicalPresets() {
72
+ const configSource = readFileSync(resolve(REPO_ROOT, "extensions/oracle/lib/config.ts"), "utf8");
73
+ const registryMatch = configSource.match(/export const ORACLE_SUBMIT_PRESETS = \{([\s\S]*?)\n\} as const;/);
74
+ if (!registryMatch) throw new Error("Could not locate ORACLE_SUBMIT_PRESETS registry in extensions/oracle/lib/config.ts");
75
+ const entries = [...registryMatch[1].matchAll(
76
+ /^\s{2}([a-z0-9_]+):\s*\{\s*label:\s*"[^"]+",\s*modelFamily:\s*"([a-z]+)"\s+as const(?:,\s*effort:\s*"([a-z]+)"\s+as const)?,\s*autoSwitchToThinking:\s*(true|false)\s*\}/gm,
77
+ )];
78
+ if (entries.length === 0) throw new Error("Could not parse ORACLE_SUBMIT_PRESETS registry entries");
79
+ return Object.fromEntries(entries.map((match) => [match[1], {
80
+ modelFamily: match[2],
81
+ effort: match[3],
82
+ autoSwitchToThinking: match[4] === "true",
83
+ }]));
84
+ }
85
+
86
+ function canonicalPresetIds() {
87
+ return Object.keys(canonicalPresets());
88
+ }
89
+
90
+ function proofPath() {
91
+ return resolve(REPO_ROOT, process.env[PROOF_PATH_ENV] || DEFAULT_PROOF_PATH);
92
+ }
93
+
94
+ function maxAgeHours() {
95
+ const raw = process.env[MAX_AGE_HOURS_ENV];
96
+ if (!raw) return DEFAULT_MAX_AGE_HOURS;
97
+ const parsed = Number(raw);
98
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${MAX_AGE_HOURS_ENV} must be a positive number of hours`);
99
+ return parsed;
100
+ }
101
+
102
+ function isIsoDate(value) {
103
+ if (typeof value !== "string" || !value.trim()) return false;
104
+ const millis = Date.parse(value);
105
+ return Number.isFinite(millis) && new Date(millis).toISOString() === value;
106
+ }
107
+
108
+ function parseIsoMillis(value) {
109
+ return isIsoDate(value) ? Date.parse(value) : undefined;
110
+ }
111
+
112
+ function unique(values) {
113
+ return [...new Set(values.filter(Boolean))];
114
+ }
115
+
116
+ function candidateJobJsonPaths(jobId, proofJob) {
117
+ const paths = [];
118
+ if (typeof proofJob.jobJsonPath === "string" && proofJob.jobJsonPath.trim()) {
119
+ paths.push(resolve(REPO_ROOT, proofJob.jobJsonPath));
120
+ }
121
+ if (typeof proofJob.jobDir === "string" && proofJob.jobDir.trim()) {
122
+ paths.push(resolve(REPO_ROOT, proofJob.jobDir, "job.json"));
123
+ }
124
+ if (process.env[JOBS_DIR_ENV]) {
125
+ paths.push(resolve(process.env[JOBS_DIR_ENV], `oracle-${jobId}`, "job.json"));
126
+ }
127
+ paths.push(resolve("/tmp", `oracle-${jobId}`, "job.json"));
128
+ return unique(paths);
129
+ }
130
+
131
+ function loadOracleJobState(jobId, proofJob) {
132
+ const candidates = candidateJobJsonPaths(jobId, proofJob);
133
+ for (const candidate of candidates) {
134
+ if (!existsSync(candidate)) continue;
135
+ return { path: candidate, state: readJson(candidate) };
136
+ }
137
+ return { path: undefined, state: undefined, candidates };
138
+ }
139
+
140
+ function requireActualJobEvidence({ preset, canonicalPreset, proofJob, packageName, packageVersion, gitHead, gitHeadCommittedAtMs, proofValidatedAtMs, errors }) {
141
+ if (!proofJob || typeof proofJob !== "object" || Array.isArray(proofJob)) {
142
+ errors.push(`missing jobs.${preset}`);
143
+ return;
144
+ }
145
+ if (proofJob.preset !== preset) errors.push(`jobs.${preset}.preset must be ${preset}`);
146
+ if (proofJob.provider !== "chatgpt") errors.push(`jobs.${preset}.provider must be chatgpt`);
147
+
148
+ const jobId = proofJob.jobId;
149
+ if (typeof jobId !== "string" || !UUID_PATTERN.test(jobId) || jobId === ZERO_UUID) {
150
+ errors.push(`jobs.${preset}.jobId must be a real oracle UUID job id, not a placeholder`);
151
+ return;
152
+ }
153
+
154
+ const loaded = loadOracleJobState(jobId, proofJob);
155
+ if (!loaded.state) {
156
+ errors.push(`jobs.${preset} could not find actual oracle job.json for ${jobId}; checked ${loaded.candidates.join(", ")}`);
157
+ return;
158
+ }
159
+
160
+ const state = loaded.state;
161
+ const responsePath = typeof state.responsePath === "string" ? state.responsePath : undefined;
162
+ const workerLogPath = typeof state.workerLogPath === "string" ? state.workerLogPath : undefined;
163
+ const response = responsePath && existsSync(responsePath) ? readFileSync(responsePath, "utf8") : "";
164
+ const workerLog = workerLogPath && existsSync(workerLogPath) ? readFileSync(workerLogPath, "utf8") : "";
165
+ const completedAtMs = parseIsoMillis(state.completedAt || state.phaseAt);
166
+
167
+ if (state.id !== jobId) errors.push(`jobs.${preset} job.json id mismatch: expected ${jobId}, got ${state.id || "<missing>"}`);
168
+ if (state.status !== "complete") errors.push(`jobs.${preset} actual job status must be complete, got ${state.status || "<missing>"}`);
169
+ if (state.phase !== "complete") errors.push(`jobs.${preset} actual job phase must be complete, got ${state.phase || "<missing>"}`);
170
+ if (state.selection?.provider !== "chatgpt") errors.push(`jobs.${preset} actual provider must be chatgpt`);
171
+ if (state.selection?.preset !== preset) errors.push(`jobs.${preset} actual preset must be ${preset}, got ${state.selection?.preset || "<missing>"}`);
172
+ if (state.selection?.modelFamily !== canonicalPreset.modelFamily) errors.push(`jobs.${preset} actual modelFamily must be ${canonicalPreset.modelFamily}, got ${state.selection?.modelFamily || "<missing>"}`);
173
+ if ((state.selection?.effort || undefined) !== canonicalPreset.effort) errors.push(`jobs.${preset} actual effort must be ${canonicalPreset.effort || "<unset>"}, got ${state.selection?.effort || "<unset>"}`);
174
+ if (state.selection?.autoSwitchToThinking !== canonicalPreset.autoSwitchToThinking) errors.push(`jobs.${preset} actual autoSwitchToThinking must be ${canonicalPreset.autoSwitchToThinking}`);
175
+ if (state.cwd !== REPO_ROOT) errors.push(`jobs.${preset} actual cwd must be this repo (${REPO_ROOT}), got ${state.cwd || "<missing>"}`);
176
+ if (state.projectId !== REPO_ROOT) errors.push(`jobs.${preset} actual projectId must be this repo (${REPO_ROOT}), got ${state.projectId || "<missing>"}`);
177
+ if (state.requestSource !== "tool" && state.requestSource !== "command") errors.push(`jobs.${preset} actual requestSource must be tool or command`);
178
+ if (typeof state.sessionId !== "string" || !state.sessionId.trim()) errors.push(`jobs.${preset} actual job must record sessionId`);
179
+ if (typeof state.originSessionFile !== "string" || !existsSync(state.originSessionFile)) errors.push(`jobs.${preset} actual originSessionFile must exist`);
180
+ if (typeof state.promptPath !== "string" || !existsSync(state.promptPath)) errors.push(`jobs.${preset} actual promptPath must exist`);
181
+ if (typeof state.logsDir !== "string" || !existsSync(state.logsDir)) errors.push(`jobs.${preset} actual logsDir must exist`);
182
+ if (typeof state.runtimeId !== "string" || !state.runtimeId.trim()) errors.push(`jobs.${preset} actual job must record runtimeId`);
183
+ if (typeof state.runtimeSessionName !== "string" || !state.runtimeSessionName.trim()) errors.push(`jobs.${preset} actual job must record runtimeSessionName`);
184
+ if (!state.config?.browser || !state.config?.worker || !state.config?.cleanup) errors.push(`jobs.${preset} actual job must include persisted oracle config with browser, worker, and cleanup sections`);
185
+ const lifecycleKinds = new Set(Array.isArray(state.lifecycleEvents) ? state.lifecycleEvents.map((event) => event?.kind) : []);
186
+ const lifecyclePhases = new Set(Array.isArray(state.lifecycleEvents) ? state.lifecycleEvents.map((event) => event?.phase) : []);
187
+ if (!lifecycleKinds.has("created")) errors.push(`jobs.${preset} lifecycle events must include job creation`);
188
+ if (!lifecyclePhases.has("configuring_model")) errors.push(`jobs.${preset} lifecycle events must include configuring_model phase`);
189
+ if (!lifecyclePhases.has("complete")) errors.push(`jobs.${preset} lifecycle events must include complete phase`);
190
+ if (state.extensionProvenance?.schemaVersion !== 1) errors.push(`jobs.${preset} actual job must record extensionProvenance.schemaVersion=1`);
191
+ if (state.extensionProvenance?.packageName !== packageName) errors.push(`jobs.${preset} actual extension packageName must be ${packageName}`);
192
+ if (state.extensionProvenance?.packageVersion !== packageVersion) errors.push(`jobs.${preset} actual extension packageVersion must be ${packageVersion}`);
193
+ if (state.extensionProvenance?.gitHead !== gitHead) errors.push(`jobs.${preset} actual extension gitHead must be ${gitHead}`);
194
+ if (state.extensionProvenance?.sourcePath !== REPO_ROOT) errors.push(`jobs.${preset} actual extension sourcePath must be this repo (${REPO_ROOT}), got ${state.extensionProvenance?.sourcePath || "<missing>"}`);
195
+ if (typeof state.archivePath !== "string" || !state.archivePath.endsWith(".tar.zst")) errors.push(`jobs.${preset} actual archivePath must end with .tar.zst`);
196
+ if (typeof state.archiveSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(state.archiveSha256)) errors.push(`jobs.${preset} actual job must record archiveSha256`);
197
+ if (typeof state.conversationId !== "string" || !state.conversationId.trim()) errors.push(`jobs.${preset} actual job must record conversationId`);
198
+ if (typeof state.chatUrl !== "string" || !state.chatUrl.startsWith("https://chatgpt.com/c/")) errors.push(`jobs.${preset} actual job must record a ChatGPT conversation URL`);
199
+ if (!responsePath || !existsSync(responsePath)) errors.push(`jobs.${preset} actual responsePath must exist`);
200
+ if (!workerLogPath || !existsSync(workerLogPath)) errors.push(`jobs.${preset} actual workerLogPath must exist`);
201
+ if (!response.includes(`PRESET ${preset} OK`)) errors.push(`jobs.${preset} actual response must include PRESET ${preset} OK`);
202
+ if (!response.includes(`PACKAGE ${packageName}`)) errors.push(`jobs.${preset} actual response must include PACKAGE ${packageName}`);
203
+ if (!workerLog.includes(`Configuring model family=${state.selection?.modelFamily}`) && !workerLog.includes("Model already appears configured")) {
204
+ errors.push(`jobs.${preset} worker log must show model configuration or an explicit already-configured skip`);
205
+ }
206
+ if (!workerLog.includes("Job completed successfully") && !workerLog.includes(`Job ${jobId} complete`)) errors.push(`jobs.${preset} worker log must show successful completion`);
207
+
208
+ if (completedAtMs === undefined) {
209
+ errors.push(`jobs.${preset} actual completedAt/phaseAt must be an ISO timestamp`);
210
+ } else {
211
+ if (completedAtMs <= gitHeadCommittedAtMs) errors.push(`jobs.${preset} must complete after current git HEAD commit time`);
212
+ if (proofValidatedAtMs !== undefined && completedAtMs > proofValidatedAtMs) errors.push(`jobs.${preset} completed after proof validatedAt`);
213
+ const maxAgeMs = maxAgeHours() * 60 * 60 * 1000;
214
+ if (Date.now() - completedAtMs > maxAgeMs) errors.push(`jobs.${preset} completedAt is older than ${maxAgeHours()} hours`);
215
+ }
216
+
217
+ if (typeof proofJob.conversation === "string" && proofJob.conversation.trim() && proofJob.conversation !== state.conversationId && proofJob.conversation !== state.chatUrl) {
218
+ errors.push(`jobs.${preset}.conversation does not match actual conversationId/chatUrl`);
219
+ }
220
+ }
221
+
222
+ function validateProof(proof, path) {
223
+ const errors = [];
224
+ const { name, version } = packageMetadata();
225
+ const gitHead = currentGitHead();
226
+ const gitHeadCommittedAt = currentGitHeadCommittedAt();
227
+ const gitHeadCommittedAtMs = Date.parse(gitHeadCommittedAt);
228
+ const gitStatus = currentGitStatus();
229
+ const presetRegistry = canonicalPresets();
230
+ const requiredPresets = Object.keys(presetRegistry);
231
+ const allowedPresets = new Set(requiredPresets);
232
+
233
+ if (gitStatus) {
234
+ errors.push(`working tree must be clean before release proof is accepted; current changes:\n${gitStatus}`);
235
+ }
236
+
237
+ if (!proof || typeof proof !== "object" || Array.isArray(proof)) {
238
+ errors.push("proof root must be a JSON object");
239
+ return errors;
240
+ }
241
+
242
+ if (proof.schemaVersion !== 1) errors.push("schemaVersion must be 1");
243
+ if (proof.packageName !== name) errors.push(`packageName must be ${name}`);
244
+ if (proof.packageVersion !== version) errors.push(`packageVersion must match package.json version ${version}`);
245
+ if (proof.gitHead !== gitHead) errors.push(`gitHead must match current HEAD ${gitHead}`);
246
+ if (proof.provider !== "chatgpt") errors.push('provider must be "chatgpt"');
247
+ if (proof.extensionUnderTest !== "loaded-extension") errors.push('extensionUnderTest must be "loaded-extension"');
248
+
249
+ let proofValidatedAtMs;
250
+ if (!isIsoDate(proof.validatedAt)) {
251
+ errors.push("validatedAt must be an ISO-8601 UTC timestamp from new Date().toISOString()");
252
+ } else {
253
+ proofValidatedAtMs = Date.parse(proof.validatedAt);
254
+ const ageMs = Date.now() - proofValidatedAtMs;
255
+ const maxAgeMs = maxAgeHours() * 60 * 60 * 1000;
256
+ if (ageMs < 0) errors.push("validatedAt must not be in the future");
257
+ if (ageMs > maxAgeMs) errors.push(`validatedAt is older than ${maxAgeHours()} hours`);
258
+ if (proofValidatedAtMs <= gitHeadCommittedAtMs) errors.push("validatedAt must be after current git HEAD commit time");
259
+ }
260
+
261
+ const jobs = proof.jobs;
262
+ if (!jobs || typeof jobs !== "object" || Array.isArray(jobs)) {
263
+ errors.push("jobs must be an object keyed by canonical preset id");
264
+ return errors;
265
+ }
266
+
267
+ for (const preset of requiredPresets) {
268
+ requireActualJobEvidence({
269
+ preset,
270
+ canonicalPreset: presetRegistry[preset],
271
+ proofJob: jobs[preset],
272
+ packageName: name,
273
+ packageVersion: version,
274
+ gitHead,
275
+ gitHeadCommittedAtMs,
276
+ proofValidatedAtMs,
277
+ errors,
278
+ });
279
+ }
280
+
281
+ for (const preset of Object.keys(jobs)) {
282
+ if (!allowedPresets.has(preset)) errors.push(`jobs.${preset} is not a canonical ORACLE_SUBMIT_PRESETS id`);
283
+ }
284
+
285
+ if (errors.length === 0) {
286
+ console.log(`ChatGPT preset release proof accepted: ${path}`);
287
+ console.log(`Validated presets: ${requiredPresets.join(", ")}`);
288
+ }
289
+
290
+ return errors;
291
+ }
292
+
293
+ function template() {
294
+ const { name, version } = packageMetadata();
295
+ const gitHead = currentGitHead();
296
+ const jobs = Object.fromEntries(canonicalPresetIds().map((preset) => [preset, {
297
+ preset,
298
+ provider: "chatgpt",
299
+ jobId: `replace-with-completed-${preset}-job-uuid`,
300
+ jobDir: `/tmp/oracle-replace-with-completed-${preset}-job-uuid`,
301
+ conversation: "replace-with-actual-conversation-id-or-chat-url",
302
+ }]));
303
+
304
+ console.log(JSON.stringify({
305
+ schemaVersion: 1,
306
+ packageName: name,
307
+ packageVersion: version,
308
+ gitHead,
309
+ provider: "chatgpt",
310
+ extensionUnderTest: "loaded-extension",
311
+ validatedAt: new Date().toISOString(),
312
+ jobs,
313
+ }, null, 2));
314
+ }
315
+
316
+ function main() {
317
+ const command = process.argv[2] || "check";
318
+ if (command === "--help" || command === "-h") {
319
+ usage();
320
+ return;
321
+ }
322
+ if (command === "template") {
323
+ template();
324
+ return;
325
+ }
326
+ if (command !== "check") {
327
+ usage();
328
+ fail(`Unknown command: ${command}`);
329
+ return;
330
+ }
331
+
332
+ const path = proofPath();
333
+ if (!existsSync(path)) {
334
+ fail(`Missing ChatGPT preset release proof: ${path}\n\nRun live loaded-extension oracle jobs for every canonical ChatGPT preset, then save proof JSON.\nCreate a non-valid starting template with:\n mkdir -p .artifacts/chatgpt-preset-proof\n node scripts/oracle-chatgpt-preset-proof.mjs template > ${DEFAULT_PROOF_PATH}\n\nThis gate is intentional: releases are blocked until every preset has fresh live proof backed by actual oracle job state.`);
335
+ return;
336
+ }
337
+
338
+ let proof;
339
+ try {
340
+ proof = readJson(path);
341
+ } catch (error) {
342
+ fail(`Could not read proof JSON at ${path}: ${error.message}`);
343
+ return;
344
+ }
345
+
346
+ const errors = validateProof(proof, path);
347
+ if (errors.length > 0) {
348
+ fail(`ChatGPT preset release proof rejected: ${path}\n- ${errors.join("\n- ")}`);
349
+ }
350
+ }
351
+
352
+ main();
@@ -92,7 +92,7 @@ function testCanonicalWorkflowConfig() {
92
92
  assert.deepEqual(config.workflows?.release?.commands, ["npm run release:check"], "release workflow should use the full local-plus-platform release gate");
93
93
  assert.equal(config.requiredCrabbox?.minVersion, "0.26.0", "Crabbox baseline should match the documented provider contract");
94
94
  assert.equal(pkg.scripts["smoke:platform:all"], "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native", "full platform smoke should remain doctor-first and cover all required targets");
95
- assert.match(pkg.scripts["release:check"], /npm run verify:oracle && npm run smoke:platform:all/, "release check should combine local verification and full platform smoke");
95
+ assert.match(pkg.scripts["release:check"], /npm run verify:oracle && npm run release:proof:chatgpt-presets && npm run smoke:platform:all/, "release check should combine local verification, ChatGPT preset proof, and full platform smoke");
96
96
  const runnerSource = readFileSync(new URL("./crabbox-runner.mjs", import.meta.url), "utf8");
97
97
  assert.match(runnerSource, /PLATFORM_SMOKE_CRABBOX/, "runner should honor reusable Crabbox binary override");
98
98
  assert.match(runnerSource, /PLATFORM_SMOKE_MAC_WORK_ROOT/, "runner should honor reusable macOS work-root override");