From 9c8ee04b591ce4403fd7563732b06f9b68a5965a Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 19:28:47 +0200 Subject: [PATCH 1/5] fix(install-agent): keep the matcher already on our hook entry A re-run replaced the whole entry, so a matcher a repository had narrowed (e.g. PreToolUse to "Grep|Glob|Agent", keeping the hook off Bash) was reset to the default on every upgrade. The entry is still replaced, but its existing matcher is carried over; only a fresh entry gets the default. --- src/install_agent.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/install_agent.rs b/src/install_agent.rs index fcb1e1be..ec1a767e 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -146,10 +146,15 @@ pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec Date: Sat, 17 Oct 2026 19:30:16 +0200 Subject: [PATCH 2/5] chore(release): bump to 0.6.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/commands.md | 2 +- docs/releases/0.6.4.md | 11 +++++++++++ npm/zerograph/package.json | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 docs/releases/0.6.4.md diff --git a/Cargo.lock b/Cargo.lock index 7f45d2bc..c5d1375f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3227,7 +3227,7 @@ dependencies = [ [[package]] name = "zerograph" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "ast-grep-language", diff --git a/Cargo.toml b/Cargo.toml index 7b9f9330..34a051e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerograph" -version = "0.6.3" +version = "0.6.4" edition = "2021" rust-version = "1.98" description = "A project knowledge graph built and queried at zero API tokens" diff --git a/docs/commands.md b/docs/commands.md index eaa89d9c..a3bb05af 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ ## Status -0.6.3 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, +0.6.4 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, C#, Rust, Python, Dart, Swift, GraphQL, SQL, Bicep, HCL, Shell, Vue, Lua, Go, C, C++, PHP, Ruby, Scala, Elixir and OCaml; every command below is implemented rather than planned: diff --git a/docs/releases/0.6.4.md b/docs/releases/0.6.4.md new file mode 100644 index 00000000..cbc2761b --- /dev/null +++ b/docs/releases/0.6.4.md @@ -0,0 +1,11 @@ +# zerograph 0.6.4 + +The release notes for the GitHub release page. The why behind each change is in the docs each line +links to. + +## Fixed + +- **`install-agent --claude` keeps a matcher you narrowed.** A re-run replaced zerograph's hook + entries whole, so a `matcher` a repository had changed on purpose (PreToolUse without `Bash`, say) + went back to the default on every upgrade. The entry is still updated, its matcher is kept; only a + new entry gets the default. diff --git a/npm/zerograph/package.json b/npm/zerograph/package.json index 515d3599..b8a17c34 100644 --- a/npm/zerograph/package.json +++ b/npm/zerograph/package.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.6.3", + "version": "0.6.4", "description": "Knowledge graph of a repository's requirement docs and code in 24 languages, built and refreshed at zero model tokens, asked by id, keyword or paraphrase", "license": "MIT", "repository": { From 5998e89ca80795fa0a503fcd80f183a7225b3db8 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 21:07:53 +0200 Subject: [PATCH 3/5] fix(install-agent): move a matcher an earlier release wrote to the new default Keeping every matcher on our entry also froze the ones we wrote, so a default widened for a changed hook would never reach an existing install. A matcher found in PAST_MATCHERS, or equal to the default, is ours and takes the current default; any other is kept and named in a note beside the default it hides. --- docs/releases/0.6.4.md | 5 +++-- src/install_agent.rs | 48 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/releases/0.6.4.md b/docs/releases/0.6.4.md index cbc2761b..2dca1d25 100644 --- a/docs/releases/0.6.4.md +++ b/docs/releases/0.6.4.md @@ -7,5 +7,6 @@ links to. - **`install-agent --claude` keeps a matcher you narrowed.** A re-run replaced zerograph's hook entries whole, so a `matcher` a repository had changed on purpose (PreToolUse without `Bash`, say) - went back to the default on every upgrade. The entry is still updated, its matcher is kept; only a - new entry gets the default. + went back to the default on every upgrade. The entry is still updated and its matcher kept, with a + note naming the default it hides. A matcher an earlier release wrote still moves to the current + default, so a hook that needs a wider matcher reaches every install that never changed it. diff --git a/src/install_agent.rs b/src/install_agent.rs index ec1a767e..c1396fdc 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -135,7 +135,9 @@ fn hook_entries(hook_path: &str) -> Vec<(&'static str, &'static str, serde_json: /// dropped from. That script is the previous surface and this one replaces it, but it is a file /// the repository owns and a person put in that list: removing it without a word would be the one /// silent edit in an installer that otherwise touches nothing it did not write. -pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec)> { +/// +/// Also returns a note for each matcher kept because the repository changed it, naming our default. +pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec, Vec)> { let mut root: serde_json::Value = match existing.trim().is_empty() { true => serde_json::json!({}), false => serde_json::from_str(existing).context("the settings file is not JSON")?, @@ -144,23 +146,39 @@ pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec = PAST_MATCHERS.iter().filter(|(e, _)| *e == event).map(|(_, m)| *m).collect(); + let chosen = kept_matcher(existing.as_deref(), matcher, &past); + if chosen != matcher { custom.push(format!("{event}: kept your matcher \"{chosen}\"; the default is \"{matcher}\"")); } + ours.as_object_mut().unwrap().insert("matcher".into(), serde_json::json!(chosen)); // Ours by the script it runs, never by position: a repository's own entries keep theirs. // `repograph-hook` is this hook under the name it had before the rename. arr.retain(|e| !["zerograph-hook", "repograph-hook", "repograph-notice"].iter().any(|s| e.to_string().contains(s))); arr.push(ours); } - Ok((serde_json::to_string_pretty(&root)? + "\n", retired)) + Ok((serde_json::to_string_pretty(&root)? + "\n", retired, custom)) +} + +/// Every default matcher an earlier release wrote, by event. When a default in `hook_entries` +/// changes, its old value goes here, so installs still carrying it are upgraded to the new one. +const PAST_MATCHERS: &[(&str, &str)] = &[]; + +/// A matcher a release wrote is ours to move to the current default; any other is the repository's +/// choice (PreToolUse narrowed to keep the hook off Bash, say), and a re-run that reset it would +/// undo that choice on every upgrade. +fn kept_matcher(existing: Option<&str>, default: &str, past: &[&str]) -> String { + match existing { + Some(m) if m != default && !past.contains(&m) => m.to_string(), + _ => default.to_string(), + } } fn is_symlink(path: &Path) -> bool { @@ -256,7 +274,8 @@ pub fn install(root: &Path, target: Target, command: &str) -> Result { if target.hooks_are_repository_scoped() { let settings = base.join("settings.json"); let existing = std::fs::read_to_string(&settings).unwrap_or_default(); - let (merged, retired) = merged_settings(&existing, &hook_path)?; + let (merged, retired, custom) = merged_settings(&existing, &hook_path)?; + report.notes.extend(custom); if !retired.is_empty() { report.notes.push(format!( "removed the retired `repograph-notice` hook from {} — this hook replaces it; \ @@ -1143,16 +1162,27 @@ mod tests { std::fs::write(dir.path().join(".claude/settings.json"), r#"{"hooks":{"PreToolUse":[ {"matcher":"Grep|Glob|Agent","hooks":[{"type":"command","command":"node /old/path/zerograph-hook.mjs"}]} ]}}"#).unwrap(); - install(dir.path(), Target::Claude, "zerograph").unwrap(); + let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); let v: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); let arr = v["hooks"]["PreToolUse"].as_array().unwrap(); assert_eq!(arr.len(), 1, "{arr:?}"); assert_eq!(arr[0]["matcher"], "Grep|Glob|Agent"); + assert!(r.notes.iter().any(|n| n.contains("PreToolUse") && n.contains("Bash|Grep|Glob|Agent")), + "a kept matcher names the default it hides: {:?}", r.notes); assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR"), "the rest of the entry is still ours to update"); assert_eq!(v["hooks"]["SessionStart"][0]["matcher"], "startup|resume|clear|compact", "a fresh entry gets the default"); } + /// A default changes only when the hook needs it, so an install still on an old one moves with + /// it; a matcher no release wrote is the repository's and stays. + #[test] + fn a_matcher_an_earlier_release_wrote_moves_to_the_new_default() { + assert_eq!(kept_matcher(Some("Bash|Grep"), "Bash|Grep|Read", &["Bash|Grep"]), "Bash|Grep|Read"); + assert_eq!(kept_matcher(Some("Grep"), "Bash|Grep|Read", &["Bash|Grep"]), "Grep"); + assert_eq!(kept_matcher(None, "Bash|Grep|Read", &[]), "Bash|Grep|Read"); + } + #[test] fn the_stanza_lands_in_a_root_instructions_file_when_there_is_one_and_replaces_only_itself() { let dir = root(); From 075fc9d9802698cf56ddf3a7bc4c8f6eac0c9f59 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 21:12:28 +0200 Subject: [PATCH 4/5] fix(install-agent): stamp the hook with what it wrote instead of a past list The hook script's second line names the release and the matchers it installed. A re-run reads it before rewriting: a matcher it wrote moves to the current default, any other is kept. Drops PAST_MATCHERS and the note. --- docs/releases/0.6.4.md | 6 ++-- src/install_agent.rs | 62 +++++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/docs/releases/0.6.4.md b/docs/releases/0.6.4.md index 2dca1d25..85643cda 100644 --- a/docs/releases/0.6.4.md +++ b/docs/releases/0.6.4.md @@ -7,6 +7,6 @@ links to. - **`install-agent --claude` keeps a matcher you narrowed.** A re-run replaced zerograph's hook entries whole, so a `matcher` a repository had changed on purpose (PreToolUse without `Bash`, say) - went back to the default on every upgrade. The entry is still updated and its matcher kept, with a - note naming the default it hides. A matcher an earlier release wrote still moves to the current - default, so a hook that needs a wider matcher reaches every install that never changed it. + went back to the default on every upgrade. The hook script now records the version and the + matchers it was installed with; a re-run moves a matcher it wrote to the current default and keeps + any other. diff --git a/src/install_agent.rs b/src/install_agent.rs index c1396fdc..87c1b3f2 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -56,7 +56,16 @@ fn with_command(template: &str, command: &str) -> String { template.replace("{{c const HOOK_COMMAND: &str = "const COMMAND = \"zerograph\";"; fn hook(command: &str) -> String { - HOOK.replacen(HOOK_COMMAND, &format!("const COMMAND = {};", serde_json::Value::from(command)), 1) + let script = HOOK.replacen(HOOK_COMMAND, &format!("const COMMAND = {};", serde_json::Value::from(command)), 1); + let (shebang, rest) = script.split_once('\n').unwrap(); + format!("{shebang}\n{}{rest}", stamp()) +} + +/// The line under the shebang records the matchers this release wrote, so a re-run can tell one it +/// wrote (moved to the new default) from one a person changed (kept). +fn stamp() -> String { + let wrote: Vec = hook_entries("").iter().map(|(e, m, _)| format!("{e}={m}")).collect(); + format!("// zerograph {} wrote: {}\n", env!("CARGO_PKG_VERSION"), wrote.join(" ")) } const BEGIN: &str = ""; @@ -136,8 +145,8 @@ fn hook_entries(hook_path: &str) -> Vec<(&'static str, &'static str, serde_json: /// the repository owns and a person put in that list: removing it without a word would be the one /// silent edit in an installer that otherwise touches nothing it did not write. /// -/// Also returns a note for each matcher kept because the repository changed it, naming our default. -pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec, Vec)> { +/// `prior_hook` is the hook script on disk before this run; its stamp says which matchers we wrote. +pub fn merged_settings(existing: &str, hook_path: &str, prior_hook: &str) -> Result<(String, Vec)> { let mut root: serde_json::Value = match existing.trim().is_empty() { true => serde_json::json!({}), false => serde_json::from_str(existing).context("the settings file is not JSON")?, @@ -146,7 +155,6 @@ pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec Result<(String, Vec = PAST_MATCHERS.iter().filter(|(e, _)| *e == event).map(|(_, m)| *m).collect(); - let chosen = kept_matcher(existing.as_deref(), matcher, &past); - if chosen != matcher { custom.push(format!("{event}: kept your matcher \"{chosen}\"; the default is \"{matcher}\"")); } + // A hook from before the stamp was written by a release whose defaults were today's. + let wrote = |m: &str| m == matcher || prior_hook.lines().nth(1).is_some_and(|l| + l.starts_with("// zerograph ") && l.split(' ').any(|t| t == format!("{event}={m}"))); + let chosen = existing.filter(|m| !wrote(m)).unwrap_or_else(|| matcher.to_string()); ours.as_object_mut().unwrap().insert("matcher".into(), serde_json::json!(chosen)); // Ours by the script it runs, never by position: a repository's own entries keep theirs. // `repograph-hook` is this hook under the name it had before the rename. arr.retain(|e| !["zerograph-hook", "repograph-hook", "repograph-notice"].iter().any(|s| e.to_string().contains(s))); arr.push(ours); } - Ok((serde_json::to_string_pretty(&root)? + "\n", retired, custom)) -} - -/// Every default matcher an earlier release wrote, by event. When a default in `hook_entries` -/// changes, its old value goes here, so installs still carrying it are upgraded to the new one. -const PAST_MATCHERS: &[(&str, &str)] = &[]; - -/// A matcher a release wrote is ours to move to the current default; any other is the repository's -/// choice (PreToolUse narrowed to keep the hook off Bash, say), and a re-run that reset it would -/// undo that choice on every upgrade. -fn kept_matcher(existing: Option<&str>, default: &str, past: &[&str]) -> String { - match existing { - Some(m) if m != default && !past.contains(&m) => m.to_string(), - _ => default.to_string(), - } + Ok((serde_json::to_string_pretty(&root)? + "\n", retired)) } fn is_symlink(path: &Path) -> bool { @@ -257,6 +252,7 @@ pub fn install(root: &Path, target: Target, command: &str) -> Result { let mut report = Report::default(); let base: PathBuf = root.join(target.dir()); let hook_file = base.join("hooks").join("zerograph-hook.mjs"); + let prior_hook = std::fs::read_to_string(&hook_file).unwrap_or_default(); write_if_changed(&hook_file, &hook(command), &mut report)?; write_if_changed(&base.join("hooks").join("rule.txt"), &with_command(RULE, command), &mut report)?; write_if_changed(&base.join("skills").join("repo-query").join("SKILL.md"), &with_command(SKILL, command), &mut report)?; @@ -274,8 +270,7 @@ pub fn install(root: &Path, target: Target, command: &str) -> Result { if target.hooks_are_repository_scoped() { let settings = base.join("settings.json"); let existing = std::fs::read_to_string(&settings).unwrap_or_default(); - let (merged, retired, custom) = merged_settings(&existing, &hook_path)?; - report.notes.extend(custom); + let (merged, retired) = merged_settings(&existing, &hook_path, &prior_hook)?; if !retired.is_empty() { report.notes.push(format!( "removed the retired `repograph-notice` hook from {} — this hook replaces it; \ @@ -1162,25 +1157,24 @@ mod tests { std::fs::write(dir.path().join(".claude/settings.json"), r#"{"hooks":{"PreToolUse":[ {"matcher":"Grep|Glob|Agent","hooks":[{"type":"command","command":"node /old/path/zerograph-hook.mjs"}]} ]}}"#).unwrap(); - let r = install(dir.path(), Target::Claude, "zerograph").unwrap(); + install(dir.path(), Target::Claude, "zerograph").unwrap(); let v: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); let arr = v["hooks"]["PreToolUse"].as_array().unwrap(); assert_eq!(arr.len(), 1, "{arr:?}"); assert_eq!(arr[0]["matcher"], "Grep|Glob|Agent"); - assert!(r.notes.iter().any(|n| n.contains("PreToolUse") && n.contains("Bash|Grep|Glob|Agent")), - "a kept matcher names the default it hides: {:?}", r.notes); assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR"), "the rest of the entry is still ours to update"); assert_eq!(v["hooks"]["SessionStart"][0]["matcher"], "startup|resume|clear|compact", "a fresh entry gets the default"); } - /// A default changes only when the hook needs it, so an install still on an old one moves with - /// it; a matcher no release wrote is the repository's and stays. + /// A matcher the stamp says we wrote is ours to move when the default changes; any other stays. #[test] - fn a_matcher_an_earlier_release_wrote_moves_to_the_new_default() { - assert_eq!(kept_matcher(Some("Bash|Grep"), "Bash|Grep|Read", &["Bash|Grep"]), "Bash|Grep|Read"); - assert_eq!(kept_matcher(Some("Grep"), "Bash|Grep|Read", &["Bash|Grep"]), "Grep"); - assert_eq!(kept_matcher(None, "Bash|Grep|Read", &[]), "Bash|Grep|Read"); + fn a_matcher_the_stamp_records_moves_to_the_new_default() { + let settings = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash|Grep","hooks":[{"type":"command","command":"node zerograph-hook.mjs"}]}]}}"#; + let ours = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash|Grep\n").unwrap().0; + assert!(ours.contains("\"Bash|Grep|Glob|Agent\""), "{ours}"); + let theirs = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash\n").unwrap().0; + assert!(theirs.contains("\"Bash|Grep\""), "{theirs}"); } #[test] @@ -1263,7 +1257,7 @@ mod tests { #[test] fn the_hook_takes_the_command_as_one_json_string_and_the_default_as_it_ships() { assert_eq!(HOOK.matches(HOOK_COMMAND).count(), 1, "the declaration the installer replaces is in the hook once"); - assert_eq!(hook("zerograph"), HOOK, "the default installs the template byte for byte"); + assert_eq!(hook("zerograph").replacen(&stamp(), "", 1), HOOK, "the default installs the template byte for byte, under the stamp"); let script = hook(r#"pnpm exec "re'po\graph" `x` ${y}"#); assert!(script.contains(r#"const COMMAND = "pnpm exec \"re'po\\graph\" `x` ${y}";"#), "the command is one string literal"); assert_eq!(script.matches("const COMMAND = ").count(), 1); From 84dedd8cbd5aecdb3aa5ebad8950641a6c27e89e Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Sat, 17 Oct 2026 21:23:42 +0200 Subject: [PATCH 5/5] refactor(install-agent): read the stamp once and pick the matcher in one chain --- src/install_agent.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/install_agent.rs b/src/install_agent.rs index 87c1b3f2..04abbedd 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -155,19 +155,17 @@ pub fn merged_settings(existing: &str, hook_path: &str, prior_hook: &str) -> Res let hooks = root.as_object_mut().unwrap().entry("hooks").or_insert_with(|| serde_json::json!({})); if !hooks.is_object() { anyhow::bail!("`hooks` in the settings file is not an object"); } let mut retired = Vec::new(); - for (event, matcher, entry) in hook_entries(hook_path) { - let mut ours = entry; + // A hook from before the stamp has none, and its release wrote today's defaults. + let stamp = prior_hook.lines().nth(1).unwrap_or_default(); + for (event, matcher, mut ours) in hook_entries(hook_path) { let list = hooks.as_object_mut().unwrap().entry(event).or_insert_with(|| serde_json::json!([])); let Some(arr) = list.as_array_mut() else { anyhow::bail!("`hooks.{event}` is not an array") }; if arr.iter().any(|e| e.to_string().contains("repograph-notice")) { retired.push(event.to_string()); } - let existing = arr.iter() + let theirs = arr.iter() .filter(|e| ["zerograph-hook", "repograph-hook"].iter().any(|s| e.to_string().contains(s))) - .find_map(|e| e.get("matcher").and_then(|m| m.as_str()).map(str::to_string)); - // A hook from before the stamp was written by a release whose defaults were today's. - let wrote = |m: &str| m == matcher || prior_hook.lines().nth(1).is_some_and(|l| - l.starts_with("// zerograph ") && l.split(' ').any(|t| t == format!("{event}={m}"))); - let chosen = existing.filter(|m| !wrote(m)).unwrap_or_else(|| matcher.to_string()); - ours.as_object_mut().unwrap().insert("matcher".into(), serde_json::json!(chosen)); + .find_map(|e| e["matcher"].as_str()) + .filter(|m| *m != matcher && !stamp.split(' ').any(|t| t == format!("{event}={m}"))); + ours["matcher"] = serde_json::json!(theirs.unwrap_or(matcher)); // Ours by the script it runs, never by position: a repository's own entries keep theirs. // `repograph-hook` is this hook under the name it had before the rename. arr.retain(|e| !["zerograph-hook", "repograph-hook", "repograph-notice"].iter().any(|s| e.to_string().contains(s)));