VideoRenderer used t as its track loop variable in two maps, so keying the panel
shadowed the translation function — renamed to track. That is the third panel with
this collision; it is worth knowing that a one-letter loop variable is the main
mechanical hazard in this phase.
The useFileViewer context Error stays English: thrown, not rendered.
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which
was right while roles were a hardcoded union and nothing could write a new one. It
stopped being right the moment roles became a table: from then on "add a role" and
"become the owner" were the same feature, reachable from whatever UI manages roles.
So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and
`isSuperAdmin` delegates to it, so no call site changes who it admits — widening any
"owner-only" route to "owner or Super Admin" is a separate decision per route, not a
side effect of this.
Still a database read rather than a bare comparison: a deleted or blocked account
must not remain the owner because its id happens to match.
`getOwnerUser` becomes a primary-key lookup. It used to select on the role with
`order by id` as a tiebreak, because two holders would otherwise make "who owns this
server" whatever Postgres returned first — an answer that decides the agent
sidecar's identity, vault access and origin scoping. That tiebreak, the write-path
refusals and the CHECK all protected an answer that is now a primary key.
Several Super Admins are allowed. What the role carries is real and belongs in the
UI rather than a refusal here: it provisions an OS account with sudo, which puts the
holder outside Officer's permission system and cannot be undone by revoking the role
afterwards. That is the operator's decision, not this endpoint's.
Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning
user 1's role is KEPT — redundant for ownership, but it still forbids the owner
being set to Member, which is a nonsense state that costs nothing to prevent. And
promotion to Super Admin is allowed while creating an account directly as one is
not: promotion happens to an account somebody has looked at, creation is a form
filled once, and the role that grants sudo should not be a dropdown default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
The layout templates are module-level data, so they carry nameKey and both consumers
resolve it — the visible label with t(), and the preview's own mini-registry with
panelName(), which is the same late-bound-thunk problem the app registry has.
Cancel comes from ui.button.cancel; Delete is panel.common.delete since nothing in
the shared components renders one.
Three agents committed to this repository in one night and `git log` could not tell
them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment
there was more than one.
So a trailer carrying the transcript id — not the session name, because names are
renamed by restarts and two sessions were renamed in a single night while their
transcript ids survived. The transcript holds the reasoning a commit message
cannot: what was considered and dropped, what was measured, what the owner decided.
A commit that names its session is one whose "why" is still answerable later.
The precondition is not built and is now a todo: an agent cannot reliably discover
its own transcript id. No environment variable carries it and the only exposure is
a background task's output path, so learning who you are means starting a job you
do not need. `commanderBlock` already hands a commander its id, so the mechanism
exists for one case and wants generalising.
Until then: an agent that knows its id adds the trailer, one that does not leaves it
out rather than guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if
it is a function. Owner-approved, with both of their conditions met.
Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately
not even i18n, so resolving a catalogue key there would invert the graph -- officerdev
depends on that package, not the reverse. The caller binds the words; the framework
only asks for them.
`string` stays valid, so the 27 names migrate one at a time and a name that never
needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude
Code and bTop are exactly that case: product names, and a key whose two sides can
never legitimately differ is a maintenance cost bought for nothing.
Resolved at every render via resolveEntryName, never cached onto the entry, with the
reason stated at both call sites -- caching would pin the name to whatever locale the
registry was seeded with, and it is seeded once before any locale has loaded. There is
a test that re-renders under a different provider locale and asserts the name changes;
it is the test that fails if anybody memoises this later.
The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in
scope. Deliberately not on the package barrel and documented as not-a-general-escape-
hatch, because it is not reactive: it is only correct where the caller re-renders for
another reason.
Diagnosed across a full night of one session managing another. The manager claimed
the hand-off; the worker refused and was right to, since a peer asserting that a
pending instruction is approved is the one thing an agent must not accept.
Two problems, and only one is about identity. Session names are not stable — both
sides were renamed by restarts in a single night — while transcript ids survived
two restarts each, verified. So durability is nearly free. Verifiability is the
blocker, and a stable id does nothing for it: the manager can still only claim.
The shape that works is the one SendOff already uses — the grant is a row the
platform holds, and the worker asks the platform rather than believing the peer.
Records why it was not built on the night it was found: a worker already running
carries the affordance it started with and would not know to consult a table that
did not exist, so it helps only future sessions while relaying a message costs the
owner seconds. Shipping the table is the easy half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example paths move from <span> to <code>. A path is user content and must not be
translated; <code> says so semantically, matches how ApiKeys and the Google guide
already render paths, and the scanner skips code elements for that exact reason.
A second sentence about turning someone off was multi-line, so the scanner never saw
it — converted on sight.
Cancel comes from ui.button.cancel. Save has no ui.* home — nothing in the shared
components renders one — so it is panel.common.save rather than a new unused key in
the guarded phase-1 catalogue.
Also adds tUi to officerdev's useTranslation, mirroring officer-web: the brief says
to reach for ui.* where the string already exists there, and 'Cancel' does. Panel
strings stay in panel.* — a ui.* key may not name a feature, and a panel is one.
The 'editor buffer changed while unfocused' Error stays English — it is the trap
marker for docs/editor-buffer-corruption.md, read from a log rather than a screen.
Pm2View's column metadata is module-level, so it carries labelKey and the header
resolves it. '#', '↺' and 'PID' keep plain labels — a glyph and an acronym read the
same in every locale, and a key whose two sides can never differ is a cost bought
for nothing.
PanelTranslationKey is now exported from the locales barrel, which panel-level data
tables need in the same way officer-web needed AppTranslationKey.
Drizzle generates foreign-key names past Postgres's 63-character identifier limit.
Postgres stores the truncated form, drizzle keeps comparing against its own longer
one, and every `db:push` on every host drops and re-adds constraints that are
already identical. Eight statements, for ever.
Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather
than a fault". What ended that was last night's deploy plan saying "expect no
changes, stop and report if it wants to alter something" — which this made
impossible to satisfy on any host, so the deploy agent had to overrule its own
instructions and say why. An annoyance had become an instruction nobody could
follow.
Two of the six are in this repo; the other four are in the atelier and
bitcoin-wallet plugin repos and are committed there. Verified the way the doc
prescribes: push twice against a scratch database, and the second run went from ten
statements to two.
The two that remain are `pk_music_now_playing`, and that one is tested rather than
assumed. Removing its explicit name produces the identical churn under drizzle's
generated name, so it is the named-composite mis-diff and not the truncation one,
and a primary key cannot escape to an index form. A push now prints exactly those
two lines — which is a far more useful baseline than ten, because anything else
really is your change.
Naming a foreign key means removing the inline `.references()` or drizzle creates
both, and `walletChainCache` had to move to the three-argument `pgTable` form
because there was nowhere else to put the declaration. Both noted in the doc.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too.
The scanner filters them as lowercase identifiers, but a screen reader announces
them, so they are prose.
Tmux, Neovim and Claude Code are product names and stay English, wrapped in
<Untranslated reason="product-name"> so the decision is visible where it is made
rather than looking like a string somebody missed.
Registry names still pending the AppRegistryEntry.name decision.
The panels need it -- they wrap Jellyfin, Soulseek, Gitea, Transmission and name
tools like Tmux and Neovim -- and it was in officer-web/lib, which officerdev cannot
import. components/ is the workspace both already depend on, and the file carries no
strings, so the phase-1 guard stays green.
`reason` gains 'product-name' alongside 'foreign-ui-label'. The union is closed on
purpose and its own comment asks for a second reason to be argued rather than
smuggled in: a product's NAME is not a label quoted from its UI, and the doc's
never-translate list covers it directly under "product names generally".
Everything except the appRegistryMetas names, which are blocked on a design decision:
AppRegistryEntry.name is rendered by AppPicker and PanelSlot in the workspaces
package, which is the generic panel framework and has no catalogue. Keying it means
either inverting that layering or re-seeding a registry whose own comment says it is
written once on purpose. Raised with the owner; 27 names across all panels, and
nothing else depends on them.
First panel, so it also proves the panel.* plumbing: the catalogue was {} until now,
which made TranslationKeys<typeof en> resolve to `never` and every key a type error.
rfb.ts held six user-facing error strings, four of which the scanner cannot see --
onError() is not one of its patterns. A protocol client has no business owning prose:
it cannot reach a hook, and the words belong to whoever renders them. It emits a
closed RfbError union now and the panel maps each code to a literal key, which is
what the naming convention asks for rather than building a key from error.code.
The install hint had <code>wine-minesweeper</code> mid-sentence. Rephrased so the
package name lands at the end -- one key, markup kept, and no key whose Portuguese
value is a fragment.
5 of 5 targeted hosts. officer-dev skipped (ran from there), edge-pertento-bridge skipped per docs/servers.md.
db:push reported changes on every host against a plan expecting none. Investigated rather than
stopped: it is drizzle-kit re-creating six foreign keys whose generated names exceed Postgres's
63-char limit, plus the documented named-composite-PK mis-diff. Present identically on the
untouched control host. Argument and evidence in the report.
The web UI can be read in Portuguese. The shell and shared components are fully
keyed, 581 keys across two complete catalogues, with the build failing on a
missing one. Plus an endpoint for spawning an agent, and spawned agents becoming
visible in both directions.
No schema change and no dependency change. db:push should report nothing, and the
plan says to stop and report it if it does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every row was wrong — bare en.json, 46 keys, three components, 5% deployed — and
it is the first thing whoever picks up phase 3 reads. platform-59 flagged it and
left it alone rather than colliding with me mid-edit, which was the right call.
Kept as a before/now table rather than replaced. The shape of the gap is the
argument for the phases, and deleting the 'before' column would leave them looking
arbitrary.
Also records two things the plan did not anticipate, both found by doing the work:
TranslationKeys walked into a plural object so t('key', {count}) — the whole point
of D7 — would not compile while the workaround did, which no test could have caught
because phase 1 had no plurals. And the typed wrappers rebuilt t every render,
which is the trap CLAUDE.md already documents against useClient().
And the shape this catalogue format cannot express: a sentence with an inline link
splits into a key whose Portuguese value is '.'. Flagged rather than solved, since
a node-level interpolation helper is a design decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 is complete: 555 -> 0. Verified the guard both ways -- exit 1 naming the
string and file when one is reintroduced, exit 0 once it is gone.
officerdev is still at 578 and stays out of ZEROED; that is phase 3.
CapabilityPage puts a suggestion in the chat composer, which the reader sees and
edits before sending -- user-facing, so translated. The hidden promptFrontmatter
beside it is NOT: that one instructs the agent and carries file paths. The scanner
never flagged it, correctly, because it opens with a tag rather than a capital.
officer-web is now at zero convertible strings.
The locale picker's labels are now AUTONYMS computed from Intl -- each language named
in its own language, so "Português europeu" reads as Portuguese whichever locale the
UI is in. That is the convention for a language picker and the useful behaviour:
somebody who cannot read the current language still has to find theirs.
CapabilityPage interpolated the capability KIND into six strings by lowercasing it in
code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now.
What is left is two strings, both deliberate: see the next commit message.
The three job detail panes and the jobs page. Status labels share one jobs.status.*
group across all three panes rather than three copies of "Running".
statusBadge in two of the panes is a module-level helper, not a component, so it
takes `t` as a parameter -- the alternative is calling a hook outside a component
body. ScriptJobDetail's version built "Failed (exit N)" by string concatenation;
that is a key with a parameter now.
shortcuts.ts is data with no component around it, so it carries catalog keys and
ShortcutsModal resolves them -- same shape as the settings sections.
gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md,
which docs:check validates. That doc stays in one language, so the generator resolves
against en-GB directly and throws if a key is missing rather than emitting a dot-path
into a file an agent will quote at somebody. The regenerated skill is byte-identical,
which is the check that keying the registry changed nothing an agent reads.
Applies the rule added to docs/localization.md in 91b9bec9: a label quoted from
another product's interface is an identifier, so the prose around it is translated
and the label itself is left in English and NOT keyed.
Gives that decision a name in the code. <Untranslated reason="foreign-ui-label">
states it at each of the ~30 call sites, where whoever next edits the sentence will
see it, and gives the scanner something exact to skip -- without which labels that
MUST stay English count as debt forever and the area can never be held at zero. That
is the same trap as counting new Error() messages. `reason` is a closed union so a
second excuse has to be argued for rather than smuggled in as free text.
The sentences needed rephrasing, not just splitting. "Go to the [New Project] page."
decomposes into "Go to the" + link + "page." -- and the Portuguese value of that
second key is "." A key that holds a full stop is the fragmentation smell, so the
English now puts the label at a clause boundary ("Open the project creation page:"
then the link), which reads naturally in both languages and needs no node-level
interpolation. Worth knowing for phase 3: sentences with an inline link are the one
shape this catalog format cannot express, and rephrasing is the cheap way out.
Scanner: the existing SCREAMING_CONSTANT rule now allows . and - so Google's
client-secret prefix example GOCSPX-... falls under it. Same rule, wider class.
Sonnet stopped phase 2 on GoogleOAuthConfig.tsx, where 55 of 67 strings are
quoted from Google's own console — 'Click Enable', 'set App name'. It called it an
editorial choice and asked. It is not a choice; it is already covered by the
principle the never-translate list is built on, and the list just did not say so.
Those labels read like prose and behave like identifiers: their whole job is to
match what the reader sees on Google's screen, and Google localises by the reader's
Google account language rather than ours. Translating them sends somebody hunting
for 'Ativar' in a console that says 'Enable'.
So: translate the prose around them, leave the label in English, and do not key it
with identical values on both sides — a key whose two halves can never legitimately
differ is a maintenance cost bought for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Authentication reaches zero -- sign-in, bootstrap, forgot and reset password. These
are the first words a new account reads, which is why the brief ranked them above
the larger screens.
DavAppPasswords had the third toLocaleDateString(undefined, ...) in the shell; it now
uses useFormat().date like the other two.
DebugPanel's three labels are keyed too. Worth knowing rather than acting on: that
panel is rendered behind showDebug, which Background.tsx hardcodes to false, so it
never appears. Keyed rather than deleted because removing it is not my call.
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.
Cancel comes from ui.button.cancel in all four places it appears here.
The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.
`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.
`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses.
Two more hand-rolled plurals became real plural entries -- maintenance said
"commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in
both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel
rather than adding a fourth app-level copy of the word, which is what phase 1 put it
there for.
Scanner: a single token carrying digits and punctuation is a name, not a sentence --
the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf.
Prose has spaces, and requiring whitespace to be absent is what keeps this from
eating real one-word labels. Verified the delta is exactly that one string.
Note for the owner: another session has src/servers/api/sub-agents/router.ts in
flight with three type errors. Untouched by me and excluded from my gate; the full
tree will not typecheck clean until they land it.
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.
So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.
The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.
`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.
Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.
Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.
Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.
ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.
Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.
The proposed second fix — the loop echoing the transcript id — turned out
unnecessary. The ids are already in the loop's spawned record, which the endpoint
ingests, so they reach the UI without the loop narrating anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.
Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.
Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.
Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.
Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>.
That is an identifier crossing a boundary on the doc's never-translate list, and
translating it would break the thing it documents. Those four elements exist to mark
content as code, so the scanner now honours them.
Verified the delta across both remaining areas is exactly that one string.
SendMessage does agent-to-agent with notify_when_idle instead of a poll, proven
tonight when one session took over the localisation hand-off from the owner. The
nudger does backend-to-session on a schedule, which is how a commander now reports
into its own conversation rather than a job panel.
Kept open deliberately: spawning a NEW session from a non-chat caller. The harness
Agent tool does not close that — it is available to a chat agent and nothing else,
which is why a commander's loop still shells out to claude -p.
Also kept: 'a spawned agent is invisible in the interface'. That is a defect in
Officer's own UI with a one-echo fix, and deleting it because an affordance now
describes coordination is exactly what docs/affordances.md warns about — an
affordance describing a defect calcifies it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.
Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.
Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.
Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.
Three things worth knowing:
CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.
Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.
The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.
ThemeSwitcher used t as its theme loop variable; renamed to entry.
Design for tomorrow, written down so the work starts from a spec. Phase 0 is
already built; everything after it is plan.
The load-bearing decision is the smallest one. Ownership is answered today by
users.role === 'Super Admin', which was fine while roles were a hardcoded union
and nothing could write a new one — and becomes a privilege-escalation path the
moment roles are editable data, because then adding a role and becoming the owner
are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every
existing call site keeps meaning owner, and nothing downstream starts until it
lands.
Teams union rather than restrict, which is the only coherent resolution and has a
consequence worth stating: a team can only ever grant. Deny rules are named under
what must not be built, along with a role that confers ownership and team-scoped
execution capabilities.
And the thing a reader would otherwise discover at the end of phase 4: there is
still no way to create a second account, so this is a permission model for
accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged
because it may be worth doing first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.
Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
Header.tsx has a disabled dev-console button inside a JSX comment and
IntegrationsSettings has a commented-out section literal. Four hits in
officer-web, all of them code nobody ships — and an area cannot reach zero while
a comment counts against it.
Only two shapes stripped, both unambiguous: block comments, and lines whose first
non-space characters are //. Stripping every // would eat the tail of any string
holding a URL. Verified the delta is exactly those four.
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.
useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
Found while converting them in phase 1: they resolve against whatever provider is
above them and render dot-paths with none. Safe today only because the second React
root imports nothing from components/. Written where the plugin-contract decision
will be made rather than solved here.
A commander read the general doctrine — run the action once before arming — and
proposed replaying the last release against it. Fair reading of what was written,
and wrong here: every tag already has a success record, so the claim refuses, and
the only way to manufacture a trigger is to tag a release, which it must never do.
So the deploy procedure now says it outright: arm, and state that the action has
never executed under you. The next real release is the first run.
This belongs in deploy documentation rather than commander doctrine. The general
rule is sound; what is specific to this job is that its triggers are all spent, and
the agent should not have to be told that by the owner after the fact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The area reached zero convertible strings, so it goes in ZEROED and the scanner's
--check now fails if a future feature reintroduces one. Verified both ways: exit 1
naming the string and file when one is added, exit 0 when it is gone.
Enabled per area rather than globally, as the doc says — officer-web and
officerdev are still at 559 and 578, and would be red for weeks.
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.
So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.
Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.
todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.