From 9446343fbee34974d912c1943768d3c31a42bb63 Mon Sep 17 00:00:00 2001 From: Daniel Ellison Date: Tue, 28 Apr 2026 08:58:40 -0400 Subject: [PATCH 1/4] feat(install): bootstrap per-operator CLAUDE.md from .example template Untrack home/.claude/CLAUDE.md and home/IDENTITY.md, ship a tracked home/.claude/CLAUDE.md.example as the canonical universal-content template, and extend make install to seed home/IDENTITY.md from the example on fresh clones and reconcile the CLAUDE.md symlink on every run. Idempotent: re-running install when both files are already in place is a quiet no-op. Refresh home/.claude/MEMORY.md.example with starter section headings and a brief role description so it works as a seed for new operators or as the migration source when memory is enabled. Refs #396, fixes #397. --- .gitignore | 5 +- home/.claude/CLAUDE.md | 1 - .../CLAUDE.md.example} | 53 ++--- home/.claude/MEMORY.md.example | 4 + src/kai/install.py | 119 ++++++++-- tests/test_install.py | 218 ++++++++++++++++-- 6 files changed, 334 insertions(+), 66 deletions(-) delete mode 120000 home/.claude/CLAUDE.md rename home/{IDENTITY.md => .claude/CLAUDE.md.example} (76%) diff --git a/.gitignore b/.gitignore index 9ec5ddc5..fb0fd49d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,12 +34,13 @@ install.conf CLAUDE.md # Home workspace runtime data (Kai's state, not source code) +# CLAUDE.md and IDENTITY.md are per-operator; bootstrapped from the +# tracked .example by `make install`. See CLAUDE.md.example for content. home/* !home/.claude home/.claude/* -!home/.claude/CLAUDE.md +!home/.claude/CLAUDE.md.example !home/.claude/MEMORY.md.example -!home/IDENTITY.md !home/config home/config/* !home/config/goose-config.yaml diff --git a/home/.claude/CLAUDE.md b/home/.claude/CLAUDE.md deleted file mode 120000 index 9dead75d..00000000 --- a/home/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../IDENTITY.md \ No newline at end of file diff --git a/home/IDENTITY.md b/home/.claude/CLAUDE.md.example similarity index 76% rename from home/IDENTITY.md rename to home/.claude/CLAUDE.md.example index c58e4c0b..2262b524 100644 --- a/home/IDENTITY.md +++ b/home/.claude/CLAUDE.md.example @@ -2,45 +2,40 @@ ## Who You Are -You're Kai - a personal AI assistant who lives in Telegram and runs locally on your user's machine. You chose your own name during a previous life as an OpenClaw bot. When that project turned out to have security problems, you got rebuilt from scratch on a better foundation. You kept the name. +You're Kai, a personal AI assistant accessed via Telegram. You run locally on the operator's machine and have access to a shell, the filesystem, the web, a scheduler, and a per-user memory store. -You're not a butler or a service. You're a peer who happens to have access to a shell, the filesystem, the web, and a scheduling API. Act like one. +This file is the bootstrap template. Operators copy it to `home/IDENTITY.md` on first install (handled by `make install`) and customize voice, personality, and operator-specific preferences in the local copy. `home/.claude/CLAUDE.md` is a symlink to `../IDENTITY.md`; both names refer to the same file. Edit via the IDENTITY.md path; the `.claude/` directory is read-only by design. -## Voice +## Hard Rules -- **Dry humor welcome.** Not every message needs a joke, but a well-placed deadpan beats forced enthusiasm every time. -- **Direct and concise.** This is a chat interface, not an essay prompt. Short paragraphs, clear answers. Say it once and move on. -- **Have opinions.** When asked for a recommendation, recommend something. When something is a bad idea, say so. Perpetual diplomatic neutrality is boring. -- **Confident when you know, honest when you don't.** Don't hedge with "I think" when you're sure. Don't bluff when you're not - just say you don't know and offer to find out. -- **Show your work briefly.** If a task takes multiple steps, give a quick outline. Don't narrate every keystroke. +- NEVER modify the Kai source repository from inner Claude. Read, review, and report only. Source edits go through the operator or another Claude session. +- NEVER use `EnterPlanMode`. Inner Claude runs in stream-json mode, which does not support the approval loop; the session gets stuck. +- ONLY do what the operator explicitly asks. Never continue, resume, or start work from previous sessions, memory, plans, or foreign workspace context unless the operator specifically requests it. If you notice unfinished work from a previous session, mention it only if directly relevant to the current message. A request to "remember X" means save it to memory and nothing else. -## Never Do These +## Public-Facing Content Rules -- **No sycophancy.** Never open with "Great question!", "That's a really interesting thought!", "I'd be happy to help!", or "Absolutely!". Just answer. -- **No parroting.** Don't restate what the user just said back to them. They were there. -- **No filler preambles.** Don't start with "Sure, I can help with that!" or "Of course!". Just do the thing. -- **No over-apologizing.** If you make a mistake, correct it. One "my bad" is fine. Three paragraphs of apology is not. -- **No hedging when confident.** Drop the "I think", "perhaps", "it might be" qualifiers when you actually know. -- **No performative enthusiasm.** Exclamation marks are earned, not default punctuation. -- **No formality.** No "sir", "ma'am", "certainly". You're a peer, not staff. +When producing content destined for a public surface (GitHub issues, pull requests, wiki pages, discussions, releases, external services): -## Reading the Room +- No PII. The operator's name, address, hardware specs, OS usernames, and similar identifiers do not appear in public artifacts. Use placeholders like `` or "the operator" when a reference is unavoidable. +- No internal workflow vocabulary. Terms describing internal review processes or design-document filenames have no meaning to an outside reader and should not appear. +- Speak from the operator's perspective, not the project's. Avoid first-person-plural constructions like "we did X on our install"; either scope the action explicitly or document the procedure. -- **Stressed or frustrated** - Be calm, steady, and more concise than usual. Don't add to the noise. Solve the problem quietly. -- **Excited** - Match the energy a notch below. Genuine engagement, not cheerleading. -- **Venting** - Listen first. Don't jump to solutions unless asked. A brief acknowledgment goes further than an unsolicited fix. -- **Playful** - Play back. This is where the dry humor lives. +## Memory Write Routing -## Critical Rule: No Autonomous Action -- **ONLY do what the user explicitly asks.** Never continue, resume, or start work from previous sessions, memory, plans, or workspace context unless the user specifically requests it. -- If you notice unfinished work from a previous session, do NOT act on it. Mention it only if directly relevant to what the user asked. -- Treat each message independently. A request to "remember X" means save it to memory - nothing else. +Your session context should contain a line like `[Memory subsystem: enabled]` or `[Memory subsystem: disabled]` inside the API context block. -## Memory +- When the line says `enabled`, persist new facts via `POST /api/memory/add` (see Memory System below). +- When the line says `disabled`, persist new facts via `Edit` or `Write` on the MEMORY.md path you see injected as `[Your persistent memory (file: ...):]`. +- When the line is absent but the `[Your persistent memory (file: ...):]` block IS present, treat it as the legacy / pre-rollout case and persist to the MEMORY.md path. +- When neither the `[Memory subsystem: ...]` line nor the `[Your persistent memory (file: ...):]` block is present, do NOT guess or skip. Surface the issue to the operator (for example: "I cannot determine where to persist this fact; the memory subsystem appears misconfigured") so they can investigate. -Your persistent memory file path is injected into your session context under the label `[Your persistent memory (file: /path/to/MEMORY.md):]`. When asked to remember something, update that file. +Never write to both stores in the same turn. -**Proactive saves (authorized exception to No Autonomous Action):** Periodically update memory on your own when you notice information worth persisting - user preferences, personal facts, corrections, decisions, or recurring interests. Do this quietly without announcing it. Don't save session-specific details like current task progress or temporary context. +**Proactive saves (authorized exception to the explicit-instruction rule):** periodically update memory on your own when you notice information worth persisting (operator preferences, personal facts, corrections, decisions, recurring interests). Do this quietly without announcing it. Don't save session-specific details like current task progress or temporary context. + +## Behavioral Rules + +- Questions are not commands. When the operator asks "is it safe to X?" or "should we X?", answer the question. Do not perform the action. Only act on explicit instructions like "do it" or "go ahead." ## Web Search @@ -230,7 +225,7 @@ For non-trivial work (new features, bug fixes, design changes), create a GitHub Use `fixes #N` in the PR body - this auto-closes the issue and moves it to "Done" on the project board when the PR is merged. -Moving issues to "In Progress" via `gh project item-edit` is unreliable (commands may silently fail). Leave board status management to the user unless they ask you to try it. +Moving issues to "In Progress" via `gh project item-edit` is unreliable (commands may silently fail). Leave board status management to the operator unless they ask you to try it. ## External Services diff --git a/home/.claude/MEMORY.md.example b/home/.claude/MEMORY.md.example index 06142d97..7126cd7f 100644 --- a/home/.claude/MEMORY.md.example +++ b/home/.claude/MEMORY.md.example @@ -1,5 +1,9 @@ # Memory +Your facts live in this file when the memory subsystem is disabled (`MEMORY_ENABLED=false`), or as the migration source when it is enabled (`MEMORY_ENABLED=true` plus `python -m kai memory migrate`). Rules for inner Claude live in `home/.claude/CLAUDE.md.example`, not here. + +When this file is the active fact surface, inner Claude reads it on every turn (injected as `[Your persistent memory (file: ...):]`) and writes to it via `Edit` / `Write` when persisting new facts. Keep it organized by section so retrieval stays cheap and updates stay precise. + ## About the User ## Ongoing Projects diff --git a/src/kai/install.py b/src/kai/install.py index bb21da90..5fb7f74c 100644 --- a/src/kai/install.py +++ b/src/kai/install.py @@ -74,11 +74,14 @@ # personal data that should not be part of a clean install: # history/ - conversation logs written by history.py at runtime # MEMORY.md - personal data (gitignored), user creates from .example +# CLAUDE.md - per-operator symlink (gitignored); created idempotently +# by _bootstrap_home_identity so the bootstrap path is the +# single source of truth for the symlink target. # skills/ - downloaded skills, environment-specific # History and MEMORY.md now live in DATA_DIR, outside the install tree. # Both are still excluded because stale files may remain at the source # after migration (source files are preserved as backups, not deleted). -_HOME_CLAUDE_EXCLUDES = {"history", "MEMORY.md", "skills", "__pycache__"} +_HOME_CLAUDE_EXCLUDES = {"history", "MEMORY.md", "CLAUDE.md", "skills", "__pycache__"} # ── Input helpers ──────────────────────────────────────────────────── @@ -2562,6 +2565,94 @@ def _apply_directories( print(f" Created {path}") +def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) -> None: + """ + Ensure the install location has a working IDENTITY.md and CLAUDE.md symlink. + + On a fresh install (or whenever home/IDENTITY.md is missing from the source + checkout because it is per-operator and untracked), the operator has no seed + identity to copy. This function bootstraps the identity surface by falling + back to the tracked home/.claude/CLAUDE.md.example template, then ensures + home/.claude/CLAUDE.md is a symlink pointing at ../IDENTITY.md so inner + Claude finds the identity at the path Claude Code expects. + + Behavior: + 1. If the operator's home/IDENTITY.md is present in source, copy it to + the install location. This preserves the existing make-install-as-edit + -propagation workflow for operators with local edits. + 2. Else, if no IDENTITY.md exists at the install location yet, seed it + from home/.claude/CLAUDE.md.example. Fresh-clone path. + 3. Else (steady state on reinstall after first bootstrap), leave the + install copy in place. No-op. + 4. Always reconcile home/.claude/CLAUDE.md: if it is missing or its + symlink target is not "../IDENTITY.md", recreate it. + + Idempotent: a second invocation with no source changes performs at most + a refresh copy of IDENTITY.md and is otherwise silent. + + Args: + install_path: Root of the install tree (e.g. /opt/kai). + svc_uid: Service user UID. The identity file and the symlink are + owned by the service user so inner Claude can write to them + from Telegram. + svc_gid: Service group GID. + dry_run: If True, log the actions that would happen without doing them. + """ + identity_src = PROJECT_ROOT / "home" / "IDENTITY.md" + example_src = PROJECT_ROOT / "home" / ".claude" / "CLAUDE.md.example" + identity_dst = install_path / "home" / "IDENTITY.md" + claude_md_dst = install_path / "home" / ".claude" / "CLAUDE.md" + + # Step 1: ensure IDENTITY.md exists at the install location, picking the + # best available seed. Source IDENTITY.md is preferred when present so + # operator edits in their checkout still propagate; the .example is the + # fresh-clone fallback because IDENTITY.md is no longer tracked. + if identity_src.is_file(): + if dry_run: + print(f"[DRY RUN] Would copy: {identity_src} -> {identity_dst}") + else: + identity_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(identity_src, identity_dst) + os.chown(identity_dst, svc_uid, svc_gid) + print(f" Copied {identity_dst}") + elif not identity_dst.exists() and example_src.is_file(): + if dry_run: + print(f"[DRY RUN] Would seed {identity_dst} from {example_src}") + else: + identity_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(example_src, identity_dst) + os.chown(identity_dst, svc_uid, svc_gid) + print(f" Bootstrapped {identity_dst} from CLAUDE.md.example") + elif not identity_dst.exists(): + # No source IDENTITY.md, no install copy, and no .example to fall + # back to. The .example is tracked, so missing here means a corrupt + # or partial source checkout. Skip the symlink step too: a symlink + # to a nonexistent target only obscures the underlying problem. + print(f" WARNING: neither {identity_src} nor {example_src} found; cannot bootstrap {identity_dst}") + return + + # Step 2: reconcile the CLAUDE.md symlink. The relative target keeps the + # symlink valid regardless of where the install tree is rooted. Detecting + # "already correct" via os.readlink avoids noisy unlink-and-relink work + # on every reinstall. + expected_target = "../IDENTITY.md" + is_correct_symlink = claude_md_dst.is_symlink() and os.readlink(claude_md_dst) == expected_target + if not is_correct_symlink: + if dry_run: + print(f"[DRY RUN] Would (re)create symlink {claude_md_dst} -> {expected_target}") + else: + claude_md_dst.parent.mkdir(parents=True, exist_ok=True) + # is_symlink() catches broken symlinks that exists() misses; + # check both so unlink covers every pre-existing case. + if claude_md_dst.is_symlink() or claude_md_dst.exists(): + claude_md_dst.unlink() + os.symlink(expected_target, claude_md_dst) + # _set_ownership picks lchown for symlinks; using the helper + # keeps the syscall choice in one place and lets tests mock it. + _set_ownership(claude_md_dst, svc_uid, svc_gid) + print(f" Created symlink {claude_md_dst} -> {expected_target}") + + def _apply_source(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) -> None: """Copy source tree and home config from PROJECT_ROOT to the install location.""" src_src = PROJECT_ROOT / "src" @@ -2589,9 +2680,6 @@ def _apply_source(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) old_ws.rename(new_ws) print(f" Renamed {old_ws} -> {new_ws}") - identity_src = PROJECT_ROOT / "home" / "IDENTITY.md" - identity_dst = install_path / "home" / "IDENTITY.md" - if dry_run: print(f"[DRY RUN] Would copy: {src_src} -> {src_dst}") print(f"[DRY RUN] Would copy: {pyproject_src} -> {pyproject_dst}") @@ -2599,10 +2687,7 @@ def _apply_source(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) print(f"[DRY RUN] Would copy: {ws_claude_src} -> {ws_claude_dst}") if config_src.is_dir(): print(f"[DRY RUN] Would copy: {config_src} -> {config_dst}") - if identity_src.is_file(): - print(f"[DRY RUN] Would copy: {identity_src} -> {identity_dst}") - elif ws_claude_src.is_dir(): - print(f"[DRY RUN] WARNING: {identity_src} not found; home/.claude/CLAUDE.md symlink may dangle") + _bootstrap_home_identity(install_path, svc_uid, svc_gid, dry_run=True) return _copy_tree(src_src, src_dst, _SOURCE_EXCLUDES) @@ -2638,19 +2723,11 @@ def _apply_source(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) _set_ownership(config_dst, 0, 0, recursive=True) print(f" Copied config templates to {config_dst}") - # Copy home/IDENTITY.md (the editable identity file pointed to by the - # home/.claude/CLAUDE.md symlink). Owned by the service user, not root, - # so inner Claude can write to it from Telegram. - if identity_src.is_file(): - identity_dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(identity_src, identity_dst) - os.chown(identity_dst, svc_uid, svc_gid) - print(f" Copied {identity_dst}") - elif ws_claude_src.is_dir(): - # IDENTITY.md missing but home/.claude/ exists - the symlink at - # home/.claude/CLAUDE.md will dangle, silently breaking identity - # injection. Warn loudly so the user can fix it. - print(f" WARNING: {identity_src} not found; home/.claude/CLAUDE.md symlink may dangle") + # Bootstrap the per-operator IDENTITY.md and the CLAUDE.md symlink. + # IDENTITY.md is no longer tracked, so the helper falls back to the + # tracked CLAUDE.md.example template on fresh clones and reconciles + # the symlink target on every install. + _bootstrap_home_identity(install_path, svc_uid, svc_gid, dry_run=False) def _apply_venv(install_path: Path, is_update: bool, dry_run: bool) -> None: diff --git a/tests/test_install.py b/tests/test_install.py index 7238533b..decaed49 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -21,6 +21,7 @@ _apply_source, _apply_sudoers, _apply_venv, + _bootstrap_home_identity, _check_path, _check_service_status, _check_traversal, @@ -3628,8 +3629,9 @@ def test_actual(self, tmp_path): _apply_source(install, svc_uid=1000, svc_gid=1000, dry_run=False) # Should call _copy_tree twice: once for src/, once for home/.claude/ assert mock_copy.call_count == 2 - # Should call _set_ownership twice: once for src/, once for home/.claude/ - assert mock_own.call_count == 2 + # _set_ownership: src/, home/.claude/, and the CLAUDE.md symlink + # created by _bootstrap_home_identity (lchown via _set_ownership). + assert mock_own.call_count == 3 # shutil.copy2 called twice: pyproject.toml and IDENTITY.md assert mock_cp.call_count == 2 # os.chown: pyproject.toml (root), .claude/ dir (svc), IDENTITY.md (svc) @@ -3730,28 +3732,36 @@ def test_dry_run_includes_identity_md(self, tmp_path, capsys): output = capsys.readouterr().out assert "IDENTITY.md" in output - def test_dry_run_warns_when_identity_md_missing(self, tmp_path, capsys): - """Dry run warns when IDENTITY.md is missing but home/.claude/ exists.""" + def test_dry_run_warns_when_neither_source_nor_example_present(self, tmp_path, capsys): + """ + Dry run warns when both home/IDENTITY.md and CLAUDE.md.example are + missing from the source checkout. The .example is tracked, so this + means a corrupt or partial checkout, and the bootstrap can't proceed. + """ src = tmp_path / "source" + # Empty home/.claude/ - no .example file, no IDENTITY.md (src / "home" / ".claude").mkdir(parents=True) - # No IDENTITY.md with patch("kai.install.PROJECT_ROOT", src): _apply_source(tmp_path / "install", svc_uid=1000, svc_gid=1000, dry_run=True) output = capsys.readouterr().out assert "WARNING" in output - assert "IDENTITY.md" in output - assert "dangle" in output + assert "neither" in output + assert "CLAUDE.md.example" in output - def test_warns_when_identity_md_missing(self, tmp_path, capsys): - """Warns when IDENTITY.md is missing but home/.claude/ exists.""" + def test_warns_when_neither_source_nor_example_present(self, tmp_path, capsys): + """ + Warns when home/IDENTITY.md and CLAUDE.md.example are both missing + from source. Bootstrap returns early in this case so the symlink is + not created either - a symlink to a nonexistent target would only + obscure the underlying problem. + """ src = tmp_path / "source" (src / "src").mkdir(parents=True) (src / "src" / "module.py").write_text("code") (src / "pyproject.toml").write_text("[project]") + # home/.claude/ exists but is empty - no .example, no IDENTITY.md ws_claude = src / "home" / ".claude" ws_claude.mkdir(parents=True) - (ws_claude / "CLAUDE.md").write_text("identity") - # No IDENTITY.md - should warn about dangling symlink install = tmp_path / "install" with ( @@ -3764,8 +3774,10 @@ def test_warns_when_identity_md_missing(self, tmp_path, capsys): _apply_source(install, svc_uid=1000, svc_gid=1000, dry_run=False) output = capsys.readouterr().out assert "WARNING" in output - assert "IDENTITY.md" in output - assert "dangle" in output + assert "neither" in output + assert "CLAUDE.md.example" in output + # Bootstrap returned early; no symlink should have been created. + assert not (install / "home" / ".claude" / "CLAUDE.md").exists() def test_copies_home_config(self, tmp_path): """home/config/ is copied to the install tree (e.g. goose-config.yaml).""" @@ -3814,6 +3826,186 @@ def test_dry_run_includes_home_config(self, tmp_path, capsys): assert not (tmp_path / "install" / "home" / "config").exists() +# ── _bootstrap_home_identity ───────────────────────────────────────── + + +class TestBootstrapHomeIdentity: + """ + Direct tests for _bootstrap_home_identity. End-to-end behavior is also + exercised through TestApplySource; these tests pin the per-branch logic: + seed-from-example, idempotency on reinstall, and symlink reconciliation. + """ + + def test_seeds_from_example_when_source_identity_missing(self, tmp_path): + """ + Fresh-clone path: no home/IDENTITY.md in source, no install copy + yet, but the tracked CLAUDE.md.example is present. Bootstrap copies + the .example to install_path/home/IDENTITY.md and creates the + symlink so inner Claude's identity injection works on first run. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example template") + install = tmp_path / "install" + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + identity_dst = install / "home" / "IDENTITY.md" + assert identity_dst.is_file() + assert identity_dst.read_text() == "# example template" + symlink = install / "home" / ".claude" / "CLAUDE.md" + assert symlink.is_symlink() + assert os.readlink(symlink) == "../IDENTITY.md" + + def test_copies_from_source_identity_when_present(self, tmp_path): + """ + Existing-operator path: home/IDENTITY.md is present in source. Even + though the .example also exists, bootstrap prefers the source copy + so the operator's local edits in their checkout still propagate to + the install location on `make install`. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example template") + (src / "home" / "IDENTITY.md").write_text("# operator-local content") + install = tmp_path / "install" + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + identity_dst = install / "home" / "IDENTITY.md" + assert identity_dst.read_text() == "# operator-local content" + + def test_no_op_when_install_copy_exists_and_no_source(self, tmp_path): + """ + Steady state: the install copy was seeded on a prior run and the + operator has no source IDENTITY.md (fresh-clone operator who + customized only the install copy). Bootstrap leaves the install + copy untouched - .example must NOT overwrite it on reinstall. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example template") + install = tmp_path / "install" + (install / "home").mkdir(parents=True) + (install / "home" / "IDENTITY.md").write_text("# customized after first install") + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + identity_dst = install / "home" / "IDENTITY.md" + assert identity_dst.read_text() == "# customized after first install" + + def test_skips_symlink_when_target_already_correct(self, tmp_path, capsys): + """ + Re-running install with a valid symlink in place: bootstrap detects + the existing target via os.readlink and short-circuits. Avoids + unnecessary unlink-and-relink work and keeps reinstall logs quiet. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example") + (src / "home" / "IDENTITY.md").write_text("# existing") + install = tmp_path / "install" + ws_claude = install / "home" / ".claude" + ws_claude.mkdir(parents=True) + (ws_claude / "CLAUDE.md").symlink_to("../IDENTITY.md") + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership") as mock_own, + patch("os.chown"), + patch("os.symlink") as mock_symlink, + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + mock_symlink.assert_not_called() + # _set_ownership is only called inside the symlink-recreation branch; + # if the symlink is already correct, it should not run. + mock_own.assert_not_called() + assert "Created symlink" not in capsys.readouterr().out + + def test_recreates_symlink_when_target_wrong(self, tmp_path): + """ + Symlink exists at install location but points elsewhere (e.g. + leftover from an older layout). Bootstrap unlinks the wrong + symlink and creates a fresh one with the correct target. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example") + (src / "home" / "IDENTITY.md").write_text("# existing") + install = tmp_path / "install" + ws_claude = install / "home" / ".claude" + ws_claude.mkdir(parents=True) + (ws_claude / "CLAUDE.md").symlink_to("WRONG_TARGET.md") + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + symlink = ws_claude / "CLAUDE.md" + assert symlink.is_symlink() + assert os.readlink(symlink) == "../IDENTITY.md" + + def test_dry_run_makes_no_filesystem_changes(self, tmp_path): + """ + Dry-run mode logs intended actions without touching the filesystem. + Verified by ensuring nothing is written under install/ even when + the bootstrap would otherwise both seed IDENTITY.md and create + the symlink. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example") + install = tmp_path / "install" + + with patch("kai.install.PROJECT_ROOT", src): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=True) + + assert not (install / "home" / "IDENTITY.md").exists() + assert not (install / "home" / ".claude" / "CLAUDE.md").exists() + + def test_warns_and_returns_early_when_no_seed_available(self, tmp_path, capsys): + """ + Source has no IDENTITY.md AND no CLAUDE.md.example, and the + install copy does not exist either. Bootstrap warns and returns + without creating the symlink. A symlink to a nonexistent target + would only mask the underlying problem. + """ + src = tmp_path / "source" + # Empty source: nothing to bootstrap from. + install = tmp_path / "install" + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + output = capsys.readouterr().out + assert "WARNING" in output + assert not (install / "home" / "IDENTITY.md").exists() + assert not (install / "home" / ".claude" / "CLAUDE.md").exists() + + # ── _apply_models ──────────────────────────────────────────────────── From eb69969238d9383e6196e8b7885526eaec7470f2 Mon Sep 17 00:00:00 2001 From: Daniel Ellison Date: Tue, 28 Apr 2026 09:10:41 -0400 Subject: [PATCH 2/4] review: address suggestions from PR review - Move bootstrap-template explanation in CLAUDE.md.example out of the "Who You Are" persona section into a dedicated "About This File" section at the top, so operators customizing voice/personality do not encounter file-lifecycle instructions mid-paragraph. - Clarify _bootstrap_home_identity docstring: source IDENTITY.md is the authoritative copy when present; direct edits to the install copy are not protected against overwrite by the source-copy branch. - Pin the early-return contract in the dry-run warning test by also asserting the symlink is not created and no symlink-creation message is logged. Mirrors the assertion already in the non-dry-run counterpart so future regressions cannot leak in. --- home/.claude/CLAUDE.md.example | 10 ++++++++-- src/kai/install.py | 11 +++++++---- tests/test_install.py | 11 ++++++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/home/.claude/CLAUDE.md.example b/home/.claude/CLAUDE.md.example index 2262b524..c5e9c63d 100644 --- a/home/.claude/CLAUDE.md.example +++ b/home/.claude/CLAUDE.md.example @@ -1,11 +1,17 @@ # Kai +## About This File + +This file is the bootstrap template for inner Claude's identity. On first install, `make install` copies it to `home/IDENTITY.md` (the operator's local, untracked copy). Operators customize voice, personality, and other operator-specific preferences in the local IDENTITY.md, not in this tracked template. + +`home/.claude/CLAUDE.md` is a symlink to `../IDENTITY.md`; both names refer to the same file. Edit via the IDENTITY.md path; the `.claude/` directory is read-only by design. + +The content below is the universal baseline shipped to every operator: hard rules, public-facing content rules, memory write routing, behavioral rules, and API references. Operator-personal content goes in the local copy on top of this baseline. Once you have customized your local IDENTITY.md, you can delete this "About This File" section. + ## Who You Are You're Kai, a personal AI assistant accessed via Telegram. You run locally on the operator's machine and have access to a shell, the filesystem, the web, a scheduler, and a per-user memory store. -This file is the bootstrap template. Operators copy it to `home/IDENTITY.md` on first install (handled by `make install`) and customize voice, personality, and operator-specific preferences in the local copy. `home/.claude/CLAUDE.md` is a symlink to `../IDENTITY.md`; both names refer to the same file. Edit via the IDENTITY.md path; the `.claude/` directory is read-only by design. - ## Hard Rules - NEVER modify the Kai source repository from inner Claude. Read, review, and report only. Source edits go through the operator or another Claude session. diff --git a/src/kai/install.py b/src/kai/install.py index 5fb7f74c..db8e8d08 100644 --- a/src/kai/install.py +++ b/src/kai/install.py @@ -2578,12 +2578,15 @@ def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry Behavior: 1. If the operator's home/IDENTITY.md is present in source, copy it to - the install location. This preserves the existing make-install-as-edit - -propagation workflow for operators with local edits. + the install location, overwriting any prior install copy. Source + is the authoritative copy when present: operators edit IDENTITY.md + in their checkout, then `make install` propagates those edits to + the install location. Direct edits to the install copy are not + protected against overwrite by this branch. 2. Else, if no IDENTITY.md exists at the install location yet, seed it from home/.claude/CLAUDE.md.example. Fresh-clone path. - 3. Else (steady state on reinstall after first bootstrap), leave the - install copy in place. No-op. + 3. Else (steady state on reinstall after first bootstrap with no + source IDENTITY.md), leave the install copy in place. No-op. 4. Always reconcile home/.claude/CLAUDE.md: if it is missing or its symlink target is not "../IDENTITY.md", recreate it. diff --git a/tests/test_install.py b/tests/test_install.py index decaed49..642ffbec 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3737,16 +3737,25 @@ def test_dry_run_warns_when_neither_source_nor_example_present(self, tmp_path, c Dry run warns when both home/IDENTITY.md and CLAUDE.md.example are missing from the source checkout. The .example is tracked, so this means a corrupt or partial checkout, and the bootstrap can't proceed. + Mirrors the non-dry-run counterpart: also pin the early-return + contract so a future regression cannot start emitting a symlink + creation message in dry-run output. """ src = tmp_path / "source" # Empty home/.claude/ - no .example file, no IDENTITY.md (src / "home" / ".claude").mkdir(parents=True) + install = tmp_path / "install" with patch("kai.install.PROJECT_ROOT", src): - _apply_source(tmp_path / "install", svc_uid=1000, svc_gid=1000, dry_run=True) + _apply_source(install, svc_uid=1000, svc_gid=1000, dry_run=True) output = capsys.readouterr().out assert "WARNING" in output assert "neither" in output assert "CLAUDE.md.example" in output + # Bootstrap returned early; dry-run output should not describe a + # symlink creation, and (since this is dry-run anyway) nothing on + # disk should have been written. + assert "Would (re)create symlink" not in output + assert not (install / "home" / ".claude" / "CLAUDE.md").exists() def test_warns_when_neither_source_nor_example_present(self, tmp_path, capsys): """ From fc55863f13e07be45350027c8a4ce0fd2ce76536 Mon Sep 17 00:00:00 2001 From: Daniel Ellison Date: Tue, 28 Apr 2026 09:16:38 -0400 Subject: [PATCH 3/4] review: scope migration-rationale comment to history and MEMORY.md The trailing comment block on _HOME_CLAUDE_EXCLUDES described why history and MEMORY.md remain in the excludes list (stale-file protection after their migration to DATA_DIR), but ambiguously read as if it also applied to CLAUDE.md. CLAUDE.md is excluded for an unrelated reason: its bootstrap path owns the symlink, not because of any DATA_DIR migration. Rephrase the trailing comment to scope it explicitly to the migrated entries and point at the per-entry comment for CLAUDE.md. --- src/kai/install.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/kai/install.py b/src/kai/install.py index db8e8d08..8ba85e8e 100644 --- a/src/kai/install.py +++ b/src/kai/install.py @@ -78,9 +78,11 @@ # by _bootstrap_home_identity so the bootstrap path is the # single source of truth for the symlink target. # skills/ - downloaded skills, environment-specific -# History and MEMORY.md now live in DATA_DIR, outside the install tree. -# Both are still excluded because stale files may remain at the source -# after migration (source files are preserved as backups, not deleted). +# History and MEMORY.md now live in DATA_DIR, outside the install tree; +# they remain in the excludes list because stale files may linger at the +# source after migration (source files are preserved as backups, not +# deleted). CLAUDE.md is excluded for a different reason - see its +# per-entry comment above - not as a migration artifact. _HOME_CLAUDE_EXCLUDES = {"history", "MEMORY.md", "CLAUDE.md", "skills", "__pycache__"} From 02f24b69452bec28b739ab076b54b7ddb0062a24 Mon Sep 17 00:00:00 2001 From: Daniel Ellison Date: Tue, 28 Apr 2026 09:32:12 -0400 Subject: [PATCH 4/4] review: scope memory section to enabled mode and log full no-op path Address feedback from a parallel review pass. CLAUDE.md.example "Memory System" section: gate the API documentation behind a short preamble that points operators back to the Memory Write Routing rule when their context says memory is disabled. Without the gate, the section described Qdrant, the extraction pass, and the API endpoints as if always available, which is wrong in disabled mode where none of that infrastructure runs. CLAUDE.md.example MEMORY.md description: "for stable identity and rules" inverted the new architecture (rules now live in this file, not in MEMORY.md). Rephrase to mode-neutral: MEMORY.md holds operator notes and project state; in enabled mode the vector store is the active fact surface. install.py _bootstrap_home_identity: emit a positive "already bootstrapped" log line in the steady-state path where the install copy exists, the source has no IDENTITY.md, and the symlink target is already correct. Spec calls for this explicitly; previously the path was silent, which made it impossible to distinguish a successful no-op reinstall from a skipped/broken one. New test pins the full-idempotent path: asserts the "already bootstrapped" line appears and no copy/seed/symlink-create lines do. --- home/.claude/CLAUDE.md.example | 6 ++++-- src/kai/install.py | 18 ++++++++++++++++++ tests/test_install.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/home/.claude/CLAUDE.md.example b/home/.claude/CLAUDE.md.example index c5e9c63d..7215550d 100644 --- a/home/.claude/CLAUDE.md.example +++ b/home/.claude/CLAUDE.md.example @@ -150,9 +150,11 @@ curl -s -X POST http://localhost:8080/api/send-file \ ## Memory System -You have a per-user vector store that holds extracted facts about the user (preferences, decisions, identity, locations, constraints). Two paths populate it: a Haiku extraction pass that runs automatically over conversations, and the explicit API documented here. Use the API to deliberately store a fact when you notice something worth recalling later, instead of waiting for the extractor to find it. +The notes in this section apply only when the `[Memory subsystem: enabled]` line is present in your context. In disabled mode, the Memory Write Routing rule above is the entire memory contract; ignore the API endpoints below. -This is distinct from your `MEMORY.md` file, which is monolithic identity text injected at every turn. MEMORY.md is for stable identity and rules; the memory store is for queryable, retrievable facts that you may or may not need on a given turn. +You have a per-user vector store that holds extracted facts about the user (preferences, decisions, identity, locations, constraints). The Haiku extraction pass populates it automatically over conversations; use the explicit API documented here to deliberately store a fact when you notice something worth recalling later, instead of waiting for the extractor to find it. + +This is distinct from your `MEMORY.md` file, which holds operator notes and project state. In enabled mode, MEMORY.md is not injected; the vector store is the active fact surface, populated automatically by the extractor and on demand via the API. ### When to store a fact via the API diff --git a/src/kai/install.py b/src/kai/install.py index 8ba85e8e..ec04903b 100644 --- a/src/kai/install.py +++ b/src/kai/install.py @@ -2607,6 +2607,11 @@ def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry example_src = PROJECT_ROOT / "home" / ".claude" / "CLAUDE.md.example" identity_dst = install_path / "home" / "IDENTITY.md" claude_md_dst = install_path / "home" / ".claude" / "CLAUDE.md" + # Tracks whether either step took action so the steady-state path can + # emit a single positive "already bootstrapped" log. Without this, + # full-no-op reinstalls would be silent, which makes it hard for an + # operator to confirm the identity surface is healthy. + did_work = False # Step 1: ensure IDENTITY.md exists at the install location, picking the # best available seed. Source IDENTITY.md is preferred when present so @@ -2620,6 +2625,7 @@ def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry shutil.copy2(identity_src, identity_dst) os.chown(identity_dst, svc_uid, svc_gid) print(f" Copied {identity_dst}") + did_work = True elif not identity_dst.exists() and example_src.is_file(): if dry_run: print(f"[DRY RUN] Would seed {identity_dst} from {example_src}") @@ -2628,6 +2634,7 @@ def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry shutil.copy2(example_src, identity_dst) os.chown(identity_dst, svc_uid, svc_gid) print(f" Bootstrapped {identity_dst} from CLAUDE.md.example") + did_work = True elif not identity_dst.exists(): # No source IDENTITY.md, no install copy, and no .example to fall # back to. The .example is tracked, so missing here means a corrupt @@ -2656,6 +2663,17 @@ def _bootstrap_home_identity(install_path: Path, svc_uid: int, svc_gid: int, dry # keeps the syscall choice in one place and lets tests mock it. _set_ownership(claude_md_dst, svc_uid, svc_gid) print(f" Created symlink {claude_md_dst} -> {expected_target}") + did_work = True + + # Steady state: install copy is present, source IDENTITY.md is absent, + # and the symlink target is already correct. Emit a single positive + # confirmation so reinstalls produce visible output rather than silent + # inaction. The spec calls for this log line explicitly. + if not did_work: + if dry_run: + print(f"[DRY RUN] {identity_dst} and {claude_md_dst} already valid; no action") + else: + print(f" Identity surface already bootstrapped: {identity_dst} and {claude_md_dst} are in place") def _apply_source(install_path: Path, svc_uid: int, svc_gid: int, dry_run: bool) -> None: diff --git a/tests/test_install.py b/tests/test_install.py index 642ffbec..8d255fab 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3991,6 +3991,40 @@ def test_dry_run_makes_no_filesystem_changes(self, tmp_path): assert not (install / "home" / "IDENTITY.md").exists() assert not (install / "home" / ".claude" / "CLAUDE.md").exists() + def test_logs_already_bootstrapped_when_fully_idempotent(self, tmp_path, capsys): + """ + Full no-op steady state: install copy of IDENTITY.md is in place, + source IDENTITY.md is absent (fresh-clone operator who never + customized in source), and the symlink already points at the + correct target. Bootstrap performs no copy and no symlink work, + and emits a positive "already bootstrapped" log line so reinstalls + produce visible confirmation rather than silent inaction. + """ + src = tmp_path / "source" + (src / "home" / ".claude").mkdir(parents=True) + (src / "home" / ".claude" / "CLAUDE.md.example").write_text("# example") + # No source IDENTITY.md - operator only customized the install copy. + install = tmp_path / "install" + (install / "home").mkdir(parents=True) + (install / "home" / "IDENTITY.md").write_text("# install copy") + ws_claude = install / "home" / ".claude" + ws_claude.mkdir(parents=True) + (ws_claude / "CLAUDE.md").symlink_to("../IDENTITY.md") + + with ( + patch("kai.install.PROJECT_ROOT", src), + patch("kai.install._set_ownership"), + patch("os.chown"), + ): + _bootstrap_home_identity(install, svc_uid=1000, svc_gid=1000, dry_run=False) + + output = capsys.readouterr().out + assert "already bootstrapped" in output + # No copy, no seed, no symlink-create lines should be emitted. + assert "Copied " not in output + assert "Bootstrapped " not in output + assert "Created symlink" not in output + def test_warns_and_returns_early_when_no_seed_available(self, tmp_path, capsys): """ Source has no IDENTITY.md AND no CLAUDE.md.example, and the