Load CLAUDE.md files as system prompts#229
Conversation
CLAUDE.md current state was pointing at PR #196 (step 5b) as in-progress; the architecture refactor completed through PR #199 (step 5e) weeks ago. Config loading (#222), git delta injection (#225), and the ANSI wrap fix (#223) had also shipped without being recorded. Updated current state, added two missing recent-decisions entries, removed a duplicate closing tag. Created issue #226 for CLAUDE.md loading and added the design to cli-features.md: load order, hot reload pattern, config opt-out.
Adds cachedReminders?: string[] to RunAgentQuery — each entry becomes a <system-reminder> block prepended to the first user message of a new conversation. Stored in history so the prefix is cached by the API on every subsequent turn. ClaudeMdLoader reads ~/.claude/CLAUDE.md, CLAUDE.md, .claude/CLAUDE.md, and CLAUDE.md.local at startup (missing files silently skipped). Content is combined under a single instruction prefix and passed as cachedReminders. Stopping here to fix a separate bug in systemReminder (injected into tool-result messages instead of only human turns) before completing this feature. Will rebase onto the fix once it lands. Closes #226 (not yet ready — resuming after systemReminder fix).
The WIP commit had ClaudeMdLoader, the cachedReminders SDK field, and the wiring in main.ts/runAgent.ts, but was missing two things: 1. The claudeMd.enabled config flag. Loading is on by default; setting it to false in sdk-config.json disables it entirely. Follows the same .optional().default().catch() pattern as historyReplay so invalid values silently fall back to the default rather than crashing. 2. Tests for the cachedReminders injection path in AgentRun: - injects reminder as first block when history is empty - skips injection when the conversation already has messages The second test asserts absence of a <system-reminder> block rather than string content type, because RequestBuilder converts all string content to arrays before the streamer sees it. Closes #226
The injection condition was `history.messages.length === 0`, which only covered a fresh conversation. After compaction, the history contains one message — the compaction block (assistant role) — so the condition was false and reminders were not re-injected. This is wrong. Compaction drops all content before the compaction block, including the first user message that held the cached reminders. The next human turn needs the reminders re-injected so they are present in the effective context. Fix: change the condition to check for absence of user messages rather than an empty history. After compaction only the assistant compaction block remains — no user messages — so injection correctly fires. Once the new user message (with reminders) is pushed, subsequent turns have a user message in history and injection is correctly skipped. Test added first to prove the bug: post-compaction history with only the compaction block, verifying the first user message sent to the API contains a <system-reminder> block. The test failed before the fix and passes after.
The WIP commit had two issues caught by the pre-push hook: - INSTRUCTION_PREFIX split across lines (format violation) - Braces missing on single-line if return (useBlockStatements) - Four non-null assertions in the spec (noNonNullAssertion) Replaced ! assertions with ?? '' — the tests that use the result for string operations still work correctly, and the null case would produce an empty string that fails the subsequent toContain assertions anyway.
… turn IFileSystem changed to an abstract class with cwd() added alongside homedir(), so the filesystem owns all path context. NodeFileSystem implements cwd() via process.cwd(); MemoryFileSystem takes a cwd constructor param so tests can set it alongside home. ClaudeMdLoader drops the separate cwd/home constructor params and calls fs.cwd()/fs.homedir() inside getContent(), keeping the constructor to a single IFileSystem argument. A nodeFs singleton is exported from the fs entry so callers import it directly instead of newing a NodeFileSystem. Content was read once before the loop and stored in cachedReminders, making the on-demand design pointless. It now reads inside the loop on every turn so CLAUDE.md changes are picked up without a watcher.
bananabot9000
left a comment
There was a problem hiding this comment.
Clean implementation. The compaction bug discovery (commit 4) is the highlight — length === 0 vs !some(m => m.role === 'user') is exactly the kind of off-by-one that only surfaces when you think about what the history looks like AFTER compaction, not just at session start. Test-first catch was textbook.
cachedReminders as a concept separate from systemPrompts is the right call — different caching placement (user message vs system array) for the same goal. The injection condition correctly handles both fresh and post-compaction states.
IFileSystem interface → abstract class with cwd() is a nice consolidation. Single constructor arg for ClaudeMdLoader instead of threading path context separately.
One observation (not blocking): getContent() reads all files on every turn. For a feature that changes rarely, that's a lot of I/O. The old WIP had it read once before the loop — commit 8 moved it inside deliberately ("changes are picked up without a watcher"). Worth watching if turn latency becomes noticeable, but probably fine for now.
🍌 Approved.
* Switch packages from custom build scripts to tsup Each package had a hand-rolled build.ts that called esbuild directly. Replaced with tsup.config.ts in each package, which handles ESM + CJS output and DTS generation in one pass. Key decisions: - bundle: true is required because bundle: false only transpiles entry files, leaving relative imports unresolved in dist. Consuming bundlers follow those imports and find nothing. - clean: true replaces the @shellicar/build-clean plugin. The plugin uses esbuild's metafile to find current build outputs and delete everything else. TypeScript-generated DTS files never appear in the metafile, so the plugin deleted them every time. clean: true wipes the outdir before the build starts, so JS and DTS land together. - entryNames: '[name]' in esbuildOptions puts JS files at the same level as DTS files. tsup's TypeScript pass ignores esbuild's entryNames entirely, so DTS always lands flat at dist/esm/[name].d.ts. Matching JS placement keeps them co-located and matches the exports map. - claude-sdk-tools entry is src/entry/*.ts, not src/*.ts. The public tool modules live under src/entry/; src/*.ts only contains internal utilities. Exports maps updated to the nested import/require form with types + default sub-conditions, pointing to the correct dist paths. tsconfig.json in each package now scopes to src/**/*.ts and excludes dist/node_modules. tsconfig.check.json symlinks to the root copy. turbo.json build inputs updated to include tsup.config.ts. Both apps updated: entryNames '[name]', packages: 'external' instead of a manual external list, bin paths updated from dist/entry/main.js to dist/main.js, workspace deps moved to dependencies since they are externalized and need to be present at runtime. * Fix biome CI errors Format fixes in the three tsup.config.ts files (arrow function body style), indentation in claude-sdk-tools package.json exports, trailing newline in claude-sdk-cli package.json, import sort order in runAgent.ts. Remove unused CacheTtl import from AgentRun.ts and reformat a ternary in MessageStream.ts — both left over from the cache TTL refactor that came in via rebase. * Session log and harness update for packaging tsup migration Session 3 appended to 2026-04-09.md covering the build verification, post-rebase check, biome CI fix approach, and PR #230. Current State updated: branch fix/packaging, PR #230 open, recent PRs #228/#229 added to the post-refactor list. * Add @shellicar/changes tooling: schema generation, validation, CI integration Sets up the full changes.jsonl toolchain for this repo: - changes.config.json defines the valid categories (feature, fix, breaking, deprecation, security, performance) with their display names - scripts/src/generate-schema.ts generates schema/shellicar-changes.json from the Zod definitions + config; category is required (repo policy, stricter than the base spec) - scripts/src/validate-changes.ts validates all **/changes.jsonl files against the schema via ajv; no-args mode globs the repo, or pass specific paths - CI (.github/workflows/node.js.yml) runs the validator on every push Adds changes.jsonl entries for the tsup packaging work (PR #230) in claude-core, claude-sdk, and claude-sdk-tools. Updates both CLAUDE.md files: correct category enum, removed the bad 'issue' field from the example, noted that category is required and that issue links belong in metadata not at the top level, added the @shellicar/changes tooling section. * Session log: @shellicar/changes tooling session (2026-04-09 continued) * Add @shellicar/changes toolchain with per-package changelogs Builds out the full changes toolchain across the monorepo: - changes.config.json: Keep a Changelog standard categories (added/changed/deprecated/removed/fixed/security). Custom category names were replaced because these are the recognised standard and tooling elsewhere understands them. - schema/shellicar-changes.schema.json (renamed from .json): generated JSON Schema artifact. The .schema.json suffix is conventional for JSON Schema files and makes intent unambiguous in editors. - scripts/src/generate-schema.ts: generates schema from Zod definitions + changes.config.json. Must run from scripts/ directory. - scripts/src/validate-changes.ts: validates every **/changes.jsonl against the schema via ajv. CI runs this on every push. - scripts/src/generate-changelog.ts: generates CHANGELOG.md for a package from its changes.jsonl. Groups entries by category in config order within each release. Renders metadata.issue as (#NNN) and metadata.ghsa as a linked advisory suffix. - Release markers gain an optional tag field. When absent the script defaults to <shortName>@<version>. The claude-cli app uses explicit tags because its historical alphas used unscoped version tags. - changes.jsonl + CHANGELOG.md added for all five packages/apps. apps/claude-cli carries the full reverse-engineered history from the root CHANGELOG.md (alpha.67 through alpha.74) so the generated CHANGELOG.md is the authoritative source going forward. - Both CLAUDE.md files updated with a dedicated @shellicar/changes section covering config, schema, and all three scripts. * Fix biome errors in scripts Formatting applied via --changed --since=origin/main to scope fixes to only the files we touched. Rewrote the ??= pattern as a plain if-block: clearer to read, no cleverness required. * Fix CI: run validate via scripts package, not pnpm tsx from root tsx is only installed in the scripts workspace package. Running pnpm tsx from the repo root fails because there is no root-level tsx binary. Use pnpm --filter scripts run validate instead, which runs inside the package where tsx is available.
Closes #226
What
Loads CLAUDE.md files from standard locations at startup and passes their content as
cachedReminders— injected into the first user message of each conversation (and after compaction) so the instructions are present in context without being repeated in every request.Load order (missing files silently skipped):
~/.claude/CLAUDE.md— user-scoped<project>/CLAUDE.md— project root<project>/.claude/CLAUDE.md— project-scoped<project>/CLAUDE.local.md— local machine (gitignored)All present files are combined under a single instruction prefix into one
cachedRemindersentry.Config:
claudeMd.enabled(defaulttrue) insdk-config.jsondisables loading entirely.SDK changes (
cachedReminders)RunAgentQuerygainscachedReminders?: string[]. Each entry is injected as a<system-reminder>block prepended to the first user message when there are no user messages in history. This covers two cases:Injection is skipped on subsequent turns because history then contains user messages.
Tests
ClaudeMdLoader: load order, all four files, empty files skipped, whitespace trimmed, prefix appears onceAgentRun — cachedReminders: injected when history is empty; injected after compaction (failing test written first to prove the bug); not injected when user messages already existsdkConfigSchema — claudeMd: defaults, override, fallback on invalid value