diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a120b2..5485986c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh ## [Unreleased] +## [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 8sync ccc auto … --as ` (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. +- **Nút "Ship tất cả" / "Duyệt tất cả" trên cột Approved của Board** (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút ở ví dụ ship all": 21 card approved nằm sau một câu Ship chưa ai bấm). Có câu Ship của lead đang chờ → nút ghi "Duyệt tất cả", một chạm trả lời đúng option đó (như khung Anh cần trả lời; màn hình đổi thì báo mở khung đó); chưa có câu nào → "Ship tất cả" nhắc lead hỏi Ship ngay lượt này (`auto ship-fast ship-ask`) và nút đổi thành "Duyệt tất cả" khi câu hỏi hiện. Test ship-all 2. + ## [0.110.39] — 2026-10-22 ### Changed - **Tổng quan gọn lại** (owner 2026-10-22: "ui ux loạn quá"): câu hỏi dài trong khung "Anh cần trả lời" (danh sách 19 card merge của lead) gập còn 3 dòng, bấm vào mở đủ; bỏ dòng "Màn hình phòng đang hiện đúng câu này" và dòng nhắc Ship khi đã có nút đáp án; cột Preview ẩn khi chưa có ảnh (tab Preview vẫn chỉ chỗ lưu ảnh). diff --git a/Cargo.lock b/Cargo.lock index 9be5befd..9cecb7d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,7 +200,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.110.39" +version = "0.110.40" dependencies = [ "anyhow", "dirs", @@ -1503,7 +1503,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.110.39" +version = "0.110.40" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 7054b158..2b60b1ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/ccc-core"] [workspace.package] -version = "0.110.39" +version = "0.110.40" edition = "2021" license = "PolyForm-Noncommercial-1.0.0" authors = ["8sync-dev <8sync.dev.1111@gmail.com>"] diff --git a/apps/hq-ui/components/hq/board-view.tsx b/apps/hq-ui/components/hq/board-view.tsx index 3cba6f00..32384f78 100644 --- a/apps/hq-ui/components/hq/board-view.tsx +++ b/apps/hq-ui/components/hq/board-view.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { addressOf, inboxText, messageTargets, sessionLabel } from "@/lib/labels"; +import type { ShipRoom } from "@/lib/ship-all"; import { cn } from "@/lib/utils"; import { EmptyState, ErrorNote, ago, shortName } from "./bits"; import { LanesView } from "./lanes-view"; @@ -24,6 +25,8 @@ export function BoardView({ boards, focus, repo = null, + rooms, + token, }: { hq: Hq; tasks: Task[]; @@ -32,6 +35,9 @@ export function BoardView({ /** A card another tab asked to open: shows the project cards and opens it. */ focus?: { root: string; id: number; n: number } | null; repo?: string | null; + /** Office rooms + token: the Approved column's "Ship tất cả" answers the project's pending Ship question. */ + rooms?: ShipRoom[]; + token?: string; }) { const [mode, setMode] = useState<"lanes" | "tasks">("lanes"); // A new "Mở card" request always lands on the project cards. @@ -51,7 +57,7 @@ export function BoardView({ {tab("lanes", "Card dự án")} {tab("tasks", "Task của session")} -
{mode === "lanes" ? : }
+
{mode === "lanes" ? : }
); } diff --git a/apps/hq-ui/components/hq/hq-app.tsx b/apps/hq-ui/components/hq/hq-app.tsx index d9f4f699..d8497675 100644 --- a/apps/hq-ui/components/hq/hq-app.tsx +++ b/apps/hq-ui/components/hq/hq-app.tsx @@ -398,7 +398,7 @@ export function HqApp({ token, ui }: { token: string; ui: string | null }) { - + diff --git a/apps/hq-ui/components/hq/lanes-view.tsx b/apps/hq-ui/components/hq/lanes-view.tsx index 5b4c5cd2..90542f19 100644 --- a/apps/hq-ui/components/hq/lanes-view.tsx +++ b/apps/hq-ui/components/hq/lanes-view.tsx @@ -10,7 +10,10 @@ import { cardParts, type Kid, nextUp, prRef, splitInto } from "@/lib/card-tags"; import { LANES, boardSummary, claimStale, openCount, rankOf, type Card, type Closed, type LaneKey } from "@/lib/lanes"; import { agoShort, agoVi, roomRole, type Move } from "@/lib/now"; import { rootKey } from "@/lib/preview"; +import { shipAskOf, type ShipAsk, type ShipRoom } from "@/lib/ship-all"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { ApiError, makeApi } from "./use-hq"; import { EmptyState, ErrorNote } from "./bits"; import { CardSheet, ClosedSheet, ForceButton, LabelChip, PrioChip, TagChips, TONE_INK, forceKind, laneTone } from "./card-sheet"; import { RoleIcon } from "./role-badge"; @@ -18,7 +21,7 @@ import { LaneStrip } from "./scroll-strip"; import type { BoardsState } from "./use-board"; /** The project board (`8sync ccc auto board`): which room holds which card, lane by lane. */ -export function LanesView({ state, focus, repo = null }: { state: BoardsState; focus?: { root: string; id: number; n: number } | null; repo?: string | null }) { +export function LanesView({ state, focus, repo = null, rooms = [], token }: { state: BoardsState; focus?: { root: string; id: number; n: number } | null; repo?: string | null; rooms?: ShipRoom[]; token?: string }) { const { boards, error, moves, at: now, reload: load } = state; const [pick, setPick] = useState(repo); useFollowRepo(repo, setPick); @@ -73,6 +76,10 @@ export function LanesView({ state, focus, repo = null }: { state: BoardsState; f const live = open ? boards?.find((b) => b.root === open.root)?.board : null; const shown = open ? ((live ? LANES.flatMap((l) => live.lanes[l.key]).find((c) => c.id === open.card.id) : undefined) ?? open.card) : null; const onForce = cur ? (c: Card) => state.force(cur.root, c) : undefined; + // "Ship tất cả" (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút"): the project's pending Ship + // question, answered with "Duyệt tất cả" in one tap; none pending → the lead is told to ask it now (ship-ask). + const shipAsk = cur && token ? shipAskOf(rooms, cur.root) : null; + const onShip = cur && token ? () => shipAll(token, cur.root, shipAsk, load) : undefined; const onOpenCard = (c: Card) => { if (!cur) return; setOpen({ root: cur.root, card: c }); @@ -136,7 +143,7 @@ export function LanesView({ state, focus, repo = null }: { state: BoardsState; f )} > {LANES.flatMap((l) => { - const lane = kids?.get(id)} now={now} next={next} onOpen={onOpenCard} onForce={onForce} />; + const lane = kids?.get(id)} now={now} next={next} onOpen={onOpenCard} onForce={onForce} ship={l.key === "approved" && onShip ? { pending: shipAsk, act: onShip } : undefined} />; return l.key === "blocked" ? [ LANES.find((l) => l.key === k)?.label ?? (k === /** Amber text that passes AA on light surfaces (same mix as idle-chip). */ const NEEDS_INK = "text-[color-mix(in_oklch,var(--st-needs),black_38%)] dark:text-st-needs"; +/** + * One tap on the Approved column. A Ship question pending → answer it with "Duyệt tất cả" (the same keys the + * ask panel sends; the answer API checks the room's screen first). None pending → `auto ship-fast ship-ask`: + * the lead asks it this turn and the button turns into "Duyệt tất cả" when the question shows. + */ +async function shipAll(token: string, root: string, pending: ShipAsk | null, after: () => void): Promise { + const api = makeApi(token); + if (pending) { + const path = `/api/sessions/${encodeURIComponent(pending.sid)}/answer`; + try { + const d = await api<{ dialog: { text: string } }>(path, { method: "POST", body: { kind: "question", keys: [] } }); + await api(path, { method: "POST", body: { kind: "question", keys: [pending.key], text: d.dialog.text } }); + toast.success(`Đã chọn “${pending.label}”: lead land các card approved (auto merge)`); + } catch (e) { + toast.error(e instanceof ApiError && e.status === 409 ? "Màn hình phòng đã đổi: trả lời ở khung Anh cần trả lời" : (e as Error).message); + } + } else { + const wait = toast.loading("Đang nhắc lead hỏi Ship…"); + try { + await api("/api/team", { method: "POST", body: { root, action: "ship-ask" } }); + toast.success("Đã nhắc lead hỏi Ship ngay lượt này", { id: wait, description: "Câu hỏi hiện ở khung Anh cần trả lời; nút này thành “Duyệt tất cả” khi lead hỏi." }); + } catch (e) { + toast.error("Không nhắc được lead", { id: wait, description: (e as Error).message }); + } + } + after(); +} + +function ShipAllButton({ pending, n, act }: { pending: ShipAsk | null; n: number; act: () => Promise }) { + const [busy, setBusy] = useState(false); + return ( +
+ +
+ ); +} + function Lane({ lane, label, @@ -252,11 +308,14 @@ function Lane({ next, onOpen, onForce, + ship, }: { lane: LaneKey; label: string; hint: string; cards: Card[] | null; + /** Approved only: the one-tap Ship (pending question → approve all; none → ask the lead now). */ + ship?: { pending: ShipAsk | null; act: () => Promise }; moveOf: (id: number) => Move | undefined; kidsOf: (id: number) => Kid[] | undefined; now: number; @@ -294,6 +353,7 @@ function Lane({

{hint}

+ {ship && loud && }
    {cards === null ? ( [0, 1].map((i) => ( diff --git a/apps/hq-ui/lib/ship-all.ts b/apps/hq-ui/lib/ship-all.ts new file mode 100644 index 00000000..e341d691 --- /dev/null +++ b/apps/hq-ui/lib/ship-all.ts @@ -0,0 +1,34 @@ +// "Ship tất cả" on the Approved column (owner 2026-10-22: "kẹt suốt vậy auto ship mà nên có thêm nút ở +// ví dụ ship all"): 21 approved cards sat while the lead's Ship question waited in the ask panel. +// Pure rules here; the button in components/hq/lanes-view.tsx does the posting. Tested in tests/ship-all.test.ts. +import type { Ask } from "./ask"; +import { rootKey } from "./preview"; + +export type ShipRoom = { session_id: string | null; delivery?: "terminal" | "inbox" | null; project: { root: string } | null; ask: Ask | null }; + +/** The Ship question a project's room holds open for the owner, and the option that approves everything. */ +export type ShipAsk = { sid: string; label: string; key: string }; + +/** `Ship` header, one single-choice question with options: what one tap may answer (see ask-panel oneTapKind). */ +export function shipQuestion(a: Ask | null | undefined) { + if (a?.kind !== "question" || a.questions.length !== 1) return null; + const q = a.questions[0]; + if ((q.header ?? "").trim().toLowerCase() !== "ship" || q.multi || q.options.length === 0) return null; + return q; +} + +/** The pending Ship question of the project at `root` (a terminal room cannot be answered from the web). */ +export function shipAskOf(rooms: T[], root: string): ShipAsk | null { + const k = rootKey(root); + for (const r of rooms) { + if (!r.session_id || r.delivery === "terminal" || !r.project || rootKey(r.project.root) !== k) continue; + const q = shipQuestion(r.ask); + if (!q) continue; + // "Duyệt tất cả (Recommended)" by name, else the recommended one, else the first. + let i = q.options.findIndex((o) => /duyệt tất cả/i.test(o.label)); + if (i < 0) i = q.options.findIndex((o) => /recommended/i.test(o.label)); + if (i < 0) i = 0; + return { sid: r.session_id, label: q.options[i].label, key: String(i + 1) }; + } + return null; +} diff --git a/apps/hq-ui/tests/ship-all.test.ts b/apps/hq-ui/tests/ship-all.test.ts new file mode 100644 index 00000000..148810a1 --- /dev/null +++ b/apps/hq-ui/tests/ship-all.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { Ask } from "@/lib/ask"; +import { shipAskOf, shipQuestion } from "@/lib/ship-all"; + +const ship = (labels: string[], header = "Ship", multi = false): Ask => ({ + kind: "question", + at: 1, + questions: [{ header, question: "Merge 21 card?", multi, options: labels.map((label) => ({ label, description: null })) }], +}); +const room = (sid: string | null, root: string, ask: Ask | null, delivery: "terminal" | "inbox" | null = "inbox") => ({ session_id: sid, delivery, project: { root }, ask }); + +describe("Ship tất cả on the Approved column (owner 2026-10-22: 21 approved cards sat behind an unanswered Ship question)", () => { + it("finds the project's pending Ship question and the option that approves everything", () => { + const rooms = [ + room("other", "C:/x/other", ship(["Duyệt tất cả (Recommended)", "Dừng"])), + room("lead", "C:/x/app", ship(["Chỉ merge, chưa deploy", "Duyệt tất cả (Recommended)", "Dừng"])), + ]; + expect(shipAskOf(rooms, "C:/x/app")).toEqual({ sid: "lead", label: "Duyệt tất cả (Recommended)", key: "2" }); + expect(shipAskOf(rooms, "c:\\x\\app\\")).toEqual({ sid: "lead", label: "Duyệt tất cả (Recommended)", key: "2" }); + expect(shipAskOf(rooms, "C:/x/none")).toBeNull(); + }); + it("falls back to the recommended option, then the first; skips terminal rooms and non-Ship questions", () => { + expect(shipAskOf([room("a", "/r", ship(["Go (Recommended)", "Stop"]))], "/r")?.key).toBe("1"); + expect(shipAskOf([room("a", "/r", ship(["Stop", "Go"]))], "/r")?.key).toBe("1"); + expect(shipAskOf([room("a", "/r", ship(["Duyệt tất cả"]), "terminal")], "/r")).toBeNull(); + expect(shipAskOf([room(null, "/r", ship(["Duyệt tất cả"]))], "/r")).toBeNull(); + expect(shipAskOf([room("a", "/r", ship(["Duyệt tất cả"], "Team"))], "/r")).toBeNull(); + expect(shipQuestion(ship(["A", "B"], "Ship", true))).toBeNull(); + expect(shipQuestion(null)).toBeNull(); + }); +}); diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index 5e916c58..c782945b 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "8sync-kit", "displayName": "8sync kit", - "version": "0.110.39", + "version": "0.110.40", "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", diff --git a/crates/cli/src/verbs/ccc/auto.rs b/crates/cli/src/verbs/ccc/auto.rs index 2a2adade..8013f878 100644 --- a/crates/cli/src/verbs/ccc/auto.rs +++ b/crates/cli/src/verbs/ccc/auto.rs @@ -48,7 +48,7 @@ use serde_json::{json, Value}; use super::{delegate, idle, target}; -const USAGE: &str = "usage: 8sync ccc auto on [--here|--all] [--omp] [--loop [30m]] [--team] [--target \"<đích>\" [--check \"\"]] [--hours N] [--after ] [--usd-per-hour X] | off | pause | status [--json] | ci [local|github] [--json] +const USAGE: &str = "usage: 8sync ccc auto on [--here|--all] [--company] [--omp] [--loop [30m]] [--team] [--target \"<đích>\" [--check \"\"]] [--hours N] [--after ] [--usd-per-hour X] | off | pause | status [--json] | ci [local|github] [--json] 8sync ccc auto omp on [--model ]|off [--json] | nudge --session --cwd [--model ] [--json] (on --omp nhận thêm --omp-model : model của đội omp) 8sync ccc auto up [--roles lead,dev,qa,review,rd] [--loop 30m] [--hours N] [--usd-per-hour X] [--clear-ss] [--omp [--omp-model ]] | up --add | down [--only ] @@ -186,7 +186,7 @@ fn write_marker(home: &Path, v: &Value) -> Result<()> { } let mut v = v.clone(); let old = idle::read_json(&p); - for k in ["teams", "omp_model", "guard", "eco", "cap", "cpu", "lease_auto"] { + for k in ["teams", "omp_model", "guard", "eco", "cap", "cpu", "lease_auto", "company"] { if v.get(k).is_none() { if let Some(t) = old.as_ref().and_then(|m| m.get(k).cloned()) { v[k] = t; @@ -229,6 +229,56 @@ pub(super) fn set_omp(home: &Path, on: bool, model: Option<&str>, now: u64) -> R write_marker(home, &m) } +/// `auto on --company` / `auto up --company`: one team serves every project in auto's scope. Marker +/// `company: true`; `auto claim` and `auto wait` then look at the other projects' boards when the +/// room's own has nothing ([`company_pick`]). 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" (17 idle rooms held 9.4 GB for two projects). +pub(super) fn set_company(home: &Path, on: bool, now: u64) -> Result<()> { + let p = marker(home); + let mut m = match idle::read_json(&p) { + Some(m) => m, + None if p.exists() => bail!("{} hỏng: sửa hoặc xoá file rồi chạy lại", p.display()), + None => json!({ "schema": 1, "on": false, "paused": false, "since": now }), + }; + m["company"] = json!(on); + write_marker(home, &m) +} + +/// Whether the marker says one team serves every project in scope ([`set_company`]). +pub(super) fn company(home: &Path) -> bool { + idle::read_json(&marker(home)).is_some_and(|m| m["company"].as_bool() == Some(true)) +} + +/// The other projects a company room may take cards from: auto's live scope without `root`. +fn other_roots(home: &Path, root: &Path) -> Vec { + let here = canon(root); + live_scope(home).into_iter().filter(|r| *r != here).collect() +} + +/// Pure: the most urgent card a company room would get from another project's board: the lowest +/// priority number wins, then scope order. `cards_of` reads one project's board; a project that +/// cannot be read (no board yet) or is pinned away ([`super::lease::focus_rest`], checked by the +/// caller through `open`) is skipped. +fn company_pick(roots: &[PathBuf], open: impl Fn(&Path) -> bool, cards_of: impl Fn(&Path) -> Result>, room: &str, lane: &str, now: u64) -> Option<(PathBuf, u64)> { + roots + .iter() + .enumerate() + .filter(|(_, r)| open(r)) + .filter_map(|(i, r)| { + let cs = cards_of(r).ok()?; + let id = target::claimable(&cs, room, lane, now)?; + let prio = cs.iter().find(|t| t.id == id).and_then(|t| t.prio).unwrap_or(2); + Some((prio, i, r.clone(), id)) + }) + .min_by_key(|(p, i, _, _)| (*p, *i)) + .map(|(_, _, r, id)| (r, id)) +} + +/// What a room reads with a card from another project: every `auto` command for it runs there. +fn elsewhere_hint(root: &Path, room: &str) -> String { + format!("card ở dự án khác ({}): làm trong worktree của repo đó; mọi lệnh auto cho card này chạy bằng `env -C {} 8sync ccc auto --as {room}`", root.display(), root.display()) +} + /// The resource guard's RAM threshold (%), None while the owner turned it off. Marker `guard`: a /// number, or `"off"`; absent = [`super::pressure::DEFAULT_PCT`] (owner 2026-10-21: "ngưỡng 60%"). pub(super) fn guard_pct(home: &Path) -> Option { @@ -695,6 +745,8 @@ pub(super) fn status(home: &Path, subscription: bool, now: u64) -> Value { // Projects `auto close` took out: never in scope, even with no list (every project). "out": out_roots(home).iter().map(|r| r.to_string_lossy().into_owned()).collect::>(), "teams": teams_of(m.as_ref()), + // One team for every project in scope ([`set_company`]; HQ shows the team once, under "Công ty"). + "company": company(home), // Repos whose team `auto up` brought up (HQ adds the sweep room to each, lib/server/sweep.ts). "team_roots": super::team::team_roots(home), "omp_model": m.as_ref().and_then(|m| m["omp_model"].as_str()), @@ -1501,6 +1553,26 @@ fn run_target( // Tight usage with other projects pinned: no card here ([`super::lease::focus_rest`]). let focus = super::lease::focus_rest(home, root, now); let card = if focus.is_some() { None } else { target::claim(home, root, &room, &lane, now)? }; + // A company room with nothing at home takes the most urgent card of another project in + // scope ([`company_pick`]); the card comes back with its `root` and how to work on it. + let elsewhere = if card.is_none() && company(home) { + let others = other_roots(home, root); + match company_pick(&others, |r| super::lease::focus_rest(home, r, now).is_none(), |r| target::list(home, r), &room, &lane, now) { + Some((r, _)) => target::claim(home, &r, &room, &lane, now)?.map(|t| (r, t)), + None => None, + } + } else { + None + }; + if let Some((r, t)) = elsewhere { + let hint = elsewhere_hint(&r, &room); + if as_json { + println!("{}", json!({ "card": t, "lane": lane, "room": room, "root": r.to_string_lossy(), "why": null, "hint": hint })); + } else { + println!("{}\n {hint}", card_lines(&t).join("\n")); + } + return Ok(()); + } // A planning room that gets nothing is told why, not left guessing: ready full, or the // board ran dry (ready low, no idea left in backlog) and it is the one to think up more. let cards = if card.is_none() && lane == "backlog" { target::list(home, root)? } else { Vec::new() }; @@ -1926,6 +1998,7 @@ fn parse_wait(args: &[String], bash_max_ms: Option<&str>, rooms: impl FnOnce() - fn wait_for( w: &Wait, cards: impl Fn() -> Result>, + elsewhere: impl Fn(u64) -> Option<(PathBuf, u64)>, status: impl Fn(u64) -> Value, now: impl Fn() -> u64, sleep: impl Fn(), @@ -1939,6 +2012,7 @@ fn wait_for( // A subscription lease that ran out (or never started) ends the room's work like `off`: the // rail is "lease ≤ 4 h, while the owner is around" (idle::gate). let lease_over = st["gate"].as_str().is_some_and(|g| g.contains("lease")); + let mut root: Option = None; let wake = if st["on"] != true || st["paused"] == true || st["here"] == false || lease_over { Some(("off", None)) } else if !st["cap"].is_null() { @@ -1959,6 +2033,12 @@ fn wait_for( .flatten() .map(|id| ("merge", id)) .or_else(|| target::claimable(&cs, &w.room, lane, t).map(|id| ("card", id))) + // A company room: a card on another project's board wakes it too. + .or_else(|| { + let (r, id) = elsewhere(t)?; + root = Some(r); + Some(("card", id)) + }) } } { Some((why, Some(id))) @@ -1968,7 +2048,7 @@ fn wait_for( None }; if let Some((why, card)) = wake { - return Ok(json!({ "wake": why, "card": card, "room": w.room, "lane": w.lane, "waited_s": t.saturating_sub(start), "cap": st["cap"], "mail": st["mail"] })); + return Ok(json!({ "wake": why, "card": card, "root": root.map(|r| r.to_string_lossy().into_owned()), "room": w.room, "lane": w.lane, "waited_s": t.saturating_sub(start), "cap": st["cap"], "mail": st["mail"] })); } sleep(); } @@ -2006,7 +2086,10 @@ fn wait_text(v: &Value) -> String { Some(m) => format!("tin nhắn mới cho phòng này (đã lấy khỏi inbox): đọc và làm theo trước khi claim, rồi quay lại `8sync ccc auto wait`\n{m}"), None => "có tin nhắn mới cho phòng này (owner hoặc phòng khác): KẾT THÚC lượt ngay, không chạy thêm lệnh — tin vào phiên khi lượt xong; làm theo tin rồi quay lại `8sync ccc auto wait`".into(), }, - Some("card") => format!("card #{} ở cột {lane}: `8sync ccc auto claim`", v["card"]), + Some("card") => match v["root"].as_str() { + Some(r) => format!("card #{} ở cột {lane} của dự án khác ({r}): `8sync ccc auto claim` (card về kèm cách làm ở repo đó)", v["card"]), + None => format!("card #{} ở cột {lane}: `8sync ccc auto claim`", v["card"]), + }, Some("merge") => format!("card #{} vừa approved, owner đã cho tự merge: `8sync ccc auto merge`", v["card"]), Some("off") => "auto đã tắt hoặc tạm dừng: dừng, không claim nữa".into(), Some("cap") => format!("chạm trần ({}): nghỉ, không claim; chạy lại `8sync ccc auto wait` (chờ cửa sổ hồi)", v["cap"].as_str().unwrap_or("")), @@ -2178,9 +2261,15 @@ pub(super) fn run( let sid = all.iter().find(|(n, _)| n == &w.room).map(|(_, s)| s.clone()).or_else(|| std::env::var("CLAUDE_CODE_SESSION_ID").ok()); let cwd = root.to_string_lossy(); let clock = || std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let company = company(home); + let others = if company { other_roots(home, root) } else { Vec::new() }; let mut v = wait_for( &w, || target::list(home, root), + |t| { + let lane = w.lane.as_deref()?; + company_pick(&others, |r| super::lease::focus_rest(home, r, t).is_none(), |r| target::list(home, r), &w.room, lane, t) + }, |t| { let mut v = focused(status(home, subscription, t), home, root, t); v["here"] = json!(here); @@ -2222,6 +2311,7 @@ pub(super) fn run( "--target", "--check", "--omp-model", + "--company", ])?; let goal = num(rest, "--target")?; let check = num(rest, "--check")?; @@ -2302,6 +2392,10 @@ pub(super) fn run( v["omp_model"] = json!(omp_model(home)); v["omp_blocked"] = json!(omp_blocked(home)); } + if rest.iter().any(|a| a == "--company") { + set_company(home, true, now)?; + v["company"] = json!(true); + } v } Some(x @ ("off" | "pause")) => { @@ -2801,6 +2895,53 @@ mod tests { let _ = std::fs::remove_dir_all(&h); } + #[test] + fn company_flag_round_trips_and_claim_picks_the_most_urgent_card_across_projects() { + let h = home("company"); + assert!(!company(&h)); + set_company(&h, true, 1).unwrap(); + assert!(company(&h)); + // `on` keeps the flag (write_marker carries it like `teams`). + set(&h, Mode::On { hours: 1.0, after: 0, rate: None, repo: None }, false, 2).unwrap(); + assert!(company(&h), "auto on must not drop company"); + set_company(&h, false, 3).unwrap(); + assert!(!company(&h)); + // Two other projects: b has a P2 card, c a P0 card → c first; a pinned-away project is skipped; + // an unreadable board is skipped; no card anywhere → None. + let card = |id: u64, prio: Option| target::Target { + id, + text: "t".into(), + check: Some("true".into()), + added: 0, + from: None, + state: "open".into(), + closed: None, + evidence: None, + by: None, + lane: "ready".into(), + label: None, + claim: None, + notes: vec![], + bounces: 0, + pr: None, + pr_repo: None, + sha: None, + role: None, + prio, + refill_at: None, + }; + let (b, c) = (PathBuf::from("/srv/b"), PathBuf::from("/srv/c")); + let roots = vec![b.clone(), c.clone()]; + let boards = |r: &Path| -> Result> { + if r == b { Ok(vec![card(1, None)]) } else if r == c { Ok(vec![card(2, Some(0))]) } else { bail!("no board") } + }; + assert_eq!(company_pick(&roots, |_| true, boards, "dev", "ready", 10), Some((c.clone(), 2))); + assert_eq!(company_pick(&roots, |r| r != c, boards, "dev", "ready", 10), Some((b.clone(), 1)), "a pinned-away project is skipped"); + assert_eq!(company_pick(&roots, |_| true, |_| bail!("no board"), "dev", "ready", 10), None); + assert_eq!(company_pick(&roots, |_| true, |_| Ok(vec![]), "dev", "ready", 10), None); + assert!(elsewhere_hint(&c, "dev").contains("env -C /srv/c 8sync ccc auto")); + } + #[test] fn json_shape_is_stable() { let h = home("shape"); @@ -2812,6 +2953,7 @@ mod tests { [ "after_s", "cap", + "company", "delegate", "five_hour_pct", "focus", @@ -3133,20 +3275,29 @@ mod tests { polls.set(polls.get() + 1); Ok(if polls.get() >= 3 { vec![card(4, "backlog"), card(7, "ready")] } else { vec![card(4, "backlog")] }) }; - let v = wait_for(&w, cards, |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + let v = wait_for(&w, cards, |_| None, |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); assert_eq!((v["wake"].as_str(), v["card"].as_u64(), v["waited_s"].as_u64()), (Some("card"), Some(7), Some(4))); assert!(wait_text(&v).contains("card #7")); + assert!(v["root"].is_null()); + // A company room (owner 2026-10-22: one team, many projects): an empty home board, a card on + // another project's board → wake "card" with that project's root; the home board wins when both have one. + let other = PathBuf::from("/srv/other"); + let v = wait_for(&w, || Ok(vec![card(4, "backlog")]), |_| Some((other.clone(), 9)), |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + assert_eq!((v["wake"].as_str(), v["card"].as_u64(), v["root"].as_str()), (Some("card"), Some(9), Some("/srv/other"))); + assert!(wait_text(&v).contains("dự án khác"), "{}", wait_text(&v)); + let v = wait_for(&w, || Ok(vec![card(7, "ready")]), |_| Some((other.clone(), 9)), |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + assert_eq!((v["card"].as_u64(), v["root"].as_str()), (Some(7), None)); // auto paused or capped: wake at once, never claim let paused = json!({ "on": true, "paused": true, "cap": null }); - assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| paused.clone(), || 5, || {}).unwrap()["wake"], "off"); + assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| None, |_| paused.clone(), || 5, || {}).unwrap()["wake"], "off"); let expired = json!({ "on": true, "paused": false, "cap": null, "gate": "gói subscription: lease hết hạn (owner vắng) — gia hạn `8sync ccc idle on`" }); - assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| expired.clone(), || 5, || {}).unwrap()["wake"], "off", "the 4 h lease ends the room's work"); + assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| None, |_| expired.clone(), || 5, || {}).unwrap()["wake"], "off", "the 4 h lease ends the room's work"); let away = json!({ "on": true, "paused": false, "cap": null, "gate": "owner vắng: không có prompt do người gõ trong session lead 20 phút qua" }); - assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| away.clone(), || 5, || {}).unwrap()["wake"], "card", "owner typing is the idle hook's rail, not the room's"); + assert_eq!(wait_for(&w, || Ok(vec![card(7, "ready")]), |_| None, |_| away.clone(), || 5, || {}).unwrap()["wake"], "card", "owner typing is the idle hook's rail, not the room's"); // A message waits in the room's inbox: end the turn so the Stop hook hands it over; off and // capped still win, and an empty inbox keeps waiting. let mail = json!({ "on": true, "paused": false, "cap": null, "mail": 1_700_000_000_123u64 }); - let v = wait_for(&w, || Ok(vec![]), |_| mail.clone(), || 5, || {}).unwrap(); + let v = wait_for(&w, || Ok(vec![]), |_| None, |_| mail.clone(), || 5, || {}).unwrap(); assert_eq!((v["wake"].as_str(), v["mail"].as_u64()), (Some("msg"), Some(1_700_000_000_123))); assert!(wait_text(&v).contains("KẾT THÚC lượt"), "nothing taken: end the turn so the Stop hook delivers"); let mut got = v.clone(); @@ -3154,25 +3305,25 @@ mod tests { let t = wait_text(&got); assert!(t.contains("đổi màu nút Play") && t.contains("trước khi claim") && !t.contains("KẾT THÚC"), "{t}"); let mail_off = json!({ "on": true, "paused": true, "cap": null, "mail": 1u64 }); - assert_eq!(wait_for(&w, || Ok(vec![]), |_| mail_off.clone(), || 5, || {}).unwrap()["wake"], "off"); + assert_eq!(wait_for(&w, || Ok(vec![]), |_| None, |_| mail_off.clone(), || 5, || {}).unwrap()["wake"], "off"); // Capped: rest until the wait is up (never claim the ready card), then report `cap`. let capped = json!({ "on": true, "paused": false, "cap": "cửa sổ 5 giờ 92%" }); let clock = std::cell::Cell::new(0u64); - let v = wait_for(&w, || Ok(vec![card(7, "ready")]), |_| capped.clone(), || clock.get(), || clock.set(clock.get() + 30)).unwrap(); + let v = wait_for(&w, || Ok(vec![card(7, "ready")]), |_| None, |_| capped.clone(), || clock.get(), || clock.set(clock.get() + 30)).unwrap(); assert_eq!((v["wake"].as_str(), v["card"].as_u64()), (Some("cap"), None)); assert!(v["waited_s"].as_u64().unwrap() >= w.timeout, "rested the whole wait: {v}"); // nothing arrives: timeout let clock = Cell::new(0u64); - let v = wait_for(&w, || Ok(vec![]), |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + let v = wait_for(&w, || Ok(vec![]), |_| None, |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); assert_eq!((v["wake"].as_str(), v["waited_s"].as_u64()), (Some("timeout"), Some(60))); assert!(wait_text(&v).contains("chờ tiếp")); // --mail (the eye room): a ready card never wakes it, a message does, nothing → timeout. let eye = Wait { room: "eye".into(), lane: None, timeout: 4 }; let clock = Cell::new(0u64); - let v = wait_for(&eye, || panic!("a mail-only wait never reads the board"), |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + let v = wait_for(&eye, || panic!("a mail-only wait never reads the board"), |_| None, |_| on.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); assert_eq!((v["wake"].as_str(), v["lane"].is_null()), (Some("timeout"), true)); assert!(wait_text(&v).contains("auto wait --mail"), "{}", wait_text(&v)); - assert_eq!(wait_for(&eye, || Ok(vec![card(7, "ready")]), |_| mail.clone(), || 5, || {}).unwrap()["wake"], "msg"); + assert_eq!(wait_for(&eye, || Ok(vec![card(7, "ready")]), |_| None, |_| mail.clone(), || 5, || {}).unwrap()["wake"], "msg"); } #[test] @@ -3235,12 +3386,12 @@ mod tests { polls.set(polls.get() + 1); Ok(if polls.get() >= 3 { vec![card(5, "approved"), card(6, "approved")] } else { vec![card(5, "approved")] }) }; - let v = wait_for(&lead, cards, |_| live.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); + let v = wait_for(&lead, cards, |_| None, |_| live.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap(); assert_eq!((v["wake"].as_str(), v["card"].as_u64()), (Some("merge"), Some(6))); assert!(wait_text(&v).contains("auto merge")); // No permission: approved cards never wake the lead (it times out). let clock = Cell::new(0u64); - let v = wait_for(&lead, || Ok(vec![card(6, "approved")]), |_| none.clone(), || clock.get(), || clock.set(clock.get() + 30)).unwrap(); + let v = wait_for(&lead, || Ok(vec![card(6, "approved")]), |_| None, |_| none.clone(), || clock.get(), || clock.set(clock.get() + 30)).unwrap(); assert_eq!(v["wake"], "timeout"); // Permission granted during the wait: the cards already approved wake it. let (clock, polls) = (Cell::new(0u64), Cell::new(0)); @@ -3248,11 +3399,11 @@ mod tests { polls.set(polls.get() + 1); if polls.get() >= 2 { live.clone() } else { none.clone() } }; - assert_eq!(wait_for(&lead, || Ok(vec![card(6, "approved")]), st, || clock.get(), || clock.set(clock.get() + 2)).unwrap()["card"], 6); + assert_eq!(wait_for(&lead, || Ok(vec![card(6, "approved")]), |_| None, st, || clock.get(), || clock.set(clock.get() + 2)).unwrap()["card"], 6); // Other rooms never wake for approved cards. let dev = Wait { room: "dev".into(), lane: Some("ready".into()), timeout: 4 }; let clock = Cell::new(0u64); - assert_eq!(wait_for(&dev, || Ok(vec![card(6, "approved")]), |_| live.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap()["wake"], "timeout"); + assert_eq!(wait_for(&dev, || Ok(vec![card(6, "approved")]), |_| None, |_| live.clone(), || clock.get(), || clock.set(clock.get() + 2)).unwrap()["wake"], "timeout"); } #[test] diff --git a/crates/cli/src/verbs/ccc/help.rs b/crates/cli/src/verbs/ccc/help.rs index 27819dfe..62f0674b 100644 --- a/crates/cli/src/verbs/ccc/help.rs +++ b/crates/cli/src/verbs/ccc/help.rs @@ -50,9 +50,9 @@ TỰ ĐỘNG (gõ trong Claude Code) 8sync ccc cancel [--all] [--session ] [--json] huỷ hết việc session này đặt: goal, urgent, loop ghi trong su-code/ship/ACTIVE của repo (ACTIVE chung cả repo), rồi ACTIVE; `--all` thêm mọi loop + idle off + delegate off; thiếu session hay huỷ hụt → exit 1 (fail-closed; /loop của Claude Code phải `/loop clear` trong session) 8sync ccc hook ship-grant (hook) ghi giấy phép khi owner duyệt câu hỏi `Ship` 8sync ccc idle on [--hours 2] [--usd-per-hour X] không-ai-rảnh cho agent team (mặc định TẮT; subscription = lease khi owner có mặt) - 8sync ccc auto on [--here|--all] [--omp] [--loop [30m]] [--team] [--hours 2] [--after 60|1m] | off | pause | status [--json] một công tắc \"team luôn work\": idle lease + delegate sau 60 s owner không gõ + auto.json cho HQ; `--here` = thêm dự án ở thư mục hiện tại vào các dự án đang chạy; không cờ (nút Chạy liên tục của HQ) = đúng các dự án lần trước, kể cả sau `pause`, chưa có thì mọi dự án; `--all` = mọi session ở mọi dự án trên máy; `off` quên danh sách dự án; `--omp` = bật cả đội omp; `--loop 30m` (≥ 30 phút) = ghi .claude/loop.md (playbook chọn skill, không ghi đè) rồi gửi vào inbox mọi phòng của dự án lệnh tự đặt loop cron (như /loop 30m) theo vai phòng; phòng không giữ cột (arch, rd, ops, docs, sec) không đặt loop, chỉ chạy khi có tin/card; loop tự xoá khi auto off/pause, chạm trần thì nghỉ nhịp (subscription: cửa sổ 5 giờ/7 ngày ≥ 90%; API key hoặc `--usd-per-hour`: $/giờ trung bình, chỉ tính session của dự án khi `--here`); Claude Code xoá sau 7 ngày (chạy lại = gia hạn); `--team` = phòng mở bằng --team tự lấp ghế rd, qa, reviewer; `--target \"<đích>\" [--check \"\"]` = đích đầu hàng cho cả team; `pause` = owner tạm dừng (HQ không tự bật lại), `off` = tắt (HQ có thể bật lại); subscription giữ lease ≤ 4 giờ, chỉ chạy khi owner có mặt; `status --json` có `ci` (ok · failing · billing · unknown) của repo dự án + 8sync ccc auto on [--here|--all] [--omp] [--loop [30m]] [--team] [--hours 2] [--after 60|1m] | off | pause | status [--json] một công tắc \"team luôn work\": idle lease + delegate sau 60 s owner không gõ + auto.json cho HQ; `--here` = thêm dự án ở thư mục hiện tại vào các dự án đang chạy; không cờ (nút Chạy liên tục của HQ) = đúng các dự án lần trước, kể cả sau `pause`, chưa có thì mọi dự án; `--all` = mọi session ở mọi dự án trên máy; `off` quên danh sách dự án; `--omp` = bật cả đội omp; `--company` = một team làm mọi dự án trong scope: phòng hết card ở dự án mình thì `claim`/`wait` lấy card gấp nhất ở dự án khác (card về kèm `root` và lệnh `env -C … --as `); `--loop 30m` (≥ 30 phút) = ghi .claude/loop.md (playbook chọn skill, không ghi đè) rồi gửi vào inbox mọi phòng của dự án lệnh tự đặt loop cron (như /loop 30m) theo vai phòng; phòng không giữ cột (arch, rd, ops, docs, sec) không đặt loop, chỉ chạy khi có tin/card; loop tự xoá khi auto off/pause, chạm trần thì nghỉ nhịp (subscription: cửa sổ 5 giờ/7 ngày ≥ 90%; API key hoặc `--usd-per-hour`: $/giờ trung bình, chỉ tính session của dự án khi `--here`); Claude Code xoá sau 7 ngày (chạy lại = gia hạn); `--team` = phòng mở bằng --team tự lấp ghế rd, qa, reviewer; `--target \"<đích>\" [--check \"\"]` = đích đầu hàng cho cả team; `pause` = owner tạm dừng (HQ không tự bật lại), `off` = tắt (HQ có thể bật lại); subscription giữ lease ≤ 4 giờ, chỉ chạy khi owner có mặt; `status --json` có `ci` (ok · failing · billing · unknown) của repo dự án 8sync ccc auto ci [--json] CI GitHub của dự án lúc này: `gh run list` 5 run gần nhất + annotation của job fail → ok · failing · billing (GitHub không chạy job vì thanh toán/spending limit) · unknown (thiếu gh/chưa login/offline); lưu cho `auto status` (đọc lại sau 10 phút, chạy nền); billing → báo owner một lần mỗi ngày ở kênh #alerts của HQ - 8sync ccc auto up [--roles lead,dev,qa,review,rd] [--loop 30m] [--clear-ss] [--omp] | down cả team chạy nền (claude --bg) bằng một lệnh, không mở terminal từng phòng: auto on --here + mỗi phòng một session nền đúng vai/model (lead opus, dev sonnet, qa sonnet, review sonnet, rd opus; thêm ba, ui, api, core, ops, docs, sec); phòng đang chạy chỉ nhận loop, phòng đã dừng chạy lại cùng id; `--clear-ss` = xoá trước mọi session nền rác của dự án (trùng phòng, sót từ lần trước) và khởi động lại phòng nền đang chạy kit cũ (nạp hook mới, giữ hội thoại), không đụng session interactive; `--omp` (hoặc khi auto.json đã có đội omp) = thêm đội omp: mỗi vai một phòng omp `omp-` (`8sync . omp-`) chạy tách terminal (tmux trên Linux/macOS, tab Windows Terminal `8sync-omp` trên Windows, không có thì in lệnh để chạy tay), bật luôn omp trong `teams`; chặn khi model omp là anthropic/…; xem tiến độ `8sync hq ui`; `down` = dừng phòng nền + phòng `omp-` của dự án (hội thoại giữ nguyên) + pause + 8sync ccc auto up [--roles lead,dev,qa,review,rd] [--loop 30m] [--clear-ss] [--omp] [--company] | down cả team chạy nền (claude --bg) bằng một lệnh, không mở terminal từng phòng: auto on --here + mỗi phòng một session nền đúng vai/model (lead opus, dev sonnet, qa sonnet, review sonnet, rd opus; thêm ba, ui, api, core, ops, docs, sec); phòng đang chạy chỉ nhận loop, phòng đã dừng chạy lại cùng id; `--clear-ss` = xoá trước mọi session nền rác của dự án (trùng phòng, sót từ lần trước) và khởi động lại phòng nền đang chạy kit cũ (nạp hook mới, giữ hội thoại), không đụng session interactive; `--omp` (hoặc khi auto.json đã có đội omp) = thêm đội omp: mỗi vai một phòng omp `omp-` (`8sync . omp-`) chạy tách terminal (tmux trên Linux/macOS, tab Windows Terminal `8sync-omp` trên Windows, không có thì in lệnh để chạy tay), bật luôn omp trong `teams`; chặn khi model omp là anthropic/…; xem tiến độ `8sync hq ui`; `down` = dừng phòng nền + phòng `omp-` của dự án (hội thoại giữ nguyên) + pause 8sync ccc auto close [--dry-run] [--json] đóng dự án THẬT (nút \"Đóng dự án\" của HQ), chỉ chạy ở đúng gốc repo (thư mục con / thư mục gom repo bị từ chối: sẽ đóng nhầm cả repo cha): dừng phòng team (`claude stop`) + bỏ dự án khỏi auto + team-roots dưới khoá revive (revive đang chạy quá 45 giây → không dừng gì, báo lỗi), không bao giờ tạm dừng auto của dự án khác (auto cho mọi dự án → dự án này vào danh sách `out`; `on --here`/`up` ở đó đưa lại) + dừng mọi session Claude Code khác chạy trong repo hay worktree của nó (nền: `claude stop`; interactive: cây tiến trình) + phiên omp của dự án, kể cả phòng omp- (session dir của registry 8sync dự án hoặc `--cwd` trong repo; không đụng worker broker của omp) + tiến trình sót mọi tuổi: node/python/uv có đường dẫn repo trong dòng lệnh (dev/preview server, vitest, tsserver, MCP), trình duyệt có `--user-data-dir` trong repo; repo lồng bên trong (kể cả đường dẫn có dấu cách) là dự án khác; không bao giờ HQ, chính lệnh này, tiến trình cha, phiên (phòng/Claude Code/omp) đang chạy lệnh này và mọi thứ dưới nó; kill theo PID (không /T); hội thoại giữ nguyên (`8sync . `, `auto up`); `--dry-run` chỉ liệt kê; lỗi dừng → exit 1 8sync ccc auto revive mở lại nền các phòng team có session nền đã chết (tắt máy, process bị kill) khi auto còn bật cho dự án: cùng hội thoại, cùng prompt như `up`; không gia hạn lease (trừ khi bật lease liên tục), không mở phòng chưa từng chạy, không làm gì khi auto tắt/tạm dừng/lease hết/chạm trần — HQ gọi mỗi ≤ 2 phút và ngay khi bấm Chạy; dự án ghim mở trước 8sync ccc auto loop on [trọng tâm] | off | wake | status [--json] vòng auto-ship liên tục của dự án có team auto (HQ `/hq-auto-ship`, `/hq-stop`): bật/tắt/wake chỉ owner (không phải một phòng); bật thì mỗi lượt revive, khi board cạn (không còn card ready/building/qa/review/approved/blocked), auto đang chạy, usage không nghỉ, guard không giữ dự án (L2/L3 hay phòng bị tạm dừng), lead đã đọc brief trước và vòng trước đã ≥ 20 phút → gửi lead brief vòng kế (R&D, optimize, nâng cấp, cải tiến, check sec, check thiếu → mỗi phát hiện một card `auto add --check`); không giới hạn vòng, 2 vòng liền không ra card thì nghỉ tới khi owner nhắn (`wake`); `auto down`/`close` tắt; `auto status --json` có `ship_loops` (`next_reason`) diff --git a/crates/cli/src/verbs/ccc/team.rs b/crates/cli/src/verbs/ccc/team.rs index 3061852c..0e17f04e 100644 --- a/crates/cli/src/verbs/ccc/team.rs +++ b/crates/cli/src/verbs/ccc/team.rs @@ -36,7 +36,7 @@ use super::{auto, idle, room, target}; /// Rooms one `auto up` starts at most (the `boost` preset is the largest team). const MAX_ROOMS: usize = 14; -pub(super) const USAGE: &str = "usage: 8sync ccc auto up [\"\" [--check \"\"]] [--roles lead,dev,qa,review,rd|full|boost] [--loop 30m] [--hours N] [--usd-per-hour X] [--clear-ss] [--omp [--omp-model ]] | up --add | down [--only [,…]] | close [--dry-run] [--json] | revive"; +pub(super) const USAGE: &str = "usage: 8sync ccc auto up [\"\" [--check \"\"]] [--roles lead,dev,qa,review,rd|full|boost] [--loop 30m] [--hours N] [--usd-per-hour X] [--clear-ss] [--omp [--omp-model ]] [--company] | up --add | down [--only [,…]] | close [--dry-run] [--json] | revive"; /// `auto up …` / `auto down` / `auto close` / `auto revive`: the verbs this module (and /// [`super::close`]) owns. @@ -390,13 +390,15 @@ pub(super) struct Opts { pub check: Option, /// `--omp`: team omp too (also turns omp on in auto.json's teams). pub omp: bool, + /// `--company`: this team serves every project in auto's scope (`auto::set_company`). + pub company: bool, /// `--add `: open one more room in a team already running here; auto's lease, hours and /// `$`/hour cap stay as they are and no other room is touched (HQ "Tự soi ảnh" adds `eye`). pub add: bool, } pub(super) fn parse(rest: &[String]) -> Result { - let mut o = Opts { roles: room::DEFAULT_TEAM.iter().map(|s| s.to_string()).collect(), every: auto::DEFAULT_LOOP_MIN, hours: idle::DEFAULT_HOURS as f64, rate: None, clear: false, task: None, check: None, omp: false, add: false }; + let mut o = Opts { roles: room::DEFAULT_TEAM.iter().map(|s| s.to_string()).collect(), every: auto::DEFAULT_LOOP_MIN, hours: idle::DEFAULT_HOURS as f64, rate: None, clear: false, task: None, check: None, omp: false, company: false, add: false }; // `--add ` stands alone: the other options set up a whole team (lease, hours, cap, task). if rest.first().map(String::as_str) == Some("--add") { let [_, name] = rest else { bail!("--add đi một mình, vd `8sync ccc auto up --add eye`\n{USAGE}") }; @@ -437,6 +439,7 @@ pub(super) fn parse(rest: &[String]) -> Result { "--usd-per-hour" => o.rate = Some(value()?.parse().map_err(|_| anyhow::anyhow!("--usd-per-hour cần một số\n{USAGE}"))?), "--clear-ss" | "--clean" => o.clear = true, "--omp" => o.omp = true, + "--company" => o.company = true, "--check" => o.check = Some(value()?), x if x.starts_with("--") => bail!("không rõ `{x}`\n{USAGE}"), w => { @@ -566,6 +569,9 @@ pub(super) fn up(t: &Team, o: &Opts, now: u64) -> Result<(Value, Vec, Vec< let status = auto::set(t.home, auto::Mode::On { hours: o.hours, after: auto::DEFAULT_AFTER_S, rate: o.rate, repo: Some(t.root.to_path_buf()) }, t.subscription, now)?; set_team_root(t.home, t.root, true); auto::write_loop_md(t.root)?; + if o.company { + auto::set_company(t.home, true, now)?; + } // Before any room starts, so the lead's first turn already sees it at the head of the queue. if let Some(task) = &o.task { super::target::add(t.home, t.root, task, o.check.as_deref(), None, true, None, now)?; @@ -1499,6 +1505,7 @@ mod tests { assert!(!o.clear); assert!(parse(&v(&["--clear-ss"])).unwrap().clear && parse(&v(&["--clean"])).unwrap().clear); assert!(!o.omp && parse(&v(&["--omp", "--roles", "lead,dev"])).unwrap().omp); + assert!(!o.company && parse(&v(&["--company"])).unwrap().company); } #[test] @@ -1586,7 +1593,7 @@ mod tests { let fake = Fake { calls: RefCell::new(vec![]), agents: RefCell::new(vec![format!("[{l},{d}]"), format!("[{l},{d}]"), format!("[{l},{d}]"), format!("[{l},{d}]"), format!("[{l},{forked}]")]), bg: "backgrounded · 3698e3a6 · {name}".into() }; // Both sessions started at 1 ms; the kit wrote settings at 1000 ms. let t = Team { home: &h, root: &root, store: &store, subscription: true, claude: &fake, has_transcript: &|_: &str| true, pending_ask: &|_| None, title: &|n: &str| n.to_string(), kit_at: Some(1000) }; - let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: true, task: None, check: None, omp: false, add: false }; + let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: true, task: None, check: None, omp: false, company: false, add: false }; let (_, _, rows) = up(&t, &o, 1).unwrap(); let calls = fake.calls.borrow(); let stops: Vec<_> = calls.iter().filter(|c| c[0] == "stop").map(|c| c[1].clone()).collect(); @@ -1838,7 +1845,7 @@ mod tests { bg: String::new(), }; let t = Team { home: &h, root: &root, store: &store, subscription: true, claude: &fake, has_transcript: &|_: &str| false, pending_ask: &|_| None, title: &|n: &str| n.to_string(), kit_at: None }; - let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: true, task: None, check: None, omp: false, add: false }; + let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: true, task: None, check: None, omp: false, company: false, add: false }; let (_, gone, rows) = up(&t, &o, 1).unwrap(); let g: Vec<_> = gone.iter().map(|g| (g.name.as_str(), g.short.as_str(), g.kept.is_some())).collect(); assert_eq!(g, [("dev", "aaaaaaaa", false), ("ui", &ui.conversation_id[..8], false), ("x", "bbbbbbbb", true)]); @@ -1875,7 +1882,7 @@ mod tests { let qa_id = qa.conversation_id.clone(); let has = move |id: &str| id == qa_id; let t = Team { home: &h, root: &root, store: &store, subscription: true, claude: &fake, has_transcript: &has, pending_ask: &|_| None, title: &|n: &str| n.to_string(), kit_at: None }; - let o = Opts { roles: vec!["lead".into(), "dev".into(), "qa".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, add: false }; + let o = Opts { roles: vec!["lead".into(), "dev".into(), "qa".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, company: false, add: false }; let (status, _, rows) = up(&t, &o, 1_000).unwrap(); assert_eq!(status["on"], true); assert!(status["repo"].as_str().is_some(), "auto is scoped to the project"); @@ -1906,11 +1913,11 @@ mod tests { let store = Store::new(&h.join("cfg"), &root, "claude-sessions", "ccc ."); let fake = Fake { calls: RefCell::new(vec![]), agents: RefCell::new(vec!["[]".into()]), bg: "Workspace not trusted. Run `claude` in C:\\proj once and accept the trust prompt".into() }; let t = Team { home: &h, root: &root, store: &store, subscription: false, claude: &fake, has_transcript: &|_: &str| false, pending_ask: &|_| None, title: &|n: &str| n.to_string(), kit_at: None }; - let o = Opts { roles: vec!["dev".into(), "qa".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, add: false }; + let o = Opts { roles: vec!["dev".into(), "qa".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, company: false, add: false }; let e = up(&t, &o, 1).unwrap_err().to_string(); assert!(e.contains("chưa trust") && e.contains("8sync ccc auto up"), "{e}"); assert_eq!(fake.calls.borrow().iter().filter(|c| c.iter().any(|a| a == "--bg")).count(), 1, "stops at the first room"); - assert!(up(&t, &Opts { roles: vec!["../x".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, add: false }, 1).is_err()); + assert!(up(&t, &Opts { roles: vec!["../x".into()], every: 10, hours: 2.0, rate: None, clear: false, task: None, check: None, omp: false, company: false, add: false }, 1).is_err()); // `auto revive` walks exactly the roots `up` brought a team up in (here only this one, once). assert_eq!(team_roots(&h), [root.to_string_lossy().into_owned()]); @@ -2029,7 +2036,7 @@ mod tests { let list = format!("[{},{}]", agent_in(game_lead, "lead", "background", &game), agent_in(DEV_BG, "dev", "background", &game)); let fake = Fake { calls: RefCell::new(vec![]), agents: RefCell::new(vec![list]), bg: "backgrounded · 3698e3a6 · {name}\n".into() }; let t = Team { home: &h, root: &root, store: &store, subscription: true, claude: &fake, has_transcript: &|id: &str| id == game_lead, pending_ask: &|_| None, title: &|n: &str| n.to_string(), kit_at: None }; - let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: false, task: Some("BE AI zus".into()), check: None, omp: false, add: false }; + let o = Opts { roles: vec!["lead".into(), "dev".into()], every: 10, hours: 2.0, rate: None, clear: false, task: Some("BE AI zus".into()), check: None, omp: false, company: false, add: false }; let (status, _, rows) = up(&t, &o, 1_000).unwrap(); let states: Vec<_> = rows.iter().map(|r| r.state.split(' ').next().unwrap()).collect(); assert_eq!(states, ["mở", "mở"], "neither the game's lead nor its dev is ours: {rows:?}");