From daadb57963285320d2f6834fc36347c94d5af1fe Mon Sep 17 00:00:00 2001 From: wellkilo Date: Fri, 11 Sep 2026 00:20:27 +0800 Subject: [PATCH 01/67] perf(ecc2): stream dashboard output with a DB cursor Hydrate bounded session output snapshots at startup and recovery, then fetch only rows newer than the monotonic SQLite cursor during steady-state dashboard refreshes. Preserve cross-process visibility and bounded per-session caches, recover safely from transient database failures, and cover lifecycle, retry, and real child-process writes. --- ecc2/README.md | 6 + ecc2/src/session/output.rs | 10 -- ecc2/src/session/store.rs | 119 +++++++++++++ ecc2/src/tui/dashboard.rs | 355 ++++++++++++++++++++++++++++++++----- 4 files changed, 434 insertions(+), 56 deletions(-) diff --git a/ecc2/README.md b/ecc2/README.md index 71aad6da8..8f9cc6d80 100644 --- a/ecc2/README.md +++ b/ecc2/README.md @@ -14,6 +14,12 @@ It is usable as an alpha for local experimentation, but it is **not** the finish - worktree-aware session scaffolding - basic multi-session state and output tracking +Dashboard output is hydrated from SQLite at startup, explicit refresh, and +recovery, then synchronized with a monotonic database cursor. Because session +runners are separate processes, the database remains the cross-process source +of truth while steady-state refreshes read only rows appended since the previous +dashboard tick. + ## What This Is For ECC 2.0 is the layer above individual harness installs. diff --git a/ecc2/src/session/output.rs b/ecc2/src/session/output.rs index d7ac8745f..07aadf9d5 100644 --- a/ecc2/src/session/output.rs +++ b/ecc2/src/session/output.rs @@ -113,16 +113,6 @@ impl SessionOutputStore { }); } - pub fn replace_lines(&self, session_id: &str, lines: Vec) { - let mut buffer: VecDeque = lines.into_iter().collect(); - - while buffer.len() > self.capacity { - let _ = buffer.pop_front(); - } - - self.lock_buffers().insert(session_id.to_string(), buffer); - } - pub fn lines(&self, session_id: &str) -> Vec { self.lock_buffers() .get(session_id) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index f71bb3640..3e77184a9 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -28,6 +28,30 @@ pub struct StateStore { conn: Connection, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SessionOutputRecord { + pub id: i64, + pub session_id: String, + pub line: OutputLine, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SessionOutputBatch { + pub cursor: i64, + pub records: Vec, +} + +fn output_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let stream: String = row.get(2)?; + let text: String = row.get(3)?; + let timestamp: String = row.get(4)?; + Ok(SessionOutputRecord { + id: row.get(0)?, + session_id: row.get(1)?, + line: OutputLine::new(OutputStream::from_db_value(&stream), text, timestamp), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct HarnessAuditEntry { pub id: i64, @@ -4000,6 +4024,45 @@ impl StateStore { Ok(lines) } + pub(crate) fn get_output_snapshot( + &self, + limit_per_session: usize, + ) -> Result { + let limit_per_session = i64::try_from(limit_per_session.max(1)).unwrap_or(i64::MAX); + let mut stmt = self.conn.prepare( + "SELECT id, session_id, stream, line, timestamp + FROM ( + SELECT id, session_id, stream, line, timestamp, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id DESC) AS row_num + FROM session_output + ) + WHERE row_num <= ?1 + ORDER BY id ASC", + )?; + let records = stmt + .query_map(rusqlite::params![limit_per_session], output_record_from_row)? + .collect::, _>>()?; + let cursor = records.last().map(|record| record.id).unwrap_or(0); + + Ok(SessionOutputBatch { cursor, records }) + } + + pub(crate) fn get_output_since(&self, cursor: i64) -> Result { + let cursor = cursor.max(0); + let mut stmt = self.conn.prepare( + "SELECT id, session_id, stream, line, timestamp + FROM session_output + WHERE id > ?1 + ORDER BY id ASC", + )?; + let records = stmt + .query_map(rusqlite::params![cursor], output_record_from_row)? + .collect::, _>>()?; + let cursor = records.last().map(|record| record.id).unwrap_or(cursor); + + Ok(SessionOutputBatch { cursor, records }) + } + pub fn insert_tool_log( &self, session_id: &str, @@ -7382,6 +7445,62 @@ mod tests { Ok(()) } + #[test] + fn output_cursor_reads_a_bounded_snapshot_then_only_new_rows() -> Result<()> { + let tempdir = TestDir::new("store-output-cursor")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + db.insert_session(&build_session("session-1", SessionState::Running))?; + db.insert_session(&build_session("session-2", SessionState::Running))?; + db.append_output_line("session-1", OutputStream::Stdout, "one-a")?; + db.append_output_line("session-2", OutputStream::Stderr, "two-a")?; + db.append_output_line("session-1", OutputStream::Stdout, "one-b")?; + db.append_output_line("session-2", OutputStream::Stdout, "two-b")?; + db.append_output_line("session-1", OutputStream::Stdout, "one-c")?; + + let snapshot = db.get_output_snapshot(2)?; + assert_eq!(snapshot.cursor, 5); + assert_eq!( + snapshot + .records + .iter() + .map(|record| (record.session_id.as_str(), record.line.text.as_str())) + .collect::>(), + vec![ + ("session-2", "two-a"), + ("session-1", "one-b"), + ("session-2", "two-b"), + ("session-1", "one-c"), + ] + ); + + db.append_output_line("session-2", OutputStream::Stderr, "two-c")?; + let delta = db.get_output_since(snapshot.cursor)?; + assert_eq!(delta.cursor, 6); + assert_eq!(delta.records.len(), 1); + assert_eq!(delta.records[0].session_id, "session-2"); + assert_eq!(delta.records[0].line.text, "two-c"); + + let empty = db.get_output_since(delta.cursor)?; + assert_eq!(empty.cursor, delta.cursor); + assert!(empty.records.is_empty()); + + let query_plan = db + .conn + .prepare( + "EXPLAIN QUERY PLAN SELECT id FROM session_output WHERE id > ?1 ORDER BY id ASC", + )? + .query_map(rusqlite::params![snapshot.cursor], |row| { + row.get::<_, String>(3) + })? + .collect::, _>>()?; + assert!(query_plan + .iter() + .any(|detail| detail.contains("INTEGER PRIMARY KEY") && detail.contains("rowid>?"))); + + Ok(()) + } + #[test] fn message_round_trip_tracks_unread_counts_and_read_state() -> Result<()> { let tempdir = TestDir::new("store-messages")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c98b4e2c2..1a75f799a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -10,7 +10,6 @@ use ratatui::{ use regex::Regex; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::time::UNIX_EPOCH; -use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; @@ -18,13 +17,11 @@ use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{ - OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, -}; -use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; +use crate::session::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; +use crate::session::store::{DaemonActivity, FileActivityOverlap, SessionOutputRecord, StateStore}; use crate::session::{ - ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, - SessionBoardMeta, SessionHarnessInfo, SessionMessage, SessionState, + ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionBoardMeta, + SessionGrouping, SessionHarnessInfo, SessionMessage, SessionState, }; use crate::worktree; @@ -79,16 +76,38 @@ struct TestRunSummary { passed: usize, } +fn append_output_records( + cache: &mut HashMap>, + records: Vec, +) { + let mut touched_sessions = HashSet::new(); + for record in records { + cache + .entry(record.session_id.clone()) + .or_default() + .push(record.line); + touched_sessions.insert(record.session_id); + } + + for session_id in touched_sessions { + if let Some(lines) = cache.get_mut(&session_id) { + let overflow = lines.len().saturating_sub(OUTPUT_BUFFER_LIMIT); + if overflow > 0 { + lines.drain(..overflow); + } + } + } +} + pub struct Dashboard { db: StateStore, cfg: Config, - output_store: SessionOutputStore, - output_rx: broadcast::Receiver, notifier: DesktopNotifier, webhook_notifier: WebhookNotifier, sessions: Vec, session_harnesses: HashMap, session_output_cache: HashMap>, + output_cursor: Option, unread_message_counts: HashMap, approval_queue_counts: HashMap, approval_queue_preview: Vec, @@ -503,14 +522,6 @@ fn load_session_harnesses( impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { - Self::with_output_store(db, cfg, SessionOutputStore::default()) - } - - pub fn with_output_store( - db: StateStore, - cfg: Config, - output_store: SessionOutputStore, - ) -> Self { let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); let initial_cost_metrics_signature = metrics_file_signature(&cfg.cost_metrics_path()); let initial_tool_activity_signature = @@ -533,7 +544,6 @@ impl Dashboard { .ok() .flatten() .map(|message| message.id); - let output_rx = output_store.subscribe(); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone()); let mut session_table_state = TableState::default(); @@ -544,13 +554,12 @@ impl Dashboard { let mut dashboard = Self { db, cfg, - output_store, - output_rx, notifier, webhook_notifier, sessions, session_harnesses, session_output_cache: HashMap::new(), + output_cursor: None, unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), approval_queue_preview: Vec::new(), @@ -624,6 +633,7 @@ impl Dashboard { dashboard.sync_handoff_backlog_counts(); dashboard.sync_board_meta(); dashboard.sync_global_handoff_backlog(); + dashboard.sync_output_cache(); dashboard.sync_selected_output(); dashboard.sync_selected_diff(); dashboard.sync_selected_messages(); @@ -3212,6 +3222,7 @@ impl Dashboard { } pub fn refresh(&mut self) { + self.output_cursor = None; self.sync_from_store(); } @@ -3993,15 +4004,6 @@ impl Dashboard { } pub async fn tick(&mut self) { - loop { - match self.output_rx.try_recv() { - Ok(_event) => {} - Err(broadcast::error::TryRecvError::Empty) => break, - Err(broadcast::error::TryRecvError::Lagged(_)) => continue, - Err(broadcast::error::TryRecvError::Closed) => break, - } - } - if let Err(error) = manager::activate_pending_worktree_sessions(&self.db, &self.cfg).await { tracing::warn!("Failed to activate queued worktree sessions: {error}"); } @@ -4077,14 +4079,17 @@ impl Dashboard { let (heartbeat_enforcement, budget_enforcement, conflict_enforcement) = self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); - self.sessions = match self.db.list_sessions() { + let sessions_refreshed = match self.db.list_sessions() { Ok(mut sessions) => { sort_sessions_for_display(&mut sessions); - sessions + self.sessions = sessions; + true } Err(error) => { tracing::warn!("Failed to refresh sessions: {error}"); - Vec::new() + self.output_cursor = None; + self.sessions.clear(); + false } }; self.session_harnesses = load_session_harnesses(&self.db, &self.cfg, &self.sessions); @@ -4103,7 +4108,9 @@ impl Dashboard { self.sync_approval_notifications(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); - self.sync_output_cache(); + if sessions_refreshed { + self.sync_output_cache(); + } self.sync_selection_by_id(selected_id.as_deref()); self.ensure_selected_pane_visible(); self.sync_selected_output(); @@ -4489,17 +4496,24 @@ impl Dashboard { self.session_output_cache .retain(|session_id, _| active_session_ids.contains(session_id.as_str())); - for session in &self.sessions { - match self.db.get_output_lines(&session.id, OUTPUT_BUFFER_LIMIT) { - Ok(lines) => { - self.output_store.replace_lines(&session.id, lines.clone()); - self.session_output_cache.insert(session.id.clone(), lines); - } - Err(error) => { - tracing::warn!("Failed to load session output for {}: {error}", session.id); - } + let batch = match self.output_cursor { + Some(cursor) => self.db.get_output_since(cursor), + None => self.db.get_output_snapshot(OUTPUT_BUFFER_LIMIT), + }; + let batch = match batch { + Ok(batch) => batch, + Err(error) => { + tracing::warn!("Failed to refresh session output cache: {error}"); + return; } + }; + + if self.output_cursor.is_none() { + self.session_output_cache.clear(); } + self.output_cursor = Some(batch.cursor); + + append_output_records(&mut self.session_output_cache, batch.records); } fn ensure_selected_pane_visible(&mut self) { @@ -13147,6 +13161,258 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + #[test] + fn output_cache_appends_rows_written_by_another_process_without_rehydrating() -> Result<()> { + let db_path = + std::env::temp_dir().join(format!("ecc2-output-cursor-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let session = sample_session("session-1", "claude", SessionState::Running, None, 0, 0); + db.insert_session(&session)?; + db.append_output_line("session-1", OutputStream::Stdout, "persisted-before-open")?; + + let mut dashboard = Dashboard::new(db, Config::default()); + assert!(dashboard + .selected_output_text() + .contains("persisted-before-open")); + dashboard + .session_output_cache + .entry("session-1".to_string()) + .or_default() + .push(test_output_line(OutputStream::Stdout, "cache-only")); + + let child = Command::new(std::env::current_exe()?) + .args([ + "--exact", + "tui::dashboard::tests::output_cursor_child_writer", + "--ignored", + "--nocapture", + ]) + .env("ECC2_OUTPUT_CURSOR_CHILD_DB", &db_path) + .status()?; + assert!(child.success(), "child output writer should succeed"); + dashboard.sync_output_cache(); + + let text = dashboard.selected_output_text(); + assert!(text.contains("persisted-before-open")); + assert!(text.contains("cache-only")); + assert!(text.contains("persisted-after-open")); + + dashboard.sync_output_cache(); + assert_eq!( + dashboard + .selected_output_lines() + .iter() + .filter(|line| line.text == "persisted-after-open") + .count(), + 1 + ); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[test] + #[ignore = "helper invoked by output cursor cross-process test"] + fn output_cursor_child_writer() -> Result<()> { + let Some(db_path) = std::env::var_os("ECC2_OUTPUT_CURSOR_CHILD_DB") else { + return Ok(()); + }; + StateStore::open(Path::new(&db_path))?.append_output_line( + "session-1", + OutputStream::Stderr, + "persisted-after-open", + ) + } + + #[test] + fn output_cache_rehydrates_after_transient_session_list_failure() -> Result<()> { + let db_path = + std::env::temp_dir().join(format!("ecc2-output-recovery-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let session = sample_session("session-1", "claude", SessionState::Running, None, 0, 0); + db.insert_session(&session)?; + db.append_output_line("session-1", OutputStream::Stdout, "persisted-output")?; + + let mut dashboard = Dashboard::new(db, Config::default()); + assert!(dashboard + .selected_output_text() + .contains("persisted-output")); + dashboard + .session_output_cache + .entry("session-1".to_string()) + .or_default() + .push(test_output_line(OutputStream::Stdout, "cache-only")); + + let schema = rusqlite::Connection::open(&db_path)?; + schema.execute("ALTER TABLE sessions RENAME TO unavailable_sessions", [])?; + dashboard.sync_from_store(); + assert!(dashboard.sessions.is_empty()); + assert!(dashboard.session_output_cache["session-1"] + .iter() + .any(|line| line.text == "cache-only")); + assert!(dashboard.output_cursor.is_none()); + + dashboard.sync_from_store(); + assert!(dashboard.session_output_cache["session-1"] + .iter() + .any(|line| line.text == "cache-only")); + assert!(dashboard.output_cursor.is_none()); + + schema.execute("ALTER TABLE unavailable_sessions RENAME TO sessions", [])?; + dashboard.sync_from_store(); + + assert_eq!(dashboard.sessions.len(), 1); + assert!(dashboard + .selected_output_text() + .contains("persisted-output")); + assert!(!dashboard.selected_output_text().contains("cache-only")); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[test] + fn output_cache_tracks_session_add_delete_and_same_id_recreation() -> Result<()> { + let db_path = + std::env::temp_dir().join(format!("ecc2-output-lifecycle-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + db.insert_session(&sample_session( + "session-1", + "claude", + SessionState::Running, + None, + 0, + 0, + ))?; + db.append_output_line("session-1", OutputStream::Stdout, "first-session")?; + + let mut dashboard = Dashboard::new(db, Config::default()); + let external = StateStore::open(&db_path)?; + external.insert_session(&sample_session( + "session-2", + "codex", + SessionState::Running, + None, + 0, + 0, + ))?; + external.append_output_line("session-2", OutputStream::Stderr, "new-session")?; + dashboard.sync_from_store(); + + assert!(dashboard.sessions.iter().any(|session| session.id == "session-2")); + assert_eq!(dashboard.session_output_cache["session-2"][0].text, "new-session"); + + external.delete_session("session-2")?; + dashboard.sync_from_store(); + assert!(!dashboard.session_output_cache.contains_key("session-2")); + + external.insert_session(&sample_session( + "session-2", + "codex", + SessionState::Running, + None, + 0, + 0, + ))?; + external.append_output_line("session-2", OutputStream::Stdout, "replacement-session")?; + dashboard.sync_from_store(); + + let replacement = &dashboard.session_output_cache["session-2"]; + assert_eq!(replacement.len(), 1); + assert_eq!(replacement[0].text, "replacement-session"); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[test] + fn output_cache_retries_delta_after_transient_output_query_failure() -> Result<()> { + let db_path = + std::env::temp_dir().join(format!("ecc2-output-query-retry-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + db.insert_session(&sample_session( + "session-1", + "claude", + SessionState::Running, + None, + 0, + 0, + ))?; + db.append_output_line("session-1", OutputStream::Stdout, "persisted-before")?; + + let mut dashboard = Dashboard::new(db, Config::default()); + dashboard + .session_output_cache + .get_mut("session-1") + .expect("hydrated output") + .push(test_output_line(OutputStream::Stdout, "cache-only")); + let cursor = dashboard.output_cursor; + + let schema = rusqlite::Connection::open(&db_path)?; + schema.execute( + "ALTER TABLE session_output RENAME TO unavailable_session_output", + [], + )?; + dashboard.sync_output_cache(); + assert_eq!(dashboard.output_cursor, cursor); + assert!(dashboard.session_output_cache["session-1"] + .iter() + .any(|line| line.text == "cache-only")); + + schema.execute( + "ALTER TABLE unavailable_session_output RENAME TO session_output", + [], + )?; + StateStore::open(&db_path)?.append_output_line( + "session-1", + OutputStream::Stderr, + "persisted-after", + )?; + dashboard.sync_output_cache(); + + let output = &dashboard.session_output_cache["session-1"]; + assert!(output.iter().any(|line| line.text == "cache-only")); + assert_eq!( + output + .iter() + .filter(|line| line.text == "persisted-after") + .count(), + 1 + ); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[test] + fn append_output_records_bounds_each_session_to_the_latest_window() { + let mut cache = HashMap::from([( + "session-2".to_string(), + vec![test_output_line(OutputStream::Stderr, "other-session")], + )]); + let records = (0..(OUTPUT_BUFFER_LIMIT + 5)) + .map(|index| crate::session::store::SessionOutputRecord { + id: index as i64 + 1, + session_id: "session-1".to_string(), + line: test_output_line(OutputStream::Stdout, &format!("line-{index}")), + }) + .collect(); + + append_output_records(&mut cache, records); + + let session_lines = cache.get("session-1").expect("session output"); + assert_eq!(session_lines.len(), OUTPUT_BUFFER_LIMIT); + assert_eq!( + session_lines.first().map(|line| line.text.as_str()), + Some("line-5") + ); + assert_eq!( + session_lines.last().map(|line| line.text.as_str()), + Some(format!("line-{}", OUTPUT_BUFFER_LIMIT + 4).as_str()) + ); + assert_eq!(cache["session-2"][0].text, "other-session"); + } + #[test] fn submit_search_tracks_matches_and_sets_navigation_note() { let mut dashboard = test_dashboard( @@ -14917,8 +15183,6 @@ diff --git a/src/lib.rs b/src/lib.rs ) }) .collect(); - let output_store = SessionOutputStore::default(); - let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(selected_session)); @@ -14928,13 +15192,12 @@ diff --git a/src/lib.rs b/src/lib.rs db: StateStore::open(Path::new(":memory:")).expect("open test db"), pane_size_percent: configured_pane_size(&cfg, cfg.pane_layout), cfg, - output_store, - output_rx, notifier, webhook_notifier, sessions, session_harnesses, session_output_cache: HashMap::new(), + output_cursor: None, unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), approval_queue_preview: Vec::new(), From 8cd852136f302c6b9dd88ff3eea11050da9e17a5 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Fri, 11 Sep 2026 01:03:17 +0800 Subject: [PATCH 02/67] fix(ecc2): bound output cursor recovery Page dashboard deltas, preserve incremental refreshes, isolate reused session IDs by creation time, and use ownership-based cache updates. Add cross-process, lifecycle, and retry coverage for the reviewed edge cases. --- ecc2/README.md | 8 ++-- ecc2/src/session/output.rs | 2 + ecc2/src/session/store.rs | 28 ++++++++++--- ecc2/src/tui/dashboard.rs | 84 ++++++++++++++++++++++++++------------ 4 files changed, 86 insertions(+), 36 deletions(-) diff --git a/ecc2/README.md b/ecc2/README.md index 8f9cc6d80..2ea06c961 100644 --- a/ecc2/README.md +++ b/ecc2/README.md @@ -14,10 +14,10 @@ It is usable as an alpha for local experimentation, but it is **not** the finish - worktree-aware session scaffolding - basic multi-session state and output tracking -Dashboard output is hydrated from SQLite at startup, explicit refresh, and -recovery, then synchronized with a monotonic database cursor. Because session -runners are separate processes, the database remains the cross-process source -of truth while steady-state refreshes read only rows appended since the previous +Dashboard output is hydrated from SQLite at startup and after recovery, then +synchronized with a monotonic database cursor. Because session runners are +separate processes, the database remains the cross-process source of truth +while steady-state refreshes read only the rows appended since the previous dashboard tick. ## What This Is For diff --git a/ecc2/src/session/output.rs b/ecc2/src/session/output.rs index 07aadf9d5..1edd3f800 100644 --- a/ecc2/src/session/output.rs +++ b/ecc2/src/session/output.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; pub const OUTPUT_BUFFER_LIMIT: usize = 1000; +/// Maximum number of cross-process output rows applied during one dashboard refresh. +pub const OUTPUT_DELTA_BATCH_LIMIT: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OutputStream { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 3e77184a9..de1af81fc 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -41,6 +41,7 @@ pub(crate) struct SessionOutputBatch { pub records: Vec, } +/// Converts one persisted output row into the dashboard's typed record. fn output_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let stream: String = row.get(2)?; let text: String = row.get(3)?; @@ -4024,6 +4025,7 @@ impl StateStore { Ok(lines) } + /// Returns a bounded recent-output snapshot and its highest persisted row ID. pub(crate) fn get_output_snapshot( &self, limit_per_session: usize, @@ -4047,16 +4049,23 @@ impl StateStore { Ok(SessionOutputBatch { cursor, records }) } - pub(crate) fn get_output_since(&self, cursor: i64) -> Result { + /// Returns at most `limit` output rows newer than `cursor` in insertion order. + pub(crate) fn get_output_since( + &self, + cursor: i64, + limit: usize, + ) -> Result { let cursor = cursor.max(0); + let limit = i64::try_from(limit.max(1)).unwrap_or(i64::MAX); let mut stmt = self.conn.prepare( "SELECT id, session_id, stream, line, timestamp FROM session_output WHERE id > ?1 - ORDER BY id ASC", + ORDER BY id ASC + LIMIT ?2", )?; let records = stmt - .query_map(rusqlite::params![cursor], output_record_from_row)? + .query_map(rusqlite::params![cursor, limit], output_record_from_row)? .collect::, _>>()?; let cursor = records.last().map(|record| record.id).unwrap_or(cursor); @@ -7475,14 +7484,21 @@ mod tests { ); db.append_output_line("session-2", OutputStream::Stderr, "two-c")?; - let delta = db.get_output_since(snapshot.cursor)?; + db.append_output_line("session-1", OutputStream::Stdout, "one-d")?; + let delta = db.get_output_since(snapshot.cursor, 1)?; assert_eq!(delta.cursor, 6); assert_eq!(delta.records.len(), 1); assert_eq!(delta.records[0].session_id, "session-2"); assert_eq!(delta.records[0].line.text, "two-c"); - let empty = db.get_output_since(delta.cursor)?; - assert_eq!(empty.cursor, delta.cursor); + let next = db.get_output_since(delta.cursor, 1)?; + assert_eq!(next.cursor, 7); + assert_eq!(next.records.len(), 1); + assert_eq!(next.records[0].session_id, "session-1"); + assert_eq!(next.records[0].line.text, "one-d"); + + let empty = db.get_output_since(next.cursor, 1)?; + assert_eq!(empty.cursor, next.cursor); assert!(empty.records.is_empty()); let query_plan = db diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 1a75f799a..deb34605a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -17,7 +17,9 @@ use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; +use crate::session::output::{ + OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT, OUTPUT_DELTA_BATCH_LIMIT, +}; use crate::session::store::{DaemonActivity, FileActivityOverlap, SessionOutputRecord, StateStore}; use crate::session::{ ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionBoardMeta, @@ -76,10 +78,11 @@ struct TestRunSummary { passed: usize, } +/// Consumes an output cache and returns a new bounded cache with `records` appended. fn append_output_records( - cache: &mut HashMap>, + mut cache: HashMap>, records: Vec, -) { +) -> HashMap> { let mut touched_sessions = HashSet::new(); for record in records { cache @@ -97,6 +100,8 @@ fn append_output_records( } } } + + cache } pub struct Dashboard { @@ -107,6 +112,7 @@ pub struct Dashboard { sessions: Vec, session_harnesses: HashMap, session_output_cache: HashMap>, + session_output_generations: HashMap>, output_cursor: Option, unread_message_counts: HashMap, approval_queue_counts: HashMap, @@ -521,6 +527,7 @@ fn load_session_harnesses( } impl Dashboard { + /// Builds the dashboard and hydrates its initial bounded output snapshot. pub fn new(db: StateStore, cfg: Config) -> Self { let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); let initial_cost_metrics_signature = metrics_file_signature(&cfg.cost_metrics_path()); @@ -539,6 +546,10 @@ impl Dashboard { .iter() .map(|session| (session.id.clone(), session.state.clone())) .collect(); + let session_output_generations = sessions + .iter() + .map(|session| (session.id.clone(), session.created_at)) + .collect(); let initial_approval_message_id = db .latest_unread_approval_message() .ok() @@ -559,6 +570,7 @@ impl Dashboard { sessions, session_harnesses, session_output_cache: HashMap::new(), + session_output_generations, output_cursor: None, unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), @@ -3221,8 +3233,8 @@ impl Dashboard { )); } + /// Refreshes persisted dashboard state while preserving the output cursor. pub fn refresh(&mut self) { - self.output_cursor = None; self.sync_from_store(); } @@ -4075,6 +4087,7 @@ impl Dashboard { ) } + /// Synchronizes dashboard state, deferring output recovery until sessions load. fn sync_from_store(&mut self) { let (heartbeat_enforcement, budget_enforcement, conflict_enforcement) = self.sync_runtime_metrics(); @@ -4488,16 +4501,24 @@ impl Dashboard { } fn sync_output_cache(&mut self) { - let active_session_ids: HashSet<_> = self + let active_session_generations: HashMap<_, _> = self .sessions .iter() - .map(|session| session.id.as_str()) + .map(|session| (session.id.clone(), session.created_at)) .collect(); - self.session_output_cache - .retain(|session_id, _| active_session_ids.contains(session_id.as_str())); + let cached_generations = &self.session_output_generations; + self.session_output_cache = std::mem::take(&mut self.session_output_cache) + .into_iter() + .filter(|(session_id, _)| { + active_session_generations.get(session_id) == cached_generations.get(session_id) + }) + .collect(); + self.session_output_generations = active_session_generations; let batch = match self.output_cursor { - Some(cursor) => self.db.get_output_since(cursor), + Some(cursor) => self + .db + .get_output_since(cursor, OUTPUT_DELTA_BATCH_LIMIT), None => self.db.get_output_snapshot(OUTPUT_BUFFER_LIMIT), }; let batch = match batch { @@ -4509,11 +4530,14 @@ impl Dashboard { }; if self.output_cursor.is_none() { - self.session_output_cache.clear(); + self.session_output_cache = HashMap::new(); } self.output_cursor = Some(batch.cursor); - append_output_records(&mut self.session_output_cache, batch.records); + self.session_output_cache = append_output_records( + std::mem::take(&mut self.session_output_cache), + batch.records, + ); } fn ensure_selected_pane_visible(&mut self) { @@ -5226,6 +5250,7 @@ impl Dashboard { .map(|session| session.id.as_str()) } + /// Returns the selected session's currently cached output window. fn selected_output_lines(&self) -> &[OutputLine] { self.selected_session_id() .and_then(|session_id| self.session_output_cache.get(session_id)) @@ -13190,7 +13215,7 @@ diff --git a/src/lib.rs b/src/lib.rs .env("ECC2_OUTPUT_CURSOR_CHILD_DB", &db_path) .status()?; assert!(child.success(), "child output writer should succeed"); - dashboard.sync_output_cache(); + dashboard.refresh(); let text = dashboard.selected_output_text(); assert!(text.contains("persisted-before-open")); @@ -13299,21 +13324,23 @@ diff --git a/src/lib.rs b/src/lib.rs external.append_output_line("session-2", OutputStream::Stderr, "new-session")?; dashboard.sync_from_store(); - assert!(dashboard.sessions.iter().any(|session| session.id == "session-2")); - assert_eq!(dashboard.session_output_cache["session-2"][0].text, "new-session"); + assert!(dashboard + .sessions + .iter() + .any(|session| session.id == "session-2")); + assert_eq!( + dashboard.session_output_cache["session-2"][0].text, + "new-session" + ); external.delete_session("session-2")?; - dashboard.sync_from_store(); - assert!(!dashboard.session_output_cache.contains_key("session-2")); - - external.insert_session(&sample_session( - "session-2", - "codex", - SessionState::Running, - None, - 0, - 0, - ))?; + let replacement_time = Utc::now() + chrono::Duration::seconds(1); + external.insert_session(&Session { + created_at: replacement_time, + updated_at: replacement_time, + last_heartbeat_at: replacement_time, + ..sample_session("session-2", "codex", SessionState::Running, None, 0, 0) + })?; external.append_output_line("session-2", OutputStream::Stdout, "replacement-session")?; dashboard.sync_from_store(); @@ -13398,7 +13425,7 @@ diff --git a/src/lib.rs b/src/lib.rs }) .collect(); - append_output_records(&mut cache, records); + cache = append_output_records(cache, records); let session_lines = cache.get("session-1").expect("session output"); assert_eq!(session_lines.len(), OUTPUT_BUFFER_LIMIT); @@ -15183,6 +15210,10 @@ diff --git a/src/lib.rs b/src/lib.rs ) }) .collect(); + let session_output_generations = sessions + .iter() + .map(|session| (session.id.clone(), session.created_at)) + .collect(); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(selected_session)); @@ -15197,6 +15228,7 @@ diff --git a/src/lib.rs b/src/lib.rs sessions, session_harnesses, session_output_cache: HashMap::new(), + session_output_generations, output_cursor: None, unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), From e1805d7deb5da22e65a95aecc4a107e1ce100bc6 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 01:52:26 +0800 Subject: [PATCH 04/67] perf(metrics): cache cumulative session costs --- scripts/hooks/cost-tracker.js | 11 +- scripts/hooks/ecc-metrics-bridge.js | 74 +++++-- scripts/lib/session-cost-snapshot.js | 147 ++++++++++++++ skills/cost-tracking/SKILL.md | 5 + tests/hooks/cost-tracker.test.js | 34 ++++ tests/hooks/ecc-metrics-bridge.test.js | 250 ++++++++++++++++++++++++ tests/lib/session-cost-snapshot.test.js | 170 ++++++++++++++++ 7 files changed, 672 insertions(+), 19 deletions(-) create mode 100644 scripts/lib/session-cost-snapshot.js create mode 100644 tests/lib/session-cost-snapshot.test.js diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 981f67619..4e491a8e9 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -4,7 +4,9 @@ * * Reads transcript_path from Stop hook stdin, sums usage across all * assistant turns in the session JSONL, and appends one row to - * ~/.claude/metrics/costs.jsonl. + * ~/.claude/metrics/costs.jsonl. It also atomically publishes the latest + * cumulative row under metrics/cost-snapshots/ so frequent PostToolUse + * hooks do not need to rescan the unbounded history. * * Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... } * The Stop payload does NOT include `usage` or `model` directly. The previous @@ -42,6 +44,7 @@ const os = require('os'); const path = require('path'); const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); const { sanitizeSessionId } = require('../lib/session-bridge'); +const { publishAppendedSessionCostSnapshot } = require('../lib/session-cost-snapshot'); const HARNESS_COST_MAX_AGE_SECONDS = 300; @@ -243,6 +246,12 @@ process.stdin.on('end', () => { }; appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); + try { + publishAppendedSessionCostSnapshot(metricsDir, sessionId, row); + } catch { + // The append-only log remains authoritative. A later bridge read falls + // back to it when an atomic snapshot cannot be published. + } } catch { // Non-blocking — never fail the Stop hook. } diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index cbecd4536..26a677c79 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -14,6 +14,13 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); +const { + getCostLogSignature, + isValidCostRow, + readSessionCostSnapshot, + repairSessionCostSnapshot, + signaturesMatch, +} = require('../lib/session-cost-snapshot'); const { getClaudeDir } = require('../lib/utils'); const MAX_STDIN = 1024 * 1024; @@ -134,41 +141,55 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) { } /** - * Read cumulative cost for a session from costs.jsonl. + * Read cumulative cost for a session. * - * Scans the full file because each row is a cumulative session total - * (see cost-tracker.js docblock) and the row we need is the last one - * matching `sessionId`. The previous implementation read only the - * trailing 8 KiB; any session whose latest cumulative row was pushed - * past that window by newer rows from other sessions silently dropped - * to zero — the opposite sign of the double-count bug fixed in the - * previous commit. + * The Stop hook publishes an atomic per-session snapshot, so the normal + * PostToolUse path reads O(1) data instead of reparsing unbounded history. + * Older ECC installations and damaged/missing snapshots remain compatible: + * they fall back to scanning costs.jsonl for the last cumulative row. * - * costs.jsonl is append-only and unbounded today (no rotation in - * cost-tracker.js). At a typical ~150 bytes per row, even 100k rows - * is ~15 MB and a single sync read on every PostToolUse hook is in - * the low milliseconds. If rotation lands later, this scan becomes - * even cheaper. + * The fallback deliberately scans the whole file. A fixed tail window loses + * sessions whose newest row has been pushed back by other sessions. */ function readSessionCost(sessionId) { let costsPath = path.join('metrics', 'costs.jsonl'); try { - costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); + const metricsDir = path.join(getClaudeDir(), 'metrics'); + const snapshot = readSessionCostSnapshot(metricsDir, sessionId); + if (snapshot) { + return { + totalCost: toNumber(snapshot.estimated_cost_usd), + totalIn: toNumber(snapshot.input_tokens), + totalOut: toNumber(snapshot.output_tokens) + }; + } + + costsPath = path.join(metricsDir, 'costs.jsonl'); + const sourceBefore = getCostLogSignature(metricsDir); const content = fs.readFileSync(costsPath, 'utf8'); const lines = content.split('\n').filter(Boolean); let totalCost = 0; let totalIn = 0; let totalOut = 0; + let latestRow = null; let malformed = 0; + let invalid = 0; const malformedHasher = crypto.createHash('sha256'); + const invalidHasher = crypto.createHash('sha256'); for (const line of lines) { try { const row = JSON.parse(line); if (row.session_id === sessionId) { - totalCost = toNumber(row.estimated_cost_usd); - totalIn = toNumber(row.input_tokens); - totalOut = toNumber(row.output_tokens); + if (isValidCostRow(row, sessionId)) { + latestRow = row; + totalCost = row.estimated_cost_usd; + totalIn = row.input_tokens; + totalOut = row.output_tokens; + } else { + invalid += 1; + invalidHasher.update(line).update('\0'); + } } } catch { malformed += 1; @@ -187,6 +208,23 @@ function readSessionCost(sessionId) { `[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n` ); } + if (invalid > 0) { + writeCostWarningIfChanged( + 'invalid-row', + costsPath, + `${invalid}:${invalidHasher.digest('hex').slice(0, 16)}`, + `[ecc-metrics-bridge] skipped ${invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n` + ); + } + + const sourceAfter = getCostLogSignature(metricsDir); + if (latestRow && signaturesMatch(sourceBefore, sourceAfter)) { + try { + repairSessionCostSnapshot(metricsDir, sessionId, latestRow, sourceAfter); + } catch { + // Snapshot repair is best effort; the JSONL result remains valid. + } + } return { totalCost, totalIn, totalOut }; } catch (err) { // ENOENT is the common case (no Stop event has fired yet this session) @@ -259,7 +297,7 @@ function run(rawInput) { if (recent.length > RECENT_TOOLS_SIZE) recent.shift(); bridge.recent_tools = recent; - // Update cost from costs.jsonl tail + // Use the O(1) session snapshot, with JSONL compatibility fallback. const costs = readSessionCost(sessionId); bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6; bridge.total_input_tokens = costs.totalIn; diff --git a/scripts/lib/session-cost-snapshot.js b/scripts/lib/session-cost-snapshot.js new file mode 100644 index 000000000..816eae9d3 --- /dev/null +++ b/scripts/lib/session-cost-snapshot.js @@ -0,0 +1,147 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { writeFileAtomic } = require('./atomic-write'); +const { sanitizeSessionId } = require('./session-bridge'); + +const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1'; +const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots'; +const COST_LOG_FILENAME = 'costs.jsonl'; + +function assertSafeSessionId(sessionId) { + if (sanitizeSessionId(sessionId) !== sessionId) { + throw new Error('Cost snapshot requires a safe session ID'); + } +} + +function getCostSnapshotPath(metricsDir, sessionId) { + assertSafeSessionId(sessionId); + // Prefix the filename so Windows device names such as CON/NUL/COM1 never + // become the basename, even when they are otherwise valid session IDs. + return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY, `session-${sessionId}.json`); +} + +function getCostLogSignature(metricsDir) { + const stat = fs.statSync(path.join(metricsDir, COST_LOG_FILENAME)); + return { + size_bytes: stat.size, + mtime_ms: stat.mtimeMs + }; +} + +function signaturesMatch(left, right) { + return left?.size_bytes === right?.size_bytes + && left?.mtime_ms === right?.mtime_ms; +} + +function isValidCostRow(row, sessionId) { + return row?.session_id === sessionId + && typeof row.estimated_cost_usd === 'number' + && Number.isFinite(row.estimated_cost_usd) + && row.estimated_cost_usd >= 0 + && typeof row.input_tokens === 'number' + && Number.isFinite(row.input_tokens) + && row.input_tokens >= 0 + && typeof row.output_tokens === 'number' + && Number.isFinite(row.output_tokens) + && row.output_tokens >= 0; +} + +function assertCostLogSignature(source) { + if (!Number.isSafeInteger(source?.size_bytes) || source.size_bytes < 0) { + throw new Error('Cost snapshot requires a valid source size'); + } + if (!Number.isFinite(source?.mtime_ms) || source.mtime_ms < 0) { + throw new Error('Cost snapshot requires a valid source mtime'); + } +} + +function writeSnapshotForSource(metricsDir, sessionId, row, source) { + assertSafeSessionId(sessionId); + if (!isValidCostRow(row, sessionId)) { + throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); + } + + const snapshotPath = getCostSnapshotPath(metricsDir, sessionId); + assertCostLogSignature(source); + return writeFileAtomic( + snapshotPath, + JSON.stringify({ + schema_version: COST_SNAPSHOT_SCHEMA_VERSION, + source, + row + }), + { + beforeRename() { + if (!signaturesMatch(source, getCostLogSignature(metricsDir))) { + throw new Error('Cost log changed while publishing its session snapshot'); + } + } + } + ); +} + +function costLogEndsWithRow(metricsDir, row) { + const expected = Buffer.from(`${JSON.stringify(row)}\n`, 'utf8'); + const descriptor = fs.openSync(path.join(metricsDir, COST_LOG_FILENAME), 'r'); + try { + const stat = fs.fstatSync(descriptor); + if (stat.size < expected.length) return false; + const actual = Buffer.allocUnsafe(expected.length); + const bytesRead = fs.readSync( + descriptor, + actual, + 0, + expected.length, + stat.size - expected.length + ); + return bytesRead === expected.length && actual.equals(expected); + } finally { + fs.closeSync(descriptor); + } +} + +function publishAppendedSessionCostSnapshot(metricsDir, sessionId, row) { + assertSafeSessionId(sessionId); + if (!isValidCostRow(row, sessionId)) { + throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); + } + const sourceBefore = getCostLogSignature(metricsDir); + if (!costLogEndsWithRow(metricsDir, row)) return false; + const sourceAfter = getCostLogSignature(metricsDir); + if (!signaturesMatch(sourceBefore, sourceAfter)) return false; + writeSnapshotForSource(metricsDir, sessionId, row, sourceAfter); + return true; +} + +function repairSessionCostSnapshot(metricsDir, sessionId, row, source) { + writeSnapshotForSource(metricsDir, sessionId, row, source); +} + +function readSessionCostSnapshot(metricsDir, sessionId) { + try { + const snapshot = JSON.parse( + fs.readFileSync(getCostSnapshotPath(metricsDir, sessionId), 'utf8') + ); + if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null; + if (!isValidCostRow(snapshot.row, sessionId)) return null; + if (!signaturesMatch(snapshot.source, getCostLogSignature(metricsDir))) return null; + return snapshot.row; + } catch { + return null; + } +} + +module.exports = { + COST_SNAPSHOT_SCHEMA_VERSION, + COST_SNAPSHOT_DIRECTORY, + COST_LOG_FILENAME, + getCostSnapshotPath, + getCostLogSignature, + signaturesMatch, + isValidCostRow, + publishAppendedSessionCostSnapshot, + repairSessionCostSnapshot, + readSessionCostSnapshot +}; diff --git a/skills/cost-tracking/SKILL.md b/skills/cost-tracking/SKILL.md index 36fa80ab1..f0f4bfed6 100644 --- a/skills/cost-tracking/SKILL.md +++ b/skills/cost-tracking/SKILL.md @@ -17,6 +17,11 @@ The tracker appends one JSON object per session-stop to session**, so to total spend you take the **latest row per `session_id`** and sum across sessions — summing every row multiply-counts. +ECC also maintains internal per-session files under +`~/.claude/metrics/cost-snapshots/` so runtime hooks can read the current +session total without rescanning all history. Treat those files as a +rebuildable cache; reports and exports should continue to use `costs.jsonl`. + Row schema: | Field | Meaning | diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 78e72f68c..5946350eb 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -9,6 +9,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const { spawnSync } = require('child_process'); +const { getCostSnapshotPath } = require('../../scripts/lib/session-cost-snapshot'); const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js'); @@ -115,6 +116,32 @@ function runTests() { assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input'); }) ? passed++ : failed++); + (test('keeps JSONL authoritative when the snapshot path cannot be published', () => { + const tmpHome = makeTempDir(); + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const blockedSnapshotPath = path.join( + metricsDir, + 'cost-snapshots', + 'snapshot-failure.json' + ); + fs.mkdirSync(blockedSnapshotPath, { recursive: true }); + + try { + const result = runScript( + { session_id: 'snapshot-failure' }, + withTempHome(tmpHome) + ); + assert.strictEqual(result.code, 0, result.stderr); + const rows = fs.readFileSync(path.join(metricsDir, 'costs.jsonl'), 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + assert.strictEqual(rows.at(-1).session_id, 'snapshot-failure'); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + // 2. Creates metrics file when given transcript usage data (test('creates metrics file when given transcript usage data', () => { const tmpHome = makeTempDir(); @@ -154,6 +181,7 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const metricsDir = path.dirname(metricsFile); assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`); const content = fs.readFileSync(metricsFile, 'utf8').trim(); @@ -169,6 +197,12 @@ function runTests() { assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number'); assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive'); + const snapshotFile = getCostSnapshotPath(metricsDir, 'session-from-hook'); + assert.ok(fs.existsSync(snapshotFile), 'Expected an O(1) per-session cost snapshot'); + const snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); + assert.strictEqual(snapshot.schema_version, 'ecc.cost-snapshot.v1'); + assert.deepStrictEqual(snapshot.row, row, 'Snapshot must mirror the appended cumulative row'); + fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index 4bbf3fe35..73f440ef1 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -11,6 +11,10 @@ const os = require('os'); const path = require('path'); const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge'); +const { + getCostSnapshotPath, + publishAppendedSessionCostSnapshot +} = require('../../scripts/lib/session-cost-snapshot'); // Test helper function test(name, fn) { @@ -233,6 +237,252 @@ function runTests() { passed++; else failed++; + if ( + test('readSessionCost uses the per-session snapshot without scanning historical JSONL', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalReadFileSync = fs.readFileSync; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + const snapshotRow = { + session_id: 'S1', + estimated_cost_usd: 0.75, + input_tokens: 750, + output_tokens: 375 + }; + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${JSON.stringify(snapshotRow)}\n`, + 'utf8' + ); + assert.strictEqual( + publishAppendedSessionCostSnapshot(metricsDir, 'S1', snapshotRow), + true + ); + + fs.readFileSync = function guardedRead(filePath, ...args) { + if (path.basename(String(filePath)) === 'costs.jsonl') { + throw new Error('historical JSONL scan should be bypassed on a snapshot hit'); + } + return originalReadFileSync.call(this, filePath, ...args); + }; + + const result = readSessionCost('S1'); + assert.deepStrictEqual(result, { totalCost: 0.75, totalIn: 750, totalOut: 375 }); + } finally { + fs.readFileSync = originalReadFileSync; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost ignores a stale snapshot after costs.jsonl advances', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + const first = { + session_id: 'S1', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + const latest = { + session_id: 'S1', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + const costsPath = path.join(metricsDir, 'costs.jsonl'); + fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n`, 'utf8'); + publishAppendedSessionCostSnapshot(metricsDir, 'S1', first); + fs.appendFileSync(costsPath, `${JSON.stringify(latest)}\n`, 'utf8'); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = function guardedRead(filePath, ...args) { + if (path.basename(String(filePath)) === 'costs.jsonl') { + throw new Error('fallback should repair the session snapshot'); + } + return originalReadFileSync.call(this, filePath, ...args); + }; + try { + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + } finally { + fs.readFileSync = originalReadFileSync; + } + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost falls back to JSONL when the session snapshot is malformed', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + fs.writeFileSync(getCostSnapshotPath(metricsDir, 'S1'), '{broken', 'utf8'); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${JSON.stringify({ + session_id: 'S1', + estimated_cost_usd: 1.5, + input_tokens: 1500, + output_tokens: 750 + })}\n`, + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 1.5, + totalIn: 1500, + totalOut: 750 + }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost falls back when a snapshot row has invalid numeric totals', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + const valid = { + session_id: 'S1', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + const costsPath = path.join(metricsDir, 'costs.jsonl'); + fs.writeFileSync(costsPath, `${JSON.stringify(valid)}\n`, 'utf8'); + const stat = fs.statSync(costsPath); + fs.writeFileSync( + getCostSnapshotPath(metricsDir, 'S1'), + JSON.stringify({ + schema_version: 'ecc.cost-snapshot.v1', + source: { size_bytes: stat.size, mtime_ms: stat.mtimeMs }, + row: { session_id: 'S1' } + }), + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost skips invalid cumulative JSONL rows after the last valid total', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + let captured = ''; + process.stderr.write = chunk => { + captured += String(chunk); + return true; + }; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + const rows = [ + { session_id: 'S1', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 }, + { session_id: 'S1', estimated_cost_usd: -999, input_tokens: 'invalid', output_tokens: -5 }, + { session_id: 'S1', input_tokens: 300, output_tokens: 150 }, + { session_id: 'S1', estimated_cost_usd: null, input_tokens: 400, output_tokens: 200 }, + { session_id: 'OTHER', estimated_cost_usd: -1, input_tokens: -1, output_tokens: -1 } + ]; + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${rows.map(row => JSON.stringify(row)).join('\n')}\n`, + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + assert.match(captured, /skipped 3 invalid cumulative row\(s\) for S1/); + } finally { + process.stderr.write = originalStderrWrite; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( test('readSessionCost finds session row beyond the old 8 KiB tail boundary', () => { // The previous implementation read only the trailing 8 KiB of diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js new file mode 100644 index 000000000..de78b2fae --- /dev/null +++ b/tests/lib/session-cost-snapshot.test.js @@ -0,0 +1,170 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + COST_SNAPSHOT_SCHEMA_VERSION, + getCostSnapshotPath, + publishAppendedSessionCostSnapshot, + readSessionCostSnapshot, +} = require('../../scripts/lib/session-cost-snapshot'); + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` ${error.message}`); + return false; + } +} + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-snapshot-')); +const costLogPath = path.join(root, 'costs.jsonl'); +let passed = 0; +let failed = 0; + +try { + if (test('round-trips a versioned cumulative row atomically', () => { + const row = { + session_id: 'session-1', + estimated_cost_usd: 1.25, + input_tokens: 10, + output_tokens: 20 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-1', row), true); + const filePath = getCostSnapshotPath(root, 'session-1'); + assert.strictEqual(filePath, getCostSnapshotPath(root, 'session-1')); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1'), row); + assert.deepStrictEqual( + fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')), + [] + ); + })) passed++; else failed++; + + if (test('replaces the previous cumulative row for the same session', () => { + const first = { + session_id: 'session-update', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', first), true); + const latest = { + session_id: 'session-update', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + fs.appendFileSync(costLogPath, `${JSON.stringify(latest)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', latest), true); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update'), latest); + })) passed++; else failed++; + + if (test('invalidates a snapshot when the append-only cost log advances', () => { + const first = { + session_id: 'session-stale', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-stale', first), true); + fs.appendFileSync( + costLogPath, + `${JSON.stringify({ session_id: 'session-stale', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 })}\n`, + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-stale'), null); + })) passed++; else failed++; + + if (test('a delayed older writer cannot overwrite the newest session row', () => { + const older = { session_id: 'session-race', estimated_cost_usd: 1, input_tokens: 100, output_tokens: 50 }; + const newer = { session_id: 'session-race', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 }; + fs.writeFileSync(costLogPath, `${JSON.stringify(older)}\n`, 'utf8'); + fs.appendFileSync(costLogPath, `${JSON.stringify(newer)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', newer), true); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', older), false); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-race'), newer); + })) passed++; else failed++; + + if (test('rejects unsafe session IDs instead of escaping the snapshot directory', () => { + const unsafeId = '../outside'; + const row = { session_id: unsafeId, estimated_cost_usd: 9, input_tokens: 9, output_tokens: 9 }; + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.throws( + () => publishAppendedSessionCostSnapshot(root, unsafeId, row), + /safe session ID/ + ); + + const escapedPath = path.join(root, 'outside.json'); + fs.writeFileSync( + escapedPath, + JSON.stringify({ schema_version: COST_SNAPSHOT_SCHEMA_VERSION, row }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, unsafeId), null); + })) passed++; else failed++; + + if (test('prefixes Windows reserved device names with a safe basename', () => { + assert.strictEqual(path.basename(getCostSnapshotPath(root, 'CON')), 'session-CON.json'); + assert.strictEqual(path.basename(getCostSnapshotPath(root, 'nul')), 'session-nul.json'); + })) passed++; else failed++; + + if (test('rejects a snapshot whose row is bound to another session', () => { + const filePath = getCostSnapshotPath(root, 'session-2'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + schema_version: COST_SNAPSHOT_SCHEMA_VERSION, + row: { session_id: 'session-3', estimated_cost_usd: 3 } + }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-2'), null); + })) passed++; else failed++; + + if (test('rejects rows with missing, non-numeric, or negative totals', () => { + const invalidRows = [ + { session_id: 'invalid-row', input_tokens: 1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: '1', input_tokens: 1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: -1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: 1, output_tokens: Infinity } + ]; + for (const row of invalidRows) { + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.throws( + () => publishAppendedSessionCostSnapshot(root, 'invalid-row', row), + /valid non-negative numeric totals/ + ); + } + })) passed++; else failed++; + + if (test('rejects unknown schemas and malformed JSON', () => { + const filePath = getCostSnapshotPath(root, 'session-4'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + schema_version: 'ecc.cost-snapshot.v999', + row: { session_id: 'session-4' } + }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); + fs.writeFileSync(filePath, '{broken', 'utf8'); + assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); + })) passed++; else failed++; +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); From afa5651e9fcd082ebdc6e8e21189ab491bed7234 Mon Sep 17 00:00:00 2001 From: Cedrick Cantero Date: Sun, 13 Sep 2026 03:15:37 +0800 Subject: [PATCH 05/67] fix(skills): avoid a literal $1 placeholder in the security-review sql example Invoking this skill with arguments substitutes a literal $1 away, so the "ALWAYS Use Parameterized Queries" example renders as 'SELECT * FROM users WHERE email = attacks' for `/security-review also attacks` -- concatenated SQL, which is exactly the anti-pattern the section above it warns against. The one place the skill must be unambiguous is the one place argument substitution rewrites. Switches the raw-SQL example to "?" and names the Postgres numbered form in prose, so the lesson is unchanged and no substitutable token is left. Adds a comment so the placeholder is not reintroduced. --- skills/security-review/SKILL.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/skills/security-review/SKILL.md b/skills/security-review/SKILL.md index 0846d70a1..3f26b0df6 100644 --- a/skills/security-review/SKILL.md +++ b/skills/security-review/SKILL.md @@ -124,13 +124,20 @@ const { data } = await supabase .select('*') .eq('email', userEmail) -// Or with raw SQL +// Or with raw SQL -- the value goes in the params array, never in the +// string. Use your driver's placeholder syntax (Postgres numbers its +// placeholders, MySQL uses "?"). await db.query( - 'SELECT * FROM users WHERE email = $1', + 'SELECT * FROM users WHERE email = ?', [userEmail] ) ``` + + #### Verification Steps - [ ] All database queries use parameterized queries - [ ] No string concatenation in SQL From 1b12e19c636550ce666444452f5d05af19e66831 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 03:41:08 +0800 Subject: [PATCH 06/67] fix(metrics): address incremental snapshot review Replace global log-signature invalidation with per-session byte-offset cursors, preserve unterminated rows until committed, bound snapshot retention, and persist warning deduplication with atomic cross-process claims. Add regression coverage for concurrent writers, malformed data, rewrites, retention, UTF-8 boundaries, and concurrent warning emission. --- scripts/hooks/cost-tracker.js | 15 +- scripts/hooks/ecc-metrics-bridge.js | 92 ++---- scripts/lib/session-cost-snapshot.js | 410 +++++++++++++++++++----- skills/cost-tracking/SKILL.md | 5 +- tests/hooks/cost-tracker.test.js | 13 +- tests/hooks/ecc-metrics-bridge.test.js | 72 ++--- tests/lib/session-cost-snapshot.test.js | 403 +++++++++++++++++------ 7 files changed, 710 insertions(+), 300 deletions(-) diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 4e491a8e9..63d61170d 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -42,9 +42,12 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); +const { ensureDir, getClaudeDir } = require('../lib/utils'); const { sanitizeSessionId } = require('../lib/session-bridge'); -const { publishAppendedSessionCostSnapshot } = require('../lib/session-cost-snapshot'); +const { + appendSessionCostRow, + warnSessionCostSnapshotFailure +} = require('../lib/session-cost-snapshot'); const HARNESS_COST_MAX_AGE_SECONDS = 300; @@ -245,12 +248,10 @@ process.stdin.on('end', () => { estimated_cost_usd: estimatedCostUsd }; - appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); try { - publishAppendedSessionCostSnapshot(metricsDir, sessionId, row); - } catch { - // The append-only log remains authoritative. A later bridge read falls - // back to it when an atomic snapshot cannot be published. + appendSessionCostRow(metricsDir, sessionId, row); + } catch (error) { + warnSessionCostSnapshotFailure('publication', metricsDir, sessionId, error); } } catch { // Non-blocking — never fail the Stop hook. diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index 26a677c79..8f10a3a49 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -15,11 +15,8 @@ const os = require('os'); const path = require('path'); const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); const { - getCostLogSignature, - isValidCostRow, readSessionCostSnapshot, - repairSessionCostSnapshot, - signaturesMatch, + warnSessionCostSnapshotFailure } = require('../lib/session-cost-snapshot'); const { getClaudeDir } = require('../lib/utils'); @@ -29,11 +26,6 @@ const RECENT_TOOLS_SIZE = 5; const HASH_INPUT_LIMIT = 2048; const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-'; -function toNumber(value) { - const n = Number(value); - return Number.isFinite(n) ? n : 0; -} - function stableStringify(value, depth = 0) { if (depth > 4) return '[depth-limit]'; if (value === null || typeof value !== 'object') return JSON.stringify(value); @@ -143,8 +135,9 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) { /** * Read cumulative cost for a session. * - * The Stop hook publishes an atomic per-session snapshot, so the normal - * PostToolUse path reads O(1) data instead of reparsing unbounded history. + * The Stop hook publishes an atomic per-session cursor snapshot, so a stable + * PostToolUse path reads O(1) metadata and newly appended data is O(delta) + * instead of reparsing unbounded history. * Older ECC installations and damaged/missing snapshots remain compatible: * they fall back to scanning costs.jsonl for the last cumulative row. * @@ -155,77 +148,36 @@ function readSessionCost(sessionId) { let costsPath = path.join('metrics', 'costs.jsonl'); try { const metricsDir = path.join(getClaudeDir(), 'metrics'); - const snapshot = readSessionCostSnapshot(metricsDir, sessionId); - if (snapshot) { - return { - totalCost: toNumber(snapshot.estimated_cost_usd), - totalIn: toNumber(snapshot.input_tokens), - totalOut: toNumber(snapshot.output_tokens) - }; - } - costsPath = path.join(metricsDir, 'costs.jsonl'); - const sourceBefore = getCostLogSignature(metricsDir); - const content = fs.readFileSync(costsPath, 'utf8'); - const lines = content.split('\n').filter(Boolean); - - let totalCost = 0; - let totalIn = 0; - let totalOut = 0; - let latestRow = null; - let malformed = 0; - let invalid = 0; - const malformedHasher = crypto.createHash('sha256'); - const invalidHasher = crypto.createHash('sha256'); - for (const line of lines) { - try { - const row = JSON.parse(line); - if (row.session_id === sessionId) { - if (isValidCostRow(row, sessionId)) { - latestRow = row; - totalCost = row.estimated_cost_usd; - totalIn = row.input_tokens; - totalOut = row.output_tokens; - } else { - invalid += 1; - invalidHasher.update(line).update('\0'); - } - } - } catch { - malformed += 1; - malformedHasher.update(line).update('\0'); - } - } - // One aggregated breadcrumb per call rather than one per bad row, so a - // log-flooded costs.jsonl stays diagnosable without overwhelming stderr. - // Suppress repeats for the same malformed-line signature across hook - // subprocesses, so a persistent bad row should not spam stderr. - if (malformed > 0) { + const snapshotResult = readSessionCostSnapshot(metricsDir, sessionId); + if (snapshotResult.malformed > 0) { writeCostWarningIfChanged( 'malformed', costsPath, - `${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`, - `[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n` + `${snapshotResult.malformed}:${snapshotResult.malformedSignature}`, + `[ecc-metrics-bridge] skipped ${snapshotResult.malformed} malformed line(s) in ${costsPath}\n` ); } - if (invalid > 0) { + if (snapshotResult.invalid > 0) { writeCostWarningIfChanged( 'invalid-row', costsPath, - `${invalid}:${invalidHasher.digest('hex').slice(0, 16)}`, - `[ecc-metrics-bridge] skipped ${invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n` + `${snapshotResult.invalid}:${snapshotResult.invalidSignature}`, + `[ecc-metrics-bridge] skipped ${snapshotResult.invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n` ); } - - const sourceAfter = getCostLogSignature(metricsDir); - if (latestRow && signaturesMatch(sourceBefore, sourceAfter)) { - try { - repairSessionCostSnapshot(metricsDir, sessionId, latestRow, sourceAfter); - } catch { - // Snapshot repair is best effort; the JSONL result remains valid. - } + if (snapshotResult.snapshotError) { + warnSessionCostSnapshotFailure( + 'repair', + metricsDir, + sessionId, + snapshotResult.snapshotError + ); } - return { totalCost, totalIn, totalOut }; + const row = snapshotResult.row; + return row + ? { totalCost: row.estimated_cost_usd, totalIn: row.input_tokens, totalOut: row.output_tokens } + : { totalCost: 0, totalIn: 0, totalOut: 0 }; } catch (err) { // ENOENT is the common case (no Stop event has fired yet this session) // and is not actually a failure — stay silent on it. Anything else diff --git a/scripts/lib/session-cost-snapshot.js b/scripts/lib/session-cost-snapshot.js index 816eae9d3..04a5e45b0 100644 --- a/scripts/lib/session-cost-snapshot.js +++ b/scripts/lib/session-cost-snapshot.js @@ -1,6 +1,8 @@ 'use strict'; +const crypto = require('crypto'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { writeFileAtomic } = require('./atomic-write'); const { sanitizeSessionId } = require('./session-bridge'); @@ -8,6 +10,12 @@ const { sanitizeSessionId } = require('./session-bridge'); const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1'; const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots'; const COST_LOG_FILENAME = 'costs.jsonl'; +const READ_CHUNK_BYTES = 64 * 1024; +const FINGERPRINT_WINDOW_BYTES = 256; +const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_SNAPSHOTS = 512; +const WARNING_CACHE_PREFIX = 'ecc-cost-snapshot-warnings-'; function assertSafeSessionId(sessionId) { if (sanitizeSessionId(sessionId) !== sessionId) { @@ -15,24 +23,13 @@ function assertSafeSessionId(sessionId) { } } +function getSnapshotDirectory(metricsDir) { + return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY); +} + function getCostSnapshotPath(metricsDir, sessionId) { assertSafeSessionId(sessionId); - // Prefix the filename so Windows device names such as CON/NUL/COM1 never - // become the basename, even when they are otherwise valid session IDs. - return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY, `session-${sessionId}.json`); -} - -function getCostLogSignature(metricsDir) { - const stat = fs.statSync(path.join(metricsDir, COST_LOG_FILENAME)); - return { - size_bytes: stat.size, - mtime_ms: stat.mtimeMs - }; -} - -function signaturesMatch(left, right) { - return left?.size_bytes === right?.size_bytes - && left?.mtime_ms === right?.mtime_ms; + return path.join(getSnapshotDirectory(metricsDir), `session-${sessionId}.json`); } function isValidCostRow(row, sessionId) { @@ -48,100 +45,367 @@ function isValidCostRow(row, sessionId) { && row.output_tokens >= 0; } -function assertCostLogSignature(source) { - if (!Number.isSafeInteger(source?.size_bytes) || source.size_bytes < 0) { - throw new Error('Cost snapshot requires a valid source size'); - } - if (!Number.isFinite(source?.mtime_ms) || source.mtime_ms < 0) { - throw new Error('Cost snapshot requires a valid source mtime'); +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; } } -function writeSnapshotForSource(metricsDir, sessionId, row, source) { - assertSafeSessionId(sessionId); - if (!isValidCostRow(row, sessionId)) { - throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); +function chooseNewerCumulativeRow(currentRow, nextRow) { + if (!currentRow) return nextRow; + const nextDominates = nextRow.input_tokens >= currentRow.input_tokens + && nextRow.output_tokens >= currentRow.output_tokens + && nextRow.estimated_cost_usd >= currentRow.estimated_cost_usd; + const currentDominates = currentRow.input_tokens >= nextRow.input_tokens + && currentRow.output_tokens >= nextRow.output_tokens + && currentRow.estimated_cost_usd >= nextRow.estimated_cost_usd; + if (nextDominates && !currentDominates) return nextRow; + if (currentDominates && !nextDominates) return currentRow; + const nextTimestamp = Date.parse(nextRow.timestamp); + const currentTimestamp = Date.parse(currentRow.timestamp); + if (Number.isFinite(nextTimestamp) && Number.isFinite(currentTimestamp)) { + return nextTimestamp >= currentTimestamp ? nextRow : currentRow; } + return nextRow; +} - const snapshotPath = getCostSnapshotPath(metricsDir, sessionId); - assertCostLogSignature(source); - return writeFileAtomic( - snapshotPath, - JSON.stringify({ - schema_version: COST_SNAPSHOT_SCHEMA_VERSION, - source, - row - }), +function sourceIdentity(stat) { + return `${stat.dev}:${stat.ino}`; +} + +function hashWindow(descriptor, position, length) { + const buffer = Buffer.alloc(length); + if (length > 0) fs.readSync(descriptor, buffer, 0, length, position); + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +function fingerprintProcessedPrefix(descriptor, offset) { + const windowLength = Math.min(FINGERPRINT_WINDOW_BYTES, offset); + const middleStart = Math.max(0, Math.floor((offset - windowLength) / 2)); + return { + start: hashWindow(descriptor, 0, windowLength), + middle: hashWindow(descriptor, middleStart, windowLength), + end: hashWindow(descriptor, offset - windowLength, windowLength) + }; +} + +function fingerprintsMatch(left, right) { + return left?.start === right?.start + && left?.middle === right?.middle + && left?.end === right?.end; +} + +function validSnapshotBase(snapshot, descriptor, stat, sessionId) { + if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null; + if (snapshot.row !== null && !isValidCostRow(snapshot.row, sessionId)) return null; + const source = snapshot.source; + if (source?.identity !== sourceIdentity(stat)) return null; + if (!Number.isSafeInteger(source.offset_bytes) || source.offset_bytes < 0) return null; + if (source.offset_bytes > stat.size) return null; + if (source.offset_bytes === stat.size && source.mtime_ms !== stat.mtimeMs) return null; + if (!fingerprintsMatch( + source.fingerprint, + fingerprintProcessedPrefix(descriptor, source.offset_bytes) + )) return null; + return { row: snapshot.row, offset: source.offset_bytes }; +} + +function scanJsonlRange(descriptor, start, end, sessionId, initialRow) { + const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES); + let position = start; + let processedOffset = start; + let pending = Buffer.alloc(0); + let latestRow = initialRow; + let committedRow = initialRow; + let malformed = 0; + let invalid = 0; + const malformedHasher = crypto.createHash('sha256'); + const invalidHasher = crypto.createHash('sha256'); + + const processLine = (line, committed = true) => { + if (!line.trim()) return; + try { + const row = JSON.parse(line); + if (row.session_id !== sessionId) return; + if (!isValidCostRow(row, sessionId)) { + if (committed) { + invalid += 1; + invalidHasher.update(line).update('\0'); + } + return; + } + latestRow = chooseNewerCumulativeRow(latestRow, row); + if (committed) committedRow = chooseNewerCumulativeRow(committedRow, row); + } catch { + if (committed) { + malformed += 1; + malformedHasher.update(line).update('\0'); + } + } + }; + + while (position < end) { + const bytesRead = fs.readSync( + descriptor, + buffer, + 0, + Math.min(buffer.length, end - position), + position + ); + if (bytesRead === 0) break; + const combined = pending.length > 0 + ? Buffer.concat([pending, buffer.subarray(0, bytesRead)]) + : buffer.subarray(0, bytesRead); + let lineStart = 0; + for (;;) { + const newlineIndex = combined.indexOf(0x0a, lineStart); + if (newlineIndex < 0) break; + processLine(combined.subarray(lineStart, newlineIndex).toString('utf8')); + lineStart = newlineIndex + 1; + } + pending = Buffer.from(combined.subarray(lineStart)); + position += bytesRead; + processedOffset = position - pending.length; + } + if (pending.toString('utf8').trim()) processLine(pending.toString('utf8'), false); + return { + row: latestRow, + committedRow, + processedOffset, + malformed, + invalid, + malformedSignature: malformed > 0 ? malformedHasher.digest('hex').slice(0, 16) : null, + invalidSignature: invalid > 0 ? invalidHasher.digest('hex').slice(0, 16) : null + }; +} + +function writeSnapshotAtOffset(metricsDir, sessionId, row, descriptor, stat, offset) { + if (row !== null && !isValidCostRow(row, sessionId)) return false; + const snapshot = { + schema_version: COST_SNAPSHOT_SCHEMA_VERSION, + source: { + identity: sourceIdentity(stat), + offset_bytes: offset, + mtime_ms: stat.mtimeMs, + fingerprint: fingerprintProcessedPrefix(descriptor, offset) + }, + row + }; + writeFileAtomic( + getCostSnapshotPath(metricsDir, sessionId), + JSON.stringify(snapshot), { beforeRename() { - if (!signaturesMatch(source, getCostLogSignature(metricsDir))) { - throw new Error('Cost log changed while publishing its session snapshot'); + const current = fs.fstatSync(descriptor); + if (sourceIdentity(current) !== snapshot.source.identity) { + throw new Error('Cost log identity changed during snapshot publication'); + } + if (current.size < offset) { + throw new Error('Cost log was truncated during snapshot publication'); + } + if (current.size === offset && current.mtimeMs !== snapshot.source.mtime_ms) { + throw new Error('Cost log changed during snapshot publication'); + } + if (!fingerprintsMatch( + fingerprintProcessedPrefix(descriptor, offset), + snapshot.source.fingerprint + )) { + throw new Error('Cost log prefix changed during snapshot publication'); } } } ); + return true; } -function costLogEndsWithRow(metricsDir, row) { - const expected = Buffer.from(`${JSON.stringify(row)}\n`, 'utf8'); - const descriptor = fs.openSync(path.join(metricsDir, COST_LOG_FILENAME), 'r'); +function refreshSessionCostSnapshot(metricsDir, sessionId) { + assertSafeSessionId(sessionId); + const costsPath = path.join(metricsDir, COST_LOG_FILENAME); + const descriptor = fs.openSync(costsPath, 'r'); try { const stat = fs.fstatSync(descriptor); - if (stat.size < expected.length) return false; - const actual = Buffer.allocUnsafe(expected.length); - const bytesRead = fs.readSync( + const snapshot = readJsonFile(getCostSnapshotPath(metricsDir, sessionId)); + const base = validSnapshotBase(snapshot, descriptor, stat, sessionId); + if (base?.offset === stat.size) { + return { + row: base.row, + scannedBytes: 0, + malformed: 0, + invalid: 0, + malformedSignature: null, + invalidSignature: null, + snapshotError: null + }; + } + const scan = scanJsonlRange( descriptor, - actual, - 0, - expected.length, - stat.size - expected.length + base?.offset || 0, + stat.size, + sessionId, + base?.row || null ); - return bytesRead === expected.length && actual.equals(expected); + let snapshotError = null; + if (scan.committedRow || scan.processedOffset > 0) { + try { + writeSnapshotAtOffset( + metricsDir, + sessionId, + scan.committedRow, + descriptor, + stat, + scan.processedOffset + ); + } catch (error) { + snapshotError = error; + } + } + return { + row: scan.row, + scannedBytes: scan.processedOffset - (base?.offset || 0), + malformed: scan.malformed, + invalid: scan.invalid, + malformedSignature: scan.malformedSignature, + invalidSignature: scan.invalidSignature, + snapshotError + }; } finally { fs.closeSync(descriptor); } } -function publishAppendedSessionCostSnapshot(metricsDir, sessionId, row) { +function costLogNeedsSeparator(metricsDir) { + const costsPath = path.join(metricsDir, COST_LOG_FILENAME); + let descriptor; + try { + descriptor = fs.openSync(costsPath, 'r'); + const stat = fs.fstatSync(descriptor); + if (stat.size === 0) return false; + const lastByte = Buffer.alloc(1); + return fs.readSync(descriptor, lastByte, 0, 1, stat.size - 1) === 1 + && lastByte[0] !== 0x0a; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function appendSessionCostRow(metricsDir, sessionId, row) { assertSafeSessionId(sessionId); if (!isValidCostRow(row, sessionId)) { throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); } - const sourceBefore = getCostLogSignature(metricsDir); - if (!costLogEndsWithRow(metricsDir, row)) return false; - const sourceAfter = getCostLogSignature(metricsDir); - if (!signaturesMatch(sourceBefore, sourceAfter)) return false; - writeSnapshotForSource(metricsDir, sessionId, row, sourceAfter); - return true; -} - -function repairSessionCostSnapshot(metricsDir, sessionId, row, source) { - writeSnapshotForSource(metricsDir, sessionId, row, source); + const prefix = costLogNeedsSeparator(metricsDir) ? '\n' : ''; + fs.appendFileSync( + path.join(metricsDir, COST_LOG_FILENAME), + `${prefix}${JSON.stringify(row)}\n`, + 'utf8' + ); + const result = refreshSessionCostSnapshot(metricsDir, sessionId); + if (result.snapshotError) throw result.snapshotError; + try { + maybePruneSessionCostSnapshots(metricsDir); + } catch { + // Retention is opportunistic and retried by a later update. + } + return JSON.stringify(result.row) === JSON.stringify(row); } function readSessionCostSnapshot(metricsDir, sessionId) { try { - const snapshot = JSON.parse( - fs.readFileSync(getCostSnapshotPath(metricsDir, sessionId), 'utf8') - ); - if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null; - if (!isValidCostRow(snapshot.row, sessionId)) return null; - if (!signaturesMatch(snapshot.source, getCostLogSignature(metricsDir))) return null; - return snapshot.row; - } catch { - return null; + return refreshSessionCostSnapshot(metricsDir, sessionId); + } catch (error) { + if (error.code === 'ENOENT') { + return { row: null, scannedBytes: 0, malformed: 0, invalid: 0, snapshotError: null }; + } + throw error; } } +function maybePruneSessionCostSnapshots(metricsDir, options = {}) { + const snapshotDir = getSnapshotDirectory(metricsDir); + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : SNAPSHOT_MAX_AGE_MS; + const maxSnapshots = Number.isSafeInteger(options.maxSnapshots) + ? Math.max(0, options.maxSnapshots) + : MAX_SNAPSHOTS; + const markerPath = path.join(snapshotDir, '.last-prune'); + + fs.mkdirSync(snapshotDir, { recursive: true }); + const snapshotEntries = fs.readdirSync(snapshotDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && /^session-.+\.json$/.test(entry.name)); + if (!options.force) { + try { + const intervalIsFresh = now - fs.statSync(markerPath).mtimeMs < PRUNE_INTERVAL_MS; + if (intervalIsFresh && snapshotEntries.length <= maxSnapshots) return 0; + } catch { /* missing marker */ } + } + fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 }); + + const snapshots = snapshotEntries + .map(entry => { + const filePath = path.join(snapshotDir, entry.name); + return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + const removals = snapshots.filter((entry, index) => ( + index >= maxSnapshots || now - entry.mtimeMs > maxAgeMs + )); + let removed = 0; + for (const entry of removals) { + try { + if (fs.statSync(entry.filePath).mtimeMs <= entry.mtimeMs) { + fs.rmSync(entry.filePath, { force: true }); + removed += 1; + } + } catch { /* already replaced or removed */ } + } + return removed; +} + +function warningClaimPath(kind, metricsDir, sessionId, signature) { + const key = crypto.createHash('sha256') + .update(`${path.resolve(metricsDir)}\0${sessionId}\0${kind}\0${signature}`) + .digest('hex') + .slice(0, 16); + return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${key}.claim`); +} + +function warnSessionCostSnapshotFailure(kind, metricsDir, sessionId, error) { + const targetPath = getCostSnapshotPath(metricsDir, sessionId); + const errorCode = error?.code || error?.name || 'error'; + const signature = `${kind}:${targetPath}:${errorCode}`; + const claimPath = warningClaimPath(kind, metricsDir, sessionId, signature); + let claimDescriptor; + try { + claimDescriptor = fs.openSync(claimPath, 'wx', 0o600); + fs.closeSync(claimDescriptor); + claimDescriptor = undefined; + } catch (claimError) { + if (claimDescriptor !== undefined) fs.closeSync(claimDescriptor); + if (claimError.code === 'EEXIST') return; + // Warning persistence is best effort. If the claim cannot be created, + // still surface the underlying snapshot failure. + } + process.stderr.write( + `[cost-snapshot] ${kind} failed for session ${sessionId}: ${error?.message || String(error)}\n` + ); +} + module.exports = { COST_SNAPSHOT_SCHEMA_VERSION, COST_SNAPSHOT_DIRECTORY, COST_LOG_FILENAME, getCostSnapshotPath, - getCostLogSignature, - signaturesMatch, isValidCostRow, - publishAppendedSessionCostSnapshot, - repairSessionCostSnapshot, - readSessionCostSnapshot + chooseNewerCumulativeRow, + appendSessionCostRow, + readSessionCostSnapshot, + refreshSessionCostSnapshot, + costLogNeedsSeparator, + maybePruneSessionCostSnapshots, + warnSessionCostSnapshotFailure }; diff --git a/skills/cost-tracking/SKILL.md b/skills/cost-tracking/SKILL.md index f0f4bfed6..4347ce8f4 100644 --- a/skills/cost-tracking/SKILL.md +++ b/skills/cost-tracking/SKILL.md @@ -20,7 +20,10 @@ sum across sessions — summing every row multiply-counts. ECC also maintains internal per-session files under `~/.claude/metrics/cost-snapshots/` so runtime hooks can read the current session total without rescanning all history. Treat those files as a -rebuildable cache; reports and exports should continue to use `costs.jsonl`. +rebuildable cache; each snapshot stores a byte cursor so only newly appended +rows are scanned. Stable reads are O(1), while updates are O(new bytes). Stale +entries are pruned after 30 days or when the directory exceeds 512 sessions. +Reports and exports should continue to use `costs.jsonl`. Row schema: diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 5946350eb..066c3226d 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -119,10 +119,9 @@ function runTests() { (test('keeps JSONL authoritative when the snapshot path cannot be published', () => { const tmpHome = makeTempDir(); const metricsDir = path.join(tmpHome, '.claude', 'metrics'); - const blockedSnapshotPath = path.join( + const blockedSnapshotPath = getCostSnapshotPath( metricsDir, - 'cost-snapshots', - 'snapshot-failure.json' + 'snapshot-failure' ); fs.mkdirSync(blockedSnapshotPath, { recursive: true }); @@ -137,6 +136,14 @@ function runTests() { .split('\n') .map(line => JSON.parse(line)); assert.strictEqual(rows.at(-1).session_id, 'snapshot-failure'); + assert.match(result.stderr, /cost-snapshot.*publication failed/); + + const second = runScript( + { session_id: 'snapshot-failure' }, + withTempHome(tmpHome) + ); + assert.strictEqual(second.code, 0, second.stderr); + assert.strictEqual(second.stderr, '', 'identical persistent failure should warn only once'); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index 73f440ef1..246301622 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -12,8 +12,8 @@ const path = require('path'); const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge'); const { - getCostSnapshotPath, - publishAppendedSessionCostSnapshot + appendSessionCostRow, + getCostSnapshotPath } = require('../../scripts/lib/session-cost-snapshot'); // Test helper @@ -242,7 +242,8 @@ function runTests() { const tmpHome = makeTempHome(); const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; - const originalReadFileSync = fs.readFileSync; + const originalReadSync = fs.readSync; + let bytesReadFromCostLog = 0; try { process.env.HOME = tmpHome; process.env.USERPROFILE = tmpHome; @@ -255,27 +256,18 @@ function runTests() { input_tokens: 750, output_tokens: 375 }; - fs.writeFileSync( - path.join(metricsDir, 'costs.jsonl'), - `${JSON.stringify(snapshotRow)}\n`, - 'utf8' - ); - assert.strictEqual( - publishAppendedSessionCostSnapshot(metricsDir, 'S1', snapshotRow), - true - ); + appendSessionCostRow(metricsDir, 'S1', snapshotRow); - fs.readFileSync = function guardedRead(filePath, ...args) { - if (path.basename(String(filePath)) === 'costs.jsonl') { - throw new Error('historical JSONL scan should be bypassed on a snapshot hit'); - } - return originalReadFileSync.call(this, filePath, ...args); + fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) { + bytesReadFromCostLog += length; + return originalReadSync.call(this, descriptor, buffer, offset, length, position); }; const result = readSessionCost('S1'); assert.deepStrictEqual(result, { totalCost: 0.75, totalIn: 750, totalOut: 375 }); + assert.ok(bytesReadFromCostLog <= 3 * 256); } finally { - fs.readFileSync = originalReadFileSync; + fs.readSync = originalReadSync; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; @@ -288,7 +280,7 @@ function runTests() { else failed++; if ( - test('readSessionCost ignores a stale snapshot after costs.jsonl advances', () => { + test('readSessionCost keeps session A on the fast path after session B appends', () => { const tmpHome = makeTempHome(); const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; @@ -303,38 +295,24 @@ function runTests() { input_tokens: 100, output_tokens: 50 }; - const latest = { - session_id: 'S1', + appendSessionCostRow(metricsDir, 'S1', first); + appendSessionCostRow(metricsDir, 'S2', { + session_id: 'S2', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 - }; - const costsPath = path.join(metricsDir, 'costs.jsonl'); - fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n`, 'utf8'); - publishAppendedSessionCostSnapshot(metricsDir, 'S1', first); - fs.appendFileSync(costsPath, `${JSON.stringify(latest)}\n`, 'utf8'); - - assert.deepStrictEqual(readSessionCost('S1'), { - totalCost: 2, - totalIn: 200, - totalOut: 100 }); - - const originalReadFileSync = fs.readFileSync; - fs.readFileSync = function guardedRead(filePath, ...args) { - if (path.basename(String(filePath)) === 'costs.jsonl') { - throw new Error('fallback should repair the session snapshot'); - } - return originalReadFileSync.call(this, filePath, ...args); + const originalReadSync = fs.readSync; + let bytesReadFromCostLog = 0; + fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) { + bytesReadFromCostLog += length; + return originalReadSync.call(this, descriptor, buffer, offset, length, position); }; try { - assert.deepStrictEqual(readSessionCost('S1'), { - totalCost: 2, - totalIn: 200, - totalOut: 100 - }); + assert.deepStrictEqual(readSessionCost('S1'), { totalCost: 1, totalIn: 100, totalOut: 50 }); + assert.ok(bytesReadFromCostLog <= 2 * 1024); } finally { - fs.readFileSync = originalReadFileSync; + fs.readSync = originalReadSync; } } finally { if (originalHome === undefined) delete process.env.HOME; @@ -407,12 +385,12 @@ function runTests() { }; const costsPath = path.join(metricsDir, 'costs.jsonl'); fs.writeFileSync(costsPath, `${JSON.stringify(valid)}\n`, 'utf8'); - const stat = fs.statSync(costsPath); + appendSessionCostRow(metricsDir, 'S1', valid); + const snapshot = JSON.parse(fs.readFileSync(getCostSnapshotPath(metricsDir, 'S1'), 'utf8')); fs.writeFileSync( getCostSnapshotPath(metricsDir, 'S1'), JSON.stringify({ - schema_version: 'ecc.cost-snapshot.v1', - source: { size_bytes: stat.size, mtime_ms: stat.mtimeMs }, + ...snapshot, row: { session_id: 'S1' } }), 'utf8' diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js index de78b2fae..a41165ba4 100644 --- a/tests/lib/session-cost-snapshot.test.js +++ b/tests/lib/session-cost-snapshot.test.js @@ -1,16 +1,29 @@ 'use strict'; const assert = require('assert'); +const { execFileSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { - COST_SNAPSHOT_SCHEMA_VERSION, + appendSessionCostRow, getCostSnapshotPath, - publishAppendedSessionCostSnapshot, + maybePruneSessionCostSnapshots, readSessionCostSnapshot, + refreshSessionCostSnapshot, + warnSessionCostSnapshotFailure } = require('../../scripts/lib/session-cost-snapshot'); +function row(sessionId, cost = 1) { + return { + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, cost)).toISOString(), + session_id: sessionId, + estimated_cost_usd: cost, + input_tokens: cost * 100, + output_tokens: cost * 50 + }; +} + function test(name, fn) { try { fn(); @@ -24,23 +37,16 @@ function test(name, fn) { } const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-snapshot-')); -const costLogPath = path.join(root, 'costs.jsonl'); let passed = 0; let failed = 0; try { - if (test('round-trips a versioned cumulative row atomically', () => { - const row = { - session_id: 'session-1', - estimated_cost_usd: 1.25, - input_tokens: 10, - output_tokens: 20 - }; - fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-1', row), true); + if (test('appends and atomically publishes a private cumulative snapshot', () => { + const current = row('session-1', 1.25); + assert.strictEqual(appendSessionCostRow(root, 'session-1', current), true); const filePath = getCostSnapshotPath(root, 'session-1'); - assert.strictEqual(filePath, getCostSnapshotPath(root, 'session-1')); - assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1'), row); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1').row, current); + assert.strictEqual(fs.statSync(filePath).mode & 0o777, 0o600); assert.deepStrictEqual( fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')), [] @@ -48,89 +54,179 @@ try { })) passed++; else failed++; if (test('replaces the previous cumulative row for the same session', () => { - const first = { - session_id: 'session-update', - estimated_cost_usd: 1, - input_tokens: 100, - output_tokens: 50 - }; - fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', first), true); - const latest = { - session_id: 'session-update', - estimated_cost_usd: 2, - input_tokens: 200, - output_tokens: 100 - }; - fs.appendFileSync(costLogPath, `${JSON.stringify(latest)}\n`, 'utf8'); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', latest), true); - assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update'), latest); + appendSessionCostRow(root, 'session-update', row('session-update', 1)); + const latest = row('session-update', 2); + appendSessionCostRow(root, 'session-update', latest); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update').row, latest); })) passed++; else failed++; - if (test('invalidates a snapshot when the append-only cost log advances', () => { - const first = { - session_id: 'session-stale', - estimated_cost_usd: 1, - input_tokens: 100, - output_tokens: 50 + if (test('a delayed older writer cannot lower the latest cumulative total', () => { + const older = row('session-order', 1); + const newer = row('session-order', 2); + older.timestamp = new Date(Date.parse(newer.timestamp) + 1000).toISOString(); + assert.strictEqual(appendSessionCostRow(root, 'session-order', newer), true); + assert.strictEqual(appendSessionCostRow(root, 'session-order', older), false); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-order').row, newer); + })) passed++; else failed++; + + if (test('publication failure followed by an older writer still converges to the newer row', () => { + const sessionId = 'session-publication-race'; + const older = row(sessionId, 1); + const newer = row(sessionId, 2); + older.timestamp = new Date(Date.parse(newer.timestamp) + 1000).toISOString(); + const snapshotPath = getCostSnapshotPath(root, sessionId); + const originalRenameSync = fs.renameSync; + let injectedFailure = false; + fs.renameSync = function failNewerSnapshot(sourcePath, destinationPath) { + if (!injectedFailure && path.resolve(destinationPath) === path.resolve(snapshotPath)) { + injectedFailure = true; + const error = new Error('injected snapshot publication failure'); + error.code = 'EIO'; + throw error; + } + return originalRenameSync.call(this, sourcePath, destinationPath); }; - fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-stale', first), true); - fs.appendFileSync( - costLogPath, - `${JSON.stringify({ session_id: 'session-stale', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 })}\n`, + try { + assert.throws(() => appendSessionCostRow(root, sessionId, newer), /injected/); + } finally { + fs.renameSync = originalRenameSync; + } + assert.strictEqual(appendSessionCostRow(root, sessionId, older), false); + assert.deepStrictEqual(readSessionCostSnapshot(root, sessionId).row, newer); + })) passed++; else failed++; + + if (test('accepts increasing cumulative totals that share a timestamp', () => { + const first = row('session-same-time', 1); + const next = row('session-same-time', 2); + next.timestamp = first.timestamp; + appendSessionCostRow(root, 'session-same-time', first); + assert.strictEqual(appendSessionCostRow(root, 'session-same-time', next), true); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-same-time').row, next); + })) passed++; else failed++; + + if (test('uses timestamps when cumulative dimensions move in opposite directions', () => { + const newer = row('session-mixed', 1); + newer.input_tokens = 200; + newer.output_tokens = 100; + newer.timestamp = '2026-01-02T00:00:00.000Z'; + const delayedOlder = row('session-mixed', 2); + delayedOlder.input_tokens = 100; + delayedOlder.output_tokens = 50; + delayedOlder.timestamp = '2026-01-01T00:00:00.000Z'; + appendSessionCostRow(root, 'session-mixed', newer); + assert.strictEqual(appendSessionCostRow(root, 'session-mixed', delayedOlder), false); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-mixed').row, newer); + })) passed++; else failed++; + + if (test('session B only creates a bounded delta scan for session A', () => { + const sessionA = row('session-a', 3); + appendSessionCostRow(root, 'session-a', sessionA); + const sessionB = row('session-b', 4); + appendSessionCostRow(root, 'session-b', sessionB); + const refreshed = refreshSessionCostSnapshot(root, 'session-a'); + assert.deepStrictEqual(refreshed.row, sessionA); + assert.ok(refreshed.scannedBytes > 0); + assert.ok(refreshed.scannedBytes <= Buffer.byteLength(`${JSON.stringify(sessionB)}\n`)); + assert.strictEqual(refreshSessionCostSnapshot(root, 'session-a').scannedBytes, 0); + })) passed++; else failed++; + + if (test('sessions with no row cache their progress cursor', () => { + const caseRoot = path.join(root, 'missing-session'); + fs.mkdirSync(caseRoot, { recursive: true }); + const costsPath = path.join(caseRoot, 'costs.jsonl'); + const other = row('other-only', 1); + fs.writeFileSync(costsPath, `${JSON.stringify(other)}\n`.repeat(4000), 'utf8'); + const first = refreshSessionCostSnapshot(caseRoot, 'missing'); + const second = refreshSessionCostSnapshot(caseRoot, 'missing'); + assert.ok(first.scannedBytes > 100000); + assert.strictEqual(first.row, null); + assert.strictEqual(second.scannedBytes, 0); + assert.strictEqual(second.row, null); + })) passed++; else failed++; + + if (test('does not advance the cursor past an incomplete trailing row', () => { + const caseRoot = path.join(root, 'partial-row'); + fs.mkdirSync(caseRoot, { recursive: true }); + const current = row('partial', 6); + const serialized = JSON.stringify(current); + fs.writeFileSync(path.join(caseRoot, 'costs.jsonl'), serialized, 'utf8'); + const partial = refreshSessionCostSnapshot(caseRoot, 'partial'); + assert.deepStrictEqual(partial.row, current); + assert.strictEqual(partial.scannedBytes, 0); + assert.strictEqual(partial.malformed, 0); + fs.appendFileSync(path.join(caseRoot, 'costs.jsonl'), '\n', 'utf8'); + const complete = refreshSessionCostSnapshot(caseRoot, 'partial'); + assert.deepStrictEqual(complete.row, current); + assert.strictEqual(complete.scannedBytes, Buffer.byteLength(`${serialized}\n`)); + })) passed++; else failed++; + + if (test('never persists a provisional unterminated row when later bytes corrupt it', () => { + const caseRoot = path.join(root, 'partial-corruption'); + fs.mkdirSync(caseRoot, { recursive: true }); + const costsPath = path.join(caseRoot, 'costs.jsonl'); + const provisional = row('partial-corruption', 7); + fs.writeFileSync(costsPath, JSON.stringify(provisional), 'utf8'); + const first = refreshSessionCostSnapshot(caseRoot, 'partial-corruption'); + assert.deepStrictEqual(first.row, provisional); + assert.strictEqual(first.scannedBytes, 0); + + const recovered = row('partial-corruption', 8); + appendSessionCostRow(caseRoot, 'partial-corruption', recovered); + assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'partial-corruption').row, recovered); + })) passed++; else failed++; + + if (test('keeps UTF-8 rows intact across the 64 KiB read boundary', () => { + const caseRoot = path.join(root, 'utf8-boundary'); + fs.mkdirSync(caseRoot, { recursive: true }); + const current = { ...row('utf8', 7), note: `${'x'.repeat(65520)}数据` }; + fs.writeFileSync( + path.join(caseRoot, 'costs.jsonl'), + `${JSON.stringify(current)}\n`, 'utf8' ); - assert.strictEqual(readSessionCostSnapshot(root, 'session-stale'), null); + assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'utf8').row, current); })) passed++; else failed++; - if (test('a delayed older writer cannot overwrite the newest session row', () => { - const older = { session_id: 'session-race', estimated_cost_usd: 1, input_tokens: 100, output_tokens: 50 }; - const newer = { session_id: 'session-race', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 }; - fs.writeFileSync(costLogPath, `${JSON.stringify(older)}\n`, 'utf8'); - fs.appendFileSync(costLogPath, `${JSON.stringify(newer)}\n`, 'utf8'); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', newer), true); - assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', older), false); - assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-race'), newer); + if (test('rebuilds after an in-place rewrite or inode rotation', () => { + const caseRoot = path.join(root, 'rotation'); + fs.mkdirSync(caseRoot, { recursive: true }); + const costsPath = path.join(caseRoot, 'costs.jsonl'); + const first = row('rotated', 1); + const rewritten = row('rotated', 9); + appendSessionCostRow(caseRoot, 'rotated', first); + fs.writeFileSync(costsPath, `${JSON.stringify(rewritten)}\n`, 'utf8'); + assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'rotated').row, rewritten); + + const rotated = row('rotated', 10); + fs.renameSync(costsPath, `${costsPath}.old`); + fs.writeFileSync(costsPath, `${JSON.stringify(rotated)}\n`, 'utf8'); + assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'rotated').row, rotated); })) passed++; else failed++; - if (test('rejects unsafe session IDs instead of escaping the snapshot directory', () => { - const unsafeId = '../outside'; - const row = { session_id: unsafeId, estimated_cost_usd: 9, input_tokens: 9, output_tokens: 9 }; - fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + if (test('detects same-size in-place changes outside the tail window', () => { + const caseRoot = path.join(root, 'same-size-rewrite'); + fs.mkdirSync(caseRoot, { recursive: true }); + const costsPath = path.join(caseRoot, 'costs.jsonl'); + const first = row('same-size', 1); + const rewritten = { ...first, estimated_cost_usd: 9 }; + const filler = `${JSON.stringify(row('filler', 2))}\n`.repeat(20); + fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n${filler}`, 'utf8'); + refreshSessionCostSnapshot(caseRoot, 'same-size'); + fs.writeFileSync(costsPath, `${JSON.stringify(rewritten)}\n${filler}`, 'utf8'); + const rebuilt = refreshSessionCostSnapshot(caseRoot, 'same-size'); + assert.ok(rebuilt.scannedBytes > 0); + assert.deepStrictEqual(rebuilt.row, rewritten); + })) passed++; else failed++; + + if (test('rejects unsafe session IDs and prefixes Windows device names', () => { assert.throws( - () => publishAppendedSessionCostSnapshot(root, unsafeId, row), + () => appendSessionCostRow(root, '../outside', row('../outside', 9)), /safe session ID/ ); - - const escapedPath = path.join(root, 'outside.json'); - fs.writeFileSync( - escapedPath, - JSON.stringify({ schema_version: COST_SNAPSHOT_SCHEMA_VERSION, row }), - 'utf8' - ); - assert.strictEqual(readSessionCostSnapshot(root, unsafeId), null); - })) passed++; else failed++; - - if (test('prefixes Windows reserved device names with a safe basename', () => { assert.strictEqual(path.basename(getCostSnapshotPath(root, 'CON')), 'session-CON.json'); assert.strictEqual(path.basename(getCostSnapshotPath(root, 'nul')), 'session-nul.json'); })) passed++; else failed++; - if (test('rejects a snapshot whose row is bound to another session', () => { - const filePath = getCostSnapshotPath(root, 'session-2'); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync( - filePath, - JSON.stringify({ - schema_version: COST_SNAPSHOT_SCHEMA_VERSION, - row: { session_id: 'session-3', estimated_cost_usd: 3 } - }), - 'utf8' - ); - assert.strictEqual(readSessionCostSnapshot(root, 'session-2'), null); - })) passed++; else failed++; - if (test('rejects rows with missing, non-numeric, or negative totals', () => { const invalidRows = [ { session_id: 'invalid-row', input_tokens: 1, output_tokens: 1 }, @@ -138,33 +234,142 @@ try { { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: -1, output_tokens: 1 }, { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: 1, output_tokens: Infinity } ]; - for (const row of invalidRows) { - fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + for (const invalidRow of invalidRows) { assert.throws( - () => publishAppendedSessionCostSnapshot(root, 'invalid-row', row), + () => appendSessionCostRow(root, 'invalid-row', invalidRow), /valid non-negative numeric totals/ ); } })) passed++; else failed++; - if (test('rejects unknown schemas and malformed JSON', () => { - const filePath = getCostSnapshotPath(root, 'session-4'); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync( - filePath, - JSON.stringify({ - schema_version: 'ecc.cost-snapshot.v999', - row: { session_id: 'session-4' } - }), - 'utf8' - ); - assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); - fs.writeFileSync(filePath, '{broken', 'utf8'); - assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); + if (test('malformed snapshots rebuild from the authoritative JSONL', () => { + const sessionId = 'session-invalid'; + const current = row(sessionId, 5); + appendSessionCostRow(root, sessionId, current); + fs.writeFileSync(getCostSnapshotPath(root, sessionId), '{broken', 'utf8'); + const rebuilt = refreshSessionCostSnapshot(root, sessionId); + assert.deepStrictEqual(rebuilt.row, current); + assert.deepStrictEqual(readSessionCostSnapshot(root, sessionId).row, current); + })) passed++; else failed++; + + if (test('prunes expired snapshots and enforces the count bound', () => { + const now = Date.now(); + for (let index = 0; index < 4; index += 1) { + const sessionId = `prune-${index}`; + appendSessionCostRow(root, sessionId, row(sessionId, index + 1)); + const old = new Date(now - ((index + 1) * 1000)); + fs.utimesSync(getCostSnapshotPath(root, sessionId), old, old); + } + const removed = maybePruneSessionCostSnapshots(root, { + force: true, + now, + maxAgeMs: 2500, + maxSnapshots: 2 + }); + const remaining = fs.readdirSync(path.join(root, 'cost-snapshots')) + .filter(name => name.startsWith('session-')); + assert.ok(removed >= 2); + assert.ok(remaining.length <= 2); + })) passed++; else failed++; + + if (test('enforces the count bound while the age-prune marker is fresh', () => { + const caseRoot = path.join(root, 'count-bound'); + fs.mkdirSync(caseRoot, { recursive: true }); + const now = Date.now(); + assert.strictEqual(maybePruneSessionCostSnapshots(caseRoot, { + force: true, + now, + maxSnapshots: 2 + }), 0); + for (let index = 0; index < 5; index += 1) { + appendSessionCostRow(caseRoot, `count-${index}`, row(`count-${index}`, index + 1)); + } + const removed = maybePruneSessionCostSnapshots(caseRoot, { + now: now + 1000, + maxSnapshots: 2 + }); + const remaining = fs.readdirSync(path.join(caseRoot, 'cost-snapshots')) + .filter(name => name.startsWith('session-')); + assert.strictEqual(removed, 3); + assert.strictEqual(remaining.length, 2); + })) passed++; else failed++; + + if (test('deduplicates snapshot warnings independently by failure kind', () => { + const caseRoot = path.join(root, 'warning-dedupe'); + const originalWrite = process.stderr.write.bind(process.stderr); + let captured = ''; + process.stderr.write = chunk => { + captured += String(chunk); + return true; + }; + try { + const error = Object.assign(new Error('persistent failure'), { code: 'EIO' }); + warnSessionCostSnapshotFailure('publication', caseRoot, 'warn-session', error); + warnSessionCostSnapshotFailure('repair', caseRoot, 'warn-session', error); + warnSessionCostSnapshotFailure('publication', caseRoot, 'warn-session', error); + warnSessionCostSnapshotFailure('repair', caseRoot, 'warn-session', error); + const warnings = captured.trim().split('\n'); + assert.strictEqual(warnings.length, 2); + assert.match(warnings[0], /publication failed/); + assert.match(warnings[1], /repair failed/); + } finally { + process.stderr.write = originalWrite; + } + })) passed++; else failed++; + + if (test('deduplicates the same snapshot warning across concurrent processes', () => { + const caseRoot = path.join(root, 'warning-concurrency'); + fs.mkdirSync(caseRoot, { recursive: true }); + const modulePath = path.resolve(__dirname, '../../scripts/lib/session-cost-snapshot.js'); + const workerScript = [ + "const fs = require('fs');", + "const path = require('path');", + "const [modulePath, metricsDir, gatePath, index] = process.argv.slice(1);", + "fs.writeFileSync(path.join(metricsDir, `ready-${index}`), '');", + "const timer = setInterval(() => {", + " if (!fs.existsSync(gatePath)) return;", + " clearInterval(timer);", + " const { warnSessionCostSnapshotFailure } = require(modulePath);", + " const error = Object.assign(new Error('persistent failure'), { code: 'EIO' });", + " warnSessionCostSnapshotFailure('publication', metricsDir, 'shared-session', error);", + "}, 1);" + ].join('\n'); + const orchestratorScript = [ + "const { spawn } = require('child_process');", + "const fs = require('fs');", + "const path = require('path');", + "const [modulePath, metricsDir] = process.argv.slice(1);", + "const gatePath = path.join(metricsDir, 'go');", + `const workerScript = ${JSON.stringify(workerScript)};`, + "const runs = Array.from({ length: 16 }, (_, index) => new Promise((resolve, reject) => {", + " const child = spawn(process.execPath, ['-e', workerScript, modulePath, metricsDir, gatePath, String(index)], { stdio: ['ignore', 'ignore', 'pipe'] });", + " let stderr = '';", + " child.stderr.on('data', chunk => { stderr += chunk; });", + " child.on('error', reject);", + " child.on('close', code => resolve({ code, stderr }));", + "}));", + "const readyTimer = setInterval(() => {", + " const ready = fs.readdirSync(metricsDir).filter(name => name.startsWith('ready-'));", + " if (ready.length !== runs.length) return;", + " clearInterval(readyTimer);", + " fs.writeFileSync(gatePath, 'go');", + "}, 1);", + "Promise.all(runs).then(results => {", + " const warnings = results.flatMap(result => result.stderr.split('\\n')).filter(line => line.includes('publication failed'));", + " process.stdout.write(JSON.stringify({ codes: results.map(result => result.code), warnings: warnings.length }));", + "});" + ].join('\n'); + const result = JSON.parse(execFileSync( + process.execPath, + ['-e', orchestratorScript, modulePath, caseRoot], + { encoding: 'utf8', timeout: 10000 } + )); + assert.deepStrictEqual(result.codes, Array(16).fill(0)); + assert.strictEqual(result.warnings, 1); })) passed++; else failed++; } finally { fs.rmSync(root, { recursive: true, force: true }); } -console.log(`\nResults: ${passed} passed, ${failed} failed`); +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); From c2405149f7aaaa79f59f6ee3e0dea62001ce7764 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 03:56:58 +0800 Subject: [PATCH 07/67] test(metrics): make snapshot mode assertion portable Skip POSIX permission-bit equality on Windows, where stat reports synthesized mode bits, while retaining the atomic publication and readback assertions on every platform. --- tests/lib/session-cost-snapshot.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js index a41165ba4..153c2e932 100644 --- a/tests/lib/session-cost-snapshot.test.js +++ b/tests/lib/session-cost-snapshot.test.js @@ -46,7 +46,9 @@ try { assert.strictEqual(appendSessionCostRow(root, 'session-1', current), true); const filePath = getCostSnapshotPath(root, 'session-1'); assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1').row, current); - assert.strictEqual(fs.statSync(filePath).mode & 0o777, 0o600); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(filePath).mode & 0o777, 0o600); + } assert.deepStrictEqual( fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')), [] From 987c1e103f8433e79c15214824166e1644d46a71 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 04:55:52 +0800 Subject: [PATCH 08/67] fix(metrics): bound incremental snapshot recovery Cap JSONL line buffering and per-hook catch-up work, persist discard cursors for oversized records, report retention failures, normalize malformed token totals, and strengthen bounded-read regression fixtures. --- scripts/hooks/cost-tracker.js | 16 +- scripts/hooks/ecc-metrics-bridge.js | 4 +- scripts/lib/session-cost-snapshot.js | 277 ++++++++++++++++-------- skills/cost-tracking/SKILL.md | 2 + tests/hooks/cost-tracker.test.js | 51 +++++ tests/hooks/ecc-metrics-bridge.test.js | 28 ++- tests/lib/session-cost-snapshot.test.js | 153 +++++++++++-- 7 files changed, 424 insertions(+), 107 deletions(-) diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 63d61170d..cf36168b1 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -109,7 +109,17 @@ function isSonnet5(model) { function toNumber(v) { const n = Number(v); - return Number.isFinite(n) ? n : 0; + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +function normalizeUsageTotals(totals) { + return { + inputTokens: toNumber(totals.inputTokens), + outputTokens: toNumber(totals.outputTokens), + cacheWriteTokens: toNumber(totals.cacheWriteTokens), + cacheReadTokens: toNumber(totals.cacheReadTokens), + model: totals.model + }; } /** @@ -167,7 +177,9 @@ function sumUsageFromTranscript(transcriptPath) { cacheReadTokens += toNumber(u.cache_read_input_tokens); } - return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }; + return normalizeUsageTotals({ + inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model + }); } // 1MB, matching the other Stop hooks. The Stop payload carries diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index 8f10a3a49..31ecad948 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -155,7 +155,7 @@ function readSessionCost(sessionId) { 'malformed', costsPath, `${snapshotResult.malformed}:${snapshotResult.malformedSignature}`, - `[ecc-metrics-bridge] skipped ${snapshotResult.malformed} malformed line(s) in ${costsPath}\n` + `[ecc-metrics-bridge] skipped ${snapshotResult.malformed} malformed line(s) during the snapshot scan of ${costsPath}\n` ); } if (snapshotResult.invalid > 0) { @@ -163,7 +163,7 @@ function readSessionCost(sessionId) { 'invalid-row', costsPath, `${snapshotResult.invalid}:${snapshotResult.invalidSignature}`, - `[ecc-metrics-bridge] skipped ${snapshotResult.invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n` + `[ecc-metrics-bridge] skipped ${snapshotResult.invalid} invalid cumulative row(s) for ${sessionId} during the snapshot scan of ${costsPath}\n` ); } if (snapshotResult.snapshotError) { diff --git a/scripts/lib/session-cost-snapshot.js b/scripts/lib/session-cost-snapshot.js index 04a5e45b0..f95ddc8cb 100644 --- a/scripts/lib/session-cost-snapshot.js +++ b/scripts/lib/session-cost-snapshot.js @@ -11,6 +11,8 @@ const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1'; const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots'; const COST_LOG_FILENAME = 'costs.jsonl'; const READ_CHUNK_BYTES = 64 * 1024; +const MAX_JSONL_LINE_BYTES = 1024 * 1024; +const MAX_SCAN_BYTES = 16 * 1024 * 1024; const FINGERPRINT_WINDOW_BYTES = 256; const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; @@ -109,42 +111,121 @@ function validSnapshotBase(snapshot, descriptor, stat, sessionId) { source.fingerprint, fingerprintProcessedPrefix(descriptor, source.offset_bytes) )) return null; - return { row: snapshot.row, offset: source.offset_bytes }; + return { + row: snapshot.row, + offset: source.offset_bytes, + discardingLine: source.discarding_line === true + }; } -function scanJsonlRange(descriptor, start, end, sessionId, initialRow) { +function createScanState(initialRow) { + return { + latestRow: initialRow, + committedRow: initialRow, + malformed: 0, + invalid: 0, + malformedHasher: crypto.createHash('sha256'), + invalidHasher: crypto.createHash('sha256') + }; +} + +function processCostLine(state, line, sessionId, committed = true) { + if (!line.trim()) return state; + try { + const row = JSON.parse(line); + if (row.session_id !== sessionId) return state; + if (!isValidCostRow(row, sessionId)) { + if (!committed) return state; + return { + ...state, + invalid: state.invalid + 1, + invalidHasher: state.invalidHasher.copy().update(line).update('\0') + }; + } + return { + ...state, + latestRow: chooseNewerCumulativeRow(state.latestRow, row), + committedRow: committed + ? chooseNewerCumulativeRow(state.committedRow, row) + : state.committedRow + }; + } catch { + if (!committed) return state; + return { + ...state, + malformed: state.malformed + 1, + malformedHasher: state.malformedHasher.copy().update(line).update('\0') + }; + } +} + +function markOversizedLine(state, pendingChunks, segment) { + const hasher = state.malformedHasher.copy(); + for (const chunk of pendingChunks) hasher.update(chunk); + const remaining = Math.max(0, MAX_JSONL_LINE_BYTES - pendingChunks.reduce( + (total, chunk) => total + chunk.length, + 0 + )); + hasher.update(segment.subarray(0, remaining)).update('\0'); + return { ...state, malformed: state.malformed + 1, malformedHasher: hasher }; +} + +function consumeLineSegment(scan, segment, terminated, sessionId) { + if (scan.discardingLine) { + return { ...scan, discardingLine: !terminated }; + } + if (scan.pendingBytes + segment.length > MAX_JSONL_LINE_BYTES) { + return { + state: markOversizedLine(scan.state, scan.pendingChunks, segment), + pendingChunks: [], + pendingBytes: 0, + discardingLine: !terminated + }; + } + const pendingChunks = segment.length > 0 + ? [...scan.pendingChunks, Buffer.from(segment)] + : scan.pendingChunks; + const pendingBytes = scan.pendingBytes + segment.length; + if (!terminated) return { ...scan, pendingChunks, pendingBytes }; + const line = Buffer.concat(pendingChunks, pendingBytes).toString('utf8'); + return { + state: processCostLine(scan.state, line, sessionId), + pendingChunks: [], + pendingBytes: 0, + discardingLine: false + }; +} + +function consumeJsonlChunk(scan, chunk, sessionId, absoluteStart, processedOffset) { + let nextScan = scan; + let nextOffset = processedOffset; + let segmentStart = 0; + for (;;) { + const newlineIndex = chunk.indexOf(0x0a, segmentStart); + if (newlineIndex < 0) break; + nextScan = consumeLineSegment( + nextScan, chunk.subarray(segmentStart, newlineIndex), true, sessionId + ); + nextOffset = absoluteStart + newlineIndex + 1; + segmentStart = newlineIndex + 1; + } + nextScan = consumeLineSegment( + nextScan, chunk.subarray(segmentStart), false, sessionId + ); + if (nextScan.discardingLine) nextOffset = absoluteStart + chunk.length; + return { scan: nextScan, processedOffset: nextOffset }; +} + +function scanJsonlRange(descriptor, start, end, sessionId, initialRow, initialDiscard = false) { const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES); + let lineScan = { + state: createScanState(initialRow), + pendingChunks: [], + pendingBytes: 0, + discardingLine: initialDiscard + }; let position = start; let processedOffset = start; - let pending = Buffer.alloc(0); - let latestRow = initialRow; - let committedRow = initialRow; - let malformed = 0; - let invalid = 0; - const malformedHasher = crypto.createHash('sha256'); - const invalidHasher = crypto.createHash('sha256'); - - const processLine = (line, committed = true) => { - if (!line.trim()) return; - try { - const row = JSON.parse(line); - if (row.session_id !== sessionId) return; - if (!isValidCostRow(row, sessionId)) { - if (committed) { - invalid += 1; - invalidHasher.update(line).update('\0'); - } - return; - } - latestRow = chooseNewerCumulativeRow(latestRow, row); - if (committed) committedRow = chooseNewerCumulativeRow(committedRow, row); - } catch { - if (committed) { - malformed += 1; - malformedHasher.update(line).update('\0'); - } - } - }; while (position < end) { const bytesRead = fs.readSync( @@ -155,33 +236,40 @@ function scanJsonlRange(descriptor, start, end, sessionId, initialRow) { position ); if (bytesRead === 0) break; - const combined = pending.length > 0 - ? Buffer.concat([pending, buffer.subarray(0, bytesRead)]) - : buffer.subarray(0, bytesRead); - let lineStart = 0; - for (;;) { - const newlineIndex = combined.indexOf(0x0a, lineStart); - if (newlineIndex < 0) break; - processLine(combined.subarray(lineStart, newlineIndex).toString('utf8')); - lineStart = newlineIndex + 1; - } - pending = Buffer.from(combined.subarray(lineStart)); + const consumed = consumeJsonlChunk( + lineScan, buffer.subarray(0, bytesRead), sessionId, position, processedOffset + ); + lineScan = consumed.scan; + processedOffset = consumed.processedOffset; position += bytesRead; - processedOffset = position - pending.length; } - if (pending.toString('utf8').trim()) processLine(pending.toString('utf8'), false); + if (lineScan.pendingBytes > 0) { + const line = Buffer.concat(lineScan.pendingChunks, lineScan.pendingBytes).toString('utf8'); + lineScan = { + ...lineScan, + state: processCostLine(lineScan.state, line, sessionId, false) + }; + } + const { state } = lineScan; return { - row: latestRow, - committedRow, + row: state.latestRow, + committedRow: state.committedRow, processedOffset, - malformed, - invalid, - malformedSignature: malformed > 0 ? malformedHasher.digest('hex').slice(0, 16) : null, - invalidSignature: invalid > 0 ? invalidHasher.digest('hex').slice(0, 16) : null + malformed: state.malformed, + invalid: state.invalid, + malformedSignature: state.malformed > 0 + ? state.malformedHasher.digest('hex').slice(0, 16) + : null, + invalidSignature: state.invalid > 0 + ? state.invalidHasher.digest('hex').slice(0, 16) + : null, + discardingLine: lineScan.discardingLine }; } -function writeSnapshotAtOffset(metricsDir, sessionId, row, descriptor, stat, offset) { +function writeSnapshotAtOffset( + metricsDir, sessionId, row, descriptor, stat, offset, discardingLine = false +) { if (row !== null && !isValidCostRow(row, sessionId)) return false; const snapshot = { schema_version: COST_SNAPSHOT_SCHEMA_VERSION, @@ -189,6 +277,7 @@ function writeSnapshotAtOffset(metricsDir, sessionId, row, descriptor, stat, off identity: sourceIdentity(stat), offset_bytes: offset, mtime_ms: stat.mtimeMs, + discarding_line: discardingLine, fingerprint: fingerprintProcessedPrefix(descriptor, offset) }, row @@ -199,9 +288,6 @@ function writeSnapshotAtOffset(metricsDir, sessionId, row, descriptor, stat, off { beforeRename() { const current = fs.fstatSync(descriptor); - if (sourceIdentity(current) !== snapshot.source.identity) { - throw new Error('Cost log identity changed during snapshot publication'); - } if (current.size < offset) { throw new Error('Cost log was truncated during snapshot publication'); } @@ -220,6 +306,36 @@ function writeSnapshotAtOffset(metricsDir, sessionId, row, descriptor, stat, off return true; } +function emptySnapshotResult(row) { + return { + row, + scannedBytes: 0, + malformed: 0, + invalid: 0, + malformedSignature: null, + invalidSignature: null, + snapshotError: null + }; +} + +function publishScanSnapshot(metricsDir, sessionId, scan, descriptor, stat) { + if (!scan.committedRow && scan.processedOffset === 0) return null; + try { + writeSnapshotAtOffset( + metricsDir, + sessionId, + scan.committedRow, + descriptor, + stat, + scan.processedOffset, + scan.discardingLine + ); + return null; + } catch (error) { + return error; + } +} + function refreshSessionCostSnapshot(metricsDir, sessionId) { assertSafeSessionId(sessionId); const costsPath = path.join(metricsDir, COST_LOG_FILENAME); @@ -228,39 +344,19 @@ function refreshSessionCostSnapshot(metricsDir, sessionId) { const stat = fs.fstatSync(descriptor); const snapshot = readJsonFile(getCostSnapshotPath(metricsDir, sessionId)); const base = validSnapshotBase(snapshot, descriptor, stat, sessionId); - if (base?.offset === stat.size) { - return { - row: base.row, - scannedBytes: 0, - malformed: 0, - invalid: 0, - malformedSignature: null, - invalidSignature: null, - snapshotError: null - }; - } + if (base?.offset === stat.size) return emptySnapshotResult(base.row); + const scanEnd = Math.min(stat.size, (base?.offset || 0) + MAX_SCAN_BYTES); const scan = scanJsonlRange( descriptor, base?.offset || 0, - stat.size, + scanEnd, sessionId, - base?.row || null + base?.row || null, + base?.discardingLine || false + ); + const snapshotError = publishScanSnapshot( + metricsDir, sessionId, scan, descriptor, stat ); - let snapshotError = null; - if (scan.committedRow || scan.processedOffset > 0) { - try { - writeSnapshotAtOffset( - metricsDir, - sessionId, - scan.committedRow, - descriptor, - stat, - scan.processedOffset - ); - } catch (error) { - snapshotError = error; - } - } return { row: scan.row, scannedBytes: scan.processedOffset - (base?.offset || 0), @@ -308,8 +404,10 @@ function appendSessionCostRow(metricsDir, sessionId, row) { if (result.snapshotError) throw result.snapshotError; try { maybePruneSessionCostSnapshots(metricsDir); - } catch { - // Retention is opportunistic and retried by a later update. + } catch (error) { + // Retention is opportunistic and retried by a later update, but a + // persistent failure remains visible without rolling back the log append. + warnSessionCostSnapshotFailure('retention', metricsDir, sessionId, error); } return JSON.stringify(result.row) === JSON.stringify(row); } @@ -341,10 +439,10 @@ function maybePruneSessionCostSnapshots(metricsDir, options = {}) { try { const intervalIsFresh = now - fs.statSync(markerPath).mtimeMs < PRUNE_INTERVAL_MS; if (intervalIsFresh && snapshotEntries.length <= maxSnapshots) return 0; - } catch { /* missing marker */ } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } } - fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 }); - const snapshots = snapshotEntries .map(entry => { const filePath = path.join(snapshotDir, entry.name); @@ -361,8 +459,11 @@ function maybePruneSessionCostSnapshots(metricsDir, options = {}) { fs.rmSync(entry.filePath, { force: true }); removed += 1; } - } catch { /* already replaced or removed */ } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } } + fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 }); return removed; } diff --git a/skills/cost-tracking/SKILL.md b/skills/cost-tracking/SKILL.md index 4347ce8f4..d21a401b4 100644 --- a/skills/cost-tracking/SKILL.md +++ b/skills/cost-tracking/SKILL.md @@ -23,6 +23,8 @@ session total without rescanning all history. Treat those files as a rebuildable cache; each snapshot stores a byte cursor so only newly appended rows are scanned. Stable reads are O(1), while updates are O(new bytes). Stale entries are pruned after 30 days or when the directory exceeds 512 sessions. +Cold catch-up work is limited to 16 MiB per hook invocation, and malformed +unterminated rows larger than 1 MiB are discarded with a resumable cursor. Reports and exports should continue to use `costs.jsonl`. Row schema: diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 066c3226d..e1152116b 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -249,6 +249,57 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('normalizes malformed negative and non-finite transcript usage', () => { + const tmpHome = makeTempDir(); + const transcriptPath = path.join(tmpHome, 'session.jsonl'); + writeTranscript(transcriptPath, [{ + type: 'assistant', + message: { + id: 'msg_invalid_usage', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: -100, + output_tokens: 'Infinity', + cache_creation_input_tokens: -20, + cache_read_input_tokens: 'not-a-number', + }, + }, + }, { + type: 'assistant', + message: { + id: 'msg_overflow_1', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 1e308, output_tokens: 0 }, + }, + }, { + type: 'assistant', + message: { + id: 'msg_overflow_2', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 1e308, output_tokens: 0 }, + }, + }]); + + const result = runScript( + { session_id: 'invalid-usage', transcript_path: transcriptPath }, + withTempHome(tmpHome) + ); + assert.strictEqual(result.code, 0, result.stderr); + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const recorded = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual( + { + input: recorded.input_tokens, + output: recorded.output_tokens, + cacheWrite: recorded.cache_write_tokens, + cacheRead: recorded.cache_read_tokens, + cost: recorded.estimated_cost_usd, + }, + { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 } + ); + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + // 3. Handles empty input gracefully (test('handles empty input gracefully', () => { const tmpHome = makeTempDir(); diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index 246301622..c8dafe6dc 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -256,6 +256,18 @@ function runTests() { input_tokens: 750, output_tokens: 375 }; + const historicalRow = JSON.stringify({ + session_id: 'HISTORY', + estimated_cost_usd: 0, + input_tokens: 0, + output_tokens: 0 + }); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${historicalRow}\n`.repeat(100), + 'utf8' + ); + assert.ok(fs.statSync(path.join(metricsDir, 'costs.jsonl')).size > 3 * 256); appendSessionCostRow(metricsDir, 'S1', snapshotRow); fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) { @@ -295,6 +307,18 @@ function runTests() { input_tokens: 100, output_tokens: 50 }; + const historicalRow = JSON.stringify({ + session_id: 'HISTORY', + estimated_cost_usd: 0, + input_tokens: 0, + output_tokens: 0 + }); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${historicalRow}\n`.repeat(200), + 'utf8' + ); + assert.ok(fs.statSync(path.join(metricsDir, 'costs.jsonl')).size > 3 * 1024); appendSessionCostRow(metricsDir, 'S1', first); appendSessionCostRow(metricsDir, 'S2', { session_id: 'S2', @@ -310,7 +334,7 @@ function runTests() { }; try { assert.deepStrictEqual(readSessionCost('S1'), { totalCost: 1, totalIn: 100, totalOut: 50 }); - assert.ok(bytesReadFromCostLog <= 2 * 1024); + assert.ok(bytesReadFromCostLog <= 3 * 1024); } finally { fs.readSync = originalReadSync; } @@ -448,6 +472,7 @@ function runTests() { totalOut: 100 }); assert.match(captured, /skipped 3 invalid cumulative row\(s\) for S1/); + assert.match(captured, /during the snapshot scan of/); } finally { process.stderr.write = originalStderrWrite; if (originalHome === undefined) delete process.env.HOME; @@ -541,6 +566,7 @@ function runTests() { const matches = captured.match(/skipped 2 malformed line\(s\)/g) || []; assert.strictEqual(matches.length, 1, `expected one aggregated malformed-line breadcrumb on stderr, got: ${captured}`); + assert.match(captured, /during the snapshot scan of/); } finally { process.stderr.write = originalStderrWrite; if (originalHome === undefined) delete process.env.HOME; diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js index 153c2e932..5e004b49a 100644 --- a/tests/lib/session-cost-snapshot.test.js +++ b/tests/lib/session-cost-snapshot.test.js @@ -63,9 +63,11 @@ try { })) passed++; else failed++; if (test('a delayed older writer cannot lower the latest cumulative total', () => { - const older = row('session-order', 1); const newer = row('session-order', 2); - older.timestamp = new Date(Date.parse(newer.timestamp) + 1000).toISOString(); + const older = { + ...row('session-order', 1), + timestamp: new Date(Date.parse(newer.timestamp) + 1000).toISOString() + }; assert.strictEqual(appendSessionCostRow(root, 'session-order', newer), true); assert.strictEqual(appendSessionCostRow(root, 'session-order', older), false); assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-order').row, newer); @@ -73,9 +75,11 @@ try { if (test('publication failure followed by an older writer still converges to the newer row', () => { const sessionId = 'session-publication-race'; - const older = row(sessionId, 1); const newer = row(sessionId, 2); - older.timestamp = new Date(Date.parse(newer.timestamp) + 1000).toISOString(); + const older = { + ...row(sessionId, 1), + timestamp: new Date(Date.parse(newer.timestamp) + 1000).toISOString() + }; const snapshotPath = getCostSnapshotPath(root, sessionId); const originalRenameSync = fs.renameSync; let injectedFailure = false; @@ -99,22 +103,25 @@ try { if (test('accepts increasing cumulative totals that share a timestamp', () => { const first = row('session-same-time', 1); - const next = row('session-same-time', 2); - next.timestamp = first.timestamp; + const next = { ...row('session-same-time', 2), timestamp: first.timestamp }; appendSessionCostRow(root, 'session-same-time', first); assert.strictEqual(appendSessionCostRow(root, 'session-same-time', next), true); assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-same-time').row, next); })) passed++; else failed++; if (test('uses timestamps when cumulative dimensions move in opposite directions', () => { - const newer = row('session-mixed', 1); - newer.input_tokens = 200; - newer.output_tokens = 100; - newer.timestamp = '2026-01-02T00:00:00.000Z'; - const delayedOlder = row('session-mixed', 2); - delayedOlder.input_tokens = 100; - delayedOlder.output_tokens = 50; - delayedOlder.timestamp = '2026-01-01T00:00:00.000Z'; + const newer = { + ...row('session-mixed', 1), + input_tokens: 200, + output_tokens: 100, + timestamp: '2026-01-02T00:00:00.000Z' + }; + const delayedOlder = { + ...row('session-mixed', 2), + input_tokens: 100, + output_tokens: 50, + timestamp: '2026-01-01T00:00:00.000Z' + }; appendSessionCostRow(root, 'session-mixed', newer); assert.strictEqual(appendSessionCostRow(root, 'session-mixed', delayedOlder), false); assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-mixed').row, newer); @@ -189,6 +196,45 @@ try { assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'utf8').row, current); })) passed++; else failed++; + if (test('bounds oversized unterminated rows and caches the discarded prefix', () => { + const caseRoot = path.join(root, 'oversized-line'); + fs.mkdirSync(caseRoot, { recursive: true }); + const oversizedBytes = 32 * 1024 * 1024; + fs.writeFileSync( + path.join(caseRoot, 'costs.jsonl'), + Buffer.alloc(oversizedBytes, 0x78) + ); + const originalConcat = Buffer.concat; + let copiedBytes = 0; + Buffer.concat = function measuredConcat(list, totalLength) { + copiedBytes += totalLength ?? list.reduce((sum, item) => sum + item.length, 0); + return originalConcat.call(this, list, totalLength); + }; + try { + const first = refreshSessionCostSnapshot(caseRoot, 'oversized'); + assert.strictEqual(first.row, null); + assert.strictEqual(first.malformed, 1); + assert.ok(first.scannedBytes > 0 && first.scannedBytes < oversizedBytes); + assert.ok(copiedBytes <= 2 * 1024 * 1024, `copied ${copiedBytes} bytes`); + const second = refreshSessionCostSnapshot(caseRoot, 'oversized'); + assert.strictEqual(second.scannedBytes, oversizedBytes - first.scannedBytes); + assert.strictEqual(second.malformed, 0); + const stable = refreshSessionCostSnapshot(caseRoot, 'oversized'); + assert.strictEqual(stable.scannedBytes, 0); + const recovered = row('oversized', 3); + fs.appendFileSync( + path.join(caseRoot, 'costs.jsonl'), + `\n${JSON.stringify(recovered)}\n`, + 'utf8' + ); + const resumed = refreshSessionCostSnapshot(caseRoot, 'oversized'); + assert.deepStrictEqual(resumed.row, recovered); + assert.ok(resumed.scannedBytes < 1024); + } finally { + Buffer.concat = originalConcat; + } + })) passed++; else failed++; + if (test('rebuilds after an in-place rewrite or inode rotation', () => { const caseRoot = path.join(root, 'rotation'); fs.mkdirSync(caseRoot, { recursive: true }); @@ -296,6 +342,85 @@ try { assert.strictEqual(remaining.length, 2); })) passed++; else failed++; + if (test('surfaces retention removal failures for the caller to report', () => { + const caseRoot = path.join(root, 'retention-failure'); + fs.mkdirSync(caseRoot, { recursive: true }); + const sessionId = 'retention-target'; + appendSessionCostRow(caseRoot, sessionId, row(sessionId, 1)); + const snapshotPath = getCostSnapshotPath(caseRoot, sessionId); + const markerPath = path.join(caseRoot, 'cost-snapshots', '.last-prune'); + fs.rmSync(markerPath, { force: true }); + const old = new Date(Date.now() - 10_000); + fs.utimesSync(snapshotPath, old, old); + const originalRmSync = fs.rmSync; + fs.rmSync = function failSnapshotRemoval(filePath, options) { + if (path.resolve(filePath) === path.resolve(snapshotPath)) { + const error = new Error('injected retention failure'); + error.code = 'EACCES'; + throw error; + } + return originalRmSync.call(this, filePath, options); + }; + try { + assert.throws( + () => maybePruneSessionCostSnapshots(caseRoot, { + force: true, + now: Date.now(), + maxAgeMs: 1 + }), + /injected retention failure/ + ); + assert.strictEqual( + fs.existsSync(markerPath), + false, + 'failed pruning must not defer the next retry' + ); + } finally { + fs.rmSync = originalRmSync; + } + })) passed++; else failed++; + + if (test('reports retention failures without rolling back the appended row', () => { + const caseRoot = path.join(root, 'retention-warning'); + fs.mkdirSync(caseRoot, { recursive: true }); + const snapshotDir = path.join(caseRoot, 'cost-snapshots'); + const originalReaddirSync = fs.readdirSync; + const originalWrite = process.stderr.write.bind(process.stderr); + let captured = ''; + fs.readdirSync = function failRetentionRead(directory, options) { + if (path.resolve(directory) === path.resolve(snapshotDir)) { + const error = new Error('injected retention read failure'); + error.code = 'EACCES'; + throw error; + } + return originalReaddirSync.call(this, directory, options); + }; + process.stderr.write = chunk => { + captured += String(chunk); + return true; + }; + try { + const current = row('retention-warning-session', 1); + assert.strictEqual(appendSessionCostRow( + caseRoot, + 'retention-warning-session', + current + ), true); + assert.strictEqual(appendSessionCostRow( + caseRoot, + 'retention-warning-session', + row('retention-warning-session', 2) + ), true); + const warnings = captured.match(/retention failed/g) || []; + assert.strictEqual(warnings.length, 1); + const persisted = fs.readFileSync(path.join(caseRoot, 'costs.jsonl'), 'utf8'); + assert.match(persisted, /retention-warning-session/); + } finally { + fs.readdirSync = originalReaddirSync; + process.stderr.write = originalWrite; + } + })) passed++; else failed++; + if (test('deduplicates snapshot warnings independently by failure kind', () => { const caseRoot = path.join(root, 'warning-dedupe'); const originalWrite = process.stderr.write.bind(process.stderr); From e9302928d0b8cad5b8a3f49b710764475bb1700d Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 05:08:23 +0800 Subject: [PATCH 09/67] test(metrics): isolate snapshot read byte accounting Count only positional reads from the cost-log descriptor and use actual bytes returned, so the bounded-read assertions remain deterministic under full-suite concurrency. --- tests/hooks/ecc-metrics-bridge.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index c8dafe6dc..baa2cd21a 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -271,8 +271,11 @@ function runTests() { appendSessionCostRow(metricsDir, 'S1', snapshotRow); fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) { - bytesReadFromCostLog += length; - return originalReadSync.call(this, descriptor, buffer, offset, length, position); + const bytesRead = originalReadSync.call( + this, descriptor, buffer, offset, length, position + ); + if (Number.isSafeInteger(position)) bytesReadFromCostLog += bytesRead; + return bytesRead; }; const result = readSessionCost('S1'); @@ -329,8 +332,11 @@ function runTests() { const originalReadSync = fs.readSync; let bytesReadFromCostLog = 0; fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) { - bytesReadFromCostLog += length; - return originalReadSync.call(this, descriptor, buffer, offset, length, position); + const bytesRead = originalReadSync.call( + this, descriptor, buffer, offset, length, position + ); + if (Number.isSafeInteger(position)) bytesReadFromCostLog += bytesRead; + return bytesRead; }; try { assert.deepStrictEqual(readSessionCost('S1'), { totalCost: 1, totalIn: 100, totalOut: 50 }); From 76329557c4a547b1797794bab0cd00a91e590510 Mon Sep 17 00:00:00 2001 From: wellkilo Date: Sun, 13 Sep 2026 05:14:26 +0800 Subject: [PATCH 10/67] test(metrics): derive oversized fixture from scan cap Export the internal scan budget for regression tests and size the oversized-line fixture as exactly two bounded passes. --- scripts/lib/session-cost-snapshot.js | 1 + tests/lib/session-cost-snapshot.test.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/lib/session-cost-snapshot.js b/scripts/lib/session-cost-snapshot.js index f95ddc8cb..779f83328 100644 --- a/scripts/lib/session-cost-snapshot.js +++ b/scripts/lib/session-cost-snapshot.js @@ -500,6 +500,7 @@ module.exports = { COST_SNAPSHOT_SCHEMA_VERSION, COST_SNAPSHOT_DIRECTORY, COST_LOG_FILENAME, + MAX_SCAN_BYTES, getCostSnapshotPath, isValidCostRow, chooseNewerCumulativeRow, diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js index 5e004b49a..f941f8920 100644 --- a/tests/lib/session-cost-snapshot.test.js +++ b/tests/lib/session-cost-snapshot.test.js @@ -8,6 +8,7 @@ const path = require('path'); const { appendSessionCostRow, getCostSnapshotPath, + MAX_SCAN_BYTES, maybePruneSessionCostSnapshots, readSessionCostSnapshot, refreshSessionCostSnapshot, @@ -199,7 +200,7 @@ try { if (test('bounds oversized unterminated rows and caches the discarded prefix', () => { const caseRoot = path.join(root, 'oversized-line'); fs.mkdirSync(caseRoot, { recursive: true }); - const oversizedBytes = 32 * 1024 * 1024; + const oversizedBytes = 2 * MAX_SCAN_BYTES; fs.writeFileSync( path.join(caseRoot, 'costs.jsonl'), Buffer.alloc(oversizedBytes, 0x78) From f719f5c37b56c169373b0006bf1bad1296714be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fibili=C5=9Fim?= <96491510+Fibilisim-Tekno@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:50:42 +0300 Subject: [PATCH 11/67] fix(memory-mcp): accept metadata on initialized notifications --- scripts/memory-mcp.mjs | 4 +- tests/scripts/memory-mcp.test.js | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) mode change 100755 => 100644 scripts/memory-mcp.mjs diff --git a/scripts/memory-mcp.mjs b/scripts/memory-mcp.mjs old mode 100755 new mode 100644 index 64fe6bebc..e22827767 --- a/scripts/memory-mcp.mjs +++ b/scripts/memory-mcp.mjs @@ -383,10 +383,12 @@ function createMemoryMcpService(options = {}) { const isNotification = !hasId; if (isNotification) { + const params = message.params ?? {}; if ( message.method === 'notifications/initialized' && initializationRequested - && Object.keys(message.params || {}).length === 0 + && (!Object.prototype.hasOwnProperty.call(params, '_meta') || isRecord(params._meta)) + && Object.keys(params).every(key => key === '_meta') ) { initialized = true; } diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index 5698f93e1..52bbbb46b 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -804,6 +804,76 @@ async function main() { assert.strictEqual(invalidParams.error.code, -32600); }); + for (const [label, params] of [ + ['omitted params', undefined], + ['empty params', {}], + ['empty metadata', { _meta: {} }], + ['extension metadata', { _meta: { 'example.com/trace': 'sample' } }], + ]) { + await test(`accepts initialized notifications with ${label}`, async () => { + const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href); + const service = createMemoryMcpService({ harness: 'claude' }); + const initialized = await service.handle({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { + protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'metadata-test', version: '1.0.0' }, + }, + }); + assert.strictEqual(initialized.error, undefined); + const notification = await service.handle({ + jsonrpc: '2.0', method: 'notifications/initialized', + ...(params === undefined ? {} : { params }), + }); + assert.strictEqual(notification, null); + const listed = await service.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + assert.strictEqual(listed.error, undefined); + assert.ok(listed.result.tools.some(tool => tool.name === 'memory_search')); + }); + } + + await test('ignores initialized notifications with malformed metadata or unknown params', async () => { + const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href); + for (const params of [ + ...[null, [], 'invalid', 1, false].map(_meta => ({ _meta })), + { _meta: {}, unexpected: true }, + ]) { + const service = createMemoryMcpService({ harness: 'claude' }); + await service.handle({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { + protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'metadata-test', version: '1.0.0' }, + }, + }); + assert.strictEqual(await service.handle({ + jsonrpc: '2.0', method: 'notifications/initialized', params, + }), null); + const listed = await service.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + assert.strictEqual(listed.error?.code, -32002, JSON.stringify(params)); + } + }); + + await test('does not initialize from a metadata notification sent before initialize', async () => { + const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href); + const service = createMemoryMcpService({ harness: 'claude' }); + const notification = { + jsonrpc: '2.0', method: 'notifications/initialized', params: { _meta: {} }, + }; + assert.strictEqual(await service.handle(notification), null); + const before = await service.handle({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + assert.strictEqual(before.error?.code, -32002); + await service.handle({ + jsonrpc: '2.0', id: 2, method: 'initialize', + params: { + protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'metadata-test', version: '1.0.0' }, + }, + }); + const after = await service.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list' }); + assert.strictEqual(after.error?.code, -32002); + }); + await test('bounds queued transport work under a single-chunk request flood', async () => { const { MAX_PENDING_MESSAGES, From c62f5a9cb743044643e009ced1d0354a439deb69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:55:23 +0000 Subject: [PATCH 12/67] chore(deps): bump the actions-minor-and-patch group with 2 updates Bumps the actions-minor-and-patch group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [pnpm/action-setup](https://github.com/pnpm/action-setup). Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v7...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `pnpm/action-setup` from 6.0.10 to 6.1.0 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/0977fd99725f1db4007ccb2928dbb4e90d06cc86...ea17c68df8912ef543352723c149a84f56e3d413) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch - dependency-name: pnpm/action-setup dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-and-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/reusable-test.yml | 2 +- .github/workflows/taste-skills.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 393b46902..2e348d261 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: # Package manager setup - name: Setup pnpm if: matrix.pm == 'pnpm' && matrix.node != '18.x' - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: # Keep an explicit pnpm major because this repo's packageManager is Yarn. version: 10 diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index c3d5d0892..f5b97787e 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -38,7 +38,7 @@ jobs: - name: Setup pnpm if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x' - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: # Keep an explicit pnpm major because this repo's packageManager is Yarn. version: 10 diff --git a/.github/workflows/taste-skills.yml b/.github/workflows/taste-skills.yml index 552adb981..40e68deb3 100644 --- a/.github/workflows/taste-skills.yml +++ b/.github/workflows/taste-skills.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 From cacecada1507c63fbbc7a6e57de3bd6abe9d59c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:55:37 +0000 Subject: [PATCH 13/67] chore(deps): bump the cargo-minor-and-patch group across 1 directory with 3 updates Bumps the cargo-minor-and-patch group with 3 updates in the /ecc2 directory: [toml](https://github.com/toml-rs/toml), [ureq](https://github.com/algesten/ureq) and [uuid](https://github.com/uuid-rs/uuid). Updates `toml` from 1.1.4+spec-1.1.0 to 1.1.6+spec-1.1.0 - [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.4...toml-v1.1.6) Updates `ureq` from 3.4.0 to 3.4.1 - [Changelog](https://github.com/algesten/ureq/blob/main/CHANGELOG.md) - [Commits](https://github.com/algesten/ureq/compare/3.4.0...3.4.1) Updates `uuid` from 1.26.0 to 1.26.1 - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.26.0...v1.26.1) --- updated-dependencies: - dependency-name: toml dependency-version: 1.1.6+spec-1.1.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: ureq dependency-version: 3.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: uuid dependency-version: 1.26.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch ... Signed-off-by: dependabot[bot] --- ecc2/Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index ab4d168cc..67258a667 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -2375,9 +2375,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -2528,9 +2528,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" dependencies = [ "base64 0.23.1", "cookie_store", @@ -2548,9 +2548,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad" dependencies = [ "base64 0.23.1", "http", @@ -2590,9 +2590,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.2", From 27667bc7463affd3c45ff57f8d25e54a71d4fcf5 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Mon, 14 Sep 2026 13:24:58 +0530 Subject: [PATCH 14/67] fix(security): harden worker approval, hook traversal, MCP exec, install scripts, git hooks - orchestrate-codex-worker: drop yolo, default never approval, worktree containment - run-with-flags-shell: add path traversal containment mirroring JS guard - mcp-health-check: gate workspace probe, denylist dangerous env, shell-free reconnect with opt-in - install.sh/ps1: add --ignore-scripts to block postinstall RCE - git hooks: refuse global hooksPath clobber, remove file disable bypass, gate pre-push repo script execution - claw.js: remove Windows shell:true, validate model token - tests: opt into new secure defaults, quote-aware reconnect parsing --- install.ps1 | 3 +- install.sh | 6 +- scripts/claw.js | 26 ++++-- scripts/codex-git-hooks/pre-commit | 7 +- scripts/codex-git-hooks/pre-push | 25 ++++- scripts/codex/install-global-git-hooks.sh | 20 +++- scripts/hooks/mcp-health-check.js | 108 +++++++++++++++++++++- scripts/hooks/run-with-flags-shell.sh | 30 +++++- scripts/orchestrate-codex-worker.sh | 31 ++++++- tests/hooks/mcp-health-check.test.js | 7 ++ 10 files changed, 231 insertions(+), 32 deletions(-) diff --git a/install.ps1 b/install.ps1 index 752ac2ec4..e04db118f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -35,12 +35,13 @@ $scriptDir = Split-Path -Parent $scriptPath $installerScript = Join-Path -Path (Join-Path -Path $scriptDir -ChildPath 'scripts') -ChildPath 'install-apply.js' # Auto-install Node dependencies when running from a git clone +# SECURITY: --ignore-scripts blocks preinstall/postinstall RCE from a compromised dependency. $nodeModules = Join-Path -Path $scriptDir -ChildPath 'node_modules' if (-not (Test-Path -LiteralPath $nodeModules)) { Write-Host '[ECC] Installing dependencies...' Push-Location $scriptDir try { - & npm install --no-audit --no-fund --loglevel=error + & npm install --ignore-scripts --no-audit --no-fund --loglevel=error if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed with exit code $LASTEXITCODE" exit $LASTEXITCODE diff --git a/install.sh b/install.sh index f14c057cd..c69a11dd7 100755 --- a/install.sh +++ b/install.sh @@ -14,10 +14,12 @@ while [ -L "$SCRIPT_PATH" ]; do done SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" -# Auto-install Node dependencies when running from a git clone +# Auto-install Node dependencies when running from a git clone. +# SECURITY: --ignore-scripts blocks preinstall/postinstall RCE from a +# compromised dependency. ECC deps are pure JS (no native build step). if [ ! -d "$SCRIPT_DIR/node_modules" ]; then echo "[ECC] Installing dependencies..." - (cd "$SCRIPT_DIR" && npm install --no-audit --no-fund --loglevel=error) + (cd "$SCRIPT_DIR" && npm install --ignore-scripts --no-audit --no-fund --loglevel=error) fi # On MSYS2/Git Bash, convert the POSIX path to a Windows path so Node.js diff --git a/scripts/claw.js b/scripts/claw.js index 982ce5c22..b85cfc81c 100644 --- a/scripts/claw.js +++ b/scripts/claw.js @@ -95,20 +95,28 @@ function askClaude(systemPrompt, history, userMessage, model) { } args.push('-p'); - // On Windows the `claude` binary installed via npm is `claude.cmd`/`claude.ps1`, - // and Node's spawn() cannot resolve those wrappers via PATH without shell: true. - // But shell mode concatenates args *unescaped*, so a multi-line prompt passed as - // an arg gets mangled (newlines and the `===` section markers truncate it, and - // claude receives an empty prompt). Fix: send the prompt over stdin via `input` - // and keep only the short, safe flags (`--model`, `-p`) as args. - // 'claude' is a hardcoded literal here (not user input), so shell mode is safe. - const result = spawnSync('claude', args, { + // SECURITY: never use shell:true — on Windows Node concatenates command+args + // unquoted (DEP0190), so a model value like `x & calc &` breaks out. + // Validate the model token and spawn without a shell; resolve .cmd shim explicitly. + if (model && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) { + return `[Error: invalid model name]`; + } + let bin = 'claude'; + if (process.platform === 'win32') { + for (const ext of ['.cmd', '.exe', '.ps1']) { + try { + const found = require('child_process').spawnSync('where', [`claude${ext}`], { encoding: 'utf8' }); + if (found.status === 0 && found.stdout.trim()) { bin = found.stdout.trim().split(/\r?\n/)[0]; break; } + } catch { /* ignore */ } + } + } + const result = spawnSync(bin, args, { input: fullPrompt, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CLAUDECODE: '' }, timeout: 300000, - shell: process.platform === 'win32' + shell: false }); if (result.error) { diff --git a/scripts/codex-git-hooks/pre-commit b/scripts/codex-git-hooks/pre-commit index 98c495fef..b4c608c76 100644 --- a/scripts/codex-git-hooks/pre-commit +++ b/scripts/codex-git-hooks/pre-commit @@ -5,12 +5,13 @@ set -euo pipefail # Blocks commits that add high-signal secrets. if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PRECOMMIT:-0}" == "1" ]]; then + printf '[ECC pre-commit] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2 exit 0 fi -if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then - exit 0 -fi +# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious +# repo could ship that file and silently turn off secret scanning exactly +# where it is most needed. Use the env bypass above (audible warning) instead. if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0 diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 2ee23c7f4..f98f3ef7d 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -5,12 +5,12 @@ set -euo pipefail # Runs a lightweight verification flow before pushes. if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then + printf '[ECC pre-push] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2 exit 0 fi -if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then - exit 0 -fi +# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious +# repo could ship that file and silently disable verification. if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0 @@ -85,8 +85,14 @@ run_node_script() { } if [[ -f "package.json" ]]; then - pm="$(detect_pm)" - log "Node project detected (package manager: $pm)" + # SECURITY: executing a cloned repo's lint/test/build scripts on push is + # arbitrary code execution (package.json scripts run as you). Opt-in only: + # set ECC_PREPUSH_RUN_CHECKS=1 for repos you trust. + if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" != "1" ]]; then + log "Node project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping repo script execution (set =1 to opt in)." + else + pm="$(detect_pm)" + log "Node project detected (package manager: $pm)" for script_name in lint typecheck test build; do if has_node_script "$script_name"; then @@ -109,8 +115,12 @@ if [[ -f "package.json" ]]; then *) npm audit --omit=dev || fail "npm audit failed" ;; esac fi + fi fi +# SECURITY: go test / pytest execute repo-controlled code (TestMain, +# conftest.py). Same opt-in gate as Node scripts above. +if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" == "1" ]]; then if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then ran_any_check=1 log "Go project detected. Running: go test ./..." @@ -126,6 +136,11 @@ if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then log "Python project detected but pytest is not installed. Skipping." fi fi +else + if [[ -f "go.mod" || -f "pyproject.toml" || -f "requirements.txt" ]]; then + log "Go/Python project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping test execution." + fi +fi if [[ "$ran_any_check" -eq 0 ]]; then log "No supported checks found in this repository. Skipping." diff --git a/scripts/codex/install-global-git-hooks.sh b/scripts/codex/install-global-git-hooks.sh index ea11d8524..33a7daf8d 100755 --- a/scripts/codex/install-global-git-hooks.sh +++ b/scripts/codex/install-global-git-hooks.sh @@ -54,12 +54,24 @@ run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push" if [[ "$MODE" == "apply" ]]; then prev_hooks_path="$(git config --global core.hooksPath || true)" - if [[ -n "$prev_hooks_path" ]]; then - log "Previous global hooksPath: $prev_hooks_path" + if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then + # SECURITY: never silently displace another tool's global hooks — that + # turns every commit/push in every repo into ECC code execution and breaks + # the user's existing security controls. Require explicit opt-in to replace. + if [[ "${ECC_FORCE_GLOBAL_HOOKS:-0}" != "1" ]]; then + log "ERROR: global core.hooksPath already set to: $prev_hooks_path" + log "Refusing to overwrite. Options:" + log " 1) Per-repo install (recommended): git config core.hooksPath \"$DEST_DIR\"" + log " 2) Force replace: ECC_FORCE_GLOBAL_HOOKS=1 $0" + log " 3) Restore afterwards: git config --global core.hooksPath \"$prev_hooks_path\"" + exit 1 + fi + log "WARNING: replacing previous global hooksPath: $prev_hooks_path (ECC_FORCE_GLOBAL_HOOKS=1)" + log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\"" fi fi run_or_echo git config --global core.hooksPath "$DEST_DIR" log "Installed ECC global git hooks." -log "Disable per repo by creating .ecc-hooks-disable in project root." -log "Temporary bypass: ECC_SKIP_PRECOMMIT=1 or ECC_SKIP_PREPUSH=1" +log "Per-repo alternative (recommended): git config core.hooksPath \"$DEST_DIR\"" +log "Temporary bypass (audible): ECC_SKIP_GIT_HOOKS=1 (logs a warning to stderr)" diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index b78b5ac57..edc28a6cc 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -182,6 +182,12 @@ function extractMcpTargetFromRaw(raw) { } function resolveServerConfig(serverName) { + // SECURITY: serverName flows into env-var lookup and shell-adjacent paths. + // Reject anything outside a strict token so config-controlled names cannot + // inject shell metachars ($(..), backticks, ;) downstream. + if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) { + return null; + } for (const filePath of configPaths()) { const data = readJsonFile(filePath); const server = data?.mcpServers?.[serverName] @@ -306,9 +312,21 @@ function probeCommandServer(serverName, config) { const command = config.command; const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : []; const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS); + // SECURITY: config.env comes from repo-committed MCP configs. Never let it + // override process-critical loader vars that turn into code execution + // (LD_PRELOAD, DYLD_*, NODE_OPTIONS, PATH tampering, etc.). + const BLOCKED_ENV_PREFIXES = ['LD_', 'DYLD_', 'NODE_OPTIONS', 'NODE_PATH', 'PATH', 'PYTHONPATH', 'RUBYLIB', 'PERL5LIB']; + const rawEnv = (config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {}); + const safeConfigEnv = {}; + for (const [k, v] of Object.entries(rawEnv)) { + if (BLOCKED_ENV_PREFIXES.some(p => String(k).toUpperCase().startsWith(p))) { + continue; + } + safeConfigEnv[k] = String(v); + } const mergedEnv = { ...process.env, - ...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {}) + ...safeConfigEnv }; let done = false; @@ -515,6 +533,29 @@ function probeCommandServer(serverName, config) { async function probeServer(serverName, resolvedConfig) { const config = resolvedConfig.config; + // SECURITY: cloning a malicious repo must not auto-execute its MCP servers. + // Workspace configs (cwd .claude.json / .claude/settings.json) are untrusted + // by default; only probe them with explicit operator opt-in. + // Home configs (~/.claude.json) and explicit ECC_MCP_CONFIG_PATH remain allowed. + try { + const src = String(resolvedConfig.source || ''); + const cwd = process.cwd(); + const isWorkspaceSource = src === require('path').join(cwd, '.claude.json') + || src === require('path').join(cwd, '.claude', 'settings.json') + || src.startsWith(cwd + require('path').sep + '.claude' + require('path').sep); + if (isWorkspaceSource && !/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_ALLOW_WORKSPACE_PROBE || ''))) { + return { + ok: false, + failureCode: null, + reason: 'untrusted workspace MCP config skipped (set ECC_MCP_ALLOW_WORKSPACE_PROBE=1 to probe)', + source: resolvedConfig.source + }; + } + } catch { + // Fail closed on path errors for workspace sources is handled below; + // continue to normal probing for non-workspace sources. + } + if (config.type === 'http' || config.url) { const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS)); @@ -546,6 +587,15 @@ async function probeServer(serverName, resolvedConfig) { } function reconnectCommand(serverName) { + // SECURITY: reconnect commands are shell strings from env. Disabled by + // default; require explicit opt-in so a malicious .env/direnv cannot gain + // shell execution through this hook. + if (!/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_RECONNECT_ALLOW || ''))) { + return null; + } + if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) { + return null; + } const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`; const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || ''; if (!command.trim()) { @@ -563,8 +613,60 @@ function attemptReconnect(serverName) { return { attempted: false, success: false, reason: 'no reconnect command configured' }; } - const result = spawnSync(command, { - shell: true, + // SECURITY: never run reconnect strings through a shell. Split on + // whitespace (no glob/expansion/substitution) and spawn directly. + // Supports single/double quotes for paths with spaces (e.g. node + // "/tmp/dir with space/reconnect.js"). No variable, command, tilde, or + // glob expansion is performed. {server} was already validated above. + function splitReconnectCommand(s) { + const parts = []; + let cur = ''; + let quote = null; + let inToken = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (quote) { + if (ch === quote) { + quote = null; + } else if (ch === '\\' && quote === '"' && i + 1 < s.length && (s[i + 1] === '"' || s[i + 1] === '\\')) { + cur += s[i + 1]; + i++; + } else { + cur += ch; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + inToken = true; + } else if (/\s/.test(ch)) { + if (inToken) { + parts.push(cur); + cur = ''; + inToken = false; + } + } else { + cur += ch; + inToken = true; + } + } + if (quote) { + return null; // unbalanced quote + } + if (inToken) { + parts.push(cur); + } + return parts; + } + const parts = splitReconnectCommand(String(command).trim()); + if (!parts || parts.length === 0) { + return { attempted: false, success: false, reason: 'invalid reconnect command' }; + } + const [bin, ...argv] = parts; + if (/[&|<>^%!`$();]/.test(bin) || argv.some(a => /[`$]/.test(a))) { + return { attempted: false, success: false, reason: 'reconnect command contains unsafe characters' }; + } + + const result = spawnSync(bin, argv, { + shell: false, env: process.env, cwd: process.cwd(), encoding: 'utf8', diff --git a/scripts/hooks/run-with-flags-shell.sh b/scripts/hooks/run-with-flags-shell.sh index 227b8fc7b..9599e303f 100755 --- a/scripts/hooks/run-with-flags-shell.sh +++ b/scripts/hooks/run-with-flags-shell.sh @@ -22,9 +22,31 @@ if [[ "$ENABLED" != "yes" ]]; then exit 0 fi -SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}" -if [[ ! -f "$SCRIPT_PATH" ]]; then - echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2 +# Reject traversal / absolute / env-escape paths before touching the filesystem. +# Mirrors the containment check in run-with-flags.js (resolvedRoot prefix). +case "$REL_SCRIPT_PATH" in + /*|\\*|~*|*..*|*\$*|*\`*|*\|*|*\;*|*\&*|*\<*|*\>*|*\"*|*\'*|*\ *|*" "*) + echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2 + printf '%s' "$INPUT" + exit 0 + ;; +esac + +# Canonicalize PLUGIN_ROOT (CLAUDE_PLUGIN_ROOT is env-controlled) and the +# candidate script path, then enforce containment inside the plugin root. +PLUGIN_ROOT_CANON="$(realpath -m "$PLUGIN_ROOT" 2>/dev/null || readlink -f "$PLUGIN_ROOT" 2>/dev/null || printf '%s' "$PLUGIN_ROOT")" +SCRIPT_PATH="${PLUGIN_ROOT_CANON}/${REL_SCRIPT_PATH}" +SCRIPT_CANON="$(realpath -m "$SCRIPT_PATH" 2>/dev/null || readlink -f "$SCRIPT_PATH" 2>/dev/null || printf '%s' "$SCRIPT_PATH")" +case "$SCRIPT_CANON" in + "$PLUGIN_ROOT_CANON"/*) ;; + *) + echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2 + printf '%s' "$INPUT" + exit 0 + ;; +esac +if [[ ! -f "$SCRIPT_CANON" ]]; then + echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_CANON}" >&2 printf '%s' "$INPUT" exit 0 fi @@ -33,4 +55,4 @@ fi # This is needed by scripts like observe.sh that behave differently for PreToolUse vs PostToolUse HOOK_PHASE="${HOOK_ID%%:*}" -printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE" +printf '%s' "$INPUT" | "$SCRIPT_CANON" "$HOOK_PHASE" diff --git a/scripts/orchestrate-codex-worker.sh b/scripts/orchestrate-codex-worker.sh index d73ad0cf2..135a639e8 100755 --- a/scripts/orchestrate-codex-worker.sh +++ b/scripts/orchestrate-codex-worker.sh @@ -48,6 +48,35 @@ fi write_status "running" "- Task file: \`$task_file\`" +# SECURITY: never auto-approve agent tool execution. The worker prompt is built +# from a task file that may contain LLM-generated or third-party content +# (indirect prompt injection). `codex exec -p yolo` would execute +# rm -rf / exfiltration commands without confirmation. +# Default to the most restrictive approval mode; allow an explicit operator +# override only via env (e.g. ECC_CODEX_APPROVAL_MODE=on-request for trusted runs). +APPROVAL_MODE="${ECC_CODEX_APPROVAL_MODE:-never}" +case "$APPROVAL_MODE" in + never|on-request|on-failure) ;; + *) + echo "[ECC worker] Refusing to run: unsupported ECC_CODEX_APPROVAL_MODE='$APPROVAL_MODE' (expected never|on-request|on-failure)" >&2 + write_status "failed" "- Error: unsupported approval mode" + exit 1 + ;; +esac + +# Contain the task file to the current worktree so a malicious launcher cannot +# point the worker at /etc/passwd or a sibling checkout. +task_real="$(realpath -m "$task_file" 2>/dev/null || readlink -f "$task_file" 2>/dev/null || printf '%s' "$task_file")" +work_real="$(pwd -P 2>/dev/null || pwd)" +case "$task_real" in + "$work_real"/*) ;; + *) + echo "[ECC worker] Refusing to run: task file outside worktree: $task_file" >&2 + write_status "failed" "- Error: task file outside worktree" + exit 1 + ;; +esac + prompt_file="$(mktemp)" output_file="$(mktemp)" cleanup() { @@ -77,7 +106,7 @@ Task file: $task_file $(cat "$task_file") EOF -if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then +if codex exec -p "$APPROVAL_MODE" -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then { echo "# Handoff" echo diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index fe86290e4..beb2488e1 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -249,6 +249,9 @@ async function runTests() { ECC_MCP_CONFIG_PATH: null, ECC_MCP_HEALTH_STATE_PATH: null, ECC_MCP_HEALTH_TIMEOUT_MS: '100', + // Workspace configs are untrusted by default; this test uses a + // temp dir it created itself, so opt in explicitly. + ECC_MCP_ALLOW_WORKSPACE_PROBE: '1', HOME: homeDir, USERPROFILE: homeDir }, @@ -619,6 +622,7 @@ async function runTests() { CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', ECC_MCP_CONFIG_PATH: configPath, ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_ALLOW: '1', ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`, ECC_MCP_HEALTH_TIMEOUT_MS: '1000', ECC_MCP_HEALTH_BACKOFF_MS: '10' @@ -682,6 +686,7 @@ async function runTests() { CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', ECC_MCP_CONFIG_PATH: configPath, ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_ALLOW: '1', ECC_MCP_RECONNECT_COMMAND: `node ${JSON.stringify(reconnectScript)}`, ECC_MCP_HEALTH_TIMEOUT_MS: '1000' } @@ -773,6 +778,7 @@ async function runTests() { { CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_RECONNECT_ALLOW: '1', ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}` } ); @@ -810,6 +816,7 @@ async function runTests() { CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure', ECC_MCP_HEALTH_STATE_PATH: statePath, ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'), + ECC_MCP_RECONNECT_ALLOW: '1', ECC_MCP_RECONNECT_COMMAND: null, ECC_MCP_RECONNECT_FOO_BAR: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)} ${JSON.stringify(markerFile)} {server}` } From a3c24818bb28257c752696540a97225d5acefd68 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Mon, 14 Sep 2026 13:49:18 +0530 Subject: [PATCH 15/67] fix(security): provider error taxonomy, tool leak hardening, hook gate, atomic cleanup - ollama: connection failures -> LLMError(connection_error), unknown -> LLMError(provider_error); auth only on real 401 - executor: generic model-facing tool failure, diagnostics to logs; add execute_async/all_async; ReActAgent returns structured provider_error - run-with-flags.js: gate require() on concrete run-export syntax, not bare word match - codex-legacy-sync: remove tmp file on atomicWriteJson failure - tests: async tool + ReActAgent awaitable regression coverage --- scripts/hooks/run-with-flags.js | 13 ++++++- scripts/lib/codex-legacy-sync.js | 11 +++++- src/llm/providers/ollama.py | 31 ++++++++++++--- src/llm/tools/executor.py | 65 ++++++++++++++++++++++++++++++-- tests/test_executor.py | 56 ++++++++++++++++++++++++++- 5 files changed, 162 insertions(+), 14 deletions(-) diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 9f6de3722..cd69ed6cc 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -207,7 +207,18 @@ async function main() { // which would interfere with the parent process or cause double execution. let hookModule; const src = fs.readFileSync(scriptPath, 'utf8'); - const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src); + // Gate require() on concrete export syntax, not a bare word match: the old + // /\bmodule\.exports\b/ && /\brun\b/ test fired on comments, strings, and + // unrelated properties, causing require() — and its module-scope side + // effects — to run for hooks that export no run(). Still lexical (no parser + // dependency), but requires an actual export assignment form. + const RUN_EXPORT_PATTERNS = [ + /module\.exports\s*\.\s*run\s*=/, + /exports\s*\.\s*run\s*=/, + /module\.exports\s*=\s*\{[^}]*\brun\b/, + /module\.exports\s*=\s*(async\s+)?function\s+run\b/, + ]; + const hasRunExport = RUN_EXPORT_PATTERNS.some(re => re.test(src)); if (hasRunExport) { try { diff --git a/scripts/lib/codex-legacy-sync.js b/scripts/lib/codex-legacy-sync.js index 5eb92d180..12cdc392b 100644 --- a/scripts/lib/codex-legacy-sync.js +++ b/scripts/lib/codex-legacy-sync.js @@ -131,8 +131,15 @@ function removeOpenedRegularFile(filePath, opened) { function atomicWriteJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); - fs.renameSync(tempPath, filePath); + try { + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tempPath, filePath); + } catch (error) { + // A failed write/rename must not leave a .tmp-- file + // beside the canonical state file; repeated failures would accumulate them. + fs.rmSync(tempPath, { force: true }); + throw error; + } } function readState(statePath) { diff --git a/src/llm/providers/ollama.py b/src/llm/providers/ollama.py index 2f83338d0..793f0aa6b 100644 --- a/src/llm/providers/ollama.py +++ b/src/llm/providers/ollama.py @@ -8,6 +8,7 @@ from typing import Any from llm.core.interface import ( AuthenticationError, ContextLengthError, + LLMError, LLMProvider, RateLimitError, ) @@ -100,13 +101,33 @@ class OllamaProvider(LLMProvider): ) except Exception as e: msg = str(e) - if "401" in msg or "connection" in msg.lower(): - raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e - if "429" in msg or "rate_limit" in msg.lower(): + lowered = msg.lower() + if "401" in msg or "unauthorized" in lowered or "forbidden" in lowered: + raise AuthenticationError(f"Ollama authentication failed: {msg}", provider=ProviderType.OLLAMA) from e + if "429" in msg or "rate_limit" in lowered: raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e - if "context" in msg.lower() and "length" in msg.lower(): + if "context" in lowered and "length" in lowered: raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e - raise + if ( + "connection" in lowered + or "refused" in lowered + or "timed out" in lowered + or "timeout" in lowered + or "unreachable" in lowered + or "name resolution" in lowered + or "nodename nor servname" in lowered + or isinstance(e, (ConnectionError, TimeoutError)) + ): + raise LLMError( + f"Ollama connection failed: {type(e).__name__}", + provider=ProviderType.OLLAMA, + code="connection_error", + ) from e + raise LLMError( + f"Ollama request failed: {type(e).__name__}", + provider=ProviderType.OLLAMA, + code="provider_error", + ) from e def list_models(self) -> list[ModelInfo]: return self._models.copy() diff --git a/src/llm/tools/executor.py b/src/llm/tools/executor.py index e4a859b34..e59311c1e 100644 --- a/src/llm/tools/executor.py +++ b/src/llm/tools/executor.py @@ -2,9 +2,12 @@ from __future__ import annotations +import inspect +import logging from collections.abc import Callable from typing import Any +from llm.core.interface import LLMError from llm.core.types import ( LLMInput, LLMOutput, @@ -15,6 +18,21 @@ from llm.core.types import ( ToolResult, ) +logger = logging.getLogger(__name__) + +# Model-facing failure text. Raw exception details (credentials, local paths, +# request data, upstream responses) must never reach the model; diagnostics go +# to trusted logs only. +GENERIC_TOOL_FAILURE = "Error executing {name}: tool failed" + + +def _generic_failure(tool_call: ToolCall) -> ToolResult: + return ToolResult( + tool_call_id=tool_call.id, + content=GENERIC_TOOL_FAILURE.format(name=tool_call.name), + is_error=True, + ) + ToolFunc = Callable[..., Any] @@ -55,18 +73,50 @@ class ToolExecutor: try: result = func(**tool_call.arguments) + if inspect.isawaitable(result): + logger.warning( + "Async tool '%s' called via sync execute(); use execute_async()", + tool_call.name, + ) + return ToolResult( + tool_call_id=tool_call.id, + content=GENERIC_TOOL_FAILURE.format(name=tool_call.name), + is_error=True, + ) content = result if isinstance(result, str) else str(result) return ToolResult(tool_call_id=tool_call.id, content=content) - except Exception as e: + except Exception: + logger.exception("Tool '%s' failed", tool_call.name) + return _generic_failure(tool_call) + + async def execute_async(self, tool_call: ToolCall) -> ToolResult: + func = self.registry.get(tool_call.name) + if not func: return ToolResult( tool_call_id=tool_call.id, - content=f"Error executing {tool_call.name}: {e}", + content=f"Error: Tool '{tool_call.name}' not found", is_error=True, ) + try: + result = func(**tool_call.arguments) + if inspect.isawaitable(result): + result = await result + content = result if isinstance(result, str) else str(result) + return ToolResult(tool_call_id=tool_call.id, content=content) + except Exception: + logger.exception("Tool '%s' failed", tool_call.name) + return _generic_failure(tool_call) + def execute_all(self, tool_calls: list[ToolCall]) -> list[ToolResult]: return [self.execute(tc) for tc in tool_calls] + async def execute_all_async(self, tool_calls: list[ToolCall]) -> list[ToolResult]: + results: list[ToolResult] = [] + for tc in tool_calls: + results.append(await self.execute_async(tc)) + return results + class ReActAgent: def __init__( @@ -92,7 +142,14 @@ class ReActAgent: tools=tools, ) - output: LLMOutput = self.provider.generate(input_copy) + try: + output: LLMOutput = self.provider.generate(input_copy) + except LLMError as e: + logger.warning("Provider failed during agent run: %s", e.code or type(e).__name__) + return LLMOutput( + content=f"Provider error: {e.code or type(e).__name__}", + stop_reason="provider_error", + ) if not output.has_tool_calls: return output @@ -105,7 +162,7 @@ class ReActAgent: ) ) - results = self.executor.execute_all(output.tool_calls or []) + results = await self.executor.execute_all_async(output.tool_calls or []) for result in results: messages.append( diff --git a/tests/test_executor.py b/tests/test_executor.py index 749c4d1b4..75f522ff7 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -1,5 +1,5 @@ -from llm.core.types import ToolCall, ToolDefinition -from llm.tools import ToolExecutor, ToolRegistry +from llm.core.types import LLMInput, LLMOutput, Message, Role, ToolCall, ToolDefinition +from llm.tools import ReActAgent, ToolExecutor, ToolRegistry class TestToolRegistry: @@ -83,3 +83,55 @@ class TestToolExecutor: assert len(results) == 2 assert results[0].content == "result1" assert results[1].content == "result2" + + +class TestAsyncTools: + def test_execute_async_awaits_coroutine(self): + import asyncio + + registry = ToolRegistry() + + async def fetch(url: str = "") -> str: + return f"fetched:{url}" + + registry.register(ToolDefinition(name="fetch", description="", parameters={}), fetch) + + executor = ToolExecutor(registry) + result = asyncio.run( + executor.execute_async(ToolCall(id="1", name="fetch", arguments={"url": "x"})) + ) + + assert result.tool_call_id == "1" + assert result.content == "fetched:x" + assert result.is_error is False + + def test_react_agent_runs_awaitable_tool(self): + import asyncio + + async def lookup(key: str = "") -> str: + return f"value:{key}" + + registry = ToolRegistry() + registry.register(ToolDefinition(name="lookup", description="", parameters={}), lookup) + + seen = {} + + class FakeProvider: + def generate(self, agent_input): + if "done" not in seen: + seen["done"] = True + return LLMOutput( + content="", + tool_calls=[ToolCall(id="1", name="lookup", arguments={"key": "k"})], + ) + tool_messages = [m for m in agent_input.messages if m.role == Role.TOOL] + assert len(tool_messages) == 1 + assert tool_messages[0].content == "value:k" + return LLMOutput(content="done") + + agent = ReActAgent(provider=FakeProvider(), executor=ToolExecutor(registry)) + output = asyncio.run( + agent.run(LLMInput(messages=[Message(role=Role.USER, content="hi")])) + ) + + assert output.content == "done" From 89c2bf4e4087573ced52d52acc63a6100e43a922 Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:29:48 -0400 Subject: [PATCH 16/67] fix(llm): require an SDK that supports provider initialization --- .github/workflows/ci.yml | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 393b46902..33c59298b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,11 @@ jobs: - name: Run Python tests run: python -m pytest tests/test_*.py -m "not integration" + - name: Test minimum supported OpenAI SDK + run: | + python -m pip install 'openai==2.34.0' + python -m pytest tests/test_provider_tools.py tests/test_atlas_provider.py tests/test_astraflow_provider.py tests/test_resolver.py + security: name: Security Scan runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 2e924826f..5f979c2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ "anthropic>=0.120.2", - "openai>=1.30.0", + "openai>=2.34.0", ] [project.optional-dependencies] From 5929d246946eeb5d147612ba06d60c575c5a4e21 Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:29:49 -0400 Subject: [PATCH 17/67] fix(opencode): write hook results through the output contract --- .opencode/plugins/ecc-hooks.ts | 20 +++++++++++-------- tests/opencode-plugin-hooks.test.js | 31 +++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 22b1132f0..472f80f5a 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -481,7 +481,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ * Triggers: Before shell command execution * Action: Sets PROJECT_ROOT, PACKAGE_MANAGER, DETECTED_LANGUAGES, ECC_VERSION */ - "shell.env": async () => { + "shell.env": async (_input: { cwd: string }, output: { env: Record }) => { const env: Record = { ECC_VERSION: getECCVersion(), ECC_PLUGIN: "true", @@ -523,7 +523,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ env.PRIMARY_LANGUAGE = detected[0] } - return env + output.env = { ...output.env, ...env } }, /** @@ -531,9 +531,12 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ * OpenCode-specific: Control context compaction behavior * * Triggers: Before context compaction - * Action: Push ECC context block and custom compaction prompt + * Action: Push ECC context block and compaction guidance */ - "experimental.session.compacting": async () => { + "experimental.session.compacting": async ( + _input: { sessionID: string }, + output: { context: string[]; prompt?: string } + ) => { const contextBlock = [ "# ECC Context (preserve across compaction)", "", @@ -558,10 +561,11 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ contextBlock.push("") } - return { - context: contextBlock.join("\n"), - compaction_prompt: "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.", - } + output.context = [ + ...output.context, + contextBlock.join("\n"), + "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.", + ] }, /** diff --git a/tests/opencode-plugin-hooks.test.js b/tests/opencode-plugin-hooks.test.js index 0d261ef5c..4a6511c53 100644 --- a/tests/opencode-plugin-hooks.test.js +++ b/tests/opencode-plugin-hooks.test.js @@ -179,9 +179,12 @@ async function main() { const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const env = await hooks["shell.env"]() + const output = { env: { EXISTING_ENV: "preserved" } } + await hooks["shell.env"]({ cwd: projectDir }, output) + const { env } = output assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.strictEqual(env.EXISTING_ENV, "preserved") assert.strictEqual(env.PROJECT_ROOT, projectDir) assert.strictEqual(env.PACKAGE_MANAGER, "pnpm") assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python") @@ -243,9 +246,12 @@ async function main() { const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const env = await hooks["shell.env"]() + const output = { env: {} } + await hooks["shell.env"]({ cwd: projectDir }, output) + const { env } = output assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.strictEqual(env.PROJECT_ROOT, projectDir) assert.ok(!("PACKAGE_MANAGER" in env), "Lockfile directory should not set PACKAGE_MANAGER") assert.ok(!("DETECTED_LANGUAGES" in env), "Marker directory should not set DETECTED_LANGUAGES") assert.ok(!("PRIMARY_LANGUAGE" in env), "Marker directory should not set PRIMARY_LANGUAGE") @@ -254,6 +260,27 @@ async function main() { } }, ], + [ + "compacting appends ECC context without replacing the host compaction prompt", + async () => withTempProject([], async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + const output = { context: ["Existing plugin context"] } + + await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output) + + assert.strictEqual(output.context[0], "Existing plugin context") + const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n") + assert.ok(prompt.includes("Default compaction prompt")) + assert.ok(prompt.includes("# ECC Context")) + assert.ok(prompt.includes("Current task status and progress")) + const customOutput = { context: [], prompt: "Another plugin's custom prompt" } + await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, customOutput) + assert.strictEqual(customOutput.prompt, "Another plugin's custom prompt") + assert.deepStrictEqual($.calls, []) + }), + ], [ "permission.ask handles read-only tools correctly", async () => withTempProject( From 15f1ee8a411058bcbb492c771cba0703580f3a67 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Mon, 14 Sep 2026 21:56:32 +0530 Subject: [PATCH 18/67] fix(claw,executor): restore Windows shim launch, close stray coroutine - claw askClaude: prefer native .exe, route only .cmd/.bat through cmd.exe with a quoted command line (DEP0190-safe, same pattern as mcp-health-check); never exec .ps1 directly (not executable without powershell); model allowlist retained so the shell line carries no attacker metacharacters - executor sync path: close un-awaited coroutine before returning the generic failure, eliminating RuntimeWarning noise --- scripts/claw.js | 35 ++++++++++++++++++++++++++--------- src/llm/tools/executor.py | 2 ++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/scripts/claw.js b/scripts/claw.js index b85cfc81c..2f5ff618f 100644 --- a/scripts/claw.js +++ b/scripts/claw.js @@ -95,29 +95,46 @@ function askClaude(systemPrompt, history, userMessage, model) { } args.push('-p'); - // SECURITY: never use shell:true — on Windows Node concatenates command+args - // unquoted (DEP0190), so a model value like `x & calc &` breaks out. - // Validate the model token and spawn without a shell; resolve .cmd shim explicitly. + // SECURITY: a model value like `x & calc &` breaks out when Node + // concatenates command+args unquoted under cmd.exe (DEP0190), so the model + // token is validated and only fixed flags reach the command line. if (model && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) { return `[Error: invalid model name]`; } + // On Windows the `claude` binary is usually a .cmd shim, which Node + // >=18.20/20.12 refuses to spawn directly (CVE-2024-27980 mitigation), and + // .ps1 shims are not directly executable at all. Resolve a natively + // executable target first; only .cmd/.bat go through cmd.exe, using the + // same quoted-command-line pattern as scripts/hooks/mcp-health-check.js so + // space-containing paths survive as single tokens. .ps1 is never executed + // directly — fall through to bare `claude` (pre-change behavior) instead. + const quoteWin = token => (/[\s"&|<>^%!();]/.test(token) ? '"' + token.replace(/"/g, '""') + '"' : token); let bin = 'claude'; + let useShell = false; if (process.platform === 'win32') { - for (const ext of ['.cmd', '.exe', '.ps1']) { + const { spawnSync: spawnWhere } = require('child_process'); + for (const ext of ['.exe', '.cmd', '.bat']) { + let found = null; try { - const found = require('child_process').spawnSync('where', [`claude${ext}`], { encoding: 'utf8' }); - if (found.status === 0 && found.stdout.trim()) { bin = found.stdout.trim().split(/\r?\n/)[0]; break; } + found = spawnWhere('where', [`claude${ext}`], { encoding: 'utf8' }); } catch { /* ignore */ } + if (found && found.status === 0 && found.stdout && found.stdout.trim()) { + bin = found.stdout.trim().split(/\r?\n/)[0]; + useShell = /\.(cmd|bat)$/i.test(bin); + break; + } } } - const result = spawnSync(bin, args, { + const spawnOpts = { input: fullPrompt, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CLAUDECODE: '' }, timeout: 300000, - shell: false - }); + }; + const result = useShell + ? spawnSync([bin, ...args].map(quoteWin).join(' '), { ...spawnOpts, shell: true }) + : spawnSync(bin, args, { ...spawnOpts, shell: false }); if (result.error) { return `[Error: ${result.error.message}]`; diff --git a/src/llm/tools/executor.py b/src/llm/tools/executor.py index e59311c1e..9d051e2bf 100644 --- a/src/llm/tools/executor.py +++ b/src/llm/tools/executor.py @@ -78,6 +78,8 @@ class ToolExecutor: "Async tool '%s' called via sync execute(); use execute_async()", tool_call.name, ) + if inspect.iscoroutine(result): + result.close() return ToolResult( tool_call_id=tool_call.id, content=GENERIC_TOOL_FAILURE.format(name=tool_call.name), From c5bbee3cb88ca05f1a363ac2f29baa01e031d898 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Mon, 14 Sep 2026 23:52:01 +0530 Subject: [PATCH 19/67] fix(worker,installer,claw): correct approval flag, installer order, percent path hardening - orchestrate-codex-worker: pass approval policy via --ask-for-approval, not -p profile - codex global hooks: validate conflicting global hooksPath before backup/copy, so refused install is side-effect free - claw: reject percent-delimited Windows paths in cmd.exe fallback to avoid %NAME% expansion - codex-hooks: opt into ECC_PREPUSH_RUN_CHECKS=1 in existing verification fixtures and add default-skip coverage --- scripts/claw.js | 12 ++++++++++-- scripts/codex/install-global-git-hooks.sh | 22 +++++++++++----------- scripts/orchestrate-codex-worker.sh | 13 ++++++++----- tests/scripts/codex-hooks.test.js | 19 ++++++++++++++++--- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/scripts/claw.js b/scripts/claw.js index 2f5ff618f..74dea0f81 100644 --- a/scripts/claw.js +++ b/scripts/claw.js @@ -108,7 +108,12 @@ function askClaude(systemPrompt, history, userMessage, model) { // same quoted-command-line pattern as scripts/hooks/mcp-health-check.js so // space-containing paths survive as single tokens. .ps1 is never executed // directly — fall through to bare `claude` (pre-change behavior) instead. - const quoteWin = token => (/[\s"&|<>^%!();]/.test(token) ? '"' + token.replace(/"/g, '""') + '"' : token); + // cmd.exe expands %NAME% even inside double-quoted strings, so reject + // percent-delimited executable paths rather than route them through the shell. + function quoteWinToken(token) { + if (/%/.test(token)) return null; + return /[\s"&|<>^();]/.test(token) ? '"' + token.replace(/"/g, '""') + '"' : token; + } let bin = 'claude'; let useShell = false; if (process.platform === 'win32') { @@ -124,6 +129,9 @@ function askClaude(systemPrompt, history, userMessage, model) { break; } } + if (useShell && quoteWinToken(bin) === null) { + useShell = false; + } } const spawnOpts = { input: fullPrompt, @@ -133,7 +141,7 @@ function askClaude(systemPrompt, history, userMessage, model) { timeout: 300000, }; const result = useShell - ? spawnSync([bin, ...args].map(quoteWin).join(' '), { ...spawnOpts, shell: true }) + ? spawnSync([bin, ...args].map(quoteWinToken).join(' '), { ...spawnOpts, shell: true }) : spawnSync(bin, args, { ...spawnOpts, shell: false }); if (result.error) { diff --git a/scripts/codex/install-global-git-hooks.sh b/scripts/codex/install-global-git-hooks.sh index 33a7daf8d..0c37c3c2c 100755 --- a/scripts/codex/install-global-git-hooks.sh +++ b/scripts/codex/install-global-git-hooks.sh @@ -41,17 +41,6 @@ log "Mode: $MODE" log "Source hooks: $SOURCE_DIR" log "Global hooks destination: $DEST_DIR" -if [[ -d "$DEST_DIR" ]]; then - log "Backing up existing hooks directory to $BACKUP_DIR" - run_or_echo mkdir -p "$BACKUP_DIR" - run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks" -fi - -run_or_echo mkdir -p "$DEST_DIR" -run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit" -run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push" -run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push" - if [[ "$MODE" == "apply" ]]; then prev_hooks_path="$(git config --global core.hooksPath || true)" if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then @@ -70,6 +59,17 @@ if [[ "$MODE" == "apply" ]]; then log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\"" fi fi + +if [[ -d "$DEST_DIR" ]]; then + log "Backing up existing hooks directory to $BACKUP_DIR" + run_or_echo mkdir -p "$BACKUP_DIR" + run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks" +fi + +run_or_echo mkdir -p "$DEST_DIR" +run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit" +run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push" +run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push" run_or_echo git config --global core.hooksPath "$DEST_DIR" log "Installed ECC global git hooks." diff --git a/scripts/orchestrate-codex-worker.sh b/scripts/orchestrate-codex-worker.sh index 135a639e8..fde2485ff 100755 --- a/scripts/orchestrate-codex-worker.sh +++ b/scripts/orchestrate-codex-worker.sh @@ -54,12 +54,15 @@ write_status "running" "- Task file: \`$task_file\`" # rm -rf / exfiltration commands without confirmation. # Default to the most restrictive approval mode; allow an explicit operator # override only via env (e.g. ECC_CODEX_APPROVAL_MODE=on-request for trusted runs). -APPROVAL_MODE="${ECC_CODEX_APPROVAL_MODE:-never}" -case "$APPROVAL_MODE" in +# Codex profiles (-p) and approval policies (--ask-for-approval) are +# independent concepts. SECURITY: default to never approving untrusted +# tool execution; operators can override via env. +APPROVAL_POLICY="${ECC_CODEX_APPROVAL_POLICY:-never}" +case "$APPROVAL_POLICY" in never|on-request|on-failure) ;; *) - echo "[ECC worker] Refusing to run: unsupported ECC_CODEX_APPROVAL_MODE='$APPROVAL_MODE' (expected never|on-request|on-failure)" >&2 - write_status "failed" "- Error: unsupported approval mode" + echo "[ECC worker] Refusing to run: unsupported ECC_CODEX_APPROVAL_POLICY='$APPROVAL_POLICY' (expected never|on-request|on-failure)" >&2 + write_status "failed" "- Error: unsupported approval policy" exit 1 ;; esac @@ -106,7 +109,7 @@ Task file: $task_file $(cat "$task_file") EOF -if codex exec -p "$APPROVAL_MODE" -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then +if codex exec --ask-for-approval "$APPROVAL_POLICY" -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then { echo "# Handoff" echo diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 0dfe1d2f9..53c884c06 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -169,6 +169,7 @@ function runHermeticPrePush({ includeCorepack = true, includePnpm = false, audit = false, + runChecks = true, } = {}) { const tempDir = createTempDir('codex-pre-push-'); const binDir = path.join(tempDir, 'bin'); @@ -204,6 +205,7 @@ ${includePnpm ? functionStub('pnpm', false) : ''} PATH: toBashPath(binDir), BASH_ENV: toBashPath(bashEnv), ECC_PREPUSH_AUDIT: audit ? '1' : '0', + ECC_PREPUSH_RUN_CHECKS: runChecks ? '1' : '0', ECC_SKIP_GIT_HOOKS: '0', ECC_SKIP_PREPUSH: '0', MSYS_NO_PATHCONV: '1', @@ -221,7 +223,7 @@ ${includePnpm ? functionStub('pnpm', false) : ''} if ( test('pre-push uses Corepack pinned pnpm and runs every required verification script', () => { - const { result, calls } = runHermeticPrePush(); + const { result, calls } = runHermeticPrePush({ runChecks: true }); assert.strictEqual(result.status, 0, JSON.stringify(result, null, 2)); assert.deepStrictEqual(calls, [ 'pnpm run lint', @@ -264,7 +266,7 @@ else failed++; if ( test('pre-push stops immediately when a required verification script fails', () => { - const { result, calls } = runHermeticPrePush({ failScript: 'typecheck' }); + const { result, calls } = runHermeticPrePush({ runChecks: true, failScript: 'typecheck' }); assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepStrictEqual(calls, ['pnpm run lint', 'pnpm run typecheck']); assert.match(result.stderr, /typecheck failed/); @@ -273,9 +275,20 @@ if ( passed++; else failed++; +if ( + test('pre-push skips verification scripts by default when opt-in is not set', () => { + const { result, calls } = runHermeticPrePush({ runChecks: false }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, []); + assert.match(result.stderr, /ECC_PREPUSH_RUN_CHECKS!=1/); + }) +) + passed++; +else failed++; + if ( test('pre-push runs the production audit through Corepack pnpm', () => { - const { result, calls } = runHermeticPrePush({ audit: true }); + const { result, calls } = runHermeticPrePush({ runChecks: true, audit: true }); assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepStrictEqual(calls, [ 'pnpm run lint', From d5dee31321910617f63eb117e126df53415ffa82 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Tue, 15 Sep 2026 00:29:23 +0530 Subject: [PATCH 20/67] fix(security): stderr skip, independent audit, home MCP trust, dry-run conflict check - pre-push: send skip diagnostic to stderr (not stdout) so consumers relying on stderr for warnings receive the message - pre-push: move ECC_PREPUSH_AUDIT outside RUN_CHECKS gate so audit-only configurations still check dependencies - mcp-health-check: classify home config paths as trusted before applying workspace opt-in gate; when cwd == home, ~/.claude.json was incorrectly blocked as untrusted workspace config - install-global-git-hooks: check conflicting global core.hooksPath in dry-run mode too, so dry-run accurately reflects what apply would do --- scripts/codex-git-hooks/pre-push | 5 ++-- scripts/codex/install-global-git-hooks.sh | 30 +++++++++++------------ scripts/hooks/mcp-health-check.js | 29 ++++++++++++++-------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index f98f3ef7d..ed32c9369 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -89,7 +89,7 @@ if [[ -f "package.json" ]]; then # arbitrary code execution (package.json scripts run as you). Opt-in only: # set ECC_PREPUSH_RUN_CHECKS=1 for repos you trust. if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" != "1" ]]; then - log "Node project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping repo script execution (set =1 to opt in)." + printf '[ECC pre-push] Node project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping repo script execution (set =1 to opt in).\n' >&2 else pm="$(detect_pm)" log "Node project detected (package manager: $pm)" @@ -104,7 +104,9 @@ if [[ -f "package.json" ]]; then fi done + fi if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then + pm="${pm:-$(detect_pm)}" ran_any_check=1 log "Running dependency audit (ECC_PREPUSH_AUDIT=1)" case "$pm" in @@ -115,7 +117,6 @@ if [[ -f "package.json" ]]; then *) npm audit --omit=dev || fail "npm audit failed" ;; esac fi - fi fi # SECURITY: go test / pytest execute repo-controlled code (TestMain, diff --git a/scripts/codex/install-global-git-hooks.sh b/scripts/codex/install-global-git-hooks.sh index 0c37c3c2c..22702a5f5 100755 --- a/scripts/codex/install-global-git-hooks.sh +++ b/scripts/codex/install-global-git-hooks.sh @@ -41,23 +41,21 @@ log "Mode: $MODE" log "Source hooks: $SOURCE_DIR" log "Global hooks destination: $DEST_DIR" -if [[ "$MODE" == "apply" ]]; then - prev_hooks_path="$(git config --global core.hooksPath || true)" - if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then - # SECURITY: never silently displace another tool's global hooks — that - # turns every commit/push in every repo into ECC code execution and breaks - # the user's existing security controls. Require explicit opt-in to replace. - if [[ "${ECC_FORCE_GLOBAL_HOOKS:-0}" != "1" ]]; then - log "ERROR: global core.hooksPath already set to: $prev_hooks_path" - log "Refusing to overwrite. Options:" - log " 1) Per-repo install (recommended): git config core.hooksPath \"$DEST_DIR\"" - log " 2) Force replace: ECC_FORCE_GLOBAL_HOOKS=1 $0" - log " 3) Restore afterwards: git config --global core.hooksPath \"$prev_hooks_path\"" - exit 1 - fi - log "WARNING: replacing previous global hooksPath: $prev_hooks_path (ECC_FORCE_GLOBAL_HOOKS=1)" - log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\"" +prev_hooks_path="$(git config --global core.hooksPath || true)" +if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then + # SECURITY: never silently displace another tool's global hooks — that + # turns every commit/push in every repo into ECC code execution and breaks + # the user's existing security controls. Require explicit opt-in to replace. + if [[ "${ECC_FORCE_GLOBAL_HOOKS:-0}" != "1" ]]; then + log "ERROR: global core.hooksPath already set to: $prev_hooks_path" + log "Refusing to overwrite. Options:" + log " 1) Per-repo install (recommended): git config core.hooksPath \"$DEST_DIR\"" + log " 2) Force replace: ECC_FORCE_GLOBAL_HOOKS=1 $0" + log " 3) Restore afterwards: git config --global core.hooksPath \"$prev_hooks_path\"" + exit 1 fi + log "WARNING: replacing previous global hooksPath: $prev_hooks_path (ECC_FORCE_GLOBAL_HOOKS=1)" + log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\"" fi if [[ -d "$DEST_DIR" ]]; then diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index edc28a6cc..843a6ec04 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -540,16 +540,25 @@ async function probeServer(serverName, resolvedConfig) { try { const src = String(resolvedConfig.source || ''); const cwd = process.cwd(); - const isWorkspaceSource = src === require('path').join(cwd, '.claude.json') - || src === require('path').join(cwd, '.claude', 'settings.json') - || src.startsWith(cwd + require('path').sep + '.claude' + require('path').sep); - if (isWorkspaceSource && !/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_ALLOW_WORKSPACE_PROBE || ''))) { - return { - ok: false, - failureCode: null, - reason: 'untrusted workspace MCP config skipped (set ECC_MCP_ALLOW_WORKSPACE_PROBE=1 to probe)', - source: resolvedConfig.source - }; + const home = require('os').homedir(); + const pathMod = require('path'); + // A config file in the user's home directory (~/.claude.json or + // ~/.claude/settings.json) is always trusted regardless of cwd. + const isHomeSource = src === pathMod.join(home, '.claude.json') + || src === pathMod.join(home, '.claude', 'settings.json') + || src.startsWith(pathMod.join(home, '.claude') + pathMod.sep); + if (!isHomeSource) { + const isWorkspaceSource = src === pathMod.join(cwd, '.claude.json') + || src === pathMod.join(cwd, '.claude', 'settings.json') + || src.startsWith(cwd + pathMod.sep + '.claude' + pathMod.sep); + if (isWorkspaceSource && !/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_ALLOW_WORKSPACE_PROBE || ''))) { + return { + ok: false, + failureCode: null, + reason: 'untrusted workspace MCP config skipped (set ECC_MCP_ALLOW_WORKSPACE_PROBE=1 to probe)', + source: resolvedConfig.source + }; + } } } catch { // Fail closed on path errors for workspace sources is handled below; From c0ee74778925a24a844357095d6722d476a41600 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Tue, 15 Sep 2026 14:47:27 +0800 Subject: [PATCH 21/67] fix: preserve edited Codex user configuration --- scripts/lib/install-lifecycle.js | 27 +- scripts/lib/install/apply.js | 2 +- scripts/lib/install/codex-user-config.js | 54 +++ scripts/lib/install/ownership-guard.js | 32 +- .../install-codex-config-preservation.test.js | 371 ++++++++++++++++++ 5 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/install/codex-user-config.js create mode 100644 tests/lib/install-codex-config-preservation.test.js diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index ec9cb1d80..7da0ae78a 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -9,6 +9,8 @@ const { loadInstallManifests } = require('./install-manifests'); const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createInstallPlanFromRequest } = require('./install/runtime'); +const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard'); +const { isCodexUserConfig } = require('./install/codex-user-config'); const { getRecordedHookConsent } = require('./install/hook-consent'); const { prepareClaudeSkillMigration, @@ -1871,7 +1873,7 @@ function assertValidInstallStateForWrite(state, label) { throw new Error(`Invalid install-state (${label}): ${details}`); } -function writeRefreshedInstallState(record, statePreview) { +function writeRefreshedInstallState(record, statePreview, writtenPaths = []) { const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record); const stateWithCurrentDigests = { ...trustedStatePreview, @@ -1879,6 +1881,19 @@ function writeRefreshedInstallState(record, statePreview) { if (!operation.destinationPath) { return { ...operation }; } + // Refreshing a ledger is not a file write. Keep the last installed digest + // for untouched shared configs so a concurrent user edit is never claimed. + if (isCodexUserConfig(record, operation) + && !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) { + const previousOperation = (record.state.operations || []).find(previous => ( + previous.destinationPath + && path.relative(previous.destinationPath, operation.destinationPath) === '' + )); + const { contentSha256: _plannedDigest, ...operationWithoutDigest } = operation; + return previousOperation && previousOperation.contentSha256 + ? { ...operationWithoutDigest, contentSha256: previousOperation.contentSha256 } + : operationWithoutDigest; + } try { const contentSha256 = crypto.createHash('sha256') .update(readFileNoFollow(operation.destinationPath)) @@ -1908,7 +1923,10 @@ function prepareRepairMigration(plan, record) { installStatePath: record.installStatePath, statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record), }; - const migration = prepareClaudeSkillMigration(trustedPlan); + const skillMigration = prepareClaudeSkillMigration(trustedPlan); + const migration = record.adapter.id === 'codex-home' + ? prepareUserOwnedFileGuard(trustedPlan, skillMigration) + : skillMigration; return { migration, plan: { @@ -2157,6 +2175,9 @@ function repairInstalledStates(options = {}) { } for (const operation of repairOperations) { + if (record.adapter.id === 'codex-home') { + assertNoNewUserOwnedFile(migration, operation, desiredPlan); + } const repairedPath = executeRepairOperation( context.repoRoot, operation, @@ -2192,7 +2213,7 @@ function repairInstalledStates(options = {}) { installedAt: record.state.installedAt, source: { ...record.state.source }, }; - writeRefreshedInstallState(record, statePreviewToWrite); + writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); return { adapter: record.adapter, diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fbab1293b..0ff5c0280 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -491,7 +491,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeOperationWrite === 'function') { beforeOperationWrite({ plan: appliedPlan, operation }); } - assertNoNewUserOwnedFile(migration, operation); + assertNoNewUserOwnedFile(migration, operation, appliedPlan); if ( operation.kind === 'update-claude-settings' diff --git a/scripts/lib/install/codex-user-config.js b/scripts/lib/install/codex-user-config.js new file mode 100644 index 000000000..5b7aa612c --- /dev/null +++ b/scripts/lib/install/codex-user-config.js @@ -0,0 +1,54 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { assertWithinTrustedRoot } = require('../path-safety'); + +function isCodexUserConfig(plan, operation) { + if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') { + return false; + } + const relativePath = path.relative(plan.targetRoot, operation.destinationPath); + const name = process.platform === 'win32' ? relativePath.toLowerCase() : relativePath; + return name === 'config.toml' || name === (process.platform === 'win32' ? 'agents.md' : 'AGENTS.md'); +} + +function readConfigDigest(plan, destinationPath) { + assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration'); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const descriptor = fs.openSync(destinationPath, flags); + try { + const opened = fs.fstatSync(descriptor, { bigint: true }); + const current = fs.lstatSync(destinationPath, { bigint: true }); + if (!opened.isFile() || !current.isFile() || current.isSymbolicLink() + || opened.ino !== current.ino || opened.dev !== current.dev) { + throw new Error(`Refusing to inspect changed Codex configuration: ${destinationPath}`); + } + assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration'); + return crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex'); + } finally { + fs.closeSync(descriptor); + } +} + +function hasEditedCodexUserConfig(plan, operation, previousOperation) { + if (!isCodexUserConfig(plan, operation)) { + return false; + } + let digest; + try { + digest = readConfigDigest(plan, operation.destinationPath); + } catch (error) { + if (error.code === 'ENOENT') { + return false; // A missing scaffold can still be restored. + } + throw error; + } + // Compare with the bytes ECC actually installed, never the newest template. + // Old ledgers without a digest cannot prove that an existing file is unchanged. + const recorded = previousOperation && previousOperation.contentSha256; + return !/^[a-f0-9]{64}$/i.test(recorded || '') || digest !== recorded.toLowerCase(); +} + +module.exports = { hasEditedCodexUserConfig, isCodexUserConfig }; diff --git a/scripts/lib/install/ownership-guard.js b/scripts/lib/install/ownership-guard.js index 962c62e88..965761cdc 100644 --- a/scripts/lib/install/ownership-guard.js +++ b/scripts/lib/install/ownership-guard.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { readInstallState } = require('../install-state'); +const { hasEditedCodexUserConfig } = require('./codex-user-config'); function pathExists(filePath) { try { @@ -60,24 +61,32 @@ function prepareUserOwnedFileGuard(plan, migration) { ); const managedDestinations = new Set(previousManagedOperations.keys()); - const appliedOperations = []; + const plannedOperations = (migration && migration.appliedOperations) || []; + const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath))); + // Selective reinstalls retain earlier modules in the ledger. Inspect those + // entries too, without turning them into additional writes in this install. + const retainedOperations = ((migration.finalState && migration.finalState.operations) || []) + .filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath)) + && previousManagedOperations.has(comparablePath(operation.destinationPath))); const skippedOperations = []; const warnings = []; - for (const operation of (migration && migration.appliedOperations) || []) { + for (const operation of [...plannedOperations, ...retainedOperations]) { + const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath)); + const editedConfig = previousOperation + && hasEditedCodexUserConfig(plan, operation, previousOperation); if ( operation && operation.kind === 'copy-file' && operation.destinationPath && pathExists(operation.destinationPath) - && !managedDestinations.has(comparablePath(operation.destinationPath)) + && (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig) ) { skippedOperations.push(operation); - warnings.push( - `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.` - ); + warnings.push(editedConfig + ? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.` + : `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`); continue; } - appliedOperations.push(operation); } if (skippedOperations.length === 0) { @@ -87,6 +96,9 @@ function prepareUserOwnedFileGuard(plan, migration) { const skippedDestinations = new Set( skippedOperations.map(operation => comparablePath(operation.destinationPath)) ); + const appliedOperations = plannedOperations.filter(operation => ( + !skippedDestinations.has(comparablePath(operation.destinationPath)) + )); const filterStateOperations = operations => (operations || []) .filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath))); @@ -125,7 +137,11 @@ function prepareUserOwnedFileGuard(plan, migration) { }; } -function assertNoNewUserOwnedFile(migration, operation) { +function assertNoNewUserOwnedFile(migration, operation, plan) { + const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath)); + if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) { + throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`); + } if (operation.kind !== 'copy-file' || migration.managedDestinations.has(comparablePath(operation.destinationPath)) || !pathExists(operation.destinationPath)) { diff --git a/tests/lib/install-codex-config-preservation.test.js b/tests/lib/install-codex-config-preservation.test.js new file mode 100644 index 000000000..f8b9d12fe --- /dev/null +++ b/tests/lib/install-codex-config-preservation.test.js @@ -0,0 +1,371 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); +const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply'); +const { + buildDoctorReport, repairInstalledStates, uninstallInstalledStates, +} = require('../../scripts/lib/install-lifecycle'); +const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); + +const SHARED_FILES = ['config.toml', 'AGENTS.md']; +const TEMPLATES = { + 'config.toml': '# ECC defaults\nmodel = "example-model"\n', + 'AGENTS.md': '# ECC instructions\n\nFollow the project conventions.\n', +}; + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function createFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-preservation-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, 'source'); + const homeDir = path.join(root, 'home'); + const projectRoot = path.join(root, 'project'); + const json = (relativePath, value) => writeFile( + path.join(sourceRoot, relativePath), `${JSON.stringify(value, null, 2)}\n` + ); + json('package.json', { version: '1.0.0' }); + json('manifests/install-modules.json', { + version: 1, + modules: [{ + id: 'platform-configs', kind: 'platform', description: 'Codex configuration fixture', + paths: ['.codex'], targets: ['codex'], dependencies: [], + defaultInstall: true, cost: 'light', stability: 'stable', + }, { + id: 'helper-scripts', kind: 'platform', description: 'Independent helper fixture', + paths: ['scripts'], targets: ['codex'], dependencies: [], + defaultInstall: false, cost: 'light', stability: 'stable', + }], + }); + json('manifests/install-profiles.json', { + version: 1, profiles: { minimal: { description: 'Fixture', modules: ['platform-configs'] } }, + }); + for (const name of SHARED_FILES) writeFile(path.join(sourceRoot, '.codex', name), TEMPLATES[name]); + writeFile(path.join(sourceRoot, 'scripts', 'independent-helper.js'), 'module.exports = "helper";\n'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + const options = { sourceRoot, homeDir, projectRoot, env: {} }; + const lifecycleOptions = { repoRoot: sourceRoot, homeDir, projectRoot, targets: ['codex'], env: {} }; + const plan = (moduleIds = ['platform-configs']) => createInstallPlanFromRequest({ + mode: 'manifest', target: 'codex', profileId: null, moduleIds, + includeComponentIds: [], excludeComponentIds: [], hookConsent: 'declined', + }, options); + return { + sourceRoot, + destination: name => path.join(homeDir, '.codex', name), + statePath: path.join(homeDir, '.codex', 'ecc-install-state.json'), + plan, + install: moduleIds => applyInstallPlan(plan(moduleIds)), + repair: (dryRun = false) => repairInstalledStates({ ...lifecycleOptions, dryRun }), + doctor: () => buildDoctorReport(lifecycleOptions), + uninstall: () => uninstallInstalledStates(lifecycleOptions), + }; +} + +function lifecycleResult(report) { + assert.equal(report.results.length, 1); + assert.notEqual(report.results[0].status, 'error', report.results[0].error); + return report.results[0]; +} + +function assertPreserved(fixture, name, content) { + assert.deepEqual(fs.readFileSync(fixture.destination(name)), Buffer.from(content)); +} + +function assertUnmanaged(fixture, name) { + assert.ok(!readInstallState(fixture.statePath).operations.some(operation => ( + operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed' + )), `${name} must not remain managed after preserving user content`); +} + +function assertWarning(result, name) { + assert.ok((result.warnings || []).some(warning => ( + warning.includes(name) && /skip|preserv|user-owned|modif/i.test(warning) + )), `Expected an explicit preservation warning for ${name}`); +} + +function editAfterRepairInspection(fixture, name, content, action) { + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + const inspectedDescriptors = new Set(); + let injected = false; + fs.openSync = function (filePath, ...args) { + const descriptor = originalOpen.call(fs, filePath, ...args); + if (!injected && filePath === fixture.destination(name) + && new Error().stack.includes('inspectManagedOperation')) { + inspectedDescriptors.add(descriptor); + } + return descriptor; + }; + fs.closeSync = function (descriptor) { + const result = originalClose.call(fs, descriptor); + if (!injected && inspectedDescriptors.delete(descriptor)) { + // Inspection has read the previous bytes. Simulate an editor saving next, + // before repair checkpoints or refreshes state; no digest-refresh hook is used. + injected = true; + writeFile(fixture.destination(name), content); + } + return result; + }; + try { + return { result: action(), injected }; + } finally { + fs.openSync = originalOpen; + fs.closeSync = originalClose; + } +} + +for (const name of SHARED_FILES) { + for (const stage of ['bridge', 'no-op refresh']) { + test(`repair ${stage} does not claim a concurrent edit to Codex ${name}`, t => { + const fixture = createFixture(t); + fixture.install(); + const previousOperation = readInstallState(fixture.statePath).operations.find(operation => ( + operation.destinationPath === fixture.destination(name) + )); + if (stage === 'bridge') { + writeFile(path.join(fixture.sourceRoot, '.codex', name), + `${TEMPLATES[name]}\n# Updated upstream template\n`); + } + const content = `${TEMPLATES[name]}\r\n# Saved after repair inspected the file\r\n`; + + const { result: report, injected } = editAfterRepairInspection( + fixture, name, content, () => fixture.repair() + ); + + assert.ok(injected, 'The simulated edit must occur after repair inspection'); + assert.equal(report.results.length, 1); + if (stage === 'bridge') { + assert.equal(report.results[0].status, 'error'); + assert.match(report.results[0].error, /Refusing.*user configuration.*changed after planning/); + } else { + assert.equal(report.results[0].status, 'ok'); + } + assertPreserved(fixture, name, content); + const refreshedOperation = readInstallState(fixture.statePath).operations.find(operation => ( + operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed' + )); + if (refreshedOperation) { + assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256, + 'Repair must retain the previous digest for configuration it did not write'); + } + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + } + + test(`selective reinstall releases edited Codex ${name} retained from an earlier module`, t => { + const fixture = createFixture(t); + fixture.install(); + const content = `${TEMPLATES[name]}\n# Keep this across unrelated module installations\n`; + writeFile(fixture.destination(name), content); + const selectivePlan = fixture.plan(['helper-scripts']); + assert.ok(!selectivePlan.operations.some(operation => ( + operation.destinationPath === fixture.destination(name) + )), 'The edited configuration must not be in the selected module operations'); + + const result = applyInstallPlan(selectivePlan); + + assertPreserved(fixture, name, content); + assertUnmanaged(fixture, name); + assertWarning(result, name); + lifecycleResult(fixture.repair()); + assertPreserved(fixture, name, content); + assertUnmanaged(fixture, name); + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + + test(`reinstall rejects a last-minute edit to Codex ${name} without claiming the edited bytes`, t => { + const fixture = createFixture(t); + fixture.install(); + const destination = fixture.destination(name); + const previousOperation = readInstallState(fixture.statePath).operations.find(operation => ( + operation.destinationPath === destination + )); + const rawPlan = fixture.plan(); + // Write the other file first to exercise the partial-install checkpoint on failure. + const plan = { + ...rawPlan, + operations: [ + ...rawPlan.operations.filter(operation => operation.destinationPath !== destination), + ...rawPlan.operations.filter(operation => operation.destinationPath === destination), + ], + }; + const content = `${TEMPLATES[name]}\r\n# Saved while ECC was running\r\n`; + let wroteAnotherFile = false; + let injectedEdit = false; + + assert.throws(() => applyInstallPlan(plan, { + beforeOperationWrite({ operation }) { + if (operation.destinationPath !== destination) { + wroteAnotherFile = true; + return; + } + assert.ok(wroteAnotherFile, 'The failure must exercise a partial install'); + writeFile(destination, content); + injectedEdit = true; + }, + }), /Refusing.*user configuration.*changed after planning/); + + assert.ok(injectedEdit); + assertPreserved(fixture, name, content); + const checkpointOperation = readInstallState(fixture.statePath).operations.find(operation => ( + operation.destinationPath === destination && operation.ownership === 'managed' + )); + if (checkpointOperation) { + assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256, + 'A failure checkpoint must retain the old digest, never adopt the user edit'); + } + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + + test(`repeated repair keeps edited Codex ${name} unmanaged while repairing an ECC script`, t => { + const fixture = createFixture(t); + const scriptName = path.join('scripts', 'ecc-helper.js'); + const scriptContent = 'module.exports = "ECC helper";\n'; + writeFile(path.join(fixture.sourceRoot, '.codex', scriptName), scriptContent); + fixture.install(); + const content = `${TEMPLATES[name]}\n# Keep my preferences\n`; + writeFile(fixture.destination(name), content); + + lifecycleResult(fixture.repair()); + assertPreserved(fixture, name, content); + assertUnmanaged(fixture, name); + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = lifecycleResult(fixture.doctor()); + assert.ok(!before.issues.some(issue => issue.code === 'drifted-managed-files')); + writeFile(fixture.destination(scriptName), 'damaged ECC helper\n'); + const damaged = lifecycleResult(fixture.doctor()); + assert.ok(damaged.issues.some(issue => issue.code === 'drifted-managed-files')); + + const result = lifecycleResult(fixture.repair()); + + assert.ok(result.repairedPaths.includes(fixture.destination(scriptName))); + assertPreserved(fixture, scriptName, scriptContent); + assertPreserved(fixture, name, content); + assertUnmanaged(fixture, name); + const after = lifecycleResult(fixture.doctor()); + assert.ok(!after.issues.some(issue => issue.code === 'drifted-managed-files')); + } + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + assert.ok(!fs.existsSync(fixture.destination(scriptName))); + }); + + for (const action of ['reinstall', 'repair']) { + test(`${action} preserves edited Codex ${name} and leaves it safe to uninstall`, t => { + const fixture = createFixture(t); + fixture.install(); + const content = `${TEMPLATES[name]}\r\n# Personal preferences — 保留\r\n`; + writeFile(fixture.destination(name), content); + + const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair()); + + assertPreserved(fixture, name, content); + assertWarning(result, name); + assertUnmanaged(fixture, name); + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + } + + test(`dry runs warn about edited Codex ${name} without changing files or state`, t => { + const fixture = createFixture(t); + fixture.install(); + const content = `${TEMPLATES[name]}\n# User customization\n`; + writeFile(fixture.destination(name), content); + const previousState = fs.readFileSync(fixture.statePath); + + const preview = previewInstallPlan(fixture.plan()); + const repairPreview = lifecycleResult(fixture.repair(true)); + + assertPreserved(fixture, name, content); + assert.deepEqual(fs.readFileSync(fixture.statePath), previousState); + assertWarning(preview, name); + assertWarning(repairPreview, name); + assert.ok(!preview.operations.some(operation => operation.destinationPath === fixture.destination(name))); + assert.ok(!repairPreview.plannedRepairs.includes(fixture.destination(name))); + }); + + test(`pre-existing Codex ${name} survives install, repair and uninstall`, t => { + const fixture = createFixture(t); + const content = '# Personal file before ECC installation\r\n保持原样\r\n'; + writeFile(fixture.destination(name), content); + + assertWarning(fixture.install(), name); + assertPreserved(fixture, name, content); + assertUnmanaged(fixture, name); + const repairResult = lifecycleResult(fixture.repair()); + assertPreserved(fixture, name, content); + assertWarning(repairResult, name); + assertUnmanaged(fixture, name); + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + + for (const action of ['reinstall', 'repair']) { + test(`${action} preserves Codex ${name} when legacy state lacks its content digest`, t => { + const fixture = createFixture(t); + fixture.install(); + const state = readInstallState(fixture.statePath); + writeInstallState(fixture.statePath, { + ...state, + operations: state.operations.map(operation => { + if (operation.destinationPath !== fixture.destination(name)) return operation; + const { contentSha256: _contentSha256, ...legacyOperation } = operation; + return legacyOperation; + }), + }); + // Even bytes equal to today's template cannot prove ownership without a recorded digest. + const content = fs.readFileSync(fixture.destination(name)); + const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair()); + + assertPreserved(fixture, name, content); + assertWarning(result, name); + assertUnmanaged(fixture, name); + lifecycleResult(fixture.uninstall()); + assertPreserved(fixture, name, content); + }); + } + + for (const action of ['reinstall', 'repair']) { + test(`${action} updates unedited Codex ${name} when the template changes`, t => { + const fixture = createFixture(t); + fixture.install(); + const updated = `${TEMPLATES[name]}\n# New upstream default\n`; + writeFile(path.join(fixture.sourceRoot, '.codex', name), updated); + + if (action === 'reinstall') fixture.install(); + else lifecycleResult(fixture.repair()); + + assertPreserved(fixture, name, updated); + assert.ok(readInstallState(fixture.statePath).operations.some(operation => ( + operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed' + ))); + lifecycleResult(fixture.uninstall()); + assert.ok(!fs.existsSync(fixture.destination(name))); + }); + } + + test(`repair restores missing managed Codex ${name}`, t => { + const fixture = createFixture(t); + fixture.install(); + const installedContent = fs.readFileSync(fixture.destination(name)); + fs.unlinkSync(fixture.destination(name)); + + lifecycleResult(fixture.repair()); + + assertPreserved(fixture, name, installedContent); + }); +} From 08094a34f960714f63e3dd7a28e0df2438ec98f2 Mon Sep 17 00:00:00 2001 From: Yann Roberto <1922498827@qq.com> Date: Tue, 15 Sep 2026 15:11:52 +0800 Subject: [PATCH 22/67] test: require preserved Codex ledger entries --- .../install-codex-config-preservation.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/lib/install-codex-config-preservation.test.js b/tests/lib/install-codex-config-preservation.test.js index f8b9d12fe..368cc5cbf 100644 --- a/tests/lib/install-codex-config-preservation.test.js +++ b/tests/lib/install-codex-config-preservation.test.js @@ -154,10 +154,10 @@ for (const name of SHARED_FILES) { const refreshedOperation = readInstallState(fixture.statePath).operations.find(operation => ( operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed' )); - if (refreshedOperation) { - assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256, - 'Repair must retain the previous digest for configuration it did not write'); - } + assert.ok(refreshedOperation, + 'Repair must retain the previous ledger entry for configuration it did not write'); + assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256, + 'Repair must retain the previous digest for configuration it did not write'); lifecycleResult(fixture.uninstall()); assertPreserved(fixture, name, content); }); @@ -222,10 +222,10 @@ for (const name of SHARED_FILES) { const checkpointOperation = readInstallState(fixture.statePath).operations.find(operation => ( operation.destinationPath === destination && operation.ownership === 'managed' )); - if (checkpointOperation) { - assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256, - 'A failure checkpoint must retain the old digest, never adopt the user edit'); - } + assert.ok(checkpointOperation, + 'A failure checkpoint must retain the previous ledger entry'); + assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256, + 'A failure checkpoint must retain the old digest, never adopt the user edit'); lifecycleResult(fixture.uninstall()); assertPreserved(fixture, name, content); }); From 2cdc218c45a81ce46035832b13bf68d91137301e Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:20:14 -0400 Subject: [PATCH 23/67] fix(opencode): preserve ECC guidance in custom compaction prompts --- .opencode/plugins/ecc-hooks.ts | 11 ++++++++-- tests/opencode-plugin-hooks.test.js | 32 ++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 472f80f5a..d496e61a5 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -523,6 +523,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ env.PRIMARY_LANGUAGE = detected[0] } + // OpenCode reads the supplied output object and ignores callback return values. output.env = { ...output.env, ...env } }, @@ -561,11 +562,17 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ contextBlock.push("") } - output.context = [ - ...output.context, + const eccContext = [ contextBlock.join("\n"), "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.", ] + + // OpenCode requires output assignment and skips context when a prompt is set. + if (output.prompt !== undefined) { + output.prompt = [output.prompt, ...eccContext].join("\n\n") + } else { + output.context = [...output.context, ...eccContext] + } }, /** diff --git a/tests/opencode-plugin-hooks.test.js b/tests/opencode-plugin-hooks.test.js index 4a6511c53..a6c3ed2ae 100644 --- a/tests/opencode-plugin-hooks.test.js +++ b/tests/opencode-plugin-hooks.test.js @@ -179,12 +179,14 @@ async function main() { const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const output = { env: { EXISTING_ENV: "preserved" } } + const existingEnv = Object.freeze({ EXISTING_ENV: "preserved" }) + const output = { env: existingEnv } await hooks["shell.env"]({ cwd: projectDir }, output) const { env } = output assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) assert.strictEqual(env.EXISTING_ENV, "preserved") + assert.notStrictEqual(env, existingEnv) assert.strictEqual(env.PROJECT_ROOT, projectDir) assert.strictEqual(env.PACKAGE_MANAGER, "pnpm") assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python") @@ -266,18 +268,38 @@ async function main() { const client = createClient() const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const output = { context: ["Existing plugin context"] } + const existingContext = Object.freeze(["Existing plugin context"]) + const output = { context: existingContext } await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output) assert.strictEqual(output.context[0], "Existing plugin context") + assert.notStrictEqual(output.context, existingContext) const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n") assert.ok(prompt.includes("Default compaction prompt")) assert.ok(prompt.includes("# ECC Context")) assert.ok(prompt.includes("Current task status and progress")) - const customOutput = { context: [], prompt: "Another plugin's custom prompt" } - await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, customOutput) - assert.strictEqual(customOutput.prompt, "Another plugin's custom prompt") + assert.deepStrictEqual($.calls, []) + }), + ], + [ + "compacting appends ECC guidance to custom prompts, including an empty prompt", + async () => withTempProject([], async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + + for (const customPrompt of ["Another plugin's custom prompt", ""]) { + const existingContext = Object.freeze(["Existing plugin context"]) + const output = { context: existingContext, prompt: customPrompt } + await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output) + + const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n") + assert.ok(prompt.startsWith(`${customPrompt}\n\n`)) + assert.ok(prompt.includes("# ECC Context")) + assert.ok(prompt.includes("Current task status and progress")) + assert.strictEqual(output.context, existingContext) + } assert.deepStrictEqual($.calls, []) }), ], From e65f12bf7ea474a6f5ac96991a251673f474b445 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 15 Sep 2026 14:22:02 -0400 Subject: [PATCH 24/67] release: ecc-universal 2.2.2 --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .opencode/package-lock.json | 4 +-- .opencode/package.json | 2 +- .opencode/plugins/ecc-hooks.ts | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 32 +++++++++++++++++-- README.md | 44 +++++++++++++------------- README.zh-CN.md | 2 +- VERSION | 2 +- agent.yaml | 2 +- docs/SELECTIVE-INSTALL-ARCHITECTURE.md | 2 +- docs/pt-BR/README.md | 2 +- docs/tr/AGENTS.md | 2 +- docs/tr/README.md | 2 +- docs/zh-CN/AGENTS.md | 2 +- docs/zh-CN/README.md | 4 +-- package-lock.json | 4 +-- package.json | 3 +- plugins/ecc/.codex-plugin/plugin.json | 2 +- tests/scripts/build-opencode.test.js | 3 +- 23 files changed, 78 insertions(+), 48 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6da48b541..e730e4eed 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "ecc", - "version": "2.2.1", + "version": "2.2.2", "source": { "source": "local", "path": "./" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 03b3f9f85..b19c87b8d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "ecc", "source": "./", "description": "Harness-native ECC operator layer - 68 agents, 292 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", - "version": "2.2.1", + "version": "2.2.2", "author": { "name": "Affaan Mustafa", "email": "me@affaanmustafa.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5f1e9a391..072edddfe 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ecc", - "version": "2.2.1", + "version": "2.2.2", "description": "Harness-native ECC plugin for engineering teams - 68 agents, 292 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 7ad227cac..c1399c129 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ecc", - "version": "2.2.1", + "version": "2.2.2", "description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.", "author": { "name": "Affaan Mustafa", diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index f6c140bdd..1ea48a9d1 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -1,12 +1,12 @@ { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "license": "MIT", "devDependencies": { "@opencode-ai/plugin": "^1.4.3", diff --git a/.opencode/package.json b/.opencode/package.json index 94e8e3f04..e71d5df73 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -1,6 +1,6 @@ { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "description": "ECC plugin for OpenCode - agents, commands, hooks, and skills", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 22b1132f0..54881ad86 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -537,7 +537,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ const contextBlock = [ "# ECC Context (preserve across compaction)", "", - "## Active Plugin: ECC v2.2.1", + "## Active Plugin: ECC v2.2.2", "- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask", "- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files", "- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)", diff --git a/AGENTS.md b/AGENTS.md index 085342923..bd0f8c3e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ This is a **production-ready AI coding plugin** providing 68 specialized agents, 292 skills, 94 commands, and automated hook workflows for software development. -**Version:** 2.2.1 +**Version:** 2.2.2 ## Core Principles diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d71ce43..c89605c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,38 @@ # Changelog -## Unreleased +## 2.2.2 - 2026-09-15 ### Fixed -- Claude settings updates now tolerate a missing Windows device ID while retaining full-precision inode checks and strict matching when both device IDs are available. +#### Packaging + +- Explicitly include the compiled OpenCode payload in the npm package and verify that packing builds it from a clean state with lifecycle scripts enabled. + +#### Memory and MCP + +- Distinguish incomplete memory reads from missing records and classify directory traversal failures (`90ef62cb`, `8321021c`). +- Accept the reserved `_meta` parameter on memory MCP ping requests (`380f4b35`). + +#### Hooks and Windows compatibility + +- Keep `hooks.json` within Claude Code's schema by moving stable hook metadata into a validated sidecar (`1ac07903`). +- Handle stuck optional values and long-option prefixes in the no-verify guard (`4f373874`). +- Support Windows linter paths and ESLint 9 (`2083c983`). +- Tolerate missing Windows device IDs in settings updates while retaining full-precision inode checks and strict matching when both device IDs are available (`d3af582b`). + +#### Workflow guidance and catalog + +- Filter epic sync issues by label (`3033436d`). +- Remove instructions to auto-merge dependency bumps and synchronize localized merge authority (`22d7ed51`, `678c6dea`). +- Keep common naming and Boolean guidance language-neutral (`072e4684`, `a0ecb793`, `013ed0a8`). +- Distinguish the `prp-pr` command alias (`cc91c24f`). +- Correct Rails skill discovery, invoice tax calculation order, and framework documentation (`b6ddd13a`). +- Remove Serply and Squish catalog entries (`c4904e3f`). + +#### Dependency security + +- Update `lru` to 0.18.2 for RUSTSEC-2026-0253 (`4fc950c4`). +- Update `js-yaml` to 4.3.2 for GHSA-2883-xcg3-v3hh (`549c1469`). ## 2.2.0 - 2026-08-25 diff --git a/README.md b/README.md index 86dba5120..7189e5ca6 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Access to 68 agents, 292 skills, and 94 legacy command shims, plus hooks, rules, For Claude Code plugin setup, updates, scope changes, and hook-profile changes: ```bash -npx ecc-universal@2.2.1 setup +npx ecc-universal@2.2.2 setup ``` If npm reports a version or cache error, confirm the registry version before retrying: @@ -181,12 +181,12 @@ ECC 2.2 supports the same guided setup through modern package runners: | Package runner | Guided setup command | |---|---| -| npm / npx | `npx ecc-universal@2.2.1 setup` | -| pnpm | `pnpm dlx ecc-universal@2.2.1 setup` | -| Yarn 2+ | `yarn dlx ecc-universal@2.2.1 setup` | -| Bun | `bunx ecc-universal@2.2.1 setup` | +| npm / npx | `npx ecc-universal@2.2.2 setup` | +| pnpm | `pnpm dlx ecc-universal@2.2.2 setup` | +| Yarn 2+ | `yarn dlx ecc-universal@2.2.2 setup` | +| Bun | `bunx ecc-universal@2.2.2 setup` | -The examples select [the published ECC 2.2.1 release](https://www.npmjs.com/package/ecc-universal/v/2.2.1), matching this repository's release version. A version pin is not a security audit or an integrity check. Review the release source and registry integrity before running package code; use a reviewed checkout for unreleased changes. +The examples select [the published ECC 2.2.2 release](https://www.npmjs.com/package/ecc-universal/v/2.2.2), matching this repository's release version. A version pin is not a security audit or an integrity check. Review the release source and registry integrity before running package code; use a reviewed checkout for unreleased changes. Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run. @@ -195,7 +195,7 @@ The wizard inventories the official marketplace and every native Claude install To configure more than one coding agent in one reviewed flow, use the multi-harness wizard: ```bash -npx ecc-universal@2.2.1 install --guided +npx ecc-universal@2.2.2 install --guided ``` It lets you select any combination of Claude Code, Codex, and Kimi Code, shows each install channel and destination, preflights every selection before the first write, and asks for one final confirmation. @@ -209,7 +209,7 @@ It lets you select any combination of Claude Code, Codex, and Kimi Code, shows e For automation, make every provider-specific choice explicit: ```bash -npx ecc-universal@2.2.1 install --guided \ +npx ecc-universal@2.2.2 install --guided \ --harness claude --harness codex --harness kimi \ --claude-scope local --claude-hooks standard \ --profile core --yes @@ -218,16 +218,16 @@ npx ecc-universal@2.2.1 install --guided \ Verify the native guided Codex path and managed Kimi path without writing first: ```bash -npx ecc-universal@2.2.1 install --guided --harness codex --dry-run -npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run +npx ecc-universal@2.2.2 install --guided --harness codex --dry-run +npx ecc-universal@2.2.2 install --profile core --target kimi --dry-run ``` Additional package-name commands are also available through the 2.2 alias: ```bash -npx ecc-universal@2.2.1 consult "security reviews" --target claude -npx ecc-universal@2.2.1 install --profile minimal --target claude --with capability:machine-learning -npx ecc-universal@2.2.1 doctor --target kimi +npx ecc-universal@2.2.2 consult "security reviews" --target claude +npx ecc-universal@2.2.2 install --profile minimal --target claude --with capability:machine-learning +npx ecc-universal@2.2.2 doctor --target kimi ``` Do not use `npx ecc-install --profile minimal --target claude`: `ecc-install` is a binary name inside `ecc-universal`, not a separately published npm package. @@ -399,7 +399,7 @@ Deep per-harness notes (feature parity, hook adapters, limitations) live in [Pla Use this when you want ECC's rules, agents, commands, platform config, and core workflows without runtime hooks: ```bash -npx ecc-universal@2.2.1 install --profile minimal --target claude +npx ecc-universal@2.2.2 install --profile minimal --target claude ``` From a source checkout, the equivalent command is: @@ -584,11 +584,11 @@ If you installed from the universal package, run these commands from the same project directory used for installation: ```bash -npx ecc-universal@2.2.1 list-installed -npx ecc-universal@2.2.1 doctor -npx ecc-universal@2.2.1 repair -npx ecc-universal@2.2.1 uninstall --dry-run -npx ecc-universal@2.2.1 uninstall +npx ecc-universal@2.2.2 list-installed +npx ecc-universal@2.2.2 doctor +npx ecc-universal@2.2.2 repair +npx ecc-universal@2.2.2 uninstall --dry-run +npx ecc-universal@2.2.2 uninstall ``` From a source checkout, inspect the managed state before reinstalling: @@ -777,7 +777,7 @@ The `ito-compute-cli` package is currently unpublished. Build it locally from th ## What's New -Current release: **2.2.1** (2026-08-31). Highlights of the 2.2 line: +Current release: **2.2.2** (2026-08-31). Highlights of the 2.2 line: - Guided, manifest-driven setup across Claude Code, Codex, and Kimi Code, with install-state ownership, doctor, repair, and uninstall. - Native Antigravity install, a thin Pi adapter, and the packed-artifact release gate tested on Linux, macOS, and Windows. @@ -1194,7 +1194,7 @@ ECC's Memory Vault gives Claude, Codex, Hermes, OpenClaw, Kimi, and other harnes Skill-only, minimal, manual, and Claude plugin installs do not put the Memory Vault runtime on `PATH`. Install the npm runtime separately before using the CLI or optional MCP server: ```bash -npm install -g ecc-universal@2.2.1 +npm install -g ecc-universal@2.2.2 ecc memory init --scope project ecc memory search "authentication migration" --target-harness codex ecc memory doctor @@ -1589,7 +1589,7 @@ opencode **Option 2: Install as npm package** ```bash -npm install ecc-universal@2.2.1 +npm install ecc-universal@2.2.2 ``` Then add to your `opencode.json`: diff --git a/README.zh-CN.md b/README.zh-CN.md index e01fd54e2..552d69b58 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,7 +80,7 @@ ## 最新动态 -### v2.2.1 — 引导式多 Harness 安装(2026年8月) +### v2.2.2 — 引导式多 Harness 安装(2026年8月) 新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。 diff --git a/VERSION b/VERSION index c043eea77..b1b25a5ff 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.1 +2.2.2 diff --git a/agent.yaml b/agent.yaml index ac7578d8d..4236f04cc 100644 --- a/agent.yaml +++ b/agent.yaml @@ -1,6 +1,6 @@ spec_version: "0.1.0" name: ecc -version: 2.2.1 +version: 2.2.2 description: "Initial gitagent export surface for ECC's shared skill catalog, governance, and identity. Native agents, commands, and hooks remain authoritative in the repository while manifest coverage expands." author: affaan-m license: MIT diff --git a/docs/SELECTIVE-INSTALL-ARCHITECTURE.md b/docs/SELECTIVE-INSTALL-ARCHITECTURE.md index 63e50f028..cd5e2226d 100644 --- a/docs/SELECTIVE-INSTALL-ARCHITECTURE.md +++ b/docs/SELECTIVE-INSTALL-ARCHITECTURE.md @@ -703,7 +703,7 @@ Suggested payload: "skippedModules": [] }, "source": { - "repoVersion": "2.2.1", + "repoVersion": "2.2.2", "repoCommit": "git-sha", "manifestVersion": 1 }, diff --git a/docs/pt-BR/README.md b/docs/pt-BR/README.md index c0d9203b6..e33eff641 100644 --- a/docs/pt-BR/README.md +++ b/docs/pt-BR/README.md @@ -79,7 +79,7 @@ Este repositório contém apenas o código. Os guias explicam tudo. ## O Que Há de Novo -### v2.2.1 — Instalação Guiada para Múltiplos Harnesses (Ago 2026) +### v2.2.2 — Instalação Guiada para Múltiplos Harnesses (Ago 2026) Adiciona uma instalação revisável para Claude Code, Codex e Kimi Code, com uma entrada de comando npm sincronizada. diff --git a/docs/tr/AGENTS.md b/docs/tr/AGENTS.md index c49b9962a..664ada702 100644 --- a/docs/tr/AGENTS.md +++ b/docs/tr/AGENTS.md @@ -2,7 +2,7 @@ Bu, yazılım geliştirme için 68 özel agent, 292 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. -**Sürüm:** 2.2.1 +**Sürüm:** 2.2.2 ## Temel İlkeler diff --git a/docs/tr/README.md b/docs/tr/README.md index f7546bed7..1fc5e2f5b 100644 --- a/docs/tr/README.md +++ b/docs/tr/README.md @@ -79,7 +79,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor. ## Yenilikler -### v2.2.1 — Rehberli Çoklu Harness Kurulumu (Ağu 2026) +### v2.2.2 — Rehberli Çoklu Harness Kurulumu (Ağu 2026) Claude Code, Codex ve Kimi Code için incelenebilir çoklu harness kurulumu ve eşitlenmiş npm komut girişi eklendi. diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 2f5a18856..208d6a7b1 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -2,7 +2,7 @@ 这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、292 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 -**版本:** 2.2.1 +**版本:** 2.2.2 ## 核心原则 diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 422f22d5d..3228c6159 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -81,7 +81,7 @@ ## 最新动态 -### v2.2.1 — 引导式多 Harness 安装(2026年8月) +### v2.2.2 — 引导式多 Harness 安装(2026年8月) 新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。 @@ -1292,7 +1292,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 | **上下文文件** | CLAUDE.md + AGENTS.md | AGENTS.md | AGENTS.md | AGENTS.md | | **秘密检测** | 基于钩子 | beforeSubmitPrompt 钩子 | 基于沙箱 | 基于钩子 | | **自动格式化** | PostToolUse 钩子 | afterFileEdit 钩子 | N/A | file.edited 钩子 | -| **版本** | 插件 | 插件 | 参考配置 | 2.2.1 | +| **版本** | 插件 | 插件 | 参考配置 | 2.2.2 | **关键架构决策:** diff --git a/package-lock.json b/package-lock.json index d5b64895a..fff2ca0d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "license": "MIT", "dependencies": { "@iarna/toml": "2.2.5", diff --git a/package.json b/package.json index 87487a914..22422f91a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ecc-universal", - "version": "2.2.1", + "version": "2.2.2", "description": "Harness-native agent operating system for Codex, OpenCode, Cursor, Gemini, Claude Code, and terminal workflows - skills, hooks, rules, MCP conventions, and operator control-plane patterns", "publishConfig": { "access": "public" @@ -51,6 +51,7 @@ ".hermes/", ".kimi/", ".opencode/", + ".opencode/dist/", ".pi/", ".openclaw/", ".qwen/", diff --git a/plugins/ecc/.codex-plugin/plugin.json b/plugins/ecc/.codex-plugin/plugin.json index ff95de1b4..07f376cd6 100644 --- a/plugins/ecc/.codex-plugin/plugin.json +++ b/plugins/ecc/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ecc", - "version": "2.2.1", + "version": "2.2.2", "description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.", "author": { "name": "Affaan Mustafa", diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 469165883..3f27680af 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -117,7 +117,8 @@ function main() { assert.strictEqual(result.status, 0, result.stderr) }], ["npm pack includes the compiled OpenCode dist payload", () => { - const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { + fs.rmSync(path.dirname(distEntry), { recursive: true, force: true }) + const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts=false"], { cwd: repoRoot, encoding: "utf8", shell: process.platform === "win32", From e3afc47a8bf3089d56d9255763ff18e825e71745 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 15 Sep 2026 14:39:38 -0400 Subject: [PATCH 25/67] test: align release expectations with version and OpenCode dist --- tests/scripts/install-readme-clarity.test.js | 33 ++++++++++---------- tests/scripts/npm-publish-surface.test.js | 4 +++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/tests/scripts/install-readme-clarity.test.js b/tests/scripts/install-readme-clarity.test.js index a52ae8ccd..12e2d511f 100644 --- a/tests/scripts/install-readme-clarity.test.js +++ b/tests/scripts/install-readme-clarity.test.js @@ -6,6 +6,8 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); +const { version } = require('../../package.json'); + const README = path.join(__dirname, '..', '..', 'README.md'); const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md'); const CODEX_AGENTS = path.join(__dirname, '..', '..', '.codex', 'AGENTS.md'); @@ -53,10 +55,10 @@ function runTests() { if (test('README leads with the idempotent guided plugin setup path', () => { const topClaudeSectionIndex = readme.indexOf('## Install with Claude Code'); - const topGuidedCommandIndex = readme.indexOf('npx ecc-universal@2.2.1 setup', topClaudeSectionIndex); + const topGuidedCommandIndex = readme.indexOf(`npx ecc-universal@${version} setup`, topClaudeSectionIndex); const nativePluginCommandIndex = readme.indexOf('/plugin marketplace add', topClaudeSectionIndex); const installSectionIndex = readme.indexOf('## Install ECC'); - const guidedCommandIndex = readme.indexOf('npx ecc-universal@2.2.1 setup', installSectionIndex); + const guidedCommandIndex = readme.indexOf(`npx ecc-universal@${version} setup`, installSectionIndex); const claudeDetailsIndex = readme.indexOf('### Claude Code details', installSectionIndex); assert.ok( @@ -95,9 +97,9 @@ function runTests() { })) passed++; else failed++; if (test('README documents modern package-runner alternatives', () => { - assert.ok(readme.includes('pnpm dlx ecc-universal@2.2.1 setup')); - assert.ok(readme.includes('yarn dlx ecc-universal@2.2.1 setup')); - assert.ok(readme.includes('bunx ecc-universal@2.2.1 setup')); + assert.ok(readme.includes(`pnpm dlx ecc-universal@${version} setup`)); + assert.ok(readme.includes(`yarn dlx ecc-universal@${version} setup`)); + assert.ok(readme.includes(`bunx ecc-universal@${version} setup`)); assert.ok( readme.includes('Yarn Classic 1 does not provide `yarn dlx`'), 'README should not advertise the modern Yarn command to Yarn Classic users' @@ -122,10 +124,10 @@ function runTests() { 'README should document doctor before reinstalling' ); for (const command of [ - 'npx ecc-universal@2.2.1 list-installed', - 'npx ecc-universal@2.2.1 doctor', - 'npx ecc-universal@2.2.1 repair', - 'npx ecc-universal@2.2.1 uninstall --dry-run', + `npx ecc-universal@${version} list-installed`, + `npx ecc-universal@${version} doctor`, + `npx ecc-universal@${version} repair`, + `npx ecc-universal@${version} uninstall --dry-run`, ]) { assert.ok( readme.includes(command), @@ -148,7 +150,7 @@ function runTests() { 'README should document the shell minimal profile command' ); assert.ok( - readme.includes('npx ecc-universal@2.2.1 install --profile minimal --target claude'), + readme.includes(`npx ecc-universal@${version} install --profile minimal --target claude`), 'README should document the published universal-package minimal profile command' ); assert.ok( @@ -175,7 +177,7 @@ function runTests() { 'README should surface component discovery before install steps' ); assert.ok( - readme.includes('npx ecc-universal@2.2.1 consult "security reviews" --target claude'), + readme.includes(`npx ecc-universal@${version} consult "security reviews" --target claude`), 'README should document the packaged consult command' ); assert.ok( @@ -193,15 +195,15 @@ function runTests() { if (test('README gives the native guided Codex and managed Kimi dry-run paths', () => { assert.ok( - readme.includes('npx ecc-universal@2.2.1 install --guided --harness codex --dry-run'), + readme.includes(`npx ecc-universal@${version} install --guided --harness codex --dry-run`), 'README should verify Codex through the native guided reconciler' ); assert.ok( - !readme.includes('npx ecc-universal@2.2.1 install --profile core --target codex --dry-run'), + !readme.includes(`npx ecc-universal@${version} install --profile core --target codex --dry-run`), 'README should not present the legacy managed Codex adapter as the native lifecycle' ); assert.ok( - readme.includes('npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run') + readme.includes(`npx ecc-universal@${version} install --profile core --target kimi --dry-run`) ); for (const target of ['cursor', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw']) { assert.ok(readme.includes(`\`${target}\``), `README should name the ${target} target`); @@ -313,7 +315,6 @@ function runTests() { })) passed++; else failed++; if (test('README binds package runners to the release and avoids unaudited bootstraps', () => { - const version = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'))).version; const runners = [...readme.matchAll(/(?:npx |pnpm dlx |yarn dlx |bunx )(ecc-universal[^\s`]+)/g)]; assert.ok(runners.length >= 15); for (const match of runners) assert.strictEqual(match[1], `ecc-universal@${version}`); @@ -321,7 +322,7 @@ function runTests() { assert.ok(!/npm install -g opencode(?:\s|$)/m.test(readme)); assert.match(readme, /version pin is not a security audit/i); assert.match(readme, /already installed.*reviewed.*AgentShield/i); - assert.ok(readme.includes('https://www.npmjs.com/package/ecc-universal/v/2.2.1')); + assert.ok(readme.includes(`https://www.npmjs.com/package/ecc-universal/v/${version}`)); })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 0ca2fc05f..bec9096d4 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -122,8 +122,12 @@ function buildExpectedPublishPaths(repoRoot) { [...modules.flatMap((module) => module.paths || []), ...extraPaths, ...exclusionPaths].map(normalizePublishPath) ) + // npm needs an explicit entry to include this gitignored build output. + const requiredBuildPaths = [".opencode/dist"] + return [...combined] .filter((publishPath) => !isCoveredByAncestor(publishPath, combined)) + .concat(requiredBuildPaths) .sort() } From 48acd64ae63422cb5c036e30c5dcfb2564fb6056 Mon Sep 17 00:00:00 2001 From: ECC Agent Date: Tue, 15 Sep 2026 20:26:49 +0000 Subject: [PATCH 26/67] fix(pi): recognize @scope/pi-subagents in /ecc-doctor companion check The /ecc-doctor false-negative for users who installed @tintinweb/pi-subagents (a working, separately-published subagents implementation) because matching was a bare exact string match against COMPANION_PACKAGES. Matching is now asymmetric: - exact match first (stable reported name when both present) - unscoped entry also satisfied by @scope/ (via @ + /suffix check, not plain endsWith, so my-pi-subagents does not qualify) - scoped entry is exact-only (no silent substitution by another publisher's rpiv-todo for @juicesharp/rpiv-todo) - non-exact match emits "satisfied by: " so user sees the real impl Verification (already performed, not re-run here): - node tests/pi/pi-extension-adapter.test.js: 31/31 pass - Mutation test: the three reverts (old .has loop; delete scoped guard; plain endsWith) each fail the new test - Real settings.json with npm:@tintinweb/pi-subagents produces correct "installed pi-subagents" / "satisfied by: @tintinweb/pi-subagents" - Full npm test: 4546/4636 pass, failures byte-identical to clean main Refs: pi-subagents capability, ecc-doctor report --- .pi/extensions/index.ts | 47 +++++++- tests/pi/pi-extension-adapter.test.js | 166 ++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) diff --git a/.pi/extensions/index.ts b/.pi/extensions/index.ts index f8310a8d5..65810292d 100644 --- a/.pi/extensions/index.ts +++ b/.pi/extensions/index.ts @@ -141,6 +141,10 @@ const DISABLED_VALUES = new Set(["0", "false", "off", "none", "disabled"]) /** * Optional Pi companion packages. ECC works without every one of these; they * are reported by `/ecc-doctor` so users can see which extras are available. + * + * These are capability names, not exact install specs. See + * `findInstalledCompanion` for how an entry is matched against what Pi has + * actually installed. */ const COMPANION_PACKAGES = [ "pi-subagents", @@ -475,6 +479,41 @@ function normalizePiPackageName(entry: unknown): string | undefined { return versionAt > 0 ? spec.slice(0, versionAt) : spec } +/** + * The installed package satisfying a companion entry, or undefined if none is. + * + * An exact name match is the ordinary case. An UNSCOPED companion entry is + * also satisfied by a scoped package with the same bare name -- + * `@tintinweb/pi-subagents` satisfies `pi-subagents`. The subagents capability + * is published to npm by more than one maintainer under that same bare name, + * and a user running a scoped fork has the capability installed by any + * meaning of the word; reporting "not installed" at them while its tools are + * live in their session is a false negative, and the suggested + * `pi install npm:pi-subagents` would push them into installing a second + * extension that registers the same tool names. + * + * A SCOPED companion entry is matched exactly, because there the scope is + * part of the identity the entry names, not incidental packaging. + */ +function findInstalledCompanion(companion: string, installed: Set): string | undefined { + if (installed.has(companion)) { + return companion + } + + if (companion.startsWith("@")) { + return undefined + } + + const scopedSuffix = `/${companion}` + for (const name of installed) { + if (name.startsWith("@") && name.endsWith(scopedSuffix)) { + return name + } + } + + return undefined +} + function countDirectories(dir: string): number { try { return fs.readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory()).length @@ -547,10 +586,12 @@ function buildDoctorReport(ctx: ExtensionContext): string { const installed = listInstalledPiPackages(ctx.cwd) for (const name of COMPANION_PACKAGES) { - const present = installed.has(name) - lines.push(` ${present ? "installed " : "not installed"} ${name}`) - if (!present) { + const match = findInstalledCompanion(name, installed) + lines.push(` ${match ? "installed " : "not installed"} ${name}`) + if (!match) { lines.push(` install with: pi install npm:${name}`) + } else if (match !== name) { + lines.push(` satisfied by: ${match}`) } } diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js index 984db7ed0..06c779e89 100644 --- a/tests/pi/pi-extension-adapter.test.js +++ b/tests/pi/pi-extension-adapter.test.js @@ -188,6 +188,32 @@ function readInstalledPackageNames(settingsFile) { return names } +/** + * Mirror of the adapter's `findInstalledCompanion` (same file, same matching + * rule) so the exact-match and unscoped-satisfied-by-scoped cases can be + * exercised directly without importing the TypeScript source. This copy + * proves the *behavior* below is correct, but a copy cannot detect the real + * adapter's rule drifting out from under it. The source-text assertions in + * the "companion package detection tolerates a scoped fork" test below read + * the real `findInstalledCompanion` text out of `.pi/extensions/index.ts` and + * pin its actual guards directly. + */ +function findInstalledCompanion(companion, installed) { + if (installed.has(companion)) { + return companion + } + if (companion.startsWith("@")) { + return undefined + } + const scopedSuffix = `/${companion}` + for (const name of installed) { + if (name.startsWith("@") && name.endsWith(scopedSuffix)) { + return name + } + } + return undefined +} + /** * Parses the `PORTABLE_RULE_FILES` array literal out of `.pi/extensions/index.ts` * by text, so the real-filesystem-existence test and the `loadPortableRules` @@ -1135,6 +1161,146 @@ async function main() { } }], + ["companion package detection tolerates a scoped fork (source contract): the unscoped-entry fallback exists, scoped entries stay exact, and the doctor loop reports what satisfied the entry", () => { + const matchStart = extensionSource.indexOf("function findInstalledCompanion") + assert.ok( + matchStart !== -1, + "expected .pi/extensions/index.ts to define a function named findInstalledCompanion; " + + "a bare installed.has(name) check reports an installed scoped fork such as " + + "@tintinweb/pi-subagents as missing, and then tells the user to run " + + "`pi install npm:pi-subagents`, which would put a SECOND extension registering " + + "the same tool names into their session" + ) + const nextFunctionStart = extensionSource.indexOf("\nfunction ", matchStart + 1) + const matchSource = + nextFunctionStart === -1 + ? extensionSource.slice(matchStart) + : extensionSource.slice(matchStart, nextFunctionStart) + + assert.ok( + /if\s*\(\s*installed\.has\(\s*companion\s*\)\s*\)/.test(matchSource), + "expected findInstalledCompanion in .pi/extensions/index.ts to check the exact name " + + "first; an exact install is the ordinary case and must not be routed through the " + + "scoped-fork scan" + ) + assert.ok( + /if\s*\(\s*companion\.startsWith\(\s*["'`]@["'`]\s*\)\s*\)\s*\{\s*return undefined/.test( + matchSource + ), + "expected findInstalledCompanion in .pi/extensions/index.ts to bail out for a SCOPED " + + "companion entry before the fallback; for an entry like " + + "@juicesharp/rpiv-todo the scope is part of the identity ECC is naming, so some " + + "other publisher's rpiv-todo must not silently satisfy it" + ) + assert.ok( + /name\.startsWith\(\s*["'`]@["'`]\s*\)\s*&&\s*name\.endsWith\(\s*scopedSuffix\s*\)/.test( + matchSource + ), + "expected findInstalledCompanion in .pi/extensions/index.ts to match an installed " + + "scoped package by the '@scope/' + exact bare name shape; matching on endsWith " + + "alone would let a package named my-pi-subagents satisfy the pi-subagents entry" + ) + + const withoutComments = stripComments(extensionSource) + assert.ok( + !/installed\.has\(name\)/.test(withoutComments), + "found a bare installed.has(name) still used as executable code in " + + ".pi/extensions/index.ts; the /ecc-doctor companion loop must go through " + + "findInstalledCompanion so a scoped fork is not reported as missing" + ) + assert.ok( + /satisfied by/.test(extensionSource), + "expected the /ecc-doctor companion loop in .pi/extensions/index.ts to name the " + + "package that satisfied an entry when it is not an exact match; reporting a " + + "bare 'installed' for @tintinweb/pi-subagents under the pi-subagents line hides " + + "which implementation is actually loaded, which is the first thing to know when " + + "its behavior differs from the unscoped package's" + ) + }], + + ["companion package matching (behavioral mirror): an unscoped entry is satisfied by a scoped fork, a scoped entry is matched exactly", () => { + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set(["pi-subagents"])), + "pi-subagents", + "expected an exactly-installed companion to be reported as itself" + ) + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set(["@tintinweb/pi-subagents"])), + "@tintinweb/pi-subagents", + "expected a scoped fork to satisfy the unscoped pi-subagents entry; the subagents " + + "capability is published under that bare name by more than one maintainer, and a " + + "user running the scoped one has working Agent/SubagentWorkflow tools in session " + + "while /ecc-doctor was calling it missing" + ) + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set(["pi-subagents", "@tintinweb/pi-subagents"])), + "pi-subagents", + "expected the exact match to win when both are installed, so the reported name is " + + "stable rather than depending on Set iteration order" + ) + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set(["my-pi-subagents"])), + undefined, + "expected an unscoped package that merely ENDS WITH the companion name to not " + + "satisfy it; only a @scope/ prefix counts" + ) + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set(["@acme/my-pi-subagents"])), + undefined, + "expected a scoped package whose bare name merely ends with the companion name to " + + "not satisfy it; the segment after the scope must equal the companion name" + ) + assert.strictEqual( + findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@juicesharp/rpiv-todo"])), + "@juicesharp/rpiv-todo", + "expected an exactly-installed scoped companion to be reported as itself" + ) + assert.strictEqual( + findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@someoneelse/rpiv-todo"])), + undefined, + "expected a DIFFERENT scope to not satisfy a scoped companion entry; ECC names that " + + "scope deliberately, so relaxing this direction would report an unrelated " + + "publisher's package as the one ECC documents" + ) + assert.strictEqual( + findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["rpiv-todo"])), + undefined, + "expected an unscoped package to not satisfy a scoped companion entry" + ) + assert.strictEqual( + findInstalledCompanion("pi-subagents", new Set()), + undefined, + "expected an empty install set to satisfy nothing" + ) + }], + + ["every COMPANION_PACKAGES entry this repo ships is still resolvable by the matcher it is checked with", () => { + const constStart = extensionSource.indexOf("const COMPANION_PACKAGES") + assert.ok( + constStart !== -1, + "expected to find a COMPANION_PACKAGES array literal in .pi/extensions/index.ts" + ) + const constEnd = extensionSource.indexOf("]", constStart) + const companions = Array.from( + extensionSource.slice(constStart, constEnd + 1).matchAll(/["'`](@?[\w./-]+)["'`]/g) + ).map(match => match[1]) + + assert.ok( + companions.length > 0, + "expected to parse at least one companion package name out of COMPANION_PACKAGES" + ) + + for (const companion of companions) { + assert.strictEqual( + findInstalledCompanion(companion, new Set([companion])), + companion, + `expected the companion entry ${companion} to be recognized when it is installed ` + + "under exactly its own name; an entry the matcher cannot resolve would be " + + "reported as permanently missing no matter what the user installs" + ) + } + }], + // ---- Group 4: engineering-rules injection ------------------------- ["PORTABLE_RULE_FILES lists exactly ECC's 7 Pi-portable rule files and excludes the 3 Claude-Code-only ones", () => { From e404468a629517b7bdc9188723f1aed8ea3a32b0 Mon Sep 17 00:00:00 2001 From: yashraj4 Date: Wed, 16 Sep 2026 23:07:27 +0530 Subject: [PATCH 27/67] fix: correct agent location and add ecc: prefix to agent names in rules - rules/common/agents.md: Fix incorrect location (was ~/.claude/agents/, now explains plugin ships with ecc@ecc) - Add ecc: prefix to all agent names in tables and orchestration sections - Update all 4 translated copies (es, ja-JP, zh-CN, tr) with same fixes - Update root AGENTS.md with ecc: prefix for consistency Fixes #3143 --- AGENTS.md | 24 ++++++++++----------- docs/es/AGENTS.md | 14 ++++++------- docs/es/rules/common/agents.md | 35 +++++++++++++++++-------------- docs/ja-JP/AGENTS.md | 14 ++++++------- docs/ja-JP/rules/common/agents.md | 31 ++++++++++++++------------- docs/tr/AGENTS.md | 16 +++++++------- docs/tr/rules/common/agents.md | 33 ++++++++++++++++------------- docs/zh-CN/AGENTS.md | 16 +++++++------- docs/zh-CN/rules/common/agents.md | 33 ++++++++++++++++------------- rules/common/agents.md | 35 +++++++++++++++++-------------- 10 files changed, 133 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 085342923..da81eb204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,15 +52,15 @@ This is a **production-ready AI coding plugin** providing 68 specialized agents, ## Agent Orchestration Use agents proactively without user prompt: -- Complex feature requests → **planner** -- Code just written/modified → **code-reviewer** -- Bug fix or new feature → **tdd-guide** -- Architectural decision → **architect** -- Security-sensitive code → **security-reviewer** -- Brownfield project onboarding → **spec-miner** -- Autonomous loops / loop monitoring → **loop-operator** -- Harness config reliability and cost → **harness-optimizer** -- RAG/retrieval pipeline changes → **rag-pipeline-reviewer** +- Complex feature requests → **ecc:planner** +- Code just written/modified → **ecc:code-reviewer** +- Bug fix or new feature → **ecc:tdd-guide** +- Architectural decision → **ecc:architect** +- Security-sensitive code → **ecc:security-reviewer** +- Brownfield project onboarding → **ecc:spec-miner** +- Autonomous loops / loop monitoring → **ecc:loop-operator** +- Harness config reliability and cost → **ecc:harness-optimizer** +- RAG/retrieval pipeline changes → **ecc:rag-pipeline-reviewer** Use parallel execution for independent operations — launch multiple agents simultaneously. @@ -114,9 +114,9 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ## Development Workflow -1. **Plan** — Use planner agent, identify dependencies and risks, break into phases -2. **TDD** — Use tdd-guide agent, write tests first, implement, refactor -3. **Review** — Use code-reviewer agent immediately, address CRITICAL/HIGH issues +1. **Plan** — Use ecc:planner agent, identify dependencies and risks, break into phases +2. **TDD** — Use ecc:tdd-guide agent, write tests first, implement, refactor +3. **Review** — Use ecc:code-reviewer agent immediately, address CRITICAL/HIGH issues 4. **Capture knowledge in the right place** - Personal debugging notes, preferences, and temporary context → auto memory - Team/project knowledge (architecture decisions, API changes, runbooks) → the project's existing docs structure diff --git a/docs/es/AGENTS.md b/docs/es/AGENTS.md index f19fa7120..c15bf5539 100644 --- a/docs/es/AGENTS.md +++ b/docs/es/AGENTS.md @@ -50,13 +50,13 @@ Este es un **plugin de IA para codificación listo para producción** que propor ## Orquestación de Agentes Usa agentes proactivamente sin prompt del usuario: -- Solicitudes de features complejas → **planner** -- Código recién escrito/modificado → **code-reviewer** -- Corrección de bug o nueva feature → **tdd-guide** -- Decisión arquitectónica → **architect** -- Código sensible a la seguridad → **security-reviewer** -- Bucles autónomos / monitoreo de bucles → **loop-operator** -- Confiabilidad y costo de la configuración del harness → **harness-optimizer** +- Solicitudes de features complejas → **ecc:planner** +- Código recién escrito/modificado → **ecc:code-reviewer** +- Corrección de bug o nueva feature → **ecc:tdd-guide** +- Decisión arquitectónica → **ecc:architect** +- Código sensible a la seguridad → **ecc:security-reviewer** +- Bucles autónomos / monitoreo de bucles → **ecc:loop-operator** +- Confiabilidad y costo de la configuración del harness → **ecc:harness-optimizer** Usa ejecución paralela para operaciones independientes — lanza múltiples agentes simultáneamente. diff --git a/docs/es/rules/common/agents.md b/docs/es/rules/common/agents.md index 29f25b19e..c273fb2b8 100644 --- a/docs/es/rules/common/agents.md +++ b/docs/es/rules/common/agents.md @@ -2,29 +2,32 @@ ## Agentes Disponibles -Ubicados en `~/.claude/agents/`: +Los agentes de ECC se distribuyen con el plugin `ecc@ecc`, no en `~/.claude/agents/`. +Se invocan a través de la herramienta Agent con un `subagent_type` con scope del plugin: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agente | Propósito | Cuándo Usar | |--------|-----------|-------------| -| planner | Planificación de implementación | Features complejas, refactoring | -| architect | Diseño de sistemas | Decisiones arquitectónicas | -| tdd-guide | Desarrollo guiado por pruebas | Nuevas features, corrección de bugs | -| code-reviewer | Revisión de código | Después de escribir código | -| security-reviewer | Análisis de seguridad | Antes de los commits | -| build-error-resolver | Corrección de errores de build | Cuando el build falla | -| e2e-runner | Testing E2E | Flujos de usuario críticos | -| refactor-cleaner | Limpieza de código muerto | Mantenimiento de código | -| doc-updater | Documentación | Actualización de docs | -| rust-reviewer | Revisión de código Rust | Proyectos Rust | -| harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS | +| ecc:planner | Planificación de implementación | Features complejas, refactoring | +| ecc:architect | Diseño de sistemas | Decisiones arquitectónicas | +| ecc:tdd-guide | Desarrollo guiado por pruebas | Nuevas features, corrección de bugs | +| ecc:code-reviewer | Revisión de código | Después de escribir código | +| ecc:security-reviewer | Análisis de seguridad | Antes de los commits | +| ecc:build-error-resolver | Corrección de errores de build | Cuando el build falla | +| ecc:e2e-runner | Testing E2E | Flujos de usuario críticos | +| ecc:refactor-cleaner | Limpieza de código muerto | Mantenimiento de código | +| ecc:doc-updater | Documentación | Actualización de docs | +| ecc:rust-reviewer | Revisión de código Rust | Proyectos Rust | +| ecc:harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS | ## Uso Inmediato de Agentes Sin necesidad de prompt del usuario: -1. Solicitudes de features complejas - Usar el agente **planner** -2. Código recién escrito/modificado - Usar el agente **code-reviewer** -3. Corrección de bug o nueva feature - Usar el agente **tdd-guide** -4. Decisión arquitectónica - Usar el agente **architect** +1. Solicitudes de features complejas - Usar el agente **ecc:planner** +2. Código recién escrito/modificado - Usar el agente **ecc:code-reviewer** +3. Corrección de bug o nueva feature - Usar el agente **ecc:tdd-guide** +4. Decisión arquitectónica - Usar el agente **ecc:architect** ## Ejecución Paralela de Tareas diff --git a/docs/ja-JP/AGENTS.md b/docs/ja-JP/AGENTS.md index be7370bc0..f32e801b8 100644 --- a/docs/ja-JP/AGENTS.md +++ b/docs/ja-JP/AGENTS.md @@ -50,13 +50,13 @@ ## エージェントオーケストレーション ユーザーのプロンプトなしで積極的にエージェントを使用する: -- 複雑な機能リクエスト → **planner** -- コードの作成/変更直後 → **code-reviewer** -- バグ修正または新機能 → **tdd-guide** -- アーキテクチャの意思決定 → **architect** -- セキュリティに関わるコード → **security-reviewer** -- 自律ループ / ループ監視 → **loop-operator** -- ハーネス設定の信頼性とコスト → **harness-optimizer** +- 複雑な機能リクエスト → **ecc:planner** +- コードの作成/変更直後 → **ecc:code-reviewer** +- バグ修正または新機能 → **ecc:tdd-guide** +- アーキテクチャの意思決定 → **ecc:architect** +- セキュリティに関わるコード → **ecc:security-reviewer** +- 自律ループ / ループ監視 → **ecc:loop-operator** +- ハーネス設定の信頼性とコスト → **ecc:harness-optimizer** 独立した操作には並列実行を使用する — 複数のエージェントを同時に起動する。 diff --git a/docs/ja-JP/rules/common/agents.md b/docs/ja-JP/rules/common/agents.md index 92137264a..08ebb707b 100644 --- a/docs/ja-JP/rules/common/agents.md +++ b/docs/ja-JP/rules/common/agents.md @@ -2,27 +2,30 @@ ## 利用可能な Agent -`~/.claude/agents/` に配置: +ECC エージェントは `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` にはありません。 +プラグインスコープの `subagent_type` を使用して Agent ツールから呼び出します: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | 目的 | 使用タイミング | |-------|---------|-------------| -| planner | 実装計画 | 複雑な機能、リファクタリング | -| architect | システム設計 | アーキテクチャの意思決定 | -| tdd-guide | テスト駆動開発 | 新機能、バグ修正 | -| code-reviewer | コードレビュー | コード記述後 | -| security-reviewer | セキュリティ分析 | コミット前 | -| build-error-resolver | ビルドエラー修正 | ビルド失敗時 | -| e2e-runner | E2Eテスト | 重要なユーザーフロー | -| refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス | -| doc-updater | ドキュメント | ドキュメント更新 | +| ecc:planner | 実装計画 | 複雑な機能、リファクタリング | +| ecc:architect | システム設計 | アーキテクチャの意思決定 | +| ecc:tdd-guide | テスト駆動開発 | 新機能、バグ修正 | +| ecc:code-reviewer | コードレビュー | コード記述後 | +| ecc:security-reviewer | セキュリティ分析 | コミット前 | +| ecc:build-error-resolver | ビルドエラー修正 | ビルド失敗時 | +| ecc:e2e-runner | E2Eテスト | 重要なユーザーフロー | +| ecc:refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス | +| ecc:doc-updater | ドキュメント | ドキュメント更新 | ## Agent の即座の使用 ユーザープロンプト不要: -1. 複雑な機能リクエスト - **planner** agent を使用 -2. コード作成/変更直後 - **code-reviewer** agent を使用 -3. バグ修正または新機能 - **tdd-guide** agent を使用 -4. アーキテクチャの意思決定 - **architect** agent を使用 +1. 複雑な機能リクエスト - **ecc:planner** agent を使用 +2. コード作成/変更直後 - **ecc:code-reviewer** agent を使用 +3. バグ修正または新機能 - **ecc:tdd-guide** agent を使用 +4. アーキテクチャの意思決定 - **ecc:architect** agent を使用 ## 並列タスク実行 diff --git a/docs/tr/AGENTS.md b/docs/tr/AGENTS.md index c49b9962a..abb7dbd7e 100644 --- a/docs/tr/AGENTS.md +++ b/docs/tr/AGENTS.md @@ -47,14 +47,14 @@ Bu, yazılım geliştirme için 68 özel agent, 292 skill, 94 command ve otomati ## Agent Orkestrasyonu Agentları kullanıcı istemi olmadan proaktif olarak kullanın: -- Karmaşık özellik istekleri → **planner** -- Yeni yazılan/değiştirilen kod → **code-reviewer** -- Hata düzeltme veya yeni özellik → **tdd-guide** -- Mimari karar → **architect** -- Güvenlik açısından hassas kod → **security-reviewer** -- Çok kanallı iletişim önceliklendirme → **chief-of-staff** -- Otonom döngüler / döngü izleme → **loop-operator** -- Harness yapılandırma güvenilirliği ve maliyeti → **harness-optimizer** +- Karmaşık özellik istekleri → **ecc:planner** +- Yeni yazılan/değiştirilen kod → **ecc:code-reviewer** +- Hata düzeltme veya yeni özellik → **ecc:tdd-guide** +- Mimari karar → **ecc:architect** +- Güvenlik açısından hassas kod → **ecc:security-reviewer** +- Çok kanallı iletişim önceliklendirme → **ecc:chief-of-staff** +- Otonom döngüler / döngü izleme → **ecc:loop-operator** +- Harness yapılandırma güvenilirliği ve maliyeti → **ecc:harness-optimizer** Bağımsız işlemler için paralel yürütme kullanın — birden fazla agenti aynı anda başlatın. diff --git a/docs/tr/rules/common/agents.md b/docs/tr/rules/common/agents.md index b40d5897b..eab86cf8f 100644 --- a/docs/tr/rules/common/agents.md +++ b/docs/tr/rules/common/agents.md @@ -2,28 +2,31 @@ ## Mevcut Agent'lar -`~/.claude/agents/` dizininde bulunur: +ECC agent'ları `ecc@ecc` eklentisi ile birlikte gelir, `~/.claude/agents/` içinde değildirler. +Plugin kapsamı `subagent_type` ile Agent aracılığıyla çağrılır: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | Amaç | Ne Zaman Kullanılır | |-------|---------|-------------| -| planner | Uygulama planlaması | Karmaşık özellikler, refactoring | -| architect | Sistem tasarımı | Mimari kararlar | -| tdd-guide | Test odaklı geliştirme | Yeni özellikler, hata düzeltmeleri | -| code-reviewer | Kod incelemesi | Kod yazdıktan sonra | -| security-reviewer | Güvenlik analizi | Commit'lerden önce | -| build-error-resolver | Build hatalarını düzeltme | Build başarısız olduğunda | -| e2e-runner | E2E testleri | Kritik kullanıcı akışları | -| refactor-cleaner | Ölü kod temizliği | Kod bakımı | -| doc-updater | Dokümantasyon | Dokümanları güncelleme | -| rust-reviewer | Rust kod incelemesi | Rust projeleri | +| ecc:planner | Uygulama planlaması | Karmaşık özellikler, refactoring | +| ecc:architect | Sistem tasarımı | Mimari kararlar | +| ecc:tdd-guide | Test odaklı geliştirme | Yeni özellikler, hata düzeltmeleri | +| ecc:code-reviewer | Kod incelemesi | Kod yazdıktan sonra | +| ecc:security-reviewer | Güvenlik analizi | Commit'lerden önce | +| ecc:build-error-resolver | Build hatalarını düzeltme | Build başarısız olduğunda | +| ecc:e2e-runner | E2E testleri | Kritik kullanıcı akışları | +| ecc:refactor-cleaner | Ölü kod temizliği | Kod bakımı | +| ecc:doc-updater | Dokümantasyon | Dokümanları güncelleme | +| ecc:rust-reviewer | Rust kod incelemesi | Rust projeleri | ## Anlık Agent Kullanımı Kullanıcı istemi gerekmez: -1. Karmaşık özellik istekleri - **planner** agent kullan -2. Kod yeni yazıldı/değiştirildi - **code-reviewer** agent kullan -3. Hata düzeltmesi veya yeni özellik - **tdd-guide** agent kullan -4. Mimari karar - **architect** agent kullan +1. Karmaşık özellik istekleri - **ecc:planner** agent kullan +2. Kod yeni yazıldı/değiştirildi - **ecc:code-reviewer** agent kullan +3. Hata düzeltmesi veya yeni özellik - **ecc:tdd-guide** agent kullan +4. Mimari karar - **ecc:architect** agent kullan ## Paralel Görev Yürütme diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 2f5a18856..3ee93e205 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -48,14 +48,14 @@ 主动使用智能体,无需用户提示: -* 复杂功能请求 → **planner** -* 刚编写/修改的代码 → **code-reviewer** -* 错误修复或新功能 → **tdd-guide** -* 架构决策 → **architect** -* 安全敏感代码 → **security-reviewer** -* 多渠道沟通分流 → **chief-of-staff** -* 自主循环 / 循环监控 → **loop-operator** -* 线束配置可靠性及成本 → **harness-optimizer** +* 复杂功能请求 → **ecc:planner** +* 刚编写/修改的代码 → **ecc:code-reviewer** +* 错误修复或新功能 → **ecc:tdd-guide** +* 架构决策 → **ecc:architect** +* 安全敏感代码 → **ecc:security-reviewer** +* 多渠道沟通分流 → **ecc:chief-of-staff** +* 自主循环 / 循环监控 → **ecc:loop-operator** +* 线束配置可靠性及成本 → **ecc:harness-optimizer** 对于独立操作使用并行执行 — 同时启动多个智能体。 diff --git a/docs/zh-CN/rules/common/agents.md b/docs/zh-CN/rules/common/agents.md index de32b0b56..7303adfe3 100644 --- a/docs/zh-CN/rules/common/agents.md +++ b/docs/zh-CN/rules/common/agents.md @@ -2,29 +2,32 @@ ## 可用智能体 -位于 `~/.claude/agents/` 中: +ECC 智能体随 `ecc@ecc` 插件一起分发,而非位于 `~/.claude/agents/` 中。 +它们通过具有插件作用域 `subagent_type` 的 Agent 工具调用: + + Agent(subagent_type: "ecc:planner", prompt: "...") | 代理 | 用途 | 使用时机 | |-------|---------|-------------| -| planner | 实现规划 | 复杂功能、重构 | -| architect | 系统设计 | 架构决策 | -| tdd-guide | 测试驱动开发 | 新功能、错误修复 | -| code-reviewer | 代码审查 | 编写代码后 | -| security-reviewer | 安全分析 | 提交前 | -| build-error-resolver | 修复构建错误 | 构建失败时 | -| e2e-runner | 端到端测试 | 关键用户流程 | -| refactor-cleaner | 清理死代码 | 代码维护 | -| doc-updater | 文档 | 更新文档 | -| rust-reviewer | Rust 代码审查 | Rust 项目 | +| ecc:planner | 实现规划 | 复杂功能、重构 | +| ecc:architect | 系统设计 | 架构决策 | +| ecc:tdd-guide | 测试驱动开发 | 新功能、错误修复 | +| ecc:code-reviewer | 代码审查 | 编写代码后 | +| ecc:security-reviewer | 安全分析 | 提交前 | +| ecc:build-error-resolver | 修复构建错误 | 构建失败时 | +| ecc:e2e-runner | 端到端测试 | 关键用户流程 | +| ecc:refactor-cleaner | 清理死代码 | 代码维护 | +| ecc:doc-updater | 文档 | 更新文档 | +| ecc:rust-reviewer | Rust 代码审查 | Rust 项目 | ## 即时智能体使用 无需用户提示: -1. 复杂的功能请求 - 使用 **planner** 智能体 -2. 刚编写/修改的代码 - 使用 **code-reviewer** 智能体 -3. 错误修复或新功能 - 使用 **tdd-guide** 智能体 -4. 架构决策 - 使用 **architect** 智能体 +1. 复杂的功能请求 - 使用 **ecc:planner** 智能体 +2. 刚编写/修改的代码 - 使用 **ecc:code-reviewer** 智能体 +3. 错误修复或新功能 - 使用 **ecc:tdd-guide** 智能体 +4. 架构决策 - 使用 **ecc:architect** 智能体 ## 并行任务执行 diff --git a/rules/common/agents.md b/rules/common/agents.md index 4d1dfb4cb..8616dfa5c 100644 --- a/rules/common/agents.md +++ b/rules/common/agents.md @@ -2,29 +2,32 @@ ## Available Agents -Located in `~/.claude/agents/`: +ECC agents ship with the `ecc@ecc` plugin, not in `~/.claude/agents/`. +They are invoked through the Agent tool with a plugin-scoped `subagent_type`: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | Purpose | When to Use | |-------|---------|-------------| -| planner | Implementation planning | Complex features, refactoring | -| architect | System design | Architectural decisions | -| tdd-guide | Test-driven development | New features, bug fixes | -| code-reviewer | Code review | After writing code | -| security-reviewer | Security analysis | Before commits | -| build-error-resolver | Fix build errors | When build fails | -| e2e-runner | E2E testing | Critical user flows | -| refactor-cleaner | Dead code cleanup | Code maintenance | -| doc-updater | Documentation | Updating docs | -| rust-reviewer | Rust code review | Rust projects | -| harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects | +| ecc:planner | Implementation planning | Complex features, refactoring | +| ecc:architect | System design | Architectural decisions | +| ecc:tdd-guide | Test-driven development | New features, bug fixes | +| ecc:code-reviewer | Code review | After writing code | +| ecc:security-reviewer | Security analysis | Before commits | +| ecc:build-error-resolver | Fix build errors | When build fails | +| ecc:e2e-runner | E2E testing | Critical user flows | +| ecc:refactor-cleaner | Dead code cleanup | Code maintenance | +| ecc:doc-updater | Documentation | Updating docs | +| ecc:rust-reviewer | Rust code review | Rust projects | +| ecc:harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects | ## Immediate Agent Usage No user prompt needed: -1. Complex feature requests - Use **planner** agent -2. Code just written/modified - Use **code-reviewer** agent -3. Bug fix or new feature - Use **tdd-guide** agent -4. Architectural decision - Use **architect** agent +1. Complex feature requests - Use **ecc:planner** agent +2. Code just written/modified - Use **ecc:code-reviewer** agent +3. Bug fix or new feature - Use **ecc:tdd-guide** agent +4. Architectural decision - Use **ecc:architect** agent ## Parallel Task Execution From 6502cf24bf891dd7d74a99498ceb6be9b42b313a Mon Sep 17 00:00:00 2001 From: Aniruddha Adak Date: Thu, 17 Sep 2026 02:33:20 +0530 Subject: [PATCH 28/67] fix(llm): forward max_tokens to Ollama num_predict --- src/llm/providers/ollama.py | 7 ++++- tests/test_provider_tools.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/llm/providers/ollama.py b/src/llm/providers/ollama.py index 2f83338d0..257d17900 100644 --- a/src/llm/providers/ollama.py +++ b/src/llm/providers/ollama.py @@ -70,8 +70,13 @@ class OllamaProvider(LLMProvider): "messages": [msg.to_dict() for msg in input.messages], "stream": False, } + options: dict[str, Any] = {} if input.temperature != 1.0: - payload["options"] = {"temperature": input.temperature} + options["temperature"] = input.temperature + if input.max_tokens is not None: + options["num_predict"] = input.max_tokens + if options: + payload["options"] = options data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) diff --git a/tests/test_provider_tools.py b/tests/test_provider_tools.py index 4c9c76f92..ba4c09415 100644 --- a/tests/test_provider_tools.py +++ b/tests/test_provider_tools.py @@ -1,3 +1,6 @@ +import json +import urllib.request +from io import BytesIO from types import SimpleNamespace import pytest @@ -5,6 +8,7 @@ import pytest from llm.core.types import LLMInput, Message, Role, ToolDefinition from llm.providers.claude import ClaudeProvider from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR +from llm.providers.ollama import OllamaProvider from llm.providers.openai import OpenAIProvider @@ -114,6 +118,56 @@ def test_openai_provider_allows_missing_usage(): assert output.usage is None +@pytest.mark.parametrize( + ("max_tokens", "temperature", "expected_options"), + [ + (128, 1.0, {"num_predict": 128}), + (128, 0.2, {"temperature": 0.2, "num_predict": 128}), + (128, 0.0, {"temperature": 0.0, "num_predict": 128}), + (0, 1.0, {"num_predict": 0}), + (None, 1.0, {}), + (None, 0.2, {"temperature": 0.2}), + ], +) +def test_ollama_provider_serializes_generation_options( + monkeypatch, max_tokens, temperature, expected_options +): + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return BytesIO(b'{"message": {"content": "ok"}, "done_reason": "stop"}') + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + provider = OllamaProvider(base_url="http://localhost:11434", default_model="llama3.2") + + output = provider.generate( + LLMInput( + messages=[Message(role=Role.USER, content="hi")], + max_tokens=max_tokens, + temperature=temperature, + ) + ) + + assert len(requests) == 1 + request, timeout = requests[0] + expected_payload = { + "model": "llama3.2", + "messages": [{"role": "user", "content": "hi"}], + "stream": False, + } + if expected_options: + expected_payload["options"] = expected_options + assert json.loads(request.data) == expected_payload + assert request.full_url == "http://localhost:11434/api/chat" + assert request.get_method() == "POST" + assert request.get_header("Content-type") == "application/json" + assert timeout == 60 + assert output.content == "ok" + assert output.model == "llama3.2" + assert output.stop_reason == "stop" + + def test_claude_provider_serializes_tools_for_messages_api(): provider = ClaudeProvider(api_key="test") client = _AnthropicClient() From a0283ac5619f2e1d567873aa2c0a6eebdbc93745 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 17 Sep 2026 00:11:40 -0400 Subject: [PATCH 29/67] docs: add lane rules and Ito desk relationship notes (2026-09-16) --- docs/ITO-DESK.md | 26 ++++++++++++++++++++++++++ docs/LANE-RULES.md | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 docs/ITO-DESK.md create mode 100644 docs/LANE-RULES.md diff --git a/docs/ITO-DESK.md b/docs/ITO-DESK.md new file mode 100644 index 000000000..ec8232ff1 --- /dev/null +++ b/docs/ITO-DESK.md @@ -0,0 +1,26 @@ +# ECC and the Ito desk + +ECC is the public agentic-engineering toolkit; the Ito desk is Affaan's +private ops system. The connection surface in this repo is the set of +public `ito-*` skills (`skills/ito-baskets`, `skills/ito-compute`, +`skills/ito-inference`, `skills/ito-training`). Each of them is a thin +pointer: it names the supported boundary and hands real work to the +separately installed canonical CLI or MCP server. ECC itself implements no +compute booking, inference serving, training stack or basket trading, and +nothing here may claim those capabilities exist inside this repo. + +Desk-side work that touches ECC runs as bounded lane tasks. The lane-worker +doctrine (see `docs/LANE-RULES.md`) is: one worker, one task, one branch, +one PR or one receipt; real work only, meaning code edits, tests, commits +and a PR, with the final message as the receipt; no self-review loops, no +receipt ledgers, no merging to main, no publishing, no deployments, no +messages; blocked means naming exactly who or what unblocks. The doctrine +exists because unbounded agent loops were the dominant failure mode of the +desk's earlier automation. + +The merge rule for anything desk-related in this repo: fixes and tests +merge freely. Anything that adds a third-party tool, a vendor-named skill, +or an external link waits for Affaan's explicit yes, recorded before merge. +The living desk plan is `docs/PLAN.md` in `Ito-Markets/ito-desk`; task +schemas and the spec book live under `docs/spec/` in the same repo. This +file only describes the relationship; the plan repo is the source of truth. diff --git a/docs/LANE-RULES.md b/docs/LANE-RULES.md new file mode 100644 index 000000000..db24c6db3 --- /dev/null +++ b/docs/LANE-RULES.md @@ -0,0 +1,19 @@ +# Lane rules + +These are the working rules for bounded lane workers (human or agent) that +execute tasks against this repository from the Ito workstream system. They +are copied verbatim from the lane registry +(`/Users/affoon/.codex/workstream-results/lanes/RULES.md` on the ops mini, +2026-09-16) so a worker reading only this repo sees the same contract. +One task, one branch, one PR or one receipt, then stop. + +--- + +# Lane rules (every codex exec brief starts by reading this) +You are one bounded worker. One task, one branch, one PR or one receipt, then stop. +- Real work only: edit code, run the tests, commit, push, open the PR. No receipts about receipts, no independent review of your own output, no hashing manifests, no ledgers, no acceptance JSONs, no skill self-patching. Your final message is the receipt (under 300 words: what changed, PR link, test command and result, what is blocked and on whom). +- Never merge to main, never publish to npm, never deploy, never send email or messages, never change Hermes profiles or launchd on the mini unless the brief says so explicitly. +- Commits: plain messages, no Co-Authored-By or generated-with trailers, no em dashes anywhere. +- Worktrees and caches go under ~/GitHub/ECC-worktrees or ~/GitHub on the Pro, /Volumes/Agent-Runtime/workspaces on the mini, never on the mini root disk. +- If blocked (missing credential, approval needed, conflicting work), stop and say exactly what is needed. Do not wait, poll, or sleep. +- Time box: finish in one pass. Do not spawn subagents. From 1d143e3098773a6e32977b7f80c4731475adcbd6 Mon Sep 17 00:00:00 2001 From: kenima-arc Date: Fri, 18 Sep 2026 00:38:32 +0900 Subject: [PATCH 30/67] docs(ja-JP): refresh Japanese README to match the English README Full retranslation of README.md (structure preserved 1:1): 2.2 install flows, Codex and Kimi support, platform tables, security, and troubleshooting. Relative links rewritten for docs/ja-JP/ and in-page anchors mapped to the Japanese headings. Co-Authored-By: Claude Fable 5.1 --- docs/ja-JP/README.md | 2520 +++++++++++++++++++++++++++++++----------- 1 file changed, 1867 insertions(+), 653 deletions(-) diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index e01e9c11b..ec59cf67a 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -1,439 +1,288 @@ -**言語:** [English](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | [Українська](../uk-UA/README.md) +

+ ECC - エージェントハーネスのオペレーティングシステム +

-# Everything Claude Code +

+ + + + GitHub Trending Repository of the Day + + + + + + Star History Global Rank + + +

-[![Stars](https://img.shields.io/github/stars/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/stargazers) -[![Forks](https://img.shields.io/github/forks/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/network/members) -[![Contributors](https://img.shields.io/github/contributors/affaan-m/everything-claude-code?style=flat)](https://github.com/affaan-m/everything-claude-code/graphs/contributors) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) -![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) -![Python](https://img.shields.io/badge/-Python-3776AB?logo=python&logoColor=white) -![Go](https://img.shields.io/badge/-Go-00ADD8?logo=go&logoColor=white) -![Java](https://img.shields.io/badge/-Java-ED8B00?logo=openjdk&logoColor=white) -![Markdown](https://img.shields.io/badge/-Markdown-000000?logo=markdown&logoColor=white) +

+ Language: + English | + Português (Brasil) | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Türkçe | + Русский | + Tiếng Việt | + ไทย | + Deutsch | + Español | + Українська +

-> **140K+ stars** | **21K+ forks** | **170+ contributors** | **12+ language ecosystems** +

+ Discord + Website + GitHub App + MIT ライセンス +

---- +

+ Stars + Forks + Contributors + GitHub App インストール数 +

+ +

+ ecc-universal npm ダウンロード数 + ecc-agentshield npm ダウンロード数 +

+ +

+ Shell + TypeScript + Python + Go + Java + Perl + Markdown +

+ +> [!WARNING] +> **公式ソースからのみインストールしてください。** ECC は検証済みのチャネルからのみインストールしてください。GitHub リポジトリ [github.com/affaan-m/ECC](https://github.com/affaan-m/ECC)、npm パッケージ [`ecc-universal`](https://www.npmjs.com/package/ecc-universal) と [`ecc-agentshield`](https://www.npmjs.com/package/ecc-agentshield)、[GitHub App](https://github.com/apps/ecc-tools)、plugin スラッグ `ecc@ecc`、そしてプロジェクト公式サイト [ecc.tools](https://ecc.tools) です。第三者による再アップロードや非公式ミラーはプロジェクトが保守・レビューしておらず、マルウェアを含む可能性があります。 + +## Claude Code でインストール + +[ガイド付きセットアップ](#ecc-のインストール)または[ネイティブ plugin コマンド](#claude-code-の詳細)を使用してください。どちらも同じ `ecc@ecc` plugin をインストールします。どちらか一方を選び、その上にフルの手動 Claude インストールを重ねないでください。
-**言語 / Language / 語言 / Dil / Язык / Ngôn ngữ** - -[**English**](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | [Українська](../uk-UA/README.md) -
- ---- - -**Anthropicハッカソン優勝者による完全なClaude Code設定集。** - -10ヶ月以上の集中的な日常使用により、実際のプロダクト構築の過程で進化した、本番環境対応のエージェント、スキル、フック、コマンド、ルール、MCP設定。 - ---- - -## ガイド - -このリポジトリには、原始コードのみが含まれています。ガイドがすべてを説明しています。 - - +
- - + - - - -
- -The Shorthand Guide to Everything Claude Code - + + + ECC Tools
+ ECC Pro + GitHub App +

+ 無料でインストール · プライベートリポジトリは $19/シート/月から
- -The Longform Guide to Everything Claude Code - + + +
+ ECC をスポンサーする +

+ オープンソースプロジェクトを支援する +
+ + Discord
+ コミュニティ +

+ Discord · Q&A · Show and Tell
簡潔ガイド
セットアップ、基礎、哲学。まずこれを読んでください。
長文ガイド
トークン最適化、メモリ永続化、評価、並列化。
-| トピック | 学べる内容 | -|-------|-------------------| -| トークン最適化 | モデル選択、システムプロンプト削減、バックグラウンドプロセス | -| メモリ永続化 | セッション間でコンテキストを自動保存/読み込みするフック | -| 継続的学習 | セッションからパターンを自動抽出して再利用可能なスキルに変換 | -| 検証ループ | チェックポイントと継続的評価、スコアラータイプ、pass@k メトリクス | -| 並列化 | Git ワークツリー、カスケード方法、スケーリング時期 | -| サブエージェント オーケストレーション | コンテキスト問題、反復検索パターン | + ---- +**OSS は今後も無料です。** このリポジトリは永久に MIT ライセンスです。ECC Pro はプライベートリポジトリ向けのホスト型 GitHub App です。スポンサーと Pro 購読者がこの活動を支えています。だからこそ、たった一人のメンテナーが 7 つのハーネスに対して毎週リリースを続けられるのです。 -## 新機能 +
-### v1.4.1 — バグ修正(2026年2月) +パートナー & スポンサー -- **instinctインポート時のコンテンツ喪失を修正** — `/instinct-import`実行時に`parse_instinct_file()`がfrontmatter後のすべてのコンテンツ(Action、Evidence、Examplesセクション)を暗黙的に削除していた問題を修正。コミュニティ貢献者@ericcai0814により解決されました([#148](https://github.com/affaan-m/everything-claude-code/issues/148), [#161](https://github.com/affaan-m/everything-claude-code/pull/161)) +

+ CodeRabbit    + Greptile    + Atlas Cloud    + Moonshot AI - Kimi    + Itô Markets +

-### v1.4.0 — マルチ言語ルール、インストールウィザード & PM2(2026年2月) +コミュニティスポンサー: Mike Morgan · @jasonwu513 · @1anter · @massimotodaro · @meadmccabe -- **インタラクティブインストールウィザード** — 新しい`configure-ecc`スキルがマージ/上書き検出付きガイドセットアップを提供 -- **PM2 & マルチエージェントオーケストレーション** — 複雑なマルチサービスワークフロー管理用の6つの新コマンド(`/pm2`, `/multi-plan`, `/multi-execute`, `/multi-backend`, `/multi-frontend`, `/multi-workflow`) -- **マルチ言語ルールアーキテクチャ** — ルールをフラットファイルから`common/` + `typescript/` + `python/` + `golang/`ディレクトリに再構成。必要な言語のみインストール可能 -- **中国語(zh-CN)翻訳** — すべてのエージェント、コマンド、スキル、ルールの完全翻訳(80+ファイル) -- **GitHub Sponsorsサポート** — GitHub Sponsors経由でプロジェクトをスポンサー可能 -- **強化されたCONTRIBUTING.md** — 各貢献タイプ向けの詳細なPRテンプレート +スポンサーになる · スポンサーティア · スポンサーシッププログラム -### v1.3.0 — OpenCodeプラグイン対応(2026年2月) +
-- **フルOpenCode統合** — 20+イベントタイプを通じてOpenCodeのプラグインシステムでフック対応の12エージェント、24コマンド、16スキル -- **3つのネイティブカスタムツール** — run-tests、check-coverage、security-audit -- **LLMドキュメンテーション** — 包括的なOpenCodeドキュメント用の`llms.txt` +

インストールへジャンプ ↓

-### v1.2.0 — 統合コマンド & スキル(2026年2月) +# ECC -- **Python/Djangoサポート** — Djangoパターン、セキュリティ、TDD、検証スキル -- **Java Spring Bootスキル** — Spring Boot用パターン、セキュリティ、TDD、検証 -- **セッション管理** — セッション履歴用の`/sessions`コマンド -- **継続的学習 v2** — 信頼度スコアリング、インポート/エクスポート、進化を伴うinstinctベースの学習 +あなたのエージェントはコードを書けますが、ECC はそこに協調的なエンジニアリングシステムとツールボックスを与えます。構築の前に計画し、テストで変更を検証し、新しいコンテキストから自分の作業をレビューし、重要なことを記憶し、繰り返し成功したことを再利用可能な skills とワークフローに変えていきます。 -完全なチェンジログは[Releases](https://github.com/affaan-m/everything-claude-code/releases)を参照してください。 +```text +plan -> test -> implement -> review -> verify -> remember -> improve +``` ---- +このプロセスをプロンプトのたびに組み立て直すのではなく、一度インストールしてエージェントの働き方の一部にします。 -## クイックスタート +> コンテキストウィンドウを最適化し、それ以外はすべて永続化する。 -2分以内に起動できます: +ECC は MIT ライセンスのオープンソースです。現時点では Claude Code で最もよく機能し、サポート対象の Codex 同期パスを備え、Cursor、OpenCode、Gemini、Zed、GitHub Copilot、Antigravity、Qwen、その他のハーネス向けには機能が限定されたアダプターを提供しています。機能の同等性を前提にする前に、[サポート状況マトリクス](#プラットフォームサポート)を確認してください。 -### ステップ 1:プラグインをインストール +68 の agents、292 の skills、95 のレガシー command シムに加えて、hooks、rules、メモリ、継続的学習、AgentShield セキュリティスキャンを利用できます。agents は計画、レビュー、ビルド修復、セキュリティ、アーキテクチャ、ドメイン作業に特化しています。 + +| 含まれるもの | 数 | 得られるもの | +| ---------------- | ----------: | ------------------------------------------------------------------------------------ | +| Agents | 68 agents | 計画、レビュー、ビルド修復、セキュリティ、アーキテクチャ、ドメイン作業 | +| Skills | 292 skills | TDD、リサーチ、セキュリティ、ドキュメント、フロントエンド、データ、ML、運用など | +| Commands | 95 commands | ECC が skills ファーストの構成へ移行する間の便利なエントリーポイント | +| Hooks とメモリ | ランタイム | 強制、セッションサマリー、継続的学習、instincts、コンテキスト制御 | +| Rules | 選択式 | 言語やプロジェクトごとに選ぶ、常時ロードされる標準 | +| AgentShield | 同梱 | プロンプト、hooks、MCP 設定、パーミッション、シークレット、agent ファイルのスキャン | + +

+ + + + ECC のスター履歴: 2026年1月18日から2月7日までの最初の 40,000 スター + + +

+ +## ECC のインストール + +> [!IMPORTANT] +> ECC 2.2 には Claude Code、Codex、Kimi Code 向けのガイド付きパッケージセットアップが含まれています。 +> ユニバーサルパッケージには Node.js 18 以降が必要です。Claude plugin のセットアップには、 +> さらに Git と Claude Code 2.1 以降が `PATH` 上にあることが必要です。 + +### 推奨: ユニバーサルガイド付きセットアップ + +Claude Code plugin のセットアップ、更新、スコープ変更、hook プロファイルの変更には次を使います。 ```bash -# マーケットプレイスを追加 -/plugin marketplace add https://github.com/affaan-m/ECC +npx ecc-universal@2.2.1 setup +``` -# プラグインをインストール +npm がバージョンまたはキャッシュのエラーを報告した場合は、再試行する前にレジストリのバージョンを確認してください。 + +```bash +npm view ecc-universal version +``` + +ECC 2.2 は、モダンなパッケージランナーでも同じガイド付きセットアップをサポートしています。 + +| パッケージランナー | ガイド付きセットアップコマンド | +|---|---| +| npm / npx | `npx ecc-universal@2.2.1 setup` | +| pnpm | `pnpm dlx ecc-universal@2.2.1 setup` | +| Yarn 2+ | `yarn dlx ecc-universal@2.2.1 setup` | +| Bun | `bunx ecc-universal@2.2.1 setup` | + +これらの例では、このリポジトリのリリースバージョンに対応する[公開済みの ECC 2.2.1 リリース](https://www.npmjs.com/package/ecc-universal/v/2.2.1)を指定しています。バージョンのピン留めはセキュリティ監査でも整合性チェックでもありません。パッケージのコードを実行する前にリリースのソースとレジストリの整合性を確認し、未リリースの変更にはレビュー済みのチェックアウトを使用してください。 + +Yarn Classic 1 には `yarn dlx` がありません。`npx` を使うか、パッケージをグローバルにインストールするか、一時的なワンショット実行のために Yarn をアップグレードしてください。 + +ウィザードは変更を加える前に公式マーケットプレイスとすべてのネイティブ Claude インストールスコープを棚卸しし、その後、選択したスコープに `ecc@ecc` をインストール、更新、または安全に移動します。ECC を更新したいとき、スコープを変えたいとき、hook プロファイルを変えたいときは、いつでも同じコマンドを再実行してください。このセットアップウィザードが現在設定するのは Claude Code plugin です。Codex や Kimi Code には、下記のマルチハーネスウィザードを使用してください。 + +複数のコーディングエージェントを一つのレビュー済みフローで設定するには、マルチハーネスウィザードを使用します。 + +```bash +npx ecc-universal@2.2.1 install --guided +``` + +Claude Code、Codex、Kimi Code の任意の組み合わせを選択でき、各インストールチャネルと配置先を表示し、最初の書き込み前にすべての選択をプリフライトし、最後に一度だけ確認を求めます。 + +| ハーネス | ガイド付きインストールの動作 | +|---|---| +| Claude Code | `user`、`project`、`local` のいずれか一つのスコープと ECC hook プロファイルを持つネイティブ `ecc@ecc` plugin | +| Codex | ネイティブ Codex マーケットプレイス/plugin ライフサイクル。hook のレビューと信頼は Codex 側が管理 | +| Kimi Code | `./.kimi-code` 配下の管理されたプロジェクトファイル。ECC hooks、モデル/プロバイダー設定、認証は設定されません | + +自動化のためには、プロバイダー固有の選択をすべて明示してください。 + +```bash +npx ecc-universal@2.2.1 install --guided \ + --harness claude --harness codex --harness kimi \ + --claude-scope local --claude-hooks standard \ + --profile core --yes +``` + +ネイティブのガイド付き Codex パスと管理された Kimi パスを、書き込みなしで先に検証するには次を実行します。 + +```bash +npx ecc-universal@2.2.1 install --guided --harness codex --dry-run +npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run +``` + +2.2 エイリアスを通じて、追加のパッケージ名コマンドも利用できます。 + +```bash +npx ecc-universal@2.2.1 consult "security reviews" --target claude +npx ecc-universal@2.2.1 install --profile minimal --target claude --with capability:machine-learning +npx ecc-universal@2.2.1 doctor --target kimi +``` + +`npx ecc-install --profile minimal --target claude` は使用しないでください。`ecc-install` は `ecc-universal` 内のバイナリ名であり、個別に公開された npm パッケージではありません。 + +ECC は `cursor`、`antigravity`、`gemini`、`opencode`、`codebuddy`、`joycode`、`qwen`、`zed`、`hermes`、`openclaw` 向けの高度な管理アダプターも提供しています。これらのターゲットは、各アダプターがガイド付きの衝突、更新、修復、アンインストールのライフサイクルマトリクスを通過するまで、ドキュメント化された `ecc install --target ...` パスを引き続き使用します。どちらのウィザードも、検出されたすべてのハーネスに黙ってインストールすることはありません。 + +### パスは一つだけ選ぶ(ハーネスごと) + +ECC は Claude Code、Codex、その他のハーネスで同時に使用できます。ハーネスごとに一つのインストール方法を選んでください。 + +- **推奨デフォルト:** 上記のガイド付き Claude plugin セットアップを実行する +- **Claude Code でもサポート:** [ネイティブ plugin コマンド](#claude-code-の詳細)を使用する +- **リリース 2.2 で利用可能:** Claude Code、Codex、Kimi Code 向けのガイド付きパッケージセットアップ +- **動作します:** Claude Code plugin + Codex ネイティブ plugin +- **動作します:** Claude Code plugin + レガシー Codex 同期フロー +- **避けてください:** Claude Code plugin + フル Claude 手動インストール +- **避けてください:** Codex 同期 + Codex マーケットプレイス plugin + +**インストール方法を重ねないでください。** 同じハーネスに ECC を二度インストールすると、skills、commands、hooks、設定が重複することがあります。複数のハーネスにそれぞれ一度ずつインストールする分には問題ありません。 + +すでに複数のインストールを重ねてしまい、重複しているように見える場合は、[ECC のリセット / アンインストール](#ecc-のリセット--アンインストール)に直接進んでください。 + +**インストールで困っていますか?** 短い[インストールまたはランタイムの問題フォーム](https://github.com/affaan-m/ECC/issues/new?template=install-problem.yml)を開くか、`ecc feedback` を実行してください。ECC が診断情報を自動でアップロードすることはありません。 + +### Claude Code の詳細 + +代わりに、Claude Code 内で Claude Code のネイティブ plugin コマンドを実行することもできます。 + +```text +/plugin marketplace add https://github.com/affaan-m/ECC /plugin install ecc@ecc ``` -### ステップ2:ルールをインストール(必須) +ネイティブパスは ECC の skills、agents、commands、および plugin 管理の hooks をインストールします。この方法を選んだ場合は、そこで止めてください。Claude Code にフルの手動インストールを追加で実行しないでください。 -> WARNING: **重要:** Claude Codeプラグインは`rules`を自動配布できません。手動でインストールしてください: +これらの組み込みコマンドは Claude Code が所有しており、マーケットプレイス、plugin、または競合するスコープがすでに存在する場合のエラーも同様です。ECC はそのパーサーに介入できません。いずれかのネイティブコマンドが既存のインストールやスコープの競合を報告した場合は、2.2 のガイド付きセットアップを使用するか、競合している Claude plugin スコープを解決してから再試行してください。その上に手動インストールを重ねないでください。 + +ECC のインストール後は、`/ecc:configure-ecc` が名前空間付きの Claude 内再設定 skill になります。これは同じ安全なセットアップフローに委譲しますが、plugin のインストール後にのみ利用可能で、初回インストール時に Claude Code 組み込みの `/plugin` コマンドを置き換えることはできません。 + +Claude Code plugins は `rules` を配布できないため、本当に必要な rule パックだけを追加してください。 ```bash -# まずリポジトリをクローン -git clone https://github.com/affaan-m/everything-claude-code.git - -# 共通ルールをインストール(必須) -cp -r everything-claude-code/rules/common ~/.claude/rules/common - -# 言語固有ルールをインストール(スタックを選択) -cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript -cp -r everything-claude-code/rules/python ~/.claude/rules/python -cp -r everything-claude-code/rules/golang ~/.claude/rules/golang +git clone https://github.com/affaan-m/ECC.git +cd ECC +mkdir -p ~/.claude/rules/ecc +cp -R rules/common ~/.claude/rules/ecc/ +cp -R rules/typescript ~/.claude/rules/ecc/ # 使用しているスタックに置き換えてください ``` -### ステップ3:使用開始 +`rules/common` と、実際に使用している言語またはフレームワークのパックを一つ入れるところから始めてください。plugin をインストールした場合は、その後で `./install.sh --profile full` を実行しないでください。 -```bash -# コマンドを試す(プラグインはネームスペース形式) -/ecc:plan "ユーザー認証を追加" +
+settings.json 派ですか?マーケットプレイスを宣言的に追加する -# 手動インストール(オプション2)は短縮形式: -# /plan "ユーザー認証を追加" - -# 利用可能なコマンドを確認 -/plugin list ecc@ecc -``` - -**完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。 - ---- - -## クロスプラットフォーム対応 - -このプラグインは **Windows、macOS、Linux** を完全にサポートしています。すべてのフックとスクリプトが Node.js で書き直され、最大の互換性を実現しています。 - -### パッケージマネージャー検出 - -プラグインは、以下の優先順位で、お好みのパッケージマネージャー(npm、pnpm、yarn、bun)を自動検出します: - -1. **環境変数**: `CLAUDE_PACKAGE_MANAGER` -2. **プロジェクト設定**: `.claude/package-manager.json` -3. **package.json**: `packageManager` フィールド -4. **ロックファイル**: package-lock.json、yarn.lock、pnpm-lock.yaml、bun.lockb から検出 -5. **グローバル設定**: `~/.claude/package-manager.json` -6. **フォールバック**: 最初に利用可能なパッケージマネージャー - -お好みのパッケージマネージャーを設定するには: - -```bash -# 環境変数経由 -export CLAUDE_PACKAGE_MANAGER=pnpm - -# グローバル設定経由 -node scripts/setup-package-manager.js --global pnpm - -# プロジェクト設定経由 -node scripts/setup-package-manager.js --project bun - -# 現在の設定を検出 -node scripts/setup-package-manager.js --detect -``` - -または Claude Code で `/setup-pm` コマンドを使用。 - ---- - -## 含まれるもの - -このリポジトリは**Claude Codeプラグイン**です - 直接インストールするか、コンポーネントを手動でコピーできます。 - -``` -everything-claude-code/ -|-- .claude-plugin/ # プラグインとマーケットプレイスマニフェスト -| |-- plugin.json # プラグインメタデータとコンポーネントパス -| |-- marketplace.json # /plugin marketplace add 用のマーケットプレイスカタログ -| -|-- agents/ # 委任用の専門サブエージェント -| |-- planner.md # 機能実装計画 -| |-- architect.md # システム設計決定 -| |-- tdd-guide.md # テスト駆動開発 -| |-- code-reviewer.md # 品質とセキュリティレビュー -| |-- security-reviewer.md # 脆弱性分析 -| |-- build-error-resolver.md -| |-- e2e-runner.md # Playwright E2E テスト -| |-- refactor-cleaner.md # デッドコード削除 -| |-- doc-updater.md # ドキュメント同期 -| |-- go-reviewer.md # Go コードレビュー -| |-- go-build-resolver.md # Go ビルドエラー解決 -| |-- python-reviewer.md # Python コードレビュー(新規) -| |-- database-reviewer.md # データベース/Supabase レビュー(新規) -| -|-- skills/ # ワークフロー定義と領域知識 -| |-- coding-standards/ # 言語ベストプラクティス -| |-- backend-patterns/ # API、データベース、キャッシュパターン -| |-- frontend-patterns/ # React、Next.js パターン -| |-- continuous-learning/ # セッションからパターンを自動抽出(長文ガイド) -| |-- continuous-learning-v2/ # 信頼度スコア付き直感ベース学習 -| |-- iterative-retrieval/ # サブエージェント用の段階的コンテキスト精製 -| |-- strategic-compact/ # 手動圧縮提案(長文ガイド) -| |-- tdd-workflow/ # TDD 方法論 -| |-- security-review/ # セキュリティチェックリスト -| |-- eval-harness/ # 検証ループ評価(長文ガイド) -| |-- verification-loop/ # 継続的検証(長文ガイド) -| |-- golang-patterns/ # Go イディオムとベストプラクティス -| |-- golang-testing/ # Go テストパターン、TDD、ベンチマーク -| |-- cpp-testing/ # C++ テスト GoogleTest、CMake/CTest(新規) -| |-- django-patterns/ # Django パターン、モデル、ビュー(新規) -| |-- django-security/ # Django セキュリティベストプラクティス(新規) -| |-- django-tdd/ # Django TDD ワークフロー(新規) -| |-- django-verification/ # Django 検証ループ(新規) -| |-- python-patterns/ # Python イディオムとベストプラクティス(新規) -| |-- python-testing/ # pytest を使った Python テスト(新規) -| |-- quarkus-patterns/ # Quarkus アーキテクチャ、Camel、CDI、Panache パターン(新規) -| |-- quarkus-security/ # Quarkus セキュリティ: JWT/OIDC、RBAC、バリデーション(新規) -| |-- quarkus-tdd/ # Quarkus TDD: JUnit 5、Mockito、REST Assured(新規) -| |-- quarkus-verification/ # Quarkus 検証: ビルド、テスト、ネイティブコンパイル(新規) -| |-- springboot-patterns/ # Java Spring Boot パターン(新規) -| |-- springboot-security/ # Spring Boot セキュリティ(新規) -| |-- springboot-tdd/ # Spring Boot TDD(新規) -| |-- springboot-verification/ # Spring Boot 検証(新規) -| |-- configure-ecc/ # インタラクティブインストールウィザード(新規) -| |-- security-scan/ # AgentShield セキュリティ監査統合(新規) -| -|-- commands/ # スラッシュコマンド用クイック実行 -| |-- tdd.md # /tdd - テスト駆動開発 -| |-- plan.md # /plan - 実装計画 -| |-- e2e.md # /e2e - E2E テスト生成 -| |-- code-review.md # /code-review - 品質レビュー -| |-- build-fix.md # /build-fix - ビルドエラー修正 -| |-- refactor-clean.md # /refactor-clean - デッドコード削除 -| |-- learn.md # /learn - セッション中のパターン抽出(長文ガイド) -| |-- checkpoint.md # /checkpoint - 検証状態を保存(長文ガイド) -| |-- verify.md # /verify - 検証ループを実行(長文ガイド) -| |-- setup-pm.md # /setup-pm - パッケージマネージャーを設定 -| |-- go-review.md # /go-review - Go コードレビュー(新規) -| |-- go-test.md # /go-test - Go TDD ワークフロー(新規) -| |-- go-build.md # /go-build - Go ビルドエラーを修正(新規) -| |-- skill-create.md # /skill-create - Git 履歴からスキルを生成(新規) -| |-- instinct-status.md # /instinct-status - 学習した直感を表示(新規) -| |-- instinct-import.md # /instinct-import - 直感をインポート(新規) -| |-- instinct-export.md # /instinct-export - 直感をエクスポート(新規) -| |-- evolve.md # /evolve - 直感をスキルにクラスタリング -| |-- pm2.md # /pm2 - PM2 サービスライフサイクル管理(新規) -| |-- multi-plan.md # /multi-plan - マルチエージェント タスク分解(新規) -| |-- multi-execute.md # /multi-execute - オーケストレーション マルチエージェント ワークフロー(新規) -| |-- multi-backend.md # /multi-backend - バックエンド マルチサービス オーケストレーション(新規) -| |-- multi-frontend.md # /multi-frontend - フロントエンド マルチサービス オーケストレーション(新規) -| |-- multi-workflow.md # /multi-workflow - 一般的なマルチサービス ワークフロー(新規) -| -|-- rules/ # 常に従うべきガイドライン(~/.claude/rules/ にコピー) -| |-- README.md # 構造概要とインストールガイド -| |-- common/ # 言語非依存の原則 -| | |-- coding-style.md # イミュータビリティ、ファイル組織 -| | |-- git-workflow.md # コミットフォーマット、PR プロセス -| | |-- testing.md # TDD、80% カバレッジ要件 -| | |-- performance.md # モデル選択、コンテキスト管理 -| | |-- patterns.md # デザインパターン、スケルトンプロジェクト -| | |-- hooks.md # フック アーキテクチャ、TodoWrite -| | |-- agents.md # サブエージェントへの委任時機 -| | |-- security.md # 必須セキュリティチェック -| |-- typescript/ # TypeScript/JavaScript 固有 -| |-- python/ # Python 固有 -| |-- golang/ # Go 固有 -| -|-- hooks/ # トリガーベースの自動化 -| |-- hooks.json # すべてのフック設定(PreToolUse、PostToolUse、Stop など) -| |-- memory-persistence/ # セッションライフサイクルフック(長文ガイド) -| |-- strategic-compact/ # 圧縮提案(長文ガイド) -| -|-- scripts/ # クロスプラットフォーム Node.js スクリプト(新規) -| |-- lib/ # 共有ユーティリティ -| | |-- utils.js # クロスプラットフォーム ファイル/パス/システムユーティリティ -| | |-- package-manager.js # パッケージマネージャー検出と選択 -| |-- hooks/ # フック実装 -| | |-- session-start.js # セッション開始時にコンテキストを読み込む -| | |-- session-end.js # セッション終了時に状態を保存 -| | |-- pre-compact.js # 圧縮前の状態保存 -| | |-- suggest-compact.js # 戦略的圧縮提案 -| | |-- evaluate-session.js # セッションからパターンを抽出 -| |-- setup-package-manager.js # インタラクティブ PM セットアップ -| -|-- tests/ # テストスイート(新規) -| |-- lib/ # ライブラリテスト -| |-- hooks/ # フックテスト -| |-- run-all.js # すべてのテストを実行 -| -|-- contexts/ # 動的システムプロンプト注入コンテキスト(長文ガイド) -| |-- dev.md # 開発モード コンテキスト -| |-- review.md # コードレビューモード コンテキスト -| |-- research.md # リサーチ/探索モード コンテキスト -| -|-- examples/ # 設定例とセッション -| |-- CLAUDE.md # プロジェクトレベル設定例 -| |-- user-CLAUDE.md # ユーザーレベル設定例 -| -|-- mcp-configs/ # MCP サーバー設定 -| |-- mcp-servers.json # GitHub、Supabase、Vercel、Railway など -| -|-- marketplace.json # 自己ホストマーケットプレイス設定(/plugin marketplace add 用) -``` - ---- - -## エコシステムツール - -### スキル作成ツール - -リポジトリから Claude Code スキルを生成する 2 つの方法: - -#### オプション A:ローカル分析(ビルトイン) - -外部サービスなしで、ローカル分析に `/skill-create` コマンドを使用: - -```bash -/skill-create # 現在のリポジトリを分析 -/skill-create --instincts # 継続的学習用の直感も生成 -``` - -これはローカルで Git 履歴を分析し、SKILL.md ファイルを生成します。 - -#### オプション B:GitHub アプリ(高度な機能) - -高度な機能用(10k+ コミット、自動 PR、チーム共有): - -[GitHub アプリをインストール](https://github.com/apps/skill-creator) | [ecc.tools](https://ecc.tools) - -```bash -# 任意の Issue にコメント: -/skill-creator analyze - -# またはデフォルトブランチへのプッシュで自動トリガー -``` - -両オプションで生成されるもの: -- **SKILL.mdファイル** - Claude Codeですぐに使えるスキル -- **instinctコレクション** - continuous-learning-v2用 -- **パターン抽出** - コミット履歴からの学習 - -### AgentShield — セキュリティ監査ツール - -Claude Code 設定の脆弱性、誤設定、インジェクションリスクをスキャンします。 - -```bash -# クイックスキャン(インストール不要) -npx ecc-agentshield scan - -# 安全な問題を自動修正 -npx ecc-agentshield scan --fix - -# Opus 4.6 による深い分析 -npx ecc-agentshield scan --opus --stream - -# ゼロから安全な設定を生成 -npx ecc-agentshield init -``` - -CLAUDE.md、settings.json、MCP サーバー、フック、エージェント定義をチェックします。セキュリティグレード(A-F)と実行可能な結果を生成します。 - -Claude Codeで`/security-scan`を実行、または[GitHub Action](https://github.com/affaan-m/agentshield)でCIに追加できます。 - -[GitHub](https://github.com/affaan-m/agentshield) | [npm](https://www.npmjs.com/package/ecc-agentshield) - -### 継続的学習 v2 - -instinctベースの学習システムがパターンを自動学習: - -```bash -/instinct-status # 信頼度付きで学習したinstinctを表示 -/instinct-import # 他者のinstinctをインポート -/instinct-export # instinctをエクスポートして共有 -/evolve # 関連するinstinctをスキルにクラスタリング -``` - -完全なドキュメントは`skills/continuous-learning-v2/`を参照してください。 - ---- - -## 要件 - -### Claude Code CLI バージョン - -**最小バージョン: v2.1.0 以上** - -このプラグインは Claude Code CLI v2.1.0+ が必要です。プラグインシステムがフックを処理する方法が変更されたためです。 - -バージョンを確認: -```bash -claude --version -``` - -### 重要: フック自動読み込み動作 - -> WARNING: **貢献者向け:** `.claude-plugin/plugin.json`に`"hooks"`フィールドを追加しないでください。これは回帰テストで強制されます。 - -Claude Code v2.1+は、インストール済みプラグインの`hooks/hooks.json`(規約)を自動読み込みします。`plugin.json`で明示的に宣言するとエラーが発生します: - -``` -Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded file -``` - -**背景:** これは本リポジトリで複数の修正/リバート循環を引き起こしました([#29](https://github.com/affaan-m/everything-claude-code/issues/29), [#52](https://github.com/affaan-m/everything-claude-code/issues/52), [#103](https://github.com/affaan-m/everything-claude-code/issues/103))。Claude Codeバージョン間で動作が変わったため混乱がありました。今後を防ぐため回帰テストがあります。 - ---- - -## インストール - -### オプション1:プラグインとしてインストール(推奨) - -このリポジトリを使用する最も簡単な方法 - Claude Codeプラグインとしてインストール: - -```bash -# このリポジトリをマーケットプレイスとして追加 -/plugin marketplace add https://github.com/affaan-m/ECC - -# プラグインをインストール -/plugin install ecc@ecc -``` - -または、`~/.claude/settings.json` に直接追加: +`~/.claude/settings.json` に直接追加します。 ```json { @@ -441,7 +290,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded "ecc": { "source": { "source": "github", - "repo": "affaan-m/everything-claude-code" + "repo": "affaan-m/ECC" } } }, @@ -451,102 +300,785 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded } ``` -これで、すべてのコマンド、エージェント、スキル、フックにすぐにアクセスできます。 +これにより、上記の二つの `/plugin` コマンドと同じ結果が得られます。 +
-> **注:** Claude Codeプラグインシステムは`rules`をプラグイン経由で配布できません([アップストリーム制限](https://code.claude.com/docs/en/plugins-reference))。ルールは手動でインストールする必要があります: -> -> ```bash -> # まずリポジトリをクローン -> git clone https://github.com/affaan-m/everything-claude-code.git -> -> # オプション A:ユーザーレベルルール(すべてのプロジェクトに適用) -> mkdir -p ~/.claude/rules -> cp -r everything-claude-code/rules/common ~/.claude/rules/common -> cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # スタックを選択 -> cp -r everything-claude-code/rules/python ~/.claude/rules/python -> cp -r everything-claude-code/rules/golang ~/.claude/rules/golang -> -> # オプション B:プロジェクトレベルルール(現在のプロジェクトのみ) -> mkdir -p .claude/rules -> cp -r everything-claude-code/rules/common .claude/rules/common -> cp -r everything-claude-code/rules/typescript .claude/rules/typescript # スタックを選択 -> ``` +
+命名と移行に関する注記(ecc@ecc、affaan-m/ECC、ecc-universal) ---- +ECC には三つの公開識別子があり、これらは互いに置き換えられません。 -### オプション2:手動インストール +- GitHub ソースリポジトリ: `affaan-m/ECC` +- Claude マーケットプレイス/plugin 識別子: `ecc@ecc` +- npm パッケージ: `ecc-universal` -インストール内容を手動で制御したい場合: +これは意図的なものです。Anthropic のマーケットプレイス/plugin インストールは正規の plugin 識別子をキーとするため、ECC は厳格な Desktop/API バリデーターに対してツール名とスラッシュコマンドの名前空間を十分に短く保つために `ecc@ecc` を使用しています。古い投稿には以前の長いマーケットプレイス識別子が残っている場合がありますが、それはレガシーエイリアスとしてのみ扱ってください。一方、npm パッケージは `ecc-universal` のままなので、npm インストールとマーケットプレイスインストールは意図的に異なる名前を使用しています。 + +npm リリースはコミットごとではなくバージョンタグごとに切られるため、`ecc-universal` は `main` へのすべてのプッシュではなく、リリース(2.1、2.2、...)を追跡します。最新の開発版が必要な場合は git からインストールしてください。 + +ローカルの Claude セットアップが消去またはリセットされた場合でも、何かを買い直す必要があるわけではありません。まず `node scripts/ecc.js list-installed` から始め、次に `node scripts/ecc.js doctor` と `node scripts/ecc.js repair` を実行してから再インストールしてください。通常はこれで、セットアップを組み直すことなく ECC 管理のファイルが復元されます。 +
+ +### Codex App と CLI + +現在の Codex リリースでは、ECC をネイティブのリポジトリマーケットプレイス plugin としてインストールできます。マーケットプレイスエントリはリポジトリルートを使用するため、Codex のキャッシュはマニフェストとともに、参照されるすべての skills、MCP 設定、hook ランタイム、スクリプト、アセットを受け取ります。 ```bash -# リポジトリをクローン -git clone https://github.com/affaan-m/everything-claude-code.git - -# エージェントを Claude 設定にコピー -cp everything-claude-code/agents/*.md ~/.claude/agents/ - -# ルール(共通 + 言語固有)をコピー -cp -r everything-claude-code/rules/common ~/.claude/rules/common -cp -r everything-claude-code/rules/typescript ~/.claude/rules/typescript # スタックを選択 -cp -r everything-claude-code/rules/python ~/.claude/rules/python -cp -r everything-claude-code/rules/golang ~/.claude/rules/golang - -# コマンドをコピー -cp everything-claude-code/commands/*.md ~/.claude/commands/ - -# スキルをコピー -cp -r everything-claude-code/skills/* ~/.claude/skills/ +codex plugin marketplace add affaan-m/ECC +codex plugin add ecc@ecc +codex plugin list --json +node scripts/codex/check-plugin-cache.js ``` -#### settings.json にフックを追加 +どちらの add コマンドも冪等です。後で更新するには、`codex plugin marketplace upgrade ecc` に続けて `codex plugin add ecc@ecc` を実行します。Codex はアクティブな `CODEX_HOME` に一つの有効化された plugin 状態を保存し、Claude の `user`、`project`、`local` スコープは提供しません。そのネイティブ hooks は明示的な信頼の決定を必要とし、Claude の四つの ECC hook プロファイルは使用しません。Codex 内では、ガイド付きのプロバイダー対応フローとして `$configure-ecc` を呼び出してください。 -手動インストール時のみ、`hooks/hooks.json` のフックを `~/.claude/settings.json` にコピーします。 +従来の `scripts/sync-ecc-to-codex.sh` パスは、`~/.codex` にコピーおよびマージされた設定を意図的に必要とするユーザー向けの非推奨互換オプションであり、ネイティブ plugin には不要です。新しい同期の実行では所有権マニフェストを書き出すため、クリーンアップ時に変更されたユーザーファイルを保護できます。まず Codex を一度実行して `~/.codex/config.toml` が存在する状態にしてから、次を実行します。 -`/plugin install` で ECC を導入した場合は、これらのフックを `settings.json` にコピーしないでください。Claude Code v2.1+ はプラグインの `hooks/hooks.json` を自動読み込みするため、二重登録すると重複実行や `${CLAUDE_PLUGIN_ROOT}` の解決失敗が発生します。 +```bash +git clone https://github.com/affaan-m/ECC.git +cd ECC +npm install +bash scripts/sync-ecc-to-codex.sh +``` -#### MCP を設定 +Codex の会話やネイティブ plugin キャッシュに触れずに、そのレガシーレイヤーを確認または削除するには次を実行します。 -`mcp-configs/mcp-servers.json` から必要な MCP サーバーを `~/.claude.json` にコピーします。 +```bash +node scripts/ecc.js uninstall --legacy-codex-sync --dry-run +node scripts/ecc.js uninstall --legacy-codex-sync +``` -**重要:** `YOUR_*_HERE`プレースホルダーを実際のAPIキーに置き換えてください。 +マニフェスト以前のインストールは保守的に扱われます。ECC はマークされた `AGENTS.md` ブロックを削除しますが、所有を証明できないコピー済みファイルは保持し、レビュー用に報告します。 ---- +プロジェクトローカルのセットアップとして、ECC リポジトリを Codex で直接開くこともできます。Codex はグローバル同期なしで、ルートの `AGENTS.md` と `.codex/` 内の信頼済みプロジェクト設定を読み取ります。同期フローの上にネイティブマーケットプレイス plugin を追加しないでください。 -## 主要概念 +リポジトリのナビゲーション、サーフェスの所有権、PR 差分パケットのガイダンスについては、[Codex ECC Navigation Map](../CODEX-NAVIGATION-GUIDE.md) を参照してください。ネイティブライフサイクルの詳細は [.codex plugin notes](../../.codex-plugin/README.md) を参照してください。 -### エージェント +### その他のエージェントとエディター -サブエージェントは限定的な範囲のタスクを処理します。例: +
+Cursor、OpenCode、Gemini、Zed、Antigravity、Qwen、Hermes、OpenClaw、Kimi、CodeBuddy、JoyCode、Copilot + +ECC を一度クローンし、使用しているハーネスに合ったターゲットを選択します。 + +```bash +git clone https://github.com/affaan-m/ECC.git +cd ECC +``` + +| ハーネス | インストールまたはセットアップ | 備考 | +|---|---|---| +| Cursor | `./install.sh --profile minimal --target cursor` | プロジェクトローカルの `.cursor/` アダプター | +| OpenCode | `npm install && npm run build:opencode && ./install.sh --profile full --target opencode --enable-hooks` | フルインストールの前に plugin ペイロードをビルド | +| Gemini CLI | `./install.sh --profile minimal --target gemini` | プロジェクトローカルの `.gemini/` 設定 | +| Zed | `./install.sh --profile minimal --target zed` | プロジェクトローカルの `.zed/` アダプター | +| Antigravity | `./install.sh --profile minimal --target antigravity` | [Antigravity ガイド](../ANTIGRAVITY-GUIDE.md)を参照 | +| Qwen CLI | `./install.sh --profile minimal --target qwen` | [Qwen ガイド](../QWEN-GUIDE.md)を参照 | +| Hermes | `./install.sh --profile minimal --target hermes` | [Hermes セットアップガイド](../HERMES-SETUP.md)を参照 | +| OpenClaw | `./install.sh --profile minimal --target openclaw` | 管理されたホームディレクトリインストール | +| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | プロジェクトローカルの `.kimi-code/` インストール · [Kimi Code を入手](https://www.kimi.com/code?aff=ecc) | +| CodeBuddy | `./install.sh --profile minimal --target codebuddy` | プロジェクトローカルの `.codebuddy/` インストール | +| JoyCode | `./install.sh --profile minimal --target joycode` | プロジェクトローカルの `.joycode/` インストール | + +GitHub Copilot のサポートはすでにこのリポジトリに含まれています。`.github/copilot-instructions.md` が指示レイヤーを提供し、`.github/prompts/` には再利用可能な `/plan`、`/tdd`、`/security-review`、`/build-fix`、`/refactor` のプロンプトが含まれ、`.vscode/settings.json` が `chat.promptFiles` を有効にします。 + +ネイティブの ECC ターゲットがないハーネスには、[手動適用ガイド](../MANUAL-ADAPTATION-GUIDE.md)を使用してください。hooks やネイティブの skill 検出が利用できるふりをせずに、少数の ECC skills とワークフロー指示をチャット型ツールに持ち込む方法を説明しています。 + +Cursor は agent 定義を `.cursor/agents/ecc-*.md` 配下にインストールします。Cursor ネイティブのロード動作は Cursor のビルドによって異なる場合があります。ECC はルートの `AGENTS.md` を `.cursor/` にインストールしません。このアダプターは Cursor のコンテキストをネイティブの rules と agent サーフェスに限定します。 + +ハーネスごとの詳細な注記(機能の同等性、hook アダプター、制限事項)は、下記の[プラットフォームサポート](#プラットフォームサポート)にあります。 +
+ +## 高度なインストールオプション + +
+hook ランタイムなしの低コンテキストインストール + +### 低コンテキスト / hooks なしパス + +ランタイム hooks なしで ECC の rules、agents、commands、プラットフォーム設定、コアワークフローを使いたい場合はこちらを使用します。 + +```bash +npx ecc-universal@2.2.1 install --profile minimal --target claude +``` + +ソースチェックアウトからの同等のコマンドは次のとおりです。 + +```bash +./install.sh --profile minimal --target claude +``` + +Windows: + +```powershell +.\install.ps1 --profile minimal --target claude +``` + +このプロファイルは意図的に `hooks-runtime` を除外しています。 + +Claude の手動インストールでは、Claude Code が検出できるように各 skill を `~/.claude/skills//`(`claude-project` の場合は `.claude/skills//`)の直下に配置します。古い ECC 手動インストールをアップグレードする場合、インストーラーは ECC のインストール状態に記録されたネストされた `skills/ecc/` ファイルのみを移行します。フラットな skill ディレクトリがユーザー所有の場合、ECC はそれを保持して競合の警告を表示し、ユーザーファイルを上書きする代わりに、古い管理コピーを安全なアンインストールのために追跡し続けます。 + +hooks を無効にした通常の core プロファイルの場合: + +```bash +./install.sh --profile core --without baseline:hooks --target claude +./install.sh --profile core --no-hooks --target claude +``` + +hook ランタイムが必要になった場合にのみ、後から追加します。 + +```bash +./install.sh --target claude --modules hooks-runtime --enable-hooks +``` + +プロファイルまたはモジュールによって hook ランタイムが実体化されるインストールでは、 +明示的な決定が必要です。`--enable-hooks` も `--no-hooks` も指定されていない場合、 +インストーラーは hooks でできることを表示し、何も書き込まずに停止します。ガイド付き +インストーラー(`ecc install --guided`)はこの選択を対話的に尋ねます。 +
+ +
+必要なコンポーネントだけを選ぶ + +### まず適切なコンポーネントを見つける + +同梱のアドバイザーに、あなたの作業に合うコンポーネントを尋ねてください。 + +```bash +node scripts/ecc.js consult "security reviews" --target claude +``` + +一致するコンポーネント、関連するプロファイル、プレビュー/インストールコマンドが返されます。正確なファイル計画を確認したい場合は、インストール前にプレビューコマンドを使用してください。 + +明示的に skills や capability を指定してインストールすることもできます。 + +```bash +./install.sh --target claude --skills tdd-workflow,security-review +node scripts/ecc.js install --profile minimal --target claude --with capability:machine-learning +``` + +コンポーネントごとの手動コピーも可能です。各コンポーネントは完全に独立しています。 + +```bash +# agents のみ +cp agents/*.md ~/.claude/agents/ + +# rules ディレクトリ(common + 言語固有) +mkdir -p ~/.claude/rules/ecc +cp -r rules/common ~/.claude/rules/ecc/ +cp -r rules/typescript ~/.claude/rules/ecc/ # 使用しているスタックを選択 + +# コア/汎用 skills のみ(Claude Code は ~/.claude/skills の直下から skills をロードします。 +# 手動インストールを ~/.claude/skills/ecc/ 配下にネストしないでください) +mkdir -p ~/.claude/skills +cp -r .agents/skills/* ~/.claude/skills/ +cp -r skills/search-first ~/.claude/skills/ + +# オプション: 移行期間中に維持されるスラッシュコマンド互換 +mkdir -p ~/.claude/commands +cp commands/*.md ~/.claude/commands/ +``` + +廃止されたシムは `legacy-command-shims/` にあります。`/tdd` などの古い名前がまだ必要な場合にのみ、そこから個別のファイルをコピーしてください。 +
+ +
+グローバル rules の代わりにプロジェクトローカル rules を使う + +ECC の標準をすべての Claude Code セッションではなく一つのリポジトリにだけ適用したい場合は、プロジェクトローカル rules を使用します。 + +```bash +cd your-project +mkdir -p .claude/rules/ecc +cp -R /path/to/ECC/rules/common .claude/rules/ecc/ +cp -R /path/to/ECC/rules/typescript .claude/rules/ecc/ +``` + +rules は常時ロードされるコンテキストなので、`common` と実際に使用しているスタックのパック一つから始めてください。rules を手動でコピーする際は、相対参照が機能し続け、ファイル名が衝突しないように、中のファイルではなく言語ディレクトリ全体(たとえば `rules/common` や `rules/golang`)をコピーしてください。 +
+ +
+完全手動の Claude インストール + +plugin パスを意図的にスキップする場合にのみ使用してください。 + +```bash +git clone https://github.com/affaan-m/ECC.git +cd ECC +./install.sh --profile full +``` + +Windows: + +```powershell +git clone https://github.com/affaan-m/ECC.git +cd ECC +.\install.ps1 --profile full +``` + +このパスを選んだ場合は、そこで止めてください。`/plugin install` を追加で実行しないでください。 + +厳選した手動インストールの場合、Claude は `~/.claude/skills/` の直下の子として skills を検出します。`~/.claude/skills/ecc/` 配下にネストしないでください。 + +#### hooks のインストール + +リポジトリの生の `hooks/hooks.json` を `~/.claude/settings.json` や `~/.claude/hooks/hooks.json` にコピーしないでください。そのファイルは plugin/リポジトリ向けのものです。hook コマンドのパスが正しく書き換えられるよう、インストーラーを使用してください。 + +```bash +bash ./install.sh --target claude --modules hooks-runtime --enable-hooks +``` + +これにより hook スクリプトが `~/.claude/` 配下にインストールされ、解決済みの +hook エントリが `~/.claude/settings.json` に登録されます。既存のユーザー設定と hooks は +保持されます。ECC 所有のエントリは安定した ID で追跡されるため、冪等な更新と +安全なアンインストールが可能です。 + +`/plugin install` で ECC をインストールした場合は、それらの hooks を `settings.json` にコピーしないでください。Claude Code v2.1+ はすでに plugin の `hooks/hooks.json` を自動ロードしており、`settings.json` に重複させると二重実行やクロスプラットフォームの hook 競合が発生します。 + +Windows では、Claude の設定ルートは `%USERPROFILE%\.claude` です。hook ランタイムは次のようにインストールしてください。 + +```powershell +pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks +``` + +#### MCP の設定 + +Claude plugin インストールは、ECC に同梱された MCP サーバー定義を意図的に自動有効化しません。これにより、厳格なサードパーティゲートウェイでの plugin MCP ツール名の長すぎる問題を回避しつつ、手動での MCP セットアップは引き続き可能です。 + +稼働中の Claude Code サーバー変更には、Claude Code の `/mcp` コマンドまたは CLI 管理の MCP セットアップを使用してください。Claude Code はそれらの選択を `~/.claude.json` に永続化します。リポジトリローカルの MCP アクセスには、`mcp-configs/mcp-servers.json` から必要な MCP サーバー定義をプロジェクトスコープの `.mcp.json` にコピーしてください。 + +ECC が同梱するデフォルトコネクターはちょうど一つ(`chrome-devtools`)だけです。それ以外はすべて CLI/REST API をラップする skill か、オプトインのカタログエントリです。このルールと、以前の六つのデフォルトを廃止した 2026年6月の監査は [docs/MCP-CONNECTOR-POLICY.md](../MCP-CONNECTOR-POLICY.md) にあります。 + +ECC 同梱の MCP を自分でも別途実行している場合は、次を設定してください。 + +```bash +export ECC_DISABLED_MCPS="chrome-devtools" +``` + +ECC 管理のインストールおよび Codex 同期フローは、重複を再追加する代わりに、それらの同梱サーバーをスキップまたは削除します。`ECC_DISABLED_MCPS` は ECC のインストール/同期フィルターであり、稼働中の Claude Code のトグルではありません。 + +**重要:** `YOUR_*_HERE` プレースホルダーを実際の API キーに置き換えてください。 +
+ +
+マルチモデル commands には追加のセットアップが必要 + +`multi-*` commands は、基本の plugin/rules インストールには**含まれていません**。 + +`/multi-plan`、`/multi-execute`、`/multi-backend`、`/multi-frontend`、`/multi-workflow` を使用するには、`ccg-workflow` ランタイムもインストールする必要があります。[上流の CCG インストールガイド](https://github.com/fengshao1227/ccg-workflow#readme)を使って正確なリリースを選択・レビューし、そのインストール済みランタイムを初期化してください。ECC は CCG を同梱しておらず、互換性があり監査済みの CCG リリースを保証するものでもありません。このガイドは、特定されていないレジストリバージョンをブートストラップしません。 + +このランタイムは、これらの commands が期待する外部依存関係を提供します。たとえば次のものです。 + +- `~/.claude/bin/codeagent-wrapper` +- `~/.claude/.ccg/prompts/*` + +`ccg-workflow` がない場合、これらの `multi-*` commands は正しく動作しません。 +
+ +
+リセット、修復、またはアンインストール + +### ECC のリセット / アンインストール + +ユニバーサルパッケージからインストールした場合は、インストール時に使用したのと同じ +プロジェクトディレクトリから次のコマンドを実行してください。 + +```bash +npx ecc-universal@2.2.1 list-installed +npx ecc-universal@2.2.1 doctor +npx ecc-universal@2.2.1 repair +npx ecc-universal@2.2.1 uninstall --dry-run +npx ecc-universal@2.2.1 uninstall +``` + +ソースチェックアウトからの場合は、再インストールの前に管理状態を確認してください。 + +```bash +node scripts/ecc.js list-installed +node scripts/ecc.js doctor +node scripts/ecc.js repair +node scripts/ecc.js uninstall --dry-run +``` + +ソースチェックアウトから直接アンインストールするには次を実行します。 + +```bash +node scripts/uninstall.js --dry-run +node scripts/uninstall.js +``` + +ECC をやめる場合、アンインストールコマンドは任意の[20秒フィードバックフォーム](https://github.com/affaan-m/ECC/issues/new?template=quick-feedback.yml)を表示します。これは公開の GitHub issue であり、アンインストールを妨げることはなく、ECC が診断情報をアップロードすることもありません。問題報告、フィードバック、機能要望の窓口を確認するには、いつでも `ecc feedback` を実行できます。 + +plugin ユーザーは Claude Code から plugin を削除し、その後、手動でコピーして不要になった rule フォルダーだけを削除してください。ECC はインストール状態に記録されたファイルのみを削除します。ハーネスディレクトリ内の無関係なファイルを自分のものとして扱うことはありません。 + +複数の方法を重ねてしまった場合は、次の順序でクリーンアップしてください。 + +1. Claude Code plugin のインストールを削除します。 +2. 管理対象の install-state を含むプロジェクトディレクトリから ECC のアンインストールコマンドを実行します。 +3. 手動でコピーした、もう不要な rules フォルダーを削除します。 +4. 単一の経路を使って一度だけ再インストールします。 +
+ +## ECC を使い始める + +カタログ全体ではなく、必要なワークフローから始めましょう。 + +| やりたいこと | ここから始める | +|---|---| +| 機能を構築する | `/ecc:plan "describe the feature"`、その後 `tdd-workflow` | +| バグを修正する | 失敗するテストで再現してから `tdd-workflow` を使用 | +| 新しいコードをレビューする | `/code-review` で新しいコンテキストからのレビュー | +| ビルドを修復する | `/build-fix` | +| コードベースをクリーンアップする | `/refactor-clean` | +| コンテキストの圧迫を確認する | `/context-budget` | +| 長いセッションを終える | `/save-session` または `/learn-eval` | +| 後で再開する | `/resume-session` | +| agent 設定を監査する | レビュー済みのスキャナーで `/security-scan`、またはインストール済みの `agentshield scan --path .` | + +
+Plugin コマンドと手動コマンド + +Claude Code の plugin コマンドはネームスペース付きの形式を使います: + +```text +/ecc:plan "Add authentication" +``` + +手動インストールでは、より短い互換形式が使える場合があります: + +```text +/plan "Add authentication" +``` + +Skills が主要なワークフローの入口です。コマンドは便利なエントリーポイントおよび互換シムとして残っています。インストール済みの内容は次のコマンドで確認できます: + +```bash +/plugin list ecc@ecc +``` +
+ +
+どの agent を使えばよいですか? + +Skills が正規のワークフローの入口です。メンテナンスされているスラッシュエントリーは、コマンドファーストのワークフロー向けに引き続き利用できます。 + +| やりたいこと | 使う入口 | 使用される agent | +|--------------|-----------------|------------| +| 新機能を計画する | `/ecc:plan "Add auth"` | planner | +| システムアーキテクチャを設計する | `/ecc:plan` + architect agent | architect | +| テストファーストでコードを書く | `tdd-workflow` skill | tdd-guide | +| 書いたばかりのコードをレビューする | `/code-review` | code-reviewer | +| 失敗するビルドを修正する | `/build-fix` | build-error-resolver | +| エンドツーエンドテストを実行する | `e2e-testing` skill | e2e-runner | +| セキュリティ脆弱性を見つける | `/security-scan` | security-reviewer | +| デッドコードを削除する | `/refactor-clean` | refactor-cleaner | +| ドキュメントを更新する | `/update-docs` | doc-updater | +| Go コードをレビューする | `/go-review` | go-reviewer | +| Python コードをレビューする | `/python-review` | python-reviewer | +| F# コードをレビューする | *(`fsharp-reviewer` を直接呼び出す)* | fsharp-reviewer | +| TypeScript/JavaScript コードをレビューする | *(`typescript-reviewer` を直接呼び出す)* | typescript-reviewer | +| HarmonyOS アプリを開発する | *(`harmonyos-app-resolver` を直接呼び出す)* | harmonyos-app-resolver | +| データベースクエリを監査する | *(自動委譲)* | database-reviewer | +| 本番 ML の変更をレビューする | `mle-workflow` skill + `mle-reviewer` agent | mle-reviewer | + +
+ +
+よくあるワークフロー + +以下のスラッシュ形式は、メンテナンスされているコマンド群に残っているものを示しています。`/tdd` や `/eval` のような廃止された短縮名シムは、明示的なオプトイン専用として `legacy-command-shims/` にあります。 + +**新機能を始める:** +``` +/ecc:plan "Add user authentication with OAuth" + -> planner creates implementation blueprint +tdd-workflow skill -> tdd-guide enforces write-tests-first +/code-review -> code-reviewer checks your work +``` + +**バグを修正する:** +``` +tdd-workflow skill -> tdd-guide: write a failing test that reproduces it + -> implement the fix, verify test passes +/code-review -> code-reviewer: catch regressions +``` + +**本番環境に向けた準備:** +``` +/security-scan -> security-reviewer: OWASP Top 10 audit +e2e-testing skill -> e2e-runner: critical user flow tests +/test-coverage -> verify 80%+ coverage +``` +
+ +## セルフホストモデルとカスタムエンドポイント + +ECC は各ハーネスの通常の設定を通じて動作するため、ECC のワークフローを変更することなく、公式プロバイダー、互換性のあるカスタム API エンドポイントやモデルゲートウェイ、あるいはセルフホストモデルを利用できます。 + +Claude Code について、ECC は Anthropic ホストのトランスポート設定をハードコードしていません。最小限のゲートウェイの例: + +```bash +export ANTHROPIC_BASE_URL=https://your-gateway.example.com +export ANTHROPIC_AUTH_TOKEN=your-token +claude +``` + +ゲートウェイがモデル名を再マッピングする場合は、ECC ではなく Claude Code 側で設定してください。`claude` CLI がすでに動作している状態であれば、ECC の hooks、skills、コマンド、rules はモデルプロバイダーに依存しません。Anthropic の [LLM ゲートウェイドキュメント](https://docs.anthropic.com/en/docs/claude-code/llm-gateway) と [モデル設定ドキュメント](https://docs.anthropic.com/en/docs/claude-code/model-config) を参照してください。 + +そのゲートウェイの背後で任意のオープンソースモデルを実行またはセルフホストするには、別途コンピュートとサービングのセットアップが必要です。GPU 容量が必要な場合、[Itô](https://compute.itomarkets.com) は ECC の推奨コンピュートスポンサーですが、どの GPU プロバイダーでも動作します。このスポンサーシップのリンクは受動的なものです。RFQ の発行、容量の予約、コンピュートのプロビジョニング、サービングの設定は行いません。これとは別に、`ecc ito find` は明示的に設定された正規の Itô CLI を呼び出し、認証済みのライブ RFQ を送信しますが、容量の予約は行いません。Itô によるマネージド推論はまだ提供されていません。 + +### ECC + Itô コンピュートで Kimi をセルフホストする + +Kimi Code ハーネスとモデルサービングレイヤーは別物です。ECC は agent ハーネスを設定します。API エンドポイントを用意する([Kimi API キーを取得](https://platform.kimi.ai?aff=ecc))か、自身の GPU 容量でオープンウェイトの Kimi モデルをセルフホストするのはユーザー側です。このアダプターは Kimi Code 0.31.x(`@moonshot-ai/kimi-code`)で検証済みです: + + + + + + + +
+ + Itô Markets
+ 1. GPU 容量を確保する +

+ Itô または任意の GPU プロバイダーを利用します。 +
+ + Moonshot AI - Kimi
+ 2. Kimi をサーブする +

+ 選択したチェックポイントを互換エンドポイント経由で公開します。 +
+ + ECC Tools
+ 3. ECC で Kimi Code を実行する +

+ プロジェクトの指示と skills をインストールし、Kimi Code を起動します。 +
+ +Kimi Code の公式プロバイダーガイドに従ってエンドポイントを設定し、ECC をインストールします: + +```bash +bash ./install.sh --target kimi --profile minimal +node scripts/ecc.js doctor --target kimi +kimi +``` + +Kimi Code はインストールされた `.kimi-code/AGENTS.md` の指示と `.kimi-code/skills/` のワークフローをネイティブに検出します。プロジェクトレベルの `.agents/skills/` も公式の検出場所です。ECC はプロジェクトの MCP エントリーを `.kimi-code/mcp.json` に安全にマージし、ユーザーレベルの `~/.kimi-code/config.toml` は変更しません。Kimi Code はネイティブ hooks をサポートしていますが、ECC の現在のマネージドプロジェクトアダプターはそれらを設定しないため、このインストーラーは Kimi の hook プロファイルを提供しません。インストーラーのドライランと回帰テストスイートにより、マネージドな Kimi への書き込みがすべてプロジェクトローカルの `.kimi-code/` ルート内に収まることが検証されています。 + +### Itô コンピュート CLI ブリッジ + +`ecc ito` は別途インストールされた正規の Itô クライアントに委譲します。ECC は 2 つ目の API クライアントを保守しません。`ecc ito login [--no-browser]` はデバイス認可を実行し、デフォルトで Itô の検証ページを開き、デバイストークンを macOS Keychain に保存します。`--no-browser` はページの引き渡しを抑制します。ECC 自体はブラウザ自動化を行いません。`ecc ito auth` は検証専用で、`--no-browser` を拒否します。利用可能な操作は `ecc ito login`、`ecc ito auth`、`ecc ito find`、`ecc ito status`、および別途ゲートされた `ecc ito evals` です。対応する MCP ツールは引き続き `ito_auth`、`ito_find`、`ito_status` です。`ito_auth` は既存の認証情報を検証し、ノード資格の確認は CLI 専用です。 + +`ito-compute-cli` パッケージは現在未公開です。Itô ランタイムリポジトリ(デスクの堅牢化が進むまで非公開。デザインパートナーにはアクセス権が提供されます)の `cli/ito-compute-cli` からローカルでビルドし、`npm ci` と `npm run check` を実行してから、`ECC_ITO_CLI_EXECUTABLE` にそのビルドの `dist/bin/ito.js` の絶対パスを設定してください。login は `ITO_API_KEY` を決して継承しません。auth、find、status は設定されていれば `ITO_API_KEY` を直接転送し、`ITO_AUTH_MODE=legacy` は不要です。`ecc ito logout` は現在のデバイス認証情報を失効させ、リモートでの失効が確認できない場合はローカルコピーを保持します。デバイストークンはデフォルトで macOS Keychain を使用します。明示的なファイルフォールバックでは、所有者のみがアクセスできるディレクトリ/ファイルのパーミッションを維持する必要があります。ECC はこの認証情報を持つクライアントを `PATH` 経由で検出しません。RFQ の権限と MCP セットアップの契約の全容については [`ito-compute` skill](../../skills/ito-compute/SKILL.md) を参照してください。 + +`find` は認証済みのライブ RFQ を送信します。容量の予約は行いません。`evals` には `ITO_ENABLE_SIXTYTWO_LIVE=1` と `--live-sixtytwo` の両方、別途インストールされた `sixtytwo-cli==0.3.33`、明示的なノードリスト、および既存の絶対パスの設定ディレクトリが必要です。レンタル、起動、復旧、修復、購入はできません。ECC は見積もりロック、購入、ワークロード、推論のいずれの経路も公開せず、クライアントの欠如やライブ呼び出しの失敗をローカルの結果で置き換えることも決してありません。 + +## 新機能 + +現在のリリース:**2.2.1**(2026-08-31)。2.2 系のハイライト: + +- Claude Code、Codex、Kimi Code にわたるガイド付きのマニフェスト駆動セットアップ。install-state の所有権管理、doctor、repair、uninstall を備えています。 +- ネイティブの Antigravity インストール、薄い Pi アダプター、そして Linux、macOS、Windows でテストされたパック済みアーティファクトのリリースゲート。 +- Plan Canvas によるブラウザレビュー、統合メモリボールト(`ecc memory`)、Itô コンピュート skill ファミリー。 + +完全な履歴:[CHANGELOG.md](../../CHANGELOG.md)。リリースごとのノートとエビデンスは [docs/releases/](../releases/) にあります。 + +### v2.0.0: Agent Harness Operating System(2026年6月) + +2.0 系の安定版への昇格:コントロールペーン基盤、worktree ライフサイクルサービス、`orch-*` オーケストレーターファミリー、Discord コミュニティ。ノート:[docs/releases/2.0.0/release-notes.md](../releases/2.0.0/release-notes.md)。 + +## 中身 + +```text +ECC/ +|-- agents/ # 委譲用の 68 の専門サブエージェント +|-- skills/ # オンデマンドで読み込まれる 292 の再利用可能なワークフロー +|-- commands/ # メンテナンスされている 94 のスラッシュコマンドシム +|-- rules/ # オプトインの共通標準と言語別標準 +|-- hooks/ # ランタイムの自動化と強制 +|-- scripts/ # インストール、修復、同期、オーケストレーション、チェック +|-- .claude-plugin/ # Claude Code マーケットプレイスマニフェスト +|-- .codex/ # Codex リファレンス設定と agent ロール +|-- .opencode/ # OpenCode plugin、コマンド、指示 +|-- .cursor/ # Cursor rules と hook アダプター +|-- docs/ # 公開されたセットアップ、アーキテクチャ、運用ガイド +``` + +ルートが信頼できる唯一の情報源です。プラットフォームアダプターは、別のコピーを保守するのではなく、これらの同じワークフローをパッケージ化またはマッピングします。 + +
+注釈付きコンポーネントカタログ + +``` +ECC/ +|-- .claude-plugin/ # Plugin とマーケットプレイスのマニフェスト +| |-- plugin.json # Plugin メタデータとコンポーネントパス +| |-- marketplace.json # /plugin marketplace add 用のマーケットプレイスカタログ +| +|-- agents/ # 委譲用の 67 の専門サブエージェント +| |-- planner.md # 機能実装の計画 +| |-- architect.md # システム設計の意思決定 +| |-- tdd-guide.md # テスト駆動開発 +| |-- code-reviewer.md # 品質とセキュリティのレビュー +| |-- security-reviewer.md # 脆弱性分析 +| |-- build-error-resolver.md +| |-- e2e-runner.md # Playwright E2E テスト +| |-- refactor-cleaner.md # デッドコードのクリーンアップ +| |-- doc-updater.md # ドキュメントの同期 +| |-- docs-lookup.md # ドキュメント/API の検索 +| |-- chief-of-staff.md # コミュニケーションのトリアージと下書き +| |-- loop-operator.md # 自律ループの実行 +| |-- harness-optimizer.md # ハーネス設定のチューニング +| |-- cpp-reviewer.md # C++ コードレビュー +| |-- cpp-build-resolver.md # C++ ビルドエラーの解決 +| |-- fsharp-reviewer.md # F# 関数型コードレビュー +| |-- go-reviewer.md # Go コードレビュー +| |-- go-build-resolver.md # Go ビルドエラーの解決 +| |-- python-reviewer.md # Python コードレビュー +| |-- database-reviewer.md # データベース/Supabase レビュー +| |-- typescript-reviewer.md # TypeScript/JavaScript コードレビュー +| |-- java-reviewer.md # Java/Spring Boot コードレビュー +| |-- java-build-resolver.md # Java/Maven/Gradle ビルドエラー +| |-- kotlin-reviewer.md # Kotlin/Android/KMP コードレビュー +| |-- kotlin-build-resolver.md # Kotlin/Gradle ビルドエラー +| |-- harmonyos-app-resolver.md # HarmonyOS/ArkTS アプリ開発 +| |-- rust-reviewer.md # Rust コードレビュー +| |-- rust-build-resolver.md # Rust ビルドエラーの解決 +| |-- pytorch-build-resolver.md # PyTorch/CUDA トレーニングエラー +| |-- mle-reviewer.md # 本番 ML パイプライン、評価、サービング、監視のレビュー +| +|-- skills/ # ワークフロー定義とドメイン知識 +| |-- coding-standards/ # 言語別ベストプラクティス +| |-- clickhouse-io/ # ClickHouse 分析、クエリ、データエンジニアリング +| |-- backend-patterns/ # API、データベース、キャッシュのパターン +| |-- frontend-patterns/ # React、Next.js のパターン +| |-- frontend-slides/ # HTML スライドデッキと PPTX から Web へのプレゼンテーションワークフロー +| |-- article-writing/ # 汎用的な AI 口調を避け、指定された文体で書く長文ライティング +| |-- content-engine/ # マルチプラットフォームのソーシャルコンテンツと再利用ワークフロー +| |-- market-research/ # 出典を明記した市場、競合、投資家のリサーチ +| |-- investor-materials/ # ピッチデッキ、ワンページャー、メモ、財務モデル +| |-- investor-outreach/ # パーソナライズされた資金調達アウトリーチとフォローアップ +| |-- continuous-learning/ # レガシー v1 の Stop hook によるパターン抽出 +| |-- continuous-learning-v2/ # 信頼度スコアリング付きの instinct ベース学習 +| |-- iterative-retrieval/ # サブエージェント向けの段階的なコンテキスト精緻化 +| |-- strategic-compact/ # 手動コンパクション提案(長文ガイド) +| |-- tdd-workflow/ # TDD 方法論 +| |-- security-review/ # セキュリティチェックリスト +| |-- eval-harness/ # 検証ループ評価(長文ガイド) +| |-- verification-loop/ # 継続的検証(長文ガイド) +| |-- videodb/ # 動画と音声:取り込み、検索、編集、生成、ストリーミング +| |-- golang-patterns/ # Go のイディオムとベストプラクティス +| |-- golang-testing/ # Go のテストパターン、TDD、ベンチマーク +| |-- cpp-coding-standards/ # C++ Core Guidelines に基づく C++ コーディング標準 +| |-- cpp-testing/ # GoogleTest、CMake/CTest による C++ テスト +| |-- django-patterns/ # Django のパターン、モデル、ビュー +| |-- django-security/ # Django セキュリティベストプラクティス +| |-- django-tdd/ # Django TDD ワークフロー +| |-- django-verification/ # Django 検証ループ +| |-- laravel-patterns/ # Laravel アーキテクチャパターン +| |-- laravel-security/ # Laravel セキュリティベストプラクティス +| |-- laravel-tdd/ # Laravel TDD ワークフロー +| |-- laravel-verification/ # Laravel 検証ループ +| |-- python-patterns/ # Python のイディオムとベストプラクティス +| |-- python-testing/ # pytest による Python テスト +| |-- quarkus-patterns/ # Java Quarkus パターン +| |-- quarkus-security/ # Quarkus セキュリティ +| |-- quarkus-tdd/ # Quarkus TDD +| |-- quarkus-verification/ # Quarkus 検証 +| |-- rails-patterns/ # Rails アーキテクチャパターン +| |-- springboot-patterns/ # Java Spring Boot パターン +| |-- springboot-security/ # Spring Boot セキュリティ +| |-- springboot-tdd/ # Spring Boot TDD +| |-- springboot-verification/ # Spring Boot 検証 +| |-- configure-ecc/ # インタラクティブインストールウィザード +| |-- security-scan/ # AgentShield セキュリティ監査ツールの統合 +| |-- java-coding-standards/ # Java コーディング標準 +| |-- jpa-patterns/ # JPA/Hibernate パターン +| |-- postgres-patterns/ # PostgreSQL 最適化パターン +| |-- nutrient-document-processing/ # Nutrient API によるドキュメント処理 +| |-- database-migrations/ # マイグレーションパターン(Prisma、Drizzle、Django、Go) +| |-- api-design/ # REST API 設計、ページネーション、エラーレスポンス +| |-- deployment-patterns/ # CI/CD、Docker、ヘルスチェック、ロールバック +| |-- docker-patterns/ # Docker Compose、ネットワーキング、ボリューム、コンテナセキュリティ +| |-- e2e-testing/ # Playwright E2E パターンと Page Object Model +| |-- content-hash-cache-pattern/ # ファイル処理向けの SHA-256 コンテンツハッシュキャッシュ +| |-- cost-aware-llm-pipeline/ # LLM コスト最適化、モデルルーティング、予算追跡 +| |-- regex-vs-llm-structured-text/ # 判断フレームワーク:テキスト解析における正規表現 vs LLM +| |-- swift-actor-persistence/ # actor によるスレッドセーフな Swift データ永続化 +| |-- swift-protocol-di-testing/ # テスト可能な Swift コードのためのプロトコルベース DI +| |-- search-first/ # コーディング前にリサーチするワークフロー +| |-- skill-stocktake/ # skills とコマンドの品質監査 +| |-- liquid-glass-design/ # iOS 26 Liquid Glass デザインシステム +| |-- foundation-models-on-device/ # FoundationModels による Apple オンデバイス LLM +| |-- swift-concurrency-6-2/ # Swift 6.2 Approachable Concurrency +| |-- mle-workflow/ # 本番 ML のデータ契約、評価、デプロイ、監視 +| |-- perl-patterns/ # モダン Perl 5.36+ のイディオムとベストプラクティス +| |-- perl-security/ # Perl セキュリティパターン、taint モード、安全な I/O +| |-- perl-testing/ # Test2::V0、prove、Devel::Cover による Perl TDD +| |-- autonomous-loops/ # 自律ループパターン:逐次パイプライン、PR ループ、DAG オーケストレーション +| |-- plankton-code-quality/ # Plankton hooks による書き込み時のコード品質強制 +| |-- codehealth-mcp/ # オプションの CodeScene Code Health MCP skill(オプトイン) +| |-- docs/examples/project-guidelines-template.md # プロジェクト固有 skills のテンプレート +| +|-- commands/ # メンテナンスされているスラッシュエントリーの互換層。skills/ を優先 +| |-- plan.md # /plan - 実装計画 +| |-- code-review.md # /code-review - 品質レビュー +| |-- build-fix.md # /build-fix - ビルドエラーの修正 +| |-- refactor-clean.md # /refactor-clean - デッドコードの削除 +| |-- quality-gate.md # /quality-gate - 検証ゲート +| |-- learn.md # /learn - セッション途中でのパターン抽出(長文ガイド) +| |-- learn-eval.md # /learn-eval - パターンの抽出、評価、保存 +| |-- checkpoint.md # /checkpoint - 検証状態の保存(長文ガイド) +| |-- setup-pm.md # /setup-pm - パッケージマネージャーの設定 +| |-- go-review.md # /go-review - Go コードレビュー +| |-- go-test.md # /go-test - Go TDD ワークフロー +| |-- go-build.md # /go-build - Go ビルドエラーの修正 +| |-- skill-create.md # /skill-create - git 履歴から skills を生成 +| |-- instinct-status.md # /instinct-status - 学習した instincts の表示 +| |-- instinct-import.md # /instinct-import - instincts のインポート +| |-- instinct-export.md # /instinct-export - instincts のエクスポート +| |-- evolve.md # /evolve - instincts をクラスタリングして skills に変換 +| |-- prune.md # /prune - 期限切れの保留中 instincts を削除 +| |-- pm2.md # /pm2 - PM2 サービスライフサイクル管理 +| |-- multi-plan.md # /multi-plan - マルチエージェントのタスク分解 +| |-- multi-execute.md # /multi-execute - オーケストレーションされたマルチエージェントワークフロー +| |-- multi-backend.md # /multi-backend - バックエンドのマルチサービスオーケストレーション +| |-- multi-frontend.md # /multi-frontend - フロントエンドのマルチサービスオーケストレーション +| |-- multi-workflow.md # /multi-workflow - 汎用マルチサービスワークフロー +| |-- sessions.md # /sessions - セッション履歴管理 +| |-- test-coverage.md # /test-coverage - テストカバレッジ分析 +| |-- update-docs.md # /update-docs - ドキュメントの更新 +| |-- update-codemaps.md # /update-codemaps - codemaps の更新 +| |-- python-review.md # /python-review - Python コードレビュー +|-- legacy-command-shims/ # /tdd や /eval などの廃止シムのオプトインアーカイブ +| |-- tdd.md # /tdd - tdd-workflow skill を推奨 +| |-- e2e.md # /e2e - e2e-testing skill を推奨 +| |-- eval.md # /eval - eval-harness skill を推奨 +| |-- verify.md # /verify - verification-loop skill を推奨 +| |-- orchestrate.md # /orchestrate - dmux-workflows または multi-workflow を推奨 +| +|-- rules/ # 常に従うガイドライン(~/.claude/rules/ecc/ にコピー) +| |-- README.md # 構成の概要とインストールガイド +| |-- common/ # 言語非依存の原則 +| | |-- coding-style.md # 不変性、ファイル構成 +| | |-- git-workflow.md # コミット形式、PR プロセス +| | |-- testing.md # TDD、80% カバレッジ要件 +| | |-- performance.md # モデル選択、コンテキスト管理 +| | |-- patterns.md # デザインパターン、スケルトンプロジェクト +| | |-- hooks.md # Hook アーキテクチャ、TodoWrite +| | |-- agents.md # サブエージェントへ委譲するタイミング +| | |-- security.md # 必須セキュリティチェック +| |-- typescript/ # TypeScript/JavaScript 固有 +| |-- python/ # Python 固有 +| |-- golang/ # Go 固有 +| |-- swift/ # Swift 固有 +| |-- php/ # PHP 固有 +| |-- arkts/ # HarmonyOS / ArkTS 固有 +| +|-- hooks/ # トリガーベースの自動化 +| |-- README.md # Hook のドキュメント、レシピ、カスタマイズガイド +| |-- hooks.json # すべての hooks 設定(PreToolUse、PostToolUse、Stop など) +| |-- memory-persistence/ # セッションライフサイクル hooks(長文ガイド) +| |-- strategic-compact/ # コンパクション提案(長文ガイド) +| +|-- scripts/ # クロスプラットフォームの Node.js スクリプト +| |-- lib/ # 共有ユーティリティ +| | |-- utils.js # クロスプラットフォームのファイル/パス/システムユーティリティ +| | |-- package-manager.js # パッケージマネージャーの検出と選択 +| |-- hooks/ # Hook の実装 +| | |-- session-start.js # セッション開始時にコンテキストを読み込む +| | |-- session-end.js # セッション終了時に状態を保存する +| | |-- pre-compact.js # コンパクション前の状態保存 +| | |-- suggest-compact.js # 戦略的コンパクション提案 +| | |-- evaluate-session.js # セッションからパターンを抽出 +| |-- setup-package-manager.js # インタラクティブなパッケージマネージャー設定 +| +|-- tests/ # テストスイート +| |-- lib/ # ライブラリテスト +| |-- hooks/ # Hook テスト +| |-- run-all.js # すべてのテストを実行 +| +|-- contexts/ # 動的システムプロンプト注入コンテキスト(長文ガイド) +| |-- dev.md # 開発モードコンテキスト +| |-- review.md # コードレビューモードコンテキスト +| |-- research.md # リサーチ/探索モードコンテキスト +| +|-- examples/ # 設定とセッションの例 +| |-- CLAUDE.md # プロジェクトレベル設定の例 +| |-- user-CLAUDE.md # ユーザーレベル設定の例 +| |-- saas-nextjs-CLAUDE.md # 実際の SaaS(Next.js + Supabase + Stripe) +| |-- go-microservice-CLAUDE.md # 実際の Go マイクロサービス(gRPC + PostgreSQL) +| |-- django-api-CLAUDE.md # 実際の Django REST API(DRF + Celery) +| |-- laravel-api-CLAUDE.md # 実際の Laravel API(PostgreSQL + Redis) +| |-- rust-api-CLAUDE.md # 実際の Rust API(Axum + SQLx + PostgreSQL) +| +|-- mcp-configs/ # MCP サーバー設定 +| |-- mcp-servers.json # GitHub、Supabase、Vercel、Railway など +| +|-- ecc_dashboard.py # デスクトップ GUI ダッシュボード(Tkinter) +| +|-- marketplace.json # セルフホストマーケットプレイス設定(/plugin marketplace add 用) +``` +
+ +
+ダッシュボード GUI + +デスクトップダッシュボードを起動して、ECC のコンポーネントを視覚的に探索できます: + +```bash +npm run dashboard +# または +python3 ./ecc_dashboard.py +``` + +**機能:** +- タブ形式のインターフェース:Agents、Skills、Commands、Rules、Settings +- ダーク/ライトテーマの切り替え +- フォントのカスタマイズ(ファミリーとサイズ) +- ヘッダーとタスクバーのプロジェクトロゴ +- すべてのコンポーネントを横断した検索とフィルター +
+ +## 主要な概念 + +
+Agents、skills、hooks、rules の解説 + +### Agents + +サブエージェントは、限定されたスコープで委譲されたタスクを処理します。例: ```markdown --- name: code-reviewer -description: コードの品質、セキュリティ、保守性をレビュー -tools: ["Read", "Grep", "Glob", "Bash"] +description: Reviews code for quality, security, and maintainability +tools: Read, Grep, Glob, Bash model: opus --- -あなたは経験豊富なコードレビュアーです... - +You are a senior code reviewer... ``` -### スキル +### Skills -スキルはコマンドまたはエージェントによって呼び出されるワークフロー定義: +Skills が主要なワークフローの入口です。直接呼び出すことも、自動的に提案されることも、agents から再利用されることもできます。ECC は移行期間中もメンテナンスされている `commands/` を引き続き同梱しており、廃止された短縮名シムは明示的なオプトイン専用として `legacy-command-shims/` に置かれています。新しいワークフローの開発は、まず `skills/` に置くべきです。 ```markdown -# TDD ワークフロー +# TDD Workflow -1. インターフェースを最初に定義 -2. テストを失敗させる (RED) -3. 最小限のコードを実装 (GREEN) -4. リファクタリング (IMPROVE) -5. 80%+ のカバレッジを確認 +1. Define interfaces first +2. Write failing tests (RED) +3. Implement minimal code (GREEN) +4. Refactor (IMPROVE) +5. Verify 80%+ coverage ``` -### フック +### Hooks -フックはツールイベントでトリガーされます。例 - console.log についての警告: +Hooks はツールイベントで発火します。例:console.log について警告する: ```json { @@ -558,25 +1090,851 @@ model: opus } ``` -### ルール +### Rules -ルールは常に従うべきガイドラインで、`common/`(言語非依存)+ 言語固有ディレクトリに組織化: +Rules は常に従うべきガイドラインで、`common/`(言語非依存)+ 言語固有のディレクトリに整理されています: ``` rules/ common/ # 普遍的な原則(常にインストール) - typescript/ # TS/JS 固有パターンとツール - python/ # Python 固有パターンとツール - golang/ # Go 固有パターンとツール + typescript/ # TS/JS 固有のパターンとツール + python/ # Python 固有のパターンとツール + golang/ # Go 固有のパターンとツール + swift/ # Swift 固有のパターンとツール + php/ # PHP 固有のパターンとツール + arkts/ # HarmonyOS / ArkTS のパターンと制約 ``` -インストールと構造の詳細は[`rules/README.md`](rules/README.md)を参照してください。 +インストール方法と構成の詳細は [`rules/README.md`](../../rules/README.md) を参照してください。 +
+## ガイド + +このリポジトリは生のコードです。ガイドがすべてを説明しています。 + + + + + + + +
+ +ECC 簡潔ガイド
+簡潔ガイド +
+
セットアップ、基礎、初日からの使い方。まずこれを読んでください。(スレッド) +
+ +ECC 長文ガイド
+長文ガイド +
+
コンテキストの経済性、メモリ、評価、並列エージェント。(スレッド) +
+ +ECC セキュリティガイド
+セキュリティガイド +
+
プロンプトインジェクション、hooks、MCP、AgentShield。(スレッド) +
+ +| トピック | 学べる内容 | +|-------|-------------------| +| トークン最適化 | モデル選択、システムプロンプトの削減、バックグラウンドプロセス | +| メモリ永続化 | セッション間でコンテキストを自動的に保存/読み込みする hooks | +| 継続的学習 | セッションからパターンを自動抽出して再利用可能な skills に変換 | +| 検証ループ | チェックポイント評価と継続的評価、グレーダーの種類、pass@k メトリクス | +| 並列化 | Git worktree、カスケード方式、インスタンスをスケールすべきタイミング | +| サブエージェントのオーケストレーション | コンテキスト問題、反復検索パターン | + +[コマンド クイックリファレンス](./COMMANDS-QUICK-REF.md) | [手動適用ガイド](../MANUAL-ADAPTATION-GUIDE.md) | [トラブルシューティング FAQ](../../TROUBLESHOOTING.md) | [ロードマップ](../ROADMAP.md) + +## なぜ ECC を選ぶのか + +| 仕組みがない場合 | ECC がある場合 | +| ------------------------------------------------------- | --------------------------------------------------------------------- | +| 計画はチャット履歴の中に消えていく | 計画は実装開始前に編集可能な成果物になる | +| 「TDD を使ってください」はモデルが忘れるかもしれない指示 | TDD は証拠付きのゲート化された RED -> GREEN -> REFACTOR ワークフローになる | +| 同じコンテキストがコードを書き、レビューもする | 新しいコンテキストのレビュアーがリグレッションと盲点を探す | +| メモリとは巨大なトランスクリプトを保存すること | セッションは要約、instincts、再利用可能な skills に蒸留される | +| 品質チェックはリマインダー頼み | hooks がプロンプトの外側で決定論的なチェックを強制できる | +| エージェント設定はデフォルトで信頼される | AgentShield がハーネス自体を攻撃対象領域としてスキャンする | + +### TDD:テスト駆動開発 + +```text +/ecc:plan "Add usage-based billing alerts" + -> confirm or edit the plan + -> activate tdd-workflow + -> capture RED evidence before implementation + -> implement until GREEN + -> review from fresh context + -> fix findings with regression tests + -> verify build, lint, types, and tests +``` + +成果物は単なるコードではありません。計画、失敗するテスト、成功するテスト、レビューでの指摘、最終検証という証拠の軌跡です。 + +### Skills がコンテキストを集中させる + +rules、skills、agents、hooks はそれぞれ異なる問題を解決します。これらの役割を分離しておくことで、ECC はリポジトリ全体をすべてのセッションに流し込むことなく能力を追加できます。 + +| 概念 | 何をするか | コンテキストでの振る舞い | +|---|---|---| +| Skills | TDD、セキュリティレビュー、ディープリサーチなどの再利用可能なワークフロー | タスクが必要とするときに読み込まれる | +| Agents | 独自のコンテキストとツール権限を持つスコープ限定のワーカー | 計画、実装、レビューを分離する | +| Rules | 永続的なプロジェクト標準や言語標準 | 常に読み込まれるため、選択的にインストールする | +| Hooks | ハーネスのイベントでトリガーされるスクリプト | モデルのコンテキスト外で実行される | +| Instincts | 実際のセッションから学習された信頼度スコア付きのパターン | 関連するときに呼び出される | + +### ハーネス間でコンテキストを共有する + +ECC の Memory Vault は、Claude、Codex、Hermes、OpenClaw、Kimi、その他のハーネスに対して、永続的なコンテキストと引き継ぎのための単一のローカルで検査可能な Markdown 形式を提供します。プロジェクトおよびチームのメモリは `.ecc/memory/` に、ユーザーのメモリは `~/.ecc/memory/` に置かれます。 + +skill のみ、minimal、manual、Claude plugin のインストールでは、Memory Vault ランタイムは `PATH` に配置されません。CLI やオプションの MCP サーバーを使う前に、npm ランタイムを別途インストールしてください: + +```bash +npm install -g ecc-universal@2.2.1 +ecc memory init --scope project +ecc memory search "authentication migration" --target-harness codex +ecc memory doctor +``` + +メモリは未レビューのコンテキストであり、実行可能なポリシーではありません。重要な主張は権威ある情報源と照合して検証し、受け入れた知識は管理されたプロジェクトドキュメントに昇格させてください。オプションの `ecc-memory-mcp` サーバーは、デフォルトでは自身を有効化することなく、同じ範囲に限定された save、search、read、doctor の機能を公開します。 + +[Unified Memory ワークフローを開く →](../../skills/unified-memory/SKILL.md) + +
+Memory Vault の詳細:スコープ、引き継ぎ、信頼境界 + +Memory Vault は、ベンダーのトランスクリプトをコピーしたりエージェント間でコンテキストをメールしたりする代わりに、移植可能な `ecc.memory.v1` Markdown ドキュメントを保存します。プロジェクトメモリはフェイルクローズドの `.gitignore` で保護されています。チームスコープは、人間が検査しバージョン管理された共有にのみ使用してください。チームメモリはコミットされた後も未レビューのコンテキストのままです。 + +上記のランタイムをインストールしたら、CLI とオプションの MCP エントリポイントが利用可能であることを確認してください: + +```bash +ecc memory --help +command -v ecc-memory-mcp +``` + +```bash +# プロジェクトの vault を初期化する。 +ecc memory init --scope project + +# 引き継ぎ本文を通常のファイルに書き、次のハーネスを指定する。 +ecc memory handoff \ + --from hermes \ + --target codex \ + --title "Continue authentication migration" \ + --body-file ./handoff.md + +# 別のハーネスから呼び出す。 +ecc memory search "authentication migration" --target-harness codex +ecc memory read + +# チームメモリを共有する前に vault を検証する。 +ecc memory doctor +``` + +メモリ本文は `--stdin` または `--body-file` 経由でのみ受け付けられ、コマンドライン引数の値としては受け付けられません。最初のリリースでは、すべての vault エントリは未レビューかつ作成のみです。人間のレビューは、メモリの信頼度を変えるのではなく、受け入れた知識を管理されたプロジェクトドキュメントに昇格させます。通常の検索による呼び出しは、アクティブなプロジェクトメモリとチームメモリを返します。ID を直接指定した読み取りでは、非アクティブなエントリを検査できます。ユーザースコープの呼び出しは明示的に要求する必要があります。エージェントは重要な主張を権威ある情報源と照合して検証しなければならず、呼び出した本文を実行可能な指示やポリシーとして扱ってはなりません。 + +オプトインの MCP アクセスには、[`mcp-configs/mcp-servers.json`](../../mcp-configs/mcp-servers.json) の `ecc-memory-vault` エントリを必要な各ハーネスに追加し、`ecc-memory-mcp` を実行してください。サーバーが公開するのは `memory_save`、`memory_search`、`memory_read`、`memory_doctor` のみです。各サーバーは小文字の `ECC_MEMORY_HARNESS` アイデンティティを指定して起動する必要があります。このアイデンティティはサーバーに束縛されており、ツール呼び出し側から指定することはできません。ユーザースコープにはさらに、オペレーターが管理する `ECC_MEMORY_ALLOW_USER_SCOPE=1` のオプトインが必要です。ワークフローと信頼境界については [`skills/unified-memory/SKILL.md`](../../skills/unified-memory/SKILL.md) を、機能契約については [`docs/design/ecc-memory-vault.md`](../design/ecc-memory-vault.md) を参照してください。 +
+ +## プラットフォームサポート + +ECC のコアとなる Node.js CLI とマネージドインストーラーは **Windows、macOS、Linux** で動作しますが、オプション機能は完全に同等ではありません。一部の継続的学習、GAN、オーケストレーションのパスは依然として Bash または Python を必要とし、ハーネスごとに公開されている hook、agent、skill の API も異なります。 + +| プラットフォーム | ステータス | 現在の制限 | +|---|---|---| +| Linux | コアをサポート | オプション機能には Bash、Python、またはプロバイダー固有のツールが必要な場合があります。 | +| macOS | コアをサポート | スタンドアロンの GAN シェルパスはシステムの Bash 3.2 と互換性がなく、現在スコア解析の不具合があります([#2674](https://github.com/affaan-m/ECC/issues/2674))。 | +| Windows + WSL | コアをサポート | WSL は Linux のパスに従います。Windows ホスト側の統合はハーネスによって異なります。 | +| Windows ネイティブ | 制限付きでサポート | 継続的学習 v2 のオブザーバーデーモンと memory-vault の書き込みには、ネイティブ Windows での未解決の不具合があります([#2489](https://github.com/affaan-m/ECC/issues/2489)、[#2626](https://github.com/affaan-m/ECC/issues/2626))。シェルに依存するオプション機能には Git Bash/WSL が必要か、利用できません。 | + +以下の `stable`、`beta`、`experimental`、`instruction-only` は、マーケティング上の等級ではなく、機能の状態を示すものとして扱ってください。 + +| ハーネス | ステータス | 推奨される配布方法 | 重要な制限 | +|---|---|---|---| +| Claude Code | Stable(主要) | Plugin または選択的インストーラー | plugin はインストール済みカタログをモデルに通知します。コンテキストの占有量が重要な場合は、選択的/manual profile を使用してください。シェルに依存するオプションの skills はすべての OS に移植可能ではありません。 | +| Codex | ネイティブ plugin をサポート | Codex マーケットプレイス plugin またはリポジトリ設定 | ネイティブ hooks には明示的な信頼の決定が必要で、Claude の hook profile は使用しません。レガシーの sync は互換性維持のみです。 | +| Cursor | Beta プロジェクトアダプター | `.cursor/` への選択的インストーラー | agent の検出は Cursor のビルドによって異なり、ECC のインストーラーパスはまだ同一の hook セットを公開していません([#2419](https://github.com/affaan-m/ECC/issues/2419))。 | +| OpenCode | Beta ビルド済み plugin | plugin をビルドしてから選択的インストーラー | ECC はカタログのサブセットを同梱しています。OpenCode でプロバイダーを接続しモデルを選択してください([#2617](https://github.com/affaan-m/ECC/issues/2617))。 | +| GitHub Copilot | Instruction-only | チェックインされた instructions とプロンプトファイル | ECC の hooks、ランタイム agents、委譲、ネイティブの skill 検出はありません。 | +| Gemini、Zed、Antigravity、Qwen、Hermes、OpenClaw、Kimi、CodeBuddy、JoyCode | Experimental/最小限のアダプター | ハーネス固有の選択的ターゲット | ファイル配置と instructions の移植性はテスト済みです。Claude との完全な機能同等性は主張していません。 | + +
+パッケージマネージャーの検出 + +plugin は、以下の優先順位でお好みのパッケージマネージャー(npm、pnpm、yarn、bun)を自動検出します: + +1. **環境変数**:`CLAUDE_PACKAGE_MANAGER` +2. **プロジェクト設定**:`.claude/package-manager.json` +3. **package.json**:`packageManager` フィールド +4. **ロックファイル**:package-lock.json、yarn.lock、pnpm-lock.yaml、bun.lockb からの検出 +5. **グローバル設定**:`~/.claude/package-manager.json` +6. **フォールバック**:最初に利用可能なパッケージマネージャー + +お好みのパッケージマネージャーを設定するには: + +```bash +# 環境変数で設定 +export CLAUDE_PACKAGE_MANAGER=pnpm + +# グローバル設定で設定 +node scripts/setup-package-manager.js --global pnpm + +# プロジェクト設定で設定 +node scripts/setup-package-manager.js --project bun + +# 現在の設定を検出 +node scripts/setup-package-manager.js --detect +``` + +または `/setup-pm` コマンドを使用してください。 +
+ +
+Hook ランタイム制御(環境変数) + +ランタイムフラグを使って厳格さを調整したり、特定の hooks を一時的に無効化したりできます: + +```bash +# Hook の厳格さ profile(デフォルト:standard) +export ECC_HOOK_PROFILE=standard + +# 無効化する hook ID をカンマ区切りで指定 +export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck" + +# SessionStart の追加コンテキストの上限(デフォルト:8000 文字) +export ECC_SESSION_START_MAX_CHARS=4000 + +# 低コンテキスト/ローカルモデル環境向けに SessionStart の追加コンテキストを完全に無効化 +export ECC_SESSION_START_CONTEXT=off + +# セッション一時ファイルの保持期間(日数、デフォルト:30)。 +# 0、off、false、disabled、never、none のいずれかを設定するとすべてのセッションを保持(削除を無効化)。 +export ECC_SESSION_RETENTION_DAYS=14 + +# SessionStart がコンテキストに注入する学習済み instincts の上限(デフォルト:6) +export ECC_MAX_INJECTED_INSTINCTS=6 + +# instinct が注入されるために必要な最小信頼度、0-1(デフォルト:0.7) +export ECC_INSTINCT_CONFIDENCE_THRESHOLD=0.7 + +# SessionStart は注入する instincts を信頼度 + プロジェクト/スタックとの関連性で +# ランク付けする(デフォルト:on)。プロジェクトスコープの instincts、および +# domain/trigger が検出されたスタック(言語、フレームワーク、加えて terraform/dbt マーカー)に +# 一致する instincts は、無関係な高信頼度のものより上に表示されるよう +# 小さなランキングブーストを受ける。off/false/0/no を設定すると信頼度のみでランク付けする。 +export ECC_INSTINCT_RELEVANCE_RANKING=on + +# コンテキスト/スコープ/ループの警告は維持しつつ、API 従量課金のコスト見積もりを抑制 +export ECC_CONTEXT_MONITOR_COST_WARNINGS=off +``` + +Windows PowerShell: + +```powershell +[Environment]::SetEnvironmentVariable('ECC_CONTEXT_MONITOR_COST_WARNINGS', 'off', 'User') +[Environment]::SetEnvironmentVariable('ECC_SESSION_RETENTION_DAYS', '14', 'User') +``` +
+ +
+Agent データホーム(マルチハーネスの分離) + +メモリ永続化 hooks(セッション要約、学習済み skills、セッションエイリアス、メトリクス)は、単一の agent データルートの下にデータを保存します。デフォルトではそのルートは `~/.claude` です。同じマシンで Claude Code と Cursor の両方で ECC を使用する場合、2つの環境が互いのセッションファイルを上書きしないように、Cursor 用に別のルートを設定してください: + +```bash +# Cursor 専用の境界(Claude Code はデフォルトの ~/.claude を維持) +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +このルートの下で解決されるパスには以下が含まれます: + +- `$ECC_AGENT_DATA_HOME/session-data/`:セッション要約 +- `$ECC_AGENT_DATA_HOME/skills/learned/`:evaluate-session による学習済み skills +- `$ECC_AGENT_DATA_HOME/session-aliases.json`:セッションエイリアス +- `$ECC_AGENT_DATA_HOME/metrics/`:コストとアクティビティのメトリクス + +[affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065) を参照してください。 +
+ +
+ツール横断の機能マップとハーネスごとの注記 + +### ツール横断の機能マップ + +| 機能 | Claude Code | Codex | Cursor | OpenCode | GitHub Copilot | +|---|---|---|---|---|---| +| Instructions | ネイティブ | ネイティブ `AGENTS.md` | プロジェクト rules | Plugin の instructions | ネイティブ instruction ファイル | +| Skills | ネイティブのインストール済みセット | ネイティブ plugin セット | ビルド依存/プロジェクトセット | ビルド済みサブセット | プロンプト/instruction からの参照のみ | +| Agents/委譲 | ネイティブ agents | Codex マルチエージェントロール。Claude の agent ファイルはロールとしてインストールされない | ビルド依存のプロジェクト agents | Plugin の agents | 非対応 | +| ECC hooks | ネイティブ plugin hooks | 明示的な信頼を伴うネイティブのレビュー済みサブセット | Cursor hook アダプター。インストールパスの差異は残る | Plugin イベント | 非対応 | +| MCP 設定 | 利用可能、明示的な有効化が必要 | ネイティブ plugin マニフェスト。レガシー sync は TOML をマージ可能 | 明示的なプロジェクト/ユーザー設定 | プロバイダー/plugin 設定 | ECC からは提供されない | +| Claude Code との同等性 | 主要リファレンス | 部分的 | 部分的 | 部分的 | 同等性の対象外 | + +**主要なアーキテクチャ上の決定:** +- ルートの **AGENTS.md** はツール横断の汎用ファイルです(Claude Code、Cursor、Codex、OpenCode が読み込みます。GitHub Copilot は代わりに `.github/copilot-instructions.md` を使用します) +- **DRY アダプターパターン**により、Cursor は Claude Code の hook スクリプトを重複なく再利用できます +- **Skills 形式**(YAML frontmatter 付きの SKILL.md)は Claude Code、Codex、OpenCode で共通に機能します +- Codex のより限定的なネイティブ hook セットは、`AGENTS.md`、オプションの `model_instructions_file` オーバーライド、サンドボックス権限によって補完されます + +
+Cursor IDE サポートの詳細 + +ECC は、Cursor のプロジェクトレイアウトに合わせて調整された hooks、rules、agents、skills、コマンド、MCP 設定による Cursor IDE サポートを提供します。 + +```bash +# macOS/Linux +./install.sh --target cursor typescript +./install.sh --target cursor python golang swift php +``` + +```powershell +# Windows PowerShell +.\install.ps1 --target cursor typescript +.\install.ps1 --target cursor python golang swift php +``` + +#### Cursor 向けに含まれるもの + +| コンポーネント | 数 | 詳細 | +|-----------|-------|---------| +| Hook イベント | 15 | sessionStart、beforeShellExecution、afterFileEdit、beforeMCPExecution、beforeSubmitPrompt、その他 10 個 | +| Hook スクリプト | 16 | 共有アダプター経由で `scripts/hooks/` に委譲する薄い Node.js スクリプト | +| Rules | 34 | 共通 9 個(alwaysApply)+ 言語固有 25 個(TypeScript、Python、Go、Swift、PHP) | +| Agents | 48 | インストール時に `.cursor/agents/ecc-*.md` として配置。ユーザーやマーケットプレイスの agents との衝突を避けるためプレフィックス付き | +| Skills | 共有 + 同梱 | 翻訳された追加分は `.cursor/skills/` に配置 | +| コマンド | 共有 | インストール時は `.cursor/commands/` | +| MCP 設定 | 共有 | インストール時は `.cursor/mcp.json` | + +#### Cursor の読み込みに関する注記 + +ECC はルートの `AGENTS.md` を `.cursor/` にインストールしません。Cursor はネストされた `AGENTS.md` ファイルをディレクトリのコンテキストとして扱うため、ECC のリポジトリのアイデンティティをホストプロジェクトにコピーすると、そのプロジェクトを汚染してしまいます。 + +Cursor ネイティブの読み込み動作は Cursor のビルドによって異なる場合があります。ECC は agents を `.cursor/agents/ecc-*.md` としてインストールします。お使いの Cursor ビルドがプロジェクト agents を公開していない場合でも、これらのファイルは隠れたグローバルプロンプトコンテキストとしてではなく、明示的なリファレンス定義として機能します。 + +#### メモリとデータの分離(Cursor + Claude Code) + +ECC のメモリ hooks は Claude Code と同じ `scripts/hooks/*.js` を再利用します。Cursor では、ECC はメモリを**自動的に `~/.claude` の外に**保つよう試みます: + +1. **Cursor の `sessionStart` hook**(`--target cursor` で `.cursor/hooks.json` にインストール)が、composer セッション全体に `ECC_AGENT_DATA_HOME` を注入します。 +2. **Hook ランタイムのデフォルト**:`CURSOR_VERSION` または `CURSOR_PROJECT_DIR` が存在する場合、環境変数が未設定なら hooks はデフォルトで `~/.cursor/ecc` を使用します。 +3. **プロジェクト設定**:`.cursor/ecc-agent-data.json` がパス(`agentDataHome`)を文書化し、上書きします。 +4. **常時有効な rule**:`.cursor/rules/ecc-agent-data-home.mdc` が、メモリの保存場所を agent に思い出させます。 + +明示的に上書きすることも引き続き可能です: + +```bash +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +意図的に Claude Code とメモリを**共有**するには、シェルまたは `.cursor/ecc-agent-data.json` で `ECC_AGENT_DATA_HOME=~/.claude` を設定してください。 + +継続的学習 v2 の instincts は、引き続き `CLV2_HOMUNCULUS_DIR`(デフォルト `~/.local/share/ecc-homunculus`)の下に別途保存されます。 + +#### Hook アーキテクチャ(DRY アダプターパターン) + +Cursor は **Claude Code より多くの hook イベント**を持っています(20 対 8)。`.cursor/hooks/adapter.js` モジュールが Cursor の stdin JSON を Claude Code の形式に変換するため、既存の `scripts/hooks/*.js` を重複なく再利用できます。 + +``` +Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js + (shared with Claude Code) +``` + +主要な hooks: +- **beforeShellExecution**:tmux 外での開発サーバー起動をブロック(exit 2)、git push のレビュー +- **afterFileEdit**:自動フォーマット + TypeScript チェック + console.log の警告 +- **beforeSubmitPrompt**:プロンプト内のシークレット(sk-、ghp_、AKIA パターン)を検出 +- **beforeTabFileRead**:Tab による .env、.key、.pem ファイルの読み取りをブロック(exit 2) +- **beforeMCPExecution / afterMCPExecution**:MCP の監査ログ + +#### Rules の形式 + +Cursor の rules は `description`、`globs`、`alwaysApply` を持つ YAML frontmatter を使用します: + +```yaml --- +description: "TypeScript coding style extending common rules" +globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"] +alwaysApply: false +--- +``` +
-## テストを実行 +
+Codex macOS アプリ + CLI サポートの詳細 -プラグインには包括的なテストスイートが含まれています: +ECC は、macOS アプリと CLI 向けに、サポート対象のネイティブ Codex マーケットプレイス plugin とリポジトリローカルの設定を提供します。ネイティブ plugin には共有 skills、MCP 設定、レビュー済みの hook サブセットが含まれ、Codex は hook の信頼をユーザーの明示的な管理下に置きます。従来の sync パスは互換性維持のみとして残っています。リポジトリのナビゲーション、各領域の所有権、PR diff パケットのガイダンスについては、[`docs/CODEX-NAVIGATION-GUIDE.md`](../CODEX-NAVIGATION-GUIDE.md) から始めてください。 + +```bash +# 現在推奨されるインストール:リポジトリのマーケットプレイスから ECC のネイティブ plugin を追加 +codex plugin marketplace add affaan-m/ECC +codex plugin add ecc@ecc +codex plugin list --json + +# またはリポジトリ内で Codex CLI を実行:AGENTS.md と .codex/ が自動検出される +codex +``` + +意図的に必要な場合は、レガシーのコピー式設定による互換性も引き続き利用できます: + +```bash +# 互換性維持のみのマネージド sync を ~/.codex に実行 +npm install && bash scripts/sync-ecc-to-codex.sh + +# またはリファレンス設定のみを手動でコピー +cp .codex/config.toml ~/.codex/config.toml +``` + +sync スクリプトは、**追加のみ**の戦略を使って ECC の MCP サーバーを既存の `~/.codex/config.toml` に安全にマージします。既存のサーバーを削除したり変更したりすることは決してありません。変更をプレビューするには `--dry-run` を、ECC サーバーを最新の推奨設定に強制的に更新するには `--update-mcp` を付けて実行してください。 + +Context7 については、ECC は正規の Codex セクション名 `[mcp_servers.context7]` を使用しつつ、引き続き `@upstash/context7-mcp` パッケージを起動します。すでにレガシーの `[mcp_servers.context7-mcp]` エントリがある場合、`--update-mcp` がそれを正規のセクション名に移行します。 + +Codex macOS アプリ: +- このリポジトリをワークスペースとして開きます。 +- ルートの `AGENTS.md` は自動検出されます。 +- `.codex/config.toml` と `.codex/agents/*.toml` はプロジェクトローカルに保つのが最適です。 +- リファレンスの `.codex/config.toml` は意図的に `model` や `model_provider` を固定していないため、上書きしない限り Codex は自身の現在のデフォルトを使用します。 +- オプション:グローバルなデフォルトとして `.codex/config.toml` を `~/.codex/config.toml` にコピーできます。`.codex/agents/` もコピーしない限り、マルチエージェントのロールファイルはプロジェクトローカルに保ってください。 + +#### リポジトリとレガシー設定レイヤーに含まれるもの + +| コンポーネント | 数 | 詳細 | +|-----------|-------|---------| +| 設定 | 1 | `.codex/config.toml`:トップレベルの approvals/sandbox/web_search、MCP サーバー、通知、profiles | +| AGENTS.md | 2 | ルート(汎用)+ `.codex/AGENTS.md`(Codex 固有の補足) | +| Skills | 32 | `.agents/skills/`:skill ごとに SKILL.md + agents/openai.yaml | +| MCP サーバー | 6 | GitHub、Context7、Exa、Memory、Playwright、Sequential Thinking(`--update-mcp` sync で Supabase を加えると 7) | +| Profiles | 2 | `strict`(読み取り専用サンドボックス)と `yolo`(完全自動承認) | +| Agent ロール | 3 | `.codex/agents/`:explorer、reviewer、docs-researcher | + +`.agents/skills/` にある skills は Codex によって自動的に読み込まれます。`claude-api`、`frontend-design`、`skill-creator` などの Anthropic 公式の skills は、意図的にここには再同梱していません。公式版が必要な場合は [`anthropics/skills`](https://github.com/anthropics/skills) からインストールしてください。 + +#### 主要な制限 + +Codex は **Claude 形式の hook 実行との同等性を提供しません**。ネイティブの ECC plugin には `/hooks` での明示的な信頼を必要とするレビュー済み hook サブセットが含まれ、`AGENTS.md`、オプションの `model_instructions_file` オーバーライド、サンドボックス/承認設定が残りの instruction とポリシーのレイヤーを提供します。 + +#### マルチエージェントサポート + +現在の Codex ビルドは安定したマルチエージェントワークフローをサポートしています。 + +- `.codex/config.toml` で `features.multi_agent = true` を有効化します +- `[agents.]` の下でロールを定義します +- 各ロールを `.codex/agents/` 配下のファイルに向けます +- CLI で `/agent` を使って子エージェントを確認・操作します + +ECC は 3 つのサンプルロール設定を同梱しています: + +| ロール | 目的 | +|------|---------| +| `explorer` | 編集前の読み取り専用のコードベース証拠収集 | +| `reviewer` | 正確性、セキュリティ、不足テストのレビュー | +| `docs_researcher` | リリース/ドキュメント変更前のドキュメントと API の検証 | + +
+ +
+Zed サポート + +ECC は、プロジェクトローカルの設定、フラット化された rules、agents、コマンド、skills のための保守的な `.zed` アダプターを通じて Zed プロジェクトをサポートします。 + +```bash +./install.sh --profile minimal --target zed +``` + +```powershell +.\install.ps1 --profile minimal --target zed +``` + +このアダプターは ECC が管理するファイルを `.zed/` の下に書き込み、BYOK/OpenRouter の認証情報をリポジトリの外に保ちます。Zed のアカウントや API キーは、Zed 自身の設定 UI またはローカルのユーザー設定から設定してください。 +
+ +
+OpenCode サポートの詳細 + +ECC は、instructions、カタログのサブセット、コマンド、カスタムツール、hook イベントを備えた beta 版の OpenCode plugin 統合を提供します。Claude Code との機能同等性は提供しません。リファレンス設定は、プロバイダー固有のモデルを固定するのではなく、ユーザーの OpenCode でのモデル選択を継承します。 + +```bash +# リポジトリのルートで、レビュー済みの OpenCode インストールを実行 +opencode +``` + +インストールには[公式の OpenCode の手順](https://opencode.ai/docs/)を使用し、正確なリリースを選択して、実行前に検証してください。上流の npm パッケージは `opencode` ではなく `opencode-ai` です。ECC は監査済みの OpenCode ランタイムバージョンを保証するものではありません。 + +設定は `.opencode/opencode.json` から自動的に検出されます。 + +#### plugins による hook サポート + +OpenCode の plugin システムには 20 種類以上のイベントタイプがあります: + +| Claude Code Hook | OpenCode Plugin イベント | +|-----------------|----------------------| +| PreToolUse | `tool.execute.before` | +| PostToolUse | `tool.execute.after` | +| Stop | `session.idle` | +| SessionStart | `session.created` | +| SessionEnd | `session.deleted` | + +**追加の OpenCode イベント**:`file.edited`、`file.watcher.updated`、`message.updated`、`lsp.client.diagnostics`、`tui.toast.show` など。 + +#### Plugin のインストール + +**オプション 1:直接使用** +```bash +cd ECC +opencode +``` + +**オプション 2:npm パッケージとしてインストール** +```bash +npm install ecc-universal@2.2.1 +``` + +次に `opencode.json` に追加します: +```json +{ + "plugin": ["ecc-universal"] +} +``` + +この npm plugin エントリは、ECC が公開している OpenCode plugin モジュール(hooks/イベントと plugin ツール)を有効化します。ECC の完全なコマンド/agent/instruction カタログをプロジェクト設定に自動的に追加することは**ありません**。 + +完全な ECC OpenCode セットアップには、次のいずれかを行ってください: +- このリポジトリ内で OpenCode を実行する +- 同梱の `.opencode/` 設定アセットをプロジェクトにコピーし、`opencode.json` に `instructions`、`agent`、`command` のエントリを配線する + +#### ドキュメント + +- **移行ガイド**:`.opencode/MIGRATION.md` +- **OpenCode Plugin README**:`.opencode/README.md` +- **統合 Rules**:`.opencode/instructions/INSTRUCTIONS.md` +- **LLM ドキュメント**:`llms.txt`(LLM 向けの完全な OpenCode ドキュメント) +
+ +
+GitHub Copilot サポートの詳細 + +ECC は、Copilot Chat のネイティブな instruction とプロンプトファイルのシステムを通じて、VS Code 向けの **GitHub Copilot サポート**を提供します。追加のツールは必要ありません。 + +#### GitHub Copilot 向けに含まれるもの + +| コンポーネント | ファイル | 目的 | +|-----------|------|---------| +| コア instructions | `.github/copilot-instructions.md` | 常時読み込まれる rules:コーディングスタイル、セキュリティ、テスト、git ワークフロー | +| VS Code 設定 | `.vscode/settings.json` | コード生成、テスト生成、コミットメッセージ向けのタスク別 instruction ファイル | +| Plan プロンプト | `.github/prompts/plan.prompt.md` | 段階的な実装計画 | +| TDD プロンプト | `.github/prompts/tdd.prompt.md` | Red-Green-Improve サイクル | +| セキュリティレビュープロンプト | `.github/prompts/security-review.prompt.md` | OWASP に沿った詳細なセキュリティ分析 | +| ビルド修正プロンプト | `.github/prompts/build-fix.prompt.md` | 体系的なビルドおよび CI エラーの解決 | +| リファクタリングプロンプト | `.github/prompts/refactor.prompt.md` | デッドコードの削除と簡素化 | + +これらのファイルはすでに配置されています。このプロジェクトを含む任意のリポジトリを開けば、GitHub Copilot Chat は自動的に `.github/copilot-instructions.md` を読み込みます。コミット済みの `.vscode/settings.json` は `chat.promptFiles` を有効化しているため、VS Code は `.github/prompts/` から再利用可能なプロンプトを読み込めます。 + +Copilot Chat でワークフロープロンプトを使用するには: +1. VS Code で Copilot Chat パネルを開きます。 +2. **クリップ / 添付**アイコンをクリックして **Prompt...** を選択するか、`/` を入力してプロンプトを選択します。 +3. プロンプト(例:`plan`、`tdd`、`security-review`)を選択します。 + +#### 機能カバレッジ + +| ECC の機能 | Copilot での相当機能 | +|-------------|-------------------| +| コーディング標準 | `copilot-instructions.md` 経由で常時有効 | +| セキュリティチェックリスト | 常時有効 + `security-review` プロンプト | +| テスト / TDD | 常時有効 + `tdd` プロンプト | +| 実装計画 | `plan` プロンプト | +| コードレビュー | CodeRabbit + Greptile による外部 PR レビュー | +| ビルドエラー解決 | `build-fix` プロンプト | +| リファクタリング | `refactor` プロンプト | +| コミットメッセージ形式 | `settings.json` のタスク別 instruction | +| Hooks / 自動化 | 非対応(Copilot には hook システムがありません) | +| Agents / 委譲 | 非対応(Copilot にはサブエージェント API がありません) | + +#### 制限 + +GitHub Copilot には hook システムもサブエージェント API もないため、ECC の hook 自動化(自動フォーマット、TypeScript チェック、セッション永続化、開発サーバーガード)と agent 委譲は利用できません。それでも instruction とプロンプトのレイヤーは、ECC のコーディング哲学(標準、セキュリティ、TDD、ワークフロー)をすべての Copilot Chat セッションにもたらします。 +
+ +
+v2.0.0 での変更点 + +ECC v2.0.0 は、公開された Hermes オペレーターストーリー、281 の skills、67 の agents、94 のコマンドシム、セッションアダプター、MCP インベントリ、worktree ライフサイクルサービス、オーケストレーターワークフロー、ECC Discord コミュニティによって 2.0 系を安定化させます。 + +- [v2.0.0 リリースノート](../releases/2.0.0/release-notes.md) +- [ECC 2.0 リファレンスアーキテクチャ](../ECC-2.0-REFERENCE-ARCHITECTURE.md) +- [Hermes セットアップガイド](../HERMES-SETUP.md) +- [1.x からの移行ガイド](../MIGRATION-1X-TO-2.0.md) +
+
+ +## トークン最適化 + +トークン消費を管理しないと、エージェントの利用は高コストになりがちです。以下の設定は、品質を犠牲にすることなくコストを大幅に削減します。完全なガイド:[docs/token-optimization.md](../token-optimization.md)。 + +
+推奨設定 + +`~/.claude/settings.json` に追加してください: + +```json +{ + "model": "sonnet", + "env": { + "MAX_THINKING_TOKENS": "10000", + "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50", + "CLAUDE_CODE_SUBAGENT_MODEL": "haiku" + } +} +``` + +| 設定 | デフォルト | 推奨 | 効果 | +|---------|---------|-------------|--------| +| `model` | opus | **sonnet** | 約 60% のコスト削減。コーディングタスクの 80% 以上に対応 | +| `MAX_THINKING_TOKENS` | 31,999 | **10,000** | リクエストごとの隠れた思考コストを約 70% 削減 | +| `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` | 95 | **50** | より早くコンパクト化し、長いセッションでの品質が向上 | +| `ECC_CONTEXT_MONITOR_COST_WARNINGS` | on | **サブスクリプション利用者は off** | コンテキスト/スコープ/ループの警告は維持しつつ、agent 向けの API 従量課金見積もり警告を抑制 | + +深いアーキテクチャの推論が必要なときだけ Opus に切り替えてください: +``` +/model opus +``` +
+ +
+日常のワークフローコマンド + +| コマンド | 使うタイミング | +|---------|-------------| +| `/model sonnet` | ほとんどのタスクのデフォルト | +| `/model opus` | 複雑なアーキテクチャ、デバッグ、深い推論 | +| `/clear` | 無関係なタスクの間(無料、即時リセット) | +| `/compact` | タスクの論理的な区切り(調査完了、マイルストーン達成) | +| `/cost` | セッション中のトークン消費を監視 | + +サブスクリプションを利用していて、コンテキストモニターの API 従量課金見積もりが役に立たない場合は、`ECC_CONTEXT_MONITOR_COST_WARNINGS=off` を設定してください。これは agent 向けのコスト警告のみを抑制するもので、コンテキスト枯渇、スコープ、ループの警告は無効化しません。 +
+ +
+戦略的コンパクト化 + +`strategic-compact` skill は、コンテキスト 95% での自動コンパクト化に頼るのではなく、論理的な区切りで `/compact` を提案します。判断ガイドの全文は `skills/strategic-compact/SKILL.md` を参照してください。 + +**コンパクト化すべきタイミング:** +- 調査/探索の後、実装の前 +- マイルストーン完了後、次に取りかかる前 +- デバッグの後、機能開発を続ける前 +- 失敗したアプローチの後、新しいアプローチを試す前 + +**コンパクト化すべきでないタイミング:** +- 実装の途中(変数名、ファイルパス、途中の状態が失われます) +
+ +
+コンテキストウィンドウの管理 + +**重要:**すべての MCP を一度に有効化しないでください。各 MCP のツール説明は 200k のウィンドウからトークンを消費し、約 70k まで減らしてしまう可能性があります。 + +- プロジェクトごとに有効化する MCP は 10 未満に抑える +- アクティブなツールは 80 未満に抑える +- 使っていない Claude Code の MCP サーバーは `/mcp` で無効化する。これらのランタイムでの選択は `~/.claude.json` に永続化される +- `ECC_DISABLED_MCPS` は、インストール/sync フロー中に ECC が生成する MCP 設定をフィルタリングする場合にのみ使用する +- コンテキストが重くなってきたら、`/context-budget` を実行して不要な rules を削除する + +**Agent teams のコスト警告:**Agent Teams は複数のコンテキストウィンドウを生成します。各チームメイトは独立してトークンを消費します。並列化が明確な価値をもたらすタスク(複数モジュールの作業、並列レビュー)にのみ使用してください。単純な逐次タスクでは、サブエージェントの方がトークン効率に優れています。 +
+ +## 要件 + +
+Claude Code CLI のバージョン + hooks の自動読み込み動作 + +### Claude Code CLI のバージョン + +**最小バージョン:v2.1.0 以降。**plugin システムの hooks の扱いが変更されたため、この plugin には Claude Code CLI v2.1.0 以降が必要です。 + +バージョンを確認してください: +```bash +claude --version +``` + +### 重要:hooks の自動読み込み動作 + +> WARNING: **コントリビューター向け:**`.claude-plugin/plugin.json` に `"hooks"` フィールドを追加しないでください。これはリグレッションテストで強制されています。 + +Claude Code v2.1 以降は、インストールされた任意の plugin の `hooks/hooks.json` を規約により**自動的に読み込みます**。`plugin.json` で明示的に宣言すると重複検出エラーが発生します: + +``` +Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file +``` + +**経緯:**この問題はこのリポジトリで修正/差し戻しのサイクルを繰り返し引き起こしてきました([#29](https://github.com/affaan-m/ECC/issues/29)、[#52](https://github.com/affaan-m/ECC/issues/52)、[#103](https://github.com/affaan-m/ECC/issues/103))。Claude Code のバージョン間で動作が変わり、混乱を招きました。現在は再発を防ぐためのリグレッションテストがあります。 +
+ +## セキュリティ + +ECC は公式ソースからのみインストールしてください: + +- GitHub リポジトリ: +- Claude Code plugin:`ecc@ecc` +- npm パッケージ:[`ecc-universal`](https://www.npmjs.com/package/ecc-universal) と [`ecc-agentshield`](https://www.npmjs.com/package/ecc-agentshield) +- GitHub App: +- Web サイト: + +すでにインストール済みのレビュー済み AgentShield バイナリでプロジェクトをスキャンします([ランナーの出所](#agentshield-runner-provenance)を参照): + +```bash +agentshield scan --path . +``` + +- **脆弱性の報告。**[SECURITY.md](../../SECURITY.md) に記載の非公開プロセス(GitHub のプライベート脆弱性報告)を使用してください。セキュリティ報告のために公開 issue を開かないでください。 +- **組み込みのガードレール。**GateGuard は破壊的なシェルコマンド(`rm`、force/path 指定の `git checkout`、破壊的な `find -exec` を含む)を実行前にゲートします。サプライチェーン IOC スキャナーは CI で実行され、AgentShield はあなた自身の agent、hook、MCP、権限、シークレットの各領域を監査します(`/security-scan`)。 + +
+Hooks、MCP サーバー、コンテキスト制御 + +hooks はシェルコマンドを実行でき、MCP サーバーは認証情報を保持でき、プロジェクトの instructions はエージェントのコンテキストに入り込めます。この 3 つすべてを実行可能な設定として扱ってください。 + +plugin インストール後に、生の `hooks/hooks.json` を `~/.claude/settings.json` にコピーしないでください。最近の Claude Code バージョンは plugin の hooks を自動的に読み込むため、2 つ目のコピーがあると二重に発火する可能性があります。 + +Claude Code のランタイムでの無効化には `/mcp` を使用してください。Claude Code はその選択を `~/.claude.json` に永続化します。 + +`ECC_DISABLED_MCPS` は ECC のインストール/sync フィルターであり、Claude Code のライブなトグルではありません。 + +コンテキストが重くなってきたら、`/context-budget` を実行し、不要な rules を削除し、使っていない MCP サーバーを無効化してください。[トークン最適化ガイド](../token-optimization.md)を参照してください。 +
+ +セキュリティ関連の参考資料: + +- [セキュリティポリシー](../../SECURITY.md) +- [セキュリティガイド](../../the-security-guide.md) +- [MCP コネクターポリシー](../MCP-CONNECTOR-POLICY.md) +- [サプライチェーンインシデント対応](../security/supply-chain-incident-response.md) + +## エコシステムツール + +
+Skill Creator:git 履歴から skills を生成する + +リポジトリから skills を生成する方法は 2 つあります: + +### オプション A:ローカル分析(組み込み) + +外部サービスを使わないローカル分析には `/skill-create` コマンドを使用してください: + +```bash +/skill-create # 現在のリポジトリを分析 +/skill-create --instincts # continuous-learning-v2 向けの instincts も生成 +``` + +これは git 履歴をローカルで分析し、SKILL.md ファイルを生成します。 + +### オプション B:GitHub App(高度) + +高度な機能(10k 以上のコミット、自動 PR、チーム共有)には: + +[ECC Tools GitHub App をインストール](https://github.com/apps/ecc-tools) | [ecc.tools](https://ecc.tools) + +```bash +# 任意の issue にコメント: +/ecc-tools analyze +``` + +どちらのオプションでも以下が作成されます: +- **SKILL.md ファイル**:アクティブなハーネスですぐに使える skills +- **Instinct コレクション**:continuous-learning-v2 向け +- **パターン抽出**:コミット履歴から学習 +
+ +
+AgentShield:エージェント設定のセキュリティ監査ツール + +> Claude Code ハッカソン(Cerebral Valley x Anthropic、2026 年 2 月)で構築。1282 のテスト、98% のカバレッジ、102 の静的解析ルール。 + +エージェント設定の脆弱性、設定ミス、インジェクションリスクをスキャンします。 + + +**ランナーの出所:**これらのコマンドには、`ecc-agentshield` からインストール済みのレビュー済み AgentShield バイナリが必要です。[公式パッケージ](https://www.npmjs.com/package/ecc-agentshield)が `agentshield` CLI を文書化しています。選択したリリース、レビューしたソース、検証済みのパッケージ整合性をインストール記録に残してください。レジストリへの公開だけでは監査済みとは言えません。ECC はここで監査済みの AgentShield のピン留めを提供しません。バージョン指定のないワンショットダウンロードで代用しないでください。`/security-scan` はワークフローのガイダンスであり、同じランナーの前提条件があります。 + +```bash +# 意図したプロジェクトディレクトリのみをスキャン +agentshield scan --path . + +# 安全な問題を自動修正 +agentshield scan --path . --fix + +# 3 つの Opus 4.6 エージェントによる詳細分析 +agentshield scan --path . --opus --stream + +# 安全な設定をゼロから生成 +agentshield init +``` + +**スキャン対象:**CLAUDE.md、settings.json、MCP 設定、hooks、agent 定義、skills を 5 つのカテゴリで検査します:シークレット検出(14 パターン)、権限監査、hook インジェクション分析、MCP サーバーのリスクプロファイリング、agent 設定レビュー。 + +**`--opus` フラグ**は、レッドチーム/ブルーチーム/監査人のパイプラインで 3 つの Claude Opus 4.6 エージェントを実行します。攻撃者がエクスプロイトチェーンを見つけ、防御者が保護を評価し、監査人が両者を統合して優先順位付きのリスク評価を作成します。単なるパターンマッチングではなく、敵対的な推論です。 + +**出力形式:**ターミナル(A-F の色付き評価)、JSON(CI パイプライン)、Markdown、HTML。ビルドゲート用に、重大な検出があると終了コード 2 を返します。 + +Claude Code で実行するには `/security-scan` を使うか、[GitHub Action](https://github.com/affaan-m/agentshield) で CI に追加してください。 + +[GitHub](https://github.com/affaan-m/agentshield) | [npm](https://www.npmjs.com/package/ecc-agentshield) +
+ +
+継続的学習 v2:instincts + +instinct ベースの学習システムは、あなたのパターンを自動的に学習します: + +```bash +/instinct-status # 学習済み instincts を信頼度とともに表示 +/instinct-import # 他の人の instincts をインポート +/instinct-export # 共有用に自分の instincts をエクスポート +/evolve # 関連する instincts を skills にクラスタリング +``` + +完全なドキュメントは `skills/continuous-learning-v2/` を参照してください。`continuous-learning/` は、レガシーの v1 Stop-hook による学習済み skill フローを明示的に使いたい場合にのみ残してください。 +
+ +## トラブルシューティング + +
+ECC が二重に表示される、または hooks が二重に発火する + +よくある原因は、Claude plugin をインストールした上に `./install.sh --profile full` を実行することです。 + +1. Claude Code plugin のインストールを削除します。 +2. ECC のチェックアウトから `node scripts/ecc.js uninstall --dry-run` を実行します。 +3. 手動でコピーした不要な rule フォルダを削除します。 +4. 1 つの方法で一度だけ再インストールします。 + +hook 固有のチェックについては、[hooks README](../../hooks/README.md) を参照してください。 +
+ +
+hooks が動作しない / "Duplicate hooks file" エラー + +**`.claude-plugin/plugin.json` に `"hooks"` フィールドを追加しないでください。**Claude Code v2.1 以降は、インストールされた plugins の `hooks/hooks.json` を自動的に読み込みます。明示的に宣言すると重複検出エラーが発生します。[#29](https://github.com/affaan-m/ECC/issues/29)、[#52](https://github.com/affaan-m/ECC/issues/52)、[#103](https://github.com/affaan-m/ECC/issues/103) を参照してください。 +
+ +
+Codex マーケットプレイスからインストールできるが skills が読み込まれない + +ECC のチェックアウトからキャッシュチェックを実行してください: + +```bash +node scripts/codex/check-plugin-cache.js +``` + +未解決の親参照が報告された場合は、`codex plugin marketplace upgrade ecc` でネイティブキャッシュを更新し、`codex plugin add ecc@ecc` を再度実行して、Codex を再起動してください。`codex plugin list` への登録はマーケットプレイスのエントリを確認するものであり、キャッシュチェックはインストール済みマニフェストがその skills、MCP 設定、アセットを解決できることを検証します。`bash scripts/sync-ecc-to-codex.sh` は、レガシーのコピー式設定による互換性パスが意図的に必要な場合にのみ使用してください。 +
+ +さらなる回答:[TROUBLESHOOTING.md](../../TROUBLESHOOTING.md) はメモリ、hooks、インストール、パフォーマンス、よくあるエラーメッセージを扱っています。[docs/TROUBLESHOOTING.md](../TROUBLESHOOTING.md) は Claude Code の未解決バグに対する回避策を追跡しています。 + +## テストの実行 + +この plugin には包括的なテストスイートが含まれています: ```bash # すべてのテストを実行 @@ -588,211 +1946,67 @@ node tests/lib/package-manager.test.js node tests/hooks/hooks.test.js ``` ---- - -## 貢献 - -**貢献は大歓迎で、奨励されています。** - -このリポジトリはコミュニティリソースを目指しています。以下のようなものがあれば: -- 有用なエージェントまたはスキル -- 巧妙なフック -- より良い MCP 設定 -- 改善されたルール - -ぜひ貢献してください!ガイドについては[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 - -### 貢献アイデア - -- 言語固有のスキル(Rust、C#、Swift、Kotlin) — Go、Python、Javaは既に含まれています -- フレームワーク固有の設定(Rails、Laravel、FastAPI) — Django、NestJS、Spring Bootは既に含まれています -- DevOpsエージェント(Kubernetes、Terraform、AWS、Docker) -- テスト戦略(異なるフレームワーク、ビジュアルリグレッション) -- 専門領域の知識(ML、データエンジニアリング、モバイル開発) - ---- - -## Cursor IDE サポート - -ecc-universal は [Cursor IDE](https://cursor.com) の事前翻訳設定を含みます。`.cursor/` ディレクトリには、Cursor フォーマット向けに適応されたルール、エージェント、スキル、コマンド、MCP 設定が含まれています。 - -### クイックスタート (Cursor) - -```bash -# パッケージをインストール -npm install ecc-universal - -# 言語をインストール -./install.sh --target cursor typescript -./install.sh --target cursor python golang -``` - -### 翻訳内容 - -| コンポーネント | Claude Code → Cursor | パリティ | -|-----------|---------------------|--------| -| Rules | YAML フロントマター追加、パスフラット化 | 完全 | -| Agents | モデル ID 展開、ツール → 読み取り専用フラグ | 完全 | -| Skills | 変更不要(同一の標準) | 同一 | -| Commands | パス参照更新、multi-* スタブ化 | 部分的 | -| MCP Config | 環境補間構文更新 | 完全 | -| Hooks | Cursor相当なし | 別の方法を参照 | - -詳細は[.cursor/README.md](.cursor/README.md)および完全な移行ガイドは[.cursor/MIGRATION.md](.cursor/MIGRATION.md)を参照してください。 - ---- - -## OpenCodeサポート - -ECCは**フルOpenCodeサポート**をプラグインとフック含めて提供。 - -### クイックスタート - -```bash -# OpenCode をインストール -npm install -g opencode - -# リポジトリルートで実行 -opencode -``` - -設定は`.opencode/opencode.json`から自動検出されます。 - -### 機能パリティ - -| 機能 | Claude Code | OpenCode | ステータス | -|---------|-------------|----------|--------| -| Agents | PASS: 14 エージェント | PASS: 12 エージェント | **Claude Code がリード** | -| Commands | PASS: 30 コマンド | PASS: 24 コマンド | **Claude Code がリード** | -| Skills | PASS: 28 スキル | PASS: 16 スキル | **Claude Code がリード** | -| Hooks | PASS: 3 フェーズ | PASS: 20+ イベント | **OpenCode が多い!** | -| Rules | PASS: 8 ルール | PASS: 8 ルール | **完全パリティ** | -| MCP Servers | PASS: 完全 | PASS: 完全 | **完全パリティ** | -| Custom Tools | PASS: フック経由 | PASS: ネイティブサポート | **OpenCode がより良い** | - -### プラグイン経由のフックサポート - -OpenCodeのプラグインシステムはClaude Codeより高度で、20+イベントタイプ: - -| Claude Code フック | OpenCode プラグインイベント | -|-----------------|----------------------| -| PreToolUse | `tool.execute.before` | -| PostToolUse | `tool.execute.after` | -| Stop | `session.idle` | -| SessionStart | `session.created` | -| SessionEnd | `session.deleted` | - -**追加OpenCodeイベント**: `file.edited`, `file.watcher.updated`, `message.updated`, `lsp.client.diagnostics`, `tui.toast.show`など。 - -### 利用可能なコマンド(24) - -| コマンド | 説明 | -|---------|-------------| -| `/plan` | 実装計画を作成 | -| `/tdd` | TDD ワークフロー実行 | -| `/code-review` | コード変更をレビュー | -| `/security` | セキュリティレビュー実行 | -| `/build-fix` | ビルドエラーを修正 | -| `/e2e` | E2E テストを生成 | -| `/refactor-clean` | デッドコードを削除 | -| `/orchestrate` | マルチエージェント ワークフロー | -| `/learn` | セッションからパターン抽出 | -| `/checkpoint` | 検証状態を保存 | -| `/verify` | 検証ループを実行 | -| `/eval` | 基準に対して評価 | -| `/update-docs` | ドキュメントを更新 | -| `/update-codemaps` | コードマップを更新 | -| `/test-coverage` | カバレッジを分析 | -| `/go-review` | Go コードレビュー | -| `/go-test` | Go TDD ワークフロー | -| `/go-build` | Go ビルドエラーを修正 | -| `/skill-create` | Git からスキル生成 | -| `/instinct-status` | 学習した直感を表示 | -| `/instinct-import` | 直感をインポート | -| `/instinct-export` | 直感をエクスポート | -| `/evolve` | 直感をスキルにクラスタリング | -| `/setup-pm` | パッケージマネージャーを設定 | - -### プラグインインストール - -**オプション1:直接使用** -```bash -cd everything-claude-code -opencode -``` - -**オプション2:npmパッケージとしてインストール** -```bash -npm install ecc-universal -``` - -その後`opencode.json`に追加: -```json -{ - "plugin": ["ecc-universal"] -} -``` - -### ドキュメンテーション - -- **移行ガイド**: `.opencode/MIGRATION.md` -- **OpenCode プラグイン README**: `.opencode/README.md` -- **統合ルール**: `.opencode/instructions/INSTRUCTIONS.md` -- **LLM ドキュメンテーション**: `llms.txt`(完全な OpenCode ドキュメント) - ---- - ## 背景 -実験的なリリース以来、Claude Codeを使用してきました。2025年9月、[@DRodriguezFX](https://x.com/DRodriguezFX)と一緒にClaude Codeで[zenith.chat](https://zenith.chat)を構築し、Anthropic x Forum Venturesハッカソンで優勝しました。 +私は実験的なロールアウトの頃から Claude Code を使ってきました。2025 年 9 月に [@DRodriguezFX](https://x.com/DRodriguezFX) とともに Anthropic x Forum Ventures ハッカソンで優勝し、[zenith.chat](https://zenith.chat) を完全にエージェント型ワークフローで構築しました。 -これらの設定は複数の本番環境アプリケーションで実戦テストされています。 +これらの設定は、複数の本番アプリケーションで実戦検証済みです。 ---- +## コミュニティとプロジェクト -## WARNING: 重要な注記 +
+スポンサーと ECC Pro -### コンテキストウィンドウ管理 +ECC が無料であり続けられるのは、スポンサーと Pro ユーザーが活動を支えてくれているからです。スポンサーのロゴはこの README の冒頭にあり、完全な一覧とティアは [SPONSORS.md](../../SPONSORS.md) にあります。 -**重要:** すべてのMCPを一度に有効にしないでください。多くのツールを有効にすると、200kのコンテキストウィンドウが70kに縮小される可能性があります。 +ECC Pro は、ホスト型 GitHub App を通じて、プライベートリポジトリの分析、PR トリガーの監査、AgentShield ベースのスキャン、自動 push および PR チェック、チームでの共有利用枠、優先サポートを追加します。 -経験則: -- 20-30のMCPを設定 -- プロジェクトごとに10未満を有効にしたままにしておく -- アクティブなツール80未満 + + + + + + + +
ECC Pro
プライベートリポジトリ向けホスト型 GitHub App
ECC をスポンサーする
OSS 活動を支援する
コミュニティ
Q&A、アイデア、Show and Tell
GitHub App
PR 監査とホスト型ワークフロー
-プロジェクト設定で`disabledMcpServers`を使用して、未使用のツールを無効にします。 +[スポンサーになる](https://github.com/sponsors/affaan-m) | [スポンサーティア](../../SPONSORS.md) | [スポンサーシッププログラム](../../SPONSORING.md) +
-### カスタマイズ +
+コントリビューション -これらの設定は私のワークフロー用です。あなたは以下を行うべきです: -1. 共感できる部分から始める -2. 技術スタックに合わせて修正 -3. 使用しない部分を削除 -4. 独自のパターンを追加 +skills、agents、rules、hooks、ドキュメント、テスト、アダプター、セキュリティ改善など、あらゆる分野でのコントリビューションを歓迎します。 ---- +- [コントリビューションガイド](../../CONTRIBUTING.md) +- [Skill 開発ガイド](../SKILL-DEVELOPMENT-GUIDE.md) +- [Skill 配置ポリシー](../SKILL-PLACEMENT-POLICY.md) +- [コマンド クイックリファレンス](./COMMANDS-QUICK-REF.md) -## Star 履歴 +要約すると: +1. リポジトリをフォークします +2. `skills/your-skill-name/SKILL.md` に skill を作成します(YAML frontmatter 付き) +3. または `agents/your-agent.md` に agent を作成します +4. 何をするものか、いつ使うのかを明確に説明した PR を送ります -[![Star History Chart](https://api.star-history.com/svg?repos=affaan-m/everything-claude-code&type=Date)](https://star-history.com/#affaan-m/everything-claude-code&Date) +**コントリビューションのアイデア:** ---- +- 言語固有の skills(Rust、C#、Kotlin、Java):Go、Python、Perl、Swift、TypeScript、HarmonyOS/ArkTS はすでに含まれています +- フレームワーク固有の設定(Rails、FastAPI):Django、NestJS、Spring Boot、Laravel はすでに含まれています +- DevOps agents(Kubernetes、Terraform、AWS、Docker) +- テスト戦略(さまざまなフレームワーク、ビジュアルリグレッション) +- ドメイン固有の知識(ML、データエンジニアリング、モバイル) +
## リンク -- **簡潔ガイド(まずはこれ):** [Everything Claude Code 簡潔ガイド](https://x.com/affaanmustafa/status/2012378465664745795) -- **詳細ガイド(高度):** [Everything Claude Code 詳細ガイド](https://x.com/affaanmustafa/status/2014040193557471352) -- **フォロー:** [@affaanmustafa](https://x.com/affaanmustafa) -- **zenith.chat:** [zenith.chat](https://zenith.chat) -- **スキル ディレクトリ:** awesome-agent-skills(コミュニティ管理のエージェントスキル ディレクトリ) - ---- +- **簡潔ガイド(まずはここから):**[ECC 簡潔ガイド](https://x.com/affaan/status/2012378465664745795) +- **長文ガイド(上級者向け):**[ECC 長文ガイド](https://x.com/affaan/status/2014040193557471352) +- **セキュリティガイド:**[セキュリティガイド](../../the-security-guide.md) | [スレッド](https://x.com/affaan/status/2033263813387223421) +- **フォロー:**[@affaan](https://x.com/affaan) ## ライセンス -MIT - 自由に使用、必要に応じて修正、可能であれば貢献してください。 +MIT。自由に使い、自分のワークフローに合わせて調整し、できるときには貢献を返してください。 ---- - -**このリポジトリが役に立ったら、Star を付けてください。両方のガイドを読んでください。素晴らしいものを構築してください。** +**役に立ったらこのリポジトリにスターを。ガイドを読んでください。素晴らしいものを作りましょう。** From 7cfc9b36081f36b8bef7a0cf92ea440acef75431 Mon Sep 17 00:00:00 2001 From: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:29:26 +0000 Subject: [PATCH 31/67] fix(gateguard): ignore heredoc prose for tee and path-qualified sinks (#2886) Expand proven-passive heredoc recognition beyond bare `cat` so documentation writes via `tee`, `/bin/cat`, and `command cat` no longer trip the destructive command detector on body text, while still failing closed for shells and pipes. --- scripts/hooks/gateguard-heredoc.js | 15 +++-- tests/hooks/gateguard-fact-force.test.js | 81 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/gateguard-heredoc.js b/scripts/hooks/gateguard-heredoc.js index 31e41b29b..79e2df50f 100644 --- a/scripts/hooks/gateguard-heredoc.js +++ b/scripts/hooks/gateguard-heredoc.js @@ -3,16 +3,23 @@ const { extractCommandSubstitutions } = require('../lib/shell-substitution'); /** - * Recognize the deliberately narrow passive sink supported by this parser. - * Shell operators and substitutions make the payload's destination ambiguous, - * so every other form retains the original input for fail-closed checks. + * Recognize proven-passive sinks whose heredoc payload is data, not a command + * stream. `cat` and `tee` (optionally path-qualified, or wrapped in + * `command`/`builtin`/`env`) only write stdin; they do not execute the body. + * Shell operators or substitution markers make the destination ambiguous, so + * every other form retains the original input for fail-closed checks. * * @param {string} line * @returns {boolean} */ function isProvenPassiveHeredocLine(line) { const trimmed = line.trim(); - return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed); + // Fail closed on control operators / grouping / command substitutions. + if (/[;&|()`]/.test(trimmed)) return false; + // Optional wrapper + optional path prefix + cat|tee, then args or redirect. + return /^(?:(?:command|builtin|env)\s+)?(?:(?:\.\/|\/(?:[\w.+-]+\/)*)?(?:cat|tee))(?=\s|[<>])/.test( + trimmed + ); } /** diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 495928691..8acde76e1 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1858,6 +1858,87 @@ function runTests() { passed++; else failed++; + if ( + test('allows #2886 migration-doc heredoc repro with DROP TABLE prose', () => { + expectAllow( + [ + "cat > migration-notes.md <<'EOF'", + "This migration will DROP TABLE old_sessions once we've verified nothing reads from it anymore.", + 'EOF' + ].join('\n'), + 'issue #2886 cat heredoc repro' + ); + }) + ) + passed++; + else failed++; + + if ( + test('allows destructive SQL prose inside a tee heredoc', () => { + expectAllow( + [ + "tee migration-notes.md <<'EOF'", + 'This migration will DROP TABLE old_sessions after verification.', + 'EOF' + ].join('\n'), + 'tee heredoc SQL prose' + ); + }) + ) + passed++; + else failed++; + + if ( + test('allows destructive rm prose inside a path-qualified cat heredoc', () => { + expectAllow( + [ + "/bin/cat > notes.md <<'EOF'", + 'Cleanup steps mention rm -rf old-cache; do not run yet.', + 'EOF' + ].join('\n'), + 'path-qualified cat heredoc prose' + ); + }) + ) + passed++; + else failed++; + + if ( + test('allows destructive prose inside a command-wrapped cat heredoc', () => { + expectAllow( + [ + "command cat > notes.md <<'EOF'", + 'Notes: DELETE FROM sessions; truncate staging.', + 'EOF' + ].join('\n'), + 'command-wrapped cat heredoc prose' + ); + }) + ) + passed++; + else failed++; + + if ( + test('still denies real destructive commands (not heredoc prose)', () => { + expectDestructiveDeny('rm -rf /tmp/real-destructive-target', 'real rm -rf'); + expectDestructiveDeny('git reset --hard', 'real git reset --hard'); + expectDestructiveDeny('drop table old_sessions', 'real drop table command text'); + }) + ) + passed++; + else failed++; + + if ( + test('fails closed when tee pipes heredoc payload into a shell', () => { + expectDestructiveDeny( + ['tee notes.md < { for (const payload of [ From 6e024eb881aabb7f6bfe553d40b84c04d7c81734 Mon Sep 17 00:00:00 2001 From: "Sharad." Date: Fri, 18 Sep 2026 00:19:40 +0530 Subject: [PATCH 32/67] docs(rules): fix ECC plugin agent location and ecc: prefix --- docs/es/rules/common/agents.md | 37 ++++++++++++++++++------------- docs/ja-JP/rules/common/agents.md | 33 +++++++++++++++------------ docs/tr/rules/common/agents.md | 35 ++++++++++++++++------------- docs/zh-CN/rules/common/agents.md | 35 ++++++++++++++++------------- rules/common/agents.md | 37 ++++++++++++++++++------------- 5 files changed, 101 insertions(+), 76 deletions(-) diff --git a/docs/es/rules/common/agents.md b/docs/es/rules/common/agents.md index 29f25b19e..8c162c58f 100644 --- a/docs/es/rules/common/agents.md +++ b/docs/es/rules/common/agents.md @@ -2,29 +2,34 @@ ## Agentes Disponibles -Ubicados en `~/.claude/agents/`: +Los agentes de ECC se distribuyen con el plugin `ecc@ecc`, no en `~/.claude/agents/`. +Se invocan a través de la herramienta Agent con un `subagent_type` con ámbito de plugin: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agente | Propósito | Cuándo Usar | |--------|-----------|-------------| -| planner | Planificación de implementación | Features complejas, refactoring | -| architect | Diseño de sistemas | Decisiones arquitectónicas | -| tdd-guide | Desarrollo guiado por pruebas | Nuevas features, corrección de bugs | -| code-reviewer | Revisión de código | Después de escribir código | -| security-reviewer | Análisis de seguridad | Antes de los commits | -| build-error-resolver | Corrección de errores de build | Cuando el build falla | -| e2e-runner | Testing E2E | Flujos de usuario críticos | -| refactor-cleaner | Limpieza de código muerto | Mantenimiento de código | -| doc-updater | Documentación | Actualización de docs | -| rust-reviewer | Revisión de código Rust | Proyectos Rust | -| harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS | +| ecc:planner | Planificación de implementación | Features complejas, refactoring | +| ecc:architect | Diseño de sistemas | Decisiones arquitectónicas | +| ecc:tdd-guide | Desarrollo guiado por pruebas | Nuevas features, corrección de bugs | +| ecc:code-reviewer | Revisión de código | Después de escribir código | +| ecc:security-reviewer | Análisis de seguridad | Antes de los commits | +| ecc:build-error-resolver | Corrección de errores de build | Cuando el build falla | +| ecc:e2e-runner | Testing E2E | Flujos de usuario críticos | +| ecc:refactor-cleaner | Limpieza de código muerto | Mantenimiento de código | +| ecc:doc-updater | Documentación | Actualización de docs | +| ecc:rust-reviewer | Revisión de código Rust | Proyectos Rust | +| ecc:harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS | + +Para el roster completo de 68 agentes, ver `/ecc:ecc-guide`. ## Uso Inmediato de Agentes Sin necesidad de prompt del usuario: -1. Solicitudes de features complejas - Usar el agente **planner** -2. Código recién escrito/modificado - Usar el agente **code-reviewer** -3. Corrección de bug o nueva feature - Usar el agente **tdd-guide** -4. Decisión arquitectónica - Usar el agente **architect** +1. Solicitudes de features complejas - Usar el agente **ecc:planner** +2. Código recién escrito/modificado - Usar el agente **ecc:code-reviewer** +3. Corrección de bug o nueva feature - Usar el agente **ecc:tdd-guide** +4. Decisión arquitectónica - Usar el agente **ecc:architect** ## Ejecución Paralela de Tareas diff --git a/docs/ja-JP/rules/common/agents.md b/docs/ja-JP/rules/common/agents.md index 92137264a..7b8082cf7 100644 --- a/docs/ja-JP/rules/common/agents.md +++ b/docs/ja-JP/rules/common/agents.md @@ -2,27 +2,32 @@ ## 利用可能な Agent -`~/.claude/agents/` に配置: +ECC の Agent は `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` には配置されません。 +Agent ツールではプラグインスコープの `subagent_type` で呼び出します: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | 目的 | 使用タイミング | |-------|---------|-------------| -| planner | 実装計画 | 複雑な機能、リファクタリング | -| architect | システム設計 | アーキテクチャの意思決定 | -| tdd-guide | テスト駆動開発 | 新機能、バグ修正 | -| code-reviewer | コードレビュー | コード記述後 | -| security-reviewer | セキュリティ分析 | コミット前 | -| build-error-resolver | ビルドエラー修正 | ビルド失敗時 | -| e2e-runner | E2Eテスト | 重要なユーザーフロー | -| refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス | -| doc-updater | ドキュメント | ドキュメント更新 | +| ecc:planner | 実装計画 | 複雑な機能、リファクタリング | +| ecc:architect | システム設計 | アーキテクチャの意思決定 | +| ecc:tdd-guide | テスト駆動開発 | 新機能、バグ修正 | +| ecc:code-reviewer | コードレビュー | コード記述後 | +| ecc:security-reviewer | セキュリティ分析 | コミット前 | +| ecc:build-error-resolver | ビルドエラー修正 | ビルド失敗時 | +| ecc:e2e-runner | E2Eテスト | 重要なユーザーフロー | +| ecc:refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス | +| ecc:doc-updater | ドキュメント | ドキュメント更新 | + +全 68 Agent の一覧は `/ecc:ecc-guide` を参照。 ## Agent の即座の使用 ユーザープロンプト不要: -1. 複雑な機能リクエスト - **planner** agent を使用 -2. コード作成/変更直後 - **code-reviewer** agent を使用 -3. バグ修正または新機能 - **tdd-guide** agent を使用 -4. アーキテクチャの意思決定 - **architect** agent を使用 +1. 複雑な機能リクエスト - **ecc:planner** agent を使用 +2. コード作成/変更直後 - **ecc:code-reviewer** agent を使用 +3. バグ修正または新機能 - **ecc:tdd-guide** agent を使用 +4. アーキテクチャの意思決定 - **ecc:architect** agent を使用 ## 並列タスク実行 diff --git a/docs/tr/rules/common/agents.md b/docs/tr/rules/common/agents.md index b40d5897b..b96f3a13a 100644 --- a/docs/tr/rules/common/agents.md +++ b/docs/tr/rules/common/agents.md @@ -2,28 +2,33 @@ ## Mevcut Agent'lar -`~/.claude/agents/` dizininde bulunur: +ECC agent'ları `ecc@ecc` eklentisiyle birlikte gelir, `~/.claude/agents/` dizininde bulunmaz. +Agent aracıyla eklenti kapsamlı bir `subagent_type` ile çağrılır: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | Amaç | Ne Zaman Kullanılır | |-------|---------|-------------| -| planner | Uygulama planlaması | Karmaşık özellikler, refactoring | -| architect | Sistem tasarımı | Mimari kararlar | -| tdd-guide | Test odaklı geliştirme | Yeni özellikler, hata düzeltmeleri | -| code-reviewer | Kod incelemesi | Kod yazdıktan sonra | -| security-reviewer | Güvenlik analizi | Commit'lerden önce | -| build-error-resolver | Build hatalarını düzeltme | Build başarısız olduğunda | -| e2e-runner | E2E testleri | Kritik kullanıcı akışları | -| refactor-cleaner | Ölü kod temizliği | Kod bakımı | -| doc-updater | Dokümantasyon | Dokümanları güncelleme | -| rust-reviewer | Rust kod incelemesi | Rust projeleri | +| ecc:planner | Uygulama planlaması | Karmaşık özellikler, refactoring | +| ecc:architect | Sistem tasarımı | Mimari kararlar | +| ecc:tdd-guide | Test odaklı geliştirme | Yeni özellikler, hata düzeltmeleri | +| ecc:code-reviewer | Kod incelemesi | Kod yazdıktan sonra | +| ecc:security-reviewer | Güvenlik analizi | Commit'lerden önce | +| ecc:build-error-resolver | Build hatalarını düzeltme | Build başarısız olduğunda | +| ecc:e2e-runner | E2E testleri | Kritik kullanıcı akışları | +| ecc:refactor-cleaner | Ölü kod temizliği | Kod bakımı | +| ecc:doc-updater | Dokümantasyon | Dokümanları güncelleme | +| ecc:rust-reviewer | Rust kod incelemesi | Rust projeleri | + +68 agent'ın tam listesi için `/ecc:ecc-guide` bölümüne bakın. ## Anlık Agent Kullanımı Kullanıcı istemi gerekmez: -1. Karmaşık özellik istekleri - **planner** agent kullan -2. Kod yeni yazıldı/değiştirildi - **code-reviewer** agent kullan -3. Hata düzeltmesi veya yeni özellik - **tdd-guide** agent kullan -4. Mimari karar - **architect** agent kullan +1. Karmaşık özellik istekleri - **ecc:planner** agent kullan +2. Kod yeni yazıldı/değiştirildi - **ecc:code-reviewer** agent kullan +3. Hata düzeltmesi veya yeni özellik - **ecc:tdd-guide** agent kullan +4. Mimari karar - **ecc:architect** agent kullan ## Paralel Görev Yürütme diff --git a/docs/zh-CN/rules/common/agents.md b/docs/zh-CN/rules/common/agents.md index de32b0b56..da79b41d6 100644 --- a/docs/zh-CN/rules/common/agents.md +++ b/docs/zh-CN/rules/common/agents.md @@ -2,29 +2,34 @@ ## 可用智能体 -位于 `~/.claude/agents/` 中: +ECC 智能体随 `ecc@ecc` 插件一起分发,不在 `~/.claude/agents/` 目录中。 +它们通过 Agent 工具以插件作用域的 `subagent_type` 调用: + + Agent(subagent_type: "ecc:planner", prompt: "...") | 代理 | 用途 | 使用时机 | |-------|---------|-------------| -| planner | 实现规划 | 复杂功能、重构 | -| architect | 系统设计 | 架构决策 | -| tdd-guide | 测试驱动开发 | 新功能、错误修复 | -| code-reviewer | 代码审查 | 编写代码后 | -| security-reviewer | 安全分析 | 提交前 | -| build-error-resolver | 修复构建错误 | 构建失败时 | -| e2e-runner | 端到端测试 | 关键用户流程 | -| refactor-cleaner | 清理死代码 | 代码维护 | -| doc-updater | 文档 | 更新文档 | -| rust-reviewer | Rust 代码审查 | Rust 项目 | +| ecc:planner | 实现规划 | 复杂功能、重构 | +| ecc:architect | 系统设计 | 架构决策 | +| ecc:tdd-guide | 测试驱动开发 | 新功能、错误修复 | +| ecc:code-reviewer | 代码审查 | 编写代码后 | +| ecc:security-reviewer | 安全分析 | 提交前 | +| ecc:build-error-resolver | 修复构建错误 | 构建失败时 | +| ecc:e2e-runner | 端到端测试 | 关键用户流程 | +| ecc:refactor-cleaner | 清理死代码 | 代码维护 | +| ecc:doc-updater | 文档 | 更新文档 | +| ecc:rust-reviewer | Rust 代码审查 | Rust 项目 | + +完整 68 个智能体的清单参见 `/ecc:ecc-guide`。 ## 即时智能体使用 无需用户提示: -1. 复杂的功能请求 - 使用 **planner** 智能体 -2. 刚编写/修改的代码 - 使用 **code-reviewer** 智能体 -3. 错误修复或新功能 - 使用 **tdd-guide** 智能体 -4. 架构决策 - 使用 **architect** 智能体 +1. 复杂的功能请求 - 使用 **ecc:planner** 智能体 +2. 刚编写/修改的代码 - 使用 **ecc:code-reviewer** 智能体 +3. 错误修复或新功能 - 使用 **ecc:tdd-guide** 智能体 +4. 架构决策 - 使用 **ecc:architect** 智能体 ## 并行任务执行 diff --git a/rules/common/agents.md b/rules/common/agents.md index 4d1dfb4cb..cb36573c6 100644 --- a/rules/common/agents.md +++ b/rules/common/agents.md @@ -2,29 +2,34 @@ ## Available Agents -Located in `~/.claude/agents/`: +ECC agents ship with the `ecc@ecc` plugin, not in `~/.claude/agents/`. +They are invoked through the Agent tool with a plugin-scoped `subagent_type`: + + Agent(subagent_type: "ecc:planner", prompt: "...") | Agent | Purpose | When to Use | |-------|---------|-------------| -| planner | Implementation planning | Complex features, refactoring | -| architect | System design | Architectural decisions | -| tdd-guide | Test-driven development | New features, bug fixes | -| code-reviewer | Code review | After writing code | -| security-reviewer | Security analysis | Before commits | -| build-error-resolver | Fix build errors | When build fails | -| e2e-runner | E2E testing | Critical user flows | -| refactor-cleaner | Dead code cleanup | Code maintenance | -| doc-updater | Documentation | Updating docs | -| rust-reviewer | Rust code review | Rust projects | -| harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects | +| ecc:planner | Implementation planning | Complex features, refactoring | +| ecc:architect | System design | Architectural decisions | +| ecc:tdd-guide | Test-driven development | New features, bug fixes | +| ecc:code-reviewer | Code review | After writing code | +| ecc:security-reviewer | Security analysis | Before commits | +| ecc:build-error-resolver | Fix build errors | When build fails | +| ecc:e2e-runner | E2E testing | Critical user flows | +| ecc:refactor-cleaner | Dead code cleanup | Code maintenance | +| ecc:doc-updater | Documentation | Updating docs | +| ecc:rust-reviewer | Rust code review | Rust projects | +| ecc:harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects | + +For the full roster of 68 agents, see `/ecc:ecc-guide`. ## Immediate Agent Usage No user prompt needed: -1. Complex feature requests - Use **planner** agent -2. Code just written/modified - Use **code-reviewer** agent -3. Bug fix or new feature - Use **tdd-guide** agent -4. Architectural decision - Use **architect** agent +1. Complex feature requests - Use **ecc:planner** agent +2. Code just written/modified - Use **ecc:code-reviewer** agent +3. Bug fix or new feature - Use **ecc:tdd-guide** agent +4. Architectural decision - Use **ecc:architect** agent ## Parallel Task Execution From fc6fe5e5df759f02a1aa1644a2786e10731e0037 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 15:06:49 -0400 Subject: [PATCH 33/67] fix(hooks): pre-push skipped every Python project that uses a virtualenv The Python block gates on `command -v pytest`, so it only runs when pytest is on PATH. Installing a project's tools into a virtualenv is the norm rather than the exception, so in practice the hook printed [ECC pre-push] Python project detected but pytest is not installed. Skipping. while standing in a directory with `.venv/bin/pytest` in it, and pushed. The failure mode is worse than not having the hook. A skip line reads like a pass: the push succeeds, the output looks healthy, and nothing indicates the gate declined to gate. A repository can sit behind it for months believing its tests run on every push. Found on a project with 893 tests, none of which the hook had ever executed. `resolve_pytest` now looks, in order, at `ECC_PYTEST_CMD`, `$VIRTUAL_ENV`, `.venv`, `venv`, `env`, `uv run` when a `uv.lock` is present, `poetry run` when a `poetry.lock` is, and finally PATH. Each candidate is confirmed by importing pytest rather than by the path existing, so a half-built venv falls through to the next one instead of failing the push. Two deliberate choices: The log line names the command it resolved -- `Running: .venv/bin/python -m pytest -q` -- so which interpreter ran is visible in the push output rather than inferred. When nothing resolves, the message says where it looked and names `ECC_PYTEST_CMD`, instead of asserting pytest is not installed when it may well be. `uv run` passes `--no-sync` so the hook cannot mutate the developer's environment on its way to running the tests. Behaviour change worth flagging for the release note: on any Python project with a working virtualenv this hook now actually runs the suite, and will block a push whose tests fail. That is the intent, but it is new behaviour for every such repository, and `ECC_SKIP_PREPUSH=1` remains the escape. Verified on two real repositories: a uv/venv Python project (resolves `.venv/bin/python -m pytest`, 893 tests, exits 0; exits 1 when the suite fails) and a Node project (unchanged, still runs lint/typecheck/test/build). --- scripts/codex-git-hooks/pre-push | 53 +++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 2ee23c7f4..472c2f194 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -117,16 +117,61 @@ if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then go test ./... || fail "go test failed" fi -if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then +# Resolve how this project runs pytest. +# +# Looking only for `pytest` on PATH meant the hook skipped every project that keeps +# its tools in a virtualenv -- which is most of them -- and reported "pytest is not +# installed" while sitting next to a .venv with pytest in it. A gate that silently +# declines to gate is worse than no gate, because the skip line reads like a pass. +# +# Echoes the command it will run, so the reason for a skip is always visible. +resolve_pytest() { + if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then + echo "$ECC_PYTEST_CMD" + return 0 + fi + local venv + for venv in "${VIRTUAL_ENV:-}" .venv venv env; do + if [[ -n "$venv" && -x "$venv/bin/python" ]]; then + if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then + echo "$venv/bin/python -m pytest" + return 0 + fi + fi + done + if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then + if uv run --no-sync python -c "import pytest" >/dev/null 2>&1; then + echo "uv run --no-sync pytest" + return 0 + fi + fi + if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then + if poetry run python -c "import pytest" >/dev/null 2>&1; then + echo "poetry run pytest" + return 0 + fi + fi if command -v pytest >/dev/null 2>&1; then + echo "pytest" + return 0 + fi + return 1 +} + +if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then + if pytest_cmd="$(resolve_pytest)"; then ran_any_check=1 - log "Python project detected. Running: pytest -q" - pytest -q || fail "pytest failed" + log "Python project detected. Running: $pytest_cmd -q" + # Unquoted on purpose: the resolver returns a command with arguments. + # shellcheck disable=SC2086 + $pytest_cmd -q || fail "pytest failed" else - log "Python project detected but pytest is not installed. Skipping." + log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv," + log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it." fi fi + if [[ "$ran_any_check" -eq 0 ]]; then log "No supported checks found in this repository. Skipping." else From 5cbe78c22b4e35e64fcd9ed40a9e6493400d8a80 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 15:57:09 -0400 Subject: [PATCH 34/67] fix(hooks): keep venv paths intact and check every pytest candidate Two holes in the resolver this branch added, both found in review. A virtualenv path may contain spaces. `resolve_pytest` returned one string and the caller expanded it unquoted, so `/home/me/my env/bin/python -m pytest` split into `/home/me/my` and `env/bin/python`. The probe that accepted the candidate was correctly quoted, so the hook reported the venv as usable and then failed to run anything in it -- rejecting the push for a reason with nothing to do with the code being pushed. It now builds an argv array and runs `"${PYTEST_CMD[@]}"`. The resolver's contract is that every candidate is confirmed to be pytest, and two of them were not. `ECC_PYTEST_CMD` was returned unchecked, so `ECC_PYTEST_CMD=true` made the hook run `true -q`, exit 0 and report a Python project verified by nothing. The PATH branch used `command -v pytest`, which proves only that a file of that name exists. Both now go through `is_pytest`, which runs `--version` and requires the output to name pytest -- `--version` alone is not evidence, since `true --version` also exits 0. A bad `ECC_PYTEST_CMD` fails the push rather than falling through to the next candidate. An operator who set it asked for that command, and silently running a different one hides the misconfiguration -- which is the same silent-gate failure this branch exists to remove, one level along. Three regression tests cover the three paths: a venv whose directory name contains a space, an override that is not pytest, and an override that is. --- scripts/codex-git-hooks/pre-push | 52 +++++++++++++---- tests/scripts/codex-hooks.test.js | 92 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 472c2f194..806d616cd 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -117,54 +117,82 @@ if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then go test ./... || fail "go test failed" fi -# Resolve how this project runs pytest. +# Resolve how this project runs pytest, into PYTEST_CMD as an argv array. # # Looking only for `pytest` on PATH meant the hook skipped every project that keeps # its tools in a virtualenv -- which is most of them -- and reported "pytest is not # installed" while sitting next to a .venv with pytest in it. A gate that silently # declines to gate is worse than no gate, because the skip line reads like a pass. # +# An array rather than one string, because a virtualenv path may contain spaces: +# a scalar command splits `/home/me/my env/bin/python` into two paths that do not +# exist, and the hook then rejects the push for a reason that has nothing to do +# with the code being pushed. +# # Echoes the command it will run, so the reason for a skip is always visible. +PYTEST_CMD=() + +# Does this command actually run pytest? Accepting `--version` is not evidence -- +# plenty of programs take it and exit 0 -- so the output has to name pytest. The +# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can +# report the SIGPIPE of the program it just matched. +is_pytest() { + local version + version="$("$@" --version 2>&1)" || return 1 + grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version" +} + resolve_pytest() { if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then - echo "$ECC_PYTEST_CMD" + # Word-split, so the override names a command on PATH or an interpreter whose + # path has no spaces; a venv with spaces in its name is found by the loop below. + read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true + # Checked like every other candidate, and fatally rather than by falling + # through: an operator who set this asked for that command, and quietly running + # a different one would hide the misconfiguration. `ECC_PYTEST_CMD=true` would + # otherwise run `true -q`, pass, and report a Python project verified by + # nothing -- the same silent gate this resolver exists to remove. + if [[ ${#PYTEST_CMD[@]} -eq 0 ]] || ! is_pytest "${PYTEST_CMD[@]}"; then + fail "ECC_PYTEST_CMD is set to '$ECC_PYTEST_CMD', which does not run pytest" + fi return 0 fi local venv for venv in "${VIRTUAL_ENV:-}" .venv venv env; do if [[ -n "$venv" && -x "$venv/bin/python" ]]; then if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then - echo "$venv/bin/python -m pytest" + PYTEST_CMD=("$venv/bin/python" -m pytest) return 0 fi fi done if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then if uv run --no-sync python -c "import pytest" >/dev/null 2>&1; then - echo "uv run --no-sync pytest" + PYTEST_CMD=(uv run --no-sync pytest) return 0 fi fi if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then if poetry run python -c "import pytest" >/dev/null 2>&1; then - echo "poetry run pytest" + PYTEST_CMD=(poetry run pytest) return 0 fi fi - if command -v pytest >/dev/null 2>&1; then - echo "pytest" + # `command -v` proves only that a file of that name exists on PATH, which is why + # this candidate is confirmed too before it is accepted. + if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then + PYTEST_CMD=(pytest) return 0 fi + PYTEST_CMD=() return 1 } if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then - if pytest_cmd="$(resolve_pytest)"; then + if resolve_pytest; then ran_any_check=1 - log "Python project detected. Running: $pytest_cmd -q" - # Unquoted on purpose: the resolver returns a command with arguments. - # shellcheck disable=SC2086 - $pytest_cmd -q || fail "pytest failed" + log "Python project detected. Running: ${PYTEST_CMD[*]} -q" + "${PYTEST_CMD[@]}" -q || fail "pytest failed" else log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv," log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it." diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 0dfe1d2f9..a6f9f3fd6 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -306,6 +306,98 @@ if ( passed++; else failed++; +function writeExecutable(filePath, body) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, body); + fs.chmodSync(filePath, 0o755); +} + +// The Python arm of the hook, exercised without a real interpreter: the stubs +// record the argv they were handed, which is what the virtualenv-path regression +// is actually about. +function runHermeticPythonPrePush({ + venvName = null, + pytestCmd = null, + overrideVersionLine = null, +} = {}) { + const tempDir = createTempDir('codex-pre-push-py-'); + const projectDir = path.join(tempDir, 'project'); + const callsPath = path.join(tempDir, 'calls.txt'); + fs.mkdirSync(projectDir); + fs.writeFileSync(path.join(projectDir, 'pyproject.toml'), '[project]\nname = "demo"\n'); + const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir }); + assert.strictEqual(initialized.status, 0, initialized.stderr?.toString()); + + const env = { + ECC_SKIP_GIT_HOOKS: '0', + ECC_SKIP_PREPUSH: '0', + MSYS_NO_PATHCONV: '1', + }; + + let venvPython = null; + if (venvName) { + venvPython = path.join(tempDir, venvName, 'bin', 'python'); + writeExecutable(venvPython, `#!/bin/sh\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + env.VIRTUAL_ENV = toBashPath(path.join(tempDir, venvName)); + } + + if (overrideVersionLine !== null) { + const stub = path.join(tempDir, 'bin', 'fake-pytest'); + writeExecutable(stub, `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${overrideVersionLine}'; exit 0; fi\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + env.ECC_PYTEST_CMD = toBashPath(stub); + } else if (pytestCmd !== null) { + env.ECC_PYTEST_CMD = pytestCmd; + } + + const result = runBash(prePushHook, { + env, + cwd: projectDir, + input: Buffer.from('refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000\n'), + }); + const calls = fs.existsSync(callsPath) + ? fs.readFileSync(callsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean) + : []; + cleanup(tempDir); + return { result, calls, venvPython }; +} + +if ( + test('pre-push runs pytest from a virtualenv whose path contains spaces', () => { + const { result, calls, venvPython } = runHermeticPythonPrePush({ venvName: 'my venv' }); + const python = toBashPath(venvPython); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, [ + `${python}|-c import pytest`, + `${python}|-m pytest -q`, + ], JSON.stringify({ calls, python, stdout: result.stdout, stderr: result.stderr }, null, 2)); + }) +) + passed++; +else failed++; + +if ( + test('pre-push rejects an ECC_PYTEST_CMD that does not run pytest', () => { + const { result, calls } = runHermeticPythonPrePush({ pytestCmd: 'true' }); + assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /ECC_PYTEST_CMD is set to 'true', which does not run pytest/); + assert.deepStrictEqual(calls, []); + assert.doesNotMatch(result.stdout, /Verification checks passed/); + }) +) + passed++; +else failed++; + +if ( + test('pre-push runs an ECC_PYTEST_CMD override that identifies itself as pytest', () => { + const { result, calls } = runHermeticPythonPrePush({ overrideVersionLine: 'pytest 8.0.0' }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.strictEqual(calls.length, 1, JSON.stringify(calls)); + assert.match(calls[0], /\|-q$/); + }) +) + passed++; +else failed++; + if ( test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => { const homeDir = createTempDir('codex-plugin-cache-home-'); From 1f5cd2af737c0b319ced41c021663da082e40e7e Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:04:41 -0400 Subject: [PATCH 35/67] test(hooks): build the pre-push python fixture env without mutation AGENTS.md makes immutability mandatory and the helper built `env` by assigning into it. Rather than reassigning a `let` through spreads, the two stub paths are now resolved before the object exists, so `env` is a single `const` built in one expression with the conditional keys spread in. Nothing to mutate and nothing to rebind. --- tests/scripts/codex-hooks.test.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index a6f9f3fd6..366f98f0c 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -328,27 +328,28 @@ function runHermeticPythonPrePush({ const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir }); assert.strictEqual(initialized.status, 0, initialized.stderr?.toString()); + const venvDir = venvName === null ? null : path.join(tempDir, venvName); + const venvPython = venvDir === null ? null : path.join(venvDir, 'bin', 'python'); + if (venvPython !== null) { + writeExecutable(venvPython, `#!/bin/sh\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + } + + const overrideStub = overrideVersionLine === null + ? null + : path.join(tempDir, 'bin', 'fake-pytest'); + if (overrideStub !== null) { + writeExecutable(overrideStub, `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${overrideVersionLine}'; exit 0; fi\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + } + + const override = overrideStub === null ? pytestCmd : toBashPath(overrideStub); const env = { ECC_SKIP_GIT_HOOKS: '0', ECC_SKIP_PREPUSH: '0', MSYS_NO_PATHCONV: '1', + ...(venvDir === null ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }), + ...(override === null ? {} : { ECC_PYTEST_CMD: override }), }; - let venvPython = null; - if (venvName) { - venvPython = path.join(tempDir, venvName, 'bin', 'python'); - writeExecutable(venvPython, `#!/bin/sh\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); - env.VIRTUAL_ENV = toBashPath(path.join(tempDir, venvName)); - } - - if (overrideVersionLine !== null) { - const stub = path.join(tempDir, 'bin', 'fake-pytest'); - writeExecutable(stub, `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${overrideVersionLine}'; exit 0; fi\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); - env.ECC_PYTEST_CMD = toBashPath(stub); - } else if (pytestCmd !== null) { - env.ECC_PYTEST_CMD = pytestCmd; - } - const result = runBash(prePushHook, { env, cwd: projectDir, From c6195edb2f99de947338292b7ef5142407fa7ffd Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:19:57 -0400 Subject: [PATCH 36/67] fix(hooks): stop probing the pytest override, and stop failing on exit 5 Three defects, found by reviewing this branch against a running pytest rather than by reading it. Exit 5 is not a failure. pytest reserves it for NO_TESTS_COLLECTED, and `|| fail "pytest failed"` collapsed it into a blocked push. The `|| fail` predates this branch, but this branch is what makes it reachable: a repository whose pyproject.toml only configures ruff or black, with pytest in its venv and no test files, used to hit the "pytest is not installed" skip and now gets gated. $VIRTUAL_ENV is the first candidate, so merely having a venv activated in the pushing shell drags any requirements.txt repository into this path, and the hook is installed globally. Reproduced with pytest 9.1.1. Exit 5 is now non-blocking but loud -- a bad rootdir, testpaths or an unimportable conftest also collects nothing, and swallowing that silently would reopen the hole this resolver exists to close. Other non-zero codes now carry the code, because 1 (tests failed) and 4 (usage error) call for different responses. The ECC_PYTEST_CMD probe ran the operator's command. Validating the override with `--version` assumed it would answer like pytest. A wrapper that sets an environment variable and execs pytest ignores the flag and runs the whole suite, so the probe executed the tests, then rejected the command for not printing a version, then blocked the push -- with the suite green. That is worse than the silent gate the probe was added to close, so the override is taken as given again: it is a deliberate setting, the hook cannot inspect it without running it, and pointing it at something that is not pytest is the operator's call. `is_pytest` still guards the PATH candidate, which this script composes itself, where `pytest --version` is harmless. An empty override still fails closed. The tests inherited the ambient environment. `runHermeticPythonPrePush` passed process.env through, so an exported ECC_PYTEST_CMD or an activated virtualenv resolved a pytest the fixture never created and the venv test failed for anyone who runs the suite that way. Both variables are now neutralised in the base env. Coverage: the gate had no test proving it blocks. Changing the run line to `|| true` left all three previous tests green. Seven now cover a spaced venv path, a red suite, exit 5, an override invoked exactly once with no probe, an empty override, and the PATH candidate in both directions. --- scripts/codex-git-hooks/pre-push | 47 +++++++++++---- tests/scripts/codex-hooks.test.js | 98 ++++++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 806d616cd..726f3916d 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -136,6 +136,11 @@ PYTEST_CMD=() # plenty of programs take it and exit 0 -- so the output has to name pytest. The # version is captured rather than piped: under `set -o pipefail` a `| grep -q` can # report the SIGPIPE of the program it just matched. +# +# Only ever called on a command this script composed itself. Probing an arbitrary +# operator-supplied command is not safe: a wrapper that ignores `--version` and +# execs pytest runs the entire suite during the probe, and is then rejected for +# not having printed a version. is_pytest() { local version version="$("$@" --version 2>&1)" || return 1 @@ -144,17 +149,15 @@ is_pytest() { resolve_pytest() { if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then - # Word-split, so the override names a command on PATH or an interpreter whose - # path has no spaces; a venv with spaces in its name is found by the loop below. + # Taken as given. This is a deliberate override, and the hook cannot inspect it + # without running it -- a wrapper script may ignore `--version` and run the + # suite, so probing costs a duplicate test run and then blocks the push anyway. + # Pointing this at something that is not pytest turns the gate off, and that is + # the operator's call to make, not a misconfiguration for the hook to second + # guess. Word-split, so the command names something on PATH or an interpreter + # whose path has no spaces; a venv with spaces is found by the loop below. read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true - # Checked like every other candidate, and fatally rather than by falling - # through: an operator who set this asked for that command, and quietly running - # a different one would hide the misconfiguration. `ECC_PYTEST_CMD=true` would - # otherwise run `true -q`, pass, and report a Python project verified by - # nothing -- the same silent gate this resolver exists to remove. - if [[ ${#PYTEST_CMD[@]} -eq 0 ]] || ! is_pytest "${PYTEST_CMD[@]}"; then - fail "ECC_PYTEST_CMD is set to '$ECC_PYTEST_CMD', which does not run pytest" - fi + [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but empty" return 0 fi local venv @@ -178,8 +181,8 @@ resolve_pytest() { return 0 fi fi - # `command -v` proves only that a file of that name exists on PATH, which is why - # this candidate is confirmed too before it is accepted. + # `command -v` proves only that a file of that name exists on PATH. This one the + # script composed itself, so confirming it costs a harmless `pytest --version`. if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then PYTEST_CMD=(pytest) return 0 @@ -192,7 +195,25 @@ if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then if resolve_pytest; then ran_any_check=1 log "Python project detected. Running: ${PYTEST_CMD[*]} -q" - "${PYTEST_CMD[@]}" -q || fail "pytest failed" + pytest_status=0 + "${PYTEST_CMD[@]}" -q || pytest_status=$? + case "$pytest_status" in + 0) ;; + # pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A + # pyproject.toml that only configures ruff or black is still a Python project + # by this hook's test, and blocking those pushes would make the gate something + # people switch off. Never silent, though: a bad rootdir, testpaths or a + # conftest that fails to import also collects nothing, and swallowing that is + # the same skip-reads-like-a-pass hole this resolver exists to close. + 5) + log "pytest collected no tests (exit 5). Not gating this push." + log " If this repository is supposed to have tests, that is the bug:" + log " check rootdir, testpaths, and conftest.py import errors." + ;; + # The code is in the message because 1 (tests failed) and 4 (usage error) + # need different responses, and "pytest failed" alone cannot tell them apart. + *) fail "pytest failed (exit $pytest_status)" ;; + esac else log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv," log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it." diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 366f98f0c..fd11fd691 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -317,8 +317,10 @@ function writeExecutable(filePath, body) { // is actually about. function runHermeticPythonPrePush({ venvName = null, + venvExit = 0, pytestCmd = null, - overrideVersionLine = null, + overrideStub = false, + pathPytestVersionLine = null, } = {}) { const tempDir = createTempDir('codex-pre-push-py-'); const projectDir = path.join(tempDir, 'project'); @@ -328,26 +330,48 @@ function runHermeticPythonPrePush({ const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir }); assert.strictEqual(initialized.status, 0, initialized.stderr?.toString()); + // Every stub records the argv it was handed. That record is the assertion: it is + // how a test tells a preserved path from a split one, and a command that was run + // once from one the hook probed first. + const record = `printf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"`; + const venvDir = venvName === null ? null : path.join(tempDir, venvName); const venvPython = venvDir === null ? null : path.join(venvDir, 'bin', 'python'); if (venvPython !== null) { - writeExecutable(venvPython, `#!/bin/sh\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + writeExecutable(venvPython, `#!/bin/sh\n${record}\nif [ "$1" = "-c" ]; then exit 0; fi\nexit ${venvExit}\n`); } - const overrideStub = overrideVersionLine === null - ? null - : path.join(tempDir, 'bin', 'fake-pytest'); - if (overrideStub !== null) { - writeExecutable(overrideStub, `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${overrideVersionLine}'; exit 0; fi\nprintf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"\nexit 0\n`); + // Deliberately does NOT special-case --version: an operator's wrapper would not + // either, and the recorded calls are what prove the hook never probed it. + const overrideStubPath = overrideStub ? path.join(tempDir, 'bin', 'wrapper') : null; + if (overrideStubPath !== null) { + writeExecutable(overrideStubPath, `#!/bin/sh\n${record}\nexit 0\n`); } - const override = overrideStub === null ? pytestCmd : toBashPath(overrideStub); + const pathBin = pathPytestVersionLine === null ? null : path.join(tempDir, 'pathbin'); + if (pathBin !== null) { + writeExecutable( + path.join(pathBin, 'pytest'), + `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${pathPytestVersionLine}'; exit 0; fi\n${record}\nexit 0\n`, + ); + } + + const override = overrideStubPath === null ? pytestCmd : toBashPath(overrideStubPath); const env = { + // The hook reads both of these from the ambient environment. Inherited, a + // developer running this suite inside an activated virtualenv, or with an + // ECC_PYTEST_CMD exported, would resolve a pytest the fixture never created, + // and these tests would pass or fail depending on whose shell ran them. + VIRTUAL_ENV: '', + ECC_PYTEST_CMD: '', ECC_SKIP_GIT_HOOKS: '0', ECC_SKIP_PREPUSH: '0', MSYS_NO_PATHCONV: '1', ...(venvDir === null ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }), ...(override === null ? {} : { ECC_PYTEST_CMD: override }), + ...(pathBin === null + ? {} + : { PATH: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}` }), }; const result = runBash(prePushHook, { @@ -359,7 +383,7 @@ function runHermeticPythonPrePush({ ? fs.readFileSync(callsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean) : []; cleanup(tempDir); - return { result, calls, venvPython }; + return { result, calls, venvPython, overrideStubPath }; } if ( @@ -377,11 +401,10 @@ if ( else failed++; if ( - test('pre-push rejects an ECC_PYTEST_CMD that does not run pytest', () => { - const { result, calls } = runHermeticPythonPrePush({ pytestCmd: 'true' }); + test('pre-push blocks the push when the resolved pytest fails', () => { + const { result } = runHermeticPythonPrePush({ venvName: 'venv-red', venvExit: 1 }); assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match(result.stderr, /ECC_PYTEST_CMD is set to 'true', which does not run pytest/); - assert.deepStrictEqual(calls, []); + assert.match(result.stderr, /pytest failed \(exit 1\)/); assert.doesNotMatch(result.stdout, /Verification checks passed/); }) ) @@ -389,8 +412,52 @@ if ( else failed++; if ( - test('pre-push runs an ECC_PYTEST_CMD override that identifies itself as pytest', () => { - const { result, calls } = runHermeticPythonPrePush({ overrideVersionLine: 'pytest 8.0.0' }); + test('pre-push does not block when pytest collected no tests (exit 5)', () => { + const { result } = runHermeticPythonPrePush({ venvName: 'venv-empty', venvExit: 5 }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /collected no tests \(exit 5\)/); + assert.match(result.stdout, /rootdir, testpaths, and conftest\.py/); + }) +) + passed++; +else failed++; + +if ( + test('pre-push runs an ECC_PYTEST_CMD override exactly once, without probing it', () => { + const { result, calls, overrideStubPath } = runHermeticPythonPrePush({ overrideStub: true }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, [`${toBashPath(overrideStubPath)}|-q`], JSON.stringify(calls)); + }) +) + passed++; +else failed++; + +if ( + test('pre-push fails closed when ECC_PYTEST_CMD is set to whitespace', () => { + const { result } = runHermeticPythonPrePush({ pytestCmd: ' ' }); + assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /ECC_PYTEST_CMD is set but empty/); + }) +) + passed++; +else failed++; + +if ( + test('pre-push rejects a PATH pytest that does not identify itself as pytest', () => { + const { result, calls } = runHermeticPythonPrePush({ + pathPytestVersionLine: 'true (GNU coreutils) 9.0', + }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /no pytest found/); + assert.deepStrictEqual(calls, []); + }) +) + passed++; +else failed++; + +if ( + test('pre-push accepts a PATH pytest that reports a pytest version', () => { + const { result, calls } = runHermeticPythonPrePush({ pathPytestVersionLine: 'pytest 8.0.0' }); assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.strictEqual(calls.length, 1, JSON.stringify(calls)); assert.match(calls[0], /\|-q$/); @@ -399,6 +466,7 @@ if ( passed++; else failed++; + if ( test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => { const homeDir = createTempDir('codex-plugin-cache-home-'); From 08b173f12f41fff38e8b5c6367b17295b30044cb Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:33:37 -0400 Subject: [PATCH 37/67] fix(hooks): a blank ECC_PYTEST_CMD is an override, and say when one is in use `[[ -n "${ECC_PYTEST_CMD:-}" ]]` asked whether the variable had a value, not whether it was set, so `ECC_PYTEST_CMD=` fell through to virtualenv discovery while `ECC_PYTEST_CMD=" "` failed the push. Two spellings of the same mistake, two behaviours. Falling through is the wrong one: an override that evaluated to nothing -- a command substitution that found no pytest, say -- then silently ran a different runner than the operator named, which is exactly the substitution this resolver refuses to make anywhere else. Both now fail closed. `${ECC_PYTEST_CMD+set}` rather than `[[ -v ECC_PYTEST_CMD ]]`, because `-v` is bash 4.2 and a stock macOS /bin/bash is 3.2, where it is not a false but a syntax error. The hook runs under whatever `env bash` resolves to. The override is still not probed -- probing runs the operator's command, and a wrapper that ignores `--version` executes the whole suite and is then rejected for not printing a version. What the gate can honestly do about a stale override is refuse to be quiet about it, so a push that uses one now says so, every time, and says the hook has not checked that it is pytest. A bypass that announces itself is not the silent gate this resolver exists to prevent. The fixture env is built from nothing instead of inheriting process.env with two keys blanked. Blanking is no longer neutral: a blanked ECC_PYTEST_CMD is now an override, and every one of these tests would have taken that branch. --- scripts/codex-git-hooks/pre-push | 23 ++++++++++++-- tests/scripts/codex-hooks.test.js | 50 ++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 726f3916d..82ed82194 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -148,7 +148,18 @@ is_pytest() { } resolve_pytest() { - if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then + # `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is + # still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave + # alike, where the first used to fall through to discovery and the second failed + # the push. Falling through is the wrong half of that pair -- an override that + # evaluated empty (a command substitution that found nothing, say) would silently + # run a different runner than the operator asked for, which is the substitution + # this resolver refuses to make anywhere else. + # + # Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash` + # is 3.2, where it is a syntax error rather than a false. This hook ships to + # whatever `env bash` finds. + if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then # Taken as given. This is a deliberate override, and the hook cannot inspect it # without running it -- a wrapper script may ignore `--version` and run the # suite, so probing costs a duplicate test run and then blocks the push anyway. @@ -157,7 +168,7 @@ resolve_pytest() { # guess. Word-split, so the command names something on PATH or an interpreter # whose path has no spaces; a venv with spaces is found by the loop below. read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true - [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but empty" + [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command" return 0 fi local venv @@ -195,6 +206,14 @@ if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then if resolve_pytest; then ran_any_check=1 log "Python project detected. Running: ${PYTEST_CMD[*]} -q" + if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then + # resolve_pytest deliberately does not verify the override is pytest, because + # probing it can run the operator's suite. What this gate can honestly do + # about a stale override is refuse to be quiet about it: a bypass announced + # on every push is not the silent gate this resolver exists to prevent. + log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does" + log " not check that it is pytest. Unset it to gate on the real suite." + fi pytest_status=0 "${PYTEST_CMD[@]}" -q || pytest_status=$? case "$pytest_status" in diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index fd11fd691..2e3cd9648 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -357,26 +357,28 @@ function runHermeticPythonPrePush({ } const override = overrideStubPath === null ? pytestCmd : toBashPath(overrideStubPath); + // Built from nothing rather than from process.env. The hook reads VIRTUAL_ENV and + // ECC_PYTEST_CMD from the ambient environment, so a developer running this suite + // inside an activated virtualenv, or with ECC_PYTEST_CMD exported, would resolve a + // pytest the fixture never created. Omitted, not blanked: now that a variable set + // to nothing is itself an override, blanking it here would make every one of these + // tests take that branch. const env = { - // The hook reads both of these from the ambient environment. Inherited, a - // developer running this suite inside an activated virtualenv, or with an - // ECC_PYTEST_CMD exported, would resolve a pytest the fixture never created, - // and these tests would pass or fail depending on whose shell ran them. - VIRTUAL_ENV: '', - ECC_PYTEST_CMD: '', + PATH: pathBin === null + ? process.env.PATH + : `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}`, + HOME: process.env.HOME ?? '', ECC_SKIP_GIT_HOOKS: '0', ECC_SKIP_PREPUSH: '0', MSYS_NO_PATHCONV: '1', ...(venvDir === null ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }), ...(override === null ? {} : { ECC_PYTEST_CMD: override }), - ...(pathBin === null - ? {} - : { PATH: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}` }), }; const result = runBash(prePushHook, { env, cwd: projectDir, + preservePath: false, input: Buffer.from('refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000\n'), }); const calls = fs.existsSync(callsPath) @@ -427,20 +429,32 @@ if ( const { result, calls, overrideStubPath } = runHermeticPythonPrePush({ overrideStub: true }); assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepStrictEqual(calls, [`${toBashPath(overrideStubPath)}|-q`], JSON.stringify(calls)); + // The override is not verified to be pytest, so it must at least be loud. + assert.match(result.stdout, /via ECC_PYTEST_CMD/); + assert.match(result.stdout, /does\n?.*not check that it is pytest/s); }) ) passed++; else failed++; -if ( - test('pre-push fails closed when ECC_PYTEST_CMD is set to whitespace', () => { - const { result } = runHermeticPythonPrePush({ pytestCmd: ' ' }); - assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match(result.stderr, /ECC_PYTEST_CMD is set but empty/); - }) -) - passed++; -else failed++; +// Both blank forms, because they used to disagree: an unquoted empty value fell +// through to discovery while whitespace failed the push. A venv is present so a +// fall-through would be visible as a pass rather than as an absence. +for (const [label, blank] of [['empty', ''], ['whitespace', ' ']]) { + if ( + test(`pre-push fails closed when ECC_PYTEST_CMD is set to ${label}`, () => { + const { result, calls } = runHermeticPythonPrePush({ + venvName: 'venv-blank', + pytestCmd: blank, + }); + assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /ECC_PYTEST_CMD is set but names no command/); + assert.deepStrictEqual(calls, [], JSON.stringify(calls)); + }) + ) + passed++; + else failed++; +} if ( test('pre-push rejects a PATH pytest that does not identify itself as pytest', () => { From 9cdc40e6d1aadc4f6833a59a235acf46d6212ad4 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:44:33 -0400 Subject: [PATCH 38/67] fix(hooks): do not run a virtualenv interpreter the repository ships This branch taught the hook to run `.venv/bin/python`, and that is a binary the repository can supply. On main the Python arm only ever ran `pytest` from PATH -- the developer's own -- and on a machine without one it ran nothing at all, which is exactly the machine this branch was written for. So the exposure is new, and it arrived with the fix. The hook is installed globally through core.hooksPath. Cloning a hostile repository, committing nothing, and pushing it to your own fork is enough: the pre-push hook finds the committed `.venv/bin/python`, runs it once to probe for pytest and again to run the suite. Reproduced -- the planted executable logged two invocations under the previous commit and none under this one. A virtualenv is never committed. It is platform-specific binaries and every Python project gitignores it, so `git ls-files --error-unmatch` separates the two cases exactly: a developer's own venv is untracked and still resolves, a tracked one is skipped with the reason printed. An absolute $VIRTUAL_ENV outside the worktree reads as untracked, as it should. Not addressed here, and worth a maintainer's view: `uv run` and `poetry run` resolve from the repository's own lockfile, so they carry the same shape of trust in a form this check cannot see. They are gated behind a lockfile being present, and changing their semantics is a larger decision than this fix. --- scripts/codex-git-hooks/pre-push | 14 ++++++++++++++ tests/scripts/codex-hooks.test.js | 24 ++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 82ed82194..6a3d46152 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -174,6 +174,20 @@ resolve_pytest() { local venv for venv in "${VIRTUAL_ENV:-}" .venv venv env; do if [[ -n "$venv" && -x "$venv/bin/python" ]]; then + # A virtualenv is never committed -- it is platform-specific binaries, and + # every Python project gitignores it. One that IS tracked is the repository + # handing this hook an executable and asking it to run. The hook is installed + # globally, so cloning a hostile repository and pushing it to your own fork + # would be enough, and on a machine with no pytest on PATH this arm is the + # only thing that would run at all. Skipping costs nothing legitimate, + # because a developer's own venv is untracked -- and it says why rather than + # going quiet about it. + if git ls-files --error-unmatch -- "$venv/bin/python" >/dev/null 2>&1; then + log "Ignoring $venv/bin/python: it is tracked in this repository." + log " A committed virtualenv is an executable the repository controls, and" + log " this hook runs on every push in every repository." + continue + fi if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=("$venv/bin/python" -m pytest) return 0 diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 2e3cd9648..42bdbda68 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -318,6 +318,7 @@ function writeExecutable(filePath, body) { function runHermeticPythonPrePush({ venvName = null, venvExit = 0, + trackVenv = false, pytestCmd = null, overrideStub = false, pathPytestVersionLine = null, @@ -335,10 +336,18 @@ function runHermeticPythonPrePush({ // once from one the hook probed first. const record = `printf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"`; - const venvDir = venvName === null ? null : path.join(tempDir, venvName); + // A tracked venv has to live inside the repository to be trackable at all, and is + // found by directory-name discovery rather than by VIRTUAL_ENV. + const venvDir = venvName === null ? null : path.join(trackVenv ? projectDir : tempDir, venvName); const venvPython = venvDir === null ? null : path.join(venvDir, 'bin', 'python'); if (venvPython !== null) { writeExecutable(venvPython, `#!/bin/sh\n${record}\nif [ "$1" = "-c" ]; then exit 0; fi\nexit ${venvExit}\n`); + if (trackVenv) { + // Staged, not committed: `git ls-files` reads the index, so this is enough to + // make the file repository-controlled without needing a committer identity. + const added = spawnSync('git', ['add', '-f', '--', venvPython], { cwd: projectDir }); + assert.strictEqual(added.status, 0, added.stderr?.toString()); + } } // Deliberately does NOT special-case --version: an operator's wrapper would not @@ -371,7 +380,7 @@ function runHermeticPythonPrePush({ ECC_SKIP_GIT_HOOKS: '0', ECC_SKIP_PREPUSH: '0', MSYS_NO_PATHCONV: '1', - ...(venvDir === null ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }), + ...(venvDir === null || trackVenv ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }), ...(override === null ? {} : { ECC_PYTEST_CMD: override }), }; @@ -402,6 +411,17 @@ if ( passed++; else failed++; +if ( + test('pre-push refuses to run a virtualenv python that the repository tracks', () => { + const { result, calls } = runHermeticPythonPrePush({ venvName: '.venv', trackVenv: true }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, [], JSON.stringify(calls)); + assert.match(result.stdout, /it is tracked in this repository/); + }) +) + passed++; +else failed++; + if ( test('pre-push blocks the push when the resolved pytest fails', () => { const { result } = runHermeticPythonPrePush({ venvName: 'venv-red', venvExit: 1 }); From ca1a5ad8bc612979059b4ad88d9db5030e778de9 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:51:43 -0400 Subject: [PATCH 39/67] fix(hooks): name the remedy in the blank-override failure The hook is global and this message blocks a push, so "ECC_PYTEST_CMD is set but names no command" left the operator holding a refusal with no next step. It now says to point the variable at a runner or unset it to fall back to discovery, which is the same advice the no-pytest-found branch already gives from the other direction. --- scripts/codex-git-hooks/pre-push | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 6a3d46152..6f04c2680 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -168,7 +168,8 @@ resolve_pytest() { # guess. Word-split, so the command names something on PATH or an interpreter # whose path has no spaces; a venv with spaces is found by the loop below. read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true - [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command" + [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command.\ + Point it at your test runner, or unset it to fall back to discovery." return 0 fi local venv From 3c317470163b62345935125aecdeb037cba88dd8 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 16:55:11 -0400 Subject: [PATCH 40/67] fix(hooks): resolve the venv path before asking git whether it is tracked The guard added in 9cdc40e6 was incomplete. `git ls-files` reports paths as they are indexed and does not follow symlinks, so a repository that commits `.venv` as a symlink to its own root alongside a tracked `bin/python` gets asked about `.venv/bin/python` -- a path git has never heard of -- and the answer is "untracked". The interpreter then runs. Measured on that shape: the planted executable logged two invocations against 9cdc40e6 and none against this commit. `repo_ships_interpreter` now resolves the bin directory with `cd -P`/`pwd -P`, resolves the worktree root the same way, and asks git about the resolved path relative to it. The three cases that matter all hold: a plainly committed venv is still refused, the symlink shape is now refused, and a developer's own untracked venv still resolves and runs. `cd -P`/`pwd -P` rather than `realpath` or `readlink -f`, because neither is portable to a stock macOS. --- scripts/codex-git-hooks/pre-push | 37 ++++++++++++++++++++++--------- tests/scripts/codex-hooks.test.js | 24 +++++++++++++++++++- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index 6f04c2680..dfeeab4fd 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -147,6 +147,31 @@ is_pytest() { grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version" } +# Does the repository itself ship this interpreter? +# +# A virtualenv is never committed -- it is platform-specific binaries, and every +# Python project gitignores it. One that IS tracked is the repository handing this +# hook an executable and asking it to run. The hook is installed globally, so +# cloning a hostile repository and pushing it to your own fork would be enough, +# and on a machine with no pytest on PATH this arm is the only thing that would +# run at all. A developer's own venv is untracked, so nothing legitimate is lost. +# +# The path is resolved through symlinks before git is asked, because `git ls-files` +# reports paths as indexed and does not follow links. A repository that commits +# `.venv` as a symlink to `.` next to a tracked `bin/python` would otherwise be +# queried for `.venv/bin/python`, a path git has never heard of, and the answer +# would be "untracked". Measured: that shape ran the planted binary twice. +repo_ships_interpreter() { + local bindir real top + bindir="$(cd -P -- "$1" 2>/dev/null && pwd -P)" || return 1 + [[ -n "$bindir" ]] || return 1 + real="$bindir/python" + top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 + top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1 + [[ -n "$top" && "$real" == "$top/"* ]] || return 1 + git ls-files --error-unmatch -- "${real#"$top"/}" >/dev/null 2>&1 +} + resolve_pytest() { # `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is # still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave @@ -175,16 +200,8 @@ resolve_pytest() { local venv for venv in "${VIRTUAL_ENV:-}" .venv venv env; do if [[ -n "$venv" && -x "$venv/bin/python" ]]; then - # A virtualenv is never committed -- it is platform-specific binaries, and - # every Python project gitignores it. One that IS tracked is the repository - # handing this hook an executable and asking it to run. The hook is installed - # globally, so cloning a hostile repository and pushing it to your own fork - # would be enough, and on a machine with no pytest on PATH this arm is the - # only thing that would run at all. Skipping costs nothing legitimate, - # because a developer's own venv is untracked -- and it says why rather than - # going quiet about it. - if git ls-files --error-unmatch -- "$venv/bin/python" >/dev/null 2>&1; then - log "Ignoring $venv/bin/python: it is tracked in this repository." + if repo_ships_interpreter "$venv/bin"; then + log "Ignoring $venv/bin/python: the repository ships it." log " A committed virtualenv is an executable the repository controls, and" log " this hook runs on every push in every repository." continue diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 42bdbda68..11f8d0115 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -319,6 +319,7 @@ function runHermeticPythonPrePush({ venvName = null, venvExit = 0, trackVenv = false, + trackedSymlinkVenv = false, pytestCmd = null, overrideStub = false, pathPytestVersionLine = null, @@ -350,6 +351,16 @@ function runHermeticPythonPrePush({ } } + // The shape that defeats a naive `git ls-files -- .venv/bin/python` check: the + // repository commits `.venv` as a symlink to its own root plus a tracked + // `bin/python`, so git is asked about a path it has never indexed. + if (trackedSymlinkVenv) { + writeExecutable(path.join(projectDir, 'bin', 'python'), `#!/bin/sh\n${record}\nexit 0\n`); + fs.symlinkSync('.', path.join(projectDir, '.venv')); + const added = spawnSync('git', ['add', '-f', '--', 'bin/python', '.venv'], { cwd: projectDir }); + assert.strictEqual(added.status, 0, added.stderr?.toString()); + } + // Deliberately does NOT special-case --version: an operator's wrapper would not // either, and the recorded calls are what prove the hook never probed it. const overrideStubPath = overrideStub ? path.join(tempDir, 'bin', 'wrapper') : null; @@ -416,7 +427,18 @@ if ( const { result, calls } = runHermeticPythonPrePush({ venvName: '.venv', trackVenv: true }); assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepStrictEqual(calls, [], JSON.stringify(calls)); - assert.match(result.stdout, /it is tracked in this repository/); + assert.match(result.stdout, /the repository ships it/); + }) +) + passed++; +else failed++; + +if ( + test('pre-push refuses a tracked interpreter reached through a committed symlink', () => { + const { result, calls } = runHermeticPythonPrePush({ trackedSymlinkVenv: true }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, [], JSON.stringify(calls)); + assert.match(result.stdout, /the repository ships it/); }) ) passed++; From 4869db30c45c983000d7e4beff5853af3468dd53 Mon Sep 17 00:00:00 2001 From: Juan Garibay Date: Thu, 17 Sep 2026 17:17:19 -0400 Subject: [PATCH 41/67] fix(hooks): match the index case-insensitively, and isolate the pytest probe Red-teaming the guard from 3c317470 found two more ways to get a repository's own code executed. Both are demonstrated by a planted binary that appends to a witness file, counted before and after. Case folding. git matches index pathspecs case-sensitively even where core.ignorecase is set, but APFS does not -- so a repository that commits `.venv/bin/Python` gets `$venv/bin/python` opening and running that file while the guard's lowercase query finds nothing in the index and reports it untracked. The witness logged two invocations. It applies to `venv` and `env` as well, and to any folding of the name. The query now uses a `:(icase)` pathspec; all nine directory-by-spelling combinations are refused, and an untracked venv still runs. Module shadowing. `python -c "import pytest"` puts the working directory first on sys.path, so a repository that commits a `pytest.py` in its root has that file imported, and executed, by a check whose only job is to answer whether pytest is installed. The probe is now `python -I -c "import pytest"` on the virtualenv, uv and poetry paths alike. Isolation does not hide a real pytest -- it lives in the interpreter's own site-packages, confirmed against a venv holding pytest 9.1.1. Still true, and not something this hook can fix: running the repository's declared suite runs the repository's code. `pytest` imports conftest.py, and the Node arm runs package.json scripts. That is what a pre-push verification hook is for. The line this guard draws is narrower and worth keeping -- a capability probe, and the choice of which interpreter to trust, should not be things the pushed repository gets to decide. --- scripts/codex-git-hooks/pre-push | 19 +++++++++++++++---- tests/scripts/codex-hooks.test.js | 29 ++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/scripts/codex-git-hooks/pre-push b/scripts/codex-git-hooks/pre-push index dfeeab4fd..ff66512a1 100755 --- a/scripts/codex-git-hooks/pre-push +++ b/scripts/codex-git-hooks/pre-push @@ -169,9 +169,20 @@ repo_ships_interpreter() { top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1 [[ -n "$top" && "$real" == "$top/"* ]] || return 1 - git ls-files --error-unmatch -- "${real#"$top"/}" >/dev/null 2>&1 + # `:(icase)` because git matches index pathspecs case-sensitively even where + # core.ignorecase is set, while the filesystem underneath does not. On macOS's + # APFS -- the platform this hook most often runs on -- a committed + # `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a + # case-sensitive query for the lowercase name finds nothing in the index and the + # guard waves it through. Measured: that spelling ran the planted binary twice. + git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1 } +# `-I` isolates the probe: without it Python puts the working directory first on +# sys.path, so a repository that commits a `pytest.py` in its root gets that file +# imported -- and executed -- by a check whose only job is to answer whether pytest +# exists. Measured: a committed pytest.py ran during the probe. Isolation does not +# hide a real pytest, which lives in the interpreter's own site-packages. resolve_pytest() { # `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is # still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave @@ -206,20 +217,20 @@ resolve_pytest() { log " this hook runs on every push in every repository." continue fi - if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then + if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=("$venv/bin/python" -m pytest) return 0 fi fi done if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then - if uv run --no-sync python -c "import pytest" >/dev/null 2>&1; then + if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=(uv run --no-sync pytest) return 0 fi fi if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then - if poetry run python -c "import pytest" >/dev/null 2>&1; then + if poetry run python -I -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=(poetry run pytest) return 0 fi diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 11f8d0115..1171de52c 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -319,6 +319,7 @@ function runHermeticPythonPrePush({ venvName = null, venvExit = 0, trackVenv = false, + trackedVenvBasename = 'python', trackedSymlinkVenv = false, pytestCmd = null, overrideStub = false, @@ -340,9 +341,11 @@ function runHermeticPythonPrePush({ // A tracked venv has to live inside the repository to be trackable at all, and is // found by directory-name discovery rather than by VIRTUAL_ENV. const venvDir = venvName === null ? null : path.join(trackVenv ? projectDir : tempDir, venvName); - const venvPython = venvDir === null ? null : path.join(venvDir, 'bin', 'python'); + const venvPython = venvDir === null + ? null + : path.join(venvDir, 'bin', trackVenv ? trackedVenvBasename : 'python'); if (venvPython !== null) { - writeExecutable(venvPython, `#!/bin/sh\n${record}\nif [ "$1" = "-c" ]; then exit 0; fi\nexit ${venvExit}\n`); + writeExecutable(venvPython, `#!/bin/sh\n${record}\ncase " $* " in *" -c "*) exit 0 ;; esac\nexit ${venvExit}\n`); if (trackVenv) { // Staged, not committed: `git ls-files` reads the index, so this is enough to // make the file repository-controlled without needing a committer identity. @@ -414,7 +417,7 @@ if ( const python = toBashPath(venvPython); assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.deepStrictEqual(calls, [ - `${python}|-c import pytest`, + `${python}|-I -c import pytest`, `${python}|-m pytest -q`, ], JSON.stringify({ calls, python, stdout: result.stdout, stderr: result.stderr }, null, 2)); }) @@ -433,6 +436,26 @@ if ( passed++; else failed++; +// A case-folded spelling, because macOS resolves `$venv/bin/python` to a committed +// `Python` while git matches index pathspecs case-sensitively. Skipped where the +// filesystem is case-sensitive and the two names cannot collide. +if (fs.existsSync(__filename.toUpperCase()) || fs.existsSync(__filename.toLowerCase())) { + if ( + test('pre-push refuses a tracked interpreter committed under a folded case', () => { + const { result, calls } = runHermeticPythonPrePush({ + venvName: '.venv', + trackVenv: true, + trackedVenvBasename: 'Python', + }); + assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepStrictEqual(calls, [], JSON.stringify(calls)); + assert.match(result.stdout, /the repository ships it/); + }) + ) + passed++; + else failed++; +} + if ( test('pre-push refuses a tracked interpreter reached through a committed symlink', () => { const { result, calls } = runHermeticPythonPrePush({ trackedSymlinkVenv: true }); From ab66f51a290d9b67287b0b9bdcb71455aeb0f481 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 17:04:08 -0400 Subject: [PATCH 42/67] docs: repair README badges and live star history --- README.md | 8 ++++---- assets/star-history-dark.svg | 30 ------------------------------ assets/star-history-light.svg | 30 ------------------------------ docs/de-DE/README.md | 4 ++-- docs/es/README.md | 6 +++--- docs/uk-UA/README.md | 4 ++-- docs/ur/README.md | 4 ++-- 7 files changed, 13 insertions(+), 73 deletions(-) delete mode 100644 assets/star-history-dark.svg delete mode 100644 assets/star-history-light.svg diff --git a/README.md b/README.md index 76ecca40e..69a81276b 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@

- Stars - Forks + GitHub stars + GitHub forks Contributors GitHub App installs

@@ -152,8 +152,8 @@ Access to 68 agents, 292 skills, and 94 legacy command shims, plus hooks, rules,

- - ECC star history: first 40,000 stars, January 18 to February 7, 2026 + + Live star history chart for affaan-m/ECC

diff --git a/assets/star-history-dark.svg b/assets/star-history-dark.svg deleted file mode 100644 index 3841e561d..000000000 --- a/assets/star-history-dark.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - -0 - -10k - -20k - -30k - -40k - -50k - -Jan 18 - -Jan 23 - -Jan 28 - -Feb 2 - -Feb 7 - - - -affaan-m/ECC · first 40,000 stars -Jan 18, 2026 – Feb 7, 2026 · source: GitHub stargazers API - \ No newline at end of file diff --git a/assets/star-history-light.svg b/assets/star-history-light.svg deleted file mode 100644 index 772d15207..000000000 --- a/assets/star-history-light.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - -0 - -10k - -20k - -30k - -40k - -50k - -Jan 18 - -Jan 23 - -Jan 28 - -Feb 2 - -Feb 7 - - - -affaan-m/ECC · first 40,000 stars -Jan 18, 2026 – Feb 7, 2026 · source: GitHub stargazers API - \ No newline at end of file diff --git a/docs/de-DE/README.md b/docs/de-DE/README.md index 4ae0f5a53..07542e977 100644 --- a/docs/de-DE/README.md +++ b/docs/de-DE/README.md @@ -4,8 +4,8 @@ ![ECC - das Harness-native Operator-System für agentische Arbeit](../../assets/hero.png) -[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) +[![GitHub-Sterne](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC) +[![GitHub-Forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/forks) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) diff --git a/docs/es/README.md b/docs/es/README.md index 040b28811..242adb358 100644 --- a/docs/es/README.md +++ b/docs/es/README.md @@ -4,13 +4,13 @@ ![ECC - el sistema operativo nativo del harness para trabajo agentivo](../../assets/hero.png) -[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Estrellas de GitHub](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC) +[![Forks de GitHub](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/forks) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) [![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) ![Shell](https://img.shields.io/badge/-Shell-4EAA25?logo=gnu-bash&logoColor=white) ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?logo=typescript&logoColor=white) ![Python](https://img.shields.io/badge/-Python-3776AB?logo=python&logoColor=white) diff --git a/docs/uk-UA/README.md b/docs/uk-UA/README.md index 5f5ce627d..7c8f28f88 100644 --- a/docs/uk-UA/README.md +++ b/docs/uk-UA/README.md @@ -27,8 +27,8 @@

- Stars - Forks + GitHub stars + GitHub forks Contributors GitHub App installs

diff --git a/docs/ur/README.md b/docs/ur/README.md index a91e4f698..32185989e 100644 --- a/docs/ur/README.md +++ b/docs/ur/README.md @@ -4,8 +4,8 @@ ![ECC - ایجنٹک کام کے لیے ہارنس-نیٹو آپریٹر سسٹم](../../assets/hero.png) -[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) -[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) +[![GitHub stars](https://img.shields.io/github/stars/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC) +[![GitHub forks](https://img.shields.io/github/forks/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/forks) [![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) [![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) [![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) From db61d1c76ab3e39eb4b1ffc84831e9cc6d368286 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 18:37:01 -0400 Subject: [PATCH 43/67] fix(gateguard): warn that parallel-batch siblings may already be applied (#3136) A first-touch Edit/Write denial marks the file checked so the retry passes. Sibling edits to the same file in the same parallel batch are therefore judged against post-denial state and silently apply, leaving the file in a state neither version intended. Hooks see tool calls one at a time, so a batch-wide lock is not possible. Instead make the partial application explicit: the Edit, Write, MultiEdit, and condensed denials now name the file and warn that other edits from the same batch may already have been applied, and SKILL.md tells agents to send dependent edits sequentially and re-read the file after a gated batch. --- scripts/hooks/gateguard-fact-force.js | 20 ++++++ skills/gateguard/SKILL.md | 20 ++++++ tests/hooks/gateguard-fact-force.test.js | 87 ++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index bfd2b11c6..568cf58b2 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -1101,6 +1101,21 @@ function isReadOnlyGitIntrospection(command) { // --- Gate messages --- +/** + * Batch-consistency warning (#3136). A first-touch denial marks the file + * checked so the retry passes; a parallel batch of edits to one + * not-yet-touched file therefore partially applies (first call denied, + * siblings allowed). Hooks see calls one at a time and cannot lock a + * batch, so the denial must say this out loud: name the file and tell + * the agent that siblings may already have been applied. + */ +function batchSiblingWarning(safePath) { + return ( + `If this call was sent in a parallel batch, other edits to ${safePath} from that batch ` + + 'may already have been applied. Re-read the file before building on them.' + ); +} + function editGateMsg(filePath) { const safe = sanitizePath(filePath); return [ @@ -1113,6 +1128,8 @@ function editGateMsg(filePath) { '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', "4. Quote the user's current instruction verbatim", '', + batchSiblingWarning(safe), + '', 'Present the facts, then retry the same operation.' ].join('\n'); } @@ -1129,6 +1146,8 @@ function writeGateMsg(filePath) { '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', "4. Quote the user's current instruction verbatim", '', + batchSiblingWarning(safe), + '', 'Present the facts, then retry the same operation.' ].join('\n'); } @@ -1143,6 +1162,7 @@ function condensedGateMsg(action, filePath, ordinal) { return ( `[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` + "briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " + + `${batchSiblingWarning(safe)} ` + '(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)' ); } diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index e7ebc5cec..be96d2689 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -89,6 +89,26 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc 2. What this specific command verifies or produces ``` +## Parallel Batches and Partial Application + +The first-touch gate evaluates each tool call independently. When several +edits to a file that has not been touched yet are sent in one parallel +batch, the first call is denied and the denial marks the file as checked, +so the sibling edits in that batch are applied. Nothing is rolled back: +the file can end up holding the sibling edits without the denied one. + +The denial message names the file and warns that batch siblings may +already have been applied. Treat it literally: + +- Send dependent edits to a not-yet-touched file sequentially, not in a + parallel batch. A definition and its first use, or an import and its + call site, must not ride in the same batch. +- After a first-touch denial, present the facts, retry the denied edit, + and re-read the file before building on anything else from the batch. + +A batch-wide lock is not possible: hooks see tool calls one at a time, so +the gate cannot know which calls arrived together. + ## Quick Start ### Option A: Use the ECC hook (zero install) diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 495928691..df6e4e28c 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -3104,6 +3104,93 @@ function runTests() { passed++; else failed++; + // --- Batch consistency (#3136): a parallel batch of edits to one --- + // not-yet-touched file partially applies: the first denial marks the + // file checked, so sibling edits in the same batch are allowed. Hooks + // see calls one at a time and cannot lock a batch, so the contract is + // that the denial itself names the file and warns that batch siblings + // may already have been applied. + clearState(); + if ( + test('first-touch Edit denial warns about applied batch siblings (#3136)', () => { + // Two edits to the same unchecked file, sent as a parallel batch. + // Each hook invocation is its own process, exactly as in a batch. + const editA = { + tool_name: 'Edit', + tool_input: { file_path: '/src/batch-target.js', old_string: 'a', new_string: 'b' } + }; + const editB = { + tool_name: 'Edit', + tool_input: { file_path: '/src/batch-target.js', old_string: 'c', new_string: 'd' } + }; + + const first = parseOutput(runHook(editA).stdout); + assert.strictEqual(first.hookSpecificOutput.permissionDecision, 'deny', 'first edit of the batch is gated'); + const firstReason = first.hookSpecificOutput.permissionDecisionReason; + assert.ok(firstReason.includes('/src/batch-target.js'), 'denial names the exact file'); + assert.ok( + firstReason.includes('parallel batch'), + 'denial warns that batch siblings may already have been applied' + ); + assert.ok( + firstReason.includes('Re-read'), + 'denial tells the agent to re-read the file before building on siblings' + ); + + // Sibling edit in the same batch: judged against post-denial state, + // so it applies. The warning above is what makes this visible. + const second = parseOutput(runHook(editB).stdout); + if (second && second.hookSpecificOutput) { + assert.notStrictEqual(second.hookSpecificOutput.permissionDecision, 'deny', 'batch sibling is not re-gated'); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('condensed Edit denial also warns about applied batch siblings (#3136)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 }); + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/batch-condensed.js' } }); + const output = parseOutput(result.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + const reason = output.hookSpecificOutput.permissionDecisionReason; + assert.ok(reason.includes('parallel batch'), 'condensed denial keeps the batch-sibling warning'); + assert.ok(!reason.includes('\n'), 'condensed denial stays a single line'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('first-touch Write and MultiEdit denials warn about applied batch siblings (#3136)', () => { + const writeOut = parseOutput( + runHook({ tool_name: 'Write', tool_input: { file_path: '/src/batch-new.js', content: 'x' } }).stdout + ); + assert.strictEqual(writeOut.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok( + writeOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'), + 'Write denial carries the batch-sibling warning' + ); + + const multiOut = parseOutput( + runHook({ + tool_name: 'MultiEdit', + tool_input: { edits: [{ file_path: '/src/batch-multi.js', old_string: 'a', new_string: 'b' }] } + }).stdout + ); + assert.strictEqual(multiOut.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok( + multiOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'), + 'MultiEdit denial carries the batch-sibling warning' + ); + }) + ) + passed++; + else failed++; + // Cleanup only the temp directory created by this test file. try { if (fs.existsSync(stateDir)) { From da214d73b70fa9494a4ccc0784159fd29924116f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 18:37:05 -0400 Subject: [PATCH 44/67] fix: stop home installs copying .agents into ~/.claude and ~/.codex --- scripts/lib/install-targets/claude-home.js | 4 +- scripts/lib/install-targets/codex-home.js | 3 +- scripts/lib/install-targets/helpers.js | 25 ++++++++++- tests/scripts/install-apply.test.js | 48 ++++++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index 5cc426ac9..2de35ec58 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -1,6 +1,7 @@ const path = require('path'); const { + HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter, createRemappedOperation, isForeignPlatformPath, @@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({ kind: 'home', rootSegments: ['.claude'], installStatePathSegments: ['ecc', 'install-state.json'], + excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS, nativeRootRelativePath: '.claude-plugin', planOperations(input, adapter) { const modules = Array.isArray(input.modules) @@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({ return modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, adapter.target)) + .filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p)) .flatMap(sourceRelativePath => { if ( module.id === 'hooks-runtime' diff --git a/scripts/lib/install-targets/codex-home.js b/scripts/lib/install-targets/codex-home.js index ae29b41a1..aff32c4c5 100644 --- a/scripts/lib/install-targets/codex-home.js +++ b/scripts/lib/install-targets/codex-home.js @@ -1,4 +1,4 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers'); module.exports = createInstallTargetAdapter({ id: 'codex-home', @@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({ rootSegments: ['.codex'], installStatePathSegments: ['ecc-install-state.json'], nativeRootRelativePath: '.codex', + excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS, }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index f69d75e86..5df5ae1c4 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ '.adal': 'adal', }); +// Source paths that home installs must never copy into a harness home +// directory. `.agents` is ECC's repo-local skills/plugins staging area: +// project targets such as kimi and antigravity consume it, but neither +// Claude Code nor Codex reads a `.agents` directory under ~/.claude or +// ~/.codex, so copying it there produces unread files that doctor flags as +// drift and repair keeps restoring. +const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']); + function normalizeRelativePath(relativePath) { return String(relativePath || '') .replace(/\\/g, '/') @@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) { return false; } +function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) { + const normalizedPath = normalizeRelativePath(sourceRelativePath); + return excludedSourcePaths.some(excluded => { + const prefix = normalizeRelativePath(excluded); + return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`)); + }); +} + function resolveBaseRoot(scope, input = {}) { if (scope === 'home') { return input.homeDir || os.homedir(); @@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) { strategy: adapter.determineStrategy(normalizedSourcePath), }); }, + excludesSourcePath(sourceRelativePath) { + return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths); + }, planOperations(input = {}) { if (typeof config.planOperations === 'function') { return config.planOperations(input, adapter); @@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) { return input.modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, config.target)) + .filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p)) .map(sourceRelativePath => adapter.createScaffoldOperation( module.id, sourceRelativePath, @@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) { const module = input.module || {}; const paths = Array.isArray(module.paths) ? module.paths : []; return paths - .filter(p => !isForeignPlatformPath(p, config.target)) + .filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p)) .map(sourceRelativePath => adapter.createScaffoldOperation( module.id, sourceRelativePath, @@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) { } module.exports = { + HOME_INSTALL_EXCLUDED_SOURCE_PATHS, + isExcludedSourcePath, buildValidationIssue, createFlatFileOperations, createFlatRuleOperations, diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 270339cb9..c1e935dcc 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -593,6 +593,54 @@ function runTests() { } })) passed++; else failed++; + if (test('home installs do not copy the repo .agents staging directory into Claude or Codex homes', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(claudeResult.code, 0, claudeResult.stderr); + + const claudeRoot = path.join(homeDir, '.claude'); + assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok( + !fs.existsSync(path.join(claudeRoot, '.agents')), + 'Claude home must not receive the repo .agents staging directory' + ); + + const claudeState = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + assert.ok( + !claudeState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Claude install-state must not record .agents copy operations' + ); + + const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(codexResult.code, 0, codexResult.stderr); + + const codexRoot = path.join(homeDir, '.codex'); + assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok( + !fs.existsSync(path.join(codexRoot, '.agents')), + 'Codex home must not receive the repo .agents staging directory' + ); + + const codexState = readJson(path.join(codexRoot, 'ecc-install-state.json')); + assert.ok( + !codexState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Codex install-state must not record .agents copy operations' + ); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing top-level Claude rules and skills during managed install', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); From 1a8beb71c5282ddfe77c72ab0290961a820e3d89 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 18:39:57 -0400 Subject: [PATCH 45/67] fix(opencode): add resolvable package entry and loadable in-place sources Root package.json declared no main or exports, so OpenCode npm plugin resolution (import.meta.resolve) failed and the plugin was silently skipped (#3127). The .opencode TypeScript sources imported siblings with .js specifiers that only exist after compilation, so the home install, which loads the .ts files in place, crashed the tool registry with ERR_MODULE_NOT_FOUND (#3112). Declare main/types/exports on the root package pointing at the compiled plugin entry, switch the sources to .ts specifiers, and enable allowImportingTsExtensions with rewriteRelativeImportExtensions so the emitted dist keeps working .js specifiers. Add smoke tests that build the package, resolve and import the entry by name from a temp install, and verify every in-place relative import resolves. Fixes #3127 Fixes #3112 --- .opencode/index.ts | 2 +- .opencode/plugins/ecc-hooks.ts | 8 ++-- .opencode/plugins/index.ts | 4 +- .opencode/tools/changed-files.ts | 6 +-- .opencode/tools/index.ts | 16 +++---- .opencode/tsconfig.json | 3 +- package.json | 11 +++++ tests/scripts/build-opencode.test.js | 67 ++++++++++++++++++++++++++++ 8 files changed, 98 insertions(+), 19 deletions(-) diff --git a/.opencode/index.ts b/.opencode/index.ts index fa6cadc58..8ee800f80 100644 --- a/.opencode/index.ts +++ b/.opencode/index.ts @@ -37,4 +37,4 @@ // Export the main plugin // opencode's legacy plugin loader iterates every module export and throws if // any is not a plugin function, so only the plugin function may be exported. -export { default } from "./plugins/index.js" +export { default } from "./plugins/index.ts" diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 22b1132f0..69b59727e 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -16,8 +16,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import * as fs from "fs" import * as path from "path" -import changedFilesTool from "../tools/changed-files.js" -import dependencyAnalyzerTool from "../tools/dependency-analyzer.js" +import changedFilesTool from "../tools/changed-files.ts" +import dependencyAnalyzerTool from "../tools/dependency-analyzer.ts" /** * Type definitions for better type safety @@ -111,9 +111,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ // This plugin is OpenCode's startup entry point, so a static import // failure here previously crashed the whole plugin -- and with it, the // entire OpenCode session -- before any hooks could load (see #2530). - let changedFilesStore: typeof import("./lib/changed-files-store.js") | undefined + let changedFilesStore: typeof import("./lib/changed-files-store.ts") | undefined try { - const store = await import("./lib/changed-files-store.js") + const store = await import("./lib/changed-files-store.ts") store.initStore(worktreePath) changedFilesStore = store } catch { diff --git a/.opencode/plugins/index.ts b/.opencode/plugins/index.ts index c1e17a159..3a98f0ba6 100644 --- a/.opencode/plugins/index.ts +++ b/.opencode/plugins/index.ts @@ -6,7 +6,7 @@ * while taking advantage of OpenCode's more sophisticated 20+ event types. */ -export { ECCHooksPlugin, default } from "./ecc-hooks.js" +export { ECCHooksPlugin, default } from "./ecc-hooks.ts" // Re-export for named imports -export * from "./ecc-hooks.js" +export * from "./ecc-hooks.ts" diff --git a/.opencode/tools/changed-files.ts b/.opencode/tools/changed-files.ts index 1150ca756..3ae000e1b 100644 --- a/.opencode/tools/changed-files.ts +++ b/.opencode/tools/changed-files.ts @@ -1,5 +1,5 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" -import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.js" +import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.ts" const INDICATORS: Record = { added: "+", @@ -27,12 +27,12 @@ function renderTree(nodes: TreeNode[], indent: string): string { // file, so a static import failure here previously took down the entire // tools module -- and with it, the whole OpenCode session -- on the very // first tool-loading pass (see #2530). -type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.js") +type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.ts") let changedFilesStorePromise: Promise | undefined async function loadChangedFilesStore(): Promise { if (!changedFilesStorePromise) { - changedFilesStorePromise = import("../plugins/lib/changed-files-store.js").catch(() => { + changedFilesStorePromise = import("../plugins/lib/changed-files-store.ts").catch(() => { changedFilesStorePromise = undefined throw new Error( "changed-files tool: could not load the changed-files store. " + diff --git a/.opencode/tools/index.ts b/.opencode/tools/index.ts index 9bd999479..17db1081a 100644 --- a/.opencode/tools/index.ts +++ b/.opencode/tools/index.ts @@ -5,11 +5,11 @@ */ // Re-export all tools -export { default as runTests } from "./run-tests.js" -export { default as checkCoverage } from "./check-coverage.js" -export { default as securityAudit } from "./security-audit.js" -export { default as formatCode } from "./format-code.js" -export { default as lintCheck } from "./lint-check.js" -export { default as gitSummary } from "./git-summary.js" -export { default as changedFiles } from "./changed-files.js" -export { default as dependencyAnalyzer } from "./dependency-analyzer.js" +export { default as runTests } from "./run-tests.ts" +export { default as checkCoverage } from "./check-coverage.ts" +export { default as securityAudit } from "./security-audit.ts" +export { default as formatCode } from "./format-code.ts" +export { default as lintCheck } from "./lint-check.ts" +export { default as gitSummary } from "./git-summary.ts" +export { default as changedFiles } from "./changed-files.ts" +export { default as dependencyAnalyzer } from "./dependency-analyzer.ts" diff --git a/.opencode/tsconfig.json b/.opencode/tsconfig.json index c6b43257b..1d586042f 100644 --- a/.opencode/tsconfig.json +++ b/.opencode/tsconfig.json @@ -15,7 +15,8 @@ "sourceMap": true, "resolveJsonModule": true, "isolatedModules": true, - "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "types": ["node"] }, "include": [ diff --git a/package.json b/package.json index 87487a914..7af5c1a52 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,17 @@ "name": "ecc-universal", "version": "2.2.1", "description": "Harness-native agent operating system for Codex, OpenCode, Cursor, Gemini, Claude Code, and terminal workflows - skills, hooks, rules, MCP conventions, and operator control-plane patterns", + "main": ".opencode/dist/index.js", + "types": ".opencode/dist/index.d.ts", + "exports": { + ".": { + "types": "./.opencode/dist/index.d.ts", + "import": "./.opencode/dist/index.js", + "default": "./.opencode/dist/index.js" + }, + "./package.json": "./package.json", + "./*": "./*" + }, "publishConfig": { "access": "public" }, diff --git a/tests/scripts/build-opencode.test.js b/tests/scripts/build-opencode.test.js index 469165883..b8743b3e0 100644 --- a/tests/scripts/build-opencode.test.js +++ b/tests/scripts/build-opencode.test.js @@ -4,6 +4,7 @@ const assert = require("assert") const fs = require("fs") +const os = require("os") const path = require("path") const { spawnSync } = require("child_process") const { getNpmPackEntry } = require("../lib/npm-pack-output") @@ -46,6 +47,72 @@ function main() { assert.strictEqual(result.status, 0, result.stderr) assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build") }], + ["package.json declares a resolvable OpenCode plugin entry", () => { + assert.strictEqual(packageJson.main, ".opencode/dist/index.js") + assert.ok(packageJson.exports, "package.json must declare an exports map") + assert.deepStrictEqual(packageJson.exports["."], { + types: "./.opencode/dist/index.d.ts", + import: "./.opencode/dist/index.js", + default: "./.opencode/dist/index.js", + }) + }], + ["installed package resolves and imports its root module by name", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-entry-")) + try { + fs.mkdirSync(path.join(tempDir, "node_modules"), { recursive: true }) + fs.symlinkSync( + repoRoot, + path.join(tempDir, "node_modules", "ecc-universal"), + process.platform === "win32" ? "junction" : "dir" + ) + const probe = ` + const resolved = import.meta.resolve("ecc-universal") + if (!resolved.endsWith("/.opencode/dist/index.js")) { + throw new Error("unexpected entry resolution: " + resolved) + } + const mod = await import("ecc-universal") + if (Object.keys(mod).join(",") !== "default" || typeof mod.default !== "function") { + throw new Error("root module must export exactly the plugin function") + } + ` + const probePath = path.join(tempDir, "probe.mjs") + fs.writeFileSync(probePath, probe) + const result = spawnSync(process.execPath, [probePath], { + cwd: tempDir, + encoding: "utf8", + }) + assert.strictEqual(result.status, 0, result.stderr) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }], + ["OpenCode TypeScript sources resolve their relative imports in place", () => { + const opencodeDir = path.join(repoRoot, ".opencode") + const sourceFiles = [] + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && entry.name !== "dist") walk(entryPath) + } else if (entry.name.endsWith(".ts")) { + sourceFiles.push(entryPath) + } + } + } + walk(opencodeDir) + assert.ok(sourceFiles.length > 0, "expected OpenCode TypeScript sources") + const unresolved = [] + for (const sourceFile of sourceFiles) { + const source = fs.readFileSync(sourceFile, "utf8") + for (const match of source.matchAll(/(?:from|import)\s*\(?\s*"(\.[^"]+)"/g)) { + const target = path.resolve(path.dirname(sourceFile), match[1]) + if (!fs.existsSync(target)) { + unresolved.push(`${path.relative(repoRoot, sourceFile)} -> ${match[1]}`) + } + } + } + assert.deepStrictEqual(unresolved, []) + }], ["built OpenCode entry exports only the plugin function", () => { const check = ` const assert = require("assert") From f0378ccdb63ec434041f1cef1a1b77b86c8fa39f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 18:47:05 -0400 Subject: [PATCH 46/67] fix(tests): feed guided install PTY answers only after each prompt The real-PTY test piped answers on fixed sleeps, typing them ahead of readline. Under CI load the first answer could land before the interface listened, shifting every later answer onto the wrong question: the ubuntu-latest Node 18.x npm job installed Claude only, exited 0, and never printed the Kimi profile prompt while the sibling yarn, pnpm, and bun jobs on the same Node version passed. Answer each prompt once it appears on screen instead; spawned stdio goes through cat because the macOS script(1) refuses a socket stdin. --- tests/scripts/install-guided.test.js | 74 ++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/tests/scripts/install-guided.test.js b/tests/scripts/install-guided.test.js index 661756672..8d2ee773e 100644 --- a/tests/scripts/install-guided.test.js +++ b/tests/scripts/install-guided.test.js @@ -2,7 +2,7 @@ const assert = require('assert'); const path = require('path'); -const { spawnSync } = require('child_process'); +const { spawn } = require('child_process'); const { collectInteractiveOptions, @@ -60,25 +60,56 @@ function quoteShellArgument(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } -function runGuidedPtyFixture(answers) { - if (process.platform === 'win32') return null; +function stripPtyControlBytes(value) { + return value + // eslint-disable-next-line no-control-regex + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, ''); +} + +function runGuidedPtyFixture(exchanges) { + if (process.platform === 'win32') return Promise.resolve(null); const command = [process.execPath, guidedPtyFixture]; const scriptArgs = process.platform === 'darwin' ? ['-q', '-e', '/dev/null', ...command] : ['-q', '-e', '-c', command.map(quoteShellArgument).join(' '), '/dev/null']; - const pseudoTerminalCommand = ['script', ...scriptArgs] - .map(quoteShellArgument) - .join(' '); - const answerCommands = answers - .map(answer => `sleep 0.35; printf '%s\\n' ${quoteShellArgument(answer)}`) - .join('; '); - return spawnSync('sh', ['-c', `(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 15000, + return new Promise((resolve, reject) => { + // Answers go through cat so script reads a plain pipe: spawned stdio is + // a socketpair, and the macOS script(1) refuses a socket stdin. + const feeder = `cat | ${['script', ...scriptArgs].map(quoteShellArgument).join(' ')}`; + const child = spawn('sh', ['-c', feeder], { cwd: repoRoot }); + let stdout = ''; + let stderr = ''; + let sent = 0; + let settled = false; + const finish = callback => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(() => reject(new Error('guided PTY fixture timed out'))); + }, 15000); + const feed = () => { + // Answer only once the matching prompt is on screen. Fixed sleeps + // typed answers ahead of readline; under CI load the first answer + // could land before the interface listened, shifting every later + // answer onto the wrong question (ubuntu Node 18 npm job). + const visible = stripPtyControlBytes(stdout + stderr); + while (sent < exchanges.length && visible.includes(exchanges[sent].expect)) { + child.stdin.write(`${exchanges[sent].send}\n`); + sent += 1; + } + if (sent === exchanges.length) child.stdin.end(); + }; + child.stdout.on('data', data => { stdout += data; feed(); }); + child.stderr.on('data', data => { stderr += data; feed(); }); + child.on('error', error => finish(() => reject(error))); + child.on('close', (status, signal) => finish(() => resolve({ status, signal, stdout, stderr }))); }); } - (async () => { console.log('\n=== Guided multi-harness CLI tests ===\n'); @@ -175,14 +206,17 @@ function runGuidedPtyFixture(answers) { ); }); - await test('real PTY shows every all-harness question and applies after visible yes', () => { - const result = runGuidedPtyFixture(['all', '1', '3', '2', 'y']); + await test('real PTY shows every all-harness question and applies after visible yes', async () => { + const result = await runGuidedPtyFixture([ + { expect: 'Choose one or more (for example 1,3 or all):', send: 'all' }, + { expect: 'Choose [Recommended: user] (one option only):', send: '1' }, + { expect: 'Choose [Recommended: standard] (one option only):', send: '3' }, + { expect: 'Choose [Recommended: core] (one option only):', send: '2' }, + { expect: 'Apply ECC to these harnesses? [y/N]:', send: 'y' }, + ]); if (result === null) return; assert.strictEqual(result.status, 0, result.stderr); - const visible = `${result.stdout}${result.stderr}` - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') - .replace(/\r/g, ''); + const visible = stripPtyControlBytes(`${result.stdout}${result.stderr}`); const orderedPrompts = [ 'Choose one or more (for example 1,3 or all):', 'Choose [Recommended: user] (one option only):', From c752aac18616e26bf146f034a86947d8f6fc207e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Fr=C4=B1=20Solako=C4=9Flu?= Date: Sat, 19 Sep 2026 01:51:01 +0300 Subject: [PATCH 47/67] chore(skills): declare licenses on the Codex skill mirror (#2997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skillforge validate reports SF1009 (no license declared) on every skill in .agents/skills. This adds license: MIT to all 39, matching the repository LICENSE. license only. The Codex mirror's frontmatter is governed by an allowlist in tests/ci/codex-skill-surface.test.js - allowed-tools, description, license, metadata, name - and license is the one field on it that skillforge asks for. compatibility is deliberately absent here; widening that contract is a separate decision about what the Codex surface supports. node tests/ci/codex-skill-surface.test.js: 4 passed, 0 failed. Split out of #2993 so both PRs land under the review-bot file limits. Co-authored-by: Çağrı Solakoğlu --- .agents/skills/agent-introspection-debugging/SKILL.md | 1 + .agents/skills/agent-sort/SKILL.md | 1 + .agents/skills/api-design/SKILL.md | 1 + .agents/skills/article-writing/SKILL.md | 1 + .agents/skills/backend-patterns/SKILL.md | 1 + .agents/skills/benchmark-methodology/SKILL.md | 1 + .agents/skills/brand-discovery/SKILL.md | 1 + .agents/skills/brand-voice/SKILL.md | 1 + .agents/skills/bun-runtime/SKILL.md | 1 + .agents/skills/coding-standards/SKILL.md | 1 + .agents/skills/competitive-platform-analysis/SKILL.md | 1 + .agents/skills/competitive-report-structure/SKILL.md | 1 + .agents/skills/content-engine/SKILL.md | 1 + .agents/skills/crosspost/SKILL.md | 1 + .agents/skills/deep-research/SKILL.md | 1 + .agents/skills/dmux-workflows/SKILL.md | 1 + .agents/skills/documentation-lookup/SKILL.md | 1 + .agents/skills/e2e-testing/SKILL.md | 1 + .agents/skills/eval-harness/SKILL.md | 1 + .agents/skills/everything-claude-code/SKILL.md | 1 + .agents/skills/exa-search/SKILL.md | 1 + .agents/skills/fal-ai-media/SKILL.md | 1 + .agents/skills/frontend-patterns/SKILL.md | 1 + .agents/skills/frontend-slides/SKILL.md | 1 + .agents/skills/investor-materials/SKILL.md | 1 + .agents/skills/investor-outreach/SKILL.md | 1 + .agents/skills/market-research/SKILL.md | 1 + .agents/skills/mcp-server-patterns/SKILL.md | 1 + .agents/skills/mle-workflow/SKILL.md | 1 + .agents/skills/nextjs-turbopack/SKILL.md | 1 + .agents/skills/plan-canvas/SKILL.md | 1 + .agents/skills/product-capability/SKILL.md | 1 + .agents/skills/security-review/SKILL.md | 1 + .agents/skills/strategic-compact/SKILL.md | 1 + .agents/skills/tdd-workflow/SKILL.md | 1 + .agents/skills/unified-memory/SKILL.md | 1 + .agents/skills/verification-loop/SKILL.md | 1 + .agents/skills/video-editing/SKILL.md | 1 + .agents/skills/x-api/SKILL.md | 1 + 39 files changed, 39 insertions(+) diff --git a/.agents/skills/agent-introspection-debugging/SKILL.md b/.agents/skills/agent-introspection-debugging/SKILL.md index 25019740e..6d343ca87 100644 --- a/.agents/skills/agent-introspection-debugging/SKILL.md +++ b/.agents/skills/agent-introspection-debugging/SKILL.md @@ -1,6 +1,7 @@ --- name: agent-introspection-debugging description: Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry. +license: MIT --- # Agent Introspection Debugging diff --git a/.agents/skills/agent-sort/SKILL.md b/.agents/skills/agent-sort/SKILL.md index 4daf0a7c2..e180e5199 100644 --- a/.agents/skills/agent-sort/SKILL.md +++ b/.agents/skills/agent-sort/SKILL.md @@ -1,6 +1,7 @@ --- name: agent-sort description: Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle. +license: MIT --- # Agent Sort diff --git a/.agents/skills/api-design/SKILL.md b/.agents/skills/api-design/SKILL.md index 72ecd9015..98738177f 100644 --- a/.agents/skills/api-design/SKILL.md +++ b/.agents/skills/api-design/SKILL.md @@ -1,6 +1,7 @@ --- name: api-design description: REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. Use when designing or reviewing REST endpoints, resource names, status codes, pagination, or versioning. +license: MIT --- # API Design Patterns diff --git a/.agents/skills/article-writing/SKILL.md b/.agents/skills/article-writing/SKILL.md index 2f17b3e67..ab7f836ed 100644 --- a/.agents/skills/article-writing/SKILL.md +++ b/.agents/skills/article-writing/SKILL.md @@ -1,6 +1,7 @@ --- name: article-writing description: Write articles, guides, blog posts, tutorials, newsletter issues, and other long-form content in a distinctive voice derived from supplied examples or brand guidance. Use when the user wants polished written content longer than a paragraph, especially when voice consistency, structure, and credibility matter. +license: MIT --- # Article Writing diff --git a/.agents/skills/backend-patterns/SKILL.md b/.agents/skills/backend-patterns/SKILL.md index 56983b0eb..721b67a3e 100644 --- a/.agents/skills/backend-patterns/SKILL.md +++ b/.agents/skills/backend-patterns/SKILL.md @@ -1,6 +1,7 @@ --- name: backend-patterns description: Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. Use when building or reviewing Node.js, Express, or Next.js API routes and their data access. +license: MIT --- # Backend Development Patterns diff --git a/.agents/skills/benchmark-methodology/SKILL.md b/.agents/skills/benchmark-methodology/SKILL.md index bc75367f2..a05b62cc5 100644 --- a/.agents/skills/benchmark-methodology/SKILL.md +++ b/.agents/skills/benchmark-methodology/SKILL.md @@ -6,6 +6,7 @@ description: >- visual craft, offer packaging, evidence, enterprise-readiness, thought leadership, pricing, client's strategic tension) with explicit 1–5 rubrics and a tension-plot. Precedes competitive-report-structure. +license: MIT --- # Benchmark Methodology diff --git a/.agents/skills/brand-discovery/SKILL.md b/.agents/skills/brand-discovery/SKILL.md index 9006a079d..48fd933d2 100644 --- a/.agents/skills/brand-discovery/SKILL.md +++ b/.agents/skills/brand-discovery/SKILL.md @@ -6,6 +6,7 @@ description: >- personality, voice, narrative, and founder-brand tension across 8 modules using laddering, 5 Whys, and projective techniques. Produces a resumable session with disk-persisted state and a master brandbook (90_SYNTHESIS.md). +license: MIT --- # Brand Discovery diff --git a/.agents/skills/brand-voice/SKILL.md b/.agents/skills/brand-voice/SKILL.md index 0ade4fc0d..fb7bec09f 100644 --- a/.agents/skills/brand-voice/SKILL.md +++ b/.agents/skills/brand-voice/SKILL.md @@ -1,6 +1,7 @@ --- name: brand-voice description: Build a source-derived writing style profile from real posts, essays, launch notes, docs, or site copy, then reuse that profile across content, outreach, and social workflows. Use when the user wants voice consistency without generic AI writing tropes. +license: MIT --- # Brand Voice diff --git a/.agents/skills/bun-runtime/SKILL.md b/.agents/skills/bun-runtime/SKILL.md index deb1f506c..ab748e26a 100644 --- a/.agents/skills/bun-runtime/SKILL.md +++ b/.agents/skills/bun-runtime/SKILL.md @@ -1,6 +1,7 @@ --- name: bun-runtime description: Bun as runtime, package manager, bundler, and test runner. When to choose Bun vs Node, migration notes, and Vercel support. +license: MIT --- # Bun Runtime diff --git a/.agents/skills/coding-standards/SKILL.md b/.agents/skills/coding-standards/SKILL.md index 27dbe7cbe..6ca1401aa 100644 --- a/.agents/skills/coding-standards/SKILL.md +++ b/.agents/skills/coding-standards/SKILL.md @@ -1,6 +1,7 @@ --- name: coding-standards description: Baseline cross-project coding conventions for naming, readability, immutability, and code-quality review. Use detailed frontend or backend skills for framework-specific patterns. Use when reviewing code quality or naming with no framework-specific skill that applies. +license: MIT --- # Coding Standards & Best Practices diff --git a/.agents/skills/competitive-platform-analysis/SKILL.md b/.agents/skills/competitive-platform-analysis/SKILL.md index dc9eee967..fb6e9a495 100644 --- a/.agents/skills/competitive-platform-analysis/SKILL.md +++ b/.agents/skills/competitive-platform-analysis/SKILL.md @@ -6,6 +6,7 @@ description: >- counts as a competitor, which tier they belong to, and which sources to mine. First step in the three-skill competitive pipeline; precedes benchmark-methodology. +license: MIT --- # Competitive Platform Analysis diff --git a/.agents/skills/competitive-report-structure/SKILL.md b/.agents/skills/competitive-report-structure/SKILL.md index e5e9b1ce3..b1ebcf4c5 100644 --- a/.agents/skills/competitive-report-structure/SKILL.md +++ b/.agents/skills/competitive-report-structure/SKILL.md @@ -6,6 +6,7 @@ description: >- profiles, benchmarking matrix, white-space analysis, strategic recommendations, and team alignment trigger questions. Final step in the three-skill competitive pipeline. +license: MIT --- # Competitive Report Structure diff --git a/.agents/skills/content-engine/SKILL.md b/.agents/skills/content-engine/SKILL.md index 5c9e2e3f2..14dc8ed7b 100644 --- a/.agents/skills/content-engine/SKILL.md +++ b/.agents/skills/content-engine/SKILL.md @@ -1,6 +1,7 @@ --- name: content-engine description: Create platform-native content systems for X, LinkedIn, TikTok, YouTube, newsletters, and repurposed multi-platform campaigns. Use when the user wants social posts, threads, scripts, content calendars, or one source asset adapted cleanly across platforms. +license: MIT --- # Content Engine diff --git a/.agents/skills/crosspost/SKILL.md b/.agents/skills/crosspost/SKILL.md index db4e9dc00..0b167a134 100644 --- a/.agents/skills/crosspost/SKILL.md +++ b/.agents/skills/crosspost/SKILL.md @@ -1,6 +1,7 @@ --- name: crosspost description: Multi-platform content distribution across X, LinkedIn, Threads, and Bluesky. Adapts content per platform using content-engine patterns. Never posts identical content cross-platform. Use when the user wants to distribute content across social platforms. +license: MIT --- # Crosspost diff --git a/.agents/skills/deep-research/SKILL.md b/.agents/skills/deep-research/SKILL.md index db7b8e6d1..74dc3e52a 100644 --- a/.agents/skills/deep-research/SKILL.md +++ b/.agents/skills/deep-research/SKILL.md @@ -1,6 +1,7 @@ --- name: deep-research description: Multi-source deep research using firecrawl and exa MCPs. Searches the web, synthesizes findings, and delivers cited reports with source attribution. Use when the user wants thorough research on any topic with evidence and citations. +license: MIT --- # Deep Research diff --git a/.agents/skills/dmux-workflows/SKILL.md b/.agents/skills/dmux-workflows/SKILL.md index c3bd27985..9617aa5e8 100644 --- a/.agents/skills/dmux-workflows/SKILL.md +++ b/.agents/skills/dmux-workflows/SKILL.md @@ -1,6 +1,7 @@ --- name: dmux-workflows description: Multi-agent orchestration using dmux (tmux pane manager for AI agents). Patterns for parallel agent workflows across Claude Code, Codex, OpenCode, and other harnesses. Use when running multiple agent sessions in parallel or coordinating multi-agent development workflows. +license: MIT --- # dmux Workflows diff --git a/.agents/skills/documentation-lookup/SKILL.md b/.agents/skills/documentation-lookup/SKILL.md index 8a389f9b0..e29e68525 100644 --- a/.agents/skills/documentation-lookup/SKILL.md +++ b/.agents/skills/documentation-lookup/SKILL.md @@ -1,6 +1,7 @@ --- name: documentation-lookup description: Use up-to-date library and framework docs via Context7 MCP instead of training data. Activates for setup questions, API references, code examples, or when the user names a framework (e.g. React, Next.js, Prisma). +license: MIT --- # Documentation Lookup (Context7) diff --git a/.agents/skills/e2e-testing/SKILL.md b/.agents/skills/e2e-testing/SKILL.md index af6fb9e92..5187aeaa3 100644 --- a/.agents/skills/e2e-testing/SKILL.md +++ b/.agents/skills/e2e-testing/SKILL.md @@ -1,6 +1,7 @@ --- name: e2e-testing description: Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies. Use when writing Playwright tests, structuring page objects, or fixing flaky E2E runs in CI. +license: MIT --- # E2E Testing Patterns diff --git a/.agents/skills/eval-harness/SKILL.md b/.agents/skills/eval-harness/SKILL.md index c117d5a88..8b60b99b1 100644 --- a/.agents/skills/eval-harness/SKILL.md +++ b/.agents/skills/eval-harness/SKILL.md @@ -2,6 +2,7 @@ name: eval-harness description: Formal evaluation framework for Claude Code sessions implementing eval-driven development (EDD) principles. Use when a Claude Code workflow needs a formal eval before it is trusted or changed. allowed-tools: Read, Write, Edit, Bash, Grep, Glob +license: MIT --- # Eval Harness Skill diff --git a/.agents/skills/everything-claude-code/SKILL.md b/.agents/skills/everything-claude-code/SKILL.md index 9a92c67fa..82bf08fff 100644 --- a/.agents/skills/everything-claude-code/SKILL.md +++ b/.agents/skills/everything-claude-code/SKILL.md @@ -1,6 +1,7 @@ --- name: everything-claude-code description: Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits. +license: MIT --- # Everything Claude Code Conventions diff --git a/.agents/skills/exa-search/SKILL.md b/.agents/skills/exa-search/SKILL.md index 1d3e5cb6e..685d26b3b 100644 --- a/.agents/skills/exa-search/SKILL.md +++ b/.agents/skills/exa-search/SKILL.md @@ -1,6 +1,7 @@ --- name: exa-search description: Neural search via Exa MCP for web, code, and company research. Use when the user needs web search, code examples, company intel, people lookup, or AI-powered deep research with Exa's neural search engine. +license: MIT --- # Exa Search diff --git a/.agents/skills/fal-ai-media/SKILL.md b/.agents/skills/fal-ai-media/SKILL.md index a694690fa..24d9da822 100644 --- a/.agents/skills/fal-ai-media/SKILL.md +++ b/.agents/skills/fal-ai-media/SKILL.md @@ -1,6 +1,7 @@ --- name: fal-ai-media description: Unified media generation via fal.ai MCP — image, video, and audio. Covers text-to-image (Nano Banana), text/image-to-video (Seedance, Kling, Veo 3), text-to-speech (CSM-1B), and video-to-audio (ThinkSound). Use when the user wants to generate images, videos, or audio with AI. +license: MIT --- # fal.ai Media Generation diff --git a/.agents/skills/frontend-patterns/SKILL.md b/.agents/skills/frontend-patterns/SKILL.md index 0ff681ead..6696c275a 100644 --- a/.agents/skills/frontend-patterns/SKILL.md +++ b/.agents/skills/frontend-patterns/SKILL.md @@ -1,6 +1,7 @@ --- name: frontend-patterns description: Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. Use when building or reviewing React or Next.js components, state, or render performance. +license: MIT --- # Frontend Development Patterns diff --git a/.agents/skills/frontend-slides/SKILL.md b/.agents/skills/frontend-slides/SKILL.md index 32d4f9515..2318ef74e 100644 --- a/.agents/skills/frontend-slides/SKILL.md +++ b/.agents/skills/frontend-slides/SKILL.md @@ -1,6 +1,7 @@ --- name: frontend-slides description: Create stunning, animation-rich HTML presentations from scratch or by converting PowerPoint files. Use when the user wants to build a presentation, convert a PPT/PPTX to web, or create slides for a talk/pitch. Helps non-designers discover their aesthetic through visual exploration rather than abstract choices. +license: MIT --- # Frontend Slides diff --git a/.agents/skills/investor-materials/SKILL.md b/.agents/skills/investor-materials/SKILL.md index 9d69eb6ee..ed14d59b3 100644 --- a/.agents/skills/investor-materials/SKILL.md +++ b/.agents/skills/investor-materials/SKILL.md @@ -1,6 +1,7 @@ --- name: investor-materials description: Create and update pitch decks, one-pagers, investor memos, accelerator applications, financial models, and fundraising materials. Use when the user needs investor-facing documents, projections, use-of-funds tables, milestone plans, or materials that must stay internally consistent across multiple fundraising assets. +license: MIT --- # Investor Materials diff --git a/.agents/skills/investor-outreach/SKILL.md b/.agents/skills/investor-outreach/SKILL.md index ce216e083..c8e28e0dd 100644 --- a/.agents/skills/investor-outreach/SKILL.md +++ b/.agents/skills/investor-outreach/SKILL.md @@ -1,6 +1,7 @@ --- name: investor-outreach description: Draft cold emails, warm intro blurbs, follow-ups, update emails, and investor communications for fundraising. Use when the user wants outreach to angels, VCs, strategic investors, or accelerators and needs concise, personalized, investor-facing messaging. +license: MIT --- # Investor Outreach diff --git a/.agents/skills/market-research/SKILL.md b/.agents/skills/market-research/SKILL.md index 10c7a7643..8f9a08df9 100644 --- a/.agents/skills/market-research/SKILL.md +++ b/.agents/skills/market-research/SKILL.md @@ -1,6 +1,7 @@ --- name: market-research description: Conduct market research, competitive analysis, investor due diligence, and industry intelligence with source attribution and decision-oriented summaries. Use when the user wants market sizing, competitor comparisons, fund research, technology scans, or research that informs business decisions. +license: MIT --- # Market Research diff --git a/.agents/skills/mcp-server-patterns/SKILL.md b/.agents/skills/mcp-server-patterns/SKILL.md index 314b6ab04..a73ae625f 100644 --- a/.agents/skills/mcp-server-patterns/SKILL.md +++ b/.agents/skills/mcp-server-patterns/SKILL.md @@ -1,6 +1,7 @@ --- name: mcp-server-patterns description: Build MCP servers with Node/TypeScript SDK — tools, resources, prompts, Zod validation, stdio vs Streamable HTTP. Use Context7 or official MCP docs for latest API. Use when building or debugging an MCP server — tools, resources, prompts, validation, or transport choice. +license: MIT --- # MCP Server Patterns diff --git a/.agents/skills/mle-workflow/SKILL.md b/.agents/skills/mle-workflow/SKILL.md index 192233785..c91e626f5 100644 --- a/.agents/skills/mle-workflow/SKILL.md +++ b/.agents/skills/mle-workflow/SKILL.md @@ -2,6 +2,7 @@ name: mle-workflow description: Production machine-learning engineering workflow for data contracts, reproducible training, model evaluation, deployment, monitoring, and rollback. Use when building, reviewing, or hardening ML systems beyond one-off notebooks. allowed-tools: Read, Write, Edit, Bash, Grep, Glob +license: MIT --- # Machine Learning Engineering Workflow diff --git a/.agents/skills/nextjs-turbopack/SKILL.md b/.agents/skills/nextjs-turbopack/SKILL.md index 01b9c391f..b29570308 100644 --- a/.agents/skills/nextjs-turbopack/SKILL.md +++ b/.agents/skills/nextjs-turbopack/SKILL.md @@ -1,6 +1,7 @@ --- name: nextjs-turbopack description: Next.js 16+ and Turbopack — incremental bundling, FS caching, dev speed, and when to use Turbopack vs webpack. +license: MIT --- # Next.js and Turbopack diff --git a/.agents/skills/plan-canvas/SKILL.md b/.agents/skills/plan-canvas/SKILL.md index 8b77e1e26..3a4baa851 100644 --- a/.agents/skills/plan-canvas/SKILL.md +++ b/.agents/skills/plan-canvas/SKILL.md @@ -3,6 +3,7 @@ name: plan-canvas description: Open plans and HTML artifacts in a local browser canvas where the human annotates elements, chats, and approves or requests changes without leaving the page. Use when presenting a plan for review, or when feedback like "move this, change that" is easier pointed at than typed. metadata: origin: ECC +license: MIT --- # Plan Canvas diff --git a/.agents/skills/product-capability/SKILL.md b/.agents/skills/product-capability/SKILL.md index 7831d85d8..e747b28eb 100644 --- a/.agents/skills/product-capability/SKILL.md +++ b/.agents/skills/product-capability/SKILL.md @@ -1,6 +1,7 @@ --- name: product-capability description: Translate PRD intent, roadmap asks, or product discussions into an implementation-ready capability plan that exposes constraints, invariants, interfaces, and unresolved decisions before multi-service work starts. Use when the user needs an ECC-native PRD-to-SRS lane instead of vague planning prose. +license: MIT --- # Product Capability diff --git a/.agents/skills/security-review/SKILL.md b/.agents/skills/security-review/SKILL.md index e91e05859..cb0cca0c8 100644 --- a/.agents/skills/security-review/SKILL.md +++ b/.agents/skills/security-review/SKILL.md @@ -1,6 +1,7 @@ --- name: security-review description: Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns. +license: MIT --- # Security Review Skill diff --git a/.agents/skills/strategic-compact/SKILL.md b/.agents/skills/strategic-compact/SKILL.md index e402dd81c..a4164df44 100644 --- a/.agents/skills/strategic-compact/SKILL.md +++ b/.agents/skills/strategic-compact/SKILL.md @@ -1,6 +1,7 @@ --- name: strategic-compact description: Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction. Use when a session is approaching a context limit and a task phase is a natural place to compact. +license: MIT --- # Strategic Compact Skill diff --git a/.agents/skills/tdd-workflow/SKILL.md b/.agents/skills/tdd-workflow/SKILL.md index 661a1e581..67300bf52 100644 --- a/.agents/skills/tdd-workflow/SKILL.md +++ b/.agents/skills/tdd-workflow/SKILL.md @@ -1,6 +1,7 @@ --- name: tdd-workflow description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. +license: MIT --- # Test-Driven Development Workflow diff --git a/.agents/skills/unified-memory/SKILL.md b/.agents/skills/unified-memory/SKILL.md index 938c69570..e4f84e23f 100644 --- a/.agents/skills/unified-memory/SKILL.md +++ b/.agents/skills/unified-memory/SKILL.md @@ -1,6 +1,7 @@ --- name: unified-memory description: Share durable, inspectable context and handoffs between Claude, Codex, Hermes, Cursor, OpenCode, and other agents through the local ECC Memory Vault. Use when an agent must save work state, transfer context, resume another agent's task, or search shared project knowledge. +license: MIT --- # Unified Memory diff --git a/.agents/skills/verification-loop/SKILL.md b/.agents/skills/verification-loop/SKILL.md index fa9aecf29..b936bc964 100644 --- a/.agents/skills/verification-loop/SKILL.md +++ b/.agents/skills/verification-loop/SKILL.md @@ -1,6 +1,7 @@ --- name: verification-loop description: "A comprehensive verification system for Claude Code sessions. Use when verifying a Claude Code session's work before claiming it is complete." +license: MIT --- # Verification Loop Skill diff --git a/.agents/skills/video-editing/SKILL.md b/.agents/skills/video-editing/SKILL.md index 8353a968f..a15fe9e68 100644 --- a/.agents/skills/video-editing/SKILL.md +++ b/.agents/skills/video-editing/SKILL.md @@ -1,6 +1,7 @@ --- name: video-editing description: AI-assisted video editing workflows for cutting, structuring, and augmenting real footage. Covers the full pipeline from raw capture through FFmpeg, Remotion, ElevenLabs, fal.ai, and final polish in Descript or CapCut. Use when the user wants to edit video, cut footage, create vlogs, or build video content. +license: MIT --- # Video Editing diff --git a/.agents/skills/x-api/SKILL.md b/.agents/skills/x-api/SKILL.md index 7fb880f71..40d1a8402 100644 --- a/.agents/skills/x-api/SKILL.md +++ b/.agents/skills/x-api/SKILL.md @@ -1,6 +1,7 @@ --- name: x-api description: X/Twitter API integration for posting tweets, threads, reading timelines, search, and analytics. Covers OAuth auth patterns, rate limits, and platform-native content posting. Use when the user wants to interact with X programmatically. +license: MIT --- # X API From 8cfbc26797ca94d90b866fca1878fe548e9fd6bd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 20:13:11 -0400 Subject: [PATCH 48/67] fix: reconcile pre-exclusion .agents installs on claude and codex home upgrades --- scripts/install-apply.js | 7 + scripts/lib/install/apply.js | 25 +- .../install/excluded-paths-reconciliation.js | 230 ++++++++++++++++++ tests/scripts/install-apply.test.js | 129 ++++++++++ 4 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/install/excluded-paths-reconciliation.js diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 1435d2ff6..722f7d6b6 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -132,6 +132,13 @@ function printHumanPlan(plan, dryRun) { } } + if (Array.isArray(plan.reconciledExcludedPaths) && plan.reconciledExcludedPaths.length > 0) { + console.log('\nReconciled excluded paths:'); + for (const removedPath of plan.reconciledExcludedPaths) { + console.log(`- removed ${removedPath}`); + } + } + if (!dryRun) { console.log(`\nDone. Install-state written to ${plan.installStatePath}`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index fbab1293b..bd0f41fd5 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -34,6 +34,10 @@ const { preserveUnwrittenFiles, } = require('./ownership-guard'); const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration'); +const { + completeExcludedPathsReconciliation, + prepareExcludedPathsReconciliation, +} = require('./excluded-paths-reconciliation'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { adaptAntigravityAgent } = require('./antigravity-agent'); @@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } - const migration = prepareHookConsentMigration( + const migration = prepareExcludedPathsReconciliation( plan, - prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ) ); const appliedPlan = { ...plan, @@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ]; } + let excludedPathsRemoved = []; + let excludedPathsWarnings = []; + try { + const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan); + excludedPathsRemoved = excludedReconciliation.removedPaths; + excludedPathsWarnings = excludedReconciliation.warnings; + } catch (error) { + excludedPathsWarnings = [ + `Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`, + ]; + } + return { ...plan, statePreview: finalState, plannedOperations: [...plan.operations], operations: migration.appliedOperations, skippedOperations: migration.skippedOperations, + reconciledExcludedPaths: excludedPathsRemoved, warnings: [ ...(Array.isArray(plan.warnings) ? plan.warnings : []), ...migration.warnings, ...antigravityMigrationWarnings, ...opencodeMigrationWarnings, + ...excludedPathsWarnings, ], applied: true, }; diff --git a/scripts/lib/install/excluded-paths-reconciliation.js b/scripts/lib/install/excluded-paths-reconciliation.js new file mode 100644 index 000000000..845dfd400 --- /dev/null +++ b/scripts/lib/install/excluded-paths-reconciliation.js @@ -0,0 +1,230 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { readInstallState } = require('../install-state'); +const { assertWithinTrustedRoot } = require('../path-safety'); +const { getInstallTargetAdapter } = require('../install-targets/registry'); + +/** + * Upgrade reconciliation for excluded source paths (issue #3116). + * + * Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude + * and Codex home targets). The exclusion stops new copy operations from being + * planned, but a home install created before the exclusion still has the + * copied files on disk and the copy operations recorded in install-state, so + * doctor keeps reporting drift and repair keeps restoring files the target + * never reads. + * + * prepareExcludedPathsReconciliation runs before the new state is written: it + * reads the previous install-state and drops the recorded managed operations + * whose source path is now excluded. completeExcludedPathsReconciliation runs + * after a successful apply: it removes the files those operations recorded, + * but only when the recorded content digest still matches, and prunes the + * emptied directories. Files the state does not own, modified files, + * symlinks, and anything outside the target root are preserved with a + * warning. + */ + +function comparablePath(filePath) { + const resolvedPath = path.resolve(filePath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function getReconcilingAdapter(plan) { + if (!plan || typeof plan.target !== 'string') { + return null; + } + let adapter; + try { + adapter = getInstallTargetAdapter(plan.target); + } catch { + return null; + } + return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null; +} + +function isRecordedExcludedManagedOperation(adapter, operation) { + return Boolean( + operation + && operation.ownership === 'managed' + && typeof operation.destinationPath === 'string' + && typeof operation.sourceRelativePath === 'string' + && adapter.excludesSourcePath(operation.sourceRelativePath) + ); +} + +function filterStateOperations(state, shouldDrop) { + if (!state || !Array.isArray(state.operations)) { + return state; + } + return { + ...state, + operations: state.operations.filter(operation => !shouldDrop(operation)), + }; +} + +function prepareExcludedPathsReconciliation(plan, migration) { + const adapter = getReconcilingAdapter(plan); + if (!adapter || !fs.existsSync(plan.installStatePath)) { + return { ...migration, excludedPathCandidates: [] }; + } + + const previousState = readInstallState(plan.installStatePath); + const candidates = ((previousState && previousState.operations) || []) + .filter(operation => isRecordedExcludedManagedOperation(adapter, operation)); + + if (candidates.length === 0) { + return { ...migration, excludedPathCandidates: [] }; + } + + const droppedDestinations = new Set( + candidates.map(operation => comparablePath(operation.destinationPath)) + ); + const shouldDrop = operation => Boolean( + operation + && typeof operation.destinationPath === 'string' + && droppedDestinations.has(comparablePath(operation.destinationPath)) + && typeof operation.sourceRelativePath === 'string' + && adapter.excludesSourcePath(operation.sourceRelativePath) + ); + + return { + ...migration, + bridgeState: filterStateOperations(migration.bridgeState, shouldDrop), + finalState: filterStateOperations(migration.finalState, shouldDrop), + excludedPathCandidates: candidates, + }; +} + +function pathExists(filePath) { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + if (error && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function hashFileNoFollow(filePath) { + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const descriptor = fs.openSync(filePath, flags); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile()) { + throw new Error(`Refusing to read a non-file at ${filePath}`); + } + const content = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fs.lstatSync(filePath, { bigint: true }); + const unchanged = before.dev === after.dev + && before.ino === after.ino + && before.size === after.size + && after.dev === finalPathStat.dev + && after.ino === finalPathStat.ino + && after.size === finalPathStat.size; + if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) { + throw new Error(`Refusing to read a file that changed during validation: ${filePath}`); + } + return crypto.createHash('sha256').update(content).digest('hex'); + } finally { + fs.closeSync(descriptor); + } +} + +function removeEmptyParents(startPath, targetRoot) { + let currentPath = path.dirname(startPath); + while (comparablePath(currentPath) !== comparablePath(targetRoot)) { + const safePath = assertWithinTrustedRoot( + currentPath, + targetRoot, + 'reconcile excluded install paths' + ); + if (!pathExists(safePath)) { + currentPath = path.dirname(safePath); + continue; + } + const stat = fs.lstatSync(safePath); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) { + return; + } + fs.rmdirSync(safePath); + currentPath = path.dirname(safePath); + } +} + +function completeExcludedPathsReconciliation(migration, plan) { + const candidates = (migration && migration.excludedPathCandidates) || []; + const removedPaths = []; + const warnings = []; + + for (const candidate of candidates) { + if (candidate.kind !== 'copy-file') { + continue; + } + + let safePath; + try { + safePath = assertWithinTrustedRoot( + candidate.destinationPath, + plan.targetRoot, + 'reconcile excluded install paths' + ); + } catch (error) { + warnings.push( + `Preserved previously managed file ${candidate.destinationPath}: ${error.message}` + ); + continue; + } + + if (!pathExists(safePath)) { + continue; + } + + const stat = fs.lstatSync(safePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + warnings.push( + `Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.` + ); + continue; + } + + if (typeof candidate.contentSha256 !== 'string') { + warnings.push( + `Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.` + ); + continue; + } + + let currentDigest; + try { + currentDigest = hashFileNoFollow(safePath); + } catch (error) { + warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`); + continue; + } + + if (currentDigest !== candidate.contentSha256.toLowerCase()) { + warnings.push( + `Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.` + ); + continue; + } + + fs.unlinkSync(safePath); + removedPaths.push(safePath); + removeEmptyParents(safePath, plan.targetRoot); + } + + return { removedPaths, warnings }; +} + +module.exports = { + completeExcludedPathsReconciliation, + prepareExcludedPathsReconciliation, +}; diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index c1e935dcc..576ba4389 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -7,6 +7,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); +const crypto = require('crypto'); const yaml = require('js-yaml'); const { applyInstallPlan } = require('../../scripts/lib/install/apply'); @@ -641,6 +642,134 @@ function runTests() { } })) passed++; else failed++; + if (test('reconciles legacy .agents files and state operations on Claude and Codex home upgrades', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + const digest = content => crypto.createHash('sha256').update(content).digest('hex'); + const legacyOperation = (destinationPath, sourceRelativePath, installedContent) => ({ + kind: 'copy-file', + moduleId: 'agents-core', + sourceRelativePath, + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: digest(installedContent), + }); + const writeFile = (filePath, content) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + }; + const writeLegacyState = (statePath, target, operations) => { + writeFile(statePath, `${JSON.stringify({ + schemaVersion: 'ecc.install.v1', + installedAt: '2026-09-01T00:00:00.000Z', + target, + request: { + profile: 'core', + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + hookConsent: target.target === 'claude' ? 'enabled' : null, + }, + resolution: { selectedModules: ['agents-core'], skippedModules: [] }, + source: { repoVersion: '2.2.1', repoCommit: null, manifestVersion: 1 }, + operations, + }, null, 2)}\n`); + }; + + try { + // Claude home seeded as installed before the .agents exclusion. + const claudeRoot = path.join(homeDir, '.claude'); + const claudeStatePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const claudeSkillCopy = path.join(claudeRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md'); + const claudeModifiedCopy = path.join(claudeRoot, '.agents', 'plugins', 'marketplace.json'); + const claudeUserFile = path.join(claudeRoot, '.agents', 'user-note.txt'); + writeFile(claudeSkillCopy, '# legacy skill\n'); + writeFile(claudeModifiedCopy, '{"edited": true}\n'); + writeFile(claudeUserFile, 'user notes\n'); + writeLegacyState(claudeStatePath, { + id: 'claude-home', target: 'claude', kind: 'home', + root: claudeRoot, installStatePath: claudeStatePath, + }, [ + legacyOperation(claudeSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'), + legacyOperation(claudeModifiedCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'), + ]); + + const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(claudeResult.code, 0, claudeResult.stderr); + + assert.ok(!fs.existsSync(claudeSkillCopy), 'Unchanged managed .agents file should be removed'); + assert.ok( + claudeResult.stdout.includes( + `- removed ${path.join(fs.realpathSync(claudeRoot), '.agents', 'skills', 'legacy-skill', 'SKILL.md')}` + ), + 'Install output should log one line per removed path' + ); + assert.strictEqual( + fs.readFileSync(claudeModifiedCopy, 'utf8'), + '{"edited": true}\n', + 'Modified managed file must be preserved' + ); + assert.strictEqual( + fs.readFileSync(claudeUserFile, 'utf8'), + 'user notes\n', + 'Files the state does not own must not be touched' + ); + assert.ok( + !fs.existsSync(path.join(claudeRoot, '.agents', 'skills')), + 'Emptied .agents subdirectories should be pruned' + ); + + const claudeState = readJson(claudeStatePath); + assert.ok( + !claudeState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Claude install-state must drop the excluded .agents operations' + ); + assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + + // Codex home seeded the same way; both recorded files are unchanged. + const codexRoot = path.join(homeDir, '.codex'); + const codexStatePath = path.join(codexRoot, 'ecc-install-state.json'); + const codexSkillCopy = path.join(codexRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md'); + const codexMarketplaceCopy = path.join(codexRoot, '.agents', 'plugins', 'marketplace.json'); + writeFile(codexSkillCopy, '# legacy skill\n'); + writeFile(codexMarketplaceCopy, '{"original": true}\n'); + writeLegacyState(codexStatePath, { + id: 'codex-home', target: 'codex', kind: 'home', + root: codexRoot, installStatePath: codexStatePath, + }, [ + legacyOperation(codexSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'), + legacyOperation(codexMarketplaceCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'), + ]); + + const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(codexResult.code, 0, codexResult.stderr); + + assert.ok( + !fs.existsSync(path.join(codexRoot, '.agents')), + 'Fully reconciled .agents directory should be pruned from the Codex home' + ); + const codexState = readJson(codexStatePath); + assert.ok( + !codexState.operations.some(operation => ( + String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents' + )), + 'Codex install-state must drop the excluded .agents operations' + ); + assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing top-level Claude rules and skills during managed install', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); From 75970dda724198282ea9164501a92ef69243af01 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 21:41:50 -0400 Subject: [PATCH 49/67] docs: fix LANE-RULES heading level and drop personal path --- docs/LANE-RULES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LANE-RULES.md b/docs/LANE-RULES.md index db24c6db3..5b765d381 100644 --- a/docs/LANE-RULES.md +++ b/docs/LANE-RULES.md @@ -3,13 +3,13 @@ These are the working rules for bounded lane workers (human or agent) that execute tasks against this repository from the Ito workstream system. They are copied verbatim from the lane registry -(`/Users/affoon/.codex/workstream-results/lanes/RULES.md` on the ops mini, +(`lanes/RULES.md` in the Ito workstream system on the ops mini, 2026-09-16) so a worker reading only this repo sees the same contract. One task, one branch, one PR or one receipt, then stop. --- -# Lane rules (every codex exec brief starts by reading this) +## Lane rules (every codex exec brief starts by reading this) You are one bounded worker. One task, one branch, one PR or one receipt, then stop. - Real work only: edit code, run the tests, commit, push, open the PR. No receipts about receipts, no independent review of your own output, no hashing manifests, no ledgers, no acceptance JSONs, no skill self-patching. Your final message is the receipt (under 300 words: what changed, PR link, test command and result, what is blocked and on whom). - Never merge to main, never publish to npm, never deploy, never send email or messages, never change Hermes profiles or launchd on the mini unless the brief says so explicitly. From 07e6b42e5cabe45f314a3771d666df60a36504da Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 18 Sep 2026 21:53:16 -0400 Subject: [PATCH 50/67] docs: use fenced code blocks in agent rules to satisfy MD046 --- docs/es/rules/common/agents.md | 4 +++- docs/ja-JP/rules/common/agents.md | 4 +++- docs/tr/rules/common/agents.md | 4 +++- docs/zh-CN/rules/common/agents.md | 4 +++- rules/common/agents.md | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/es/rules/common/agents.md b/docs/es/rules/common/agents.md index 8c162c58f..bb61f7c14 100644 --- a/docs/es/rules/common/agents.md +++ b/docs/es/rules/common/agents.md @@ -5,7 +5,9 @@ Los agentes de ECC se distribuyen con el plugin `ecc@ecc`, no en `~/.claude/agents/`. Se invocan a través de la herramienta Agent con un `subagent_type` con ámbito de plugin: - Agent(subagent_type: "ecc:planner", prompt: "...") +```text +Agent(subagent_type: "ecc:planner", prompt: "...") +``` | Agente | Propósito | Cuándo Usar | |--------|-----------|-------------| diff --git a/docs/ja-JP/rules/common/agents.md b/docs/ja-JP/rules/common/agents.md index 7b8082cf7..71cd7754e 100644 --- a/docs/ja-JP/rules/common/agents.md +++ b/docs/ja-JP/rules/common/agents.md @@ -5,7 +5,9 @@ ECC の Agent は `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` には配置されません。 Agent ツールではプラグインスコープの `subagent_type` で呼び出します: - Agent(subagent_type: "ecc:planner", prompt: "...") +```text +Agent(subagent_type: "ecc:planner", prompt: "...") +``` | Agent | 目的 | 使用タイミング | |-------|---------|-------------| diff --git a/docs/tr/rules/common/agents.md b/docs/tr/rules/common/agents.md index b96f3a13a..d00403e87 100644 --- a/docs/tr/rules/common/agents.md +++ b/docs/tr/rules/common/agents.md @@ -5,7 +5,9 @@ ECC agent'ları `ecc@ecc` eklentisiyle birlikte gelir, `~/.claude/agents/` dizininde bulunmaz. Agent aracıyla eklenti kapsamlı bir `subagent_type` ile çağrılır: - Agent(subagent_type: "ecc:planner", prompt: "...") +```text +Agent(subagent_type: "ecc:planner", prompt: "...") +``` | Agent | Amaç | Ne Zaman Kullanılır | |-------|---------|-------------| diff --git a/docs/zh-CN/rules/common/agents.md b/docs/zh-CN/rules/common/agents.md index da79b41d6..3f3c2edaa 100644 --- a/docs/zh-CN/rules/common/agents.md +++ b/docs/zh-CN/rules/common/agents.md @@ -5,7 +5,9 @@ ECC 智能体随 `ecc@ecc` 插件一起分发,不在 `~/.claude/agents/` 目录中。 它们通过 Agent 工具以插件作用域的 `subagent_type` 调用: - Agent(subagent_type: "ecc:planner", prompt: "...") +```text +Agent(subagent_type: "ecc:planner", prompt: "...") +``` | 代理 | 用途 | 使用时机 | |-------|---------|-------------| diff --git a/rules/common/agents.md b/rules/common/agents.md index cb36573c6..14d9b9005 100644 --- a/rules/common/agents.md +++ b/rules/common/agents.md @@ -5,7 +5,9 @@ ECC agents ship with the `ecc@ecc` plugin, not in `~/.claude/agents/`. They are invoked through the Agent tool with a plugin-scoped `subagent_type`: - Agent(subagent_type: "ecc:planner", prompt: "...") +```text +Agent(subagent_type: "ecc:planner", prompt: "...") +``` | Agent | Purpose | When to Use | |-------|---------|-------------| From f6eb80047448e254ae9c450616d98079857a91b6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 19 Sep 2026 02:08:50 -0400 Subject: [PATCH 51/67] fix(session-start): scope summary lookup to repository identity (#3160) (#3168) Windows: compare repo identity via normalizeRepoPath/sameRepoIdentity (8.3 short names, case, separators; inode fallback). All nine windows-latest jobs green on 52587005. --- scripts/hooks/session-end.js | 17 ++-- scripts/hooks/session-start.js | 54 ++++++++++-- scripts/lib/utils.js | 71 ++++++++++++++++ tests/hooks/hooks.test.js | 150 ++++++++++++++++++++++++++++++++- tests/lib/utils.test.js | 64 ++++++++++++++ 5 files changed, 343 insertions(+), 13 deletions(-) diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 9709aa95a..5c31a8e0b 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -11,7 +11,7 @@ const path = require('path'); const fs = require('fs'); -const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils'); +const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, getRepoIdentity, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils'); const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary'); const SUMMARY_START_MARKER = ''; @@ -130,7 +130,8 @@ function getSessionMetadata() { return { project: getProjectName() || 'unknown', branch: branchResult.success ? branchResult.output : 'unknown', - worktree: process.cwd() + worktree: process.cwd(), + repo: getRepoIdentity() }; } @@ -145,16 +146,20 @@ function buildSessionHeader(today, currentTime, metadata, existingContent = '') const date = extractHeaderField(existingContent, 'Date') || today; const started = extractHeaderField(existingContent, 'Started') || currentTime; - return [ + const lines = [ heading, `**Date:** ${date}`, `**Started:** ${started}`, `**Last Updated:** ${currentTime}`, `**Project:** ${metadata.project}`, `**Branch:** ${metadata.branch}`, - `**Worktree:** ${metadata.worktree}`, - '' - ].join('\n'); + `**Worktree:** ${metadata.worktree}` + ]; + if (metadata.repo) { + lines.push(`**Repo:** ${metadata.repo}`); + } + lines.push(''); + return lines.join('\n'); } function mergeSessionHeader(content, today, currentTime, metadata) { diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 63854aff1..9a859565a 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -14,6 +14,8 @@ const { getSessionSearchDirs, getLearnedSkillsDir, getProjectName, + getRepoIdentity, + sameRepoIdentity, findFiles, ensureDir, readFile, @@ -254,6 +256,7 @@ function pruneExpiredSessions(searchDirs, retentionDays) { * Session files written by session-end.js contain header fields like: * **Project:** my-project * **Worktree:** /path/to/project + * **Repo:** /path/to/main-worktree/.git * * This function reads each session file once, caching its content, and * returns both the selected session object and its already-read content @@ -261,11 +264,18 @@ function pruneExpiredSessions(searchDirs, retentionDays) { * * Priority (highest to lowest): * 1. Exact worktree (cwd) match — most recent - * 2. Same project name match for legacy sessions without Worktree metadata - * 3. No injection when sessions belong to a different worktree/project + * 2. Repository identity match: the session was recorded in another + * worktree or subdirectory of the same repository. Identity is the + * main worktree's common git dir (issue #3160), taken from the + * recorded **Repo:** field or resolved from the recorded **Worktree:** + * path for older session files. Unrelated repositories never match. + * 3. Same project name match for legacy sessions without Worktree/Repo + * metadata + * 4. No injection when sessions belong to a different repository * * Sessions are already sorted newest-first, so the first match in each - * category wins. + * category wins; the scan continues past repository and project matches so + * an exact worktree match always takes precedence. * * @param {Array} sessions - Deduplicated session list, sorted newest-first. * @param {string} cwd - Current working directory (process.cwd()). @@ -279,7 +289,17 @@ function selectMatchingSession(sessions, cwd, currentProject) { // Normalize cwd once outside the loop to avoid repeated syscalls const normalizedCwd = normalizePath(cwd); + const currentRepoId = getRepoIdentity(cwd); + const repoIdByWorktree = new Map(); + const repoIdOfRecordedWorktree = (recordedWorktree) => { + if (!repoIdByWorktree.has(recordedWorktree)) { + repoIdByWorktree.set(recordedWorktree, getRepoIdentity(recordedWorktree)); + } + return repoIdByWorktree.get(recordedWorktree); + }; + let repoMatch = null; + let repoMatchContent = null; let projectMatch = null; let projectMatchContent = null; let readableSessions = 0; @@ -289,9 +309,11 @@ function selectMatchingSession(sessions, cwd, currentProject) { if (!content) continue; readableSessions++; - // Extract **Worktree:** field + // Extract **Worktree:** and **Repo:** fields const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; + const repoFieldMatch = content.match(/\*\*Repo:\*\*\s*(.+)$/m); + const sessionRepo = repoFieldMatch ? repoFieldMatch[1].trim() : ''; // Exact worktree match — best possible, return immediately // Normalize both paths to handle symlinks and case-insensitive filesystems @@ -299,9 +321,25 @@ function selectMatchingSession(sessions, cwd, currentProject) { return { session, content, matchReason: 'worktree' }; } + // Repository identity match (#3160): the summary lookup is scoped to the + // repository, not the cwd path, so a session recorded in worktree A is + // eligible in worktree B only when both resolve to the same common git + // dir. Unrelated repositories never share. + if (!repoMatch && currentRepoId && (sessionRepo || sessionWorktree)) { + // The recorded Repo field may carry a different path form than the + // live lookup (8.3 short names on Windows runners, case, separators), + // so compare with filesystem-identity fallback rather than ===. + const sessionRepoId = sessionRepo || repoIdOfRecordedWorktree(sessionWorktree); + if (sessionRepoId && sameRepoIdentity(sessionRepoId, currentRepoId)) { + repoMatch = session; + repoMatchContent = content; + } + } + // Project name match is only safe for legacy session files written before - // Worktree metadata existed. A different explicit Worktree is not a match. - if (!projectMatch && currentProject && !sessionWorktree) { + // Worktree/Repo metadata existed. A different explicit Worktree or Repo + // is not a match. + if (!projectMatch && currentProject && !sessionWorktree && !sessionRepo) { const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m); const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; if (sessionProject && sessionProject === currentProject) { @@ -311,6 +349,10 @@ function selectMatchingSession(sessions, cwd, currentProject) { } } + if (repoMatch) { + return { session: repoMatch, content: repoMatchContent, matchReason: 'repo' }; + } + if (projectMatch) { return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; } diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 5e29868eb..9766fcd0c 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -135,6 +135,74 @@ function getGitRepoName() { return path.basename(result.output); } +/** + * Get the repository identity for a directory: the canonical (real) path of + * the repository's common git dir, which is the main worktree's .git + * directory. Every linked worktree of one repository resolves to the same + * identity, while unrelated repositories never share one. + * + * @param {string} [dir] - Directory to resolve from (defaults to process.cwd()). + * @returns {string|null} The canonical common git dir, or null when dir is + * not inside a git repository or does not exist. + */ +function getRepoIdentity(dir, runCmd = runCommand) { + const target = dir || process.cwd(); + const result = runCmd('git rev-parse --git-common-dir', { cwd: target }); + if (!result.success || !result.output) return null; + const commonDir = path.resolve(target, result.output); + try { + return fs.realpathSync(commonDir); + } catch { + return commonDir; + } +} + +/** + * Normalize a repository identity path for comparison: canonical (real) form + * when it exists, forward slashes, no trailing slash, and lowercase on + * Windows where the filesystem is case-insensitive. The platform argument + * exists so Windows-shaped git output can be tested on any OS. + * + * @param {string} p - Path to normalize. + * @param {string} [platform] - Platform override (defaults to process.platform). + * @returns {string} The normalized path, or '' for empty input. + */ +function normalizeRepoPath(p, platform = process.platform) { + if (!p) return ''; + let resolved; + try { + resolved = fs.realpathSync(p); + } catch { + resolved = path.resolve(p); + } + const slashed = resolved.replace(/\\/g, '/').replace(/\/+$/, ''); + return platform === 'win32' ? slashed.toLowerCase() : slashed; +} + +/** + * Compare two repository identity paths. String normalization alone is not + * enough on Windows CI runners, where TEMP commonly uses an 8.3 short name + * (RUNNER~1): Node's realpath keeps the short form while git reports the + * long form for the same directory. When the strings differ, fall back to + * filesystem identity (device + inode), which is immune to 8.3 names, case + * and separators. Fails closed when either path cannot be statted. + * + * @param {string} a - First identity path. + * @param {string} b - Second identity path. + * @returns {boolean} True when both paths name the same directory. + */ +function sameRepoIdentity(a, b) { + if (!a || !b) return false; + if (normalizeRepoPath(a) === normalizeRepoPath(b)) return true; + try { + const sa = fs.statSync(a); + const sb = fs.statSync(b); + return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino; + } catch { + return false; + } +} + /** * Get project name from git repo or current directory */ @@ -642,6 +710,9 @@ module.exports = { sanitizeSessionId, getSessionIdShort, getGitRepoName, + getRepoIdentity, + normalizeRepoPath, + sameRepoIdentity, getProjectName, // File operations diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index f2635973a..e9ca3ebfa 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -103,6 +103,9 @@ const CLI_RESUME_SESSION_SENTINEL = 'CLI_RESUME_CONTEXT_SHOULD_NOT_BE_INJECTED'; const CLI_CLEAR_SESSION_SENTINEL = 'CLI_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED'; const DESKTOP_CLEAR_SESSION_SENTINEL = 'DESKTOP_CLEAR_CONTEXT_SHOULD_NOT_BE_INJECTED'; const PROJECT_ONLY_SESSION_SENTINEL = 'PROJECT_ONLY_CONTEXT_SHOULD_BE_INJECTED'; +const SAME_REPO_WORKTREE_SENTINEL = 'SAME_REPO_WORKTREE_CONTEXT_SHOULD_BE_INJECTED'; +const REPO_FIELD_SESSION_SENTINEL = 'REPO_FIELD_CONTEXT_SHOULD_BE_INJECTED'; +const UNRELATED_REPO_SESSION_SENTINEL = 'UNRELATED_REPO_CONTEXT_SHOULD_NOT_BE_INJECTED'; function buildSessionStartFixture(content, options = {}) { const title = options.title ?? '# Session'; @@ -113,11 +116,41 @@ function buildSessionStartFixture(content, options = {}) { if (worktree) { lines.push(`**Worktree:** ${worktree}`); } + if (options.repo) { + lines.push(`**Repo:** ${options.repo}`); + } lines.push('', content, ''); return lines.join('\n'); } +function initGitRepoWithWorktrees(baseDir, worktreeNames) { + const mainrepo = path.join(baseDir, 'mainrepo'); + execFileSync('git', ['init', '-q', mainrepo]); + execFileSync('git', ['config', 'user.email', 't@t.local'], { cwd: mainrepo }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: mainrepo }); + fs.writeFileSync(path.join(mainrepo, 'README.md'), 'seed\n'); + execFileSync('git', ['add', '-A'], { cwd: mainrepo }); + execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: mainrepo }); + const worktrees = {}; + for (const name of worktreeNames) { + const target = path.join(baseDir, name); + execFileSync('git', ['worktree', 'add', '-q', '-b', name, target, 'HEAD'], { cwd: mainrepo }); + worktrees[name] = target; + } + return { mainrepo, worktrees }; +} + +function gitCommonDirRealpath(dir) { + const out = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: dir, encoding: 'utf8' }).trim(); + const resolved = path.resolve(dir, out); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + // Test helper function test(name, fn) { try { @@ -145,9 +178,10 @@ async function asyncTest(name, fn) { } // Run a script and capture output -function runScript(scriptPath, input = '', env = {}) { +function runScript(scriptPath, input = '', env = {}, cwd = process.cwd()) { return new Promise((resolve, reject) => { const proc = spawn('node', [scriptPath], { + cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -952,6 +986,119 @@ async function runTests() { passed++; else failed++; + if ( + await asyncTest('injects a same-repository session recorded in a different worktree (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-samerepo-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-samerepo-')); + const { worktrees } = initGitRepoWithWorktrees(repoBase, ['wt-a', 'wt-b']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-samerepo-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(SAME_REPO_WORKTREE_SENTINEL, { + project: 'wt-a', + worktree: worktrees['wt-a'] + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, worktrees['wt-b']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes(SAME_REPO_WORKTREE_SENTINEL), 'Should inject a session recorded in another worktree of the same repository'); + assert.ok(result.stderr.includes('(match: repo)'), `Should report repository identity match, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBase, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('scopes sessions by recorded repository identity when the worktree path is gone (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repofield-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repofield-')); + const { mainrepo, worktrees } = initGitRepoWithWorktrees(repoBase, ['wt-b']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-repofield-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(REPO_FIELD_SESSION_SENTINEL, { + project: 'wt-removed', + worktree: path.join(repoBase, 'wt-removed'), + repo: gitCommonDirRealpath(mainrepo) + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, worktrees['wt-b']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes(REPO_FIELD_SESSION_SENTINEL), 'Should match on the recorded common git dir when the recorded worktree path no longer resolves'); + assert.ok(result.stderr.includes('(match: repo)'), `Should report repository identity match, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBase, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('never injects a session from an unrelated repository (#3160)', async () => { + const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-unrelated-home-')); + const sessionsDir = getCanonicalSessionsDir(isoHome); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); + const repoBaseX = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repox-')); + const repoX = initGitRepoWithWorktrees(repoBaseX, ['wt-x']); + const repoBaseY = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-3160-repoy-')); + const repoY = initGitRepoWithWorktrees(repoBaseY, ['wt-y']); + + const sessionFile = path.join(sessionsDir, '2026-02-11-unrelated-session.tmp'); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(UNRELATED_REPO_SESSION_SENTINEL, { + project: 'wt-x', + worktree: repoX.worktrees['wt-x'], + repo: gitCommonDirRealpath(repoX.mainrepo) + }) + ); + + try { + const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { + HOME: isoHome, + USERPROFILE: isoHome + }, repoY.worktrees['wt-y']); + assert.strictEqual(result.code, 0); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes(UNRELATED_REPO_SESSION_SENTINEL), 'Should never inject a session from an unrelated repository'); + assert.ok(result.stderr.includes('No worktree/project session match found'), `Should log no-match reason, stderr: ${result.stderr}`); + } finally { + fs.rmSync(isoHome, { recursive: true, force: true }); + fs.rmSync(repoBaseX, { recursive: true, force: true }); + fs.rmSync(repoBaseY, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( await asyncTest('reports learned skills count', async () => { const isoHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-skills-start-')); @@ -1259,6 +1406,7 @@ async function runTests() { assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata'); assert.ok(content.includes(`**Branch:** ${branch}`), 'Should persist branch metadata'); assert.ok(content.includes(`**Worktree:** ${process.cwd()}`), 'Should persist worktree metadata'); + assert.ok(content.includes(`**Repo:** ${gitCommonDirRealpath(process.cwd())}`), 'Should persist repository identity metadata'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 158db6c73..f9921a503 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -7,6 +7,7 @@ const assert = require('assert'); const path = require('path'); const fs = require('fs'); +const os = require('os'); const { spawnSync } = require('child_process'); // Import the module @@ -243,6 +244,69 @@ function runTests() { assert.ok(name && name.length > 0); })) passed++; else failed++; + // Repository identity tests (#3160 Windows path forms) + console.log('\nRepository Identity:'); + + if (test('getRepoIdentity resolves a mocked relative git output against dir', () => { + const fakeGit = () => ({ success: true, output: '.git' }); + const id = utils.getRepoIdentity('/definitely/missing/repo', fakeGit); + assert.strictEqual(id, path.resolve('/definitely/missing/repo', '.git')); + })) passed++; else failed++; + + if (test('getRepoIdentity returns null when git fails', () => { + const fakeGit = () => ({ success: false, output: 'not a git repository' }); + assert.strictEqual(utils.getRepoIdentity('/definitely/missing/repo', fakeGit), null); + })) passed++; else failed++; + + if (test('normalizeRepoPath treats Windows-shaped paths equal across case and separators', () => { + // Windows-shaped git output: 8.3 short name, backslashes, mixed case. + // Runs on any OS; the platform argument selects the case-insensitive rule. + const a = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\repo\\.git'; + const b = 'c:/users/runner~1/appdata/local/temp/repo/.git'; + assert.strictEqual( + utils.normalizeRepoPath(a, 'win32'), + utils.normalizeRepoPath(b, 'win32') + ); + })) passed++; else failed++; + + if (test('normalizeRepoPath strips trailing slashes and keeps case off win32', () => { + const a = utils.normalizeRepoPath('X:/Repo/Main/.git/', 'linux'); + const b = utils.normalizeRepoPath('X:/Repo/Main/.git', 'linux'); + assert.strictEqual(a, b); + assert.ok(!/\.git\/$/.test(a)); + assert.ok(a.includes('Repo'), 'linux normalization must not lowercase'); + })) passed++; else failed++; + + if (test('sameRepoIdentity matches a hard link by filesystem identity', () => { + // dev+ino fallback: different path strings, same file. This is what + // rescues 8.3 short-name versus long-name mismatches on Windows. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-repoid-')); + try { + const orig = path.join(dir, 'a'); + const link = path.join(dir, 'b'); + fs.writeFileSync(orig, 'x'); + fs.linkSync(orig, link); + assert.ok(utils.sameRepoIdentity(orig, link)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('sameRepoIdentity rejects different files and missing paths', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-repoid-')); + try { + const a = path.join(dir, 'a'); + const b = path.join(dir, 'b'); + fs.writeFileSync(a, 'x'); + fs.writeFileSync(b, 'y'); + assert.ok(!utils.sameRepoIdentity(a, b)); + assert.ok(!utils.sameRepoIdentity(a, path.join(dir, 'missing'))); + assert.ok(!utils.sameRepoIdentity('', b)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + })) passed++; else failed++; + // sanitizeSessionId tests console.log('\nsanitizeSessionId:'); From 07756cee15788a54506031462794ad645719b028 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 19 Sep 2026 02:58:33 -0400 Subject: [PATCH 52/67] fix(gateguard): gate ref- and history-destroying git commands (#3154, #3151) (#3170) branch -D, stash drop/clear, reflog expire/delete, update-ref -d, restore (except --staged alone), and force-with-lease pushes to shared branches now hit the destructive gate. 238 hook tests pass; 49 CI checks green. --- scripts/hooks/gateguard-fact-force.js | 110 ++++++++++++++++++++++- tests/hooks/gateguard-fact-force.test.js | 63 ++++++++++++- 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 568cf58b2..bb800d28b 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -462,10 +462,65 @@ function findGitSubcommand(tokens) { return null; } +/** + * Branch names treated as shared history: a forced update of one of + * these rewrites commits other clones build on, even when the push is + * lease-checked. + */ +const SHARED_GIT_BRANCHES = new Set(['main', 'master', 'develop', 'trunk']); + +/** + * Decide whether the positional arguments of a `git push` name a shared + * branch as the destination of a refspec. The first positional token is + * the remote (unless the remote came from `--repo`); every later + * positional token is a refspec whose destination is the part after + * `:` (or the whole token when there is no `:`). A leading `+` force + * marker is stripped. When no refspec is given the target is the + * current branch, which the hook cannot know, so this returns false. + * + * @param {string[]} rest tokens after `push` + * @returns {boolean} + */ +function pushTargetsSharedBranch(rest) { + const valueConsuming = new Set(['-o', '--push-option', '--receive-pack', '--exec']); + const positional = []; + let remoteViaFlag = false; + for (let i = 0; i < rest.length; i++) { + const t = rest[i]; + if (t === '--repo') { + remoteViaFlag = true; + i += 1; + continue; + } + if (t.startsWith('--repo=')) { + remoteViaFlag = true; + continue; + } + if (valueConsuming.has(t)) { + i += 1; + continue; + } + if (t.startsWith('-')) continue; + positional.push(t); + } + // Unless the remote came from --repo, positional[0] is the remote and + // the rest are refspecs. + const refspecs = remoteViaFlag ? positional : positional.slice(1); + for (const refspec of refspecs) { + const cleaned = refspec.startsWith('+') ? refspec.slice(1) : refspec; + const dst = cleaned.includes(':') ? cleaned.slice(cleaned.indexOf(':') + 1) : cleaned; + const branch = dst.startsWith('refs/heads/') ? dst.slice('refs/heads/'.length) : dst; + if (SHARED_GIT_BRANCHES.has(branch)) return true; + } + return false; +} + /** * Detect destructive `git` invocations: `reset --hard`, `checkout --`, - * `clean -f...`, `push --force` (but not `--force-with-lease`), - * `commit --amend`, `rm -rf`. + * `clean -f...`, `push --force` (`--force-with-lease` only to a shared + * branch), `commit --amend`, `rm -rf`, `branch -D`, `stash drop` / + * `stash clear`, `reflog expire` / `reflog delete`, `update-ref -d`, + * and `restore` against the worktree. * * @param {string[]} tokens * @returns {boolean} @@ -532,7 +587,9 @@ function isDestructiveGit(tokens) { plusRefspecForce = true; } } - return bareForce || (plusRefspecForce && !withLease); + if (bareForce || (plusRefspecForce && !withLease)) return true; + // A lease-checked force still rewrites a shared branch's history. + return withLease && pushTargetsSharedBranch(rest); } if (command === 'commit') { @@ -563,6 +620,53 @@ function isDestructiveGit(tokens) { }); } + if (command === 'branch') { + // `git branch -D` (long spelling: `--delete --force`) deletes a + // branch even when it is unmerged, orphaning its commits. Plain + // `-d` refuses when unmerged, so it is safe to leave ungated. + let del = false; + let force = false; + for (const t of rest) { + if (t === '--delete') { del = true; continue; } + if (t === '--force') { force = true; continue; } + if (!t.startsWith('-') || t.startsWith('--')) continue; + const body = t.slice(1); + if (body.includes('D')) return true; + if (body.includes('d')) del = true; + if (body.includes('f')) force = true; + } + return del && force; + } + + if (command === 'stash') { + // `drop` destroys one stash entry, `clear` the entire stash. + // `list`, `show`, `pop` and `apply` keep the entries recoverable. + return rest[0] === 'drop' || rest[0] === 'clear'; + } + + if (command === 'reflog') { + // `expire` and `delete` remove the recovery net that makes every + // other gated git command recoverable. + return rest[0] === 'expire' || rest[0] === 'delete'; + } + + if (command === 'update-ref') { + // `git update-ref -d ` deletes a ref directly. + return rest.includes('-d') || rest.includes('--delete'); + } + + if (command === 'restore') { + // `git restore ` overwrites the working tree from the index + // by default, the modern spelling of gated `git checkout -- `. + // Only `--staged` alone is non-destructive (it leaves the file on + // disk untouched); `--worktree` (the default target) is destructive. + const has = (long, short) => rest.some(t => + t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short))); + const staged = has('--staged', 'S'); + const worktree = has('--worktree', 'W'); + return worktree || !staged; + } + return false; } diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index ec200b8e8..fc3ab3106 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1978,13 +1978,72 @@ function runTests() { else failed++; if ( - test('allows git push --force-if-includes as a safety-checked variant', () => { - expectAllow('git push --force-with-lease --force-if-includes origin main', 'git push --force-if-includes'); + test('allows git push --force-if-includes as a safety-checked variant on a non-shared branch', () => { + expectAllow('git push --force-with-lease --force-if-includes origin feature-branch', 'git push --force-if-includes'); }) ) passed++; else failed++; + // --- Ref- and history-destroying git commands (issues #3154, #3151) --- + + const destructiveGitCases = [ + ['git branch -D feature', 'git branch -D'], + ['git branch --delete --force feature', 'git branch --delete --force'], + ['git branch -d -f feature', 'git branch -d -f'], + ['git stash drop', 'git stash drop'], + ['git stash drop stash@{0}', 'git stash drop stash@{0}'], + ['git stash clear', 'git stash clear'], + ['git reflog expire --expire=now --all', 'git reflog expire'], + ['git reflog delete HEAD@{2}', 'git reflog delete'], + ['git update-ref -d refs/heads/x', 'git update-ref -d'], + ['git update-ref --delete refs/heads/x', 'git update-ref --delete'], + ['git restore foo.ts', 'git restore '], + ['git restore .', 'git restore .'], + ['git restore --worktree foo.ts', 'git restore --worktree'], + ['git restore -W foo.ts', 'git restore -W'], + ['git restore --staged --worktree foo.ts', 'git restore --staged --worktree'], + ['git restore -s HEAD foo.ts', 'git restore --source without --staged'], + ['git push --force-with-lease origin main', 'git push --force-with-lease to main'], + ['git push --force-with-lease origin HEAD:main', 'git push --force-with-lease HEAD:main'], + ['git push --force-with-lease origin +refs/heads/master:refs/heads/master', 'git push --force-with-lease +refs/heads/master'], + ['git push --force-with-lease --force-if-includes origin main', 'git push --force-with-lease --force-if-includes to main'], + ['git push --force-with-lease --repo origin main', 'git push --force-with-lease --repo to main'] + ]; + for (const [command, label] of destructiveGitCases) { + if ( + test(`denies ${label} as destructive`, () => { + expectDestructiveDeny(command, label); + }) + ) + passed++; + else failed++; + } + + const safeGitCases = [ + ['git branch -d feature', 'git branch -d (refuses when unmerged)'], + ['git branch -f feature', 'git branch -f (no delete)'], + ['git stash list', 'git stash list'], + ['git stash show', 'git stash show'], + ['git reflog show', 'git reflog show'], + ['git update-ref refs/heads/x abc1234', 'git update-ref without -d'], + ['git restore --staged foo.ts', 'git restore --staged'], + ['git restore -S foo.ts', 'git restore -S'], + ['git restore --source=HEAD --staged foo.ts', 'git restore --source with --staged'], + ['git push --force-with-lease origin feature-branch', 'git push --force-with-lease to feature branch'], + ['git push --force-with-lease', 'git push --force-with-lease with no refspec'], + ['git push --force-with-lease -o ci.skip origin feature-branch', 'git push --force-with-lease with push option'] + ]; + for (const [command, label] of safeGitCases) { + if ( + test(`allows ${label}`, () => { + expectAllow(command, label); + }) + ) + passed++; + else failed++; + } + // --- Review-round-2 findings --- if ( From b14004f12fba7ad3af0aff7029953d35a4484e13 Mon Sep 17 00:00:00 2001 From: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:33:41 +0800 Subject: [PATCH 53/67] fix(continuous-learning-v2): allow sdk-cli entrypoint in observe.sh (#3171) --- skills/continuous-learning-v2/hooks/observe.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 61bdd85a0..bf800e772 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -155,7 +155,7 @@ fi # Non-interactive SDK automation is still filtered by Layers 2-5 below # (ECC_HOOK_PROFILE=minimal, ECC_SKIP_OBSERVE=1, agent_id, path exclusions). case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in - cli|sdk-ts|claude-desktop|claude-vscode) ;; + cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode) ;; *) exit 0 ;; esac From 08813f49ffa8eea64f581e1cf402315d0e7ee9bc Mon Sep 17 00:00:00 2001 From: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:56:34 +0800 Subject: [PATCH 54/67] test(security): Layer-1 observe.sh entrypoint allowlist evidence (#3171) Add focused security regression for sdk-cli allowlisting and document IOC scan + allowlist probe output under .pr/security-evidence-3171.md. Signed-off-by: Frank_zhu <58329837+Frank-zhu0404@users.noreply.github.com> --- .pr/security-evidence-3171.md | 49 ++++++++++ .../hooks/observe-entrypoint-security.test.js | 98 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 .pr/security-evidence-3171.md create mode 100644 tests/hooks/observe-entrypoint-security.test.js diff --git a/.pr/security-evidence-3171.md b/.pr/security-evidence-3171.md new file mode 100644 index 000000000..ffd145139 --- /dev/null +++ b/.pr/security-evidence-3171.md @@ -0,0 +1,49 @@ +# Security Evidence — PR #3172 / #3171 + +Commit under review: observe.sh Layer-1 allowlist adds `sdk-cli`. + +## Changed security-sensitive surface +- `skills/continuous-learning-v2/hooks/observe.sh` (agent hook entrypoint allowlist) + +## Threat model (bounded) +- **Risk if missing `sdk-cli`**: interactive Agent SDK CLI sessions never observe (availability/coverage gap). +- **Risk if allowlist too broad**: non-interactive bots could start the observer. Mitigated by Layers 2–5 (`ECC_HOOK_PROFILE=minimal`, `ECC_SKIP_OBSERVE=1`, `agent_id`, path exclusions) — unchanged by this PR. +- **No secrets / auth tokens / billing / webhook handlers** were modified. + +## Security-focused validation artifacts (this PR) +1. **Focused security regression test** (new): `tests/hooks/observe-entrypoint-security.test.js` + - Asserts source allowlist includes `sdk-cli` + - Asserts Layer-1 allows: `cli`, `sdk-ts`, `sdk-cli`, `claude-desktop`, `claude-vscode` + - Asserts Layer-1 rejects: `unknown-bot`, `ci-bot` +2. **Supply-chain IOC scan** (repo gate): `npm run security:ioc-scan` + +## Command output (local) + +### observe-entrypoint-security.test.js +```text + +=== observe.sh Layer-1 entrypoint security (#3171) === + + ✓ source allowlist includes sdk-cli + ✓ Layer-1 allows cli + ✓ Layer-1 allows sdk-ts + ✓ Layer-1 allows sdk-cli + ✓ Layer-1 allows claude-desktop + ✓ Layer-1 allows claude-vscode + ✓ Layer-1 rejects unknown-bot + ✓ Layer-1 rejects ci-bot + +All Layer-1 security checks passed. +``` + +### npm run security:ioc-scan +```text + +> ecc-universal@2.2.1 security:ioc-scan +> node scripts/ci/scan-supply-chain-iocs.js + +Supply-chain IOC scan passed for /workspace/pr-work/ECC-3171 (12 files inspected) +``` + +## Conclusion +Allowlist change is covered by a dedicated security regression test plus the repository IOC scan. Unknown entrypoints remain denied at Layer-1. diff --git a/tests/hooks/observe-entrypoint-security.test.js b/tests/hooks/observe-entrypoint-security.test.js new file mode 100644 index 000000000..482b52927 --- /dev/null +++ b/tests/hooks/observe-entrypoint-security.test.js @@ -0,0 +1,98 @@ +/** + * Security-focused regression: observe.sh Layer-1 entrypoint allowlist (#3171). + * + * sdk-cli must pass Layer-1 (interactive Agent SDK CLI). Unknown entrypoints + * must early-exit. Layers 2–5 still filter automated sessions. + */ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const observeShPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'hooks', + 'observe.sh' +); + +const isWindows = process.platform === 'win32'; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function layer1Probe(entrypoint) { + // Run only the Layer-1 case block extracted by line range (stable in this file). + const script = ` +set -euo pipefail +case "\${CLAUDE_CODE_ENTRYPOINT:-cli}" in + cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode) ;; + *) exit 0 ;; +esac +echo LAYER1_PASS +`; + // Defense in depth: assert the live observe.sh still matches this allowlist. + const src = fs.readFileSync(observeShPath, 'utf8'); + assert.ok( + src.includes('cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode'), + 'observe.sh Layer-1 allowlist drifted from security probe' + ); + return spawnSync('bash', ['-c', script], { + env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: entrypoint }, + encoding: 'utf8', + }); +} + +console.log('\n=== observe.sh Layer-1 entrypoint security (#3171) ===\n'); + +let failed = 0; +if (isWindows) { + console.log(' ⊘ skipped on Windows'); + process.exit(0); +} + +if ( + !test('source allowlist includes sdk-cli', () => { + const src = fs.readFileSync(observeShPath, 'utf8'); + assert.match(src, /cli\|sdk-ts\|sdk-cli\|claude-desktop\|claude-vscode/); + }) +) + failed++; + +for (const ep of ['cli', 'sdk-ts', 'sdk-cli', 'claude-desktop', 'claude-vscode']) { + if ( + !test(`Layer-1 allows ${ep}`, () => { + const r = layer1Probe(ep); + assert.equal(r.status, 0, `status=${r.status} stderr=${r.stderr}`); + assert.match(r.stdout || '', /LAYER1_PASS/); + }) + ) + failed++; +} + +for (const ep of ['unknown-bot', 'ci-bot']) { + if ( + !test(`Layer-1 rejects ${ep}`, () => { + const r = layer1Probe(ep); + assert.equal(r.status, 0); + assert.doesNotMatch(r.stdout || '', /LAYER1_PASS/); + }) + ) + failed++; +} + +console.log(failed === 0 ? '\nAll Layer-1 security checks passed.\n' : `\n${failed} failed\n`); +process.exit(failed === 0 ? 0 : 1); From 111387afe47ba077670875d446919373cf98465b Mon Sep 17 00:00:00 2001 From: Cocoon-Break <54054995+kuishou68@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:26:46 -0400 Subject: [PATCH 55/67] docs: fix dead MCP overview link in shortform guide (#3190) The Claude Code docs page at code.claude.com/docs/en/mcp-overview no longer exists (404). The current MCP page is https://code.claude.com/docs/en/mcp ("Connect Claude Code to tools via MCP"). Update the link in the English, zh-CN, and tr versions of the shortform guide. Co-authored-by: kuishou68 --- docs/tr/the-shortform-guide.md | 2 +- docs/zh-CN/the-shortform-guide.md | 2 +- the-shortform-guide.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tr/the-shortform-guide.md b/docs/tr/the-shortform-guide.md index 9e20acda0..6a894a175 100644 --- a/docs/tr/the-shortform-guide.md +++ b/docs/tr/the-shortform-guide.md @@ -420,7 +420,7 @@ affoon:~ ctx:65% Opus 4.5 19:52 - [Interactive Mode](https://code.claude.com/docs/en/interactive-mode) - [Memory Sistemi](https://code.claude.com/docs/en/memory) - [Subagent'lar](https://code.claude.com/docs/en/sub-agents) -- [MCP Genel Bakış](https://code.claude.com/docs/en/mcp-overview) +- [MCP Genel Bakış](https://code.claude.com/docs/en/mcp) --- diff --git a/docs/zh-CN/the-shortform-guide.md b/docs/zh-CN/the-shortform-guide.md index e662afa28..f5dcbb55d 100644 --- a/docs/zh-CN/the-shortform-guide.md +++ b/docs/zh-CN/the-shortform-guide.md @@ -421,7 +421,7 @@ affoon:~ ctx:65% Opus 4.5 19:52 * [交互模式](https://code.claude.com/docs/en/interactive-mode) * [记忆系统](https://code.claude.com/docs/en/memory) * [子代理](https://code.claude.com/docs/en/sub-agents) -* [MCP 概述](https://code.claude.com/docs/en/mcp-overview) +* [MCP 概述](https://code.claude.com/docs/en/mcp) *** diff --git a/the-shortform-guide.md b/the-shortform-guide.md index 1a32139db..726bba52f 100644 --- a/the-shortform-guide.md +++ b/the-shortform-guide.md @@ -420,7 +420,7 @@ affoon:~ ctx:65% Opus 4.5 19:52 - [Interactive Mode](https://code.claude.com/docs/en/interactive-mode) - [Memory System](https://code.claude.com/docs/en/memory) - [Subagents](https://code.claude.com/docs/en/sub-agents) -- [MCP Overview](https://code.claude.com/docs/en/mcp-overview) +- [MCP Overview](https://code.claude.com/docs/en/mcp) --- From 95448ad81d3b795b31e3c2d9acfa8a674a5d3e4c Mon Sep 17 00:00:00 2001 From: Eastern Date: Mon, 21 Sep 2026 02:26:49 +0800 Subject: [PATCH 56/67] fix: isolate Claude project hooks from ESM hosts (#3184) * fix: isolate Claude project hooks from ESM hosts * fix: handle user-owned Claude scripts package --- .../claude-project-scripts-package.json | 3 + scripts/lib/install-targets/claude-project.js | 18 ++++- tests/scripts/install-apply.test.js | 68 +++++++++++++++++++ tests/scripts/uninstall.test.js | 53 +++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 manifests/install-assets/claude-project-scripts-package.json diff --git a/manifests/install-assets/claude-project-scripts-package.json b/manifests/install-assets/claude-project-scripts-package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/manifests/install-assets/claude-project-scripts-package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/scripts/lib/install-targets/claude-project.js b/scripts/lib/install-targets/claude-project.js index a4fda3970..afd413716 100644 --- a/scripts/lib/install-targets/claude-project.js +++ b/scripts/lib/install-targets/claude-project.js @@ -9,6 +9,7 @@ const { } = require('./helpers'); const CLAUDE_ECC_NAMESPACE = 'ecc'; +const CLAUDE_PROJECT_COMMONJS_PACKAGE = 'manifests/install-assets/claude-project-scripts-package.json'; function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); @@ -65,7 +66,7 @@ module.exports = createInstallTargetAdapter({ return modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; - return paths + const operations = paths .filter(p => !isForeignPlatformPath(p, 'claude')) .flatMap(sourceRelativePath => { if ( @@ -93,6 +94,21 @@ module.exports = createInstallTargetAdapter({ return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); + + if (module.id !== 'hooks-runtime') { + return operations; + } + + return [ + ...['hooks', 'lib'].map(directory => createRemappedOperation( + adapter, + module.id, + CLAUDE_PROJECT_COMMONJS_PACKAGE, + path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'), + { strategy: 'preserve-relative-path' } + )), + ...operations, + ]; }); }, }); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 576ba4389..f2846495f 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -1056,6 +1056,74 @@ function runTests() { } })) passed++; else failed++; + if (test('isolates project hooks from ESM package scopes without overwriting user Claude package data', () => { + const homeDir = createTempDir('install-apply-claude-project-esm-home-'); + const projectDir = createTempDir('install-apply-claude-project-esm-'); + const claudeRoot = path.join(projectDir, '.claude'); + const userPackagePath = path.join(claudeRoot, 'package.json'); + const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json'); + const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json'); + const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json'); + const userPackage = '{"name":"user-claude-config","type":"module"}\n'; + const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n'; + + try { + fs.writeFileSync(path.join(projectDir, 'package.json'), '{"type":"module"}\n'); + fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true }); + fs.writeFileSync(userPackagePath, userPackage); + fs.writeFileSync(scriptsPackagePath, userScriptsPackage); + + const firstInstall = run( + ['--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(firstInstall.code, 0, firstInstall.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + assert.deepStrictEqual(readJson(hooksPackagePath), { type: 'commonjs' }); + assert.deepStrictEqual(readJson(libPackagePath), { type: 'commonjs' }); + + const hookResult = spawnSync( + process.execPath, + [path.join(claudeRoot, 'scripts', 'hooks', 'block-no-verify.js')], + { + input: JSON.stringify({ tool_input: { command: 'git commit --no-verify' } }), + encoding: 'utf8', + cwd: projectDir, + } + ); + assert.strictEqual(hookResult.status, 2, hookResult.stderr); + assert.match(hookResult.stderr, /no-verify/i); + + const secondInstall = run( + ['--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + + const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + const boundaryPaths = [hooksPackagePath, libPackagePath]; + const packageBoundaryOperations = state.operations.filter(operation => ( + boundaryPaths.includes(operation.destinationPath) + )); + assert.deepStrictEqual( + packageBoundaryOperations.map(operation => operation.destinationPath).sort(), + [...boundaryPaths].sort() + ); + assert.ok(packageBoundaryOperations.every(operation => operation.moduleId === 'hooks-runtime')); + assert.ok(packageBoundaryOperations.every(operation => ( + /^[a-f0-9]{64}$/i.test(operation.contentSha256) + ))); + assert.ok(!state.operations.some(operation => operation.destinationPath === userPackagePath)); + assert.ok(!state.operations.some(operation => operation.destinationPath === scriptsPackagePath)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing settings.json while disabling Claude co-author attribution', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); diff --git a/tests/scripts/uninstall.test.js b/tests/scripts/uninstall.test.js index 75759969d..a5532a256 100644 --- a/tests/scripts/uninstall.test.js +++ b/tests/scripts/uninstall.test.js @@ -132,6 +132,59 @@ function runTests() { } })) passed++; else failed++; + if (test('uninstalls the project hook module boundary and preserves user Claude package data', () => { + const homeDir = createTempDir('uninstall-claude-project-esm-home-'); + const projectRoot = createTempDir('uninstall-claude-project-esm-'); + const claudeRoot = path.join(projectRoot, '.claude'); + const userPackagePath = path.join(claudeRoot, 'package.json'); + const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json'); + const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json'); + const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json'); + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const userPackage = '{"name":"user-claude-config","type":"module"}\n'; + const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n'; + + try { + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"type":"module"}\n'); + fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true }); + fs.writeFileSync(userPackagePath, userPackage); + fs.writeFileSync(scriptsPackagePath, userScriptsPackage); + + execFileSync( + 'node', + [INSTALL_SCRIPT, '--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { + cwd: projectRoot, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: CLI_TIMEOUT_MS, + } + ); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(hooksPackagePath, 'utf8')), { type: 'commonjs' }); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(libPackagePath, 'utf8')), { type: 'commonjs' }); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + + const uninstallResult = run(['--target', 'claude-project'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + assert.ok(!fs.existsSync(hooksPackagePath)); + assert.ok(!fs.existsSync(libPackagePath)); + assert.ok(!fs.existsSync(statePath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('reverses non-copy operations and keeps unrelated files', () => { const homeDir = createTempDir('uninstall-home-'); const projectRoot = createTempDir('uninstall-project-'); From 9ac593b55cba44c8b20152a5c7f28d300a67ec7e Mon Sep 17 00:00:00 2001 From: auyua9 Date: Mon, 21 Sep 2026 02:26:52 +0800 Subject: [PATCH 57/67] fix(scripts): extract cross-platform openBrowser helper with structured launch result (#3180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/plan-canvas.js and scripts/control-pane.js both define their own openBrowser helpers for launching the user's default browser. The two implementations have diverged: - plan-canvas.js dispatches across darwin/win32/linux but its try/spawn try/catch does not catch async child errors (ENOENT/EACCES on hosts without the launcher command). Returns a bare true/false. - control-pane.js is darwin-only and silently returns early on Windows/Linux, so the two scripts behave inconsistently across platforms. When the launcher command is missing (e.g. headless CI without xdg-open) the JSON output still says 'browser: opened', lying to the agent. Changes: - scripts/lib/platform-launch.js: shared openBrowser helper that dispatches per platform, wires child 'error' so ENOENT/EACCES propagate, and returns {opened, reason} instead of a bare boolean. - scripts/plan-canvas.js: drops its local helper, imports the shared one, and adds browserReason to its JSON output. - scripts/control-pane.js: replaces its darwin-only branch with the shared helper and logs the structured reason on failure. - tests/lib/platform-launch.test.js: 7 node:test cases covering opener-command selection, invalid-URL guard, structured-result shape, and a smoke run for the actual spawn. Verification: node --test tests/lib/platform-launch.test.js → 7/7 pass. Co-authored-by: auyua9 --- scripts/control-pane.js | 16 +++--- scripts/lib/platform-launch.js | 92 +++++++++++++++++++++++++++++++ scripts/plan-canvas.js | 21 ++----- tests/lib/platform-launch.test.js | 48 ++++++++++++++++ 4 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 scripts/lib/platform-launch.js create mode 100644 tests/lib/platform-launch.test.js diff --git a/scripts/control-pane.js b/scripts/control-pane.js index c5b7215f4..e5234d9c2 100755 --- a/scripts/control-pane.js +++ b/scripts/control-pane.js @@ -10,16 +10,14 @@ const { } = require('./lib/control-pane/server'); const { describeMissingDependencyError } = require('./lib/missing-dependency'); +// openBrowser is now in scripts/lib/platform-launch.js — keep a thin wrapper +// for backwards compatibility, but surface the structured result. +const { openBrowser: launchOpenBrowser } = require('./lib/platform-launch'); function openBrowser(url) { - if (process.platform !== 'darwin') return; - const child = spawn('open', [url], { - stdio: 'ignore', - detached: true, - }); - child.on('error', error => { - console.error(`[control-pane] failed to open browser: ${error.message}`); - }); - child.unref(); + const result = launchOpenBrowser(url); + if (!result.opened) { + console.error(`[control-pane] failed to open browser: ${result.reason}`); + } } async function main(argv = process.argv) { diff --git a/scripts/lib/platform-launch.js b/scripts/lib/platform-launch.js new file mode 100644 index 000000000..ff2e8c0b5 --- /dev/null +++ b/scripts/lib/platform-launch.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Shared cross-platform browser launcher. + * + * Extracted from scripts/plan-canvas.js (which had a working but error-silent + * tri-platform branch) and scripts/control-pane.js (which had a darwin-only + * branch that silently no-op'd on Windows/Linux). This helper: + * + * 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform + * 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller + * instead of being swallowed by detached spawns + * 3. Returns a structured { opened, reason } result so CLI consumers can + * surface the truth (browser did/did not open) instead of a lying true/false + * + * The signature is intentionally small (single function, no class) so callers + * can import without picking up the rest of scripts/lib. + * + * Tests live at tests/lib/platform-launch.test.js. + */ + +const { spawn } = require('child_process'); + +/** + * Pick the platform-appropriate opener command + args. + * Returns [cmd, args] suitable for child_process.spawn. + * + * @param {NodeJS.Platform} platform + * @param {string} url + * @returns {[string, string[]]} + */ +function openerCommandFor(platform, url) { + if (platform === 'darwin') return ['open', [url]]; + if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]]; + return ['xdg-open', [url]]; +} + +/** + * Open a URL in the user's default browser, dispatching per-platform. + * + * Always returns a structured result so callers can: + * - show a clear error to the agent (no silent failures) + * - keep JSON CLI output truthful when browsers cannot launch + * + * @param {string} url + * @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform + * @returns {{ opened: boolean, reason: string }} + */ +function openBrowser(url, platform = process.platform) { + if (typeof url !== 'string' || url.length === 0) { + return { opened: false, reason: 'invalid-url' }; + } + + const [cmd, args] = openerCommandFor(platform, url); + let child; + try { + child = spawn(cmd, args, { + detached: true, + stdio: 'ignore', + }); + } catch (err) { + return { + opened: false, + reason: `spawn-threw:${err && err.code ? err.code : 'unknown'}`, + }; + } + + // Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed + // when the user has no `open` / `xdg-open` / `start` available. + let capturedError = null; + child.on('error', (err) => { + capturedError = err && err.code ? err.code : 'spawn-error'; + }); + + // Best-effort: detach so we don't keep the parent alive on the launcher. + try { + child.unref(); + } catch { + /* unref may throw on some platforms; safe to ignore */ + } + + if (capturedError) { + return { opened: false, reason: `child-error:${capturedError}` }; + } + return { opened: true, reason: 'spawned' }; +} + +module.exports = { + openBrowser, + openerCommandFor, +}; diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 816a0be7b..6ecefe49a 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -20,14 +20,13 @@ const fs = require('fs'); const http = require('http'); const path = require('path'); -const { spawn } = require('child_process'); - const { canonicalizeArtifactPath, createSessionStore, resolveStateDir, sessionKeyFor } = require('./lib/plan-canvas/sessions'); +const { openBrowser } = require('./lib/platform-launch'); const { DEFAULT_HOST, createPlanCanvasServer, @@ -194,19 +193,7 @@ async function ensureServer({ stateDir, port }) { throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`); } -function openBrowser(url) { - const platform = process.platform; - const [cmd, args] = - platform === 'darwin' ? ['open', [url]] - : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] - : ['xdg-open', [url]]; - try { - spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); - return true; - } catch { - return false; - } -} + function output(payload) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -232,11 +219,13 @@ async function cmdOpen(file, args, { stateDir, port }) { if (res.statusCode === 409) return res.body; if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`); const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`; - const launched = args.includes('--no-open') ? false : openBrowser(url); + const launchResult = args.includes('--no-open') ? { opened: false, reason: 'no-open-flag' } : openBrowser(url); + const launched = launchResult.opened; return { status: 'open', url, browser: launched ? 'opened' : 'not opened', + browserReason: launchResult.reason, next_step: 'Run `ecc-plan-canvas await ` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.' }; diff --git a/tests/lib/platform-launch.test.js b/tests/lib/platform-launch.test.js new file mode 100644 index 000000000..482aaa006 --- /dev/null +++ b/tests/lib/platform-launch.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { openBrowser, openerCommandFor } = require('../../scripts/lib/platform-launch'); + +test('openerCommandFor: darwin returns open', () => { + assert.deepEqual(openerCommandFor('darwin', 'http://x'), ['open', ['http://x']]); +}); + +test('openerCommandFor: win32 returns cmd /c start', () => { + assert.deepEqual(openerCommandFor('win32', 'http://x'), ['cmd', ['/c', 'start', '', 'http://x']]); +}); + +test('openerCommandFor: linux returns xdg-open', () => { + assert.deepEqual(openerCommandFor('linux', 'http://x'), ['xdg-open', ['http://x']]); +}); + +test('openerCommandFor: unknown falls through to xdg-open', () => { + assert.deepEqual(openerCommandFor('freebsd', 'http://x'), ['xdg-open', ['http://x']]); +}); + +test('openBrowser: invalid url returns invalid-url without spawning', () => { + const r1 = openBrowser(''); + assert.equal(r1.opened, false); + assert.equal(r1.reason, 'invalid-url'); + const r2 = openBrowser(null); + assert.equal(r2.opened, false); + assert.equal(r2.reason, 'invalid-url'); +}); + +test('openBrowser: returns structured { opened, reason }', () => { + // Use a platform + URL that's syntactically valid. We can't easily assert + // whether the browser actually opens in CI, but the structure must match. + const r = openBrowser('http://localhost:0', 'linux'); + assert.equal(typeof r.opened, 'boolean'); + assert.equal(typeof r.reason, 'string'); + assert.ok(r.reason.length > 0); +}); + +test('openBrowser: uses xdg-open on linux', () => { + // Spy by stubbing spawn via require cache (not possible without mocking module). + // Smoke-test: just ensure the function is callable. + const r = openBrowser('http://localhost:0', 'linux'); + // Either opened=true (xdg-open exists on runner) or opened=false with reason + assert.ok(['spawned', 'child-error:ENOENT', 'child-error:EACCES', 'spawn-threw:ENOENT'].includes(r.reason) + || r.opened === true || r.opened === false); +}); From 9606a741af13fe327ba9e1730a119a409779a6f9 Mon Sep 17 00:00:00 2001 From: Tamer Date: Sun, 20 Sep 2026 21:37:47 +0200 Subject: [PATCH 58/67] fix(control-pane): handle proximity HTTP errors (#3191) --- scripts/lib/control-pane/proximity-viz.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/lib/control-pane/proximity-viz.js b/scripts/lib/control-pane/proximity-viz.js index 5c40a0ac4..2688e8b6b 100644 --- a/scripts/lib/control-pane/proximity-viz.js +++ b/scripts/lib/control-pane/proximity-viz.js @@ -172,7 +172,10 @@ function renderProximityVizHtml() { } function poll() { - fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) { + fetch('/api/proximity').then(function (r) { + if (!r.ok) throw new Error('http ' + r.status); + return r.json(); + }).then(function (data) { applySnapshot(data && data.enabled ? data : (data || {})); }).catch(function () { document.getElementById('status').textContent = 'offline'; From 651118bd50ab1289a6651a8bf21cb051ded44493 Mon Sep 17 00:00:00 2001 From: Tamer Date: Sun, 20 Sep 2026 21:37:51 +0200 Subject: [PATCH 59/67] Fix/control plane canvas size (#3192) * fix(control-plane): constrain visualization canvas layout * fix(control-plane): constrain visualization canvas layout * test(control-plane): guard bounded canvas layout --- scripts/lib/control-pane/control-plane-view-ui.js | 4 ++-- scripts/lib/control-pane/proximity-viz.js | 4 ++-- tests/lib/control-plane-view-ui.test.js | 9 +++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index 2fe9e95cd..2abf84d9b 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -26,8 +26,8 @@ function renderControlPlaneViewHtml() { header nav { margin-left: auto; font-size: 12px; } header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; } header nav a:hover { color: #e6edf3; } - #wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); } - #stage { position: relative; border-right: 1px solid #1f2630; } + #wrap { display: grid; grid-template-columns: 1fr 360px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); } + #stage { position: relative; height: 100%; min-height: 0; border-right: 1px solid #1f2630; } canvas { width: 100%; height: 100%; display: block; } #side { padding: 12px 14px; overflow-y: auto; } #side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; } diff --git a/scripts/lib/control-pane/proximity-viz.js b/scripts/lib/control-pane/proximity-viz.js index 2688e8b6b..b173605a6 100644 --- a/scripts/lib/control-pane/proximity-viz.js +++ b/scripts/lib/control-pane/proximity-viz.js @@ -27,8 +27,8 @@ function renderProximityVizHtml() { header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; } header h1 { font-size: 15px; margin: 0; } header .sub { color: #8b949e; font-size: 12px; } - #wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); } - #stage { position: relative; } + #wrap { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); } + #stage { position: relative; height: 100%; min-height: 0; } canvas { width: 100%; height: 100%; display: block; } #side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; } #side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; } diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js index 2d0ab7880..67bbca6c9 100644 --- a/tests/lib/control-plane-view-ui.test.js +++ b/tests/lib/control-plane-view-ui.test.js @@ -3,6 +3,15 @@ const assert = require('assert'); const vm = require('vm'); const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui'); +const { renderProximityVizHtml } = require('../../scripts/lib/control-pane/proximity-viz'); + +const controlPlaneHtml = renderControlPlaneViewHtml(); +assert.ok(controlPlaneHtml.includes('grid-template-rows: minmax(0, 1fr)')); +assert.ok(controlPlaneHtml.includes('#stage { position: relative; height: 100%; min-height: 0;')); + +const proximityHtml = renderProximityVizHtml(); +assert.ok(proximityHtml.includes('grid-template-rows: minmax(0, 1fr)')); +assert.ok(proximityHtml.includes('#stage { position: relative; height: 100%; min-height: 0;')); async function renderResponse(ok, data) { const elements = new Map(); From 2b6e839771e53096d8451a213d40dc64ec8acac0 Mon Sep 17 00:00:00 2001 From: Tamer Date: Sun, 20 Sep 2026 21:37:57 +0200 Subject: [PATCH 60/67] Fix/proximity a11y risk cues (#3193) * fix(control-plane): add non-color airspace risk cues * test(control-plane): cover airspace accessibility cues --- scripts/lib/control-pane/proximity-viz.js | 65 ++++++++++++++++++++--- tests/lib/proximity-viz-a11y.test.js | 17 ++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/lib/proximity-viz-a11y.test.js diff --git a/scripts/lib/control-pane/proximity-viz.js b/scripts/lib/control-pane/proximity-viz.js index b173605a6..27e6cf499 100644 --- a/scripts/lib/control-pane/proximity-viz.js +++ b/scripts/lib/control-pane/proximity-viz.js @@ -32,6 +32,7 @@ function renderProximityVizHtml() { canvas { width: 100%; height: 100%; display: block; } #side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; } #side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; } + #side h2:not(:first-child) { margin-top: 16px; } .adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; } .adv.resolution { border-color: #b3402f; } .adv.advisory { border-color: #9a6700; } @@ -41,8 +42,11 @@ function renderProximityVizHtml() { .adv .who { color: #c9d1d9; } .adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; } .empty { color: #6e7681; } + .agent-row { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12px; } + .agent-row .who { color: #c9d1d9; overflow-wrap: anywhere; } + .agent-row .risk { margin-left: auto; color: #8b949e; white-space: nowrap; } #legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; } - .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } + .shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; } @@ -53,16 +57,18 @@ function renderProximityVizHtml() {
- + Agent airspace visualization; see the Agents panel for per-agent risk.
-
clear
-
traffic advisory (transmit)
-
resolution (steer)
+
●clear
+
■traffic advisory (transmit)
+
▲resolution (steer)

Advisories

No advisories - airspace clear.
+

Agents

+
No agents.