Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions scripts/sync-conditions-core.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
167 changes: 167 additions & 0 deletions src/shared/conditions-core/evaluator.ts
Original file line number Diff line number Diff line change
@@ -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<P extends string, I> = {
// 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<P>
// 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<P>) => 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<void>
}

export function makeEvaluator<P extends string, I>(
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<string, I, any> is the erased
// form the evaluator routes over (it only ever reads `kind` and calls
// `actions(state)`; it never inspects the state's shape), so erasing S to
// `any` here is sound and matches how ConditionOutlet erases the view side.
modules: readonly ConditionModule<string, I, any>[],
now: () => number,
): ConditionEvaluator<P, I> {
// 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<P> {
// 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, ConditionRecord> = {}

for (const module of registry) {
const state = module.detect(inputs)
// `detect` returning null means the condition is not live this tick — skip
// it. (A module never emits a record for a condition that isn't present;
// that's how the map stays "at most one record per live kind".)
//
// NOTE: we compare against `null` specifically, NOT a general falsy check.
// A module's state could legitimately be a falsy-but-present value; the
// contract's `detect: (I) => S | null` says null is the ONLY "not live"
// sentinel, so that's the only thing we skip on.
if (state === null) continue
const actions: ConditionAction[] = module.actions(state)
// Insertion happens HERE, in module order. This is the observable ordering
// the dedupe key depends on. The record shape { kind, state, actions } is
// exactly the legacy CodexCondition shape, so the serialized bytes match.
conditions[module.kind] = { kind: module.kind, state, actions }
}

return {
provider,
conditions,
ts: now(),
}
}

function keyOf(snapshot: ConditionSnapshot<P>): 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<void> {
// First module whose `resolve` exists handles it. We don't try to match by
// kind because a custom action's `name` (not `kind`) identifies its
// resolver, and the dispatch contract narrows to ConditionCustomAction
// already. Returns the first resolver's result (which may be a Promise the
// caller awaits). No live module defines `resolve` yet, so today this is a
// no-op fall-through; it exists so the multi-step answering driver (later PR)
// has a wired path. The first module that defines `resolve` wins — if more
// than one ever does, the registration order decides, matching how the
// detect loop above is order-sensitive.
for (const module of registry) {
if (module.resolve) return module.resolve(action, ctx)
}
}

return { evaluate, keyOf, changed, resolveAction }
}