Compare commits

...
5 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 89fc3241f6 officer-dev is twenty-six PM2 entries, not thirteen
nothing about the platform changed: all 21 plugins were installed on that
machine so the marketplace UI had something real to render. the paragraph under
this one already says the number tracks plugin installs rather than releases —
this is that working, not an exception to it.

caught by `bun run docs:check`, which is the whole reason the number is checkable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:44:58 +00:00
pastilhasandClaude Opus 5 3552a5a7df officer.dev: account creation exists, and roles are a table
This file said there was no way to create a second account. There is, and has
been since 69a31051 — POST /api/users, owner-gated, with a form under Settings.
platform/CLAUDE.md and docs/roles-and-teams.md carried the same stale claim and
were corrected in the platform repo; this one was missed because I said it was
not in a git repository. It is, through the symlink.

Also records roles as a table with foreign keys onto roles.name, USER_ROLES
gone, and UserRole as a plain string guarded at the write path instead of by the
compiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 23:50:00 +00:00
pastilhasandClaude Opus 5 7c41c56577 officer.dev: the pm2 count tracks plugin installs, not releases
Fourteen while Minesweeper was installed, thirteen once it was removed, with no
platform change either way — a plugin that declares a sidecar writes it into the
generated ecosystem file. Says so now, so the next reader does not treat a
change here as a platform change or expect two machines to agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 20:31:13 +00:00
pastilhas dc91f78f4f officer.dev: the restart rule was stale, and said the opposite
This file said "NEVER run pm2 yourself — tell the owner the exact command." The
owner granted the opposite on this machine on 2026-08-19, so the document and
the instruction disagreed for a day, and user/CLAUDE.md already flagged it as a
local rule to follow "while it says so".

It now says to run the restart and report it. The scope is unchanged and is the
part that mattered: only the process the change needs, never the whole
ecosystem.config.cjs.

Added the caveat the grant did not remove — check who else is live first. The
owner edits the platform using the platform, several sessions share one
checkout, and a restart is the moment a half-applied change from someone else's
work in progress would ship.
2026-08-21 00:24:29 +00:00
pastilhas eec6a4962e install.sh: clone anywhere, run one command
A manifest declares repo path -> target, and the script walks it. Symlinks, not
copies, so an installed file edits the repo and one commit reaches every other
machine. Idempotent — a correct link is a no-op, safe after every pull. A real
file where a link should go is moved to .bak-<timestamp> and reported, because
on a new machine that may be the only copy of something written by hand.

Conditional targets are what make it portable: officer.dev/CLAUDE.md and the
officer-chat-events hook both name ~/officer-dev/platform, so a laptop with no
Officer install gets neither instead of getting a document that lies.

mask-secret is a command now rather than a path only true on this machine, and
user/CLAUDE.md says so. It is a generated wrapper, not a symlink, and that is
forced: mask-secret.py resolves its own path to find mask-hooks.d, so a symlink
would reach back into the repo and run every machine's hooks. The wrapper sets
MASK_HOOKS_DIR to the ones this machine installed and still runs the script from
the repo, so no code is copied.

Verified against a scratch HOME: re-run is all ok, the hand-written file
survives as .bak, and a machine without Officer skips both conditional lines.
2026-08-21 00:24:22 +00:00
6 changed files with 253 additions and 56 deletions
+53
View File
@@ -1,3 +1,56 @@
# claude-mds
CLAUDE.md files and Claude Code configuration, kept under version control.
## Install, on any machine
```sh
git clone git@gitea.officer.dev:pastilhas/claude-mds.git ~/projects/claude-mds
~/projects/claude-mds/install.sh
```
`--dry-run` first if you want to see it before it happens.
Everything is **symlinked, not copied** — so editing an installed file edits the repo, and the change
is one `git commit` away from every other machine. Re-run it after each `git pull`; a correct link is
a no-op.
It will not overwrite anything in silence. A real file where a link should go is moved to
`<name>.bak-<timestamp>` and reported, because on a new machine that file may be the only copy of
something written by hand.
## What lands where
`manifest` is the whole answer, and it is the only place paths are declared. Today:
| repo | target | only if |
| ---- | ------ | ------- |
| `user/CLAUDE.md` | `~/.claude/CLAUDE.md` | always |
| `officer.dev/CLAUDE.md` | `~/officer-dev/CLAUDE.md` | `~/officer-dev/platform` exists |
| `tools/mask-hooks.d/officer-chat-events` | `~/.local/claude-scripts/mask-hooks.d/` | `~/officer-dev/platform` exists |
Plus `mask-secret` on `~/.local/bin`, as a command rather than a path.
Adding a file later is one manifest line, not an edit to the shell.
## The two kinds of file
- **`user/`** — who you are working with and how. Loads in every session on every machine.
- **`officer.dev/`** — one workspace on one machine. Conditional, so it never lands where it would be
a lie.
## mask-secret
Masks a pasted secret everywhere the chat history keeps it:
```sh
printf '%s' "$SECRET" | mask-secret
```
It owns Claude Code's own transcripts. Anywhere else that keeps a second copy of the chat — a
database, a mirror, a log shipper — is a **hook**: an executable in `tools/mask-hooks.d/`, listed in
the manifest with the condition that says which machines it belongs on.
The installed `mask-secret` is a small generated wrapper, and that is deliberate. The script finds its
hooks beside itself and resolves symlinks to do it, so a bare symlink would reach back into the repo
and run *every* machine's hooks. The wrapper points it at the ones this machine installed.
+24 -36
View File
@@ -1,47 +1,35 @@
# TODO
## Bootstrap installer (next session — 2026-08-14)
## Bootstrap installer — BUILT 2026-08-20
**Goal:** clone this repo on any machine, run one command, and every file lands
where the tooling expects it.
`install.sh` plus `manifest`. Clone the repo, run one command, every file lands where the tooling
expects it. See README.md.
git clone <this repo> ~/projects/claude-mds && ~/projects/claude-mds/install.sh
Everything the plan asked for is in: a manifest rather than paths in shell, symlinks rather than
copies, idempotent, conditional targets, backups instead of silent clobber, `--dry-run`.
### Shape
Verified on this machine and against a scratch `HOME`: a re-run is all `ok`, a real file in the way is
moved to `.bak-<timestamp>` and survives, and a machine with no `~/officer-dev/platform` skips both
the workspace file and its hook.
- **A manifest, not a script full of paths.** Declare `repo path -> target path`
in one file and let `install.sh` walk it. Adding a doc later should mean one
manifest line, not editing shell.
- **Symlinks, not copies.** Same reason as here: two copies drift, and the point
of the repo is that an edit lands in git. `ln -sfn`.
- **Idempotent.** Safe to re-run after every `git pull`. Re-linking an existing
correct link is a no-op.
- **Conditional targets.** `user/CLAUDE.md` installs everywhere. `officer.dev/`
only where that workspace exists. A manifest entry needs a "only if this path
exists" condition.
- **Never clobber silently.** If the target is a real file and not our link,
back it up (`.bak-<date>`) and say so. That file may be the only copy of
something written by hand on that machine.
- **Dry run by default, or at least `--dry-run`.** Show what it would link.
**`mask-secret` is a command now**, on `~/.local/bin`, and `user/CLAUDE.md` names it instead of the
hardcoded path that was only ever true here. It is a generated wrapper, not a symlink, and that is
forced: `mask-secret.py` finds hooks at `Path(__file__).resolve().parent/mask-hooks.d`, and `.resolve()`
follows a symlink back into the repo — which carries every machine's hooks, not this one's. The
wrapper sets `MASK_HOOKS_DIR` and still executes the script from the repo, so no code is copied.
### Tools are installed, not referenced in place — DECIDED
### Still open
`tools/` stays in the repo as the source, and the installer puts it under
`~/.local/claude-scripts/` (confirm the name), with `mask-secret` linked onto
`~/.local/bin` so it is a command rather than a path.
That fixes a real bug: `user/CLAUDE.md` currently hardcodes
`~/projects/claude-mds/tools/mask-secret.py`, which is only true on this
machine. Once installed, the doc says `mask-secret` and is true everywhere.
Carry `mask-hooks.d/` across with it, keeping the executable bits. The hooks are
the machine-specific half, so the installer should place only the hooks that
belong on that machine — same "only if this path exists" condition as the docs.
`officer-chat-events` should not land on a laptop with no Officer install, even
though it already exits cleanly when it finds no database.
Update `user/CLAUDE.md` to name the command in the same change, or the doc and
the layout disagree the moment this ships.
- **Bootstrapping the bootstrap.** The machine needs git, an SSH key for Gitea, and the repo URL
before any of this runs. A one-line `curl | sh` would close it, and may be deliberately not worth
having.
- **`settings.json` and hooks.** Not managed here yet. The repo is named for the docs but already
carries `tools/`, so the line is already crossed; the question is whether per-machine settings can
be expressed as one file plus conditions, or whether they are genuinely per-machine.
- **Which files are universal.** `officer.dev/` is the only machine-specific one so far. The
`platform-appstore/` set is a candidate and was never folded in.
- **PATH.** `install.sh` warns when `~/.local/bin` is missing from `PATH` but does not edit a shell
profile. Editing someone's `.zshrc` from an installer is a bigger intrusion than the warning.
### Prior art to discuss: akitaonrails/ai-memory
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Put every file in this repo where the tooling expects it, on any machine.
#
# git clone git@gitea.officer.dev:pastilhas/claude-mds.git ~/projects/claude-mds
# ~/projects/claude-mds/install.sh
#
# Symlinks, never copies. Two copies drift, and the point of the repo is that an edit lands in git.
# Safe to re-run after every pull: a correct link is a no-op. A real file in the way is backed up,
# never overwritten in silence.
#
# --dry-run say what would happen, change nothing
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MANIFEST="$REPO/manifest"
CLAUDE_SCRIPTS="${CLAUDE_SCRIPTS:-$HOME/.local/claude-scripts}"
HOOKS_DIR="$CLAUDE_SCRIPTS/mask-hooks.d"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
STAMP="$(date +%Y%m%d-%H%M%S)"
DRY=0
[[ ${1:-} == --dry-run ]] && DRY=1
say() { printf ' %-7s %s\n' "$1" "$2"; }
run() { (( DRY )) || "$@"; }
# Symlink $2 -> $1, refusing to destroy anything that is not already our link.
link() {
local src=$1 dst=$2
if [[ ! -e $src ]]; then say "MISSING" "$src — not in the repo"; return 1; fi
if [[ -L $dst && "$(readlink -f "$dst")" == "$(readlink -f "$src")" ]]; then
say "ok" "$dst"; return 0
fi
# A real file, or a link somewhere else. It may be the only copy of something hand-written here.
if [[ -e $dst || -L $dst ]]; then
say "backup" "$dst -> $dst.bak-$STAMP"
run mv "$dst" "$dst.bak-$STAMP"
fi
run mkdir -p "$(dirname "$dst")"
run ln -sfn "$src" "$dst"
say "link" "$dst"
}
# `mask-secret` as a COMMAND, not a path — so user/CLAUDE.md can name it and be true everywhere.
#
# A wrapper rather than a symlink, and that is forced: mask-secret.py finds its hooks at
# `Path(__file__).resolve().parent/mask-hooks.d`, and .resolve() follows a symlink back into the repo
# — which holds EVERY machine's hooks. The wrapper points MASK_HOOKS_DIR at the ones this machine
# actually installed, and still executes the script from the repo, so the code is never copied.
install_command() {
local script="$REPO/tools/mask-secret.py"
[[ -e $script ]] || { say "MISSING" "$script"; return 1; }
run mkdir -p "$BIN_DIR" "$HOOKS_DIR"
if (( ! DRY )); then
cat > "$BIN_DIR/mask-secret" <<WRAPPER
#!/bin/sh
# Generated by claude-mds/install.sh — edit install.sh, not this.
export MASK_HOOKS_DIR="$HOOKS_DIR"
exec "$script" "\$@"
WRAPPER
chmod +x "$BIN_DIR/mask-secret"
fi
say "cmd" "$BIN_DIR/mask-secret"
}
main() {
(( DRY )) && echo "dry run — nothing will change"
echo "repo: $REPO"
echo
local kind src dst cond
while read -r kind src dst cond || [[ -n ${kind:-} ]]; do
[[ -z ${kind:-} || $kind == \#* ]] && continue
# A `hook` line has no target column: its destination is derived, so the condition shifts left.
if [[ $kind == hook ]]; then cond=$dst; dst="$HOOKS_DIR/$(basename "$src")"; fi
dst=${dst/#\~/$HOME}
cond=${cond:-}; cond=${cond/#\~/$HOME}
if [[ -n $cond && ! -e $cond ]]; then
say "skip" "$dst — no $cond on this machine"
continue
fi
link "$REPO/$src" "$dst" || true
done < "$MANIFEST"
echo
install_command
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo; echo " note $BIN_DIR is not on PATH — add it, or mask-secret is not a command yet" ;;
esac
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
# What goes where. One line per file; install.sh walks this and nothing else.
#
# kind source (in this repo) target condition
#
# kind link — symlink target -> source. The file lives in git; the edit lands in git.
# hook — a mask-secret hook. Target is derived: $CLAUDE_SCRIPTS/mask-hooks.d/<name>.
#
# condition — optional. A path that must exist on this machine, or the line is skipped.
# This is what keeps officer.dev's workspace file off a laptop that has no Officer.
#
# ~ means $HOME. Columns are whitespace-separated, so no spaces in paths.
link user/CLAUDE.md ~/.claude/CLAUDE.md
link officer.dev/CLAUDE.md ~/officer-dev/CLAUDE.md ~/officer-dev/platform
hook tools/mask-hooks.d/officer-chat-events ~/officer-dev/platform
+57 -19
View File
@@ -28,10 +28,16 @@ This file captures the **cross-cutting operational reality** that neither of tho
After editing anything under `platform/src` (backend *or* frontend), it needs a
**`pm2 restart officer`** to take effect. Forgetting this looks exactly like "my fix didn't
work." Confirm the reload before diagnosing further.
- **NEVER run pm2 yourself — tell the owner the exact command to run.** After a platform change,
print the precise `pm2 restart <process>` command that reloads ONLY the process(es) the change
needs (for platform TS edits that is `pm2 restart officer` alone — never the whole
`ecosystem.config.cjs`, which bounces every sidecar). The owner runs it.
- **Run the restart yourself, and say that you did.** This said "NEVER run pm2 yourself" until
2026-08-20. The owner granted the opposite on this machine: restart when a change needs it rather
than handing over a command to paste. What did NOT change is the scope — restart ONLY the
process(es) the change needs (for platform TS edits that is `pm2 restart officer` alone, never the
whole `ecosystem.config.cjs`, which bounces every sidecar).
- **Check who else is live first.** The owner edits the platform *using* the platform, and often has
several sessions open, so a restart can cut another agent off mid-turn or bounce the process you
are both running inside. Look at `pm2 list`, the session list, the process table. Restarting is
also the one moment a half-applied change from a shared checkout ships — confirm the tree is
coherent (typecheck clean, database schema matching the code) before you bounce anything.
- **Capabilities ARE hot — no restart.** Files under `capabilities/` are re-read from disk on each
run / request (Pi per agent session, the app per request). Edit `TASK.md` / `index.ts` /
`categories.yaml` and it's live immediately. This asymmetry is the #1 source of confusion:
@@ -49,18 +55,33 @@ This file captures the **cross-cutting operational reality** that neither of tho
process that runs `claude` was spawned by the main server, so PM2's tree-kill took it down every time.
A restart now costs the agent a socket reconnect, not the session, and output produced while officer is
down survives — the sidecar writes to `chat_session_events` itself. There is no `officer-claude`.
- **Officer is twenty PM2 entries** (verified 2026-08-07 with `pm2 jlist`): `officer`,
`officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`,
`officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`,
`officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`, `officer-notify`,
`officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`.
**`pm2 list` shows twenty-two** — `mybiblepal-api` and `mybiblepal-audio` are a different project sharing
this PM2 daemon, and nothing here should touch them.
Never `pm2 restart ecosystem.config.cjs` — it bounces every
sidecar including the vault. Use `--only <name>`, or restart the single process the change needs.
This list goes stale every time a sidecar lands; `pm2 jlist` is the source of truth.
- **Officer is TWENTY-SIX PM2 entries here today** (verified 2026-08-27 by `bun run docs:check`, which
fails if this drifts): the five core processes — `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty` — plus `officer-notify`, `officer-marketplace`,
`officer-oas`, and one per installed plugin.
## Environment (this machine)
It was thirteen this morning. **Nothing about the platform changed**: all 21 plugins were installed on
this machine so the marketplace UI had something real to render. That is the paragraph below working
exactly as written, not an exception to it.
It was fifteen on 2026-08-25; `officer-minesweeper` and `officer-transcribe` were uninstalled since,
which is the point of the paragraph below rather than an exception to it.
**This number tracks PLUGIN INSTALLS, not platform releases.** It went to fourteen when Minesweeper was
installed and back to thirteen when it was removed, with no platform change either time — installing a
plugin that declares a sidecar writes it into the generated ecosystem file. Do not read a change here as
a platform change, and do not expect two machines to agree.
It said "twenty" and listed a different set until 2026-08-23 — including `officer-vault`, `officer-slskd`,
`officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`,
`officer-jellyfin`, `officer-gitea`, `officer-music`, `officer-vnc`, none of which run here, while omitting
six that do. That is what a hand-maintained list of processes becomes: accurate on 2026-08-07, and nobody
re-derives a list while editing the sentence around it. `mybiblepal-api` and `mybiblepal-audio` are also
gone; nothing shares this daemon today.
Never `pm2 restart ecosystem.config.cjs` — it bounces every sidecar. Use `--only <name>`, or restart the
single process the change needs. `pm2 jlist` is still the source of truth; the check exists so the copy
cannot silently stop matching it.
- `OFFICER_ITEMS_DIR=/home/pastilhas/dockers/officer.dev/capabilities` ← items live here
- `DATA_PATH=/home/pastilhas/dockers/officer.dev/data`
@@ -161,10 +182,17 @@ keyed on **role**, never on a user, and live in `role_capabilities`; absence den
capabilities (terminal, chat, files, tasks, items, desktop, browser) run as the owner's OS user in the
owner's home and are **never grantable at any level** — that is structural, not a policy default.
What is NOT built: **there is no way to create a second account.** `createUser` has one call site,
`api/auth/bootstrap.ts`, gated on an empty `users` table. No signup route, no invites, no admin
create-user handler — the six accounts in Postgres were inserted by hand. Details and the open defect
list are in `platform/TODO.md` → "Multi-user"; the design write-up is `COMMS/capability-permissions-2026-08-07.md`.
**Account creation now exists** — `POST /api/users`, owner-gated, with a form under Settings → User
Management, plus role change, password reset, Linux provisioning and delete. It landed in `69a31051`; this
file claimed the opposite for days afterwards, and so did `platform/CLAUDE.md` and
`docs/roles-and-teams.md`, which sequenced a phase around building the thing that already existed.
Roles are a table as of 2026-08-23, with `users.role` and `role_permissions.role` as foreign keys onto
`roles.name` with `ON UPDATE CASCADE`. `USER_ROLES` is gone and `UserRole` is `string`, so the guard the
compiler used to provide is now a runtime check at every write path that takes a role from outside.
The design write-up is `COMMS/capability-permissions-2026-08-07.md`; the roles plan and what is still open
is `docs/roles-and-teams.md`.
The single-user claims that used to be all over this repo's docs were corrected on 2026-08-07. If you
find another one, it is stale — `platform/src/servers/capabilities/registry.ts` is the authority.
@@ -225,6 +253,16 @@ actually reset at the providers — it is the only record of which ones.
## Working agreement
- **Delegate the writing.** When a feature is agreed and what remains is typing — a change repeated
across many files, translations, a form, a migration over a list of call sites — hand it to a cheaper
agent, keep working, and review the DIFF when it returns. The owner's architecture, somebody else's
typing, your review in the middle. This is the default here, not a per-task decision, and it does not
need asking for. Read `capabilities/skills/delegating/SKILL.md` before writing the brief: an agent that
has to ask a question has already cost more than it saved. **Pick the cheapest model that will get it
right** — Haiku for mechanical, Sonnet by default, Opus only when the work still holds a decision, which
usually means the brief needs finishing rather than a bigger model. Do NOT delegate anything where a
wrong guess is quiet and expensive — permission checks, credentials, anything that deletes — because reviewing
those properly costs as much as writing them.
- **Fix what you find.** If a defect is diagnosed and you know the fix, apply it — don't stop to ask.
The owner retests immediately and restarts cost nothing. Still ask when it's a genuine product
decision rather than a defect, when it widens scope, or when it's hard to reverse.
+4 -1
View File
@@ -87,7 +87,7 @@ When he pastes a secret, mask it in the stored history as soon as you have
finished using it — same turn, unprompted. He reloads and sometimes shares whole
chat histories, and that is the copy that matters to him.
printf '%s' '<secret>' | ~/projects/claude-mds/tools/mask-secret.py
printf '%s' '<secret>' | mask-secret
It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with
asterisks of the same length, overwriting in place so the live session's appends
@@ -100,6 +100,9 @@ chat — a database, a mirror, a log shipper — declares it as a hook** in
machine-specific stores to this file; add a hook, and describe it in that
project's own `CLAUDE.md`.
`mask-secret` is a command because `install.sh` made it one. It is missing only
where this repo was never installed — see its README.
**Do not mask a throwaway.** He sometimes sets a real password aside, hands you
a temporary one so you can drive an API — Nginx Proxy Manager, a router, a
service admin — and reverts it afterwards. Leave those in the history. They are