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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions .claude/commands/github-pr.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Commit, create PR, monitor CI, fix failures, merge
allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*), Bash(git checkout:*), Bash(git log:*), Bash(gh pr:*), Bash(gh run:*), Bash(cargo check:*), Bash(cargo test:*), Bash(cargo clippy:*), Read, Edit, Write, Grep, Glob
allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*), Bash(git checkout:*), Bash(git log:*), Bash(git fetch:*), Bash(git merge:*), Bash(git rebase:*), Bash(git cherry-pick:*), Bash(git reset:*), Bash(gh pr:*), Bash(gh run:*), Bash(gh api:*), Bash(cargo check:*), Bash(cargo test:*), Bash(cargo clippy:*), Bash(bazel:*), Bash(rustfmt:*), Bash(sleep:*), Read, Edit, Write, Grep, Glob
---

# GitHub PR 全流程
Expand Down Expand Up @@ -54,6 +54,43 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c

4. 记录 PR 编号,后续阶段会用到。

## Phase 2.5: Conflict Detection

CI 不触发的常见原因是 PR 有 conflict。创建 PR 后立即检查:

```
gh pr view <PR#> --json mergeable,mergeStateStatus
```

- **mergeStateStatus: "CLEAN"** → 无冲突,继续 Phase 3
- **mergeStateStatus: "DIRTY" / mergeable: "CONFLICTING"** → 需要解决冲突:

**解决流程:**
1. `git fetch origin main` 获取最新 main
2. `git log --oneline origin/main..HEAD` 查看当前分支比 main 多了哪些 commit
3. 判断多余 commit 是否已经在 main 上(通过其他 PR squash 合并过):
- `git log --oneline <merge-base>..origin/main` 对比 main 上的新 commit
- 如果当前分支的旧 commit 已在 main 上 squash 合并,只需保留本次 PR 的 commit
4. 解决方案(按优先级):
- **Cherry-pick 法**(推荐,当旧 commit 已在 main 上):
```
git reset --hard origin/main
git cherry-pick <本次PR的commit>
git push --force-with-lease
```
- **Rebase 法**(通用):
```
git rebase origin/main
# 逐个解决冲突
git push --force-with-lease
```
5. 推送后重新检查 `gh pr view <PR#> --json mergeable,mergeStateStatus`

**注意**:worktree 中 `gh pr merge` 可能因为无法 checkout main 而失败,此时用 API 合并:
```
gh api repos/{owner}/{repo}/pulls/{PR#}/merge -f merge_method=squash
```

## Phase 3: Monitor CI

1. 等待 10 秒让 CI 启动,然后开始轮询:
Expand Down Expand Up @@ -115,6 +152,7 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c

- 每个阶段完成后简要汇报进度
- 遇到不确定的决策(如 CI 失败原因不明)时询问用户
- 绝不 force push
- 绝不直接 push 到 main/master
- Force push 仅限 `--force-with-lease` 且仅在 rebase/cherry-pick 解决冲突后使用
- 如果 PR 需要 review approval 才能 merge,报告并等待用户指示
- 在 worktree 中合并 PR 时,优先使用 `gh api` 而非 `gh pr merge`(避免 checkout main 失败)
2 changes: 2 additions & 0 deletions crates/loopal-agent-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ impl AgentClient {
no_sandbox: bool,
resume: Option<&str>,
lifecycle: Option<&str>,
agent_type: Option<&str>,
) -> anyhow::Result<String> {
let params = serde_json::json!({
"cwd": cwd.to_string_lossy(),
Expand All @@ -65,6 +66,7 @@ impl AgentClient {
"no_sandbox": no_sandbox,
"resume": resume,
"lifecycle": lifecycle,
"agent_type": agent_type,
});
let result = self
.connection
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ pub async fn handle_spawn_agent(
let model = params["model"].as_str().map(String::from);
let prompt = params["prompt"].as_str().map(String::from);
let permission_mode = params["permission_mode"].as_str().map(String::from);
let agent_type = params["agent_type"].as_str().map(String::from);

// Parent: use explicit "parent" field from params if present (cross-hub),
// otherwise use from_agent (local spawn).
Expand All @@ -180,6 +181,7 @@ pub async fn handle_spawn_agent(
prompt,
parent,
permission_mode,
agent_type,
)
.await
});
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent-hub/src/spawn_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use loopal_protocol::{AgentEvent, AgentEventPayload, Envelope};
use crate::hub::Hub;

/// Spawn a real agent process, initialize, start, and register in Hub.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_and_register(
hub: Arc<Mutex<Hub>>,
name: String,
Expand All @@ -20,6 +21,7 @@ pub async fn spawn_and_register(
prompt: Option<String>,
parent: Option<String>,
permission_mode: Option<String>,
agent_type: Option<String>,
) -> Result<String, String> {
info!(agent = %name, parent = ?parent, "spawn: forking process");
let agent_proc = loopal_agent_client::AgentProcess::spawn(None)
Expand All @@ -44,6 +46,7 @@ pub async fn spawn_and_register(
false,
None,
Some("ephemeral"), // sub-agents always exit on idle
agent_type.as_deref(),
)
.await
{
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async fn full_bootstrap_hub_to_agent_roundtrip() {
true, // no sandbox
None,
None, // lifecycle: default
None, // agent_type
)
.await
.expect("start_agent should work");
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pub fn build_with_frontend(
&cwd.to_string_lossy(),
&skills_summary,
&config.memory,
start.agent_type.as_deref(),
);

// Append MCP server instructions (from initialize handshake).
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/memory_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ impl MemoryProcessor for ServerMemoryProcessor {
cwd_override: None,
permission_mode: None,
target_hub: None,
agent_type: None,
};
spawn_agent(&self.shared, params).await?;
info!("memory-maintainer agent spawned via Hub");
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-agent-server/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub struct StartParams {
pub resume: Option<String>,
/// Explicit lifecycle mode. Ephemeral exits on idle, Persistent waits.
pub lifecycle: loopal_runtime::LifecycleMode,
/// Agent type for fragment selection (e.g. "explore", "plan").
pub agent_type: Option<String>,
}

/// Build a Kernel from config (production path: MCP, tools).
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub(crate) async fn start_session(
no_sandbox: params["no_sandbox"].as_bool().unwrap_or(false),
resume: params["resume"].as_str().map(String::from),
lifecycle,
agent_type: params["agent_type"].as_str().map(String::from),
};

let mut config = load_config(&cwd)?;
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/tests/suite/hub_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub async fn build_hub_harness_with(
no_sandbox: true,
resume: None,
lifecycle: loopal_runtime::LifecycleMode::Persistent,
agent_type: None,
};
let (hub_conn, _hub_peer) = loopal_ipc::duplex_pair();
let hub_connection = std::sync::Arc::new(loopal_ipc::Connection::new(hub_conn));
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent/src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct SpawnParams {
pub permission_mode: Option<String>,
/// Target hub for cross-hub spawn (e.g. "hub-b"). None = local hub.
pub target_hub: Option<String>,
/// Agent type for fragment selection (e.g. "explore", "plan").
pub agent_type: Option<String>,
}

/// Result returned from Hub after spawning.
Expand All @@ -45,6 +47,7 @@ pub async fn spawn_agent(
"model": params.model,
"prompt": params.prompt,
"permission_mode": params.permission_mode,
"agent_type": params.agent_type,
});
if let Some(ref hub) = params.target_hub {
request["target_hub"] = json!(hub);
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent/src/tools/collaboration/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ async fn action_spawn(
cwd_override,
permission_mode: Some(perm_mode.to_string()),
target_hub,
agent_type: subagent_type.map(String::from),
},
)
.await;
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent/tests/suite/bridge_chain_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ async fn full_chain_sub_agent_result_delivered_to_parent() {
false,
None,
None,
None,
)
.await
.expect("start_agent");
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-agent/tests/suite/bridge_child_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub(crate) async fn start_bridge_client(
false,
None,
None,
None,
)
.await
.expect("start_agent");
Expand Down Expand Up @@ -156,6 +157,7 @@ async fn bridge_cancel_sends_shutdown() {
false,
None,
None,
None,
)
.await
.unwrap();
Expand Down
30 changes: 6 additions & 24 deletions crates/loopal-context/src/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub fn build_system_prompt(
cwd: &str,
skills_summary: &str,
memory: &str,
agent_type: Option<&str>,
) -> String {
let mut registry = FragmentRegistry::new(system_fragments());

Expand All @@ -23,26 +24,15 @@ pub fn build_system_prompt(

let builder = PromptBuilder::new(registry);

// Tool names/descriptions feed Minijinja conditionals in fragments
// (e.g. `{% if "Bash" in tool_names %}`). Full JSON schemas are sent
// separately via ChatParams.tools — no need to duplicate them here.
let tool_names: Vec<String> = tools.iter().map(|t| t.name.clone()).collect();
let tool_descriptions: HashMap<String, String> = tools
.iter()
.map(|t| (t.name.clone(), t.description.clone()))
.collect();

// Build tool schema section (kept as-is for LLM function calling)
let tools_section = if tools.is_empty() {
String::new()
} else {
let mut s = String::from("# Available Tools\n");
for tool in tools {
s.push_str(&format!(
"\n## {}\n{}\nParameters: {}\n",
tool.name, tool.description, tool.input_schema
));
}
s
};

let ctx = PromptContext {
cwd: cwd.to_string(),
platform: std::env::consts::OS.to_string(),
Expand All @@ -61,18 +51,10 @@ pub fn build_system_prompt(
skills_summary: skills_summary.to_string(),
features: Vec::new(),
agent_name: None,
agent_type: None,
agent_type: agent_type.map(String::from),
};

let mut prompt = builder.build(&ctx);

// Append tool schemas after fragments (LLM needs the JSON schemas)
if !tools_section.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(&tools_section);
}

prompt
builder.build(&ctx)
}

fn today() -> String {
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-context/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mod pipeline_test;
mod smart_compact_test;
#[path = "suite/store_test.rs"]
mod store_test;
#[path = "suite/system_prompt_agent_test.rs"]
mod system_prompt_agent_test;
#[path = "suite/system_prompt_test.rs"]
mod system_prompt_test;
#[path = "suite/token_counter_test.rs"]
Expand Down
108 changes: 108 additions & 0 deletions crates/loopal-context/tests/suite/system_prompt_agent_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use loopal_context::build_system_prompt;
use loopal_tool_api::ToolDefinition;

#[test]
fn explore_subagent_full_prompt() {
let tools = vec![
ToolDefinition {
name: "Read".into(),
description: "Read a file".into(),
input_schema: serde_json::json!({"type": "object"}),
},
ToolDefinition {
name: "Grep".into(),
description: "Search file contents".into(),
input_schema: serde_json::json!({"type": "object"}),
},
ToolDefinition {
name: "Bash".into(),
description: "Execute commands".into(),
input_schema: serde_json::json!({"type": "object"}),
},
];
let result = build_system_prompt(
"Project rules",
&tools,
"act",
"/project",
"",
"",
Some("explore"),
);

// Explore-specific content present
assert!(
result.contains("READ-ONLY MODE"),
"explore fragment should be included"
);
// Default sub-agent fragment excluded (explore is specific)
assert!(
!result.contains("sub-agent named"),
"default-subagent should be excluded when explore matches"
);
// Core fragments still present
assert!(
result.contains("Output Efficiency"),
"core fragments should be included for sub-agents"
);
// Tool usage policy still present
assert!(
result.contains("Tool Usage Policy"),
"tool usage policy should be included for sub-agents"
);
// User instructions still present
assert!(result.contains("Project rules"), "instructions missing");
// Tool schemas NOT in prompt
assert!(
!result.contains("# Available Tools"),
"tool schemas should not be in system prompt"
);
}

#[test]
fn root_agent_excludes_agent_fragments() {
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None);
// No agent fragments in root prompt
assert!(
!result.contains("sub-agent named"),
"default-subagent should not appear for root agent"
);
assert!(
!result.contains("READ-ONLY MODE"),
"explore fragment should not appear for root agent"
);
// Core fragments present
assert!(result.contains("Output Efficiency"));
}

#[test]
fn plan_subagent_gets_plan_fragment() {
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("plan"));
assert!(
result.contains("software architect"),
"plan fragment should be included"
);
assert!(
!result.contains("sub-agent named"),
"default-subagent excluded when plan matches"
);
assert!(
!result.contains("READ-ONLY MODE"),
"explore fragment should not be included for plan agent"
);
}

#[test]
fn general_subagent_gets_default_fragment() {
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general"));
// Default sub-agent fragment (fallback for unknown types)
assert!(
result.contains("sub-agent named"),
"default-subagent should be included for unknown agent types"
);
// Specialized fragments excluded
assert!(
!result.contains("READ-ONLY MODE"),
"explore fragment should not appear for general"
);
}
Loading
Loading