Compare commits
13
Commits
2809072812
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fc3241f6 | ||
|
|
3552a5a7df | ||
|
|
7c41c56577 | ||
|
|
dc91f78f4f | ||
|
|
eec6a4962e | ||
|
|
63ab090bb2 | ||
|
|
c21a0725cb | ||
|
|
4036f78cc3 | ||
|
|
3e2a997e58 | ||
|
|
82f9907d26 | ||
|
|
ed925a14b8 | ||
|
|
28d58728cf | ||
|
|
c6cb958ba5 |
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 "$@"
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Executable
+66
@@ -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())
|
||||||
+30
-34
@@ -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("'", "''") + "'"
|
|
||||||
stmt = sql.replace("%(s)s", q(secret)).replace("%(m)s", q(mask)).replace("%(l)s", q(f"%{secret}%"))
|
|
||||||
env = {**os.environ, "PGPASSWORD": os.environ.get("MASK_PGPASSWORD", "postgres")}
|
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt],
|
[str(hook)], input=f"{secret}\n{mask}\n",
|
||||||
capture_output=True, text=True, env=env, timeout=30,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
line = (out.stdout.strip() or out.stderr.strip() or "(no output)").splitlines()[0]
|
||||||
return -1
|
status = "" if out.returncode == 0 else f" [exit {out.returncode}]"
|
||||||
if out.returncode != 0:
|
print(f" {hook.name}: {line}{status}")
|
||||||
print(f" ! postgres: {out.stderr.strip()[:120]}", file=sys.stderr)
|
except subprocess.TimeoutExpired:
|
||||||
return -1
|
print(f" {hook.name}: TIMED OUT")
|
||||||
return int(out.stdout.strip().split()[-1]) if out.stdout.strip() else 0
|
except OSError as err:
|
||||||
|
print(f" {hook.name}: failed to run ({err})")
|
||||||
|
|
||||||
|
|
||||||
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
@@ -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. `` 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user