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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,4 @@ package-lock.json

# browser UAT screenshots (regenerated by su-code/ship/*/uat.sh)
su-code/ship/*/uat-out/
su-code/ship/*/SPRINT.md.lock
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh

## [Unreleased]

## [0.91.0] — 2026-10-15
### Added
- **`8sync learn trending`** (Rust): the most-starred repos created in the last N days (`--lang`, `--days 7`, `--top 25`, `--json`) through GitHub's official search API — not the trending page, which must not be scraped — written to `su-code/research/trending/<date>.md` with a "Có ích cho 8sync vì" column for the R&D room to fill. Unauthenticated, no token read; adult-content repos are dropped.
- **`8sync skill learn <owner/repo>`** (Rust): shallow-clone a public repo, delete its `.git`, check the licence (MIT/Apache/BSD/ISC/MPL/CC BY/CC BY-SA/CC0/Unlicense/GPL family allowed; NC/ND/none refused with the reason), and distil the README into a ≤ 6 KB `SKILL.md` (frontmatter, source + licence + attribution, a section index with `refs/README.md:<line>` pointers) beside the untouched `refs/README.md` + `refs/LICENSE` in `~/.claude/skills/<name>` — local only, never committed. First target: donnemartin/system-design-primer (CC BY 4.0; 109,830 B README → 6,042 B skill).
- **HQ office: sprint meeting + standing up/sitting down + carrying a task.** A room whose `su-code/ship/<slug>/STANDUP.md` was written < 2 min ago shows the `meeting` state ("họp sprint"); desks play a `stand` clip when work starts and `sit` when it ends; a walker bringing a task carries a paper. All code-drawn, reduced-motion safe.
- **`scripts/uat/hook-latency.sh`**: median hook latency with budgets (guard/wake/watchdog/resume-note ≤ 20 ms, verify no-op ≤ 50 ms). Measured on Linux (`bash scripts/uat/hook-latency.sh`, 7 runs): median 2–3 ms each; the Orca per-event shell hook on this machine adds ≈ 5 ms (not verified: macOS, Windows). `HOOK_BUDGET_SCALE=3` relaxes budgets on a loaded CI runner; `EPOCHREALTIME` is read locale-safe, macOS falls back to python3/perl. The real verify check (tsc/cargo) runs with `asyncRewake` and never blocks the model.
### Changed
- **"R&D trực" preset** now studies the codebase (`codegraph`), the web (primary sources), papers (arXiv / Semantic Scholar) and today's trending file, then hands ≤ 2 concrete tasks to the rooms that are free (`8sync ccc . ls` + SendMessage, or HQ Giao việc), instead of only writing a research note. It proposes `8sync skill learn <owner/repo>` to the owner and never installs a skill unattended (`~/.claude/skills` is loaded globally; a human approves).
### Security
- `8sync skill learn`: README/LICENSE symlinks are ignored (a hostile repo cannot pull a local file into a globally loaded skill); source-available texts that quote OSI wording (Commons Clause, SSPL, BSL, Elastic, PolyForm, "sell the software") are refused before the allow-list; README/LICENSE capped at 2 MB; `SKILL.md` has a hard 6144-byte ceiling; skill names must start with `[a-z0-9]`, ≤ 64 chars, no Windows device names; `--name --force` no longer yields a skill called "--force".
- `8sync learn trending`: rows whose `owner/repo` is not GitHub-legal are dropped; description/licence cells lose link, image, HTML, backtick, control characters and URLs, so the file the R&D room reads carries no injected links; the only link per row is the github.com URL; curl is pinned to `--proto =https`; `--days` is clamped to 1–3650.

## [0.90.0] — 2026-10-15
### Added
- **HQ: giao việc theo lịch.** The "Giao việc" dialog has "Khi nào": now, once at HH:MM, daily at HH:MM, or every 5–30 min / 1–12 h. HQ runs no timer of its own: it hands the session one prompt to book the task with Claude Code's native `CronCreate` (local time, fires between turns while the session is open, recurring jobs expire after 7 days by design and are not silently re-created). The task row reads `… · ⏰ mỗi 30 phút`. Preset **"R&D trực"**: every 30 min the R&D room researches the next backlog item when it has nothing assigned, or says `rest`.
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.90.0"
version = "0.91.0"
edition = "2021"
license = "MIT"
authors = ["8sync-dev <[email protected]>"]
Expand Down
16 changes: 14 additions & 2 deletions apps/hq-ui/components/hq/office-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const SCENE = {
} as CSSProperties;

const SEAT_PX = 64;
/** A visit whose text is the HQ assign prompt (app/api/tasks/route.ts `plain` / scheduledPrompt): someone brings a task in. */
const bringsTask = (text: string | null | undefined) => /^(New task:|Lên lịch)/.test(text ?? "");
const STATUS_TEXT: Record<Room["status"], string> = {
running: "Đang làm",
idle: "Rảnh",
Expand Down Expand Up @@ -92,6 +94,7 @@ function RoomCard({
const needs = room.status === "needs_input" || !!room.waiting;
const working = room.seats.reduce((a, s) => a + s.active.length, 0);
const id = `room-${room.key.replace(/[^\w-]/g, "_")}`;
const bringing = room.pending > 0 && !incoming.slice(0, 2).some((v) => bringsTask(v.text));
return (
<article
aria-labelledby={id}
Expand Down Expand Up @@ -150,17 +153,26 @@ function RoomCard({
))}
</ul>
)}
{(incoming.length > 0 || outgoing.length > 0) && (
{(incoming.length > 0 || outgoing.length > 0 || bringing) && (
<div className="mt-2 flex flex-col gap-1.5" role="status">
{incoming.slice(0, 2).map((v) => (
<div key={`in-${v.ts}-${v.from}`} className="flex min-w-0 items-end gap-2">
<WalkerCanvas shirt={shirtFor(v.from)} ts={v.ts} />
<WalkerCanvas shirt={shirtFor(v.from)} ts={v.ts} carry={bringsTask(v.text)} />
<span className="min-w-0 truncate rounded-md bg-white/10 px-2 py-1 text-xs text-(--ink)">
<span className="font-medium">Từ {labelOf(v.from)}</span>
{v.text && <span className="text-(--soft)">: {v.text}</span>}
</span>
</div>
))}
{/* Queued inbox work: one carrier at the door, unless a visit already shows one. */}
{bringing && (
<div className="flex min-w-0 items-end gap-2">
<WalkerCanvas shirt={shirtFor("owner")} ts={room.pending} carry />
<span className="min-w-0 truncate rounded-md bg-white/10 px-2 py-1 text-xs text-(--soft)">
Đang mang việc tới · {room.pending} chờ giao
</span>
</div>
)}
{outgoing.slice(0, 1).map((v) => (
<p key={`out-${v.ts}-${v.to}`} className="truncate text-xs text-(--soft)">
Lead đang sang phòng {labelOf(v.to)}
Expand Down
9 changes: 6 additions & 3 deletions apps/hq-ui/components/hq/sprite-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ export function SpriteCanvas({
return <canvas ref={ref} aria-hidden className={className ?? "block"} />;
}

/** A specialist walking in from another room (shared rAF loop; reduced motion: stands). */
export function WalkerCanvas({ shirt, ts }: { shirt: string; ts: number }) {
/**
* A specialist walking in from another room (shared rAF loop; reduced motion: stands).
* `carry`: holding a task paper, i.e. bringing work into the room.
*/
export function WalkerCanvas({ shirt, ts, carry = false }: { shirt: string; ts: number; carry?: boolean }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => (ref.current ? mountWalker(ref.current, { shirt }) : undefined), [shirt, ts]);
useEffect(() => (ref.current ? mountWalker(ref.current, { shirt, carry }) : undefined), [shirt, ts, carry]);
return (
<canvas
key={ts}
Expand Down
15 changes: 13 additions & 2 deletions apps/hq-ui/lib/floor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ export type LsInput = {
export type HqInput = {
status?: string | null; // needs_input | running | idle | ended
detail?: string | null; // waitingFor
/** Newest `su-code/ship/<slug>/STANDUP.md` under the session's repo (`at` = mtime, unix ms). */
standup?: { slug: string; at: number } | null;
} | null;

/** A standup written less than this long ago puts the room in a sprint meeting. */
export const MEETING_MS = 2 * 60_000;

export type Derived = {
state: FloorState;
label: string;
Expand Down Expand Up @@ -55,6 +60,7 @@ export const LABEL: Record<FloorState, string> = {
waiting: "chờ anh",
stalled: "kẹt",
done: "xong",
meeting: "họp sprint",
};

export function deriveFloor(ls: LsInput | null, hq: HqInput, nowMs = Date.now()): Derived {
Expand All @@ -77,6 +83,10 @@ export function deriveFloor(ls: LsInput | null, hq: HqInput, nowMs = Date.now())
if (ls?.stalled_error) {
return make("stalled", typeof ls.stalled_error === "string" ? ls.stalled_error : "Lỗi API, cần xem lại");
}
// `8sync ccc standup <slug>` just wrote the board: the room is in its sprint meeting for a moment.
if (hq?.standup && nowMs - hq.standup.at >= 0 && nowMs - hq.standup.at < MEETING_MS && status !== "idle") {
return make("meeting", `Họp sprint ${hq.standup.slug}${text ? `: ${text}` : ""}`);
}
// A live process whose transcript has not moved for 5 min is only "stuck" while
// it still has open work; a session parked at its prompt (no open task, or hq
// says idle) is resting.
Expand Down Expand Up @@ -105,6 +115,7 @@ export const GROUP_ORDER: Record<FloorState, number> = {
typing: 2,
running: 2,
reading: 2,
meeting: 2,
done: 3,
idle: 4,
};
Expand Down Expand Up @@ -196,8 +207,8 @@ export type RepoGroup = {
working: number;
};

const ACTIVE = new Set<FloorState>(["waiting", "stalled", "typing", "running", "reading"]);
const WORKING = new Set<FloorState>(["typing", "running", "reading"]);
const ACTIVE = new Set<FloorState>(["waiting", "stalled", "typing", "running", "reading", "meeting"]);
const WORKING = new Set<FloorState>(["typing", "running", "reading", "meeting"]);

/** Annotate `who`: identical names inside one repo get a suffix so "core" x3 stays tellable. */
export function disambiguate(rows: FloorRow[]): DeskRow[] {
Expand Down
4 changes: 2 additions & 2 deletions apps/hq-ui/lib/office.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@ export function groupByProject<T extends { key: string; project: ProjectInfo | n
export function seatState(
seat: Pick<Seat, "key" | "active">,
room: { status: string; state: string | null; waiting: string | null },
): "idle" | "typing" | "running" | "reading" | "waiting" | "stalled" | "done" | null {
): "idle" | "typing" | "running" | "reading" | "waiting" | "stalled" | "done" | "meeting" | null {
if (seat.key !== "lead") return seat.active.length ? "typing" : null;
if (room.waiting || room.status === "needs_input") return "waiting";
const known = ["idle", "typing", "running", "reading", "waiting", "stalled", "done"] as const;
const known = ["idle", "typing", "running", "reading", "waiting", "stalled", "done", "meeting"] as const;
const st = known.find((k) => k === room.state);
if (st) return st;
return room.status === "running" ? "typing" : "idle";
Expand Down
20 changes: 14 additions & 6 deletions apps/hq-ui/lib/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,20 @@ export function scheduledPrompt(task: string, s: Schedule): string {
].join("\n");
}

/** Preset "R&D trực": the R&D room keeps researching when it has nothing assigned. */
/**
* Preset "R&D trực": the R&D room never sits idle — it studies the codebase, the web, papers and
* GitHub trending, then hands concrete work to the rooms that are free. Owner (2026-10-15): "R&D
* nghiên cứu codebase, internet, học thuật… rồi giao task tất cả session (phòng ban)".
*/
export const RD_DUTY = {
subject: "R&D trực: không có việc thì nghiên cứu backlog",
details:
"Nếu phòng đang có việc dở: bỏ qua lượt này. Nếu rảnh: lấy item kế tiếp trong su-code/STATE.md Next / dòng `failure:` trong su-code/KNOWLEDGE.md chưa có ghi chú nghiên cứu, " +
"trả lời một câu hỏi của nó theo /deep-research bản gọn (≤5 lần search, nguồn primary), ghi su-code/research/<slug>.md và báo phòng liên quan qua SendMessage. " +
"Không còn item: nói 'rest: hết backlog' một dòng. Không sửa code sản phẩm.",
subject: "R&D trực: nghiên cứu rồi giao việc cho phòng rảnh",
details: [
"Nếu phòng đang có việc dở: bỏ qua lượt này.",
"1. Backlog: item kế trong su-code/STATE.md Next, dòng `failure:` trong su-code/KNOWLEDGE.md chưa có ghi chú, hoặc dòng còn trống cột 'Có ích cho 8sync vì' trong su-code/research/trending/<hôm nay>.md (chưa có file hôm nay → chạy `8sync learn trending` một lần).",
"2. Nghiên cứu (≤5 lần search, nguồn primary, ghi ngày/phiên bản): codebase (`codegraph query`), internet (WebSearch/WebFetch), học thuật (arXiv / Semantic Scholar, trích DOI hoặc arXiv id). Ghi su-code/research/<slug>.md: kết luận trước, bằng chứng, việc đề xuất.",
"3. Giao việc: `8sync ccc . ls` xem phòng nào rảnh (không có task in_progress); gửi ≤2 task cụ thể (file/lệnh kiểm/tiêu chí) cho phòng khớp tên bằng SendMessage, ghi 'từ R&D trực, theo research/<slug>.md'. Việc không khớp phòng nào → HQ Giao việc (POST /api/tasks) hoặc ghi vào STATE.md Next.",
"Repo đáng học (có licence) → đề xuất lệnh `8sync skill learn <owner/repo>` trong STATE.md Next cho owner chạy; KHÔNG tự cài (skill cài vào ~/.claude/skills được nạp toàn cục, cần người duyệt).",
"Không còn item: nói 'rest: hết backlog' một dòng. Không sửa code sản phẩm; không tự merge/push/deploy.",
].join("\n"),
schedule: { kind: "every", n: 30, unit: "m" } as const satisfies Schedule,
};
38 changes: 36 additions & 2 deletions apps/hq-ui/lib/server/floor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { sessionLabel } from "../labels";
import { deriveFloor, type FloorRow, type LsInput } from "../floor";
Expand Down Expand Up @@ -91,11 +93,43 @@ async function lsRows(): Promise<LsRow[]> {
}
}

/**
* Newest `su-code/ship/<slug>/STANDUP.md` under a session's repo ("~/x" or absolute), or null.
* `8sync ccc standup <slug>` appends it; a fresh one puts the room in a sprint meeting (lib/floor.ts).
*/
export function newestStandup(repo: string | null | undefined, home = os.homedir()): { slug: string; at: number } | null {
if (!repo || repo.startsWith("-")) return null;
const dir = path.join(expand(repo, home), "su-code", "ship");
let best: { slug: string; at: number } | null = null;
let slugs: string[];
try {
slugs = fs.readdirSync(dir);
} catch {
return null;
}
for (const slug of slugs) {
try {
const at = fs.statSync(path.join(dir, slug, "STANDUP.md")).mtimeMs;
if (!best || at > best.at) best = { slug, at };
} catch {
/* no standup in this ship dir */
}
}
return best;
}

async function build(db: DatabaseSync): Promise<FloorRow[]> {
const [ls, merged] = await Promise.all([lsRows(), mergedSessions(db).catch(() => [] as SessionView[])]);
const now = Date.now();
const used = new Set<string>();
const ids = new Set<string>();
// One readdir per repo per build, not per session.
const standups = new Map<string, ReturnType<typeof newestStandup>>();
const standupOf = (repo: string | null | undefined) => {
const k = repo ?? "";
if (!standups.has(k)) standups.set(k, newestStandup(repo));
return standups.get(k) ?? null;
};
const uniq = (base: string) => {
let id = base;
for (let n = 2; ids.has(id); n++) id = `${base}#${n}`;
Expand All @@ -121,7 +155,7 @@ async function build(db: DatabaseSync): Promise<FloorRow[]> {
engine,
sid: sidOf(hq?.session_id, r.name),
tokens: typeof r.tokens === "number" ? r.tokens : null,
...deriveFloor(r, hq, now),
...deriveFloor(r, { ...(hq ?? {}), standup: standupOf(repo) }, now),
};
});
// hq sessions `8sync ls` does not know (fresh tmux panes, `claude agents` only).
Expand All @@ -138,7 +172,7 @@ async function build(db: DatabaseSync): Promise<FloorRow[]> {
repo,
engine: "ccc",
sid: sidOf(s.session_id, s.name),
...deriveFloor(null, s, now),
...deriveFloor(null, { ...s, standup: standupOf(repo) }, now),
});
}
return rows;
Expand Down
Loading
Loading