sinter-io 0.48.0

sinter command-line interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
//! `sinter install`: write the Claude Code skill card. The card ships
//! embedded in the binary so integration text can never drift from the
//! tool's actual verbs — rerun after upgrading to refresh it.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde_json::{Value, json};

pub const SKILL: &str = include_str!("../skill/SKILL.md");
/// Claude Code enforcement hook script, embedded so it can never drift
/// from the modes the settings entries invoke.
pub const ENFORCE_HOOK: &str = include_str!("../skill/sinter-first.sh");
/// PowerShell variant of the enforcement hook, installed on Windows where
/// implicit bash execution is not available.
pub const ENFORCE_HOOK_PS1: &str = include_str!("../skill/sinter-first.ps1");

/// Enforcement hook the LOCAL platform installs: (file name, embedded
/// content). Windows gets the PowerShell port; everything else the
/// original bash script.
pub const PLATFORM_HOOK: (&str, &str) = if cfg!(windows) {
    ("sinter-first.ps1", ENFORCE_HOOK_PS1)
} else {
    ("sinter-first.sh", ENFORCE_HOOK)
};

/// Card body without the Claude-specific YAML frontmatter — the single
/// source every assistant adapter wraps. One content, many writers:
/// forked per-assistant content is the failure mode this design forbids.
pub fn card_body() -> &'static str {
    SKILL
        .strip_prefix("---")
        .and_then(|rest| rest.split_once("---"))
        .map(|(_, body)| body.trim_start_matches('\n'))
        .unwrap_or(SKILL)
}

/// Compact always-in-context block for AGENTS.md. Deliberately smaller
/// than the skill card (which loads on demand): an always-on block gets
/// skimmed, so it carries only the behavior rules and routing. Keep the
/// two in sync when verbs change — `agents_block_routes_match_card`
/// enforces the command surface.
const AGENTS_CARD: &str = r#"## sinter

This repo has a code knowledge graph at `.sinter/` (derived state — never
commit or edit it). When `.sinter/graph.redb` exists, query sinter BEFORE
any broad filesystem search for symbol location, callers, dependency
impact, structural paths, or diff impact. Fall back to grep only when
sinter returns no usable evidence; read source directly for
function-body behavior.

| Question | Command |
|---|---|
| First look in an unfamiliar repo (module inventory, dependency hubs, docs, graph health) | `sinter map` |
| Vague/conceptual discovery (calibrated lexical search) | `sinter ask "<question>"` (`--explain` adds ranking diagnostics) |
| Exact or fuzzy symbol lookup | `sinter query <symbol>` |
| Inspect one symbol (signature, docs, callers) | `sinter show <symbol>` |
| What depends on X / blast radius | `sinter affected <symbol>` |
| What does X depend on (forward) | `sinter deps <symbol>` |
| How does A reach B | `sinter path <A> <B>` |
| Check gaps before a negative proof | `sinter unresolved [--file <f>] [--name <n>]` |
| What does this commit/diff/PR affect downstream | `sinter impact <rev-range>` (default is capped; `--limit 0` returns all) |
| Where do proposed changes overlap | `sinter overlap <rangeA> <rangeB> ...` |
| Build a cross-repo graph | `sinter workspace <manifest.toml>`; then add `--workspace <manifest.toml>` to reads |
| Create missing derived graph state | `sinter ensure <repo>` |
| Diagnose graph or integration problems | `sinter doctor <repo>` |
| Add compiler-grade call/type evidence | `sinter scip <repo>` |

- Every read verb takes `--json` and exits grep-style (0 results,
  1 none, 2 error) — branch on the code, not the prose. Results carry
  call sites (`file:line`).
- `--relations calls,uses` on affected/deps/path drops file-level
  import noise from a blast radius.
- Queries self-sync before answering — no manual refresh needed
  (`sinter build` remains for CI/scripts; git hooks refresh on commit).
- `not_proven`, unresolved references, and candidate lists are real answers —
  refine and rerun, never report zero or guess a binding. Receiver-typed call
  coverage may require `sinter scip`; `sinter unresolved` lists the gaps.
  Ambiguous symbol? Rerun as `name@file-suffix` (e.g. `run@init.rs`).
- Spawning subagents? Their prompts must mandate sinter for structure
  claims (callers, dependencies, blast radius, "no usages" proofs) and
  reserve grep/rg for content-only searches.
- Cross-repo symbols may be `member:Symbol`.
- `sinter ensure <repo>` creates only derived `.sinter/` state. Run
  `sinter init <repo>` only when full hook and client integration installation
  was explicitly requested.
- MCP registered? `mcp__sinter__*` tools (ask/show/query/affected/deps/
  path/unresolved/impact/overlap/map) answer the same questions as the
  CLI verbs above — either route is fine.
- Anything else: `sinter --help`; graph problems: `sinter doctor`.
"#;

pub(crate) const AGENTS_BEGIN: &str =
    "<!-- BEGIN sinter (managed by `sinter install`; edits inside are overwritten) -->";
pub(crate) const AGENTS_END: &str = "<!-- END sinter -->";

/// Write the Cursor project rule (own file, native .mdc format).
pub fn cursor(repo: &Path) -> Result<PathBuf> {
    let dir = repo.join(".cursor").join("rules");
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("sinter.mdc");
    let content = format!(
        "---
description: Query the sinter code graph for any codebase-structure question
alwaysApply: false
---

{}",
        card_body()
    );
    std::fs::write(&path, content)?;
    Ok(path)
}

/// Merge a managed sinter block into the repo's AGENTS.md (the convention
/// Codex, Gemini, and most non-Claude agents read). Existing content is
/// preserved; an existing sinter block is replaced in place — idempotent.
pub fn agents(repo: &Path) -> Result<PathBuf> {
    let path = repo.join("AGENTS.md");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let block = format!(
        "{AGENTS_BEGIN}

{}
{AGENTS_END}",
        AGENTS_CARD.trim_end()
    );
    let merged = match (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
        (Some(start), Some(end)) if end > start => {
            let after = existing[end + AGENTS_END.len()..].to_string();
            format!("{}{}{}", &existing[..start], block, after)
        }
        _ if existing.trim().is_empty() => format!(
            "{block}
"
        ),
        _ => format!(
            "{}

{block}
",
            existing.trim_end()
        ),
    };
    std::fs::write(&path, merged)?;
    // Claude Code reads CLAUDE.md, not AGENTS.md. When the repo has a
    // CLAUDE.md, make it import the block once (`@AGENTS.md`); the
    // per-prompt hook already routes Claude when CLAUDE.md is absent.
    let claude_md = repo.join("CLAUDE.md");
    if let Ok(existing) = std::fs::read_to_string(&claude_md)
        && !existing.contains("AGENTS.md")
    {
        std::fs::write(
            &claude_md,
            format!("{}\n\n@AGENTS.md\n", existing.trim_end()),
        )?;
    }
    Ok(path)
}

/// True when this content is current with the embedded card (drift check).
/// AGENTS.md carries the compact block, everything else the full card.
pub fn block_current(content: &str) -> bool {
    content.contains(card_body().trim_end()) || content.contains(AGENTS_CARD.trim_end())
}

/// Default install location for the skill card.
pub fn default_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|home| {
            PathBuf::from(home)
                .join(".claude")
                .join("skills")
                .join("sinter")
        })
}

/// Register the sinter server in every client's project-scope MCP config:
/// `.mcp.json` (Claude Code), `.cursor/mcp.json` (Cursor), and a managed
/// block in `.codex/config.toml`. Registration uses the portable `sinter`
/// command name and initially leaves the Codex server non-required: a broken
/// PATH must not prevent an agent session from starting before the server has
/// completed a successful handshake. Other entries are preserved; only the
/// sinter entry is written. Global client configs belong to their applications
/// and are never edited here.
pub fn mcp(repo: &Path) -> Result<()> {
    let repo = repo.canonicalize()?;
    let command = mcp_command();
    for path in [repo.join(".mcp.json"), repo.join(".cursor/mcp.json")] {
        std::fs::create_dir_all(path.parent().unwrap())?;
        let mut root: Value = match std::fs::read_to_string(&path) {
            Ok(existing) => serde_json::from_str(&existing)
                .with_context(|| format!("{} exists but is not valid JSON", path.display()))?,
            Err(_) => json!({}),
        };
        root.as_object_mut()
            .ok_or_else(|| anyhow::anyhow!("{} top level is not an object", path.display()))?
            .entry("mcpServers")
            .or_insert(json!({}));
        root["mcpServers"]["sinter"] = json!({
            "command": command,
            "args": ["serve", "--repo", "."],
        });
        std::fs::write(
            &path,
            format!(
                "{}
",
                serde_json::to_string_pretty(&root)?
            ),
        )?;
        println!("registered sinter MCP server in {}", path.display());
    }
    codex_mcp(&repo, command)?;
    Ok(())
}

/// Project-scoped MCP configuration is commonly shared across machines and
/// checkout locations, so it must not capture the installer's absolute path.
fn mcp_command() -> &'static str {
    "sinter"
}

pub(crate) const CODEX_BEGIN: &str =
    "# BEGIN sinter (managed by `sinter install`; edits inside are overwritten)";
pub(crate) const CODEX_END: &str = "# END sinter";

/// Merge a managed sinter server block into `.codex/config.toml` (marker
/// replacement, same convention as the AGENTS.md block — no TOML parser
/// needed for an append-or-replace of our own block).
fn codex_mcp(repo: &Path, command: &str) -> Result<()> {
    let dir = repo.join(".codex");
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("config.toml");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    #[derive(serde::Serialize)]
    struct Server<'a> {
        command: &'a str,
        args: [&'static str; 3],
        required: bool,
    }
    let server = toml::to_string(&Server {
        command,
        args: ["serve", "--repo", "."],
        required: false,
    })
    .context("serialize the Codex MCP registration")?;
    let block = format!("{CODEX_BEGIN}\n[mcp_servers.sinter]\n{server}{CODEX_END}");
    let merged = match (existing.find(CODEX_BEGIN), existing.find(CODEX_END)) {
        (Some(start), Some(end)) if end > start => {
            let after = existing[end + CODEX_END.len()..].to_string();
            format!("{}{}{}", &existing[..start], block, after)
        }
        _ if existing.trim().is_empty() => format!("{block}\n"),
        _ => format!("{}\n\n{block}\n", existing.trim_end()),
    };
    std::fs::write(&path, merged)?;
    println!("registered sinter MCP server in {}", path.display());
    Ok(())
}

/// Installed-but-stale artifacts: each entry is one warning line naming
/// the artifact and its fix. Only artifacts that exist and differ from
/// this binary's embedded copies count — a user who never installed one
/// is never nagged about it. Missing files and unreadable paths are not
/// findings here; `sinter doctor` owns the full diagnosis.
pub fn stale_artifacts(repo: &Path) -> Vec<String> {
    let mut out = Vec::new();
    if let Some(dir) = default_dir()
        && let Ok(card) = std::fs::read_to_string(dir.join("SKILL.md"))
        && card != SKILL
    {
        out.push("skill card is stale — run `sinter install`".to_string());
    }
    let (hook_file, hook_body) = PLATFORM_HOOK;
    for (claude, fix) in [
        (Some(repo.join(".claude")), "run `sinter install enforce`"),
        (claude_home(), "run `sinter install enforce -g`"),
    ] {
        if let Some(claude) = claude
            && let Ok(script) = std::fs::read_to_string(claude.join("hooks").join(hook_file))
            && script != hook_body
        {
            out.push(format!(
                "enforcement hook {} is stale — {fix}",
                claude.join("hooks").join(hook_file).display()
            ));
        }
    }
    if let Ok(agents) = std::fs::read_to_string(repo.join("AGENTS.md"))
        && agents.contains(AGENTS_BEGIN)
        && !block_current(&agents)
    {
        out.push("AGENTS.md sinter block is stale — run `sinter install agents`".to_string());
    }
    if let Ok(rule) = std::fs::read_to_string(repo.join(".cursor/rules/sinter.mdc"))
        && !block_current(&rule)
    {
        out.push("Cursor rule is stale — run `sinter install cursor`".to_string());
    }
    out
}

/// Claude Code home (`~/.claude`), shared with the skill install.
pub(crate) fn claude_home() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|home| PathBuf::from(home).join(".claude"))
}

/// Install Claude Code enforcement: the sinter-first hook script plus the
/// three settings entries that fire it (once-per-session prompt router,
/// Bash grep nudge, Grep-tool nudge). The script gates on `.sinter/graph.redb`
/// existing, so hooks stay silent in graph-less repos. Merging is
/// idempotent and preserves every other setting and hook.
///
/// `repo` Some = project scope: <repo>/.claude with a relative command,
/// so the settings file is committable and works for every teammate and
/// checkout path. None = global scope: ~/.claude, absolute command.
///
/// `strict` opts the two grep entries into the script's `-strict` modes
/// (first search of a session is denied with a sinter redirect; its retry
/// gets the session's one search nudge). Search, git-archaeology, and
/// prompt nudges are otherwise emitted at most once per session. Switching
/// strictness is idempotent: the same settings slot is replaced either way.
/// Strict uses only permissionDecision "deny" — the hooks never emit
/// "allow".
pub fn enforce(repo: Option<&Path>, strict: bool) -> Result<()> {
    let claude = match repo {
        Some(repo) => repo.canonicalize()?.join(".claude"),
        None => claude_home().ok_or_else(|| anyhow::anyhow!("cannot locate home directory"))?,
    };
    let hooks_dir = claude.join("hooks");
    std::fs::create_dir_all(&hooks_dir)?;
    let (hook_file, hook_body) = PLATFORM_HOOK;
    let script = hooks_dir.join(hook_file);
    std::fs::write(&script, hook_body)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?;
    }
    println!("installed {}", script.display());

    let settings_path = claude.join("settings.json");
    let mut root: Value = match std::fs::read_to_string(&settings_path) {
        Ok(existing) => serde_json::from_str(&existing)
            .with_context(|| format!("{} exists but is not valid JSON", settings_path.display()))?,
        Err(_) => json!({}),
    };
    let script_str = match repo {
        Some(_) => format!(".claude/hooks/{hook_file}"),
        None => script.display().to_string(),
    };
    // Windows entries carry "shell": "powershell" so Claude Code runs the
    // script with PowerShell instead of implicit bash; the `&` call
    // operator tolerates spaces in the (quoted) global-scope path.
    let entry = |mode: &str| {
        if cfg!(windows) {
            json!({"type": "command", "shell": "powershell",
                   "command": format!("& '{script_str}' {mode}")})
        } else {
            json!({"type": "command", "command": format!("bash {script_str} {mode}")})
        }
    };
    let hooks = root
        .as_object_mut()
        .ok_or_else(|| anyhow::anyhow!("{} top level is not an object", settings_path.display()))?
        .entry("hooks")
        .or_insert(json!({}));
    // (event, matcher, base mode): matcher None = event-level hook (no
    // matcher key). Only the two grep modes have strict variants.
    let (grep_mode, greptool_mode) = if strict {
        ("grep-strict", "greptool-strict")
    } else {
        ("grep", "greptool")
    };
    for (event, matcher, mode) in [
        ("PreToolUse", Some("Bash"), grep_mode),
        ("PreToolUse", Some("Grep"), greptool_mode),
        ("UserPromptSubmit", None, "prompt"),
    ] {
        let groups = hooks
            .as_object_mut()
            .ok_or_else(|| anyhow::anyhow!("settings `hooks` is not an object"))?
            .entry(event)
            .or_insert(json!([]));
        let groups = groups
            .as_array_mut()
            .ok_or_else(|| anyhow::anyhow!("settings hooks.{event} is not an array"))?;
        let group = groups
            .iter_mut()
            .find(|g| g.get("matcher").and_then(Value::as_str) == matcher);
        let group = match group {
            Some(g) => g,
            None => {
                groups.push(match matcher {
                    Some(m) => json!({"matcher": m, "hooks": []}),
                    None => json!({"hooks": []}),
                });
                groups.last_mut().expect("just pushed")
            }
        };
        let list = group
            .as_object_mut()
            .and_then(|g| g.get_mut("hooks"))
            .and_then(Value::as_array_mut)
            .ok_or_else(|| anyhow::anyhow!("settings hooks.{event} group has no hooks array"))?;
        // Idempotent: refresh a stale entry in place (either platform's
        // variant — a repo settings.json may have been written on the
        // other OS — and either strictness: strict and non-strict share
        // one slot, so switching modes replaces rather than duplicates),
        // append when absent.
        let base = mode.strip_suffix("-strict").unwrap_or(mode);
        let ours = |c: &str| {
            c.contains("sinter-first.")
                && (c.ends_with(&format!(" {base}")) || c.ends_with(&format!(" {base}-strict")))
        };
        match list
            .iter_mut()
            .find(|h| h.get("command").and_then(Value::as_str).is_some_and(&ours))
        {
            Some(existing) => *existing = entry(mode),
            None => list.push(entry(mode)),
        }
    }
    std::fs::write(
        &settings_path,
        format!("{}\n", serde_json::to_string_pretty(&root)?),
    )?;
    println!(
        "registered enforcement hooks in {}",
        settings_path.display()
    );
    Ok(())
}

/// Dispatch install targets. Unknown names fail loudly with the list.
pub fn run_targets(
    targets: &[String],
    dir: Option<PathBuf>,
    mcp_flag: bool,
    repo: &Path,
    global: bool,
    strict: bool,
) -> Result<()> {
    let expanded: Vec<&str> = if targets.iter().any(|t| t == "all") {
        vec!["claude", "cursor", "agents", "enforce"]
    } else {
        targets.iter().map(String::as_str).collect()
    };
    for target in expanded {
        match target {
            "claude" => run(dir.clone())?,
            "cursor" => {
                let path = cursor(&repo.canonicalize()?)?;
                println!("installed {}", path.display());
            }
            "agents" => {
                let path = agents(&repo.canonicalize()?)?;
                println!("merged managed sinter block into {}", path.display());
            }
            "enforce" => enforce((!global).then_some(repo), strict)?,
            other => {
                bail!("unknown install target `{other}` (claude, cursor, agents, enforce, all)")
            }
        }
    }
    if mcp_flag {
        mcp(repo)?;
    }
    Ok(())
}

pub fn run(dir: Option<PathBuf>) -> Result<()> {
    let target = match dir.or_else(default_dir) {
        Some(dir) => dir,
        None => bail!("cannot locate home directory; pass --dir"),
    };
    std::fs::create_dir_all(&target).with_context(|| format!("create {}", target.display()))?;
    let path = target.join("SKILL.md");
    std::fs::write(&path, SKILL).with_context(|| format!("write {}", path.display()))?;
    println!("installed {}", path.display());
    println!("rerun `sinter install` after upgrading sinter to refresh the card");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The compact AGENTS block and the full skill card are separate
    /// constants; this is the seam that keeps them from drifting: every
    /// verb the compact block routes to must appear in the full card,
    /// and both must state the load-bearing behavior rules.
    #[test]
    fn agents_block_routes_match_card() {
        let card = card_body();
        let durable = [AGENTS_CARD, card];
        for command in [
            "sinter map",
            "sinter ask",
            "sinter query",
            "sinter show",
            "sinter affected",
            "sinter deps",
            "sinter path",
            "sinter unresolved",
            "sinter impact",
            "sinter overlap",
            "sinter workspace",
            "sinter ensure",
            "sinter doctor",
            "sinter scip",
        ] {
            for text in durable {
                assert!(text.contains(command), "durable card lost `{command}`");
            }
        }
        for text in durable {
            assert!(
                text.find("sinter map") < text.find("sinter ask"),
                "orientation must route to map before ask"
            );
            for contract in ["--explain", "--limit 0", "not_proven"] {
                assert!(text.contains(contract), "durable card lost `{contract}`");
            }
        }
        for chunk in AGENTS_CARD.split("`sinter ").skip(1) {
            let verb = chunk.split([' ', '`', '\n']).next().unwrap();
            if verb.starts_with('-') {
                continue; // flag, not a verb
            }
            assert!(
                card.contains(&format!("sinter {verb}")),
                "compact block routes `sinter {verb}` but the full card never mentions it"
            );
        }
        for rule in ["never", "unresolved", "sinter build", "--workspace"] {
            assert!(
                AGENTS_CARD.contains(rule),
                "compact block lost rule: {rule}"
            );
            assert!(card.contains(rule), "full card lost rule: {rule}");
        }
    }

    #[test]
    fn mcp_registration_is_portable_and_non_required() {
        let dir = tempfile::tempdir().unwrap();

        mcp(dir.path()).unwrap();

        let json: Value =
            serde_json::from_str(&std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap())
                .unwrap();
        assert_eq!(json["mcpServers"]["sinter"]["command"], "sinter");

        let codex: toml::Value = toml::from_str(
            &std::fs::read_to_string(dir.path().join(".codex/config.toml")).unwrap(),
        )
        .unwrap();
        assert_eq!(
            codex["mcp_servers"]["sinter"]["command"].as_str(),
            Some("sinter")
        );
        assert_eq!(
            codex["mcp_servers"]["sinter"]["required"].as_bool(),
            Some(false)
        );
    }

    #[test]
    fn enforcement_install_writes_session_deduplicating_platform_hook() {
        let repo = tempfile::tempdir().unwrap();

        enforce(Some(repo.path()), false).unwrap();

        let (hook_name, hook_body) = PLATFORM_HOOK;
        let installed =
            std::fs::read_to_string(repo.path().join(".claude").join("hooks").join(hook_name))
                .unwrap();
        assert_eq!(installed, hook_body);
        assert!(ENFORCE_HOOK.contains("mark_session_once"));
        assert!(ENFORCE_HOOK_PS1.contains("New-SessionMarker"));
        assert!(!ENFORCE_HOOK.contains("permissionDecision\":\"allow"));
        assert!(!ENFORCE_HOOK_PS1.contains("permissionDecision\":\"allow"));
    }
}