Skip to content

v5.20.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 00:45
· 52 commits to main since this release
64db86b

Added

  • A settings save says when the running session will not see it (#758). A setting the
    session resolves at launch — engine, silent prime, default launch mode, show-launch-mode-picker
    — was stored immediately and never reached the running process, so the modal accepted the
    input, reported success, and changed nothing observable. lib/projects.js now declares that
    class once as LAUNCH_TIME_ONLY_SETTINGS and launchTimeOnlyChanges reports which of them an
    update actually changes, compared against what is stored and read before any mutation runs.
    When the project has an active session, updateProject adds a warning onto the existing
    warnings channel (#1148), so it reaches every caller rather than one modal, and never names
    an engine: settings resolve at launch on all of them. Declared as one list on purpose, because
    warning for a single setting while its siblings stay silent teaches that no warning means "this
    applied", which would then be false for the rest. The advice is not one sentence, because the
    four are not one situation.
    engine and silentPrime leave the live process genuinely
    diverged, and only a relaunch reconciles them; defaultLaunchMode and showLaunchModePicker
    are read fresh at the start of every launch (lib/sessions.js), so nothing about the running
    session is stale and telling the operator to relaunch would ask them to kill live work for
    nothing — each group gets its own sentence, and both tails are asserted by iterating the roster.
    A PATCH carrying a key at its stored value stays silent (the settings modal sends engine on
    every save), and a project with no live session behaves exactly as before. The session page,
    whose own engine picker is the one place a live session is guaranteed, was discarding the PATCH
    response entirely; it now renders both outcomes — the warning, and api.lastError when the save
    is rejected, which had been the one silent case left. The banner's markup and renderer are
    single-sourced in public/api-helper.js (tcSettingsWarningsMarkup, tcRenderSettingsWarnings)
    with its styles in the stylesheet both pages load, after the hand-copied first version rendered
    a successful save in danger-red. setActivePlan reads the same warnings array as a failure
    report; that remains sound only because its PATCH body carries one key, and now says so. No
    timer: the warning names something the operator must go and do.

  • The session chime toggle moved to the live session banner (#1181). Arming the chime before
    stepping away was three interactions deep inside the Session Settings modal. It is now a control
    in the banner itself (#chimeBtn), built by tcCreateChimeControl in public/api-helper.js so
    it can be lifted and run by a test rather than pinned by source-matching. Moved, not duplicated —
    the modal's row is gone, because two controls for one setting is how they drift. The install-wide
    mute still outranks it, gated where it always was, inside playChime.

  • Eval Audit can be switched on from the settings modal, and says where it works (#1236). It
    had no write path at all: the only way to enable it was hand-editing project.json, so the
    dashboard panel was empty on every install and the feature read as dead — #1227 was filed to
    delete a complete, working feature (three scoring tiers, 15 API routes) and closed NOT_PLANNED
    once that premise was disproven. PATCH /api/projects/:name now accepts evalAuditMode, the
    modal renders a control, and enrichProject reports the setting for every project rather than
    only for the ones that already had it on — a modal cannot render a toggle whose value it cannot
    read, which is why it stayed hand-edit-only.
    It is not a universal setting, and the design that said so was wrong. POST /api/audit/ingest
    authenticates a bearer token against openclaw_connections.auditSecret and resolves the project
    as the one whose engine is openclaw:<connectionId>; that is the only write path into
    evalExchanges, and every score, anomaly and incident is downstream of an exchange. So the
    control is live on a project fed by an OpenClaw connection and inert with a stated reason
    everywhere else
    , through the disposition table rather than a fourth hand-built gate — shipping
    the universal toggle would have re-created the exact defect #1236 was filed for.
    The cost is visible before the switch, which #1236 asks for by name: the hint says enabling
    spends real money, and names this project's own judge model and per-session cap rather than the
    shipped pair, so an operator who has edited them reads what they set. The write merges — the
    key holds fifteen scoring tunables beside enabled, and assigning over it would let a settings
    save quietly destroy settings — and an unknown key is refused rather than ignored, since
    accepting input that does nothing is the defect this whole train is closing. The dashboard's
    "No projects have Eval Audit enabled." is replaced by copy naming what the feature does, what
    it costs, where to turn it on, and why most projects cannot: the old string was true and
    actionable by nobody, and it is what #1227 read.

  • Project creation collects the launch posture it was already half-asking about (#626). Step 2
    showed a Launch Posture dropdown and then dropped its pair on the floor: showLaunchModePicker
    could only be set by creating the project and editing it. The wizard now collects it and
    createProject persists it.
    The reason it is not merely a fifth field is the guard it carries. Hiding the picker while the
    default mode carries a warning (bypassPermissions / fullAuto / yesAlways) removes the red
    isolated-environments warning from the launch flow entirely, so updateProject has refused that
    combination without an explicit confirm since the settings existed. createProject never has —
    and creation is both where the posture is first established and the one route the guard had never
    run on, so a project could be made that launches straight into a warned mode having shown the
    warning to nobody. Both halves run there now: the server refuses, and the browser routes through
    the same confirm modal the edit path uses rather than a create-only copy, because the warning
    text is the guard and a second copy is the one that goes stale. Creation also validates
    defaultLaunchMode against the engine, which an edit has always done — the route that
    establishes a project's posture was the one route that did not check it — and it refuses in the
    disposition's own words. A refusal happens before the directory is created, so it leaves nothing
    on disk. silentPrime gets the same treatment for the same reason — creation stored it raw while
    a PATCH refused both a non-boolean and an engine that cannot honor it, and every reader tests
    === true, so a stored "true" ran with silent prime off against a shipped default of on. The
    guard itself is one function both routes call rather than a refusal sentence written twice with
    its inputs derived differently, and the wizard's confirmation is keyed to the engine and mode
    actually confirmed: a failed create leaves the drawer open, and a latched boolean would have
    waved a different warned mode through without showing its warning. The drawer's POST body is
    built by tcCreateProjectBody, which re-asks the disposition against the engine finally chosen —
    toggling silent prime on Claude, going Back and switching to an engine that cannot honor it left
    the value set with no control on screen, and the server now refuses it, so the operator would
    have read a rejection for a setting they could no longer see.

Changed

  • One mechanism answers whether a setting applies on a project's engine, and says why it does
    not (ADR 0013).
    lib/engines.js carried two capability predicates written in the same shape
    with separate implementations — honorsLaunchMode and silentPrimeDisposition — and each
    surface that offered one of those settings composed its own explanation, or offered none.
    engines.settingDisposition(setting, projConfig, engineProfile) is now the single answer:
    whether the setting applies, an operator-readable reason when it does not, the profile fact
    behind that reason for the log, and whether the stored value was a real operator choice. It is
    not a rename of the two — honorsLaunchMode still owns "will this engine run that mode",
    because the launch picker asks that about a mode nobody has stored, and the disposition table is
    expressed in terms of it; silentPrimeDisposition keeps its tri-state answer and now asks the
    mechanism for the gate. A setting with no declared gate throws rather than answering "it
    applies", because a silent yes is the no-op the norm exists to end.
    The log level is derived from provenance, not attached to the setting, which is the part
    most likely to be flattened by a later cleanup: a stored value that differs from what the
    product ships is real intent being dropped and warns, while one indistinguishable from the
    shipped default was set by nobody and records at info. That makes silentPrime no longer "the
    info one" — a stored false is a choice and now warns where it previously recorded. Deriving it
    is why the mechanism takes the shipped default as an input; a signature without it leaves every
    call site picking a level by hand.
    The browser cannot require() lib/, so public/api-helper.js carries the restated copy
    (tcSettingDisposition) and test/setting-disposition.test.js asserts the two agree field for
    field — reason text included — over every bundled profile plus a declared-but-disabled mode
    and a profile declaring nothing. A reason that drifts tells the operator something the server
    does not believe. The settings modal and the create wizard now render that reason rather than
    their own words, and the wizard no longer drops the Silent Prime control on an engine that
    cannot honor it: an absent control answers no question, so it renders inert with the reason
    beside it.
    Three consequences of having one owner, each of which was a separate small wrong answer before:
    the five remaining sites that spelled the silent-prime gate by hand — the baseline-hooks builder,
    the rules-channel choice, the two prime pointers and the PATCH validation — now ask it, and a
    guard counts every capability read across lib/ and public/ so a sixth cannot appear quietly;
    an engine TangleClaw holds no profile for is told apart from one whose profile says no, and
    reads "TangleClaw has no profile for this engine, so it cannot say whether this setting applies
    here" rather than a stated fact about a flag nobody read; and 'default' is treated as the
    absence of a launch mode rather than one an engine must declare, matching reconcileLaunchMode,
    so a profile declaring no modes stops producing "does not offer the launch mode "default", so
    this project launches in its engine default instead"
    on every launch of a project that
    configured nothing.
    An OpenClaw project's settings modal reads its capabilities from the project rather than the
    picker roster
    , which drops connection-backed ids — the row was reporting that TangleClaw has
    no profile for an engine it does know. And renderSilentPrimeToggle is now executed by a test
    against a mini-DOM (#1037) instead of matched as source: it depends on a global published by
    another file, and a regex cannot tell whether that identifier resolves — the failure mode is a
    ReferenceError on every open of the modal, which has shipped to the live install once before.
    The engine a gate asks about is resolved, never fabricated. engines.resolveProfile is the
    one answer to "which profile is this id", including the connection-backed openclaw:<id> form
    that store.engines.get returns null for; getWithAvailability layers detection on top of it.
    Three call sites had resolved it three ways, so the same project could be refused by the API in
    different words from the ones its settings modal renders — and a synthesized stub answers
    correctly only until a disposition row reads something other than the id.

  • An engine's wake signature is now declared in its own profile, and the modal says where a
    session cannot be nudged (#1255).
    ENGINE_WAKE_PROFILES was a literal in lib/medusa-wake.js
    hardcoding claude and antigravity, so adding a sixth engine to TangleClaw meant editing a
    lib/ module — the construction prime-delivery-direction.md § Direction §1 already forbids one
    layer up. Each engine now declares a capabilities.wake block (busyMarker, promptPattern,
    promptGlyph, promptPad, placeholderSgr, idleMarker, and the optional
    pasteRejectedMarker) and the table is derived from it; every consumer in lib/sessions.js and
    the tests that read ENGINE_WAKE_PROFILES.claude keep their spelling. The one form that had to
    change is a destructuring import in test/prime-readiness-gate.test.js: reading the export at
    require time is exactly the eager read the design forbids.

    The migration is what makes the honesty affordable. The one operator-facing string about the
    feature was a settings hint reading "Claude sessions only for now", which had been wrong since
    antigravity was profiled (#560) — and the engines that genuinely cannot be nudged got no warning
    at all. Fixing that with the wake data in lib/ would have meant a second hardcoded engine list
    in public/, because the browser cannot require() it. With the data in the profile,
    GET /api/engines already ships it, so the medusaWake row in ENGINE_CONDITIONAL_SETTINGS and
    its browser half in public/api-helper.js compute the same answer from the same bytes, and
    renderMedusaWakeToggle renders an inert control with the reason on codex, aider and openclaw.
    The inert branch carries no #settingsMedusaWake element, so doSaveSettings attaches no value
    and cannot post a stale checkbox — the pattern renderSilentPrimeToggle established. ADR 0013
    asks that a row be checked for partial application before an applies gate is written for it
    (#1252): checked, and recorded at the row — an unprofiled engine is skipped before every other
    gate, so there is no half that runs.

    Provenance is carried per FIELD, as a sibling evidence map keyed by field name rather than
    one claim for the block. Antigravity's busyMarker was measured on a live pane (#560) and its
    promptPad never has been; flattening those into one verifiedOn would turn the gap into an
    assurance. verifiedOn: null is the honest form for a value nobody has measured, and is a
    different claim from Claude's idleMarker, which is null and carries a date because the
    absence is what got measured (#1114). A guard holds the map and the declared fields to each
    other in both directions.

    Derived lazily, and that is load-bearing. The runtime reads profiles from
    ~/.tangleclaw/engines/, which store.init() canonical-source-overwrites from the bundle on
    every boot (#251) — that sync is what carries the new block to an existing install with no
    migration step. But server.js requires every module before calling store.init(), so a table
    built at module load would read that directory pre-sync: empty on a fresh install, and stale on
    any other. ENGINE_WAKE_PROFILES is therefore a getter that derives on first use and memoises,
    and an empty directory is treated as "not initialised yet" rather than as an answer. A guard runs
    the ordering in a child process, because it is a property of a fresh module registry.

    A malformed block is refused at the read, not only over the bundled files: an operator profile
    in ~/.tangleclaw/engines/ never passes through this suite, and a promptPattern that will not
    compile, a field the schema does not know, or a promptGlyph/promptPad wider than the single
    terminal cell it is compared against would otherwise reach the gate that decides whether to type
    into a live pane. Such an engine is logged and left unprofiled — the existing honest skip — and
    the refusal is per block, so one engine's bad declaration does not disturb the others. (One
    unparsable file is a different matter: the store parses the directory in a single pass, so it
    answers for all of them; that is reported once per process, names every gate that went dark, and
    clears without a restart once the file is fixed.)

    Declaring badly and declaring nothing are one answer, decided once in
    medusaWake.wakeSignature and read by the monitor's table, by the browser projection, and by the
    disposition row. Gating the settings control on the raw key being present would have offered a
    live Auto-wake checkbox for a profile the monitor then refuses to nudge — this row's own silence,
    produced by the guard built to end it.

    The set of nudgeable engines is unchanged by the migration, and detectAtPrompt's #1180 bounded
    exception is not widened.

    Pane fixtures moved to test/_wake-fixtures.js so the profile guard and the monitor suite cannot
    drift on the exact bytes — a hand-retyped prompt padded with an ordinary space is how the #1109
    gap survived. test/_engine-store.js points the four suites that read the table at a throwaway
    store, closing the host-dependence that test/engine-config-managed-block.test.js had already
    recorded once for writeEngineConfig.

Fixed

  • An engine with no config file says what it cannot carry (#1251). On an OpenClaw project the
    whole generated config is skipped — writeEngineConfig returns {skipped: true} when the
    profile declares no configFormat.filename — and with it go all five rules.core flags, all
    six rules.extensions flags, the Global Rules document, and the PortHub, shared-docs and
    session-memory guides. The skip is right on the write path (#240 asked for it so a launch does
    not shout about an engine that simply has no file); what was wrong is that nothing anywhere told
    the operator, which is the failure ADR 0013 names by number. The unit that applies or does not
    is the carrier, not each rule — one file is absent, so everything riding it is absent — so
    the new generatedConfig row in ENGINE_CONDITIONAL_SETTINGS gates on configFormat.filename
    and the settings modal renders its reason where the engine is chosen, re-rendered on every
    dropdown change so it is read while deciding rather than after a launch that dropped the lot.
    It is keyed after the file the operator can go and look at, not after its contents, and the
    sentence says "the project rule settings and TangleClaw guides it would carry" for the same
    reason: the modal has a separate "Project Rules" section a few groups down — free-text
    session_rules riding a different channel — and this row has not checked that one (filed as
    #1269, along with #1268 for writeEngineConfig's second silent skip). The engine-specific half
    of the sentence is declared in the profile
    (configFormat.absentReason, documented in
    docs/engine-guide.md) rather than written in lib/, so both realms read the same bytes and
    cross-realm parity is structural instead of a hand-copied string — a test asserts neither
    lib/engines.js, public/api-helper.js nor public/ui.js contains a copy of it, and a sixth
    engine with no carrier states its own case in its own file with no code change.
    All four writers (launch, create, engine PATCH, boot sync) discarded the skip, so it also reaches
    the log now — reported by writeEngineConfig itself rather than by each caller, because a
    guard pairing a report with every call site holds only until a fifth writer appears where the
    guard does not look. Its level the disposition derives: a project that turned
    independentCritic on and gets nothing has lost real intent and warns, while one running the
    stock rules never expressed a preference and records at info. The provenance input is derived,
    because a rules block has no single stored scalar to compare — rules.core is deliberately not
    consulted, since updateProject refuses to disable a core rule and counting it would report
    every project as customized. A test pins that the four engines which do have a config file are
    unaffected — a gate keyed on the wrong field would ship a "your rules are not delivered" notice
    on all of them to fix the one where they genuinely are not.
  • The engine the browser receives has one definition (#1251, found reviewing it). The row above
    gates on configFormat, and no engine payload carried it: listWithAvailability and
    enrichProject each hand-built their own projection of a profile, and neither projected that
    field. So the browser answered "no config file" for every engine — the notice firing on claude,
    codex, aider and antigravity, contradicting the server, and the declared sentence never appearing
    on the one engine it was written for. Both now project through engines.engineClientPayload,
    and the cross-realm parity test drives its browser side through that same function rather than
    through raw bundled profiles: a predicate reading a field the projection drops is
    indistinguishable from one reading a field about an engine that genuinely lacks it, and a fixture
    richer than production hides exactly that. The two shapes mattered unequally and invisibly —
    listWithAvailability drops pickerHidden profiles, so OpenClaw, the engine the row exists for,
    reaches the modal only through the per-project payload, which was the thinner of the two.
  • Warning text is readable in the Light theme (#1252, found reviewing it). --warning was
    spelled at five sites across public/style.css and public/session.css and declared in no
    palette, so every one of them silently took its #ffb300 fallback — 1.79:1 against the Light
    theme's white card, against the 4.5:1 floor nonfunctional-requirements.md § Direction binds.
    The fifth site was the new caveat line, which made the failure exactly the one the caveat exists
    to remove: the operator reads nothing. The token is now declared per theme, so the dark and
    high-contrast themes render byte-identically and Light gets a value that clears the floor on
    every surface it can land on. Nothing enforced that floor, which is why this shipped five times;
    test/theme-contrast.test.js now measures every semantic text token against the surfaces its own
    theme declares. It found six further pairs below the floor in the v2 palette — recorded with
    their exact measured ratios, so a regression fails while the status quo passes, and filed as
    #1265 rather than dropped from the guard, because changing what --danger looks like
    product-wide is not a side effect this chunk gets to have.
  • An unchecked silent prime survives browsing the engine dropdown (#1252, found reviewing it).
    The modal recovered that checkbox's state from an element the inert branch does not render, so
    claude → codex → claude re-checked a box the operator had unchecked and saved it back on. The
    state is held outside the DOM now. Pre-existing, but the index toggles' new caveat rides the same
    value and would have disappeared at the same moment, for the same wrong reason.
  • The Feature Index and Project Map toggles say which half of them does not run here (#1252).
    Both settings have two halves and only one is engine-agnostic: the wrap seeds and maintains
    FEATURES.md / PROJECT-MAP.md on every engine, while the SessionStart pointer that tells the
    agent the file exists is gated on capabilities.supportsSilentPrime, which only Claude declares.
    On four of five engines the toggle therefore built a file no session was ever told to read, and
    the modal said nothing — the silence ADR 0013 forbids. The disposition mechanism grew a third
    answer for it.
    applies is a boolean, and neither value was honest here: reporting the setting
    as not applying would call a running feature dead, reporting it as applying would say nothing
    about the half that is lost. A row in ENGINE_CONDITIONAL_SETTINGS may now declare a caveat
    instead of an applies gate, and settingDisposition answers applies: true with the sentence
    naming what does not happen here — derived once by the mechanism rather than composed at each
    call site, restated in tcSettingDisposition for the browser, and held to the server's wording
    by the cross-realm parity test like every other reason. The caveat asks
    silentPrimeDisposition, the same predicate the pointer itself is gated on, so it cannot report
    a loss the launch path does not have; a test drives both over every bundled profile and fails if
    they ever disagree. The gate is a triple, and the operator's own leg is now named too:
    turning silent prime off on Claude costs the pointer as well, which was true and undocumented.
    The two rows render as one function, because written twice the caveat would appear in one toggle
    and be forgotten in the other, and the launch logs the same sentence at the level the disposition
    derives — ADR 0013 names that log as the record behind the modal, so an operator who enabled the
    toggle on an engine that cannot announce the file leaves a trail rather than a file nothing reads
    and nothing to find. lib/project-config.js's "engine-agnostic so the toggle is not
    engine-gated" comment stopped being half-false, as did four more of it in public/ui.js.
  • A rule that carries a value reaches the generated config (#1253). _getRulesContent
    collected extension rules with filter(([, v]) => v === true), so loggingLevel — the only
    non-boolean rule the product ships, default 'info' — could never reach the prose path.
    _generateCodexYaml and _generateAiderConf read it straight off the config and worked; the
    three markdown generators never mentioned it, making a real setting do nothing on three of five
    engines. Repaired rather than removed: removal was available (no project on this install sets a
    non-default level) but would have changed every codex and aider config to buy nothing, and left
    the underlying filter unable to render any value-carrying rule ever added. A string or finite
    number now renders with its value on every generator, including at its default — the two engines
    that read the key emit their native field at the default too, and a rule the agent hears about on
    codex but not on claude is the same inconsistency in a new place. false, absence, an empty
    string and an object stay unrendered: this widens the filter, it does not remove it, and the
    boolean rules are pinned to the exact sentences they have always produced.
  • Read engine capabilities are distinguishable from declared ones (#1254). An engine profile
    declares more than TangleClaw consumes — supportsSlashCommands, supportsCoAuthor (with its
    coAuthorFormat payload), supportsRemote and supportsModes have no readers at all — and
    nothing in the data said so, so a capability panel (#764) would render supportsCoAuthor: true
    on aider as a promise the product does not keep. lib/engines.js now exports
    READ_CAPABILITIES, the flags application code actually acts on and what each decides, and a
    guard holds it to the code in both directions: a listed flag with no reader outside test/
    fails, and a flag that gains a reader without being listed fails too, so the set cannot decay
    into a description of what somebody once believed. Nothing was deleted from data/engines/*.json
    — the unread flags describe the engines accurately, and wiring any one of them is a feature
    decision per flag rather than a cleanup. docs/engine-guide.md's capability table now carries
    the distinction as a column.
  • A regenerated CLAUDE.md no longer strands the self-updater (#1241). TangleClaw manages its
    own clone, so it splices its BEGIN/END:tangleclaw region into CLAUDE.md on every launch — and
    since #833 that file is tracked, so any release changing generated guide text dirtied it.
    _classifyDirty counted only .tangleclaw/* and .claude/settings.json as TC-written, so the
    carrier landed in realWork and POST /api/update/apply returned a hard 409 with no discard
    offer
    , leaving the operator stuck on the running version. Same shape as the .codex.yaml
    incident that motivated ignoring generated engine configs here in the first place.
    The ratified rule it collided with is preserved, not relaxed. "A modified CLAUDE.md is not
    provably TC's, and a hand edit must never be discarded" still holds — what changed is that the
    question is now answerable: a carrier is discardable only when the working copy matches HEAD
    everywhere outside the markers, which is compared by eliding the managed region from both sides.
    Content outside the block is a hand edit and stays real work, so the existing guard on that rule
    passes unchanged. Every branch fails closed — file absent from HEAD, unreadable, markers missing,
    duplicated or inverted — because a wrong "no" costs the operator the old refusal while a wrong
    "yes" costs them their uncommitted work. The containment test is injected, defaulting to null,
    so _classifyDirty stays pure over its string input and a caller that cannot answer gets the
    pre-#1241 refusal rather than a guess. An untracked carrier is never discardable: git checkout --
    has nothing to restore it from. Every verdict is logged, positive and negative, naming which of
    the five causes declined it — the five are indistinguishable from outside, and a silent refusal
    would reproduce the opacity this fix exists to remove. The dashboard's discard confirmation and
    the agent-facing update prompt both say what changed: discardable can now name a file the
    operator and the plugin also write, and it appears only when their content is provably untouched.
  • The project settings modal opens again. #596's ui.js refactor routed
    renderLaunchModeSettings through the shared tcHonoredLaunchModes helper and removed the
    const modes binding it replaced — but a reference to modes survived further down the same
    function. That is a ReferenceError on every open of the modal, and it reached the live install.
    Membership is now tested against the entries actually rendered, which also fixes a smaller bug the
    original carried: a stored mode the engine declares but has disabled was named as selected
    even though no such <option> exists, so the browser showed the first entry while the markup
    claimed another.
    Why a green suite and five review rounds missed it: the only two guards on that function were
    assert.match(uiSource, /renderLaunchModeSettings/) — source-text pins that prove the symbol is
    spelled, and nothing else. No test called the function, so an undeclared identifier inside it was
    invisible. test/settings-launch-mode-render.test.js now RUNS the renderer against real bundled
    profiles; with the defect reinstated it fails with ReferenceError while the source-regex suite
    still passes every case. This is the concrete instance of #1037, which asks for exactly this shift.
  • CLAUDE.md is a committed artifact again, and a fresh clone no longer destroys it (#833).
    CLAUDE.md — the file Claude Code actually reads in this directory — was gitignored, so a clone
    got whatever TangleClaw generated rather than the hand-maintained content. Committing it alone
    would have been worse than leaving it: writeEngineConfig picks its merge strategy from
    governance (governed ? 'managed-block' : declared || 'whole-file'), CLAUDE.md is not in
    SHARED_CONVENTION_CARRIERS, and data/engines/claude.json declares no strategy of its own — so
    an ungoverned clone resolves whole-file and the first session launch overwrites the committed
    file entirely. Governance is read from .claude/settings.json, which was itself ignored, so no
    clone could ever read as governed. Both files are now tracked, which closes the loop: the
    reference makes isPluginGoverned() true, that forces managed-block, and TangleClaw splices its
    region instead of replacing the document. settings.local.json (per-machine permissions) and the
    agent worktrees stay ignored — the negation is deliberately one file wide.
    test/repo-governance-reference.test.js asserts the whole chain, because every link is invisible
    on the machine that breaks it and only shows up on someone else's first clone. Tracking the file
    also makes the reference reviewable, which is what #833 was filed about: it silently held three
    different values across two days with no diff and no commit. Only the portable governance keys are
    committed — the hooks block is stripped, because its absolute paths would make every clone
    elsewhere run two nonexistent commands at each Claude Code start; syncEngineHooks rewrites it at
    launch, so the tracked file shows as modified from then on, and moving machine-local keys out for
    good is #1242. While in the file: CLAUDE.md regained the PRAWDUCT:ANCHOR governance section
    its own header pointed at (absent, most likely eaten by an earlier ungoverned whole-file write —
    the failure this change prevents), gained a section recording this repo's exceptions to the
    mirrored Global Rules so docs/release-process.md's "do not tag by hand" citation is finally true,
    and its Global Rules mirror is now pinned equal to data/global-rules.md by test rather than by
    hope. Two hazards surfaced by tracking the file are filed rather than folded in: #1240 (the
    ungoverned _generateClaudeMd path can inline a live service token into what is now a tracked
    file) and #1241 (a regenerated CLAUDE.md lands in the self-updater's hard-refusal path, which
    cannot be fixed without amending a ratified decision about discarding hand edits).
  • The launch-mode picker now opens on the project's configured mode, which is what makes that
    setting work at all (#596).
    A project could store a defaultLaunchMode and never launch in it.
    The picker seeded its selection from the engine's default (public/landing.js) and then sent
    that selection explicitly on every launch — and an explicit caller choice beats the stored
    default server-side by design (lib/sessions.js). So the setting was consulted only on the
    hidden-picker path, and never on the shipped default where the picker is shown
    (showLaunchModePicker defaults true): a project set to plan launched interactive every time
    unless someone re-picked by hand. The picker now seeds from the project, falling back to the
    engine default when the stored key is stale or disabled rather than checking a radio that is not
    rendered. A warning-carrying mode the operator configured is preselected like any other — the
    settings modal's eyes-open guard already refuses to hide the picker for that combination, and
    that guard only means something if a shown picker reflects what is stored. Same shape as #741
    and #758: a control that saved the operator's choice and then did nothing observable with it.
    Found while re-scoping #596, whose written body (facelift plus a test net) had already shipped
    in PR #1003 without the issue being closed.
  • Every browser surface offering a launch mode now agrees on which modes exist (#596). Four
    places asked "which modes will this engine run" and answered it three different ways:
    proceedWithLaunchModeCheck counted non-disabled modes to decide the picker was worth opening,
    the picker itself rendered every declared mode, and the settings modal's default-mode dropdown
    and the create flow's Launch Posture each spelled it !m.disabled. Two consequences. The gate
    could see two real choices while the picker offered three, one of which the engine refuses. And
    !m.disabled is not the same predicate as the server's disabled !== true: for a truthy
    non-true value the picker would offer a mode both dropdowns hide, so the operator could not
    configure the mode they had just been offered. All four now read tcHonoredLaunchModes in
    public/api-helper.js — the shared frontend base, so no site depends on index.html's script
    order — and test/launch-mode-picker.test.js pins it against engines.honorsLaunchMode over
    every bundled profile, including that disagreeing value shape, so the browser copy stays a
    process boundary rather than drifting into a variant. Latent rather than live: no bundled
    profile ships a disabled mode today, but updateProject already validates against the field
    and test/launch-mode-settings.test.js pins that rejection as "symmetric with the picker
    filter" — a symmetry that did not exist.
  • A chime switched off no longer leaves its indicator lit (#1181). updateChimeIndicator only
    ever added the active class, so disarming the chime left the button lit until reload — and it
    painted onto the Cmd button, whose active state also means "the command bar is open", putting
    two unrelated meanings on one pixel. The function is retired rather than corrected: the new
    control paints its own pressed state in both directions, and carries the state in its label and
    title so the meaning does not rest on colour.

Internal

  • An engine's display name is normalised once, where profile data becomes client data (#736).
    Only id is validated when an engine profile is saved, and engines.get() JSON-parses whatever
    is on disk, so a hand-added or hand-edited profile can carry no name — or a truthy non-string,
    which esc renders as ''. #707 fixed that at the two surfaces it touched by guarding at the
    render site, which left the same predicate duplicated across public/api-helper.js and
    public/setup.js (twice), mirrored in two test fixtures, and absent at every other site that
    reads engine.name straight
    public/ui.js, public/landing.js and public/session.js among
    them, all fed from enrichProject off the same id-only-validated record. Counting them is the
    wrong instinct: the number is what grows. Guarding per site is the wrong shape: every new
    surface showing an engine name has to remember, nothing fails when one does not, and the failure
    is a blank unidentifiable control rather than an error.

    engineClientPayload now guarantees name is a non-empty string, falling back to the id, and the
    three per-site guards are gone. That boundary is the whole fix only because #1251 made it the
    single projection through which both the engine roster and the per-project engine reach the
    browser — before that there were two hand-built shapes and no one place to establish the
    invariant. Normalised after the overrides, so a connection-backed engine's
    operator-authored "<conn> (OpenClaw)" label is covered rather than bypassing the rule.

    Not validated at the write, deliberately — the issue left that open. store.engines.save is
    unused outside tests: an operator profile arrives as a hand-dropped file in
    ~/.tangleclaw/engines/, so save-time validation would guard the path the exposure does not take.
    Establishing it at the read is the same reasoning the wake block's read guard already follows
    (#1255).

    The two tests that pinned the retired guards now drive engineClientPayload instead of raw
    fixtures — they were asserting against a shape production never sends the browser, which is the
    fixture trap that let a browser predicate gate on an unprojected field and answer for every engine
    (#1251).

    Three guards came out of the review round. One walks every .js under public/ — the first
    cut listed three by hand, which answered "clean" about every file it never opened and passed with
    a live private copy sitting inside one it did — and fails if any of them re-establishes the
    fallback, since a re-added copy changes no behaviour and is otherwise invisible. One pins that no
    browser code fetches GET /api/engines/:id: that endpoint returns the raw profile by design
    (it carries detection, errorPatterns and statusPage, which the projection drops), so it is
    the single engine response without the guarantee, and it is safe only while nothing in public/
    reads it. The third asserts the guarantee is total — the id gets the same type test as the
    name, because falling back to an id that is itself a number just moves the blank label one level
    down.

    engineDisplayName now shares the predicate instead of keeping its own ||. Its callers pass raw
    profiles, so a name: 42 would otherwise have the two realms narrating the same engine
    differently — and the cross-realm parity loop could not have caught it while every fixture it
    iterated carried a good name. Two unusable-name profiles now go through that loop.

  • Ratified the norm that a setting TangleClaw offers must take effect, or say why it does not
    (docs/adr/0013-settings-take-effect-or-say-why-not.md).
    The design pass classified the
    per-project and global settings it audited against the engine roster and found that "universal but unevenly
    supported" is not the edge case between universal and engine-specific — it is nearly the whole
    population, so the rule for that class carries the settings surface. The norm is recorded in
    architecture.md § Direction and registered in project-preferences.md § Enforcement, but the
    ADR is authoritative: .prawduct/artifacts/* is gitignored so ~54 internal design docs
    including the security model stay out of a public repo, which would otherwise leave a ratified
    norm alive on one machine only — the same reasoning ADR 0012 and ADR 0009 already record. It is
    deliberately distinct from two neighbours it would otherwise be folded into: ADR 0001 governs
    internal state symmetry across two storage locations, and the honest-degradation Direction
    governs a read that could not be established. This governs a read that succeeded and returned
    a real value the engine will not act on — nothing is unknown, so reporting null would be its
    own falsehood; the value is known and the effect is missing. Retroactivity is migrate, and the
    five instances the audit found are filed as #1251-#1255 rather than grandfathered. The ADR also
    pins the log-level rule that is easiest to "clean up" wrongly: the level is a function of whether
    the stored value was a real choice, which is why defaultLaunchMode warns when dropped and
    silentPrime records at info.

  • Train 12.5 chunk C1 design and the chunk C2 build plan (.tangleclaw/plans/). The design
    records the finding that reframes #764: a project settings modal is scoped to one project, which
    has exactly one engine, so it never needs the per-engine tabs the issue asks for. The genuinely
    multi-engine surface is per-engine global defaults and inventory, which is also where #581's
    tunables dock. Splitting them resolves two constraints at once that tabs cannot meet — the
    ratified mobile norm — the modal is 288-337px on the primary client against a >=44px floor, and
    #764 requires the tab set to grow with the roster without a UI change, which a bar sized for
    today's four engines cannot do. Also records that #764's motivating example is dead: #763, which
    it calls "the mechanism", was closed not-planned on the engine-agnostic rule itself.