fix(coding-agent): deliver project rules to the model on the claude-agent-sdk lane and on every turn - #542
Merged
code-yeongyu merged 4 commits intoJul 31, 2026
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Project rules discovered by the
rulesbuiltin (.omo/rules,.claude/rules,.cursor/rules,.github/instructions) never reach the model on theclaude-agent-sdklane, and reach it only on the first user prompt of a session on every other provider./ruleskeeps reporting them as loaded the whole time.Fixes #541.
Root cause
Two independent defects.
1. The
claude-agent-sdklane discards the composed system prompt.buildClaudeAgentSdkQueryOptions()never sendsinput.context.systemPrompt; it rebuilds one from theclaude_codepreset plusappend, andappendonly holdsextractAgentsAppend()+extractSkillsAppend(). The## Project Instructionsblock therulesbuiltin appends inbefore_agent_starthas no extractor, so it is dropped.AGENTS.mdsurvives only because it is re-read from disk, which is why the symptom looked like "only one rule source is broken".2. Static selection was gated on a previous turn's mark.
rules/index.tsfiltered candidates on!engine.isStaticInjected(rule)and marked every rule immediately after formatting. The host re-emitsbefore_agent_startfrom the base system prompt on every user prompt (agent-session.ts), andstaticDedupclears only onsession_start/ acceptedsession_compact. From turn 2 the filter emptied, the handler returnedundefined, and the block was gone — on every provider, not just this lane. Nothing surfaced it because other extensions still return a modified prompt.Why this could not be an extension
The
appendlist is assembled insidebuildClaudeAgentSdkQueryOptions()in the builtin provider; no extension hook can reach it. The region also has to be delimited by whoever produces it, which is therulesbuiltin. Both adaptations are documented in the respectivechanges.mdper the fork contract.Fix
rules/rules/constants.ts,rules/rules/formatter.ts—formatStaticBlockwraps its output in a model-facing<project_rules>envelope, and wraps that in opaque region sentinels. Rule paths and bodies are emitted with the four marker literals neutralized.claude-agent-sdk/options.ts—extractProjectRulesAppend()locates the region by the sentinels, structurally validates each candidate, skips invalid ones, and fails closed when a candidate has no end sentinel. Wired as the thirdappendentry;extractAgentsAppendandextractSkillsAppendare untouched.rules/index.ts— static selection is no longer gated onisStaticInjected; the marks remain, serving only the dynamictool_resultdedup.The block deliberately is not located by its
## Project Instructionsheading or by a bare<project_rules>tag: rule bodies, context files, and extensions registered afterrules(mcpis registered last) can all legitimately contain such text, and any of those shapes silently mislabels or truncates the region. Each shape has a test.How this was found (evidence)
Measured at the SDK boundary by replacing the
@anthropic-ai/claude-agent-sdkpackage entry with a shim that captures thequery()options and returns a canned stream — zero tokens, zero network. Fixture: a temp git repo containing onealwaysApplyrule and anAGENTS.md. Same commit (d99936a1), same command, only the production files differ:systemPrompt.appendlength## Project InstructionspresentAGENTS.mdmarker present</available_skills>presentReproduced with
-ne, so it is not caused by a loaded extension.Discovery was never the problem: driving the engine directly returns the rule with
matchReason: "alwaysApply"and builds the static block, which was then thrown away.For defect 2,
emitBeforeAgentStart()called twice with the same base prompt returned the block only on the first call. Driving--mode rpcwith two prompts in a single process now shows both turns carrying the region.Upstream
The
rulesbuiltin is vendored fromcode-yeongyu/pi-rules. The envelope and the per-turn re-emission belong there too so this adaptation can be dropped on the next re-vendor;rules/changes.mdrecords that.Validation
npm run build— exit 0npm run check— exit 0 (working tree clean afterwards)claude-agent-sdk*,agent-session*, both new suites) — 42 files / 324 tests passed, 0 failed. On the subset that existed before this PR the counts are identical to the pristine baseline atd99936a1(40 files / 301 tests), so no regressions.test/claude-agent-sdk-project-instructions.test.ts(including producer→consumer round-trips through the realformatStaticBlock, marker-quoting rule bodies and paths, decoy marker pairs before and after the region, a dangling sentinel, and fail-closed) and 3 intest/rules-before-agent-start.test.ts(per-turn re-emission, dynamic dedup preserved, nativecontextFilesexclusion). Every production change was landed against tests that failed first for the right reason.npm testin the deterministic mode CI uses (CI=1, whichvitest.config.tsserializes to a single fork) — 1009 test files / 8469 tests passed, 0 failed across all five workspaces (@code-yeongyu/senpialone: 737 files / 6128 tests). NoFAIL, nonot ok, no unhandled rejections; thenpm error ETARGETlines in that log come from the package-command suite's own negative-path fixtures (nonexistent-package@1.0.0).npm test(default parallel pool) surfaced 4 failures —test/mcp/connection.test.ts,test/suite/hooks-command-runner.test.ts,test/suite/terminal-reload-survival.test.ts,test/suite/regressions/5303-bash-output-truncation.test.ts. All four pass in isolation, and a second run of the same workspace on the same commit produced a disjoint single failure (test/omo-local-update.test.ts) with none of the original four. That is the CPU-oversubscription flakinessvitest.config.tsalready documents and is exactly why it serializes underCI. None of those suites touch the code paths in this PR.Scope
This PR only claims that the static project-rules block reaches the model on the
claude-agent-sdklane and on every turn. The same lane still drops the otherbefore_agent_startsystem-prompt mutations (hooks,compaction,mcp,todotools,terminal) and projectCLAUDE.md, and/rulesstill reports discovered rather than delivered rules. Those are noted in #541 and left for separate changes.