mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-18 06:43:05 +08:00
Compare commits
35 Commits
9d766af025
...
1b3ccb85aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3ccb85aa | ||
|
|
2e5e94cb7f | ||
|
|
adfe8a8311 | ||
|
|
b3f781a648 | ||
|
|
86cbe3d616 | ||
|
|
9bd8e8b3c7 | ||
|
|
e226772a72 | ||
|
|
e363c54057 | ||
|
|
eb274d25d9 | ||
|
|
dada133784 | ||
|
|
d8c8178f92 | ||
|
|
27d7964bb1 | ||
|
|
e6460534e3 | ||
|
|
4834dfd280 | ||
|
|
7f2c14ecf8 | ||
|
|
027d77468e | ||
|
|
689235af16 | ||
|
|
4834b63b35 | ||
|
|
2dee4072a3 | ||
|
|
e7be2ddf8d | ||
|
|
10b8471e3c | ||
|
|
dd14888f5f | ||
|
|
87d520f0b1 | ||
|
|
5070b2d785 | ||
|
|
afb97961e3 | ||
|
|
dc12e902b1 | ||
|
|
2b7b717664 | ||
|
|
d738089e3e | ||
|
|
bcf8d0617e | ||
|
|
da4c7791fe | ||
|
|
53d8cee6f8 | ||
|
|
cd94878374 | ||
|
|
0ff58108e4 | ||
|
|
1bc9b9c585 | ||
|
|
10e34aa47a |
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
80
agent.yaml
80
agent.yaml
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 钩子
|
||||
|
||||
| 钩子 | 匹配器 | 功能 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1998
ecc2/src/main.rs
1998
ecc2/src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"schemas/",
|
||||
"scripts/ci/",
|
||||
"scripts/ecc.js",
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/hooks/",
|
||||
"scripts/lib/",
|
||||
"scripts/claw.js",
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
189
scripts/gemini-adapt-agents.js
Normal file
189
scripts/gemini-adapt-agents.js
Normal 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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -118,4 +118,3 @@ src/integrations/
|
||||
- `backend-patterns`
|
||||
- `mcp-server-patterns`
|
||||
- `github-ops`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -106,4 +106,3 @@ Every panel should answer a real question. If it does not, remove it.
|
||||
- `research-ops`
|
||||
- `backend-patterns`
|
||||
- `terminal-ops`
|
||||
|
||||
|
||||
79
tests/ci/agent-yaml-surface.test.js
Normal file
79
tests/ci/agent-yaml-surface.test.js
Normal 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();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
136
tests/scripts/gemini-adapt-agents.test.js
Normal file
136
tests/scripts/gemini-adapt-agents.test.js
Normal 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();
|
||||
Reference in New Issue
Block a user