Replies: 1 comment
Match ordering: the most specific spec currently loses to the broadest oneAdding this because it's a live bug rather than a design preference, and it's the glob model's version of a problem the directory-based mechanisms have in a different form. The contrast worth namingClaude Code and OpenCode both resolve nested They have to. In those systems a rule's scope is implicit in where the file sits, so the only way to recover the parent rule is to keep climbing. The author can't say "I apply here and no further". Our specs declare their own scope in frontmatter, so there is no chain to walk — we match exactly what declared itself relevant. That's a real advantage of the glob model and it's worth stating explicitly, because it's also the reason the symlink-into-code-dirs idea was rejected: symlinks would have put us back into position-implies-scope. But the same over-inclusion can reappear through broad globs, and it already has. Reproduced on the branch
matches.sort(key=lambda m: m.rel_path)
Editing
So the spec whose scope is literally this one file is crowded out by a directory-wide one, purely on alphabetical order — and the winner is 162 KB, which the 8,192-byte cap truncates to the first 5% anyway. Both specs end up useless in that event: one as a 5% fragment, the other as an index line. Alphabetical order isn't a tie-break here, it's the whole ranking. Two changesRank by specificity. A spec that matched via an exact path or a deep glob is more relevant to the current edit than one that matched via a tree-wide glob. Ranking by the specificity of the glob that matched (path segment depth, wildcard count, exact-vs-pattern) puts the precise one first and lets the broad one degrade to an index line — which is the right way round. Alphabetical order only makes sense as the final tie-break. Discourage over-broad scopes. Rules that genuinely apply repo-wide belong in the session-start context, delivered once — not as a spec declaring Neither depends on the provider direction above; both apply to the current implementation as-is. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Spec injection needs one fact at every tool event: has this spec fallen out of the model's context? #468 answers it by keeping our own state and inferring staleness from transcript line count or wall clock. Both inputs are wrong on the only platform it currently ships to.
This thread is for settling the cross-platform shape so #468 can stay scoped to the Claude Code implementation.
Scope: Claude Code, Codex, Pi, OpenCode, Cursor. Everything below was checked against current official docs and official source on 2026-07-25, with links; anything not documented is labelled as such.
What's broken today
Three failure modes, reproduced against the #468 branch with real
python3:The
/compactbranch never fires. The code reads a negative transcript-line delta as "compaction happened". Claude Code transcripts are append-only. Scanned 2,917 real transcripts under~/.claude/projects: 51 contain a compaction boundary, none ever shrank. After a compact the spec body is gone from context, the line count is still climbing, so the hook stays silent — precisely when a refresh is due.Line count is not turn count. A real session here: 1,112 transcript lines, 9 real user turns — ~124 lines/turn, the rest being assistant messages, tool results, attachments, snapshots.
refresh_window_lines: 300, documented as "about 30 turns", is in practice ≈2.4 turns. The ratio isn't stable either: tool-heavy turns run 100+ lines, conversational turns a handful.Subagents measure the parent's clock. Mid-session hooks receive the parent's
transcript_path. While a subagent works the parent is idle, so the count barely moves — a subagent can run 100+ turns and never leave the window. Subagents are the long-horizon case this feature exists for.One root cause: we're estimating a quantity the platform reports directly.
Why not read the transcript
The obvious fix is to read the conversation and look. Two of the three file-based platforms explicitly rule that out.
Claude Code — Manage sessions:
Claude Code — Hooks, on
transcript_path:Codex — Hooks:
Cursor documents
transcript_pathandCURSOR_TRANSCRIPT_PATHbut never documents the format or the on-disk location — neither promising stability nor disclaiming it.And
compact_boundary, the record my earlier draft keyed on, appears nowhere in Claude Code's docs. It's an observed internal detail of a format the vendor says will change.So: the path is contractual, parsing what's inside it is not.
What every platform does document: compaction events
PreCompact(reason: manual/auto),PostCompactSessionStartwithsource: "compact"PreCompact,PostCompact(trigger: manual/auto)SessionStartwithsource: "compact"preCompactCompactionEntryin the session;ctx.compact()to triggercustom_messageentriesexperimental.session.compacting,experimental.compaction.autocontinueAll five fire something at compaction time. That's the load-bearing fact.
Note the asymmetry that shapes the design: on Cursor there is no post-compaction event at all, and on Codex neither compaction hook can inject. So a design that depends on re-injecting at compaction time only works on two of five.
A design that depends on knowing compaction happened works on all five.
Proposal: event-driven state, not inferred state
Keep the state file — it's the correct substrate, and #468 already has it. Change what goes into it.
Nothing is inferred. "Was it compacted away" stops being a heuristic and becomes a recorded fact, on documented surface, on every platform in scope.
This is a smaller change than it looks: #468's state file, decision function, budget and envelope layers all survive. What's deleted is the line-delta heuristic and the line/wallclock windows.
Provider contract
Adding a platform must not mean writing another injector. Only history access varies, so the contract stays narrow — a provider knows nothing about specs, budgets, or envelopes:
Everything above it — spec matching (already shared in
common/spec_match.py), the decision, truncation, envelope rendering — is written once.The three file-based platforms share one implementation plus a small dialect (which event names, which payload fields). Pi and OpenCode are genuinely different — in-process, no subprocess, messages already in hand — and writing those separately is right. They should still not re-implement the decision: gather the inputs in TS/JS, hand them to the shared Python core as JSON, get a verdict back. Both extensions already spawn Python helpers today.
Resolved by the docs
Three of the original open questions have answers now.
Injection ceilings are per-platform, and the units differ
<temp_dir>/hook_outputs/<session_id>/<uuid>.txt, model gets head-and-tail previewConsequences for #468's fixed 8,192-byte constant: it's too small for Claude Code, and the wrong unit for Codex. CJK is hit hardest — 2,500 tokens is roughly 1,200–2,500 Chinese characters, against Claude Code's 10,000 characters, so the same spec fits very differently on the two.
Budget must be a provider property. Cursor's needs measuring, and until then a conservative default.
Concurrency: one platform has a real fix, the rest need a lock
Claude Code has
PostToolBatch:It fires "before the next model call" — exactly the window where injection matters — with no matcher filtering. That removes duplicate injection under parallel tool use at the root.
Codex and Cursor have no batch event and no documented parallel-tool hook semantics. OpenCode's
tool.execute.afterfires per call and interleaves; pairing must key oncallID(source:packages/opencode/src/session/tools.ts:103-131). Pi documents the same caveat fortool_call:So
PostToolBatchis an optimization where available, and a lock around the state read/write is the portable answer.Context-usage signals are uneven — so don't build the threshold on them
ctx.getContextUsage()→{tokens, contextWindow, percent}— a documented first-class API. Returnstokens: nullright after compaction, before the next LLM response.preCompactpayload carriescontext_usage_percent(0–100),context_tokens,context_window_size,message_count,messages_to_compact,is_first_compaction— but only at compaction timeTokenCountevents withtotal_token_usage/model_context_windoware persisted in the rollout (codex-rs/rollout/src/policy.rs) — but that's the transcript we're told not to parseusagein the transcript — same caveattokensTwo of five expose it on documented, non-transcript surface. So the portable threshold can't be token distance. The portable signal is binary and available everywhere: did a reset happen since the last injection. Token-based distance is an enhancement on Pi and Cursor, not the base mechanism.
Stability grading — why the state file stays
GET /session/:id/messagesessionManager/getContextUsage()experimental.*hooksThe state file isn't a portability floor any more (all five are covered). It's the landing zone for when one of the lower rows changes under us: a provider failure should degrade accuracy, never the feature.
Caveats found in the source that the docs don't mention
ReadonlySessionManager, a hardcodedPick<>whitelist (packages/coding-agent/src/core/session-manager.ts:190-206).buildSessionContext()is listed in the session-format docs but is not in the whitelist. Probe for methods at runtime rather than trusting the doc list.CompactionEntryshapes coexist. The docs describe aretainedTailfield that exists in the newerpackages/agentharness but not incoding-agent's type — so on-disk sessions may carry fields the type doesn't declare.customvscustom_message. Onlycustom_messageenters LLM context;customis extension state and is explicitly ignored by context building. Injections must use the former.Hooksinpackages/plugin/src/index.ts:222-335; several hooks (includingexperimental.chat.messages.transform) have zero documentation coverage.experimental.chat.messages.transformreceives{}— nosessionID. It hands you the messages but not which session they belong to.packages/core/src/session/sql.ts(Drizzle), CLIopencode db, earliest migration dated 2026-01-27, while the troubleshooting page still documents astorage/file layout. Live doc drift — another reason not to read anyone's session store directly.--continue/--resumereplays saved hook output rather than re-running the hook, "so values like timestamps or commit SHAs become stale". Injected blocks should be time-invariant; anything time-sensitive belongs inSessionStart, which does re-run.projects/<project>/<session>/subagents/(in the .claude directory reference), andSubagentStop'stranscript_pathpoints at the subagent's own file. Cursor has the parallelagent_transcript_path. Mid-session events still hand you the parent's.What this changes in #468
refresh_window_lines/refresh_window_secondsagent_id; its own reset events are its ownO_APPENDto one file → 2,400 lines, zero corruption. Sharding addresses a race that doesn't occur, and not the read-before-write race that does.)PostToolUseper tool callPostToolBatchon Claude Code; lock elsewhereThe state file, identity resolution, decision function, budget layer and envelope rendering all stay.
Still open
common/spec_match.py, or a package?paths:in feat(hooks): path-scoped on-demand spec injection #468 exceed the per-spec cap, so FULL is often the first ~5% (one governed spec is 162 KB, truncated mid-table, followed by a note telling the model to go read the 162 KB file). If specs carried an explicit rules section, FULL would be small by construction and the FULL/TICKET distinction might not be needed at all. Bigger change — it touches spec authoring conventions.Sources and verification
Official docs — Claude Code hooks · sessions · .claude directory · Codex hooks · Cursor hooks · Pi extensions · Pi session format · OpenCode plugins · OpenCode server API
Source — openai/codex
codex-rs/protocol/src/protocol.rs,codex-rs/rollout/src/policy.rs,codex-rs/hooks/src/output_spill.rs· earendil-works/pipackages/coding-agent/src/core/session-manager.ts,.../extensions/types.ts· sst/opencodepackages/plugin/src/index.ts,packages/opencode/src/session/tools.ts,packages/core/src/session/sql.ts(checked at pia3ee1d28, opencodedb88c423/ v1.18.4, 2026-07-25)Reproduced locally on 2026-07-25 — transcript scan (2,917 files), line-to-turn ratio,
O_APPENDconcurrency, spec sizes, duplicate-injection race. Probe scripts available on request.🤖 Researched and verified with Claude Code.
All reactions