diff --git a/.gitignore b/.gitignore index 3999ea22..681addb8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b97de84..2b5f905c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.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 `** (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:` pointers) beside the untouched `refs/README.md` + `refs/LICENSE` in `~/.claude/skills/` — 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//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 ` 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`. diff --git a/Cargo.lock b/Cargo.lock index b83364da..878d43f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.90.0" +version = "0.91.0" dependencies = [ "anyhow", "serde", @@ -1398,7 +1398,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.90.0" +version = "0.91.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 16f519d9..c588d9eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 <8sync.dev.1111@gmail.com>"] diff --git a/apps/hq-ui/components/hq/office-view.tsx b/apps/hq-ui/components/hq/office-view.tsx index b6668caf..2727b907 100644 --- a/apps/hq-ui/components/hq/office-view.tsx +++ b/apps/hq-ui/components/hq/office-view.tsx @@ -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 = { running: "Đang làm", idle: "Rảnh", @@ -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 (
)} - {(incoming.length > 0 || outgoing.length > 0) && ( + {(incoming.length > 0 || outgoing.length > 0 || bringing) && (
{incoming.slice(0, 2).map((v) => (
- + Từ {labelOf(v.from)} {v.text && : {v.text}}
))} + {/* Queued inbox work: one carrier at the door, unless a visit already shows one. */} + {bringing && ( +
+ + + Đang mang việc tới · {room.pending} chờ giao + +
+ )} {outgoing.slice(0, 1).map((v) => (

Lead đang sang phòng {labelOf(v.to)} diff --git a/apps/hq-ui/components/hq/sprite-canvas.tsx b/apps/hq-ui/components/hq/sprite-canvas.tsx index f8fd0a1b..5f7e366e 100644 --- a/apps/hq-ui/components/hq/sprite-canvas.tsx +++ b/apps/hq-ui/components/hq/sprite-canvas.tsx @@ -39,10 +39,13 @@ export function SpriteCanvas({ return ; } -/** 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(null); - useEffect(() => (ref.current ? mountWalker(ref.current, { shirt }) : undefined), [shirt, ts]); + useEffect(() => (ref.current ? mountWalker(ref.current, { shirt, carry }) : undefined), [shirt, ts, carry]); return ( /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; @@ -55,6 +60,7 @@ export const LABEL: Record = { waiting: "chờ anh", stalled: "kẹt", done: "xong", + meeting: "họp sprint", }; export function deriveFloor(ls: LsInput | null, hq: HqInput, nowMs = Date.now()): Derived { @@ -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 ` 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. @@ -105,6 +115,7 @@ export const GROUP_ORDER: Record = { typing: 2, running: 2, reading: 2, + meeting: 2, done: 3, idle: 4, }; @@ -196,8 +207,8 @@ export type RepoGroup = { working: number; }; -const ACTIVE = new Set(["waiting", "stalled", "typing", "running", "reading"]); -const WORKING = new Set(["typing", "running", "reading"]); +const ACTIVE = new Set(["waiting", "stalled", "typing", "running", "reading", "meeting"]); +const WORKING = new Set(["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[] { diff --git a/apps/hq-ui/lib/office.ts b/apps/hq-ui/lib/office.ts index 8caafed7..6285520d 100644 --- a/apps/hq-ui/lib/office.ts +++ b/apps/hq-ui/lib/office.ts @@ -129,10 +129,10 @@ export function groupByProject, 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"; diff --git a/apps/hq-ui/lib/schedule.ts b/apps/hq-ui/lib/schedule.ts index 32727146..994f335a 100644 --- a/apps/hq-ui/lib/schedule.ts +++ b/apps/hq-ui/lib/schedule.ts @@ -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/.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/.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/.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/.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 ` 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, }; diff --git a/apps/hq-ui/lib/server/floor.ts b/apps/hq-ui/lib/server/floor.ts index 634ff4ff..75cb09aa 100644 --- a/apps/hq-ui/lib/server/floor.ts +++ b/apps/hq-ui/lib/server/floor.ts @@ -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"; @@ -91,11 +93,43 @@ async function lsRows(): Promise { } } +/** + * Newest `su-code/ship//STANDUP.md` under a session's repo ("~/x" or absolute), or null. + * `8sync ccc standup ` 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 { const [ls, merged] = await Promise.all([lsRows(), mergedSessions(db).catch(() => [] as SessionView[])]); const now = Date.now(); const used = new Set(); const ids = new Set(); + // One readdir per repo per build, not per session. + const standups = new Map>(); + 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}`; @@ -121,7 +155,7 @@ async function build(db: DatabaseSync): Promise { 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). @@ -138,7 +172,7 @@ async function build(db: DatabaseSync): Promise { repo, engine: "ccc", sid: sidOf(s.session_id, s.name), - ...deriveFloor(null, s, now), + ...deriveFloor(null, { ...s, standup: standupOf(repo) }, now), }); } return rows; diff --git a/apps/hq-ui/lib/sprite/frames.ts b/apps/hq-ui/lib/sprite/frames.ts index f9d56b94..795f8109 100644 --- a/apps/hq-ui/lib/sprite/frames.ts +++ b/apps/hq-ui/lib/sprite/frames.ts @@ -6,11 +6,14 @@ import type { SeatKey } from "../office"; export const SPRITE_SIZE = 32; -export const SPRITE_STATES = ["idle", "typing", "running", "reading", "waiting", "stalled", "done"] as const; +export const SPRITE_STATES = ["idle", "typing", "running", "reading", "waiting", "stalled", "done", "meeting"] as const; export type SpriteState = (typeof SPRITE_STATES)[number]; -/** Every clip a desk sprite can play: the session states plus "rest" (empty seat, screen off). */ -export const CLIPS = [...SPRITE_STATES, "rest"] as const; +/** + * Every clip a desk sprite can play: the session states, "rest" (empty seat, screen off) and + * the two one-shot transitions "stand" (rise from the chair before work) / "sit" (back to idle). + */ +export const CLIPS = [...SPRITE_STATES, "rest", "stand", "sit"] as const; export type Clip = (typeof CLIPS)[number]; /** '.' is transparent. 'c' (shirt) is per role / session, see shirtOf(). */ @@ -92,6 +95,7 @@ type Pose = { eyes?: Eyes; hands?: [number, number][]; // 2x2 skin hands on the keyboard armUp?: [number, number][]; // [x, y] of raised hands (2 wide, drawn down to the shoulder) + chair?: boolean; // the chair back shows beside the torso (person has risen off it) }; /** @@ -135,15 +139,36 @@ export const PROPS: Record = { critic: { desk: [0, 14, [" e ", "eeeee", "e e e", "e e e", "nnenn", " e ", " e ", " eee "]] }, }; -function scene(screenOff = false): G { +/** Sprint board on the wall where the monitor stands: 3 columns of tiny cards (todo / doing / done). */ +function board(g: G, moved: boolean) { + rect(g, 19, 5, 13, 14, "o"); + rect(g, 20, 6, 11, 12, "w"); + rect(g, 20, 8, 11, 1, "o"); // header rule under the title + stamp(g, 21, 6, ["o o o o o"]); // "sprint" title: dashes, too small for letters + for (const x of [23, 27]) rect(g, x, 9, 1, 9, "m"); // column rules + const cards: [number, number, string][] = [ + [21, 10, "y"], + [21, 13, "y"], + [25, 10, "b"], + [29, 10, "t"], + [29, 13, "t"], + ]; + // one card moves from todo to doing when the meeting "decides" + cards.push(moved ? [25, 13, "b"] : [21, 16, "y"]); + for (const [x, y, c] of cards) rect(g, x, y, 2, 2, c); +} + +function scene(screenOff = false, noMonitor = false): G { const g = blank(); - // monitor - rect(g, 20, 8, 11, 11, "o"); - rect(g, 21, 9, 9, 9, "m"); - rect(g, 22, 10, 7, 7, "k"); - rect(g, 24, 19, 3, 2, "m"); - rect(g, 23, 21, 5, 1, "m"); - if (screenOff) px(g, 29, 17, "t"); // standby light + if (!noMonitor) { + // monitor + rect(g, 20, 8, 11, 11, "o"); + rect(g, 21, 9, 9, 9, "m"); + rect(g, 22, 10, 7, 7, "k"); + rect(g, 24, 19, 3, 2, "m"); + rect(g, 23, 21, 5, 1, "m"); + if (screenOff) px(g, 29, 17, "t"); // standby light + } // desk rect(g, 0, 22, 32, 1, "o"); rect(g, 0, 23, 32, 3, "d"); @@ -156,6 +181,11 @@ function scene(screenOff = false): G { function person(g: G, p: Pose, prop?: Prop) { const hy = p.hy ?? 8; const ty = p.torsoTop ?? hy + 8; + // chair back behind the torso, 1 px wider on each side so it shows once the person rises + if (p.chair) { + rect(g, 4, 15, 14, 7, "o"); + rect(g, 5, 16, 12, 6, "n"); + } // torso (x 6..15) down to the desk for (let y = ty; y <= 21; y++) { const edge = y === ty; @@ -210,7 +240,34 @@ function bubble(g: G, kind: "?" | "!", dy: number) { glyph.forEach((r, j) => [...r].forEach((c, i) => c === "o" && px(g, 22 + i, dy + 1 + j, ink))); } -type Spec = { ms: number[]; pose: (i: number) => Pose; deco?: (g: G, i: number) => void; off?: boolean; loop?: boolean }; +/** Small speech bubble (top left, clear of the board) with 1..3 dots: someone is talking. */ +function dots(g: G, n: number) { + rect(g, 0, 1, 11, 6, "o"); + rect(g, 1, 2, 9, 4, "w"); + px(g, 8, 7, "o"); + for (let k = 0; k < n; k++) px(g, 3 + k * 2, 4, "o"); +} + +/** + * Rising from the chair, frame 0..2: head and torso go up 1 px per frame (3 px in all), the + * hands leave the keyboard for the sides on frame 1, and the chair back shows throughout. + */ +const risePose = (i: number): Pose => ({ + hy: 7 - i, + torsoTop: 15 - i, + chair: true, + hands: i === 0 ? [[13, 20], [17, 20]] : [[4, 18 - i], [16, 18 - i]], +}); + +type Spec = { + ms: number[]; + pose: (i: number) => Pose; + deco?: (g: G, i: number) => void; + off?: boolean; + loop?: boolean; + /** No monitor in the scene (the clip draws a wall board instead). */ + noMonitor?: boolean; +}; const idleScreen = (g: G) => lines(g, "g", [3, 2]); const typeHands = (i: number): [number, number][] => (i % 2 ? [[13, 21], [17, 20]] : [[13, 20], [17, 21]]); @@ -281,19 +338,33 @@ const SPECS: Record = { for (const [n, [x, y]] of ([[3, 2], [14, 3], [27, 4], [6, 4], [30, 2]] as const).entries()) px(g, x, y, n % 2 ? "y" : "c"); }, }, + // sprint meeting: turned to the board (eyes shift), hands off the keyboard, a point at the + // board on frame 1, a card moves to "doing" on frame 2 while the speech dots build up + meeting: { + ms: [600, 650, 550], + noMonitor: true, + pose: (i) => ({ eyes: "shift", hands: i === 1 ? [[13, 21]] : [[13, 21], [17, 21]], armUp: i === 1 ? [[17, 11]] : [] }), + deco: (g, i) => { + board(g, i === 2); + dots(g, i + 1); + }, + }, // empty seat: screen off, settled lower, slow breath rest: { ms: [1400, 1100], off: true, pose: (i) => ({ hy: 9 + i, torsoTop: 17 + i, hands: [[13, 21], [16, 21]] }) }, + // transitions (one-shot): rise before work, sit back down before idle; render.ts chains them + stand: { ms: [140, 130, 150], loop: false, pose: risePose, deco: idleScreen }, + sit: { ms: [150, 130, 140], loop: false, pose: (i) => risePose(2 - i), deco: idleScreen }, }; /** Per-frame durations of a clip; same length as its frame list. */ export const CLIP_MS = {} as Record; for (const c of CLIPS) CLIP_MS[c] = SPECS[c].ms; -/** Clips that play once and then hand over to idle. */ +/** Clips that play once and then hand over (done/sit -> idle, stand -> the clip it was started for). */ export const ONE_SHOT: ReadonlySet = new Set(CLIPS.filter((c) => SPECS[c].loop === false)); /** Clips where a blink makes sense (eyes are open in the pose). */ -export const BLINKS: ReadonlySet = new Set(["idle", "typing", "running", "reading", "waiting", "rest"]); +export const BLINKS: ReadonlySet = new Set(["idle", "typing", "running", "reading", "waiting", "meeting", "rest"]); const built = new Map(); @@ -304,7 +375,7 @@ export function spriteFrame(role: SeatKey | null, clip: Clip, i: number, blink = const key = `${role}|${clip}|${f}|${blink ? 1 : 0}`; const hit = built.get(key); if (hit) return hit; - const g = scene(spec.off); + const g = scene(spec.off, spec.noMonitor); spec.deco?.(g, f); const prop = role ? PROPS[role] : undefined; if (prop?.desk) stamp(g, prop.desk[0], prop.desk[1], prop.desk[2]); diff --git a/apps/hq-ui/lib/sprite/render.ts b/apps/hq-ui/lib/sprite/render.ts index b5d68413..54baf9cf 100644 --- a/apps/hq-ui/lib/sprite/render.ts +++ b/apps/hq-ui/lib/sprite/render.ts @@ -6,7 +6,19 @@ import type { SeatKey } from "../office"; import { BLINKS, CLIP_MS, ONE_SHOT, PALETTE, SPRITE_SIZE, spriteFrame, type Clip } from "./frames"; import { blinker, frameAt, memo, phaseOf } from "./anim"; -import { DUST_MS, WALK_MS, WALKER_DUST, WALKER_FRAMES, WALKER_H, WALKER_PALETTE, WALKER_STAND, WALKER_W } from "./walker"; +import { + DUST_MS, + WALK_MS, + WALKER_CARRY, + WALKER_CARRY_DUST, + WALKER_CARRY_STAND, + WALKER_DUST, + WALKER_FRAMES, + WALKER_H, + WALKER_PALETTE, + WALKER_STAND, + WALKER_W, +} from "./walker"; /** The loop wakes at most this often; the shortest frame is 110 ms. */ const TICK_MS = 33; @@ -65,9 +77,15 @@ const deskCache = memo((key: string) => { return paint(rows, SPRITE_SIZE, SPRITE_SIZE, PALETTE, shirt); }); +const WALKER_SETS = { + plain: { walk: WALKER_FRAMES, dust: WALKER_DUST, stand: WALKER_STAND }, + carry: { walk: WALKER_CARRY, dust: WALKER_CARRY_DUST, stand: WALKER_CARRY_STAND }, +} as const; + const walkerCache = memo((key: string) => { - const [kind, i, shirt] = key.split("|"); - const rows = kind === "dust" ? WALKER_DUST[Number(i)] : kind === "stand" ? WALKER_STAND : WALKER_FRAMES[Number(i)]; + const [set, kind, i, shirt] = key.split("|"); + const s = WALKER_SETS[set === "carry" ? "carry" : "plain"]; + const rows = kind === "dust" ? s.dust[Number(i)] : kind === "stand" ? s.stand : s.walk[Number(i)]; return paint(rows, WALKER_W, WALKER_H, WALKER_PALETTE, shirt); }); @@ -102,6 +120,18 @@ export type SpriteHandle = { dispose(): void; }; +/** Clips where the person is up at the desk working; reached from a seated clip via "stand". */ +const WORKING: ReadonlySet = new Set(["typing", "running", "reading", "meeting", "waiting"]); +/** Clips where the person sits back (or the seat is empty); leaving them for work plays "stand". */ +const SEATED: ReadonlySet = new Set(["idle", "rest", "done"]); + +/** The transition to play before `next`, if any: stand up for work, sit down to idle. */ +export function transitionFor(from: Clip, next: Clip): Clip | null { + if (SEATED.has(from) && WORKING.has(next)) return "stand"; + if (WORKING.has(from) && next === "idle") return "sit"; + return null; +} + /** * A desk sprite. `size` is the CSS width/height; `seed` (e.g. room:seat) offsets its loop * phase and blink timing so a room never moves in lockstep; `shirt` is its colour. @@ -117,6 +147,8 @@ export function mountSprite( const role = opts.role ?? ""; const blinkAt = blinker(opts.seed, performance.now()); let clip = opts.state; + /** What a one-shot hands over to when it ends (stand -> the work clip); null = idle. */ + let then: Clip | null = null; let t0 = performance.now(); let shown = ""; @@ -134,15 +166,19 @@ export function mountSprite( const once = ONE_SHOT.has(clip); let i = frameAt(ms, now - t0 + (once ? 0 : phaseOf(opts.seed, ms)), { loop: !once }); if (i < 0) { - // "done" cheers once, then the character sits down again. - clip = "idle"; + // A one-shot is over: "stand" hands over to the work clip it was played for, + // "done" (cheer) and "sit" to idle. + clip = then ?? "idle"; + then = null; t0 = now; - i = frameAt(CLIP_MS.idle, phaseOf(opts.seed, CLIP_MS.idle)); + i = ONE_SHOT.has(clip) ? 0 : frameAt(CLIP_MS[clip], phaseOf(opts.seed, CLIP_MS[clip])); } draw(keyOf(i, BLINKS.has(clip) && blinkAt(now))); }, }; draw(keyOf(0, false)); + /** The state the sprite is in or heading to (a transition counts as its target). */ + const target = () => then ?? clip; let io: IntersectionObserver | null = null; if (!reduced) { @@ -153,8 +189,11 @@ export function mountSprite( return { setState(next) { - if (next === clip && next !== "done") return; - clip = next; + if (next === target() && next !== "done") return; + // Reduced motion: no transition, just the static frame of the new state. + const via = reduced ? null : transitionFor(target(), next); + clip = via ?? next; + then = via ? next : null; t0 = performance.now(); if (reduced) draw(keyOf(0, false)); else p.tick(t0); @@ -171,17 +210,19 @@ export const WALK_IN_MS = 1600; /** * A specialist walking into a room: 4-frame walk for WALK_IN_MS, a dust puff on arrival, - * then it stands and leaves the loop. Reduced motion: stands from the start. + * then it stands and leaves the loop. `carry`: holding a task paper (bringing work in). + * Reduced motion: stands from the start. */ -export function mountWalker(canvas: HTMLCanvasElement, opts: { shirt: string; size?: number }): () => void { +export function mountWalker(canvas: HTMLCanvasElement, opts: { shirt: string; carry?: boolean; size?: number }): () => void { backing(canvas, WALKER_W, WALKER_H, opts.size ?? WALKER_W * 2); const ctx = canvas.getContext("2d"); if (!ctx) return () => {}; let shown = ""; + const set = opts.carry ? "carry" : "plain"; const draw = (key: string) => { if (key === shown) return; shown = key; - blit(ctx, walkerCache.get(`${key}|${opts.shirt}`), canvas.width, canvas.height); + blit(ctx, walkerCache.get(`${set}|${key}|${opts.shirt}`), canvas.width, canvas.height); }; if (reducedMotion()) { draw("stand|0"); diff --git a/apps/hq-ui/lib/sprite/walker.ts b/apps/hq-ui/lib/sprite/walker.ts index 4acb7e7b..7e05bbb4 100644 --- a/apps/hq-ui/lib/sprite/walker.ts +++ b/apps/hq-ui/lib/sprite/walker.ts @@ -16,6 +16,7 @@ export const WALKER_PALETTE: Record = { b: "oklch(0.2 0.01 260)", u: "oklch(0.78 0.03 70)", // dust v: "oklch(0.55 0.02 70)", // cooled dust + w: "oklch(0.98 0 0)", // paper (a task being carried) }; const HEAD = [ @@ -70,9 +71,33 @@ export const WALKER_STAND: string[] = WALKER_FRAMES[2]; const puff = (rows: string[], spots: [number, number, string][]) => rows.map((r, y) => [...r].map((c, x) => spots.find(([sx, sy]) => sx === x && sy === y)?.[2] ?? c).join("")); -/** Arrival dust: two puffs at the feet that shrink and cool (no alpha fades in pixel art). */ -export const WALKER_DUST: string[][] = [ - puff(WALKER_STAND, [[1, 16], [2, 16], [1, 15], [9, 16], [10, 16], [10, 15]].map(([x, y]) => [x, y, "u"] as [number, number, string])), - puff(WALKER_STAND, [[0, 14], [11, 14]].map(([x, y]) => [x, y, "v"] as [number, number, string])), +/** + * The same walk carrying a task: a 2x3 white paper with one line of "text" held in front at + * chest height (x 10..11, the front hand on row 9 of the body already touches it). The pass + * frames start with a blank row, so the paper follows the 1 px bob. + */ +const PAPER: [number, number, string][] = [ + [10, 8, "w"], + [11, 8, "w"], + [10, 9, "o"], + [11, 9, "o"], + [10, 10, "w"], + [11, 10, "w"], ]; +const carry = (rows: string[]) => { + const dy = rows[0] === BLANK ? 1 : 0; + return puff(rows, PAPER.map(([x, y, c]) => [x, y + dy, c])); +}; + +export const WALKER_CARRY: string[][] = WALKER_FRAMES.map(carry); +export const WALKER_CARRY_STAND: string[] = WALKER_CARRY[2]; + +const dust = (stand: string[]): string[][] => [ + puff(stand, [[1, 16], [2, 16], [1, 15], [9, 16], [10, 16], [10, 15]].map(([x, y]) => [x, y, "u"] as [number, number, string])), + puff(stand, [[0, 14], [11, 14]].map(([x, y]) => [x, y, "v"] as [number, number, string])), +]; + +/** Arrival dust: two puffs at the feet that shrink and cool (no alpha fades in pixel art). */ +export const WALKER_DUST: string[][] = dust(WALKER_STAND); +export const WALKER_CARRY_DUST: string[][] = dust(WALKER_CARRY_STAND); export const DUST_MS = [120, 140] as const; diff --git a/apps/hq-ui/tests/floor-api.test.ts b/apps/hq-ui/tests/floor-api.test.ts index 03c6bc84..e17e0d73 100644 --- a/apps/hq-ui/tests/floor-api.test.ts +++ b/apps/hq-ui/tests/floor-api.test.ts @@ -10,8 +10,10 @@ vi.mock("@/lib/server/proc", async (orig) => ({ import { GET } from "@/app/api/floor/route"; import { getToken } from "@/lib/server/auth"; -import { resetFloorCache } from "@/lib/server/floor"; +import { newestStandup, resetFloorCache } from "@/lib/server/floor"; import { req, tempHome } from "./helpers"; +import fs from "node:fs"; +import path from "node:path"; let token = ""; beforeAll(() => { @@ -152,3 +154,24 @@ describe("GET /api/floor", () => { expect((await res.json()).rows).toEqual([]); }); }); + +describe("newestStandup (sprint meeting signal)", () => { + it("picks the freshest su-code/ship//STANDUP.md under the repo, null otherwise", () => { + const home = tempHome(); + const repo = path.join(home, "proj"); + for (const [slug, age] of [["old-loop", 60_000], ["autowork", 1_000]] as const) { + const d = path.join(repo, "su-code", "ship", slug); + fs.mkdirSync(d, { recursive: true }); + const f = path.join(d, "STANDUP.md"); + fs.writeFileSync(f, "# standup\n"); + const t = new Date(Date.now() - age); + fs.utimesSync(f, t, t); + } + fs.mkdirSync(path.join(repo, "su-code", "ship", "no-standup"), { recursive: true }); + expect(newestStandup("~/proj", home)?.slug).toBe("autowork"); + expect(newestStandup(repo, home)?.slug).toBe("autowork"); + expect(newestStandup(path.join(home, "elsewhere"), home)).toBeNull(); + expect(newestStandup("-home-me-dashed", home)).toBeNull(); + expect(newestStandup(null, home)).toBeNull(); + }); +}); diff --git a/apps/hq-ui/tests/floor.test.ts b/apps/hq-ui/tests/floor.test.ts index ff440865..e7eb2793 100644 --- a/apps/hq-ui/tests/floor.test.ts +++ b/apps/hq-ui/tests/floor.test.ts @@ -35,6 +35,10 @@ describe("deriveFloor", () => { ["all done but still running -> keeps working", { status: "running", tasks_done: 3, tasks_total: 3, last_tool: "Bash", last_active: ago(10) }, null, "running", "chạy lệnh"], ["total 0 is not done", { status: "idle", tasks_done: 0, tasks_total: 0 }, null, "idle", "nghỉ"], ["idle ignores old tool", { status: "idle", last_tool: "Edit", last_active: ago(7200) }, null, "idle", "nghỉ"], + ["fresh STANDUP.md -> sprint meeting", { status: "running", last_tool: "Edit", last_active: ago(5) }, { status: "running", standup: { slug: "autowork", at: NOW - 30_000 } }, "meeting", "họp sprint"], + ["standup older than 2 min -> back to work", { status: "running", last_tool: "Edit", last_active: ago(5) }, { status: "running", standup: { slug: "autowork", at: NOW - 3 * 60_000 } }, "typing", "đang gõ code"], + ["needs_input wins over a meeting", { status: "running", last_tool: "Edit", last_active: ago(5) }, { status: "needs_input", detail: "Ship?", standup: { slug: "a", at: NOW } }, "waiting", "chờ anh"], + ["an idle desk does not meet", { status: "idle", last_active: ago(5) }, { status: "idle", standup: { slug: "a", at: NOW } }, "idle", "nghỉ"], ]; it.each(cases)("%s", (_n, ls, hq, state, label) => { const d = deriveFloor(ls, hq, NOW); @@ -136,6 +140,7 @@ describe("grouping by repo", () => { r("b2", "fix", "~/b", "waiting", NOW / 1000 - 3000), r("c1", "old", "~/c", "idle", NOW / 1000 - 90_000), r("n1", "x", null, "running", NOW / 1000 - 1), + r("a4", "rd", "~/a", "meeting", NOW / 1000 - 10), ], NOW, ); @@ -145,8 +150,8 @@ describe("grouping by repo", () => { expect(b.desks.map((x) => x.id)).toEqual(["b2"]); expect(b.rest.map((x) => x.id)).toEqual(["b1"]); const a = groups[2]; - expect(a.working).toBe(1); - expect(a.desks.map((x) => x.id)).toEqual(["a1"]); + expect(a.working).toBe(2); + expect(a.desks.map((x) => x.id)).toEqual(["a1", "a4"]); expect(a.rest.map((x) => x.id)).toEqual(["a3", "a2"]); expect(groups[1].name).toBe("Khác"); expect(old.map((x) => x.id)).toEqual(["c1"]); diff --git a/apps/hq-ui/tests/office.test.ts b/apps/hq-ui/tests/office.test.ts index c0057100..ac87700a 100644 --- a/apps/hq-ui/tests/office.test.ts +++ b/apps/hq-ui/tests/office.test.ts @@ -27,7 +27,19 @@ import { seatsFor, visitsFrom, } from "@/lib/office"; -import { DUST_MS, WALK_MS, WALKER_DUST, WALKER_FRAMES, WALKER_H, WALKER_PALETTE, WALKER_STAND, WALKER_W } from "@/lib/sprite/walker"; +import { + DUST_MS, + WALK_MS, + WALKER_CARRY, + WALKER_CARRY_DUST, + WALKER_CARRY_STAND, + WALKER_DUST, + WALKER_FRAMES, + WALKER_H, + WALKER_PALETTE, + WALKER_STAND, + WALKER_W, +} from "@/lib/sprite/walker"; import { ORIGIN, req, tempHome } from "./helpers"; const SID = "1eb79553-8369-4a90-b428-2dedb50e18db"; @@ -204,6 +216,7 @@ describe("what each seat shows", () => { expect(seatState({ key: "lead", active: [] }, { ...room, waiting: "Trust this folder?" })).toBe("waiting"); expect(seatState({ key: "lead", active: [] }, { ...room, status: "needs_input" })).toBe("waiting"); expect(seatState({ key: "lead", active: [] }, { ...room, status: "idle", state: "bogus" })).toBe("idle"); + expect(seatState({ key: "lead", active: [] }, { ...room, state: "meeting" })).toBe("meeting"); expect(seatState({ key: "review", active: ["reviewer"] }, room)).toBe("typing"); expect(seatState({ key: "review", active: [] }, room)).toBeNull(); }); @@ -277,6 +290,32 @@ describe("walker sprite", () => { } expect(WALKER_FRAMES[0].join()).not.toBe(WALKER_FRAMES[1].join()); }); + it("carrying a task: same 4-beat walk with a white paper in front, plus its own stand and dust", () => { + expect(WALKER_CARRY.length).toBe(WALKER_FRAMES.length); + expect(WALKER_CARRY_DUST.length).toBe(DUST_MS.length); + for (const f of [...WALKER_CARRY, ...WALKER_CARRY_DUST, WALKER_CARRY_STAND]) { + expect(f.length).toBe(WALKER_H); + for (const row of f) { + expect(row.length).toBe(WALKER_W); + for (const ch of row) expect(Object.hasOwn(WALKER_PALETTE, ch), ch).toBe(true); + } + expect(f.join()).toMatch(/w/); + } + expect(WALKER_PALETTE.w).toBeTruthy(); + WALKER_CARRY.forEach((f, i) => { + expect(f.join(), `carry#${i}`).not.toBe(WALKER_FRAMES[i].join()); + // the paper: 2 wide, 3 tall, a line of ink in the middle, at chest height in front (x 10..11) + const dy = f[0] === "............" ? 1 : 0; + expect(f[8 + dy].slice(10, 12)).toBe("ww"); + expect(f[9 + dy].slice(10, 12)).toBe("oo"); + expect(f[10 + dy].slice(10, 12)).toBe("ww"); + // nothing else changed: same rows outside the paper + f.forEach((row, y) => expect(row.slice(0, 10), `carry#${i} row ${y}`).toBe(WALKER_FRAMES[i][y].slice(0, 10))); + }); + expect(new Set(WALKER_CARRY.map((f) => f.join())).size).toBe(4); + expect(WALKER_CARRY_STAND.join()).not.toBe(WALKER_STAND.join()); + expect(WALKER_FRAMES.join()).not.toMatch(/w/); + }); }); describe("Board / Giao việc to a session started elsewhere", () => { diff --git a/apps/hq-ui/tests/schedule.test.ts b/apps/hq-ui/tests/schedule.test.ts index 0fb186f9..128c0984 100644 --- a/apps/hq-ui/tests/schedule.test.ts +++ b/apps/hq-ui/tests/schedule.test.ts @@ -31,4 +31,13 @@ describe("schedule", () => { expect(scheduleLabel({ kind: "every", n: 5, unit: "m" })).toBe("mỗi 5 phút"); expect(RD_DUTY.schedule).toEqual({ kind: "every", n: 30, unit: "m" }); }); + it("R&D duty studies code, web, papers and trending, then hands work to free rooms", () => { + const d = RD_DUTY.details; + for (const must of ["codegraph", "WebSearch", "arXiv", "8sync learn trending", "8sync skill learn", "KHÔNG tự cài", "8sync ccc . ls", "SendMessage", "≤2 task", "bỏ qua lượt này", "Không sửa code sản phẩm"]) { + expect(d, must).toContain(must); + } + expect(d.length).toBeLessThanOrEqual(2000); // POST /api/tasks clamps details at 2000 + const p = scheduledPrompt(`New task: ${RD_DUTY.subject}\n${d}`, RD_DUTY.schedule); + expect(p).toContain("[8sync:wake] New task: R&D trực"); + }); }); diff --git a/apps/hq-ui/tests/sprite.test.ts b/apps/hq-ui/tests/sprite.test.ts index 23345b53..9b45c7b7 100644 --- a/apps/hq-ui/tests/sprite.test.ts +++ b/apps/hq-ui/tests/sprite.test.ts @@ -140,6 +140,69 @@ describe("clips and timing", () => { }); }); +import { transitionFor } from "@/lib/sprite/render"; + +describe("meeting state and the stand / sit transitions", () => { + it("meeting is a looping state with a wall board instead of the monitor, 2-3 frames ~600 ms, and blinks", () => { + expect(SPRITE_STATES).toContain("meeting"); + expect(CLIPS).toEqual([...SPRITE_STATES, "rest", "stand", "sit"]); + expect(ONE_SHOT.has("meeting")).toBe(false); + expect(BLINKS.has("meeting")).toBe(true); + expect(CLIP_MS.meeting.length).toBeGreaterThanOrEqual(2); + expect(CLIP_MS.meeting.length).toBeLessThanOrEqual(3); + for (const ms of CLIP_MS.meeting) expect(ms).toBeGreaterThanOrEqual(500); + expect(SPRITES.meeting.length).toBe(CLIP_MS.meeting.length); + const f = spriteFrame(null, "meeting", 0); + expect(sameRows(f)).toBe(true); + // no monitor glow / screen-off pixels where the monitor used to be; a white board + cards instead + const wall = f.slice(6, 18).map((r) => r.slice(20, 31)).join(); + expect(wall).not.toMatch(/[kg]/); + expect(wall).toMatch(/w/); + expect(wall).toMatch(/y/); + expect(wall).toMatch(/b/); + expect(wall).toMatch(/t/); + // eyes turned to the board (shift), speech dots build up frame by frame + expect(f[12][10]).toBe("o"); + expect(f[12][13]).toBe("o"); + const dotsOf = (i: number) => [...spriteFrame(null, "meeting", i)[4].slice(1, 10)].filter((c) => c === "o").length; + expect([dotsOf(0), dotsOf(1), dotsOf(2)]).toEqual([1, 2, 3]); + expect(new Set(SPRITES.meeting.map((x) => x.join())).size).toBe(SPRITES.meeting.length); + }); + it("stand and sit are one-shot, >= 3 frames, the head rises 3 px and the hands leave the keyboard", () => { + for (const c of ["stand", "sit"] as const) { + expect(ONE_SHOT.has(c), c).toBe(true); + expect(CLIP_MS[c].length, c).toBeGreaterThanOrEqual(3); + for (const ms of CLIP_MS[c]) expect(ms).toBeGreaterThanOrEqual(120); + for (let i = 0; i < CLIP_MS[c].length; i++) expect(sameRows(spriteFrame(null, c, i)), `${c}#${i}`).toBe(true); + } + const idle = spriteFrame(null, "idle", 0); + const up = spriteFrame(null, "stand", 2); + const headTop = (f: string[]) => f.findIndex((r) => r.slice(7, 15) === ".oooooo."); + expect(headTop(idle)).toBe(8); + expect(headTop(up)).toBe(5); + // hands off the keyboard (row 20 at x 13..14 is torso/outline now, not skin) + expect(idle[20].slice(13, 15)).toBe("ss"); + expect(up[20].slice(13, 15)).not.toBe("ss"); + // the chair back shows beside the torso + expect(up[18][5]).toBe("n"); + expect(up[18][16]).toBe("n"); + // sit is the reverse of stand + expect(spriteFrame(null, "sit", 0).join()).toBe(spriteFrame(null, "stand", 2).join()); + expect(spriteFrame(null, "sit", 2).join()).toBe(spriteFrame(null, "stand", 0).join()); + }); + it("transitions: seated -> work via stand, work -> idle via sit, everything else direct", () => { + expect(transitionFor("idle", "typing")).toBe("stand"); + expect(transitionFor("rest", "meeting")).toBe("stand"); + expect(transitionFor("done", "running")).toBe("stand"); + expect(transitionFor("typing", "idle")).toBe("sit"); + expect(transitionFor("meeting", "idle")).toBe("sit"); + expect(transitionFor("typing", "reading")).toBeNull(); + expect(transitionFor("idle", "stalled")).toBeNull(); + expect(transitionFor("idle", "done")).toBeNull(); + expect(transitionFor("typing", "rest")).toBeNull(); + }); +}); + function sameRows(f: string[]) { return f.length === SPRITE_SIZE && f.every((r) => r.length === SPRITE_SIZE && [...r].every((ch) => Object.hasOwn(PALETTE, ch))); } diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index 9507391c..314acef9 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "8sync-kit", - "version": "0.90.0", + "version": "0.91.0", "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://github.com/8-Sync-Dev/su-code", diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index abba6b91..dfc0ac18 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -165,6 +165,9 @@ enum Cmd { /// Always-on guard: alerts Claude Code + you and SIGTERMs runaway harness helpers (MCP servers, LSPs, index workers) Watchdog(verbs::watchdog::Args), + + /// Học từ repo mới nổi trên GitHub (search API chính thức): 8sync learn trending + Learn(verbs::learn::Args), } fn main() -> Result<()> { @@ -227,6 +230,7 @@ fn main() -> Result<()> { Some(Cmd::Forge(a)) => verbs::forge::run(a), Some(Cmd::Ccc(a)) => verbs::ccc::run(a), Some(Cmd::Watchdog(a)) => verbs::watchdog::run(a), + Some(Cmd::Learn(a)) => verbs::learn::run(a), Some(Cmd::Ls(a)) => verbs::session_cmds::run_ls(a), Some(Cmd::Show(a)) => verbs::session_cmds::run_show(a), Some(Cmd::Guide) => verbs::session_cmds::run_guide(), diff --git a/crates/cli/src/verbs/learn.rs b/crates/cli/src/verbs/learn.rs new file mode 100644 index 00000000..2bf67ac7 --- /dev/null +++ b/crates/cli/src/verbs/learn.rs @@ -0,0 +1,332 @@ +//! `8sync learn trending` — find new, popular GitHub repos for the R&D session to learn from. +//! +//! Why the official search API and not github.com/trending: scraping the trending page is +//! against GitHub's ToS and breaks whenever the HTML changes. The REST search endpoint +//! (`created:>DATE stars:>50`, sorted by stars) is documented, stable and gives a close +//! approximation. Unauthenticated (10 req/min, one request per run); no token is read. +//! The response is untrusted data: only a few typed fields are parsed and the description is +//! truncated before it reaches the markdown table. + +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args as ClapArgs, Subcommand}; +use serde::{Deserialize, Serialize}; + +#[derive(ClapArgs, Debug)] +pub struct Args { + #[command(subcommand)] + pub cmd: LearnCmd, +} + +#[derive(Subcommand, Debug)] +pub enum LearnCmd { + /// Repo mới nổi bật gần đây (GitHub search API chính thức, không scrape trang trending) + #[command(after_help = "Ví dụ:\n 8sync learn trending\n 8sync learn trending --lang rust --days 14 --top 10\n 8sync learn trending --json")] + Trending { + /// Ngôn ngữ GitHub (vd rust, typescript, \"c++\") + #[arg(long)] + lang: Option, + /// Chỉ lấy repo tạo trong N ngày qua + #[arg(long, default_value_t = 7)] + days: u32, + /// Số repo tối đa (1-100) + #[arg(long, default_value_t = 25)] + top: u32, + /// In JSON thay vì ghi file markdown + #[arg(long)] + json: bool, + /// Thư mục ghi (mặc định /su-code/research/trending) + #[arg(long)] + out: Option, + /// Ghi đè file của hôm nay nếu đã có + #[arg(long)] + force: bool, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct License { + pub spdx_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Repo { + pub full_name: String, + pub html_url: String, + #[serde(default)] + pub stargazers_count: u64, + pub description: Option, + pub license: Option, + pub language: Option, +} + +#[derive(Deserialize)] +struct SearchResp { + items: Vec, +} + +pub fn run(a: Args) -> Result<()> { + match a.cmd { + LearnCmd::Trending { lang, days, top, json, out, force } => trending(lang, days, top, json, out, force), + } +} + +fn trending(lang: Option, days: u32, top: u32, json: bool, out: Option, force: bool) -> Result<()> { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let today = ymd_from_days((now / 86_400) as i64); + let top = top.clamp(1, 100); + let url = query_url(lang.as_deref(), days, top, now / 86_400); + + let dir = match out { + Some(p) => p, + None => { + let root = crate::verbs::skill::discover::detect_current_project_root() + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| anyhow!("không xác định được thư mục project"))?; + root.join("su-code/research/trending") + } + }; + let path = dir.join(format!("{today}.md")); + if !json && !force && path.exists() { + println!("đã có {} (dùng --force để ghi lại)", path.display()); + return Ok(()); + } + + let mut items = fetch(&url)?; + items.retain(|r| !is_noise(r)); + if json { + println!("{}", serde_json::to_string_pretty(&items)?); + return Ok(()); + } + let desc = match &lang { + Some(l) => format!("{days} ngày qua, ngôn ngữ {l}"), + None => format!("{days} ngày qua"), + }; + let md = render(&items, &desc, &today); + std::fs::create_dir_all(&dir).with_context(|| format!("tạo {}", dir.display()))?; + std::fs::write(&path, &md).with_context(|| format!("ghi {}", path.display()))?; + println!("{}", path.display()); + for r in items.iter().filter(|r| valid_full_name(&r.full_name)).take(5) { + println!("{} ⭐{} {}", r.full_name, r.stargazers_count, safe_url(r)); + } + Ok(()) +} + +/// Repos the R&D session should not be handed (adult content shows up in star-sorted search). +fn is_noise(r: &Repo) -> bool { + let hay = format!("{} {}", r.full_name, r.description.as_deref().unwrap_or("")).to_lowercase(); + ["nsfw", "porn", "hentai"].iter().any(|w| hay.contains(w)) +} + +/// Percent-encode a query value; spaces become `+`. +fn enc(s: &str) -> String { + let mut o = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => o.push(b as char), + b' ' => o.push('+'), + _ => o.push_str(&format!("%{b:02X}")), + } + } + o +} + +fn query_url(lang: Option<&str>, days: u32, top: u32, today_days: u64) -> String { + let days = days.clamp(1, 3650); + let since = ymd_from_days(today_days as i64 - days as i64); + let mut q = format!("created:>{since}+stars:>50"); + if let Some(l) = lang.map(str::trim).filter(|l| !l.is_empty()) { + q.push_str(&format!("+language:{}", enc(l))); + } + format!("https://api.github.com/search/repositories?q={q}&sort=stars&order=desc&per_page={top}") +} + +/// Days since 1970-01-01 → `YYYY-MM-DD` (Howard Hinnant's civil_from_days). +fn ymd_from_days(days: i64) -> String { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}") +} + +fn fetch(url: &str) -> Result> { + let out = Command::new("curl") + .args([ + "-s", "--proto", "=https", "--max-time", "12", "-A", "8sync-learn", + "-H", "Accept: application/vnd.github+json", + "-H", "X-GitHub-Api-Version: 2022-11-28", + url, + ]) + .output() + .context("không chạy được curl")?; + if !out.status.success() { + bail!("không gọi được GitHub API (curl thoát {}) — kiểm tra mạng rồi thử lại", out.status); + } + parse_response(&out.stdout) +} + +fn parse_response(body: &[u8]) -> Result> { + let v: serde_json::Value = + serde_json::from_slice(body).map_err(|e| anyhow!("GitHub API trả về dữ liệu không hợp lệ: {e}"))?; + if v.get("items").is_none() { + let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("không rõ lỗi"); + if msg.to_ascii_lowercase().contains("rate limit") { + bail!("GitHub API đang giới hạn tốc độ (rate limit, 10 req/phút khi chưa đăng nhập) — đợi ~1 phút rồi chạy lại"); + } + bail!("GitHub API từ chối yêu cầu: {}", msg.chars().take(200).collect::()); + } + Ok(serde_json::from_value::(v).map_err(|e| anyhow!("GitHub API trả về dữ liệu không hợp lệ: {e}"))?.items) +} + +fn truncate(s: &str, max: usize) -> String { + let one: String = s.split_whitespace().collect::>().join(" "); + if one.chars().count() > max { + format!("{}…", one.chars().take(max).collect::()) + } else { + one + } +} + +/// `owner/repo` made only of GitHub-legal characters; anything else is dropped as untrusted. +pub(crate) fn valid_full_name(n: &str) -> bool { + let ok = |s: &str| !s.is_empty() && s.len() <= 100 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + matches!(n.split_once('/'), Some((o, r)) if ok(o) && ok(r)) +} + +/// A table cell: no pipes, no link/image/HTML syntax, no control characters (the file is read by an agent). +fn cell(s: &str) -> String { + let flat: String = s + .chars() + .filter(|c| !c.is_control() && !matches!(c, '[' | ']' | '(' | ')' | '<' | '>' | '!' | '`' | '\\')) + .map(|c| if c == '|' { '¦' } else { c }) + .collect(); + // No URLs in free text either: renderers auto-link them and the agent would follow. + flat.split_whitespace().filter(|w| !w.contains("://") && !w.starts_with("www.")).collect::>().join(" ") +} + +/// The only link we emit: GitHub's own URL when it is plain, else one rebuilt from the validated name. +fn safe_url(r: &Repo) -> String { + let plain = r.html_url.starts_with("https://github.com/") && r.html_url.chars().all(|c| c.is_ascii_alphanumeric() || "/-_.:".contains(c)); + if plain { r.html_url.clone() } else { format!("https://github.com/{}", r.full_name) } +} + +pub fn render(items: &[Repo], query_desc: &str, date: &str) -> String { + let mut s = format!("# GitHub trending (xấp xỉ) — {date}\n\n"); + s.push_str(&format!( + "Repo tạo trong {query_desc}, ≥50 sao, sắp theo sao — API search chính thức, không phải trang trending.\n\n" + )); + s.push_str("| # | Repo | ⭐ | Licence | Mô tả | Có ích cho 8sync vì |\n|---|---|---|---|---|---|\n"); + for (i, r) in items.iter().filter(|r| valid_full_name(&r.full_name)).enumerate() { + let lic = cell(r.license.as_ref().and_then(|l| l.spdx_id.as_deref()).filter(|l| !l.is_empty()).unwrap_or("—")); + let d = cell(&truncate(r.description.as_deref().unwrap_or(""), 100)); + // The API answer is data: the only link is a github.com URL made of safe characters. + let url = safe_url(r); + s.push_str(&format!( + "| {} | [{}]({}) | {} | {} | {} | |\n", + i + 1, + r.full_name, + url, + r.stargazers_count, + lic, + d + )); + } + s.push_str("\n## Cách dùng: `8sync skill learn ` để chắt lọc thành skill (chỉ repo có licence).\n"); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> String { + let long = "x".repeat(200); + format!( + r#"{{"total_count":3,"items":[ + {{"full_name":"a/one","html_url":"https://github.com/a/one","stargazers_count":900,"description":"First","license":{{"spdx_id":"MIT"}},"language":"Rust"}}, + {{"full_name":"b/two","html_url":"https://github.com/b/two","stargazers_count":500,"description":null,"license":null,"language":null}}, + {{"full_name":"c/three","html_url":"https://github.com/c/three","stargazers_count":60,"description":"{long}","license":{{"spdx_id":"Apache-2.0"}},"language":"Go"}} +]}}"# + ) + } + + #[test] + fn learn_renders_fixture() { + let items = parse_response(fixture().as_bytes()).unwrap(); + let md = render(&items, "7 ngày qua", "2026-10-15"); + assert!(md.starts_with("# GitHub trending (xấp xỉ) — 2026-10-15")); + let rows = md.lines().filter(|l| l.starts_with("| ") && l.contains("github.com")).count(); + assert_eq!(rows, 3); + assert!(md.contains("| — |")); + let long_row = md.lines().find(|l| l.contains("c/three")).unwrap(); + assert!(long_row.contains("…")); + assert!(!long_row.contains(&"x".repeat(101))); + } + + #[test] + fn learn_render_only_links_github_urls() { + let mut items = parse_response(fixture().as_bytes()).unwrap(); + items[0].html_url = "javascript:alert(1)".into(); + items[1].html_url = "https://github.com/b/two)[x](https://evil".into(); + let md = render(&items, "7 ngày qua", "2026-10-15"); + assert!(!md.contains("javascript:") && !md.contains("evil"), "{md}"); + assert!(md.contains("[a/one](https://github.com/a/one)")); + } + + #[test] + fn learn_filters_noise() { + let mut items = parse_response(fixture().as_bytes()).unwrap(); + items[1].description = Some("NSFW pose pack".into()); + items.retain(|r| !is_noise(r)); + assert_eq!(items.iter().map(|r| r.full_name.as_str()).collect::>(), ["a/one", "c/three"]); + } + + #[test] + fn learn_render_neutralises_hostile_fields() { + let mut items = parse_response(fixture().as_bytes()).unwrap(); + items[0].description = Some("see [here](http://evil.test) ![i](http://x) `rm` \u{1b}[31m| x".into()); + items[0].license = Some(License { spdx_id: Some("[MIT](http://evil.test)".into()) }); + items[1].full_name = "bad name/with space](http://evil.test)".into(); + items[2].full_name = "../../etc/passwd".into(); + let full = render(&items, "7 ngày qua", "2026-01-01"); + let md: String = full.lines().filter(|l| l.starts_with("| ")).collect::>().join("\n"); + assert!(!md.contains("evil.test"), "{md}"); + assert!(!md.contains("2023-12-25+stars:>50+language:c%2B%2B+x&sort=stars&order=desc&per_page=25" + ); + assert!(!query_url(None, 7, 25, 19_723).contains("language")); + } + + #[test] + fn learn_rate_limit_message() { + let e = parse_response(br#"{"message":"API rate limit exceeded for 1.2.3.4"}"#).unwrap_err(); + assert!(e.to_string().contains("rate limit")); + } +} diff --git a/crates/cli/src/verbs/mod.rs b/crates/cli/src/verbs/mod.rs index 184c5017..a3b09f48 100644 --- a/crates/cli/src/verbs/mod.rs +++ b/crates/cli/src/verbs/mod.rs @@ -34,6 +34,7 @@ pub mod gen_img; pub mod codex_web; pub mod forge; pub mod ccc; +pub mod learn; pub mod watchdog; pub mod uuid_sessions; pub mod session_view; diff --git a/crates/cli/src/verbs/skill/deploy.rs b/crates/cli/src/verbs/skill/deploy.rs index 7e5f4d41..32d58216 100644 --- a/crates/cli/src/verbs/skill/deploy.rs +++ b/crates/cli/src/verbs/skill/deploy.rs @@ -1985,8 +1985,6 @@ mod bundled_tests { #[cfg(test)] mod command_prefix_tests { - use super::*; - #[test] fn prefixing_is_idempotent() { assert_eq!(crate::brand::cmd_file("auto.md"), "sx-auto.md"); diff --git a/crates/cli/src/verbs/skill/learn.rs b/crates/cli/src/verbs/skill/learn.rs new file mode 100644 index 00000000..fdb5cb44 --- /dev/null +++ b/crates/cli/src/verbs/skill/learn.rs @@ -0,0 +1,578 @@ +//! `8sync skill learn [--name n] [--force]` — +//! distil a public repo's README into a token-lean LOCAL skill: +//! `~/.claude/skills//{SKILL.md, refs/README.md, refs/LICENSE}`. +//! The clone's `.git` is deleted; nothing is registered or committed. +use anyhow::{anyhow, Result}; +use std::path::{Path, PathBuf}; + +use super::spec::{git_clone_at, github_owner_repo, resolve_head_sha}; +use crate::{env_detect, ui}; + +const BUDGET: usize = 6144; + +#[derive(Debug, PartialEq)] +pub(crate) enum Licence { + Allowed(String), + Refused(String), +} + +/// Classify a licence text by its first 40 lines. +pub(crate) fn classify_licence(text: &str) -> Licence { + let head: String = text.lines().take(40).collect::>().join("\n").to_lowercase(); + let has = |s: &str| head.contains(s); + let ref_nc = |why: &str| Licence::Refused(format!("licence cấm dùng thương mại/dẫn xuất ({why}); xin phép tác giả hoặc dùng --force để chỉ lưu ghi chú 1 trang do bạn tự viết")); + let ok = |id: &str| Licence::Allowed(id.to_string()); + // Source-available / restricted texts embed OSI wording ("Commons Clause" + MIT, SSPL quotes the AGPL): deny first. + for (needle, id) in [ + ("commons clause", "Commons Clause"), + ("server side public license", "SSPL"), + ("business source license", "BSL"), + ("elastic license", "Elastic License"), + ("polyform", "PolyForm"), + ("sell the software", "no-sell clause"), + ] { + if has(needle) { + return Licence::Refused(format!("licence {id} hạn chế tái phân phối; xin phép tác giả hoặc dùng --force để chỉ lưu ghi chú 1 trang do bạn tự viết")); + } + } + if has("noncommercial") || has("non-commercial") { return ref_nc("NC"); } + if has("noderivatives") || has("no derivatives") { return ref_nc("ND"); } + if has("lesser general public") { return ok("LGPL (copyleft; đừng chép code vào sản phẩm)"); } + if has("affero general public") { return ok("AGPL (copyleft; đừng chép code vào sản phẩm)"); } + if has("gnu general public license") { return ok("GPL (copyleft; đừng chép code vào sản phẩm)"); } + if has("mozilla public license") { return ok("MPL-2.0"); } + if has("apache license") { return ok("Apache-2.0"); } + if has("attribution-sharealike 4.0") { return ok("CC BY-SA 4.0"); } + if has("attribution 4.0 international") { return ok("CC BY 4.0"); } + if has("cc0") || has("creative commons zero") { return ok("CC0"); } + if has("unlicense") || has("free and unencumbered software") { return ok("Unlicense"); } + if has("permission is hereby granted, free of charge") { return ok("MIT"); } + if has("permission to use, copy, modify, and/or distribute") { return ok("ISC"); } + if has("redistribution and use in source and binary forms") { + return ok(if has("neither the name") { "BSD-3-Clause" } else { "BSD-2-Clause" }); + } + if has("all rights reserved") { + return Licence::Refused("all rights reserved; xin phép tác giả hoặc dùng --force để chỉ lưu ghi chú 1 trang do bạn tự viết".into()); + } + Licence::Refused("không nhận diện được licence; xin phép tác giả hoặc dùng --force để chỉ lưu ghi chú 1 trang do bạn tự viết".into()) +} + +/// Largest README / LICENSE we read (a distilled index needs no more; keeps hostile repos from filling memory). +const MAX_SRC_BYTES: u64 = 2 * 1024 * 1024; + +fn read_capped(p: &Path) -> Result { + let len = std::fs::metadata(p)?.len(); + if len > MAX_SRC_BYTES { + return Err(anyhow!("{} quá lớn ({len} bytes > {MAX_SRC_BYTES})", p.display())); + } + Ok(std::fs::read_to_string(p)?) +} + +fn find_root_file(dir: &Path, names: &[&str]) -> Option { + for e in std::fs::read_dir(dir).ok()?.flatten() { + let f = e.file_name().to_string_lossy().to_lowercase(); + // `file_type()` does not follow symlinks: a hostile repo must not pull ~/.ssh/… into a skill. + let regular = e.file_type().map(|t| t.is_file()).unwrap_or(false); + if names.contains(&f.as_str()) && regular { + return Some(e.path()); + } + } + None +} + +/// Remove HTML tags, images, link targets and anchors; collapse whitespace. +fn clean(s: &str) -> String { + let mut out = String::new(); + let mut in_tag = false; + for c in s.chars() { + match c { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + // images ![alt](url) → dropped; links [t](url) → t + let ch: Vec = out.chars().collect(); + let mut r = String::new(); + let mut i = 0; + let mut no_close = false; // once no `]` remains, stop scanning for links + while i < ch.len() { + let img = ch[i] == '!' && ch.get(i + 1) == Some(&'['); + if !no_close && (img || ch[i] == '[') { + let st = if img { i + 1 } else { i }; + let close = (st..ch.len()).find(|&j| ch[j] == ']'); + if close.is_none() { + no_close = true; + } + if let Some(cb) = close { + if ch.get(cb + 1) == Some(&'(') { + if let Some(cp) = (cb + 2..ch.len()).find(|&j| ch[j] == ')') { + if !img { + r.extend(&ch[st + 1..cb]); + } + i = cp + 1; + continue; + } + } + } + } + r.push(ch[i]); + i += 1; + } + r.split_whitespace().collect::>().join(" ") +} + +fn trunc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let t: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", t.trim_end()) +} + +/// Real sentences, not a "English ∙ 日本語 ∙ …" language bar or a row of badges: mostly ASCII +/// letters, at least four words, no `∙`/`|` separators. +fn is_prose(p: &str) -> bool { + let letters = p.chars().filter(|c| c.is_ascii_alphabetic()).count(); + let total = p.chars().filter(|c| !c.is_whitespace()).count().max(1); + p.split_whitespace().count() >= 4 && letters * 2 >= total && !p.contains('∙') && !p.contains(" | ") +} + +fn first_sentence(s: &str, max: usize) -> String { + let cut = s.find(". ").map(|i| &s[..=i]).unwrap_or(s); + trunc(cut, max) +} + +fn skip_heading(h: &str) -> bool { + let l = h.to_lowercase(); + ["contribut", "license", "licence", "credit", "contact", "acknowledg", "table of contents"] + .iter() + .any(|k| l.contains(k)) +} + +/// Deterministic distilled SKILL.md (≤ 6144 bytes). +pub(crate) fn distil(readme: &str, name: &str, source: &str, licence: &str, sha: Option<&str>) -> String { + let lines: Vec<&str> = readme.trim_start_matches('\u{feff}').lines().collect(); + // Description: first paragraph that is not a heading/badge/code. + let mut desc = String::new(); + let mut in_code = false; + let mut para = String::new(); + for l in lines.iter().chain(std::iter::once(&"")) { + if l.trim_start().starts_with("```") { + in_code = !in_code; + continue; + } + if in_code { + continue; + } + if l.trim().is_empty() { + if !para.is_empty() { + if is_prose(¶) { + break; + } + para.clear(); // a language bar / badge row / separator line is not a description + } + continue; + } + if l.trim_start().starts_with('#') { + para.clear(); + continue; + } + let c = clean(l); + if !c.is_empty() { + if !para.is_empty() { + para.push(' '); + } + para.push_str(&c); + } + } + if !para.is_empty() { + desc = para; + } + let desc: String = desc + .chars() + .filter(|c| !matches!(c, '#' | '[' | ']' | '"' | '\\' | '`')) + .collect(); + let desc = trunc(&desc, 200); + let short_src: String = github_owner_repo(source) + .map(|(o, r)| format!("{o}/{r}")) + .unwrap_or_else(|| source.to_string()) + .chars() + .filter(|c| !matches!(c, '"' | '\\' | '\n' | '\r')) + .take(120) + .collect(); + let source = trunc(&source.replace(['\n', '\r'], " "), 160); + let source = source.as_str(); + let lic_id = licence.split(" (").next().unwrap_or(licence); + let sha7: String = sha.map(|s| s.chars().take(7).collect()).unwrap_or_else(|| "HEAD".into()); + + let mut out = format!( + "---\nname: {name}\ndescription: \"{desc} (nguồn: {short_src}, {lic_id})\"\n---\n\n# {name}\n\n\ + Source: {source}@{sha7} · Licence: {licence} · bản gốc: refs/README.md, refs/LICENSE\n\n\ + Nội dung dưới đây là bản mục lục distil từ {source} (© tác giả gốc, giấy phép {lic_id}); \ + khi trích dẫn phải ghi nguồn và giữ attribution.\n\n## Mục lục\n\n" + ); + let usage = "\n## Cách dùng\n\n\ + - Chỉ đọc phần cần: `Read refs/README.md` với offset/limit theo số dòng ở mục lục.\n\ + - Trích dẫn ngắn, không dán nguyên văn; luôn ghi nguồn và giấy phép.\n\ + - Mục lục chỉ là chỉ dẫn; sự thật nằm ở refs/README.md.\n"; + + // Collect headings (## / ###) outside code fences. + let mut entries: Vec = Vec::new(); + let mut in_code = false; + for (i, l) in lines.iter().enumerate() { + let t = l.trim_start(); + if t.starts_with("```") || t.starts_with("~~~") { + in_code = !in_code; + continue; + } + if in_code || !(t.starts_with("## ") || t.starts_with("### ")) { + continue; + } + let h = clean(t.trim_start_matches('#')); + if h.is_empty() || skip_heading(&h) { + continue; + } + let mut summary = String::new(); + let mut code = false; + for nl in &lines[i + 1..] { + let nt = nl.trim_start(); + if nt.starts_with("```") { + code = !code; + continue; + } + if code { + continue; + } + if nt.starts_with('#') { + break; + } + let c = clean(nt.trim_start_matches(['-', '*', '>', ' '])); + if !c.is_empty() { + summary = first_sentence(&c, 110); + break; + } + } + let mut e = format!("- **{}** (refs/README.md:{})", trunc(&h, 60), i + 1); + if !summary.is_empty() { + e.push_str(" — "); + e.push_str(&summary); + } + e.push('\n'); + entries.push(e); + } + let total = entries.len(); + let tail_reserve = 80; // "- … (còn N mục, xem refs/README.md)\n" + slack + let mut added = 0; + for e in &entries { + if out.len() + e.len() + usage.len() + tail_reserve > BUDGET { + break; + } + out.push_str(e); + added += 1; + } + if added < total { + out.push_str(&format!("- … (còn {} mục, xem refs/README.md)\n", total - added)); + } + out.push_str(usage); + // Hard ceiling regardless of header length (char boundary). + if out.len() > BUDGET { + let mut cut = BUDGET; + while !out.is_char_boundary(cut) { + cut -= 1; + } + out.truncate(cut); + } + out +} + +fn valid_name(n: &str) -> bool { + const RESERVED: [&str; 22] = [ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", + "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", + ]; + !n.is_empty() + && n.len() <= 64 + && n.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && n.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-')) + && !RESERVED.contains(&n) +} + +fn kebab(s: &str) -> String { + let mut o = String::new(); + for c in s.to_lowercase().chars() { + if c.is_ascii_alphanumeric() { o.push(c) } else if !o.ends_with('-') { o.push('-') } + } + o.trim_matches('-').to_string() +} + +/// Core: returns (skill_dir, work_dir). Errors on licence refusal without --force. +pub(crate) fn learn_into(home: &Path, spec: &str, name: Option<&str>, force: bool) -> Result<(PathBuf, PathBuf)> { + if spec.starts_with('-') || spec.is_empty() { + return Err(anyhow!("spec không hợp lệ: `{spec}` (dùng owner/repo, URL GitHub hoặc path:/dir)")); + } + let local: Option = match spec.strip_prefix("path:") { + Some(p) => Some(PathBuf::from(p)), + None if Path::new(spec).is_dir() => Some(PathBuf::from(spec)), + None => None, + }; + let (url, default_name) = if let Some(p) = &local { + let n = p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + (p.display().to_string(), n) + } else if let Some((o, r)) = github_owner_repo(spec) { + (format!("https://github.com/{o}/{r}"), r) + } else if spec.matches('/').count() == 1 && !spec.contains(':') && !spec.starts_with('/') { + let (o, r) = spec.split_once('/').unwrap(); + (format!("https://github.com/{o}/{r}"), r.to_string()) + } else if ["https://", "ssh://", "git://", "file://"].iter().any(|p| spec.starts_with(p)) { + // Only real transport URLs: a spec like `--upload-pack=…` or `ext::…` would otherwise reach `git clone` as an option / command. + let last = spec.trim_end_matches('/').trim_end_matches(".git").rsplit('/').next().unwrap_or(""); + (spec.to_string(), last.to_string()) + } else { + return Err(anyhow!("spec không hợp lệ: `{spec}` (dùng owner/repo, URL GitHub hoặc path:/dir)")); + }; + let name = match name { + Some(n) => n.to_string(), + None => kebab(&default_name), + }; + if !valid_name(&name) { + return Err(anyhow!("tên skill `{name}` không hợp lệ (chỉ [a-z0-9-], bắt đầu bằng chữ/số, ≤64 ký tự, không trùng tên thiết bị Windows)")); + } + let target = home.join(".claude/skills").join(&name); + if target.exists() && !force { + return Err(anyhow!("{} đã tồn tại; dùng --force để ghi đè", target.display())); + } + + let work = home.join(".cache/8sync/skill-learn").join(&name); + let mut sha = None; + let root: PathBuf = if let Some(p) = local { + p + } else { + if work.exists() { + std::fs::remove_dir_all(&work)?; + } + std::fs::create_dir_all(work.parent().unwrap())?; + git_clone_at(&url, &work, None)?; + sha = resolve_head_sha(&work); + let _ = std::fs::remove_dir_all(work.join(".git")); + work.clone() + }; + + let lic_file = find_root_file(&root, &["license", "license.md", "license.txt", "copying"]); + let lic_text = lic_file.as_ref().and_then(|p| read_capped(p).ok()); + let verdict = match &lic_text { + Some(t) => classify_licence(t), + None => Licence::Refused("không có licence = all rights reserved; xin phép tác giả hoặc dùng --force để chỉ lưu ghi chú 1 trang do bạn tự viết".into()), + }; + let licence = match verdict { + Licence::Allowed(id) => id, + Licence::Refused(why) => { + if !force { + return Err(anyhow!("từ chối: {why}")); + } + ui::warn(&format!("--force: KHÔNG chép README ({why}); chỉ ghi stub trỏ tới URL")); + std::fs::create_dir_all(&target)?; + let stub = format!( + "---\nname: {name}\ndescription: \"Ghi chú tự viết về {url} (licence chưa cho phép chép nội dung)\"\n---\n\n# {name}\n\nSource: {url}\n\nChưa chép nội dung vì licence: {why}\nTự viết ghi chú của bạn ở đây.\n" + ); + std::fs::write(target.join("SKILL.md"), stub)?; + ui::ok(&format!("stub → {} — local only — không commit vào repo", target.display())); + return Ok((target, work)); + } + }; + + let readme_p = find_root_file(&root, &["readme.md", "readme", "readme.markdown", "readme.rst", "readme.txt"]) + .ok_or_else(|| anyhow!("không thấy README ở root repo"))?; + let readme = read_capped(&readme_p)?; + let skill = distil(&readme, &name, &url, &licence, sha.as_deref()); + + if target.exists() { + std::fs::remove_dir_all(&target)?; + } + std::fs::create_dir_all(target.join("refs"))?; + std::fs::write(target.join("refs/README.md"), &readme)?; + std::fs::write(target.join("refs/LICENSE"), lic_text.unwrap_or_default())?; + std::fs::write(target.join("SKILL.md"), &skill)?; + ui::ok(&format!( + "{} ({} bytes, README gốc {} bytes) — local only — không commit vào repo", + target.display(), + skill.len(), + readme.len() + )); + Ok((target, work)) +} + +pub(crate) fn run(env: &env_detect::Env, args: &[String]) -> Result<()> { + let force = args.iter().any(|a| a == "--force" || a == "-f"); + let mut name: Option<&str> = None; + let mut spec: Option<&str> = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + if a == "--name" { + name = it.next().map(|s| s.as_str()).filter(|s| !s.starts_with('-')); + } else if !a.starts_with('-') && spec.is_none() { + spec = Some(a); + } + } + let Some(spec) = spec else { + return Err(anyhow!("usage: 8sync skill learn [--name ] [--force]")); + }; + learn_into(&env.home, spec, name, force).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn fixture() -> String { + let mut s = String::from("# Title\n\n

\n\n[![Build](https://x/y.svg)](https://x)\n\nA [great](http://a.b) primer #1 about things. More text.\n\n"); + for i in 0..40 { + s.push_str(&format!("## Section {i} \n\nThis is the first sentence of section {i}. And a second one that is long enough to pad the file out a bit more for the test.\n\n```\n## not a heading {i}\n```\n\n", )); + for _ in 0..3 { + s.push_str("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n"); + } + s.push('\n'); + } + s.push_str("## License\n\nCC BY 4.0\n"); + s + } + + #[test] + fn skill_learn_description_skips_language_bar() { + let readme = "# Primer\n\n*English ∙ 日本語 ∙ 简体中文 ∙ tiếng Việt*\n\n[![badge](x.svg)](y)\n\nLearn how to design large-scale systems. Prep for the interview.\n\n## Motivation\n\nText.\n"; + let out = distil(readme, "primer", "https://github.com/a/primer", "CC BY 4.0", None); + let desc = out.lines().find(|l| l.starts_with("description:")).unwrap(); + assert!(desc.contains("Learn how to design large-scale systems"), "{desc}"); + assert!(!desc.contains('∙'), "{desc}"); + assert!(is_prose("Learn how to design large-scale systems.") && !is_prose("*English ∙ 日本語*")); + } + + #[test] + fn skill_learn_licence_denylist_beats_allowlist() { + let commons = "MIT License with Commons Clause\n\nPermission is hereby granted, free of charge, to any person\n"; + assert!(matches!(classify_licence(commons), Licence::Refused(w) if w.contains("Commons Clause"))); + let sspl = "Server Side Public License, VERSION 1\n\nThis License is based on the GNU Affero General Public License\n"; + assert!(matches!(classify_licence(sspl), Licence::Refused(w) if w.contains("SSPL"))); + let bsl = "Business Source License 1.1\n"; + assert!(matches!(classify_licence(bsl), Licence::Refused(_))); + assert!(matches!(classify_licence("MIT License\n\nPermission is hereby granted, free of charge\n"), Licence::Allowed(id) if id == "MIT")); + } + + #[test] + fn skill_learn_name_rules() { + for ok in ["a", "sdp", "system-design-primer", "x1-2"] { + assert!(valid_name(ok), "{ok}"); + } + for bad in ["", "-force", "--force", "Con", "con", "nul", "com1", "a_b", "a b", &"x".repeat(65)] { + assert!(!valid_name(bad), "{bad}"); + } + } + + #[cfg(unix)] + #[test] + fn skill_learn_ignores_symlinked_readme_and_licence() { + let tmp = std::env::temp_dir().join(format!("8sync-skill-learn-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let outside = tmp.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret.txt"), "MIT License\nPermission is hereby granted, free of charge\n").unwrap(); + let repo = tmp.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + std::os::unix::fs::symlink(outside.join("secret.txt"), repo.join("LICENSE")).unwrap(); + std::os::unix::fs::symlink(outside.join("secret.txt"), repo.join("README.md")).unwrap(); + assert!(find_root_file(&repo, &["license"]).is_none()); + assert!(find_root_file(&repo, &["readme.md"]).is_none()); + let err = learn_into(&tmp.join("home"), &format!("path:{}", repo.display()), Some("sym"), false).unwrap_err(); + assert!(err.to_string().contains("không có licence"), "{err}"); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn skill_learn_distil_hard_budget_and_pathological_brackets() { + let long_src = format!("https://example.com/{}", "a".repeat(5000)); + let readme = "# T\n\nA plain description paragraph for this repo here.\n\n## One\n\nx\n"; + let out = distil(readme, "t", &long_src, "MIT", None); + assert!(out.len() <= BUDGET); + let brackets = "[".repeat(20_000); + let t = std::time::Instant::now(); + let c = clean(&brackets); + assert_eq!(c.len(), 20_000); + assert!(t.elapsed() < std::time::Duration::from_secs(2)); + } + + #[test] + fn skill_learn_rejects_option_like_specs() { + let home = std::env::temp_dir().join(format!("8sync-skill-learn-spec-{}", std::process::id())); + for bad in ["--upload-pack=touch /tmp/x", "-c", "ext::sh -c id", "", "owner/repo/extra:bad"] { + assert!(learn_into(&home, bad, None, false).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn skill_learn_distil_fits_budget() { + let r = fixture(); + assert!(r.len() > 12_000, "{}", r.len()); + let o = distil(&r, "demo", "https://github.com/o/demo", "CC BY 4.0", Some("abcdef123456")); + assert!(o.len() <= BUDGET, "{}", o.len()); + assert!(o.starts_with("---\nname: demo\n")); + assert!(o.contains("refs/README.md:")); + assert!(o.contains("@abcdef1")); + assert!(!o.contains("not a heading")); + assert!(!o.contains("License**")); + let d = o.lines().find(|l| l.starts_with("description:")).unwrap(); + assert!(!d.contains('#') && !d.contains('['), "{d}"); + assert!(d.contains("nguồn: o/demo")); + assert_eq!(o, distil(&r, "demo", "https://github.com/o/demo", "CC BY 4.0", Some("abcdef123456"))); + } + + #[test] + fn skill_learn_licence_gate() { + let mit = "MIT License\n\nCopyright (c) 2020 X\nAll rights reserved\n\nPermission is hereby granted, free of charge, to any person"; + assert_eq!(classify_licence(mit), Licence::Allowed("MIT".into())); + let by = "Attribution 4.0 International\n\n=====\nCreative Commons Corporation"; + assert_eq!(classify_licence(by), Licence::Allowed("CC BY 4.0".into())); + let nc = "Attribution-NonCommercial 4.0 International\n"; + assert!(matches!(classify_licence(nc), Licence::Refused(_))); + assert!(matches!(classify_licence("Copyright me. All rights reserved."), Licence::Refused(_))); + let tmp = std::env::temp_dir().join(format!("skilllearn-none-{}", std::process::id())); + let repo = tmp.join("nolic"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write(repo.join("README.md"), "# x\n\nhello\n").unwrap(); + let home = tmp.join("home"); + let r = learn_into(&home, repo.to_str().unwrap(), None, false); + assert!(r.is_err()); + assert!(!home.join(".claude/skills/nolic").exists()); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn skill_learn_local_repo_drops_git() { + if Command::new("git").arg("--version").output().is_err() { + return; + } + let tmp = std::env::temp_dir().join(format!("skilllearn-git-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let repo = tmp.join("fixture-repo"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write(repo.join("README.md"), fixture()).unwrap(); + std::fs::write(repo.join("LICENSE"), "MIT License\n\nPermission is hereby granted, free of charge, to any person\n").unwrap(); + let g = |args: &[&str]| { + Command::new("git").arg("-C").arg(&repo).args(["-c", "user.name=t", "-c", "user.email=t@t"]).args(args).output().unwrap().status.success() + }; + assert!(g(&["init", "-q"]) && g(&["add", "."]) && g(&["commit", "-q", "-m", "i"])); + let home = tmp.join("home"); + let p = repo.display().to_string().replace('\\', "/"); + let url = if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{p}") }; + let (skill, work) = learn_into(&home, &url, None, false).unwrap(); + assert!(!work.join(".git").exists()); + assert!(work.join("README.md").exists()); + let md = std::fs::metadata(skill.join("SKILL.md")).unwrap(); + assert!(md.len() as usize <= BUDGET); + assert!(skill.join("refs/README.md").exists() && skill.join("refs/LICENSE").exists()); + assert!(learn_into(&home, &url, None, false).is_err(), "no overwrite without --force"); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/crates/cli/src/verbs/skill/mod.rs b/crates/cli/src/verbs/skill/mod.rs index 1170982a..d8ebf721 100644 --- a/crates/cli/src/verbs/skill/mod.rs +++ b/crates/cli/src/verbs/skill/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod discover; pub(crate) mod gen; pub(crate) mod index; pub(crate) mod inject; +pub(crate) mod learn; pub(crate) mod list; pub(crate) mod meta; pub(crate) mod spec; @@ -39,6 +40,7 @@ pub(crate) use inject::inject_agents_md; 8sync skill new command ship-notes same, bare word; add --force to overwrite 8sync skill gen 1 2 FUSE local skill #1 and #2 into one combined SKILL.md 8sync skill gen karpathy-guidelines codegraph same, but by name + 8sync skill learn donnemartin/system-design-primer DISTIL a repo README (≤6 KB SKILL.md + refs/, .git dropped) into ~/.claude/skills/ — local only 8sync skill update [name] re-pull registered skills from their source (git/builtin/path) NOTE @@ -78,6 +80,7 @@ pub fn run(a: Args) -> Result<()> { let spec = a.args.iter().find(|s| !s.starts_with('-')).map(|s| s.as_str()); add::add_skill(&env, &skills_toml, spec, force) } + Some("learn") => learn::run(&env, &a.args), Some("new") => create::run_new(&env, &a.args), Some("gen") => gen::gen_skill(&env, &a.args), Some("update") => update::update_skills(&env, &skills_toml, a.args.first().map(|s| s.as_str())), diff --git a/scripts/uat/README.md b/scripts/uat/README.md index 3a3cae52..33be1987 100644 --- a/scripts/uat/README.md +++ b/scripts/uat/README.md @@ -5,6 +5,15 @@ PASS/FAIL per assertion and exits non-zero on failure. No API key is read or set owner's existing subscription login. Nothing here touches the owner's `~/.claude/settings.json`, `~/.local/bin/8sync` or `~/.config/8sync`. Not run in CI (they spend real tokens / need network). +## hook-latency.sh — how long the kit hooks keep Claude waiting (no model, free) + +`bash scripts/uat/hook-latency.sh [8sync] [runs]`: sandbox HOME, each hook fed the JSON Claude Code would send, +median of 7 runs against a budget — guard, wake, watchdog, resume-note ≤ 20 ms; `verify` with a file outside any +project (its no-op path) ≤ 50 ms. The real verify check (tsc/cargo) runs with `asyncRewake` and never blocks the +model. Measured 2026-10-15 on Linux (v0.90.0 + this branch): every hook median 3 ms, PASS; the Orca per-event shell +hook on the same machine ≈ 5 ms. Timing uses bash 5 `EPOCHREALTIME` with a `date +%s%N` fallback — not verified on +macOS `/bin/bash` 3.2 (run it with Homebrew bash) or Windows. + ## no-idle-hook.sh — "không ai rảnh" ladder and rails (no model, free) `bash scripts/uat/no-idle-hook.sh target/debug/8sync`: sandbox HOME + a fake team task list; checks diff --git a/scripts/uat/hook-latency.sh b/scripts/uat/hook-latency.sh new file mode 100755 index 00000000..a21fc084 --- /dev/null +++ b/scripts/uat/hook-latency.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# UAT (no model, free): how long each kit hook keeps Claude Code waiting. The hooks run on every +# prompt / tool call, so they must stay far below the model's own latency; omp/forge have no such +# hooks, and this is where ccc pays. Budget (median of N runs, sandbox HOME): guard, wake, watchdog, +# resume-note <= 20 ms; verify with a file outside any project (its no-op path) <= 50 ms. The real +# verify check (tsc/cargo) runs with asyncRewake and never blocks the model: see ccc/verify.rs. +# Usage: scripts/uat/hook-latency.sh [path/to/8sync] [runs] Exit 0 = every hook within budget. +# HOOK_BUDGET_SCALE=3 relaxes every budget (loaded CI runner); the numbers printed are the real medians. +set -uo pipefail +B="${1:-8sync}"; N="${2:-7}"; SCALE="${HOOK_BUDGET_SCALE:-1}" +command -v "$B" >/dev/null || [ -x "$B" ] || { echo "missing $B"; exit 2; } +T=$(mktemp -d -t uat-hook-lat.XXXXXX); trap 'rm -rf "$T"' EXIT +H="$T/home"; mkdir -p "$H" +# Microseconds. bash >= 5 has EPOCHREALTIME (decimal separator follows the locale: "." or ","); +# GNU date has %N; macOS bash 3.2 + BSD date have neither, so fall back to python3 / perl. +now_us() { + if [ -n "${EPOCHREALTIME-}" ]; then echo "${EPOCHREALTIME/[.,]/}"; return; fi + local n; n=$(date +%s%N 2>/dev/null) + case "$n" in "" | *[!0-9]*) ;; *) echo $(( n / 1000 )); return ;; esac + if command -v python3 >/dev/null 2>&1; then python3 -c 'import time; print(int(time.time() * 1e6))' + else perl -MTime::HiRes=time -e 'printf "%d\n", time * 1e6'; fi +} +fail=0 +# bench