Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a6c5f1d
feat(projects): add agent and CLI project-home support
thomaspblock Aug 23, 2026
7a9af2a
fix(projects): authenticate channel home routing
Aug 23, 2026
7bbed3f
fix(projects): validate reused repo binding
Aug 23, 2026
ac2c45e
feat(projects): make project creation channel-first (#6591)
thomaspblock Aug 23, 2026
6c0abfd
feat(channels): support project-home channel surfaces (#6594)
thomaspblock Aug 23, 2026
7d6c4ab
feat(projects): make the home channel the project surface (#6595)
thomaspblock Aug 23, 2026
2e0fe69
fix(projects): address review feedback
Aug 24, 2026
adff0ac
fix(projects): scope agent membership mutations
Aug 24, 2026
8b1d7e7
fix(projects): refresh and exhaust project lookups
Aug 24, 2026
e1b1f1b
Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes
Aug 24, 2026
9ee5e05
fix(projects): preserve authoritative project context
Aug 24, 2026
06dcc18
Merge origin/main into wrench/pr6590-fixes
Aug 24, 2026
86bf494
refactor(desktop): keep channel panes within size ratchet
Aug 24, 2026
7c630f5
test(projects): cover causal discovery and create races
Aug 24, 2026
73d4e2a
fix(projects): verify fallback repository winner
Aug 24, 2026
b4e1d8c
test(desktop): enter repository workspace for project thread
Aug 24, 2026
e722010
Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes
Aug 25, 2026
8231a73
fix(projects): fail closed on bounded discovery
Aug 25, 2026
67d8dca
test(projects): prove scoped discovery bound
Aug 25, 2026
fef7aee
Merge current main into projects CLI stack
Aug 25, 2026
90f046d
feat(projects): unify navigation and reviewed channel requests (#6597)
thomaspblock Aug 25, 2026
dd9c6d8
fix(projects): secure project request routing
Aug 25, 2026
b539ee9
fix(projects): bound channel request queue
Aug 25, 2026
aa7e175
fix(projects): stop prompts on indeterminate context
Aug 26, 2026
710bcb4
Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes
Aug 26, 2026
5c8f8d2
fix(acp): guard project context before sessions
Aug 26, 2026
8428b32
Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes
Aug 26, 2026
d2c5f7e
fix(desktop): disclose requested channel lifetime
Aug 26, 2026
a28cfeb
Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes
Aug 26, 2026
495f1d9
Merge main and preserve project context framing
Aug 26, 2026
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
10 changes: 10 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz projects` | `create`, `get`, `list`, `add-repo`, `add-channel` |
| `buzz issues` | `create`, `get`, `list`, `status`, `assign` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
Expand All @@ -31,6 +32,15 @@ Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message

When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `<context>`. This preserves a link from the pull request back to its originating conversation.

## Projects

A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project.

- If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `<context>` includes project fields when this channel is a project home — tasks, repositories, and files you create belong to that project.
- To add a codebase: `buzz repos create --id <id> --name "…" --channel <current-channel-uuid>`. `mkdir` in `REPOS/` is not a Buzz repository.
- To add tasks: `buzz issues create --channel <current-channel-uuid> --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project.
- To add another channel to this project: `buzz projects add-channel --home-channel <current-channel-uuid> --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it.

`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.

To assign an issue to someone, run `buzz issues assign --issue <event-id> --repo-owner <hex> --repo-id <id> --assignee <hex> --label <name>` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves.
Expand Down
113 changes: 111 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod observer;
mod pool;
mod pool_lifecycle;
mod prompt_framing;
mod prompt_project;
mod queue;
mod relay;
mod setup_mode;
Expand Down Expand Up @@ -291,7 +292,7 @@ pub(crate) async fn is_dm_channel(
channel_id: Uuid,
channel_info: &pool::ChannelInfoResolver,
) -> bool {
match channel_info.resolve(channel_id).await {
match channel_info.resolve_channel_metadata(channel_id).await {
Some(info) => info.channel_type == "dm",
None => {
tracing::warn!(
Expand Down Expand Up @@ -4013,6 +4014,7 @@ fn handle_prompt_result(
}
PromptOutcome::AgentExited => "the agent process exited".to_string(),
PromptOutcome::Error(e) => format!("{e}"),
PromptOutcome::ProjectContextIndeterminate(reason) => reason.clone(),
_ => "repeated failures".to_string(),
};
let content = format!(
Expand Down Expand Up @@ -4045,6 +4047,7 @@ fn handle_prompt_result(
let outcome_label = match &result.outcome {
PromptOutcome::Ok(_) => "ok",
PromptOutcome::Error(_) => "error",
PromptOutcome::ProjectContextIndeterminate(_) => "project_context_indeterminate",
PromptOutcome::Timeout(TimeoutKind::Idle) => "idle_timeout",
PromptOutcome::Timeout(TimeoutKind::Hard { .. }) => "hard_timeout",
PromptOutcome::AgentExited => "exited",
Expand Down Expand Up @@ -4206,6 +4209,16 @@ fn handle_prompt_result(
);
pool.return_agent(result.agent);
}
PromptOutcome::ProjectContextIndeterminate(reason) => {
tracing::warn!(
agent = agent_index,
outcome = outcome_label,
reason,
"agent_returned (local project context indeterminate — pipe intact)"
);
emit_turn_error(&reason, None);
pool.return_agent(result.agent);
}
PromptOutcome::Error(ref e) => {
let is_transport_error = matches!(
e,
Expand Down Expand Up @@ -4490,6 +4503,14 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("update the team's shared guidance"));
}

#[test]
fn shared_base_prompt_teaches_not_to_duplicate_projects() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("do **not** run `buzz projects create`"));
assert!(prompt.contains("buzz issues create --channel"));
assert!(prompt.contains("is not a Buzz repository"));
}

#[test]
fn shared_base_prompt_teaches_single_command_mentions_and_preflight() {
let prompt = include_str!("base_prompt.md");
Expand Down Expand Up @@ -5682,7 +5703,7 @@ mod author_gate_tests {
assert_eq!(
requests.load(Ordering::SeqCst),
1,
"second resolution uses cache"
"author-gate DM classification resolves and caches channel metadata only"
);
server.abort();
}
Expand Down Expand Up @@ -8249,6 +8270,94 @@ mod error_outcome_emission_tests {
assert_eq!(turn_errors_emitted_for(PromptOutcome::Error(app)).await, 1);
}

#[tokio::test]
async fn indeterminate_project_context_requeues_without_poisoning_agent_or_circuit() {
let channel_id = Uuid::new_v4();
let event = EventBuilder::new(Kind::Custom(9), "project work")
.sign_with_keys(&Keys::generate())
.unwrap();
let batch = FlushBatch {
channel_id,
events: vec![BatchEvent {
event,
prompt_tag: "test".into(),
received_at: std::time::Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

let mut agent = dummy_agent(0).await;
agent
.state
.sessions
.insert(channel_id, "healthy-session".into());
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
pool.task_map_mut().insert(
task_id,
crate::pool::TaskMeta {
agent_index: 0,
channel_id: Some(channel_id),
turn_id: "indeterminate-project".into(),
recoverable_batch: None,
control_tx: None,
steer_tx: None,
successful_steer_deliveries: HashSet::new(),
},
);
let mut queue = EventQueue::new(config::DedupMode::Queue);
let config = test_config();
let mut heartbeat_in_flight = false;
let removed_channels = HashSet::new();
let mut crash_history = vec![SlotCircuit {
crash_times: Vec::new(),
open_until: None,
respawn_in_flight: false,
}];
let (respawn_tx, _respawn_rx) = mpsc::channel(8);
let mut respawn_tasks = tokio::task::JoinSet::new();
let result = PromptResult {
agent,
source: PromptSource::Channel(channel_id),
turn_id: "indeterminate-project".into(),
outcome: PromptOutcome::ProjectContextIndeterminate(
"project context is indeterminate".into(),
),
batch: Some(batch),
};

assert!(matches!(
handle_prompt_result(
&mut pool,
&mut queue,
&config,
result,
&mut heartbeat_in_flight,
&removed_channels,
&mut crash_history,
&respawn_tx,
&mut respawn_tasks,
None,
None,
),
LoopAction::Continue
));

let returned = pool.agents_mut()[0]
.as_ref()
.expect("healthy agent returns to its slot");
assert_eq!(
returned.state.sessions.get(&channel_id).map(String::as_str),
Some("healthy-session")
);
assert_eq!(queue.queued_event_count(&channel_id), 1);
assert!(crash_history[0].crash_times.is_empty());
assert!(crash_history[0].open_until.is_none());
assert!(!crash_history[0].respawn_in_flight);
assert!(respawn_tasks.is_empty());
}

// ── is_auth_error classification ───────────────────────────────────────

#[test]
Expand Down
Loading
Loading