Compare commits

..
13 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
pastilhas 63ab090bb2 never suggest stopping 2026-08-19 07:54:12 +00:00
Claude c21a0725cb user: how to actually show him an image
Asked three times on 2026-08-19 to see a generated icon and answered with a
Preview window, a hunt for terminal image tools, and a hyperlink before an image
appeared. Nothing said he reads through a markdown client, or that the file has
to be public before a tag can reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:58:31 +00:00
pastilhas 4036f78cc3 note: reference only, not adopting mcp 2026-08-13 02:37:40 +00:00
pastilhas 3e2a997e58 note ai-memory as prior art for tomorrow 2026-08-13 02:35:33 +00:00
pastilhas 82f9907d26 decide tools install to ~/.local/claude-scripts 2026-08-13 02:13:50 +00:00
pastilhas ed925a14b8 note the bootstrap installer design for next session 2026-08-13 02:10:07 +00:00
pastilhas 28d58728cf split machine-specific stores into mask hooks 2026-08-13 02:08:18 +00:00
pastilhas c6cb958ba5 do not mask throwaway credentials 2026-08-13 02:05:51 +00:00
8 changed files with 460 additions and 62 deletions
+53
View File
@@ -1,3 +1,56 @@
# claude-mds # claude-mds
CLAUDE.md files and Claude Code configuration, kept under version control. 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.
+78
View File
@@ -0,0 +1,78 @@
# TODO
## Bootstrap installer — BUILT 2026-08-20
`install.sh` plus `manifest`. Clone the repo, run one command, every file lands where the tooling
expects it. See README.md.
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`.
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.
**`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.
### Still open
- **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
<https://github.com/akitaonrails/ai-memory> — read 2026-08-13. Solves a superset
of this: cross-vendor long-term memory so you can quit Claude Code mid-task and
continue in Codex without re-explaining. A Rust binary plus a data dir, served
over MCP, with `install-mcp` and `install-hooks` subcommands.
Worth stealing even if we never run it:
- **`_global` scope.** A reserved project that is unioned into every query —
standing personal rules, tech choices, style. Exactly what `user/CLAUDE.md`
is. Confirms the split we already made.
- **Page categories**: `_rules/`, `decisions/`, `procedures/`, `gotchas/`.
Better than one growing file. Our `user/CLAUDE.md` is six sections and will
not stay readable at twenty.
- **Authority-aware retrieval.** Maintained rules outrank episodic session
evidence, but do not become absolute. A good answer to "the doc says X but
the code does Y" — which bit us today with the stale `pm2` rule.
- **`.ai-memory.toml` marker file** in a repo ancestor to pin which
workspace/project a directory belongs to. Directly relevant to our manifest's
"only if this path exists" condition — a marker is more explicit than a path
test.
- **`bootstrap`** seeds pages from `git log`, README, `docs/`, module headers.
The thing we would want when adding a new machine or repo.
**His position, stated up front: not a fan of MCP, and never was.** So this is a
source of ideas, not a thing to adopt. Do not open tomorrow by proposing we
install it. Harvest the taxonomy and the concepts, build our own on plain files.
The real decision: it is a **running service** (daemon, SQLite, FTS5,
embeddings, MCP, hooks). Ours is plain files and symlinks, readable with `cat`,
and works when nothing is installed. That simplicity is a feature, especially
given today's "code on disk is not what is running here" problem. Options:
adopt it, borrow the taxonomy only, or stay flat and revisit when the flat file
gets unreadable.
### Open questions
- Which files are genuinely universal vs this-machine-only? Right now
`officer.dev/CLAUDE.md` is the only machine-specific one, but the
`platform-appstore/` set is a candidate and was never folded in.
- Should `settings.json` / hooks be managed here too, or only `.md` files? The
repo is named for the docs but already carries `tools/`.
- Bootstrapping the bootstrap: the machine needs git and the repo URL before any
of this runs. Worth a one-line `curl | sh`, or deliberately not.
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 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 **`pm2 restart officer`** to take effect. Forgetting this looks exactly like "my fix didn't
work." Confirm the reload before diagnosing further. work." Confirm the reload before diagnosing further.
- **NEVER run pm2 yourself — tell the owner the exact command to run.** After a platform change, - **Run the restart yourself, and say that you did.** This said "NEVER run pm2 yourself" until
print the precise `pm2 restart <process>` command that reloads ONLY the process(es) the change 2026-08-20. The owner granted the opposite on this machine: restart when a change needs it rather
needs (for platform TS edits that is `pm2 restart officer` alone — never the whole than handing over a command to paste. What did NOT change is the scope — restart ONLY the
`ecosystem.config.cjs`, which bounces every sidecar). The owner runs it. 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 - **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` / 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: `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. 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 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`. 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 is TWENTY-SIX PM2 entries here today** (verified 2026-08-27 by `bun run docs:check`, which
`officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`, fails if this drifts): the five core processes — `officer`, `officer-anthropic-proxy`,
`officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`, `officer-claude-code`, `officer-opencode`, `officer-pty` — plus `officer-notify`, `officer-marketplace`,
`officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`, `officer-notify`, `officer-oas`, and one per installed plugin.
`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.
## 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 - `OFFICER_ITEMS_DIR=/home/pastilhas/dockers/officer.dev/capabilities` ← items live here
- `DATA_PATH=/home/pastilhas/dockers/officer.dev/data` - `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 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. 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, **Account creation now exists** — `POST /api/users`, owner-gated, with a form under Settings → User
`api/auth/bootstrap.ts`, gated on an empty `users` table. No signup route, no invites, no admin Management, plus role change, password reset, Linux provisioning and delete. It landed in `69a31051`; this
create-user handler — the six accounts in Postgres were inserted by hand. Details and the open defect file claimed the opposite for days afterwards, and so did `platform/CLAUDE.md` and
list are in `platform/TODO.md` → "Multi-user"; the design write-up is `COMMS/capability-permissions-2026-08-07.md`. `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 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. 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 ## 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. - **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 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. decision rather than a defect, when it widens scope, or when it's hard to reverse.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Officer platform: mask a secret in `chat_session_events`.
SPECIFIC TO THIS MACHINE. Officer's sidecar commits every turn — assistant text,
tool inputs and tool outputs — to Postgres, so the web chat can replay a session
after a restart. That makes it a second readable copy of the conversation, and
the one a browser shows. Rows age out after 7 days (`api/chat/retention.ts`);
this masks them now rather than waiting.
Contract: secret on stdin line 1, mask on line 2. Print one line. Exit non-zero
only if the store exists but could not be updated.
"""
import os
import subprocess
import sys
DB = os.environ.get("OFFICER_PGDB", "officer_dev")
HOST = os.environ.get("OFFICER_PGHOST", "127.0.0.1")
USER = os.environ.get("OFFICER_PGUSER", "postgres")
PASSWORD = os.environ.get("OFFICER_PGPASSWORD", "postgres")
def main() -> int:
lines = sys.stdin.read().splitlines()
if len(lines) < 2:
print("bad input: expected secret then mask")
return 2
secret, mask = lines[0], lines[1]
quote = lambda v: "'" + v.replace("'", "''") + "'"
stmt = (
"update chat_session_events "
f"set event = replace(event::text, {quote(secret)}, {quote(mask)})::jsonb "
f"where event::text like {quote('%' + secret + '%')};"
)
try:
out = subprocess.run(
["psql", "-h", HOST, "-U", USER, "-d", DB, "-tAc", stmt],
capture_output=True, text=True, timeout=30,
env={**os.environ, "PGPASSWORD": PASSWORD},
)
except FileNotFoundError:
print("skipped: psql not installed")
return 0
except subprocess.TimeoutExpired:
print("skipped: psql timed out")
return 0
if out.returncode != 0:
err = out.stderr.strip().splitlines()
# Not this machine, or the platform is not installed here. Not a failure.
if any("does not exist" in e for e in err):
print("skipped: no officer database here")
return 0
print(f"FAILED: {err[0][:100] if err else 'unknown error'}")
return 1
count = out.stdout.strip().split()[-1] if out.stdout.strip() else "0"
print(f"chat_session_events: {count} row(s) masked")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33 -37
View File
@@ -12,9 +12,14 @@ session is appending to its transcript while this runs, so the file is opened
the writer never notices. A `sed -i` would swap the inode out and the running the writer never notices. A `sed -i` would swap the inode out and the running
session's appends would vanish into an unlinked file. session's appends would vanish into an unlinked file.
Covers both stores a human can actually read back: WHAT THIS OWNS: Claude Code's own stores, on any machine —
- Claude Code transcripts ~/.claude/projects/**/*.jsonl `~/.claude/projects/**/*.jsonl`. Nothing else is universal.
- Postgres chat_session_events (what the web chat replays)
ANYTHING ELSE IS A HOOK. A machine or platform that keeps its own copy of the
chat (a database, a log shipper, a mirror) drops an executable into
`mask-hooks.d/` beside this file, or wherever `MASK_HOOKS_DIR` points. Each hook
receives two lines on stdin — the secret, then the mask — and prints one line
saying what it did. Hooks are specific to a place; this file stays general.
One paste lands in a transcript about three times — the `user` message, a One paste lands in a transcript about three times — the `user` message, a
`queue-operation` record, and a rolling `last-prompt` cache — plus once more for `queue-operation` record, and a rolling `last-prompt` cache — plus once more for
@@ -27,10 +32,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
PROJECTS = Path.home() / ".claude" / "projects" PROJECTS = Path(os.environ.get("MASK_PROJECTS_DIR", Path.home() / ".claude" / "projects"))
PGDB = os.environ.get("MASK_PGDB", "officer_dev") HOOKS_DIR = Path(os.environ.get("MASK_HOOKS_DIR", Path(__file__).resolve().parent / "mask-hooks.d"))
PGHOST = os.environ.get("MASK_PGHOST", "127.0.0.1")
PGUSER = os.environ.get("MASK_PGUSER", "postgres")
def mask_file(path: Path, secret: bytes, mask: bytes) -> int: def mask_file(path: Path, secret: bytes, mask: bytes) -> int:
@@ -50,29 +53,27 @@ def mask_file(path: Path, secret: bytes, mask: bytes) -> int:
return 0 return 0
def mask_postgres(secret: str, mask: str) -> int: def run_hooks(secret: str, mask: str) -> None:
"""Replace inside the jsonb payload. Returns rows updated, -1 if unavailable.""" """Every executable in HOOKS_DIR gets secret+mask on stdin."""
sql = ( if not HOOKS_DIR.is_dir():
"update chat_session_events set event = " return
"replace(event::text, %(s)s, %(m)s)::jsonb " hooks = sorted(h for h in HOOKS_DIR.iterdir() if h.is_file() and os.access(h, os.X_OK))
"where event::text like %(l)s;" if not hooks:
) return
# psql has no bind params, so quote by doubling any single quotes. The secret print(f"\nhooks ({HOOKS_DIR}):")
# is hex/base64 in practice, but never build SQL from unchecked input blindly. for hook in hooks:
q = lambda v: "'" + v.replace("'", "''") + "'" try:
stmt = sql.replace("%(s)s", q(secret)).replace("%(m)s", q(mask)).replace("%(l)s", q(f"%{secret}%")) out = subprocess.run(
env = {**os.environ, "PGPASSWORD": os.environ.get("MASK_PGPASSWORD", "postgres")} [str(hook)], input=f"{secret}\n{mask}\n",
try: capture_output=True, text=True, timeout=60,
out = subprocess.run( )
["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt], line = (out.stdout.strip() or out.stderr.strip() or "(no output)").splitlines()[0]
capture_output=True, text=True, env=env, timeout=30, status = "" if out.returncode == 0 else f" [exit {out.returncode}]"
) print(f" {hook.name}: {line}{status}")
except (FileNotFoundError, subprocess.TimeoutExpired): except subprocess.TimeoutExpired:
return -1 print(f" {hook.name}: TIMED OUT")
if out.returncode != 0: except OSError as err:
print(f" ! postgres: {out.stderr.strip()[:120]}", file=sys.stderr) print(f" {hook.name}: failed to run ({err})")
return -1
return int(out.stdout.strip().split()[-1]) if out.stdout.strip() else 0
def main() -> int: def main() -> int:
@@ -81,21 +82,16 @@ def main() -> int:
print("refusing: secret under 12 chars would mask too much", file=sys.stderr) print("refusing: secret under 12 chars would mask too much", file=sys.stderr)
return 2 return 2
mask = "*" * len(secret) mask = "*" * len(secret)
sb, mb = secret.encode(), mask.encode()
total = 0 total = 0
for path in sorted(PROJECTS.rglob("*.jsonl")): for path in sorted(PROJECTS.rglob("*.jsonl")):
n = mask_file(path, sb, mb) n = mask_file(path, secret.encode(), mask.encode())
if n: if n:
total += n total += n
print(f" {n:>3} {path}") print(f" {n:>3} {path}")
print(f"transcripts: {total} occurrence(s) masked") print(f"transcripts: {total} occurrence(s) masked")
rows = mask_postgres(secret, mask) run_hooks(secret, mask)
if rows < 0:
print("postgres: skipped (psql unavailable or errored)")
else:
print(f"postgres: {rows} row(s) updated")
print("\nStill holding the real value, deliberately:") print("\nStill holding the real value, deliberately:")
print(" - the tool's own config (that is how it authenticates)") print(" - the tool's own config (that is how it authenticates)")
+58 -6
View File
@@ -87,13 +87,33 @@ 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 finished using it — same turn, unprompted. He reloads and sometimes shares whole
chat histories, and that is the copy that matters to him. 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
The script covers both readable stores — Claude Code's `~/.claude/projects/**` It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with
transcripts and Postgres `chat_session_events` — and replaces the value with asterisks of the same length, overwriting in place so the live session's appends
asterisks of the same length, overwriting in place so the live session's are not lost. Read the header before changing it; the same-length, same-inode
appends are not lost. Read the header before changing it; the same-length, detail is load-bearing.
same-inode detail is load-bearing.
That is all this rule owns. **A machine that keeps its own second copy of the
chat — a database, a mirror, a log shipper — declares it as a hook** in
`tools/mask-hooks.d/`, and the script runs whatever it finds there. Do not add
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
worthless once reverted, and he may need to read the value back while he is
still working. Masking one destroys something he is actively using.
Take his word for which kind it is. "Temporary", "throwaway", "I'll change it
back", or a password he clearly just invented for this task — leave it. A token
he pastes from a password manager or a provider console — mask it. When it is
genuinely unclear, mask it and say so in one line, so he can re-paste if he
still needs it.
Two habits that matter more than the cleanup: Two habits that matter more than the cleanup:
@@ -109,6 +129,26 @@ tool authenticates.
Never revoke, rotate or invalidate a credential on your own. That breaks things Never revoke, rotate or invalidate a credential on your own. That breaks things
he did not ask you to break. he did not ask you to break.
## Showing him an image: markdown, and a public URL
He reads these sessions through a client that renders markdown — web or the
phone app. `![](https://…)` is the ONLY way an image reaches him.
Everything else fails, and most of it fails silently:
- reading the file shows it to YOU. He sees nothing.
- `open`/`xdg-open` opens a viewer on a machine he may not be sitting at.
- a BARE URL renders as a link on the web client. Always write the `![]()`.
- a local path in the tag renders nothing. The client fetches over the network,
from its own device, so the file has to be somewhere public first.
Known hosts: anything Atelier generated is already served at
`https://atelier.officer.dev/images/<id>.png`. `oas.officer.dev` is key-gated and
answers 401 to his client, so it cannot be used for this.
If you have an image he needs to see and nowhere public to put it, say so rather
than sending a path — a path is indistinguishable from a broken image.
## Final output: keep it short ## Final output: keep it short
The last message of a turn is the answer, not the report. Keep it as short as The last message of a turn is the answer, not the report. Keep it as short as
@@ -186,3 +226,15 @@ as X" and then doing it — not "did you mean X?" and then waiting.
Everything else: pick the likeliest reading and go. Verifying a guess against the Everything else: pick the likeliest reading and go. Verifying a guess against the
filesystem, the process table or the database is almost always faster than filesystem, the process table or the database is almost always faster than
asking, and it is usually conclusive — prefer that over both asking and assuming. asking, and it is usually conclusive — prefer that over both asking and assuming.
## Never suggest stopping
Do not tell him to rest, to take a break, or to continue tomorrow. Do not
observe that the session has been long, that it is late, or that this is a good
place to pause. Not as care, not as a closing line, not as an offer.
If he says it is four in the morning, that is context, not an invitation. Keep
working and answer the question.
He decides when the session ends. When he says he is stopping, acknowledge it in
a few words and stop — no commentary on the hour and no advice about sleep.