pi-oracle 0.7.12 → 0.7.14

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,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.14 - 2026-06-22
6
+
7
+ ### Changed
8
+ - updated the local pi development and validation baseline to `@earendil-works/*` `0.79.10`
9
+ - refreshed oracle docs and sanity contracts for pi `0.79.10`, and removed the obsolete fleet-tested marker
10
+
11
+ ### Fixed
12
+ - used pi's exported `CONFIG_DIR_NAME` for project config and workspace-root detection instead of hardcoding `.pi`
13
+ - clarified `oracle_preflight` path labels so isolated-session probes distinguish the current persisted session from the provider auth seed profile
14
+ - fixed ChatGPT response completion detection for the current DOM, where assistant text uses `data-message-author-role="assistant"` without legacy `.message-bubble` nodes
15
+
16
+ ### Compatibility
17
+ - reviewed the pi `0.79.10` changelog, extension lifecycle docs/types, compaction event docs, project-trust docs, and package/update docs; no oracle compaction hook changes were required
18
+
19
+ ### Validation
20
+ - ran `npm run verify:oracle`, `npm run smoke:real:packed`, source-mode isolated pi model-agent smoke with the `instant` preset, and `npm run smoke:platform:all`
21
+
22
+ ## 0.7.13 - 2026-06-15
23
+
24
+ ### Added
25
+ - 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
26
+
27
+ ### Fixed
28
+ - 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
29
+ - fixed `instant_auto_switch` under the compact ChatGPT UI, where the legacy auto-switch control is absent after selecting the compact `Instant` tier
30
+ - made ChatGPT model-configuration opening tolerate slower compact-UI hydration before reporting UI drift
31
+ - stabilized archive creation when the compression subprocess exits before tar, so the worker terminates upstream tar immediately instead of waiting for the archive timeout
32
+ - surfaced provider rate-limit/outage modals explicitly during ChatGPT model setup, upload, send, and response waits instead of reporting generic UI drift
33
+
5
34
  ## 0.7.12 - 2026-06-15
6
35
 
7
36
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `pi-oracle` lets a `pi` agent send hard, long-running work to ChatGPT.com or Grok through the web app, with repo archives, background execution, saved results, and a best-effort wake-up back into `pi` when the answer is ready.
4
4
 
5
- > Status: experimental public beta. Validated on macOS, Linux, and Windows native with Chromium-family browsers and pi `0.79.4`. Pi `0.79.4+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases. Normal oracle jobs run in an isolated browser profile, not your active browser window.
5
+ > Status: experimental public beta. Validated on macOS, Linux, and Windows native with Chromium-family browsers and pi `0.79.10`. Pi `0.79.10+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases. Normal oracle jobs run in an isolated browser profile, not your active browser window.
6
6
 
7
7
  ## What a successful run looks like
8
8
 
@@ -77,7 +77,7 @@ You need:
77
77
 
78
78
  - macOS, Linux, or Windows native
79
79
  - Node.js 22 or newer
80
- - Suggested tested floor: `pi` 0.79.4 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
80
+ - Suggested tested floor: `pi` 0.79.10 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
81
81
  - Google Chrome/Chromium or another Chromium-family browser
82
82
  - ChatGPT or Grok already signed in to the configured local browser profile for the provider you plan to use
83
83
  - `agent-browser` and `tar` available on the machine; `zstd` is also required when submitting ChatGPT `.tar.zst` archives
@@ -184,7 +184,7 @@ Agent-facing tools:
184
184
 
185
185
  Most users can start with defaults. Set an agent-level config only when you need a non-default provider, mode, preset, or browser profile.
186
186
 
187
- Pi 0.79.4 gates project-local inputs behind project trust. `pi-oracle` preserves its historical risk-on extension behavior for existing users: project-local `.pi/extensions/oracle.json` safe overrides still load by default for compatibility. They are ignored when you explicitly opt out of project-local inputs with `--no-approve` or save a “do not trust” decision for the project. Privileged browser/auth settings still come only from the agent-level config.
187
+ Pi 0.79+ gates project-local inputs behind project trust. `pi-oracle` preserves its historical risk-on extension behavior for existing users: project-local `.pi/extensions/oracle.json` safe overrides still load by default for compatibility. They are ignored when you explicitly opt out of project-local inputs with `--no-approve` or save a “do not trust” decision for the project. Privileged browser/auth settings still come only from the agent-level config.
188
188
 
189
189
  `~/.pi/agent/extensions/oracle.json`
190
190
 
@@ -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
 
@@ -7,7 +7,7 @@ Companion doc:
7
7
  - `docs/ORACLE_RECOVERY_DRILL.md` — safe expired-auth recovery validation drill
8
8
 
9
9
  Compatibility target:
10
- - `pi` 0.79.4+ is the suggested tested floor for current project-trust-aware package/runtime validation
10
+ - `pi` 0.79.10+ is the suggested tested floor for current project-trust-aware package/runtime validation
11
11
  - package metadata keeps pi runtime packages as optional wildcard peers, so this suggested floor is not enforced as a hard npm install requirement
12
12
  - current extension lifecycle only; no backward-compatibility shims for removed `session_switch` / `session_fork` events
13
13
 
@@ -234,7 +234,7 @@ Merged config locations:
234
234
  - global: `~/.pi/agent/extensions/oracle.json`
235
235
  - project: `.pi/extensions/oracle.json`
236
236
 
237
- Project config remains restricted to safe overrides only. On Pi 0.79.4+, pi itself gates project-local inputs behind project trust, but `pi-oracle` keeps its historical risk-on extension behavior for this package-specific safe override file: `.pi/extensions/oracle.json` loads by default for compatibility, and is ignored when Pi reports the project is untrusted, including `--no-approve` or saved “do not trust” decisions. This preserves the existing extension experience while still honoring explicit opt-out/distrust decisions. Browser/auth settings remain global-only because they control local privileged browser state.
237
+ Project config remains restricted to safe overrides only. On Pi 0.79+, pi itself gates project-local inputs behind project trust, but `pi-oracle` keeps its historical risk-on extension behavior for this package-specific safe override file: `.pi/extensions/oracle.json` loads by default for compatibility, and is ignored when Pi reports the project is untrusted, including `--no-approve` or saved “do not trust” decisions. This preserves the existing extension experience while still honoring explicit opt-out/distrust decisions. Browser/auth settings remain global-only because they control local privileged browser state.
238
238
 
239
239
  ### Current config shape
240
240
 
@@ -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
@@ -639,6 +639,10 @@ Remaining non-blocking hardening work:
639
639
  - keep hardening model-selection verification against future ChatGPT UI variation
640
640
 
641
641
  Recent proof points:
642
+ - Pi 0.79.10 local gate: `npm run verify:oracle` passed on 2026-06-22 after the 0.79.10 baseline refresh and `CONFIG_DIR_NAME` cleanup
643
+ - Pi 0.79.10 isolated extension smokes: `.artifacts/real-smoke/run-1782137209549-0xe67z` passed packed-install proof, and `.artifacts/real-smoke/run-1782137217821-95a1po` passed source model-agent proof
644
+ - Pi 0.79.10 platform artifacts: `.artifacts/platform-smoke/run-1782137574391-7lay68` (macOS platform-build), `.artifacts/platform-smoke/run-1782137619352-gku7jz` (macOS real-extension), `.artifacts/platform-smoke/run-1782137587082-d7kg4p` (Ubuntu platform-build), `.artifacts/platform-smoke/run-1782137619176-lgxezy` (Ubuntu real-extension), `.artifacts/platform-smoke/run-1782137625964-66z0oc` (Windows native platform-build), `.artifacts/platform-smoke/run-1782137752969-pbmdj1` (Windows native real-extension)
645
+ - Pi 0.79.10 isolated agent feedback: `.artifacts/isolated-agent-feedback/run-1782137385` confirmed local extension loading and useful `oracle_preflight` output after the path-label polish
642
646
  - Pi 0.79.1 release gate: `npm run release:check` passed on 2026-06-11 after the project-trust, prompt-history, ChatGPT selector, and send-acceptance updates, including `verify:oracle` plus Crabbox macOS, Ubuntu, and Windows native `platform-build` and `real-extension` suites
643
647
  - Pi 0.79.1 platform artifacts: `.artifacts/platform-smoke/run-1781196218405-311wzs` (macOS platform-build), `.artifacts/platform-smoke/run-1781196261807-eb0391` (macOS real-extension), `.artifacts/platform-smoke/run-1781196230636-ze1hai` (Ubuntu platform-build), `.artifacts/platform-smoke/run-1781196265638-kxiwh9` (Ubuntu real-extension), `.artifacts/platform-smoke/run-1781196255488-ucuf35` (Windows native platform-build), `.artifacts/platform-smoke/run-1781196369098-4qlzjs` (Windows native real-extension)
644
648
  - Pi 0.79.1 live source-extension send-acceptance smoke: new-chat job `4b98776f-d422-4bfb-8a6a-7aef73c31bf6` reached `https://chatgpt.com/c/6a2ac99d-fc5c-83e8-88d7-5e1e8f427499` and completed; same-thread follow-up job `abb4f590-96a1-4aab-b91a-c0a7cc15a162` completed on the unchanged conversation URL after send-acceptance evidence
@@ -653,4 +657,4 @@ Recent proof points:
653
657
  - repo-owned sanity harness: `npm run sanity:oracle`
654
658
  - 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
659
  - 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
660
+ - 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
@@ -27,7 +27,7 @@ The extension is loaded from the local checkout with:
27
27
  pi --approve --no-extensions -e "$REPO/extensions/oracle/index.ts"
28
28
  ```
29
29
 
30
- That ensures the session is exercising the in-repo code, not a globally installed package. `--approve` is intentional for this isolated workflow on Pi 0.79.4+: the test fixture is this trusted checkout, and non-interactive/scripted validation must not block on the project-trust prompt.
30
+ That ensures the session is exercising the in-repo code, not a globally installed package. `--approve` is intentional for this isolated workflow on Pi 0.79+: the test fixture is this trusted checkout, and non-interactive/scripted validation must not block on the project-trust prompt.
31
31
 
32
32
  The local extension now intercepts TUI `/oracle` and `/oracle-followup` before prompt-template expansion, re-injects the compact slash request as the visible user message for prompt-history/up-arrow recall, and reads the in-repo prompt files as hidden dispatch instructions, so do not pass `--prompt-template` for normal local-extension validation. In print/json/rpc modes, the extension contributes the prompt templates itself.
33
33
 
@@ -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
 
@@ -90,7 +91,7 @@ On each required target, `platform-build`:
90
91
  5. runs `npm pack`;
91
92
  6. creates a fresh target-local pi project;
92
93
  7. runs `npm install --no-save <packed tarball>`;
93
- 8. runs `pi install -l ./node_modules/pi-oracle --approve` so Pi 0.79.4 project-trust gating intentionally trusts the temporary fixture;
94
+ 8. runs `pi install -l ./node_modules/pi-oracle --approve` so Pi 0.79+ project-trust gating intentionally trusts the temporary fixture;
94
95
  9. runs `pi list --approve`;
95
96
  10. asserts the installed package came from `node_modules/pi-oracle` and did not use `pi -e` / source-extension shortcuts.
96
97
 
@@ -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
  );
@@ -6,7 +6,7 @@
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
9
+ import { CONFIG_DIR_NAME, getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
10
10
  import { isAbsolute, join, normalize } from "node:path";
11
11
  import {
12
12
  assertNotKnownBrowserUserDataPath,
@@ -377,7 +377,7 @@ export function getOracleConfigLoadDetails(cwd: string, options?: OracleConfigLo
377
377
  const agentDir = getAgentDir();
378
378
  const projectRoot = getProjectId(cwd);
379
379
  const agentConfigPath = join(agentDir, "extensions", "oracle.json");
380
- const projectConfigPath = join(projectRoot, ".pi", "extensions", "oracle.json");
380
+ const projectConfigPath = join(projectRoot, CONFIG_DIR_NAME, "extensions", "oracle.json");
381
381
  const projectConfigExists = existsSync(projectConfigPath);
382
382
  const projectConfigTrusted = isProjectConfigTrusted(projectRoot, agentDir, projectConfigExists, options);
383
383
  const projectConfigLoaded = projectConfigExists && projectConfigTrusted;
@@ -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,
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import { constants as fsConstants, existsSync, realpathSync, readFileSync } from "node:fs";
9
9
  import { access, cp as copyDirectory, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { delimiter, dirname, join } from "node:path";
11
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
11
12
  import { assertNotKnownBrowserUserDataPath, sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
12
13
  import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
13
14
  import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
@@ -42,8 +43,8 @@ function killProcess(child: ReturnType<typeof spawn>): void {
42
43
  child.kill("SIGKILL");
43
44
  }
44
45
  const WORKSPACE_ROOT_MARKERS = [
45
- ".pi/extensions/oracle.json",
46
- ".pi",
46
+ join(CONFIG_DIR_NAME, "extensions", "oracle.json"),
47
+ CONFIG_DIR_NAME,
47
48
  "AGENTS.md",
48
49
  ] as const;
49
50
  function cpCommand(): string {
@@ -677,10 +677,10 @@ function formatOraclePreflightResponse(details: OraclePreflightDetails): string
677
677
  if (details.ready) {
678
678
  return [
679
679
  `Oracle preflight ready for ${providerLabel}.`,
680
- details.session.sessionFile ? `Persisted session: ${details.session.sessionFile}` : undefined,
681
- details.auth.seedProfileDir ? `Auth seed profile: ${details.auth.seedProfileDir}` : undefined,
680
+ details.session.sessionFile ? `Persisted pi session (current run): ${details.session.sessionFile}` : undefined,
681
+ details.auth.seedProfileDir ? `Auth seed profile (${providerLabel} login source): ${details.auth.seedProfileDir}` : undefined,
682
682
  `Preflight validates the persisted pi session, local oracle config, and ${providerLabel} auth seed created by oracle_auth.`,
683
- "You can continue with oracle context gathering and submission.",
683
+ "If you are dispatching an oracle job, continue with context gathering and submission.",
684
684
  ].filter(Boolean).join("\n");
685
685
  }
686
686
 
@@ -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
  }
@@ -1742,12 +1790,15 @@ async function grokAssistantMessages(job) {
1742
1790
  return text;
1743
1791
  };
1744
1792
  const bubbles = Array.from(document.querySelectorAll('.message-bubble'));
1793
+ const roleMessages = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]'));
1745
1794
  const sourceNodes = bubbles.length > 0
1746
1795
  ? bubbles
1747
- : Array.from(document.querySelectorAll('div')).filter((node) => {
1748
- const classText = String(node.className || '');
1749
- return classText.includes('group') && classText.includes('flex') && classText.includes('flex-col') && classText.includes('justify-center');
1750
- });
1796
+ : roleMessages.length > 0
1797
+ ? roleMessages
1798
+ : Array.from(document.querySelectorAll('div')).filter((node) => {
1799
+ const classText = String(node.className || '');
1800
+ return classText.includes('group') && classText.includes('flex') && classText.includes('flex-col') && classText.includes('justify-center');
1801
+ });
1751
1802
  const messages = sourceNodes
1752
1803
  .map((node) => node.closest('[data-message-author-role], [data-testid*="message"], .group') || node)
1753
1804
  .filter((node, index, all) => all.indexOf(node) === index)
@@ -1793,6 +1844,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1793
1844
  const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
1794
1845
  const hasRetryButton = snapshot.includes('button "Retry"');
1795
1846
  const copyResponseCount = isGrokJob(job) ? (snapshot.match(/button "Copy"/g) || []).length : (snapshot.match(/Copy response/g) || []).length;
1847
+ throwIfProviderTransientError(job, snapshot, "waiting for response completion");
1796
1848
  const responseFailureText = detectResponseFailureText(`${snapshot}\n${body}`);
1797
1849
  const messages = await assistantMessages(job);
1798
1850
  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.14",
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"
@@ -80,8 +83,8 @@
80
83
  "protobufjs": "7.6.1"
81
84
  },
82
85
  "devDependencies": {
83
- "@earendil-works/pi-ai": "0.79.4",
84
- "@earendil-works/pi-coding-agent": "0.79.4",
86
+ "@earendil-works/pi-ai": "^0.79.10",
87
+ "@earendil-works/pi-coding-agent": "^0.79.10",
85
88
  "@types/node": "^22.19.19",
86
89
  "esbuild": "^0.28.0",
87
90
  "tsx": "^4.22.3",
@@ -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");