@sema-agent/client-core
v0.41.0
Published
Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, a
Readme
@sema-agent/client-core
Client-side session runtime, shared by every sema human client — TUI, web and desktop.
It turns sema wire frames (AgentEvent) into CC-shaped session state (SDKMessage transcript
messages + ChromeEvent UI events), and it is where the client-side session logic lives, so
that each UI is left with rendering and input handling only.
Renamed from @sema-agent/wire-cc-adapter (0.1.x, deprecated — see Migration below).
Integrating a host? Read
docs/INTEGRATION-CLIENTS.mdfirst — the formal integration contract for the three consumer hosts (TUI / web / desktop): export-surface map, the event-projection contract, the ack-consumption obligations (which receipt keys may be absent and what absence means), capability-gate duties, the multi-sessionsessionKeycontract, the open-gap ledger, and a new-host checklist. Every section carries an implementation anchor so you can audit it against the source — if the doc is wrong, that is a bug in the doc, so report it.That file ships inside the tarball, together with
CHANGELOG.mdanddocs/REFACTOR-LEDGER.md(the two the checklist names as required reading before an upgrade), so the link above resolves for someone who installed the package rather than being rewritten onto a repository they cannot read.scripts/run-integration-doc-freshness-test.mjschecks that against the realnpm packmanifest.
Design axioms
- Every client copies the CC UI/UX — but no CC shape reaches the wire. The CC vocabulary
(
SDKMessage), chrome events and CC-worded copy are collected here; the wire stays free of them. - The wire carries only neutral, universal UI-plane information — structured results, DAG relations, lifecycle discrimination, ownership filtering: what any human client needs, CC-skinned or not.
- The debt this package pays off is an information cut, not a vocabulary problem. In a single-process client (CC) the UI and the engine share memory and UI-plane facts are free; here they cross a wire on which the model was historically the first-class consumer and the human UI second. This package makes that cut an explicit, first-class contract instead of per-bug patches inside one shell.
Scope
Version: 0.41.0
- Today — the adapter seam, the whole
adapt()pipeline (all 14 A-layer arms plus the B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver- downstream projectors), the request-shaping surface for both lanes (interactive and the
headless
-pprint lane), the fleet/panel projections and the subagent wire family, the workflow monitor contract + live source, the model catalog tables, and the HITL family (hitl/: the D-1 approval bridge, the ask-gate stream bridge, the tool-approval wire, plan review, and the pending-approvals push feed).
- downstream projectors), the request-shaping surface for both lanes (interactive and the
headless
- Planned — what is still in the shell: the registry user-plane hand-copies (they retire onto
@sema-agent/sdk/registry, not into this package); plus everything that is genuinely host-specific — Ink rendering and keyboard interaction, the print lane's stream-json projector and stderr triage copy, filesystem persistence, pty/terminal, and the signal-path teardown. The web session view (first target consumer) and the desktop client (second) consume the same package.
Dependencies
- Runtime dependencies:
diff(jsdiff 9 — zero transitive deps, pure JS, browser-safe). It is the T20 client-side hunk computation; core ruled that hunks belong to the client, so keeping it out of this package would mean three re-implementations (TUI / web / desktop). The portability guard pins the runtime dependency set with an equality check, so this list cannot drift.sideEffects: false. - Peers:
@sema-agent/agent-types— the CC session vocabulary (SDKMessageand friends). Type-only: it ships no runtime code at all.@sema-agent/sdk— the wire contract. Value-level, not type-only:AgentClient,SseIdleError,probeHealth,APIErrorandTaskStopConflictErrorare imported as values in five modules, and the browser bundle really bundles the SDK through (the portability guard would exit 3 rather than quietly mark it external).- The declared floor is
>=7.2.0, and it is witnessed: the guard checks that an actually installed SDK at that line still exports every value-level symbol this package imports and still declaresTaskStats.costMicroUsd(the keycostOrNullreads). A floor nobody ever ran is a promise, not a contract.
- The direction is constitutional: sdk (wire) + agent-types (vocabulary) → this package, and neither depends back.
Signed design
- Dual-plane output —
transcript(persistable CC messages) vschrome(ephemeral UI events). Criterion: "what should still be visible after a restart goes to transcript; pure-transient goes to chrome." - Deterministic transcript ids — derived from wire stable keys (frame id > seq >
toolCallId);
ctx.uuid()only for keyless synthetic frames. Invariant (guarded): same stream replayed ⇒ same id sequence. - Lane discipline as a type — chrome events require a
LaneProof; a historical ghost-row bug family is structurally impossible to reintroduce. - Field-level round-trip guard — every semantic wire field either maps into the CC message/chrome event or carries an explicit DROPPED annotation; reflective tests turn upstream field additions into compile-time red.
Shipped so far
0.1.0 shipped the seam types, the id derivation, and the first tranche of battle-tested pure
functions extracted from the sema CLI shell (workflow lane discrimination, workflow poll
envelope/projection, task-notification XML rendering with the <result> discipline, dedup
keys).
0.1.2 landed the first half of the adapt() pipeline:
createWireToCcAdapter()/adapt(frames, ctx)—AsyncIterable<frame>→AsyncGenerator<AdapterOutput>, covering the pure-projection arms: assistant text / thinking / tool_use (engine-subagent param remap,_sema_*sanitising, reject/cancel/error sentinels, per-response grouping id), live delta coalescing,task_notificationXML, diagnostics / steering / workspace-changed attachments, compact boundaries, retry status, workflow completion enqueue.- Host-coupled arms (panel store writes, hook fires, queue drops, task-ledger sync) project
into additive
ChromeEventarms, each carrying its host consumption duty in theseam.tscomment — the side effect stays with the host, the decision logic moves here. - Ledger serialisation (
exportLedger/importLedger/importLedgerFromTranscript) so a restarted host reloads the notification dedup state instead of re-feeding the model (the at-least-once completion-inbox hazard). - A differential guard: the same frame stream is fed to
adapt()and to the CLI'ssdkMessagesToCcEvents, asserting field-level equivalence of the transcript plane, the attachment payloads, the concatenated live deltas, and the observable host-store trajectories — plus the replay id invariant. Arms not yet ported are listed inADAPTER_COVERAGE.todoand pinned per-fixture, so a silently missing arm turns the guard red.
0.2.0 is the repo/name migration: no behaviour change, both guard suites carried over and green (22 + 203 checks).
0.3.0 moved in the dependency-free pure functions and side-channel ledgers (subagent content, engine agent-panel, fleet panel projection, live question store, inline task stats, print tool-result frame, caps cache, tool-label store, task-description projection) with 140 new behaviour checks.
0.4.0 moved in the request-shaping gate family and the notification family:
notifications.tsabsorbed the whole background-completion subsystem — the process-lifetime dedup ledgers (model-notification vs completion-card are separate key spaces), the idle completion watcher for workflow + background-agent runs, the outstanding-run counter with its subscription, the own-run ledger used for frame ownership, the delivery-failure supplement, and thehook_noticeclassifier half (ownership fail-closed mirrored from the server).- 17
*WireCapsprojections (attachments, client context, cloud config, fork, images, mcp, model, permission, prompt profile, retain-background, rewind, self-orchestration, skills, ultracode, web search, agents, plus the wire client factory), the SSE idle triage, the classifier-verdict recogniser, the control router, and the model-budget/catalog/latch/effort rules. - New
hostEnv.ts: the single host-environment read port. The moved projections used to default theirenvparameter toprocess.env, which throws in a browser; they now default tohostEnv()(the very same object under Node,{}where there is noprocess). - 🔴 One host assembly duty:
installNotificationQueuePort(). Without it, task-notification delivery is dropped —notificationQueuePortMisses()must stay 0.
0.5.0 moved in the adapter kernel — the stream driver and the downstream projectors — and grew the host context to carry what a non-Node host must supply:
AdapterContextgained five optional ports (minor, not breaking):log,probe,signal,setTimer/clearTimer,coalesceIntervalMs. Every one is native in a browser (console, omit,AbortController.signal,setTimeout, a number) — no polyfill. Absent means that capability is off, never a fallback: the kernel does not reach forconsole,setTimeoutor a filesystem probe on its own.adapter/runStream.ts+adapter/downstream/*+ the runtime half ofadapter/types.tsmoved in. The driver used to drag 1,571 files / 26 Node builtins / 412 react files into its transitive closure through two dynamic imports back into the shell; both are nowChromeEventarms (last_turn_usage,plan_review_park) with their host duty written on the arm. Measured closure after the cut: 6 files, zero Node builtins, zero react.- Six stream-driver behaviours landed with it: the turn-open triple-clear (the third clear —
stale prompt suggestions — plus the fifth sub-flow cut-out), the 1.5 s IDLE-FLUSH race that
surfaces already-generated content while the model is emitting a long tool argument, manual
iterator cleanup, the abort branch, and the no-
resulttail leg. - The coalescing cadence is now a host knob. CC itself runs two values in two hosts (its desktop host coalesces single-frame deltas at 16 ms; the 2.1.207 CLI line we track uses 100 ms), so the cadence is host render policy while the merge semantics stay here, in one place.
0.6.0 – 0.10.0 (batches B4 – B8) completed the frame dispatch and moved in everything that is not
host-specific. The blow-by-blow lives in the src/index.ts head comment (the source of truth); the
short version:
0.6.0 — A-layer dispatch finished (14th arm:
task_progress, with row binding, the workflow lane gate and the panel sweeps), the request surface unified into one builder with a field matrix, one credential resolver for all construction points, the compile-time verb façade, and package-level host assemblyinstallHost({log,probe,queue,timers,settings,fs,session}).0.7.0 — the tool-result card B/D/E layers, structured-output whitelist takeover of the regex reverse-parsers, client-side diff hunks, and the paired ledgers folded back to module scope.
0.8.0 — fleet ledger/projection + the subagent wire family, the headless detach wire (split: decision here, signal-path dispatch stays in the host), the workflow monitor contract + live source,
hooksWireCapson theSettingsPort,liveInitToolFace, the model catalog tables, andMIGRATED_COMPENSATIONS— the ledger of which shell-side compensation was split where, which chrome arm carries the host's half, and when upstream lets it retire.0.9.0 — the HITL family.
hitl/hitlBridgecarries the D-1 action binding: theboundCallId+boundInputHashtwo-tuple is read off the pending row the human saw and echoed back verbatim — never recomputed — and a 409 binding mismatch is a safety stop that is never auto-retried. Both code paths are pinned byte-for-byte by the pure gate.hitl/toolApprovalWireis a split: the gate decisions and both decide legs live here, the three-choice card itself is anApprovalCardPortthe host installs.hitl/askGateWireruns the park → decide → re-attach loop;hitl/planReviewWirecloses the seventh and lastwireConfig()hand-copy.hitl/approvalsFeedis the one behavioural change in the batch: a pending-approvals push channel overapprovals.stream()that falls back to polling and periodically retries the push leg — it deliberately does not take over the D-1 fetch, which must stay authoritative.0.10.0 — the print lane (
-p), the last batch on the migration bus. The request builder is now the only construction point for both lanes: the shell's headless path used to assemble its ownTaskRequestliteral, so the two lanes had silently drifted apart (REQUEST_FIELD_MATRIXrecords every remaining difference field-by-field with its reason, plus agapflag where the difference is an omission rather than a decision).unregisteredRequestKeysmakes that matrix executable: the request that actually goes on the wire must account for every key it carries.request/printNotificationunifies thetask_notificationfield set — the print exit now shares the interactive lane's single judgement (normalizeTaskNotification) and carries the residual attribution it used to drop (stoppedBy,resumable,partial,exitCode,diagnostics,result,lines,recentSteps,editedFiles,task_type,source,seq,injected), which matters most on the one lane with no human watching. Exactly one lane difference survives, and it is CC's own:status: killedbecomesstoppedon the print exit. The stream-json projector and the stderr triage copy deliberately stay in the shell.
Known intentional deltas from the CLI are enumerated in ADAPTER_DIVERGENCES. The compensation
ledger is a different table (MIGRATED_COMPENSATIONS): the entries there behave identically on
both sides — what it records is why a compensation exists and when it can be deleted.
Guards
npm install
npm test # = node scripts/run-client-core-all-gates.mjs — runs every suite belownpm test is a collecting runner, not a && chain: a suite that exits 3 (SKIP, its material is
absent) does not short-circuit the ones after it, and the runner prints the three-part verdict
itself (FAILED names + skipped names + arithmetic reconciliation). All-SKIP reports exit 3, never 0.
The suite set is a name-equality gate, not a lower bound: the runner cross-checks what it finds
on disk against scripts/gates-manifest.json in both directions. A registered suite that is missing
is red (a guard was deleted or renamed); a suite on disk that is not registered is red (whoever
added it skipped the registration). Adding a guard is therefore three actions in one commit — the
run-*-test.mjs file, its row in gates-manifest.json, and its row in the table below (the
public-surface guard checks that last one).
| Suite | What it guards |
|---|---|
| scripts/run-client-core-pure-test.mjs | Consumer-view behaviour of every moved-in module; segment floors (B1–B8 + C) that only move up |
| scripts/run-client-core-portability-test.mjs | Kernel / A-layer / index import closures, the runtime-dependency equality gate, barrel reachability, and a real esbuild --platform=browser bundle |
| scripts/run-client-core-diff-test.mjs | Differential equivalence against the CLI reference bridge + replay-id invariant + ledger round-trip |
| scripts/run-seat-contract-keys-test.mjs | The seat IPC contract: verb list ↔ SPEC ↔ types, element-wise |
| scripts/run-approval-frame-keys-test.mjs | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal) |
| scripts/run-print-bash-iserror-test.mjs | The print lane's Bash is_error authority (structured over regex) |
| scripts/run-sdk-floor-test.mjs | The SDK version floor — and, more to the point, that the installed type declarations still carry the keys this package reads |
| scripts/run-engine-vocab-floor-test.mjs | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the installed @sema-agent/core |
| scripts/run-limits-env-failloud-test.mjs | SEMA_HEADLESS_* env-lane limits reject invalid values as loudly as the flag lane (no silent "no budget" runs) |
| scripts/run-streamjson-timing-honesty-test.mjs | Stream timing & terminal honesty ([2084]): held errored fs-write results release on model progress; a wall-clock stop maps to error_during_execution with a truthful salvage note; the synthetic API-error assistant row carries the <synthetic> in-message sentinel. Also ([2489], core 5.8.0): the run-limit errorCode -> CC subtype map is pinned code by code (limits.max_{cost,turns,tokens,walltime}_exceeded), token/wall-clock stops keep the text the engine already produced, and the failed event arm shares the one mapping point. The 5.7 dual-vocabulary legs retired with server 6.0.0 (which bundles core 5.8.0); four retirement negative controls stand in their place — the retired status:'timeout' and the retired codes must fall to the honest fallback subtype and must never drop back to an empty success, so putting any of them back turns the gate red |
| scripts/run-background-view-test.mjs | createBackgroundView lifecycle: polling/notify pairing, per-source degrade (501 → not-configured vs unavailable), the capabilities scheduler probe, and dispose really aborting the in-flight fleet snapshot (pure projection lives in the pure suite's W-A segment) |
| scripts/run-fleet-view-keys-test.mjs | The fleet projection views, both directions: FleetTaskView/FleetWorkflowView ⇄ their key lists (compile-pinned) ⇄ what a maximal/minimal row really projects, plus a wire-key coverage ledger (every FleetTaskRow key is either projected or carries a written reason why not) and a drift ledger against the shell's render contract. A one-directional assignability check is blind to optional keys — which is how startedAt was silently dropped |
| scripts/run-usage-verbatim-channel-test.mjs | The two complementary usage disciplines (core 3.0.0 metering semantics): the CC ModelUsage mirror stays pure (five pinned keys, totalInputTokens has no seat), while the sema-owned channel forwards the engine turn_end.usage object verbatim (six keys, incl. totalInputTokens) via last_turn_usage.engineUsage / handle.latestEngineUsage — honest absence on pre-3.0.0 engines, no fabricated zeros |
| scripts/run-plan-review-decide-verify-test.mjs | decidePlanReview's post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is not a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
| scripts/run-shell-gate-durable-allow-test.mjs | #110: the durable approval leg for shell gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame Operation aborted hits the transcript, endedCalls swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; lastEventId must track numeric seq too. Mutation-proven: each of the three fixes reverted turns the gate red |
| scripts/run-hitl-gate-honesty-test.mjs | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the tool_end predicate must cover the same set — the park side admits a first-class kind:'tool_approval' gate for any tool name, and a tool_end frame carries no kind, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and markEnded swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is one-shot: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real approvals.list outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A plan_review card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) HitlSafetyError is a safety signal — the remember fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips mode() to idle once it consistently fails, so the honesty surface stops reporting a dead feed as live |
| scripts/run-notif-fleet-honesty-test.mjs | [2393] the five notification/fleet disciplines a green type-check cannot see, each proven by reverting the fix. (1) The workflow-side dedup return keeps a count and a trace — without it "suppressed by design" and "a real completion swallowed because the runId minting changed" are the same observation. (2) seq normalisation has exactly one mint point, so a 0-based or fractional wire seq cannot make the watcher lane and the frame lane key the same completion differently (which would feed the model twice). (3) The TTL sweep defers to a probe arm that is still inside its own deadline — an entry recorded as "abandoned" must not be delivered a moment later — while an arm that has outlived its deadline never blocks the sweep, so the headless exit gate keeps its liveness. (4) The reset hook really clears every ledger it claims to (the sticky prompt ledger leaked across cases). (5) The fleet ledger counts all three drop paths (malformed / unknown frame type / isolation drop), and the panel projection's settled recycling is anchored on the settle instant and skips still-present rows, so the dedup token is never carried off with the entry (which would re-emit end) |
| scripts/run-public-surface-test.mjs | The outward promises: the npm export surface baseline (an exact set, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
| scripts/run-client-core-message-branching-test.mjs | §B8 (branching on error text) and §B10 (truthiness standing in for existence when the value can be 0). AST + type-checker census over src/, a named ALLOW list carrying owner and expiry, a known-site floor, and two fixed corpora with a known verdict judged by the same classifier on every run |
| scripts/run-client-core-failloud-test.mjs | §C1/§C2: an empty catch with no comment anywhere inside it, a pure-swallow catch nobody reasoned about, and void <write> that really returns a Promise with no .catch. The exemption instrument is a comment saying why this failure may die; the documented-swallow count is a ratchet that only goes down |
| scripts/run-client-core-typeshape-test.mjs | Type discipline as a guard rather than a build side effect: the set of enabled strict knobs (one silently switched off is red), tsc --noEmit, and export-surface ratchets for inline anonymous shapes (≥3 members), unknown leaving the surface, and bare unknown returns — zero slack in either direction |
| scripts/run-client-core-singleton-test.mjs | Module-level singletons ⇄ docs/refactor/p1-scan/singleton-manifest.json, both directions: an unregistered singleton is red (registering it forces someone to answer "what if this got duplicated"), a stale entry is red, and the dupRisk: high count only goes down |
| scripts/run-catalog-loader-gates-test.mjs | The model-catalog candidate chain (loadCatalogWithSources) and the provider device-code seam: offline ⇒ bundled with an honest online.reason, a good source ⇒ online plus a cache write, a second offline run ⇒ cacheHit; the three hostile source shapes (malformed JSON, schemaVersion: 99, off-domain http) each fall through to the bundled table, and an off-allowlist target is never dialled — including a 302 to another host, proven by a real loopback server's hit counter staying at zero; a one-byte edit to catalog.sha256 drops that source while an unavailable sidecar only warns; and the device-code poller's pending → ok / expired arms run against a real loopback HTTP server with an injected clock |
| scripts/run-abortable-sleep-test.mjs | The shared abortableSleep(ms, signal) leaf (consumed by workflowClient.ts and agentSession/backgroundView.ts's poll backoff): normal timeout resolution, immediate wake-up on abort mid-wait, clearTimeout really firing on that path, and a post-resolve late abort staying a no-op |
| scripts/run-durable-card-display-keys-test.mjs | The durable approval row's two display keys survive the row→card recast in surfaceFsApprovalAndDecide: governanceForced stamps on strict true only (absence is "no evidence", never false), ruleSuggestions passes through the same shape-narrowing reader as the live-frame leg and lands on the read-only card key — plus a standing pin that the durable leg never stamps the redeemable ruleSuggestions card position (the /decide body has no rule slot; offering a "don't ask again" option there would be an affordance nothing can honour) |
| scripts/run-package-hygiene-test.mjs | Everything package.json files ships — dist JS/typings and the Markdown docs — is screened line-by-line against a deny-list of strings that must never appear in a published artefact. The guard first proves each pattern still bites on a constructed sample (a screen that cannot fail is worse than none) and honours a per-pattern allow-list for legitimate product vocabulary, so the verdict is "clean surface", not "quiet grep". |
| scripts/run-integration-doc-freshness-test.mjs | The integration contract (docs/INTEGRATION-CLIENTS.md) and the changelog (CHANGELOG.md) checked against the code, because a document with no guard rots — this one had a whole nest of drift found on it within a day of being written. Five directions, each a claim a machine can actually evaluate. (1) Counting discipline: the version-anchor row for the guard count may no longer carry a hand-copied number at all — it changes every time a guard is added, and writing it down is planting a timer; the export counts that are still hand-copied (the surface total, the test-hook count, the sentence describing the surface's internal composition, the sum of the sixteen domain rows, and the three sub-counts) are each compared against a value derived from public-export-baseline.json, which is the drift a human reviewer caught last time. (2) Coordinates alive: every src/ scripts/ docs/ path the doc quotes must be on disk and tracked by git — on disk is not in the repo, and a doc that points readers at a file living only in its author's working tree sends every clone to nothing. A file landing in the same commit takes a named carve-out that stops applying the moment the file is really tracked (it can no longer let anything through, and the guard prints a line asking for it to be deleted) — deliberately not a red, since turning red on the very commit that lands the file would just manufacture a break that only a follow-up commit could clear. (3) Arm tables: the hitl_out_of_slice row and the not_in_slice fenced list must equal, name for name and in both directions, the case labels that really fall into those two buckets — read through the TypeScript AST, since which bucket an arm lands in is decided by the argument to nothing(...) and by nothing a comment says. The extractor is anchored to the one production projector: exactly one function named eventToSdkMessage, exactly one switch (ev.type) inside it, and no repeated case label — anything else is a broken anchor rather than a verdict, because a second same-shaped switch elsewhere in the file would otherwise overwrite the real one's conclusions and leave the doc agreeing with a switch nobody runs. The list is delimited by a machine-readable fence rather than by section headings, because the same section also names the terminal arms as a counter-example and prose boundaries cannot tell a member from a foil. (4) Released sections are frozen: an append-only ledger carries every version ever published — its number, the commit it was published from, and the sha256 of its section — and each one is checked, not just the current release, since pinning only the latest would set every earlier version free the moment the next one ships. The ledger cannot vouch for itself either: each recorded hash is re-derived from that release commit through git, so editing an old section and its constant together no longer passes — and the commit the row names is in turn checked against the gitHead npm recorded at publish time, which is the one value this repository cannot rewrite, so pointing an old version at a freshly written commit does not pass either. The set of versions that must be frozen comes from the registry too, so deleting an old row together with its section — which would otherwise remove that version from every set the guard looks at — is red rather than invisible. A failed registry call is classified rather than swallowed, and the classification consults the registry's own status code before it considers connection-level symptoms, so an auth refusal whose body happens to mention the network is still red rather than a skip. The version set is compared as full SemVer including prereleases — matching only x.y.z would silently drop a published 0.30.0-beta.1 and reopen the very hole this direction closes — and section headings are matched on a whole-version boundary so a stable release cannot bind itself to the release-candidate section sitting above it. Publishing itself is a two-phase protocol rather than a paradox: before a release, exactly one row may be marked pending and must name the current package.json version, exempt from the checks whose inputs do not exist yet; once the registry has that version the row must be promoted, so the temporary state cannot survive its own release. And because the pending exemption rests entirely on "this version is not out yet," it is refused outright when the registry cannot be reached to confirm that — an unverifiable premise is not a licence. Three reverse directions close the rest: a section claiming to be released but absent from the ledger, a ledger entry whose section has vanished, and a package.json version that was never frozen. Publishing appends a row; it never rewrites one. (6) Sentinels: the readers §5a hands hosts for "is this port installed" are checked against what the source actually declares it returns — hasXxx() is a boolean, the card port / HITL surface / wire target return T | null, the installHost family returns T | undefined. Testing a null-returning reader for !== undefined is always true, and a self-check that passes whether or not the port is installed is worse than none, because hosts retire their own fallback on the strength of it. Both directions are red: an implementation that changes its sentinel without the doc following, and a doc that names the wrong one. The roster covers the zero-argument readers and their *For variants alike — a multi-session host reads the variants, so leaving them off would let exactly the surface desktop depends on drift unwatched — and the §5a table and the §8-B checklist line are each checked against the source, because hosts tick the checklist, and a guard that only watches the prose table misses the line people actually follow. (5) Packaging: the README ships with the package and opens by pointing hosts at the integration doc, and the checklist names two more files as required reading before an upgrade — all three must really appear in the npm pack manifest, or an npm consumer follows a relative link that npmjs rewrites onto a private repository. Missing tooling never takes the whole verdict down with it: when git, npm or the registry is unreachable those legs print the SKIPPED-SECTION marker and the rest still judges, while a release commit the ledger names but git cannot resolve is red rather than skipped. The guard says in its own header what it does not do: it judges counts, coordinates, arm sets, released bytes and the packing list — whether a sentence is right is still for review and for the hosts to report |
| scripts/run-type-superset-ledger-test.mjs | The type/wire superset ledger (docs/type-superset.json): positions this package adds on top of a CC-shaped contract, each carrying the evidence for what CC's own type surface does or does not have there. Completeness is deliberately uneven and the ledger says so. The _sema_* private-key class is checked in both directions (a key in the source that never entered the ledger is red, naming key and file; a ledger row whose key left the source is red) — but only for keys written as literals, which is the convention the ledger mandates. A key assembled by string arithmetic is beyond what any static rule can enumerate, so the guard fails closed on every shape it can decide (a bare _sema_ prefix is red wherever it appears, save one pinned guard site) and leaves the rest as a convention violation for review to catch, rather than claiming a completeness it does not have. The two hand-surveyed classes are only checked for coordinate and evidence integrity, never discovered. Both directions read the source through the TypeScript AST, not a text scan, and they read two different sets out of it. A key site is an identifier, or a string whose whole value is the key — so '_sema_decision-v2' is carried whole rather than truncated at the first non-identifier character into some other key that happens to be registered. A mention is the key appearing inside a longer string, which is prose, not usage. The staleness direction counts key sites only: a comment or a doc sentence left behind after the last real mint site is deleted must not keep the row alive (mutation-proven — with both the comment and the prose string untouched, removing the one real site turns the guard red). And because a prefix can be concatenated or interpolated into a key no static set will ever see, the bare _sema_ literal is refused outright rather than traced: every occurrence is red except the single inline startsWith guard the sanitizer needs, because the set of expressions a bare prefix can travel through on its way to a concatenation is open-ended and enumerating it is always one form behind. Every row's host must still resolve, with the key being a real member of that declaration rather than a string occurring somewhere in the same file — governanceForced/delegation each live on two different shapes in one file, and a member commented out is a member deleted, which a text-shaped check happily reads as still present. And the direction worth the most: each machine-form ccAbsenceEvidence is re-derived from the row's own key — the ledger's recorded string must match that derivation verbatim, since a row quietly witnessing \bnever_present\b is green forever while watching nothing (mutation-proven: the same edit passes the unbound form and is caught by the bound one) — and the check runs against the names the installed @sema-agent/agent-types .d.ts set actually declares, parsed with the TypeScript AST rather than grepped, so a name CC merely mentions in a comment cannot force the row into the manual escape hatch and thereby retire the very witness that was supposed to fire the day CC declares that name for real. That escape hatch is gated by an allowlist living in the guard, not the ledger, so claiming it costs a reviewed diff. Missing material never reads as a pass, and the verdict splits by why it is missing: no TypeScript parser skips the suite before it starts; a missing agent-types still runs and prints the first three directions, then exits 1 when package.json declares the mirror but it is not installed — a broken install must not retire the repository's only "the day CC declares this name" alarm, and reporting it as a skip would leave "never evaluated" and "evaluated, no drift" indistinguishable to the runner — and exits 3 only when nothing declares the mirror at all, which is the one case where the direction genuinely does not apply. Either way a run that evaluated no witness is never counted as one that did. When the mirror is present its installed version is witnessed too (the two declared floors must agree with each other and the installed copy must meet them), since four preflight probes are satisfied by an arbitrarily stale mirror — they prove the extractor speaks, not that it is current. Every direction carries a positive control — known-present CC symbols, a comment-only sample proving the extractor distinguishes declaration from mention, and synthetic corpora fed through the same discriminator function the real verdict uses, so a verdict quietly rewritten to return nothing takes its own control down with it |
| scripts/run-rules-side-test.mjs | The persisted-permission-rules lane's shared decision half. The two capability bits are checked as two independent gates — a worker can honestly advertise the rules lane while predating the revoke routes, and that shape must hide the governance surface rather than render a dead entry. Failure classification is by disposition, not cause: the two 404s (route missing vs. dead ticket) never share a bucket, a 503 rule_import_retry means the ticket is still alive (the opposite handling of a dead one), and a stale-cursor 400 drops the cursor and re-lists from the top exactly once — never resuming a stale keyset, never surfacing a partial governance list, and never paging past the hard cap. The persist-ack reader is merged into readToolApprovalRespondAck: the three-state verdict (persisted / refused / unknown) is derived only from an ack that passed the package's structural narrowing, and a half-shaped object such as {rulePersisted: true} with no delivery reads as unknown — the pre-merge shell read would have said persisted, which is precisely the double-ledger drift this file closes, so that case is pinned in reverse. The local-allow-rule skeleton pins all five narrowings (whole-tool, tool-name match, literal anchor with the escaped-star counter-example, bare interpreter prefix consulted only for Bash, and the canonical dangerous-pattern overlay) with their refusal strings byte-for-byte — the cli's 128-assertion suite anchors the same strings, so a one-character edit here changes observable behaviour on three clients — and asserts the parse is a pure function of its input, because the same call backs both "render the option" and "resolve the selected value" |
| scripts/run-park-decision-layer-test.mjs | The decision layer behind the "stuck behind a card" family, shared by every client. A pending row that is not in the queue is three states, not one: a bounded, interruptible re-probe loop distinguishes a decidable row, not born yet (no positive evidence that anything settled — an empty queue proves nothing) and settled elsewhere, always probes at least once so a zero budget keeps the pre-fix semantics verbatim, cuts a hung read face off at the window rather than only noticing afterwards, and reports the honest failure when the window is spent instead of inventing a decision. The decision-note reader is likewise three-state: an explicit noteRecorded: false outranks an echoed note body, absence renders no line at all, and untrusted note text is flattened and bounded before it ever reaches a renderer. Row routing anchors on the deciding quantity — a row carrying gateKind: "human" with toolName: "Write" is a tool gate, because human is the engine's generic "someone must decide", not a synonym for a question — and the queue scan refuses to surface a row it cannot positively prove belongs to this session. A chain that fails after the row vanished is split by whether a card was ever presented: decided-elsewhere, or not-its-turn-yet. A row-level single-flight makes "at most one card per pending item" structural rather than incidental. The resume three-way card pins the option order (the zero-effect choice sits at index 0, because the frame carries no default-focus field and a stray Enter must not attach or cancel), renders only options the wired verbs can honour, collapses every ambiguous answer to zero action, omits the liveness line entirely when the engine gave no evidence, and — when there is no card lane at all — prints three real routes and exits on a dedicated code rather than reporting success |
| scripts/run-selfheal-reopen-test.mjs | The 409 active-run self-heal decision chain: governanceForced narrows on strict true only; triage prefers the wire's pendingGate.kind and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of presented: false demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (hitl/gateIdentity.ts, AST string-token scan); the armed-gate presentation ledger is per-session; and the plan_review reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the running three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent exactly once with its three delivery outcomes worded apart (a queued receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into provably not delivered (4xx) and delivery unknown, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is unknown rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
| scripts/run-terminal-identity-copy-test.mjs | Terminal-state identity, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set limits.* family, output.invalid, and the blocked contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it API Error: sends the reader to check the network, the key and the quota when the handle is the --max-turns they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is not an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real gateway HTTP 502, a conflict.session_active_run and any unknown code all keep the API Error: prefix, and the row keeps its isApiErrorMessage class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: selfHealSubmissionDisposition is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
Each suite carries a floor that only moves up — a refactor that stops executing a group of assertions is a failure, not a quieter pass. Guards anchor on the installed artefact's content rather than on a version number, a directory name or a count: a version string can lie, a type declaration cannot, and a count has no discriminating power when the content changes but the cardinality does not.
The portability guard walks the value-level import closure of the stream driver (asserting zero
Node builtins, zero react, zero shell debug edge, closure size under its ceiling) and then really
bundles it with esbuild --platform=browser. It carries its own positive control: a
deliberately poisoned file must be reported by the same scanner, otherwise the run exits 9
(the tool is broken) rather than green.
The differential half needs the CLI tree (the reference bridge lives there). It is located via
SEMA_CLI_ROOT, else a sibling checkout of the sema CLI source tree (internal reference bridge; not part of the published package); a pinned SEMA_CLI_ROOT without
src/sema/upstreamBridge.ts fails loudly instead of falling back to another tree. With no CLI
tree in sight the runner still executes the package-local replay/ledger half and then exits 3
(SKIP) — a run that never compared anything is never reported as a pass.
SEMA_DIFF_EMPTY_ADAPT=1 replaces adapt() with an empty generator and must turn the guard
red (the fence proving it has teeth).
Migration
@sema-agent/wire-cc-adapter (published 0.1.0 / 0.1.1) is deprecated in favour of this
package. Same seam design, same guards; the version line restarts at 0.2.0 so the two
names never overlap. For consumers the change is the dependency name only — the exported API
(createWireToCcAdapter, deriveTranscriptId, renderTaskNotificationXml,
taskNotificationDedupKey, isWorkflowAgentTaskId, parseWorkflowPollEnvelope,
projectWorkflowTaskOutput, the seam.ts types …) is unchanged.
