diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..808c2ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + - main + +jobs: + test: + name: Rust checks (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - name: Format + run: cargo fmt --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Test + run: cargo test --workspace --locked + + audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + secrets: + name: Secret scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Install Gitleaks + env: + GITLEAKS_VERSION: 8.30.1 + run: | + set -euo pipefail + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + artifact="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL "${base}/${artifact}" -o "${tmp}/${artifact}" + curl -sSfL "${base}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" -o "${tmp}/checksums.txt" + (cd "$tmp" && grep " ${artifact}$" checksums.txt | sha256sum -c -) + tar -xzf "${tmp}/${artifact}" -C "$tmp" gitleaks + sudo install -m 0755 "${tmp}/gitleaks" /usr/local/bin/gitleaks + - name: Scan + run: gitleaks detect --source . --config .gitleaks.toml --redact --verbose diff --git a/.gitignore b/.gitignore index 108223a..89b0da4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # Local-only config (per-user settings, permissions) *.local.json +.claude/settings.json # OS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..2061f1d --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,26 @@ +title = "Tempyr secret scanning" + +[extend] +useDefault = true + +[[allowlists]] +description = "Intentional fake credentials used by redaction and no-leak tests" +condition = "AND" +paths = [ + '''crates/tempyr-cli/src/commands/onboarding\.rs''', + '''crates/tempyr-cli/tests/integration\.rs''', + '''crates/tempyr-index/src/embeddings\.rs''', + '''crates/tempyr-journal/src/redact\.rs''', + '''crates/tempyr-journal/src/writer\.rs''', +] +regexes = [ + '''pa-1234567890abcdef''', + '''AIzaSyA-LongerLookingKey123''', + '''sk-doctor-test-secret-must-not-leak''', + '''sk-mcp-doctor-secret-must-not-leak''', + '''sk-ant-abcdefghijklmnop1234567890qrstuvwx''', + '''sk-proj-abc1234567890defghij''', + '''ghp_abcdefghijklmnopqrstuvwxyz0123456789AB''', + '''AKIAIOSFODNN7EXAMPLE''', + '''MIIEvQ\.\.\.etc''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a1d0450 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to Tempyr will be documented here. + +This project follows human-readable release notes. Until the first tagged +release, changes are tracked through pull requests and the `master` +branch history. + +## Unreleased + +- Initial public-readiness documentation and CI setup. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..baed07b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,12 @@ +# Code of Conduct + +Tempyr contributors are expected to keep project spaces respectful, +professional, and focused on the work. + +Do not harass, threaten, insult, or deliberately derail other contributors. +Assume good intent where reasonable, but prioritize clear technical discussion +and maintainers' moderation decisions. + +Report conduct concerns privately by opening a GitHub Security Advisory at +https://github.com/cleak/tempyr/security/advisories/new. Maintainers will keep +reports confidential. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c400ef1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Thanks for helping improve Tempyr. + +## Development Setup + +Install the stable Rust toolchain, then run the standard checks from the +repository root: + +```sh +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --locked +cargo audit +gitleaks detect --source . --config .gitleaks.toml --redact --verbose +``` + +`cargo audit` and `gitleaks` are separate developer tools; install them before +running the full local suite. + +The source install path targets the CLI crate: + +```sh +cargo install --path crates/tempyr-cli --locked +``` + +## Pull Requests + +- Keep changes focused on one behavior or documentation area. +- Add or update tests for user-visible behavior changes. +- Do not commit secrets, `.env` files, local agent settings, generated indexes, + or rendered output unless a maintainer explicitly asks for them. +- When changing graph or journal behavior, update the relevant docs under + `docs/`. + +## Agent-Specific Files + +The checked-in `.claude/skills` and `.claude/agents` files are examples for +Claude Code integration. Active hook settings are intentionally not committed; +copy the example from `docs/claude-settings.example.json` or generate them +locally with `tempyr init` when needed. diff --git a/Cargo.lock b/Cargo.lock index b7a5a4f..dae51aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1671,16 +1671,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2688,9 +2678,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2875,18 +2865,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -3147,7 +3135,7 @@ dependencies = [ "rand", "serde", "serde_json", - "serde_yml", + "serde_yaml", "tempfile", "thiserror", "toml", @@ -3166,7 +3154,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "serde_yml", + "serde_yaml", "tempfile", "tempyr-core", "tempyr-journal", @@ -3182,7 +3170,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "serde_yml", + "serde_yaml", "strsim", "tempfile", "tempyr-core", @@ -3269,7 +3257,7 @@ version = "0.1.0" dependencies = [ "chrono", "serde", - "serde_yml", + "serde_yaml", "tempfile", "tempyr-core", "thiserror", @@ -3599,6 +3587,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index afbd38d..f884e0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yml = "0.0.12" +serde_yaml = "0.9" toml = "0.8" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } diff --git a/README.md b/README.md index 9f61a68..6b375ab 100644 --- a/README.md +++ b/README.md @@ -45,21 +45,23 @@ cargo build --workspace Install the CLI from the local checkout: ```sh -cargo install --path crates/tempyr-cli +cargo install --path crates/tempyr-cli --locked ``` Confirm the binary is available: ```sh -tempyr doctor +tempyr --help ``` ## Quick Start -Initialize a Tempyr project: +Create or enter a project directory, then initialize Tempyr: ```sh -tempyr init +mkdir tempyr-demo +cd tempyr-demo +tempyr init --no-wizard ``` Add a node: @@ -92,6 +94,12 @@ Render a document from a root node: tempyr render prd feat-session-replay --output renders/session-replay-prd.md ``` +Inspect project health: + +```sh +tempyr doctor +``` + Start an interview from a brain dump: ```sh @@ -117,11 +125,12 @@ provider-specific keys you need. `.env` is intentionally ignored by git. Useful commands: ```sh -cargo build -cargo test +cargo build --workspace --locked +cargo test --workspace --locked cargo test --lib -cargo clippy +cargo clippy --workspace --all-targets -- -D warnings cargo fmt --check +cargo audit cargo run -- ``` diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..96e30c7 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,34 @@ +# Release Process + +1. Run the full local check suite: + + ```sh + cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace --locked + cargo audit + gitleaks detect --source . --config .gitleaks.toml --redact --verbose + ``` + +2. Update `CHANGELOG.md` with user-facing changes. +3. Confirm the managed Claude settings example matches the embedded asset: + + ```sh + git diff --no-index --exit-code docs/claude-settings.example.json crates/tempyr-cli/assets/claude.settings.json + ``` + +4. Confirm install scripts still work on their target platforms: + + ```sh + bash install.sh --no-path-update + powershell -ExecutionPolicy Bypass -File .\install.ps1 -NoPathUpdate + ``` + +5. Tag the release: + + ```sh + git tag -a v0.1.0 -m "Tempyr v0.1.0" + git push origin v0.1.0 + ``` + +6. Publish release notes from the changelog entry. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..df2177f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting Vulnerabilities + +Please report suspected vulnerabilities privately by opening a GitHub security +advisory on this repository. If advisories are unavailable, contact the +maintainers through the repository owner profile and avoid posting exploit +details in a public issue. + +Include: + +- Affected version or commit. +- Impact and expected exposure. +- Steps to reproduce, if safe to share. +- Any suggested fix or mitigation. + +## Secret Handling + +Tempyr reads provider keys from the environment or local `.env` files. Do not +commit real API keys, tokens, private keys, or generated local configuration. +The repository includes fake secret-shaped strings in redaction tests; they are +allowlisted in `.gitleaks.toml` for scanner noise only and are not usable +credentials. GitHub's native secret scanning may still report these fixtures; +maintainers should dismiss them as test credentials only after verifying the +exact literal appears in the allowlist. diff --git a/.claude/settings.json b/crates/tempyr-cli/assets/claude.settings.json similarity index 76% rename from .claude/settings.json rename to crates/tempyr-cli/assets/claude.settings.json index 39febca..bba0015 100644 --- a/.claude/settings.json +++ b/crates/tempyr-cli/assets/claude.settings.json @@ -33,15 +33,6 @@ "command": "tempyr index update --json" } ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "bash -c 'INPUT=$(cat); if echo \"$INPUT\" | grep -q \"graph/\"; then tempyr index update --json; fi'" - } - ] } ] } diff --git a/crates/tempyr-cli/src/commands/git_hooks.rs b/crates/tempyr-cli/src/commands/git_hooks.rs index 1b8d81e..24dbc62 100644 --- a/crates/tempyr-cli/src/commands/git_hooks.rs +++ b/crates/tempyr-cli/src/commands/git_hooks.rs @@ -578,6 +578,12 @@ mod tests { let managed = render_managed_block(&GIT_HOOKS[0]); let hook = format!("#!/bin/sh\nif some_check; then\n exit 0\nfi\n\n{managed}"); fs::write(&path, hook).unwrap(); + #[cfg(unix)] + { + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + } assert_eq!(hook_status(&path, &managed).unwrap(), HookStatus::UpToDate); } @@ -628,7 +634,7 @@ mod tests { fs::write(&path, &managed).unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_mode(0o055); + perms.set_mode(0o455); fs::set_permissions(&path, perms).unwrap(); assert_eq!(hook_status(&path, &managed).unwrap(), HookStatus::Stale); diff --git a/crates/tempyr-cli/src/commands/managed.rs b/crates/tempyr-cli/src/commands/managed.rs index c444dae..63cf841 100644 --- a/crates/tempyr-cli/src/commands/managed.rs +++ b/crates/tempyr-cli/src/commands/managed.rs @@ -6,7 +6,7 @@ use std::path::Path; // Embedded artifacts // --------------------------------------------------------------------------- -const TEMPYR_HOOKS_JSON: &str = include_str!("../../../../.claude/settings.json"); +const TEMPYR_HOOKS_JSON: &str = include_str!("../../assets/claude.settings.json"); const SKILL_INTERVIEW_MD: &str = include_str!("../../../../.claude/skills/tempyr-interview/SKILL.md"); const AGENT_EXTRACTOR_MD: &str = include_str!("../../../../.claude/agents/tempyr-extractor.md"); @@ -38,7 +38,7 @@ const MANAGED_FILES: &[ManagedFileDef] = &[ path: ".claude/settings.json", content: TEMPYR_HOOKS_JSON, strategy: Strategy::Merge, - description: "Claude Code hooks for validation and indexing", + description: "Claude Code hooks for journaling, validation, and indexing", }, ManagedFileDef { artifact: ManagedArtifact::Skill, @@ -456,22 +456,28 @@ fn upsert_manifest_entry(entries: &mut Vec, entry: ManagedFile) { // settings.json merge // --------------------------------------------------------------------------- -fn is_tempyr_hook(entry: &serde_json::Value) -> bool { - if let Some(matcher) = entry.get("matcher").and_then(|m| m.as_str()) - && matcher.contains("mcp__tempyr__") - { - return true; - } - if let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) { - for hook in hooks { - if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) - && cmd.contains("tempyr ") - { - return true; - } - } +fn is_managed_hook(entry: &serde_json::Value, managed_entries: &[serde_json::Value]) -> bool { + managed_entries + .iter() + .any(|managed_entry| entry == managed_entry) + || is_legacy_managed_hook(entry) +} + +fn is_legacy_managed_hook(entry: &serde_json::Value) -> bool { + const LEGACY_GRAPH_WRITE_COMMAND: &str = "bash -c 'INPUT=$(cat); if echo \"$INPUT\" | grep -q \"graph/\"; then tempyr index update --json; fi'"; + + if entry.get("matcher").and_then(|m| m.as_str()) != Some("Edit|Write") { + return false; } - false + + entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command").and_then(|c| c.as_str()) == Some(LEGACY_GRAPH_WRITE_COMMAND) + }) + }) } fn merge_settings(existing_json: &str, tempyr_hooks_json: &str) -> anyhow::Result { @@ -485,32 +491,36 @@ fn merge_settings(existing_json: &str, tempyr_hooks_json: &str) -> anyhow::Resul let tempyr_settings: serde_json::Value = serde_json::from_str(tempyr_hooks_json) .with_context(|| "Failed to parse embedded tempyr hooks")?; - let tempyr_entries = tempyr_settings - .pointer("/hooks/PostToolUse") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); + let tempyr_hooks = tempyr_settings + .get("hooks") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow::anyhow!("embedded tempyr hooks must contain a hooks object"))?; - // Ensure hooks.PostToolUse exists as an array. + // Ensure hooks exists as an object. let hooks = doc .as_object_mut() .ok_or_else(|| anyhow::anyhow!(".claude/settings.json root is not an object"))? .entry("hooks") .or_insert(serde_json::json!({})); - let post_tool_use = hooks + let hooks_obj = hooks .as_object_mut() - .ok_or_else(|| anyhow::anyhow!("hooks is not an object in .claude/settings.json"))? - .entry("PostToolUse") - .or_insert(serde_json::json!([])); - let arr = post_tool_use - .as_array_mut() - .ok_or_else(|| anyhow::anyhow!("hooks.PostToolUse is not an array"))?; + .ok_or_else(|| anyhow::anyhow!("hooks is not an object in .claude/settings.json"))?; - // Remove existing tempyr entries. - arr.retain(|entry| !is_tempyr_hook(entry)); - - // Append fresh tempyr entries. - arr.extend(tempyr_entries); + for (event, tempyr_entries) in tempyr_hooks { + let tempyr_entries = tempyr_entries + .as_array() + .ok_or_else(|| anyhow::anyhow!("embedded hooks.{event} is not an array"))? + .clone(); + let entries = hooks_obj + .entry(event.clone()) + .or_insert(serde_json::json!([])); + let arr = entries + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("hooks.{event} is not an array"))?; + + arr.retain(|entry| !is_managed_hook(entry, &tempyr_entries)); + arr.extend(tempyr_entries); + } let mut output = serde_json::to_string_pretty(&doc)?; output.push('\n'); @@ -526,30 +536,32 @@ mod tests { use super::*; #[test] - fn test_is_tempyr_hook_mcp_matcher() { - let entry = serde_json::json!({ + fn test_is_managed_hook_matches_exact_managed_entry() { + let managed_entry = serde_json::json!({ "matcher": "mcp__tempyr__graph_add_node|mcp__tempyr__graph_update_node", "hooks": [{"type": "command", "command": "tempyr validate --json"}] }); - assert!(is_tempyr_hook(&entry)); + assert!(is_managed_hook( + &managed_entry, + std::slice::from_ref(&managed_entry) + )); } #[test] - fn test_is_tempyr_hook_tempyr_command() { + fn test_is_managed_hook_matches_legacy_graph_write_hook() { let entry = serde_json::json!({ "matcher": "Edit|Write", - "hooks": [{"type": "command", "command": "bash -c 'if echo \"$INPUT\" | grep -q \"graph/\"; then tempyr index update --json; fi'"}] + "hooks": [{"type": "command", "command": "bash -c 'INPUT=$(cat); if echo \"$INPUT\" | grep -q \"graph/\"; then tempyr index update --json; fi'"}] }); - assert!(is_tempyr_hook(&entry)); + assert!(is_managed_hook(&entry, &[])); } #[test] - fn test_is_tempyr_hook_user_entry() { + fn test_is_managed_hook_preserves_user_tempyr_command() { let entry = serde_json::json!({ - "matcher": "Edit|Write", - "hooks": [{"type": "command", "command": "eslint --fix $FILE"}] + "hooks": [{"type": "command", "command": "tempyr custom-health --json"}] }); - assert!(!is_tempyr_hook(&entry)); + assert!(!is_managed_hook(&entry, &[])); } #[test] @@ -563,7 +575,11 @@ mod tests { .as_array() .unwrap(); assert_eq!(arr.len(), 1); - assert!(is_tempyr_hook(&arr[0])); + let managed_entry = arr[0].clone(); + assert!(is_managed_hook( + &managed_entry, + std::slice::from_ref(&managed_entry) + )); } #[test] @@ -572,7 +588,7 @@ mod tests { "hooks": { "PostToolUse": [ {"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "eslint --fix $FILE"}]}, - {"matcher": "mcp__tempyr__graph_add_node", "hooks": [{"type": "command", "command": "tempyr validate --json"}]} + {"matcher": "mcp__tempyr__graph_add_node|mcp__tempyr__graph_update_node", "hooks": [{"type":"command","command":"tempyr validate --json"},{"type":"command","command":"tempyr index update --json"}]} ] } }"#; @@ -587,8 +603,11 @@ mod tests { // User's eslint hook preserved, old tempyr hook removed, new tempyr hook added. assert_eq!(arr.len(), 2); - assert!(!is_tempyr_hook(&arr[0])); // eslint - assert!(is_tempyr_hook(&arr[1])); // new tempyr + assert!(!is_managed_hook(&arr[0], &[])); // eslint + assert_eq!( + arr[1]["matcher"].as_str().unwrap(), + "mcp__tempyr__graph_add_node|mcp__tempyr__graph_update_node" + ); assert_eq!( arr[0]["hooks"][0]["command"].as_str().unwrap(), "eslint --fix $FILE" @@ -603,6 +622,79 @@ mod tests { assert_eq!(first, second); } + #[test] + fn test_merge_settings_installs_full_tempyr_hook_set() { + let result = merge_settings("", TEMPYR_HOOKS_JSON).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + for event in ["SessionStart", "SessionEnd", "PostToolUse"] { + let arr = parsed + .pointer(&format!("/hooks/{event}")) + .and_then(|value| value.as_array()) + .unwrap_or_else(|| panic!("missing hooks.{event}")); + assert!( + !arr.is_empty(), + "hooks.{event} should include a managed tempyr hook" + ); + } + } + + #[test] + fn test_merge_settings_preserves_user_session_hooks() { + let existing = r#"{ + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "echo hello"}]} + ] + } +}"#; + + let result = merge_settings(existing, TEMPYR_HOOKS_JSON).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + let arr = parsed + .pointer("/hooks/SessionStart") + .and_then(|value| value.as_array()) + .unwrap(); + + assert_eq!(arr.len(), 2); + assert_eq!( + arr[0]["hooks"][0]["command"].as_str().unwrap(), + "echo hello" + ); + assert_eq!( + arr[1]["hooks"][0]["command"].as_str().unwrap(), + "tempyr journal bootstrap --quiet --json" + ); + } + + #[test] + fn test_merge_settings_preserves_user_tempyr_session_hook() { + let existing = r#"{ + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "tempyr custom-health --json"}]} + ] + } +}"#; + + let result = merge_settings(existing, TEMPYR_HOOKS_JSON).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + let arr = parsed + .pointer("/hooks/SessionStart") + .and_then(|value| value.as_array()) + .unwrap(); + + assert_eq!(arr.len(), 2); + assert_eq!( + arr[0]["hooks"][0]["command"].as_str().unwrap(), + "tempyr custom-health --json" + ); + assert_eq!( + arr[1]["hooks"][0]["command"].as_str().unwrap(), + "tempyr journal bootstrap --quiet --json" + ); + } + #[test] fn test_manifest_round_trip() { let manifest = Manifest { diff --git a/crates/tempyr-cli/src/commands/migrate.rs b/crates/tempyr-cli/src/commands/migrate.rs index e928cfd..1a43961 100644 --- a/crates/tempyr-cli/src/commands/migrate.rs +++ b/crates/tempyr-cli/src/commands/migrate.rs @@ -157,7 +157,7 @@ fn add_field(graph_dir: &Path, node_type: &str, field: &str, default: &str) -> a let has_field = match field { "status" => node.frontmatter.status.is_some(), "owner" => node.frontmatter.owner.is_some(), - _ => false, // Custom fields would need serde_yml manipulation + _ => false, // Custom fields would need YAML value manipulation. }; if !has_field { diff --git a/crates/tempyr-cli/src/commands/onboarding.rs b/crates/tempyr-cli/src/commands/onboarding.rs index fbe48fe..8698972 100644 --- a/crates/tempyr-cli/src/commands/onboarding.rs +++ b/crates/tempyr-cli/src/commands/onboarding.rs @@ -384,15 +384,9 @@ fn handle_provider(state: &mut WizardState, key: KeyCode) { let all = EmbeddingProviderChoice::all(); match key { - KeyCode::Up | KeyCode::Char('k') => { - if current > 0 { - update_provider(state, all[current - 1]); - } - } - KeyCode::Down | KeyCode::Char('j') => { - if current + 1 < all.len() { - update_provider(state, all[current + 1]); - } + KeyCode::Up | KeyCode::Char('k') if current > 0 => update_provider(state, all[current - 1]), + KeyCode::Down | KeyCode::Char('j') if current + 1 < all.len() => { + update_provider(state, all[current + 1]) } KeyCode::Enter | KeyCode::Right | KeyCode::Char('n') => state.next_page(), KeyCode::Left | KeyCode::Backspace | KeyCode::Char('b') => state.prev_page(), diff --git a/crates/tempyr-cli/src/commands/render_cmd.rs b/crates/tempyr-cli/src/commands/render_cmd.rs index 5f37113..53e20fc 100644 --- a/crates/tempyr-cli/src/commands/render_cmd.rs +++ b/crates/tempyr-cli/src/commands/render_cmd.rs @@ -51,6 +51,11 @@ pub fn run( }; if let Some(output_path) = output { + if let Some(parent) = output_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } std::fs::write(output_path, &result)?; println!("Rendered to {}", output_path.display()); } else { diff --git a/crates/tempyr-cli/tests/integration.rs b/crates/tempyr-cli/tests/integration.rs index 4c89757..f7c97fc 100644 --- a/crates/tempyr-cli/tests/integration.rs +++ b/crates/tempyr-cli/tests/integration.rs @@ -1983,6 +1983,40 @@ fn test_render_to_file() { assert!(content.contains("Product Requirements Document")); } +#[test] +fn test_render_to_file_creates_parent_directories() { + let tmp = TempDir::new().unwrap(); + init_project(&tmp); + + write_node( + &tmp, + "features", + "feat-a", + "---\nid: feat-a\ntype: feature\nstatus: draft\nowner: caleb\n---\n# Feature A\n\nBody text.\n", + ); + + let output_path = tmp.path().join("renders").join("feature-a.md"); + let parent = output_path.parent().unwrap(); + assert!( + !parent.exists(), + "precondition failed: parent dir should not exist before render" + ); + tempyr() + .current_dir(tmp.path()) + .args([ + "render", + "prd", + "feat-a", + "--output", + output_path.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Rendered to")); + + assert!(output_path.exists()); +} + #[test] fn test_json_output() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tempyr-core/Cargo.toml b/crates/tempyr-core/Cargo.toml index 1957043..66a4f9e 100644 --- a/crates/tempyr-core/Cargo.toml +++ b/crates/tempyr-core/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } -serde_yml = { workspace = true } +serde_yaml = { workspace = true } toml = { workspace = true } chrono = { workspace = true } blake3 = { workspace = true } diff --git a/crates/tempyr-core/src/node.rs b/crates/tempyr-core/src/node.rs index 12b7032..1f7becb 100644 --- a/crates/tempyr-core/src/node.rs +++ b/crates/tempyr-core/src/node.rs @@ -67,7 +67,7 @@ pub fn parse_node(content: &str, file_path: PathBuf) -> Result { let (frontmatter_str, body) = split_frontmatter(content)?; let frontmatter: NodeFrontmatter = - serde_yml::from_str(frontmatter_str).map_err(|e| TempyrError::Yaml(e.to_string()))?; + serde_yaml::from_str(frontmatter_str).map_err(|e| TempyrError::Yaml(e.to_string()))?; let content_hash = blake3::hash(body.as_bytes()).to_hex().to_string(); @@ -82,7 +82,7 @@ pub fn parse_node(content: &str, file_path: PathBuf) -> Result { /// Serialize a node back to its file format (YAML frontmatter + markdown body). pub fn serialize_node(node: &Node) -> Result { let yaml = - serde_yml::to_string(&node.frontmatter).map_err(|e| TempyrError::Yaml(e.to_string()))?; + serde_yaml::to_string(&node.frontmatter).map_err(|e| TempyrError::Yaml(e.to_string()))?; Ok(format!("---\n{}---\n{}", yaml, node.body)) } diff --git a/crates/tempyr-index/Cargo.toml b/crates/tempyr-index/Cargo.toml index ecad78a..549de0d 100644 --- a/crates/tempyr-index/Cargo.toml +++ b/crates/tempyr-index/Cargo.toml @@ -28,4 +28,4 @@ optional = true [dev-dependencies] tempfile = { workspace = true } -serde_yml = { workspace = true } +serde_yaml = { workspace = true } diff --git a/crates/tempyr-interview/Cargo.toml b/crates/tempyr-interview/Cargo.toml index 35c9365..060b426 100644 --- a/crates/tempyr-interview/Cargo.toml +++ b/crates/tempyr-interview/Cargo.toml @@ -16,4 +16,4 @@ strsim = { workspace = true } [dev-dependencies] tempfile = { workspace = true } -serde_yml = { workspace = true } +serde_yaml = { workspace = true } diff --git a/crates/tempyr-interview/src/session.rs b/crates/tempyr-interview/src/session.rs index f3988f4..ffb19f6 100644 --- a/crates/tempyr-interview/src/session.rs +++ b/crates/tempyr-interview/src/session.rs @@ -1,3 +1,4 @@ +use std::cmp::Reverse; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -199,7 +200,7 @@ impl InterviewSession { } } - summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + summaries.sort_by_key(|summary| Reverse(summary.updated_at)); Ok(summaries) } diff --git a/crates/tempyr-journal/src/git.rs b/crates/tempyr-journal/src/git.rs index de4b59a..2f60dc4 100644 --- a/crates/tempyr-journal/src/git.rs +++ b/crates/tempyr-journal/src/git.rs @@ -15,7 +15,7 @@ use std::io::{Read, Write}; use std::path::Path; -use std::process::{Command, ExitStatus, Stdio}; +use std::process::{Child, Command, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -114,11 +114,12 @@ pub(crate) fn wait_with_timeout( break s; } if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - // Drain after kill so the threads exit cleanly. - let _ = out_h.join(); - let _ = err_h.join(); + terminate_child_tree(&mut child); + let _ = wait_after_terminate(&mut child, Duration::from_secs(2)); + // Do not join the drain threads on timeout. On Windows, `git.exe` + // can spawn a helper process that inherits the pipes; if the + // helper survives the parent, joining the drainers can wedge the + // caller even though the timeout path already did its job. return Err(JournalError::Git(format!( "git {} timed out after {}s", args.join(" "), @@ -137,6 +138,37 @@ pub(crate) fn wait_with_timeout( }) } +fn terminate_child_tree(child: &mut Child) { + #[cfg(windows)] + { + let pid = child.id().to_string(); + let _ = Command::new("taskkill") + .args(["/PID", &pid, "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + + let _ = child.kill(); +} + +fn wait_after_terminate(child: &mut Child, timeout: Duration) -> Result> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child + .try_wait() + .map_err(|e| JournalError::Git(format!("wait after kill: {e}")))? + { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + thread::sleep(Duration::from_millis(20)); + } +} + fn drain(mut r: R) -> String { let mut buf = String::new(); let _ = r.read_to_string(&mut buf); diff --git a/crates/tempyr-journal/src/path.rs b/crates/tempyr-journal/src/path.rs index 16feb82..620e74a 100644 --- a/crates/tempyr-journal/src/path.rs +++ b/crates/tempyr-journal/src/path.rs @@ -110,10 +110,11 @@ pub fn worktree_hash(worktree_top: &Path) -> String { pub fn repo_relative_path(path: &str, worktree_top: &Path) -> String { let p = Path::new(path); let body = if p.is_absolute() { - let canon_p = canonicalize_or_keep(p); + let canon_p = canonicalize_with_existing_prefix(p); let canon_top = canonicalize_or_keep(worktree_top); canon_p .strip_prefix(&canon_top) + .or_else(|_| p.strip_prefix(worktree_top)) .map(|rel| rel.to_string_lossy().into_owned()) .unwrap_or_else(|_| path.to_string()) } else { @@ -130,14 +131,15 @@ pub fn repo_relative_path(path: &str, worktree_top: &Path) -> String { /// When `cwd` is `None`, falls back to [`repo_relative_path`]'s pass-through /// behavior for relative inputs. pub fn resolve_file_path(path: &str, worktree_top: &Path, cwd: Option<&Path>) -> String { - let p = Path::new(path); + let normalized = path.replace('\\', "/"); + let p = Path::new(&normalized); if !p.is_absolute() && let Some(base) = cwd { let abs = base.join(p); return repo_relative_path(&abs.to_string_lossy(), worktree_top); } - repo_relative_path(path, worktree_top) + repo_relative_path(&normalized, worktree_top) } /// Tempyr's directory under the git common dir: `/tempyr/`. @@ -214,6 +216,33 @@ fn canonicalize_or_keep(p: &Path) -> PathBuf { } } +fn canonicalize_with_existing_prefix(p: &Path) -> PathBuf { + if let Ok(canonical) = p.canonicalize() { + return strip_unc(canonical); + } + + let mut missing = Vec::new(); + let mut current = p; + while !current.exists() { + let Some(name) = current.file_name() else { + return p.to_path_buf(); + }; + missing.push(name.to_os_string()); + let Some(parent) = current.parent() else { + return p.to_path_buf(); + }; + current = parent; + } + + let Ok(mut canonical) = current.canonicalize().map(strip_unc) else { + return p.to_path_buf(); + }; + for part in missing.iter().rev() { + canonical.push(part); + } + canonical +} + /// On Windows, `Path::canonicalize` returns paths prefixed with `\\?\`. /// Strip that to keep paths interoperable with shelled-out git invocations, /// which don't always handle the long-path prefix. @@ -373,6 +402,16 @@ mod tests { assert_eq!(normalized, "crates/foo/bar.rs"); } + #[test] + fn repo_relative_path_strips_worktree_prefix_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("crates").join("foo"); + std::fs::create_dir_all(&nested).unwrap(); + let absolute = nested.join("missing.rs"); + let normalized = repo_relative_path(&absolute.to_string_lossy(), dir.path()); + assert_eq!(normalized, "crates/foo/missing.rs"); + } + #[test] fn repo_relative_path_passes_through_relative_input() { let dir = tempfile::tempdir().unwrap(); @@ -419,6 +458,16 @@ mod tests { assert_eq!(resolved, "crates/foo/bar.rs"); } + #[test] + fn resolve_file_path_joins_missing_relative_against_cwd_then_normalizes() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("crates").join("foo"); + std::fs::create_dir_all(&sub).unwrap(); + + let resolved = resolve_file_path("missing.rs", dir.path(), Some(&sub)); + assert_eq!(resolved, "crates/foo/missing.rs"); + } + #[test] fn resolve_file_path_passes_absolute_through_repo_relative() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tempyr-mcp/src/shutdown.rs b/crates/tempyr-mcp/src/shutdown.rs index a751268..b2c83cb 100644 --- a/crates/tempyr-mcp/src/shutdown.rs +++ b/crates/tempyr-mcp/src/shutdown.rs @@ -2,7 +2,7 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::thread; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Result, anyhow}; use tokio_util::sync::CancellationToken; #[derive(Clone, Debug)] @@ -90,7 +90,7 @@ fn spawn_parent_watcher( { let _ = reason; let _ = cancellation_token; - bail!("parent watcher is not implemented for this platform") + anyhow::bail!("parent watcher is not implemented for this platform") } } @@ -151,7 +151,7 @@ fn spawn_windows_parent_watcher( trigger_parent_exit(reason, cancellation_token, parent_pid); return Ok(()); } - bail!( + anyhow::bail!( "OpenProcess failed for parent pid {parent_pid}: {}", std::io::Error::last_os_error() ); @@ -240,7 +240,7 @@ unsafe fn find_process_entry( let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; if snapshot == INVALID_HANDLE_VALUE { - bail!("{}: {}", snapshot_error(), std::io::Error::last_os_error()); + anyhow::bail!("{}: {}", snapshot_error(), std::io::Error::last_os_error()); } let mut entry = PROCESSENTRY32W { @@ -288,7 +288,7 @@ unsafe fn find_process_entry( } if let Some(err) = close_handle_error { - bail!("{}: {}", close_error(), err); + anyhow::bail!("{}: {}", close_error(), err); } Ok(found) diff --git a/crates/tempyr-render/Cargo.toml b/crates/tempyr-render/Cargo.toml index d9ae5d0..9e8f538 100644 --- a/crates/tempyr-render/Cargo.toml +++ b/crates/tempyr-render/Cargo.toml @@ -13,4 +13,4 @@ thiserror = { workspace = true } [dev-dependencies] tempfile = { workspace = true } -serde_yml = { workspace = true } +serde_yaml = { workspace = true } diff --git a/docs/claude-settings.example.json b/docs/claude-settings.example.json new file mode 100644 index 0000000..bba0015 --- /dev/null +++ b/docs/claude-settings.example.json @@ -0,0 +1,39 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "tempyr journal bootstrap --quiet --json" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "tempyr journal finalize --quiet --json" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "mcp__tempyr__graph_add_node|mcp__tempyr__graph_update_node|mcp__tempyr__graph_add_edge|mcp__tempyr__interview_commit", + "hooks": [ + { + "type": "command", + "command": "tempyr validate --json" + }, + { + "type": "command", + "command": "tempyr index update --json" + } + ] + } + ] + } +} diff --git a/docs/journal-spec.md b/docs/journal-spec.md index f8970b4..a5facd7 100644 --- a/docs/journal-spec.md +++ b/docs/journal-spec.md @@ -30,7 +30,7 @@ The graph (PRD/TDD/task nodes) captures the *current* state of a project. The jo | **1. Capture** | ✅ Shipped (PR #20) | JSONL writer, redaction, session lifecycle, `journal_log` MCP tool, `tempyr journal log` CLI | | **2. Publish** | ✅ Shipped (PRs #22, #23, #24) | Publisher pipeline (commit + push + cleanup), in-process tokio ticker, lockfile coordination, `[journal]` config, `tempyr journal flush`/`status`/`logs`/`fetch`, init wizard with public-repo detection, pack-refs cadence, multi-machine sync | | **3. Search** | ✅ Shipped (PRs #26, #27, #28) | SQLite + FTS5 + sqlite-vec index, hybrid retrieval (BM25 + vector + RRF + recency + kind boost), optional BGE-Reranker cross-encoder pass, `journal_search` and `journal_get` MCP tools, `tempyr journal search`/`show`/`sessions`/`tail`/`index` CLIs | -| **4. Polish** | ✅ Shipped (PRs #29, #30, #31, #32) | Auto-emit on task status transitions (4a) and interview lifecycle (4b), `.claude/settings.json` SessionStart/SessionEnd hooks + `tempyr journal bootstrap`/`finalize` (4c), MCP annotations across all tools (4c), `tempyr doctor` journal section (4c), CLAUDE.md/AGENTS.md journal section (4d) | +| **4. Polish** | ✅ Shipped (PRs #29, #30, #31, #32) | Auto-emit on task status transitions (4a) and interview lifecycle (4b), managed local `.claude/settings.json` SessionStart/SessionEnd hooks + `tempyr journal bootstrap`/`finalize` (4c), MCP annotations across all tools (4c), `tempyr doctor` journal section (4c), CLAUDE.md/AGENTS.md journal section (4d) | --- @@ -372,7 +372,7 @@ With search shipped, the journal became useful when agents queried it. Phase 4 c - `in_progress → blocked` → emit `risk` with `severity = blocker` - Implementation lives in [`tempyr_journal::auto_emit`](../crates/tempyr-journal/src/auto_emit.rs); both call sites treat write failures as soft warnings, never aborting the underlying status change. - Auto-emit on interview lifecycle: start, answer, adjust, phase, commit, rollback. All provisional until session commit. **Implemented in slice 4b** for the five operations that exist today (start / answer / phase / adjust / commit); rollback is deferred until the interview engine grows a corresponding operation. Implementation lives in [`tempyr_journal::auto_emit::interview`](../crates/tempyr-journal/src/auto_emit/interview.rs); both call sites treat write failures as soft warnings, never aborting the underlying interview operation. -- `.claude/settings.json` template with `SessionStart`/`SessionEnd` hooks invoking `tempyr journal bootstrap` and `tempyr journal finalize`. **Implemented in slice 4c.** The hooks are part of the managed `.claude/settings.json` written by `tempyr init` / `tempyr update`. `bootstrap` ensures the journal layout exists (idempotent); `finalize` writes the `.ready` marker on the active session for the (worktree, agent) pair, leaving `tempyr journal flush` to actually push to the remote. Both commands silently no-op outside a git repository so a Claude session opened in a non-tempyr directory doesn't fail. +- Managed local `.claude/settings.json` template with `SessionStart`/`SessionEnd` hooks invoking `tempyr journal bootstrap` and `tempyr journal finalize`. **Implemented in slice 4c.** The hooks are embedded in the CLI and mirrored in `docs/claude-settings.example.json`; `tempyr init` / `tempyr update` write them into each user's local `.claude/settings.json`, which is intentionally not checked in. `bootstrap` ensures the journal layout exists (idempotent); `finalize` writes the `.ready` marker on the active session for the (worktree, agent) pair, leaving `tempyr journal flush` to actually push to the remote. Both commands silently no-op outside a git repository so a Claude session opened in a non-tempyr directory doesn't fail. - MCP annotations (`read_only`/`destructive`/`idempotent`/`open_world`) across all existing tempyr tools (orthogonal but worth bundling). **Implemented in slice 4c.** All 27 tools tagged with explicit hints — read-only queries (`graph_search`, `graph_get_node`, `system_doctor`, etc.), destructive mutations (`graph_update_node`, `interview_commit`, `linear_pull`/`sync`), and `open_world_hint = true` for the Linear bridge tools. - README "Session journal" section + mirror to `CLAUDE.md` and `AGENTS.md`. **Implemented in slice 4d.** The repo uses `CLAUDE.md` / `AGENTS.md` as its agent-facing entry-point docs (no separate README), so the section lives in both — covering when to log manually, what's auto-emitted (the 4a/4b transitions), how to search prior reasoning, the session lifecycle (manual finalize / `final = true` / `SessionEnd` hook), and the `tempyr doctor` diagnostics. - `tempyr doctor` extension to surface journal-related health checks (lockfile orphaned, state.json corrupt, etc.). **Implemented in slice 4c.** Both `tempyr doctor` and the `system_doctor` MCP tool now emit a journal section: open / ready session counts, publisher lock state, and the stamped publisher PID. The probe is best-effort — read errors during the scan surface as a `probe error` line rather than failing the whole report. diff --git a/docs/tempyr-interview-spec.md b/docs/tempyr-interview-spec.md index af93d2c..ab30c27 100644 --- a/docs/tempyr-interview-spec.md +++ b/docs/tempyr-interview-spec.md @@ -322,18 +322,24 @@ Return ONLY valid JSON, no markdown fences, no preamble: ### 2.4 Validation Hook -File: `.claude/settings.json` (relevant excerpt) +File: local `.claude/settings.json`, generated by `tempyr init` / `tempyr update` +(relevant excerpt; see `docs/claude-settings.example.json` for the full +managed template) ```json { "hooks": { "PostToolUse": [ { - "matcher": "mcp__tempyr__graph_add_node|mcp__tempyr__graph_add_edge|mcp__tempyr__interview_commit", + "matcher": "mcp__tempyr__graph_add_node|mcp__tempyr__graph_update_node|mcp__tempyr__graph_add_edge|mcp__tempyr__interview_commit", "hooks": [ { "type": "command", - "command": "tempyr validate --json --quiet" + "command": "tempyr validate --json" + }, + { + "type": "command", + "command": "tempyr index update --json" } ] } @@ -342,7 +348,9 @@ File: `.claude/settings.json` (relevant excerpt) } ``` -This ensures graph validation runs after every mutation — node creation, edge addition, or interview commit. Deterministic, 100% execution, no LLM involvement. +This ensures graph validation and index refresh run after every mutation: +node creation, node update, edge addition, or interview commit. Deterministic, +100% execution, no LLM involvement. ---