Skip to content

Nudge Claude to declare a plan document when it reads one - #1313

Merged
alexeyzimarev merged 2 commits into
mainfrom
alexey/plan-read-nudge
Oct 5, 2026
Merged

alexeyzimarev merged 2 commits into
mainfrom
alexey/plan-read-nudge

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #1312 — AI-3472

What & why

An agent handed a plan file often never declares it through kcap-plans. A new synchronous PostToolUse hook on Read (kcap hook --claude --plan-read) sends a .md read to kcap-server's POST /hooks/plan-read (kurrent-io/kcap-server#2240) and hands the reminder it answers with to the agent as hook context, at most once per session and document. Same gate and opt-out as the SessionStart plans nudge; only the first 16,384 characters of the file are sent, after secret redaction.

Where to look

The catch-all PostToolUse stays async; this is a second, Read-only entry in hooks.json, because an async hook's context does not reach the agent before its next step. It dispatches ahead of the spool drain in Program.cs and fails open on a 2 s budget. Needs kcap-server#2240 deployed; until then the endpoint 404s and the hook stays silent.

Verification

  • AOT binary, real dispatch: a non-Markdown Read returns in ~70 ms, a .md Read with the server round trip in ~270 ms; both exit 0.
  • New tests: nudge emitted once per document, re-asked after {}, skipped for non-Markdown, unregistered kcap-plans and the opt-out; parser and ledger. ClaudeHookCommandTests 94/94.
  • dotnet publish -c Release: 0 IL warnings.

🤖 Generated with Claude Code

A separate synchronous PostToolUse entry on Read: the catch-all is async, and an async hook's
context never reaches the agent before its next step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T09:17:16.553853Z 9ec347f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Nudge Claude to declare plan documents after reading them

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Add a synchronous Claude Read hook so plan reminders reach the agent before its next step.
• Ask the server about Markdown reads, sending only the document head and suppressing repeat
 reminders.
• Reuse the plans-nudge opt-out and MCP availability gate; add tests and user documentation.
Diagram

graph TD
  A["Claude Read"] --> B["Synchronous hook"] --> C{"Markdown and eligible?"} -->|yes| D["Session ledger"] -->|not nudged| E["Plan-read API"] -->|nudge| F["Hook context"] --> G["Claude agent"]
Loading
High-Level Assessment

Keep the separate synchronous Read hook: the existing async catch-all cannot deliver context before Claude's next step. The bounded, fail-open server request and local per-document ledger fit this interaction better than changing the catch-all hook.

Files changed (9) +347 / -1

Enhancement (4) +154 / -1
ClaudeHookCommand.csHandle plan reads with bounded, fail-open server requests +59/-1

Handle plan reads with bounded, fail-open server requests

• Applies the plans opt-out, MCP registration, capture suppression, and per-document ledger before requesting a nudge. Emits returned context and clears session markers at session end.

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs

ClaudePlanRead.csParse Markdown reads and format plan-read hook output +59/-0

Parse Markdown reads and format plan-read hook output

• Extracts the session, path, and document head from Claude Read events, then constructs server requests and PostToolUse context. Limits content to 16,384 characters without splitting a surrogate pair.

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs

PlanReadNudgeLedger.csTrack reminders by session and document +32/-0

Track reminders by session and document

• Stores best-effort hashed document markers to avoid repeating a delivered reminder and supports session-end eviction.

src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs

Program.csDispatch plan reads before the general spool drain +4/-0

Dispatch plan reads before the general spool drain

• Routes the dedicated Claude command directly so the synchronous Read hook does not wait for unrelated spool processing.

src/Capacitor.Cli/Program.cs

Tests (3) +182 / -0
ClaudeHookCommandTests.csTest plan-read delivery and suppression gates +89/-0

Test plan-read delivery and suppression gates

• Covers one reminder per document, retries after empty responses, non-Markdown files, missing kcap-plans registration, and the opt-out. Adds a fixture path through the new handler.

test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs

ClaudePlanReadTests.csTest plan-read parsing and content limits +71/-0

Test plan-read parsing and content limits

• Covers event filtering, malformed input, absent content, document-head truncation, surrogate safety, and responses without usable nudge text.

test/Capacitor.Cli.Tests.Unit/Harness/Claude/ClaudePlanReadTests.cs

PlanReadNudgeLedgerTests.csTest per-session document reminder markers +22/-0

Test per-session document reminder markers

• Verifies that markers distinguish sessions and documents and disappear when the session is evicted.

test/Capacitor.Cli.Tests.Unit/Harness/Claude/PlanReadNudgeLedgerTests.cs

Documentation (1) +1 / -0
README.mdDocument the Claude plan-read reminder +1/-0

Document the Claude plan-read reminder

• Explains when the reminder appears, its once-per-document behavior, and its shared gate and opt-out with the SessionStart plans nudge.

README.md

Other (1) +10 / -0
hooks.jsonRegister a synchronous Read-only PostToolUse hook +10/-0

Register a synchronous Read-only PostToolUse hook

• Adds a dedicated plan-read command while leaving the catch-all PostToolUse hook asynchronous.

kcap/hooks/hooks.json

@qodo-code-review

qodo-code-review Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Published hooks never send plan reads 🐞 Bug ≡ Correctness
Description
ClaudePlanRead.ToRequest assigns strings directly to a JsonObject, which requires unavailable
JSON metadata in the CLI's NativeAOT build. On a qualifying Markdown read, that assignment throws
before the POST, and HandlePlanReadCore catches the exception and exits without a reminder;
RenderHookOutput contains the same failure pattern if request construction is fixed alone.
Code

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[R29-30]

+        var request = new JsonObject { ["session_id"] = SessionId, ["path"] = Path };
+        if (Content is not null) request["content"] = Content;
Relevance

●●● Strong

AOT-sensitive JSON construction failures are treated as correctness bugs, especially when fail-open
handling hides them.

PR-#480
PR-#1178

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The repository explicitly documents that direct string assignment into JsonObject throws under
NativeAOT and provides a parsed-string workaround. The new request is constructed inside a catch-all
before the POST, while the CLI project enables NativeAOT.

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[28-31]
src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[43-49]
src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[107-118]
src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[1335-1339]
src/Capacitor.Cli/Commands/McpWorkItemsServer.cs[875-888]
src/Capacitor.Cli/Capacitor.Cli.csproj[6-10]
PR-#1178

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Direct string assignments to the new JSON objects fail under the CLI's NativeAOT configuration, so the plan-read handler silently exits before posting or rendering a nudge.
## Fix Focus Areas
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[28-31]
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[43-49]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[1335-1339]
## Recommended Fix
Construct every string-valued JSON node through the existing parsed-string approach, including request fields and hook output fields. Verify both the outbound request and emitted context with a published NativeAOT binary.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗



Remediation recommended

2. Parallel reads repeat the same reminder ✗ Dismissed
Description
HandlePlanReadCore checks WasNudged before its server call but records the marker only after
writing the nudge, without an atomic claim between those steps. When two hook processes read the
same document concurrently in one session, both can observe a missing marker and both deliver the
reminder.
Code

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[R99-100]

+            var ledger = new PlanReadNudgeLedger(config);
+            if (ledger.WasNudged(read.SessionId, read.Path)) return 0;
Relevance

●●● Strong

Concurrent check-then-record races are accepted when they can duplicate attribution or reminders.

PR-#242

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The hook reads the marker, awaits authentication and a server POST, then writes the marker. The
ledger implements those operations as independent File.Exists and File.WriteAllText calls.

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[99-114]
src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs[12-20]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Separate marker-check and marker-write operations let concurrent plan-read hooks both emit a once-per-document reminder.
## Fix Focus Areas
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[99-114]
- src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs[12-21]
## Recommended Fix
Use a cross-process atomic claim or lock for each session/document while checking and posting. Release an unsuccessful claim so a later read can retry when no nudge was emitted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Multibyte documents exceed the upload cap ✗ Dismissed
Description
ClaudePlanRead.Head truncates at 16,384 UTF-16 characters rather than at 16 KB of encoded content.
When a Markdown document contains multibyte text, StringContent UTF-8-encodes that
character-limited head into a request substantially larger than the stated upload limit.
Code

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[R51-54]

+    static string? Head(string? content) {
+        if (content is null || content.Length <= MaxContentChars) return content;
+        var end = char.IsHighSurrogate(content[MaxContentChars - 1]) ? MaxContentChars - 1 : MaxContentChars;
+        return content[..end];
Relevance

●●● Strong

Repository precedent accepts correcting character-based caps when the documented limit is bytes.

PR-#129

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new constant is counted as characters when slicing a .NET string, but the request subsequently
encodes that string as UTF-8; the PR description states a 16 KB file-head limit.

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[8-10]
src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[51-54]
src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[107-108]
README.md[296-296]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The documented 16 KB file-head limit is implemented as 16,384 UTF-16 characters, so multibyte Markdown sends more file data than promised.
## Fix Focus Areas
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[8-10]
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[51-54]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[107-108]
## Recommended Fix
Truncate the file head against its UTF-8 byte length without splitting a Unicode scalar value, and add a multibyte-content test asserting the encoded upload stays within 16 KB.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. One document gets reminders by each path ✗ Dismissed
Description
PlanReadNudgeLedger.Marker hashes the unmodified path string rather than a normalized document
path. When Claude reads the same undeclared document using relative and absolute paths, or another
path alias, each spelling gets a separate marker and can trigger another reminder.
Code

src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs[R30-31]

+    string Marker(string sessionId, string path) =>
+        Path.Combine(SessionDir(sessionId), Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(path)))[..32]);
Relevance

●● Moderate

Path alias deduplication is plausible, but no close precedent confirms normalization is required
here.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The parser accepts the tool-input path without normalization, the hook passes it directly to the
ledger, and the ledger hashes its exact bytes to select a marker.

src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[20-25]
src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[99-114]
src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs[28-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The once-per-document ledger treats different path spellings for the same file as different documents.
## Fix Focus Areas
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[20-25]
- src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs[28-31]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[99-114]
## Recommended Fix
Derive a stable document key using the read event's working directory for relative paths and appropriate path normalization before both ledger lookup and recording. Test relative and absolute reads of the same document.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (1)
5. Slow servers suppress plan reminders ✓ Resolved
Description
HandlePlanReadCore applies a two-second ceiling through HookBudget.Remaining, which subtracts
the shared 1.5-second safety reserve before allowing authentication or the POST. With process
startup time charged to the same budget, a server round trip that fits within two seconds can
exhaust the much smaller effective allowance and cause the hook to exit without context.
Code

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[R102-105]

+            var budget = clock.Budget(PlanReadCeiling);
+            if (await ShouldSuppressCaptureAsync(read.SessionId, body, "post-tool-use", profile, budget)) return 0;
+
+            if (await clientWithin(budget.Remaining) is not { } client) return 0;
Relevance

●● Moderate

Budget-reservation concerns have mixed precedent; timing tradeoffs remain context-dependent.

PR-#397
PR-#395

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new handler uses a two-second ceiling and passes budget.Remaining to both auth and POST.
HookBudget.Remaining subtracts 1.5 seconds plus time elapsed since process entry.

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[66-67]
src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[102-108]
src/Capacitor.Cli/Commands/HookBudget.cs[8-16]
src/Capacitor.Cli/Program.cs[99-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The two-second plan-read ceiling inherits a 1.5-second spool-and-exit reserve, leaving at most 500 ms for authentication and the server request.
## Fix Focus Areas
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[66-67]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[102-108]
- src/Capacitor.Cli/Commands/HookBudget.cs[8-16]
## Recommended Fix
Use a plan-read-specific budget or configurable safety reserve appropriate for a hook that does not spool, while retaining a hard deadline below the hook timeout. Test a response that arrives after 500 ms but before that deadline.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
  Explored: repo: kurrent-io/kcap-server (branch: capacitor/agent-4e6d29dc6d964d, sha: 914f44de) — View relationship
Review mode: Auto: ⚖️ Balanced: This introduces synchronous hook behavior, HTTP/auth interactions, session/document ledger state, configuration gating, and CLI dispatch changes across multiple paths, warranting a complete review but not redundant extended passes.

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +29 to +30
var request = new JsonObject { ["session_id"] = SessionId, ["path"] = Path };
if (Content is not null) request["content"] = Content;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Published hooks never send plan reads 🐞 Bug ≡ Correctness

ClaudePlanRead.ToRequest assigns strings directly to a JsonObject, which requires unavailable
JSON metadata in the CLI's NativeAOT build. On a qualifying Markdown read, that assignment throws
before the POST, and HandlePlanReadCore catches the exception and exits without a reminder;
RenderHookOutput contains the same failure pattern if request construction is fixed alone.
Agent Prompt
## Issue description
Direct string assignments to the new JSON objects fail under the CLI's NativeAOT configuration, so the plan-read handler silently exits before posting or rendering a nudge.
## Fix Focus Areas
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[28-31]
- src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs[43-49]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[1335-1339]
## Recommended Fix
Construct every string-valued JSON node through the existing parsed-string approach, including request fields and hook output fields. Verify both the outbound request and emitted context with a published NativeAOT binary.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproduced: the published NativeAOT binary, run against a stub server, sends the request and writes the hook output; the existing hook code assigns strings into JsonObject the same way. dotnet publish reports no IL warnings.

Comment thread src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
Comment thread src/Capacitor.Cli/Harness/Claude/ClaudePlanRead.cs Outdated
Comment thread src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs
Comment thread src/Capacitor.Cli/Harness/Claude/PlanReadNudgeLedger.cs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ec347f365

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +107 to +108
using var content = new StringContent(read.ToRequest().ToJsonString(), Encoding.UTF8, "application/json");
using var resp = await client.PostOnceAsync($"{Url}/hooks/plan-read", content, clock.Time, budget.Remaining);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact Markdown content before posting it

When a Markdown file contains a token, password, or other credential-like value, read.ToRequest() serializes the raw tool_response.file.content and this new request sends it directly to /hooks/plan-read. This bypasses the SecretRedactor used by TranscriptCapture.Encode, so secrets removed from normal transcript uploads are transmitted verbatim and may be logged or stored by the endpoint; redact the content before constructing this request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the content is passed through SecretRedactor before it is cut, with a margin so a secret straddling the cut is still matched whole. Verified with the published AOT binary against a stub server: the request carries key [REDACTED].

HookBudget holds back a 1.5 s reserve, so a 2 s ceiling left nothing after startup and the
exclusion probe; the ceiling now matches the entry's 3 s timeout in hooks.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit b00baf8 into main Oct 5, 2026
8 checks passed
@alexeyzimarev
alexeyzimarev deleted the alexey/plan-read-nudge branch October 5, 2026 11:38
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.

Nudge Claude to declare a plan document when it reads one

1 participant