35 Commits

Author SHA1 Message Date
Affaan Mustafa
1b3ccb85aa docs: mark continuous-learning v1 as legacy 2026-04-08 16:31:58 -07:00
Affaan Mustafa
2e5e94cb7f fix: harden claude plugin manifest surfaces 2026-04-08 16:27:30 -07:00
Affaan Mustafa
adfe8a8311 feat: auto-prune inactive ecc2 worktrees 2026-04-08 16:08:29 -07:00
Affaan Mustafa
b3f781a648 feat: default ecc2 worktrees through policy 2026-04-08 15:58:31 -07:00
Affaan Mustafa
86cbe3d616 feat: add c language compatibility 2026-04-08 15:42:49 -07:00
Affaan Mustafa
9bd8e8b3c7 fix: resolve markdownlint violations 2026-04-08 15:40:26 -07:00
Affaan Mustafa
e226772a72 feat: add gemini agent adapter 2026-04-08 15:38:49 -07:00
Affaan Mustafa
e363c54057 fix: treat oauth mcp 401 probes as reachable 2026-04-08 15:34:34 -07:00
Affaan Mustafa
eb274d25d9 feat: add ecc2 split diff viewer 2026-04-08 15:30:21 -07:00
Affaan Mustafa
dada133784 feat: surface ecc2 daemon auto-merge activity 2026-04-08 15:27:16 -07:00
Affaan Mustafa
d8c8178f92 feat: add ecc2 worktree conflict protocol 2026-04-08 15:17:45 -07:00
Affaan Mustafa
27d7964bb1 feat: add ecc2 worktree auto-merge policy 2026-04-08 15:11:22 -07:00
Affaan Mustafa
e6460534e3 feat: add ecc2 bulk worktree merge actions 2026-04-08 15:04:52 -07:00
Affaan Mustafa
4834dfd280 feat: add ecc2 worktree merge actions 2026-04-08 14:57:46 -07:00
Affaan Mustafa
7f2c14ecf8 feat: surface ecc2 worktree pressure 2026-04-08 14:43:42 -07:00
Affaan Mustafa
027d77468e feat: add ecc2 dashboard worktree pruning 2026-04-08 14:33:30 -07:00
Affaan Mustafa
689235af16 feat: add ecc2 worktree pruning command 2026-04-08 14:30:08 -07:00
Affaan Mustafa
4834b63b35 feat: add ecc2 global worktree status 2026-04-08 14:13:26 -07:00
Affaan Mustafa
2dee4072a3 feat: add ecc2 worktree patch previews 2026-04-08 14:10:24 -07:00
Affaan Mustafa
e7be2ddf8d feat: add ecc2 worktree status checks 2026-04-08 14:04:55 -07:00
Affaan Mustafa
10b8471e3c feat: add ecc2 worktree status command 2026-04-08 14:02:01 -07:00
Affaan Mustafa
dd14888f5f feat: add ecc2 worktree merge readiness 2026-04-08 13:54:31 -07:00
Affaan Mustafa
87d520f0b1 feat: add ecc2 diff viewer mode 2026-04-08 13:49:35 -07:00
Affaan Mustafa
5070b2d785 feat: add ecc2 worktree file previews 2026-04-08 13:45:32 -07:00
Affaan Mustafa
afb97961e3 feat: add ecc2 maintain coordination command 2026-04-08 13:31:11 -07:00
Affaan Mustafa
dc12e902b1 feat: add ecc2 coordinate backlog health checks 2026-04-08 13:26:45 -07:00
Affaan Mustafa
2b7b717664 feat: add ecc2 coordinate backlog json output 2026-04-08 13:24:32 -07:00
Affaan Mustafa
d738089e3e feat: add ecc2 looping backlog coordination 2026-04-08 13:22:02 -07:00
Affaan Mustafa
bcf8d0617e feat: add ecc2 coordination status health metadata 2026-04-08 13:19:24 -07:00
Affaan Mustafa
da4c7791fe feat: add ecc2 coordination status health checks 2026-04-08 13:16:45 -07:00
Affaan Mustafa
53d8cee6f8 feat: add ecc2 coordination status json output 2026-04-08 13:15:21 -07:00
Affaan Mustafa
cd94878374 feat: add ecc2 coordination status command 2026-04-08 13:13:46 -07:00
Affaan Mustafa
0ff58108e4 fix: restore agent yaml command export 2026-04-08 12:58:02 -07:00
Affaan Mustafa
1bc9b9c585 feat: escalate ecc2 chronic saturation 2026-04-08 12:39:34 -07:00
Affaan Mustafa
10e34aa47a feat: track ecc2 chronic saturation streak 2026-04-08 12:36:32 -07:00
36 changed files with 6176 additions and 381 deletions

View File

@@ -1,7 +1,5 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "ecc",
"description": "Battle-tested Claude Code configurations from an Anthropic hackathon winner — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use",
"owner": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com"

View File

@@ -25,8 +25,8 @@ This is a **production-ready AI coding plugin** providing 47 specialized agents,
| e2e-runner | End-to-end Playwright testing | Critical user flows |
| refactor-cleaner | Dead code cleanup | Code maintenance |
| doc-updater | Documentation and codemaps | Updating docs |
| cpp-reviewer | C++ code review | C++ projects |
| cpp-build-resolver | C++ build errors | C++ build failures |
| cpp-reviewer | C/C++ code review | C and C++ projects |
| cpp-build-resolver | C/C++ build errors | C and C++ build failures |
| docs-lookup | Documentation lookup via Context7 | API/docs questions |
| go-reviewer | Go code review | Go projects |
| go-build-resolver | Go build errors | Go build failures |

View File

@@ -351,7 +351,7 @@ everything-claude-code/
| |-- market-research/ # Source-attributed market, competitor, and investor research (NEW)
| |-- investor-materials/ # Pitch decks, one-pagers, memos, and financial models (NEW)
| |-- investor-outreach/ # Personalized fundraising outreach and follow-up (NEW)
| |-- continuous-learning/ # Auto-extract patterns from sessions (Longform Guide)
| |-- continuous-learning/ # Legacy v1 Stop-hook pattern extraction
| |-- continuous-learning-v2/ # Instinct-based learning with confidence scoring
| |-- iterative-retrieval/ # Progressive context refinement for subagents
| |-- strategic-compact/ # Manual compaction suggestions (Longform Guide)
@@ -515,7 +515,7 @@ Use the `/skill-create` command for local analysis without external services:
```bash
/skill-create # Analyze current repo
/skill-create --instincts # Also generate instincts for continuous-learning
/skill-create --instincts # Also generate instincts for continuous-learning-v2
```
This analyzes your git history locally and generates SKILL.md files.
@@ -580,6 +580,7 @@ The instinct-based learning system automatically learns your patterns:
```
See `skills/continuous-learning-v2/` for full documentation.
Keep `continuous-learning/` only when you explicitly want the legacy v1 Stop-hook learned-skill flow.
---

View File

@@ -1,6 +1,6 @@
# Working Context
Last updated: 2026-04-05
Last updated: 2026-04-08
## Purpose
@@ -10,7 +10,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa
- Default branch: `main`
- Public release surface is aligned at `v1.10.0`
- Public catalog truth is `39` agents, `73` commands, and `179` skills
- Public catalog truth is `47` agents, `79` commands, and `181` skills
- Public plugin slug is now `ecc`; legacy `everything-claude-code` install paths remain supported for compatibility
- Release discussion: `#1272`
- ECC 2.0 exists in-tree and builds, but it is still alpha rather than GA
@@ -36,6 +36,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa
- control plane primitives
- operator surface
- self-improving skills
- keep `agent.yaml` export parity with the shipped `commands/` and `skills/` directories so modern install surfaces do not silently lose command registration
- Skill quality:
- rewrite content-facing skills to use source-backed voice modeling
- remove generic LLM rhetoric, canned CTA patterns, and forced platform stereotypes
@@ -175,3 +176,4 @@ Keep this file detailed for only the current sprint, blockers, and next actions.
- `skills/oura-health` and `skills/pmx-guidelines` are user- or project-specific, not canonical ECC surfaces
- `docs/releases/2.0.0-preview/*` is premature collateral and should be rebuilt from current product truth later
- nested `skills/hermes-generated/*` is superseded by the top-level ECC-native operator skills already ported to `main`
- 2026-04-08: Fixed the command-export regression reported in `#1327` by restoring a canonical `commands:` section in `agent.yaml` and adding `tests/ci/agent-yaml-surface.test.js` to enforce exact parity between the YAML export surface and the real `commands/` directory. Verified with the full repo test sweep: `1764/1764` passing.

View File

@@ -143,6 +143,86 @@ skills:
- videodb
- visa-doc-translate
- x-api
commands:
- agent-sort
- aside
- build-fix
- checkpoint
- claw
- code-review
- context-budget
- cpp-build
- cpp-review
- cpp-test
- devfleet
- docs
- e2e
- eval
- evolve
- feature-dev
- flutter-build
- flutter-review
- flutter-test
- gan-build
- gan-design
- go-build
- go-review
- go-test
- gradle-build
- harness-audit
- hookify
- hookify-configure
- hookify-help
- hookify-list
- instinct-export
- instinct-import
- instinct-status
- jira
- kotlin-build
- kotlin-review
- kotlin-test
- learn
- learn-eval
- loop-start
- loop-status
- model-route
- multi-backend
- multi-execute
- multi-frontend
- multi-plan
- multi-workflow
- orchestrate
- plan
- pm2
- projects
- promote
- prompt-optimize
- prp-commit
- prp-implement
- prp-plan
- prp-pr
- prp-prd
- prune
- python-review
- quality-gate
- refactor-clean
- resume-session
- review-pr
- rules-distill
- rust-build
- rust-review
- rust-test
- santa-loop
- save-session
- sessions
- setup-pm
- skill-create
- skill-health
- tdd
- test-coverage
- update-codemaps
- update-docs
- verify
tags:
- agent-harness
- developer-tools

View File

@@ -1,6 +1,6 @@
---
description: Quick commit with natural language file targeting — describe what to commit in plain English
argument-hint: [target description] (blank = all changes)
description: "Quick commit with natural language file targeting — describe what to commit in plain English"
argument-hint: "[target description] (blank = all changes)"
---
# Smart Commit

View File

@@ -1,6 +1,6 @@
---
description: Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes
argument-hint: [base-branch] (default: main)
description: "Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes"
argument-hint: "[base-branch] (default: main)"
---
# Create Pull Request

View File

@@ -1,6 +1,6 @@
---
description: Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning
argument-hint: [feature/product idea] (blank = start with questions)
description: "Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning"
argument-hint: "[feature/product idea] (blank = start with questions)"
---
# Product Requirements Document Generator

View File

@@ -25,6 +25,7 @@
| **Git 推送提醒器** | `Bash` | 在 `git push` 前提醒检查变更 | 0 (警告) |
| **文档文件警告器** | `Write` | 对非标准 `.md`/`.txt` 文件发出警告(允许 README、CLAUDE、CONTRIBUTING、CHANGELOG、LICENSE、SKILL、docs/、skills/);跨平台路径处理 | 0 (警告) |
| **策略性压缩提醒器** | `Edit\|Write` | 建议在逻辑间隔(约每 50 次工具调用)手动执行 `/compact` | 0 (警告) |
### PostToolUse 钩子
| 钩子 | 匹配器 | 功能 |

View File

@@ -31,6 +31,8 @@ pub struct Config {
pub default_agent: String,
pub auto_dispatch_unread_handoffs: bool,
pub auto_dispatch_limit_per_session: usize,
pub auto_create_worktrees: bool,
pub auto_merge_ready_worktrees: bool,
pub cost_budget_usd: f64,
pub token_budget: u64,
pub theme: Theme,
@@ -57,6 +59,8 @@ impl Default for Config {
default_agent: "claude".to_string(),
auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true,
auto_merge_ready_worktrees: false,
cost_budget_usd: 10.0,
token_budget: 500_000,
theme: Theme::Dark,
@@ -154,6 +158,11 @@ theme = "Dark"
config.auto_dispatch_limit_per_session,
defaults.auto_dispatch_limit_per_session
);
assert_eq!(config.auto_create_worktrees, defaults.auto_create_worktrees);
assert_eq!(
config.auto_merge_ready_worktrees,
defaults.auto_merge_ready_worktrees
);
}
#[test]
@@ -174,11 +183,13 @@ theme = "Dark"
}
#[test]
fn save_round_trips_auto_dispatch_settings() {
fn save_round_trips_automation_settings() {
let path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4()));
let mut config = Config::default();
config.auto_dispatch_unread_handoffs = true;
config.auto_dispatch_limit_per_session = 9;
config.auto_create_worktrees = false;
config.auto_merge_ready_worktrees = true;
config.save_to_path(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
@@ -186,6 +197,8 @@ theme = "Dark"
assert!(loaded.auto_dispatch_unread_handoffs);
assert_eq!(loaded.auto_dispatch_limit_per_session, 9);
assert!(!loaded.auto_create_worktrees);
assert!(loaded.auto_merge_ready_worktrees);
let _ = std::fs::remove_file(path);
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
tracing::error!("Backlog coordination pass failed: {e}");
}
if let Err(e) = maybe_auto_merge_ready_worktrees(&db, &cfg).await {
tracing::error!("Worktree auto-merge pass failed: {e}");
}
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await {
tracing::error!("Worktree auto-prune pass failed: {e}");
}
time::sleep(heartbeat_interval).await;
}
}
@@ -97,15 +105,19 @@ fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> {
}
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let summary = maybe_auto_dispatch_with_recorder(cfg, || {
manager::auto_dispatch_backlog(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
}, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads))
let summary = maybe_auto_dispatch_with_recorder(
cfg,
|| {
manager::auto_dispatch_backlog(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
},
|routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads),
)
.await?;
Ok(summary.routed)
}
@@ -116,26 +128,34 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> {
cfg,
&activity,
|| {
maybe_auto_dispatch_with_recorder(cfg, || {
manager::auto_dispatch_backlog(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
}, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads))
maybe_auto_dispatch_with_recorder(
cfg,
|| {
manager::auto_dispatch_backlog(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
},
|routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads),
)
},
|| {
maybe_auto_rebalance_with_recorder(cfg, || {
manager::rebalance_all_teams(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
}, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads))
maybe_auto_rebalance_with_recorder(
cfg,
|| {
manager::rebalance_all_teams(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
},
|rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads),
)
},
|routed, leads| db.record_daemon_recovery_dispatch_pass(routed, leads),
)
@@ -163,7 +183,11 @@ where
tracing::warn!(
"Skipping immediate dispatch retry because chronic saturation cooloff is active"
);
return Ok((DispatchPassSummary::default(), rebalanced, DispatchPassSummary::default()));
return Ok((
DispatchPassSummary::default(),
rebalanced,
DispatchPassSummary::default(),
));
}
let first_dispatch = dispatch().await?;
if first_dispatch.routed > 0 {
@@ -206,7 +230,11 @@ where
F: Fn() -> Fut,
Fut: Future<Output = Result<Vec<manager::LeadDispatchOutcome>>>,
{
Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(())).await?.routed)
Ok(
maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(()))
.await?
.routed,
)
}
async fn maybe_auto_dispatch_with_recorder<F, Fut, R>(
@@ -254,9 +282,7 @@ where
);
}
if deferred > 0 {
tracing::warn!(
"Deferred {deferred} task handoff(s) because delegate teams were saturated"
);
tracing::warn!("Deferred {deferred} task handoff(s) because delegate teams were saturated");
}
Ok(DispatchPassSummary {
@@ -267,15 +293,19 @@ where
}
async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result<usize> {
maybe_auto_rebalance_with_recorder(cfg, || {
manager::rebalance_all_teams(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
}, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads))
maybe_auto_rebalance_with_recorder(
cfg,
|| {
manager::rebalance_all_teams(
db,
cfg,
&cfg.default_agent,
true,
cfg.max_parallel_sessions,
)
},
|rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads),
)
.await
}
@@ -315,6 +345,109 @@ where
Ok(rerouted)
}
async fn maybe_auto_merge_ready_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
maybe_auto_merge_ready_worktrees_with_recorder(
cfg,
|| manager::merge_ready_worktrees(db, true),
|merged, active, conflicted, dirty, failed| {
db.record_daemon_auto_merge_pass(merged, active, conflicted, dirty, failed)
},
)
.await
}
async fn maybe_auto_merge_ready_worktrees_with<F, Fut>(cfg: &Config, merge: F) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreeBulkMergeOutcome>>,
{
maybe_auto_merge_ready_worktrees_with_recorder(cfg, merge, |_, _, _, _, _| Ok(())).await
}
async fn maybe_auto_merge_ready_worktrees_with_recorder<F, Fut, R>(
cfg: &Config,
merge: F,
mut record: R,
) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreeBulkMergeOutcome>>,
R: FnMut(usize, usize, usize, usize, usize) -> Result<()>,
{
if !cfg.auto_merge_ready_worktrees {
return Ok(0);
}
let outcome = merge().await?;
let merged = outcome.merged.len();
let active = outcome.active_with_worktree_ids.len();
let conflicted = outcome.conflicted_session_ids.len();
let dirty = outcome.dirty_worktree_ids.len();
let failed = outcome.failures.len();
record(merged, active, conflicted, dirty, failed)?;
if merged > 0 {
tracing::info!("Auto-merged {merged} ready worktree(s)");
}
if conflicted > 0 {
tracing::warn!(
"Skipped {} conflicted worktree(s) during auto-merge",
conflicted
);
}
if dirty > 0 {
tracing::warn!("Skipped {} dirty worktree(s) during auto-merge", dirty);
}
if active > 0 {
tracing::info!("Skipped {active} active worktree(s) during auto-merge");
}
if failed > 0 {
tracing::warn!("Auto-merge failed for {failed} worktree(s)");
}
Ok(merged)
}
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
maybe_auto_prune_inactive_worktrees_with_recorder(
|| manager::prune_inactive_worktrees(db),
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
)
.await
}
async fn maybe_auto_prune_inactive_worktrees_with<F, Fut>(prune: F) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
{
maybe_auto_prune_inactive_worktrees_with_recorder(prune, |_, _| Ok(())).await
}
async fn maybe_auto_prune_inactive_worktrees_with_recorder<F, Fut, R>(
prune: F,
mut record: R,
) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
R: FnMut(usize, usize) -> Result<()>,
{
let outcome = prune().await?;
let pruned = outcome.cleaned_session_ids.len();
let active = outcome.active_with_worktree_ids.len();
record(pruned, active)?;
if pruned > 0 {
tracing::info!("Auto-pruned {pruned} inactive worktree(s)");
}
if active > 0 {
tracing::info!("Skipped {active} active worktree(s) during auto-prune");
}
Ok(pruned)
}
#[cfg(unix)]
fn pid_is_alive(pid: u32) -> bool {
if pid == 0 {
@@ -528,7 +661,8 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()> {
async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()>
{
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -607,7 +741,8 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()> {
async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()>
{
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -653,7 +788,8 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure() -> Result<()> {
async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure(
) -> Result<()> {
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -664,12 +800,22 @@ mod tests {
last_dispatch_routed: 0,
last_dispatch_deferred: 2,
last_dispatch_leads: 1,
chronic_saturation_streak: 1,
last_recovery_dispatch_at: None,
last_recovery_dispatch_routed: 0,
last_recovery_dispatch_leads: 0,
last_rebalance_at: None,
last_rebalance_rerouted: 0,
last_rebalance_leads: 0,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let dispatch_order = order.clone();
@@ -708,7 +854,8 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work() -> Result<()> {
async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work(
) -> Result<()> {
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -719,12 +866,22 @@ mod tests {
last_dispatch_routed: 0,
last_dispatch_deferred: 2,
last_dispatch_leads: 1,
chronic_saturation_streak: 1,
last_recovery_dispatch_at: None,
last_recovery_dispatch_routed: 0,
last_recovery_dispatch_leads: 0,
last_rebalance_at: None,
last_rebalance_rerouted: 0,
last_rebalance_leads: 0,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
let recorded = std::sync::Arc::new(std::sync::Mutex::new(None));
let recorded_clone = recorded.clone();
@@ -755,7 +912,8 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help() -> Result<()> {
async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help(
) -> Result<()> {
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -766,12 +924,22 @@ mod tests {
last_dispatch_routed: 0,
last_dispatch_deferred: 3,
last_dispatch_leads: 1,
chronic_saturation_streak: 1,
last_recovery_dispatch_at: None,
last_recovery_dispatch_routed: 0,
last_recovery_dispatch_leads: 0,
last_rebalance_at: Some(now - chrono::Duration::seconds(1)),
last_rebalance_rerouted: 0,
last_rebalance_leads: 1,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_clone = calls.clone();
@@ -803,7 +971,67 @@ mod tests {
}
#[tokio::test]
async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy() -> Result<()> {
async fn coordinate_backlog_cycle_skips_dispatch_when_persistent_saturation_streak_hits_cooloff(
) -> Result<()> {
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
};
let now = chrono::Utc::now();
let activity = DaemonActivity {
last_dispatch_at: Some(now),
last_dispatch_routed: 0,
last_dispatch_deferred: 1,
last_dispatch_leads: 1,
chronic_saturation_streak: 3,
last_recovery_dispatch_at: None,
last_recovery_dispatch_routed: 0,
last_recovery_dispatch_leads: 0,
last_rebalance_at: Some(now - chrono::Duration::seconds(1)),
last_rebalance_rerouted: 0,
last_rebalance_leads: 1,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_clone = calls.clone();
let (first, rebalanced, recovery) = coordinate_backlog_cycle_with(
&cfg,
&activity,
move || {
let calls_clone = calls_clone.clone();
async move {
calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(DispatchPassSummary {
routed: 1,
deferred: 0,
leads: 1,
})
}
},
|| async move { Ok(0) },
|_, _| Ok(()),
)
.await?;
assert_eq!(first, DispatchPassSummary::default());
assert_eq!(rebalanced, 0);
assert_eq!(recovery, DispatchPassSummary::default());
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
Ok(())
}
#[tokio::test]
async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy(
) -> Result<()> {
let cfg = Config {
auto_dispatch_unread_handoffs: true,
..Config::default()
@@ -814,12 +1042,22 @@ mod tests {
last_dispatch_routed: 2,
last_dispatch_deferred: 0,
last_dispatch_leads: 1,
chronic_saturation_streak: 0,
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
last_recovery_dispatch_routed: 1,
last_recovery_dispatch_leads: 1,
last_rebalance_at: Some(now),
last_rebalance_rerouted: 1,
last_rebalance_leads: 1,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
let rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let rebalance_calls_clone = rebalance_calls.clone();
@@ -957,4 +1195,91 @@ mod tests {
let _ = std::fs::remove_file(path);
Ok(())
}
#[tokio::test]
async fn maybe_auto_merge_ready_worktrees_noops_when_disabled() -> Result<()> {
let mut cfg = Config::default();
cfg.auto_merge_ready_worktrees = false;
let invoked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let invoked_flag = invoked.clone();
let merged = maybe_auto_merge_ready_worktrees_with(&cfg, move || {
let invoked_flag = invoked_flag.clone();
async move {
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(manager::WorktreeBulkMergeOutcome {
merged: Vec::new(),
active_with_worktree_ids: Vec::new(),
conflicted_session_ids: Vec::new(),
dirty_worktree_ids: Vec::new(),
failures: Vec::new(),
})
}
})
.await?;
assert_eq!(merged, 0);
assert!(!invoked.load(std::sync::atomic::Ordering::SeqCst));
Ok(())
}
#[tokio::test]
async fn maybe_auto_merge_ready_worktrees_merges_ready_worktrees_when_enabled() -> Result<()> {
let mut cfg = Config::default();
cfg.auto_merge_ready_worktrees = true;
let merged = maybe_auto_merge_ready_worktrees_with(&cfg, || async move {
Ok(manager::WorktreeBulkMergeOutcome {
merged: vec![
manager::WorktreeMergeOutcome {
session_id: "worker-a".to_string(),
branch: "ecc/worker-a".to_string(),
base_branch: "main".to_string(),
already_up_to_date: false,
cleaned_worktree: true,
},
manager::WorktreeMergeOutcome {
session_id: "worker-b".to_string(),
branch: "ecc/worker-b".to_string(),
base_branch: "main".to_string(),
already_up_to_date: true,
cleaned_worktree: true,
},
],
active_with_worktree_ids: vec!["worker-c".to_string()],
conflicted_session_ids: vec!["worker-d".to_string()],
dirty_worktree_ids: vec!["worker-e".to_string()],
failures: Vec::new(),
})
})
.await?;
assert_eq!(merged, 2);
Ok(())
}
#[tokio::test]
async fn maybe_auto_prune_inactive_worktrees_records_pruned_and_active_counts() -> Result<()> {
let recorded = std::sync::Arc::new(std::sync::Mutex::new(None));
let recorded_clone = recorded.clone();
let pruned = maybe_auto_prune_inactive_worktrees_with_recorder(
|| async move {
Ok(manager::WorktreePruneOutcome {
cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()],
active_with_worktree_ids: vec!["running-a".to_string()],
})
},
move |pruned, active| {
*recorded_clone.lock().unwrap() = Some((pruned, active));
Ok(())
},
)
.await?;
assert_eq!(pruned, 2);
assert_eq!(*recorded.lock().unwrap(), Some((2, 1)));
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -70,11 +70,7 @@ impl DbWriter {
}
}
fn run_db_writer(
db_path: PathBuf,
session_id: String,
mut rx: mpsc::UnboundedReceiver<DbMessage>,
) {
fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedReceiver<DbMessage>) {
let (opened, open_error) = match StateStore::open(&db_path) {
Ok(db) => (Some(db), None),
Err(error) => (None, Some(error.to_string())),
@@ -84,7 +80,9 @@ fn run_db_writer(
match message {
DbMessage::UpdateState { state, ack } => {
let result = match opened.as_ref() {
Some(db) => db.update_state(&session_id, &state).map_err(|error| error.to_string()),
Some(db) => db
.update_state(&session_id, &state)
.map_err(|error| error.to_string()),
None => Err(open_error
.clone()
.unwrap_or_else(|| "Failed to open state store".to_string())),
@@ -93,7 +91,9 @@ fn run_db_writer(
}
DbMessage::UpdatePid { pid, ack } => {
let result = match opened.as_ref() {
Some(db) => db.update_pid(&session_id, pid).map_err(|error| error.to_string()),
Some(db) => db
.update_pid(&session_id, pid)
.map_err(|error| error.to_string()),
None => Err(open_error
.clone()
.unwrap_or_else(|| "Failed to open state store".to_string())),
@@ -205,9 +205,7 @@ where
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines.next_line().await? {
db_writer
.append_output_line(stream, line.clone())
.await?;
db_writer.append_output_line(stream, line.clone()).await?;
output_store.push_line(&session_id, stream, line);
}

View File

@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -13,18 +14,28 @@ pub struct StateStore {
conn: Connection,
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, Serialize)]
pub struct DaemonActivity {
pub last_dispatch_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_dispatch_routed: usize,
pub last_dispatch_deferred: usize,
pub last_dispatch_leads: usize,
pub chronic_saturation_streak: usize,
pub last_recovery_dispatch_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_recovery_dispatch_routed: usize,
pub last_recovery_dispatch_leads: usize,
pub last_rebalance_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_rebalance_rerouted: usize,
pub last_rebalance_leads: usize,
pub last_auto_merge_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_auto_merge_merged: usize,
pub last_auto_merge_active_skipped: usize,
pub last_auto_merge_conflicted_skipped: usize,
pub last_auto_merge_dirty_skipped: usize,
pub last_auto_merge_failed: usize,
pub last_auto_prune_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_auto_prune_pruned: usize,
pub last_auto_prune_active_skipped: usize,
}
impl DaemonActivity {
@@ -44,12 +55,11 @@ impl DaemonActivity {
}
pub fn dispatch_cooloff_active(&self) -> bool {
self.prefers_rebalance_first() && self.last_dispatch_deferred >= 2
self.prefers_rebalance_first()
&& (self.last_dispatch_deferred >= 2 || self.chronic_saturation_streak >= 3)
}
pub fn chronic_saturation_cleared_at(
&self,
) -> Option<&chrono::DateTime<chrono::Utc>> {
pub fn chronic_saturation_cleared_at(&self) -> Option<&chrono::DateTime<chrono::Utc>> {
if self.prefers_rebalance_first() {
return None;
}
@@ -58,14 +68,14 @@ impl DaemonActivity {
self.last_dispatch_at.as_ref(),
self.last_recovery_dispatch_at.as_ref(),
) {
(Some(dispatch_at), Some(recovery_at)) if recovery_at > dispatch_at => Some(recovery_at),
(Some(dispatch_at), Some(recovery_at)) if recovery_at > dispatch_at => {
Some(recovery_at)
}
_ => None,
}
}
pub fn stabilized_after_recovery_at(
&self,
) -> Option<&chrono::DateTime<chrono::Utc>> {
pub fn stabilized_after_recovery_at(&self) -> Option<&chrono::DateTime<chrono::Utc>> {
if self.last_dispatch_deferred != 0 {
return None;
}
@@ -74,10 +84,18 @@ impl DaemonActivity {
self.last_dispatch_at.as_ref(),
self.last_recovery_dispatch_at.as_ref(),
) {
(Some(dispatch_at), Some(recovery_at)) if dispatch_at > recovery_at => Some(dispatch_at),
(Some(dispatch_at), Some(recovery_at)) if dispatch_at > recovery_at => {
Some(dispatch_at)
}
_ => None,
}
}
pub fn operator_escalation_required(&self) -> bool {
self.dispatch_cooloff_active()
&& self.chronic_saturation_streak >= 5
&& self.last_rebalance_rerouted == 0
}
}
impl StateStore {
@@ -147,12 +165,22 @@ impl StateStore {
last_dispatch_routed INTEGER NOT NULL DEFAULT 0,
last_dispatch_deferred INTEGER NOT NULL DEFAULT 0,
last_dispatch_leads INTEGER NOT NULL DEFAULT 0,
chronic_saturation_streak INTEGER NOT NULL DEFAULT 0,
last_recovery_dispatch_at TEXT,
last_recovery_dispatch_routed INTEGER NOT NULL DEFAULT 0,
last_recovery_dispatch_leads INTEGER NOT NULL DEFAULT 0,
last_rebalance_at TEXT,
last_rebalance_rerouted INTEGER NOT NULL DEFAULT 0,
last_rebalance_leads INTEGER NOT NULL DEFAULT 0
last_rebalance_leads INTEGER NOT NULL DEFAULT 0,
last_auto_merge_at TEXT,
last_auto_merge_merged INTEGER NOT NULL DEFAULT 0,
last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0,
last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0,
last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0,
last_auto_merge_failed INTEGER NOT NULL DEFAULT 0,
last_auto_prune_at TEXT,
last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0,
last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state);
@@ -199,7 +227,9 @@ impl StateStore {
"ALTER TABLE daemon_activity ADD COLUMN last_recovery_dispatch_at TEXT",
[],
)
.context("Failed to add last_recovery_dispatch_at column to daemon_activity table")?;
.context(
"Failed to add last_recovery_dispatch_at column to daemon_activity table",
)?;
}
if !self.has_column("daemon_activity", "last_recovery_dispatch_routed")? {
@@ -220,6 +250,96 @@ impl StateStore {
.context("Failed to add last_recovery_dispatch_leads column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "chronic_saturation_streak")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN chronic_saturation_streak INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add chronic_saturation_streak column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_at")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_at TEXT",
[],
)
.context("Failed to add last_auto_merge_at column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_merged")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_merged INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_merge_merged column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_active_skipped")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_merge_active_skipped column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_conflicted_skipped")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_merge_conflicted_skipped column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_dirty_skipped")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_merge_dirty_skipped column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_merge_failed")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_failed INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_merge_failed column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_prune_at")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_at TEXT",
[],
)
.context("Failed to add last_auto_prune_at column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_prune_pruned")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_prune_pruned column to daemon_activity table")?;
}
if !self.has_column("daemon_activity", "last_auto_prune_active_skipped")? {
self.conn
.execute(
"ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0",
[],
)
.context("Failed to add last_auto_prune_active_skipped column to daemon_activity table")?;
}
Ok(())
}
@@ -550,9 +670,7 @@ impl StateStore {
})
})?;
messages
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
messages.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn unread_task_handoff_count(&self, session_id: &str) -> Result<usize> {
@@ -582,9 +700,7 @@ impl StateStore {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
})?;
targets
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
targets.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn mark_messages_read(&self, session_id: &str) -> Result<usize> {
@@ -624,8 +740,13 @@ impl StateStore {
self.conn
.query_row(
"SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_deferred, last_dispatch_leads,
chronic_saturation_streak,
last_recovery_dispatch_at, last_recovery_dispatch_routed, last_recovery_dispatch_leads,
last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads
last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads,
last_auto_merge_at, last_auto_merge_merged, last_auto_merge_active_skipped,
last_auto_merge_conflicted_skipped, last_auto_merge_dirty_skipped,
last_auto_merge_failed, last_auto_prune_at, last_auto_prune_pruned,
last_auto_prune_active_skipped
FROM daemon_activity
WHERE id = 1",
[],
@@ -652,12 +773,22 @@ impl StateStore {
last_dispatch_routed: row.get::<_, i64>(1)? as usize,
last_dispatch_deferred: row.get::<_, i64>(2)? as usize,
last_dispatch_leads: row.get::<_, i64>(3)? as usize,
last_recovery_dispatch_at: parse_ts(row.get(4)?)?,
last_recovery_dispatch_routed: row.get::<_, i64>(5)? as usize,
last_recovery_dispatch_leads: row.get::<_, i64>(6)? as usize,
last_rebalance_at: parse_ts(row.get(7)?)?,
last_rebalance_rerouted: row.get::<_, i64>(8)? as usize,
last_rebalance_leads: row.get::<_, i64>(9)? as usize,
chronic_saturation_streak: row.get::<_, i64>(4)? as usize,
last_recovery_dispatch_at: parse_ts(row.get(5)?)?,
last_recovery_dispatch_routed: row.get::<_, i64>(6)? as usize,
last_recovery_dispatch_leads: row.get::<_, i64>(7)? as usize,
last_rebalance_at: parse_ts(row.get(8)?)?,
last_rebalance_rerouted: row.get::<_, i64>(9)? as usize,
last_rebalance_leads: row.get::<_, i64>(10)? as usize,
last_auto_merge_at: parse_ts(row.get(11)?)?,
last_auto_merge_merged: row.get::<_, i64>(12)? as usize,
last_auto_merge_active_skipped: row.get::<_, i64>(13)? as usize,
last_auto_merge_conflicted_skipped: row.get::<_, i64>(14)? as usize,
last_auto_merge_dirty_skipped: row.get::<_, i64>(15)? as usize,
last_auto_merge_failed: row.get::<_, i64>(16)? as usize,
last_auto_prune_at: parse_ts(row.get(17)?)?,
last_auto_prune_pruned: row.get::<_, i64>(18)? as usize,
last_auto_prune_active_skipped: row.get::<_, i64>(19)? as usize,
})
},
)
@@ -675,7 +806,11 @@ impl StateStore {
SET last_dispatch_at = ?1,
last_dispatch_routed = ?2,
last_dispatch_deferred = ?3,
last_dispatch_leads = ?4
last_dispatch_leads = ?4,
chronic_saturation_streak = CASE
WHEN ?3 > 0 THEN chronic_saturation_streak + 1
ELSE 0
END
WHERE id = 1",
rusqlite::params![
chrono::Utc::now().to_rfc3339(),
@@ -693,7 +828,8 @@ impl StateStore {
"UPDATE daemon_activity
SET last_recovery_dispatch_at = ?1,
last_recovery_dispatch_routed = ?2,
last_recovery_dispatch_leads = ?3
last_recovery_dispatch_leads = ?3,
chronic_saturation_streak = 0
WHERE id = 1",
rusqlite::params![chrono::Utc::now().to_rfc3339(), routed as i64, leads as i64],
)?;
@@ -708,7 +844,62 @@ impl StateStore {
last_rebalance_rerouted = ?2,
last_rebalance_leads = ?3
WHERE id = 1",
rusqlite::params![chrono::Utc::now().to_rfc3339(), rerouted as i64, leads as i64],
rusqlite::params![
chrono::Utc::now().to_rfc3339(),
rerouted as i64,
leads as i64
],
)?;
Ok(())
}
pub fn record_daemon_auto_merge_pass(
&self,
merged: usize,
active_skipped: usize,
conflicted_skipped: usize,
dirty_skipped: usize,
failed: usize,
) -> Result<()> {
self.conn.execute(
"UPDATE daemon_activity
SET last_auto_merge_at = ?1,
last_auto_merge_merged = ?2,
last_auto_merge_active_skipped = ?3,
last_auto_merge_conflicted_skipped = ?4,
last_auto_merge_dirty_skipped = ?5,
last_auto_merge_failed = ?6
WHERE id = 1",
rusqlite::params![
chrono::Utc::now().to_rfc3339(),
merged as i64,
active_skipped as i64,
conflicted_skipped as i64,
dirty_skipped as i64,
failed as i64,
],
)?;
Ok(())
}
pub fn record_daemon_auto_prune_pass(
&self,
pruned: usize,
active_skipped: usize,
) -> Result<()> {
self.conn.execute(
"UPDATE daemon_activity
SET last_auto_prune_at = ?1,
last_auto_prune_pruned = ?2,
last_auto_prune_active_skipped = ?3
WHERE id = 1",
rusqlite::params![
chrono::Utc::now().to_rfc3339(),
pruned as i64,
active_skipped as i64,
],
)?;
Ok(())
@@ -1023,7 +1214,12 @@ mod tests {
db.insert_session(&build_session("planner", SessionState::Running))?;
db.insert_session(&build_session("worker", SessionState::Pending))?;
db.send_message("planner", "worker", "{\"question\":\"Need context\"}", "query")?;
db.send_message(
"planner",
"worker",
"{\"question\":\"Need context\"}",
"query",
)?;
db.send_message(
"worker",
"planner",
@@ -1066,17 +1262,11 @@ mod tests {
);
assert_eq!(
db.delegated_children("planner", 10)?,
vec![
"worker-3".to_string(),
"worker-2".to_string(),
]
vec!["worker-3".to_string(), "worker-2".to_string(),]
);
assert_eq!(
db.unread_task_handoff_targets(10)?,
vec![
("worker-2".to_string(), 1),
("worker-3".to_string(), 1),
]
vec![("worker-2".to_string(), 1), ("worker-3".to_string(), 1),]
);
Ok(())
@@ -1090,18 +1280,30 @@ mod tests {
db.record_daemon_dispatch_pass(4, 1, 2)?;
db.record_daemon_recovery_dispatch_pass(2, 1)?;
db.record_daemon_rebalance_pass(3, 1)?;
db.record_daemon_auto_merge_pass(2, 1, 1, 1, 0)?;
db.record_daemon_auto_prune_pass(3, 1)?;
let activity = db.daemon_activity()?;
assert_eq!(activity.last_dispatch_routed, 4);
assert_eq!(activity.last_dispatch_deferred, 1);
assert_eq!(activity.last_dispatch_leads, 2);
assert_eq!(activity.chronic_saturation_streak, 0);
assert_eq!(activity.last_recovery_dispatch_routed, 2);
assert_eq!(activity.last_recovery_dispatch_leads, 1);
assert_eq!(activity.last_rebalance_rerouted, 3);
assert_eq!(activity.last_rebalance_leads, 1);
assert_eq!(activity.last_auto_merge_merged, 2);
assert_eq!(activity.last_auto_merge_active_skipped, 1);
assert_eq!(activity.last_auto_merge_conflicted_skipped, 1);
assert_eq!(activity.last_auto_merge_dirty_skipped, 1);
assert_eq!(activity.last_auto_merge_failed, 0);
assert_eq!(activity.last_auto_prune_pruned, 3);
assert_eq!(activity.last_auto_prune_active_skipped, 1);
assert!(activity.last_dispatch_at.is_some());
assert!(activity.last_recovery_dispatch_at.is_some());
assert!(activity.last_rebalance_at.is_some());
assert!(activity.last_auto_merge_at.is_some());
assert!(activity.last_auto_prune_at.is_some());
Ok(())
}
@@ -1121,21 +1323,48 @@ mod tests {
last_dispatch_routed: 0,
last_dispatch_deferred: 2,
last_dispatch_leads: 1,
chronic_saturation_streak: 1,
last_recovery_dispatch_at: None,
last_recovery_dispatch_routed: 0,
last_recovery_dispatch_leads: 0,
last_rebalance_at: None,
last_rebalance_rerouted: 0,
last_rebalance_leads: 0,
last_auto_merge_at: None,
last_auto_merge_merged: 0,
last_auto_merge_active_skipped: 0,
last_auto_merge_conflicted_skipped: 0,
last_auto_merge_dirty_skipped: 0,
last_auto_merge_failed: 0,
last_auto_prune_at: None,
last_auto_prune_pruned: 0,
last_auto_prune_active_skipped: 0,
};
assert!(unresolved.prefers_rebalance_first());
assert!(unresolved.dispatch_cooloff_active());
assert!(unresolved.chronic_saturation_cleared_at().is_none());
assert!(unresolved.stabilized_after_recovery_at().is_none());
let persistent = DaemonActivity {
last_dispatch_deferred: 1,
chronic_saturation_streak: 3,
..unresolved.clone()
};
assert!(persistent.prefers_rebalance_first());
assert!(persistent.dispatch_cooloff_active());
assert!(!persistent.operator_escalation_required());
let escalated = DaemonActivity {
chronic_saturation_streak: 5,
last_rebalance_rerouted: 0,
..persistent.clone()
};
assert!(escalated.operator_escalation_required());
let recovered = DaemonActivity {
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
last_recovery_dispatch_routed: 1,
chronic_saturation_streak: 0,
..unresolved
};
assert!(!recovered.prefers_rebalance_first());
@@ -1161,4 +1390,27 @@ mod tests {
stabilized.last_dispatch_at.as_ref()
);
}
#[test]
fn daemon_activity_tracks_chronic_saturation_streak() -> Result<()> {
let tempdir = TestDir::new("store-daemon-streak")?;
let db = StateStore::open(&tempdir.path().join("state.db"))?;
db.record_daemon_dispatch_pass(0, 1, 1)?;
db.record_daemon_dispatch_pass(0, 1, 1)?;
let saturated = db.daemon_activity()?;
assert_eq!(saturated.chronic_saturation_streak, 2);
assert!(!saturated.dispatch_cooloff_active());
db.record_daemon_dispatch_pass(0, 1, 1)?;
let chronic = db.daemon_activity()?;
assert_eq!(chronic.chronic_saturation_streak, 3);
assert!(chronic.dispatch_cooloff_active());
db.record_daemon_recovery_dispatch_pass(1, 1)?;
let recovered = db.daemon_activity()?;
assert_eq!(recovered.chronic_saturation_streak, 0);
Ok(())
}
}

View File

@@ -45,12 +45,19 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
(_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(),
(_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(),
(_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1),
(_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1),
(_, KeyCode::Char('s')) => dashboard.stop_selected().await,
(_, KeyCode::Char('u')) => dashboard.resume_selected().await,
(_, KeyCode::Char('x')) => dashboard.cleanup_selected_worktree().await,
(_, KeyCode::Char('X')) => dashboard.prune_inactive_worktrees().await,
(_, KeyCode::Char('d')) => dashboard.delete_selected_session().await,
(_, KeyCode::Char('r')) => dashboard.refresh(),
(_, KeyCode::Char('?')) => dashboard.toggle_help(),

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,38 @@
use anyhow::{Context, Result};
use std::path::Path;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::Config;
use crate::session::WorktreeInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeReadinessStatus {
Ready,
Conflicted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeReadiness {
pub status: MergeReadinessStatus,
pub summary: String,
pub conflicts: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorktreeHealth {
Clear,
InProgress,
Conflicted,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct MergeOutcome {
pub branch: String,
pub base_branch: String,
pub already_up_to_date: bool,
}
/// Create a new git worktree for an agent session.
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
@@ -53,18 +81,59 @@ pub(crate) fn create_for_session_in_repo(
}
/// Remove a worktree and its branch.
pub fn remove(path: &Path) -> Result<()> {
pub fn remove(worktree: &WorktreeInfo) -> Result<()> {
let repo_root = match base_checkout_path(worktree) {
Ok(path) => path,
Err(error) => {
tracing::warn!(
"Falling back to filesystem-only cleanup for {}: {error}",
worktree.path.display()
);
if worktree.path.exists() {
if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) {
tracing::warn!(
"Fallback worktree directory cleanup warning for {}: {remove_error}",
worktree.path.display()
);
}
}
return Ok(());
}
};
let output = Command::new("git")
.arg("-C")
.arg(path)
.arg(&repo_root)
.args(["worktree", "remove", "--force"])
.arg(path)
.arg(&worktree.path)
.output()
.context("Failed to remove worktree")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("Worktree removal warning: {stderr}");
if worktree.path.exists() {
if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) {
tracing::warn!(
"Fallback worktree directory cleanup warning for {}: {remove_error}",
worktree.path.display()
);
}
}
}
let branch_output = Command::new("git")
.arg("-C")
.arg(&repo_root)
.args(["branch", "-D", &worktree.branch])
.output()
.context("Failed to delete worktree branch")?;
if !branch_output.status.success() {
let stderr = String::from_utf8_lossy(&branch_output.stderr);
tracing::warn!(
"Worktree branch deletion warning for {}: {stderr}",
worktree.branch
);
}
Ok(())
@@ -107,6 +176,191 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result<Option<String>> {
}
}
pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> {
let mut preview = Vec::new();
let base_ref = format!("{}...HEAD", worktree.base_branch);
let committed = git_diff_name_status(&worktree.path, &[&base_ref])?;
if !committed.is_empty() {
preview.extend(
committed
.into_iter()
.map(|entry| format!("Branch {entry}"))
.take(limit.saturating_sub(preview.len())),
);
}
if preview.len() < limit {
let working = git_status_short(&worktree.path)?;
if !working.is_empty() {
preview.extend(
working
.into_iter()
.map(|entry| format!("Working {entry}"))
.take(limit.saturating_sub(preview.len())),
);
}
}
Ok(preview)
}
pub fn diff_patch_preview(worktree: &WorktreeInfo, max_lines: usize) -> Result<Option<String>> {
let mut remaining = max_lines.max(1);
let mut sections = Vec::new();
let base_ref = format!("{}...HEAD", worktree.base_branch);
let committed = git_diff_patch_lines(&worktree.path, &[&base_ref])?;
if !committed.is_empty() && remaining > 0 {
let taken = take_preview_lines(&committed, &mut remaining);
sections.push(format!(
"--- Branch diff vs {} ---\n{}",
worktree.base_branch,
taken.join("\n")
));
}
let working = git_diff_patch_lines(&worktree.path, &[])?;
if !working.is_empty() && remaining > 0 {
let taken = take_preview_lines(&working, &mut remaining);
sections.push(format!("--- Working tree diff ---\n{}", taken.join("\n")));
}
if sections.is_empty() {
Ok(None)
} else {
Ok(Some(sections.join("\n\n")))
}
}
pub fn merge_readiness(worktree: &WorktreeInfo) -> Result<MergeReadiness> {
let output = Command::new("git")
.arg("-C")
.arg(&worktree.path)
.args([
"merge-tree",
"--write-tree",
&worktree.base_branch,
&worktree.branch,
])
.output()
.context("Failed to generate merge readiness preview")?;
let merged_output = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let conflicts = merged_output
.lines()
.filter_map(parse_merge_conflict_path)
.collect::<Vec<_>>();
if output.status.success() {
return Ok(MergeReadiness {
status: MergeReadinessStatus::Ready,
summary: format!("Merge ready into {}", worktree.base_branch),
conflicts: Vec::new(),
});
}
if !conflicts.is_empty() {
let conflict_summary = conflicts
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let overflow = conflicts.len().saturating_sub(3);
let detail = if overflow > 0 {
format!("{conflict_summary}, +{overflow} more")
} else {
conflict_summary
};
return Ok(MergeReadiness {
status: MergeReadinessStatus::Conflicted,
summary: format!("Merge blocked by {} conflict(s): {detail}", conflicts.len()),
conflicts,
});
}
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git merge-tree failed: {stderr}");
}
pub fn health(worktree: &WorktreeInfo) -> Result<WorktreeHealth> {
let merge_readiness = merge_readiness(worktree)?;
if merge_readiness.status == MergeReadinessStatus::Conflicted {
return Ok(WorktreeHealth::Conflicted);
}
if diff_file_preview(worktree, 1)?.is_empty() {
Ok(WorktreeHealth::Clear)
} else {
Ok(WorktreeHealth::InProgress)
}
}
pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
Ok(!git_status_short(&worktree.path)?.is_empty())
}
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
let readiness = merge_readiness(worktree)?;
if readiness.status == MergeReadinessStatus::Conflicted {
anyhow::bail!(readiness.summary);
}
if has_uncommitted_changes(worktree)? {
anyhow::bail!(
"Worktree {} has uncommitted changes; commit or discard them before merging",
worktree.branch
);
}
let repo_root = base_checkout_path(worktree)?;
let current_branch = get_current_branch(&repo_root)?;
if current_branch != worktree.base_branch {
anyhow::bail!(
"Base branch {} is not checked out in repo root (currently {})",
worktree.base_branch,
current_branch
);
}
if !git_status_short(&repo_root)?.is_empty() {
anyhow::bail!(
"Repository root {} has uncommitted changes; commit or stash them before merging",
repo_root.display()
);
}
let output = Command::new("git")
.arg("-C")
.arg(&repo_root)
.args(["merge", "--no-edit", &worktree.branch])
.output()
.context("Failed to merge worktree branch into base")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git merge failed: {stderr}");
}
let merged_output = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(MergeOutcome {
branch: worktree.branch.clone(),
base_branch: worktree.base_branch.clone(),
already_up_to_date: merged_output.contains("Already up to date."),
})
}
fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result<Option<String>> {
let mut command = Command::new("git");
command
@@ -137,6 +391,104 @@ fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result<Optio
}
}
fn git_diff_name_status(worktree_path: &Path, extra_args: &[&str]) -> Result<Vec<String>> {
let mut command = Command::new("git");
command
.arg("-C")
.arg(worktree_path)
.arg("diff")
.arg("--name-status");
command.args(extra_args);
let output = command
.output()
.context("Failed to generate worktree diff file preview")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"Worktree diff file preview warning for {}: {stderr}",
worktree_path.display()
);
return Ok(Vec::new());
}
Ok(parse_nonempty_lines(&output.stdout))
}
fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result<Vec<String>> {
let mut command = Command::new("git");
command
.arg("-C")
.arg(worktree_path)
.arg("diff")
.args(["--stat", "--patch", "--find-renames"]);
command.args(extra_args);
let output = command
.output()
.context("Failed to generate worktree patch preview")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"Worktree patch preview warning for {}: {stderr}",
worktree_path.display()
);
return Ok(Vec::new());
}
Ok(parse_nonempty_lines(&output.stdout))
}
fn git_status_short(worktree_path: &Path) -> Result<Vec<String>> {
let output = Command::new("git")
.arg("-C")
.arg(worktree_path)
.args(["status", "--short"])
.output()
.context("Failed to generate worktree status preview")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"Worktree status preview warning for {}: {stderr}",
worktree_path.display()
);
return Ok(Vec::new());
}
Ok(parse_nonempty_lines(&output.stdout))
}
fn parse_nonempty_lines(stdout: &[u8]) -> Vec<String> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn take_preview_lines(lines: &[String], remaining: &mut usize) -> Vec<String> {
let count = (*remaining).min(lines.len());
let taken = lines.iter().take(count).cloned().collect::<Vec<_>>();
*remaining = remaining.saturating_sub(count);
taken
}
fn parse_merge_conflict_path(line: &str) -> Option<String> {
if !line.contains("CONFLICT") {
return None;
}
line.split(" in ")
.nth(1)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(ToOwned::to_owned)
}
fn get_current_branch(repo_root: &Path) -> Result<String> {
let output = Command::new("git")
.arg("-C")
@@ -148,6 +500,62 @@ fn get_current_branch(repo_root: &Path) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn base_checkout_path(worktree: &WorktreeInfo) -> Result<PathBuf> {
let output = Command::new("git")
.arg("-C")
.arg(&worktree.path)
.args(["worktree", "list", "--porcelain"])
.output()
.context("Failed to resolve git worktree list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree list --porcelain failed: {stderr}");
}
let target_branch = format!("refs/heads/{}", worktree.base_branch);
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
let mut fallback: Option<PathBuf> = None;
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.is_empty() {
if let Some(path) = current_path.take() {
if fallback.is_none() && path != worktree.path {
fallback = Some(path.clone());
}
if current_branch.as_deref() == Some(target_branch.as_str())
&& path != worktree.path
{
return Ok(path);
}
}
current_branch = None;
continue;
}
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(path.trim()));
} else if let Some(branch) = line.strip_prefix("branch ") {
current_branch = Some(branch.trim().to_string());
}
}
if let Some(path) = current_path.take() {
if fallback.is_none() && path != worktree.path {
fallback = Some(path.clone());
}
if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path {
return Ok(path);
}
}
fallback.context(format!(
"Failed to locate base checkout for {} from git worktree list",
worktree.base_branch
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -157,7 +565,11 @@ mod tests {
use uuid::Uuid;
fn run_git(repo: &Path, args: &[&str]) -> Result<()> {
let output = Command::new("git").arg("-C").arg(repo).args(args).output()?;
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()?;
if !output.status.success() {
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr));
}
@@ -196,7 +608,10 @@ mod tests {
base_branch: "main".to_string(),
};
assert_eq!(diff_summary(&info)?, Some("Clean relative to main".to_string()));
assert_eq!(
diff_summary(&info)?,
Some("Clean relative to main".to_string())
);
fs::write(worktree_dir.join("README.md"), "hello\nmore\n")?;
let dirty = diff_summary(&info)?.expect("dirty summary");
@@ -212,4 +627,217 @@ mod tests {
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn diff_file_preview_reports_branch_and_working_tree_files() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-worktree-preview-{}", Uuid::new_v4()));
let repo = root.join("repo");
fs::create_dir_all(&repo)?;
run_git(&repo, &["init", "-b", "main"])?;
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
run_git(&repo, &["config", "user.name", "ECC"])?;
fs::write(repo.join("README.md"), "hello\n")?;
run_git(&repo, &["add", "README.md"])?;
run_git(&repo, &["commit", "-m", "init"])?;
let worktree_dir = root.join("wt-1");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"ecc/test",
worktree_dir.to_str().expect("utf8 path"),
"HEAD",
],
)?;
fs::write(worktree_dir.join("src.txt"), "branch\n")?;
run_git(&worktree_dir, &["add", "src.txt"])?;
run_git(&worktree_dir, &["commit", "-m", "branch file"])?;
fs::write(worktree_dir.join("README.md"), "hello\nworking\n")?;
let info = WorktreeInfo {
path: worktree_dir.clone(),
branch: "ecc/test".to_string(),
base_branch: "main".to_string(),
};
let preview = diff_file_preview(&info, 6)?;
assert!(preview
.iter()
.any(|line| line.contains("Branch A") && line.contains("src.txt")));
assert!(preview
.iter()
.any(|line| line.contains("Working M") && line.contains("README.md")));
let _ = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["worktree", "remove", "--force"])
.arg(&worktree_dir)
.output();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn diff_patch_preview_reports_branch_and_working_tree_sections() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-worktree-patch-{}", Uuid::new_v4()));
let repo = root.join("repo");
fs::create_dir_all(&repo)?;
run_git(&repo, &["init", "-b", "main"])?;
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
run_git(&repo, &["config", "user.name", "ECC"])?;
fs::write(repo.join("README.md"), "hello\n")?;
run_git(&repo, &["add", "README.md"])?;
run_git(&repo, &["commit", "-m", "init"])?;
let worktree_dir = root.join("wt-1");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"ecc/test",
worktree_dir.to_str().expect("utf8 path"),
"HEAD",
],
)?;
fs::write(worktree_dir.join("src.txt"), "branch\n")?;
run_git(&worktree_dir, &["add", "src.txt"])?;
run_git(&worktree_dir, &["commit", "-m", "branch file"])?;
fs::write(worktree_dir.join("README.md"), "hello\nworking\n")?;
let info = WorktreeInfo {
path: worktree_dir.clone(),
branch: "ecc/test".to_string(),
base_branch: "main".to_string(),
};
let preview = diff_patch_preview(&info, 40)?.expect("patch preview");
assert!(preview.contains("--- Branch diff vs main ---"));
assert!(preview.contains("--- Working tree diff ---"));
assert!(preview.contains("src.txt"));
assert!(preview.contains("README.md"));
let _ = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["worktree", "remove", "--force"])
.arg(&worktree_dir)
.output();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn merge_readiness_reports_ready_worktree() -> Result<()> {
let root =
std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4()));
let repo = root.join("repo");
fs::create_dir_all(&repo)?;
run_git(&repo, &["init", "-b", "main"])?;
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
run_git(&repo, &["config", "user.name", "ECC"])?;
fs::write(repo.join("README.md"), "hello\n")?;
run_git(&repo, &["add", "README.md"])?;
run_git(&repo, &["commit", "-m", "init"])?;
let worktree_dir = root.join("wt-1");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"ecc/test",
worktree_dir.to_str().expect("utf8 path"),
"HEAD",
],
)?;
fs::write(worktree_dir.join("src.txt"), "branch only\n")?;
run_git(&worktree_dir, &["add", "src.txt"])?;
run_git(&worktree_dir, &["commit", "-m", "branch file"])?;
let info = WorktreeInfo {
path: worktree_dir.clone(),
branch: "ecc/test".to_string(),
base_branch: "main".to_string(),
};
let readiness = merge_readiness(&info)?;
assert_eq!(readiness.status, MergeReadinessStatus::Ready);
assert!(readiness.summary.contains("Merge ready into main"));
assert!(readiness.conflicts.is_empty());
let _ = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["worktree", "remove", "--force"])
.arg(&worktree_dir)
.output();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn merge_readiness_reports_conflicted_worktree() -> Result<()> {
let root =
std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4()));
let repo = root.join("repo");
fs::create_dir_all(&repo)?;
run_git(&repo, &["init", "-b", "main"])?;
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
run_git(&repo, &["config", "user.name", "ECC"])?;
fs::write(repo.join("README.md"), "hello\n")?;
run_git(&repo, &["add", "README.md"])?;
run_git(&repo, &["commit", "-m", "init"])?;
let worktree_dir = root.join("wt-1");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"ecc/test",
worktree_dir.to_str().expect("utf8 path"),
"HEAD",
],
)?;
fs::write(worktree_dir.join("README.md"), "hello\nbranch\n")?;
run_git(&worktree_dir, &["commit", "-am", "branch change"])?;
fs::write(repo.join("README.md"), "hello\nmain\n")?;
run_git(&repo, &["commit", "-am", "main change"])?;
let info = WorktreeInfo {
path: worktree_dir.clone(),
branch: "ecc/test".to_string(),
base_branch: "main".to_string(),
};
let readiness = merge_readiness(&info)?;
assert_eq!(readiness.status, MergeReadinessStatus::Conflicted);
assert!(readiness.summary.contains("Merge blocked by 1 conflict"));
assert_eq!(readiness.conflicts, vec!["README.md".to_string()]);
let _ = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["worktree", "remove", "--force"])
.arg(&worktree_dir)
.output();
let _ = fs::remove_dir_all(root);
Ok(())
}
}

View File

@@ -26,6 +26,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
| **Pre-commit quality check** | `Bash` | Runs quality checks before `git commit`: lints staged files, validates commit message format when provided via `-m/--message`, detects console.log/debugger/secrets | 2 (blocks critical) / 0 (warns) |
| **Doc file warning** | `Write` | Warns about non-standard `.md`/`.txt` files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling | 0 (warns) |
| **Strategic compact** | `Edit\|Write` | Suggests manual `/compact` at logical intervals (every ~50 tool calls) | 0 (warns) |
### PostToolUse Hooks
| Hook | Matcher | What It Does |

View File

@@ -193,6 +193,14 @@
"framework-language"
]
},
{
"id": "lang:c",
"family": "language",
"description": "C engineering guidance using the shared C/C++ standards and testing stack. Currently resolves through the shared framework-language module.",
"modules": [
"framework-language"
]
},
{
"id": "lang:kotlin",
"family": "language",
@@ -350,7 +358,7 @@
{
"id": "skill:continuous-learning",
"family": "skill",
"description": "Session pattern extraction and continuous learning skill.",
"description": "Legacy v1 Stop-hook session pattern extraction skill; prefer continuous-learning-v2 for new installs.",
"modules": [
"workflow-quality"
]

View File

@@ -197,7 +197,7 @@
{
"id": "workflow-quality",
"kind": "skills",
"description": "Evaluation, TDD, verification, learning, and compaction skills.",
"description": "Evaluation, TDD, verification, compaction, and learning skills, including the legacy continuous-learning v1 path.",
"paths": [
"skills/agent-sort",
"skills/agent-introspection-debugging",

View File

@@ -67,6 +67,7 @@
"schemas/",
"scripts/ci/",
"scripts/ecc.js",
"scripts/gemini-adapt-agents.js",
"scripts/hooks/",
"scripts/lib/",
"scripts/claw.js",

View File

@@ -12,6 +12,53 @@ const COMMANDS_DIR = path.join(ROOT_DIR, 'commands');
const AGENTS_DIR = path.join(ROOT_DIR, 'agents');
const SKILLS_DIR = path.join(ROOT_DIR, 'skills');
function validateFrontmatter(file, content) {
if (!content.startsWith('---\n')) {
return [];
}
const endIndex = content.indexOf('\n---\n', 4);
if (endIndex === -1) {
return [`${file} - frontmatter block is missing a closing --- delimiter`];
}
const block = content.slice(4, endIndex);
const errors = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!match) {
errors.push(`${file} - invalid frontmatter line: ${rawLine}`);
continue;
}
const value = match[2].trim();
const isQuoted = (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
);
if (!isQuoted && value.startsWith('[') && !value.endsWith(']')) {
errors.push(
`${file} - frontmatter value for "${match[1]}" starts with "[" but is not a closed YAML sequence; wrap it in quotes`,
);
}
if (!isQuoted && value.startsWith('{') && !value.endsWith('}')) {
errors.push(
`${file} - frontmatter value for "${match[1]}" starts with "{" but is not a closed YAML mapping; wrap it in quotes`,
);
}
}
return errors;
}
function validateCommands() {
if (!fs.existsSync(COMMANDS_DIR)) {
console.log('No commands directory found, skipping validation');
@@ -68,6 +115,11 @@ function validateCommands() {
continue;
}
for (const error of validateFrontmatter(file, content)) {
console.error(`ERROR: ${error}`);
hasErrors = true;
}
// Strip fenced code blocks before checking cross-references.
// Examples/templates inside ``` blocks are not real references.
const contentNoCodeBlocks = content.replace(/```[\s\S]*?```/g, '');

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const TOOL_NAME_MAP = new Map([
['Read', 'read_file'],
['Write', 'write_file'],
['Edit', 'replace'],
['Bash', 'run_shell_command'],
['Grep', 'grep_search'],
['Glob', 'glob'],
['WebSearch', 'google_web_search'],
['WebFetch', 'web_fetch'],
]);
function usage() {
return [
'Adapt ECC agent frontmatter for Gemini CLI.',
'',
'Usage:',
' node scripts/gemini-adapt-agents.js [agents-dir]',
'',
'Defaults to .gemini/agents under the current working directory.',
'Rewrites tools: to Gemini-compatible tool names and removes unsupported color: metadata.'
].join('\n');
}
function parseArgs(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
return { help: true };
}
const positional = argv.filter(arg => !arg.startsWith('-'));
if (positional.length > 1) {
throw new Error('Expected at most one agents directory argument');
}
return {
help: false,
agentsDir: path.resolve(positional[0] || path.join(process.cwd(), '.gemini', 'agents')),
};
}
function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
throw new Error(`Agents directory not found: ${dirPath}`);
}
if (!fs.statSync(dirPath).isDirectory()) {
throw new Error(`Expected a directory: ${dirPath}`);
}
}
function stripQuotes(value) {
return value.trim().replace(/^['"]|['"]$/g, '');
}
function parseToolList(line) {
const match = line.match(/^(\s*tools\s*:\s*)\[(.*)\]\s*$/);
if (!match) {
return null;
}
const rawItems = match[2].trim();
if (!rawItems) {
return [];
}
return rawItems
.split(',')
.map(part => stripQuotes(part))
.filter(Boolean);
}
function adaptToolName(toolName) {
const mapped = TOOL_NAME_MAP.get(toolName);
if (mapped) {
return mapped;
}
if (toolName.startsWith('mcp__')) {
return toolName
.replace(/^mcp__/, 'mcp_')
.replace(/__/g, '_')
.replace(/[^A-Za-z0-9_]/g, '_')
.toLowerCase();
}
return toolName;
}
function formatToolLine(tools) {
return `tools: [${tools.map(tool => JSON.stringify(tool)).join(', ')}]`;
}
function adaptFrontmatter(text) {
const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/);
if (!match) {
return { text, changed: false };
}
let changed = false;
const updatedLines = [];
for (const line of match[1].split('\n')) {
if (/^\s*color\s*:/.test(line)) {
changed = true;
continue;
}
const tools = parseToolList(line);
if (tools) {
const adaptedTools = [];
const seen = new Set();
for (const tool of tools.map(adaptToolName)) {
if (seen.has(tool)) {
continue;
}
seen.add(tool);
adaptedTools.push(tool);
}
const updatedLine = formatToolLine(adaptedTools);
if (updatedLine !== line) {
changed = true;
}
updatedLines.push(updatedLine);
continue;
}
updatedLines.push(line);
}
if (!changed) {
return { text, changed: false };
}
return {
text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`,
changed: true,
};
}
function adaptAgents(dirPath) {
ensureDirectory(dirPath);
let updated = 0;
let unchanged = 0;
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) {
continue;
}
const filePath = path.join(dirPath, entry.name);
const original = fs.readFileSync(filePath, 'utf8');
const adapted = adaptFrontmatter(original);
if (adapted.changed) {
fs.writeFileSync(filePath, adapted.text);
updated += 1;
} else {
unchanged += 1;
}
}
return { updated, unchanged };
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const result = adaptAgents(options.agentsDir);
console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`);
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}

View File

@@ -24,7 +24,10 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_BACKOFF_MS = 30 * 1000;
const MAX_BACKOFF_MS = 10 * 60 * 1000;
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 405]);
// The preflight HTTP probe only checks reachability; it does not have access to
// Claude Code's stored OAuth bearer token. Treat auth-gated responses as
// reachable so the real MCP client can attempt the authenticated call.
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 405]);
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
const FAILURE_PATTERNS = [
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },

View File

@@ -37,6 +37,7 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
],
});
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
c: 'c',
cpp: 'cpp',
csharp: 'csharp',
go: 'go',
@@ -52,6 +53,7 @@ const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
typescript: 'typescript',
});
const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({
c: ['framework-language'],
cpp: ['framework-language'],
csharp: ['framework-language'],
go: ['framework-language'],

View File

@@ -50,6 +50,11 @@ const LANGUAGE_RULES = [
markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
extensions: ['.java']
},
{
type: 'c',
markers: [],
extensions: ['.c']
},
{
type: 'csharp',
markers: [],

View File

@@ -118,4 +118,3 @@ src/integrations/
- `backend-patterns`
- `mcp-server-patterns`
- `github-ops`

View File

@@ -139,7 +139,7 @@ For each selected category, print the full list of skills below and ask the user
| Skill | Description |
|-------|-------------|
| `continuous-learning` | Auto-extract reusable patterns from sessions as learned skills |
| `continuous-learning` | Legacy v1 Stop-hook session pattern extraction; prefer `continuous-learning-v2` for new installs |
| `continuous-learning-v2` | Instinct-based learning with confidence scoring, evolves into skills, agents, and optional legacy command shims |
| `eval-harness` | Formal evaluation framework for eval-driven development (EDD) |
| `iterative-retrieval` | Progressive context refinement for subagent context problem |

View File

@@ -106,4 +106,3 @@ Every panel should answer a real question. If it does not, remove it.
- `research-ops`
- `backend-patterns`
- `terminal-ops`

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Validate agent.yaml exports the legacy command shim surface.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..', '..');
const AGENT_YAML_PATH = path.join(REPO_ROOT, 'agent.yaml');
const COMMANDS_DIR = path.join(REPO_ROOT, 'commands');
function extractTopLevelList(yamlSource, key) {
const lines = yamlSource.replace(/^\uFEFF/, '').split(/\r?\n/);
const results = [];
let collecting = false;
for (const line of lines) {
if (!collecting) {
if (line.trim() === `${key}:`) {
collecting = true;
}
continue;
}
if (/^[A-Za-z0-9_-]+:\s*/.test(line)) {
break;
}
const match = line.match(/^\s*-\s+(.+?)\s*$/);
if (match) {
results.push(match[1]);
}
}
return results;
}
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function run() {
console.log('\n=== Testing agent.yaml export surface ===\n');
let passed = 0;
let failed = 0;
const yamlSource = fs.readFileSync(AGENT_YAML_PATH, 'utf8');
const declaredCommands = extractTopLevelList(yamlSource, 'commands').sort();
const actualCommands = fs.readdirSync(COMMANDS_DIR)
.filter(file => file.endsWith('.md'))
.map(file => path.basename(file, '.md'))
.sort();
if (test('agent.yaml declares commands export surface', () => {
assert.ok(declaredCommands.length > 0, 'Expected non-empty commands list in agent.yaml');
})) passed++; else failed++;
if (test('agent.yaml commands stay in sync with commands/ directory', () => {
assert.deepStrictEqual(declaredCommands, actualCommands);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
run();

View File

@@ -358,6 +358,68 @@ async function runTests() {
}
})) passed++; else failed++;
if (await asyncTest('treats HTTP 401 probe responses as healthy reachable OAuth-protected servers', async () => {
const tempDir = createTempDir();
const configPath = path.join(tempDir, 'claude.json');
const statePath = path.join(tempDir, 'mcp-health.json');
const serverScript = path.join(tempDir, 'http-401-server.js');
const portFile = path.join(tempDir, 'server-port.txt');
fs.writeFileSync(
serverScript,
[
"const fs = require('fs');",
"const http = require('http');",
"const portFile = process.argv[2];",
"const server = http.createServer((_req, res) => {",
" res.writeHead(401, {",
" 'Content-Type': 'application/json',",
" 'WWW-Authenticate': 'Bearer realm=\"OAuth\", error=\"invalid_token\"'",
" });",
" res.end(JSON.stringify({ error: 'missing bearer token' }));",
"});",
"server.listen(0, '127.0.0.1', () => {",
" fs.writeFileSync(portFile, String(server.address().port));",
"});",
"setInterval(() => {}, 1000);"
].join('\n')
);
const serverProcess = spawn(process.execPath, [serverScript, portFile], {
stdio: 'ignore'
});
try {
const port = waitForFile(portFile).trim();
writeConfig(configPath, {
mcpServers: {
atlassian: {
type: 'http',
url: `http://127.0.0.1:${port}/mcp`
}
}
});
const input = { tool_name: 'mcp__atlassian__search', tool_input: {} };
const result = runHook(input, {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '500'
});
assert.strictEqual(result.code, 0, `Expected HTTP 401 probe to be treated as healthy, got ${result.code}`);
assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout');
const state = readState(statePath);
assert.strictEqual(state.servers.atlassian.status, 'healthy', 'Expected OAuth-protected HTTP MCP server to be marked healthy');
} finally {
serverProcess.kill('SIGTERM');
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}

View File

@@ -74,10 +74,20 @@ function runTests() {
const components = listInstallComponents();
assert.ok(components.some(component => component.id === 'lang:typescript'),
'Should include lang:typescript');
assert.ok(components.some(component => component.id === 'lang:c'),
'Should include lang:c');
assert.ok(components.some(component => component.id === 'capability:security'),
'Should include capability:security');
})) passed++; else failed++;
if (test('labels continuous-learning as a legacy v1 install surface', () => {
const components = listInstallComponents({ family: 'skill' });
const component = components.find(entry => entry.id === 'skill:continuous-learning');
assert.ok(component, 'Should include skill:continuous-learning');
assert.match(component.description, /legacy/i, 'Should label continuous-learning as legacy');
assert.match(component.description, /continuous-learning-v2/, 'Should point new installs to continuous-learning-v2');
})) passed++; else failed++;
if (test('lists supported legacy compatibility languages', () => {
const languages = listLegacyCompatibilityLanguages();
assert.ok(languages.includes('typescript'));
@@ -87,6 +97,7 @@ function runTests() {
assert.ok(languages.includes('kotlin'));
assert.ok(languages.includes('rust'));
assert.ok(languages.includes('cpp'));
assert.ok(languages.includes('c'));
assert.ok(languages.includes('csharp'));
})) passed++; else failed++;
@@ -183,6 +194,17 @@ function runTests() {
'cpp should resolve to framework-language module');
})) passed++; else failed++;
if (test('resolves c legacy compatibility into framework-language module', () => {
const selection = resolveLegacyCompatibilitySelection({
target: 'cursor',
legacyLanguages: ['c'],
});
assert.ok(selection.moduleIds.includes('rules-core'));
assert.ok(selection.moduleIds.includes('framework-language'),
'c should resolve to framework-language module');
})) passed++; else failed++;
if (test('resolves csharp legacy compatibility into framework-language module', () => {
const selection = resolveLegacyCompatibilitySelection({
target: 'cursor',

View File

@@ -220,6 +220,20 @@ function runTests() {
}
})) passed++; else failed++;
console.log('\nC Detection:');
if (test('detects c from top-level .c files', () => {
const dir = createTempDir();
try {
writeTestFile(dir, 'main.c', 'int main(void) { return 0; }\n');
const result = detectProjectType(dir);
assert.ok(result.languages.includes('c'));
assert.strictEqual(result.primary, 'c');
} finally {
cleanupDir(dir);
}
})) passed++; else failed++;
// Go detection
console.log('\nGo Detection:');

View File

@@ -68,6 +68,7 @@ function assertSafeRepoRelativePath(relativePath, label) {
console.log('\n=== .claude-plugin/plugin.json ===\n');
const claudePluginPath = path.join(repoRoot, '.claude-plugin', 'plugin.json');
const claudeMarketplacePath = path.join(repoRoot, '.claude-plugin', 'marketplace.json');
test('claude plugin.json exists', () => {
assert.ok(fs.existsSync(claudePluginPath), 'Expected .claude-plugin/plugin.json to exist');
@@ -131,6 +132,30 @@ test('claude plugin.json does NOT have explicit hooks declaration', () => {
);
});
console.log('\n=== .claude-plugin/marketplace.json ===\n');
test('claude marketplace.json exists', () => {
assert.ok(fs.existsSync(claudeMarketplacePath), 'Expected .claude-plugin/marketplace.json to exist');
});
const claudeMarketplace = loadJsonObject(claudeMarketplacePath, '.claude-plugin/marketplace.json');
test('claude marketplace.json keeps only Claude-supported top-level keys', () => {
const unsupportedTopLevelKeys = ['$schema', 'description'];
for (const key of unsupportedTopLevelKeys) {
assert.ok(
!(key in claudeMarketplace),
`.claude-plugin/marketplace.json must not declare unsupported top-level key "${key}"`,
);
}
});
test('claude marketplace.json has plugins array with a short ecc plugin entry', () => {
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
assert.strictEqual(claudeMarketplace.name, 'ecc');
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
});
// ── Codex plugin manifest ─────────────────────────────────────────────────────
// Per official docs: https://platform.openai.com/docs/codex/plugins
// - .codex-plugin/plugin.json is the required manifest

View File

@@ -0,0 +1,136 @@
/**
* Tests for scripts/gemini-adapt-agents.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gemini-adapt-agents.js');
function run(args = [], options = {}) {
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
cwd: options.cwd,
timeout: 10000,
});
return { code: 0, stdout, stderr: '' };
} catch (error) {
return {
code: error.status || 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
};
}
}
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gemini-adapt-'));
}
function cleanupTempDir(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeAgent(dirPath, name, body) {
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, name), body);
}
function runTests() {
console.log('\n=== Testing gemini-adapt-agents.js ===\n');
let passed = 0;
let failed = 0;
if (test('shows help with an explicit help flag', () => {
const result = run(['--help']);
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(result.stdout.includes('Adapt ECC agent frontmatter for Gemini CLI'));
assert.ok(result.stdout.includes('Usage:'));
})) passed++; else failed++;
if (test('adapts Claude Code tool names and strips unsupported color metadata', () => {
const tempDir = createTempDir();
const agentsDir = path.join(tempDir, '.gemini', 'agents');
try {
writeAgent(
agentsDir,
'gan-planner.md',
[
'---',
'name: gan-planner',
'description: Planner agent',
'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__resolve-library-id]',
'model: opus',
'color: purple',
'---',
'',
'Body'
].join('\n')
);
const result = run([agentsDir]);
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(result.stdout.includes('Updated 1 agent file(s)'));
const updated = fs.readFileSync(path.join(agentsDir, 'gan-planner.md'), 'utf8');
assert.ok(updated.includes('tools: ["read_file", "write_file", "replace", "run_shell_command", "grep_search", "glob", "google_web_search", "web_fetch", "mcp_context7_resolve_library_id"]'));
assert.ok(!updated.includes('color: purple'));
} finally {
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
if (test('defaults to the cwd .gemini/agents directory', () => {
const tempDir = createTempDir();
const agentsDir = path.join(tempDir, '.gemini', 'agents');
try {
writeAgent(
agentsDir,
'architect.md',
[
'---',
'name: architect',
'description: Architect agent',
'tools: ["Read", "Grep", "Glob"]',
'model: opus',
'---',
'',
'Body'
].join('\n')
);
const result = run([], { cwd: tempDir });
assert.strictEqual(result.code, 0, result.stderr);
const updated = fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8');
assert.ok(updated.includes('tools: ["read_file", "grep_search", "glob"]'));
} finally {
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();