use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
const SESSION_START_SH: &str = r#"#!/bin/bash
# kazam workspace — session start hook
# Surfaces anatomy drift and ready tasks at the start of each agent session.
# Silent when nothing is actionable (no drift, no ready tasks).
if ! command -v kazam &>/dev/null; then
echo '{"ok":false,"error":"kazam not installed — run: cargo install --git https://github.com/tdiderich/kazam"}'
exit 0
fi
DRIFT=$(kazam ctx scan --check --json 2>/dev/null)
READY=$(kazam track ready --json 2>/dev/null)
HAS_DRIFT=$(echo "$DRIFT" | grep -c '"new_files":\[\|"deleted_files":\[\|"changed_files":\[' 2>/dev/null || true)
HAS_READY=$(echo "$READY" | grep -c '"data":\[{' 2>/dev/null || true)
if [ "$HAS_DRIFT" != "0" ] || [ "$HAS_READY" != "0" ]; then
[ "$HAS_DRIFT" != "0" ] && echo "$DRIFT"
[ "$HAS_READY" != "0" ] && echo "$READY"
fi
"#;
const POST_WRITE_SH: &str = r#"#!/bin/bash
# kazam workspace — post-write hook
# Logs file modifications to the activity feed.
FILE="$(echo "$KAZAM_TOOL_INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4)"
if [ -n "$FILE" ]; then
kazam track log add "Modified $FILE" --source "${KAZAM_AGENT:-agent}" --severity info 2>/dev/null
fi
"#;
const STOP_SH: &str = r#"#!/bin/bash
# kazam workspace — session stop hook
# Rescans anatomy, then summarizes session activity and suggests enrichment.
kazam ctx scan 2>/dev/null
DIFF=$(kazam ctx scan --check --json 2>/dev/null)
NEW=$(echo "$DIFF" | grep -o '"new_files":\[[^]]*\]' | grep -o '"[^"]*\..*"' | wc -l 2>/dev/null | tr -d ' ')
CHANGED=$(echo "$DIFF" | grep -o '"changed_files":\[[^]]*\]' | grep -o '"[^"]*\..*"' | wc -l 2>/dev/null | tr -d ' ')
if [ "${NEW:-0}" != "0" ] || [ "${CHANGED:-0}" != "0" ]; then
echo "kazam: session touched ${CHANGED:-0} changed + ${NEW:-0} new files"
echo " → enrich descriptions: kazam ctx describe <path> \"what this file does\""
echo " → record learnings: kazam ctx learn \"lesson\" --category correction"
echo " → record bugs: kazam ctx bug \"symptom\" --file <path>"
fi
"#;
const WORKSPACE_RULES: &str = r#"# Kazam Workspace
This project uses **kazam** for task tracking and context intelligence.
Use kazam for ALL task tracking — do NOT use the built-in TaskCreate/TaskUpdate tools.
State lives in `.kazam/` as YAML files.
## Prerequisites
- kazam must be installed: `cargo install --git https://github.com/tdiderich/kazam`
- If `kazam` is not on PATH, install it before using any workspace commands.
## Navigating the codebase — MANDATORY
**Before you `grep`, `find`, `ls`, or spawn a subagent to explore, read the
anatomy index.** This is not optional. The index exists so you don't waste
tokens scanning the filesystem.
**Step 1 — Read the summary:**
`.kazam/ctx/anatomy.tsv` — compact index with root files and directory rollups
(file count, total tokens, description). ~68 lines even for huge repos.
**Step 2 — Drill into a directory:**
`.kazam/ctx/anatomy/<dir>.tsv` — individual files in that directory.
Nested paths use `--` as separator: `frontend/src/app` → `anatomy/frontend--src--app.tsv`.
**Step 3 — Read the source file you need.**
Summary → detail → source. Three reads, zero exploration.
**When delegating to subagents:** subagents don't see these rules, so you
must brief them. Include in every subagent prompt:
1. **Anatomy:** "Read `.kazam/ctx/anatomy.tsv` for project layout, then
`.kazam/ctx/anatomy/<dir>.tsv` for the directory you need — don't
grep or find for structure."
2. **Task context:** "You are working on task `<ID>`: <title>. When done,
run `kazam track close <ID> --reason '<what you did>'`."
3. **Enrichment:** "After reading an unfamiliar file, run
`kazam ctx describe <path> '<description>'`."
## On session start or context recovery
Run `kazam track ready --json` to orient — see unblocked tasks sorted by priority.
If resuming from a compacted context, this is how you re-establish what needs doing.
## Before starting work
- Claim a task: `kazam track claim <ID> --name <your-name>`.
- **MANDATORY: before fixing any error**, run `kazam ctx bugs --file <path>`
to check if it was solved before. Do not skip this step.
## During work — close tasks as you go, don't batch
- **After each commit**, check if it completes an open task. If so, close it
immediately: `kazam track close <ID> --reason "what you did"`.
- Tasks with `--owner human` are not yours to close. If one blocks your work,
mark it blocked: `kazam track block <ID> --reason "why"`. When the user
completes a human task, close it for them.
- After reading an unfamiliar file, enrich its description:
`kazam ctx describe <path> "what this file actually does"`.
- Record non-obvious learnings: `kazam ctx learn "lesson" --category correction`.
- Record bugs you find: `kazam ctx bug "symptom" --file <path>`.
- When the user corrects your approach, record it immediately:
`kazam ctx correction "what you did wrong" "what to do instead" --file <path>`.
## Quick reference
```
kazam track ready --json # unblocked tasks by priority
kazam track close <ID> --reason "..." # mark task done
kazam track block <ID> --reason "..." # mark task blocked
kazam track list --json # all tasks with status
kazam ctx describe <path> "description" # enrich file description
kazam ctx bugs --file <path> # known bugs on a file
kazam ctx learn "lesson" --category correction
kazam ctx bug "symptom" --file <path>
kazam ctx correction "mistake" "fix" --file <path> # record a correction
kazam ctx corrections --json # view past corrections
```
## Direct YAML editing
You may edit `.kazam/track/tasks.yaml` or `.kazam/ctx/*.yaml` directly.
The board (`kazam board`) auto-refreshes on any `.kazam/*.yaml` change.
"#;
pub fn install(project: &Path, agent: &str, skunkworks: bool) -> Result<()> {
let hooks_dir = crate::workspace::root(project).join("hooks");
fs::create_dir_all(&hooks_dir).context("create hooks dir")?;
fs::write(hooks_dir.join("session-start.sh"), SESSION_START_SH)?;
fs::write(hooks_dir.join("post-write.sh"), POST_WRITE_SH)?;
fs::write(hooks_dir.join("stop.sh"), STOP_SH)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for name in ["session-start.sh", "post-write.sh", "stop.sh"] {
let p = hooks_dir.join(name);
fs::set_permissions(&p, fs::Permissions::from_mode(0o755))?;
}
}
if agent == "claude" || agent == "all" {
install_claude_hooks(project, skunkworks)?;
}
let rules_dir = project.join(".claude").join("rules");
fs::create_dir_all(&rules_dir).context("create .claude/rules")?;
let override_path = crate::workspace::root(project).join("ctx/rules-override.md");
let mut rules = WORKSPACE_RULES.to_string();
if override_path.exists() {
let custom = fs::read_to_string(&override_path).unwrap_or_default();
if !custom.trim().is_empty() {
rules.push_str("\n## Team overrides\n\n");
rules.push_str(&custom);
rules.push('\n');
}
}
fs::write(rules_dir.join("kazam-workspace.md"), &rules).context("write workspace rules")?;
let settings_name = if skunkworks {
"settings.local.json"
} else {
"settings.json"
};
println!(" ✓ hooks installed to .kazam/hooks/");
if agent == "claude" || agent == "all" {
println!(" ✓ Claude Code hooks registered in .claude/{settings_name}");
}
println!(" ✓ workspace rules written to .claude/rules/kazam-workspace.md");
if override_path.exists() {
println!(" ✓ team overrides applied from .kazam/ctx/rules-override.md");
}
Ok(())
}
pub fn uninstall(project: &Path) -> Result<()> {
let hooks_dir = crate::workspace::root(project).join("hooks");
if hooks_dir.exists() {
fs::remove_dir_all(&hooks_dir).context("remove hooks dir")?;
fs::create_dir_all(&hooks_dir).context("recreate hooks dir")?;
}
let rules_file = project.join(".claude/rules/kazam-workspace.md");
if rules_file.exists() {
fs::remove_file(&rules_file).context("remove workspace rules")?;
}
let settings_path = project.join(".claude/settings.json");
if settings_path.exists() {
let text = fs::read_to_string(&settings_path)?;
if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(obj) = settings.as_object_mut() {
if let Some(hooks) = obj.get_mut("hooks") {
if let Some(hooks_obj) = hooks.as_object_mut() {
for event in ["SessionStart", "PostToolUse", "Stop"] {
if let Some(arr) =
hooks_obj.get_mut(event).and_then(|v| v.as_array_mut())
{
arr.retain(|item| {
let nested = item
.pointer("/hooks/0/description")
.and_then(|d| d.as_str());
let flat =
item.pointer("/description").and_then(|d| d.as_str());
!nested.is_some_and(|d| d.starts_with("kazam-workspace:"))
&& !flat.is_some_and(|d| d.starts_with("kazam-workspace:"))
});
if arr.is_empty() {
hooks_obj.remove(event);
}
}
}
}
}
}
let json = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, json)?;
}
}
println!(" ✓ hooks uninstalled");
Ok(())
}
pub fn status(project: &Path) -> Result<()> {
let hooks_dir = crate::workspace::root(project).join("hooks");
let scripts = ["session-start.sh", "post-write.sh", "stop.sh"];
let mut installed = 0;
for name in &scripts {
if hooks_dir.join(name).exists() {
installed += 1;
}
}
let settings_path = project.join(".claude/settings.json");
let claude_registered = if settings_path.exists() {
let text = fs::read_to_string(&settings_path).unwrap_or_default();
text.contains("kazam-workspace")
} else {
false
};
let rules_exist = project.join(".claude/rules/kazam-workspace.md").exists();
println!(" hook scripts: {installed}/{} installed", scripts.len());
println!(
" claude hooks: {}",
if claude_registered {
"registered"
} else {
"not registered"
}
);
println!(
" workspace rules: {}",
if rules_exist { "present" } else { "missing" }
);
Ok(())
}
fn install_claude_hooks(project: &Path, skunkworks: bool) -> Result<()> {
let settings_file = if skunkworks {
"settings.local.json"
} else {
"settings.json"
};
let settings_path = project.join(".claude").join(settings_file);
fs::create_dir_all(project.join(".claude")).context("create .claude")?;
let mut settings: serde_json::Value = if settings_path.exists() {
let text = fs::read_to_string(&settings_path)?;
serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
let hooks_dir = crate::workspace::root(project).join("hooks");
let hooks_abs = hooks_dir
.canonicalize()
.unwrap_or(hooks_dir.clone())
.to_string_lossy()
.to_string();
let obj = settings.as_object_mut().unwrap();
let hooks = obj
.entry("hooks")
.or_insert(serde_json::json!({}))
.as_object_mut()
.unwrap();
let kazam_hooks = [
(
"SessionStart",
serde_json::json!({
"matcher": "",
"hooks": [{
"type": "command",
"command": format!("bash {hooks_abs}/session-start.sh"),
"description": "kazam-workspace: surface anatomy drift and ready tasks"
}]
}),
),
(
"PostToolUse",
serde_json::json!({
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": format!("bash {hooks_abs}/post-write.sh"),
"description": "kazam-workspace: log file modifications"
}]
}),
),
(
"Stop",
serde_json::json!({
"matcher": "",
"hooks": [{
"type": "command",
"command": format!("bash {hooks_abs}/stop.sh"),
"description": "kazam-workspace: rescan anatomy on session end"
}]
}),
),
];
for (event, entry) in kazam_hooks {
let arr = hooks
.entry(event)
.or_insert(serde_json::json!([]))
.as_array_mut()
.unwrap();
arr.retain(|item| {
let nested = item
.pointer("/hooks/0/description")
.and_then(|d| d.as_str());
let flat = item.pointer("/description").and_then(|d| d.as_str());
!nested.is_some_and(|d| d.starts_with("kazam-workspace:"))
&& !flat.is_some_and(|d| d.starts_with("kazam-workspace:"))
});
arr.push(entry);
}
let json = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, json).with_context(|| format!("write .claude/{settings_file}"))?;
Ok(())
}