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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh

## [Unreleased]

## [0.110.41] — 2026-10-22
### Fixed
- **`auto merge` không kẹt cả board vì một card chờ Ship** (owner: "bị kẹt hoài vậy auto ship chứ"; live zus/ide: 19/27 card approved có PR đã merge từ hôm trước nhưng card ngồi yên vì 8 card không merge được làm cả lệnh dừng): chưa có quyền tự merge, `auto merge` vẫn land những việc không cần quyền — PR đã merge → card done, conflict/base sai → về ready/review — rồi MỚI hỏi owner MỘT câu Ship cho đúng những card cần `gh pr merge` thật (hoặc chưa có PR). Không bao giờ tự merge khi chưa có quyền.
## [0.110.40] — 2026-10-22
### Added
- **Một team cho nhiều dự án: `8sync ccc auto up --company` / `auto on --company`** (owner 2026-10-22: "1 cty rooms/roles agent nhận nhiều dự án … chứ hiện tại mỗi dự án 1 hạm đội riêng"; đo 2026-10-22: 17 phòng rảnh giữ 9,4 GB cho hai dự án). Cờ `company` trong auto.json (giữ qua on/off/pause như `teams`): phòng hết card ở dự án mình thì `auto claim` lấy card gấp nhất (P0 trước, rồi thứ tự scope) ở dự án khác trong scope của auto, `auto wait` thức dậy khi dự án khác có card; card về kèm `root` và hướng dẫn `env -C <root> 8sync ccc auto … --as <phòng>` (board, inbox, grant vẫn theo từng repo; dự án bị pin bằng lease vẫn bị bỏ qua). `auto status --json` thêm `company`. Test auto +1 (round trip, chọn theo prio, bỏ qua dự án pin/không board), wait +2 (card dự án khác, dự án mình ưu tiên). HQ nhóm "Công ty" sẽ ở bản sau.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/ccc-core"]

[workspace.package]
version = "0.110.40"
version = "0.110.41"
edition = "2021"
license = "PolyForm-Noncommercial-1.0.0"
authors = ["8sync-dev <[email protected]>"]
Expand Down
2 changes: 1 addition & 1 deletion assets/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "8sync-kit",
"displayName": "8sync kit",
"version": "0.110.40",
"version": "0.110.41",
"description": "8 Sync kit for Claude Code: role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier, pm, architect, qa, critic) and the /ship /rules /sec /harden skills. Hooks, rules and the full skill set come from `8sync ccc kit`.",
"author": { "name": "8 Sync", "url": "https://8syncdev.com" },
"homepage": "https://get.8syncdev.com",
Expand Down
84 changes: 75 additions & 9 deletions crates/cli/src/verbs/ccc/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ struct Ctx<'a> {
/// This project's `owner/repo` ([`origin_repo`]), read only for a card without a PR field.
origin: &'a dyn Fn() -> Option<String>,
gh: Gh<'a>,
/// Whether this run may really `gh pr merge`. The ungranted `auto merge` (owner 2026-10-22:
/// "bị kẹt hoài vậy … auto ship chứ") still lands what needs no permission — a PR already
/// merged is marked done, a conflict goes back to ready — and collects the rest for the one
/// Ship question instead of stalling the whole board on it.
merge_allowed: bool,
}

/// Pure: the PR a card without a `pr` field names in its notes, newest note first: `zus-backend#19
Expand Down Expand Up @@ -419,6 +424,7 @@ fn land(t: &Target, c: &Ctx, first: bool) -> Outcome {
}
};
Ok(match decide(&sha, &v, base_merged, main) {
Step::Merge(head) if !c.merge_allowed => ("ask", None, Some("PR mở, chờ owner Duyệt tất cả (Ship)".into())),
Step::Merge(head) => {
// GitHub refuses to merge a draft; review already approved this head (4 failed
// merges on 3d-game, 2026-10-20).
Expand Down Expand Up @@ -489,20 +495,39 @@ pub(super) fn run(as_json: bool, home: &Path, root: &Path, who: Option<&str>, no
if let Some(lane) = who.and_then(super::room::find).and_then(|r| r.lane).filter(|l| *l != "backlog") {
bail!("`auto merge` là việc của phòng lead, không phải phòng cột {lane}");
}
// Never guess `main`: on a master repo that would merge stacked PRs into the wrong branch.
let Some(main) = default_branch(root) else { bail!(NO_DEFAULT) };
let gh = |args: &[&str]| gh(root, args);
let main_of = |repo: &str| default_branch_of(root, Some(repo));
let origin = || origin_repo(root);
if super::grant::self_merge(&super::grant::dir(home), root, now).is_none() {
let approved: Vec<Target> = target::list(home, root)?.into_iter().filter(|t| t.state == "open" && t.lane == "approved").collect();
if approved.is_empty() {
println!("không có card approved");
return Ok(());
}
bail!("{}", ship_question(&approved));
// Free outcomes first (owner 2026-10-22: 19 of 27 approved cards on zus/ide had PRs merged
// on GitHub the day before and sat in approved because one unmergeable card made the whole
// run bail): done / back / retarget need no merge permission. What really needs
// `gh pr merge` (or has no PR at all) goes into the owner's ONE Ship question.
let c = Ctx { home, root, who: Some("lead"), now, main: &main, main_of: &main_of, origin: &origin, gh: &gh, merge_allowed: false };
let mut left: Vec<Target> = Vec::new();
for t in &approved {
let mut o = land(t, &c, true);
if o.action == "deferred" {
o = land(t, &c, false);
}
match o.action.as_ref() {
"ask" | "wait" | "failed" => left.push(t.clone()),
a => println!("{a}: {}", line(&o)),
}
}
if left.is_empty() {
return Ok(());
}
bail!("{}", ship_question(&left));
}
// Never guess `main`: on a master repo that would merge stacked PRs into the wrong branch.
let Some(main) = default_branch(root) else { bail!(NO_DEFAULT) };
let gh = |args: &[&str]| gh(root, args);
let main_of = |repo: &str| default_branch_of(root, Some(repo));
let origin = || origin_repo(root);
run_with(as_json, &Ctx { home, root, who, now, main: &main, main_of: &main_of, origin: &origin, gh: &gh })
run_with(as_json, &Ctx { home, root, who, now, main: &main, main_of: &main_of, origin: &origin, gh: &gh, merge_allowed: true })
}

/// HQ's revive tick (every 2 min): land approved cards in code while the owner's self-merge grant
Expand All @@ -520,7 +545,7 @@ pub(super) fn background(home: &Path, root: &Path, now: u64) -> Result<Vec<Strin
let gh = |args: &[&str]| gh(root, args);
let main_of = |repo: &str| default_branch_of(root, Some(repo));
let origin = || origin_repo(root);
let c = Ctx { home, root, who: Some("lead"), now, main: &main, main_of: &main_of, origin: &origin, gh: &gh };
let c = Ctx { home, root, who: Some("lead"), now, main: &main, main_of: &main_of, origin: &origin, gh: &gh, merge_allowed: true };
// HQ kills a revive that runs past 120 s and each card is several gh calls (≤ 20 s each): start
// no card after [`BACKGROUND_BUDGET`]. A card stacked on an approved card not landed yet is
// deferred, not bounced: try the next.
Expand Down Expand Up @@ -695,6 +720,47 @@ mod tests {
assert_eq!(approved_merge(8, Some("8-Sync-Dev/zus-backend"), HEAD, &cards, || Some(("main".into(), "develop".into()))), None, "not that repo's default branch");
}

/// Owner 2026-10-22 ("bị kẹt hoài"): without the grant, `auto merge` still lands what needs
/// no permission — a card whose PR is already merged is done — and the OPEN one is only "ask",
/// never `gh pr merge`.
#[test]
fn ungranted_lands_merged_prs_and_never_merges_open_ones() {
let h = std::env::temp_dir().join(format!("8sync-merge-declog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&h);
let root = h.join("r");
std::fs::create_dir_all(&root).unwrap();
// `move_to` validates a `--pr` against the real gh; the fixture sets the sha on the card
// (what approved requires) and the PR number in memory, which is all `land` reads.
let mk = |pr: &str| {
let (t, _) = target::add(&h, &root, &format!("việc {pr}"), Some("true"), None, false, None, 1).unwrap();
for lane in ["building", "qa", "review", "approved"] {
target::move_to(&h, &root, t.id, lane, &target::Move { sha: Some("0d596904e5cbf53f94fbc8198fcd9b0f9848866e".into()), ..Default::default() }, None, 2).unwrap();
}
let mut c = target::list(&h, &root).unwrap().into_iter().find(|c| c.id == t.id).unwrap();
c.pr = Some(pr.into());
c
};
let a = mk("61");
let b = mk("62");
let merged = json!({ "state": "MERGED", "baseRefName": "main", "headRefOid": "0d596904e5cbf53f94fbc8198fcd9b0f9848866e", "mergeable": "MERGEABLE", "isDraft": false });
let open = json!({ "state": "OPEN", "baseRefName": "main", "headRefOid": "0d596904e5cbf53f94fbc8198fcd9b0f9848866e", "mergeable": "MERGEABLE", "isDraft": false });
let main_of = |r: &str| (r == "main").then(|| "main".to_string()).or_else(|| Some("main".to_string()));
let origin = || Some("o/r".to_string());
let gh_merged = |_: &[&str]| Ok(merged.to_string());
let gh_open = |args: &[&str]| {
assert!(!args.contains(&"merge"), "no gh pr merge without the grant: {args:?}");
Ok(open.to_string())
};
let c1 = Ctx { home: &h, root: &root, who: Some("lead"), now: 1_000, main: "main", main_of: &main_of, origin: &origin, gh: &gh_merged, merge_allowed: false };
let o = land(&a, &c1, true);
assert_eq!(o.action, "done", "{:?} - a merged PR needs no permission", o.why);
assert_eq!(target::list(&h, &root).unwrap().into_iter().find(|c| c.id == a.id).unwrap().state, "done");
let c2 = Ctx { home: &h, root: &root, who: Some("lead"), now: 1_000, main: "main", main_of: &main_of, origin: &origin, gh: &gh_open, merge_allowed: false };
let o = land(&b, &c2, true);
assert_eq!(o.action, "ask", "{:?}", o.why);
let _ = std::fs::remove_dir_all(&h);
}

#[test]
fn the_ship_refusal_carries_the_exact_question() {
let mut other = card(7, "approved", "https://github.com/o/r/pull/90", "0123456");
Expand Down Expand Up @@ -772,7 +838,7 @@ mod tests {
};
let main_of = |r: &str| (r == "8-Sync-Dev/zus-backend").then(|| "develop".to_string());
let origin = || Some("8-Sync-Dev/zus".to_string());
let ctx = Ctx { home: &home, root: &root, who: None, now: 1_000, main, main_of: &main_of, origin: &origin, gh: &gh };
let ctx = Ctx { home: &home, root: &root, who: None, now: 1_000, main, main_of: &main_of, origin: &origin, gh: &gh, merge_allowed: true };
run_with(true, &ctx).unwrap();
let card = target::list(&home, &root).unwrap().into_iter().find(|t| t.id == 1).unwrap();
let _ = std::fs::remove_dir_all(&home);
Expand Down
1 change: 1 addition & 0 deletions su-code/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ CLI = on/off; everything else from HQ (local or internet link). Rooms never stay
- [x] Release v0.101.1 (12 assets) → installed → HQ 8740 restarted on 0.101.1 → `auto up --roles full --clear-ss` (rd, ui restarted on the new kit; 6 busy rooms move at their next idle `--clear-ss`) → `--public` UAT: wrong key 401, right key 303 + `hq_pub` HttpOnly Secure cookie, app loads data at 430 px; HQ back to local-only.

## Current
v0.110.41: ungranted auto merge lands merged-PR cards (done) and asks Ship only for what really needs gh pr merge.
v0.110.38: critic immediate (after 0 default), HQ critic toggle default-on, stuck lead → owner banner.
v0.110.36: stuck-question relay — idle room ending on an unanswered AskUserQuestion → lead mailed + woken, 10-min debounce per room.
v0.110.29: tick auto chạy server 60 s (không cần tab); Báo cáo có dòng +/− từ transcript; phòng giữ card giữ thư mục build; guard nhắc kết thúc lượt khi có lệnh nền. Đang UAT: quét transcript phòng xem sau khi bị chặn `sleep` phòng làm gì. Chờ owner: repo công khai, token HQ, merge tay zus-backend #5-#14.
Expand Down
Loading