fix: mirror Claude Code's project-slug encoding exactly; repair the mis-filed transcripts (desktop + Android) - #321
Merged
Merged
Conversation
…en nativeStoreSlug
…eral (review fix) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lete dead watcher slug (spec §5.3)
…SyncService re-key (Tasks 10-11)
…o (review fix) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ivation is fallback-only
…sist the resume fallback warning
…every CI leg (review fix)
…g walk with backtracking; capped slugs decline
… (spec §8, plan Task 12) Investigation verdict (already settled): resolveProjectName in reconciler.ts resolves a transcript's project bucket as (1) the session's own record's projectName, (2) a known-folder basename recovered by re-slugging, (3) a lossy last-segment fallback. A planned data repair (spec §6.0 case-C aftermath) builds on "record wins" — this test guards that invariant. Two variants isolate tier 1 from tier 2: with a pre-existing record present, its projectName wins even against a competing known-folder hint whose slug collides with the transcript's directory; with no record, the same known-folder hint DOES win, proving tier 1 wasn't simply the only source of truth in variant A. Mutation-checked: inverting the tier-1/tier-2 order in resolveProjectName fails variant A (RealProj -> wronghint) while variant B stays green, confirming the test actually exercises the precedence it claims to pin.
…set/fork, never merge
… outside the projects tree
… C surfaced untouched
…enames (review fix)
…-keys legitimate buckets
…; protect the home bucket (review fix)
…ner with bounded live-deferral Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…guard and fail-closed renames
repairOrphanDirs mirrored repairHomeForks's pre-review-fix shape: the
correct-dir copy (CC's actively-tracked file) had no live-guard before the
superset/fork branches touched its inode, and both promotion renames were
unguarded. Since §6.3's `correct` is the same CC-tracked file §6.1 already
protects, bring it to the same case discipline the brief's own context
calls for ("reuse the same case discipline" as §6.1's current state).
Also fixes a real defect found via TDD: Quarantine.move() threw ENOTEMPTY
when retiring an emptied directory whose files were quarantined
individually moments earlier, because that already created a same-path
shadow directory under quarantine. Falls back to a plain rmdir only when
the source is verified empty — never for anything holding real content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nding (review fix)
runSlugRepair's deferral loop iterated ALL findings (6.1+6.2+6.3 concatenated)
and incremented state.deferred[sessionId] once per 'deferred-live' finding, so
a session live in two mis-filed locations (e.g. the $HOME slug dir AND an
orphan-dir pair) got +2 in one launch, reaching MAX_DEFERRALS in fewer real
runs than the contract states ("3 runs in a row"). Dedupe deferred-live
sessionIds into a Set before incrementing, so each session's counter moves by
at most 1 per run regardless of how many steps flagged it as live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gs (final review) Part (a) of the §6.2 repair-set build previously added EVERY top-level transcript in a known folder's correct CC dir with no ownership gate, so a foreign-cwd session materialized there by cross-device sync got its record's originalPath (the ORIGIN device's path, store-core.ts:26) silently overwritten with this device's path and synced to peers. Gate it to R2's own definition: skip when firstCwd is foreign or doesn't match P via sameDir. The convergence skip only fired on zero space copies anywhere, so a healthy session (record correct, single copy already in the right bucket) was re-evaluated on every launch — emitting record-repaired findings forever and occasionally tripping the live guard into a false deferred-live/ATTENTION surfacing. Skip whenever the record is correct AND no copies remain outside the target bucket, before any live-guard check runs. Also: log every record repair to the decisions log (old -> new projectName/originalPath/transcriptRef, spec §6.0), wrap the previously unguarded mkdirSync in repairHomeForks's promotion path with the module's fail-closed discipline, note the symlink non-realpath tradeoff on sameDir, and add a WHY comment at the §6.3 call site explaining the one-launch record lag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eview) resolveSlugToPath(slug) ran inside files.map(...) — once per transcript file — even though it depends only on the slug directory, not the file. R1's tier-2 fallback can whole-file-scan every top-level transcript in the dir, so invoking it per file turned that into an N×N full-read multiplier on foreign-heavy directories, on the Resume Browser's hot path. Hoisted to once per slug directory, before files.map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view) - Fix the stale docs/cc-dependencies.md reference to the deleted cwdToProjectSlug; point it at ccProjectSlug. Add the two coupling entries Task 19 mandates: the CC slug mirror (slug-encoding.ts + CcProjectSlug.kt, anchored to cc-slug-pairs.json / ccVersion 2.1.229) and hook payload transcript_path/cwd consumption (ipc-handlers session-start handler + Android EventBridge/pushSession). - slug-encoding.ts header: reconcile "rule recovered from CC 2.1.228 binary; fixtures regenerated against 2.1.229 — behavior identical". - ipc-handlers.ts: harden the two hook-payload casts to `typeof x === 'string' ? x : undefined` instead of a blind `as` cast. - CcProjectSlug.kt: WHY caveat — Kotlin regex matches per code point, JS per UTF-16 unit, so non-BMP chars (emoji) would diverge from CC; currently unreachable (only ASCII canonicalHome is passed); behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng the reconciler (found on the first real-data run) runSlugRepair's default knownFolders previously came from readFolders() (saved folders) alone. runReconcile defines known folders as managed projects FIRST, then saved folders — the repair only saw half of it. On the real device the target project was managed but never saved, so the repair silently did nothing for its mis-filed transcripts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g repair runs (found on the real-data run)
startConversationStore fired the reconciler and materialize sweep detached,
then main.ts ran the one-shot slug repair concurrently. On the real device
this raced: the repair quarantined a mis-slugged file, the materialize sweep
(holding the pre-repair record) re-created it from the space copy, and the
reconciler (holding a pre-repair store.list() snapshot from its startup scan)
mirrored files back into the buckets the repair had just retired. Nothing was
lost, but every launch re-quarantined and re-resurrected the same records.
Adds a pause/resume gate to conversations/service.ts: pauseSweeps() makes
runReconcile/materializeSweep record a pending flag instead of running;
resumeSweeps() fires each pending one exactly once. main.ts now starts the
store with { pauseSweeps: true }, runs the repair, and resumes in a .finally
so a throwing repair can't leave sweeps stuck off.
…resolved (found on the real-data run) A surfaced fork (spec §6.0 Case C) left both diverged copies on disk for the user to resolve, but the pre-existing space<->local mirrors don't know a fork when they see one: transcript-mirror.ts's materializeOut/mirrorIn are grow-only BY SIZE, and for a fork larger != superset. Two seconds after the repair released the sweeps on the real-data run, materializeOut overwrote the smaller fork copy of session 26d919ff (74 unique messages) with the larger one, displacing those messages into quarantine-only existence and making the next launch see two identical copies — the fork silently stopped being surfaced. Fix: slug-repair.ts's state file (~/.youcoded/slug-repair-state.json) gains surfacedForks: string[] — session ids of forks it has surfaced. New leaf module slug-repair-state.ts (fs/path/os only, no cycle risk) owns the on-disk shape and a heldForkIds() reader shared by slug-repair.ts and service.ts. service.ts's materializeSweep, materializeOne, and the reconciler's mirror closure all skip held ids in both directions. slug-repair.ts also skips re-snapshotting an already-held fork on subsequent runs (still surfaces the finding + ATTENTION log) and auto-releases the hold once a run finds the session is no longer a fork on disk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nges (found on the real-data run) repairRecordsAndSpace's copiesElsewhere path called store.upsert with identical values whenever a session's stray space copy needed quarantining even if its record was already correct, logging a no-op RECORD-REPAIR line and pushing a record-repaired finding. Device run 4 produced 6 such no-op lines — noise in the decisions log and an inflated record-repaired count in the INFO summary. Now the three target fields are compared against the existing record first; the upsert/log/finding only fire when something actually differs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly on positive evidence (review fix) Senior review of 6be7925 found two ways the fork hold could still get silently clobbered: - runSlugRepair's store.setNote and repairRecordsAndSpace's store.upsert were unguarded. A lock-timeout throw (conversation-store.ts:166) rejected the whole run BEFORE writeState() persisted the just-computed hold, so main.ts's .finally(resumeSweeps) unpaused the mirror sweeps over an unrecorded hold — the exact clobber the fork-hold fix exists to prevent. Now both calls are guarded (best-effort setNote, per-session upsert failure), and writeState() runs immediately after the in-memory hold/deferral bookkeeping — before any store call that can throw — and again (idempotently) at the end. - The auto-release check dropped a hold on ANY run that produced no fork/deferred finding for the id, including a run that never reached the pair at all (e.g. the owning folder dropped out of knownFolders, or readFolders() threw and returned []). surfacedForks now records each held fork's paths, not just its id, so release requires positive evidence: a non-fork finding for the id this run (the pair converged), or a recorded path missing from disk (the user resolved it by hand). Silence with every copy still present keeps the hold. Also drops the dead `export { heldForkIds }` re-export in slug-repair.ts — service.ts already imports it from the leaf module directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tate file in tests (review fix) Folds four cheap review MINORs from the fork-hold commit review: - pauseSweeps/resumeSweeps now use a depth counter instead of a boolean — a second pauser can no longer have its resumeSweeps() lift the gate out from under a still-active first pause. - stopConversationStore resets pauseDepth/reconcilePending/materializePending — this module is a true singleton, and a pause left dangling from a prior store instance (or a caller that paused without resuming) must not stick every future sweep trigger in "pending" forever. - materializeOne logs an INFO line when it skips a held session, so a takeover attempt that silently no-ops against a held fork is diagnosable instead of looking like a bug. - conversations-service.test.ts and conversations-service-sweep-pause.test.ts read the developer's REAL ~/.youcoded/slug-repair-state.json through service.ts's unmocked heldForkIds() calls — non-hermetic (this dev machine's file already holds a real fork id). slug-repair-state.ts's defaultStateFile now honors a YOUCODED_SLUG_REPAIR_STATE env override (test-only; production never sets it), and both suites point it at a tmp file per test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… holds; record-repair-failed kind (review fix) - New RepairFinding kind 'record-repair-failed', used at the §6.2 upsert-failure site — 'rename-failed' there read backwards (the rename already succeeded; only the record write threw). - runSlugRepair now wraps each of 6.1/6.2/6.3 in its own try/catch, logging and continuing to the next stage and to finalization instead of aborting the whole run — a filesystem throw in a later stage must never discard holds/findings an earlier stage already gathered, since finalization is what persists them. Order (6.1->6.2->6.3) is unchanged; a failed stage is skipped, never reordered. Added a test-only `overrides.stages` seam since vi.spyOn on the module's exports can't intercept same-module calls. - Clarified the YOUCODED_SLUG_REPAIR_STATE env override's comment: test-only seam, never documented for users, production never sets it.
…coding Manual resolution: desktop/src/main/harness/permission-store.ts (conflict markers) — kept nativeStoreSlug for the disk key, kept master's normalizeRule mapping on the read path. Semantic re-routing (no textual conflict, but master's cwdToProjectSlug call sites broke against this branch's removal of that function from transcript-watcher.ts): desktop/src/main/harness/native-session-host.ts (revokeRule/revokeProject live-session slug matching), and comment-only references in desktop/src/renderer/components/PermissionsSection.tsx, desktop/src/shared/types.ts, desktop/src/shared/permission-types.ts, desktop/src/main/harness/permission-store.ts, and desktop/tests/native-session-host.test.ts (import + all call sites) — all routed to nativeStoreSlug (the frozen app-private permissions.json key), matching PermissionStore's own slug function, since these are Always-allow revoke paths, not ~/.claude/projects/ lookups.
…tching to nativeStoreSlug master added native-session-host revocation (PR #3xx) importing cwdToProjectSlug from transcript-watcher — a function this branch deleted. The slug it matches against comes from PermissionStore's own disk keys, which are nativeStoreSlug-keyed (app-private, not a CC mirror), so this is the native lane. Auto-merged without a conflict marker; caught by the post-merge totality grep + tsc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ests hold on macOS/Windows CI Fixtures assumed the raw mkdtemp path was canonical, which is false on macOS (os.tmpdir() -> /var/folders/... symlinks to /private/var/...) and Windows CI (8.3 short names resolve to a different string). The product code already realpaths (matching Claude Code's own behavior), so the fixtures were wrong, not the code. Also swapped two fixed 1200ms sleeps in transcript-watcher.test.ts for vi.waitFor polling, per the file's own documented rule against fixed sleeps on busy runners.
itsdestin
added a commit
that referenced
this pull request
Aug 16, 2026
…iveStoreSlug PR #321's slug split (cwdToProjectSlug → ccProjectSlug/nativeStoreSlug) renamed the symbol and dropped the old 'RAW' import comment, but conversations.md still described the native transcript path as cwdToProjectSlug and anchored on 'RAW' — a broken doc-anchor and a deleted symbol a future session would hunt for. session-store.ts uses nativeStoreSlug; prose and anchor now match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
The app's mirror of Claude Code's project-directory encoding was wrong: it replaced 4 characters (
/ \ : space) where CC replaces every non-alphanumeric with-, caps at 200 chars + base36 hash, and realpaths the cwd first. Any project path with,&._(etc. made the chat view, project memory, sync buckets and resume silently read a directory CC never writes — a second, wrong copy of transcripts accumulated per project, and resume could land in$HOME.Spec (Revision 3, now
shipped):youcoded-dev/docs/active/specs/2026-08-11-project-slug-encoding-repair.md. Plan with execution record:…/plans/2026-08-12-project-slug-encoding-repair.md.The change
desktop/src/main/slug-encoding.ts, zero-import leaf):ccProjectSlug— the true CC mirror, anchored totests/fixtures/cc-slug-pairs.jsongenerated by real CC 2.1.229 sessions on this device (probe covered_/., punctuation, >200-char cap, symlink→realpath);nativeStoreSlug— the FROZEN historical rule that keys~/.youcoded/sessions/andpermissions.json(byte-identical, freeze-pinned; changing it orphans user data).cwdToProjectSlugis deleted; the compiler proves totality (rg→ 0 hits).transcript_path+cwdfrom the hook payload verbatim; slug derivation is fallback-only. Subagents dir follows the transcript's own dirname.transcript-cwd.ts) with the mid-file cwd-switch fixture pinned; platform-injectable foreign-cwd filter (windows-latest CI).CcProjectSlug.kt, Long-widened abs);SyncServicere-keyed to the CC slug everywhere (the "freeze the sync key" premise was refuted by investigation — the index slug is resolved as a real path on restore, and the remote corpus was already CC-real-keyed; per-session push had never worked). Dead watcher slug + its test deleted.conversations/slug-repair.ts): §6.1$HOME-forked transcripts, §6.2 records + sync-space buckets (per session, R2-owned, record-wins pinned by test), §6.3 orphan old-rule dirs. Ground rules enforced in code and tests: never unlink (quarantine to~/.youcoded/repair-quarantine/<stamp>/with a decisions log; EXDEV → skip, never copy+delete), never merge, classify every pair by content (per-uuid content hashes — same-uuid divergence is a fork), case-C forks never automated (surfaced via WARN + record note; keeper selection fork-gated), both-sides live-guard, fail-closed renames, bounded live-deferral (3 runs → surfaced), sweeps quiesced during the repair, surfaced forks held out of both mirror directions until resolved.Verified against real data (5 supervised launches, built app closed)
Steady state after run 5: findings = the one surfaced fork only; zero moves/writes. PAF project: 8 sessions in one bucket, all records on the real path, orphan dir retired file-by-file (each ⊆ correct first),
$HOMEcopy quarantined, nothing deleted (21 transcript copies preserved in quarantine). Three defects only real data could show were fixed with tests: known folders must include managed projects; the startup reconcile/materialize sweeps must be paused during the repair (a stale record snapshot resurrected 9 duplicates); the size-gated grow-only mirror clobbers the smaller fork copy → fork hold. Full spec §11 checklist ticked with command evidence in the workspace ledger.Review trail
Every task reviewed (spec + quality) with fixes re-confirmed; whole-branch final review + a post-real-data review of the 4 late fixes: READY TO MERGE.
verify.sh --full+gradlew testgreen on the tip after merging master (one semantic re-route of master's newrevokeRule/revokeProjectslug matching tonativeStoreSlug— it imported the deleted function and auto-merged without a marker).Deferred (in
ROADMAP.md): fork-resolution UX + cross-device staleness while held; stale deferred-key sweep; Windows lowercase-drive over-cap probe; Kotlin non-BMP caveat; cross-deviceoriginalPathsemantics.🤖 Generated with Claude Code