## Platform Behavior

#### `apply_resolved_edits` flattens CRLF source files to LF on every doc rewrite
- **Difficulty:** medium
- **Location:** src/doc_writer/rewriter.rs:478-512 (`apply_resolved_edits`)
- **Description:** The doc-rewrite path reads the file with `read_to_string`, then `apply_resolved_edits` does `content.lines().map(|l| format!("{l}\n"))`. `str::lines()` strips both `\r\n` and `\n`; the re-emit is bare `\n`. Result: every `cqs index --improve-docs` (or any `rewrite_file` caller) silently rewrites a Windows-source file from CRLF to bare LF across the whole file, churning every line in git's diff and fighting `core.autocrlf=true`. Same root cause that `note.rs::write_notes_file` already fixed (PB-V1.33-9 / #1356) — that fix sniffed CRLF in the existing content and re-translated `\n → \r\n` before writing. The doc rewriter has no equivalent guard. `compute_rewrite_from_content` (line 263) feeds `apply_resolved_edits` directly, and `write_proposed_patch` (line 523) builds `similar::TextDiff::from_lines` over the lossy `new_content`, so even the patch-only path emits a diff that flips line endings on every line.
- **Suggested fix:** Mirror the note.rs CRLF-preservation pattern: sniff `content.contains("\r\n")` once, switch the re-join to `"\r\n"` if so. Or — more robustly — split using a regex / byte-level scan that preserves the per-line trailing terminator instead of `str::lines()`. The note.rs comment block at line 349-373 is the template.

#### `audit-mode.json` SEC-1 promise is bypassed on Windows
- **Difficulty:** medium
- **Location:** src/audit.rs:140-159 (`save_audit_state`)
- **Description:** P4-19 in the v1.33 triage. Confirmed still present at v1.36.2: the `#[cfg(unix)]` arm uses `OpenOptions::mode(0o600)` so the tmp file is born private; the `#[cfg(not(unix))]` arm calls `std::fs::write(&tmp_path, &content)` with no ACL hardening at all, then `atomic_replace` moves it into `.cqs/audit-mode.json` with whatever default ACL the OS picks. The same shape exists in `project.rs::save` (line 129-132), `config.rs:899-905` and `config.rs:997-1003`. Audit mode encodes "the operator believes notes are untrusted" — leaking that state to other Windows users on a multi-user host (or to per-user services like the embedded SYSTEM-runtime indexer) breaks SEC-1's "0o600-equivalent" claim that doc-comments and SECURITY.md make.
- **Suggested fix:** Wire a Windows ACL fixup (e.g., `windows-acl` or a manual `SetFileSecurityW` call to deny everyone-except-owner) inside the `#[cfg(not(unix))]` arms. Alternatively, downgrade SEC-1's claim in SECURITY.md so the doc and the code agree (per the "docs lying is P1" rule).

#### `db_file_identity` Windows fallback uses mtime — misses rapid `--force` rebuilds
- **Difficulty:** medium
- **Location:** src/cli/watch/reindex.rs:42-45
- **Description:** P4-17 in the v1.33 triage, still present. On Unix the daemon detects DB-replacement via `(dev, ino)` after `cqs index --force`; on Windows the fallback returns `metadata.modified()`. NTFS mtime granularity is 100ns in theory but Win32 file caching often returns ~1-second-old timestamps, and a sub-second `index --force` (very common in tests / quick re-indexes) leaves the daemon serving the stale handle because `db_id == old_db_id`. The downstream code at `cli/watch/mod.rs:1281,1333,1461,1506` re-reads the index in lockstep with this signal, so a stale `db_file_identity` on Windows means the daemon hands out queries against a freed mmap — at best wrong results, at worst a SIGBUS-equivalent if NTFS truncates.
- **Suggested fix:** On Windows use `meta.file_index()` (nightly) or call `GetFileInformationByHandleEx` for the file index + volume serial number — that pair is the NTFS analogue of `(dev, ino)`. The `winapi`/`windows-sys` crate already pulls in the necessary bindings.

#### `worktree::lookup_main_cqs_dir` doesn't canonicalize `dir`, so worktree-vs-main equality is asymmetric on case-insensitive filesystems
- **Difficulty:** medium
- **Location:** src/worktree.rs:119-140
- **Description:** `lookup_main_cqs_dir(dir)` joins `dir` with `INDEX_DIR` directly (line 120) and then calls `resolve_main_project_dir(dir)` (line 124), which `dunce::canonicalize`s only the resolved `.git/` parent. The returned `MainIndexLookup::WorktreeUseMain { worktree_root: dir.to_path_buf(), main_root, .. }` therefore has a canonicalized `main_root` but a non-canonical `worktree_root`. Downstream callers compare these fields (search-result `_meta.worktree_stale`, daemon ping responses) and string-compare against `find_project_root()` output, which IS canonicalized via `dunce::canonicalize` (cli/config.rs:138). On Windows `C:\Projects\Foo` vs `C:\projects\foo` and on macOS `/Users/Foo` vs `/Users/foo` produce a worktree_root that doesn't equal find_project_root's output, so the `worktree_stale` flag fires (or fails to fire) inconsistently — same lab as the #1254 leakage class.
- **Suggested fix:** Call `dunce::canonicalize(dir)` once at the top of `lookup_main_cqs_dir` and use that for both the `own_cqs.exists()` check and `MainIndexLookup::*::worktree_root`. Same for `MainIndexLookup::WorktreeMainEmpty.worktree_root`.

#### Multiple `strip_prefix(root)` callers silently leak absolute paths on case-skew without the warn shim
- **Difficulty:** medium
- **Location:** src/scout.rs:319, src/gather.rs:269, src/onboard.rs:160 + 413, src/doc_writer/rewriter.rs:543
- **Description:** `enumerate_files` learned (lib.rs:943) that on case-insensitive FS, `path.starts_with(root)` can pass while `path.strip_prefix(root)` byte-equals fails — the fix logs a warn and skips. Five other call sites still use the original anti-pattern: `file.strip_prefix(root).unwrap_or(&file).to_path_buf()`. When the user's `find_project_root` canonicalization picks `C:\Projects\Cqs` but the SQLite-stored chunk paths were ingested as `C:\projects\cqs`, every JSON envelope leaks the full absolute path into a field documented as "relative to project root", and downstream agents that string-compare `file` against `<root>/...` mismatch silently. No warn fires — the operator only sees "scout returned full paths" without explanation. Equivalent on macOS HFS+ default and on any Linux ZFS pool with `casesensitivity=insensitive`.
- **Suggested fix:** Wrap the pattern in a single helper `relativize_or_warn(file, root)` that returns `Option<PathBuf>` with the same shim that `enumerate_files` already emits, and switch all five call sites to it. Or normalise both sides to lowercase comparison on `cfg(any(target_os = "windows", target_os = "macos"))` before stripping.

#### `note::path_matches_mention` is case-sensitive, breaks on Windows / macOS notes
- **Difficulty:** easy
- **Location:** src/note.rs:525-542
- **Description:** Note mentions are matched via `str::strip_prefix` / `strip_suffix` after slash-normalization. Case-sensitive byte comparison. On Windows a note mentioning `"Cargo.toml"` won't match an indexed chunk file `"cargo.toml"` (and vice versa for path components like `Src/` vs `src/`). The CLAUDE.md and README explicitly invite users to commit `docs/notes.toml` cross-platform, so a note authored on Linux silently fails to apply on Windows. Worse, the sentiment-boost in the search ranker therefore vanishes for affected files — silent quality regression rather than a hard error.
- **Suggested fix:** On `cfg(any(target_os = "windows", target_os = "macos"))`, use `eq_ignore_ascii_case` for the strip checks (or normalise both strings to lowercase before comparison). On Linux keep the case-sensitive behaviour — Linux ext4 is case-sensitive and `Cargo.toml`-vs-`cargo.toml` collisions there are intentional.

#### `daemon_translate` is unconditionally `#[cfg(unix)]` — `cqs ping`, `cqs watch --serve`, daemon RPC all silently degrade on Windows
- **Difficulty:** hard
- **Location:** src/daemon_translate.rs (29× `#[cfg(unix)]`, zero `#[cfg(not(unix))]` shims), src/cli/commands/infra/ping.rs:166-200
- **Description:** Every public daemon-RPC entry point — `daemon_socket_path`, `daemon_ping`, `daemon_status`, `daemon_reconcile` — is gated `#[cfg(unix)]` only. Windows builds ship `cqs ping` that prints "not supported, exit 1" (per ping.rs:160-166) and `cqs watch --serve` falls back to CLI-only mode. README.md's release matrix advertises `Linux x86_64, macOS ARM64, Windows x86_64` and the CLAUDE.md / docs talk about the daemon's 3-19ms-vs-2s startup advantage as a blanket claim. Windows users get the cold-start path on every invocation with no warning at install time. Either the docs lie, or Windows needs a `Named Pipe` daemon path. Docs-lying-is-P1 (per memory.md).
- **Suggested fix:** Two options. (1) Implement a `\\.\pipe\cqs-<hash>` named-pipe variant for Windows — the wire format is already JSON-line so the transport swap is mostly mechanical. (2) Update README + SECURITY.md to explicitly mark the daemon as Linux+macOS-only and remove Windows from the daemon-aware perf claims. Either way, `cqs ping` on Windows should print a one-line "daemon not implemented on Windows; using CLI mode (~2s startup)" hint instead of failing with exit 1.

#### `tasklist` parsing assumes UTF-8 stdout — Windows console code pages can corrupt the comparison
- **Difficulty:** medium
- **Location:** src/cli/files.rs:104-111
- **Description:** `String::from_utf8_lossy(&o.stdout)` on `tasklist /FO CSV /NH` output. The `/FO CSV` format is locale-independent for the *separator* characters (comma, double-quote, PID digits — all ASCII), so the substring match `,\"<pid>\",` actually works correctly even when CMD's active code page is CP932 (Japanese) or CP1252 (Windows-1252) — those are ASCII-supersets. BUT: tasklist on some Windows builds emits a UTF-16 LE BOM (`\xFF\xFE`) when invoked with `/FO CSV` from certain process contexts (notably PowerShell-spawned children with `[Console]::OutputEncoding = Unicode`). `from_utf8_lossy` then sees `\xFF\xFE,...` and the BOM-prefixed PID column never matches `,"12345",`. `process_exists` returns false, which `acquire_index_lock` interprets as "stale lock holder" and races the live daemon for the lock file — the exact bug PB-V1.33-10 was supposed to fix.
- **Suggested fix:** Detect a UTF-16 LE BOM in the first two bytes of stdout and decode via `String::from_utf16_lossy(...)` before the substring search. Or pass `/FO LIST` and parse line-by-line — LIST format prefixes each field with the field name and is identically-shaped across encodings.

#### Hardcoded `/tmp` in `Path::new` test fixtures fails on Windows (test-portability bug, not runtime)
- **Difficulty:** easy
- **Location:** src/store/notes.rs:331,352,419,435,470,499,520,546; src/store/backup.rs:373-399; src/cli/watch/tests.rs:164-300+
- **Description:** Many test cases construct `Path::new("/tmp/notes.toml")` or `PathBuf::from("/tmp/test_project")` as a placeholder label. They work on Linux/macOS (the path is just stored as a string, never opened), but on Windows `Path::new("/tmp/...")` is a relative path under whichever volume the test runner is on, so behaviour diverges from intent. The `_test_project` watch tests at cli/watch/tests.rs make assumptions (e.g., that `/tmp/test_project/.cqs/index.db` is "inside" `/tmp/test_project`) that hold on POSIX but produce different `starts_with` results on Windows. Not load-bearing today because the project's CI doesn't run the full Windows test matrix, but it bites whoever stands up Windows CI next.
- **Suggested fix:** Replace `/tmp/<x>` with `tempfile::TempDir::new().unwrap().path().join("<x>")` for any test that exercises path-comparison logic; for tests that only need a label string (notes.toml), use `Path::new(if cfg!(windows) { "C:\\test\\notes.toml" } else { "/tmp/notes.toml" })` or just `Path::new("notes.toml")` if absoluteness doesn't matter.

#### `lookup_main_cqs_dir` predicates on `own_cqs.exists()` without distinguishing dir-vs-file
- **Difficulty:** easy
- **Location:** src/worktree.rs:120-122
- **Description:** `if own_cqs.exists()` — on a worktree where someone created a `.cqs` regular file (e.g. `touch .cqs` by mistake, or a packaged tarball with a stray entry) the code returns `MainIndexLookup::OwnIndex { path: own_cqs }` and downstream code tries to open `<file>/index.db` which fails with a confusing "is not a directory" error rather than the clean "this worktree has no index, use main's" error path. Cross-platform issue but most likely on Windows where users hand-edit zip-extracted directories.
- **Suggested fix:** Use `own_cqs.is_dir()` rather than `own_cqs.exists()`. Same fix in the `main_cqs.exists()` branch at line 128.

DONE
