Skip to content

feat(install): bootstrap per-operator CLAUDE.md from .example template#398

Merged
dcellison merged 4 commits intomainfrom
feature/397-untrack-claude-md
Apr 28, 2026
Merged

feat(install): bootstrap per-operator CLAUDE.md from .example template#398
dcellison merged 4 commits intomainfrom
feature/397-untrack-claude-md

Conversation

@dcellison
Copy link
Copy Markdown
Owner

@dcellison dcellison commented Apr 28, 2026

Summary

Untrack home/.claude/CLAUDE.md and home/IDENTITY.md so they become per-operator local files, ship home/.claude/CLAUDE.md.example as the tracked universal-content template, and extend make install to bootstrap the operator's local home/IDENTITY.md from the example on a fresh clone (and reconcile the home/.claude/CLAUDE.md symlink) on every run. Re-running install when both files are already in place performs no copy or symlink work and emits a single "already bootstrapped" log line; the symlink reconciliation specifically is a quiet short-circuit when the target is already correct.

This is the first piece of #396; subsequent PRs gate the memory subsystem on MEMORY_ENABLED, drive write routing from a runtime context flag, and migrate existing MEMORY.md content to Qdrant.

What changed

  • home/.claude/CLAUDE.md.example (new, tracked). Universal content only: hard rules (no source modification, no EnterPlanMode, symlink target convention), public-facing content rules, conditional memory write rule (routes new facts to either the API or MEMORY.md based on a runtime [Memory subsystem: ...] line, with explicit handling of the absent-line and absent-block cases), behavioral rules, and the full API reference block (scheduling, send-message, send-file, memory, external services). The Memory System section is gated on enabled mode at the top so disabled-mode operators do not read about API endpoints that are unavailable to them.
  • home/.claude/CLAUDE.md and home/IDENTITY.md removed from tracking; both kept out via .gitignore (existing unblock-by-negation pattern updated). Operators' local copies persist on disk and are not affected.
  • src/kai/install.py:
    • New _bootstrap_home_identity() helper. Picks the best available seed for home/IDENTITY.md (operator's source copy when present, .example template otherwise) and reconciles the home/.claude/CLAUDE.md symlink. Returns early with a warning when neither seed is available so the install does not produce a symlink to a nonexistent target. Logs a single "already bootstrapped" line when both files are in place and the symlink target is correct.
    • _HOME_CLAUDE_EXCLUDES now also excludes CLAUDE.md so the operator's local untracked symlink is not copied during the home/.claude/ tree walk; the bootstrap helper is the single source of truth for the symlink.
    • _apply_source() rewired to call the helper for both dry-run and real installs.
  • home/.claude/MEMORY.md.example: refreshed with a brief role description (its file's role shifts: it is now the seed file for new operators on the disabled-mode path, and the migration source on the enabled-mode path) plus starter section headings.

Note for existing operators

Existing operators with their own home/IDENTITY.md will keep using it across reinstalls (source-IDENTITY.md wins over .example in branch 1). That means the new universal baseline shipped here (Memory Write Routing rule, gated Memory System section, refined Public-Facing Content Rules) will not be picked up automatically. To adopt the new baseline, diff your local home/IDENTITY.md against home/.claude/CLAUDE.md.example and merge in the universal pieces by hand. This is intentional per the broader Phase 2 triage step.

Tests

  • 8 new unit tests in TestBootstrapHomeIdentity covering: seed-from-example on fresh clone, prefer-source over example when both present, no-op when install copy exists and no source IDENTITY.md, symlink reconciliation (skip when correct, recreate when wrong target, create when missing), dry-run does not mutate the filesystem, warn-and-return-early when no seed is available, and the "already bootstrapped" log line in the full-idempotent path.
  • 3 existing TestApplySource tests updated to match the new contract: mock_own.call_count adjusted from 2 to 3 to account for the symlink lchown via _set_ownership; the two "warns when IDENTITY.md missing" tests renamed to assert the new "neither source nor example present" warning behavior.
  • Full suite: 2532 passed, 1 skipped.

Test plan

  • make check (ruff check + format)
  • pytest tests/test_install.py::TestBootstrapHomeIdentity tests/test_install.py::TestApplySource
  • Full pytest suite
  • Manual: confirm a fresh clone followed by make install produces a writable home/IDENTITY.md and a working home/.claude/CLAUDE.md symlink at the install location.
  • Manual: re-run make install immediately after; verify the second run prints the "already bootstrapped" log line and no "Created symlink" / "Bootstrapped" / "Copied" lines for the identity surface.

fixes #397
refs #396

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.
@dcellison
Copy link
Copy Markdown
Owner Author

Review by Kai

The installed version at /opt/kai is the pre-PR state, so I'll base the review entirely on the diff.


Review: PR #398 feat(install): bootstrap per-operator CLAUDE.md from .example template

Overall this is a clean, well-tested change. The three-branch Step 1 logic is correct, idempotency guarantees are sound, and tests cover the key paths.


Warning

_apply_source dry_run path silently drops the ws_claude_src.is_dir() guard for the real run (install.py)

In the dry_run path the copy of home/.claude/ is guarded:

if ws_claude_src.is_dir():
    print(f"[DRY RUN] Would copy: {ws_claude_src} -> {ws_claude_dst}")

But the real-run path calls _copy_tree(ws_claude_src, ws_claude_dst, _HOME_CLAUDE_EXCLUDES) without that guard (pre-existing pattern). This means dry_run output can say "not copying home/.claude/ (doesn't exist)" while the real run would still call _copy_tree on a missing source. This is pre-existing, not introduced here - but worth noting since the PR touches this code path.


Suggestions

1. Dry-run warning test missing symlink assertion (tests/test_install.py)

test_dry_run_warns_when_neither_source_nor_example_present checks for "WARNING" and "neither" in output, but unlike its non-dry_run counterpart (line ~3778), it doesn't assert:

assert not (install / "home" / ".claude" / "CLAUDE.md").exists()

The early return prevents symlink creation in both modes, but the dry_run test doesn't verify it. Minor coverage gap.

2. Meta-content placement in CLAUDE.md.example

The paragraph starting "This file is the bootstrap template. Operators copy it to home/IDENTITY.md..." sits inside the "Who You Are" narrative section. An operator customizing voice/personality will encounter instructions about the file's own lifecycle mid-paragraph. This content would be clearer at the top of the file as a header comment block, separated from the behavioral identity content.

3. _bootstrap_home_identity always overwrites install copy when source IDENTITY.md is present

Step 1 copies identity_src unconditionally whenever it exists - including overwriting manual edits the operator made directly to the install copy. This is documented as intentional ("preserves the make-install-as-edit-propagation workflow"), but a first-time reader of the docstring may expect copy-only-if-changed semantics. The docstring's "This preserves the existing..." sentence is slightly misleading - it means the SOURCE is the authoritative copy, not the install copy. Worth a one-line clarification.


No issues found in:

  • .gitignore negation pattern update
  • _HOME_CLAUDE_EXCLUDES addition and comment
  • Symlink reconciliation logic (correct use of is_symlink() to catch broken symlinks that exists() misses)
  • mock_own.call_count == 3 update - the fixture still has source IDENTITY.md (evidenced by the unchanged copy2 called twice: pyproject.toml and IDENTITY.md comment), so bootstrap reaches Step 2 on a fresh install dir, making the count correct
  • MEMORY.md.example additions

- 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.
@dcellison
Copy link
Copy Markdown
Owner Author

Review by Kai

This is a clean PR. All three suggestions from the prior review were addressed - the dry-run test now asserts the symlink is absent, the "About This File" meta-content was moved to a top-level section, and the docstring now explicitly states that direct edits to the install copy are not protected.

I traced through the test_actual mock counts and they're correct: mock_own (3) = src/ + home/.claude/ recursive + CLAUDE.md symlink; mock_cp (2) = pyproject.toml + IDENTITY.md; os.chown (3) = .claude dir itself + pyproject.toml + IDENTITY.md. The _copy_tree - _set_ownership - os.chown two-step for the .claude/ directory is a pre-existing pattern for mixed root/svc ownership.

One minor note:

Suggestion - _HOME_CLAUDE_EXCLUDES comment (install.py, line ~82):
The trailing comment block says "Both are still excluded because stale files may remain at the source after migration" - the "both" refers to history and MEMORY.md, which migrated to DATA_DIR. The new CLAUDE.md entry is excluded for an entirely different reason (bootstrap path is the single source of truth), and the existing comment doesn't apply to it. The per-entry comment above CLAUDE.md explains it correctly, but the sentence below creates a mild false impression that CLAUDE.md is also a migration artifact. Worth a one-word fix: "All three" or splitting the sentence to exclude the CLAUDE.md entry from the migration rationale.

Nothing else to flag.

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.
@dcellison
Copy link
Copy Markdown
Owner Author

Review by Kai

The _HOME_CLAUDE_EXCLUDES comment has been updated to say "they remain in the excludes list" (referring to history and MEMORY.md) with an explicit "CLAUDE.md is excluded for a different reason" sentence - that addresses the one remaining note from the prior round.

The rest is clean. Tracing through the three-branch logic in _bootstrap_home_identity:

  • Branch 1 (identity_src.is_file()): copies and chowns - correct.
  • Branch 2 (not identity_dst.exists() and example_src.is_file()): seeds from example and chowns - correct.
  • Branch 3 (not identity_dst.exists(), no example): warns and returns before touching the symlink - correct; the check not identity_dst.exists() evaluates True for broken symlinks as intended.
  • Implicit else (identity_dst.exists(), no source): falls through to symlink reconciliation with no copy - correct no-op behavior.

Symlink reconciliation uses is_symlink() (not exists()) to guard the pre-unlink check, which correctly handles broken symlinks - noted as correct in the prior review and unchanged.

test_skips_symlink_when_target_already_correct patches _set_ownership and os.symlink but calls real os.readlink on a real symlink created in tmp_path - this is correct since the test needs an actual ../IDENTITY.md symlink on disk to exercise the short-circuit path.

The test_no_op_when_install_copy_exists_and_no_source test leaves the symlink uncreated, which means the function does create the symlink even in the steady-state path - the test only verifies IDENTITY.md content is preserved, which is the contract being tested. No issue.

One minor observation, not a bug: in branch 2, identity_dst.parent.mkdir(parents=True, exist_ok=True) creates install_path/home/ but not install_path/home/.claude/. The symlink step has its own claude_md_dst.parent.mkdir(parents=True, exist_ok=True) call covering that, so the directory chain is complete. No gap.

This is clean. No new issues.

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.
@dcellison
Copy link
Copy Markdown
Owner Author

Review by Kai

This PR is clean. The three prior review rounds addressed all meaningful issues, and the final state is sound.

I traced through the logic independently:

_bootstrap_home_identity branch coverage:

  • Branch 1 (identity_src.is_file()): copies and chowns - correct.
  • Branch 2 (not identity_dst.exists() and example_src.is_file()): seeds from example and chowns - correct.
  • Branch 3 (not identity_dst.exists(), no example): warns and returns before touching the symlink - correct. The not identity_dst.exists() check also fires for broken symlinks, which is the right behavior.
  • Implicit else (identity_dst.exists(), no source): falls through to symlink reconciliation with no copy - correct no-op.

Symlink reconciliation: Uses is_symlink() rather than exists() in the pre-unlink guard, which correctly handles broken symlinks. The unlink guard claude_md_dst.is_symlink() or claude_md_dst.exists() handles the edge case of a regular file named CLAUDE.md being replaced.

did_work flag: Correctly set in both dry_run and real paths so the "already bootstrapped" steady-state confirmation is suppressed when any action (real or simulated) was taken.

Test counts in test_actual:

  • mock_own == 3: src/ tree + home/.claude/ tree + CLAUDE.md symlink (lchown via _set_ownership) - correct.
  • mock_cp == 2: pyproject.toml + IDENTITY.md - correct.
  • os.chown == 3: pyproject.toml (root) + .claude dir itself (svc) + IDENTITY.md (svc) - correct.

_HOME_CLAUDE_EXCLUDES comment: The updated comment correctly distinguishes the CLAUDE.md exclusion reason from the migration-artifact rationale for history and MEMORY.md.

No bugs, security issues, or convention violations found.

@dcellison dcellison merged commit 5575dfb into main Apr 28, 2026
1 check passed
@dcellison dcellison deleted the feature/397-untrack-claude-md branch April 28, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1: untrack home/.claude/CLAUDE.md, ship .example template

2 participants