v5.20.0
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.jsnow declares that
class once asLAUNCH_TIME_ONLY_SETTINGSandlaunchTimeOnlyChangesreports 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,updateProjectadds a warning onto the existing
warningschannel (#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.engineandsilentPrimeleave the live process genuinely
diverged, and only a relaunch reconciles them;defaultLaunchModeandshowLaunchModePicker
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 sendsengineon
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, andapi.lastErrorwhen the save
is rejected, which had been the one silent case left. The banner's markup and renderer are
single-sourced inpublic/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.setActivePlanreads the samewarningsarray 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 bytcCreateChimeControlinpublic/api-helper.jsso
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, insideplayChime. -
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-editingproject.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/:namenow acceptsevalAuditMode, the
modal renders a control, andenrichProjectreports 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 againstopenclaw_connections.auditSecretand resolves the project
as the one whose engine isopenclaw:<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 besideenabled, 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
createProjectpersists 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, soupdateProjecthas refused that
combination without an explicit confirm since the settings existed.createProjectnever 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
defaultLaunchModeagainst 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.silentPrimegets the same treatment for the same reason — creation stored it raw while
aPATCHrefused 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 bytcCreateProjectBody, 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.jscarried two capability predicates written in the same shape
with separate implementations —honorsLaunchModeandsilentPrimeDisposition— 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 —honorsLaunchModestill 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;silentPrimeDispositionkeeps 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 makessilentPrimeno longer "the
info one" — a storedfalseis 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 cannotrequire()lib/, sopublic/api-helper.jscarries the restated copy
(tcSettingDisposition) andtest/setting-disposition.test.jsasserts 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 thePATCHvalidation — now ask it, and a
guard counts every capability read acrosslib/andpublic/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, matchingreconcileLaunchMode,
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. AndrenderSilentPrimeToggleis 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
ReferenceErroron 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.resolveProfileis the
one answer to "which profile is this id", including the connection-backedopenclaw:<id>form
thatstore.engines.getreturns null for;getWithAvailabilitylayers 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_PROFILESwas a literal inlib/medusa-wake.js
hardcodingclaudeandantigravity, so adding a sixth engine to TangleClaw meant editing a
lib/module — the constructionprime-delivery-direction.md§ Direction §1 already forbids one
layer up. Each engine now declares acapabilities.wakeblock (busyMarker,promptPattern,
promptGlyph,promptPad,placeholderSgr,idleMarker, and the optional
pasteRejectedMarker) and the table is derived from it; every consumer inlib/sessions.jsand
the tests that readENGINE_WAKE_PROFILES.claudekeep their spelling. The one form that had to
change is a destructuring import intest/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 inlib/would have meant a second hardcoded engine list
inpublic/, because the browser cannotrequire()it. With the data in the profile,
GET /api/enginesalready ships it, so themedusaWakerow inENGINE_CONDITIONAL_SETTINGSand
its browser half inpublic/api-helper.jscompute the same answer from the same bytes, and
renderMedusaWakeTogglerenders an inert control with the reason on codex, aider and openclaw.
The inert branch carries no#settingsMedusaWakeelement, sodoSaveSettingsattaches no value
and cannot post a stale checkbox — the patternrenderSilentPrimeToggleestablished. ADR 0013
asks that a row be checked for partial application before anappliesgate 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
evidencemap keyed by field name rather than
one claim for the block. Antigravity'sbusyMarkerwas measured on a live pane (#560) and its
promptPadnever has been; flattening those into oneverifiedOnwould turn the gap into an
assurance.verifiedOn: nullis the honest form for a value nobody has measured, and is a
different claim from Claude'sidleMarker, which isnulland 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/, whichstore.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. Butserver.jsrequires every module before callingstore.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_PROFILESis 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 apromptPatternthat will not
compile, a field the schema does not know, or apromptGlyph/promptPadwider 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.wakeSignatureand 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.jsso 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.jspoints the four suites that read the table at a throwaway
store, closing the host-dependence thattest/engine-config-managed-block.test.jshad already
recorded once forwriteEngineConfig.
Fixed
- An engine with no config file says what it cannot carry (#1251). On an OpenClaw project the
whole generated config is skipped —writeEngineConfigreturns{skipped: true}when the
profile declares noconfigFormat.filename— and with it go all fiverules.coreflags, all
sixrules.extensionsflags, 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 newgeneratedConfigrow inENGINE_CONDITIONAL_SETTINGSgates onconfigFormat.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_rulesriding a different channel — and this row has not checked that one (filed as
#1269, along with #1268 forwriteEngineConfig'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 inlib/, 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.jsnorpublic/ui.jscontains 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 bywriteEngineConfigitself 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
independentCriticon 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.coreis deliberately not
consulted, sinceupdateProjectrefuses 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 onconfigFormat, and no engine payload carried it:listWithAvailabilityand
enrichProjecteach 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 throughengines.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 —
listWithAvailabilitydropspickerHiddenprofiles, 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).
--warningwas
spelled at five sites acrosspublic/style.cssandpublic/session.cssand declared in no
palette, so every one of them silently took its#ffb300fallback — 1.79:1 against the Light
theme's white card, against the 4.5:1 floornonfunctional-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.jsnow 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--dangerlooks 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.mdon every engine, while the SessionStart pointer that tells the
agent the file exists is gated oncapabilities.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.appliesis 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 inENGINE_CONDITIONAL_SETTINGSmay now declare acaveat
instead of anappliesgate, andsettingDispositionanswersapplies: truewith the sentence
naming what does not happen here — derived once by the mechanism rather than composed at each
call site, restated intcSettingDispositionfor 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 inpublic/ui.js. - A rule that carries a value reaches the generated config (#1253).
_getRulesContent
collected extension rules withfilter(([, v]) => v === true), sologgingLevel— the only
non-boolean rule the product ships, default'info'— could never reach the prose path.
_generateCodexYamland_generateAiderConfread 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
coAuthorFormatpayload),supportsRemoteandsupportsModeshave no readers at all — and
nothing in the data said so, so a capability panel (#764) would rendersupportsCoAuthor: true
on aider as a promise the product does not keep.lib/engines.jsnow 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 outsidetest/
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 fromdata/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.mdno longer strands the self-updater (#1241). TangleClaw manages its
own clone, so it splices itsBEGIN/END:tangleclawregion intoCLAUDE.mdon every launch — and
since #833 that file is tracked, so any release changing generated guide text dirtied it.
_classifyDirtycounted only.tangleclaw/*and.claude/settings.jsonas TC-written, so the
carrier landed inrealWorkandPOST /api/update/applyreturned 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 modifiedCLAUDE.mdis 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_classifyDirtystays 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:discardablecan 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.jsrefactor routed
renderLaunchModeSettingsthrough the sharedtcHonoredLaunchModeshelper and removed the
const modesbinding it replaced — but a reference tomodessurvived further down the same
function. That is aReferenceErroron 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 asselected
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.jsnow RUNS the renderer against real bundled
profiles; with the defect reinstated it fails withReferenceErrorwhile the source-regex suite
still passes every case. This is the concrete instance of #1037, which asks for exactly this shift. CLAUDE.mdis 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:writeEngineConfigpicks its merge strategy from
governance (governed ? 'managed-block' : declared || 'whole-file'),CLAUDE.mdis not in
SHARED_CONVENTION_CARRIERS, anddata/engines/claude.jsondeclares 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 makesisPluginGoverned()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.jsasserts 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 — thehooksblock is stripped, because its absolute paths would make every clone
elsewhere run two nonexistent commands at each Claude Code start;syncEngineHooksrewrites 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.mdregained thePRAWDUCT:ANCHORgovernance 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 sodocs/release-process.md's "do not tag by hand" citation is finally true,
and its Global Rules mirror is now pinned equal todata/global-rules.mdby test rather than by
hope. Two hazards surfaced by tracking the file are filed rather than folded in: #1240 (the
ungoverned_generateClaudeMdpath can inline a live service token into what is now a tracked
file) and #1241 (a regeneratedCLAUDE.mdlands 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 adefaultLaunchModeand 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
(showLaunchModePickerdefaults true): a project set toplanlaunched 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:
proceedWithLaunchModeCheckcounted 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.disabledis not the same predicate as the server'sdisabled !== true: for a truthy
non-truevalue 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 readtcHonoredLaunchModesin
public/api-helper.js— the shared frontend base, so no site depends onindex.html's script
order — andtest/launch-mode-picker.test.jspins it againstengines.honorsLaunchModeover
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 adisabledmode today, butupdateProjectalready validates against the field
andtest/launch-mode-settings.test.jspins 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).
updateChimeIndicatoronly
ever added theactiveclass, so disarming the chime left the button lit until reload — and it
painted onto the Cmd button, whoseactivestate 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).
Onlyidis validated when an engine profile is saved, andengines.get()JSON-parses whatever
is on disk, so a hand-added or hand-edited profile can carry noname— or a truthy non-string,
whichescrenders as''. #707 fixed that at the two surfaces it touched by guarding at the
render site, which left the same predicate duplicated acrosspublic/api-helper.jsand
public/setup.js(twice), mirrored in two test fixtures, and absent at every other site that
readsengine.namestraight —public/ui.js,public/landing.jsandpublic/session.jsamong
them, all fed fromenrichProjectoff the sameid-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.engineClientPayloadnow guaranteesnameis 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.saveis
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
engineClientPayloadinstead 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
.jsunderpublic/— 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 fetchesGET /api/engines/:id: that endpoint returns the raw profile by design
(it carriesdetection,errorPatternsandstatusPage, which the projection drops), so it is
the single engine response without the guarantee, and it is safe only while nothing inpublic/
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.engineDisplayNamenow shares the predicate instead of keeping its own||. Its callers pass raw
profiles, so aname: 42would 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 inproject-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 reportingnullwould 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 whydefaultLaunchModewarns when dropped and
silentPrimerecords 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.