Skip to content

Add Cursor editor and CLI capture with native session recovery - #1640

Open
philcunliffe wants to merge 5 commits into
masterfrom
codex/cursor-native-capture
Open

Add Cursor editor and CLI capture with native session recovery#1640
philcunliffe wants to merge 5 commits into
masterfrom
codex/cursor-native-capture

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Cursor's native hooks omit intermediate assistant replies and reduce some tool results to summaries, even for a 32-byte file. This adds a bundled Cursor adapter that recovers conversation messages and richer tool results from the editor and CLI's saved session databases.

Native message identities are authoritative. Hooks trigger bounded recovery and retain the additional beforeReadFile observations. Replaying live or historical records converges without duplicate conversation rows, and native results correctly preserve CLI Shell rejections that hooks reported as ordinary completions.

Changes

  • Add Cursor to onboarding detection, selection, attach and disk-driven detach, without requiring a provider gateway.
  • Add read-only, WAL-aware native recovery, manual hyp backfill cursor, and scheduled recovery using the existing backfill controls.
  • Apply folder ignore, local-only export restrictions and in-process session ignore; retain unknown tool outcomes instead of inventing success.
  • Avoid inherited Claude hook misattribution and fix Cursor CLI's JSONC parser incompatibility with URL arguments in generated hook commands.
  • Include bounded fixtures, controlled research evidence, implementation evidence, and Draft LLP 0399.

Validation

  • npm test: 6,570 passed, 3 skipped.
  • npm run typecheck and npm run smoke -- cursor_capture: passed. The smoke checks actual storage/query behavior and capture, recovery and privacy telemetry.
  • Fresh CLI acceptance: 8 callbacks produced 1 user message, all 3 assistant segments, 5 tools and 1 file observation. Replay before and after source restart added zero rows.
  • Operator-confirmed editor batch: all 13 tools and 3 assistant segments recovered, including dynamic-tool calls. The complete editor session yielded 42 unique parts; replay added zero rows.
  • Diff hygiene and cross-branch LLP number checks passed. The draft was renumbered to 0399 after fetching current branches.

Limits and release acceptance

The adapter reads a private, version-specific Cursor format. Normalized token accounting, complete subagent graphs and revision history remain unsupported. Live editor hook delivery, interactive CLI completion, additional interruption/resume/regeneration scenarios, and installed-client acceptance remain documented release checks in docs/ACCEPTANCE.md. Editor evidence above verifies saved-session recovery, not live hook delivery.

CPU and memory

No runtime dependencies added. Queues, record sizes, graph reads and fingerprint caches are bounded; unchanged roots skip graph decoding and storage dedupe. The CPU/memory pass found no unbounded new queue or lifetime content retention. Synchronous large-graph parsing and shared waiting-spool scans remain latency concerns for the release acceptance measurements.

@philcunliffe philcunliffe added neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) labels Sep 10, 2026
philcunliffe and others added 2 commits September 11, 2026 00:09
…N, list the new gate

runClaudeSessionContextHook put the CURSOR_VERSION skip above its --help
branch, so `hyp claude-hook session-context --help` printed nothing in a
Cursor environment. LLP 0399#coexistence says help and other commands
retain normal dispatch, and bin/hypaware.js's own skip deliberately spares
--help; classify_hook.js already orders it that way. Move the guard below
the help branch and cover it in the inherited-hooks test.

AGENTS.md gains cursor_editor_cli_capture in the written acceptance
procedure list and cursor_capture in the release smoke battery, the way
launchd_supervisor_env and the GitHub flows were listed when they landed.

docs/ACCEPTANCE.md step 8 gains the direction its fixture cannot settle:
the skip keys on CURSOR_VERSION alone, so a Cursor shell that exports it
would silently disable Claude Code's own session-context and spool-sweep
lane. Only a real client can say whether Cursor exports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t check

listener.js computed a `lifecycle` flag after the guard above it had
already returned for every hook that is not beforeReadFile, so the flag
was always false, its counter increment was dead, and the `lifecycle_only`
skip reason it advertised could never be produced. Collapse to the one
reachable outcome.

docs/ACCEPTANCE.md step 2 now names every human prompt in the comparison
against the real client, and says a missing one points at the typed
user-message suppression field. That field decides whether a prompt row is
written at all, and no fixture sets it, so only a real session can check it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 — PR #1640, head 0c53b2b4

Verdict: approve with fixes applied. A well-built and unusually well-evidenced adapter. 16 findings across two independent passes: 3 fixed on the branch, 10 deferred with evidence, 3 rejected as disproven, 0 blockers. New head after this round: 9bbee912.

Two passes ran over this head: a manual line-by-line read of the whole diff, and the code-review skill at high effort. Both are folded into one numbered list below; where they agreed I say so, and I verified every finding from the skill against the tree myself rather than relaying it.

What was checked

Full diff origin/master...0c53b2b4 (51 files, +6158/-8). Read in full: native.js, listener.js, recovery.js, attach.js, projector.js, hook.mjs, index.js, config.js, the five new test files, cursor_capture.js, cursor_native_fixture.js, LLP 0399, and the four shared-file edits (bin/hypaware.js, client_detach_disk.js, client_settings_path.js, message_projector.js). Ran npm test, npm run typecheck, npm run smoke -- cursor_capture, node scripts/llp-numbers.js check, npm pack --dry-run, and a clean origin/master baseline to separate pre-existing failures from new ones.


Fixed

1. runClaudeSessionContextHook suppressed --help under CURSOR_VERSION — medium — fix (landed)

hypaware-core/plugins-workspace/claude/src/hook_command.js:62 placed the env skip above the --help/-h branch, so hyp claude-hook session-context --help printed nothing whenever CURSOR_VERSION was set. Both passes found this independently.

It contradicts three things inside the PR:

  • LLP 0399#coexistence: "help and other commands retain normal dispatch."
  • bin/hypaware.js:25 deliberately carves --help/-h out of its own skip — which only means anything if the handler behind it answers.
  • claude/src/classify_hook.js:58 already orders the identical guard after its help branch.

Moved the guard below the help branch and extended the @ref gloss to say why the order is load-bearing. Added a regression assertion to the existing inherited-hooks test.

Verified in the committed tree, not inferred from a green suite:

$ git show HEAD:hypaware-core/plugins-workspace/claude/src/hook_command.js | sed -n '61,72p'
export async function runClaudeSessionContextHook(argv, ctx, deps = {}) {
  if (argv.includes('--help') || argv.includes('-h')) {
    ctx.stdout.write('usage: hyp claude-hook session-context --state-file <absolute-path>\n')
    return 0
  }

  // @ref LLP 0399#coexistence: inherited hooks are not Claude activity and
  // must not write Claude context or run its body-spool maintenance. Below the
  // help branch, ...
  if (ctx.env.CURSOR_VERSION) return 0

And the new assertion genuinely guards it — reverting only hook_command.js to its pre-fix blob and rerunning:

not ok 3 - inherited Claude hooks neither record context, sweep spool, nor inject classification
# pass 4 / # fail 1

fix restored: # pass 5 / # fail 0.

2. Unreachable lifecycle_only branch in the hook receiver — low — fix (landed)

hypaware-core/plugins-workspace/cursor/src/listener.js:235 computed const lifecycle = ['sessionStart', 'sessionEnd', 'stop', 'subagentStart', 'subagentStop'].includes(hook), but line 228 already returned for every hook that is not beforeReadFile. lifecycle was therefore always false, counts.lifecycle_events++ at 236 was dead, and the lifecycle_only skip reason could never be emitted — a status reason advertised to operators that no request can produce.

Collapsed to the only reachable outcome, with a comment naming why. Verified:

$ git show HEAD:hypaware-core/plugins-workspace/cursor/src/listener.js | sed -n '233,240p'
          // Every lifecycle hook already returned above, so the only way past
          // the projector here is a beforeReadFile whose own fields are unusable.
          const projection = projectCursorHook(raw)
          if (!projection) {
            counts.incomplete++
            skip('incomplete_hook')
            return
          }

node --test test/plugins/cursor-listener.test.js → 11 pass / 0 fail; npm run smoke -- cursor_capture → ok.

3. New acceptance procedure and new smoke were not listed in AGENTS.md — low — fix (landed)

docs/ACCEPTANCE.md gained cursor_editor_cli_capture and hypaware-core/smoke/flows/cursor_capture.js was added, but AGENTS.md (which CLAUDE.md symlinks to) listed neither in its "Written acceptance procedures" block nor in the release smoke battery. Precedent is consistent that both get listed when a procedure lands: bca3e97a (launchd_supervisor_env), aea83613 (GitHub capture flows), d42c7946 (github_since_inclusivity).

Verified: AGENTS.md:190 now carries the procedure entry, AGENTS.md:318 carries hyp dev smoke cursor_capture next to opencode_capture.


Deferred

4. CURSOR_VERSION is an env-only discriminator with an unestablished blast radius — medium — defer + acceptance clause landed

bin/hypaware.js:21-26 and hook_command.js key the inherited-hook skip on process.env.CURSOR_VERSION alone. The payload checks (typeof event.cursor_version === 'string', hook_command.js:119, classify_hook.js:59) are precise; the env check is not. Nothing in this PR, the research corpus, or docs/cursor-capture-evidence.md establishes whether Cursor exports CURSOR_VERSION into the shells it launches. If it does, attached Claude Code run from a Cursor terminal silently loses both its session-context lane and its body-spool sweep — a silent degradation of a shipped adapter, in the one direction the new fixture cannot test.

Not repaired in code: the precise alternative would be a different env key, and guessing one is exactly what the repo's "do not invent" rule forbids. Not a blocker: LLP 0399 is Draft, the whole adapter is gated behind a release acceptance procedure, and message capture itself is untouched.

Landed instead: docs/ACCEPTANCE.md step 8 now carries the reverse-direction check — run attached Claude Code from Cursor's integrated terminal and from a plain terminal, print the hook process environment, confirm both lanes still run, and record whether Cursor exports the variable. That is the only thing that can settle it, and it belongs in the procedure this PR authored.

5. A beforeReadFile write queues behind a recovery pass and outlives the hook's socket timeout — medium — defer

listener.js:241 chains the hook write on the same serial promise the recovery pass holds at listener.js:52. A recovery pass can hold that chain through up to 16 sessions of synchronous SQLite + protobuf decoding (up to 32 MiB per graph), while hook.mjs:32 sets req.setTimeout(800, () => req.destroy()). So a beforeReadFile callback landing during a pass is held past 800 ms, destroyed, retried (correctly deduped by delivery_id), timed out again, and then prints HypAware Cursor capture unavailable; this callback was not confirmed. into Cursor's stderr — even though the row does land.

Real, and the highest-value follow-up here. Deferred because the outcome is a misleading message plus stderr noise, not data loss (storage dedupe makes the eventual write correct), and because decoupling the two chains, or retuning the hook timeout, is a concurrency design change rather than a review repair — I cannot measure real-world pass latency from here. docs/ACCEPTANCE.md step 9 already asks for hook-latency and queue-saturation measurement, which is where this should be settled with numbers.

6. One unknown field on one tool step discards the entire conversation — medium — defer

native.js:358: if (variants.length !== 1) throw new CursorReadError('native_tool_shape') aborts readCursorSession for the whole session. A single extra field number on a single tool step — any field outside 54/57/59/60 added by a Cursor update — loses every turn of that conversation: live recovery retries 3 times (listener.js:96, item.attempt < 2) then drops it, and the sweep fingerprint never advances, so it re-fails on every sweep. The research probe took the tolerant approach instead (next((n for n in [1,4,5,8] if n in tool), None), cli-store-probe.py).

Deferred, not dismissed. It is a deliberate fail-closed choice consistent with LLP 0399's refusal posture, and it is observable rather than silent (cursor.recovery.incomplete with error_kind: native_tool_shape, plus a yielded backfill event). Softening it — degrading that one step to the unknown outcome the code already models at native.js:364-366 rather than aborting the read — changes what a Draft LLP decided, which is the author's call, not a reviewer's repair.

Worth flagging for whoever picks this up: docs/ACCEPTANCE.md step 7 covers "leaves other valid sessions recoverable" — other sessions, not other turns in the same session. The blast radius this finding describes is inside one session, so step 7 as written would pass while the defect stands.

7. The typed user-message suppression field is undocumented and unexercised — medium — defer + acceptance clause landed

native.js:331: if (user.get(5)?.[0] !== 1) messages.push({ role: 'user', ... }) suppresses the human prompt whenever typed user field 5 is the varint 1. Field 5's meaning appears in neither research probe (editor-storage-probe.py reads fields 1, 2, 25; cli-store-probe.py reads 1, 2) nor in LLP 0399, which documents only the 18/19 external-text refusal at native.js:330.

I confirmed independently that no test reaches it: cursor_native_fixture.js:61 writes the user blob as wire([1, prompt], [2, 'native-user'], [25, ts]) — field 5 is never set, in any fixture or test, so the suppression branch is never executed. If that field carries an unrelated flag that is commonly 1, every human prompt vanishes while assistant and tool rows still land: a silent half-capture, and the worst failure shape in this diff.

Landed instead of a code change (I cannot determine the field's true semantics without a real Cursor store): docs/ACCEPTANCE.md step 2 now names every human prompt explicitly in the comparison against the real UI, and says that a missing prompt is the signal to check the typed user-message suppression field no fixture exercises.

8. The recovered-file scope guard keys on one literal tool name — medium — defer

recovery.js:28: if (block.name !== 'Read') continue. The realpath / workspace-containment / CLASS_RANK check that LLP 0399#file-content rests on applies only to tool results whose name is exactly Read. Any other file-reading tool name, or an upstream rename in a private version-specific format, silently bypasses it.

Deferred on a specific piece of counter-evidence: this guard is additive over the repo baseline, not a regression. I grepped the tree — no other adapter applies any per-tool file-scope filtering at all (the only other 'Read' reference is ai-gateway-graph/src/graph_contract.js:29's FILE_TOOLS set, which is a graph contract, not a policy gate). Exchange-level cwd policy and local-only withholding still apply to the whole row regardless. So Cursor ships strictly more protection than anything already merged; widening the guard (e.g. reusing that FILE_TOOLS set) is a good follow-up, not a reason to hold this PR.

9. A session whose native Read result has no recoverable args never converges — low — defer

recovery.js:30-31: a Read block whose input is absent (no matching assistant tool-call among the recovered refs) yields file === undefined and is dropped, which sets complete = false (recovery.js:108, listener.js:78) and by design refuses to advance the root fingerprint. A session permanently in that state is re-read and re-projected on every sweep (default */5 * * * *) and on every live callback pass, forever. Same shape as finding 6, different trigger.

Bounded per pass (4096 blob reads, 1 MiB/record, 32 MiB cumulative), and the alternative — advancing the fingerprint over a partial read — would silently lose data on retry. docs/ACCEPTANCE.md step 5 already requires "a second unchanged sweep must report unchanged roots with zero graph/materialization work", which is precisely the assertion that catches a session stuck this way.

10. Picker detection probes a file Cursor does not create — low — defer

hypaware.plugin.json picker detect.settings_file is .cursor/hooks.json — opt-in user config, not something a Cursor install writes. Most real Cursor users will not be auto-detected.

Low impact: pick.js:141 (@ref LLP 0011#autodetect-vs-default) makes detection seed a checkbox only, so an undetected row is still listed and still selectable, and INIT_SOURCE_CHOICES carries cursor so --source cursor works regardless. The three supported probe variants (settings_file, app_bundle, path, manifest.js:233) offer no directory probe, and choosing a better file needs real-client evidence this PR does not have. Revisit when the acceptance run produces a real install to look at.

11. Four of five new fixture files are referenced by nothing — low — defer

test/fixtures/cursor/ adds 1435 lines; only file-content-2026-09-08.json is read (test/plugins/cursor-projector.test.js:16). editor-3-19-19.json, editor-tools-3-19-19.json, headless-2026-09-08.json and tool-matrix-2026-09-08.json (682 lines) are unexercised.

Not accidental: docs/cursor-capture-evidence.md:20 deliberately retains them, and they are sanitized (/probe/workspace, probe-headless-session, thought events omitted). Arguably mis-homed — research/ is this repo's place for evidence — but zero runtime cost and not worth a churn commit.

12. unknown_entrypoints always equals callbacks — low — defer

listener.js:162 increments it unconditionally on every callback, so the hyp status --json field can never distinguish anything. It is accurate (hooks genuinely never carry an entrypoint; only native recovery resolves one), so it is redundant rather than wrong, and removing or renaming a status field is a design call.

13. Daemon-down produces per-callback stderr noise in the editor — low — defer

hook.mjs:37 writes HypAware Cursor capture unavailable after two fast ECONNREFUSED attempts. With all ten attached events firing, ordinary states (daemon stopped, restarting, mid-upgrade) produce a line per callback inside the user's editor and CLI rather than one notice. A silent hook would be worse, so this is a real trade-off rather than a clear defect; worth a rate-limit if the acceptance run finds it intrusive.


Rejected

14. previous_message_id: [] on every native row — reject

native.js:375 sets an empty parent list on all recovered rows, which message_projector.js:1038 honours verbatim, so a consumer walking previous_message_id sees N disconnected roots even though the native turn order (user → step ordinal → tool) is fully known from the store.

Disproven as a defect: LLP 0399#identity settles it in terms that name this exact case — "No parent chain is invented from callback arrival or typed completion order." The typed step ordinal is precisely "typed completion order". The consequence is real and worth knowing, but it is a decided design position in an in-tree LLP, not something this review can call a bug.

15. message_projector.js change alters shared behavior for every provider — reject

hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js:1418 now gates on b.is_error !== null, so an explicit null yields no tool_status where it previously yielded 'success'.

Disproven as a risk: the change is deliberate and documented in place ("Explicit null means the producer cannot establish the outcome"), it is required by native.js:364-366 where an unverified tool variant yields is_error: null rather than inventing success, and the matrix test added at test/plugins/ai-gateway-message-projector.test.js:681 pins all four cases (undefined → success, false → success, true → error, null → undefined), so the legacy missing-field convention is provably intact. I grepped the tree: no existing projector emits is_error: null, so no shipped provider's rows change.

16. client_settings_path.js special-case bypasses the <CLIENT>_HOME override — reject

src/core/daemon/client_settings_path.js:114-118 short-circuits the generic CURSOR_HOME override.

Disproven as an inconsistency: LLP 0399#attachment states the fact directly — "Neither hook loader uses CURSOR_HOME. Core path resolution follows the actual hook loader for attach, probe, and disk detach." The @ref is honest and tells you something the code does not. It mirrors the existing opencode special case immediately above it, and test/plugins/cursor-attach.test.js:20-23 pins it with CURSOR_HOME: '/wrong', CURSOR_CONFIG_DIR: '/also-wrong' and XDG_CONFIG_HOME: '/wrong-too' all set at once.


Explicit CPU and memory pass

Required by CLAUDE.md. I walked every new hot path: the hook receiver, the live recovery queue, the native graph reader, the scheduled sweep, and the shared message_projector edit.

No unbounded growth found. Every retained structure is capped, and every cap is enforced in code rather than only documented:

Structure Cap Enforced at
In-flight request bodies 4 concurrent, 1 MiB each listener.js:127, :139
Pending recovery queue 64 entries, 16 per pass listener.js:108, :53
Live root fingerprints 64, FIFO eviction listener.js:86
Sweep root fingerprints 1000, FIFO eviction, pruned against present sessions recovery.js:112, :76
Blob reads per graph 4096 native.js:249
Bytes per graph 32 MiB cumulative, 1 MiB per record native.js:254, :185
Wire fields per record 16384 native.js:203
Session discovery 1000 editor headers, 1000 CLI dirs; reaching the cap is reported, not silently truncated native.js:82, :97
Identity strings 256 chars native.js:323, :354, projector.js:40
Hook subprocess 1 MiB stdin, 2 attempts, 2.5 s deadline hook.mjs:5, :6, :24
Writer identity maps rotated every 1024 callbacks; a fresh per-pass writer for recovery listener.js:250, :73
Usage-policy memo rotated every 1024 callbacks listener.js:160

Repeated work, called out as the rule requires:

  • listener.js:63 constructs cursorAdmission (and a fresh createUsagePolicyResolver) inside the per-item loop, discarding the resolver's TTL memo between items. Construction is allocation-only (matcher.js:122-131, no I/O), so the real cost is re-walking ancestors with existsSync per item. Bounded at 16 items per ~1 s pass, and the freshness is privacy-favorable — a .hypignore written mid-batch is honored. Accepted as deliberate.
  • listener.js:242 re-runs the full beforeReadFile admission (2 realpath calls) inside the serialized write after already running it at :220 — 4 realpath per file observation. Deliberate and correct; the comment at :182 says so and cursor-listener.test.js:153 proves a session ignored while queued cannot slip through. Accepted.
  • Per completed turn the live lane re-reads and re-projects the whole conversation graph, so cost over a session is roughly quadratic in turn count. Mitigated three ways, each verified: an in-progress graph fails fast after reading only the root (native.js:264, :278); the root fingerprint short-circuits before any graph walk (native.js:275-276); the live pass is debounced at 1 s and rearms only after the previous pass settles (listener.js:48, :101). Consistent with how the Claude transcript sweep already behaves. Findings 6 and 9 are the residual cases where the fingerprint can never advance.

Latency, not leakage: readCursorSession is fully synchronous (node:sqlite DatabaseSync) and can decode up to 32 MiB plus per-blob JSON.parse and sha256 on the daemon's event loop. That is the mechanism behind finding 5, and it is the one number the acceptance run most needs. node:sqlite is not a new dependency or a new engine constraint — hermes already uses it under the same node_engine: ">=22.12" (hermes/src/state_db.js:110).

Shutdown is clean: stop() clears the timer, drains pending, closes all connections and awaits the serial chain (listener.js:293-302); activeRecoveries entries are released in a finally on both lanes (listener.js:97, recovery.js:119); SQLite handles close in a finally (native.js:380). The bounded-queue test asserts pending_recovery returns to 0 after cleanup (cursor-listener.test.js:329).

Verdict of the pass: no CPU or memory concern that should block this PR. The one item worth a number before release is finding 5's head-of-line blocking.

Convention and privacy compliance

  • No new runtime dependencies. npm pack --dry-run confirms all ten plugin files ship, hook.mjs included; it imports only node:crypto and node:http.
  • Style: no semicolons, no em dashes (U+2014), no NUL bytes anywhere in the added lines — grepped the whole diff.
  • Types: JSDoc only, no @typedef, no inline import('...'); types.d.ts interfaces imported via @import with repo-root-anchored .js specifiers (listener.js:19, native.js:10, recovery.js:10).
  • LLP: every @ref resolves. LLP 0399's anchors #capture #identity #native-format #recovery #file-content #attachment #coexistence #resources all exist; the one cross-doc ref, LLP 0306#endpoint-free-clients, resolves to llp/0306-*.md:63. node scripts/llp-numbers.js check"1 LLP number minted against refs/remotes/origin/master, no collision".
  • Capture conventions: two lanes present (live hook receiver + bounded recovery, plus manual hyp backfill cursor); entrypoint distinguishes editor from cli off the native store, asserted at cursor_capture.js:132; backfill.window_days bounding is inherited correctly from the kernel (backfill_sweep.js:460, @ref LLP 0359#sweep-context) rather than reimplemented, and sweep registration follows the claude adapter's onJoin !== false pattern exactly.
  • Privacy: no credentials, prompts or reasoning in telemetry — CursorReadError carries fixed codes only (native.js:20-24), storage errors are never logged (listener.js:270), and both are pinned by tests asserting 'PRIVATE' never appears in status or errors (cursor-listener.test.js:62, cursor-native.test.js:106). Local-only withholding is proven against the real export seam, not a stub (cursor-listener.test.js:227-260). Detach removes only marker-owned entries and preserves a user-edited command verbatim (cursor-attach.test.js:41-45).
  • native.js:330's native_external_user_text refusal — declining rather than substituting empty text for a real prompt — is the right instinct, and the direct counterpart to finding 7, where the same care is missing.

Check results

Check Head 0c53b2b4 After fixes origin/master baseline
npm test 6569 pass / 3 fail / 1 skip 6569 pass / 3 fail / 1 skip 6537 pass / 3 fail / 1 skip
npm run typecheck exit 0 exit 0
npm run smoke -- cursor_capture ok ok n/a
node scripts/llp-numbers.js check no collision no collision
npm pack --dry-run all 10 plugin files present unchanged

The 3 failures are identical in all three columns — the known environmental test/core/hyparquet-floor-pin.test.js cases (installed node_modules behind package.json in this container):

not ok - icebird uses the root parquet pins directly or through overrides
not ok - every read-path dependency that carries hyparquet is held at the floor
not ok - the read path resolves the one root hyparquet, not a nested copy

Confirmed pre-existing by actually running a clean origin/master worktree, not assumed from the label. No other failure, and none introduced by this PR or by my fixes. The branch adds 32 passing tests over master; this round added an assertion inside an existing test rather than a new one, so the count is unchanged.

Disposition summary

# Finding Severity Disposition
1 --help suppressed under CURSOR_VERSION medium fix (landed, verified)
2 Unreachable lifecycle_only branch low fix (landed, verified)
3 New gate/smoke missing from AGENTS.md low fix (landed, verified)
4 CURSOR_VERSION blast radius unestablished medium defer + acceptance clause landed
5 Hook write blocks behind recovery, trips the 800 ms timeout medium defer (noise, not loss; acceptance step 9)
6 Unknown tool field discards the whole conversation medium defer (fail-closed by design, observable)
7 Undocumented, untested user-prompt suppression field medium defer + acceptance clause landed
8 File-scope guard keys on the literal name Read medium defer (additive over repo baseline)
9 Non-converging session re-reads every sweep low defer (bounded; acceptance step 5)
10 Picker probes a file Cursor does not create low defer (detection only seeds a checkbox)
11 Four unexercised fixture files low defer (retained evidence, documented)
12 unknown_entrypoints always equals callbacks low defer (accurate but redundant)
13 Per-callback stderr noise when the daemon is down low defer (real trade-off)
14 previous_message_id: [] on native rows reject (LLP 0399#identity settles it)
15 is_error !== null changes the shared projector reject (deliberate; pinned by a matrix test)
16 CURSOR_HOME bypass reject (LLP 0399#attachment states it; test pins it)

No blockers. The two things most worth a real-client number before this ships are finding 7 (does any human prompt go missing?) and finding 5 (how long does a recovery pass actually hold the chain?). Both now have a written acceptance step pointing at them.

philcunliffe and others added 2 commits September 11, 2026 00:56
…in place

`native.js` suppressed a recovered human prompt whenever typed user field 5
held the varint 1, with no fixture reaching the branch (the native fixture
writes fields 1/2/25 only) and no note anywhere saying what the field is.
Its failure shape is the worst in the adapter: every human prompt vanishes
while assistant and tool rows still land, silently.

The field's real meaning is only settleable against a real Cursor store, and
`docs/ACCEPTANCE.md` step 2 already asks for that. What a fixture can settle
is the predicate itself, so pin both directions: field 5 = 1 drops the prompt
and nothing else, and any other value keeps it. Both assertions fail if the
suppression is dropped or widened to any present field 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or in --client

`cursorAdmission().filter` assigned `allowed` per block with no break, so a
second file block could reinstate a message the first one refused: a Read
resolving outside the workspace sets `allowed = false`, and a following
missing-file Read inside it assigns `allowed = true` again, persisting the
first block's out-of-workspace contents. Unreachable today because the native
reader emits one content block per tool message, but this is the function
standing between a foreign directory and persisted file text, so it should
fail closed structurally rather than by the producer's current shape.

Every rejection now breaks, and a test pins it with two blocks that are each
admissible or refusable on their own. It fails if the breaks are removed.

`hyp setup --help` also still enumerated the four pre-Cursor `--client`
choices while `INIT_CLIENT_CHOICES` carries five, so `--client cursor` worked
but was undiscoverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 (final) - PR #1640, head 9bbee912

Verdict: approve with fixes applied. Round 1's three fixes all hold up under independent re-verification. Ten findings this round: 3 fixed on the branch, 5 deferred with evidence, 2 rejected as disproven, 0 blockers. Round 1's thirteen still-open findings are re-checked against this head and all still hold.

New head after this round: c8011eee.

Two passes again: a manual line-by-line read of the whole diff, and the code-review skill at high effort (codex is not installed here, so no dual-review). The skill found three things my pass did not, and my pass found three the skill did not; I verified every skill finding against the tree myself rather than relaying it, and two of its seven are sharpened or partly disproven below.

What was checked

Full diff origin/master...9bbee912 (51 files, +6158/-8) and, separately, round 1's fix diff 0c53b2b4..9bbee912. Read in full: native.js, listener.js, recovery.js, attach.js, projector.js, hook.mjs, index.js, config.js, hypaware.plugin.json, all five new test files, cursor_capture.js, cursor_native_fixture.js, LLP 0399 end to end, the new docs/ACCEPTANCE.md procedure, docs/cursor-capture-evidence.md, the four research probe scripts, and every shared-file edit (bin/hypaware.js, client_detach_disk.js, client_settings_path.js, message_projector.js, classify_hook.js, hook_command.js, init.js, bundled.js, core_commands.js). Ran npm test, npm run typecheck, npm run smoke -- cursor_capture, node scripts/llp-numbers.js check, npm pack --dry-run, and a separate clean origin/master worktree to separate pre-existing failures from new ones.


Round 1's fixes: do they hold?

Yes, all three, re-verified in the committed tree rather than inferred from a green suite.

Fix 1 (--help suppressed under CURSOR_VERSION). hook_command.js:62-71 now puts the help branch first and the env skip second, matching the order classify_hook.js:39-59 already used. The regression assertion is non-vacuous: I restored only hook_command.js to its 0c53b2b4 blob and reran the file.

not ok 3 - inherited Claude hooks neither record context, sweep spool, nor inject classification
# pass 4 / # fail 1        (restored: # pass 5 / # fail 0)

Fix 2 (unreachable lifecycle_only). listener.js:233-239 is collapsed correctly, and the collapse did not silently orphan the counter the dead branch shared: counts.lifecycle_events++ still runs at listener.js:229 for every non-beforeReadFile callback, and a tree-wide grep finds no other reader of lifecycle_only or lifecycle_events, so no status consumer or test lost a value. node --test test/plugins/cursor-listener.test.js -> 11 pass / 0 fail.

Fix 3 (AGENTS.md). AGENTS.md:190 carries cursor_editor_cli_capture in the written-procedures block; AGENTS.md:318 carries hyp dev smoke cursor_capture in the release battery.

Both docs/ACCEPTANCE.md clauses round 1 added (step 2's prompt check, step 8's reverse-direction CURSOR_VERSION check) are present and aimed at the right risk.


Fixed

1. The typed prompt-suppression field was unexercised, and that was fixable - medium - fix (landed)

native.js:331:

if (user.get(5)?.[0] !== 1) messages.push({ role: 'user', message_id: id('user'), ... })

Round 1 confirmed the coverage gap and judged it unfixturable, landing only an acceptance clause. I confirmed the gap again from scratch: cursor_native_fixture.js:61 writes the typed user blob as wire([1, prompt], [2, 'native-user'], [25, ts]), and grepping research/, llp/0399, docs/cursor-capture-evidence.md and the tests for the field returns exactly one hit, the line itself. Neither probe decodes it (editor-storage-probe.py:161 reads fields 1 and 2, cli-store-probe.py the same). The branch was dead in every test, and its failure shape is the worst in the diff: every human prompt vanishes while assistant and tool rows still land.

"Unfixturable" conflates two different things:

  • The field's meaning is genuinely acceptance-only. Nothing short of a real Cursor store can say what field 5 is, and round 1's step-2 clause is the right home for that.
  • The predicate is ordinary deterministic logic, which CLAUDE.md asks for traditional tests over. A fixture can pin it without pretending to settle the semantics.

Landed the cheap half, in the same commit as the doc correction it makes necessary:

  • test/plugins/cursor-native.test.js gains "only the exact suppression value drops a typed prompt, and it drops nothing else", built the way the existing variant tests are built (f.put(wire(...)) + f.writeRoot(...), no fixture-API change): field 5 = varint 1 drops the prompt and only the prompt (7 messages, no role: 'user'), and field 5 = 0 or 2 keeps it (8 messages, prompt text intact).
  • native.js:330-333 gains a comment saying the meaning is inferred rather than documented, that only the exact varint 1 is read that way, and that a prompt missing in a real client is the signal to recheck it.
  • docs/ACCEPTANCE.md step 2 is corrected from "which no fixture exercises" to "a fixture pins the predicate, but only a real store can say what the field means", so the procedure stays true after the test landed.

Verified in the committed tree:

$ git show HEAD:hypaware-core/plugins-workspace/cursor/src/native.js | sed -n '330,334p'
      // Typed field 5 suppresses the prompt. Its meaning is inferred from the
      // inspected stores, not documented, so only the exact varint 1 is read
      // that way: any other value keeps the prompt. A prompt missing from a
      // real client is the signal to recheck this (docs/ACCEPTANCE.md step 2).
      if (user.get(5)?.[0] !== 1) messages.push({ role: 'user', message_id: id('user'), provider_uuid: userId,

And the test is non-vacuous in both directions - I mutated the predicate in the source and reran:

Mutation Result
if (true) messages.push (suppression dropped) not ok 5 - 10 pass / 1 fail
if (!user.has(5)) messages.push (suppression widened to any field 5) not ok 5 - 10 pass / 1 fail
unmutated 11 pass / 0 fail

One piece of reassurance for whoever runs the gate: docs/cursor-capture-evidence.md:35-37 records that the candidate reader recovered 5 of 5 editor user turns and 1 of 1 in each CLI session, so in all three inspected stores the predicate never fired. That lowers the odds that field 5 is a commonly-set flag; it does not settle it, which is why step 2 stays.

2. One refused file block could be reinstated by the block after it - medium - fix (landed)

Found by the code-review pass; I verified it and it is real. recovery.js:24-44, the function standing between a foreign or ignored directory and persisted file text, assigned allowed per block with no break:

if (!isEqualOrDescendant(target, root) || [...].some(...)) allowed = false   // no break
} catch {
  allowed = block.is_error === true && isEqualOrDescendant(full, cwd) && ...  // plain assignment
}

So a tool message whose first Read resolves outside the workspace (allowed = false) and whose second Read is a missing-file error inside it (allowed = true) is written whole, including the first block's out-of-workspace contents.

Unreachable today, and I checked why rather than assuming: native.js:368-372 emits exactly one content block per tool message, so no current producer can build the two-block shape. That is the producer's shape protecting the filter, not the filter protecting anything. A privacy gate should fail closed structurally, so every rejection now breaks, and a test pins it with two blocks that are each individually admissible or refusable:

$ git show HEAD:hypaware-core/plugins-workspace/cursor/src/recovery.js | sed -n '32,42p'
            const full = path.resolve(cwd, file)
            try {
              const [root, target] = await Promise.all([realpath(cwd), realpath(full)])
              // Every rejection breaks: a later block must never be able to
              // reinstate a message an earlier one refused.
              if (!isEqualOrDescendant(target, root) || [...].some(...)) { allowed = false; break }
            } catch {
              ...
              allowed = block.is_error === true && ...
              if (!allowed) break
            }

Non-vacuous: restoring only recovery.js to its 9bbee912 blob and rerunning gives not ok 9 - one refused file block refuses the whole message, whatever block follows it (11 pass / 1 fail); with the fix, 12 pass / 0 fail.

3. hyp setup --help omits cursor from --client - low - fix (landed)

Also from the skill pass, also verified. init.js:190 added cursor to INIT_CLIENT_CHOICES, but core_commands.js:358-359 still enumerated the four pre-Cursor choices, and that list is meant to be exhaustive, so --client cursor worked but was undiscoverable. One-line correction; no test pins the string (grepped).


Deferred

4. A hook write queues behind a synchronous graph decode - medium - defer (re-examined, still deferrable)

Both passes raised this; it is round 1's finding 5. listener.js:241 chains the beforeReadFile write on the same serial promise the recovery pass takes at listener.js:52, a pass runs up to 16 sessions (listener.js:53) of fully synchronous readCursorSession, and hook.mjs:32 destroys the request at 800 ms, so the user gets HypAware Cursor capture unavailable; this callback was not confirmed. on stderr for a callback whose row later lands.

I re-examined whether it is safe to leave, and it is, on four grounds that are firmer than round 1 stated:

  1. Decoupling the chains would not fix it. readCursorSession is synchronous end to end (node:sqlite DatabaseSync, plus per-blob sha256 and JSON.parse, up to 32 MiB and 4,096 blobs per graph, native.js:249-255). While it runs, the daemon's event loop is blocked outright: the hook request is not merely queued behind serial, it is not read at all. A separate write chain would leave the stall in place while looking like a repair. The real fix moves the decode off the loop (worker thread, or chunking) and teaches stop() to drain both chains: a concurrency redesign, not a review repair, and nothing here can size it.
  2. It is a decided, documented position. LLP 0399#resources says it outright: "Native parsing is synchronous and may pause the daemon within the per-graph limits ... Process startup, large-session latency, catalog-cap coverage and sustained heap behavior remain acceptance measurements." docs/ACCEPTANCE.md step 9 asks for hook latency and queue saturation with numbers.
  3. No data is lost, and that is pinned by a test rather than by argument. hook.mjs:21 allocates delivery_id once before the retry loop, so the post-timeout retry is the same delivery, and cursor-listener.test.js:57-75 proves concurrent redelivery plus a source restart still yields exactly one row.
  4. It is bounded, at 16 x 32 MiB per pass, and the live pass is debounced 1 s and rearms only after the previous one settles (listener.js:48, :101).

One correction to the skill's version of this, which claimed a full re-parse "roughly once per second for the whole duration" of a live session: the pending map is keyed by [session, cwd] (listener.js:107), so a burst of callbacks in one turn coalesces into one entry, and a mid-turn read aborts after reading only the root (native.js:264 for the editor, :278 for the CLI) without touching the graph. The accurate cost is one full re-read per completed turn, so roughly quadratic in turn count over a session, not per callback.

Residual risk, stated plainly because it is what I would want measured first: the stall is the whole daemon's event loop, not just this plugin's, so a pathological pass also delays the OTEL listener and the gateway. That is exactly what step 9 is for, and it is the top follow-up on this branch.

5. The CURSOR_VERSION skip also disables Claude's body-spool sweep - medium - defer (round 1 finding 4, sharpened)

The skill raised this twice (as hook_command.js:71 and as bin/hypaware.js:23); both are one finding, and it is round 1's finding 4 with a consequence round 1 named but did not weigh: runClaudeSessionContextHook's own contract says the sweep runs "LAST, and on EVERY invocation ... which is the half of LLP 0253's bound the daemon cannot deliver on its own" (hook_command.js:49-51, :83-87). The skip returns before it.

Two corrections, both load-bearing:

  • The skill's proposed repair does not work. Moving the handler guard below the sweep changes nothing in production, because bin/hypaware.js:23-27 exits 0 for claude-hook session-context before dispatch ever reaches the handler. Any real repair has to change the binary guard, which is precisely the part that cannot be narrowed without evidence.
  • "The spool grows without bound, silently" is overstated. LLP 0253#byte-cap puts the cap on the daemon ("The cap is enforced by the daemon, not by hoping the reader keeps up"), and LLP 0263 extends it to the client hook specifically "so the bound holds while the daemon is down". So the exposure is the narrower one: a stopped daemon, plus Claude Code running from a Cursor-launched shell that exports CURSOR_VERSION, leaves the down-daemon half of the bound unenforced.

Still deferred, for round 1's reason plus one of my own: I went looking for a discriminator that is not invented and there is none. grep -rn "CURSOR_VERSION" research/ returns nothing, and the tree carries no Claude runtime env marker to pair it with (HYP_CLAUDE_CODE_VERSION is a HypAware attach-floor override, not a signal Claude sets). Picking one would be exactly what CLAUDE.md's do-not-invent rule forbids. docs/ACCEPTANCE.md step 8 already asks the operator to print the hook process environment from both a Cursor terminal and a plain one and to confirm Claude still records context and still sweeps its body spool in both, which is the only thing that can settle it.

6. openDb collapses a missing node:sqlite builtin into a read failure - low - defer

native.js:39-50 catches everything into one code, native_database_unavailable, so an absent builtin, a corrupt store, SQLITE_BUSY and a permissions error are indistinguishable in cursor.recovery.incomplete. hermes/src/state_db.js:106-119 shows the in-tree pattern: a distinct sqlite_unavailable naming the Node floor.

Deferred, and the skill's framing of the cause is rejected: it argued the manifest's node_engine: ">=22.12" is wrong because node:sqlite unflags later. That floor is not this PR's invention. It is the repo's settled, deliberate value: root package.json engines.node is ">=22.12", hermes and github already declare exactly ">=22.12" for the same builtin, and llp/0123 T1 records the repo-wide bump to >=22.12 as the node:sqlite decision (@ref LLP 0125). Cursor matching that is consistency, not a new hazard. If the floor is wrong it is wrong repo-wide and belongs to LLP 0125, not to this branch. The remaining half, diagnostic granularity, is a real but low-value nicety that would cost a cross-plugin import at the last review round.

7. Live recovery contention is counted as a read failure and surfaces as lastError - low - defer

listener.js:60 throws native_recovery_busy when the scheduled sweep already holds the session, and it lands in the same catch as a genuine read failure (listener.js:92-96): native_failures++, and lastError set, which status() exposes (listener.js:284). So ordinary overlap between the live lane and the default 5-minute sweep reads as an error in hyp status. The backfill lane handles the mirror case correctly, yielding a distinct cursor.recovery.busy event (recovery.js:86-89), so the asymmetry is demonstrable rather than a matter of taste.

Deferred on concrete counter-evidence: the current behavior is deliberately pinned by an existing test - cursor-listener.test.js:303 waits for native_failures >= 1 as the assertion that a busy session did not read - so changing it edits a decided behavior rather than repairing a slip. It is also self-clearing: listener.js:90 clears lastError on the next success, pinned at cursor-listener.test.js:291.

8. counts.lifecycle_events counts every callback that is not beforeReadFile - low - defer

listener.js:229 increments it for beforeSubmitPrompt, afterAgentResponse, postToolUse and postToolUseFailure as well as the five genuine lifecycle hooks, so the hyp status --json field means "callbacks that scheduled native recovery", not what it is named. Pre-existing at 0c53b2b4 (both the live branch and the dead one incremented it), so round 1's fix 2 neither caused nor worsened it.

9. unknown_entrypoints always equals callbacks - low - defer

Both passes found this; it is round 1's finding 12. listener.js:162 increments unconditionally. It is accurate (hooks genuinely never carry an entrypoint; only native recovery resolves one), so it is redundant rather than wrong. Same class as finding 8: renaming or dropping a field in a shipped hyp status --json shape is a design call, and both are accurate-but-misnamed.


Rejected

10. hook_command.js:71's env guard is unreachable through the shipped binary - reject

Worth stating because after round 1 moved it, it can look like dead code: bin/hypaware.js:23-27 exits 0 for claude-hook session-context whenever CURSOR_VERSION is set and --help/-h is absent, so the handler-level if (ctx.env.CURSOR_VERSION) return 0 is not reachable through hyp or hypaware for a non-help invocation. Not a defect: it is defense in depth for direct and library invocation, it is what round 1's regression assertion exercises (cursor-attach.test.js:71), and classify_hook.js:59 carries the identical pair.

11. The node_engine: ">=22.12" floor is not this PR's to answer - reject

Covered in finding 6 above: root engines, two shipped plugins and llp/0123 T1 all set the same floor for the same builtin.


Round 1's open findings, re-affirmed

I re-checked each of the ten deferrals and three rejections against this head. All still hold.

# Round 1 finding Round 2
4 CURSOR_VERSION blast radius unestablished defer - see finding 5; sharpened with the LLP 0253/0263 scoping and a failed search for a non-invented discriminator
5 Head-of-line blocking defer - see finding 4; re-argued, and the skill's per-callback framing corrected
6 Unknown tool field discards the whole conversation defer - native.js:358 unchanged; still fail-closed by design and observable via cursor.recovery.incomplete
7 Undocumented prompt-suppression field partially fixed - see finding 1; the coverage half is closed, the semantics half stays with acceptance step 2
8 File-scope guard keys on the literal Read defer - recovery.js:28 unchanged; still additive over the repo baseline (finding 2 hardened the block loop around it, not the name match)
9 Non-converging session re-reads every sweep defer - bounded; acceptance step 5 catches it
10 Picker probes .cursor/hooks.json defer - detection only seeds a checkbox; init.js:177 carries cursor in INIT_SOURCE_CHOICES and cursor-picker.test.js:14 pins it
11 Four unexercised fixture files defer - retained evidence, zero runtime cost
12 unknown_entrypoints always equals callbacks defer - see finding 9
13 Per-callback stderr noise when the daemon is down defer - real trade-off
14 previous_message_id: [] reject - LLP 0399#identity settles it verbatim
15 is_error !== null in the shared projector reject - re-verified: no other in-tree producer emits is_error: null, and ai-gateway-message-projector.test.js:681 pins all four cases
16 CURSOR_HOME bypass reject - LLP 0399#attachment states it; cursor-attach.test.js:20-23 pins it with all three env vars set wrong at once

One thing neither round 1 nor the skill checked, and the riskiest shared-file edit in the diff: stripManagedHooks at client_detach_disk.js:635 now runs if (isManagedHandler(group, command) && groupMatcherEquals(group, matcher)) continue for every client's disk detach, not just Cursor. It cannot reach a Claude-shaped group: isManagedHandler requires group.type === 'command' and group.command === command, and a Claude group is { matcher, hooks: [...] } with neither. The only shape it newly removes is a bare command handler whose command exactly matches one the marker recorded as owned, which is Cursor's shape and is by definition marker-owned. cursor-attach.test.js:41-45 proves a user-edited command in that position survives detach verbatim.


Explicit CPU and memory pass

Required by CLAUDE.md. I walked the changed code and affected paths independently of round 1: the hook receiver, the live recovery queue, the synchronous native reader, the scheduled sweep, the shared message_projector and stripManagedHooks edits, and my own three changes.

No unbounded growth, and no new concern. Every retained structure is capped in code, not merely documented: request bodies (4 x 1 MiB, listener.js:127/:139), pending recovery (64 queued, 16 per pass, listener.js:108/:53), live root fingerprints (64 FIFO, listener.js:86), sweep fingerprints (1000 FIFO, pruned against present sessions, recovery.js:112/:76), blob reads (4096, native.js:249), graph bytes (32 MiB cumulative, 1 MiB per record, native.js:254/:185), wire fields (16384, native.js:203), discovery (1000 editor headers + 1000 CLI dirs, and hitting the cap is reported, native.js:83/:97), identity strings (256 chars), hook subprocess (1 MiB stdin, 2 attempts, 2.5 s deadline, hook.mjs:5/:6/:24), and both the exchange writer and the usage-policy memo rotate every 1024 callbacks (listener.js:250, :160). Shutdown releases everything: listener.js:293-302 clears the timer, drains pending, closes connections and awaits the chain; activeRecoveries is released in a finally on both lanes (listener.js:97, recovery.js:119); the SQLite handle closes in a finally (native.js:380).

Repeated work, called out as the rule requires. Per-item cursorAdmission construction inside the recovery loop (listener.js:63) re-walks ancestors with existsSync rather than reusing the resolver's TTL memo: construction is allocation-only, it is bounded at 16 items per pass, and the freshness is privacy-favorable, so accepted as deliberate. The double beforeReadFile admission (listener.js:220 and :242, four realpath per observation) is deliberate and proven necessary by cursor-listener.test.js:153. Per completed turn the live lane re-reads the whole conversation graph, so cost over a long session is roughly quadratic in turn count; mitigated by the in-progress fast path (native.js:264, :278), the root-fingerprint short circuit before any graph walk (native.js:275-276), and 1 s debouncing that rearms only after the previous pass settles.

The one number worth measuring is latency, not leakage, and it is finding 4: synchronous decoding on the daemon's event loop, bounded at 32 MiB per graph and 16 graphs per pass. node:sqlite is stdlib, not a new dependency, and hermes/src/state_db.js:110 already uses it under the same declared floor.

My own changes cost nothing at runtime: two comments, one earlier break in a loop that already ran to completion, one help string, and two unit tests that read temp fixtures. No added allocation, no hot-path change; finding 2's fix strictly reduces work by exiting the block loop on the first rejection.

Verdict of the pass: no CPU or memory concern that should hold this PR.

Convention and privacy compliance

  • No new runtime dependencies. npm pack --dry-run ships all ten plugins-workspace/cursor files including hook.mjs, which imports only node:crypto and node:http.
  • Style: re-grepped the whole origin/master...HEAD diff - no semicolons in added JS, no U+2014 anywhere, and no file in the changed set contains a NUL byte.
  • Types: JSDoc only, no @typedef, no inline import('...'); types.d.ts interfaces imported through @import with repo-root-anchored .js specifiers.
  • LLP: every @ref in the diff resolves. LLP 0399's #capture #identity #native-format #recovery #file-content #attachment #coexistence #resources all exist as {#anchor} headings, and the one cross-doc ref resolves to llp/0306-*.md:63 ### Endpoint-free clients {#endpoint-free-clients}. node scripts/llp-numbers.js check -> "1 LLP number minted against refs/remotes/origin/master, no collision". My changes add no @ref and need no LLP: two tests, two comments and a help string, which CLAUDE.md names explicitly as changes that get none. The one doc edit (docs/ACCEPTANCE.md step 2) lands in the same commit as the test that made it necessary.
  • Capture conventions: two lanes (live hook receiver plus bounded recovery, and manual hyp backfill cursor); entrypoint distinguishes editor from cli off the native store (cursor_capture.js:132); backfill.window_days / sweep_cron bounding is inherited from the kernel via readBackfillPolicy and resolveWindow rather than reimplemented (LLP 0359); detach removes only marker-owned entries and preserves a user-edited command verbatim.
  • Privacy: CursorReadError carries fixed codes only (native.js:20-24), storage errors are never logged (listener.js:270), and both are pinned by tests asserting 'PRIVATE' never appears in status or errors. Finding 2's fix strengthens the file-content gate. My new tests assert counts, prompt text and admission outcomes only; they add no payload to any log or fixture.

Check results

All run in a clean worktree, with node_modules symlinked from the main checkout.

Check 9bbee912 After fixes origin/master baseline
npm test 6569 pass / 3 fail / 1 skip 6571 pass / 3 fail / 1 skip 6537 pass / 3 fail / 1 skip
npm run typecheck exit 0 exit 0 -
npm run smoke -- cursor_capture ok ok n/a
node scripts/llp-numbers.js check no collision no collision -
npm pack --dry-run all 10 plugin files present unchanged -

The 3 failures are the known environmental test/core/hyparquet-floor-pin.test.js cases. I did not take that on trust: I built a separate origin/master worktree and ran the file there, and the same three fail with the same names (# pass 6 / # fail 3):

not ok 3 - icebird uses the root parquet pins directly or through overrides
not ok 6 - every read-path dependency that carries hyparquet is held at the floor
not ok 9 - the read path resolves the one root hyparquet, not a nested copy

No other failure, and none introduced by this PR or by my fixes. The pass count rises by exactly 2, the two tests this round added.

Disposition summary

# Finding Severity Disposition
1 Prompt-suppression predicate unexercised (round 1 called it unfixturable) medium fix (landed, mutation-proved)
2 A refused file block could be reinstated by the next block medium fix (landed, mutation-proved)
3 hyp setup --help omits cursor from --client low fix (landed)
4 Hook write blocks behind a synchronous graph decode medium defer (decoupling would not fix it; acceptance step 9)
5 CURSOR_VERSION skip also disables Claude's down-daemon spool sweep medium defer (no non-invented discriminator; acceptance step 8)
6 openDb collapses a missing builtin into a read failure low defer
7 Recovery contention counted as a failure and shown as lastError low defer (pinned by an existing test)
8 lifecycle_events counts every non-beforeReadFile callback low defer (status-field naming)
9 unknown_entrypoints always equals callbacks low defer (accurate but redundant)
10 hook_command.js:71 unreachable through the binary - reject (defense in depth; pinned by the round-1 assertion)
11 node_engine: ">=22.12" is wrong for node:sqlite - reject (repo-wide settled floor: root engines, hermes, github, LLP 0123 T1)

No blockers. Two things still need a real Cursor before this ships, and both now have a written acceptance step pointing straight at them: what typed user field 5 actually means (step 2, with the predicate now pinned and labelled, and no prompt lost in any of the three inspected stores), and how long a recovery pass actually holds the daemon's event loop (step 9).

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Finding disposition — PR #1640, head c8011eee

Both review rounds are exhausted, so this is the finding-disposition pass, not another review. Every finding from both review records is accounted for below, verified at the current head c8011eeecc687140938e2ef1e86db9463bb57b76 in a fresh worktree (all cited fix sites re-inspected in the committed tree; the five cursor test files rerun at head: 30 pass / 0 fail). Ordinals 1-16 are round 1's findings; ordinals 17-27 are round 2's, in their original order. No blockers. 12 findings deferred (11 preference, 1 evidenced defect), 15 resolved or rejected with cited counter-evidence.

canFix: false on this pass, so a finding requiring a change in this PR would have been a blocker; none does.

Round 1 findings (ordinals 1-16)

# Finding Disposition Evidence / issue
1 --help suppressed under CURSOR_VERSION resolved (round 1 fix holds) hook_command.js:62-65 help branch first, skip at :72; round 2 proved the regression assertion non-vacuous by restoring the pre-fix blob
2 Unreachable lifecycle_only branch resolved (round 1 fix holds) listener.js:233-234 collapsed; lifecycle_events still counted at :229; no reader lost a value
3 Gate/smoke missing from AGENTS.md resolved (round 1 fix holds) AGENTS.md:190 procedure entry, :318 smoke entry, both verified at head
4 CURSOR_VERSION blast radius unestablished defer (preference) No observed misbehavior; no non-invented discriminator exists; ACCEPTANCE step 8 is the settling instrument → #1653
5 Hook write blocks behind synchronous graph decode defer (defect, neutral:fix) Sync readCursorSession stalls the whole daemon event loop; hook destroyed at 800 ms prints a misleading unavailable notice for a row that lands; no data loss (delivery_id pre-allocated, pinned by cursor-listener.test.js:57-75); real fix is a worker-thread redesign sized by ACCEPTANCE step 9 numbers → #1654
6 Unknown tool field discards the whole conversation defer (preference) Deliberate fail-closed per LLP 0399, observable via native_tool_shape; softening changes a Draft LLP's decision → #1655
7 Prompt-suppression field undocumented and unexercised resolved (round 2 fix 1) Test at cursor-native.test.js:83 (mutation-proved), comment at native.js:331-334, ACCEPTANCE step 2 corrected; semantics half stays with the shipped acceptance step
8 File-scope guard keys on the literal Read defer (preference) LLP 0399#file-content decides the scope ("Other tool results use the existing workspace policy contract"); additive over the repo baseline; gate now fails closed within its scope → #1656
9 Non-converging session re-reads every sweep defer (preference) Bounded per pass; the alternative silently loses data; ACCEPTANCE step 5 surfaces it → #1657
10 Picker probes .cursor/hooks.json defer (preference) Detection only seeds a checkbox; needs real-install evidence → #1658
11 Four unexercised fixture files defer (preference) Deliberately retained sanitized evidence per docs/cursor-capture-evidence.md; zero runtime cost → #1659
12 unknown_entrypoints always equals callbacks defer (preference) Accurate but redundant (listener.js:162); status-shape design call → #1660
13 Daemon-down stderr noise per callback defer (preference) Silent hooks would be worse; rate-limit pending acceptance evidence → #1661
14 previous_message_id: [] on native rows reject LLP 0399#identity verbatim at head: "No parent chain is invented from callback arrival or typed completion order"
15 is_error !== null shared-projector change reject Deliberate, LLP-documented, required by native.js unknown outcomes; four-case matrix pinned at ai-gateway-message-projector.test.js:681-690; no other in-tree producer emits is_error: null
16 CURSOR_HOME bypass reject LLP 0399#attachment states it; client_settings_path.js:114-117 @ref honest; cursor-attach.test.js:20 pins all three env overrides wrong at once

Round 2 findings (ordinals 17-27)

# Finding Disposition Evidence / issue
17 Suppression predicate unexercised (fix 1) resolved Landed and verified at head; mutation-proved both directions
18 Refused file block reinstated by the next block (fix 2) resolved recovery.js:33-43 breaks on every rejection, including the catch path; pinned by cursor-native.test.js:147; the privacy gate fails closed structurally
19 hyp setup --help omits cursor (fix 3) resolved core_commands.js:359 lists cursor, opencode (repeatable)
20 Hook write blocks behind graph decode duplicate of ordinal 5 Round 2's own text: "it is round 1's finding 5"; consolidated into #1654
21 CURSOR_VERSION skip disables the down-daemon spool sweep duplicate of ordinal 4 Round 2's own text: "both are one finding, and it is round 1's finding 4"; the LLP 0253/0263 scoping is carried into #1653
22 openDb collapses every failure into one code defer (preference) Correct fail-closed; granularity nicety mirroring hermes → #1662
23 Recovery contention counted as failure / lastError defer (preference) Deliberately pinned by an existing test; self-clearing; asymmetry with recovery.js:89-91 noted → #1663
24 lifecycle_events misnamed defer (preference) Pre-existing at 0c53b2b4; naming-only status-shape change → #1664
25 unknown_entrypoints always equals callbacks duplicate of ordinal 12 Consolidated into #1660
26 Handler-level env guard unreachable through the binary reject Defense in depth for direct/library invocation; exercised by round 1's regression assertion; identical pair in classify_hook.js
27 node_engine: ">=22.12" wrong for node:sqlite reject Repo-settled floor: root package.json:70-72, hermes, github, LLP 0123 T1

Duplicates are recorded as reject in the machine-readable disposition solely so one finding mints one issue; the table above is the authoritative human account.

Deferral integrity

Every deferral has a real issue keyed to this head. Only #1654 carries neutral:fix (the one deferral with an observed incorrect behavior, an expected behavior, and a testable acceptance condition); the eleven preferences stay backlog until a human delegates them. The two things that still need a real Cursor before release remain exactly where round 2 left them: what typed user field 5 means (ACCEPTANCE cursor step 2) and how long a recovery pass holds the daemon's event loop (step 9, now also tracked by #1654).

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Ship risk: medium

Who could be affected: People who run Claude Code inside Cursor's built-in terminal, and anyone who turns on the new Cursor recording option.

What could happen:

  • Claude Code sessions started from a Cursor terminal could stop being recorded, appearing as missing history rather than an error. Whether this actually happens depends on a detail of Cursor that has not yet been confirmed on a real machine.
  • A Cursor conversation using a tool this release has not seen before would not be recorded at all, and would keep being retried without ever succeeding.
  • While a large Cursor conversation is being read, Cursor can briefly say capture is unavailable even though the record is saved a moment later.

Why this level: Nothing here exposes private content, deletes anything, or blocks access. Conversations stay in Cursor's and Claude Code's own storage and can be re-imported afterwards. The effect is missing or delayed history for some people: noticeable, but recoverable.

What was checked: The rules deciding what may be recorded were exercised directly and refused everything outside the folder a session works in, including missing files and symbolic links pointing away; deliberately broken copies of those rules failed the same checks, so the result is meaningful. Error reports were confirmed to carry fixed labels only, never file paths or conversation text. The full test suite and the Cursor end-to-end check passed.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant