diff --git a/packages/codex-headless b/packages/codex-headless index 40fa9ca36..9480f5801 160000 --- a/packages/codex-headless +++ b/packages/codex-headless @@ -1 +1 @@ -Subproject commit 40fa9ca36693fc5d123f079570db063335cf246f +Subproject commit 9480f580124b7156c17058ed3e7fc0810d7da16c diff --git a/scripts/sync-conditions-core.mjs b/scripts/sync-conditions-core.mjs new file mode 100644 index 000000000..ef57fd44f --- /dev/null +++ b/scripts/sync-conditions-core.mjs @@ -0,0 +1,119 @@ +// sync-conditions-core.mjs +// +// Vendors the HEADLESS slice of `src/shared/conditions-core` into the headless +// submodules that cannot import the app's `src/shared` directly. +// +// WHY this exists (the submodule import wall) +// ------------------------------------------- +// `packages/codex-headless` and `packages/claude-code-headless` are independent +// git submodules with their OWN tsconfig, their OWN package boundary, and no +// path mapping back into the host app. They physically cannot `import` from +// `../../src/shared/conditions-core` — that path is outside their rootDir and +// belongs to a different package. So the contract + evaluator that MUST stay +// identical between app and headless are copied in, and this script is the +// single source of that copy. Edit `src/shared/conditions-core/*`, then run this +// to re-emit the vendored copies. The generated files carry a header banner so a +// future reader knows not to hand-edit them. +// +// WHY only contract.ts + evaluator.ts (the "headless slice") +// ---------------------------------------------------------- +// conditions-core has two halves. The HEADLESS half (contract.ts: the wire +// types + module contract; evaluator.ts: the detect/dedupe engine) is pure +// TypeScript with zero runtime deps and zero React — it runs inside the headless +// process. The RENDERER half (view.ts, ConditionOutlet.tsx, dispatch.ts) pulls +// in React and app-only concerns and has NO business inside a headless package. +// We vendor ONLY the headless slice. If you add a new headless-side file to +// conditions-core, add it to HEADLESS_SLICE below. +// +// WHY a banner instead of a generated/ folder convention +// ------------------------------------------------------ +// The vendored copies live under each package's `src/conditions/core/` so the +// migrated modules import them with a short relative path (`./core/contract.js`). +// They are real, committed, typechecked source inside the submodule (the +// submodule builds standalone in CI without the host app present), so they can't +// be gitignored. The per-file banner is the guardrail against someone editing +// the copy instead of the source. + +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +// scripts/ sits at the repo root; conditions-core is under src/shared. +const REPO_ROOT = path.resolve(__dirname, '..') +const SOURCE_DIR = path.join(REPO_ROOT, 'src', 'shared', 'conditions-core') + +// The exact files that make up the headless slice. Order is irrelevant (each is +// copied independently), but evaluator.ts imports contract.ts via `./contract.js` +// so both MUST be present together for the vendored copy to typecheck. +const HEADLESS_SLICE = ['contract.ts', 'evaluator.ts'] + +// Banner stamped at the top of every generated file. Kept byte-stable so a +// re-run produces a no-op diff when the source is unchanged. +const BANNER = + '// GENERATED by scripts/sync-conditions-core.mjs — edit src/shared/conditions-core, do not edit here\n' + +// Vendor targets. Each entry is a package whose headless conditions slice gets +// the copy. `sync: false` wires the path for a package WITHOUT copying yet — the +// claude-code-headless migration is a later PR, so its destination is recorded +// here (so the next session reuses this exact script) but skipped in this run. +const TARGETS = [ + { + name: 'codex-headless', + dest: path.join( + REPO_ROOT, + 'packages', + 'codex-headless', + 'src', + 'conditions', + 'core', + ), + sync: true, + }, + { + name: 'claude-code-headless', + // NOTE: this package has no `conditions/` dir yet — its migration (PR-3) + // creates it. The path is declared now so PR-3 flips `sync: true` and reuses + // this script unchanged rather than re-deriving the destination. + dest: path.join( + REPO_ROOT, + 'packages', + 'claude-code-headless', + 'src', + 'conditions', + 'core', + ), + sync: false, + }, +] + +async function syncTarget(target) { + await mkdir(target.dest, { recursive: true }) + for (const file of HEADLESS_SLICE) { + const src = path.join(SOURCE_DIR, file) + const raw = await readFile(src, 'utf8') + // Prepend the banner. The source file's own header comment follows it, so the + // generated file reads: banner, blank line, original WHY comment, code. + const stamped = `${BANNER}\n${raw}` + const out = path.join(target.dest, file) + await writeFile(out, stamped) + console.error(` wrote ${path.relative(REPO_ROOT, out)}`) + } +} + +async function main() { + for (const target of TARGETS) { + if (!target.sync) { + console.error(`skip ${target.name} (sync: false — wired for a later PR)`) + continue + } + console.error(`sync ${target.name}:`) + await syncTarget(target) + } + console.error('done.') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/shared/conditions-core/evaluator.ts b/src/shared/conditions-core/evaluator.ts new file mode 100644 index 000000000..465a9dcbc --- /dev/null +++ b/src/shared/conditions-core/evaluator.ts @@ -0,0 +1,167 @@ +// conditions-core / evaluator.ts +// +// The GENERIC HEADLESS EVALUATOR. This is the headless half's runtime engine: +// given a list of ConditionModules and a per-tick set of `inputs`, it produces +// the unified ConditionSnapshot that goes over the wire, and it owns the +// change-detection (dedupe) that decides whether a snapshot is worth emitting. +// +// WHY this file exists +// -------------------- +// Before this, each provider's headless package re-implemented the SAME loop by +// hand: call each builder, drop the nulls, stuff the survivors into a `conditions` +// map keyed by `kind`, JSON.stringify it to dedupe, then emit. Codex did this in +// `evaluateCodexConditions` + `codexConditionSnapshotKey` + the dedupe latch +// inside `CodexHeadless.publishConditionSnapshot`. Claude will need the exact +// same shape next. Re-deriving identical glue per provider is how subtle drift +// (different key ordering, different dedupe semantics) creeps in. This evaluator +// is that glue, written ONCE, parameterized over the provider tag `P` and the +// per-provider input bundle `I`. +// +// WHY it's pure + clock-injected +// ------------------------------ +// `now` is injected rather than calling `Date.now()` inline so the whole thing +// is deterministic and testable: the PR-2 byte-for-byte verification (a +// throwaway script that pinned `ts` to a fixed value and diffed the OLD and NEW +// serialized snapshots — run out-of-band, NOT committed, per the repo's +// no-committed-tests policy) relied on this, and any future test can do the +// same. The evaluator holds exactly one piece of mutable state — +// `lastKey` for dedupe — and nothing else; everything else is a pure function of +// its arguments. +// +// THE ONE INVARIANT THAT MUST NOT DRIFT: insertion order is observable. +// -------------------------------------------------------------------- +// `keyOf` is `JSON.stringify(snapshot.conditions)`. JSON.stringify serializes a +// plain object's keys in INSERTION ORDER. The dedupe latch compares these +// strings verbatim. Therefore the order in which modules add their records to +// the `conditions` map is part of the wire contract: two snapshots with the same +// records inserted in a different order produce DIFFERENT keys and would be +// treated as a change. This mirrors the legacy Codex behavior exactly (trust +// inserted before approval). The caller's `modules` array order IS that +// insertion order — DO NOT sort, DO NOT reorder. See CODEX_MODULES. + +import type { + ConditionAction, + ConditionCustomAction, + ConditionModule, + ConditionRecord, + ConditionSnapshot, +} from './contract.js' + +// The evaluator the factory returns. Generic over provider tag `P` and the +// per-provider input bundle `I` so a single implementation serves Codex, Claude, +// and anything later. +export type ConditionEvaluator
= { + // Run every module over `inputs` and assemble the snapshot. Pure apart from + // reading the injected clock; does NOT touch the dedupe state (that's `changed`). + evaluate: (inputs: I) => ConditionSnapshot
+ // The dedupe key for a snapshot. IDENTICAL to the legacy + // `codexConditionSnapshotKey`: JSON.stringify of the `conditions` map only + // (NOT `ts` — `ts` changes every tick and would defeat deduping). Insertion + // order is load-bearing; see the file header. + keyOf: (snapshot: ConditionSnapshot
) => string
+ // Change-detection latch. Returns true (and updates the stored key) when `key`
+ // differs from the last key passed in. This is the single mutable cell in the
+ // evaluator; callers gate their emit on it exactly as the old latch did.
+ changed: (key: string) => boolean
+ // Route a `custom` action to the first module that can resolve it. Dormant
+ // today (no live module defines `resolve`), present so the contract is whole.
+ resolveAction: (
+ action: ConditionCustomAction,
+ ctx: unknown,
+ ) => void | Promise (
+ provider: P,
+ // `readonly` + the `any` state param: a module list is heterogeneous — each
+ // module has its own concrete state type S, and there is no single S that
+ // unifies them. The contract's ConditionModule {
+ // DEFENSIVE COPY of the module list. The caller hands us a `modules` array
+ // (e.g. the exported CODEX_MODULES), and that array is a shared, importable
+ // object that anything could push/splice/reorder after we're constructed.
+ // Snapshotting it once here means a running evaluator's behavior — which
+ // modules run, in what order — is frozen at construction and cannot be changed
+ // out from under it by mutating the original array. Order is load-bearing (it
+ // IS the insertion order of the conditions map, see the header invariant), so
+ // a late reorder of the source array would otherwise silently drift the dedupe
+ // key. `[...modules]` is a shallow copy, which is exactly right: we must not
+ // clone the module objects themselves (their identity/closures matter), only
+ // detach our iteration from the caller's array container. Every internal loop
+ // below iterates `registry`, never `modules`.
+ const registry = [...modules]
+
+ // The dedupe cell. Seeded to '{}' so the FIRST evaluation of an empty
+ // conditions map is correctly treated as "no change from the initial empty
+ // snapshot" — matching CodexHeadless, whose `lastConditionKey` starts at '{}'
+ // and whose initial `conditionSnapshot` is `{ conditions: {}, ts }`.
+ let lastKey = '{}'
+
+ function evaluate(inputs: I): ConditionSnapshot {
+ // Plain object, NOT a Map: JSON.stringify(conditions) must serialize in
+ // insertion order for the dedupe key to be stable, and that's exactly what a
+ // plain object gives us. Keys are added in module-array order below.
+ const conditions: Record ): string {
+ // EXACTLY codexConditionSnapshotKey: stringify the conditions map, excluding
+ // `ts`. Do not sort keys — see header invariant.
+ return JSON.stringify(snapshot.conditions)
+ }
+
+ function changed(key: string): boolean {
+ if (key === lastKey) return false
+ lastKey = key
+ return true
+ }
+
+ function resolveAction(
+ action: ConditionCustomAction,
+ ctx: unknown,
+ ): void | Promise