Commit Graph
1446 Commits
Author SHA1 Message Date
pastilhasandClaude Fable 5 db96f53bc2 todos: a backburner column, and the parked items on it
backburner joins TODO_STATUSES and the board's columns; the three items in the
backburner theme move onto the status, so parked is now a lane rather than a
folder you have to remember to open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:34:41 +00:00
pastilhasandClaude Opus 5 03601db69c definitions instead of examples
every example is gone from the commander template. what replaces them is
an exact definition of each of the four words, because an example is a
thing to copy and a definition is a thing to reason from — and a commander
copying an example produced a first turn that read like understanding and
was recitation.

trigger: the condition, on a schedule, answering one question and needing
no judgement. action: the work done to ONE item, usually a task the
platform already has. classification: which category a produced item
belongs to — changes where it IS, never what it is. dispatch: doing
something with a result beyond keeping it, off until armed, and the only
one of the four that acts outside the commander's own directory.

with the line that stops the confusion the first real conversation ran
into: storing the result is NOT dispatch. every action keeps what it
produced, here, by default. that is the action finishing, not a decision
anybody has to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:17:14 +00:00
pastilhasandClaude Opus 5 ddabf46bf3 the first turn asks one question, and gets its suggestion from the platform
three corrections, all the owner's, all from watching a real first turn.

it asked too much at once. the section listed five things to settle, so the
commander laid out the trigger, the action AND the destination in its
second message. every question must now be answerable with "yes" or "no, do
this instead" — if the reply needs a paragraph, the question was too big.
turns are cheap; a wrong guess armed for a week is not.

it must not raise the destination at all. output goes in the commander's
own directory, by default and without discussion. i had written "beside the
source is often exactly right", which was wrong and was the thing prompting
the question.

and the suggestion has to come from `GET /api/tasks` and the skills index —
what this platform actually has — not from an example in this file. which
it could not have, because the examples WERE the answer: the first-turn
section spelled out nine .m4a files named memo-<date>-<time>, and the shape
section named "fire on a new recording, transcribe it" as the simplest
commander. it read as a commander that understood the job. it was a
commander reading its own documentation back.

those examples are gone or varied. the template was written from one
commander last night and fitted it perfectly, which proves nothing. the
test is the second one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 04:15:38 +00:00
pastilhasandClaude Fable 5 420cd07507 /todos: a kanban board over the todo/ tree, owner-only
The filesystem is the database: GET reads the tree through the same module the
index generator now imports, PATCH splices frontmatter (status, priority, due,
order) and never touches a body byte, and every write regenerates TODO.md.
Columns open/doing/blocked/done, native drag between them, selection and theme
filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check
green), isSuperAdmin double-gate on the router.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:18:14 +00:00
pastilhasandClaude Opus 5 236572107b declaring a long timeout was giving a shorter one
Bun stores a per-request timeout in ONE BYTE. anything above 255 seconds
clamps to 255 — so a plugin declaring a generous 1800 got a SHORTER
timeout than one declaring nothing at all, which falls through to 0 and is
unlimited. the line meant to fix this is what armed it.

that is what killed every long transcription. 1800 became 255, the upload
took a second, the connection dropped at 4m16s, and whisperx went on and
finished into a dead socket. every time, all night, while three separate
fixes above it were already live and correct.

six plugins declare more than 255 and all six were getting 255:
transcribe, email and music at 1800, jellyfin at 3600, minesweeper and
photos at 600. fixed here rather than by deleting the declaration from one
router, because the other five have the same bomb.

anything Bun cannot represent becomes 0. a plugin asking for longer than
Bun's maximum is asking for "do not cut this off", and 0 is the only value
that means it.

found by the owner, after i had already looked at this file twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 22:10:41 +00:00
pastilhasandClaude Opus 5 61c546ab79 sidecar timeouts: a constant, not an env var
const TIMEOUTS_ON = false, in create-proxy.ts. flip the line to turn them
back on. no environment variable, nothing to set per host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:56:43 +00:00
pastilhasandClaude Opus 5 fdd53b0dd9 one switch for sidecar timeouts
SIDECAR_TIMEOUTS=1 puts the old behaviour back — Bun's built-in fetch
ceiling outbound, and the inbound drop at whatever each plugin declared.
unset, which is the default, means no ceiling on either leg.

one constant in one file rather than a knob per plugin: turning it on or
off should not mean remembering which of eight routers to edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:55:07 +00:00
pastilhasandClaude Opus 5 a321babb28 no timeouts on a sidecar request
a 42-minute recording was transcribed successfully and the result thrown
away. whisperx logged POST /inference 200 OK; the platform had already
answered 502 "transcribe sidecar unreachable" four minutes in. the work was
done, nobody received it, and the error blamed the machine that had just
finished the job.

timeoutSeconds only ever governed the INBOUND leg — how long Bun holds the
caller's connection. the proxy's own fetch to the sidecar had nothing on
it, so it took Bun's built-in ceiling no matter what a plugin declared.
transcribe declares 1800 and still gave up at four minutes.

both legs now: `timeout: false` on the outbound fetch, unconditionally, and
the inbound timeout applied to every proxied request with 0 — no timeout —
as the default. the owner's call, and the right one: long requests will
keep arriving from everywhere and every ceiling is a number somebody
guessed.

an AbortSignal is deliberately NOT used. it cannot raise Bun's ceiling,
only lower it, and it aborts the response BODY too — this proxy carries
video, and jellyfin declares 3600, so a signal would have cut a film at
exactly one hour of playback. i wrote that version first.

and a timeout no longer reports as "unreachable". one means the sidecar is
not there, the other means it is there and still working. that sentence
sent an hour of debugging at the wrong machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:50:47 +00:00
pastilhasandClaude Opus 5 a9dd264a72 a commander's children stay out of the chat list
/chat already hid commanders. it did not hid their spawned agents, and a
commander that fires once an hour spawns one per run — each with its own
transcript, titled after whatever its first message happened to be. one
commander had thirteen sessions under its directory, twelve of them reading
"Read /home/.../procedures/classify-t…". a loop's bookkeeping, filling the
list you keep conversations in.

listCommandHqOwnedSessionIds is the union: commanders plus their children.
kept separate from listCommandHqSessionIds rather than replacing it,
because a CHILD is not a commander — isCommander has to stay false for one
or the composer would offer it the handoff watcher.

/chat/live is deliberately untouched. it is the "what is running right
now" panel, and a spawned agent that is still going belongs there — hiding
it would mean an agent running with nowhere showing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:23:27 +00:00
pastilhasandClaude Opus 5 46ae8051d4 the spawned list collapses
a commander firing once an hour has spawned a hundred children by the
weekend, and every id was rendered as a chip in a wrapping grid above the
transcript. so the longer a commander worked, the more of the screen its
own history took from the conversation you came to read.

collapsed by default, one row: SPAWNED 12, plus how many are still running
and the cost caveat. click to see the ids. local state rather than the URL
— a disclosure is not a selection, nothing addresses it and it should not
survive navigating away.

`n running` is on the collapsed row deliberately. it is the difference
between "twelve happened" and "one is happening", which is the only part
worth interrupting the transcript for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:19:24 +00:00
pastilhasandClaude Opus 5 7e50134200 Stop could not stop a commander that had re-armed
liveLoopPid read the pid file as Number(text.trim()), which is NaN as soon
as there are two lines. the voice-memos commander wrote its pid with `>>`,
so after one re-arm the file had two — and NaN read as "no loop". the Stop
button reported zero processes signalled and left both loops running,
silently. exactly backwards: the more a commander was re-armed, the less
stoppable it became. verified on alpha, where both pids are alive right
now.

so the reader is lenient — every live pid in the file, in order — and
stopCommanderProcesses signals all of them rather than the newest. killing
one of two leaves the folder still watched, which from outside is
indistinguishable from Stop doing nothing.

parseLivePids is split out and tested. the caller resolves a path under
DATA_PATH, so a test of the whole thing would have been a test of this
machine.

three lessons from what the first commander actually wrote, into the
template:

- the DATABASE is a thing behind the platform, and it is the one that gets
  missed. that loop writes nudges with `psql $POSTGRES_URL` when
  PUT /api/chat/sessions/:id/nudge exists. worth knowing: a MEMBER's agent
  could not do this — spawn-as-member.ts builds an env allowlist and throws
  on anything unvetted. the owner's runs with the platform's environment.
- refuse to arm without a credential or a target. that watcher already does
  it, and it is the difference between failing and running all night doing
  nothing while reporting armed.
- overwrite the pid file, append the spawned list. which is the bug above,
  stated where the next commander will read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:15:37 +00:00
pastilhasandClaude Opus 5 dd6d0a8e12 the first turn: look, then ask
setup opened blank. the commander was told the name and the path and then
asked whether it could guess the job — so the first message back was
generic, and the owner had to explain the subject from nothing.

it should read the reference path FIRST and open with what is there: nine
.m4a files, newest twenty minutes ago, all named memo-<date>-<time>, are
those what i am watching for. that is worth ten generic questions, and it
shows immediately whether it is looking at the right thing.

and the section now lists what has to be settled before anything is armed
— what counts as a new item, what happens to one, where the result goes,
whether there is classification at all, how often to look and that the
number is a guess. "have a conversation" was too vague to act on.

the name is a clue, not a specification. it says what the subject is and
never what to do about it, which is how a commander ends up doing
something nobody asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 17:02:02 +00:00
pastilhasandClaude Opus 5 5ee09a3fb6 act, classify and dispatch are optional; the ledger is not
a commander is a trigger, a ledger, and up to three steps between them.
most need fewer than three, and the simplest useful one has no
classification at all: fire on a new recording, transcribe it, write the
text beside the original. at that point it is a schedule over a task that
already exists, which is the right answer rather than a lesser one.

so the layout is conditional now — classification/ only where there is
classification, procedures/ only where an agent is spawned. a directory
that exists and stays empty reads as something broken.

and "never write into what you watch" was too wide. that was aimed at
BOOKKEEPING — ledger, reports, logs, state — which must not pollute the
subject. output goes wherever the job says, and beside the source with the
same name is often exactly right. as written, a commander would have read
it and refused to do the simplest thing anyone would ask of it.

the ledger stays compulsory in every shape. the .md beside the .m4a is
tempting as the record and is output-as-claim again: it breaks the moment
either file is renamed or moved, which is the bug that started all of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:53:23 +00:00
pastilhasandClaude Opus 5 0a242e15ba one directory layout for every commander
classification/ with a directory per category and an unclassified/ that
always exists, plus procedures/, reports/ and state/.

named for the SHAPE, not the payload. the voice-memos commander called it
transcripts/, which bakes one job into the layout — the same structure has
to hold for a commander over email or photos, and none of those produce
transcripts.

and the distinction that makes the two records make sense together: the
ledger says whether an item was HANDLED, the directory says where it
BELONGS. unclassified/ is finished work waiting on a person rather than a
failure, so nothing re-runs the action on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:47:25 +00:00
pastilhasandClaude Opus 5 c6a92735d3 a commander is told its shape at creation
trigger, action, ledger — settled with the owner over several turns and
true of every commander, so it belongs in the file each one reads at
session start rather than being re-derived in conversation each time.

the part that took the longest to see: an action that takes ONE item all
the way through means a half-finished item never exists, so there is
nothing to queue and no second watcher needed to notice it. this commander
was built as two cycles — transcribe everything, then file everything —
and the second cycle existed only to clean up after the first stopped
early.

and the ledger keys on a hash of the SOURCE. names change (a memo renamed
from a phone), outputs move (a transcript filed into a category folder),
and both of those had already caused the same file to be handled twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:38:33 +00:00
pastilhasandClaude Opus 5 cb2dcfa4cc the notify sidecar stops trusting whoever reaches it
to send one message to four accounts an hour ago i found the sidecar's
ephemeral port and POSTed to it directly, once per user, with no
authentication of any kind. that worked, which was the problem: the same
door was open to anything else on the machine, including a commander with
a shell.

two changes.

POST /api/notify/broadcast, owner only via `ownerOnly` on the permission —
the supported way to say one thing to everyone. teams will narrow it later;
today the choice is everyone or one person.

and the sidecar now requires a secret on everything under /_officer/. it
is generated per boot, held in memory, announced to the platform over the
registration socket beside the port, and never written to disk.

X-Officer-User could not carry this on its own, and my first attempt got
that wrong: it says WHO is asking, and a caller on loopback can write it
itself. only the secret says the PLATFORM is asking. so the check covers
the whole /_officer/ surface rather than just the send route — /_officer/
devices takes the same header to decide whose phones are listed and
deleted.

what it bounds, honestly: a MEMBER's commander runs as its own Linux user
and cannot read this process, so for every account but the owner's this is
kernel-enforced. the owner's own commander runs as the owner, same as the
platform, and is not bounded by this and cannot be — the same reason
`execution` permissions are never grantable. the owner's decision, and
this is a real improvement rather than a boundary.

`bun run notify:test` posts to that port and will now be refused. it is a
developer CLI on a box where the platform is running; left to fail loudly
rather than given a way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:29:48 +00:00
pastilhasandClaude Opus 5 de633f8697 a plain message, in the owner's own words
officer/core:test has fixed words and officer/core:commander prefixes a
name, so there was no way to say one sentence to somebody without it
arriving dressed as something else. this entry adds nothing around the
producer's title and body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:13:58 +00:00
pastilhasandClaude Opus 5 16d02ffee1 notify is core, not grantable
it was an `app` permission and nothing granted it, so every Member's app
got a 403 the moment it tried to register its own push token — silently, at
launch, with no notification ever arriving and nothing saying why. four
member accounts on alpha, none of them able to receive anything. invisible
for days because the owner bypasses the check.

the grant was not what was missing. these are the caller's OWN devices, and
the send route takes its target from the authenticated header rather than
the body (resolve-user.ts), so an account can only ever reach its own
phone. "you may not subscribe to your own notifications" is not a coherent
thing to configure.

6 core, 3 app now. docs:check holds the count and the generated platform
skill was regenerated with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:52:54 +00:00
pastilhasandClaude Opus 5 40a03d987f tell an existing commander how to reach its owner
the voice-memos commander was asked for a notification and reached for its
harness's own push tool — a different channel, which refuses to interrupt a
live terminal. it did that because nothing had told it Officer has a route
of its own. that rule is the harness's; the platform's route has none, and
had already pushed to a phone twice with the owner sitting at the terminal.

so the notify part is its own section now, appendable on its own, and the
backfill lands it on a commander that already has a credential — which is
every commander that predates this, i.e. the ones that need it.

the backfill also stopped being all-or-nothing. it skipped everything after
the credential check, so a commander with an officer.env could never
receive anything added later. each section is now appended independently,
keyed on a marker the section itself contains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:31:37 +00:00
pastilhasandClaude Opus 5 2d5e9835f5 the catalogue shapes a commander's words, not the commander
"it should always say Commander: <name>" was left to whether the commander
remembered to write it. that is not a structure, it is a hope — and the
producer's title reached the lock screen untouched, because both transports
built the alert from n.title rather than from the renderer.

so the code owns the shape and the producer only supplies values.
officer/core:commander is `Commander: {title}` / `{body}`; the commander
sends "Voice Memos", never the prefix. interpolation gains {title} and
{body} alongside {n} and {id}, for the same reason the others exist.

apns-payload and fcm now send the RENDERED text. that was the actual
delivery bug: the catalogue could say whatever it liked and neither
transport read it.

a placeholder that resolves to nothing takes its punctuation with it, or
`Commander: {title}` with no title ships `Commander: ` and every template
has to assume its values are always present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:24:17 +00:00
pastilhasandClaude Opus 5 478ddb13e9 a commander can say something in its own words
asked whether the voice-memos commander could send a notification, the
answer was no, three times over. the platform skill gives it the prefix
/notify and not the route. the route wants a catalogue code, not a title
and a message. and of the six codes that exist, none means "a commander has
something to tell you" — the nearest renders "Test notification".

so: officer/core:commander, one entry rather than one per commander, with
the commander travelling as {id}. arming a new one needs no catalogue
change and no app resync.

and `title`/`body` on the notification itself, which override the
catalogue. this is a deliberate exception to the rule the whole design
rests on — content does not travel — and it is the owner's decision, made
2026-08-24. a commander's output is one sentence only it could write; there
is no entry to render it from, and "1 item" is not worth waking a phone
for. sendoff.landed already carries the same relaxation for the same kind
of reason.

the control sits at the PRODUCER, not the transport. the commander's
CLAUDE.md now says what may go in those two fields and what may never — and
that a notification is an interruption, so send few. this file cannot judge
what is sensitive and does not try.

`title` is the opt-in, not `body`. the existing smuggle test passes a stray
`body` and asserts it does not reach the wire; a body that travelled on its
own would have silently defeated it. pinned on both transports.

FCM gets a `notification` block only for producer text — data-only is what
lets the app compose from its catalogue, and text with no entry to compose
from would arrive and show nothing.

worth recording: there is NO Notification Service Extension, in this repo
or the mobile one. mutable-content: 1 is set for something never built, so
aps.alert is what the phone actually shows and this works with no app
release. i said the opposite an hour ago, from the comments rather than
from looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:13:39 +00:00
pastilhasandClaude Opus 5 00baa83a2c a commander's own files, beside its transcript
a commander writes into its own directory — procedures, reports, the loop
it armed — and there was no way to look at any of it. /files cannot reach
it and never could: the file browser serves two roots, `home` and
`user-data`, and a commander lives at $DATA_PATH/<email>/commanders/<dir>,
which is under the one /files does not use.

so the Plan button becomes a files button on a commander. instead, not
beside: plan mode is research-and-propose-until-a-human-approves, and a
commander is the case where nobody is watching to approve. the control
promised something that cannot happen there, while the thing genuinely
missing was any view of what it had written.

an EPHEMERAL panel, the same trade /files makes for the file viewer.
/command-hq is a locked workspace with a three-item allow-list whose
comments are explicit that its structure is code's rather than the user's;
a persisted panel would mean unlocking it and widening that list. an
ephemeral layout renders beside workspace.value and never enters it, so
neither has to change.

?files=1 rather than a route, and a panel rather than a link: the
transcript stays mounted and stays the page, and a link on this surface
opens a second copy of the whole app in a new tab and loses the
conversation you were reading.

breadcrumbs stop at the commander for free — Breadcrumb renders relative
to basePath and its home crumb IS the base. worth knowing that this is the
UI's floor and not the server's: the root really is all of user-data, which
is the owner's own data either way, so it bounds the navigation rather than
the account.

the button reads its state from props rather than useSearchParams.
ModelSelector is rendered bare in its own tests and reaching for the router
inside it failed all twelve — a control that has to be mounted in a route
to render is not a control any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 14:22:58 +00:00
pastilhasandClaude Opus 5 a3a8516e66 backfill: a credential for a commander that predates them
a commander made before credentials existed has no officer.env and no key
row, so it cannot reach the platform at all — the state that made the first
one curl whisper directly. deleting and recreating it would fix that and
throw away the conversation, which is the one thing a commander IS: a
long-running agreement about a job.

so this is additive and re-runnable. a commander that already has one is
left alone. CLAUDE.md is APPENDED to, never rewritten — it is a living
document its commander has been extending, and surviving is the whole
point of not recreating it — and only when it does not already mention
officer.env, so a hand-edited one does not end up with the section twice.

the credential section moved out of commanderClaudeMd for this: it is now
written in two places, and the backfill needs a self-contained section
rather than a slice of one file's layout.

verified locally: minted for the one commander here, second run reported
"already has a credential" and changed nothing, file is 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:57:01 +00:00
pastilhasandClaude Opus 5 12a9605b01 a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.

so a credential now narrows two ways, and both only subtract:

  permissions   an ALLOW list. exact, a snapshot. right for a credential
                with one job.
  excludedKinds kinds it may never reach, resolved against the LIVE
                registry on every request. right for a credential whose
                job is open-ended.

a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.

verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.

the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:47:46 +00:00
pastilhasandClaude Opus 5 e29a4ee380 a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.

api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.

checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.

it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.

POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.

script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.

a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.

default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.

also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 13:40:42 +00:00
pastilhasandClaude Opus 5 247291eeb9 tasks: a direct run runs, it does not queue
"direct" meant a job row and a link to the jobs page. that is not direct —
it is the same work with a screen nobody opened between you and the result.

inline decides which kind of direct run it is, which is what that field has
always meant. true: run it here and wait, a toast while it works and the
listing refreshed when it lands — the file viewer's old transcribe button,
reached by the menu instead of a header icon. false: a background job,
because a task saying inline false is saying it expects to outlive the
click.

runTaskNow is the promise-shaped sibling of useTaskRunner: same socket,
same protocol, opened for the run and closed after it. the hook could not
be reused — it holds a connection for as long as it is mounted, and a hook
in the file browser would open one per panel, permanently, for a path that
fires on a right-click.

a socket that closes before exit rejects. it did not finish, and a success
toast would be a lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:47:16 +00:00
pastilhasandClaude Opus 5 121ddb9964 tasks: run straight from the menu when there is nothing to ask
a task that takes real options — which audio tracks to keep, what format
to convert to — has to ask, and the runner modal is for that. a task that
is one command over the clicked file has nothing to ask, and putting a
form in front of it with one pre-filled field and a Run button is a click
that decides nothing.

derived, not declared. the rule is whether anything is left over once the
file browser has filled in what it knows, which is two things and only
two: file_path by name, and any input declaring autofill entry_name /
entry_path. a default does NOT count — a default is a suggestion and the
user has to see it to change it. agentic, pipeline, inline true and
inline 'ask' always take the modal; the last because the choice is itself
the question, and the one before it because the modal is where an
ephemeral run's output goes.

and the reason the transcribe task's one field came up EMPTY: inputs is a
list in TASK.md and a map everywhere else. the app's own task-creation
guide teaches the list form, so that is what gets written; every consumer
reads the map. a list arrived at the form as Object.entries(array), key
"0" — so the input rendered nameless, was never matched against what the
browser auto-fills, and would have run with nothing in it. normalised in
parseTaskMd, which fixes the form, the INPUT_<NAME> env vars and the
positional args together. the map form still passes through.

GET /tasks now carries inline and inputs so the menu can decide without a
second request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:40:07 +00:00
pastilhasandClaude Opus 5 6d673a1321 plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and
notification catalogues already loaded from each installed and enabled
plugin's own directory; tasks did not, so the transcribe plugin shipped a
task nothing could see — right-clicking an .m4a offered no Run Task at all.

it turned out to be one function rather than several. every path that runs
a task resolves it through getTaskByDirName and then reads implementation
off the record, so the sources go in there and in listTasks and a plugin's
task is runnable everywhere at once, identity unchanged: still a directory
name, still what pipeline_jobs stores.

the record carries `plugin` and that one field does the rest. updateTask
and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the
plugin, and the capability screen shows whose it is instead of an edit and
a delete that cannot work. without the delete guard it was quietly a no-op:
rm -rf on a path in the owner's store that does not exist, then ok: true,
and the task still in the menu.

owner's store first, so a plugin cannot take over a name already in use.

getTaskByDirName now refuses a name containing a separator. it always
joined a caller-supplied string onto a path — from a websocket message and
a job row — and searching several roots is the moment that stops being one
directory nobody could escape.

capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and
carries the provenance, with capabilityRoots the thin map over it that the
loaders that only want paths still use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:20:18 +00:00
pastilhasandClaude Opus 5 50e96e0d77 plugin settings: the client already adds /api, and a Test button
Save returned 404 and the form silently showed what was typed rather than what
was stored. Both from one mistake: useClient prefixes /api itself, so
`/api${prefix}/config` reached /api/api/transcribe/config. Every other caller in
the tree passes a bare path; I copied the shape of a curl instead.

The GET failing was the worse half, because it looked like it worked — an empty
field populated by typing is indistinguishable from one populated by the server.

Adds Test, which asks the plugin's /_health whether its connection works NOW. The
plugin validates on save, so a stored value has been reached once — but once,
whenever that was, is not the same as now, and the server it points at is a
separate machine that can stop answering. Offered only once something is stored,
since there is nothing to test before that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:07:16 +00:00
pastilhasandClaude Opus 5 c427cb5b61 plugins: a backend plugin can have settings
A plugin with no web/ has no route, no dock tile and no panel — so it had
nowhere to put a setting. transcribe shipped exactly like that: installed,
running, and configurable only by an HTTP call somebody had to already know
about.

The plugin DECLARES its fields and the platform renders them, on the plugin
detail page — the one screen that exists for every plugin whether or not it has
a frontend. A component would have survived settings too complicated to
describe, at the cost of every backend plugin carrying a frontend to draw one
text field, each looking slightly different.

The platform stores nothing. It is a form over the plugin's own /config: GET to
populate, PUT to save. So a plugin that validates before saving still does, and
one that keeps a credential keeps it where it already kept it.

A `secret` field is write-only across the boundary — never read back, rendered
as a password, and left out of the PUT when untouched so an untyped box is not
read as "clear it". The plugin reports whether it HAS one, never the value.

Eight strings across all six locales, because the key type refuses anything
else — which is the localisation work from earlier tonight doing its job.

1426 pass, tsgo clean, docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:00:04 +00:00
pastilhasandClaude Opus 5 a10757ba7c marketplace: transcribe in the catalogue
Without an entry the plugin is invisible to every server but the one it was
built on — the catalogue is what a remote host reads, and the bundled constant
is only the fallback when marketplace.officer.dev cannot be reached.

Declares no osDependencies, matching the plugin's own manifest: it reaches
whisper.cpp with fetch and shells out to nothing. An entry claiming ffmpeg would
make an install demand a binary the code never calls.

Synced. 19 in the catalogue, and the repository answers an anonymous https clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:39:54 +00:00
pastilhasandClaude Opus 5 e35ff5c7ad stt leaves the platform for the transcribe plugin
Speech-to-text is now plugins/transcribe — sidecar, its own config, its own
skill and a task for the file browser's context menu. The transcription logic
moved BYTE FOR BYTE: 416 lines whose every decision has a reason attached, from
the two-pass detect-then-transcribe to the 25 language priming prompts to
timeout:false. Verified identical rather than assumed.

Removed here: api/stt/, server-settings/stt.ts and its mount, POST /chat/stt,
POST /file-browser/transcribe, the FileViewer handler behind the button hidden
last release, the Settings screen, the dead useAudioRecording hook and the
orphaned locale keys across all six locales.

blobToWav stayed. It lived in the same file as the dead hook and is still used
by DictateDialog — unrelated to STT, and deleting by file rather than by use is
how that would have gone.

No permission entry to remove: STT never had one, living under server-admin,
chat and files. The registry count is unchanged and docs:check is green.

The plugin ships a task that will not appear in the menu yet — the platform
loads a plugin's skills, affordances and notifications from its own directory
but not its tasks. That loader is the remaining piece of docs/plugin-capabilities.md
and is deliberately not built here.

1426 pass, tsgo clean, 18 plugins discovered with none broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 03:29:58 +00:00
pastilhasandClaude Opus 5 5dec221a68 file viewer: hide the transcribe button
It calls POST /file-browser/transcribe, which refuses on every server here — no
STT endpoint is configured anywhere, so it is a control that can only fail. One
of those teaches people to distrust the controls beside it, and OCR and extract
audio sit right there.

Hidden rather than deleted. Speech-to-text is leaving the platform for a plugin,
and the wiring is the useful part: the handler, the loading state and the
sibling-markdown behaviour in FileViewerProvider are all still correct, so when
the plugin exists this becomes a call to it rather than a rediscovery.

It may not come back here at all — the other route to the same job is the file
browser's context menu, which a plugin reaches with a task, and that needs no
button in this header. The comment says so, so whoever finds `false &&` knows it
is a decision rather than debugging left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:55:00 +00:00
pastilhasandClaude Opus 5 b6d6de7e53 commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.

The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.

commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.

Four things found in review, none of which the build had.

Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.

getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.

The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.

And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.

Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.

The single existing commander was deleted rather than migrated, per the design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:10:37 +00:00
pastilhasandClaude Opus 5 75d5a795a2 i18n: parity checks itself, over the locales actually on disk
master was out of parity and every test was green. sv-SE named
commandHq.referencePath and referencePathHint; en-GB named workingDirectory and
cwdHint. Two reasons neither was caught.

The three parity tests LISTED their locales literally, so adding a sixth added a
file none of them looked at. A guard that has to be maintained to keep guarding
is the thing it exists to prevent — the same shape as the bug that made three
locales ship dead last night. They now read the directory.

And findMissingKeys is one-directional: it asks whether every English key is
present, so a rename reports half of itself. The new key is missing and caught;
the stale one the translation still carries is invisible. findExtraKeys is the
other half, and both are asserted.

Realigns sv-SE onto the committed key names. It was translated from a working
tree where that rename existed and was not yet committed — the commander
directory work, still unreviewed — so on master it named keys English did not
have. The rename will land across all six locales together when that work does.

Also asserts there IS at least one locale besides en-GB, because otherwise every
assertion passes over an empty set and reports nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 02:02:40 +00:00
pastilhasandClaude Opus 5 1600a57b66 add swedish (sv-SE) locale — all three catalogues, wired in all four registration points
538 app.* + 541 panel.* + 43 ui.* = 1122 keys, full parity with en-GB. Registered in
catalogues.ts, locales.ts SHIPPED_LOCALES, and both workspace locales/index.ts —
the four-edit checklist docs/localization.md calls out, since a missed import is
what shipped es-ES/de-DE/fr-FR dead the first time.

files.menu.selected (officerdev) was number-invariant in English ("selected") but
needs one/other in Swedish ("vald"/"valda"), same class of bug as the "1
seleccionados" fix in es-ES — split it rather than copying the English shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 01:59:09 +00:00
pastilhasandClaude Opus 5 2a0f688e10 plugins: installing one grants it to every role
The default was deny-everything: a newly installed plugin reached nobody, and
every grant had to be added by hand, per role, on a different screen, by the
owner. That made sense while the permission model was being built and there were
no members. It stopped making sense once installing a plugin became a deliberate
administrative act — somebody who installs a thing has already decided the server
should have it, and a second decision elsewhere has one reliable outcome: it gets
forgotten and the plugin looks broken.

So install grants, the owner revokes. uninstallPlugin already removed those
grants, so the pair is symmetric.

ROWS, NOT A RULE CHANGE. authorize.ts still denies on absence. The other way to
get this behaviour is to make absence mean allow, and that is the version that
bites in a year: every capability with no row for any reason — a failed write, a
half-finished install, a bug — becomes permitted and nothing looks wrong. The
blast radius here is only ever a plugin's own keys; core, execution, admin and
confined are unreachable from it.

Super Admin is skipped because ck_role_permissions_not_owner refuses it outright
and would fail the whole insert. The owner bypasses grants anyway.

Also fixes something older that would have made this half-true. A role created
through /api/roles began with NOTHING — DEFAULT_ROLE_PERMISSIONS was only ever
applied in bootstrap, so a new role could not reach chat, files or terminal while
Member could, and nothing said why. A new role now starts where Member does, plus
every installed plugin. Leaving it would mean the policy held for roles that
existed when a plugin was installed and not for any created after, which reads as
a bug in whichever half you meet first.

Verified against the live database with a throwaway role and a throwaway plugin:
the new role seeded with the confined baseline plus every installed plugin, the
grant reached all four roles and never Super Admin, and cleanup left the table
exactly as it was. 1417 pass.

NOT done: existing installs. browser, email and offscale are installed here and
granted to nobody, because they predate this. A backfill is one query and is the
owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 01:39:22 +00:00
pastilhasandClaude Opus 5 54f9f24726 uploads land atomically: write aside, then rename
Bun.write(filePath, file) streams to the name the file will end up with, so for
as long as an upload takes, that name exists holding a partial file and nothing
watching the folder can tell. The failure is silent rather than loud — a
truncated m4a decodes fine, it is just short — so a watcher transcribing voice
memos as they arrive would produce a clean transcript of the first few seconds
of a two-minute recording and report success.

Writing to <name>.part and renaming makes the final name either absent or
complete. Every consumer gets that for free: the phone's own uploadVerified, a
watcher, an agent reading the folder. The alternative was each of them inventing
"the size stopped changing", which cannot tell a finished upload from a stalled
one.

SendOff had the same shape and it matters more there, because that is a folder
things arrive in unattended — neither a recipient watching it nor the push
notification may name a file still being written.

.part rather than a dotfile, deliberately: a leftover should be VISIBLE, named
for what it was going to be, beside where it belongs. A failed write cleans up
after itself; only an interrupted process leaves a fragment, which is the case
worth being able to see. Nothing sweeps them — a job deleting files it did not
create is a worse trade than a fragment somebody can spot.

Server-side only. The phone uploads exactly as before and its verification read
happens after the response, by which time the rename has landed. No new build.

Verified: a watcher polling every millisecond through a 40 MB write never saw a
partial file under the final name, and a real 8 MB upload through the live route
left the file complete with no .part behind. 1422 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 00:12:33 +00:00
pastilhasandClaude Opus 5 8770f98dcd capabilities: read a plugin's where they live, stop copying them in
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
2026-08-23 23:19:11 +00:00
pastilhasandClaude Opus 5 99149443b7 plugins: a plugin can ship a task
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
2026-08-23 23:02:07 +00:00
pastilhasandClaude Opus 5 7370a422d1 stt: priming prompts for 23 more languages
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
2026-08-23 22:37:32 +00:00
pastilhasandClaude Opus 5 c38f02afa3 roles phase 3: dev tools are a row, not a hardcoded name
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
2026-08-23 21:32:42 +00:00
pastilhasandClaude Opus 5 4ca4becaa8 plugins: a dependency can declare the architecture its package needs
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
2026-08-23 18:24:54 +00:00
pastilhasandClaude Opus 5 6e092c32ea marketplace: minesweeper in the catalogue
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
2026-08-23 18:07:50 +00:00
pastilhas 1ff271bb5e localization: fix two defects found by actually rendering the shipped locales
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.
2026-08-23 17:50:28 +00:00
pastilhasandClaude Opus 5 fbdfd3fb89 minesweeper leaves the platform; plugin sockets relay ws-to-ws
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
2026-08-23 17:43:43 +00:00
pastilhasandClaude Opus 5 6490dbc368 localization: three shipped locales were never loaded
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
2026-08-23 17:38:55 +00:00
pastilhas d7a4a18ce8 localization: ship es-ES
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.
2026-08-23 17:21:37 +00:00
pastilhas d6a3bea7c9 localization: ship de-DE
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.
2026-08-23 17:19:59 +00:00
pastilhas 0457bf9ff3 localization: ship fr-FR
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.
2026-08-23 17:17:50 +00:00