Skip to content

fix: mirror Claude Code's project-slug encoding exactly; repair the mis-filed transcripts (desktop + Android) - #321

Merged
itsdestin merged 41 commits into
masterfrom
fix/project-slug-encoding
Aug 16, 2026
Merged

fix: mirror Claude Code's project-slug encoding exactly; repair the mis-filed transcripts (desktop + Android)#321
itsdestin merged 41 commits into
masterfrom
fix/project-slug-encoding

Conversation

@itsdestin

Copy link
Copy Markdown
Owner

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

  • Two functions, on purpose (desktop/src/main/slug-encoding.ts, zero-import leaf): ccProjectSlug — the true CC mirror, anchored to tests/fixtures/cc-slug-pairs.json generated 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/ and permissions.json (byte-identical, freeze-pinned; changing it orphans user data). cwdToProjectSlug is deleted; the compiler proves totality (rg → 0 hits).
  • Watcher consumes CC's own transcript_path + cwd from the hook payload verbatim; slug derivation is fallback-only. Subagents dir follows the transcript's own dirname.
  • R1/R2 transcript-ownership rules (transcript-cwd.ts) with the mid-file cwd-switch fixture pinned; platform-injectable foreign-cwd filter (windows-latest CI).
  • Exact slug inversion for resume: recorded cwd (R1) → forward re-slug walk with backtracking (declines capped slugs) → legacy split. Resolved once per slug dir, not per file.
  • Android: fixture-anchored Kotlin mirror (CcProjectSlug.kt, Long-widened abs); SyncService re-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.
  • One-time idempotent startup data repair (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), $HOME copy 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 test green on the tip after merging master (one semantic re-route of master's new revokeRule/revokeProject slug matching to nativeStoreSlug — 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-device originalPath semantics.

🤖 Generated with Claude Code

itsdestin and others added 30 commits August 12, 2026 15:07
…eral (review fix)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o (review fix)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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.
…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>
itsdestin and others added 11 commits August 12, 2026 17:39
…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
itsdestin merged commit 30e8ea5 into master Aug 16, 2026
4 checks passed
@itsdestin
itsdestin deleted the fix/project-slug-encoding branch August 16, 2026 06:17
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>
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.

1 participant