diff --git a/CHANGELOG.md b/CHANGELOG.md index 20604ff1..0b97de84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh ## [Unreleased] +## [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`. +- **`8sync ccc delegate on --after 1m`.** The critic answers a product/technical question only when the owner has typed nothing in that session for the given time; while the owner is active the question still reaches them. Owner-only topics (Ship, secrets, destructive, money, deploy/merge/release, high-risk) are unchanged. A hook cannot answer a question already on screen, so "no reply for 1 minute" is approximated by "no typing for 1 minute"; an unreadable transcript means the owner is asked. + +- **`8sync ccc hook wake`** (Rust, UserPromptSubmit, no model call): a scheduled HQ prompt (marked `[8sync:wake]`) is dropped before it reaches Claude — zero tokens — when the session has an `in_progress` task, ran a scheduled prompt < 4 min ago (queued fires collapse), or, on a subscription login, the owner has typed nothing in that session for 30 min (unattended use needs a Console API key). Claude Code runs UserPromptSubmit hooks on cron and `/loop` fires too (https://code.claude.com/docs/en/hooks). Idea after ProactiveAgent's decision engine + minimum interval (github.com/leomariga/ProactiveAgent, BSD-3). + +### Changed +- HQ tab "Chat nhóm" is now **"Chat tổng"**: every room (session) shares one chat. + +### Fixed +- `8sync ccc . goal --help` / `-h` printed nothing and stored "--help" as the goal; it now prints usage and leaves the registry alone, and an unknown `-flag` is an error. + ## [0.89.1] — 2026-10-15 ### Changed - Release size ceiling 6400 → 6592 KiB, attributed in `scripts/size-gate.sh` (v0.89.0 linux-x86_64 was 6,616,688 B, 63,088 B over; +69,664 B vs v0.88.0). v0.89.0 was tagged but not published; 0.89.1 ships everything listed under 0.89.0. diff --git a/Cargo.lock b/Cargo.lock index 76ed9159..b83364da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ dependencies = [ [[package]] name = "ccc-core" -version = "0.89.1" +version = "0.90.0" dependencies = [ "anyhow", "serde", @@ -1398,7 +1398,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "su-code" -version = "0.89.1" +version = "0.90.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2bb9feb1..16f519d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/ccc-core"] [workspace.package] -version = "0.89.1" +version = "0.90.0" edition = "2021" license = "MIT" authors = ["8sync-dev <8sync.dev.1111@gmail.com>"] diff --git a/apps/hq-ui/app/api/tasks/route.ts b/apps/hq-ui/app/api/tasks/route.ts index a1ffe178..c61e5254 100644 --- a/apps/hq-ui/app/api/tasks/route.ts +++ b/apps/hq-ui/app/api/tasks/route.ts @@ -8,6 +8,7 @@ import { capturePlain, listTmux, sendKeys } from "@/lib/server/proc"; import { isSessionId, queueInbox } from "@/lib/server/inbox"; import { ownerMessage, tasksFor } from "@/lib/server/queries"; import { clampText, isTmuxName, validateProjectPath } from "@/lib/server/validate"; +import { parseSchedule, scheduleLabel, scheduledPrompt } from "@/lib/schedule"; export const runtime = "nodejs"; @@ -41,12 +42,17 @@ export async function POST(req: Request) { if (!hasSession && !role) return bad("session or role is required", 422); const prio = typeof body.prio === "string" && body.prio ? body.prio.toUpperCase() : null; if (prio && !PRIOS.includes(prio)) return bad("prio must be one of P0-P3", 422); + const sched = parseSchedule(body.schedule); + if (!sched.ok) return bad(`schedule: ${sched.error}`, 422); const { db } = o; let session = hasSession ? (body.session as string) : ""; let started = false; const extra = [role && `role: ${role}`, prio && `prio: ${prio}`].filter(Boolean).join(", "); - const text = `New task: ${subject.value}${extra ? ` (${extra})` : ""}${details?.ok ? `\n${details.value}` : ""}`; + const plain = `New task: ${subject.value}${extra ? ` (${extra})` : ""}${details?.ok ? `\n${details.value}` : ""}`; + // Scheduled: the session books it with its own CronCreate; the task row says when. + const text = sched.value ? scheduledPrompt(plain, sched.value) : plain; + const title = sched.value ? `${subject.value} · ⏰ ${scheduleLabel(sched.value)} (nhờ phiên lên lịch)` : subject.value; if (!hasSession) { const picked = await pickByRole(db, role as string); if (picked) session = picked; @@ -63,7 +69,7 @@ export async function POST(req: Request) { const taskId = `owner:${stamp()}`; db.prepare( "INSERT INTO tasks(session, task_id, subject, role, prio, status, updated_at) VALUES (?, ?, ?, ?, ?, 'todo', ?)", - ).run(session, taskId, subject.value, role, prio, stamp()); + ).run(session, taskId, title, role, prio, stamp()); const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(taskId); queueInbox(session, text); const message = ownerMessage(db, session, text, "inbox"); @@ -75,7 +81,7 @@ export async function POST(req: Request) { const taskId = `owner:${stamp()}`; db.prepare( "INSERT INTO tasks(session, task_id, subject, role, prio, status, updated_at) VALUES (?, ?, ?, ?, ?, 'todo', ?)", - ).run(session, taskId, subject.value, role, prio, stamp()); + ).run(session, taskId, title, role, prio, stamp()); const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(taskId); if (started) return json({ task, session, started, message: null, delivery: "first-prompt", error: null }, 201); let r: Awaited>; diff --git a/apps/hq-ui/components/hq/assign-dialog.tsx b/apps/hq-ui/components/hq/assign-dialog.tsx index 803469be..8e5f56b2 100644 --- a/apps/hq-ui/components/hq/assign-dialog.tsx +++ b/apps/hq-ui/components/hq/assign-dialog.tsx @@ -9,11 +9,20 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ROLES, modelShort, type RoleId } from "@/lib/roles"; +import { EVERY_HOURS, EVERY_MINUTES, RD_DUTY, scheduleLabel, type Schedule } from "@/lib/schedule"; import { addressOf, messageTargets, sessionLabel } from "@/lib/labels"; import type { Hq } from "./use-hq"; import type { Message, Project, Session, Task } from "./types"; const PRIOS = ["P0", "P1", "P2", "P3"] as const; +/** "Khi nào": now, once at HH:MM, daily at HH:MM, or every N minutes/hours (`m:30`, `h:2`). */ +const WHEN_EVERY = [...EVERY_MINUTES.map((n) => `m:${n}`), ...EVERY_HOURS.map((n) => `h:${n}`)]; + +function toSchedule(when: string, at: string): Schedule | null { + if (when === "once" || when === "daily") return { kind: when, at }; + if (when.startsWith("m:") || when.startsWith("h:")) return { kind: "every", n: Number(when.slice(2)), unit: when[0] as "m" | "h" }; + return null; +} type Target = `role:${string}` | `session:${string}`; export type AssignDialogProps = { @@ -41,6 +50,8 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau const [details, setDetails] = useState(""); const [target, setTarget] = useState(defaultSession ? `session:${defaultSession}` : `role:${defaultRole ?? "fullstack"}`); const [prio, setPrio] = useState("P2"); + const [when, setWhen] = useState("now"); + const [at, setAt] = useState("09:03"); const [projectId, setProjectId] = useState( String(usable.find((p) => p.id === defaultProjectId)?.id ?? usable[0]?.id ?? ""), ); @@ -58,7 +69,8 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau setError(null); try { const [kind, value] = [target.split(":")[0], target.slice(target.indexOf(":") + 1)]; - const body: Record = { subject: subject.trim(), details: details.trim() || undefined, prio }; + const schedule = toSchedule(when, at); + const body: Record = { subject: subject.trim(), details: details.trim() || undefined, prio, schedule: schedule ?? undefined }; if (kind === "session") body.session = value; else { body.role = value; @@ -71,10 +83,12 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau else { const s = sessions.find((x) => x.name === r.session || x.session_id === r.session); const who = s ? sessionLabel(s) : r.session.slice(0, 8); - toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}`); + const plan = schedule ? ` · đã nhờ phiên lên lịch ${scheduleLabel(schedule)} (chưa xác nhận: xem câu trả lời của phiên)` : ""; + toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}${plan}`); } setSubject(""); setDetails(""); + setWhen("now"); onOpenChange(false); } catch (err) { // Shown inside the dialog: a toast behind the modal is easy to miss. @@ -92,6 +106,20 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau Giao việc Chọn một vai trò (dùng phiên đang chạy hoặc mở phiên mới) hoặc một phiên cụ thể. +
setSubject(e.target.value)} maxLength={300} required autoFocus /> @@ -153,6 +181,34 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, defau
+
+ +
+ + {(when === "once" || when === "daily") && ( + setAt(e.target.value)} required className="w-32" /> + )} +
+ {when !== "now" && ( +

+ Phiên tự lên lịch bằng CronCreate của Claude Code, theo giờ của máy chạy phiên: chạy giữa các lượt khi phiên còn mở, lịch lặp trễ tối đa nửa chu kỳ và tự hết hạn sau 7 ngày, giờ đã qua thì chạy ngày mai. Lượt bị bỏ khi phiên đang bận hoặc (login subscription) bạn vắng quá 30 phút. +

+ )} +
{error && (

{error} diff --git a/apps/hq-ui/components/hq/guide.tsx b/apps/hq-ui/components/hq/guide.tsx index 137d02db..a75b85d5 100644 --- a/apps/hq-ui/components/hq/guide.tsx +++ b/apps/hq-ui/components/hq/guide.tsx @@ -45,8 +45,8 @@ export const GUIDE_STEPS: GuideStep[] = [ }, { icon: MessagesSquareIcon, - title: "Chat nhóm", - body: "Xem ai nhắn ai theo thời gian. Khi một người chuyển việc cho người khác, bạn sẽ thấy thẻ bàn giao (handoff) \"A → B\".", + title: "Chat tổng", + body: "Mọi phòng (session) chung một cuộc chat: xem ai nhắn ai theo thời gian. Khi một người chuyển việc cho người khác, bạn sẽ thấy thẻ bàn giao (handoff) \"A → B\".", }, { icon: BellRingIcon, diff --git a/apps/hq-ui/components/hq/hq-app.tsx b/apps/hq-ui/components/hq/hq-app.tsx index fcaf435b..27fef720 100644 --- a/apps/hq-ui/components/hq/hq-app.tsx +++ b/apps/hq-ui/components/hq/hq-app.tsx @@ -53,7 +53,7 @@ export function HqApp({ token }: { token: string }) { }, []); // Everything below the header is filtered to the selected project or session. - // `group` ignores the session scope on purpose: Chat nhóm is every room talking. + // `group` ignores the session scope on purpose: Chat tổng is every room talking. const scopeOf = useCallback( (session: string | null) => { const all = hq.sessions.data; @@ -184,7 +184,7 @@ export function HqApp({ token }: { token: string }) { Văn phòng - Chat nhóm + Chat tổng Chat phòng Board @@ -288,7 +288,7 @@ function RoomPicker({ sessions, onPick }: { sessions: Session[]; onPick: (s: Ses

Chọn phòng để chat

- Chat phòng chỉ gồm tin của một session và team của nó. Tin giữa mọi phòng nằm ở Chat nhóm. + Chat phòng chỉ gồm tin của một session và team của nó. Tin giữa mọi phòng nằm ở Chat tổng.

{sessions.length === 0 ? (

Chưa có session nào đang chạy.

diff --git a/apps/hq-ui/lib/schedule.ts b/apps/hq-ui/lib/schedule.ts new file mode 100644 index 00000000..32727146 --- /dev/null +++ b/apps/hq-ui/lib/schedule.ts @@ -0,0 +1,78 @@ +/** + * "Giao việc theo lịch": HQ does not run its own timer. It hands the session one prompt asking it to + * schedule the task with Claude Code's native `CronCreate` (https://code.claude.com/docs/en/scheduled-tasks): + * the session's own scheduler fires it between turns, in local time, recurring tasks expire after 7 days + * (a bound Claude Code sets on purpose; HQ does not work around it), and the owner sees and cancels + * the job like any other (`CronList` / `CronDelete`, or Esc on a /loop). + */ +export type Schedule = + | { kind: "once"; at: string } + | { kind: "daily"; at: string } + | { kind: "every"; n: number; unit: "m" | "h" }; + +/** Steps that divide the hour / day evenly, so the cron step is exact (docs: other steps get rounded). */ +export const EVERY_MINUTES = [5, 10, 15, 20, 30] as const; +export const EVERY_HOURS = [1, 2, 3, 4, 6, 8, 12] as const; + +const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** Validate `body.schedule` from the API: `null`/absent = deliver now. */ +export function parseSchedule(v: unknown): { ok: true; value: Schedule | null } | { ok: false; error: string } { + if (v == null || v === "") return { ok: true, value: null }; + if (typeof v !== "object") return { ok: false, error: "must be an object" }; + const o = v as Record; + if (o.kind === "once" || o.kind === "daily") { + if (typeof o.at !== "string" || !HHMM.test(o.at)) return { ok: false, error: "at must be HH:MM (24h)" }; + return { ok: true, value: { kind: o.kind, at: o.at } }; + } + if (o.kind === "every") { + const n = Number(o.n); + if (o.unit === "m" && (EVERY_MINUTES as readonly number[]).includes(n)) return { ok: true, value: { kind: "every", n, unit: "m" } }; + if (o.unit === "h" && (EVERY_HOURS as readonly number[]).includes(n)) return { ok: true, value: { kind: "every", n, unit: "h" } }; + return { ok: false, error: `every: n phút ∈ ${EVERY_MINUTES.join(",")} hoặc n giờ ∈ ${EVERY_HOURS.join(",")}` }; + } + return { ok: false, error: "kind must be once, daily or every" }; +} + +/** 5-field cron in the session's local time, and whether it recurs. */ +export function cronOf(s: Schedule): { cron: string; recurring: boolean } { + if (s.kind === "every") { + // Minute 7 for hourly steps: off the hour, so it does not stack with other :00 jobs. + return s.unit === "m" ? { cron: `*/${s.n} * * * *`, recurring: true } : { cron: `7 ${s.n === 1 ? "*" : `*/${s.n}`} * * *`, recurring: true }; + } + const [h, m] = s.at.split(":").map(Number); + return { cron: `${m} ${h} * * *`, recurring: s.kind === "daily" }; +} + +/** Short Vietnamese label for the task list and toasts. */ +export function scheduleLabel(s: Schedule): string { + if (s.kind === "once") return `lúc ${s.at}`; + if (s.kind === "daily") return `hằng ngày ${s.at}`; + // Recurring runs get jitter (docs: up to half the gap, ≤30 min late; only */5 is exact). + return `${s.n === 5 && s.unit === "m" ? "" : "≈"}mỗi ${s.n} ${s.unit === "m" ? "phút" : "giờ"}`; +} + +/** Lets `8sync ccc hook wake` (UserPromptSubmit) skip a fire while the session is busy, at zero tokens. */ +export const WAKE_MARK = "[8sync:wake]"; + +/** The prompt HQ delivers instead of the task itself. */ +export function scheduledPrompt(task: string, s: Schedule): string { + const { cron, recurring } = cronOf(s); + return [ + `Lên lịch, KHÔNG làm ngay: gọi CronCreate với cron "${cron}" (giờ máy local), recurring: ${recurring}, prompt là nguyên văn phần dưới dòng ---. Xong trả lời một dòng: job id + lần chạy kế (${scheduleLabel(s)}).`, + recurring ? "Lịch lặp tự hết hạn sau 7 ngày (giới hạn của Claude Code); không tự tạo lại, owner sẽ giao lại nếu cần." : "Một lần, tự xoá sau khi chạy.", + "Giữ nguyên dấu [8sync:wake] ở đầu prompt (hook của kit dùng nó để bỏ qua lượt khi phiên đang bận). Tới giờ mà việc không còn ý nghĩa: nói một dòng lý do rồi bỏ qua lượt đó.", + "---", + `${WAKE_MARK} ${task}`, + ].join("\n"); +} + +/** Preset "R&D trực": the R&D room keeps researching when it has nothing assigned. */ +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.", + schedule: { kind: "every", n: 30, unit: "m" } as const satisfies Schedule, +}; diff --git a/apps/hq-ui/tests/assign.test.ts b/apps/hq-ui/tests/assign.test.ts index 6e3a579a..c682385e 100644 --- a/apps/hq-ui/tests/assign.test.ts +++ b/apps/hq-ui/tests/assign.test.ts @@ -90,6 +90,30 @@ describe("assign by role", () => { }); }); +describe("scheduled assign", () => { + it("delivers a CronCreate booking prompt and labels the task row", async () => { + const res = await post({ subject: "check CI", session: "hq-abc", schedule: { kind: "every", n: 30, unit: "m" } }); + expect(res.status).toBe(201); + expect(m.sendKeys).toHaveBeenCalledWith("hq-abc", expect.stringContaining('CronCreate với cron "*/30 * * * *"')); + expect(m.sendKeys).toHaveBeenCalledWith("hq-abc", expect.stringContaining("New task: check CI")); + expect(m.sendKeys).toHaveBeenCalledWith("hq-abc", expect.stringContaining("[8sync:wake] New task: check CI")); + expect(getDb().prepare("SELECT subject FROM tasks WHERE session = 'hq-abc'").get()).toEqual({ subject: "check CI · ⏰ ≈mỗi 30 phút (nhờ phiên lên lịch)" }); + }); + it("a fresh role session gets the booking prompt as its first prompt", async () => { + m.live = []; + const res = await post({ subject: "nightly audit", role: "rd", project: pid, schedule: { kind: "daily", at: "02:13" } }); + const j = await res.json(); + expect(j).toMatchObject({ started: true, delivery: "first-prompt" }); + expect(m.startTmux).toHaveBeenCalledWith(j.session, expect.any(String), expect.objectContaining({ prompt: expect.stringContaining('cron "13 2 * * *"') })); + }); + it("rejects a bad schedule before storing anything", async () => { + expect((await post({ subject: "x", session: "hq-abc", schedule: { kind: "once", at: "25:00" } })).status).toBe(422); + expect((await post({ subject: "x", session: "hq-abc", schedule: { kind: "every", n: 7, unit: "m" } })).status).toBe(422); + expect(m.sendKeys).not.toHaveBeenCalled(); + expect(getDb().prepare("SELECT COUNT(*) AS n FROM tasks").get()).toEqual({ n: 0 }); + }); +}); + describe("validation and auth", () => { it("rejects bad role, bad model, missing target, bad session", async () => { expect((await post({ subject: "x", role: "wizard", project: pid })).status).toBe(422); diff --git a/apps/hq-ui/tests/schedule.test.ts b/apps/hq-ui/tests/schedule.test.ts new file mode 100644 index 00000000..0fb186f9 --- /dev/null +++ b/apps/hq-ui/tests/schedule.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { cronOf, parseSchedule, RD_DUTY, scheduleLabel, scheduledPrompt } from "@/lib/schedule"; + +describe("schedule", () => { + it("parses the four shapes and rejects the rest", () => { + expect(parseSchedule(undefined)).toEqual({ ok: true, value: null }); + expect(parseSchedule("")).toEqual({ ok: true, value: null }); + expect(parseSchedule({ kind: "once", at: "15:42" })).toEqual({ ok: true, value: { kind: "once", at: "15:42" } }); + expect(parseSchedule({ kind: "daily", at: "09:03" }).ok).toBe(true); + expect(parseSchedule({ kind: "every", n: "30", unit: "m" })).toEqual({ ok: true, value: { kind: "every", n: 30, unit: "m" } }); + for (const bad of [{ kind: "once", at: "24:00" }, { kind: "once", at: "9:3" }, { kind: "every", n: 7, unit: "m" }, { kind: "every", n: 5, unit: "h" }, { kind: "every", n: 1, unit: "s" }, { kind: "cron", cron: "* * * * *" }, "x", { kind: "every", n: 30.5, unit: "m" }, { kind: "every", n: null, unit: "m" }]) { + expect(parseSchedule(bad).ok, JSON.stringify(bad)).toBe(false); + } + }); + it("maps to local cron, recurring only for daily/every", () => { + expect(cronOf({ kind: "once", at: "15:42" })).toEqual({ cron: "42 15 * * *", recurring: false }); + expect(cronOf({ kind: "daily", at: "09:03" })).toEqual({ cron: "3 9 * * *", recurring: true }); + expect(cronOf({ kind: "every", n: 15, unit: "m" })).toEqual({ cron: "*/15 * * * *", recurring: true }); + expect(cronOf({ kind: "every", n: 1, unit: "h" })).toEqual({ cron: "7 * * * *", recurring: true }); + expect(cronOf({ kind: "every", n: 6, unit: "h" })).toEqual({ cron: "7 */6 * * *", recurring: true }); + }); + it("prompt books CronCreate, keeps the 7-day bound and carries the task verbatim", () => { + const p = scheduledPrompt("New task: x\nline2", { kind: "every", n: 30, unit: "m" }); + expect(p).toContain('CronCreate với cron "*/30 * * * *"'); + expect(p).toContain("recurring: true"); + expect(p).toContain("7 ngày"); + expect(p).toContain("không tự tạo lại"); + expect(p.endsWith("---\n[8sync:wake] New task: x\nline2")).toBe(true); + expect(scheduledPrompt("t", { kind: "once", at: "08:05" })).toContain("recurring: false"); + expect(scheduleLabel({ kind: "every", n: 2, unit: "h" })).toBe("≈mỗi 2 giờ"); + 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" }); + }); +}); diff --git a/assets/claude/.claude-plugin/plugin.json b/assets/claude/.claude-plugin/plugin.json index 7ceb99e3..9507391c 100644 --- a/assets/claude/.claude-plugin/plugin.json +++ b/assets/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "8sync-kit", - "version": "0.89.1", + "version": "0.90.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/assets/claude/skills/sprint/SKILL.md b/assets/claude/skills/sprint/SKILL.md index cbcd9121..7c9f49dd 100644 --- a/assets/claude/skills/sprint/SKILL.md +++ b/assets/claude/skills/sprint/SKILL.md @@ -61,6 +61,7 @@ keep / drop / try → `/remember`. Sau đó chạy `8sync ccc . goal clear`. ## Rails - Effort scaling (Anthropic): việc đơn giản dùng một agent; so sánh dùng 2–4; multi-agent tốn khoảng 15× token, và code có ít việc thật sự song song hơn research. +- Phòng `rd` rảnh giữa các sprint: owner giao mẫu "R&D trực" trên HQ (CronCreate mỗi 30 phút, hết hạn sau 7 ngày). Hook `ccc hook wake` bỏ lượt khi phòng đang bận, vừa chạy, hoặc (login subscription) owner không gõ gì 30 phút. Owner đang làm việc khác mà không kịp trả lời: `8sync ccc delegate on --after 1m` (critic chọn; Ship/secret/tiền vẫn chờ owner). - Tối đa 4 writer cùng lúc. Watchdog báo áp lực thì không mở thêm agent. Build và test nặng chạy trên CI. - **Không người trông** (autopilot, `claude -p`, scheduled): chỉ dispatch subagent khi session chạy bằng API key Console và có `--max-budget-usd`; nếu không thì làm một agent, không dispatch. Login subscription chỉ dùng khi owner có mặt (`skills/aup-audit/8sync-anthropic-rules.md`). - Không in secret. Mọi câu "xong/pass" phải kèm lệnh + output; không có thì ghi "not verified". diff --git a/crates/cli/src/verbs/ccc.rs b/crates/cli/src/verbs/ccc.rs index 1b08c49e..43d45f28 100644 --- a/crates/cli/src/verbs/ccc.rs +++ b/crates/cli/src/verbs/ccc.rs @@ -41,6 +41,7 @@ mod taskdone; mod urgent; mod goal; mod standup; +mod wake; mod verify; #[derive(ClapArgs, Debug)] @@ -999,6 +1000,13 @@ fn apply_kit_settings(v: &mut Value, exe: &str, skills: &[String]) -> Result<()> "UserPromptSubmit", json!({ "hooks": [{ "type": "command", "command": exe, "args": ["ccc", "hook", "resume-note"], "timeout": 5 }] }), ); + // Scheduled HQ prompts (`[8sync:wake]`): skip a fire while the session is busy or just ran one, + // before it reaches Claude (UserPromptSubmit also fires on CronCreate / /loop prompts). + upsert_hook_group( + hooks, + "UserPromptSubmit", + json!({ "hooks": [{ "type": "command", "command": exe, "args": ["ccc", "hook", "wake"], "timeout": 5 }] }), + ); // Agent-team events -> `8sync hq hook `. Event names, `async` (background, never // blocks) and `timeout` (seconds) per https://code.claude.com/docs/en/hooks (checked // 2026-10-12). `hq hook` is a fast no-op until `8sync hq setup`, so always registering is safe. @@ -1776,13 +1784,20 @@ fn hook(rest: &[String]) -> Result<()> { } None } + Some("wake") => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + dirs::home_dir().filter(|h| h.is_absolute()).and_then(|h| { + let sub = login_kind_at(&h).kind == "subscription"; + wake::hook(&input, &h, now, sub) + }) + } Some("delegate") => dirs::home_dir().filter(|h| h.is_absolute()).and_then(|h| delegate::hook(&input, &h)), Some("session-start") => { let home = std::env::var_os("HOME").map(PathBuf::from); session_start_output(&input, home.as_deref()) } - _ => bail!("usage: 8sync ccc hook verify|verify-stop|guard|watchdog|session-start|stop-failure|resume-note|ship-grant|idle|task-done|delegate (run by the kit's Claude Code hooks)"), + _ => bail!("usage: 8sync ccc hook verify|verify-stop|guard|watchdog|session-start|stop-failure|resume-note|ship-grant|idle|task-done|delegate|wake (run by the kit's Claude Code hooks)"), }; if let Some(out) = out { println!("{out}"); @@ -2199,7 +2214,8 @@ mod tests { assert_eq!(args("StopFailure"), vec![json!(["ccc", "hook", "stop-failure"])]); let ups = args("UserPromptSubmit"); assert!(ups.contains(&json!(["ccc", "hook", "resume-note"])) && ups.contains(&json!(["ccc", "hook", "watchdog"])), "both coexist: {ups:?}"); - assert_eq!(ups.len(), 2); + assert!(ups.contains(&json!(["ccc", "hook", "wake"])), "scheduled-prompt gate: {ups:?}"); + assert_eq!(ups.len(), 3); strip_kit_hooks(&mut v); assert!(v["hooks"].get("StopFailure").is_none() && v["hooks"].get("UserPromptSubmit").is_none()); } diff --git a/crates/cli/src/verbs/ccc/delegate.rs b/crates/cli/src/verbs/ccc/delegate.rs index b3039e93..e05d010d 100644 --- a/crates/cli/src/verbs/ccc/delegate.rs +++ b/crates/cli/src/verbs/ccc/delegate.rs @@ -12,12 +12,22 @@ //! deploys, merges and releases, and high-risk decisions (legal, medical, hiring, credit). Those //! pass through untouched. OFF by default (`delegate = true` in features.toml or //! `EIGHTSYNC_DELEGATE=1`). +//! +//! `on --after ` ("owner treo"): the critic only steps in when no prompt typed by a human reached +//! this session in the last `s` seconds (`delegate_after_s`; 0 = always). A hook cannot answer a +//! question that is already on screen, so "the owner has not replied for 1 minute" is approximated +//! by "the owner has not typed here for 1 minute"; when the transcript cannot be read the owner is +//! asked. Limitation: answering an earlier question in a dialog is not counted as typing, so an owner +//! who only clicks answers looks away after `s` seconds (help text says so). use std::path::Path; use anyhow::{bail, Result}; use serde_json::{json, Value}; pub(super) const KEY: &str = "delegate"; +const AFTER_KEY: &str = "delegate_after_s"; +/// Longest "owner away" threshold accepted (1 day). +const AFTER_MAX: u64 = 86_400; const ENV: &str = "EIGHTSYNC_DELEGATE"; /// Words that keep a question with the owner (lowercase substrings, Vietnamese + English). @@ -72,6 +82,16 @@ pub(super) fn decide(tool_input: &Value) -> Option { ) } +fn after_s(home: &Path) -> u64 { + crate::verbs::harness::cbm::read_int_key(home, AFTER_KEY).map_or(0, |n| (n.max(0) as u64).min(AFTER_MAX)) +} + +/// Pure: with a threshold, the owner counts as away only when their last typed prompt is known and +/// at least `after` seconds old. +fn owner_away(after: u64, last_human: Option, now: u64) -> bool { + after == 0 || last_human.is_some_and(|t| now.saturating_sub(t) >= after) +} + /// `8sync ccc hook delegate`: PreToolUse JSON in, a deny decision out (or nothing). pub(super) fn hook(input: &str, home: &Path) -> Option { if !enabled(home) { @@ -81,6 +101,14 @@ pub(super) fn hook(input: &str, home: &Path) -> Option { if v["tool_name"].as_str() != Some("AskUserQuestion") { return None; } + let after = after_s(home); + if after > 0 { + let last = v["transcript_path"].as_str().and_then(|p| super::idle::last_human_ts(Path::new(p))); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + if !owner_away(after, last, now) { + return None; + } + } let reason = decide(&v["tool_input"])?; Some(json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason } }).to_string()) } @@ -89,9 +117,18 @@ pub(super) fn hook(input: &str, home: &Path) -> Option { pub(super) fn run(rest: &[String], home: &Path) -> Result<()> { match rest.first().map(String::as_str) { Some(x @ ("on" | "off")) => { + let after = match rest.get(1..).unwrap_or_default() { + [] => 0, + [f, n] if f == "--after" && x == "on" => parse_after(n)?, + _ => bail!("usage: 8sync ccc delegate on [--after ] | off | status"), + }; crate::verbs::harness::cbm::write_flag_key(home, KEY, x == "on")?; + crate::verbs::harness::cbm::write_key(home, AFTER_KEY, toml::Value::Integer(after as i64))?; if x == "on" { println!("delegate: BẬT — critic chọn thay owner cho câu hỏi sản phẩm/kỹ thuật, ghi su-code/DECISIONS.md."); + if after > 0 { + println!("Chỉ khi owner không gõ gì vào session đó trong {after} giây qua; đang gõ thì câu hỏi vẫn tới owner."); + } println!("Vẫn hỏi owner: Ship, secret/login, xoá/không hoàn tác, tiền, deploy prod, merge/release, high-risk."); println!("Cần hook + agent critic: `8sync ccc kit` rồi mở session mới."); } else { @@ -100,11 +137,26 @@ pub(super) fn run(rest: &[String], home: &Path) -> Result<()> { Ok(()) } Some("status") | None => { - println!("delegate: {}", if enabled(home) { "BẬT" } else { "TẮT (mặc định)" }); + let after = after_s(home); + let when = if after > 0 { format!(" — khi owner không gõ {after} giây") } else { String::new() }; + println!("delegate: {}", if enabled(home) { format!("BẬT{when}") } else { "TẮT (mặc định)".to_string() }); println!("Đổi: `8sync ccc delegate on` · `8sync ccc delegate off`"); Ok(()) } - Some(x) => bail!("usage: 8sync ccc delegate [on | off | status] (got `{x}`)"), + Some(x) => bail!("usage: 8sync ccc delegate [on [--after ] | off | status] (got `{x}`)"), + } +} + +/// `60`, `60s`, `1m`, `5m` → seconds, 1..=1 day. +fn parse_after(s: &str) -> Result { + let t = s.trim().to_ascii_lowercase(); + let (num, mul) = match t.strip_suffix('m') { + Some(n) => (n, 60), + None => (t.strip_suffix('s').unwrap_or(&t), 1), + }; + match num.parse::() { + Ok(n) if n > 0 && n.checked_mul(mul).is_some_and(|v| v <= AFTER_MAX) => Ok(n * mul), + _ => bail!("--after: số giây (vd 60) hoặc phút (vd 1m), tối đa 1 ngày (got `{s}`)"), } } @@ -146,6 +198,45 @@ mod tests { assert!(decide(&json!({})).is_none() && decide(&json!({ "questions": [] })).is_none()); } + #[test] + fn after_threshold_waits_for_the_owner() { + assert!(owner_away(0, None, 100), "no threshold = always"); + assert!(!owner_away(60, None, 100), "unknown presence = ask the owner"); + assert!(!owner_away(60, Some(50), 100), "typed 50 s ago = present"); + assert!(owner_away(60, Some(40), 100), "60 s without typing = away"); + assert_eq!(parse_after("60").unwrap(), 60); + assert_eq!(parse_after("1m").unwrap(), 60); + assert_eq!(parse_after("90s").unwrap(), 90); + for bad in ["0", "-1", "x", "2000m", "", "18446744073709551615m"] { + assert!(parse_after(bad).is_err(), "{bad}"); + } + } + + #[test] + fn after_mode_reads_the_transcript() { + let home = std::env::temp_dir().join(format!("8sync-dlg-after-{}", std::process::id())); + std::fs::create_dir_all(&home).unwrap(); + run(&["on".into(), "--after".into(), "1m".into()], &home).unwrap(); + assert_eq!(after_s(&home), 60); + let tp = home.join("t.jsonl"); + let line = |ts: &str| json!({ "type": "user", "timestamp": ts, "message": { "role": "user", "content": "làm tiếp" } }).to_string(); + let input = |tp: &Path| json!({ "tool_name": "AskUserQuestion", "transcript_path": tp, "tool_input": ask("UI", "Màu nút?", &["Lime", "Blue"]) }).to_string(); + std::fs::write(&tp, line("2020-01-01T00:00:00Z") + "\n").unwrap(); + if std::env::var(ENV).is_err() { + assert!(hook(&input(&tp), &home).is_some(), "owner silent for years → critic"); + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let now = super::super::standup::utc_stamp(secs).replace('Z', ":00Z"); + std::fs::write(&tp, line(&now) + "\n").unwrap(); + assert!(hook(&input(&tp), &home).is_none(), "owner just typed → ask the owner"); + assert!(hook(&input(&home.join("missing.jsonl")), &home).is_none(), "unreadable transcript → ask the owner"); + } + run(&["on".into()], &home).unwrap(); + assert_eq!(after_s(&home), 0, "plain `on` clears the threshold"); + assert!(run(&["off".into(), "--after".into(), "1m".into()], &home).is_err()); + assert!(run(&["on".into(), "--after".into()], &home).is_err(), "missing value"); + let _ = std::fs::remove_dir_all(&home); + } + #[test] fn hook_is_off_by_default_and_denies_only_ask_user_question() { let home = std::env::temp_dir().join(format!("8sync-dlg-{}", std::process::id())); diff --git a/crates/cli/src/verbs/ccc/goal.rs b/crates/cli/src/verbs/ccc/goal.rs index dcb15e06..c1055cd4 100644 --- a/crates/cli/src/verbs/ccc/goal.rs +++ b/crates/cli/src/verbs/ccc/goal.rs @@ -26,10 +26,12 @@ pub(super) fn run(store: &Store, args: &[String], env_id: Option<&str>, now: u64 Some(n) => session = Some(n.clone()), None => bail!("{usage}"), }, + "--help" | "-h" => return Ok(usage.to_string()), "--files" => match it.next() { Some(f) => files.extend(f.split(',').map(str::trim).filter(|p| !p.is_empty()).map(|p| clean(p, PATH_MAX))), None => bail!("{usage}"), }, + s if s.starts_with('-') && s.len() > 1 => bail!("unknown flag `{s}`\n{usage}"), s => words.push(s), } } @@ -120,6 +122,10 @@ mod tests { assert!(run(&st, &s(&["x", "--session", "ghost"]), None, 1).unwrap_err().to_string().contains("no session 'ghost'")); assert!(run(&st, &s(&["x"]), Some("not-ours"), 1).unwrap_err().to_string().contains("not a named")); assert!(run(&st, &s(&["x", "--files"]), None, 1).is_err()); + assert!(run(&st, &s(&["--help"]), None, 1).unwrap().starts_with("usage:"), "help prints usage"); + assert!(run(&st, &s(&["-h", "--session", "rd"]), None, 1).unwrap().starts_with("usage:")); + assert!(run(&st, &s(&["fix", "--force", "--session", "rd"]), None, 1).unwrap_err().to_string().contains("unknown flag")); + assert!(st.load().sessions[0].goal.is_none(), "help / bad flag leave the registry alone"); run(&st, &s(&["\u{1b}[2Jdọn", "màn", "--session", "rd"]), None, 1).unwrap(); assert_eq!(st.load().sessions[0].goal.as_ref().unwrap().text, "[2Jdọn màn"); let key = format!("deploy sk-ant-api03-{}", "A".repeat(40)); diff --git a/crates/cli/src/verbs/ccc/help.rs b/crates/cli/src/verbs/ccc/help.rs index 16d072a9..8cc7c390 100644 --- a/crates/cli/src/verbs/ccc/help.rs +++ b/crates/cli/src/verbs/ccc/help.rs @@ -48,7 +48,7 @@ TỰ ĐỘNG (gõ trong Claude Code) 8sync ccc hook ship-grant (hook) ghi giấy phép khi owner duyệt câu hỏi `Ship` 8sync ccc idle on [--hours 2] [--usd-per-hour 5] không-ai-rảnh cho agent team (mặc định TẮT; subscription = lease khi owner có mặt) 8sync ccc idle off | status [--json] (bật thêm gate TaskCompleted: task sửa code chưa test thì chưa cho xong) - 8sync ccc delegate on | off | status critic chọn thay owner câu hỏi sản phẩm/kỹ thuật (mặc định TẮT; Ship/secret/xoá/tiền vẫn hỏi) + 8sync ccc delegate on [--after 1m] | off | status critic chọn thay owner câu hỏi sản phẩm/kỹ thuật (mặc định TẮT; --after: chỉ khi owner không gõ prompt N giây — bấm trả lời dialog không tính; Ship/secret/xoá/tiền vẫn hỏi) XEM 8sync ccc . show [--json] tóm tắt một session @@ -56,7 +56,7 @@ XEM 8sync hq ui dashboard HQ trong trình duyệt AN TOÀN - 8sync ccc hook + 8sync ccc hook hook do kit đăng ký, không gõ tay 8sync ccc secret keys tên key trong file (không bao giờ in giá trị) 8sync ccc secret set (--from | --generate) --type prod,dev | --env production diff --git a/crates/cli/src/verbs/ccc/idle.rs b/crates/cli/src/verbs/ccc/idle.rs index 419d938e..b8e93ae2 100644 --- a/crates/cli/src/verbs/ccc/idle.rs +++ b/crates/cli/src/verbs/ccc/idle.rs @@ -33,7 +33,7 @@ const DEFAULT_HOURS: u64 = 2; const DEFAULT_USD_PER_HOUR: f64 = 5.0; /// Subscription: lease cap and "owner present" window (last prompt typed by a human). const SUB_MAX_HOURS: f64 = 4.0; -const PRESENT_S: u64 = 30 * 60; +pub(super) const PRESENT_S: u64 = 30 * 60; /// Another session's task list counts as "has work" only when touched this recently. const ROOM_FRESH_S: u64 = 2 * 3600; @@ -391,7 +391,7 @@ pub(super) fn gate(lease: Option<&Value>, subscription: bool, spent: f64, now: u } /// "2026-10-14T03:31:00.123Z" → unix seconds (UTC only, as Claude Code writes it). -fn iso_secs(s: &str) -> Option { +pub(super) fn iso_secs(s: &str) -> Option { let n = |r: std::ops::Range| s.get(r)?.parse::().ok(); let (y, mo, d, h, mi, se) = (n(0..4)?, n(5..7)?, n(8..10)?, n(11..13)?, n(14..16)?, n(17..19)?); // days from civil (Howard Hinnant) @@ -406,7 +406,7 @@ fn iso_secs(s: &str) -> Option { /// Time of the last prompt a human typed in a transcript (cross-session and teammate messages /// do not count), from its last 1 MiB. -fn last_human_ts(transcript: &Path) -> Option { +pub(super) fn last_human_ts(transcript: &Path) -> Option { use std::io::{Read, Seek, SeekFrom}; let mut f = std::fs::File::open(transcript).ok()?; let len = f.metadata().ok()?.len(); diff --git a/crates/cli/src/verbs/ccc/standup.rs b/crates/cli/src/verbs/ccc/standup.rs index b50a9bc6..447b6840 100644 --- a/crates/cli/src/verbs/ccc/standup.rs +++ b/crates/cli/src/verbs/ccc/standup.rs @@ -253,7 +253,7 @@ fn report_json(b: &Board, peers: &[Peer]) -> serde_json::Value { } /// `YYYY-MM-DDTHH:MMZ` from epoch seconds (UTC, proleptic Gregorian; days→civil by H. Hinnant). -fn utc_stamp(secs: u64) -> String { +pub(super) fn utc_stamp(secs: u64) -> String { let days = (secs / 86_400) as i64; let rem = secs % 86_400; let z = days + 719_468; diff --git a/crates/cli/src/verbs/ccc/wake.rs b/crates/cli/src/verbs/ccc/wake.rs new file mode 100644 index 00000000..821ba066 --- /dev/null +++ b/crates/cli/src/verbs/ccc/wake.rs @@ -0,0 +1,155 @@ +//! "Thức dậy có đáng không": `8sync ccc hook wake` (UserPromptSubmit, no model call) decides whether a +//! scheduled prompt runs, before it costs a single token. +//! +//! HQ's scheduled tasks ("Giao việc" with "Khi nào", the "R&D trực" preset) put [`MARK`] in the prompt +//! the session books with `CronCreate`. UserPromptSubmit hooks also fire when a scheduled task or a +//! `/loop` iteration fires, and `decision: "block"` "stops the prompt before it reaches Claude" +//! (https://code.claude.com/docs/en/hooks, checked 2026-10-15). Idea after ProactiveAgent's +//! decision engine + `min_response_interval` (github.com/leomariga/ProactiveAgent, BSD-3): +//! - **busy**: the session has an `in_progress` task → skip this fire (the work in hand comes first); +//! - **floor**: a marked prompt already ran here less than [`MIN_GAP_S`] ago → skip (fires that +//! queued up while the session was busy collapse into one, docs: "no catch-up for missed fires"). +//! +//! - **owner away on a subscription login**: no prompt typed by a human in this session for +//! [`super::idle::PRESENT_S`] → skip. Unattended automated use belongs on a Console API key +//! (Consumer Terms; `assets/skills/aup-audit/8sync-anthropic-rules.md`), same rail as `ccc idle`. +//! Marked prompts never count as the owner, whatever origin Claude Code gives a cron fire. +//! +//! Prompts without the mark (typed by the owner, other loops) are never touched. +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +/// Put in the scheduled prompt by HQ (`apps/hq-ui/lib/schedule.ts`). +pub(super) const MARK: &str = "[8sync:wake]"; +/// Shortest gap between two marked runs in one session (just under the 5-minute minimum step). +const MIN_GAP_S: u64 = 4 * 60; + +#[derive(Debug, PartialEq)] +pub(super) enum Verdict { + Run, + Skip(String), +} + +/// Pure decision from the facts the hook gathered. `owner_seen` is only checked on a subscription +/// login (`None` there = owner away). +pub(super) fn decide(subscription: bool, owner_seen: Option, busy_with: Option<&str>, last_run: Option, now: u64) -> Verdict { + if subscription && !owner_seen.is_some_and(|t| now.saturating_sub(t) <= super::idle::PRESENT_S) { + return Verdict::Skip(format!( + "owner vắng (không gõ gì trong session này {} phút qua, login subscription); chạy không người trông cần Console API key", + super::idle::PRESENT_S / 60 + )); + } + if let Some(t) = busy_with { + return Verdict::Skip(format!("đang làm việc dở \"{}\"", crate::verbs::session_view::truncate(t, 60))); + } + match last_run.map(|t| now.saturating_sub(t)) { + Some(ago) if ago < MIN_GAP_S => Verdict::Skip(format!("vừa chạy lượt lịch {ago} giây trước")), + _ => Verdict::Run, + } +} + +fn valid_sid(s: &str) -> bool { + !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +fn claude_dir(home: &Path) -> PathBuf { + std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from).unwrap_or_else(|| home.join(".claude")) +} + +fn stamp_path(home: &Path, sid: &str) -> PathBuf { + crate::brand::config_dir(home).join("wake").join(sid) +} + +/// Last prompt typed by a human in a transcript (last 1 MiB), ignoring marked scheduled prompts. +fn owner_seen(transcript: &Path) -> Option { + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(transcript).ok()?; + let len = f.metadata().ok()?.len(); + f.seek(SeekFrom::Start(len.saturating_sub(1 << 20))).ok()?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf).ok()?; + String::from_utf8_lossy(&buf) + .lines() + .rev() + .filter(|l| super::gate::human_text(l).is_some_and(|t| !t.contains(MARK))) + .find_map(|l| serde_json::from_str::(l).ok()?["timestamp"].as_str().and_then(super::idle::iso_secs)) +} + +/// `8sync ccc hook wake`: UserPromptSubmit JSON in, a block decision out (or nothing = run). +pub(super) fn hook(input: &str, home: &Path, now: u64, subscription: bool) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + if !v["prompt"].as_str().is_some_and(|p| p.contains(MARK)) { + return None; + } + let sid = v["session_id"].as_str().filter(|s| valid_sid(s))?; + let tasks = super::idle::load_tasks(&claude_dir(home).join("tasks").join(sid)); + let busy = tasks.iter().find(|t| t.status == "in_progress").map(|t| t.subject.as_str()); + let stamp = stamp_path(home, sid); + let last = std::fs::read_to_string(&stamp).ok().and_then(|s| s.trim().parse::().ok()); + let seen = if subscription { v["transcript_path"].as_str().and_then(|p| owner_seen(Path::new(p))) } else { None }; + match decide(subscription, seen, busy, last, now) { + Verdict::Run => { + if let Some(d) = stamp.parent() { + let _ = std::fs::create_dir_all(d); + } + let _ = std::fs::write(&stamp, now.to_string()); + None + } + Verdict::Skip(why) => Some( + json!({ "decision": "block", "reason": format!("8sync wake: bỏ qua lượt lịch này — {why}. Lịch vẫn giữ, lượt sau chạy lại."), "suppressOriginalPrompt": true }) + .to_string(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decides_busy_then_floor_then_run() { + assert_eq!(decide(false, None, None, None, 1000), Verdict::Run); + assert!(matches!(decide(false, None, Some("ship T7"), None, 1000), Verdict::Skip(w) if w.contains("ship T7"))); + assert!(matches!(decide(false, None, None, Some(900), 1000), Verdict::Skip(w) if w.contains("100 giây"))); + assert_eq!(decide(false, None, None, Some(1000 - MIN_GAP_S), 1000), Verdict::Run); + } + + #[test] + fn subscription_runs_only_while_the_owner_is_present() { + let now = 100_000; + assert!(matches!(decide(true, None, None, None, now), Verdict::Skip(w) if w.contains("Console API key"))); + assert!(matches!(decide(true, Some(now - super::super::idle::PRESENT_S - 1), None, None, now), Verdict::Skip(_))); + assert_eq!(decide(true, Some(now - 60), None, None, now), Verdict::Run); + let d = std::env::temp_dir().join(format!("8sync-wake-tp-{}-{}", std::process::id(), crate::verbs::uuid_sessions::new_uuid())); + std::fs::create_dir_all(&d).unwrap(); + let tp = d.join("t.jsonl"); + let line = |ts: &str, text: &str| json!({ "type": "user", "timestamp": ts, "message": { "role": "user", "content": text } }).to_string(); + std::fs::write(&tp, [line("2026-10-15T01:00:00Z", "làm T7"), line("2026-10-15T05:00:00Z", &format!("{MARK} R&D trực"))].join("\n")).unwrap(); + assert_eq!(owner_seen(&tp), super::super::idle::iso_secs("2026-10-15T01:00:00Z"), "a cron fire is not the owner"); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn hook_only_touches_marked_prompts_and_records_runs() { + let home = std::env::temp_dir().join(format!("8sync-wake-{}-{}", std::process::id(), crate::verbs::uuid_sessions::new_uuid())); + std::fs::create_dir_all(&home).unwrap(); + let input = |p: &str, sid: &str| json!({ "hook_event_name": "UserPromptSubmit", "session_id": sid, "prompt": p }).to_string(); + let sid = "0f1e2d3c-aaaa-bbbb-cccc-123456789abc"; + if std::env::var_os("CLAUDE_CONFIG_DIR").is_none() { + assert!(hook(&input("làm tiếp", sid), &home, 100, false).is_none(), "unmarked prompt untouched"); + let p = format!("{MARK} New task: research"); + assert!(hook(&input(&p, sid), &home, 100, false).is_none(), "first fire runs"); + let out: Value = serde_json::from_str(&hook(&input(&p, sid), &home, 160, false).unwrap()).unwrap(); + assert_eq!(out["decision"], "block"); + assert!(hook(&input(&p, sid), &home, 100 + MIN_GAP_S, false).is_none(), "after the floor it runs again"); + let td = home.join(".claude/tasks").join(sid); + std::fs::create_dir_all(&td).unwrap(); + std::fs::write(td.join("1.json"), json!({ "id": "1", "subject": "T7 chips", "status": "in_progress" }).to_string()).unwrap(); + let out: Value = serde_json::from_str(&hook(&input(&p, sid), &home, 10_000, false).unwrap()).unwrap(); + assert!(out["reason"].as_str().unwrap().contains("T7 chips")); + assert!(hook(&input(&p, "../etc"), &home, 20_000, false).is_none(), "bad session id = no-op"); + } + let _ = std::fs::remove_dir_all(&home); + } +} diff --git a/crates/cli/src/verbs/harness/cbm.rs b/crates/cli/src/verbs/harness/cbm.rs index 4e5da95a..81e2933c 100644 --- a/crates/cli/src/verbs/harness/cbm.rs +++ b/crates/cli/src/verbs/harness/cbm.rs @@ -41,19 +41,29 @@ pub(crate) fn flag_enabled(home: &Path, key: &str, env_var: &str) -> bool { resolve(std::env::var(env_var).ok().as_deref(), read_flag_key(home, key)) } +pub(crate) fn read_int_key(home: &Path, key: &str) -> Option { + let s = std::fs::read_to_string(features_path(home)).ok()?; + s.parse::().ok()?.get(key)?.as_integer() +} + pub(crate) fn read_flag_key(home: &Path, key: &str) -> Option { let s = std::fs::read_to_string(features_path(home)).ok()?; s.parse::().ok()?.get(key)?.as_bool() } pub(crate) fn write_flag_key(home: &Path, key: &str, on: bool) -> Result<()> { + write_key(home, key, toml::Value::Boolean(on)) +} + +/// Any value in `features.toml`, keeping the owner's other keys. +pub(crate) fn write_key(home: &Path, key: &str, value: toml::Value) -> Result<()> { let p = features_path(home); let mut t = match std::fs::read_to_string(&p) { // never replace a file we cannot parse: it may hold the owner's other keys Ok(s) => s.parse::().map_err(|e| anyhow::anyhow!("{} is not valid TOML ({e}); fix it first", p.display()))?, Err(_) => toml::Table::new(), }; - t.insert(key.into(), toml::Value::Boolean(on)); + t.insert(key.into(), value); if let Some(d) = p.parent() { std::fs::create_dir_all(d)?; }