Compare commits
10
Commits
2809072812
...
dc91f78f4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc91f78f4f | ||
|
|
eec6a4962e | ||
|
|
63ab090bb2 | ||
|
|
c21a0725cb | ||
|
|
4036f78cc3 | ||
|
|
3e2a997e58 | ||
|
|
82f9907d26 | ||
|
|
ed925a14b8 | ||
|
|
28d58728cf | ||
|
|
c6cb958ba5 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
+10
-4
@@ -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:
|
||||
|
||||
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())
|
||||
+33
-37
@@ -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
|
||||
session's appends would vanish into an unlinked file.
|
||||
|
||||
Covers both stores a human can actually read back:
|
||||
- Claude Code transcripts ~/.claude/projects/**/*.jsonl
|
||||
- Postgres chat_session_events (what the web chat replays)
|
||||
WHAT THIS OWNS: Claude Code's own stores, on any machine —
|
||||
`~/.claude/projects/**/*.jsonl`. Nothing else is universal.
|
||||
|
||||
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
|
||||
`queue-operation` record, and a rolling `last-prompt` cache — plus once more for
|
||||
@@ -27,10 +32,8 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECTS = Path.home() / ".claude" / "projects"
|
||||
PGDB = os.environ.get("MASK_PGDB", "officer_dev")
|
||||
PGHOST = os.environ.get("MASK_PGHOST", "127.0.0.1")
|
||||
PGUSER = os.environ.get("MASK_PGUSER", "postgres")
|
||||
PROJECTS = Path(os.environ.get("MASK_PROJECTS_DIR", Path.home() / ".claude" / "projects"))
|
||||
HOOKS_DIR = Path(os.environ.get("MASK_HOOKS_DIR", Path(__file__).resolve().parent / "mask-hooks.d"))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def mask_postgres(secret: str, mask: str) -> int:
|
||||
"""Replace inside the jsonb payload. Returns rows updated, -1 if unavailable."""
|
||||
sql = (
|
||||
"update chat_session_events set event = "
|
||||
"replace(event::text, %(s)s, %(m)s)::jsonb "
|
||||
"where event::text like %(l)s;"
|
||||
)
|
||||
# psql has no bind params, so quote by doubling any single quotes. The secret
|
||||
# is hex/base64 in practice, but never build SQL from unchecked input blindly.
|
||||
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:
|
||||
out = subprocess.run(
|
||||
["psql", "-h", PGHOST, "-U", PGUSER, "-d", PGDB, "-tAc", stmt],
|
||||
capture_output=True, text=True, env=env, timeout=30,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return -1
|
||||
if out.returncode != 0:
|
||||
print(f" ! postgres: {out.stderr.strip()[:120]}", file=sys.stderr)
|
||||
return -1
|
||||
return int(out.stdout.strip().split()[-1]) if out.stdout.strip() else 0
|
||||
def run_hooks(secret: str, mask: str) -> None:
|
||||
"""Every executable in HOOKS_DIR gets secret+mask on stdin."""
|
||||
if not HOOKS_DIR.is_dir():
|
||||
return
|
||||
hooks = sorted(h for h in HOOKS_DIR.iterdir() if h.is_file() and os.access(h, os.X_OK))
|
||||
if not hooks:
|
||||
return
|
||||
print(f"\nhooks ({HOOKS_DIR}):")
|
||||
for hook in hooks:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[str(hook)], input=f"{secret}\n{mask}\n",
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
line = (out.stdout.strip() or out.stderr.strip() or "(no output)").splitlines()[0]
|
||||
status = "" if out.returncode == 0 else f" [exit {out.returncode}]"
|
||||
print(f" {hook.name}: {line}{status}")
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" {hook.name}: TIMED OUT")
|
||||
except OSError as err:
|
||||
print(f" {hook.name}: failed to run ({err})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -81,21 +82,16 @@ def main() -> int:
|
||||
print("refusing: secret under 12 chars would mask too much", file=sys.stderr)
|
||||
return 2
|
||||
mask = "*" * len(secret)
|
||||
sb, mb = secret.encode(), mask.encode()
|
||||
|
||||
total = 0
|
||||
for path in sorted(PROJECTS.rglob("*.jsonl")):
|
||||
n = mask_file(path, sb, mb)
|
||||
n = mask_file(path, secret.encode(), mask.encode())
|
||||
if n:
|
||||
total += n
|
||||
print(f" {n:>3} {path}")
|
||||
print(f"transcripts: {total} occurrence(s) masked")
|
||||
|
||||
rows = mask_postgres(secret, mask)
|
||||
if rows < 0:
|
||||
print("postgres: skipped (psql unavailable or errored)")
|
||||
else:
|
||||
print(f"postgres: {rows} row(s) updated")
|
||||
run_hooks(secret, mask)
|
||||
|
||||
print("\nStill holding the real value, deliberately:")
|
||||
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
|
||||
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/**`
|
||||
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 are not lost. Read the header before changing it; the same-length,
|
||||
same-inode detail is load-bearing.
|
||||
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
|
||||
are not lost. Read the header before changing it; the same-length, 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:
|
||||
|
||||
@@ -109,6 +129,26 @@ tool authenticates.
|
||||
Never revoke, rotate or invalidate a credential on your own. That breaks things
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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