//! JS: skill/scripts/hook.mjs (`impeccable hook`) and hook-lib.mjs //! `runHook` / `runStopHook`: the PostToolUse per-edit pass and the Stop deep //! pass. Always exits 0; stdout is one JSON document or nothing. use impeccable_core::findings::Finding; use impeccable_core::js; use serde_json::{Map, Value}; use std::collections::HashMap; use crate::hook_lib::*; use crate::stop_baseline; use crate::util::{ exists, iso_now, jsp, node_read_error, now_ms, str_field, truthy_value, utf16_len, }; /// JS `runHook` / `runStopHook` result: `{ exitCode: 0, stdout, audit }`. pub struct RunResult { pub stdout: String, pub audit: Map, } fn ms_since(started: f64) -> Value { Value::from((now_ms() - started).max(0.0) as u64) } fn with(audit: &Map, extra: Vec<(&str, Value)>) -> Map { let mut out = audit.clone(); for (k, v) in extra { out.insert(k.to_string(), v); } out } fn result(audit: &Map, extra: Vec<(&str, Value)>) -> RunResult { RunResult { stdout: String::new(), audit: with(audit, extra), } } /// Parse stdin the way `runHook` does: `Err` for malformed JSON, `Ok(None)` /// for a parsed non-object. fn parse_event(stdin: &str) -> Result>, ()> { match serde_json::from_str::(stdin) { Ok(Value::Object(o)) => Ok(Some(o)), Ok(_) => Ok(None), Err(_) => Err(()), } } /// `event.session_id || 'unknown'` as the cache key; `None` when absent. fn session_id_of(event: &Map) -> Option { event .get("session_id") .filter(|v| truthy_value(Some(v))) .cloned() } fn session_key(v: &Option) -> String { match v { Some(v) => crate::util::js_string(v), None => "unknown".to_string(), } } /// JS: runHook({ stdinJson, env, cwd }) pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { let mut audit: Map = Map::new(); audit.insert("ts".into(), Value::String(iso_now())); audit.insert("event".into(), Value::String("PostToolUse".into())); if depth_is_set(rt.env("IMPECCABLE_HOOK_DEPTH")) || depth_is_set(rt.env("CLAUDE_HOOK_DEPTH")) { return result( &audit, vec![ ("reentrant", Value::Bool(true)), ("durationMs", Value::from(0)), ], ); } if truthy(rt.env("IMPECCABLE_HOOK_DISABLED")) { return result( &audit, vec![ ("skipped", Value::from("env-disabled")), ("durationMs", Value::from(0)), ], ); } let started = now_ms(); let event = match parse_event(stdin) { Err(()) => { return result( &audit, vec![ ("skipped", Value::from("stdin-malformed")), ("durationMs", ms_since(started)), ], ) } Ok(None) => { return result( &audit, vec![ ("skipped", Value::from("stdin-empty")), ("durationMs", ms_since(started)), ], ) } Ok(Some(e)) => e, }; let harness = resolve_harness(rt, Some(&event)); let event = normalize_hook_event(rt, &event, &rt.proc_cwd, harness); audit.insert("harness".into(), Value::from(harness)); let session_cwd = str_field(&event, "cwd").unwrap_or(&rt.proc_cwd).to_string(); let primary_files = normalize_scan_targets( rt, &resolve_target_files(rt, &event, &session_cwd), &session_cwd, ); let project_cwd = resolve_cache_cwd(rt, primary_files.first().map(String::as_str), &session_cwd); audit.insert("cwd".into(), Value::String(project_cwd.clone())); let target_files = expand_scan_targets(rt, &primary_files, &project_cwd); let session_value = session_id_of(&event); audit.insert( "session".into(), session_value.clone().unwrap_or(Value::Null), ); if let Some(tool) = event.get("tool_name").filter(|v| truthy_value(Some(v))) { audit.insert("tool".into(), tool.clone()); } if target_files.is_empty() { return result( &audit, vec![ ("skipped", Value::from("no-file-path")), ("durationMs", ms_since(started)), ], ); } let config = read_config(&project_cwd); if !config.enabled { return result( &audit, vec![ ("skipped", Value::from("config-disabled")), ("durationMs", ms_since(started)), ], ); } let platform = resolve_project_platform(rt, &project_cwd); if is_native_platform(platform.as_deref()) { return result( &audit, vec![ ("skipped", Value::from("native-platform")), ("platform", Value::String(platform.unwrap_or_default())), ("durationMs", ms_since(started)), ], ); } let mut cache = read_cache(&project_cwd); let session_id = session_key(&session_value); let mut scans = HashMap::new(); let tiered = per_edit_tiering_active(&config, harness); struct Pending { file_path: String, known: Vec, } let mut pending_winner: Option = None; let mut clean_winner: Option = None; let mut fresh_groups: Vec = Vec::new(); let mut suppression_winner: Option = None; let mut clean_ack_deduped = false; let mut skipped_bytes: u64 = 0; let quiet_mode = truthy(rt.env("IMPECCABLE_HOOK_QUIET")) || config.quiet; let mut detector_threw_any = false; let mut last_skip = "no-scannable-file"; let mut live_preview_edit: Option = None; let mut suppressed_hit = false; let mut cache_dirty = false; let mut deferred_total: usize = 0; for file_path in &target_files { audit.insert("file".into(), Value::String(file_path.clone())); if has_path_traversal(file_path) || is_sensitive_path(file_path) { last_skip = "sensitive"; continue; } if is_generated_path(file_path) { last_skip = "generated"; continue; } let ext = js::to_lower_case(&jsp::extname(file_path)); let configured = match_configured_extension(file_path, &config.extensions); audit.insert( "ext".into(), Value::String( configured .map(|c| c.ext.clone()) .unwrap_or_else(|| ext.clone()), ), ); if !ALLOWED_EXTS.contains(&ext.as_str()) && configured.is_none() { last_skip = "extension"; continue; } let rel_for_match = relativize(rt, file_path, &project_cwd); if matches_any_glob_list(&rel_for_match, &config.ignore_files) || matches_any_glob_list(file_path, &config.ignore_files) { last_skip = "config-ignore-file"; continue; } if !exists(file_path) { last_skip = "file-missing"; continue; } if !is_scan_target_inside_project(rt, file_path, &project_cwd) { last_skip = "outside-project"; continue; } let max_file_bytes = config.limits.max_file_bytes; if max_file_bytes > 0.0 { let size = std::fs::metadata(file_path).map(|m| m.len()).unwrap_or(0); if size as f64 > max_file_bytes { skipped_bytes = size; last_skip = "too-large"; continue; } } // A live variant session owns a file carrying preview markers: stand // down before the per-session edit cap can turn the variants wrap // into a suppression notice. if primary_files.contains(file_path) { if let Ok(bytes) = std::fs::read(file_path) { if crate::hook_lib::has_live_preview_markers(&String::from_utf8_lossy(&bytes)) { if live_preview_edit.is_none() { live_preview_edit = Some(file_path.clone()); } last_skip = "live-preview"; continue; } } } let use_html_engine = match configured { Some(c) => c.engine == "html", None => ext == ".html" || ext == ".htm", }; if primary_files.contains(file_path) { if harness == "claude" { stop_baseline::capture(rt, &event, &mut cache, &session_id, file_path, use_html_engine); } let edit_count = bump_edit_count(&mut cache, &session_id, file_path); cache_dirty = true; audit.insert("editCount".into(), Value::from(edit_count as u64)); if edit_count > EDIT_COUNT_THRESHOLD as f64 { stop_baseline::invalidate(&mut cache, &session_id, file_path); let just_crossed = edit_count == (EDIT_COUNT_THRESHOLD + 1) as f64; if just_crossed && suppression_winner.is_none() { suppression_winner = Some(file_path.clone()); } last_skip = "suppressed"; suppressed_hit = true; continue; } } let content = match std::fs::read(file_path) { Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), Err(e) => { // JS: fs.readFileSync throws out of runHook's try; the catch // records the error and emits nothing. return RunResult { stdout: String::new(), audit: with( &audit, vec![("error", Value::String(node_read_error(file_path, &e)))], ), }; } }; if crate::hook_lib::has_live_preview_markers(&content) { // A live variant session owns this file. When it is the edited // (primary) file, the whole event stands down, co-scanned // stylesheets included: a clean ack or a finding about the // companion file is the same mid-session noise the stand-down // exists to prevent. if primary_files.contains(file_path) && live_preview_edit.is_none() { live_preview_edit = Some(file_path.clone()); } last_skip = "live-preview"; continue; } let scan = scans.entry(file_path.clone()).or_insert_with(|| { design_system_options_for_file(rt, &config, &project_cwd, file_path) }); let mut detector_threw = false; let findings: Vec = if use_html_engine { match detector_detect_html(rt, file_path, scan) { Ok(f) => f, Err(_) => { detector_threw = true; vec![] } } } else { detector_detect_text(&content, file_path, scan) }; if !detector_threw && !use_html_engine { stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings); } let raw_count = findings.len(); let filtered = filter_findings(findings, &config); let (immediate, deferred) = if tiered { split_findings_by_tier(filtered) } else { (filtered, vec![]) }; if !deferred.is_empty() { touch_file(&mut cache, &session_id, file_path); cache_dirty = true; deferred_total += deferred.len(); } let fresh = dedupe_against_cache(&immediate, &mut cache, &session_id, file_path); audit.insert("findings".into(), Value::from(raw_count)); audit.insert("freshFindings".into(), Value::from(fresh.len())); if deferred_total > 0 { audit.insert("deferred".into(), Value::from(deferred_total)); } if detector_threw { detector_threw_any = true; continue; } // JS: Grok ignores PostToolUse stdout, so Stop is the user-visible // pass. Remembering here would dedupe those findings out of Stop. // Touch the file so Stop has it, and leave the finding list empty // (#646). if harness == "grok" { touch_file(&mut cache, &session_id, file_path); } else { remember_findings(&mut cache, &session_id, file_path, &immediate); } cache_dirty = true; if !fresh.is_empty() { fresh_groups.push(Group { file_path: file_path.clone(), findings: fresh, }); continue; } if !immediate.is_empty() && pending_winner.is_none() { pending_winner = Some(Pending { file_path: file_path.clone(), known: immediate.iter().map(finding_cache_key).collect(), }); } else if immediate.is_empty() && clean_winner.is_none() { if quiet_mode || !should_emit_ack_for_file(file_path, &config) { clean_winner = Some(file_path.clone()); } else if truthy_value( ensure_file(&mut cache, &session_id, file_path).get("cleanAcked"), ) { clean_ack_deduped = true; } else { ensure_file(&mut cache, &session_id, file_path) .insert("cleanAcked".into(), Value::Bool(true)); clean_winner = Some(file_path.clone()); clean_ack_deduped = false; } } } if let Some(file) = live_preview_edit { audit.insert("file".into(), Value::String(file)); return result( &audit, vec![ ("emitted", Value::Bool(false)), ("skipped", Value::from("live-preview")), ("durationMs", ms_since(started)), ], ); } if !fresh_groups.is_empty() { let scan = &scans[&fresh_groups[0].file_path]; let short = footer_mode_short(&mut cache, &session_id); let reserve = design_note_reserve(rt, scan, &mut cache, &session_id); let rendered = render_grouped_template( rt, &fresh_groups, &config, &RenderOpts { cwd: Some(project_cwd.clone()), short_footer: short, reserve_chars: reserve, }, ); let text = append_design_system_note_once(rt, &rendered, scan, &mut cache, &session_id, &config); commit_footer_shown(rt, &mut cache, &session_id, &text); persist_cache(rt, &project_cwd, &cache); let all: usize = fresh_groups.iter().map(|g| g.findings.len()).sum(); return RunResult { stdout: payload(&text, "PostToolUse", harness), audit: with( &audit, vec![ ("file", Value::String(fresh_groups[0].file_path.clone())), ("emitted", Value::Bool(true)), ("freshFiles", Value::from(fresh_groups.len())), ("freshFindings", Value::from(all)), ("chars", Value::from(utf16_len(&text))), ("durationMs", ms_since(started)), ], ), }; } enum Ack { Pending(String), Clean(String), } let mut ack: Option = None; if !quiet_mode { if let Some(p) = pending_winner .as_ref() .filter(|p| should_emit_ack_for_file(&p.file_path, &config)) { let base = render_pending_ack(rt, &p.file_path, &p.known, &project_cwd); let scan = &scans[&p.file_path]; ack = Some(Ack::Pending(append_design_system_note_once( rt, &base, scan, &mut cache, &session_id, &config, ))); } else if suppression_winner.is_none() && !clean_ack_deduped { if let Some(c) = clean_winner .as_ref() .filter(|c| should_emit_ack_for_file(c, &config)) { let base = render_clean_ack(rt, c, &project_cwd); let scan = &scans[c]; ack = Some(Ack::Clean(append_design_system_note_once( rt, &base, scan, &mut cache, &session_id, &config, ))); } } } // JS: an already-present `.impeccable/` dir marks a project that opted // in (issues #344, #305). An existing cache file also counts as opted // in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives outside the // project, so the project dir alone can't carry the marker — without // this, clean-edit editCount bumps would stop persisting the moment // state relocates. Under stock paths the cache sits inside // `.impeccable/`, so the extra check changes nothing there. if deferred_total > 0 || (cache_dirty && (exists(&jsp::join(&[&project_cwd, ".impeccable"])) || exists(&get_cache_path(&project_cwd)))) { persist_cache(rt, &project_cwd, &cache); } if detector_threw_any && pending_winner.is_none() && clean_winner.is_none() { return result( &audit, vec![ ("emitted", Value::Bool(false)), ("error", Value::from("detector-threw")), ("durationMs", ms_since(started)), ], ); } if quiet_mode { return result( &audit, vec![ ("emitted", Value::Bool(false)), ("quiet", Value::Bool(true)), ("durationMs", ms_since(started)), ], ); } if let Some(Ack::Pending(text)) = &ack { let p = pending_winner.as_ref().unwrap(); return RunResult { stdout: payload(text, "PostToolUse", harness), audit: with( &audit, vec![ ("file", Value::String(p.file_path.clone())), ("emitted", Value::Bool(true)), ("kind", Value::from("pending")), ("pending", Value::from(p.known.len())), ("chars", Value::from(utf16_len(text))), ("durationMs", ms_since(started)), ], ), }; } if let Some(sw) = &suppression_winner { let text = suppression_notice(rt, &relativize(rt, sw, &project_cwd)); return RunResult { stdout: payload(&text, "PostToolUse", harness), audit: with( &audit, vec![ ("file", Value::String(sw.clone())), ("suppressed", Value::Bool(true)), ("emitted", Value::Bool(true)), ("durationMs", ms_since(started)), ], ), }; } if let Some(Ack::Clean(text)) = &ack { let c = clean_winner.as_ref().unwrap(); return RunResult { stdout: payload(text, "PostToolUse", harness), audit: with( &audit, vec![ ("file", Value::String(c.clone())), ("emitted", Value::Bool(true)), ("kind", Value::from("clean")), ("chars", Value::from(utf16_len(text))), ("durationMs", ms_since(started)), ], ), }; } if pending_winner.is_some() || clean_winner.is_some() { return result( &audit, vec![ ("emitted", Value::Bool(false)), ("skipped", Value::from("non-ui-ack")), ("durationMs", ms_since(started)), ], ); } if clean_ack_deduped { return result( &audit, vec![ ("emitted", Value::Bool(false)), ("skipped", Value::from("clean-ack-deduped")), ("durationMs", ms_since(started)), ], ); } if suppressed_hit { return result( &audit, vec![ ("suppressed", Value::Bool(true)), ("emitted", Value::Bool(false)), ("durationMs", ms_since(started)), ], ); } let mut extra = vec![("skipped", Value::from(last_skip))]; if last_skip == "too-large" { extra.push(("bytes", Value::from(skipped_bytes))); } extra.push(("durationMs", ms_since(started))); result(&audit, extra) } /// JS: runStopHook({ stdinJson, env, cwd }) pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult { let mut audit: Map = Map::new(); audit.insert("ts".into(), Value::String(iso_now())); audit.insert("event".into(), Value::String("Stop".into())); if depth_is_set(rt.env("IMPECCABLE_HOOK_DEPTH")) || depth_is_set(rt.env("CLAUDE_HOOK_DEPTH")) { return result( &audit, vec![ ("reentrant", Value::Bool(true)), ("durationMs", Value::from(0)), ], ); } if truthy(rt.env("IMPECCABLE_HOOK_DISABLED")) { return result( &audit, vec![ ("skipped", Value::from("env-disabled")), ("durationMs", Value::from(0)), ], ); } let started = now_ms(); let event = match parse_event(stdin) { Err(()) => { return result( &audit, vec![ ("skipped", Value::from("stdin-malformed")), ("durationMs", ms_since(started)), ], ) } Ok(None) => { return result( &audit, vec![ ("skipped", Value::from("stdin-empty")), ("durationMs", ms_since(started)), ], ) } Ok(Some(e)) => e, }; let harness = resolve_harness(rt, Some(&event)); audit.insert("harness".into(), Value::from(harness)); let event = normalize_hook_event(rt, &event, &rt.proc_cwd, harness); // Stop-hook re-entry guard (#400): Claude Code and Codex send // `stop_hook_active`; Grok sends `stopHookActive`, copied onto the // snake_case field by the normalizer. Cursor and GitHub Copilot omit // the field, so the strict `=== true` is a no-op for them. if event.get("stop_hook_active") == Some(&Value::Bool(true)) { return result( &audit, vec![ ("skipped", Value::from("stop-hook-active")), ("durationMs", ms_since(started)), ], ); } // JS: Grok fires Stop twice: `end_turn` (the gate that can inject // additionalContext) then an observe-only `shutdown`. A second deep // pass would re-emit the same findings. Claude omits `reason`; only // skip when Grok named a reason that is not end_turn (#646). if harness == "grok" { if let Some(Value::String(reason)) = event.get("reason") { if reason != "end_turn" { return result( &audit, vec![ ("skipped", Value::from("stop-reason")), ("reason", Value::String(reason.clone())), ("durationMs", ms_since(started)), ], ); } } } let project_cwd = rt.resolve(&[str_field(&event, "cwd").unwrap_or(&rt.proc_cwd)]); audit.insert("cwd".into(), Value::String(project_cwd.clone())); let session_value = session_id_of(&event); let session_id = session_key(&session_value); audit.insert( "session".into(), session_value.unwrap_or_else(|| Value::from("unknown")), ); let config = read_config(&project_cwd); if !config.enabled { return result( &audit, vec![ ("skipped", Value::from("config-disabled")), ("durationMs", ms_since(started)), ], ); } let mut cache = read_cache(&project_cwd); let touched = touched_files(&cache, &session_id); if touched.is_empty() { return result( &audit, vec![ ("skipped", Value::from("no-touched-files")), ("durationMs", ms_since(started)), ], ); } let platform = resolve_project_platform(rt, &project_cwd); if is_native_platform(platform.as_deref()) { return result( &audit, vec![ ("skipped", Value::from("native-platform")), ("platform", Value::String(platform.unwrap_or_default())), ("durationMs", ms_since(started)), ], ); } let mut scans = HashMap::new(); let mut fresh_groups: Vec = Vec::new(); let mut scanned = 0usize; let mut cache_dirty = false; let mut pre_existing = 0usize; let mut new_findings = 0usize; let mut unknown = 0usize; for file_path in &touched { if scanned >= STOP_MAX_FILES { break; } if has_path_traversal(file_path) || is_sensitive_path(file_path) || is_generated_path(file_path) { continue; } let ext = js::to_lower_case(&jsp::extname(file_path)); let configured = match_configured_extension(file_path, &config.extensions); if !ALLOWED_EXTS.contains(&ext.as_str()) && configured.is_none() { continue; } let rel = relativize(rt, file_path, &project_cwd); if matches_any_glob_list(&rel, &config.ignore_files) || matches_any_glob_list(file_path, &config.ignore_files) { continue; } if !exists(file_path) || !is_scan_target_inside_project(rt, file_path, &project_cwd) { continue; } scanned += 1; let content = match std::fs::read(file_path) { Ok(b) => String::from_utf8_lossy(&b).into_owned(), Err(_) => continue, }; if crate::hook_lib::has_live_preview_markers(&content) { continue; } let use_html_engine = match configured { Some(c) => c.engine == "html", None => ext == ".html" || ext == ".htm", }; let scan = scans.entry(file_path.clone()).or_insert_with(|| { design_system_options_for_file(rt, &config, &project_cwd, file_path) }); // JS: a detector failure tells us nothing about the file. Leave // whatever was remembered alone rather than recording an empty scan // as truth. (detectText cannot throw here: the Rust engine returns // findings directly.) let findings = if use_html_engine { match detector_detect_html(rt, file_path, scan) { Ok(f) => f, Err(_) => continue, } } else { detector_detect_text(&content, file_path, scan) }; if !use_html_engine { stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings); } let filtered = filter_findings(findings, &config); let classified = stop_baseline::classify(&cache, &session_id, file_path, use_html_engine, filtered.clone()); pre_existing += classified.pre_existing; new_findings += classified.new; unknown += classified.unknown; let fresh = dedupe_against_cache(&classified.findings, &mut cache, &session_id, file_path); // JS: sync to the live scan, including empty. Remembering only // `fresh` (or skipping the write on a clean Stop) left stale keys in // place, so a finding that was fixed and later reintroduced never // fired again. remember_findings(&mut cache, &session_id, file_path, &filtered); cache_dirty = true; if !fresh.is_empty() { fresh_groups.push(Group { file_path: file_path.clone(), findings: fresh, }); } } audit.insert("scannedFiles".into(), Value::from(scanned)); audit.insert("preExistingFindings".into(), Value::from(pre_existing)); audit.insert("newFindings".into(), Value::from(new_findings)); audit.insert("unknownFindings".into(), Value::from(unknown)); if fresh_groups.is_empty() { if cache_dirty { persist_cache(rt, &project_cwd, &cache); } return result( &audit, vec![ ("emitted", Value::Bool(false)), ("skipped", Value::from("stop-clean")), ("durationMs", ms_since(started)), ], ); } let scan = &scans[&fresh_groups[0].file_path]; let short = footer_mode_short(&mut cache, &session_id); let first_unknown = fresh_groups.iter().flat_map(|group| &group.findings) .position(|f| f.name.starts_with("[attribution unknown]")); let mut attribution_note = if first_unknown.is_some() { format!("{ENVELOPE_PREFIX} {}", stop_baseline::UNKNOWN_NOTE) } else { String::new() }; // Findings and attribution take priority. Append the lower-priority stale // DESIGN.md notice only if it fits, without consuming its session flag. let render = |note: &str, render_config: &HookConfig| render_grouped_template( rt, &fresh_groups, render_config, &RenderOpts { cwd: Some(project_cwd.clone()), short_footer: short, reserve_chars: if note.is_empty() { 0.0 } else { (utf16_len(note) + 2) as f64 }, }, ); let mut rendered = render(&attribution_note, &config); if !attribution_note.is_empty() && !rendered.lines().any(|line| { line.starts_with("- ") && (line.contains("[attribution unknown]") || line.contains("[new]")) }) { // At the minimum budget, a grouped header and policy footer may crowd // out even the first finding. Shorten the notice before losing it. attribution_note = format!("{ENVELOPE_PREFIX} {}", stop_baseline::COMPACT_UNKNOWN_NOTE); rendered = render(&attribution_note, &config); } // maxFindings / maxChars may also remove all unknown findings. Do not // attach their guidance to an output that only shows confirmed new debt. let shows_unknown = rendered.lines().any(|line| { line.starts_with("- ") && line.contains("[attribution unknown]") }); if !shows_unknown { if let Some(prefix @ 1..) = first_unknown { // Reclaim the unused notice budget for the known-new prefix. // Keep the unknown suffix omitted: simply expanding the budget // could reveal an unknown finding without its required guidance. let mut visible_config = config.clone(); visible_config.limits.max_findings = cap_of(&config).min(prefix) as f64; rendered = render("", &visible_config); } } let text = if shows_unknown { format!("{attribution_note}\n\n{rendered}") } else { rendered }; let text = append_design_system_note_once(rt, &text, scan, &mut cache, &session_id, &config); commit_footer_shown(rt, &mut cache, &session_id, &text); persist_cache(rt, &project_cwd, &cache); let all: usize = fresh_groups.iter().map(|g| g.findings.len()).sum(); RunResult { stdout: payload(&text, "Stop", harness), audit: with( &audit, vec![ ("emitted", Value::Bool(true)), ("freshFiles", Value::from(fresh_groups.len())), ("freshFindings", Value::from(all)), ("chars", Value::from(utf16_len(&text))), ("durationMs", ms_since(started)), ], ), } } /// JS: hook.mjs#isStopEvent(stdinJson) fn is_stop_event(stdin: &str) -> bool { // JS: hook.mjs#stdinIsStop routes on the raw stdin via hook-lib's // isStopEvent, which matches Claude's `hook_event_name: "Stop"` and // Grok Build's `hookEventName: "stop"`. match serde_json::from_str::(stdin) { Ok(Value::Object(o)) => crate::hook_lib::is_stop_event(&o), _ => false, } } /// `impeccable hook` (hook.mjs main). Returns the exit code (always 0). pub fn run(rt: &Runtime, stdin: &str, io: &mut impeccable_common::Io) -> i32 { // JS: process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1' // is exported for child processes; this binary spawns none, so the // pre-mutation snapshot in `rt.env` is the only value that matters. let result = if is_stop_event(stdin) { run_stop_hook(rt, stdin) } else { run_hook(rt, stdin) }; write_audit_log(rt, &result.audit, &rt.proc_cwd); if !result.stdout.is_empty() { io.out(&result.stdout); } 0 }