Four publishers copied a plugin's affordances, notifications and skills into
capabilities/ at install. That directory is meant to be forked — core's work
plus the owner's — so a fork accumulated files it did not author with nothing on
disk saying which, and there was no good answer: commit them and carry another
plugin's work, leave them and git status stays dirty.
Copying was also wrong in two ways unrelated to git. A copy goes stale, because
plugins:update pulls the plugin's directory and the copy keeps describing the
old version. And a copy survived plugins:disable, so a disabled plugin's skill
stayed in the agent's index — off, and still being announced.
Now the loaders take a list of roots: the owner's store, plus each installed AND
enabled plugin's own directory. capability-sources.ts resolves that list. No
namespace is needed to keep them apart, because separate directories do it
structurally rather than by convention.
The owner's own win a skill name collision — their root is first and the merge
keeps the first of each id — so a plugin cannot take over a name already in use.
Affordances are deliberately NOT de-duplicated: several can be true at once.
Notifications needed more than a root list, and it would have failed silently.
Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml.
Read in place the path is just notifications/catalogue.yaml and carries no
identity, so every plugin code would have vanished with nothing raised.
loadPluginEntries takes the identity from the manifest instead, which is better
provenance anyway — publisher and appName are validated at discovery, where a
path was only ever a directory somebody chose. A yaml key still cannot claim
another namespace, and that is pinned by test.
createSession is async now, only because resolving enabled plugins reads the
database. Once per session; its single caller already awaited.
Deletes all four publishers including api/tasks/publish.ts from earlier today,
superseded before it shipped. app-store/assets stays — images served over HTTP,
where the copy is what makes them reachable.
NOT done: tasks. That store is read-write, so reads must span sources while
writes stay in the owner's directory, and a plugin's task has to be read-only in
the UI. Next.
Two things the design predicted as orphans turned out not to be, and both were
checked before deleting rather than after. capabilities/extensions/tool-loader
is written by the platform's own bootstrap every boot, not published by a
plugin. And skills/email-db has no source to fall back on — the email plugin
ships no skills directory, so that file exists in exactly one place and was
hand-written. Neither was touched.
1422 pass, tsgo clean, officer restarts and serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Assets, affordances, notification catalogues and skills each had a publisher.
Tasks did not, which is the one that matters for reaching the FILE BROWSER'S
CONTEXT MENU: a task carrying `{ type: 'file', extensions: [...] }` already
appears on a right-click with no platform change, because matchesTrigger in
useTasks.ts does that work. What was missing was only delivery — the capability
had to be installed by hand beside the plugin and removed by hand after.
Flat rather than namespaced by publisher, matching skills. A task's directory
name is its identity everywhere: the taskDirName in a job row, the path a script
runner materialises, the label in a run history. Wrapping that in a publisher
would put one in all of those for a collision nobody has had, and a task claims
no authority — it is a menu entry behind a permission that already exists. That
trade is NOT available to affordances, which stay namespaced, because one that
could claim `officer/core:` would be claiming the platform's voice.
The test that earns its keep is the uninstall one. `tasks/` is the owner's own
directory and nothing on disk marks which entries came from a plugin, so the
scope of that delete is the only thing standing between an uninstall and
somebody's hand-written work. Proven rather than reasoned about.
Written for the Whisper extraction, where the transcribe entry has to arrive
with the plugin instead of being seeded separately.
1429 pass, tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
The table had two entries, en and pt. Every other language got no prompt at
all, which is why Swedish transcription had nothing steering it.
The prompt is not an instruction — whisper.cpp conditions on it and IMITATES it.
So it carries three things: punctuation style, which is the largest effect;
the language's diacritics, which is the one that fails silently, because a
Swedish prompt without å ä ö teaches it to omit them; and register, which here
is one person talking into a phone rather than prose from a book.
Each entry is an original scenario in that language's idiom rather than a
translation of the English, because a literal rendering produces translationese
and primes badly. Diacritic coverage was verified per language rather than
eyeballed, and the CJK and Arabic entries use native punctuation — 。and 、for
Japanese, full-width for Chinese, ، and ؟ for Arabic, ; as the Greek question
mark.
Three are lower confidence and worth a native read before they are trusted: ar,
he and hi. Arabic is the structural one — it is written in Modern Standard,
which is not how anyone dictates a voice memo, though it is close to how Arabic
is written down and to what Whisper emits anyway. Kept on that reasoning; cut ar
first if it reads wrong.
Detection still chooses the language and the prompt still follows the detected
one, not the UI locale. Keying it on the interface language would prime
Portuguese audio with an English paragraph whenever someone runs the UI in
English, and an initial prompt biases output rather than merely hinting.
tsgo clean, prettier clean, 11 stt tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real
grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role
the owner created at runtime could never have either, which defeats the point of
roles being data.
Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row.
A role that no longer exists reads as neither grant, matching "absence denies"
everywhere else.
NOT the `provisioningScript` D8 asked for, and that column is removed rather
than left dead. A shell script stored on a role row and run at provisioning time
is arbitrary root-level code in a database column, and D8's own warning says why
that is worse than it looks: `usermod -aG docker` is root by a longer route. So
the row CHOOSES from a vocabulary the platform already knows how to carry out —
the same shape as `multiarch` on a plugin's osDependencies, for the same reason.
The seed re-asserts both flags on every run for system roles, and only for them.
Without that, existing rows take the column default of false the moment it lands
and Developer silently loses its tools on the next deploy — every host already
has these four rows, so an insert-only seed would have shipped that. `description`
stays insert-only, because an edit made on one server must survive a deploy.
`db:push` could not apply this and said nothing. Dropping a column while adding
another makes drizzle-kit ask whether it is a rename; with no terminal it bails
and the wrapper still exits 0, leaving the seed to fail against an unchanged
schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit
every host on the next deploy and no agent driving a rollout has a terminal.
Verified here: SQL applied guarded, Developer keeps both grants and no other role
gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Minesweeper could not be installed by the plugin flow at all. Its game is an
i386 PE, so it needs wine32:i386 — and that package does not exist on a 64-bit
host until dpkg has been told the architecture does. No package name can say
that, so the step fell outside the manifest entirely and had to be run by hand,
which meant an install that reported success and left a game dying with
`Bad EXE format` several steps from anything mentioning architectures.
Two fields, both declarative:
`multiarch: 'i386'` on an osDependency. The platform runs `dpkg
--add-architecture` and `apt-get update` before installing, apt hosts only,
skipped when already enabled. It is a closed enum rather than a command
because it runs as root: a manifest that could supply the string could run
anything as root on every machine that installs the plugin, which is a much
larger grant than "may install wine". Same principle as a plugin websocket
naming a sidecar rather than a port.
`packages.apt` may now be a LIST. One binary really can need more than one
package — `wine` provides the launcher on PATH and `wine32:i386` the 32-bit
loader, and either alone gives a wine that cannot run what the plugin came for.
Every element goes through the same regex and reaches apt as separate argv, so
a list is no more powerful than the names in it. The regex also now accepts an
architecture suffix from the same closed set.
Enabling an architecture is system-wide and permanent — nothing here ever
disables one, including plugins:delete, because by then something else may
depend on it. So it is reported before the owner consents rather than mentioned
in a log afterwards, and only for dependencies actually being installed: a
binary already present had its architecture enabled at some point in the past.
multiarch.test.ts pins the enum against shell-shaped input, the suffix against
an unknown arch, and the report against re-announcing a change that is not
happening. 1409 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Without an entry the plugin is invisible to every server but this one — the
catalogue is what a remote host reads, and the bundled constant is only the
fallback when marketplace.officer.dev cannot be reached.
Its git remote was the tailnet address, which marketplace-sync would have
published verbatim: a clone URL that works on this network and nowhere else,
failing at authentication in a way that reads as a broken plugin rather than a
wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync
script's own comment already spells out.
Synced. 18/18, and the entry is live with the right source, permissions and six
osDependencies; the repo answers an anonymous https clone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Neither was visible from the source or from any test — both only showed up
switching the live UI to es-ES/de-DE/fr-FR in a browser, which is what
turned up the catalogue-wiring bug this builds on top of (see prior commit).
1. FileItem.tsx had two `isDir ? 'Folder' : ...` ternaries the scanner
cannot see (values computed inside a JSX expression, not JSX text) —
the Type column and the grid-view size line both showed the English
word "Folder" regardless of locale. New key files.grid.folderType,
translated in all five catalogs.
2. files.menu.selected was a single hardcoded string ("selected" /
"seleccionados" / "sélectionnés" / ...), not a plural form. English
doesn't inflect "selected" for number so this was invisible in the
source locale, but Spanish and French do: "1 seleccionados" and
"1 sélectionnés" are both wrong, should be singular. Converted to a
{one, other} pair in all five locales (pt-PT included — same bug,
pre-existing) and updated SelectionActions.tsx to pass count. German
was fine either way ("ausgewählt" doesn't inflect here).
Verified by minting a token for the owner's own account, driving a
headless browser through es-ES/de-DE/fr-FR at /settings/profile, /files,
and the file context menu, and reading the screenshots — not just running
the test suite. Owner's real settings were read before and restored after.
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper).
Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the
permission registry entry, the App.tsx route and the hand-written websocket
provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3.
The relay changed transport, and that is the part worth reading.
A sidecar announces exactly one port. That single announcement is the whole
provenance argument in target.ts — the platform watched the port arrive at
registration, which is the only thing separating it from a number a manifest
asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so
the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes
have to fall back to the typed command vocabulary over the registration
socket. That is the mechanism the fleet has spent months retiring, and
Minesweeper would have been the first thing to go back to it.
Relaying websocket-to-websocket dissolves the conflict. The sidecar serves
HTTP and /ws on the one port it announced, exactly as the pty sidecar already
does; createSidecarProxy handles the HTTP half; and the conversion from frames
to whatever the plugin actually speaks happens inside the plugin, which is the
only process that knows. The relay is still ignorant of the payload — the
ignorance sits one layer up.
Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use
for it and forwarding one puts a bearer token into whatever that process logs.
X-Officer-User carries the only part it needs. (The terminal relay still
forwards its token to the pty sidecar; not touched here.)
Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was
translated and in parity, all three were in SHIPPED_LOCALES so the picker
offered them, and choosing one rendered the whole UI in raw keys.
All three catalogue files still imported only en-GB and pt-PT, so
`catalogues[code]` resolved to `{}` in every namespace — present, and useless.
Adding a locale is four edits across four files and nothing knew whether all
four had happened.
The parity tests could not have caught this and never will. They compare the
JSON files against each other, and a file nobody imports is exactly as
parity-correct as one that is loaded. The gap was between "the translation
exists" and "the app can reach it".
Registers all three in appCatalogs, panelCatalogs and uiCatalogs, moves the
merge out of I18nBridge into lib/catalogues.ts so it is testable without
mounting React, and adds catalogues.test.ts as the place that knows: every
shipped locale must resolve to a populated catalogue in all three namespaces,
and be the same size as the fallback.
Verified the test fails against the shipped code — 6 failures, panel.* for
each of the three — and passes after. 26 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Complete Spanish catalog across all three namespaces, 1129 keys,
machine-translated then reviewed for terminology and plurals.
Fixed on review: an awkward literal fragment in settings.accounts.description
("Quién tiene acceso, y como qué") reworded to use the established
role → "función" glossary term instead of an ad-hoc phrase. Everything
else held up — "compositor" for the chat composer avoided the same
Editor-collision German needed fixing for, Minesweeper stayed
untranslated matching pt-PT/fr-FR/de-DE precedent, and the impersonal
"se" constructions used for the file-operation plurals (Se copió /
Se copiaron) read correctly at both n=1 and n=0 — Spanish's plural
rule is one/other like English, count=0 takes the plural branch.
All 17 plural entries verified at count 0/1/2.
This closes out the three-locale rollout: es-ES, de-DE and fr-FR all
now in SHIPPED_LOCALES and all three parity tests, each shipped in
its own commit only after passing review. pt-BR stays out per the
owner's call — European Portuguese first, Brazilian is a separate
decision.
Complete German catalog across all three namespaces, 1129 keys,
machine-translated then reviewed for terminology and plurals.
Fixed on review: "composer" (the chat input box) had been translated
as "Editor" in two shortcuts strings, colliding with this app's actual
Editor/Code-Editor feature — changed to "Eingabefeld". scopeDockers
had drifted from the lowercase, untranslated "dockers" every other
locale (including this one's own pt-PT/fr-FR precedent) uses for that
filter-scope label. All 17 plural entries verified at count 0/1/2 —
German uses the same one/other split as English, so this was mostly
confirming "0 Elemente" reads right, not hunting for a CLDR surprise
the way French needed.
SHIPPED_LOCALES and the three parity tests now include de-DE. es-ES
is drafted and under review — held out of the picker until it passes
its own pass.
Complete French catalog across all three namespaces (app/panel/ui),
1129 keys, machine-translated then reviewed for the two things that
go wrong automatically: technical vocabulary and plurals.
Fixed on review: a gender-agreement slip ("décrivez-la" referring to
the masculine "Prompt", should be "décrivez-le") and an incomplete
verb construction in agentRunner.keepsGoing. Also reverted "Minesweeper"
→ "Démineur" back to the untranslated proper name, matching pt-PT's
own precedent of not translating this bundled app's name.
French is the language where the 0/1/2 plural check actually bites:
Intl.PluralRules puts count=0 in the SAME category as count=1 ("one"),
unlike English/Portuguese/German/Spanish where 0 is "other". All 17
plural entries were verified against 0, 1 and 2 rather than trusting
the category names — the "one" branch is phrased as a plain singular
so it reads correctly for both 0 and 1.
Parity tests now check every non-en locale in one assertion instead
of hardcoding pt-PT; SHIPPED_LOCALES gains fr-FR so the picker offers
it. es-ES and de-DE catalogs are drafted and under review — held out
of this commit and out of SHIPPED_LOCALES until each passes its own
review, so a half-translated locale never reaches the picker.
Last stretch of phase 3: FileBrowserWidget's five components (tabs,
search, empty states), FileGrid (column headers, sort labels, empty
folder), UploadProgress, DictateDialog and AgentRunnerModal. Reused
ui.button.cancel/close from the shared catalog where the word was
already there instead of adding panel-local duplicates.
The two remaining scanner hits — useTaskRunner's "Running:"/"Error:"
framing — are marked i18n-exempt rather than converted: they're
interleaved with raw stdout/stderr inside one monospace <pre> feed,
so translating the frame alone would read as broken mid-buffer. Same
reasoning as the never-translate thrown-Error list, just not a thrown
Error.
officerdev (panels) added to the scanner's ZEROED guard — the check
now holds three areas at zero.
Toolbar, ActionButtons, SelectionActions and DefaultActions. Almost every string
here reused an existing files.menu.* key from the last two commits rather than
adding a duplicate — cut/copy/paste/download/delete/newFolder/uploadFiles/
uploadFolder/hideHiddenFiles were all already there. Three new: gitClone,
searchFiles, clearSelection, refresh, selected.
The clone-URL placeholder here is the same hint as NewEntryDialog's, reused rather
than restated with different example names (user/repo vs owner/repo) — one key per
meaning, not per occurrence.
The "N selected" count splits the number and the word across two spans (the word
hidden on mobile); only the word needed a key, so files.menu.selected is standalone
prose rather than an interpolated frame.
URL/playlist fetch, per-item preview, and the download-all flow.
Most of what the scanner flagged here was the smaller half — the bulk was ternaries
and text following a `}` rather than a `>` (the same structural miss "Open" and
"Fetch" hit in FileItem), found by reading the file. dlLabel's three-way ternary,
Retry/Download, Audio only/Video, and the "· N items" count were all invisible to
the tool.
Distinguished two classes of error string carefully. `pl.error` and `info.error`
from a successful-but-failed server response are LEFT AS IS — server content,
same as apiError.message in phase 1's ServerError dialog. Our own fallback
defaults for a network failure the server never got to respond to ("Could not
fetch info", "Timed out", "Could not start the download") are ours and are
translated.
One real plural, itemCount, checked at 0/1/2 in both locales.
New file / new folder / clone repository, one dialog for all three.
nameProblem and urlProblem are pure functions with no hook in scope, called on every
keystroke — so they cannot call t() themselves. They now return a closed union
(NameProblem, a discriminated key plus params where one is needed) rather than an
English string, and the component resolves it. Same shape as RfbError in the
Minesweeper panel: a lower layer emits a code, the UI layer owns the words.
NewEntryDialog.test.ts asserted on the literal English message; updated to assert on
the key instead. That is a strict improvement independent of localisation — the old
test would have failed the moment anyone tightened the wording, for a change that
touched no logic. It now only fails if the LOGIC picks the wrong problem.
The COPY table (title/description/label/placeholder/submit per kind) is module-level
data, so it carries key names now and the component resolves them — same pattern as
the settings sections and the shortcut registry in earlier phases.
Example placeholders "documents" and "notes.txt" are translated ("documentos",
"notas.txt") rather than left as identifiers: unlike a credential format or a model
filename, these are prose hints meant to guide the reader, and the scanner's own
isHuman heuristic only skipped them because they happen to be lowercase with no
spaces, not because they are not content.
The single row menu shared by the dropdown and the context-menu variants: open,
edit, paste-into, read aloud, extract, run task/agent, cut/copy/duplicate/rename,
compress, copy path, download, pin, chat, create dashboard, delete.
Four strings the scanner cannot see, converted anyway since the file was open:
"Open" sits after a conditional icon expression (`{cond ? <A/> : <B/>}`) rather than
directly after a tag's `>`, which is outside the jsx-text pattern's reach. "Cut" is
three characters and the pattern requires four. "Open in editor"/"(too large)" and
"Compress"/"Compress selection" are both ternaries, which the scanner's own docs
already say it cannot see.
formatDate() called toLocaleDateString(undefined, {...}) — the browser-locale
contradiction D7 describes, same shape as ApiKeys and DavAppPasswords in phase 2.
Replaced with useFormat().date, threaded from the exported FileItem component since
the old helper was module-level and a hook cannot be called from one. Verified the
two locales now genuinely differ: "14 Nov 2023" against "14/11/2023", not "whatever
the browser happens to be set to".
task.name, agent.name and group.category stay untouched — content, not chrome.
Reused files.menu.copyPath, .createDashboardHere and .download from the previous
commit rather than adding duplicates for the same words.
The folder right-click menu and the search-results menu: copy path, chat about this,
paste, new file/folder, upload, clone, hidden-files toggle, create dashboard,
download video, dictate, open folder, show location, and the drop-to-upload overlay.
"Hide hidden files" / "Show hidden files" was a ternary inside JSX, which the
scanner's own docs say it cannot see — found by reading the file rather than by the
tool. Two keys, one per branch, since English and Portuguese verbs differ by more
than a negation.
files.menu.* is a new sub-namespace under the existing files.* group rather than a
flat name, since a bare "download" would otherwise collide with nothing today but
reads as ambiguous next to files.downloadFailed.
The batch prompt sent when running a task against files: subtitle/audio track
pickers, pipeline status, and the multi-group conflict UI.
Three module-level helpers (trackLabel, defaultGroupSel, groupChanges) take `t` as
a parameter rather than calling a hook, because they are plain functions, not
components — same shape as the status-badge helpers from phase 2.
defaultInput, built from the task name and directory, is exempt: it becomes the
brief sent to the agent, never shown as editable text (unlike CapabilityPage's
composer suggestion, which is exactly that and stays translated). Two lines marked,
both with the reason.
Two real plurals: "Track {{n}}" and "Run from step {{n}}", checked in both locales.
The fixed-port form shipped this morning and lasted about an hour. The owner asked
whether declaring a port was safe, and it is not: nothing stopped a manifest naming
a port it does not own, so `websocket: { port: 5432 }` plus a grant would have been
a raw TCP relay onto Postgres — authenticated by the platform, available to anyone
that plugin was granted to. Port 22 and officer's own port are the same sentence.
The relay checked that the HOST was loopback and I mistook that for having checked
anything about the port. They are different questions and only the second one
matters: loopback is exactly where everything worth reaching already is.
So the only form is `{ sidecar }`. A sidecar's port is trustworthy for a reason a
declaration can never be — the platform watched it arrive at registration, so the
association between a port and a plugin is observed rather than asserted. The same
distinction that decides the delegation problem in todo/agent-coordination: a claim
is not evidence, whoever is making it.
The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony,
it is the thing that makes the port mean anything. It was added as a shortcut for
Minesweeper, whose x11vnc listens on a known port — which is precisely the case
that looked harmless and generalised into a hole.
Nothing had used it. Minesweeper is still whole in the platform and its extraction
had not started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
No behaviour change. Separated from the conversion because a 40-site rename hiding
inside a string diff is unreviewable.
Read first rather than renamed blindly, and it earned it twice. `\bt\b` matches 93
times in this file and only some are tracks: `const t = ctx.currentTime` is an audio
timestamp in the beep helper, and `border-t` inside classNames matches too — a
blanket rename would have silently corrupted five CSS classes and the typecheck would
not have said a word about them.
So this renames the track bindings only, by property (`t.id`, `t.lang`, `t.codec`,
`t.channels`, `t.bitrate`, `t.title`) and by parameter, leaving the timestamp and the
class names alone.
useFileBrowserApp.ts, the largest single file in the phase at 47 hits — and contrary
to what I predicted, all 47 were convertible. I expected roughly half to be content
and it was none: every one is a toast the reader sees, and the file NAMES in them are
interpolated parameters inside a translated frame, which is where content belongs.
Worth recording because the guess was wrong in the useful direction.
Six hand-rolled plurals (`item${n !== 1 ? 's' : ''}` and friends) are real plural
entries now, checked at 0, 1 and 2 in both locales. Portuguese reorders these — "1
item eliminado" rather than "Deleted 1 item" — which a frame with the count spliced
in could not have expressed.
Closes the gap left by the thunk: resolving at render proves nothing is cached, but
AppPicker and PanelSlot are not subscribed to anything that changes when the locale
does, so a switch updated every string in the app except the panel titles.
An opaque revision, which this package interprets in no way at all. It is a string
that changed; the caller decides what changing means. officer-web sets it to the
active locale from I18nBridge, during render rather than in an effect — an effect
leaves the first paint after a switch showing the previous language's titles.
A module-level store rather than a prop on WorkspaceView, deliberately: that
component has 28 call sites, and a prop is a thing 28 places can forget. A forgotten
one is a silently stale title, which is the failure being closed.
The new test bumps the revision and asserts the name follows with NO rerender from the
test. The existing one calls rerender, so it could only ever prove the absence of
caching — it could not have caught this.
Terminal's metas are in index.tsx and the migration globbed index.ts, so its three
translatable names were never keyed. Found by diffing the remainder against
FileBrowser rather than by reading the glob.
The product names — Tmux, Neovim, Claude Code, Minesweeper, SendOff, and the three
system-monitor scopes, which name bTop, pm2 and docker themselves — carry
i18n-exempt markers now. They were correct as plain strings but the scanner counted
them, so the area could not have reached zero either way.
Everything outside FileBrowser is now thrown Errors only.
The remaining interpolated strings and the shared Cancel/Save/Delete labels. What is
left in Chat is three thrown Errors, which the never-translate category already
excludes.
useAudioRecording used `t` as its media-track loop variable in four places, which the
translation function then shadowed. Fourth panel with that collision.
The bulk of the chat UI: composer, background-job dialog, task list, usage popover,
attachments, the Claude sign-in flow and the message bubbles.
background-job-prompt.ts is exempt in full — the module builds the brief the MODEL
executes, and translating it would change what the agent does. Two marked lines,
both with the reason.
QuestionActivity.test.tsx now renders inside an I18nProvider with the real panel
catalogue. It asserted on 'Other...' and would otherwise have seen the dot-path;
wrapping keeps it asserting the English a person actually reads. Same fix the
DataTable test needed in phase 1.
Scanner, two counting bugs, each delta verified:
- `new Error(`…`)` with a template literal fell through to 'template str' and was
counted as convertible — the opposite of true. Now it lands in thrown errors.
- and it was then matched by BOTH patterns, so it was counted twice while staying
convertible; the template-string pattern now declines what a thrown Error owns.
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a
plugin installed afterwards could never add a socket route. That was a hard
ceiling, not a preference: /minesweeper had to live in the platform and
plugins/desktop could not stream a display at all.
The way out was already in the tree and had one consumer. dev-proxy serves a socket
with no WS_ROUTES entry, through a WILDCARD route whose handler calls
server.upgrade itself. Nothing has to become dynamic, because a plugin name is a
path segment rather than a route — one wildcard declared at boot serves every
plugin that will ever be installed.
Authorisation is the PLUGIN's own capability, asked as if the socket were a GET
against its HTTP prefix. That is the safety argument: there is no second permission
to grant, forget, or drift, so granting a plugin grants its socket and revoking it
closes it. It is also why plugin-ws is exempt from the boot totality check rather
than carrying a registry entry — one entry covering every plugin's socket would
mean granting one plugin granted them all.
A target is a TCP address and never a URL, which keeps the relay ignorant of what
it carries. That ignorance is the only reason one relay can serve an RFB display
and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an
arbitrary host would be a request-forgery primitive handed to every plugin author.
sidecarPort() is new because createSidecarProxy learned announced ports per-proxy
and that state was unreachable from anywhere else — a second subscription would
have been a second thing to keep in step.
Verified: boots past the totality gate, and both an existing and a non-existent
plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte.
Minesweeper is about to be the first caller, which is what proves it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
hooks holds two thrown Errors, already excluded as a neverTranslate category.
The AppRegistry duplicate-key message is a console.error — a log line for whoever is
reading a terminal, which the doc's list covers, so it is marked with i18n-exempt
rather than keyed.
Scanner: a namespaced storage key like `FULLSCREEN_PANEL:${workspace.key}` is an
identifier. The SCREAMING_CONSTANT rule missed it because the interpolated half is
lowercase. Verified the delta is exactly that key and the console line.
Three module-level tab/option tables carried labels, so they carry labelKey now and
the components resolve them.
The hidden-subfolder hint hand-rolled a plural with a ternary ("one is" / "ones are"),
which is a real plural entry now — verified at 0, 1 and 2 in both locales.
OpenCode and Claude are product names, wrapped rather than keyed.
Scanner: the jsx-text pattern now rejects a preceding `=`, so an arrow return type is
not read as prose — `(id: string) => Promise<unknown>` was being reported as the string
"Promise", seven times across the panels. Verified the delta is exactly those seven.
Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each
returning a discriminated result rather than throwing, so a router can say which
rule refused and why instead of parsing an error string. Written by a Sonnet
subagent against the plan; reviewed, exercised against the live database, and
amended here.
The amendment is the reason a review was worth doing. renameRole changes
roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as
its own release — so a rename would leave every holder pointing at a name that no
longer exists. It is unreachable today only by accident: manage-users.ts validates
against the USER_ROLES union, so a runtime-created role cannot be assigned and a
non-system role has no holders. That evaporates the moment part 4 makes UserRole a
plain string, and parts 2 and 4 could land in either order.
So rename now refuses while anybody holds it, sharing one holder-count definition
with delete so the two cannot disagree about what "in use" means. It comes out when
the cascade lands, and then the cascade does the job properly by moving holders
rather than refusing.
Adding that reason made the router's switch non-exhaustive, which the typechecker
caught — the discriminated-result shape earning its keep on its first change.
System roles refuse both rename and delete: the platform's own code names them by
string, so renaming one is a code change that has not happened. Delete never
reassigns holders to a fallback, because that is a permission change nobody asked
for.
Exercised end to end: duplicate, empty name, system rename, system delete, a real
rename, a rename onto an existing name, delete, and delete of something absent.
The four seeded roles survive with the owner counted against Super Admin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
Adds `// i18n-exempt: <reason>`, which exempts the next line. The reason is mandatory
-- the regex will not match a bare marker -- so somebody has to write down why, and
`grep -rn i18n-exempt` lists every one for review.
It exists because CommandHQ's startBrief is a prompt written FOR THE MODEL: the person
sees a short displayText instead, and translating it would change how the agent
behaves. No syntactic rule can tell that from prose, and the alternative was a seventh
scanner heuristic. <Untranslated> covers JSX, this covers everything else, and together
they should mean the scanner needs no further special cases.
The new-commander guide was module-level data whose `body` paragraphs the scanner never
saw -- eight paragraphs of real user-facing prose. It carries titleKey and bodyKeys now
and the renderer resolves them.
Four implicit-return arrows needed bodies to hold the hook.
Repairs my own mistake rather than adding anything. The commit that moved
Untranslated into components/ was meant to widen `reason` to
'foreign-ui-label' | 'product-name' at the same time; its `git add` listed the
pre-move path with stderr suppressed, so the add failed silently, nothing but the
`git mv` rename was staged, and the widening stayed in my working tree.
Consequence, stated plainly: f4ab274d..fa32c82e do not typecheck in isolation,
because the terminal panel uses reason="product-name" against a union that did not
yet have it. The working tree was green throughout, which is exactly why I did not
notice — the per-commit guarantee was broken while the visible state looked fine.
Not rewriting the pushed history: master is shared and another session is pushing to
it, and a force-push to fix a type union is the wrong trade.
The lesson worth keeping is about `git add ... 2>/dev/null`, which turns a bad
pathspec into a no-op that still lets the commit succeed.
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.
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.
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.
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.