Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions .tickets/_docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,10 @@ replaces the terminal.

---

## 🔴 Critical now (blocks comfortable daily-driving)
## 🔴 Critical now

1. **Two-claude footgun (env hygiene).** Stale `/usr/local/bin/claude` (2.1.138 npm
copy) can win in some PATHs. App now resolves absolute paths correctly, but
`rm /usr/local/bin/claude` removes the ambiguity. One-liner, do it.
Empty. The last item — the stale `/usr/local/bin/claude` — was removed
2026-08-21; `which -a claude` now returns exactly one path (2.1.239).

## 🟡 Important (rough edges, not blockers)

Expand All @@ -92,6 +91,22 @@ replaces the terminal.
needs an `/api/*` route first. Add as needed (the checklist spec establishes the
route+tool pattern).

## 🟢 Env hygiene (latent, not breaking)

- **Two `codex` copies**, the same shape as the claude footgun that was just
closed: `/usr/local/bin/codex` is **0.130.0** (root-owned, stale) while fnm's
shim is **0.145.0** — the version every codex behaviour in this repo was
verified against. The app resolves via `which`, so it picks whichever the
launching shell's PATH puts first. Today the running instance gets 0.145.0
through an fnm shim under `/run/user/1000/…`, but that path is tmpfs, is
per-shell-session, and vanishes on reboot or when launched from a desktop
launcher — at which point `/usr/local/bin/codex` wins.
**Checked 2026-08-21: not currently breaking anything.** Both versions carry
`--skip-git-repo-check` and `--dangerously-bypass-approvals-and-sandbox`, and
both have the `resume` subcommand with `--last`. So this is version ambiguity
waiting to bite, not a live bug. `sudo rm /usr/local/bin/codex` closes it the
same way the claude one was closed (root-owned, needs your own shell).

## ⚪ Inert / deferred (known, intentional)

- Custom keyboard shortcuts render but are inert (`shortcuts-tab.tsx:54`).
Expand All @@ -118,5 +133,6 @@ replaces the terminal.

## Suggested order

`#1 rm stale claude` (free) → `#3 effort wiring` → `#4 checklist/roadmap MCP` →
`#5 MCP gaps`. `#2` is cosmetic cleanup; do it opportunistically.
Nothing is critical. Next by value: **dogfood agents-in-columns** on a real
board → `#3 effort wiring` → `#4 checklist/roadmap MCP` → `#5 MCP gaps`. `#2`
(inert settings surfaces) and the codex env-hygiene note are opportunistic.
106 changes: 106 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ git2 = "0.19"
axum = "0.8"
dirs = "5"
log = "0.4"
# Makes the app's existing log:: diagnostics visible. Without a logger
# installed the `log` crate is a silent no-op, so every warn!/info! in the
# pipeline and chat layers went nowhere. Off unless RUST_LOG is set.
env_logger = "0.11"

# Local whisper transcription (optional - requires macOS 10.15+)
whisper-rs = { version = "0.15", optional = true }
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ use whisper::AudioRecorder;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Install a logger so the app's `log::` diagnostics are actually emitted.
// The `log` crate is a silent no-op until something registers a logger, so
// until now every warn!/info! in the pipeline, chat and trigger layers went
// nowhere — which made a failing trigger indistinguishable from one that
// never fired. Quiet by default; `RUST_LOG=kaitencode=debug` turns it on.
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("kaitencode=warn"),
)
.format_timestamp_millis()
.init();

let conn = db::init().expect("Failed to initialize database");

// Clear stale cli_session_id references (previous app sessions are dead)
Expand Down
153 changes: 123 additions & 30 deletions src-tauri/src/pipeline/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,71 @@ pub(crate) fn resolve_model_override(
.map(ToString::to_string)
}

/// Add patterns to the repo's git exclude file, so trigger scratch files
/// (`.task.md`, `.agent.md`) don't end up in the agent's commit.
///
/// The path has to come from git, not from string-joining `.git/info/exclude`
/// onto the working dir. In a **linked worktree `.git` is a file**, not a
/// directory, so that join names something that never exists — the old code
/// checked `.exists()`, found nothing, and silently skipped the whole
/// exclusion. Every worktree-based agent run has been committing `.task.md`
/// as a result. `rev-parse --git-path` resolves to the real common-dir file.
fn exclude_from_git(working_dir: &str, patterns: &[&str]) {
if working_dir.is_empty() {
return;
}
let Ok(output) = std::process::Command::new("git")
.args(["-C", working_dir, "rev-parse", "--git-path", "info/exclude"])
.output()
else {
return;
};
if !output.status.success() {
return;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
return;
}
// `--git-path` answers relative to the -C directory when the path is inside
// the repo it was asked from.
let path = {
let p = PathBuf::from(&raw);
if p.is_absolute() {
p
} else {
Path::new(working_dir).join(p)
}
};

let existing = std::fs::read_to_string(&path).unwrap_or_default();
let missing: Vec<&str> = patterns
.iter()
.copied()
.filter(|pat| !existing.lines().any(|line| line.trim() == *pat))
.collect();
if missing.is_empty() {
return;
}

if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut next = existing.trim_end().to_string();
if !next.is_empty() {
next.push('\n');
}
next.push_str(&missing.join("\n"));
next.push('\n');
if let Err(e) = std::fs::write(&path, next) {
log::warn!(
"[triggers] Could not update git exclude at {}: {}",
path.display(),
e
);
}
}

/// Look up the skills an agent references, dropping ids that no longer exist.
///
/// Deliberately lenient: a deleted skill shows in the dossier as "missing
Expand Down Expand Up @@ -1841,36 +1906,15 @@ fn execute_spawn_cli(
}
}

// Exclude .task.md from git (avoid agent committing it)
let exclude_path = std::path::Path::new(&working_dir)
.join(".git")
.join("info")
.join("exclude");
if exclude_path.exists() {
if let Ok(content) = std::fs::read_to_string(&exclude_path) {
if !content.contains(".task.md") {
let _ = std::fs::write(
&exclude_path,
format!(
"{}\n.task.md\n.task-handoff.md\n{}\n",
content.trim_end(),
roster::plan::AGENT_INSTRUCTIONS_FILE
),
);
} else if !content.contains(roster::plan::AGENT_INSTRUCTIONS_FILE) {
// Worktrees created before agents existed already list
// .task.md, so the branch above never fires for them.
let _ = std::fs::write(
&exclude_path,
format!(
"{}\n{}\n",
content.trim_end(),
roster::plan::AGENT_INSTRUCTIONS_FILE
),
);
}
}
}
// Keep the scratch files out of git.
exclude_from_git(
&working_dir,
&[
".task.md",
".task-handoff.md",
roster::plan::AGENT_INSTRUCTIONS_FILE,
],
);
}

// `runtime_mode` (resolved above) is already normalized:
Expand Down Expand Up @@ -3525,6 +3569,55 @@ mod tests {
db::update_column(conn, column_id, None, None, None, None, None, Some(&json)).unwrap();
}

#[test]
fn exclude_from_git_writes_to_the_path_git_reports() {
// The bug this replaced: joining `.git/info/exclude` onto the working
// dir. In a linked worktree `.git` is a FILE, so that path never
// exists and the exclusion was silently skipped — every worktree run
// committed `.task.md`.
let dir = std::env::temp_dir().join(format!("kc-excl-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let ok = std::process::Command::new("git")
.args(["-C", dir.to_str().unwrap(), "init", "-q"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return; // no git on this machine; nothing to assert
}

let repo = dir.to_string_lossy().to_string();
exclude_from_git(&repo, &[".task.md", ".agent.md"]);

let exclude = dir.join(".git").join("info").join("exclude");
let body = std::fs::read_to_string(&exclude).unwrap();
assert!(body.lines().any(|l| l.trim() == ".task.md"), "{}", body);
assert!(body.lines().any(|l| l.trim() == ".agent.md"), "{}", body);

// Idempotent: running again must not duplicate entries.
exclude_from_git(&repo, &[".task.md", ".agent.md"]);
let again = std::fs::read_to_string(&exclude).unwrap();
assert_eq!(again.matches(".task.md").count(), 1, "{}", again);

// A pattern not yet present is appended without disturbing the rest.
exclude_from_git(&repo, &[".task.md", "brand-new.md"]);
let third = std::fs::read_to_string(&exclude).unwrap();
assert!(third.lines().any(|l| l.trim() == "brand-new.md"));
assert_eq!(third.matches(".agent.md").count(), 1);

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn exclude_from_git_is_a_no_op_outside_a_repo() {
let dir = std::env::temp_dir().join(format!("kc-norepo-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
exclude_from_git(&dir.to_string_lossy(), &[".task.md"]);
assert!(!dir.join(".git").exists());
exclude_from_git("", &[".task.md"]);
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn columns_using_agent_sweeps_every_workspace() {
// Agents are global, so deleting one has to account for boards the
Expand Down
Loading