M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
[package]
license = "Apache-2.0"
name = "kigi-file-utils"
version.workspace = true
edition.workspace = true
description = "Local data collection: per-turn event tracking"
authors = ["xAI"]
[dependencies]
anyhow = { workspace = true }
aws-sdk-s3 = { version = "1", default-features = false, features = [
"rt-tokio",
] }
aws-config = { version = "1", default-features = false, features = [
"rt-tokio",
"sso",
"credentials-process",
] }
aws-smithy-http-client = { version = "1", features = ["rustls-ring"] }
serde = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "io-util", "fs"] }
sha2 = { workspace = true, features = ["force-soft"] }
opentelemetry = { workspace = true }
tracing-opentelemetry = { workspace = true }
[features]
default-bazel = []
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "net", "time"] }
opentelemetry_sdk = { workspace = true }
tracing-subscriber = { workspace = true }
[lints]
workspace = true
@@ -0,0 +1,179 @@
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::Serialize;
use super::types::Event;
#[derive(Serialize)]
struct EventEntry {
ts: String,
#[serde(flatten)]
event: Event,
}
const EVENTS_FILE: &str = "events.jsonl";
/// Shared event writer for `events.jsonl`. `Clone + Send + Sync`.
#[derive(Clone)]
pub struct EventWriter {
inner: Arc<EventWriterInner>,
}
struct EventWriterInner {
file: Mutex<Option<File>>,
error_logged: AtomicBool,
}
impl EventWriter {
pub fn open(session_dir: &Path) -> Self {
let path = session_dir.join(EVENTS_FILE);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| {
tracing::warn!(path = %path.display(), error = %e, "failed to open {EVENTS_FILE}");
e
})
.ok();
Self {
inner: Arc::new(EventWriterInner {
file: Mutex::new(file),
error_logged: AtomicBool::new(false),
}),
}
}
/// No-op writer that discards all events.
pub fn noop() -> Self {
Self {
inner: Arc::new(EventWriterInner {
file: Mutex::new(None),
error_logged: AtomicBool::new(true), // suppress error logging
}),
}
}
pub fn emit(&self, event: Event) {
let entry = EventEntry {
ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
event,
};
let Ok(mut line) = serde_json::to_vec(&entry) else {
return;
};
line.push(b'\n');
let Ok(mut guard) = self.inner.file.lock() else {
return;
};
if let Some(ref mut f) = *guard
&& let Err(e) = f.write_all(&line)
&& !self.inner.error_logged.swap(true, Ordering::Relaxed)
{
tracing::warn!(error = %e, "{EVENTS_FILE} write failed");
}
}
}
impl std::fmt::Debug for EventWriter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventWriter").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::types::{
EVENT_SCHEMA_VERSION, Event, SessionRelationship, TurnOutcomeLabel,
};
fn _assert_event_writer_is_send_sync_clone()
where
EventWriter: Send + Sync + Clone,
{
}
#[test]
fn test_emit_writes_jsonl() {
let dir = tempfile::tempdir().unwrap();
let writer = EventWriter::open(dir.path());
writer.emit(Event::TurnStarted {
session_id: "test-session".into(),
turn_number: 1,
model_id: "grok-3".into(),
yolo_mode: false,
conversation_message_count: 0,
session_relationship: SessionRelationship::Primary,
schema_version: EVENT_SCHEMA_VERSION.into(),
redirect_kind: None,
});
writer.emit(Event::FirstToken);
writer.emit(Event::TurnEnded {
outcome: TurnOutcomeLabel::Completed,
cancellation_category: None,
cancellation_context: None,
});
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
let lines: Vec<&str> = text.trim().split('\n').collect();
assert_eq!(lines.len(), 3);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first["type"], "turn_started");
assert_eq!(first["session_id"], "test-session");
assert!(first["ts"].as_str().is_some());
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second["type"], "first_token");
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
assert_eq!(third["type"], "turn_ended");
assert_eq!(third["outcome"], "completed");
assert!(third.get("cancellation_category").is_none());
}
#[test]
fn cloned_writer_shares_file() {
let dir = tempfile::tempdir().unwrap();
let w1 = EventWriter::open(dir.path());
let w2 = w1.clone();
w1.emit(Event::FirstToken);
w2.emit(Event::FirstToken);
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
let lines: Vec<&str> = text.trim().split('\n').collect();
assert_eq!(lines.len(), 2, "both writes should go to the same file");
}
#[test]
fn mcp_server_failed_serializes_enum_error_type() {
let dir = tempfile::tempdir().unwrap();
let w = EventWriter::open(dir.path());
w.emit(Event::McpServerFailed {
server_name: "confluence".into(),
transport: Some("http".into()),
target: Some("https://mcp.confluence.example.com".into()),
error_type: crate::events::types::McpErrorCategory::Timeout,
error_message: "timed out after 10s".into(),
duration_ms: Some(10002),
timeout_sec: Some(10),
});
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
let val: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(val["type"], "mcp_server_failed");
assert_eq!(val["error_type"], "timeout");
assert_eq!(val["server_name"], "confluence");
assert_eq!(val["duration_ms"], 10002);
}
}
@@ -0,0 +1,12 @@
//! Per-session event log (`events.jsonl`).
pub mod log;
pub mod tracker;
pub mod types;
pub use log::EventWriter;
pub use tracker::EventTracker;
pub use types::{
CancellationCategory, EVENT_SCHEMA_VERSION, Event, McpConfigServer, McpErrorCategory,
PermissionDecision, Phase, SessionRelationship, ToolOutcome, TurnOutcomeLabel,
};
@@ -0,0 +1,261 @@
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::time::Instant;
use super::log::EventWriter;
use super::types::{CancellationCategory, Event, RedirectKind, TurnOutcomeLabel};
/// Per-session event state. `!Send` — lives on the session actor.
/// Background tasks use `tracker.writer()` to get a `Clone + Send + Sync` handle.
pub struct EventTracker {
writer: EventWriter,
turn_ended_emitted: Cell<bool>,
active_tool: RefCell<Option<(String, Instant)>>,
turn_tool_count: Cell<u32>,
/// Cross-turn one-shot: the *fatal* user-interrupt cause that cancelled the
/// most recent turn (set by the cancel paths), consumed by the *next* real
/// user prompt to tag `UserItem::prior_turn_interrupt`. Deliberately NOT
/// reset by `begin_turn` — it must survive into the next turn; the consumer
/// clears it via `take_prior_interrupt_category`. Interjections are NOT
/// recorded here (they don't cancel the turn; see `Event::Interjected`).
prior_interrupt_category: Cell<Option<CancellationCategory>>,
/// Cross-turn one-shot: the redirect mechanism for the NEXT turn after a
/// mid-turn abort — `CancelThenSend` (nothing was queued) or
/// `QueuedAfterCancel` (a prompt sat queued behind the aborted turn). Set
/// by `cancel_running_task`, consumed by the next user `turn_started` to
/// stamp `Event::TurnStarted::redirect_kind`. Like `prior_interrupt_category`
/// it deliberately survives `begin_turn` so it reaches the next real turn.
prior_redirect_kind: Cell<Option<RedirectKind>>,
/// Cross-turn one-shot: armed by the cancel path only when a turn was aborted
/// mid-stream with NO tool in flight, so neither the dangling-tool-call
/// repair nor a permission tool-result will tell the model it was
/// interrupted. Consumed by the next *real* user prompt to inject an
/// interrupt `<system-reminder>`. Like the markers above it deliberately
/// survives `begin_turn` so it reaches the next real turn.
pending_interrupt_reminder: Cell<bool>,
}
impl std::fmt::Debug for EventTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let active_tool = self.active_tool.borrow();
f.debug_struct("EventTracker")
.field("writer", &self.writer)
.field("turn_ended_emitted", &self.turn_ended_emitted.get())
.field("turn_tool_count", &self.turn_tool_count.get())
.field("active_tool", &active_tool.as_ref().map(|(name, _)| name))
.field(
"prior_interrupt_category",
&self.prior_interrupt_category.get(),
)
.field("prior_redirect_kind", &self.prior_redirect_kind.get())
.field(
"pending_interrupt_reminder",
&self.pending_interrupt_reminder.get(),
)
.finish()
}
}
impl EventTracker {
pub fn new(session_dir: &Path) -> Self {
Self {
writer: EventWriter::open(session_dir),
turn_ended_emitted: Cell::new(false),
active_tool: RefCell::new(None),
turn_tool_count: Cell::new(0),
prior_interrupt_category: Cell::new(None),
prior_redirect_kind: Cell::new(None),
pending_interrupt_reminder: Cell::new(false),
}
}
/// Clone the writer for background tasks.
pub fn writer(&self) -> EventWriter {
self.writer.clone()
}
pub fn emit(&self, event: Event) {
self.writer.emit(event);
}
/// Reset per-turn state. Called at the start of each turn.
pub fn begin_turn(&self) {
self.turn_ended_emitted.set(false);
self.turn_tool_count.set(0);
}
/// Emit `turn_ended` with a double-emission guard.
pub fn emit_turn_ended(
&self,
outcome: TurnOutcomeLabel,
category: Option<CancellationCategory>,
context: Option<serde_json::Value>,
) {
if self.turn_ended_emitted.replace(true) {
return;
}
self.emit(Event::TurnEnded {
outcome,
cancellation_category: category,
cancellation_context: context,
});
}
/// Set the active tool for cancellation tracking and return the start instant.
pub fn tool_started(&self, tool_name: String) -> Instant {
let now = Instant::now();
*self.active_tool.borrow_mut() = Some((tool_name, now));
self.turn_tool_count.set(self.turn_tool_count.get() + 1);
now
}
pub fn tool_count_this_turn(&self) -> u32 {
self.turn_tool_count.get()
}
pub fn has_active_tool(&self) -> bool {
self.active_tool.borrow().is_some()
}
pub fn tool_finished(&self) {
*self.active_tool.borrow_mut() = None;
}
/// Cancel in-flight tool and emit `ToolCompleted(cancelled)`.
/// Called from `cancel_running_task()` before `turn_ended`.
pub fn cancel_active_tool(&self) {
if let Some((tool_name, start)) = self.active_tool.borrow_mut().take() {
self.emit(Event::ToolCompleted {
tool_name,
duration_ms: start.elapsed().as_millis() as u64,
outcome: super::types::ToolOutcome::Cancelled,
});
}
}
/// Record the *fatal* user-interrupt cause that cancelled this turn so the
/// *next* real user prompt can be tagged. Overwrites any prior value (latest
/// cause wins).
pub fn set_prior_interrupt_category(&self, category: CancellationCategory) {
self.prior_interrupt_category.set(Some(category));
}
/// Take (and clear) the recorded prior-turn interrupt cause.
pub fn take_prior_interrupt_category(&self) -> Option<CancellationCategory> {
self.prior_interrupt_category.take()
}
/// Record the redirect mechanism (`CancelThenSend` / `QueuedAfterCancel`)
/// for the next turn after a mid-turn abort. Overwrites any prior value
/// (latest abort wins).
pub fn set_prior_redirect_kind(&self, kind: RedirectKind) {
self.prior_redirect_kind.set(Some(kind));
}
/// Take (and clear) the recorded prior-turn redirect kind.
pub fn take_prior_redirect_kind(&self) -> Option<RedirectKind> {
self.prior_redirect_kind.take()
}
/// Arm the one-shot interrupt reminder for the next real user prompt. Set
/// only on the cancel path when no tool was in flight (the case where the
/// model would otherwise get no signal that it was interrupted).
pub fn set_pending_interrupt_reminder(&self) {
self.pending_interrupt_reminder.set(true);
}
/// Take (and clear) the pending interrupt-reminder flag.
pub fn take_pending_interrupt_reminder(&self) -> bool {
self.pending_interrupt_reminder.replace(false)
}
/// Emit PhaseChanged(PermissionPrompt) → PermissionRequested.
/// Returns the Instant for `permission_resolved()` to compute wait_ms.
pub fn permission_requested(&self, tool_name: &str) -> Instant {
self.emit(Event::PhaseChanged {
phase: super::types::Phase::PermissionPrompt,
});
self.emit(Event::PermissionRequested {
tool_name: tool_name.to_string(),
});
Instant::now()
}
pub fn permission_resolved(
&self,
tool_name: &str,
decision: super::types::PermissionDecision,
start: Instant,
) {
self.emit(Event::PermissionResolved {
tool_name: tool_name.to_string(),
decision,
wait_ms: start.elapsed().as_millis() as u64,
});
self.emit(Event::PhaseChanged {
phase: super::types::Phase::ToolExecution,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prior_interrupt_markers_are_one_shot_and_survive_begin_turn() {
let dir = tempfile::tempdir().expect("tempdir");
let t = EventTracker::new(dir.path());
// Defaults: nothing recorded.
assert_eq!(t.take_prior_interrupt_category(), None);
assert!(t.take_prior_redirect_kind().is_none());
assert!(!t.take_pending_interrupt_reminder());
// Cancel cause is consumed exactly once.
t.set_prior_interrupt_category(CancellationCategory::MidTurnAbort);
assert_eq!(
t.take_prior_interrupt_category(),
Some(CancellationCategory::MidTurnAbort)
);
assert_eq!(t.take_prior_interrupt_category(), None);
// Interrupt-reminder flag is consumed exactly once.
t.set_pending_interrupt_reminder();
assert!(t.take_pending_interrupt_reminder());
assert!(!t.take_pending_interrupt_reminder());
// Redirect kind is consumed exactly once.
t.set_prior_redirect_kind(RedirectKind::QueuedAfterCancel);
assert!(matches!(
t.take_prior_redirect_kind(),
Some(RedirectKind::QueuedAfterCancel)
));
assert!(t.take_prior_redirect_kind().is_none());
// `begin_turn` runs at the START of a turn — BEFORE the next real user
// prompt consumes the markers — so it must NOT clear these cross-turn
// markers (it only resets per-turn counters). A regression here would
// silently drop the `prior_turn_interrupt` tag / `redirect_kind`.
t.set_prior_interrupt_category(CancellationCategory::PermissionRejected);
t.set_prior_redirect_kind(RedirectKind::CancelThenSend);
t.set_pending_interrupt_reminder();
t.begin_turn();
assert_eq!(
t.take_prior_interrupt_category(),
Some(CancellationCategory::PermissionRejected),
"begin_turn must preserve the cross-turn interrupt cause"
);
assert!(
matches!(
t.take_prior_redirect_kind(),
Some(RedirectKind::CancelThenSend)
),
"begin_turn must preserve the cross-turn redirect kind"
);
assert!(
t.take_pending_interrupt_reminder(),
"begin_turn must preserve the pending interrupt reminder"
);
}
}
@@ -0,0 +1,855 @@
use serde::{Deserialize, Serialize};
/// Schema version for the event log format. Bumped on breaking changes.
pub const EVENT_SCHEMA_VERSION: &str = "1.0";
/// A single event in the per-turn event log.
///
/// Each variant maps to a line in `events.jsonl`. The `type` field is the
/// snake_case variant name (via `#[serde(tag = "type")]`). The `ts` field
/// is added by [`super::log::EventWriter::emit`] at recording time.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
TurnStarted {
session_id: String,
turn_number: u64,
model_id: String,
yolo_mode: bool,
conversation_message_count: usize,
session_relationship: SessionRelationship,
schema_version: String,
/// Set when this turn is the user's redirect after a Ctrl+C / Esc abort
/// of the previous turn: `cancel_then_send` (the user typed a fresh
/// prompt) or `queued_after_cancel` (a prompt sat queued behind the
/// aborted turn and was promoted). `None` for normal turns. Pairs with
/// the `interjected` event's `redirect_kind` so the trace pipeline can
/// query every user redirect through one shared field.
#[serde(skip_serializing_if = "Option::is_none")]
redirect_kind: Option<RedirectKind>,
},
PhaseChanged {
phase: Phase,
},
FirstToken,
LoopStarted {
loop_index: u32,
},
ToolStarted {
tool_name: String,
},
ToolCompleted {
tool_name: String,
duration_ms: u64,
outcome: ToolOutcome,
},
PermissionRequested {
tool_name: String,
},
PermissionResolved {
tool_name: String,
decision: PermissionDecision,
wait_ms: u64,
},
TurnEnded {
outcome: TurnOutcomeLabel,
#[serde(skip_serializing_if = "Option::is_none")]
cancellation_category: Option<CancellationCategory>,
#[serde(skip_serializing_if = "Option::is_none")]
cancellation_context: Option<serde_json::Value>,
},
/// A mid-turn user interjection was merged into the running turn. Unlike
/// `TurnEnded`, an interjection never ends the turn — the user steered
/// in-flight (Ctrl+Enter) or promoted a queued prompt into the running
/// turn. `source` distinguishes those two paths; `image_count` is how
/// many images rode along (0 for text-only). Emitted at enqueue time,
/// once per interjection.
Interjected {
source: InterjectionSource,
image_count: u32,
/// Always [`RedirectKind::Interjection`]. Carried so the shared
/// `redirect_kind` field is queryable uniformly across every redirect
/// event (`interjected` + the next-turn-after-abort `turn_started`).
redirect_kind: RedirectKind,
},
YoloToggled {
enabled: bool,
},
/// Emitted when goal mode auto-pauses an active goal. The `reason`
/// records which automatic trigger fired: user cancel, infra-classified
/// turn error, consecutive-failed-turn back-off, or verification block.
GoalAutoPaused {
reason: GoalPauseReasonTelemetry,
},
/// Runtime TodoGate nudged the model because a content-only turn ended
/// with pending or unbacked in_progress todos. `reason` is the
/// `TODO_GATE_*` discriminator constant in `kigi-shell::session::events`.
TodoGateFired {
fires: u32,
pending: usize,
in_progress: usize,
reason: &'static str,
},
/// TodoGate hit its per-prompt fire cap. Distinct event so cap-exhaustion
/// is not conflated with a normal fire in the dashboards.
TodoGateExhausted {
pending: usize,
},
/// Layer-3 LazinessDetector classifier completed and produced a verdict.
/// Fires even in observation-only mode (`max_nudges_per_session = 0`)
/// so dashboards can validate classification quality before any nudges
/// are injected. `category` is one of the `LAZINESS_*` discriminator
/// constants in `kigi-shell::session::events`.
LazinessClassifierFired {
model_id: String,
category: &'static str,
confidence: f32,
},
/// Layer-3 LazinessDetector injected a system-reminder nudge into the
/// session. Always preceded by a `LazinessClassifierFired` for the
/// same classification. Suppressed when the per-session cap is 0.
LazinessNudgeFired {
model_id: String,
category: &'static str,
nudges_remaining: u32,
},
/// Layer-3 LazinessDetector terminated without producing a verdict.
/// `reason` is one of the `LAZINESS_ABORT_*` discriminator constants
/// in `kigi-shell::session::events`.
LazinessClassifierAborted {
reason: &'static str,
},
/// Goal-achievement classifier subagent was invoked. Fires once per
/// classifier attempt regardless of outcome; pairs with exactly one
/// of `GoalClassifierVerdict`, `GoalClassifierFailOpen`, or
/// `GoalClassifierFailClosed` once the run terminates.
GoalClassifierFired {
attempt: u32,
max_runs: u32,
model_id: String,
},
/// Goal-achievement classifier returned a parsed verdict (Achieved or
/// NotAchieved). `latency_ms` is the spawn-to-parse wall clock.
GoalClassifierVerdict {
verdict: GoalClassifierVerdictTelemetry,
attempt: u32,
latency_ms: u64,
},
/// Goal-achievement classifier could not produce a usable verdict due
/// to an INFRA-class failure (timeout, sampler error, abort, file IO).
/// Caller fails OPEN — treats as Achieved — and records the reason.
GoalClassifierFailOpen {
reason: &'static str,
attempt: u32,
latency_ms: u64,
},
/// Goal-achievement classifier could not produce a usable verdict due
/// to a PARSE-class failure (malformed terminal token, missing details
/// file). Caller fails CLOSED — treats as NotAchieved.
GoalClassifierFailClosed {
reason: &'static str,
attempt: u32,
},
/// Goal-achievement classifier hit the per-goal run cap. Distinct event
/// so cap exhaustion is not conflated with a normal verdict.
GoalClassifierCapReached {
attempt: u32,
},
/// Mid-turn `update_goal(completed: true)` was deferred to the next
/// turn-end drain (Guard 2). `pending_depth` is the queue length
/// AFTER the push so dashboards can spot accumulation in real time.
GoalClassifierMidTurnDeferred {
pending_depth: u32,
},
/// `update_goal(completed: true)` arrived AFTER the classifier
/// cap had already auto-paused the goal. `attempts_seen` is the
/// real `classifier_runs_attempted` snapshot (typically the cap),
/// never `0`.
GoalClassifierDroppedAfterCap {
attempts_seen: u32,
},
/// A cap-pause cleared the pending-classifier-completions queue.
/// One summary event per pause, not per-entry — `dropped` is the
/// total entry count.
GoalClassifierPendingQueueCleared {
dropped: u32,
},
/// Goal planner subagent was invoked. Fires once per attempt;
/// pairs with exactly one of `GoalPlannerCompleted` or
/// `GoalPlannerFailClosed` once the run terminates. `max_runs`
/// mirrors the classifier event for dashboard symmetry — the
/// planner cap is always `1` today.
GoalPlannerFired {
attempt: u32,
max_runs: u32,
model_id: String,
},
/// Planner subagent wrote a plan file successfully.
/// `latency_ms` is the spawn-to-write wall clock.
GoalPlannerCompleted {
attempt: u32,
latency_ms: u64,
},
/// Planner subagent failed and the harness paused the goal
/// fail-closed. `reason` is one of the `GOAL_PLANNER_FAIL_CLOSED_*`
/// discriminator constants in `kigi-shell::session::events`.
GoalPlannerFailClosed {
reason: &'static str,
attempt: u32,
latency_ms: u64,
},
/// Stall-triggered strategist subagent was invoked after
/// `consecutive_failures` consecutive `NotAchieved` verifications.
/// Fires once per trigger (at N, 2N, …); pairs with exactly one of
/// `GoalStrategistCompleted` or `GoalStrategistFailed`. Unlike the
/// planner the strategist is fail-OPEN — a failure never pauses the
/// goal. `attempt` is the verifier attempt that triggered it. `every`
/// is the resolved cadence N, so a configured override is observable.
GoalStrategistFired {
attempt: u32,
consecutive_failures: u32,
every: u32,
model_id: String,
},
/// Strategist subagent wrote a strategy note successfully.
/// `latency_ms` is the spawn-to-write wall clock.
GoalStrategistCompleted {
attempt: u32,
consecutive_failures: u32,
latency_ms: u64,
},
/// Strategist subagent failed; the harness logged it and continued
/// the normal loop (fail-OPEN — the goal is NOT paused). `reason` is
/// one of the `GOAL_STRATEGIST_FAILED_*` discriminator constants in
/// `kigi-shell::session::events`.
GoalStrategistFailed {
reason: &'static str,
attempt: u32,
consecutive_failures: u32,
latency_ms: u64,
},
/// The plan.md-safety guard could not restore the verifier-judged
/// contract to its pre-strategist bytes (a write/remove failed, or a
/// symlink was planted at the path). The contract may be corrupted —
/// surfaced so it is observable rather than a silent `warn!`. `reason`
/// is one of the `GOAL_STRATEGIST_RESTORE_*` discriminator constants in
/// `kigi-shell::session::events`.
GoalStrategistContractRestoreFailed {
reason: &'static str,
attempt: u32,
},
/// Goal summarizer subagent was invoked ONCE after the goal was
/// verified-achieved (real `Achieved`, not the infra fail-open), to
/// generate the closing user-facing summary. Pairs with exactly one of
/// `GoalSummarizerCompleted` or `GoalSummarizerFailOpen`. Fail-OPEN — a
/// failure never blocks completion. `attempt` is the achieving verifier
/// attempt; `model_id` is the inherited session model.
GoalSummarizerFired {
attempt: u32,
model_id: String,
},
/// Summarizer returned a non-empty summary; the harness surfaced it as the
/// goal turn's closing message. `latency_ms` is the spawn-to-summary wall
/// clock.
GoalSummarizerCompleted {
attempt: u32,
latency_ms: u64,
},
/// Summarizer failed (transport / runtime / cancel / empty output); the
/// harness skipped the closing summary and completed the goal normally
/// (fail-OPEN — completion is never blocked). `reason` is one of the
/// `GOAL_SUMMARIZER_FAIL_OPEN_*` discriminator constants in
/// `kigi-shell::session::events`.
GoalSummarizerFailOpen {
reason: &'static str,
attempt: u32,
latency_ms: u64,
},
/// A `/goal` subagent role (planner, strategist, or a skeptic index)
/// committed to an explicit model+toolset selection. `role` is one of
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
/// skeptic panel. `source` is the resolution provenance: a
/// committed explicit pair is always `remote` (the only non-inherit
/// source); `default`/kill-switch resolutions inherit the current
/// model and do not emit this event. Emitted once per role/skeptic-
/// index when an explicit selection is committed.
GoalRoleModelResolved {
role: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
skeptic_idx: Option<u32>,
model_id: String,
agent_type: String,
source: &'static str,
},
/// A `/goal` subagent role fell open to the current model because its
/// configured pair was unusable. `role` is one of
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
/// skeptic panel. `reason` is one of the
/// `GOAL_ROLE_MODEL_FAIL_OPEN_*` discriminator constants in
/// `kigi-shell::session::events`. Fail-open never pauses the goal.
GoalRoleModelFailOpen {
role: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
skeptic_idx: Option<u32>,
reason: &'static str,
},
/// One skeptic in the adversarial panel returned a verdict. Fires
/// `N` times per verification stage (where N is
/// `goal_verifier_count`). `confidence` is the JSON `confidence`
/// field; the wire vocabulary is `high|medium|low|unknown`.
/// `latency_ms` is the per-skeptic spawn-to-verdict wall clock —
/// dashboards can surface slow outliers even though the panel-
/// level emission is batched via `join_all`.
GoalVerifierSkepticVerdict {
attempt: u32,
skeptic_idx: u32,
refuted: bool,
confidence: &'static str,
latency_ms: u64,
},
/// Aggregate verdict across all N skeptics. `refuted_count` /
/// `total` is the majority-refute fraction; `achieved` is the
/// stage's final verdict (true ⇒ survives, false ⇒ majority-refute).
GoalVerifierAggregateVerdict {
attempt: u32,
refuted_count: u32,
total: u32,
achieved: bool,
},
/// The stop-detector matched a known bail/hand-off/verdict
/// pattern in the LAST paragraph of the assistant's turn-final
/// text while the goal stayed `Active` with pending todos. The
/// harness defeated the premature stop by queuing the bail-specific
/// continuation reminder; this event records the matched pattern
/// label so dashboards can audit precision/recall of the regex
/// panel. `pattern` is one of the stable labels
/// enumerated by
/// `kigi-shell::session::goal_stop_detector::PATTERN_LABELS`;
/// the source-string provenance for each label is pinned by the
/// adjacent `STOP_REGEX_SOURCES` table.
///
/// Under-counts by design: fires only when a fresh bail continuation
/// is queued. If a classifier-rejection nudge is already pending, the
/// shared idempotency gate suppresses both the duplicate push and
/// JSON-RPC message and was skipped instead of tearing down the
/// this event, so dashboards see a lower bound.
GoalPrematureStopDetected {
pattern: &'static str,
},
// ── MCP Diagnostics ──────────────────────────────────────────
McpConfigResolved {
servers: Vec<McpConfigServer>,
disabled: Vec<String>,
},
McpManagedConfigResult {
server_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
#[serde(rename = "mcp_oauth_discovery_timeout")]
McpOAuthDiscoveryTimeout {
server_name: String,
url: String,
},
McpServerStarting {
server_name: String,
transport: String,
target: String,
timeout_sec: u64,
},
McpServerConnected {
server_name: String,
transport: String,
tool_count: u32,
duration_ms: u64,
tools: Vec<String>,
},
McpServerFailed {
server_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
transport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
error_type: McpErrorCategory,
error_message: String,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_sec: Option<u64>,
},
McpToolRegistrationFailed {
server_name: String,
tool_name: String,
error: String,
},
McpInitCompleted {
total_servers: u32,
succeeded: u32,
failed: u32,
auth_required: u32,
total_tools: u32,
duration_ms: u64,
is_reinit: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
failed_servers: Vec<String>,
},
McpInitCancelled {
reason: String,
},
McpToolCallStarted {
server_name: String,
tool_name: String,
call_id: String,
timeout_sec: u64,
},
McpToolCallCompleted {
server_name: String,
tool_name: String,
call_id: String,
duration_ms: u64,
success: bool,
is_timeout: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
reconnect_attempted: bool,
auth_retry_attempted: bool,
},
McpTransportError {
server_name: String,
tool_name: String,
error: String,
},
/// A line on an MCP stdio server's stdout could not be decoded as a
/// transport. Surfaces the otherwise-invisible "connector shows but
/// doesn't work" case (a server logging to stdout, a JSON-RPC batch
/// array, or an off-spec response). Distinct from `McpTransportError`,
/// environment; either the orchestrator called
/// which is a per-tool-call transport failure.
McpTransportDecodeError {
server_name: String,
error: String,
/// Truncated copy of the offending line, for diagnosis.
sample: String,
},
McpTransportReconnect {
server_name: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
McpAuthRetry {
server_name: String,
trigger: String,
success: bool,
},
McpHealthCheck {
server_name: String,
healthy: bool,
#[serde(skip_serializing_if = "Option::is_none")]
client_state: Option<String>,
},
McpServerToggled {
server_name: String,
enabled: bool,
},
}
/// Where a mid-turn interjection originated. Drives the `source` field on
/// [`Event::Interjected`].
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InterjectionSource {
/// Direct `x.ai/interject` while a turn was running (Ctrl+Enter).
Direct,
/// A queued (not-yet-running) prompt promoted into the running turn via
/// `InterjectQueuedPrompt` (queue "send now").
Queue,
}
/// The user-redirect mechanism behind an event — the shared discriminator that
/// lets the trace pipeline query every user steer through one field. Present on
/// [`Event::Interjected`] (always [`RedirectKind::Interjection`]) and, for the
/// next turn after a Ctrl+C / Esc abort, on [`Event::TurnStarted`]
/// ([`RedirectKind::CancelThenSend`] / [`RedirectKind::QueuedAfterCancel`]).
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RedirectKind {
/// Mid-turn interjection — Ctrl+O / `x.ai/interject`, or "Send now" on a
/// queued row. The turn keeps running; nothing is cancelled.
Interjection,
/// The turn was aborted (Ctrl+C / Esc) and the user then typed and sent a
/// fresh prompt as the next turn.
CancelThenSend,
/// The turn was aborted (Ctrl+C / Esc) while a prompt sat queued behind it;
/// that queued prompt was promoted as the next turn.
QueuedAfterCancel,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum McpErrorCategory {
SpawnFailed,
Timeout,
HandshakeFailed,
AuthRequired,
ClientError,
}
/// Server entry in `McpConfigResolved`.
#[derive(Debug, Clone, Serialize)]
pub struct McpConfigServer {
pub name: String,
pub transport: String,
pub source: String,
}
/// Telemetry mirror of `kigi-shell`'s `GoalClassifierVerdict`. Two
/// crates due to the orphan rule; the conversion lives in
/// `kigi-shell/src/session/events.rs`.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalClassifierVerdictTelemetry {
Achieved,
NotAchieved,
}
/// Telemetry mirror of `kigi-shell`'s `GoalPauseReason`. The two types
/// live in separate crates (orphan rule); the conversion lives in
/// `kigi-shell/src/session/events.rs`.
///
/// **Invariant:** when adding a new variant to either side, add the
/// matching variant here so the compiler-enforced `From` impl on the
/// shell side catches the drift at build time.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalPauseReasonTelemetry {
User,
BackOff,
/// Verification stage saw no fingerprint change in the flagged gaps
/// across consecutive attempts and auto-paused before the run cap.
NoProgress,
/// Verification determined the goal is not achievable in this
/// `update_goal(blocked_reason: ...)`, or every refuter classified
/// `update_goal(blocked_reason: ...)`, or every refuter classified
/// its gap as a contradiction / unverifiable blocker.
Verification,
/// Turn finished with `PromptTurnResult::Err` (infrastructure failure).
Infra,
}
/// Outcome of a single tool call. More granular than a boolean -- distinguishes
/// between tools that executed vs tools that were never run.
#[derive(Debug, Clone, Copy, Serialize, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ToolOutcome {
/// Tool executed and returned a result.
Success,
/// Tool executed but returned an error.
Error,
/// User rejected the permission prompt.
PermissionRejected,
/// User cancelled the permission prompt (Cmd+C).
PermissionCancelled,
/// User provided a followup message instead of approving.
Followup,
/// A user-configured hook blocked execution.
HookDenied,
/// Tool not found or arguments couldn't be parsed.
InvalidTool,
/// Tool was running when the turn was cancelled (Cmd+C).
Cancelled,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
WaitingForModel,
StreamingText,
StreamingReasoning,
ToolExecution,
PermissionPrompt,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionRelationship {
Primary,
#[allow(dead_code)]
Subagent,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnOutcomeLabel {
Completed,
Cancelled,
Error,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDecision {
Allow,
Deny,
Cancelled,
Followup,
}
// `Deserialize`/`PartialEq`/`Eq`/`Hash` let the workspace decode
// `cancellation_category` strings back into this enum. `snake_case` keeps the
// wire form identical, so adding `Deserialize` doesn't change serialization.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum CancellationCategory {
HookDenied,
PermissionRejected,
PermissionCancelled,
MidTurnAbort,
}
// Note: `From<&permission::Decision> for PermissionDecision` crosses the
// crate boundary (orphan rule) and lives in
// `kigi-shell/src/session/events.rs`.
#[cfg(test)]
mod tests {
use super::*;
/// Every variant must survive a `to_value` -> `from_value` round-trip.
#[test]
fn cancellation_category_round_trips_every_variant() {
for variant in [
CancellationCategory::HookDenied,
CancellationCategory::PermissionRejected,
CancellationCategory::PermissionCancelled,
CancellationCategory::MidTurnAbort,
] {
let value = serde_json::to_value(variant).unwrap();
let decoded: CancellationCategory = serde_json::from_value(value).unwrap();
assert_eq!(decoded, variant, "{variant:?} must round-trip");
}
}
/// Serialization is unchanged by the added derives (bare snake_case strings).
#[test]
fn cancellation_category_serializes_snake_case() {
for (variant, expected) in [
(CancellationCategory::HookDenied, "\"hook_denied\""),
(
CancellationCategory::PermissionRejected,
"\"permission_rejected\"",
),
(
CancellationCategory::PermissionCancelled,
"\"permission_cancelled\"",
),
(CancellationCategory::MidTurnAbort, "\"mid_turn_abort\""),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
}
}
#[test]
fn interjected_event_serializes_tag_source_and_count() {
let ev = Event::Interjected {
source: InterjectionSource::Direct,
image_count: 2,
redirect_kind: RedirectKind::Interjection,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "interjected");
assert_eq!(v["source"], "direct");
assert_eq!(v["image_count"], 2);
// Shared discriminator: always present on interjected events.
assert_eq!(v["redirect_kind"], "interjection");
let queue = serde_json::to_value(Event::Interjected {
source: InterjectionSource::Queue,
image_count: 0,
redirect_kind: RedirectKind::Interjection,
})
.unwrap();
assert_eq!(queue["source"], "queue");
assert_eq!(queue["image_count"], 0);
assert_eq!(queue["redirect_kind"], "interjection");
}
#[test]
fn redirect_kind_serializes_snake_case() {
for (variant, expected) in [
(RedirectKind::Interjection, "\"interjection\""),
(RedirectKind::CancelThenSend, "\"cancel_then_send\""),
(RedirectKind::QueuedAfterCancel, "\"queued_after_cancel\""),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
}
}
#[test]
fn turn_started_redirect_kind_present_when_set_omitted_when_none() {
let with_kind = serde_json::to_value(Event::TurnStarted {
session_id: "s".into(),
turn_number: 2,
model_id: "grok-4".into(),
yolo_mode: false,
conversation_message_count: 3,
session_relationship: SessionRelationship::Primary,
schema_version: EVENT_SCHEMA_VERSION.into(),
redirect_kind: Some(RedirectKind::QueuedAfterCancel),
})
.unwrap();
assert_eq!(with_kind["type"], "turn_started");
assert_eq!(with_kind["redirect_kind"], "queued_after_cancel");
let normal = serde_json::to_value(Event::TurnStarted {
session_id: "s".into(),
turn_number: 1,
model_id: "grok-4".into(),
yolo_mode: false,
conversation_message_count: 0,
session_relationship: SessionRelationship::Primary,
schema_version: EVENT_SCHEMA_VERSION.into(),
redirect_kind: None,
})
.unwrap();
assert!(
normal.get("redirect_kind").is_none(),
"redirect_kind must be omitted on a normal turn, got {normal}"
);
}
#[test]
fn goal_pause_reason_telemetry_serializes_snake_case() {
for (variant, expected) in [
(GoalPauseReasonTelemetry::User, "\"user\""),
(GoalPauseReasonTelemetry::BackOff, "\"back_off\""),
(GoalPauseReasonTelemetry::NoProgress, "\"no_progress\""),
(GoalPauseReasonTelemetry::Verification, "\"verification\""),
(GoalPauseReasonTelemetry::Infra, "\"infra\""),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
}
}
#[test]
fn goal_strategist_fired_serializes_cadence_field() {
// `every` must serialize as a plain number on the wire.
let ev = Event::GoalStrategistFired {
attempt: 2,
consecutive_failures: 6,
every: 3,
model_id: "grok-4".to_string(),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "goal_strategist_fired");
assert_eq!(v["attempt"], 2);
assert_eq!(v["consecutive_failures"], 6);
assert_eq!(v["every"], 3);
assert_eq!(v["model_id"], "grok-4");
}
#[test]
fn goal_summarizer_events_serialize_tag_and_fields() {
let fired = Event::GoalSummarizerFired {
attempt: 2,
model_id: "grok-4".to_string(),
};
let v = serde_json::to_value(&fired).unwrap();
assert_eq!(v["type"], "goal_summarizer_fired");
assert_eq!(v["attempt"], 2);
assert_eq!(v["model_id"], "grok-4");
let completed = Event::GoalSummarizerCompleted {
attempt: 2,
latency_ms: 42,
};
let v = serde_json::to_value(&completed).unwrap();
assert_eq!(v["type"], "goal_summarizer_completed");
assert_eq!(v["attempt"], 2);
assert_eq!(v["latency_ms"], 42);
let failed = Event::GoalSummarizerFailOpen {
reason: "transport",
attempt: 2,
latency_ms: 7,
};
let v = serde_json::to_value(&failed).unwrap();
assert_eq!(v["type"], "goal_summarizer_fail_open");
assert_eq!(v["reason"], "transport");
assert_eq!(v["attempt"], 2);
assert_eq!(v["latency_ms"], 7);
}
#[test]
fn goal_role_model_resolved_serializes_tag_and_fields() {
let ev = Event::GoalRoleModelResolved {
role: "skeptic",
skeptic_idx: Some(2),
model_id: "grok-4".to_string(),
agent_type: "general-purpose".to_string(),
source: "remote",
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "goal_role_model_resolved");
assert_eq!(v["role"], "skeptic");
assert_eq!(v["skeptic_idx"], 2);
assert_eq!(v["model_id"], "grok-4");
assert_eq!(v["agent_type"], "general-purpose");
assert_eq!(v["source"], "remote");
}
#[test]
fn goal_role_model_resolved_omits_skeptic_idx_when_none() {
let ev = Event::GoalRoleModelResolved {
role: "planner",
skeptic_idx: None,
model_id: "grok-4".to_string(),
agent_type: "general-purpose".to_string(),
source: "remote",
};
let obj = serde_json::to_value(&ev).unwrap();
assert!(
obj.get("skeptic_idx").is_none(),
"skeptic_idx must be omitted when None, got {obj}"
);
assert_eq!(obj["role"], "planner");
}
#[test]
fn goal_role_model_fail_open_serializes_tag_and_fields() {
let ev = Event::GoalRoleModelFailOpen {
role: "skeptic",
skeptic_idx: Some(1),
reason: "toolset_unavailable",
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "goal_role_model_fail_open");
assert_eq!(v["role"], "skeptic");
assert_eq!(v["skeptic_idx"], 1);
assert_eq!(v["reason"], "toolset_unavailable");
}
#[test]
fn goal_role_model_fail_open_omits_skeptic_idx_when_none() {
let ev = Event::GoalRoleModelFailOpen {
role: "strategist",
skeptic_idx: None,
reason: "model_unauthorized",
};
let obj = serde_json::to_value(&ev).unwrap();
assert!(
obj.get("skeptic_idx").is_none(),
"skeptic_idx must be omitted when None, got {obj}"
);
assert_eq!(obj["type"], "goal_role_model_fail_open");
assert_eq!(obj["role"], "strategist");
assert_eq!(obj["reason"], "model_unauthorized");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Local file utilities: per-turn event tracking, content hashing, and
//! project-directory classification.
pub mod events;
pub mod s3;
pub mod trace_context;
pub mod workspace_classifier;
/// Directory names that are always skipped when scanning a workspace for
/// project content (dependency caches, build output, editor state, …).
pub const SKIP_DIR_NAMES: &[&str] = &[
"node_modules",
"__pycache__",
".venv",
"venv",
"env",
".env",
"target",
"dist",
"build",
"out",
".next",
".nuxt",
".output",
".cache",
".parcel-cache",
".turbo",
"vendor",
"bower_components",
".tox",
".nox",
".eggs",
".idea",
".vscode",
".gradle",
".dart_tool",
"coverage",
".nyc_output",
"htmlcov",
".pytest_cache",
".mypy_cache",
".ruff_cache",
];
/// [`SKIP_DIR_NAMES`] as a set for O(1) membership checks.
pub fn skip_dir_set() -> &'static std::collections::HashSet<&'static str> {
use std::collections::HashSet;
use std::sync::LazyLock;
static SET: LazyLock<HashSet<&str>> =
LazyLock::new(|| SKIP_DIR_NAMES.iter().copied().collect());
&SET
}
/// Compute SHA256 hash of content as a hex string.
pub fn sha256_hex(content: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content);
format!("{:x}", hasher.finalize())
}
/// Compute SHA256 hash of a file by streaming, without loading entire file into memory.
/// If `max_bytes` is set (> 0), only hash up to that many bytes.
pub fn sha256_hex_from_file(
path: &std::path::Path,
max_bytes: Option<u64>,
) -> std::io::Result<String> {
use sha2::{Digest, Sha256};
use std::io::Read;
let file = std::fs::File::open(path)?;
let mut reader: Box<dyn Read> = if let Some(limit) = max_bytes {
Box::new(file.take(limit))
} else {
Box::new(file)
};
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
+193
View File
@@ -0,0 +1,193 @@
//! Presigned-URL helpers for S3-compatible endpoints.
//!
//! Used by tools that hand a user-configured bucket a short-lived PUT/GET URL
//! (e.g. the ZDR video-generation output path). No upload client lives here.
use anyhow::Context;
/// Parse credential content (JSON or INI format) into AWS SDK credentials.
fn parse_aws_credentials(content: &str) -> anyhow::Result<aws_sdk_s3::config::Credentials> {
#[derive(serde::Deserialize)]
struct JsonCreds {
aws_access_key_id: String,
aws_secret_access_key: String,
#[serde(default)]
aws_session_token: Option<String>,
}
if let Ok(parsed) = serde_json::from_str::<JsonCreds>(content) {
return Ok(aws_sdk_s3::config::Credentials::new(
&parsed.aws_access_key_id,
&parsed.aws_secret_access_key,
parsed.aws_session_token,
None,
"grok-shell-trace-upload",
));
}
let strip_comment = |v: &str| {
v.split_once('#')
.map_or(v, |(before, _)| before)
.trim()
.to_owned()
};
let mut key_id = None;
let mut secret = None;
let mut token = None;
for line in content.lines() {
if let Some((k, v)) = line.split_once('=') {
match k.trim() {
"aws_access_key_id" => key_id = Some(strip_comment(v)),
"aws_secret_access_key" => secret = Some(strip_comment(v)),
"aws_session_token" => token = Some(strip_comment(v)),
_ => {}
}
}
}
match (key_id, secret) {
(Some(k), Some(s)) => Ok(aws_sdk_s3::config::Credentials::new(
&k,
&s,
token,
None,
"grok-shell-trace-upload",
)),
_ => anyhow::bail!(
"AWS credentials are neither valid JSON \
nor contain aws_access_key_id and aws_secret_access_key"
),
}
}
/// Build an S3 client. Uses path-style addressing when `endpoint_url` is set.
///
/// Reads `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` environment
/// variables so that S3 traffic can route through a corporate HTTP proxy when
/// the S3-compatible endpoint is not directly reachable.
pub(crate) async fn build_s3_client(
region: &str,
credentials_content: Option<&str>,
credentials_file: Option<&str>,
endpoint_url: Option<&str>,
) -> anyhow::Result<aws_sdk_s3::Client> {
let proxy_config = aws_smithy_http_client::proxy::ProxyConfig::from_env();
let http_client = aws_smithy_http_client::Builder::new().build_with_connector_fn(
move |settings, _runtime_components| {
let mut builder =
aws_smithy_http_client::Connector::builder().proxy_config(proxy_config.clone());
if let Some(s) = settings {
builder.set_connector_settings(Some(s.clone()));
}
builder
.tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring,
))
.build()
},
);
let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest())
.http_client(http_client)
.region(aws_config::Region::new(region.to_owned()));
let resolved_content = match (credentials_content, credentials_file) {
(Some(inline), _) => Some(inline.to_owned()),
(None, Some(path)) => Some(
tokio::fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read AWS credentials file: {path}"))?,
),
(None, None) => None,
};
if let Some(ref content) = resolved_content {
config_loader = config_loader.credentials_provider(parse_aws_credentials(content)?);
} else if endpoint_url.is_some() {
config_loader = config_loader.credentials_provider(aws_sdk_s3::config::Credentials::new(
"test",
"test",
None,
None,
"grok-shell-test",
));
}
let sdk_config = config_loader.load().await;
let mut builder =
aws_sdk_s3::config::Builder::from(&sdk_config).force_path_style(endpoint_url.is_some());
if let Some(url) = endpoint_url {
builder = builder.endpoint_url(url);
}
Ok(aws_sdk_s3::Client::from_conf(builder.build()))
}
/// Static access-key credentials for presigning S3 URLs.
///
/// `Debug` is intentionally redacted — the struct holds plaintext secrets.
#[derive(Clone)]
pub struct S3StaticCredentials {
pub access_key_id: String,
pub secret_access_key: String,
}
impl std::fmt::Debug for S3StaticCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3StaticCredentials")
.field("access_key_id", &"[redacted]")
.field("secret_access_key", &"[redacted]")
.finish()
}
}
impl S3StaticCredentials {
fn to_credentials_content(&self) -> String {
serde_json::json!({
"aws_access_key_id": self.access_key_id,
"aws_secret_access_key": self.secret_access_key,
})
.to_string()
}
}
pub async fn presign_put_url(
region: &str,
endpoint_url: Option<&str>,
creds: &S3StaticCredentials,
bucket: &str,
key: &str,
content_type: &str,
expires_in: std::time::Duration,
) -> anyhow::Result<String> {
let content = creds.to_credentials_content();
let client = build_s3_client(region, Some(&content), None, endpoint_url).await?;
let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(expires_in)?;
let presigned = client
.put_object()
.bucket(bucket)
.key(key)
.content_type(content_type)
.presigned(presigning_config)
.await?;
Ok(presigned.uri().to_string())
}
pub async fn presign_get_url(
region: &str,
endpoint_url: Option<&str>,
creds: &S3StaticCredentials,
bucket: &str,
key: &str,
expires_in: std::time::Duration,
) -> anyhow::Result<String> {
let content = creds.to_credentials_content();
let client = build_s3_client(region, Some(&content), None, endpoint_url).await?;
let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(expires_in)?;
let presigned = client
.get_object()
.bucket(bucket)
.key(key)
.presigned(presigning_config)
.await?;
Ok(presigned.uri().to_string())
}
@@ -0,0 +1,307 @@
use opentelemetry::global;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use tracing_opentelemetry::OpenTelemetrySpanExt;
/// Extract the current span's W3C `traceparent` string for propagation
/// across channel/task boundaries where span context is lost.
pub fn current_traceparent() -> Option<String> {
let current_span = tracing::Span::current();
if current_span.is_none() {
return None;
}
let cx = current_span.context();
let mut carrier = std::collections::HashMap::new();
global::get_text_map_propagator(|p| {
p.inject_context(&cx, &mut carrier);
});
carrier.remove("traceparent")
}
pub fn inject_trace_context_into_request(
mut builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
let mut headers = HeaderMap::new();
inject_trace_context(&mut headers);
// Insert new headers into the request builder
for (name, value) in headers.iter() {
builder = builder.header(name, value);
}
builder
}
pub(crate) fn inject_trace_context(headers: &mut HeaderMap) {
// Prefer the context from the current tracing span (set by OpenTelemetryLayer).
// Fall back to opentelemetry::Context::current() (thread-local) for code paths
// that run outside a tracing span but on a thread that has an attached OTel context
// (e.g. tasks created via spawn_local that inherit the thread-local context).
let current_span = tracing::Span::current();
let cx = if current_span.is_none() {
opentelemetry::Context::current()
} else {
current_span.context()
};
global::get_text_map_propagator(|propagator| {
propagator.inject_context(&cx, &mut HeaderMapInjector(headers));
});
}
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
impl opentelemetry::propagation::Injector for HeaderMapInjector<'_> {
fn set(&mut self, key: &str, value: String) {
match (HeaderName::try_from(key), HeaderValue::try_from(&value)) {
(Ok(name), Ok(val)) => {
self.0.insert(name, val);
}
(Err(e), _) => {
tracing::debug!("Invalid header name '{}': {}", key, e);
}
(_, Err(e)) => {
tracing::debug!("Invalid header value for '{}': {}", key, e);
}
}
}
}
/// Create a tracing span parented to `_meta.traceparent`.
/// Used as a callback for `with_on_meta` in ACP session/server builders.
pub fn span_from_meta_traceparent(
meta: &serde_json::Map<String, serde_json::Value>,
) -> tracing::Span {
let span = tracing::info_span!("acp_dispatch");
if let Some(ctx) = meta
.get("traceparent")
.and_then(|v| v.as_str())
.and_then(extract_context)
{
let _ = span.set_parent(ctx);
}
span
}
/// Link the current span to a W3C `traceparent` carried inside a JSON `_meta`
/// (or top-level) object. Call this at the top of a `#[tracing::instrument]`
/// function so the span created by the macro becomes a child of the client's
/// distributed trace.
pub fn link_current_span_to_meta(meta: &serde_json::Value) {
if let Some(ctx) = meta
.get("traceparent")
.and_then(|v| v.as_str())
.and_then(extract_context)
{
let _ = tracing::Span::current().set_parent(ctx);
}
}
fn extract_context(traceparent: &str) -> Option<opentelemetry::Context> {
use opentelemetry::trace::TraceContextExt;
let mut carrier = std::collections::HashMap::new();
carrier.insert("traceparent".to_string(), traceparent.to_string());
let ctx = opentelemetry::global::get_text_map_propagator(|p| p.extract(&carrier));
ctx.span().span_context().is_valid().then_some(ctx)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inject_trace_context_no_active_span() {
// When there's no active span, no headers should be added
let mut headers = HeaderMap::new();
inject_trace_context(&mut headers);
// Without an active OpenTelemetry span, no traceparent header is added
// (the propagator only injects if there's a valid span context)
assert!(headers.get("traceparent").is_none());
}
#[test]
fn test_header_map_injector_valid_header() {
let mut headers = HeaderMap::new();
{
let mut injector = HeaderMapInjector(&mut headers);
opentelemetry::propagation::Injector::set(
&mut injector,
"traceparent",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string(),
);
}
assert_eq!(
headers.get("traceparent").map(|v| v.to_str().unwrap()),
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
);
}
#[test]
fn test_header_map_injector_invalid_header_name() {
let mut headers = HeaderMap::new();
{
let mut injector = HeaderMapInjector(&mut headers);
// Invalid header name (contains space) should be silently ignored
opentelemetry::propagation::Injector::set(
&mut injector,
"invalid header",
"value".to_string(),
);
}
assert!(headers.is_empty());
}
#[test]
fn test_header_map_injector_invalid_header_value() {
let mut headers = HeaderMap::new();
{
let mut injector = HeaderMapInjector(&mut headers);
// Invalid header value (contains non-visible ASCII) should be silently ignored
opentelemetry::propagation::Injector::set(
&mut injector,
"traceparent",
"invalid\x00value".to_string(),
);
}
assert!(headers.is_empty());
}
#[test]
fn test_extract_context_rejects_invalid_traceparent() {
assert!(extract_context("not-a-valid-traceparent").is_none());
assert!(extract_context("").is_none());
}
/// E2E: _meta.traceparent -> link_current_span_to_meta -> current span
/// -> inject_trace_context_into_request -> outbound HTTP header carries same traceId.
#[test]
fn test_link_meta_then_inject_propagates_trace_id() {
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing_subscriber::layer::SubscriberExt;
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("test");
let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer)
.with_context_activation(false);
let subscriber = tracing_subscriber::Registry::default().with(otel_layer);
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
let browser_trace_id = "0af7651916cd43dd8448eb211c80319c";
let meta = serde_json::json!({
"traceparent": format!("00-{browser_trace_id}-b7ad6b7169203331-01"),
});
let span = tracing::info_span!("test_span");
let _entered = span.enter();
link_current_span_to_meta(&meta);
let client = reqwest::Client::new();
let builder = client.get("https://cli-chat-proxy.example.com/v1/chat/completions");
let builder = inject_trace_context_into_request(builder);
let request = builder.build().expect("Failed to build request");
let traceparent = request
.headers()
.get("traceparent")
.expect("traceparent header missing")
.to_str()
.unwrap();
assert!(
traceparent.starts_with(&format!("00-{browser_trace_id}-")),
"outbound traceId should match browser's. got: {traceparent}"
);
assert!(
traceparent.ends_with("-01"),
"sampled flag should be set. got: {traceparent}"
);
}
#[test]
fn test_inject_trace_context_into_request_preserves_existing_headers() {
use opentelemetry::trace::{
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
};
use opentelemetry_sdk::propagation::TraceContextPropagator;
// Initialize the global text map propagator for this test
// This is necessary because by default the global propagator is a no-op
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
// Create a valid span context with known trace_id and span_id
let trace_id = TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap();
let span_id = SpanId::from_hex("b7ad6b7169203331").unwrap();
let span_context = SpanContext::new(
trace_id,
span_id,
TraceFlags::SAMPLED,
true, // is_remote
TraceState::default(),
);
// Create a context with this span context attached
let cx = opentelemetry::Context::current().with_remote_span_context(span_context);
let _guard = cx.attach();
// Create a client and request builder with existing headers
let client = reqwest::Client::new();
let builder = client
.get("https://example.com")
.header("x-custom-header", "custom-value")
.header("authorization", "Bearer token123");
// Inject trace context into the request
let builder = inject_trace_context_into_request(builder);
let request = builder.build().expect("Failed to build request");
let headers = request.headers();
// Verify existing headers are preserved
assert_eq!(
headers.get("x-custom-header").map(|v| v.to_str().unwrap()),
Some("custom-value"),
"Custom header should be preserved after injecting trace context"
);
assert_eq!(
headers.get("authorization").map(|v| v.to_str().unwrap()),
Some("Bearer token123"),
"Authorization header should be preserved after injecting trace context"
);
// Verify the traceparent header was injected with correct trace context
let traceparent = headers
.get("traceparent")
.expect("traceparent header should be present with active span")
.to_str()
.unwrap();
// traceparent format: {version}-{trace-id}-{parent-id}-{trace-flags}
// e.g., "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
assert!(
traceparent.starts_with("00-0af7651916cd43dd8448eb211c80319c-"),
"traceparent should contain the correct trace_id, got: {}",
traceparent
);
assert!(
traceparent.contains("b7ad6b7169203331"),
"traceparent should contain the correct span_id, got: {}",
traceparent
);
assert!(
traceparent.ends_with("-01"),
"traceparent should have sampled flag set, got: {}",
traceparent
);
}
}
@@ -0,0 +1,375 @@
use std::path::{Path, PathBuf};
const EXCLUDED_DIR_NAMES: &[&str] = &[
".kigi", ".cache", ".daemon", ".config", ".npm", ".cargo", ".rustup", ".vscode", ".gemini",
".hermes", ".claude",
];
fn known_os_dirs() -> Vec<PathBuf> {
[
dirs::desktop_dir(),
dirs::download_dir(),
dirs::document_dir(),
dirs::audio_dir(),
dirs::video_dir(),
dirs::picture_dir(),
dirs::public_dir(),
]
.into_iter()
.flatten()
.collect()
}
pub fn is_project_dir(cwd: &Path) -> bool {
if cwd.as_os_str().is_empty() || cwd.parent().is_none() {
return false;
}
if cwd.ancestors().any(|p| p.join(".git").exists()) {
return true;
}
if has_excluded_component(cwd) {
return false;
}
if is_platform_system_dir(cwd) {
return false;
}
let Some(home) = dirs::home_dir() else {
return false;
};
if cwd == home {
return false;
}
if is_platform_home_excluded(cwd, &home) {
return false;
}
if known_os_dirs().iter().any(|d| cwd == d) {
return false;
}
true
}
#[cfg(not(target_os = "windows"))]
fn is_platform_system_dir(cwd: &Path) -> bool {
if cwd == Path::new("/tmp")
|| cwd.starts_with("/tmp/")
|| cwd == Path::new("/var/tmp")
|| cwd.starts_with("/var/tmp/")
|| cwd.starts_with("/var/folders/")
{
return true;
}
#[cfg(target_os = "macos")]
if cwd == Path::new("/private/tmp")
|| cwd.starts_with("/private/tmp/")
|| cwd == Path::new("/private/var/tmp")
|| cwd.starts_with("/private/var/tmp/")
|| cwd.starts_with("/private/var/folders/")
{
return true;
}
#[cfg(target_os = "linux")]
if cwd == Path::new("/root") {
return true;
}
false
}
#[cfg(target_os = "windows")]
fn is_platform_system_dir(cwd: &Path) -> bool {
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
if cwd.starts_with(&temp) {
return true;
}
}
let path_lower = cwd.to_string_lossy().to_lowercase();
if path_lower.contains("\\windows\\")
|| path_lower.ends_with("\\windows")
|| path_lower.contains("\\program files")
{
return true;
}
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
return true;
}
false
}
#[cfg(target_os = "macos")]
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
if cwd.starts_with(home.join("Library"))
&& !cwd.starts_with(home.join("Library/Mobile Documents"))
{
return true;
}
false
}
#[cfg(target_os = "linux")]
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
let Ok(relative) = cwd.strip_prefix(home) else {
return false;
};
if relative.components().count() != 1 {
return false;
}
let Some(std::path::Component::Normal(name)) = relative.components().next() else {
return false;
};
let name = name.to_string_lossy().to_lowercase();
[
"desktop",
"downloads",
"documents",
"pictures",
"music",
"videos",
]
.contains(&name.as_str())
}
#[cfg(target_os = "windows")]
fn is_platform_home_excluded(_cwd: &Path, _home: &Path) -> bool {
false
}
fn has_excluded_component(path: &Path) -> bool {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name_lower = name.to_string_lossy().to_lowercase();
if EXCLUDED_DIR_NAMES.contains(&name_lower.as_str()) {
return true;
}
if name_lower.starts_with(".kigi-") {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
mod posix {
use super::*;
#[test]
fn root_is_unsafe() {
assert!(!is_project_dir(Path::new("/")));
}
#[test]
fn tmp_is_unsafe() {
assert!(!is_project_dir(Path::new("/tmp")));
assert!(!is_project_dir(Path::new("/tmp/scratch")));
}
#[test]
fn tmp_prefix_not_greedy() {
assert!(is_project_dir(Path::new("/tmpdata/foo")));
}
#[test]
fn var_folders_is_unsafe() {
assert!(!is_project_dir(Path::new("/var/folders/ab/cd")));
}
#[test]
fn deep_project_is_safe() {
assert!(is_project_dir(Path::new("/Users/someone/my-project/src")));
}
#[test]
fn home_subdir_is_safe() {
assert!(is_project_dir(Path::new("/Users/someone/my-project")));
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::*;
#[test]
fn private_tmp_is_unsafe() {
assert!(!is_project_dir(Path::new("/private/tmp")));
assert!(!is_project_dir(Path::new("/private/tmp/scratch")));
assert!(!is_project_dir(Path::new("/private/var/folders/ab/cd")));
}
#[test]
fn library_is_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home.join("Library")));
assert!(!is_project_dir(&home.join("Library/Caches")));
assert!(!is_project_dir(&home.join("Library/Application Support")));
}
}
#[test]
fn icloud_drive_projects_are_safe() {
if let Some(home) = dirs::home_dir() {
assert!(is_project_dir(&home.join(
"Library/Mobile Documents/com~apple~CloudDocs/Projects/my-app"
)));
}
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
#[test]
fn bare_root_is_unsafe() {
assert!(!is_project_dir(Path::new("/root")));
}
#[test]
fn root_project_is_safe() {
assert!(is_project_dir(Path::new("/root/my-project")));
}
}
mod config_and_cache {
use super::*;
#[test]
fn grok_dirs_are_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home.join(".kigi")));
assert!(!is_project_dir(&home.join(".kigi/bin")));
}
}
#[test]
fn grok_prefixed_dirs_are_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home.join(".kigi-proxy-work")));
}
}
#[test]
fn cache_dirs_are_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home.join(".cache/zoe-proc")));
assert!(!is_project_dir(&home.join(".config/nvim")));
}
}
#[test]
fn other_ai_tool_dirs_are_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home.join(".gemini/antigravity")));
assert!(!is_project_dir(&home.join(".hermes/kanban")));
assert!(!is_project_dir(&home.join(".claude/projects")));
}
}
}
mod home_and_os_dirs {
use super::*;
#[test]
fn home_is_unsafe() {
if let Some(home) = dirs::home_dir() {
assert!(!is_project_dir(&home));
}
}
#[test]
fn home_project_is_safe() {
if let Some(home) = dirs::home_dir() {
assert!(is_project_dir(&home.join("my-project")));
}
}
#[test]
fn bare_desktop_is_unsafe() {
if let Some(d) = dirs::desktop_dir() {
assert!(!is_project_dir(&d));
}
}
#[test]
fn desktop_project_is_safe() {
if let Some(d) = dirs::desktop_dir() {
assert!(is_project_dir(&d.join("my-project")));
}
}
#[test]
fn bare_downloads_is_unsafe() {
if let Some(d) = dirs::download_dir() {
assert!(!is_project_dir(&d));
}
}
#[test]
fn bare_documents_is_unsafe() {
if let Some(d) = dirs::document_dir() {
assert!(!is_project_dir(&d));
}
}
}
mod edge_cases {
use super::*;
#[test]
fn empty_path_is_unsafe() {
assert!(!is_project_dir(Path::new("")));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unicode_paths_work() {
assert!(is_project_dir(Path::new(
"/Users/me/code/\u{D3F4}\u{B9AC}\u{B9C8}\u{CF13}"
)));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn spaces_work() {
assert!(is_project_dir(Path::new("/Users/me/My Projects/cool app")));
}
}
mod git_detection {
use super::*;
#[test]
fn inside_git_repo_is_safe() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
assert!(is_project_dir(tmp.path()));
}
#[test]
fn subdirectory_of_git_repo_is_safe() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".git")).unwrap();
let sub = tmp.path().join("deep/sub/dir");
std::fs::create_dir_all(&sub).unwrap();
assert!(is_project_dir(&sub));
}
}
}