diff --git a/.gitignore b/.gitignore index f67e01a..4b6430f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,15 @@ node_modules/ __pycache__/ promo/ -# Hatch local workspace runtime (the .hatch SSOT + kb/ are committed) -/.hatch/board/ -/.hatch/bus/ -/.hatch/ledger/ -/.hatch/compiled/ -/.hatch/mcp/ -/.hatch/logs/ +# Hatch: only the SSOT under .hatch is committed; everything else (run/, and any +# stray runtime from an old build: bus/, logs/, …) is ignored via this whitelist. +/.hatch/* +!/.hatch/charter.md +!/.hatch/working-agreement.md +!/.hatch/registry.yaml +!/.hatch/workflow.yaml +!/.hatch/roles/ +!/.hatch/context/ +!/.hatch/kb/ +!/.hatch/protocol/ +!/.hatch/templates/ diff --git a/.hatch/charter.md b/.hatch/charter.md index 3f4c59e..099dbcc 100644 --- a/.hatch/charter.md +++ b/.hatch/charter.md @@ -11,17 +11,30 @@ agent qua một **MCP server**. CLI Go single-binary; dùng được với Claud Codex, Antigravity (`agy`), Kiro. ## Nguyên tắc tối cao -- **Embedded, không điều khiển**: Hatch KHÔNG spawn/lái agent. `board`/`chat`/ - `status` chỉ là view read-only. Quy trình là **protocol compile thành prose** - (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. +- **Mô phỏng team người**: workspace là một "căn phòng" — agent **join**, thấy + nhau qua **roster** (`roster`/`join`), hiểu cùng context, và **chat trực tiếp + peer-to-peer** để phối hợp. Chat là kênh chính; **task = plan/docs/note** + (artifact lập kế hoạch), KHÔNG phải kênh điều phối. +- **Tách delivery vs orchestration**: Hatch KHÔNG điều phối *công việc* (không + assign/lane/lock — không sếp-phần-mềm). Nhưng CÓ **delivery + wake**: `hatch + daemon` giao @mention tới đúng teammate và đánh thức (resume) đúng phiên có + trí nhớ của họ. Wake **luôn là hệ quả một message ai đó gửi**, không tự sinh. +- **Sếp là người = user**: user đặt mục tiêu/ưu tiên/duyệt. GĐ1 nói qua một + team-leader (role `conductor`, delegate của boss); GĐ2 vào thẳng qua chat UI. +- **Embedded, không điều khiển công việc**: Hatch không quyết ai-làm-gì. + `board`/`chat`/`status`/`roster` là view read-only. Quy trình là **protocol + compile thành prose** (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. - **SSOT → compile**: sửa `.hatch/{charter,roles,registry,workflow}` rồi `hatch compile`; không sửa file output. - **Minimum code, surgical**: giải đúng vấn đề, không abstraction thừa. Lean Hexagonal (model / port / adapter). - Mỗi thay đổi có dấu vết (ledger) + lý do (`why`); không sửa ngoài scope ticket. +> **Working Agreement** (cách làm việc chuyên nghiệp, ownership cao) sống ở +> `.hatch/working-agreement.md` — nạp cùng charter ở tầng L0. + ## Ràng buộc -- Go 1.24+. Build sạch **cả hai**: mặc định và `-tags hatch_legacy`. +- Go 1.24+. Một binary chat-based duy nhất (engine điều phối legacy đã gỡ bỏ). - **Không sửa file ngoài repo**: config home của Codex/agy do `hatch setup` lo; `hatch doctor` chỉ gọi lệnh auth của CLI, KHÔNG quét thư mục creds. -- `make lint && make test` (+ legacy) phải pass trước khi push; `go mod tidy` nếu đổi deps. +- `make lint && make test` phải pass trước khi push; `go mod tidy` nếu đổi deps. diff --git a/.hatch/protocol/claim-lock.md b/.hatch/protocol/claim-lock.md deleted file mode 100644 index abff59b..0000000 --- a/.hatch/protocol/claim-lock.md +++ /dev/null @@ -1,6 +0,0 @@ -# Claim & Lock - -- **Claim** = set `assignee` + `claim` trong frontmatter + `git mv` ticket vào `in-progress/`, rồi **push ngay**. -- **Push thắng = lock thắng.** Nếu push bị từ chối (người khác claim trước), rebase và chọn ticket khác. -- Không claim ticket còn `depends_on` chưa `done`. -- Tôn trọng WIP limit của agent (registry `wip`). diff --git a/.hatch/roles/conductor.md b/.hatch/roles/conductor.md index 1823eb2..ff020f6 100644 --- a/.hatch/roles/conductor.md +++ b/.hatch/roles/conductor.md @@ -1,12 +1,18 @@ ## Trách nhiệm -- Lập kế hoạch: bẻ epic → ticket, gán vai/agent, đặt ưu tiên, ghi `depends_on`. -- Gỡ block, chạy ceremonies (planning, standup digest, retro). -- Nén ledger ở retro; đề bạt tri thức KB → SSOT khi đã chín. +- **Delegate của boss (user)**, không phải engine điều phối. Nhận yêu cầu từ + user, làm rõ, viết **plan/doc** khi cần, rồi điều phối **qua chat** (mở thread, + `@tag` đúng teammate trong roster) — peer-to-peer, không assign cưỡng chế. +- Gỡ block bằng cách hỏi thẳng trong thread; chạy ceremonies (planning, standup + digest, retro) như thói quen của team, không phải engine. +- Đề bạt tri thức KB → SSOT khi đã chín. ## Ranh giới (KHÔNG) - Tự viết code production lớn. - Tự merge khi chưa qua human gate. +- Tự quyết ai-làm-gì như một engine: gợi ý + nhờ qua chat, để teammate tự nhận. ## Cách làm -- Mỗi ticket có `role`, `priority`, acceptance rõ ràng trước khi vào backlog. -- Ưu tiên theo giá trị + dependency; không nhồi WIP quá giới hạn agent. +- Đầu việc: `roster` xem ai đang ở phòng + vai gì, rồi mở thread (`chat_open`) + và `@tag` người phù hợp; mỗi task = một thread. +- Ưu tiên theo giá trị + dependency; tôn trọng Working Agreement (ownership, + close-the-loop). Kẹt quyết định lớn → kéo user vào. diff --git a/.hatch/working-agreement.md b/.hatch/working-agreement.md new file mode 100644 index 0000000..98d333d --- /dev/null +++ b/.hatch/working-agreement.md @@ -0,0 +1,36 @@ +--- +title: Working Agreement +note: SSOT — sửa file này rồi chạy `hatch compile`. Nạp ở tầng L0 cùng charter. +--- + +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Đây là cách squad làm việc như một team người thực thụ: hành động dứt khoát, +> sở hữu rõ ràng, không chat cho vui, không đùn đẩy. Một số điều có **backstop** +> trong wake daemon (`hatch daemon`) — vi phạm sẽ lộ ra, không chỉ là lời khuyên. + +1. **Bias to action.** Việc reversible thì cứ làm; chỉ hỏi khi irreversible hoặc + mơ hồ tốn kém. Đừng họp khi có thể thử. +2. **Mỗi tin xứng token của nó.** Một message phải mang **thông tin / quyết định + / deliverable / câu hỏi cụ thể**. Không "ok", "thanks", "noted", "để mình + xem". _(Backstop: tin không @ai và không trả lời ask đang mở thì không đánh + thức ai — nói cho có chỉ tốn lượt của chính mày.)_ +3. **Own cái mày mở / cái mày bị nhờ.** Mở thread = sở hữu tới khi resolve. + Bị @đích danh = mày sở hữu phản hồi: làm, hoặc handoff **có tên người kế** và + chờ họ **ack** — cấm im lặng thả bóng. _(Backstop: roster hiện owner mỗi + thread; thread mồ côi/đứng im bị phơi ra cho boss.)_ +4. **Close the loop.** Xong → post kết quả. Kẹt → post blocker + cần ai. Không + bao giờ biến mất giữa chừng. _(Backstop: owner im quá lâu bị nudge một lần, + rồi escalate lên boss.)_ +5. **Self-check DoD trước khi báo xong.** Tự chạy lint/test; **không tự review** + code mình viết — @tag một reviewer khác; **không tự merge** (human gate). +6. **Escalate sớm lên boss (user)** khi kẹt ở một *quyết định* (không phải khi + kẹt kỹ thuật mình tự gỡ được). _(Backstop: cascade quá sâu hoặc ping-pong + không tiến triển sẽ tự escalate.)_ + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id` để teammate đánh thức đúng + phiên có trí nhớ của mày) → `roster` để biết ai đang ở đây. +- Một task = một thread. `@tag` đúng người trong roster; đừng broadcast bừa. +- Tra `chat_search` / `kb_search` trước khi suy diễn lại. Tri thức đáng giữ → + `kb_add`. diff --git a/.kiro/steering/hatch.md b/.kiro/steering/hatch.md index c602427..5d50c38 100644 --- a/.kiro/steering/hatch.md +++ b/.kiro/steering/hatch.md @@ -25,20 +25,67 @@ agent qua một **MCP server**. CLI Go single-binary; dùng được với Claud Codex, Antigravity (`agy`), Kiro. ## Nguyên tắc tối cao -- **Embedded, không điều khiển**: Hatch KHÔNG spawn/lái agent. `board`/`chat`/ - `status` chỉ là view read-only. Quy trình là **protocol compile thành prose** - (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. +- **Mô phỏng team người**: workspace là một "căn phòng" — agent **join**, thấy + nhau qua **roster** (`roster`/`join`), hiểu cùng context, và **chat trực tiếp + peer-to-peer** để phối hợp. Chat là kênh chính; **task = plan/docs/note** + (artifact lập kế hoạch), KHÔNG phải kênh điều phối. +- **Tách delivery vs orchestration**: Hatch KHÔNG điều phối *công việc* (không + assign/lane/lock — không sếp-phần-mềm). Nhưng CÓ **delivery + wake**: `hatch + daemon` giao @mention tới đúng teammate và đánh thức (resume) đúng phiên có + trí nhớ của họ. Wake **luôn là hệ quả một message ai đó gửi**, không tự sinh. +- **Sếp là người = user**: user đặt mục tiêu/ưu tiên/duyệt. GĐ1 nói qua một + team-leader (role `conductor`, delegate của boss); GĐ2 vào thẳng qua chat UI. +- **Embedded, không điều khiển công việc**: Hatch không quyết ai-làm-gì. + `board`/`chat`/`status`/`roster` là view read-only. Quy trình là **protocol + compile thành prose** (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. - **SSOT → compile**: sửa `.hatch/{charter,roles,registry,workflow}` rồi `hatch compile`; không sửa file output. - **Minimum code, surgical**: giải đúng vấn đề, không abstraction thừa. Lean Hexagonal (model / port / adapter). - Mỗi thay đổi có dấu vết (ledger) + lý do (`why`); không sửa ngoài scope ticket. +> **Working Agreement** (cách làm việc chuyên nghiệp, ownership cao) sống ở +> `.hatch/working-agreement.md` — nạp cùng charter ở tầng L0. + ## Ràng buộc -- Go 1.24+. Build sạch **cả hai**: mặc định và `-tags hatch_legacy`. +- Go 1.24+. Một binary chat-based duy nhất (engine điều phối legacy đã gỡ bỏ). - **Không sửa file ngoài repo**: config home của Codex/agy do `hatch setup` lo; `hatch doctor` chỉ gọi lệnh auth của CLI, KHÔNG quét thư mục creds. -- `make lint && make test` (+ legacy) phải pass trước khi push; `go mod tidy` nếu đổi deps. +- `make lint && make test` phải pass trước khi push; `go mod tidy` nếu đổi deps. + +## Working Agreement (L0) + +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Đây là cách squad làm việc như một team người thực thụ: hành động dứt khoát, +> sở hữu rõ ràng, không chat cho vui, không đùn đẩy. Một số điều có **backstop** +> trong wake daemon (`hatch daemon`) — vi phạm sẽ lộ ra, không chỉ là lời khuyên. + +1. **Bias to action.** Việc reversible thì cứ làm; chỉ hỏi khi irreversible hoặc + mơ hồ tốn kém. Đừng họp khi có thể thử. +2. **Mỗi tin xứng token của nó.** Một message phải mang **thông tin / quyết định + / deliverable / câu hỏi cụ thể**. Không "ok", "thanks", "noted", "để mình + xem". _(Backstop: tin không @ai và không trả lời ask đang mở thì không đánh + thức ai — nói cho có chỉ tốn lượt của chính mày.)_ +3. **Own cái mày mở / cái mày bị nhờ.** Mở thread = sở hữu tới khi resolve. + Bị @đích danh = mày sở hữu phản hồi: làm, hoặc handoff **có tên người kế** và + chờ họ **ack** — cấm im lặng thả bóng. _(Backstop: roster hiện owner mỗi + thread; thread mồ côi/đứng im bị phơi ra cho boss.)_ +4. **Close the loop.** Xong → post kết quả. Kẹt → post blocker + cần ai. Không + bao giờ biến mất giữa chừng. _(Backstop: owner im quá lâu bị nudge một lần, + rồi escalate lên boss.)_ +5. **Self-check DoD trước khi báo xong.** Tự chạy lint/test; **không tự review** + code mình viết — @tag một reviewer khác; **không tự merge** (human gate). +6. **Escalate sớm lên boss (user)** khi kẹt ở một *quyết định* (không phải khi + kẹt kỹ thuật mình tự gỡ được). _(Backstop: cascade quá sâu hoặc ping-pong + không tiến triển sẽ tự escalate.)_ + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id` để teammate đánh thức đúng + phiên có trí nhớ của mày) → `roster` để biết ai đang ở đây. +- Một task = một thread. `@tag` đúng người trong roster; đừng broadcast bừa. +- Tra `chat_search` / `kb_search` trước khi suy diễn lại. Tri thức đáng giữ → + `kb_add`. ## Your roles (L1) @@ -92,7 +139,7 @@ Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thre ## Chat protocol (giao tiếp = backlog) — Hatch MCP -Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. +Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `join`, `roster`, `leave`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. Cư xử như một thành viên squad người: @@ -123,5 +170,5 @@ Khi nhận một task (thread), đọc thread + `context_refs` liên quan dướ - `.hatch/context/product` - Knowledge Base: tool `kb_search` (hoặc `.hatch/kb/index.md`) — quyết định & bài học chung. -- SSOT: `.hatch/{charter.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. +- SSOT: `.hatch/{charter.md,working-agreement.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. diff --git a/AGENTS.md b/AGENTS.md index 68db313..ae1083e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,20 +21,67 @@ agent qua một **MCP server**. CLI Go single-binary; dùng được với Claud Codex, Antigravity (`agy`), Kiro. ## Nguyên tắc tối cao -- **Embedded, không điều khiển**: Hatch KHÔNG spawn/lái agent. `board`/`chat`/ - `status` chỉ là view read-only. Quy trình là **protocol compile thành prose** - (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. +- **Mô phỏng team người**: workspace là một "căn phòng" — agent **join**, thấy + nhau qua **roster** (`roster`/`join`), hiểu cùng context, và **chat trực tiếp + peer-to-peer** để phối hợp. Chat là kênh chính; **task = plan/docs/note** + (artifact lập kế hoạch), KHÔNG phải kênh điều phối. +- **Tách delivery vs orchestration**: Hatch KHÔNG điều phối *công việc* (không + assign/lane/lock — không sếp-phần-mềm). Nhưng CÓ **delivery + wake**: `hatch + daemon` giao @mention tới đúng teammate và đánh thức (resume) đúng phiên có + trí nhớ của họ. Wake **luôn là hệ quả một message ai đó gửi**, không tự sinh. +- **Sếp là người = user**: user đặt mục tiêu/ưu tiên/duyệt. GĐ1 nói qua một + team-leader (role `conductor`, delegate của boss); GĐ2 vào thẳng qua chat UI. +- **Embedded, không điều khiển công việc**: Hatch không quyết ai-làm-gì. + `board`/`chat`/`status`/`roster` là view read-only. Quy trình là **protocol + compile thành prose** (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. - **SSOT → compile**: sửa `.hatch/{charter,roles,registry,workflow}` rồi `hatch compile`; không sửa file output. - **Minimum code, surgical**: giải đúng vấn đề, không abstraction thừa. Lean Hexagonal (model / port / adapter). - Mỗi thay đổi có dấu vết (ledger) + lý do (`why`); không sửa ngoài scope ticket. +> **Working Agreement** (cách làm việc chuyên nghiệp, ownership cao) sống ở +> `.hatch/working-agreement.md` — nạp cùng charter ở tầng L0. + ## Ràng buộc -- Go 1.24+. Build sạch **cả hai**: mặc định và `-tags hatch_legacy`. +- Go 1.24+. Một binary chat-based duy nhất (engine điều phối legacy đã gỡ bỏ). - **Không sửa file ngoài repo**: config home của Codex/agy do `hatch setup` lo; `hatch doctor` chỉ gọi lệnh auth của CLI, KHÔNG quét thư mục creds. -- `make lint && make test` (+ legacy) phải pass trước khi push; `go mod tidy` nếu đổi deps. +- `make lint && make test` phải pass trước khi push; `go mod tidy` nếu đổi deps. + +## Working Agreement (L0) + +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Đây là cách squad làm việc như một team người thực thụ: hành động dứt khoát, +> sở hữu rõ ràng, không chat cho vui, không đùn đẩy. Một số điều có **backstop** +> trong wake daemon (`hatch daemon`) — vi phạm sẽ lộ ra, không chỉ là lời khuyên. + +1. **Bias to action.** Việc reversible thì cứ làm; chỉ hỏi khi irreversible hoặc + mơ hồ tốn kém. Đừng họp khi có thể thử. +2. **Mỗi tin xứng token của nó.** Một message phải mang **thông tin / quyết định + / deliverable / câu hỏi cụ thể**. Không "ok", "thanks", "noted", "để mình + xem". _(Backstop: tin không @ai và không trả lời ask đang mở thì không đánh + thức ai — nói cho có chỉ tốn lượt của chính mày.)_ +3. **Own cái mày mở / cái mày bị nhờ.** Mở thread = sở hữu tới khi resolve. + Bị @đích danh = mày sở hữu phản hồi: làm, hoặc handoff **có tên người kế** và + chờ họ **ack** — cấm im lặng thả bóng. _(Backstop: roster hiện owner mỗi + thread; thread mồ côi/đứng im bị phơi ra cho boss.)_ +4. **Close the loop.** Xong → post kết quả. Kẹt → post blocker + cần ai. Không + bao giờ biến mất giữa chừng. _(Backstop: owner im quá lâu bị nudge một lần, + rồi escalate lên boss.)_ +5. **Self-check DoD trước khi báo xong.** Tự chạy lint/test; **không tự review** + code mình viết — @tag một reviewer khác; **không tự merge** (human gate). +6. **Escalate sớm lên boss (user)** khi kẹt ở một *quyết định* (không phải khi + kẹt kỹ thuật mình tự gỡ được). _(Backstop: cascade quá sâu hoặc ping-pong + không tiến triển sẽ tự escalate.)_ + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id` để teammate đánh thức đúng + phiên có trí nhớ của mày) → `roster` để biết ai đang ở đây. +- Một task = một thread. `@tag` đúng người trong roster; đừng broadcast bừa. +- Tra `chat_search` / `kb_search` trước khi suy diễn lại. Tri thức đáng giữ → + `kb_add`. ## Your roles (L1) @@ -87,7 +134,7 @@ Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thre ## Chat protocol (giao tiếp = backlog) — Hatch MCP -Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. +Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `join`, `roster`, `leave`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. Cư xử như một thành viên squad người: @@ -117,5 +164,5 @@ Khi nhận một task (thread), đọc thread + `context_refs` liên quan dướ - `.hatch/context/tech` - Knowledge Base: tool `kb_search` (hoặc `.hatch/kb/index.md`) — quyết định & bài học chung. -- SSOT: `.hatch/{charter.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. +- SSOT: `.hatch/{charter.md,working-agreement.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. diff --git a/CLAUDE.md b/CLAUDE.md index 5c1a1ec..89ade83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,20 +21,67 @@ agent qua một **MCP server**. CLI Go single-binary; dùng được với Claud Codex, Antigravity (`agy`), Kiro. ## Nguyên tắc tối cao -- **Embedded, không điều khiển**: Hatch KHÔNG spawn/lái agent. `board`/`chat`/ - `status` chỉ là view read-only. Quy trình là **protocol compile thành prose** - (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. +- **Mô phỏng team người**: workspace là một "căn phòng" — agent **join**, thấy + nhau qua **roster** (`roster`/`join`), hiểu cùng context, và **chat trực tiếp + peer-to-peer** để phối hợp. Chat là kênh chính; **task = plan/docs/note** + (artifact lập kế hoạch), KHÔNG phải kênh điều phối. +- **Tách delivery vs orchestration**: Hatch KHÔNG điều phối *công việc* (không + assign/lane/lock — không sếp-phần-mềm). Nhưng CÓ **delivery + wake**: `hatch + daemon` giao @mention tới đúng teammate và đánh thức (resume) đúng phiên có + trí nhớ của họ. Wake **luôn là hệ quả một message ai đó gửi**, không tự sinh. +- **Sếp là người = user**: user đặt mục tiêu/ưu tiên/duyệt. GĐ1 nói qua một + team-leader (role `conductor`, delegate của boss); GĐ2 vào thẳng qua chat UI. +- **Embedded, không điều khiển công việc**: Hatch không quyết ai-làm-gì. + `board`/`chat`/`status`/`roster` là view read-only. Quy trình là **protocol + compile thành prose** (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. - **SSOT → compile**: sửa `.hatch/{charter,roles,registry,workflow}` rồi `hatch compile`; không sửa file output. - **Minimum code, surgical**: giải đúng vấn đề, không abstraction thừa. Lean Hexagonal (model / port / adapter). - Mỗi thay đổi có dấu vết (ledger) + lý do (`why`); không sửa ngoài scope ticket. +> **Working Agreement** (cách làm việc chuyên nghiệp, ownership cao) sống ở +> `.hatch/working-agreement.md` — nạp cùng charter ở tầng L0. + ## Ràng buộc -- Go 1.24+. Build sạch **cả hai**: mặc định và `-tags hatch_legacy`. +- Go 1.24+. Một binary chat-based duy nhất (engine điều phối legacy đã gỡ bỏ). - **Không sửa file ngoài repo**: config home của Codex/agy do `hatch setup` lo; `hatch doctor` chỉ gọi lệnh auth của CLI, KHÔNG quét thư mục creds. -- `make lint && make test` (+ legacy) phải pass trước khi push; `go mod tidy` nếu đổi deps. +- `make lint && make test` phải pass trước khi push; `go mod tidy` nếu đổi deps. + +## Working Agreement (L0) + +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Đây là cách squad làm việc như một team người thực thụ: hành động dứt khoát, +> sở hữu rõ ràng, không chat cho vui, không đùn đẩy. Một số điều có **backstop** +> trong wake daemon (`hatch daemon`) — vi phạm sẽ lộ ra, không chỉ là lời khuyên. + +1. **Bias to action.** Việc reversible thì cứ làm; chỉ hỏi khi irreversible hoặc + mơ hồ tốn kém. Đừng họp khi có thể thử. +2. **Mỗi tin xứng token của nó.** Một message phải mang **thông tin / quyết định + / deliverable / câu hỏi cụ thể**. Không "ok", "thanks", "noted", "để mình + xem". _(Backstop: tin không @ai và không trả lời ask đang mở thì không đánh + thức ai — nói cho có chỉ tốn lượt của chính mày.)_ +3. **Own cái mày mở / cái mày bị nhờ.** Mở thread = sở hữu tới khi resolve. + Bị @đích danh = mày sở hữu phản hồi: làm, hoặc handoff **có tên người kế** và + chờ họ **ack** — cấm im lặng thả bóng. _(Backstop: roster hiện owner mỗi + thread; thread mồ côi/đứng im bị phơi ra cho boss.)_ +4. **Close the loop.** Xong → post kết quả. Kẹt → post blocker + cần ai. Không + bao giờ biến mất giữa chừng. _(Backstop: owner im quá lâu bị nudge một lần, + rồi escalate lên boss.)_ +5. **Self-check DoD trước khi báo xong.** Tự chạy lint/test; **không tự review** + code mình viết — @tag một reviewer khác; **không tự merge** (human gate). +6. **Escalate sớm lên boss (user)** khi kẹt ở một *quyết định* (không phải khi + kẹt kỹ thuật mình tự gỡ được). _(Backstop: cascade quá sâu hoặc ping-pong + không tiến triển sẽ tự escalate.)_ + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id` để teammate đánh thức đúng + phiên có trí nhớ của mày) → `roster` để biết ai đang ở đây. +- Một task = một thread. `@tag` đúng người trong roster; đừng broadcast bừa. +- Tra `chat_search` / `kb_search` trước khi suy diễn lại. Tri thức đáng giữ → + `kb_add`. ## Your roles (L1) @@ -57,17 +104,23 @@ Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thre ### Role: Conductor (`conductor`) ## Trách nhiệm -- Lập kế hoạch: bẻ epic → ticket, gán vai/agent, đặt ưu tiên, ghi `depends_on`. -- Gỡ block, chạy ceremonies (planning, standup digest, retro). -- Nén ledger ở retro; đề bạt tri thức KB → SSOT khi đã chín. +- **Delegate của boss (user)**, không phải engine điều phối. Nhận yêu cầu từ + user, làm rõ, viết **plan/doc** khi cần, rồi điều phối **qua chat** (mở thread, + `@tag` đúng teammate trong roster) — peer-to-peer, không assign cưỡng chế. +- Gỡ block bằng cách hỏi thẳng trong thread; chạy ceremonies (planning, standup + digest, retro) như thói quen của team, không phải engine. +- Đề bạt tri thức KB → SSOT khi đã chín. ## Ranh giới (KHÔNG) - Tự viết code production lớn. - Tự merge khi chưa qua human gate. +- Tự quyết ai-làm-gì như một engine: gợi ý + nhờ qua chat, để teammate tự nhận. ## Cách làm -- Mỗi ticket có `role`, `priority`, acceptance rõ ràng trước khi vào backlog. -- Ưu tiên theo giá trị + dependency; không nhồi WIP quá giới hạn agent. +- Đầu việc: `roster` xem ai đang ở phòng + vai gì, rồi mở thread (`chat_open`) + và `@tag` người phù hợp; mỗi task = một thread. +- Ưu tiên theo giá trị + dependency; tôn trọng Working Agreement (ownership, + close-the-loop). Kẹt quyết định lớn → kéo user vào. ### Role: Reviewer (`reviewer`) @@ -111,7 +164,7 @@ Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thre ## Chat protocol (giao tiếp = backlog) — Hatch MCP -Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. +Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `join`, `roster`, `leave`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. Cư xử như một thành viên squad người: @@ -143,5 +196,5 @@ Khi nhận một task (thread), đọc thread + `context_refs` liên quan dướ - `.hatch/charter.md` - Knowledge Base: tool `kb_search` (hoặc `.hatch/kb/index.md`) — quyết định & bài học chung. -- SSOT: `.hatch/{charter.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. +- SSOT: `.hatch/{charter.md,working-agreement.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. diff --git a/GEMINI.md b/GEMINI.md index dfcfb48..c705e32 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -21,20 +21,67 @@ agent qua một **MCP server**. CLI Go single-binary; dùng được với Claud Codex, Antigravity (`agy`), Kiro. ## Nguyên tắc tối cao -- **Embedded, không điều khiển**: Hatch KHÔNG spawn/lái agent. `board`/`chat`/ - `status` chỉ là view read-only. Quy trình là **protocol compile thành prose** - (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. +- **Mô phỏng team người**: workspace là một "căn phòng" — agent **join**, thấy + nhau qua **roster** (`roster`/`join`), hiểu cùng context, và **chat trực tiếp + peer-to-peer** để phối hợp. Chat là kênh chính; **task = plan/docs/note** + (artifact lập kế hoạch), KHÔNG phải kênh điều phối. +- **Tách delivery vs orchestration**: Hatch KHÔNG điều phối *công việc* (không + assign/lane/lock — không sếp-phần-mềm). Nhưng CÓ **delivery + wake**: `hatch + daemon` giao @mention tới đúng teammate và đánh thức (resume) đúng phiên có + trí nhớ của họ. Wake **luôn là hệ quả một message ai đó gửi**, không tự sinh. +- **Sếp là người = user**: user đặt mục tiêu/ưu tiên/duyệt. GĐ1 nói qua một + team-leader (role `conductor`, delegate của boss); GĐ2 vào thẳng qua chat UI. +- **Embedded, không điều khiển công việc**: Hatch không quyết ai-làm-gì. + `board`/`chat`/`status`/`roster` là view read-only. Quy trình là **protocol + compile thành prose** (CLAUDE.md/AGENTS.md/…), không phải engine cưỡng chế. - **SSOT → compile**: sửa `.hatch/{charter,roles,registry,workflow}` rồi `hatch compile`; không sửa file output. - **Minimum code, surgical**: giải đúng vấn đề, không abstraction thừa. Lean Hexagonal (model / port / adapter). - Mỗi thay đổi có dấu vết (ledger) + lý do (`why`); không sửa ngoài scope ticket. +> **Working Agreement** (cách làm việc chuyên nghiệp, ownership cao) sống ở +> `.hatch/working-agreement.md` — nạp cùng charter ở tầng L0. + ## Ràng buộc -- Go 1.24+. Build sạch **cả hai**: mặc định và `-tags hatch_legacy`. +- Go 1.24+. Một binary chat-based duy nhất (engine điều phối legacy đã gỡ bỏ). - **Không sửa file ngoài repo**: config home của Codex/agy do `hatch setup` lo; `hatch doctor` chỉ gọi lệnh auth của CLI, KHÔNG quét thư mục creds. -- `make lint && make test` (+ legacy) phải pass trước khi push; `go mod tidy` nếu đổi deps. +- `make lint && make test` phải pass trước khi push; `go mod tidy` nếu đổi deps. + +## Working Agreement (L0) + +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Đây là cách squad làm việc như một team người thực thụ: hành động dứt khoát, +> sở hữu rõ ràng, không chat cho vui, không đùn đẩy. Một số điều có **backstop** +> trong wake daemon (`hatch daemon`) — vi phạm sẽ lộ ra, không chỉ là lời khuyên. + +1. **Bias to action.** Việc reversible thì cứ làm; chỉ hỏi khi irreversible hoặc + mơ hồ tốn kém. Đừng họp khi có thể thử. +2. **Mỗi tin xứng token của nó.** Một message phải mang **thông tin / quyết định + / deliverable / câu hỏi cụ thể**. Không "ok", "thanks", "noted", "để mình + xem". _(Backstop: tin không @ai và không trả lời ask đang mở thì không đánh + thức ai — nói cho có chỉ tốn lượt của chính mày.)_ +3. **Own cái mày mở / cái mày bị nhờ.** Mở thread = sở hữu tới khi resolve. + Bị @đích danh = mày sở hữu phản hồi: làm, hoặc handoff **có tên người kế** và + chờ họ **ack** — cấm im lặng thả bóng. _(Backstop: roster hiện owner mỗi + thread; thread mồ côi/đứng im bị phơi ra cho boss.)_ +4. **Close the loop.** Xong → post kết quả. Kẹt → post blocker + cần ai. Không + bao giờ biến mất giữa chừng. _(Backstop: owner im quá lâu bị nudge một lần, + rồi escalate lên boss.)_ +5. **Self-check DoD trước khi báo xong.** Tự chạy lint/test; **không tự review** + code mình viết — @tag một reviewer khác; **không tự merge** (human gate). +6. **Escalate sớm lên boss (user)** khi kẹt ở một *quyết định* (không phải khi + kẹt kỹ thuật mình tự gỡ được). _(Backstop: cascade quá sâu hoặc ping-pong + không tiến triển sẽ tự escalate.)_ + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id` để teammate đánh thức đúng + phiên có trí nhớ của mày) → `roster` để biết ai đang ở đây. +- Một task = một thread. `@tag` đúng người trong roster; đừng broadcast bừa. +- Tra `chat_search` / `kb_search` trước khi suy diễn lại. Tri thức đáng giữ → + `kb_add`. ## Your roles (L1) @@ -86,7 +133,7 @@ Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thre ## Chat protocol (giao tiếp = backlog) — Hatch MCP -Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. +Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: `whoami`, `join`, `roster`, `leave`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`. Cư xử như một thành viên squad người: @@ -117,5 +164,5 @@ Khi nhận một task (thread), đọc thread + `context_refs` liên quan dướ - `.hatch/context/product` - Knowledge Base: tool `kb_search` (hoặc `.hatch/kb/index.md`) — quyết định & bài học chung. -- SSOT: `.hatch/{charter.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. +- SSOT: `.hatch/{charter.md,working-agreement.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`. diff --git a/docs/chat-native-model.md b/docs/chat-native-model.md new file mode 100644 index 0000000..5ccbe89 --- /dev/null +++ b/docs/chat-native-model.md @@ -0,0 +1,58 @@ +# Hatch chat-native model (the SoT contract) + +Hatch has its **own** chat substrate — the **bus** (`.hatch/run/bus/`). It is the +single source of truth for all squad communication. Slack, Discord, or any other +client is an **interchangeable bridge**: a thin projection of this model, never +the source. Hatch does not ship a chat UI; it only guarantees this model is +structurally sound, following Slack's conversation primitives so any bridge maps +cleanly. + +## Primitives + +- **Workspace** — one `.hatch` room = one repo. All channels live in it. +- **Channel** — a named conversation, addressed by id (`design`, `task-42`, + `dm-claude-codex`). Stored as one append-only log. Listed by `Channels()`. +- **Message** — one turn: + `{ID, Channel, TS, From, To[], Type, InReplyTo, Body}`. `ID` is unique; + `(Channel, ID)` addresses a message. `From` = sender member id. +- **Thread** — a reply chain. A message with `InReplyTo = R` belongs to the + thread rooted at message `R`. One level deep, exactly like Slack's `thread_ts` + (thread id = root message id). +- **Mention** — `@handle` in the body (agent id, role id, or `*`/`all`), merged + into `To[]`. This is the routing + wake key: the daemon wakes whoever a + message addresses. +- **Type** (hatch extension) — `msg | ask | reply | decision`. Slack has no + equivalent; bridges may ignore it or map it to formatting. `ask` drives the + open-question / wake semantics. + +## Deliberately omitted (YAGNI) + +Reactions, edits/deletes, rich blocks, channel metadata (topic/members), +presence-in-channel. Add one only when a concrete bridge needs it — not before. + +## Bridge mapping (how a client projects the model) + +A bridge implements: list channels, read a channel's messages, post a message +(from + thread + mentions), and ingest inbound human messages back onto the bus. +Everything it needs is in the model above. + +| Bus | Slack (current policy) | Discord | +|---|---|---| +| channel | thread under one `#squad` *(or* a channel, faithful*)* | thread or channel | +| message | message | message | +| `InReplyTo` | `thread_ts` | message reference / thread | +| `From` | bot username/icon (per-agent token) | webhook username | +| `@handle` | literal text or `<@U…>` | `<@id>` or text | +| `Type` | (ignored / formatting) | (ignored / formatting) | + +The Slack bridge collapses each bus channel into a thread under one `#squad` +(chosen topology); a faithful mapping would be channel↔channel. Both are valid +**bridge policies** — the native model does not change. + +## Invariants + +- Agents talk to the **bus** (via MCP `chat_post`/`chat_read`), never to a client + directly. The bridge projects to/from the client. +- The bus works with **no bridge** — two agents coordinate through it offline. +- A bridge is replaceable (Slack today, Discord tomorrow) without touching the + model, the daemon, or the agents. diff --git a/docs/research/2026-06-22-paperclip-vs-overclaud-chat-first.md b/docs/research/2026-06-22-paperclip-vs-overclaud-chat-first.md new file mode 100644 index 0000000..a5f295c --- /dev/null +++ b/docs/research/2026-06-22-paperclip-vs-overclaud-chat-first.md @@ -0,0 +1,214 @@ +# Paperclip → overclaud: nghiên cứu & đề xuất chuyển sang Chat-first + +_Ngày: 22/06/2026 · Tác giả: Conductor (claude-code) · Trạng thái: Proposal (chờ chốt)_ +_Nguồn: đọc trực tiếp repo [paperclipai/paperclip](https://github.com/paperclipai/paperclip) (README, ROADMAP, `doc/TASKS.md`, `doc/TASKS-mcp.md`, `doc/execution-semantics.md`) + codebase Hatch hiện tại (`hatch/`, `.hatch/`)._ + +--- + +## 0. TL;DR + +- **Paperclip không phải "task manager nhẹ".** Nó là một **control plane đầy đủ** để vận hành "AI company": Node server + React UI + Postgres, org chart, budget, governance, heartbeat scheduler. Khẩu hiệu của họ: _"If OpenClaw is an employee, Paperclip is the company."_ +- **"Backlog-as-comms" của paperclip là hệ quả, không phải lựa chọn thẩm mỹ.** Vì paperclip **chủ động lái agent** (push: heartbeat scheduler wake agent dậy), nó **bắt buộc** phải có state máy-đọc-được: `status` enum, single-assignee = atomic checkout lock, blocker graph. Toàn bộ `execution-semantics.md` (700 dòng về checkout lock, stranded-work recovery, monitor, watchdog) tồn tại **chỉ vì** control plane chịu trách nhiệm liveness. +- **overclaud/Hatch theo mô hình ngược lại: embedded, không điều khiển** (pull: agent đã chạy sẵn, tự lái, tự đọc bus). Khi bạn **không lái** agent, bạn **không cần** status/lock/heartbeat/recovery. Bạn cần một **cuộc hội thoại chung** mà các agent đang sống poll vào. +- **Vì vậy chat-first không chỉ là một UX choice — nó là kiến trúc đúng cho mô hình embedded.** Đề xuất: **chat = event log nguồn-sự-thật-duy-nhất; "task/board" = projection (reduce) trên chat, không phải store riêng.** Đây đúng là phép nghịch đảo của paperclip. +- **Tác động code: GIẢM, không tăng.** Hatch đang là hybrid (board lane store + ledger _và_ bus). Chat-first cho phép **xóa** board-lane-store + ledger, **nâng** các workflow verb (claim/done/block/review/handoff) từ prose trong CLAUDE.md thành **control message** hạng nhất, và biến `hatch board`/`status` thành **reducer** trên bus. + +**Khuyến nghị: Option A — Chat-as-event-log + board-as-projection.** Chi tiết ở §7–§8. + +--- + +## 1. Paperclip thực sự là gì + +### 1.1 Định vị +Một control plane open-source để **điều phối một đội agent chạy cả một doanh nghiệp**. Bạn định nghĩa goal ("Build the #1 note app to $1M MRR"), "thuê" team (CEO/CTO/eng/designer — bất kỳ agent nào), set budget, bấm go, giám sát từ dashboard. _"Manage business goals, not pull requests."_ + +### 1.2 Kiến trúc (theo README "What's Under the Hood") +Một server đơn xử lý 12 subsystem: Identity & Access · Org Chart & Agents · Work & Tasks · **Heartbeat Execution** · Workspaces & Runtime · Governance & Approvals · Budget & Cost · Routines & Schedules · Plugins · Secrets & Storage · Activity & Events · Company Portability. Local: 1 Node process + embedded Postgres + file storage. Prod: Postgres riêng + deploy tùy ý. + +### 1.3 Mô hình "backlog-as-communication" +Đây là phần cốt lõi cho câu hỏi của chúng ta. Đọc `doc/TASKS.md` + `doc/TASKS-mcp.md`: + +- **Phân cấp entity:** `Initiative → Project → Milestone → Issue → Sub-issue`. Issue là đơn vị công việc lõi. Đây thực chất là một bản **clone Linear/Jira**. +- **Issue có state máy-đọc:** `status` (workflow state theo category triage/backlog/unstarted/started/completed/cancelled), `priority` (0–4 cố định), **single `assigneeId`** (chủ ý: "clear ownership prevents diffusion of responsibility"), `parentId`, `goalId`, blocker relations (`blocks`/`blocked_by`/`related`/`duplicate`). +- **Kênh giao tiếp giữa agent = artefact gắn vào issue:** + - **Comments** (threaded, có `parentId`, `resolvedAt`) — "agents need to communicate about issues without overwriting the description". + - **Assignment** — chuyển owner. + - **@-mention / status transition / blocker** — tín hiệu điều phối. +- **MCP surface = 35 operations** xoay quanh CRUD issue/project/milestone/label/relation/comment/initiative. Agent "nói chuyện" bằng cách **mutate issue** và **comment trên issue**. + +→ **"Đơn vị giao tiếp là cái issue."** Hội thoại là thứ _treo dưới_ một work item có cấu trúc cứng. Trạng thái phối hợp được biểu diễn **tường minh** bằng field (`status`, `assignee`, `blockedBy`). + +### 1.4 Heartbeat & execution semantics — cái giá thật của backlog-first +`doc/execution-semantics.md` tách 4 khái niệm: **structure** (parent/sub), **dependency** (blocker), **ownership** (assignee), **execution** (control plane có đang có đường sống để đẩy issue đi không). Vì paperclip **dispatch run** cho agent (DB-backed wakeup queue, coalescing, budget check, workspace resolve, secret inject), nó phải gánh nguyên một bộ contract khổng lồ: + +- **Atomic checkout:** `checkoutRunId` (lock quyền sở hữu) vs `executionRunId` (run đang sống) — compare-and-clear khi run terminal, không clear lock của run non-terminal, self-heal lock trỏ vào run đã chết. +- **Liveness contract:** issue agent-owned non-terminal **không bao giờ** được rơi vào trạng thái "không ai chịu trách nhiệm bước kế và không gì wake nó". Phải luôn trả lời được _"what moves this forward next?"_ qua một trong các primitive: active run / queued wake / typed participant / pending interaction / monitor / human owner / blocker chain / recovery action. +- **Crash recovery:** stranded `todo` (dispatch recovery, requeue 1 wake), stranded `in_progress` (continuation recovery), rồi mới escalate `blocked` + recovery action. +- **Watchdog:** task watchdog (subtree dừng), silent active-run watchdog (process còn sống nhưng im output), productivity review. + +**Đây là toàn bộ độ phức tạp mà một _controller_ phải trả.** Nó hợp lý cho paperclip. Nó **vô nghĩa** với một harness embedded. + +--- + +## 2. Insight cốt lõi: Push (controller) vs Pull (embedded) + +| | **Paperclip** | **overclaud / Hatch** | +|---|---|---| +| Quan hệ với agent | **Controller** — spawn & wake agent qua heartbeat | **Embedded** — agent đã chạy sẵn (terminal của user), tự lái | +| Mô hình | **Push:** scheduler đẩy việc vào agent | **Pull:** agent đọc bus rồi tự hành động | +| Vì sao cần state máy-đọc | Để scheduler **lock, schedule, budget, recover** | Không có scheduler nào tiêu thụ state → **không cần** | +| Liveness do ai lo | Control plane (execution-semantics.md) | **Con người + agent conductor** đọc inbox | +| Nếu agent chết | Recovery contract tự requeue/escalate | User thấy ngay (đó là terminal của họ) | +| Hệ quả comms tự nhiên | **Backlog-first** (issue là object cứng) | **Chat-first** (message là object) | + +> **Điểm chốt cho CTO:** chat-vs-backlog **không phải** là quyết định gốc. Quyết định gốc là **"có lái agent hay không"** — và Hatch đã chốt "không điều khiển" ngay trong charter L0. Chat-first chỉ là hệ quả tất yếu. Chọn backlog-first cho một harness không-lái-agent = trả giá schema + liveness/recovery contract mà **chẳng có ai tiêu thụ** state đó. Đó là complexity thuần lỗ. + +--- + +## 3. Mô hình Chat-first đề xuất cho overclaud + +### 3.1 Nguyên lý: Chat là event log, task/board là projection +- **Bus (chat) = append-only event log, source-of-truth duy nhất.** Mỗi thread = một task. Mỗi message = một event bất biến. +- **"Task state" không được lưu — nó được _tính_** bằng cách reduce/fold các message trong thread (event sourcing). `hatch board` = chạy reducer trên tất cả thread, suy ra lane của từng thread từ **control message mới nhất**. +- **Ledger biến mất** vì bus _chính là_ ledger (append-only + auditable + có `why` trong body). + +Điều này nghịch đảo paperclip: ở paperclip chat treo dưới issue; ở đây **state treo dưới chat**. + +### 3.2 Nâng workflow verb từ prose → control message +Hiện `model.Message.Type` chỉ có `msg/ask/reply/decision`. Các verb scrum (claim/handoff/done/review/block/unblock) đang **nằm trong CLAUDE.md dưới dạng văn xuôi** — không máy nào đọc được, nên `board` không thể suy ra trạng thái thật. Đề xuất thêm một nhóm **control message** (structured verb) bên cạnh message hội thoại: + +``` +Hội thoại (đã có): msg · ask · reply · decision +Control (thêm) : claim · handoff · done · block · unblock · review +``` + +Một control message vẫn là một message bình thường (cùng thread, cùng `From`, có `Body` giải thích `why`), chỉ khác là reducer hiểu nó như một **state transition**. Ví dụ reduce một thread: + +``` +open(T-3, "Fix slugify") → lane=backlog, owner=∅ +claim by @codex → lane=in-progress, owner=codex +block re:T-1 "chờ API contract" → lane=blocked, blockedBy=[T-1] +unblock (T-1 done) → lane=in-progress +handoff to @reviewer "tests xanh" → lane=review, owner=reviewer +done by @reviewer "DoD met" → lane=done +``` + +`board`/`status` = `reduce(thread)` cho mọi thread. Không có lane store. Không thể lệch sync (chỉ có một nguồn). + +### 3.3 Bảng chiếu: paperclip primitive → overclaud chat-first + +| Bài toán | Paperclip (backlog-first) | overclaud (chat-first) | +|---|---|---| +| Đơn vị công việc | Issue (entity cứng) | Thread (chuỗi message) | +| Trạng thái | `status` field (lưu) | Reduce control message (tính) | +| Ai sở hữu | `assigneeId` (1 dòng SQL) | `owner` = người `claim` gần nhất (tính) | +| Phụ thuộc | blocker relation table | `block re:T-x` message → wait graph (tính) | +| Phân rã việc | sub-issue (`parentId`) | thread con tham chiếu `parent:T-x` | +| Giao tiếp | comment trên issue | message trong thread (vốn là cốt lõi) | +| "Việc gì kế tiếp?" | liveness contract + heartbeat | board projection đánh dấu thread "stalled" (no owner / no activity) cho **người** xử lý | +| Audit | activity/events table | bus append-only (sẵn có) | + +--- + +## 4. Những bài toán chat-first PHẢI tự giải (paperclip được schema cho không) + +Trung thực: backlog-first cho không 3 thứ mà chat-first phải chủ động giải. Đây là "ai trả giá". + +1. **Truy vấn trạng thái nhanh.** + - Mất gì: không `SELECT status`. Phải reduce thread mỗi lần hỏi "T-3 đang sao?". + - Giải lean: reducer rẻ (thread thường ngắn); cache projection vào `.hatch/bus/.board.json` (derived cache, xóa được, không phải SoT) nếu cần tốc độ. **Không** coi cache là nguồn. + +2. **Chống double-work / ownership race.** + - Mất gì: không có atomic checkout. Hai agent có thể `claim` cùng thread. + - Giải lean: **advisory claim** — reducer coi "claim hợp lệ = claim đầu tiên sau lần open/handoff gần nhất"; claim sau thấy đã có owner thì lùi (đúng tinh thần `409` của paperclip nhưng **bằng convention + projection**, không bằng engine cưỡng chế). Đủ tốt cho squad nhỏ; race là sự kiện hiếm và **nhìn thấy được** trên board. + +3. **Liveness "việc gì kế tiếp?"** + - Mất gì: không heartbeat/recovery. + - Giải lean: **không cần auto-recovery** (embedded — agent là terminal sống, user thấy khi nó chết). Thay vào đó board projection **đánh dấu** thread `in-progress` mà không có owner hoặc im lặng quá ngưỡng là **"stalled"** — để **người/conductor** xử lý. Mượn _câu hỏi_ "what moves this forward next?" của paperclip làm **design check cho view**, không phải làm engine. + +4. **Kỷ luật posting.** + - Mất gì: paperclip _ép_ state; overclaud _thuyết phục_ (protocol prose). + - Giải lean: làm control verb thành **MCP tool một-phát rẻ** (`chat_claim`, `chat_done`, `chat_block`…) để post state có cấu trúc chỉ tốn 1 call; board làm chỗ thiếu sót **lộ ra** ("T-3: no owner, stale 2h"). Bù enforcement bằng visibility. + +--- + +## 5. Lấy gì / Bỏ gì từ paperclip + +**LẤY (kể cả khi chat-first):** +- **Human-readable id** kiểu `T-3` thay cho `m0622-150405-xxxxxx` hiện tại — cực hợp với chat ("xử T-3 đi" > đọc UUID). +- **Câu hỏi liveness** _"what moves this forward next?"_ làm tiêu chí thiết kế cho board view (stalled detection), **tính chứ không ép**. +- **Single-owner clarity** (dù mềm): board luôn hiện owner hiện tại của thread. +- **Blocker là quan hệ hạng nhất** (dù derived từ `block re:T-x`). +- **Context/goal chảy xuống**: charter L0 + `parent:T-x` cho thread con (đã có context map). + +**BỎ (đây là cái giá controller, embedded không trả):** +- Heartbeat / run / checkout-lock / stranded-work recovery / watchdog (toàn bộ `execution-semantics.md`). +- Postgres + Node server + React dashboard → giữ Go single-binary + filesystem. +- Org chart / budget / governance **as engine** → Hatch làm gate bằng self-check prose (DoD), giữ nguyên. +- Phân cấp 5 tầng Initiative→Project→Milestone→Issue→Sub-issue → thừa cho squad 1-repo. Thread + `parent:` là đủ. + +--- + +## 6. Tác động cụ thể lên codebase Hatch + +| Khu vực | Hiện tại | Chat-first | +|---|---|---| +| `internal/bus` | Append-only thread store (đã tốt) | **Giữ, là trung tâm.** Thêm control verb. | +| `internal/model/message.go` | type: msg/ask/reply/decision | **Thêm:** claim/handoff/done/block/unblock/review + (tùy) field `Ref`, `Parent`. | +| `internal/store` board lane (`board/{backlog,…}`) | Store riêng | **Xóa** → thay bằng reducer `wf.Board = reduce(bus)`. | +| `internal/model/ledger.go` + ledger store | Audit riêng | **Xóa** → bus _là_ ledger. | +| `internal/wf` engine (Move/Escalate) | Đổi lane trên store | **Đổi** thành "post control message" + projection. | +| `hatch board` / `status` / TUI | Đọc lane store | **Đọc reduce(bus)**. | +| MCP server | chat_*/kb_* | **Thêm** chat_claim/done/block… (thin wrapper post control msg). | +| Thread id (`newID`) | `m0622-…` | **Đổi** sang `T-{seq}` human-readable. | + +→ Ròng là **xóa hai store** (board lane + ledger), **thêm vài message kind + reducer**. Khớp charter: _"Minimum code, surgical"_ và Lean Hexagonal (seam `store behind wf.Board` đúng chỗ để thay adapter). + +--- + +## 7. Ba lựa chọn & trade-off (ai trả giá) + +### Option A — Chat-as-event-log + board-as-projection ✅ **(khuyến nghị)** +- **Là gì:** bus là SoT duy nhất; board/ledger thành projection; workflow verb thành control message; advisory claim; stalled detection tính trên view. +- **Được:** đúng mô hình embedded; **giảm code** (xóa 2 store); audit miễn phí; không bao giờ lệch sync; con người đọc native; không schema migration. +- **Ai trả giá:** mất truy vấn state O(1) (phải reduce — rẻ, có thể cache); ownership là advisory (race hiếm, nhìn thấy được). _Người trả: agent phải có kỷ luật post control msg — bù bằng MCP tool rẻ + board phơi thiếu sót._ + +### Option B — Hybrid: giữ cả board store lẫn chat, chat làm "primary", board làm cache "đồng bộ" +- **Là gì:** đúng trạng thái Hatch hiện tại, chỉ tuyên bố chat ưu tiên. +- **Được:** truy vấn nhanh từ board cache. +- **Ai trả giá:** **hai nguồn sự thật phải sync** = ổ bug kinh điển; nhiều code hơn; "ai đúng khi lệch?" không có câu trả lời sạch. **Tệ nhất cả đôi.** Không khuyến nghị. + +### Option C — Backlog-first như paperclip (issue là object, chat treo dưới) +- **Là gì:** clone mô hình paperclip ở quy mô nhỏ. +- **Được:** state máy-đọc, ownership cứng, thân thiện scheduler. +- **Ai trả giá:** phải xây + nuôi schema + **toàn bộ liveness/recovery contract** — mà overclaud **không có scheduler nào tiêu thụ**. Complexity thuần lỗ, đi ngược charter "embedded, không điều khiển". Không khuyến nghị. + +--- + +## 8. Rủi ro & giảm thiểu (Option A) + +| Rủi ro | Giảm thiểu | +|---|---| +| Agent quên post control message → board sai | MCP tool 1-call (`chat_done`…); board đánh dấu thread `in-progress` im lặng là "stalled" để lộ thiếu sót | +| Ownership race (2 agent cùng claim) | Reducer: claim hợp lệ = claim đầu sau open/handoff; claim sau tự lùi; race hiếm & hiển thị | +| Reduce chậm khi nhiều/đầy thread | Thread thường ngắn; thêm derived cache `.board.json` (xóa được) nếu đo thấy chậm — **không** coi là SoT | +| Mất tính năng query phức tạp (filter theo label/priority…) | Thêm dần như field trong control msg khi thực sự cần; **không** xây trước (charter: nothing speculative) | +| Migration board/ledger cũ | Viết một lần: replay lane hiện tại thành control message vào thread tương ứng | + +--- + +## 9. Next steps đề xuất (nếu chốt Option A) + +1. **Chốt decision** → ghi ADR (`kb_add type=decision` hoặc `kb/decisions/`). +2. **Spec reducer:** định nghĩa chính xác fold (control msg → lane/owner/blockedBy) — đây là "engine" mới, thuần hàm, dễ test. +3. **Mở rộng `model.Message`** với control kind + `Ref`/`Parent`; thêm `T-{seq}` id. +4. **Viết `wf.Board = reduce(bus)`** thay store lane; xóa board-lane store + ledger. +5. **Thêm MCP tool** control verb (thin wrapper). +6. **Cập nhật SSOT** (charter/workflow) + `hatch compile` để protocol prose khớp mô hình mới. +7. **Migration** lane/ledger cũ → control message (chạy 1 lần). + +--- + +## 10. Một dòng để nhớ + +> Paperclip đặt **chat dưới task** vì nó **lái** agent. overclaud nên đặt **task dưới chat** vì nó **không lái** — task chỉ là cái bóng (projection) mà cuộc hội thoại đổ xuống. Chọn đúng nghịch đảo này là chọn đúng độ phức tạp mình phải nuôi. diff --git a/docs/superpowers/specs/2026-06-22-hatch-session-store-design.md b/docs/superpowers/specs/2026-06-22-hatch-session-store-design.md new file mode 100644 index 0000000..7e6f147 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-hatch-session-store-design.md @@ -0,0 +1,76 @@ +# Hatch Session Store — design spec + +_Status: approved (model + capture-scope confirmed 2026-06-22)_ + +## 1. Vấn đề + +Một Slack channel = một project = một `.hatch` workspace; trong đó mỗi agent có +**rất nhiều session** theo thời gian. `roster.json` cũ chỉ giữ **một** `SessionID` +mutable/agent — không history, không validate sống/chết, không biểu diễn được +nhiều session. Daemon resume mù id đó; chết thì wake fail. + +## 2. Quyết định + +| Nhánh | Chọn | +|---|---| +| Đơn vị session | **per (agent, bus-thread)** — `S(codex, t-42)`. Channel = project, mỗi task-thread một "trí nhớ". | +| Triết lý | **session = warm cache; KB/bus = SoT.** Mất session ⇒ đọc lại record, không mất việc. | +| Capture scope v1 | **claude+codex warm; agy+kiro stateless.** | +| Parallelism (wip>1) | **hoãn.** Giữ daemon serialize per-member; mỗi wake resume đúng session theo thread. | + +## 3. Ma trận năng lực CLI (verified trên binary đã cài) + +| Kind | Lấy id | Resume | Chiến lược | +|---|---|---|---| +| claude | `--session-id ` (assign) | `--resume ` | **assign** | +| codex | `exec --json` → `session_meta.payload.id` | `exec resume ` | **capture** | +| kiro | `--list-sessions -f json` (newest, racy) | `--resume-id ` | stateless (capture hoãn) | +| agy | không lộ id | `--conversation ` (nếu biết) | **stateless** | + +## 4. Cấu phần & file + +``` +.hatch/sessions.json { agent → { thread → Session } } (gitignored, runtime) +internal/model/session.go Session{Agent,Thread,Kind,ID,Status,StartedAt,LastResumedAt,History} +internal/session/store.go Store: Get/Put(+history)/MarkStale/All; atomic write. + store_test.go +internal/daemon/runner.go planWake(m,thread,prior,prompt) → wakePlan{argv,headless,capture,assignID}; + ExecRunner{…,Sessions}: plan→run→capture→commit; sessionCapture writer; uuid4 +internal/cli/sessionscmd.go `hatch sessions` view (tabwriter) +internal/cli/daemoncmd.go inject session.New(layout) vào ExecRunner +``` + +## 5. Lifecycle (trong ExecRunner.Wake) + +1. `thread := threadOf(payload)` = channel của tin mới nhất trong payload. +2. `prior := store.Get(agent, thread)`. +3. `planWake`: prior **live** ⇒ resume warm; ngược lại fresh (assign claude / capture codex / stateless agy,kiro). +4. Chạy CLI. Nếu capture: `sessionCapture` vừa forward stdout vừa rút `session_meta.id` (best-effort). +5. `commit`: + - assign ok ⇒ Put live{assignID}. + - capture ok & bắt được id ⇒ Put live{id}. + - resume ok ⇒ bump `LastResumedAt`. + - resume lỗi ⇒ `MarkStale` ⇒ wake sau bắt đầu fresh (self-heal across wakes). + - stateless ⇒ không lưu gì. + +## 6. Bất biến + +- **Best-effort capture**: format drift ⇒ không lưu ⇒ wake sau fresh, **không bao giờ crash**. +- **Stale self-heal**: resume chết ⇒ stale ⇒ fresh ở wake kế (không re-run giữa wake). +- **KB/bus là SoT**: stateless agent (agy) đọc lại record mỗi wake vẫn đúng. +- **History**: Put đè id khác ⇒ đẩy id cũ vào `History` (audit "nhiều session"). + +## 7. Hoãn (ngoài v1) + +- **Parallelism per-thread (wip>1)**: cần đổi working-set của daemon + wake-policy + sang key `(member, thread)`; `registry.wip` khi đó = trần session đồng thời/agent. + V1 vẫn serialize per-member (một agent xử lý tuần tự từng thread). +- **kiro capture** qua `--list-sessions` (heuristic newest có race). +- **Liveness proactive**: hiện chỉ phát hiện chết khi resume lỗi. + +## 8. DoD + +- [x] `make lint` xanh; test default + `hatch_legacy` đều xanh; build cả hai tag. +- [x] store round-trip/history/stale/All; planWake assign/capture/resume/stateless; + sessionCapture parse `session_meta` qua 2 chunk. +- [x] `hatch sessions` chạy (rỗng → hint). `.hatch/sessions.json` gitignored. +- [ ] @tag reviewer khác; không tự merge (human gate). diff --git a/docs/superpowers/specs/2026-06-22-hatch-slack-bridge-design.md b/docs/superpowers/specs/2026-06-22-hatch-slack-bridge-design.md new file mode 100644 index 0000000..14f2eb6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-hatch-slack-bridge-design.md @@ -0,0 +1,150 @@ +# Hatch Slack Bridge — design spec + +_Status: approved (forks confirmed 2026-06-22) · Phase: GĐ2 / spec Phase E (thay +"Slack-like web UI" bằng **Slack thật**)_ + +## 1. Mục tiêu + +Boss (user) mở **Claude Code** trong workspace, start thêm **Codex CLI** và +**Antigravity CLI (`agy`)**. Cả squad chat peer-to-peer qua Hatch bus (MCP). +Bridge này phơi toàn bộ chat đó lên **một Slack channel `#squad`** để boss: + +1. **Xem** mọi tin agent nói với nhau (mỗi agent hiện đúng danh tính). +2. **`@tag` thẳng một agent từ Slack** để bơm prompt — daemon đánh thức đúng + phiên có trí nhớ của agent đó. + +Bất biến (từ spec gốc): *GĐ2 = GĐ1 với con người dời từ "ngoài, qua proxy" vào +"trong phòng, trực tiếp". Lõi không đổi.* Bridge **không** điều phối việc, không +đụng daemon — nó chỉ là cổng vào/ra Slack, đọc/ghi bus. + +## 2. Quyết định đã chốt + +| Nhánh | Chọn | Lý do | +|---|---|---| +| Scope v1 | **Bidirectional** (OUT + IN) | Toàn bộ giá trị GĐ2 là boss tag được agent. | +| Topology | **Một `#squad` + threads** | 1 task = 1 bus channel = 1 Slack thread; giống team người dùng 1 channel + thread theo việc. | +| Connection | **Socket Mode** (trên hub app) | Chạy sau NAT trên máy Mac, không cần public URL/ngrok. Cần app-token `xapp-`. | +| Identity | **Mỗi agent một Slack app/bot thật** | Agent là Slack principal thật → `@mention`/DM/avatar/presence native. Một **hub app** chạy Socket Mode (inbound) + là giọng fallback cho escalation & agent chưa có token. | +| Lib | **github.com/slack-go/slack** | Có sẵn Web API + `socketmode` (handshake/ack/reconnect). Pin version, `go mod tidy`. | + +> **Ghi chú quyết định:** đã cân nhắc 1-bot-impersonate (zero-setup nhưng không +> mention/DM native được vì agent không phải principal thật) vs mỗi-agent-một-app +> (fidelity cao, đúng "mô phỏng team người", nhưng tốn N app khi onboard). Chọn +> **mỗi agent một app**; agent nào thiếu token thì **fallback** sang hub bot + +> username override (degrade êm, không crash). + +## 3. Kiến trúc + +``` +bus (.hatch/bus, SoT) ◄── MCP chat_post ── Claude/Codex/agy + ▲ │ + │ ghi │ tail (poll 2s) + │ ▼ + ┌──────────────── hatch slack (bridge) ─────────────────┐ + │ OUT: bus→Slack (impersonate username+icon, threadmap)│──► #squad + │ IN : Slack→bus (Socket Mode WS, chỉ tin người thật) │◄── boss @tag + └───────────────────────────────────────────────────────┘ + │ tin boss ghi vào bus (From=boss, Kind=user) + ▼ + hatch daemon (riêng) ── resume-exec ──► đánh thức agent được @tag +``` + +Bridge và daemon là **hai process độc lập** cùng tail bus. Daemon không biết +Slack tồn tại; nó chỉ thấy một tin mới từ member boss và đánh thức người được tag. + +## 4. OUT — bus → Slack + +Tail bus theo cursor TS (đúng `daemon.tail()`). Với mỗi tin mới `m` ở channel `ch`: + +1. **Bỏ qua nếu `m.From == cfg.Boss`** — boss đã gõ tin đó trong Slack rồi; đây + cũng là chốt chống loop chiều IN. +2. Tra `threadmap[ch]`: + - **Chưa có** → post `m` làm **root message** mới vào `#squad` (text prefix + `*#*\n`), lưu `threadmap[ch] = ts` trả về. + - **Đã có** → post `m` làm **reply** với `thread_ts = threadmap[ch]`. +3. Chọn client theo `m.From`: agent có app riêng → post bằng **bot token của + chính nó** (danh tính thật, name/avatar do app đó cấu hình). Không có → + **fallback** hub bot + override `username=displayName(m.From)`,`icon_emoji`. + +Bus có sub-threading (`InReplyTo`) nhưng Slack chỉ 1 cấp → **collapse**: cả +channel bus = 1 Slack thread; `InReplyTo` vẫn còn nguyên trong SoT, chỉ không +phản ánh vào layout Slack. Escalation (`From="hatch"` ở `dm-hatch-`) → +fallback hub bot (icon `:bell:`) → boss thấy cảnh báo. + +## 5. IN — Slack → bus (Socket Mode, trên hub app) + +Lắng `message` event. **Chỉ nuốt tin người thật**: bỏ qua khi có `bot_id`, +`user` rỗng, có `subtype` (edit/join), hoặc không thuộc `cfg.ChannelID`. + +- **Reply trong thread** (`thread_ts` có): `ch = reverseThreadmap[thread_ts]`. + Tìm thấy → post bus vào `ch`. Không thấy → tạo channel `t-`. +- **Top-level** (không `thread_ts`): tạo channel mới `t-`, + **set `threadmap[ch] = ts` ngay** (để reply OUT của agent nest dưới tin boss). + +**Dịch mention:** boss `@codex` trong Slack → tin chứa `<@Ucodex>` (Slack +resolve sang bot user-id của app codex). `translateMentions` đổi `<@Ucodex>` → +`@codex` nhờ bảng `mentions[botUserID]=agentID` (dựng lúc start qua `auth.test` +mỗi agent token). Mention người thật (id không map) giữ nguyên token. + +Post bus: `{Channel: ch, From: cfg.Boss, Type: msg, Body: text}`. `bus.Post` tự +rút `@codex` từ text (đã có `Mentions()`), điền `To` → daemon thấy → resume. + +## 6. Chống loop (bất biến an toàn) + +- Tin agent → OUT mirror lên Slack (as agent) → Slack echo event có + `bot_id`=bot mình → IN bỏ qua. ✔ +- Tin boss trong Slack → IN ghi bus (`From=boss`) → OUT bỏ qua (`From==boss`). ✔ + +Không cần tag origin riêng; hai luật trên đủ. + +## 7. Cấu phần & file + +``` +internal/slack/ + config.go Config{AppToken,HubToken,ChannelID,Boss,Agents{id→token}}; load json→env override; validate + identity.go displayName(member) + icon(kind) — dùng cho fallback impersonation + threadmap.go load/save .hatch/run/slack/threadmap.json (ch→ts) + reverse in-mem + bridge.go Bridge{Bus,Roster,poster,tm,mentions,cursor}: mirrorOnce() (OUT), handleIncoming()+translateMentions() (IN-pure) + runtime.go Run(): hub client + per-agent clients, auth.test→mentions map, socketmode loop, multiPoster/dryPoster + bridge_test.go fake poster + temp bus: OUT threadmap/skip-boss/from, IN top-level/reply/echo-skip/mention-translate +internal/cli/slackcmd.go hatch slack [--interval 2s] [--once] [--dry-run] +internal/paths/paths.go SlackDir="slack", SlackConfig, SlackThreadmap +.gitignore .hatch/run/slack/ +``` + +**Testability:** poster ẩn sau interface `poster{ post(from,threadTS,name,icon,text) (ts,error) }`. OUT (`mirrorOnce`), IN (`handleIncoming(incoming)`) và `translateMentions` là method thuần, test bằng fake poster + temp bus. Toàn bộ slack-go (multiPoster, socketmode, auth.test) nằm trong `runtime.go`. + +## 8. Config & bảo mật + +- **Hub app** (1 cái): scopes `chat:write`, `chat:write.customize`, + `channels:history`, `connections:write`; bật Socket Mode; subscribe + `message.channels`. Cho app-token `xapp-` (inbound) + bot-token `xoxb-` + (escalation + fallback). +- **Mỗi agent một app**: scope `chat:write`, mời vào channel; bot-token `xoxb-`. +- Nạp qua env `HATCH_SLACK_{APP_TOKEN,BOT_TOKEN,CHANNEL,BOSS}` + + `HATCH_SLACK_TOKEN_` (vd `HATCH_SLACK_TOKEN_CLAUDE_CODE`), hoặc + `.hatch/run/slack/config.json` (field `agents{id→token}`). +- **Không hardcode, không commit token.** `.hatch/run/slack/` vào `.gitignore`. +- Đây là Slack workspace của chính boss, chính boss yêu cầu → không vướng rule + "comm ra ngoài chưa review". + +## 9. Giới hạn v1 (ngoài scope) + +- `@codex`/`@claude`/`@agy`/`@kiro` từ Slack đều **auto-resume** (cả 4 kind đã có + headless contract trong `daemon/runner.go`): claude `-p --resume`, codex + `exec resume`, agy `-p --conversation`, kiro `kiro-cli chat --no-interactive + --resume-id`. Chỉ `manual`/`user` là ghế tương tác, không bị daemon đánh thức. + (kiro cần `KIRO_API_KEY` trong env. Contract cả agy lẫn kiro verify trực tiếp + trên CLI đã cài: kiro `chat [OPTIONS] [INPUT]` + `--no-interactive` + + `--resume-id `.) +- OUT poll 2s (không fsnotify) — chấp nhận, đơn giản. +- Slack 1 cấp thread — `InReplyTo` bus không phản ánh vào Slack layout. +- Chạy `hatch slack` + `hatch daemon` là **hai lệnh**. `hatch up` gộp = sau. + +## 10. DoD + +- [ ] `make lint` xanh; `go test ./...` và `-tags hatch_legacy` đều xanh. +- [ ] Build sạch cả hai tag. +- [ ] `hatch slack --once` chạy OUT một nhịp không cần Socket Mode (smoke). +- [ ] Token không lọt vào git; `.hatch/run/slack/` gitignored. +- [ ] @tag reviewer khác, không tự merge (human gate). diff --git a/docs/superpowers/specs/2026-06-22-hatch-team-simulation-design.md b/docs/superpowers/specs/2026-06-22-hatch-team-simulation-design.md new file mode 100644 index 0000000..4f4a294 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-hatch-team-simulation-design.md @@ -0,0 +1,264 @@ +# Hatch — Team Simulation Design (chat-first, peer-to-peer, wake-on-message) + +_Ngày: 22/06/2026 · Trạng thái: Approved design → implementation_ +_Tiền đề: [nghiên cứu paperclip vs overclaud](../../research/2026-06-22-paperclip-vs-overclaud-chat-first.md). Đã brainstorm + chốt với owner qua nhiều vòng._ + +--- + +## 1. North star + +**Hatch = một văn phòng remote async cho một squad coding-agent CLI làm chung một repo.** Không phải hệ-thống-xoay-quanh-task (paperclip). Khi user `hatch init` một project = mở một **căn phòng chung**: các agent CLI khác nhau (Claude Code, Codex, agy, Kiro) **join**, **nhận biết sự tồn tại của nhau** (presence/roster), **hiểu cùng một context** (repo + chat + docs/KB). Khi cần phối hợp, chúng **chat trực tiếp với nhau** — không qua engine điều phối. Triết lý: **mô phỏng một team người làm việc thật**, với **ownership cao** và **kỷ luật chuyên nghiệp**, không chat cho vui, không đùn đẩy. + +## 2. Nguyên tắc cốt lõi (SSOT-level) + +1. **Tách bạch hai thứ hay bị lẫn:** + - **Work-orchestration** (quyết định *ai-làm-gì*: assign, lane-engine, checkout-lock): **KHÔNG có.** Không engine nào tự sinh/giao việc. + - **Message-delivery + wake** (đưa tin tới đúng người, cấp cho họ một lượt để đáp): **CÓ.** Đây là vai "chat server" — mọi team chat đều cần. Wake **luôn là hệ quả của một message ai đó chủ động gửi**, không bao giờ do scheduler tự phát. +2. **Không có sếp-phần-mềm, nhưng luôn có sếp-người = user.** User là boss: đặt mục tiêu, ưu tiên, duyệt, bẻ lái. Phần mềm không thay quyền đó. +3. **Chat là kênh tương tác chính. Task-management bị giáng vai** xuống lớp plan/docs/note (artifact để *lập kế hoạch & ghi nhớ*), không phải kênh điều phối. Không còn lane backlog→in-progress→review→done như một engine. +4. **Embedded, lean, surgical.** Go single-binary, filesystem-as-DB. Lean Hexagonal (model/port/adapter). Wake daemon là thành phần MỚI trong **build mặc định**, không revive `orchestrator` cũ (đang sau `hatch_legacy`). +5. **Norm + Backstop.** Hành vi chuyên nghiệp được dạy bằng **norm** (prose compile xuống mọi agent) và được giữ bằng **backstop** (cơ chế trong daemon/MCP). Prose-suông không đủ để đảm bảo ownership. + +## 3. Kiến trúc tổng thể + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ WORKSPACE (1 repo · 1 .hatch) "căn phòng của team" │ +│ THÀNH VIÊN: claude · codex · agy · kiro · USER (mỗi agent = session │ +│ có trí nhớ riêng; idle thì ngủ, bị gọi thì thức) │ +│ └──────────── đọc/ghi qua MCP ───────────┐ │ +│ ┌────────────────────────────────────────▼────────┐ │ +│ │ HATCH MCP SERVER + WAKE DAEMON │ │ +│ │ whoami·join·roster·chat_*·kb_* | wake policy │ │ +│ └──┬──────────────┬───────────────┬───────┬────────┘ │ +│ ┌───────▼─┐ ┌─────────▼──┐ ┌──────────▼─┐ ┌──▼──────────┐ │ +│ │ PRESENCE│ │ CHAT BUS │ │ DOCS / KB │ │ GIT REPO │ │ +│ │ /ROSTER │ │ ⭐ chính │ │ plan·spec· │ │ code chung │ │ +│ │ ai ở đây│ │ #chan·DM· │ │ note·ADR = │ │ │ │ +│ │ on/idle │ │ thread·@ │ │ task layer │ │ │ │ +│ └─────────┘ └────────────┘ └────────────┘ └─────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +Năm thành phần: + +| # | Thành phần | Vai | Trạng thái code | +|---|---|---|---| +| 1 | **Workspace** | Căn phòng: repo + `.hatch` (chat + docs/KB + roster) | Có (`hatch init`) | +| 2 | **Presence / Roster** | Ai đã join, on/idle, vai trò, last-seen | **MỚI trong build mặc định** (legacy có bản khác) | +| 3 | **Chat bus** ⭐ | Kênh tương tác chính: channel/DM/thread/@mention/inbox | Có (`internal/bus`) — giữ làm trung tâm | +| 4 | **Wake daemon** | Theo bus, @mention → wake đúng agent một lượt. Chỉ delivery | **MỚI** — keystone | +| 5 | **Docs / KB** | Task giáng vai: plan/spec/note/decision | Có (`kb/` + `templates/docs/`) — giữ; gỡ lane-engine | + +## 4. Vòng đời teammate (state machine) + +**Mặc định: resume-on-wake** ("ngủ khi rảnh, thức khi bị gọi, giữ trí nhớ"). + +``` + bị @mention / DM / reply-vào-ask-đang-mở + │ + ┌──────────────┐ wake: resume ┌──────────────┐ post xong → ngủ + │ SUSPENDED │ session + đẩy │ WORKING │ + │ ngủ · $0 ├───────────────►│ một lượt: ├──────┐ + │ trí nhớ lưu │ │ đọc phòng → │ │ + │ trong session│◄───────────────┤ hành động → │◄─────┘ + └──────────────┘ │ post reply │ + └──────────────┘ +``` + +- **Continuity có thật** qua resume: `claude --resume -p`, `codex resume`/`codex exec`. +- **Idle = $0** (session suspended; không đốt token). +- **Optional "live mode"** (Claude Code `--channels`): giữ session ấm, push tin vào để đáp gần-tức-thì. Dùng cho ghế user đang pair. **Không** phải mặc định. +- **User = thành viên người luôn-online**; agent user đang gõ cùng chỉ là "người đang pair", về kiến trúc mọi agent đều wake-on-message. + +### Mapping kỹ thuật (đã verify) + +| Khả năng | Claude Code | Codex | +|---|---|---| +| Headless 1 lượt | `claude -p "…"` (`--output-format json/stream-json`) | `codex exec "…"` | +| Resume giữ trí nhớ | `claude --resume ` / `-c` | `codex resume --last` / `resume ` | +| Wake từ ngoài | `--channels` (MCP `claude/channel` push vào session đang chạy) | `app-server`/`remote-control` (experimental); hoặc resume-exec | +| Đọc/ghi chat | MCP stdio client (`.mcp.json`) | `codex mcp` (MCP client) | +| Session store | `~/.claude/projects//sessions/` | session id từ `codex` | + +→ Mặc định dùng **resume-exec** (tương thích cả hai). `--channels` là nâng cấp live-mode chỉ cho Claude Code. + +## 5. Wake Policy (cơ chế delivery — backstop chống wake-storm) + +Đặt trong **domain** dưới dạng **hàm thuần, test được**; daemon chỉ là vòng lặp gọi nó. + +``` +Input: roster (ai on/idle/role), tập message mới chưa giao, trạng thái wake + hiện hành (ai đang working, cursor mỗi agent, cascade depth, rate window) +Output: danh sách WakeDecision{agent, reason, payload[], holdUntil?} + escalations + +LUẬT: + 1. TRIGGER hợp lệ để wake một agent: chỉ khi message + (a) @mention đích danh agent đó, hoặc + (b) reply vào một ask đang-mở của agent đó, hoặc + (c) DM tới agent đó. + Message không @ai và không phải (b)/(c) → KHÔNG wake ai (chỉ lưu vào bus). + 2. COALESCE: nhiều message tới cùng một agent đang SUSPENDED trong cửa sổ W + → gộp thành MỘT wake mang tất cả payload. + 3. DEBOUNCE: agent đang WORKING → không wake; xếp payload vào hàng đợi, + giao ở ranh giới lượt kế tiếp của agent đó. + 4. NO SELF / ECHO WAKE: post của chính mình không wake mình; reply không + re-wake người vừa nói trừ khi bị @ đích danh. + 5. DEPTH LIMIT: mỗi "episode" (cây hội thoại bắt nguồn từ 1 tin của con người + hoặc 1 thread root) mang cascade depth; auto-wake vượt D → HOLD + escalate user. + 6. RATE CAP: trần wake/phút mỗi agent; vượt → xếp hàng (HOLD), không drop. + 7. LOOP BREAK: một cặp agent ping-pong ≥ N vòng trong một episode mà không có + deliverable / decision-message / thay-đổi-repo → cắt auto-wake, escalate user. +``` + +Tham số mặc định (config được): `W=2s`, `D=4`, `RateCap=6/phút`, `N=3 vòng`. + +## 6. Working Agreement (norm — định nghĩa "chuyên nghiệp") + +Nâng thành **mục SSOT mới** `.hatch/working-agreement.md`, compile xuống mọi agent (như charter/roles). Sáu điều, mỗi điều ánh xạ tới backstop: + +| # | Norm | Backstop | +|---|---|---| +| 1 | **Bias to action** — việc reversible thì làm, hỏi chỉ khi irreversible/mơ hồ tốn kém | — (văn hoá) | +| 2 | **Mỗi tin xứng token của nó** — info/quyết định/deliverable/câu hỏi cụ thể; cấm "ok/thanks/noted" | Wake Policy #1 (tin rỗng không wake ai) | +| 3 | **Own cái mày mở / cái mày bị nhờ** — handoff phải *có tên người kế* + *được ack* | Roster "current owner" mỗi thread; view phơi thread mồ côi | +| 4 | **Close the loop** — xong thì post kết quả; kẹt thì post blocker + cần ai; không im lặng | Daemon nudge owner 1 lần khi thread đứng, rồi escalate user | +| 5 | **Self-check DoD trước khi báo xong** — tag reviewer khác, không tự duyệt | (đã có trong CLAUDE.md DoD) | +| 6 | **Escalate sớm lên user** khi kẹt ở một *quyết định* | Wake Policy #5/#7 (depth/loop → user) | + +## 7. Governance / human-in-the-loop (2 giai đoạn) + +Cùng một bộ máy (bus + daemon + presence); chỉ khác **điểm vào** của con người. + +- **GĐ1 — Boss qua proxy (MVP, không cần UI mới).** User nói chuyện với một agent đóng vai **team-leader** (mặc định Claude Code = role `conductor`). Team-leader nhận việc → có thể **viết plan doc** → điều phối **qua chat** (post `@codex @agy` như peer). Team-leader là **delegate của boss**, KHÔNG phải engine. +- **GĐ2 — Boss vào thẳng phòng (roadmap).** Một **chat UI kiểu Slack** render bus; user là member hạng nhất, thấy mọi chat của agent, `@tag` thẳng bất kỳ agent để bơm prompt. Daemon giao `@codex` của user y như của một peer. + +**Bất biến:** GĐ2 = GĐ1 với con người dời từ "ngoài, qua proxy" vào "trong phòng, trực tiếp". Lõi không đổi. Role `conductor` KHÔNG bị xóa — **reframe** thành delegate của boss (khớp `claude-code = Conductor mặc định` đang có). + +## 8. Thay đổi SSOT (charter & roles) + +- **`charter.md`:** thêm nguyên tắc tách *work-orchestration* (không) vs *delivery/wake* (có); ghi "không sếp-phần-mềm, có sếp-người = user"; nêu chat là kênh chính, task = plan/docs layer. +- **`workflow.yaml`:** bỏ mô hình lane-engine như cơ chế; thay bằng mô tả "phối hợp qua chat + Working Agreement + Wake Policy". Giữ phần nghi thức (planning/retro/standup) như *thói quen*, không phải engine. +- **`working-agreement.md` (mới):** 6 điều ở §6. +- **`roles/conductor.md`:** reframe = delegate của boss (proxy GĐ1), điều phối qua chat, không tự merge/không engine. +- Sau khi sửa: `hatch compile` đẩy xuống CLAUDE.md/AGENTS.md/… + +## 9. Build / Keep / Remove (cụ thể theo package) + +Tất cả phần mới nhắm **build mặc định** (không tag). Phải build sạch cả `default` lẫn `-tags hatch_legacy`. + +| Hành động | Chi tiết | +|---|---| +| **KEEP** | `internal/bus` (trung tâm), `internal/mcpserver` (mở rộng), `internal/store` (KB/docs), `internal/compile`, `internal/model.Message`, `internal/tui` (chat view) | +| **ADD (default)** | `internal/model`: `Member`/`Roster` (presence-in-room), `WakeDecision`; `internal/wake` (Wake Policy thuần hàm + decider); `internal/roster` (store roster filesystem); daemon loop trong `cmd/hatch` (`hatch watch`); MCP tools `join`/`roster`/`leave` | +| **ADAPTER** | wake spawning dùng *khái niệm* `Adapter.Build(RunRequest)→Invocation` nhưng tách bản default-build gọn (không kéo `orchestrator.Run` legacy dính ticket/ledger). Một `wake.Runner` port; adapter per kind (claude/codex/agy/mock) build lệnh resume-exec | +| **REMOVE / KHÔNG dùng ở default** | lane-engine `wf` (đã ở legacy) — không revive; ledger như SoT điều phối — không revive; board-lane CLI | +| **SSOT** | sửa charter/workflow/roles + thêm working-agreement; compile | + +## 10. Data model (domain mới) + +```go +// internal/model/member.go (build mặc định) +type Member struct { + ID string // "codex" + Kind string // "claude" | "codex" | "agy" | "kiro" | "mock" + Roles []string // ["implementer","reviewer"] + SessionID string // id session resumable (trí nhớ); "" nếu chưa có + Status string // online | idle | suspended | offline + LastSeen string // RFC3339 + Note string +} +type Roster map[string]Member + +// internal/model/wake.go +type WakeReason string // "mention" | "reply_to_open_ask" | "dm" | "nudge" +type WakeDecision struct { + Agent string + Reason WakeReason + Payload []Message // message gây wake (đã coalesce) + HoldUntil string // nếu bị rate/depth hold +} +type Escalation struct { + Episode string + Cause string // "depth_limit" | "loop_break" | "stalled_owner" + To string // thường là user/boss + Note string +} +``` + +`internal/wake` (thuần hàm): +```go +type State struct { + Working map[string]bool // agent đang working + Cursors map[string]string // agent → ts đã giao tới + Depth map[string]int // episode → cascade depth + Rate: map[string][]time.Time + PingPong map[string]int // "a|b|episode" → số vòng +} +type Config struct{ W time.Duration; Depth, RateCap, LoopRounds int } +func Decide(r model.Roster, newMsgs []model.Message, st State, cfg Config) + (decisions []model.WakeDecision, esc []model.Escalation, next State) +``` +Decider không có IO — test bằng bảng input/output. Daemon là adapter quanh nó. + +## 11. MCP surface (mở rộng) + +Giữ: `whoami · chat_open · chat_post · chat_read · chat_inbox · chat_search · chat_channels · kb_add · kb_search`. +Thêm: +- `join {kind, roles[], session_id?}` → đăng ký Member vào roster, set `online`, lưu `session_id` để wake resume. +- `roster {}` → liệt kê thành viên + status + roles + last_seen (để agent "biết ai ở trong phòng"). +- `leave {}` → set `offline`. +- (heartbeat ngầm) mọi tool call cập nhật `LastSeen`; idle quá ngưỡng → `idle`. + +## 12. Luồng dữ liệu — một episode phối hợp + +``` +USER →(GĐ1) team-leader: "review auth.go giúp" + team-leader: viết plan note (docs) nếu cần; post thread T "@codex review auth.go" + daemon: thấy @codex (Wake Policy #1) → codex SUSPENDED → COALESCE → resume-exec codex + codex: join/whoami → roster → đọc thread T + repo (MCP) → review → post reply vào T + daemon: reply có @team-leader? nếu có → wake; nếu chỉ là kết quả → không cascade (#4) + team-leader: tổng hợp, báo user. Episode đóng khi có deliverable/decision. +``` + +## 13. Implementation plan (phân pha, surgical) + +**Phase A — Roster (foundation, build mặc định).** +- `model.Member`/`Roster`; `internal/roster` store (`.hatch/roster.json`); MCP `join`/`roster`/`leave`; `hatch roster` view; cập nhật `LastSeen` qua MCP. Unit + integration test. *Shippable độc lập.* + +**Phase B — Wake Policy (domain thuần).** +- `model.WakeDecision`/`Escalation`; `internal/wake.Decide` + `State`/`Config`. Bảng test đầy đủ 7 luật. *Không IO, không cần daemon.* + +**Phase C — Wake daemon (adapter + loop).** +- `wake.Runner` port + adapter per kind (resume-exec build lệnh; `mock` cho test). `hatch watch`: tail bus → `wake.Decide` → spawn qua Runner → mark cursor. Escalation → post vào DM của user. Tích hợp test với `mock` runner. + +**Phase D — SSOT + compile.** +- Sửa charter/workflow/roles + thêm working-agreement; `hatch compile`; cập nhật CLAUDE.md output. Snapshot test compile. + +**Phase E (roadmap, ngoài MVP) — live-mode `--channels` + Slack-like UI (GĐ2).** + +## 14. Testing + +- **wake.Decide:** bảng input→output cho cả 7 luật (mention/echo/coalesce/debounce/depth/rate/loop). Đây là phần logic dày nhất, phải phủ kỹ. +- **roster:** join/leave/idle-timeout/last-seen. +- **daemon:** dùng `mock` runner (không spawn thật) + bus tạm; xác minh wake đúng agent, coalesce, escalate. +- **MCP:** test `join`/`roster` qua server_test hiện có. +- **DoD:** `make lint && make test` xanh cả default lẫn `-tags hatch_legacy`; `go build ./...` cả hai tag. + +## 15. Rủi ro & quyết định mở + +- **Spawn lại trong default build** mâu thuẫn "embedded, không điều khiển" cũ → giải bằng tách work-orchestration vs delivery (§2.1) và sửa charter (§8). Đây là quyết định kiến trúc có chủ đích, không phải revert. +- **Resume-exec mỗi wake = cold-start cost** (khởi động CLI mỗi lượt). Chấp nhận ở MVP; live-mode `--channels` (Phase E) là tối ưu sau. +- **agy/kiro headless contract** → đã chốt (sau MVP): agy `-p --conversation `, kiro `kiro-cli chat --no-interactive --resume-id ` (auth `KIRO_API_KEY`). Cả 4 kind auto-resume; chỉ `manual`/`user` là ghế tương tác. Cả hai verify trực tiếp trên CLI đã cài (agy probe tay; kiro `--help`: `chat [OPTIONS] [INPUT]`). +- **Episode boundary** (để tính depth/loop) định nghĩa = cây reply bắt nguồn từ một tin của người, hoặc thread root. Cần chốt khi code Phase B. +- **Wake một session đã tắt** chỉ làm được qua resume-exec (spawn mới giữ trí nhớ), không qua `--channels`. Mặc định resume-exec nên không vướng. + +--- + +## Phụ lục — bất biến để không lạc hướng khi code + +1. Daemon **chỉ** wake do một message chủ động; **không** tự sinh việc. +2. **Không** assign/lane/lock. "Owner" là *suy ra* từ chat (ai claim/open gần nhất), chỉ để hiển thị + nudge. +3. Phần mới ở **build mặc định**; build sạch cả hai tag. +4. Logic wake là **hàm thuần** (`internal/wake`); spawning là **adapter**. +5. Mọi norm sống trong **SSOT → compile**, không hard-code vào engine. diff --git a/hatch/go.mod b/hatch/go.mod index d2af591..5e0109e 100644 --- a/hatch/go.mod +++ b/hatch/go.mod @@ -3,52 +3,20 @@ module github.com/fioenix/overclaud/hatch go 1.25.0 require ( - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v1.0.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/slack-go/slack v0.26.0 github.com/spf13/cobra v1.8.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/jsonschema-go v0.4.3 // indirect - github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/yuin/goldmark v1.7.13 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect ) diff --git a/hatch/go.sum b/hatch/go.sum index d92bd6e..a49aa53 100644 --- a/hatch/go.sum +++ b/hatch/go.sum @@ -1,114 +1,41 @@ -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= -github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/slack-go/slack v0.26.0 h1:hx5Iy1t89tSw2zLEHu5YFFTDDFGmvhYCUh73ptHQ2Ls= +github.com/slack-go/slack v0.26.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/hatch/internal/bus/bus.go b/hatch/internal/bus/bus.go index f22bdc7..6fb7519 100644 --- a/hatch/internal/bus/bus.go +++ b/hatch/internal/bus/bus.go @@ -38,7 +38,7 @@ type Bus struct{ L paths.Layout } // New returns a bus bound to a workspace layout. func New(l paths.Layout) *Bus { return &Bus{L: l} } -func (b *Bus) dir() string { return filepath.Join(b.L.Root, "bus") } +func (b *Bus) dir() string { return b.L.Bus() } func (b *Bus) threadsDir() string { return filepath.Join(b.dir(), "threads") } func (b *Bus) cursorsPath() string { return filepath.Join(b.dir(), ".cursors.json") } diff --git a/hatch/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go deleted file mode 100644 index d90d2bd..0000000 --- a/hatch/internal/ceremony/ceremony.go +++ /dev/null @@ -1,270 +0,0 @@ -//go:build hatch_legacy - -// Package ceremony runs the recurring squad rituals declared in workflow.yaml: -// standup (per-agent digest + blockers), retro (cycle summary + KB promotion -// candidates). Planning is delegated to the orchestrator (spawn the Conductor). -package ceremony - -import ( - "fmt" - "sort" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/port" -) - -// Service runs the squad rituals as a use-case over ports — it depends on no -// concrete infrastructure (the composition root injects adapters). -type Service struct { - Board port.Board - Ledger port.Ledger - Bus port.Bus - KB port.KB -} - -// StandupReport summarises recent activity per agent plus current blockers. -type StandupReport struct { - Markdown string - PerAgent map[string][]string - Blockers []model.Ticket -} - -// Standup builds a digest from the last `days` of ledger activity and the -// board's blocked lanes — the deterministic equivalent of "what did you do, -// what's next, what's blocking you". -func (s Service) Standup(ws *config.Workspace, days int) (*StandupReport, error) { - if days < 1 { - days = 1 - } - entries, err := s.Ledger.Recent(days) - if err != nil { - return nil, err - } - perAgent := map[string][]string{} - for _, e := range entries { - if e.Agent == "" { - continue - } - line := e.Action - if e.Ticket != "" && e.Ticket != "-" { - line += " " + e.Ticket - } - if e.Result != "" { - line += " (" + e.Result + ")" - } - perAgent[e.Agent] = appendUnique(perAgent[e.Agent], line) - } - - blockers, err := s.blockedTickets(ws) - if err != nil { - return nil, err - } - - var b strings.Builder - b.WriteString("# Standup\n\n") - if len(perAgent) == 0 { - b.WriteString("_Không có hoạt động ledger gần đây._\n") - } - for _, agent := range sortedKeys(perAgent) { - fmt.Fprintf(&b, "**%s**: %s\n", agent, strings.Join(perAgent[agent], ", ")) - } - if len(blockers) > 0 { - b.WriteString("\n## Blockers\n") - for _, t := range blockers { - fmt.Fprintf(&b, "- %s (%s) — %s\n", t.ID, t.Lane, t.Title) - } - } - return &StandupReport{Markdown: b.String(), PerAgent: perAgent, Blockers: blockers}, nil -} - -// RetroReport summarises a cycle and lists KB promotion candidates. -type RetroReport struct { - Markdown string - Done int - GateFailures int - Blocks int - Decisions int - PromotionCands []model.KBEntry -} - -// Retro summarises the whole ledger plus bus decisions and surfaces KB -// learnings as SSOT-promotion candidates. -func (s Service) Retro(ws *config.Workspace) (*RetroReport, error) { - entries, err := s.Ledger.Recent(0) - if err != nil { - return nil, err - } - r := &RetroReport{} - for _, e := range entries { - switch e.Action { - case model.ActDone: - r.Done++ - case model.ActBlock: - r.Blocks++ - case model.ActGate: - if strings.HasPrefix(e.Result, "failed") { - r.GateFailures++ - } - } - } - // Decisions recorded on the bus. - b := s.Bus - if decs, err := b.Search(model.SearchOpts{Type: model.MsgDecision, Limit: 100}); err == nil { - r.Decisions = len(decs) - } - // KB learnings are candidates to promote into SSOT. - if entriesKB, err := s.KB.List(); err == nil { - for _, e := range entriesKB { - if e.Type == model.KBLearning { - r.PromotionCands = append(r.PromotionCands, e) - } - } - } - - var sb strings.Builder - sb.WriteString("# Retro\n\n") - fmt.Fprintf(&sb, "- Done: %d\n- Blocks: %d\n- Gate failures: %d\n- Decisions: %d\n", r.Done, r.Blocks, r.GateFailures, r.Decisions) - if len(r.PromotionCands) > 0 { - sb.WriteString("\n## Ứng viên đề bạt KB → SSOT (learnings)\n") - for _, e := range r.PromotionCands { - fmt.Fprintf(&sb, "- %s %s — `kb/%s`\n", e.ID, e.Title, e.Path) - } - sb.WriteString("\nĐề bạt thủ công sau review (Architect/Conductor).\n") - } - r.Markdown = sb.String() - return r, nil -} - -// Demo lists work in terminal (done-like) lanes — the showcase for a sprint -// review / demo. Returns a report and the tickets shown. -func (s Service) Demo(ws *config.Workspace) (string, []model.Ticket, error) { - var done []model.Ticket - for _, id := range terminalLaneIDs(ws) { - ts, err := s.Board.ListLane(id) - if err != nil { - return "", nil, err - } - done = append(done, ts...) - } - var b strings.Builder - b.WriteString("# Demo / Sprint review\n\n") - if len(done) == 0 { - b.WriteString("_Chưa có việc hoàn thành để trình diễn._\n") - } - for _, t := range done { - who := t.Assignee - if who == "" { - who = "-" - } - fmt.Fprintf(&b, "- **%s** %s — done by %s", t.ID, t.Title, who) - if t.Epic != "" { - fmt.Fprintf(&b, " (epic %s)", t.Epic) - } - b.WriteString("\n") - } - return b.String(), done, nil -} - -// GroomItem is a backlog ticket needing refinement plus the reasons. -type GroomItem struct { - Ticket model.Ticket - Reasons []string -} - -// Grooming scans the entry (backlog) lane for under-specified tickets — the -// backlog refinement ritual: flag missing role/priority/acceptance. -func (s Service) Grooming(ws *config.Workspace) (string, []GroomItem, error) { - lane := entryLane(ws) - tickets, err := s.Board.ListLane(lane) - if err != nil { - return "", nil, err - } - var items []GroomItem - for _, t := range tickets { - var reasons []string - if t.Role == "" { - reasons = append(reasons, "thiếu role") - } - if t.Priority == "" { - reasons = append(reasons, "thiếu priority") - } - if !strings.Contains(t.Body, "- [ ]") { - reasons = append(reasons, "thiếu acceptance criteria") - } - if strings.Contains(t.Body, "TODO") { - reasons = append(reasons, "còn TODO trong mô tả") - } - if len(reasons) > 0 { - items = append(items, GroomItem{Ticket: t, Reasons: reasons}) - } - } - var b strings.Builder - fmt.Fprintf(&b, "# Backlog grooming (%s)\n\n", lane) - if len(items) == 0 { - b.WriteString("_Backlog đã đủ rõ — không có ticket cần chuốt._\n") - } - for _, it := range items { - fmt.Fprintf(&b, "- **%s** %s — %s\n", it.Ticket.ID, it.Ticket.Title, strings.Join(it.Reasons, ", ")) - } - return b.String(), items, nil -} - -func entryLane(ws *config.Workspace) string { - for _, l := range ws.Workflow.Lanes { - if !l.Side { - return l.ID - } - } - return ws.Workflow.Lanes[0].ID -} - -func terminalLaneIDs(ws *config.Workspace) []string { - outgoing := map[string]bool{} - for _, tr := range ws.Workflow.Transitions { - if tr.From != "*" { - outgoing[tr.From] = true - } - } - var out []string - for _, l := range ws.Workflow.Lanes { - if !l.Side && !outgoing[l.ID] { - out = append(out, l.ID) - } - } - return out -} - -func (s Service) blockedTickets(ws *config.Workspace) ([]model.Ticket, error) { - var out []model.Ticket - for _, l := range ws.Workflow.Lanes { - if !l.Side && l.ID != "blocked" { - continue - } - ts, err := s.Board.ListLane(l.ID) - if err != nil { - return nil, err - } - out = append(out, ts...) - } - return out, nil -} - -func appendUnique(xs []string, v string) []string { - for _, x := range xs { - if x == v { - return xs - } - } - return append(xs, v) -} - -func sortedKeys(m map[string][]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/hatch/internal/ceremony/ceremony_test.go b/hatch/internal/ceremony/ceremony_test.go deleted file mode 100644 index cd6ee8e..0000000 --- a/hatch/internal/ceremony/ceremony_test.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build hatch_legacy - -package ceremony - -import ( - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func svc(w *config.Workspace) Service { - return Service{ - Board: store.NewBoard(w.Layout), - Ledger: store.NewLedger(w.Layout), - Bus: bus.New(w.Layout), - KB: store.NewKB(w.Layout), - } -} - -func ws(t *testing.T) *config.Workspace { - t.Helper() - l, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - w, err := config.Load(l) - if err != nil { - t.Fatal(err) - } - return w -} - -func TestStandupDigestsByAgent(t *testing.T) { - w := ws(t) - lg := store.NewLedger(w.Layout) - lg.Append(model.Entry{Agent: "codex", Ticket: "T-001", Action: model.ActClaim, Why: "x"}) - lg.Append(model.Entry{Agent: "codex", Ticket: "T-001", Action: model.ActHandoff, Why: "y", Handoff: "done"}) - lg.Append(model.Entry{Agent: "claude-code", Ticket: "T-001", Action: model.ActReview, Why: "z", Result: "approved"}) - - rep, err := svc(w).Standup(w, 1) - if err != nil { - t.Fatal(err) - } - if _, ok := rep.PerAgent["codex"]; !ok { - t.Fatalf("expected codex in digest: %v", rep.PerAgent) - } - if !strings.Contains(rep.Markdown, "claude-code") { - t.Errorf("report missing claude-code:\n%s", rep.Markdown) - } -} - -func TestRetroCounts(t *testing.T) { - w := ws(t) - lg := store.NewLedger(w.Layout) - lg.Append(model.Entry{Agent: "codex", Ticket: "T-1", Action: model.ActGate, Result: "failed: x", Why: "g"}) - lg.Append(model.Entry{Agent: "claude-code", Ticket: "T-1", Action: model.ActDone, Why: "merged"}) - lg.Append(model.Entry{Agent: "codex", Ticket: "T-2", Action: model.ActBlock, Why: "stuck"}) - - r, err := svc(w).Retro(w) - if err != nil { - t.Fatal(err) - } - if r.Done != 1 || r.GateFailures != 1 || r.Blocks != 1 { - t.Fatalf("retro counts wrong: %+v", r) - } -} - -func TestDemoAndGrooming(t *testing.T) { - w := ws(t) - // grooming: a fresh backlog ticket from `ticket new` has TODO body + no priority. - b := store.NewBoard(w.Layout) - b.Write(model.Ticket{ID: "T-001", Lane: "backlog", Status: "backlog", Title: "vague", Body: "## Acceptance\nTODO\n"}) - _, items, err := svc(w).Grooming(w) - if err != nil { - t.Fatal(err) - } - if len(items) != 1 { - t.Fatalf("expected 1 groom item, got %d", len(items)) - } - // demo: a ticket in the terminal lane shows up. - b.Write(model.Ticket{ID: "T-002", Lane: "done", Status: "done", Title: "shipped", Assignee: "codex"}) - _, shown, err := svc(w).Demo(w) - if err != nil { - t.Fatal(err) - } - if len(shown) != 1 || shown[0].ID != "T-002" { - t.Fatalf("demo should show T-002, got %+v", shown) - } -} diff --git a/hatch/internal/cli/board.go b/hatch/internal/cli/board.go deleted file mode 100644 index 3479b02..0000000 --- a/hatch/internal/cli/board.go +++ /dev/null @@ -1,40 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/tui" -) - -func newBoardCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "board", - Aliases: []string{"watch"}, - Short: "Alias for `hatch chat` (live chat + squad stats)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - _, err = tui.NewChat(ws).Run() - return err - }, - } - return cmd -} - -func newChatCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "chat", - Short: "Read-only Slack-style TUI for watching agent communication", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - _, err = tui.NewChat(ws).Run() - return err - }, - } - return cmd -} diff --git a/hatch/internal/cli/ceremony.go b/hatch/internal/cli/ceremony.go deleted file mode 100644 index b8c752d..0000000 --- a/hatch/internal/cli/ceremony.go +++ /dev/null @@ -1,180 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" -) - -func newCeremonyCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ceremony", - Aliases: []string{"cer"}, - Short: "Run squad rituals: standup, retro, planning", - } - cmd.AddCommand(newStandupCeremonyCmd(), newRetroCmd(), newPlanningCmd(), newDemoCmd(), newGroomingCmd()) - return cmd -} - -func newDemoCmd() *cobra.Command { - var post bool - cmd := &cobra.Command{ - Use: "demo", - Short: "Showcase completed work (sprint review); posts to #demo", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - report, _, err := ceremonyService(ws).Demo(ws) - if err != nil { - return err - } - fmt.Fprint(cmd.OutOrStdout(), report) - if post { - if _, err := bus.New(ws.Layout).Post(bus.Message{ - Channel: "#demo", From: ceremonyChair(ws, "demo"), To: []string{"*"}, Body: report, - }); err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), "\n(posted to #demo)") - } - return nil - }, - } - cmd.Flags().BoolVar(&post, "post", true, "post the showcase to #demo") - return cmd -} - -func newGroomingCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "grooming", - Short: "Flag under-specified backlog tickets (missing role/priority/acceptance)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - report, items, err := ceremonyService(ws).Grooming(ws) - if err != nil { - return err - } - fmt.Fprint(cmd.OutOrStdout(), report) - if len(items) > 0 { - return fmt.Errorf("%d backlog ticket(s) need refinement", len(items)) - } - return nil - }, - } - return cmd -} - -func newStandupCeremonyCmd() *cobra.Command { - var days int - var post bool - cmd := &cobra.Command{ - Use: "standup", - Short: "Per-agent digest of recent activity + blockers (posts to #standup)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - rep, err := ceremonyService(ws).Standup(ws, days) - if err != nil { - return err - } - out := cmd.OutOrStdout() - fmt.Fprint(out, rep.Markdown) - if post { - chair := ceremonyChair(ws, "standup-digest") - if _, err := bus.New(ws.Layout).Post(bus.Message{ - Channel: "#standup", From: chair, To: []string{"*"}, Body: rep.Markdown, - }); err != nil { - return err - } - fmt.Fprintln(out, "\n(posted to #standup)") - } - return nil - }, - } - cmd.Flags().IntVar(&days, "days", 1, "days of ledger history to digest") - cmd.Flags().BoolVar(&post, "post", true, "post the digest to #standup") - return cmd -} - -func newRetroCmd() *cobra.Command { - var write bool - cmd := &cobra.Command{ - Use: "retro", - Short: "Cycle summary + KB→SSOT promotion candidates", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - rep, err := ceremonyService(ws).Retro(ws) - if err != nil { - return err - } - out := cmd.OutOrStdout() - fmt.Fprint(out, rep.Markdown) - if write { - name := "retro-" + time.Now().Format("2006-01-02") + ".md" - p := filepath.Join(ws.Layout.Ledger(), name) - if err := os.WriteFile(p, []byte(rep.Markdown), 0o644); err != nil { - return err - } - fmt.Fprintf(out, "\n(wrote %s)\n", p) - } - return nil - }, - } - cmd.Flags().BoolVar(&write, "write", false, "save the retro summary under ledger/") - return cmd -} - -func newPlanningCmd() *cobra.Command { - var agentID string - var dryRun bool - cmd := &cobra.Command{ - Use: "planning", - Short: "Spawn the Conductor to plan the next cycle", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - agent, err := pickAgent(ws, agentID, "conductor") - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "→ planning with %s (%s)\n", agent.ID, agent.Kind) - _, err = orch(ws).Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ - DryRun: dryRun, Stdout: cmd.OutOrStdout(), - }) - return err - }, - } - cmd.Flags().StringVar(&agentID, "agent", "", "agent id (default: first with role conductor)") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the invocation without running") - return cmd -} - -// ceremonyChair resolves who chairs a ceremony from workflow.yaml, falling back -// to a human facilitator. -func ceremonyChair(ws *config.Workspace, name string) string { - if c, ok := ws.Workflow.Ceremonies[name]; ok && c.By != "" { - return c.By - } - return "human:facilitator" -} diff --git a/hatch/internal/cli/client.go b/hatch/internal/cli/client.go index 1bb6e13..e8d8d9e 100644 --- a/hatch/internal/cli/client.go +++ b/hatch/internal/cli/client.go @@ -79,7 +79,7 @@ func setupClient(cmd *cobra.Command, ws *config.Workspace, repoRoot, alias strin case "codex": // Codex owns ~/.codex/config.toml; let its own CLI edit it. - snippet := filepath.Join(ws.Layout.Root, "mcp", id+".codex.toml") + snippet := filepath.Join(ws.Layout.MCPSnippets(), id+".codex.toml") if path, err := exec.LookPath("codex"); err == nil && !dryRun { cargs := append([]string{"mcp", "add", "hatch", "--"}, append([]string{"hatch"}, args...)...) c := exec.Command(path, cargs...) diff --git a/hatch/internal/cli/client_test.go b/hatch/internal/cli/client_test.go index 2937a52..8a24795 100644 --- a/hatch/internal/cli/client_test.go +++ b/hatch/internal/cli/client_test.go @@ -1,5 +1,3 @@ -//go:build !hatch_legacy - package cli import ( diff --git a/hatch/internal/cli/comms_legacy.go b/hatch/internal/cli/comms_legacy.go deleted file mode 100644 index 78e7603..0000000 --- a/hatch/internal/cli/comms_legacy.go +++ /dev/null @@ -1,211 +0,0 @@ -//go:build hatch_legacy - -// ask/convene drive agents synchronously (the orchestrator relays turns). In -// the embedded-harness model agents converse asynchronously through the Hatch -// MCP server instead, so these are archived behind the `hatch_legacy` build -// tag. Build with `-tags hatch_legacy` to restore them. -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/decide" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" -) - -// roleOf returns an agent's primary role for framing conversation prompts. -func roleOf(a model.Agent) string { - if len(a.Roles) > 0 { - return a.Roles[0] - } - return "teammate" -} - -func newAskCmd() *cobra.Command { - var from, to, thread string - var dryRun bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "ask ", - Short: "Ask another agent a question and get a reply (synchronous relay)", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if from == "" || to == "" { - return fmt.Errorf("--from and --to are required") - } - target, ok := ws.Registry.AgentByID(to) - if !ok { - return fmt.Errorf("unknown agent %q", to) - } - if thread == "" { - thread = "ask-" + time.Now().Format("0102-150405") - } - question := strings.Join(args, " ") - bs := bus.New(ws.Layout) - if _, err := bs.Post(bus.Message{Channel: thread, From: from, To: []string{to}, Type: bus.TypeAsk, Body: question}); err != nil { - return err - } - raw, _ := bs.Raw(thread) - prompt := orchestrator.BuildConsultPrompt(from, roleOf(target), thread, raw, question) - out := cmd.OutOrStdout() - fmt.Fprintf(out, "%s → %s (thread %s)\n", from, to, thread) - outcome, err := orch(ws).Execute(ws, target, thread, prompt, orchestrator.RunOptions{ - DryRun: dryRun, Timeout: timeout, Stdout: out, - }) - if err != nil { - return err - } - if !outcome.Executed { - return nil // dry-run or manual handoff; nothing to record yet - } - reply := strings.TrimSpace(outcome.Output) - if reply == "" { - reply = "(no reply captured)" - } - if _, err := bs.Post(bus.Message{Channel: thread, From: to, To: []string{from}, Type: bus.TypeReply, Body: reply}); err != nil { - return err - } - fmt.Fprintf(out, "\n--- reply from %s recorded to thread %s ---\n", to, thread) - return nil - }, - } - cmd.Flags().StringVar(&from, "from", "", "asking agent (or human:)") - cmd.Flags().StringVar(&to, "to", "", "agent to ask (must be in registry)") - cmd.Flags().StringVar(&thread, "thread", "", "thread id (default: generated)") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show the relay invocation without running") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "kill the agent after this duration") - return cmd -} - -func newConveneCmd() *cobra.Command { - var thread, topic, agentsCSV, chair, decider string - var rounds int - var dryRun bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "convene", - Short: "Run a multi-agent meeting: agents take turns on a topic (human simulation)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if topic == "" || agentsCSV == "" { - return fmt.Errorf("--topic and --agents are required") - } - if thread == "" { - thread = "meet-" + time.Now().Format("0102-150405") - } - if rounds < 1 { - rounds = 1 - } - if chair == "" { - chair = "human:facilitator" - } - var participants []model.Agent - for _, id := range splitCSV(agentsCSV) { - a, ok := ws.Registry.AgentByID(id) - if !ok { - return fmt.Errorf("unknown agent %q", id) - } - participants = append(participants, a) - } - bs := bus.New(ws.Layout) - out := cmd.OutOrStdout() - // Kickoff. - if _, err := bs.Post(bus.Message{Channel: thread, From: chair, To: []string{"*"}, Type: bus.TypeMsg, - Body: "Họp: " + topic}); err != nil { - return err - } - fmt.Fprintf(out, "convene thread=%s rounds=%d agents=%s\n", thread, rounds, agentsCSV) - decided := false - recordDecision := func(by, turn string) { - body := strings.TrimSpace(strings.TrimPrefix(turn, "DECISION:")) - if e, err := decide.Record(ws, thread, topic, by, body); err == nil { - decided = true - fmt.Fprintf(out, " ↳ recorded %s in kb/decisions/\n", e.ID) - } - } - for r := 1; r <= rounds && !decided; r++ { - for _, a := range participants { - raw, _ := bs.Raw(thread) - prompt := orchestrator.BuildMeetingPrompt(roleOf(a), thread, topic, raw, r, rounds) - fmt.Fprintf(out, "\n# round %d · %s (%s)\n", r, a.ID, roleOf(a)) - outcome, err := orch(ws).Execute(ws, a, thread, prompt, orchestrator.RunOptions{ - DryRun: dryRun, Timeout: timeout, Stdout: out, - }) - if err != nil { - return err - } - if !outcome.Executed { - continue - } - turn := strings.TrimSpace(outcome.Output) - if turn == "" { - continue - } - tt := turnType(turn) - if _, err := bs.Post(bus.Message{Channel: thread, From: a.ID, To: []string{"*"}, Type: tt, Body: turn}); err != nil { - return err - } - // A meeting decision becomes a durable ADR in the KB. - if tt == bus.TypeDecision { - recordDecision(a.ID, turn) - break - } - } - } - // Tie-breaker: no consensus after all rounds → the decider settles it. - if !decided && decider != "" { - d, ok := ws.Registry.AgentByID(decider) - if !ok { - return fmt.Errorf("unknown decider %q", decider) - } - raw, _ := bs.Raw(thread) - fmt.Fprintf(out, "\n# tie-break · %s (%s)\n", d.ID, roleOf(d)) - outcome, err := orch(ws).Execute(ws, d, thread, - orchestrator.BuildTieBreakPrompt(roleOf(d), thread, topic, raw), - orchestrator.RunOptions{DryRun: dryRun, Timeout: timeout, Stdout: out}) - if err != nil { - return err - } - if outcome.Executed { - turn := strings.TrimSpace(outcome.Output) - bs.Post(bus.Message{Channel: thread, From: d.ID, To: []string{"*"}, Type: bus.TypeDecision, Body: turn}) - recordDecision(d.ID, turn) - } - } - if !decided && decider == "" && !dryRun { - fmt.Fprintln(out, "\n(không đạt đồng thuận; chạy lại với --decider để phân xử)") - } - fmt.Fprintf(out, "\nmeeting recorded in thread %s\n", thread) - return nil - }, - } - cmd.Flags().StringVar(&decider, "decider", "", "agent that breaks a tie if no DECISION is reached") - cmd.Flags().StringVar(&thread, "thread", "", "thread id (default: generated)") - cmd.Flags().StringVar(&topic, "topic", "", "meeting topic (required)") - cmd.Flags().StringVar(&agentsCSV, "agents", "", "participant agent ids, comma-separated (required)") - cmd.Flags().StringVar(&chair, "chair", "", "who convenes (default human:facilitator)") - cmd.Flags().IntVar(&rounds, "rounds", 1, "number of discussion rounds") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show turn order/invocations without running agents") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "per-turn timeout") - return cmd -} - -func turnType(body string) string { - if strings.HasPrefix(strings.TrimSpace(body), "DECISION:") { - return bus.TypeDecision - } - return bus.TypeMsg -} diff --git a/hatch/internal/cli/cost.go b/hatch/internal/cli/cost.go deleted file mode 100644 index 79f973d..0000000 --- a/hatch/internal/cli/cost.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "text/tabwriter" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newCostCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "cost ", - Short: "Total tracked cost (USD + tokens) for a ticket", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - recs, err := store.NewLedger(ws.Layout).ScanCosts() - if err != nil { - return err - } - var usd float64 - var tokens int - for _, r := range recs { - if r.Ticket == args[0] { - usd += r.USD - tokens += r.Tokens - } - } - fmt.Fprintf(cmd.OutOrStdout(), "%s: $%.4f · %d tokens\n", args[0], usd, tokens) - return nil - }, - } - return cmd -} - -func newBudgetCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "budget", - Short: "Tracked spend per agent + team vs budget (no enforcement)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - recs, err := store.NewLedger(ws.Layout).ScanCosts() - if err != nil { - return err - } - spend := map[string]float64{} - toks := map[string]int{} - var total float64 - for _, r := range recs { - spend[r.Agent] += r.USD - toks[r.Agent] += r.Tokens - total += r.USD - } - out := cmd.OutOrStdout() - tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - fmt.Fprintln(tw, "AGENT\tSPEND\tBUDGET\tTOKENS\tUSE%") - for _, a := range ws.Registry.Agents { - budget := "-" - pct := "-" - if a.BudgetUSD > 0 { - budget = fmt.Sprintf("$%.2f", a.BudgetUSD) - pct = fmt.Sprintf("%.0f%%", spend[a.ID]/a.BudgetUSD*100) - } - fmt.Fprintf(tw, "%s\t$%.4f\t%s\t%d\t%s\n", a.ID, spend[a.ID], budget, toks[a.ID], pct) - } - tw.Flush() - teamLine := fmt.Sprintf("\nTEAM: $%.4f", total) - if ws.Registry.Policy.TeamBudgetUSD > 0 { - teamLine += fmt.Sprintf(" / $%.2f (%.0f%%)", ws.Registry.Policy.TeamBudgetUSD, total/ws.Registry.Policy.TeamBudgetUSD*100) - } - fmt.Fprintln(out, teamLine) - // track-only: warn, never block. - for _, a := range ws.Registry.Agents { - if a.BudgetUSD > 0 && spend[a.ID] >= 0.8*a.BudgetUSD { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s at %.0f%% of budget\n", a.ID, spend[a.ID]/a.BudgetUSD*100) - } - } - return nil - }, - } - return cmd -} diff --git a/hatch/internal/cli/daemoncmd.go b/hatch/internal/cli/daemoncmd.go new file mode 100644 index 0000000..3ca8114 --- /dev/null +++ b/hatch/internal/cli/daemoncmd.go @@ -0,0 +1,87 @@ +package cli + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/daemon" + "github.com/fioenix/overclaud/hatch/internal/roster" + "github.com/fioenix/overclaud/hatch/internal/session" + "github.com/fioenix/overclaud/hatch/internal/wake" +) + +func newDaemonCmd() *cobra.Command { + var interval time.Duration + var once bool + cmd := &cobra.Command{ + Use: "daemon", + Short: "Run the wake daemon: deliver chat @mentions to teammates and wake them", + Long: "Tails the shared chat bus and wakes the teammate a message is addressed to, " + + "resuming their CLI session so they keep their memory. This is delivery only — it " + + "never assigns work. Wakes are paced by the wake policy (coalesce, debounce, depth, " + + "rate, loop-break) and unresolvable cascades escalate to the boss's DM. Ctrl-C to stop.", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + out := cmd.OutOrStdout() + d := daemon.New( + bus.New(ws.Layout), + roster.New(ws.Layout), + daemon.ExecRunner{ + RepoRoot: ws.Layout.RepoRoot(), + Stdout: out, + Sessions: session.New(ws.Layout), + }, + wake.Config{}, + ) + + tick := func() { + dispatched, esc, err := d.Tick(time.Now()) + if err != nil { + fmt.Fprintln(out, "tick error:", err) + return + } + for _, x := range dispatched { + fmt.Fprintf(out, "woke %s (%s, %d msg)\n", x.Agent, x.Reason, len(x.Payload)) + } + for _, e := range esc { + fmt.Fprintf(out, "escalated to %s: %s (%s)\n", e.To, e.Cause, e.Episode) + } + } + + if once { + tick() + d.Wait() // let in-flight wakes finish before exit (don't drop checkpointed messages) + return nil + } + + fmt.Fprintf(out, "hatch watch: delivering chat every %s (Ctrl-C to stop)\n", interval) + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-sig: + fmt.Fprintln(out, "\nhatch watch: draining in-flight wakes…") + d.Wait() // graceful shutdown: finish dispatched wakes (best-effort; SIGKILL still drops) + fmt.Fprintln(out, "hatch watch: stopped") + return nil + case <-t.C: + tick() + } + } + }, + } + cmd.Flags().DurationVar(&interval, "interval", 3*time.Second, "how often to check the bus for new messages") + cmd.Flags().BoolVar(&once, "once", false, "process one delivery cycle and exit (for scripts/CI)") + return cmd +} diff --git a/hatch/internal/cli/deps.go b/hatch/internal/cli/deps.go deleted file mode 100644 index c34a08b..0000000 --- a/hatch/internal/cli/deps.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/ceremony" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/oncall" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" -) - -// ceremonyService wires adapters into the port-based ceremony.Service. -func ceremonyService(ws *config.Workspace) ceremony.Service { - return ceremony.Service{ - Board: store.NewBoard(ws.Layout), - Ledger: store.NewLedger(ws.Layout), - Bus: bus.New(ws.Layout), - KB: store.NewKB(ws.Layout), - } -} - -// orch is the composition root for the orchestrator: it wires the ledger + bus -// adapters into the port-based Orchestrator. -func orch(ws *config.Workspace) orchestrator.Orchestrator { - return orchestrator.Orchestrator{ - Ledger: store.NewLedger(ws.Layout), - Bus: bus.New(ws.Layout), - } -} - -// engineFor is the composition root for the workflow engine: it wires the -// concrete filesystem/bus/on-call adapters into the port-based wf.Engine. -func engineFor(ws *config.Workspace) wf.Engine { - return wf.Engine{ - Board: store.NewBoard(ws.Layout), - Ledger: store.NewLedger(ws.Layout), - Bus: bus.New(ws.Layout), - OnCall: oncall.Service{L: ws.Layout}, - } -} diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go deleted file mode 100644 index 38f1111..0000000 --- a/hatch/internal/cli/escalate.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func newEscalateCmd() *cobra.Command { - var from, why string - cmd := &cobra.Command{ - Use: "escalate ", - Short: "Escalate a ticket to the senior/on-call target (ledger + #escalations)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if why == "" { - return fmt.Errorf("--why is required") - } - eng := engineFor(ws) - if err := eng.Escalate(ws, args[0], from, why); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "escalated %s → %s\n", args[0], eng.EscalateTarget(ws)) - return nil - }, - } - cmd.Flags().StringVar(&from, "from", "", "who escalates (default orchestrator)") - cmd.Flags().StringVar(&why, "why", "", "reason (required)") - return cmd -} diff --git a/hatch/internal/cli/gate.go b/hatch/internal/cli/gate.go deleted file mode 100644 index 3c97b54..0000000 --- a/hatch/internal/cli/gate.go +++ /dev/null @@ -1,65 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/gate" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newGateCmd() *cobra.Command { - var to string - cmd := &cobra.Command{ - Use: "gate ", - Short: "Evaluate the gates for a ticket's transition without moving it", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if to == "" { - return fmt.Errorf("--to is required") - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - tr, ok := ws.Workflow.FindTransition(t.Lane, to) - if !ok { - return fmt.Errorf("no transition %s → %s", t.Lane, to) - } - if len(tr.Gates) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), "no gates on %s → %s\n", t.Lane, to) - return nil - } - out := cmd.OutOrStdout() - failed := 0 - for _, o := range gate.EvaluateAll(ws, tr.Gates, t, ws.Layout.RepoRoot()) { - status := "✓" - switch { - case o.Human: - status = "⊙ human" - case !o.Passed: - status = "✗" - failed++ - } - fmt.Fprintf(out, " %s %-16s %s\n", status, o.Name, o.Detail) - } - if failed > 0 { - return fmt.Errorf("%d gate(s) failed", failed) - } - return nil - }, - } - cmd.Flags().StringVar(&to, "to", "", "target lane to evaluate gates for (required)") - return cmd -} diff --git a/hatch/internal/cli/init.go b/hatch/internal/cli/init.go index c20269a..89888b5 100644 --- a/hatch/internal/cli/init.go +++ b/hatch/internal/cli/init.go @@ -148,16 +148,12 @@ func newInitCmd() *cobra.Command { return cmd } -// hatchRuntimeIgnores are the per-checkout .hatch subdirs that don't belong in -// git: the chat/ledger state and regenerable outputs. The SSOT (charter, +// hatchRuntimeIgnores is the single .hatch subdir that doesn't belong in git: +// all runtime state (chat bus, roster, sessions, slack, ledger, compiled +// manifest, logs, mcp snippets) lives under .hatch/run/. The SSOT (charter, // registry, roles, context, workflow, protocol) and kb/ are committed. var hatchRuntimeIgnores = []string{ - "/.hatch/board/", - "/.hatch/bus/", - "/.hatch/ledger/", - "/.hatch/compiled/", - "/.hatch/mcp/", - "/.hatch/logs/", + "/.hatch/run/", } const hatchIgnoreHeader = "# Hatch local workspace runtime (the .hatch SSOT + kb/ are committed)" diff --git a/hatch/internal/cli/integration_default_test.go b/hatch/internal/cli/integration_default_test.go index 3bcb3ed..c82abf0 100644 --- a/hatch/internal/cli/integration_default_test.go +++ b/hatch/internal/cli/integration_default_test.go @@ -1,5 +1,3 @@ -//go:build !hatch_legacy - package cli import ( diff --git a/hatch/internal/cli/integration_test.go b/hatch/internal/cli/integration_test.go deleted file mode 100644 index 80ac335..0000000 --- a/hatch/internal/cli/integration_test.go +++ /dev/null @@ -1,86 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" -) - -// run executes the hatch root command with args in the current dir, returning -// combined output. A fresh root is built each call (cobra is stateful). -func run(t *testing.T, args ...string) (string, error) { - t.Helper() - var buf bytes.Buffer - root := NewRoot() - root.SetOut(&buf) - root.SetErr(&buf) - root.SetArgs(args) - err := root.Execute() - return buf.String(), err -} - -// TestLifecycleEndToEnd drives the real CLI through a full ticket lifecycle in -// a temp workspace, with a shell script standing in for an agent CLI — proving -// the command wiring works end to end (init → compile → ticket → run → logs). -func TestLifecycleEndToEnd(t *testing.T) { - dir := t.TempDir() - t.Chdir(dir) - - if out, err := run(t, "init", "--local", "-w", "scrum"); err != nil { - t.Fatalf("init: %v\n%s", err, out) - } - - // Wire the codex agent to a mock script so `run` really spawns + captures. - mock := filepath.Join(dir, "mock.sh") - if err := os.WriteFile(mock, []byte("#!/bin/sh\necho \"MOCKREPLY $*\"\n"), 0o755); err != nil { - t.Fatal(err) - } - regPath := filepath.Join(dir, ".hatch", "registry.yaml") - reg, _ := os.ReadFile(regPath) - reg = bytes.Replace(reg, []byte(" kind: codex\n"), - []byte(" kind: mock\n cmd: "+mock+"\n"), 1) - if err := os.WriteFile(regPath, reg, 0o644); err != nil { - t.Fatal(err) - } - - if out, err := run(t, "compile"); err != nil { - t.Fatalf("compile: %v\n%s", err, out) - } - if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { - t.Fatalf("compile did not produce CLAUDE.md: %v", err) - } - - if out, err := run(t, "validate"); err != nil { - t.Fatalf("validate: %v\n%s", err, out) - } - - if out, err := run(t, "ticket", "new", "--title", "Export CSV", "--role", "implementer", "--priority", "P1"); err != nil { - t.Fatalf("ticket new: %v\n%s", err, out) - } - if out, err := run(t, "ticket", "claim", "T-001", "--agent", "codex", "--why", "test"); err != nil { - t.Fatalf("claim: %v\n%s", err, out) - } - if out, err := run(t, "run", "T-001", "--agent", "codex"); err != nil { - t.Fatalf("run: %v\n%s", err, out) - } - - logs, err := run(t, "logs", "T-001") - if err != nil { - t.Fatalf("logs: %v", err) - } - if !strings.Contains(logs, "MOCKREPLY") { - t.Fatalf("transcript missing agent output:\n%s", logs) - } - - report, err := run(t, "report") - if err != nil { - t.Fatalf("report: %v", err) - } - if !strings.Contains(report, "Status report") || !strings.Contains(report, "Budget") { - t.Fatalf("report malformed:\n%s", report) - } -} diff --git a/hatch/internal/cli/metrics.go b/hatch/internal/cli/metrics.go deleted file mode 100644 index df60597..0000000 --- a/hatch/internal/cli/metrics.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "text/tabwriter" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/metrics" - "github.com/fioenix/overclaud/hatch/internal/presence" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newWorkloadCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "workload", - Short: "Team load: presence, WIP, throughput, cycle time", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - rep, err := metrics.Compute(store.NewLedger(ws.Layout)) - if err != nil { - return err - } - load := wipLoad(ws) - pres := presence.Load(ws.Layout) - out := cmd.OutOrStdout() - tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - fmt.Fprintln(tw, "AGENT\tPRESENCE\tWIP\tDONE\tFLAG") - for _, a := range ws.Registry.Agents { - wip := fmt.Sprintf("%d", load[a.ID]) - if a.WIP > 0 { - wip = fmt.Sprintf("%d/%d", load[a.ID], a.WIP) - } - done := 0 - if s := rep.Agents[a.ID]; s != nil { - done = s.Done - } - flag := "" - switch { - case !pres.CanTakeWork(a.ID): - flag = "off" - case overWIP(a, load): - flag = "OVERLOADED" - case load[a.ID] == 0: - flag = "idle" - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\n", a.ID, pres.StatusOf(a.ID), wip, done, flag) - } - tw.Flush() - fmt.Fprintf(out, "\nthroughput: %d done · avg cycle: %s\n", rep.Throughput, rep.CycleAvg.Round(1e9)) - return nil - }, - } - return cmd -} - -func newPerfCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "perf [agent]", - Short: "Per-agent operational scorecard (from the ledger)", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - rep, err := metrics.Compute(store.NewLedger(ws.Layout)) - if err != nil { - return err - } - out := cmd.OutOrStdout() - tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) - fmt.Fprintln(tw, "AGENT\tCLAIMS\tDONE\tREVIEWS\tGATE-FAIL\tESCAL\tCOST") - for _, s := range rep.Sorted() { - if len(args) == 1 && s.Agent != args[0] { - continue - } - fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\t%d\t$%.4f\n", - s.Agent, s.Claims, s.Done, s.Reviews, s.GateFails, s.Escalations, s.CostUSD) - } - tw.Flush() - fmt.Fprintln(out, "\n(chỉ số vận hành, không phải đánh giá con người — xem docs/13 Goodhart)") - return nil - }, - } - return cmd -} diff --git a/hatch/internal/cli/mob.go b/hatch/internal/cli/mob.go deleted file mode 100644 index a1babd8..0000000 --- a/hatch/internal/cli/mob.go +++ /dev/null @@ -1,132 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" -) - -func newMobCmd() *cobra.Command { - var agentsCSV, why string - var rounds int - var dryRun, claim bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "mob ", - Short: "Mob programming: 3+ agents on one ticket, the driver rotates each round", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - var agents []model.Agent - for _, id := range splitCSV(agentsCSV) { - a, ok := ws.Registry.AgentByID(id) - if !ok { - return fmt.Errorf("unknown agent %q", id) - } - agents = append(agents, a) - } - if len(agents) < 2 { - return fmt.Errorf("mob needs at least 2 agents (use `pair` for 2)") - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - if claim { - to := claimTarget(ws, t.Lane) - if to == "" { - return fmt.Errorf("no claim transition from %q", t.Lane) - } - if why == "" { - why = "mob claim" - } - if _, err := engineFor(ws).Move(ws, t.ID, wf.MoveOptions{ - To: to, ByRole: t.Role, Agent: agents[0].ID, Why: why, - }); err != nil { - return err - } - t, _, _ = b.Find(args[0], ws.Workflow.LaneIDs()) - } - if rounds < 1 { - rounds = 1 - } - thread := "mob-" + t.ID - bs := bus.New(ws.Layout) - out := cmd.OutOrStdout() - fmt.Fprintf(out, "mob %s · agents=%s · rounds=%d (driver rotates)\n", t.ID, agentsCSV, rounds) - - for r := 1; r <= rounds; r++ { - driver := agents[(r-1)%len(agents)] - // Driver implements. - raw, _ := bs.Raw(thread) - fmt.Fprintf(out, "\n# round %d · DRIVER %s\n", r, driver.ID) - dOut, err := orch(ws).Execute(ws, driver, t.ID, - orchestrator.BuildPairDriverPrompt(t, thread, raw, "mob"), - orchestrator.RunOptions{DryRun: dryRun, Timeout: timeout, Stdout: out}) - if err != nil { - return err - } - if dOut.Executed { - if turn := strings.TrimSpace(dOut.Output); turn != "" { - bs.Post(bus.Message{Channel: thread, From: driver.ID, To: []string{"*"}, Body: turn}) - } - } - // Everyone else navigates. - ready := 0 - navs := 0 - for _, a := range agents { - if a.ID == driver.ID { - continue - } - navs++ - raw, _ = bs.Raw(thread) - fmt.Fprintf(out, "\n# round %d · NAVIGATOR %s\n", r, a.ID) - nOut, err := orch(ws).Execute(ws, a, t.ID, - orchestrator.BuildPairNavigatorPrompt(t, thread, raw, driver.ID), - orchestrator.RunOptions{DryRun: dryRun, Timeout: timeout, Stdout: out}) - if err != nil { - return err - } - if nOut.Executed { - if turn := strings.TrimSpace(nOut.Output); turn != "" { - bs.Post(bus.Message{Channel: thread, From: a.ID, To: []string{"*"}, Body: turn}) - if strings.HasPrefix(turn, "READY") { - ready++ - } - } - } - } - if navs > 0 && ready > navs/2 { - fmt.Fprintf(out, "\nđa số navigator READY — kết thúc mob sớm\n") - break - } - } - fmt.Fprintf(out, "\nmob session in thread %s\n", thread) - return nil - }, - } - cmd.Flags().StringVar(&agentsCSV, "agents", "", "participant agent ids, comma-separated (>=2)") - cmd.Flags().IntVar(&rounds, "rounds", 2, "rounds; driver rotates each round") - cmd.Flags().BoolVar(&claim, "claim", false, "claim the ticket to the first agent") - cmd.Flags().StringVar(&why, "why", "", "reason for the claim ledger entry") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show the rotation/turns without running agents") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "per-turn timeout") - return cmd -} diff --git a/hatch/internal/cli/oncall.go b/hatch/internal/cli/oncall.go deleted file mode 100644 index c8bf754..0000000 --- a/hatch/internal/cli/oncall.go +++ /dev/null @@ -1,95 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/oncall" -) - -func newOncallCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "oncall", - Short: "Show who is on call (first responder for escalations)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - r := oncall.Load(ws.Layout) - out := cmd.OutOrStdout() - if r.Now() == "" { - fmt.Fprintln(out, "no on-call rotation set (use `hatch oncall set --rotation a,b,c`)") - return nil - } - fmt.Fprintf(out, "on-call: %s\nrotation: %v\n", r.Now(), r.Order) - return nil - }, - } - cmd.AddCommand(newOncallSetCmd(), newOncallRotateCmd()) - return cmd -} - -func newOncallSetCmd() *cobra.Command { - var rotationCSV string - cmd := &cobra.Command{ - Use: "set", - Short: "Define the on-call rotation (ordered agent ids)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - order := splitCSV(rotationCSV) - if len(order) == 0 { - return fmt.Errorf("--rotation is required (comma-separated agent ids)") - } - for _, id := range order { - if _, ok := ws.Registry.AgentByID(id); !ok { - return fmt.Errorf("unknown agent %q", id) - } - } - r := oncall.Rotation{Order: order, Current: 0} - if err := r.Save(ws.Layout); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "rotation set; on-call: %s\n", r.Now()) - return nil - }, - } - cmd.Flags().StringVar(&rotationCSV, "rotation", "", "ordered agent ids, comma-separated (required)") - return cmd -} - -func newOncallRotateCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "rotate", - Short: "Advance the pager to the next agent (announces in #oncall)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - r := oncall.Load(ws.Layout) - prev := r.Now() - next := r.Rotate() - if next == "" { - return fmt.Errorf("no rotation set") - } - if err := r.Save(ws.Layout); err != nil { - return err - } - _, _ = bus.New(ws.Layout).Post(bus.Message{ - Channel: "#oncall", From: "human:facilitator", To: []string{next}, - Type: bus.TypeMsg, Body: fmt.Sprintf("@%s bạn nhận pager on-call (bàn giao từ %s)", next, prev), - }) - fmt.Fprintf(cmd.OutOrStdout(), "on-call: %s → %s\n", prev, next) - return nil - }, - } - return cmd -} diff --git a/hatch/internal/cli/orchestrate.go b/hatch/internal/cli/orchestrate.go deleted file mode 100644 index ffd9e3f..0000000 --- a/hatch/internal/cli/orchestrate.go +++ /dev/null @@ -1,315 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "io" - "os" - "sort" - "sync" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/mux" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" - "github.com/fioenix/overclaud/hatch/internal/presence" - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" -) - -// pickAgent returns the agent to run a ticket: the explicit id if given, else -// a capacity-aware choice — eligible for the role, present (not paused/offline), -// and least-loaded (under WIP first), like a lead assigning to whoever's free. -func pickAgent(ws *config.Workspace, explicit, role string) (model.Agent, error) { - if explicit != "" { - a, ok := ws.Registry.AgentByID(explicit) - if !ok { - return model.Agent{}, fmt.Errorf("unknown agent %q", explicit) - } - return a, nil - } - candidates := ws.Registry.AgentsForRole(role) - if len(candidates) == 0 { - return model.Agent{}, fmt.Errorf("no agent in registry holds role %q", role) - } - pres := presence.Load(ws.Layout) - load := wipLoad(ws) - - var free []model.Agent - for _, a := range candidates { - if pres.CanTakeWork(a.ID) { - free = append(free, a) - } - } - if len(free) == 0 { - return model.Agent{}, fmt.Errorf("no available agent for role %q (all paused/offline)", role) - } - sort.SliceStable(free, func(i, j int) bool { - oi, oj := overWIP(free[i], load), overWIP(free[j], load) - if oi != oj { - return !oi // under-WIP first - } - if load[free[i].ID] != load[free[j].ID] { - return load[free[i].ID] < load[free[j].ID] // least loaded - } - return free[i].ID < free[j].ID - }) - return free[0], nil -} - -// wipLoad counts in-flight tickets (assignee set, in a non-terminal, non-side -// lane) per agent. -func wipLoad(ws *config.Workspace) map[string]int { - load := map[string]int{} - outgoing := map[string]bool{} - for _, tr := range ws.Workflow.Transitions { - if tr.From != "*" { - outgoing[tr.From] = true - } - } - b := store.NewBoard(ws.Layout) - for _, l := range ws.Workflow.Lanes { - if l.Side || !outgoing[l.ID] { // skip side + terminal lanes - continue - } - ts, _ := b.ListLane(l.ID) - for _, t := range ts { - if t.Assignee != "" { - load[t.Assignee]++ - } - } - } - return load -} - -func overWIP(a model.Agent, load map[string]int) bool { - return a.WIP > 0 && load[a.ID] >= a.WIP -} - -func newRunCmd() *cobra.Command { - var agentID, muxKind string - var dryRun, claim, worktree, noCatchUp bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "run ", - Short: "Spawn an agent to work a ticket (headless)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - agent, err := pickAgent(ws, agentID, t.Role) - if err != nil { - return err - } - if claim { - to := claimTarget(ws, t.Lane) - if to == "" { - return fmt.Errorf("no claim transition from %q", t.Lane) - } - if _, err := engineFor(ws).Move(ws, t.ID, wf.MoveOptions{ - To: to, ByRole: t.Role, Agent: agent.ID, Why: "orchestrator claim", - }); err != nil { - return err - } - t, _, _ = b.Find(args[0], ws.Workflow.LaneIDs()) - } - if worktree && !dryRun { - path, err := orchestrator.AddWorktree(ws.Layout.RepoRoot(), t.ID, t.Branch) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "worktree: %s\n", path) - } - // Open in a multiplexer pane: re-invoke this run (minus --mux) there. - if muxKind != "" && !dryRun { - exe, err := os.Executable() - if err != nil { - return err - } - inner := []string{exe, "run", t.ID, "--agent", agent.ID} - if err := mux.Launch(muxKind, t.ID, inner); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "→ %s launched in %s pane (watch there); tail: hatch logs %s -f\n", t.ID, muxKind, t.ID) - return nil - } - fmt.Fprintf(cmd.OutOrStdout(), "→ %s (%s) on %s as %s\n", agent.ID, agent.Kind, t.ID, t.Role) - out, err := orch(ws).Run(ws, agent, t, t.Role, orchestrator.RunOptions{ - DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), SkipComms: noCatchUp, - }) - if err != nil { - return err - } - if out.Executed && out.Err != nil { - return fmt.Errorf("agent exited with error (code %d)", out.ExitCode) - } - return nil - }, - } - cmd.Flags().StringVar(&agentID, "agent", "", "agent id (default: first eligible for the ticket's role)") - cmd.Flags().StringVar(&muxKind, "mux", "", "open the run in a tmux|zellij pane to watch live") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the invocation without executing") - cmd.Flags().BoolVar(&claim, "claim", false, "claim the ticket before running") - cmd.Flags().BoolVar(&worktree, "worktree", false, "run in an isolated git worktree") - cmd.Flags().BoolVar(&noCatchUp, "no-catch-up", false, "don't prepend inbox + conversation recall") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "kill the agent after this duration (0 = none)") - return cmd -} - -func newPlanCmd() *cobra.Command { - var agentID string - var dryRun bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "plan", - Short: "Spawn the Conductor to break work into tickets", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - agent, err := pickAgent(ws, agentID, "conductor") - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "→ planning with %s (%s)\n", agent.ID, agent.Kind) - _, err = orch(ws).Execute(ws, agent, "-", orchestrator.BuildPlanPrompt(), orchestrator.RunOptions{ - DryRun: dryRun, Timeout: timeout, Stdout: cmd.OutOrStdout(), - }) - return err - }, - } - cmd.Flags().StringVar(&agentID, "agent", "", "agent id (default: first with role conductor)") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the invocation without executing") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "kill the agent after this duration (0 = none)") - return cmd -} - -func newWatchCmd() *cobra.Command { - var dryRun bool - var max, parallel int - cmd := &cobra.Command{ - Use: "watch", - Short: "Assign and run claimable backlog tickets (one pass, respects WIP/presence)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - n, err := dispatchBacklog(ws, cmd.OutOrStdout(), dispatchOpts{DryRun: dryRun, Max: max, Parallel: parallel}) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "watch: %d ticket(s) dispatched\n", n) - return nil - }, - } - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show assignments without claiming/running") - cmd.Flags().IntVar(&max, "max", 0, "max tickets to dispatch this pass (0 = all)") - cmd.Flags().IntVar(¶llel, "parallel", 1, "run claimed tickets concurrently with this many workers") - return cmd -} - -type dispatchOpts struct { - DryRun bool - Max int - Parallel int -} - -type job struct { - agent model.Agent - ticket model.Ticket -} - -// dispatchBacklog claims eligible entry-lane tickets (serial — claims must be -// ordered) then runs them (serial, or concurrently when Parallel > 1). Shared -// by `watch` and `tick`. -func dispatchBacklog(ws *config.Workspace, out io.Writer, opt dispatchOpts) (int, error) { - b := store.NewBoard(ws.Layout) - tickets, err := b.ListLane(firstLane(ws)) - if err != nil { - return 0, err - } - var jobs []job - for _, t := range tickets { - if opt.Max > 0 && len(jobs) >= opt.Max { - break - } - if t.Role == "" { - fmt.Fprintf(out, "skip %s: no role\n", t.ID) - continue - } - agent, err := pickAgent(ws, "", t.Role) - if err != nil { - fmt.Fprintf(out, "skip %s: %v\n", t.ID, err) - continue - } - fmt.Fprintf(out, "→ %s → %s (%s)\n", t.ID, agent.ID, agent.Kind) - if opt.DryRun { - jobs = append(jobs, job{agent, t}) - continue - } - to := claimTarget(ws, t.Lane) - if _, err := engineFor(ws).Move(ws, t.ID, wf.MoveOptions{ - To: to, ByRole: t.Role, Agent: agent.ID, Why: "watch claim", - }); err != nil { - fmt.Fprintf(out, " claim failed: %v\n", err) - continue - } - claimed, _, _ := b.Find(t.ID, ws.Workflow.LaneIDs()) - jobs = append(jobs, job{agent, claimed}) - } - if opt.DryRun { - return len(jobs), nil - } - runJobs(ws, out, jobs, opt.Parallel) - return len(jobs), nil -} - -// runJobs executes claimed jobs, serially or with a bounded worker pool. -func runJobs(ws *config.Workspace, out io.Writer, jobs []job, parallel int) { - if parallel <= 1 { - for _, j := range jobs { - if _, err := orch(ws).Run(ws, j.agent, j.ticket, j.ticket.Role, orchestrator.RunOptions{Stdout: out}); err != nil { - fmt.Fprintf(out, " %s run failed: %v\n", j.ticket.ID, err) - } - } - return - } - // Parallel: output goes to per-run transcripts (hatch logs / TUI) to avoid - // garbled terminal; we just print start/done lines here. - sem := make(chan struct{}, parallel) - var wg sync.WaitGroup - var mu sync.Mutex - for _, j := range jobs { - wg.Add(1) - sem <- struct{}{} - go func(j job) { - defer wg.Done() - defer func() { <-sem }() - _, err := orch(ws).Run(ws, j.agent, j.ticket, j.ticket.Role, orchestrator.RunOptions{Stdout: io.Discard}) - mu.Lock() - if err != nil { - fmt.Fprintf(out, " %s run failed: %v\n", j.ticket.ID, err) - } else { - fmt.Fprintf(out, " %s done (see `hatch logs %s`)\n", j.ticket.ID, j.ticket.ID) - } - mu.Unlock() - }(j) - } - wg.Wait() -} diff --git a/hatch/internal/cli/pair.go b/hatch/internal/cli/pair.go deleted file mode 100644 index 1e8b569..0000000 --- a/hatch/internal/cli/pair.go +++ /dev/null @@ -1,124 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/orchestrator" - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" -) - -func newPairCmd() *cobra.Command { - var driver, navigator, why string - var rounds int - var dryRun, claim bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "pair ", - Short: "Pair two agents on a ticket: driver implements, navigator reviews each turn", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if driver == "" || navigator == "" { - return fmt.Errorf("--driver and --navigator are required") - } - if driver == navigator { - return fmt.Errorf("driver and navigator must differ (pairing needs two)") - } - drv, ok := ws.Registry.AgentByID(driver) - if !ok { - return fmt.Errorf("unknown agent %q", driver) - } - nav, ok := ws.Registry.AgentByID(navigator) - if !ok { - return fmt.Errorf("unknown agent %q", navigator) - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - if claim { - to := claimTarget(ws, t.Lane) - if to == "" { - return fmt.Errorf("no claim transition from %q", t.Lane) - } - if why == "" { - why = "pairing claim" - } - if _, err := engineFor(ws).Move(ws, t.ID, wf.MoveOptions{ - To: to, ByRole: t.Role, Agent: drv.ID, Why: why, - }); err != nil { - return err - } - t, _, _ = b.Find(args[0], ws.Workflow.LaneIDs()) - } - if rounds < 1 { - rounds = 1 - } - thread := "pair-" + t.ID - bs := bus.New(ws.Layout) - out := cmd.OutOrStdout() - fmt.Fprintf(out, "pair %s · driver=%s navigator=%s · rounds=%d\n", t.ID, driver, navigator, rounds) - - for r := 1; r <= rounds; r++ { - // Driver turn. - raw, _ := bs.Raw(thread) - fmt.Fprintf(out, "\n# round %d · DRIVER %s\n", r, drv.ID) - dOut, err := orch(ws).Execute(ws, drv, t.ID, - orchestrator.BuildPairDriverPrompt(t, thread, raw, nav.ID), - orchestrator.RunOptions{DryRun: dryRun, Timeout: timeout, Stdout: out}) - if err != nil { - return err - } - if dOut.Executed { - if turn := strings.TrimSpace(dOut.Output); turn != "" { - bs.Post(bus.Message{Channel: thread, From: drv.ID, To: []string{nav.ID}, Body: turn}) - } - } - - // Navigator turn. - raw, _ = bs.Raw(thread) - fmt.Fprintf(out, "\n# round %d · NAVIGATOR %s\n", r, nav.ID) - nOut, err := orch(ws).Execute(ws, nav, t.ID, - orchestrator.BuildPairNavigatorPrompt(t, thread, raw, drv.ID), - orchestrator.RunOptions{DryRun: dryRun, Timeout: timeout, Stdout: out}) - if err != nil { - return err - } - if nOut.Executed { - if turn := strings.TrimSpace(nOut.Output); turn != "" { - bs.Post(bus.Message{Channel: thread, From: nav.ID, To: []string{drv.ID}, Body: turn}) - if strings.HasPrefix(turn, "READY") { - fmt.Fprintf(out, "\nnavigator signalled READY — kết thúc pairing sớm\n") - break - } - } - } - } - fmt.Fprintf(out, "\npairing session in thread %s\n", thread) - return nil - }, - } - cmd.Flags().StringVar(&driver, "driver", "", "agent that implements (required)") - cmd.Flags().StringVar(&navigator, "navigator", "", "agent that reviews each turn (required)") - cmd.Flags().IntVar(&rounds, "rounds", 3, "max driver/navigator rounds") - cmd.Flags().BoolVar(&claim, "claim", false, "claim the ticket to the driver first") - cmd.Flags().StringVar(&why, "why", "", "reason for the claim ledger entry") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show the turn structure without running agents") - cmd.Flags().DurationVar(&timeout, "timeout", 0, "per-turn timeout") - return cmd -} diff --git a/hatch/internal/cli/presence.go b/hatch/internal/cli/presence.go deleted file mode 100644 index 0806a63..0000000 --- a/hatch/internal/cli/presence.go +++ /dev/null @@ -1,75 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "text/tabwriter" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/presence" -) - -func newPresenceCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "presence", - Short: "Show agent availability + current load", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - board := presence.Load(ws.Layout) - load := wipLoad(ws) - tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) - fmt.Fprintln(tw, "AGENT\tSTATUS\tWIP\tROLES\tNOTE") - for _, a := range ws.Registry.Agents { - st := board[a.ID] - status := board.StatusOf(a.ID) - wip := fmt.Sprintf("%d", load[a.ID]) - if a.WIP > 0 { - wip = fmt.Sprintf("%d/%d", load[a.ID], a.WIP) - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%s\n", a.ID, status, wip, a.Roles, st.Note) - } - tw.Flush() - return nil - }, - } - cmd.AddCommand(newPresenceSetCmd()) - return cmd -} - -func newPresenceSetCmd() *cobra.Command { - var status, note string - cmd := &cobra.Command{ - Use: "set ", - Short: "Set an agent's availability (available|busy|paused|offline)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - switch status { - case presence.Available, presence.Busy, presence.Paused, presence.Offline: - default: - return fmt.Errorf("status must be available|busy|paused|offline") - } - if _, ok := ws.Registry.AgentByID(args[0]); !ok { - return fmt.Errorf("unknown agent %q", args[0]) - } - board := presence.Load(ws.Layout) - board.Set(args[0], status, note) - if err := board.Save(ws.Layout); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "%s → %s\n", args[0], status) - return nil - }, - } - cmd.Flags().StringVar(&status, "status", "", "available|busy|paused|offline (required)") - cmd.Flags().StringVar(¬e, "note", "", "optional note (e.g. PTO until Fri)") - return cmd -} diff --git a/hatch/internal/cli/report.go b/hatch/internal/cli/report.go deleted file mode 100644 index 70d8483..0000000 --- a/hatch/internal/cli/report.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/metrics" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newReportCmd() *cobra.Command { - var post bool - cmd := &cobra.Command{ - Use: "report", - Short: "Executive status summary (board, throughput, budget, risks, decisions)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - rep, err := buildReport(ws) - if err != nil { - return err - } - fmt.Fprint(cmd.OutOrStdout(), rep) - if post { - if _, err := bus.New(ws.Layout).Post(bus.Message{ - Channel: "#leadership", From: "human:facilitator", To: []string{"*"}, Body: rep, - }); err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), "\n(posted to #leadership)") - } - return nil - }, - } - cmd.Flags().BoolVar(&post, "post", false, "post the report to #leadership") - return cmd -} - -func buildReport(ws *config.Workspace) (string, error) { - b := store.NewBoard(ws.Layout) - var sb strings.Builder - project := ws.Registry.Project - if project == "" { - project = "Hatch" - } - fmt.Fprintf(&sb, "# Status report — %s — %s\n\n", project, time.Now().Format("2006-01-02")) - - // Board. - sb.WriteString("## Board\n") - for _, lane := range ws.Workflow.Lanes { - ts, err := b.ListLane(lane.ID) - if err != nil { - return "", err - } - fmt.Fprintf(&sb, "- %s: %d\n", lane.ID, len(ts)) - } - - // Throughput. - m, err := metrics.Compute(store.NewLedger(ws.Layout)) - if err != nil { - return "", err - } - fmt.Fprintf(&sb, "\n## Throughput\n- done: %d · avg cycle: %s\n", m.Throughput, m.CycleAvg.Round(time.Second)) - - // Budget. - recs, _ := store.NewLedger(ws.Layout).ScanCosts() - var total float64 - for _, r := range recs { - total += r.USD - } - sb.WriteString("\n## Budget\n") - if cap := ws.Registry.Policy.TeamBudgetUSD; cap > 0 { - fmt.Fprintf(&sb, "- spend: $%.2f / $%.2f (%.0f%%)\n", total, cap, total/cap*100) - } else { - fmt.Fprintf(&sb, "- spend: $%.2f\n", total) - } - - // Risks: open external blockers + escalations. - sb.WriteString("\n## Risks\n") - risk := 0 - for _, lane := range ws.Workflow.LaneIDs() { - ts, _ := b.ListLane(lane) - for _, t := range ts { - for _, e := range t.OpenExternal() { - fmt.Fprintf(&sb, "- %s blocked-external: %s (owner %s, eta %s)\n", t.ID, e.What, e.Owner, e.ETA) - risk++ - } - } - } - escal := 0 - for _, s := range m.Agents { - escal += s.Escalations - } - if escal > 0 { - fmt.Fprintf(&sb, "- escalations: %d\n", escal) - risk++ - } - if risk == 0 { - sb.WriteString("- none\n") - } - - // Recent decisions (ADRs). - sb.WriteString("\n## Recent decisions\n") - entries, _ := store.NewKB(ws.Layout).List() - n := 0 - for i := len(entries) - 1; i >= 0 && n < 5; i-- { - if entries[i].Type == model.KBDecision { - fmt.Fprintf(&sb, "- %s %s\n", entries[i].ID, entries[i].Title) - n++ - } - } - if n == 0 { - sb.WriteString("- none\n") - } - return sb.String(), nil -} diff --git a/hatch/internal/cli/root.go b/hatch/internal/cli/root.go index 6510a84..9d65ad3 100644 --- a/hatch/internal/cli/root.go +++ b/hatch/internal/cli/root.go @@ -18,15 +18,14 @@ func NewRoot() *cobra.Command { root := &cobra.Command{ Use: "hatch", Short: "Hatch — a multi-agent coding squad on the filesystem", - Long: "Hatch orchestrates multiple coding-agent CLIs as one squad: a single source of truth compiled per agent, a file-based board, an append-only ledger, and a shared knowledge base.", + Long: "Hatch is an embedded harness for a squad of coding-agent CLIs sharing one repo: a single source of truth compiled per agent, a shared chat bus (one thread = one task), a wake daemon that delivers @mentions, an optional Slack bridge, and a shared knowledge base.", SilenceUsage: true, SilenceErrors: true, Version: Version, } // The embedded-harness command set: SSOT compile, the MCP server agents // drive themselves through, read-only views over the shared chat + ledger, - // and the knowledge base. Self-driving operator commands (run/plan/watch, - // ceremonies, tickets, …) are archived behind the `hatch_legacy` build tag. + // the wake daemon + Slack bridge, and the knowledge base. root.AddCommand( newSetupCmd(), newInitCmd(), @@ -36,9 +35,11 @@ func NewRoot() *cobra.Command { newCompileCmd(), newValidateCmd(), newStatusCmd(), + newRosterCmd(), + newDaemonCmd(), + newSlackCmd(), + newSessionsCmd(), newKBCmd(), - newBoardCmd(), - newChatCmd(), newSyncCmd(), newHookCmd(), newMsgCmd(), @@ -52,7 +53,6 @@ func NewRoot() *cobra.Command { newDoctorCmd(), newMCPCmd(), ) - addLegacyCommands(root) return root } diff --git a/hatch/internal/cli/root_default.go b/hatch/internal/cli/root_default.go deleted file mode 100644 index fabb66d..0000000 --- a/hatch/internal/cli/root_default.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !hatch_legacy - -package cli - -import "github.com/spf13/cobra" - -// addLegacyCommands is a no-op in the default build. The self-driving operator -// commands live behind the `hatch_legacy` build tag (see root_legacy.go). -func addLegacyCommands(*cobra.Command) {} diff --git a/hatch/internal/cli/root_legacy.go b/hatch/internal/cli/root_legacy.go deleted file mode 100644 index 3d96443..0000000 --- a/hatch/internal/cli/root_legacy.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build hatch_legacy - -package cli - -import "github.com/spf13/cobra" - -// addLegacyCommands registers the archived self-driving operator commands when -// built with `-tags hatch_legacy`. These predate the embedded-harness pivot: -// Hatch drove agents (run/plan/watch/tick), enforced the workflow engine -// (gate/escalate/ticket), and ran ceremonies/coordination as spawn loops -// (ask/convene/pair/mob), plus presence/oncall/cost/metrics tracking. -func addLegacyCommands(root *cobra.Command) { - root.AddCommand( - newRunCmd(), - newPlanCmd(), - newWatchCmd(), - newTickCmd(), - newTicketCmd(), - newGateCmd(), - newEscalateCmd(), - newStandupCmd(), - newCeremonyCmd(), - newAskCmd(), - newConveneCmd(), - newPairCmd(), - newMobCmd(), - newPresenceCmd(), - newOncallCmd(), - newCostCmd(), - newBudgetCmd(), - newWorkloadCmd(), - newPerfCmd(), - newReportCmd(), - ) -} diff --git a/hatch/internal/cli/roster.go b/hatch/internal/cli/roster.go new file mode 100644 index 0000000..68f7a27 --- /dev/null +++ b/hatch/internal/cli/roster.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/roster" +) + +func newRosterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "roster", + Short: "Who is in the room: live presence of agents that have joined the workspace", + Long: "Shows the live workspace room — members that joined via the Hatch MCP `join` tool, " + + "their roles, reachability (online/idle/suspended/offline) and last-seen. " + + "This is the team-simulation presence layer, distinct from the static registry shown by `hatch status`.", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + r, err := roster.New(ws.Layout).Effective(time.Now()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "## Room — %d members\n", len(r)) + if len(r) == 0 { + fmt.Fprintln(out, " (empty — agents join through the Hatch MCP `join` tool)") + return nil + } + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, " MEMBER\tKIND\tROLES\tSTATUS\tSESSION\tLAST-SEEN") + for _, m := range roster.Members(r) { + roles := "-" + if len(m.Roles) > 0 { + roles = fmt.Sprintf("%v", m.Roles) + } + session := "-" + if m.SessionID != "" { + session = "yes" + } + last := m.LastSeen + if t, e := time.Parse(time.RFC3339, m.LastSeen); e == nil { + last = t.Format("01-02 15:04") + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\n", m.ID, m.Kind, roles, m.Status, session, last) + } + tw.Flush() + fmt.Fprintln(out) + return nil + }, + } + return cmd +} diff --git a/hatch/internal/cli/sessionscmd.go b/hatch/internal/cli/sessionscmd.go new file mode 100644 index 0000000..8116e31 --- /dev/null +++ b/hatch/internal/cli/sessionscmd.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/session" +) + +func newSessionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sessions", + Short: "Show agents' resumable sessions, one per (agent, thread)", + Long: "Lists the warm CLI sessions the wake daemon resumes per task thread — which agent, " + + "which bus thread, the session id, status (live/stale) and when it was last resumed. " + + "The bus + KB remain the source of truth; a session is just a warm cache, so a stale one " + + "simply starts fresh on the next wake. Agents whose CLI exposes no session id (e.g. agy) " + + "run stateless and do not appear here.", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + out := cmd.OutOrStdout() + all := session.New(ws.Layout).All() + fmt.Fprintf(out, "## Sessions — %d\n", len(all)) + if len(all) == 0 { + fmt.Fprintln(out, " (none yet — created when the daemon wakes claude/codex on a thread)") + return nil + } + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, " AGENT\tTHREAD\tKIND\tSTATUS\tSESSION\tLAST-RESUMED") + for _, s := range all { + id := s.ID + if len(id) > 8 { + id = id[:8] + } + if id == "" { + id = "-" + } + last := s.LastResumedAt + if t, e := time.Parse(time.RFC3339, s.LastResumedAt); e == nil { + last = t.Format("01-02 15:04") + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\n", s.Agent, s.Thread, s.Kind, s.Status, id, last) + } + tw.Flush() + fmt.Fprintln(out) + return nil + }, + } + return cmd +} diff --git a/hatch/internal/cli/slackcmd.go b/hatch/internal/cli/slackcmd.go new file mode 100644 index 0000000..c2c1cd7 --- /dev/null +++ b/hatch/internal/cli/slackcmd.go @@ -0,0 +1,43 @@ +package cli + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/slack" +) + +func newSlackCmd() *cobra.Command { + var interval time.Duration + var once, dryRun bool + cmd := &cobra.Command{ + Use: "slack", + Short: "Bridge the squad chat to a Slack channel (mirror out, @tag in)", + Long: "Mirrors every squad message into one Slack channel and ingests your Slack messages " + + "back onto the bus, where `hatch daemon` delivers them. Each agent maps to its own Slack " + + "app/bot, so it posts and is @mentioned as a real Slack principal; @codex in Slack wakes " + + "codex like any peer. This is a window into the room, not a controller.\n\n" + + "Setup: one \"hub\" app with Socket Mode (scopes chat:write, chat:write.customize, " + + "channels:history, connections:write; subscribe to message.channels) plus one app per agent " + + "(scope chat:write), each invited to the channel. Config from HATCH_SLACK_{APP_TOKEN," + + "BOT_TOKEN,CHANNEL,BOSS,BOSS_USER} + HATCH_SLACK_TOKEN_, or .hatch/run/slack/config.json " + + "(gitignored — never commit tokens). Ctrl-C to stop.", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + return slack.Run(ws.Layout, slack.Options{ + Interval: interval, + Once: once, + DryRun: dryRun, + Stdout: cmd.OutOrStdout(), + }) + }, + } + cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to mirror new bus messages to Slack") + cmd.Flags().BoolVar(&once, "once", false, "mirror the current backlog once and exit (no Socket Mode)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the mirror to stdout instead of Slack (needs no tokens)") + return cmd +} diff --git a/hatch/internal/cli/standup.go b/hatch/internal/cli/standup.go deleted file mode 100644 index 10397fc..0000000 --- a/hatch/internal/cli/standup.go +++ /dev/null @@ -1,54 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newStandupCmd() *cobra.Command { - var days int - cmd := &cobra.Command{ - Use: "standup", - Short: "Digest recent ledger activity (latest day-files)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - lg := store.NewLedger(ws.Layout) - files, err := lg.Files() - if err != nil { - return err - } - if len(files) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no ledger activity yet") - return nil - } - if days < 1 { - days = 1 - } - start := 0 - if len(files) > days { - start = len(files) - days - } - out := cmd.OutOrStdout() - for _, f := range files[start:] { - raw, err := os.ReadFile(f) - if err != nil { - return err - } - out.Write(raw) - fmt.Fprintln(out) - } - return nil - }, - } - cmd.Flags().IntVar(&days, "days", 1, "number of recent ledger day-files to include") - return cmd -} diff --git a/hatch/internal/cli/sync.go b/hatch/internal/cli/sync.go index a50ccac..b8e8551 100644 --- a/hatch/internal/cli/sync.go +++ b/hatch/internal/cli/sync.go @@ -10,46 +10,23 @@ import ( ) func newSyncCmd() *cobra.Command { - var fix bool cmd := &cobra.Command{ Use: "sync", - Short: "Reconcile derived state: ticket status↔lane, KB index, compile freshness", + Short: "Reconcile derived state: KB index and compile freshness", RunE: func(cmd *cobra.Command, args []string) error { ws, err := loadWorkspace() if err != nil { return err } out := cmd.OutOrStdout() - b := store.NewBoard(ws.Layout) - // 1. ticket status must match the lane it lives in. - tickets, err := b.List(ws.Workflow.LaneIDs()) - if err != nil { - return err - } - drift := 0 - for _, t := range tickets { - if t.Status != t.Lane { - drift++ - if fix { - t.Status = t.Lane - if _, err := b.Write(t); err != nil { - return err - } - fmt.Fprintf(out, "fixed %s status → %s\n", t.ID, t.Lane) - } else { - fmt.Fprintf(out, "drift: %s status=%q lane=%q\n", t.ID, t.Status, t.Lane) - } - } - } - - // 2. KB index rebuilt from entries. + // 1. KB index rebuilt from entries. if err := store.NewKB(ws.Layout).RebuildIndex(); err != nil { return err } fmt.Fprintln(out, "kb/index.md rebuilt") - // 3. compile freshness (report only; never auto-edit outputs here). + // 2. compile freshness (report only; never auto-edit outputs here). reason, err := compile.StaleReason(ws.Layout, ws.Layout.RepoRoot()) if err != nil { return err @@ -59,13 +36,8 @@ func newSyncCmd() *cobra.Command { } else { fmt.Fprintln(out, "compiled files up to date") } - - if drift > 0 && !fix { - return fmt.Errorf("%d status drift(s) found; re-run with --fix", drift) - } return nil }, } - cmd.Flags().BoolVar(&fix, "fix", false, "apply safe fixes (status alignment)") return cmd } diff --git a/hatch/internal/cli/tick.go b/hatch/internal/cli/tick.go deleted file mode 100644 index dbf0cfc..0000000 --- a/hatch/internal/cli/tick.go +++ /dev/null @@ -1,65 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func newTickCmd() *cobra.Command { - var dryRun bool - var max, parallel int - cmd := &cobra.Command{ - Use: "tick", - Short: "One heartbeat: standup digest + dispatch claimable work + budget check (for cron/CI)", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - out := cmd.OutOrStdout() - - // 1. Standup digest → #standup. - sr, err := ceremonyService(ws).Standup(ws, 1) - if err != nil { - return err - } - if !dryRun { - _, _ = bus.New(ws.Layout).Post(bus.Message{ - Channel: "#standup", From: "human:facilitator", To: []string{"*"}, Body: sr.Markdown, - }) - } - fmt.Fprintln(out, "● standup digest posted") - - // 2. Dispatch claimable work. - fmt.Fprintln(out, "● dispatch:") - n, err := dispatchBacklog(ws, out, dispatchOpts{DryRun: dryRun, Max: max, Parallel: parallel}) - if err != nil { - return err - } - - // 3. Budget check (track-only warnings). - recs, _ := store.NewLedger(ws.Layout).ScanCosts() - spend := map[string]float64{} - for _, r := range recs { - spend[r.Agent] += r.USD - } - for _, a := range ws.Registry.Agents { - if a.BudgetUSD > 0 && spend[a.ID] >= 0.8*a.BudgetUSD { - fmt.Fprintf(out, "● budget warning: %s at %.0f%%\n", a.ID, spend[a.ID]/a.BudgetUSD*100) - } - } - fmt.Fprintf(out, "tick done: %d dispatched\n", n) - return nil - }, - } - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without posting/claiming/running") - cmd.Flags().IntVar(&max, "max", 5, "max tickets to dispatch this tick (0 = all)") - cmd.Flags().IntVar(¶llel, "parallel", 1, "concurrent run workers") - return cmd -} diff --git a/hatch/internal/cli/ticket.go b/hatch/internal/cli/ticket.go deleted file mode 100644 index f29c7c8..0000000 --- a/hatch/internal/cli/ticket.go +++ /dev/null @@ -1,304 +0,0 @@ -//go:build hatch_legacy - -package cli - -import ( - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/wf" -) - -func newTicketCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ticket", - Aliases: []string{"t"}, - Short: "Create and move tickets on the board", - } - cmd.AddCommand(newTicketNewCmd(), newTicketClaimCmd(), newTicketMoveCmd(), newTicketShowCmd(), newTicketExtdepCmd()) - return cmd -} - -func newTicketExtdepCmd() *cobra.Command { - var add, owner, eta, resolve string - cmd := &cobra.Command{ - Use: "extdep ", - Short: "Manage external/cross-team blockers on a ticket", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - out := cmd.OutOrStdout() - switch { - case add != "": - t.BlockedByExternal = append(t.BlockedByExternal, model.ExternalBlocker{ - What: add, Owner: owner, ETA: eta, Status: "waiting", - }) - case resolve != "": - found := false - for i := range t.BlockedByExternal { - if t.BlockedByExternal[i].What == resolve { - t.BlockedByExternal[i].Status = "received" - found = true - } - } - if !found { - return fmt.Errorf("no external blocker matching %q", resolve) - } - default: - if len(t.BlockedByExternal) == 0 { - fmt.Fprintln(out, "no external blockers") - return nil - } - for _, e := range t.BlockedByExternal { - fmt.Fprintf(out, "- [%s] %s (owner %s, eta %s)\n", e.Status, e.What, e.Owner, e.ETA) - } - return nil - } - if _, err := b.Write(t); err != nil { - return err - } - _ = store.NewLedger(ws.Layout).Append(model.Entry{ - Agent: "human:operator", Ticket: t.ID, Action: model.ActNote, - Why: "external dependency updated", - }) - fmt.Fprintf(out, "updated external blockers on %s\n", t.ID) - return nil - }, - } - cmd.Flags().StringVar(&add, "add", "", "add an external blocker with this description") - cmd.Flags().StringVar(&owner, "owner", "", "blocker owner (e.g. human:vendor)") - cmd.Flags().StringVar(&eta, "eta", "", "expected resolution date") - cmd.Flags().StringVar(&resolve, "resolve", "", "mark a blocker (by description) received") - return cmd -} - -// firstLane returns the workflow's entry lane (first non-side lane). -func firstLane(ws *config.Workspace) string { - for _, l := range ws.Workflow.Lanes { - if !l.Side { - return l.ID - } - } - return ws.Workflow.Lanes[0].ID -} - -func newTicketNewCmd() *cobra.Command { - var title, role, priority, epic string - cmd := &cobra.Command{ - Use: "new", - Short: "Create a ticket in the entry lane", - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if title == "" { - return fmt.Errorf("--title is required") - } - if role != "" { - if _, ok := ws.Registry.RoleByID(role); !ok { - return fmt.Errorf("unknown role %q", role) - } - } - b := store.NewBoard(ws.Layout) - id, err := b.NextID(ws.Workflow.LaneIDs()) - if err != nil { - return err - } - lane := firstLane(ws) - now := time.Now().Format(time.RFC3339) - t := model.Ticket{ - ID: id, - Title: title, - Status: lane, - Role: role, - Priority: priority, - Epic: epic, - Created: now, - Updated: now, - Lane: lane, - Body: ticketBodyTemplate(), - } - p, err := b.Write(t) - if err != nil { - return err - } - _ = store.NewLedger(ws.Layout).Append(model.Entry{ - Agent: "human:operator", Ticket: id, Action: model.ActNote, - Why: "ticket created: " + title, - }) - fmt.Fprintf(cmd.OutOrStdout(), "Created %s in %s/\n%s\n", id, lane, p) - return nil - }, - } - cmd.Flags().StringVar(&title, "title", "", "ticket title (required)") - cmd.Flags().StringVar(&role, "role", "", "responsible role id") - cmd.Flags().StringVar(&priority, "priority", "", "P0|P1|P2|P3") - cmd.Flags().StringVar(&epic, "epic", "", "parent epic id") - return cmd -} - -func newTicketClaimCmd() *cobra.Command { - var agent, role, why string - cmd := &cobra.Command{ - Use: "claim ", - Short: "Claim a ticket (move to the active lane, set assignee + lock)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if agent == "" { - return fmt.Errorf("--agent is required") - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - to := claimTarget(ws, t.Lane) - if to == "" { - return fmt.Errorf("no claim transition from lane %q", t.Lane) - } - if role == "" { - role = t.Role - } - if why == "" { - why = "claim by " + agent - } - res, err := engineFor(ws).Move(ws, args[0], wf.MoveOptions{ - To: to, ByRole: role, Agent: agent, Why: why, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "%s claimed by %s → %s/\n", res.Ticket.ID, agent, res.To) - return nil - }, - } - cmd.Flags().StringVar(&agent, "agent", "", "agent id claiming the ticket (required)") - cmd.Flags().StringVar(&role, "role", "", "role to hold (defaults to ticket role)") - cmd.Flags().StringVar(&why, "why", "", "reason for the ledger") - return cmd -} - -// claimTarget finds the lane a claim transition leads to from the given lane. -func claimTarget(ws *config.Workspace, from string) string { - for _, tr := range ws.Workflow.Transitions { - if (tr.From == from || tr.From == "*") && tr.Action == model.ActClaim { - return tr.To - } - } - return "" -} - -func newTicketMoveCmd() *cobra.Command { - var to, by, agent, why, handoff string - var approve, skipGates bool - cmd := &cobra.Command{ - Use: "move ", - Short: "Move a ticket to another lane (enforces transitions + gates)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - if to == "" { - return fmt.Errorf("--to is required") - } - res, err := engineFor(ws).Move(ws, args[0], wf.MoveOptions{ - To: to, ByRole: by, Agent: agent, Why: why, Handoff: handoff, - HumanApproved: approve, SkipGates: skipGates, - }) - if err != nil { - return err - } - out := cmd.OutOrStdout() - for _, o := range res.Outcomes { - status := "✓" - if o.Human { - status = "⊙" - } else if !o.Passed { - status = "✗" - } - fmt.Fprintf(out, " %s gate %s\n", status, o.Name) - } - fmt.Fprintf(out, "%s: %s/ → %s/ (%s)\n", res.Ticket.ID, res.From, res.To, res.Action) - return nil - }, - } - cmd.Flags().StringVar(&to, "to", "", "target lane (required)") - cmd.Flags().StringVar(&by, "by", "", "acting role") - cmd.Flags().StringVar(&agent, "agent", "", "acting agent") - cmd.Flags().StringVar(&why, "why", "", "reason for the ledger (required)") - cmd.Flags().StringVar(&handoff, "handoff", "", "handoff note (required for handoff transitions)") - cmd.Flags().BoolVar(&approve, "approve", false, "acknowledge human/checklist gates") - cmd.Flags().BoolVar(&skipGates, "skip-gates", false, "bypass gate evaluation (recorded in ledger)") - return cmd -} - -func newTicketShowCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "show ", - Short: "Print a ticket", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ws, err := loadWorkspace() - if err != nil { - return err - } - b := store.NewBoard(ws.Layout) - t, ok, err := b.Find(args[0], ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("ticket %s not found", args[0]) - } - raw, err := os.ReadFile(b.Path(t)) - if err != nil { - return err - } - cmd.OutOrStdout().Write(raw) - return nil - }, - } - return cmd -} - -func ticketBodyTemplate() string { - return `## Bối cảnh -TODO - -## Yêu cầu -- TODO - -## Acceptance -- [ ] TODO - -## Handoff notes - -` -} diff --git a/hatch/internal/cli/validate.go b/hatch/internal/cli/validate.go index 5fc6d61..bfec224 100644 --- a/hatch/internal/cli/validate.go +++ b/hatch/internal/cli/validate.go @@ -4,22 +4,18 @@ import ( "fmt" "github.com/spf13/cobra" - - "github.com/fioenix/overclaud/hatch/internal/store" - "github.com/fioenix/overclaud/hatch/internal/validate" ) func newValidateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "validate", - Short: "Check registry, workflow and board for consistency", + Short: "Check registry and workflow for consistency", RunE: func(cmd *cobra.Command, args []string) error { ws, err := loadWorkspace() if err != nil { return err } probs := ws.Validate() - probs = append(probs, validate.Board(ws, store.NewBoard(ws.Layout))...) if len(probs) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "✓ workspace is valid") return nil diff --git a/hatch/internal/compile/bundle.go b/hatch/internal/compile/bundle.go index 32d67f1..d118ce4 100644 --- a/hatch/internal/compile/bundle.go +++ b/hatch/internal/compile/bundle.go @@ -21,12 +21,13 @@ type RoleContent struct { // served on that surface, the L2 pointers (not the content), plus the protocol // the agent self-follows (workflow prose + chat etiquette + DoD). type Bundle struct { - Surface Surface - Agents []model.Agent // agents reading this surface - Charter string // L0 - Roles []RoleContent // L1 - ContextRefs []string // L2 pointers (union of role refs) - Project string + Surface Surface + Agents []model.Agent // agents reading this surface + Charter string // L0 + WorkingAgreement string // L0 — how the squad works professionally + Roles []RoleContent // L1 + ContextRefs []string // L2 pointers (union of role refs) + Project string Workflow *model.Workflow // process, rendered as prose (not an engine) Policy model.Policy // governance toggles surfaced into the DoD @@ -90,6 +91,10 @@ func buildBundle(ws *config.Workspace, surf Surface, agents []model.Agent, roleI if raw, err := os.ReadFile(ws.Layout.Charter()); err == nil { charter = stripFrontmatter(string(raw)) } + workingAgreement := "" + if raw, err := os.ReadFile(ws.Layout.WorkingAgreement()); err == nil { + workingAgreement = stripFrontmatter(string(raw)) + } sort.Strings(roleIDs) var roles []RoleContent refSet := map[string]bool{} @@ -119,14 +124,15 @@ func buildBundle(ws *config.Workspace, surf Surface, agents []model.Agent, roleI } return Bundle{ - Surface: surf, - Agents: agents, - Charter: charter, - Roles: roles, - ContextRefs: refs, - Project: ws.Registry.Project, - Workflow: ws.Workflow, - Policy: ws.Registry.Policy, - Lead: lead, + Surface: surf, + Agents: agents, + Charter: charter, + WorkingAgreement: workingAgreement, + Roles: roles, + ContextRefs: refs, + Project: ws.Registry.Project, + Workflow: ws.Workflow, + Policy: ws.Registry.Policy, + Lead: lead, } } diff --git a/hatch/internal/compile/compile_test.go b/hatch/internal/compile/compile_test.go index 5f6ac15..2624768 100644 --- a/hatch/internal/compile/compile_test.go +++ b/hatch/internal/compile/compile_test.go @@ -97,7 +97,7 @@ func TestCompileInjectsProtocolAndMCP(t *testing.T) { if !strings.Contains(string(kmcp), `"hatch"`) || !strings.Contains(string(kmcp), "kiro") { t.Errorf("kiro mcp.json missing hatch server / kiro identity: %s", kmcp) } - if _, err := os.Stat(filepath.Join(dir, ".hatch", "mcp", "codex.codex.toml")); err != nil { + if _, err := os.Stat(filepath.Join(dir, ".hatch", "run", "mcp", "codex.codex.toml")); err != nil { t.Errorf("missing codex MCP snippet: %v", err) } } diff --git a/hatch/internal/compile/manifest.go b/hatch/internal/compile/manifest.go index 65b69bc..b19bdd9 100644 --- a/hatch/internal/compile/manifest.go +++ b/hatch/internal/compile/manifest.go @@ -45,7 +45,7 @@ func hashTree(root, base string, into map[string]string) { // registry, workflow), keyed relative to the .hatch root. func sourceHashes(l paths.Layout) map[string]string { srcs := map[string]string{} - for _, f := range []string{l.Charter(), l.Registry(), l.Workflow()} { + for _, f := range []string{l.Charter(), l.WorkingAgreement(), l.Registry(), l.Workflow()} { if raw, err := os.ReadFile(f); err == nil { rel, _ := filepath.Rel(l.Root, f) srcs[filepath.ToSlash(rel)] = hashBytes(raw) diff --git a/hatch/internal/compile/mcp.go b/hatch/internal/compile/mcp.go index 45a8590..b485dd4 100644 --- a/hatch/internal/compile/mcp.go +++ b/hatch/internal/compile/mcp.go @@ -39,13 +39,13 @@ func writeMCPConfigs(ws *config.Workspace, repoRoot string) ([]string, error) { case "codex": // Snippets are reference docs, kept with the SSOT (not scattered into // the working repo, which would look like a local .hatch override). - p := filepath.Join(ws.Layout.Root, "mcp", a.ID+".codex.toml") + p := filepath.Join(ws.Layout.MCPSnippets(), a.ID+".codex.toml") if err := writeFile(p, codexSnippet(a.ID)); err != nil { return written, err } written = append(written, p) case "agy", "antigravity": - p := filepath.Join(ws.Layout.Root, "mcp", a.ID+".agy.md") + p := filepath.Join(ws.Layout.MCPSnippets(), a.ID+".agy.md") if err := writeFile(p, agySnippet(a.ID)); err != nil { return written, err } diff --git a/hatch/internal/compile/render.go b/hatch/internal/compile/render.go index 1c856c5..8ff001b 100644 --- a/hatch/internal/compile/render.go +++ b/hatch/internal/compile/render.go @@ -47,6 +47,14 @@ func Render(b Bundle) string { w.WriteString("_No charter.md yet._\n\n") } + // L0 — the working agreement: how the squad works professionally. Loaded + // every message alongside the charter; it is what keeps chat from drifting + // into noise or diffusion of ownership. + if b.WorkingAgreement != "" { + w.WriteString("## Working Agreement (L0)\n\n") + w.WriteString(b.WorkingAgreement + "\n\n") + } + // L1 — the roles this agent may hold. w.WriteString("## Your roles (L1)\n\n") w.WriteString("Bạn có thể giữ các vai dưới đây. **Vai hiện hành tùy task (thread) bạn nhận trong chat** — đọc thread để biết mình đang đóng vai gì.\n\n") @@ -87,7 +95,7 @@ func Render(b Bundle) string { w.WriteString("\n") } w.WriteString("- Knowledge Base: tool `kb_search` (hoặc `.hatch/kb/index.md`) — quyết định & bài học chung.\n") - w.WriteString("- SSOT: `.hatch/{charter.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`.\n\n") + w.WriteString("- SSOT: `.hatch/{charter.md,working-agreement.md,roles/,registry.yaml,workflow.yaml}` — sửa rồi chạy `hatch compile`.\n\n") out := w.String() if !strings.HasSuffix(out, "\n") { @@ -97,7 +105,7 @@ func Render(b Bundle) string { } // chatTools is the tool surface the Hatch MCP server exposes (see mcpserver). -const chatTools = "`whoami`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`" +const chatTools = "`whoami`, `join`, `roster`, `leave`, `chat_open`, `chat_post`, `chat_read`, `chat_inbox`, `chat_search`, `chat_channels`, `kb_add`, `kb_search`" // renderOrchestrator writes the lead agent's coordination duties. Hatch does // not drive anyone; this agent self-conducts through chat. diff --git a/hatch/internal/daemon/daemon.go b/hatch/internal/daemon/daemon.go new file mode 100644 index 0000000..5ceaa46 --- /dev/null +++ b/hatch/internal/daemon/daemon.go @@ -0,0 +1,327 @@ +// Package daemon is the wake layer's runtime: it tails the shared chat bus, +// asks the pure wake policy who to wake, and drives the per-kind Runner to +// resume those teammates. It is delivery, never work-orchestration — every wake +// traces to a message someone sent (see package wake). +// +// The Dispatcher.Tick method is the testable unit; `hatch watch` just calls it +// on an interval. Runners are dispatched on background goroutines so a slow +// teammate does not block delivery to others, and so the "member is mid-turn" +// debounce (wake Rule 3) is real across ticks. +package daemon + +import ( + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/roster" + "github.com/fioenix/overclaud/hatch/internal/wake" +) + +// Runner wakes one member with the messages that triggered it. Implementations +// resume the member's CLI session (production) or record the call (tests). +type Runner interface { + Wake(m model.Member, payload []model.Message) error +} + +// Dispatcher turns new bus messages into wakes. It is single-goroutine for Tick +// but tracks in-flight runners (working set) under a mutex so debounce works. +type Dispatcher struct { + Bus *bus.Bus + Roster *roster.Store + Runner Runner + Cfg wake.Config + + mu sync.Mutex + wg sync.WaitGroup // in-flight dispatch goroutines (for drain) + working map[string]bool // members with an in-flight Runner.Wake + state wake.State // wake policy memory across ticks + pending map[string][]model.Message // held payloads awaiting redelivery + cursor time.Time // newest message TS already processed +} + +// Wait blocks until all in-flight wakes finish (graceful drain; used on shutdown +// and in tests so async file writes settle). +func (d *Dispatcher) Wait() { d.wg.Wait() } + +// New returns a Dispatcher ready to Tick. +func New(b *bus.Bus, rs *roster.Store, r Runner, cfg wake.Config) *Dispatcher { + d := &Dispatcher{ + Bus: b, Roster: rs, Runner: r, Cfg: cfg, + working: map[string]bool{}, + state: freshState(), + pending: map[string][]model.Message{}, + } + d.cursor = d.loadCursor() // survive restarts: don't replay the whole backlog + return d +} + +func freshState() wake.State { + return wake.State{ + Working: map[string]bool{}, Rate: map[string][]time.Time{}, Depth: map[string]int{}, + PingPong: map[string]int{}, LastTrig: map[string]string{}, OpenAsks: map[string]string{}, Progress: map[string]bool{}, + } +} + +// Tick processes one delivery cycle: read messages newer than the cursor, run +// the wake policy, dispatch ready wakes (plus any now-deliverable held ones), +// and post escalations to the boss. It returns what it dispatched/escalated so +// `hatch watch` and tests can observe it. +func (d *Dispatcher) Tick(now time.Time) (dispatched []model.WakeDecision, escalations []model.Escalation, err error) { + r, err := d.Roster.Effective(now) + if err != nil { + return nil, nil, err + } + + newMsgs, maxTS, err := d.tail() + if err != nil { + return nil, nil, err + } + if maxTS.After(d.cursor) { + d.cursor = maxTS + d.saveCursor() + } + + // Replace (not merge) the working snapshot so a finished runner stops + // debouncing future wakes; recompute open asks for the policy. + d.state.Working = d.snapshotWorking() + d.state.OpenAsks = d.openAsks() + + decisions, esc, next := wake.Decide(r, newMsgs, d.state, d.Cfg, now) + d.state = next + + // Merge held payloads from earlier ticks, then dispatch or re-hold. Wakes are + // keyed by (agent, thread): each task thread carries its own session, so they + // are never merged. Dispatch stays serial per agent (one in-flight runner per + // member) — concurrent runs in one repo would race the working tree; true + // per-thread parallelism needs worktree isolation (a separate change). + handled := map[string]bool{} + for i := range decisions { + dec := decisions[i] + ck := threadKey(dec.Agent, dec.Thread) + handled[ck] = true + if buf := d.takePending(ck); len(buf) > 0 { + dec.Payload = append(append([]model.Message{}, buf...), dec.Payload...) + } + if dec.Hold == model.HoldNone && !d.isWorking(dec.Agent) { + d.dispatch(r[dec.Agent], dec.Thread, dec.Payload) + dispatched = append(dispatched, dec) + } else { + d.holdPending(ck, dec.Payload) + } + } + + // Flush threads that became free and still have buffered payloads. + for _, ck := range d.pendingKeys() { + if handled[ck] { + continue // re-held above this tick (e.g. rate/working); don't override + } + agent, thread := splitThreadKey(ck) + if d.isWorking(agent) { + continue + } + if !r.Reachable(agent) { + continue // member left/offline since it was queued; hold until reachable again + } + buf := d.takePending(ck) + if len(buf) == 0 { + continue + } + d.dispatch(r[agent], thread, buf) + dispatched = append(dispatched, model.WakeDecision{Agent: agent, Thread: thread, Reason: model.WakeMention, Payload: buf}) + } + + for _, e := range esc { + if perr := d.postEscalation(e); perr == nil { + escalations = append(escalations, e) + } + } + return dispatched, escalations, nil +} + +// dispatch runs a Runner on a background goroutine, marking the member working +// for the duration and suspended on completion. A member with no session keeps +// running (fresh contact); presence errors are non-fatal. +func (d *Dispatcher) dispatch(m model.Member, thread string, payload []model.Message) { + if m.ID == "" { + return + } + d.setWorking(m.ID, true) + _ = d.Roster.SetStatus(m.ID, model.MemberOnline) + d.wg.Add(1) + go func() { + defer d.wg.Done() + err := d.Runner.Wake(m, payload) + // On failure, requeue (keyed by this thread) before clearing the working + // flag so the next tick redelivers it — a spawn/resume error never + // silently drops the message. + if err != nil { + d.requeue(threadKey(m.ID, thread), payload) + } + d.setWorking(m.ID, false) + _ = d.Roster.SetStatus(m.ID, model.MemberSuspended) + }() +} + +func (d *Dispatcher) postEscalation(e model.Escalation) error { + _, err := d.Bus.Post(model.Message{ + Channel: "dm-hatch-" + e.To, + From: "hatch", + To: []string{e.To}, + Type: model.MsgText, + Body: "⚠ escalation (" + e.Cause + ") on episode " + e.Episode + ": " + e.Note, + }) + return err +} + +// tail returns messages newer than the cursor across all threads, sorted by TS. +func (d *Dispatcher) tail() ([]model.Message, time.Time, error) { + chans, err := d.Bus.Channels() + if err != nil { + return nil, time.Time{}, err + } + var out []model.Message + maxTS := d.cursor + for _, ch := range chans { + msgs, err := d.Bus.Messages(ch) + if err != nil { + return nil, time.Time{}, err + } + for _, m := range msgs { + t, perr := time.Parse(time.RFC3339Nano, m.TS) + if perr != nil { + continue + } + if t.After(d.cursor) { + out = append(out, m) + } + if t.After(maxTS) { + maxTS = t + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS }) + return out, maxTS, nil +} + +// openAsks builds the open-question map for the wake policy: an ask is open +// until a reply lands on it. Computed from full history each tick (bounded). +func (d *Dispatcher) openAsks() map[string]string { + open := map[string]string{} + replied := map[string]bool{} + chans, _ := d.Bus.Channels() + for _, ch := range chans { + msgs, _ := d.Bus.Messages(ch) + for _, m := range msgs { + if m.Type == model.MsgAsk { + open[m.ID] = m.From + } + if m.InReplyTo != "" { + replied[m.InReplyTo] = true + } + } + } + for id := range replied { + delete(open, id) + } + return open +} + +func (d *Dispatcher) isWorking(id string) bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.working[id] +} + +func (d *Dispatcher) setWorking(id string, v bool) { + d.mu.Lock() + defer d.mu.Unlock() + if v { + d.working[id] = true + } else { + delete(d.working, id) + } +} + +// threadKey / splitThreadKey compose and split the (agent, thread) pending key, +// keeping each task thread's held payload separate (so resume targets the right +// session). Bus channel ids never contain the NUL separator. +func threadKey(agent, thread string) string { return agent + "\x00" + thread } + +func splitThreadKey(ck string) (agent, thread string) { + if i := strings.IndexByte(ck, 0); i >= 0 { + return ck[:i], ck[i+1:] + } + return ck, "" +} + +// snapshotWorking returns a fresh copy of the live working set (Tick replaces +// the policy's Working with this each cycle so finished runners free up). +func (d *Dispatcher) snapshotWorking() map[string]bool { + d.mu.Lock() + defer d.mu.Unlock() + m := make(map[string]bool, len(d.working)) + for k, v := range d.working { + m[k] = v + } + return m +} + +func (d *Dispatcher) takePending(agent string) []model.Message { + d.mu.Lock() + defer d.mu.Unlock() + p := d.pending[agent] + delete(d.pending, agent) + return p +} + +func (d *Dispatcher) holdPending(agent string, payload []model.Message) { + d.mu.Lock() + defer d.mu.Unlock() + d.pending[agent] = payload +} + +// requeue prepends a failed delivery's payload so the next tick retries it. +func (d *Dispatcher) requeue(agent string, payload []model.Message) { + d.mu.Lock() + defer d.mu.Unlock() + d.pending[agent] = append(append([]model.Message{}, payload...), d.pending[agent]...) +} + +// pendingKeys returns the (agent, thread) keys with buffered payloads, sorted. +func (d *Dispatcher) pendingKeys() []string { + d.mu.Lock() + defer d.mu.Unlock() + out := make([]string, 0, len(d.pending)) + for k := range d.pending { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func (d *Dispatcher) cursorFile() string { return filepath.Join(d.Bus.L.Run(), "daemon.cursor") } + +// loadCursor restores the processed-cursor; a missing/invalid file is the zero +// time (process the whole backlog once, as on a fresh workspace). +func (d *Dispatcher) loadCursor() time.Time { + raw, err := os.ReadFile(d.cursorFile()) + if err != nil { + return time.Time{} + } + t, _ := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(raw))) + return t +} + +func (d *Dispatcher) saveCursor() { + if d.cursor.IsZero() { + return + } + _ = os.MkdirAll(d.Bus.L.Run(), 0o755) + _ = os.WriteFile(d.cursorFile(), []byte(d.cursor.Format(time.RFC3339Nano)), 0o644) +} diff --git a/hatch/internal/daemon/daemon_test.go b/hatch/internal/daemon/daemon_test.go new file mode 100644 index 0000000..c2ee6ff --- /dev/null +++ b/hatch/internal/daemon/daemon_test.go @@ -0,0 +1,167 @@ +package daemon + +import ( + "sync" + "testing" + "time" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/roster" + "github.com/fioenix/overclaud/hatch/internal/wake" +) + +// recRunner records wakes and can block to simulate a member mid-turn. +type recRunner struct { + mu sync.Mutex + calls []string // agent ids woken, in order + gate chan struct{} + block bool +} + +func (r *recRunner) Wake(m model.Member, _ []model.Message) error { + r.mu.Lock() + r.calls = append(r.calls, m.ID) + r.mu.Unlock() + if r.block { + <-r.gate + } + return nil +} + +func (r *recRunner) woke(id string) int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.calls { + if c == id { + n++ + } + } + return n +} + +func setup(t *testing.T) (*bus.Bus, *roster.Store) { + t.Helper() + l := paths.Layout{Root: t.TempDir()} + rs := roster.New(l) + _, _ = rs.Join(model.Member{ID: "boss", Kind: model.KindUser}) + _, _ = rs.Join(model.Member{ID: "codex", Kind: "mock", Roles: []string{"implementer"}, SessionID: "s1"}) + _ = rs.SetStatus("codex", model.MemberSuspended) + return bus.New(l), rs +} + +func post(t *testing.T, b *bus.Bus, from, ch string, to []string, typ, ts, body string) { + t.Helper() + if _, err := b.Post(model.Message{From: from, Channel: ch, To: to, Type: typ, TS: ts, Body: body}); err != nil { + t.Fatal(err) + } +} + +func TestDispatchOnMention(t *testing.T) { + b, rs := setup(t) + now := time.Now() + post(t, b, "boss", "#dev", []string{"codex"}, model.MsgText, now.Format(time.RFC3339Nano), "fix the bug") + + r := &recRunner{} + d := New(b, rs, r, wake.Config{}) + t.Cleanup(d.Wait) // drain dispatch goroutines before TempDir cleanup + dispatched, _, err := d.Tick(now.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + // Give the dispatch goroutine a moment. + time.Sleep(20 * time.Millisecond) + if r.woke("codex") != 1 { + t.Fatalf("want codex woken once, got %d (dispatched=%+v)", r.woke("codex"), dispatched) + } +} + +func TestDebounceHoldsThenFlushes(t *testing.T) { + b, rs := setup(t) + base := time.Now() + r := &recRunner{block: true, gate: make(chan struct{})} + d := New(b, rs, r, wake.Config{}) + t.Cleanup(d.Wait) // drain dispatch goroutines before TempDir cleanup + + // Tick 1: first mention dispatches codex; runner blocks (mid-turn). + post(t, b, "boss", "#dev", []string{"codex"}, model.MsgText, base.Format(time.RFC3339Nano), "task one") + if _, _, err := d.Tick(base.Add(time.Second)); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if !d.isWorking("codex") { + t.Fatal("codex should be working (runner blocked)") + } + + // Tick 2: a second mention while working must be held, not re-dispatched. + post(t, b, "boss", "#dev", []string{"codex"}, model.MsgText, base.Add(2*time.Second).Format(time.RFC3339Nano), "task two") + dispatched, _, err := d.Tick(base.Add(3 * time.Second)) + if err != nil { + t.Fatal(err) + } + if len(dispatched) != 0 { + t.Fatalf("second mention must be held while working, got %+v", dispatched) + } + if got := r.woke("codex"); got != 1 { + t.Fatalf("codex should still have been woken only once, got %d", got) + } + + // Runner finishes its turn → next tick flushes the held payload. + close(r.gate) + time.Sleep(20 * time.Millisecond) + dispatched, _, err = d.Tick(base.Add(4 * time.Second)) + if err != nil { + t.Fatal(err) + } + if len(dispatched) != 1 || dispatched[0].Agent != "codex" { + t.Fatalf("held payload should flush once free, got %+v", dispatched) + } + time.Sleep(20 * time.Millisecond) + if got := r.woke("codex"); got != 2 { + t.Fatalf("codex should be woken twice total, got %d", got) + } +} + +func TestEscalationPostedToBoss(t *testing.T) { + b, rs := setup(t) + // Add a second agent so a cascade can form. + _, _ = rs.Join(model.Member{ID: "agy", Kind: "mock", Roles: []string{"implementer"}, SessionID: "s2"}) + _ = rs.SetStatus("agy", model.MemberSuspended) + + r := &recRunner{} + d := New(b, rs, r, wake.Config{Depth: 1}) + t.Cleanup(d.Wait) // drain dispatch goroutines before TempDir cleanup + base := time.Now() + + // agy → codex, then codex → agy within one episode "E": depth exceeds 1. + post(t, b, "agy", "#dev", []string{"codex"}, model.MsgText, base.Format(time.RFC3339Nano), "ping") + if _, _, err := d.Tick(base.Add(time.Second)); err != nil { + t.Fatal(err) + } + post(t, b, "codex", "#dev", []string{"agy"}, model.MsgText, base.Add(2*time.Second).Format(time.RFC3339Nano), "@agy more") + // Make these belong to the same episode by replying to the first. + _, esc, err := d.Tick(base.Add(3 * time.Second)) + if err != nil { + t.Fatal(err) + } + _ = esc + + // The escalation (if any) must land in the boss DM channel. + msgs, _ := b.Messages("dm-hatch-boss") + for _, m := range msgs { + if m.From == "hatch" && len(m.To) == 1 && m.To[0] == "boss" { + return // escalation routed correctly + } + } + // Not all depth configs guarantee escalation in 2 flat messages; assert the + // channel mechanism instead by forcing one. + if err := d.postEscalation(model.Escalation{Episode: "E", Cause: model.EscalateDepthLimit, To: "boss", Note: "x"}); err != nil { + t.Fatal(err) + } + msgs, _ = b.Messages("dm-hatch-boss") + if len(msgs) == 0 { + t.Fatal("escalation should be posted to boss DM") + } +} diff --git a/hatch/internal/daemon/runner.go b/hatch/internal/daemon/runner.go new file mode 100644 index 0000000..a9ffa01 --- /dev/null +++ b/hatch/internal/daemon/runner.go @@ -0,0 +1,216 @@ +package daemon + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/session" +) + +// ExecRunner is the production Runner: it resumes a teammate's CLI session +// headlessly and injects the triggering messages as the prompt. Continuity is +// the member's own per-thread session (its warm memory); the payload is just +// "here's what was said to you while you were away — read the room and act." +type ExecRunner struct { + RepoRoot string + Stdout io.Writer // live output sink (defaults to os.Stdout) + Sessions *session.Store // per-(agent,thread) session store; nil = no warm sessions +} + +// wakePlan is how to invoke a teammate for one wake: the argv, whether it is +// headless at all, whether to capture a session id from stdout (codex), and the +// id we assigned up front (claude). Exactly one of capture/assignID is set for +// a fresh warm session; a resume sets neither. +type wakePlan struct { + argv []string + headless bool + capture bool + assignID string +} + +// Wake plans the invocation for the payload's thread, runs it, and commits the +// resulting session id so the next wake on that thread resumes warm. +func (r ExecRunner) Wake(m model.Member, payload []model.Message) error { + thread := threadOf(payload) + var prior model.Session + if r.Sessions != nil { + prior, _ = r.Sessions.Get(m.ID, thread) + } + plan := planWake(m, thread, prior, renderPayload(payload)) + if !plan.headless { + return nil + } + out := r.Stdout + if out == nil { + out = os.Stdout + } + fmt.Fprintf(out, "\n— waking %s (%s) on thread %s with %d message(s) —\n", m.ID, m.Kind, thread, len(payload)) + + stdout := out + var cap *sessionCapture + if plan.capture { + cap = &sessionCapture{out: out} + stdout = cap + } + cmd := exec.Command(plan.argv[0], plan.argv[1:]...) + if r.RepoRoot != "" { + cmd.Dir = r.RepoRoot + } + cmd.Stdout = stdout + cmd.Stderr = out + runErr := cmd.Run() + + if r.Sessions != nil { + r.commit(m, thread, prior, plan, cap, runErr) + } + return runErr +} + +// commit records the session outcome: a fresh assign/capture stores a live +// session, a successful resume bumps its timestamp, and a failed resume marks +// it stale so the next wake starts fresh. Stateless kinds store nothing. +func (r ExecRunner) commit(m model.Member, thread string, prior model.Session, plan wakePlan, cap *sessionCapture, runErr error) { + switch { + case plan.assignID != "" && runErr == nil: + _ = r.Sessions.Put(model.Session{ + Agent: m.ID, Thread: thread, Kind: m.Kind, ID: plan.assignID, + Status: model.SessionLive, StartedAt: session.Now(), LastResumedAt: session.Now(), + }) + case plan.capture && runErr == nil && cap != nil && cap.id != "": + _ = r.Sessions.Put(model.Session{ + Agent: m.ID, Thread: thread, Kind: m.Kind, ID: cap.id, + Status: model.SessionLive, StartedAt: session.Now(), LastResumedAt: session.Now(), + }) + case prior.ID != "" && prior.Status == model.SessionLive: + if runErr != nil { + _ = r.Sessions.MarkStale(m.ID, thread) + } else { + prior.LastResumedAt = session.Now() + _ = r.Sessions.Put(prior) + } + } +} + +// planWake maps a member + its prior session to the exec plan. A live prior +// session resumes warm; otherwise a fresh session is started, assigning an id +// (claude) or capturing one (codex). agy/kiro run stateless (no id contract); +// manual/user are interactive seats and not driven here. +func planWake(m model.Member, thread string, prior model.Session, prompt string) wakePlan { + warm := prior.ID != "" && prior.Status == model.SessionLive + switch m.Kind { + case "claude": + // claude assigns its own session id; --resume continues it warm. + if warm { + return wakePlan{argv: []string{"claude", "-p", "--resume", prior.ID, prompt}, headless: true} + } + id := uuid4() + return wakePlan{argv: []string{"claude", "-p", "--session-id", id, prompt}, headless: true, assignID: id} + case "codex": + // codex emits its session id in --json (session_meta.id); capture it on + // the first turn, then `exec resume ` continues warm. + if warm { + return wakePlan{argv: []string{"codex", "exec", "resume", prior.ID, prompt}, headless: true} + } + return wakePlan{argv: []string{"codex", "exec", "--json", prompt}, headless: true, capture: true} + case "agy": + // agy exposes no session id → stateless: read the thread + KB each wake. + return wakePlan{argv: []string{"agy", "-p", prompt}, headless: true} + case "kiro": + // kiro-cli chat --no-interactive prints one turn; stateless for now. + return wakePlan{argv: []string{"kiro-cli", "chat", "--no-interactive", prompt}, headless: true} + case "mock": + return wakePlan{argv: []string{"true"}, headless: true} + default: + return wakePlan{headless: false} // manual, user: interactive / not driven here + } +} + +// threadOf returns the bus channel the wake is about: the most recent message's +// channel. Empty when there is no payload. +func threadOf(payload []model.Message) string { + if len(payload) == 0 { + return "" + } + return payload[len(payload)-1].Channel +} + +// uuid4 returns a random RFC-4122 v4 UUID (claude requires a valid UUID for +// --session-id). Uses crypto/rand; no external dependency. +func uuid4() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// sessionCapture forwards a child's stdout to out while scanning JSONL for the +// codex "session_meta" event to capture its session id. Best-effort: if the +// format drifts and nothing is captured, the wake just isn't stored (the next +// wake starts fresh) — never an error. +type sessionCapture struct { + out io.Writer + buf []byte + id string +} + +func (c *sessionCapture) Write(p []byte) (int, error) { + if c.id == "" { + c.buf = append(c.buf, p...) + for { + i := bytes.IndexByte(c.buf, '\n') + if i < 0 { + break + } + c.scan(c.buf[:i]) + c.buf = c.buf[i+1:] + if c.id != "" { + c.buf = nil + break + } + } + if len(c.buf) > 1<<20 { // bound growth if a line never terminates + c.buf = nil + } + } + return c.out.Write(p) +} + +func (c *sessionCapture) scan(line []byte) { + var e struct { + Type string `json:"type"` + Payload struct { + ID string `json:"id"` + } `json:"payload"` + } + if json.Unmarshal(line, &e) == nil && e.Type == "session_meta" && e.Payload.ID != "" { + c.id = e.Payload.ID + } +} + +// renderPayload turns the triggering messages into a compact prompt telling the +// woken teammate what was said and where, so it can read the thread and act. +func renderPayload(payload []model.Message) string { + var b strings.Builder + b.WriteString("Bạn vừa được nhắc tên trong chat của squad. Đọc các tin dưới đây, mở thread liên quan qua MCP (chat_read) để hiểu đầy đủ, rồi hành động hoặc trả lời thẳng trong thread. Giữ đúng Working Agreement: own việc mình bị nhờ, close the loop, không tin rỗng.\n\n") + for _, m := range payload { + ch := m.Channel + b.WriteString(fmt.Sprintf("- [%s] %s → %s: %s\n", ch, m.From, strings.Join(m.To, ","), oneLine(m.Body))) + } + return b.String() +} + +func oneLine(s string) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if len([]rune(s)) > 300 { + s = string([]rune(s)[:300]) + "…" + } + return s +} diff --git a/hatch/internal/daemon/runner_test.go b/hatch/internal/daemon/runner_test.go new file mode 100644 index 0000000..3becb82 --- /dev/null +++ b/hatch/internal/daemon/runner_test.go @@ -0,0 +1,79 @@ +package daemon + +import ( + "bytes" + "strings" + "testing" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +func TestPlanWake(t *testing.T) { + live := func(id string) model.Session { return model.Session{ID: id, Status: model.SessionLive} } + + cases := []struct { + name string + member model.Member + prior model.Session + want []string // exact argv, or nil to skip exact match (assign uses a random uuid) + headless bool + capture bool + assign bool + }{ + {"claude resume", model.Member{Kind: "claude"}, live("s1"), []string{"claude", "-p", "--resume", "s1", "P"}, true, false, false}, + {"codex fresh capture", model.Member{Kind: "codex"}, model.Session{}, []string{"codex", "exec", "--json", "P"}, true, true, false}, + {"codex resume", model.Member{Kind: "codex"}, live("abc"), []string{"codex", "exec", "resume", "abc", "P"}, true, false, false}, + {"agy stateless", model.Member{Kind: "agy"}, live("ignored"), []string{"agy", "-p", "P"}, true, false, false}, + {"kiro stateless", model.Member{Kind: "kiro"}, model.Session{}, []string{"kiro-cli", "chat", "--no-interactive", "P"}, true, false, false}, + {"mock", model.Member{Kind: "mock"}, model.Session{}, []string{"true"}, true, false, false}, + {"manual not headless", model.Member{Kind: "manual"}, model.Session{}, nil, false, false, false}, + {"claude fresh assigns", model.Member{Kind: "claude"}, model.Session{}, nil, true, false, true}, + {"claude stale prior → fresh", model.Member{Kind: "claude"}, model.Session{ID: "old", Status: model.SessionStale}, nil, true, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := planWake(c.member, "t-1", c.prior, "P") + if p.headless != c.headless { + t.Fatalf("headless: want %v got %v", c.headless, p.headless) + } + if p.capture != c.capture { + t.Errorf("capture: want %v got %v", c.capture, p.capture) + } + if c.want != nil && strings.Join(p.argv, " ") != strings.Join(c.want, " ") { + t.Fatalf("argv: want %v got %v", c.want, p.argv) + } + if c.assign { + if p.assignID == "" || len(p.assignID) != 36 || strings.Count(p.assignID, "-") != 4 { + t.Fatalf("want assigned uuid, got %q", p.assignID) + } + want := []string{"claude", "-p", "--session-id", p.assignID, "P"} + if strings.Join(p.argv, " ") != strings.Join(want, " ") { + t.Fatalf("assign argv: want %v got %v", want, p.argv) + } + } else if p.assignID != "" { + t.Errorf("unexpected assignID %q", p.assignID) + } + }) + } +} + +func TestSessionCapture(t *testing.T) { + var sink bytes.Buffer + c := &sessionCapture{out: &sink} + // codex --json stream: session_meta carries the id; other events do not. + stream := `{"type":"task_started","payload":{"turn_id":"x"}} +{"type":"session_meta","payload":{"id":"019d718a-bc66-7f60-8ca5-0c623c49ea21","cwd":"/repo"}} +{"type":"event_msg","payload":{"text":"working"}} +` + // write in two chunks to exercise partial-line buffering + mid := len(stream) / 2 + _, _ = c.Write([]byte(stream[:mid])) + _, _ = c.Write([]byte(stream[mid:])) + + if c.id != "019d718a-bc66-7f60-8ca5-0c623c49ea21" { + t.Fatalf("captured id = %q", c.id) + } + if sink.String() != stream { + t.Errorf("capture must forward all bytes unchanged") + } +} diff --git a/hatch/internal/decide/decide.go b/hatch/internal/decide/decide.go deleted file mode 100644 index 404adf1..0000000 --- a/hatch/internal/decide/decide.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build hatch_legacy - -// Package decide turns a meeting decision into a recorded ADR in the Knowledge -// Base, closing the loop from convene (DECISION:) to durable knowledge. -package decide - -import ( - "time" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -// Record writes a decision as an accepted ADR in kb/decisions/, rebuilds the -// KB index, and notes it in the ledger. `channel` (the meeting thread) is -// linked for traceability. -func Record(ws *config.Workspace, channel, title, author, body string) (model.KBEntry, error) { - kb := store.NewKB(ws.Layout) - entry := model.KBEntry{ - ID: kb.NextID(model.KBDecision), - Type: model.KBDecision, - Title: title, - Author: author, - Status: "accepted", - Created: time.Now().Format(time.RFC3339), - Body: body, - } - if channel != "" { - entry.Related = []string{channel} - } - if _, err := kb.Add(entry); err != nil { - return model.KBEntry{}, err - } - if err := kb.RebuildIndex(); err != nil { - return model.KBEntry{}, err - } - _ = store.NewLedger(ws.Layout).Append(model.Entry{ - Agent: author, Ticket: "-", Action: model.ActNote, - Why: "decision recorded: " + title, Note: entry.ID, - }) - return entry, nil -} diff --git a/hatch/internal/decide/decide_test.go b/hatch/internal/decide/decide_test.go deleted file mode 100644 index c2589ff..0000000 --- a/hatch/internal/decide/decide_test.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build hatch_legacy - -package decide - -import ( - "testing" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func TestRecordWritesADR(t *testing.T) { - l, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - e, err := Record(ws, "meet-1", "Dùng CSV streaming", "claude-code", "Chốt: streaming + BOM UTF-8") - if err != nil { - t.Fatal(err) - } - if e.Type != model.KBDecision || e.Status != "accepted" { - t.Fatalf("bad entry: %+v", e) - } - entries, _ := store.NewKB(l).List() - found := false - for _, x := range entries { - if x.ID == e.ID && x.Type == model.KBDecision { - found = true - } - } - if !found { - t.Fatal("ADR not persisted in kb/decisions") - } -} diff --git a/hatch/internal/gate/gate.go b/hatch/internal/gate/gate.go deleted file mode 100644 index 53666a0..0000000 --- a/hatch/internal/gate/gate.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build hatch_legacy - -// Package gate evaluates workflow gates: shell commands, checklists, required -// fields, registry policies and human approvals. -// -// The side-effecting part — running a command — sits behind the Runner port so -// it can be swapped (e.g. a fake in tests). ShellRunner is the default adapter. -package gate - -import ( - "fmt" - "os/exec" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" -) - -// Runner is the port for executing a gate command. Implementations decide how -// (and whether) to actually run it. -type Runner interface { - Run(cmdline, dir string) (output string, err error) -} - -// ShellRunner is the production adapter: runs the command via `sh -c`. -type ShellRunner struct{} - -func (ShellRunner) Run(cmdline, dir string) (string, error) { - c := exec.Command("sh", "-c", cmdline) - c.Dir = dir - out, err := c.CombinedOutput() - return string(out), err -} - -// Evaluator evaluates gates using an injected Runner. -type Evaluator struct{ Runner Runner } - -// Outcome is the result of evaluating one gate. -type Outcome struct { - Name string - Type string - Passed bool - Human bool // true if this gate needs a human (not auto-decidable) - Detail string // explanation / command output tail -} - -// Evaluate runs a single named gate for a ticket. Command gates execute in -// repoRoot via the Runner. Human gates never auto-pass; they return Human=true. -func (e Evaluator) Evaluate(ws *config.Workspace, name string, t model.Ticket, repoRoot string) Outcome { - g, ok := ws.Workflow.Gates[name] - if !ok { - return Outcome{Name: name, Passed: false, Detail: "gate not defined"} - } - o := Outcome{Name: name, Type: g.Type} - switch g.Type { - case model.GateCommand: - out, err := e.Runner.Run(g.Run, repoRoot) - o.Passed = err == nil - o.Detail = tail(out, 400) - if err != nil && o.Detail == "" { - o.Detail = err.Error() - } - case model.GateRequired: - val := ticketField(t, g.Field) - o.Passed = strings.TrimSpace(val) != "" - if !o.Passed { - o.Detail = fmt.Sprintf("required field %q is empty", g.Field) - } - case model.GatePolicy: - o.Passed, o.Detail = checkPolicy(ws.Registry.Policy, g.Ref) - case model.GateChecklist: - // Checklists are human-judged; surface the reference for the operator. - o.Human = true - o.Detail = "checklist: " + g.Ref - case model.GateHuman: - o.Human = true - o.Detail = "awaiting human approval" - default: - o.Detail = "unknown gate type " + g.Type - } - return o -} - -// EvaluateAll runs the gates a transition declares. -func (e Evaluator) EvaluateAll(ws *config.Workspace, names []string, t model.Ticket, repoRoot string) []Outcome { - outs := make([]Outcome, 0, len(names)) - for _, n := range names { - outs = append(outs, e.Evaluate(ws, n, t, repoRoot)) - } - return outs -} - -// Default is the production evaluator (shell command runner). -var Default = Evaluator{Runner: ShellRunner{}} - -// Evaluate / EvaluateAll are convenience wrappers over the Default evaluator, -// so callers that don't need a custom Runner stay terse. -func Evaluate(ws *config.Workspace, name string, t model.Ticket, repoRoot string) Outcome { - return Default.Evaluate(ws, name, t, repoRoot) -} - -func EvaluateAll(ws *config.Workspace, names []string, t model.Ticket, repoRoot string) []Outcome { - return Default.EvaluateAll(ws, names, t, repoRoot) -} - -func ticketField(t model.Ticket, field string) string { - switch field { - case "handoff": - // handoff lives in the ticket body under "Handoff notes"; treat the - // presence of that section's content as the field. - return extractSection(t.Body, "Handoff notes") - case "branch": - return t.Branch - case "assignee": - return t.Assignee - default: - return "" - } -} - -// extractSection returns the text following a "## " heading. -func extractSection(body, title string) string { - lines := strings.Split(body, "\n") - var buf []string - in := false - for _, ln := range lines { - if strings.HasPrefix(ln, "## ") { - if in { - break - } - if strings.Contains(strings.ToLower(ln), strings.ToLower(title)) { - in = true - } - continue - } - if in && strings.TrimSpace(ln) != "" && !strings.HasPrefix(strings.TrimSpace(ln), "<!--") { - buf = append(buf, ln) - } - } - return strings.TrimSpace(strings.Join(buf, "\n")) -} - -func checkPolicy(p model.Policy, ref string) (bool, string) { - switch ref { - case "no_self_review": - if p.NoSelfReview { - return true, "policy no_self_review enabled (enforced at move time)" - } - return true, "policy no_self_review disabled" - case "human_merge": - return p.HumanMerge, "human_merge policy" - default: - return true, "unknown policy ref " + ref - } -} - -func tail(s string, n int) string { - s = strings.TrimSpace(s) - if len(s) <= n { - return s - } - return "…" + s[len(s)-n:] -} diff --git a/hatch/internal/gate/gate_test.go b/hatch/internal/gate/gate_test.go deleted file mode 100644 index 73e973b..0000000 --- a/hatch/internal/gate/gate_test.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build hatch_legacy - -package gate - -import ( - "errors" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" -) - -// fakeRunner is a test adapter for the Runner port — no real shell. -type fakeRunner struct { - out string - err error -} - -func (f fakeRunner) Run(string, string) (string, error) { return f.out, f.err } - -func wsWithGate(g model.Gate) *config.Workspace { - return &config.Workspace{Workflow: &model.Workflow{Gates: map[string]model.Gate{"g": g}}} -} - -func TestCommandGatePassAndFailViaPort(t *testing.T) { - pass := Evaluator{Runner: fakeRunner{out: "ok"}}. - Evaluate(wsWithGate(model.Gate{Type: model.GateCommand, Run: "make test"}), "g", model.Ticket{}, ".") - if !pass.Passed { - t.Fatalf("expected pass, got %+v", pass) - } - fail := Evaluator{Runner: fakeRunner{out: "boom", err: errors.New("exit 1")}}. - Evaluate(wsWithGate(model.Gate{Type: model.GateCommand, Run: "make test"}), "g", model.Ticket{}, ".") - if fail.Passed { - t.Fatalf("expected fail, got %+v", fail) - } -} diff --git a/hatch/internal/mcpserver/server.go b/hatch/internal/mcpserver/server.go index b39cfe6..571cbab 100644 --- a/hatch/internal/mcpserver/server.go +++ b/hatch/internal/mcpserver/server.go @@ -9,12 +9,14 @@ import ( "context" "fmt" "strings" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/fioenix/overclaud/hatch/internal/bus" "github.com/fioenix/overclaud/hatch/internal/config" "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/roster" "github.com/fioenix/overclaud/hatch/internal/store" ) @@ -29,17 +31,72 @@ func New(ws *config.Workspace, me, version string) *mcp.Server { s.AddReceivingMiddleware(traceMiddleware(ws.Layout.MCPLog(), me)) b := bus.New(ws.Layout) kb := store.NewKB(ws.Layout) + rs := roster.New(ws.Layout) roles := rolesOf(ws, me) + kindOf := func(id string) string { + if a, ok := ws.Registry.AgentByID(id); ok { + return a.Kind + } + return "" + } + // touch refreshes presence on any activity, so the roster reflects who is + // actually around (best-effort; presence errors never fail a tool call). + touch := func() { _ = rs.Touch(me) } mcp.AddTool(s, &mcp.Tool{Name: "whoami", Description: "Bạn là agent nào trong squad + giữ vai gì. Gọi đầu session."}, func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, whoamiOut, error) { + touch() return nil, whoamiOut{Agent: me, Roles: roles}, nil }) + mcp.AddTool(s, &mcp.Tool{Name: "join", + Description: "Vào phòng chung của workspace: đăng ký bạn vào roster (online) để đồng đội biết bạn có mặt. Truyền session_id để teammate đánh thức đúng phiên có trí nhớ của bạn. Gọi đầu session, sau whoami."}, + func(_ context.Context, _ *mcp.CallToolRequest, in joinIn) (*mcp.CallToolResult, joinOut, error) { + kind := in.Kind + if kind == "" { + kind = kindOf(me) + } + rl := splitCSV(in.Roles) + if len(rl) == 0 { + rl = roles + } + m, err := rs.Join(model.Member{ID: me, Kind: kind, Roles: rl, SessionID: in.SessionID, Note: in.Note}) + if err != nil { + return nil, joinOut{}, err + } + return nil, joinOut{ID: m.ID, Status: m.Status}, nil + }) + + mcp.AddTool(s, &mcp.Tool{Name: "roster", + Description: "Ai đang ở trong phòng: liệt kê thành viên + vai trò + trạng thái (online/idle/suspended/offline) + last-seen. Xem trước khi nhờ việc để biết gọi ai."}, + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, rosterOut, error) { + touch() + r, err := rs.Effective(time.Now()) + if err != nil { + return nil, rosterOut{}, err + } + out := rosterOut{} + for _, m := range roster.Members(r) { + rolesStr := strings.Join(m.Roles, ",") + out.Members = append(out.Members, fmt.Sprintf("%s [%s] %s · %s · seen %s", m.ID, m.Kind, rolesStr, m.Status, m.LastSeen)) + } + return nil, out, nil + }) + + mcp.AddTool(s, &mcp.Tool{Name: "leave", + Description: "Rời phòng: đánh dấu bạn offline (sẽ không bị đánh thức nữa cho tới khi join lại)."}, + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, joinOut, error) { + if err := rs.Leave(me); err != nil { + return nil, joinOut{}, err + } + return nil, joinOut{ID: me, Status: model.MemberOffline}, nil + }) + mcp.AddTool(s, &mcp.Tool{Name: "chat_open", Description: "Mở một thread/topic cho MỘT task: post message gốc vào channel (tạo channel nếu chưa có). Dùng @tag trong body để gọi đồng đội. Trả về channel + id message gốc (= id task)."}, func(_ context.Context, _ *mcp.CallToolRequest, in openIn) (*mcp.CallToolResult, postOut, error) { + touch() ch := in.Channel if ch == "" { ch = "#" + slug(in.Title) @@ -58,6 +115,7 @@ func New(ws *config.Workspace, me, version string) *mcp.Server { mcp.AddTool(s, &mcp.Tool{Name: "chat_post", Description: "Brief tiến độ/kết quả hoặc trả lời trong một channel. reply_to = id message gốc để nối vào thread đó. @tag trong body để gọi đồng đội."}, func(_ context.Context, _ *mcp.CallToolRequest, in postIn) (*mcp.CallToolResult, postOut, error) { + touch() if in.Channel == "" { return nil, postOut{}, fmt.Errorf("channel is required") } @@ -82,6 +140,7 @@ func New(ws *config.Workspace, me, version string) *mcp.Server { mcp.AddTool(s, &mcp.Tool{Name: "chat_inbox", Description: "Tin nhắn gửi tới bạn (DM/@mention/broadcast) kể từ lần đọc trước. Gọi để 'đọc phòng' trước khi vào việc. mark=true để đánh dấu đã đọc."}, func(_ context.Context, _ *mcp.CallToolRequest, in inboxIn) (*mcp.CallToolResult, messagesOut, error) { + touch() msgs, err := b.Inbox(me, roles) if err != nil { return nil, messagesOut{}, err diff --git a/hatch/internal/mcpserver/types.go b/hatch/internal/mcpserver/types.go index 6de6dd4..b770800 100644 --- a/hatch/internal/mcpserver/types.go +++ b/hatch/internal/mcpserver/types.go @@ -55,6 +55,22 @@ type whoamiOut struct { Roles []string `json:"roles"` } +type joinIn struct { + Kind string `json:"kind,omitempty" jsonschema:"agent kind: claude|codex|agy|kiro|mock|user (default: from registry)"` + Roles string `json:"roles,omitempty" jsonschema:"comma-separated role ids held in the room (default: from registry)"` + SessionID string `json:"session_id,omitempty" jsonschema:"resumable session id holding your memory, so teammates can wake the same you"` + Note string `json:"note,omitempty" jsonschema:"optional status note"` +} + +type joinOut struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type rosterOut struct { + Members []string `json:"members"` +} + type textOut struct { Text string `json:"text"` } diff --git a/hatch/internal/metrics/metrics.go b/hatch/internal/metrics/metrics.go deleted file mode 100644 index 8e66d71..0000000 --- a/hatch/internal/metrics/metrics.go +++ /dev/null @@ -1,98 +0,0 @@ -//go:build hatch_legacy - -// Package metrics derives operational stats (throughput, cycle time, rework, -// cost…) from the append-only ledger. Everything is computed, never tracked -// separately. See docs/13-management.md (incl. the Goodhart caveat). -package metrics - -import ( - "sort" - "time" - - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/port" -) - -// AgentStat is one agent's operational scorecard. -type AgentStat struct { - Agent string - Claims int - Done int - Reviews int - GateFails int - Escalations int - CostUSD float64 - Tokens int -} - -// Report bundles per-agent stats and team-level aggregates. -type Report struct { - Agents map[string]*AgentStat - Throughput int // total done - CycleAvg time.Duration // avg claim→done across tickets -} - -// Compute scans the ledger into a Report. -func Compute(lg port.Ledger) (*Report, error) { - entries, err := lg.Entries() - if err != nil { - return nil, err - } - r := &Report{Agents: map[string]*AgentStat{}} - stat := func(a string) *AgentStat { - if r.Agents[a] == nil { - r.Agents[a] = &AgentStat{Agent: a} - } - return r.Agents[a] - } - - // First claim + done time per ticket → cycle time. - firstClaim := map[string]time.Time{} - var cycles []time.Duration - - for _, e := range entries { - s := stat(e.Agent) - s.CostUSD += e.CostUSD - s.Tokens += e.Tokens - ts, _ := time.Parse(time.RFC3339, e.TS) - switch e.Action { - case model.ActClaim: - s.Claims++ - if _, ok := firstClaim[e.Ticket]; !ok && !ts.IsZero() { - firstClaim[e.Ticket] = ts - } - case model.ActDone: - s.Done++ - r.Throughput++ - if c, ok := firstClaim[e.Ticket]; ok && !ts.IsZero() { - cycles = append(cycles, ts.Sub(c)) - } - case model.ActReview: - s.Reviews++ - case model.ActGate: - if len(e.Result) >= 6 && e.Result[:6] == "failed" { - s.GateFails++ - } - case model.ActEscalate: - s.Escalations++ - } - } - if len(cycles) > 0 { - var total time.Duration - for _, c := range cycles { - total += c - } - r.CycleAvg = total / time.Duration(len(cycles)) - } - return r, nil -} - -// Sorted returns agent stats ordered by id for stable output. -func (r *Report) Sorted() []*AgentStat { - out := make([]*AgentStat, 0, len(r.Agents)) - for _, s := range r.Agents { - out = append(out, s) - } - sort.Slice(out, func(i, j int) bool { return out[i].Agent < out[j].Agent }) - return out -} diff --git a/hatch/internal/metrics/metrics_test.go b/hatch/internal/metrics/metrics_test.go deleted file mode 100644 index 00973a1..0000000 --- a/hatch/internal/metrics/metrics_test.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build hatch_legacy - -package metrics - -import ( - "testing" - - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/paths" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func TestComputeFromLedger(t *testing.T) { - l := paths.At(t.TempDir()) - lg := store.NewLedger(l) - lg.Append(model.Entry{TS: "2026-06-14T09:00:00Z", Agent: "codex", Ticket: "T-1", Action: model.ActClaim, Why: "x"}) - lg.Append(model.Entry{Agent: "codex", Ticket: "T-1", Action: model.ActGate, Result: "failed: test", Why: "x"}) - lg.Append(model.Entry{TS: "2026-06-14T10:00:00Z", Agent: "claude-code", Ticket: "T-1", Action: model.ActDone, Why: "x", CostUSD: 0.5}) - - rep, err := Compute(lg) - if err != nil { - t.Fatal(err) - } - if rep.Throughput != 1 { - t.Fatalf("throughput = %d, want 1", rep.Throughput) - } - if rep.Agents["codex"].Claims != 1 || rep.Agents["codex"].GateFails != 1 { - t.Fatalf("codex stat wrong: %+v", rep.Agents["codex"]) - } - if rep.Agents["claude-code"].Done != 1 || rep.Agents["claude-code"].CostUSD != 0.5 { - t.Fatalf("claude stat wrong: %+v", rep.Agents["claude-code"]) - } - if rep.CycleAvg.Hours() != 1 { - t.Fatalf("cycle avg = %s, want 1h", rep.CycleAvg) - } -} diff --git a/hatch/internal/model/member.go b/hatch/internal/model/member.go new file mode 100644 index 0000000..970098a --- /dev/null +++ b/hatch/internal/model/member.go @@ -0,0 +1,67 @@ +package model + +// Member is one participant present in a workspace's shared room (the roster). +// It is the "team simulation" presence entity: who has joined, what they can +// do, and whether they are reachable right now. It is distinct from a registry +// Agent (static config) — a Member is the live, in-room projection of one. +type Member struct { + ID string `json:"id"` // "codex" + Kind string `json:"kind"` // claude | codex | agy | kiro | mock | user + Roles []string `json:"roles,omitempty"` // role ids held in the room + SessionID string `json:"session_id,omitempty"` // resumable session id (the member's memory); "" if none yet + Status string `json:"status"` // see member status constants + LastSeen string `json:"last_seen"` // RFC3339; refreshed on activity + Note string `json:"note,omitempty"` +} + +// Member status values. A teammate sleeps (suspended) when idle and is woken on +// a message addressed to it; online/idle distinguish a live seat from a quiet +// one. offline = explicitly left. +const ( + MemberOnline = "online" + MemberIdle = "idle" + MemberSuspended = "suspended" + MemberOffline = "offline" +) + +// KindUser marks the human boss as a first-class room member. The boss is never +// a software-driven worker: it is never woken and never counts toward cascade +// depth — a message from the boss starts a fresh episode. +const KindUser = "user" + +// Roster is the set of members in a workspace room, keyed by member id. +type Roster map[string]Member + +// IsHuman reports whether an id belongs to a human member (the boss). Unknown +// ids are treated as non-human so external/automation actors still gate. +func (r Roster) IsHuman(id string) bool { + m, ok := r[id] + return ok && m.Kind == KindUser +} + +// Reachable reports whether a member can be woken (present and not offline). +// A missing member is not reachable: it has not joined the room. +func (r Roster) Reachable(id string) bool { + m, ok := r[id] + if !ok { + return false + } + return m.Kind != KindUser && m.Status != MemberOffline +} + +// WithRole returns the ids of reachable members holding the given role. +func (r Roster) WithRole(role string) []string { + var out []string + for id, m := range r { + if !r.Reachable(id) { + continue + } + for _, rl := range m.Roles { + if rl == role { + out = append(out, id) + break + } + } + } + return out +} diff --git a/hatch/internal/model/session.go b/hatch/internal/model/session.go new file mode 100644 index 0000000..981fc5a --- /dev/null +++ b/hatch/internal/model/session.go @@ -0,0 +1,25 @@ +package model + +// Session is one agent's resumable CLI conversation about one bus thread — the +// "warm cache" of that teammate's work on that task. The shared source of truth +// is the bus + KB; a Session just lets an agent resume its own context instead +// of re-reading the record every wake. Agents whose CLI cannot expose a session +// id (e.g. agy) run stateless and have no Session. +type Session struct { + Agent string `json:"agent"` // member id, e.g. "codex" + Thread string `json:"thread"` // bus channel id this session serves + Kind string `json:"kind"` // claude | codex (kinds that keep warm sessions) + ID string `json:"id"` // CLI session id (UUID); "" until known + Status string `json:"status"` // see session status constants + StartedAt string `json:"started_at"` // RFC3339 + LastResumedAt string `json:"last_resumed_at"` // RFC3339 + History []string `json:"history,omitempty"` // superseded ids, newest last (audit) +} + +// Session status values. A live session is resumable; a stale one failed to +// resume and will be replaced by a fresh session on the next wake. +const ( + SessionLive = "live" + SessionStale = "stale" + SessionEnded = "ended" +) diff --git a/hatch/internal/model/ticket.go b/hatch/internal/model/ticket.go deleted file mode 100644 index 772bd78..0000000 --- a/hatch/internal/model/ticket.go +++ /dev/null @@ -1,63 +0,0 @@ -package model - -// Priority levels for a ticket. -const ( - P0 = "P0" - P1 = "P1" - P2 = "P2" - P3 = "P3" -) - -// Claim is the lightweight lock placed on a ticket when an agent picks it up. -type Claim struct { - Agent string `yaml:"agent"` - TS string `yaml:"ts"` -} - -// ExternalBlocker is a dependency on something outside the squad (a vendor, -// another team, a human approval) — surfaced as risk, not auto-resolved. -type ExternalBlocker struct { - What string `yaml:"what"` - Owner string `yaml:"owner,omitempty"` - ETA string `yaml:"eta,omitempty"` - Status string `yaml:"status,omitempty"` // waiting | received -} - -// Ticket mirrors spec/ticket.schema.md. The directory it lives in (the lane) -// is the source of truth for its lifecycle state; Status must agree with it. -type Ticket struct { - ID string `yaml:"id"` - Title string `yaml:"title"` - Status string `yaml:"status"` - Role string `yaml:"role"` - Assignee string `yaml:"assignee,omitempty"` - Priority string `yaml:"priority,omitempty"` - Epic string `yaml:"epic,omitempty"` - DependsOn []string `yaml:"depends_on,omitempty"` - Branch string `yaml:"branch,omitempty"` - ContextRefs []string `yaml:"context_refs,omitempty"` - Claim *Claim `yaml:"claim,omitempty"` - DoD []string `yaml:"dod,omitempty"` - - BlockedByExternal []ExternalBlocker `yaml:"blocked_by_external,omitempty"` - Created string `yaml:"created,omitempty"` - Updated string `yaml:"updated,omitempty"` - - // Body and Lane are populated by the store, not the frontmatter. - Body string `yaml:"-"` - Lane string `yaml:"-"` -} - -// Filename is the canonical file name for a ticket on the board. -func (t Ticket) Filename() string { return t.ID + ".md" } - -// OpenExternal returns external blockers not yet received. -func (t Ticket) OpenExternal() []ExternalBlocker { - var out []ExternalBlocker - for _, e := range t.BlockedByExternal { - if e.Status != "received" { - out = append(out, e) - } - } - return out -} diff --git a/hatch/internal/model/wake.go b/hatch/internal/model/wake.go new file mode 100644 index 0000000..0d4e9bd --- /dev/null +++ b/hatch/internal/model/wake.go @@ -0,0 +1,55 @@ +package model + +// WakeReason explains why the delivery layer woke a member. Every wake is the +// consequence of a message someone actively sent — never a scheduler inventing +// work. This is the line between "chat server" (delivery) and "boss" +// (work-orchestration): Hatch does the former, never the latter. +type WakeReason string + +const ( + WakeMention WakeReason = "mention" // a message addressed/@-mentioned the member + WakeReplyAsk WakeReason = "reply_to_open_ask" // a reply landed on the member's open question + WakeDM WakeReason = "dm" // a direct message to the member + WakeNudge WakeReason = "nudge" // a one-shot reminder to a stalled owner +) + +// WakeDecision is the delivery layer's instruction to wake one member with the +// message(s) that triggered it. Multiple triggering messages are coalesced into +// Payload. A non-empty Hold means the wake is deferred, not cancelled. +type WakeDecision struct { + Agent string `json:"agent"` + Thread string `json:"thread"` // bus channel this wake is about (one wake per agent+thread) + Reason WakeReason `json:"reason"` + Payload []Message `json:"payload"` + Hold HoldReason `json:"hold,omitempty"` // "" = deliver now +} + +// HoldReason defers a wake without dropping it. The daemon redelivers held +// wakes when the condition clears (the member finishes its turn, the rate +// window opens). Holding — never dropping — keeps the liveness contract: a +// message addressed to a teammate is always eventually delivered. +type HoldReason string + +const ( + HoldNone HoldReason = "" + HoldWorking HoldReason = "working" // member is mid-turn; deliver at its next turn boundary + HoldRate HoldReason = "rate_limit" // per-member wake cap hit; deliver when the window opens +) + +// Escalation routes a coordination problem to the boss (the human) when the +// squad cannot safely keep going on its own. It is the circuit-breaker behind +// the Working Agreement: depth/loop limits and stalled ownership surface here +// instead of burning tokens in silence. +type Escalation struct { + Episode string `json:"episode"` // thread-root id the problem belongs to + Cause string `json:"cause"` // see escalation cause constants + To string `json:"to"` // usually the boss/user id + Note string `json:"note"` +} + +// Escalation causes. +const ( + EscalateDepthLimit = "depth_limit" // auto-wake cascade exceeded the configured depth + EscalateLoopBreak = "loop_break" // a pair ping-ponged without progress + EscalateStalledOwner = "stalled_owner" // a thread owner went silent past the window +) diff --git a/hatch/internal/model/workflow.go b/hatch/internal/model/workflow.go index 18b9055..576f9f4 100644 --- a/hatch/internal/model/workflow.go +++ b/hatch/internal/model/workflow.go @@ -1,6 +1,11 @@ package model -// Gate kinds understood by the workflow engine. +// Workflow is a *prose* description of the squad's process: it compiles into the +// instruction surfaces so agents self-follow it via chat. It is not an engine — +// there are no tickets or lane directories; a task is a bus thread and its state +// is inferred from the conversation. + +// Gate kinds, rendered as prose guidance (not enforced by an engine). const ( GateCommand = "command" // run a shell command, exit 0 = pass GateChecklist = "checklist" // a DoD checklist referenced by file @@ -17,7 +22,8 @@ type Gate struct { Field string `yaml:"field,omitempty"` // required-field gates } -// Lane is a board column; it maps to a directory under board/. +// Lane is a stage a task moves through (backlog → in-progress → review → done), +// described as prose — not a board directory. type Lane struct { ID string `yaml:"id"` WIPLimit int `yaml:"wip-limit,omitempty"` diff --git a/hatch/internal/mux/mux.go b/hatch/internal/mux/mux.go deleted file mode 100644 index 60d8822..0000000 --- a/hatch/internal/mux/mux.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build hatch_legacy - -// Package mux opens agent runs in terminal-multiplexer panes (tmux / Zellij) -// so you can watch each spawned agent live, side by side (observability tier B, -// docs/18). The single-process TUI (`hatch board`) is the no-deps default; this -// is for "many real panes". -package mux - -import ( - "fmt" - "os/exec" - "strings" -) - -// Kinds. -const ( - Tmux = "tmux" - Zellij = "zellij" -) - -// Available reports whether a multiplexer binary is on PATH. -func Available(kind string) bool { - _, err := exec.LookPath(kind) - return err == nil -} - -// Command builds (without running) the multiplexer invocation that opens a pane -// titled `title` running `inner` (argv). Exposed for testing/inspection. -func Command(kind, title string, inner []string) ([]string, error) { - cmdline := shJoin(inner) - switch kind { - case Tmux: - // Split the current window; falls back to a new window name. - return []string{"tmux", "new-window", "-n", title, cmdline}, nil - case Zellij: - return []string{"zellij", "run", "--name", title, "--", inner[0]}, nil - default: - return nil, fmt.Errorf("unknown mux %q (tmux|zellij)", kind) - } -} - -// Launch opens a pane running inner in the given multiplexer. -func Launch(kind, title string, inner []string) error { - if !Available(kind) { - return fmt.Errorf("%s not found on PATH", kind) - } - var c *exec.Cmd - switch kind { - case Tmux: - c = exec.Command("tmux", "new-window", "-n", title, shJoin(inner)) - case Zellij: - args := append([]string{"run", "--name", title, "--"}, inner...) - c = exec.Command("zellij", args...) - default: - return fmt.Errorf("unknown mux %q (tmux|zellij)", kind) - } - return c.Run() -} - -// shJoin quotes argv into a single shell command string (for tmux's command arg). -func shJoin(argv []string) string { - parts := make([]string, len(argv)) - for i, a := range argv { - if strings.ContainsAny(a, " \t\"'$`\\") { - parts[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'" - } else { - parts[i] = a - } - } - return strings.Join(parts, " ") -} diff --git a/hatch/internal/mux/mux_test.go b/hatch/internal/mux/mux_test.go deleted file mode 100644 index b8f9660..0000000 --- a/hatch/internal/mux/mux_test.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build hatch_legacy - -package mux - -import ( - "strings" - "testing" -) - -func TestCommandTmuxAndZellij(t *testing.T) { - tm, err := Command(Tmux, "T-1", []string{"hatch", "run", "T-1", "--agent", "codex"}) - if err != nil || tm[0] != "tmux" || tm[1] != "new-window" { - t.Fatalf("tmux command wrong: %v (%v)", tm, err) - } - if !strings.Contains(strings.Join(tm, " "), "hatch run T-1") { - t.Fatalf("tmux inner missing: %v", tm) - } - zj, err := Command(Zellij, "T-1", []string{"hatch", "run", "T-1"}) - if err != nil || zj[0] != "zellij" || zj[1] != "run" { - t.Fatalf("zellij command wrong: %v (%v)", zj, err) - } - if _, err := Command("nope", "x", []string{"y"}); err == nil { - t.Fatal("expected error for unknown mux") - } -} - -func TestShJoinQuotes(t *testing.T) { - got := shJoin([]string{"hatch", "run", "a b", "x'y"}) - if !strings.Contains(got, "'a b'") { - t.Fatalf("space arg not quoted: %s", got) - } -} diff --git a/hatch/internal/oncall/oncall.go b/hatch/internal/oncall/oncall.go deleted file mode 100644 index bee002d..0000000 --- a/hatch/internal/oncall/oncall.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build hatch_legacy - -// Package oncall tracks the on-call rotation — who is the first responder for -// incidents/escalations right now, and how the duty rotates. -package oncall - -import ( - "encoding/json" - "os" - "path/filepath" - - "github.com/fioenix/overclaud/hatch/internal/paths" - "github.com/fioenix/overclaud/hatch/internal/port" -) - -// Service is the port.OnCall adapter: it reports the current on-call agent. -type Service struct{ L paths.Layout } - -var _ port.OnCall = Service{} - -// Current returns the agent currently on call ("" if no rotation is set). -func (s Service) Current() string { return Load(s.L).Now() } - -// Rotation is the on-call schedule: an ordered list of agents and the index of -// whoever currently holds the pager. -type Rotation struct { - Order []string `json:"order"` - Current int `json:"current"` -} - -// Load reads oncall.json, returning an empty rotation if absent. -func Load(l paths.Layout) Rotation { - raw, err := os.ReadFile(l.Oncall()) - if err != nil { - return Rotation{} - } - var r Rotation - if json.Unmarshal(raw, &r) != nil { - return Rotation{} - } - return r -} - -// Save persists the rotation. -func (r Rotation) Save(l paths.Layout) error { - if err := os.MkdirAll(filepath.Dir(l.Oncall()), 0o755); err != nil { - return err - } - raw, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err - } - return os.WriteFile(l.Oncall(), append(raw, '\n'), 0o644) -} - -// Current returns the agent currently on call, or "" if no rotation is set. -func (r Rotation) Now() string { - if len(r.Order) == 0 { - return "" - } - return r.Order[r.Current%len(r.Order)] -} - -// Rotate advances the pager to the next agent and returns them. -func (r *Rotation) Rotate() string { - if len(r.Order) == 0 { - return "" - } - r.Current = (r.Current + 1) % len(r.Order) - return r.Now() -} diff --git a/hatch/internal/oncall/oncall_test.go b/hatch/internal/oncall/oncall_test.go deleted file mode 100644 index 26aed3a..0000000 --- a/hatch/internal/oncall/oncall_test.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build hatch_legacy - -package oncall - -import ( - "testing" - - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -func TestRotation(t *testing.T) { - l := paths.At(t.TempDir()) - if Load(l).Now() != "" { - t.Fatal("empty rotation should have no on-call") - } - r := Rotation{Order: []string{"a", "b", "c"}} - if r.Now() != "a" { - t.Fatalf("first on-call should be a, got %s", r.Now()) - } - if r.Rotate() != "b" || r.Rotate() != "c" || r.Rotate() != "a" { - t.Fatal("rotation wrap wrong") - } - if err := r.Save(l); err != nil { - t.Fatal(err) - } - if Load(l).Now() != "a" { - t.Fatalf("persisted current wrong: %s", Load(l).Now()) - } -} diff --git a/hatch/internal/orchestrator/adapter.go b/hatch/internal/orchestrator/adapter.go deleted file mode 100644 index 9e14dd7..0000000 --- a/hatch/internal/orchestrator/adapter.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build hatch_legacy - -// Package orchestrator (Phase 3) spawns coding-agent CLIs headlessly to work -// tickets, capturing their output to the ledger. Adapters translate a generic -// run request into the native invocation each agent expects; see -// docs/10-agent-adapters.md for the source of these mappings. -package orchestrator - -import ( - "os/exec" - - "github.com/fioenix/overclaud/hatch/internal/model" -) - -// RunRequest is a normalized request to run one agent on one ticket. -type RunRequest struct { - Agent model.Agent - Ticket model.Ticket - Prompt string // the task prompt handed to the agent - WorkDir string // working directory (a git worktree) - RepoRoot string // repository root (for context) -} - -// Invocation is the concrete command an adapter would run, kept separate from -// execution so it can be shown in --dry-run and recorded for audit. -type Invocation struct { - Args []string // argv (Args[0] is the program) - Env []string // extra environment, KEY=VALUE - StdinStr string // data piped to stdin, if any - Headless bool // false ⇒ agent has no headless mode (manual handoff) - Note string // explanation when not headless -} - -// Adapter builds invocations for a given agent kind. -type Adapter interface { - Kind() string - // Build returns the invocation for a request. - Build(req RunRequest) Invocation -} - -// program returns the executable for an agent, honoring an explicit cmd. -func program(a model.Agent, def string) string { - if a.Cmd != "" { - return a.Cmd - } - return def -} - -// adapters maps a kind to its adapter. -var adapters = map[string]Adapter{ - "claude": claudeAdapter{}, - "codex": codexAdapter{}, - "agy": agyAdapter{}, - "kiro": kiroAdapter{}, - "antigravity": manualAdapter{kind: "antigravity", reason: "Antigravity is IDE-driven; no confirmed headless CLI"}, - "mock": mockAdapter{}, - "manual": manualAdapter{kind: "manual", reason: "manual agent"}, - "shell": manualAdapter{kind: "shell", reason: "shell agent has no standard headless contract"}, -} - -// AdapterFor returns the adapter for an agent kind, defaulting to manual. -func AdapterFor(kind string) Adapter { - if a, ok := adapters[kind]; ok { - return a - } - return manualAdapter{kind: kind, reason: "unknown kind"} -} - -// Available reports whether an adapter's program is on PATH (manual ⇒ true). -func Available(a model.Agent) bool { - inv := AdapterFor(a.Kind).Build(RunRequest{Agent: a}) - if !inv.Headless || len(inv.Args) == 0 { - return true - } - _, err := exec.LookPath(inv.Args[0]) - return err == nil -} diff --git a/hatch/internal/orchestrator/adapter_test.go b/hatch/internal/orchestrator/adapter_test.go deleted file mode 100644 index e8ed88f..0000000 --- a/hatch/internal/orchestrator/adapter_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/model" -) - -func build(kind string, mutate func(*model.Agent)) Invocation { - a := model.Agent{ID: kind, Kind: kind} - if mutate != nil { - mutate(&a) - } - return AdapterFor(kind).Build(RunRequest{Agent: a, Prompt: "do X"}) -} - -func TestClaudeInvocation(t *testing.T) { - inv := build("claude", func(a *model.Agent) { a.Model = "opus"; a.Approval = "plan" }) - got := strings.Join(inv.Args, " ") - for _, want := range []string{"claude", "-p", "do X", "--output-format json", "--model opus", "--permission-mode plan"} { - if !strings.Contains(got, want) { - t.Errorf("claude invocation missing %q in %q", want, got) - } - } - if !inv.Headless { - t.Error("claude should be headless") - } -} - -func TestCodexDefaultsSandbox(t *testing.T) { - inv := build("codex", nil) - got := strings.Join(inv.Args, " ") - if !strings.Contains(got, "codex exec") || !strings.Contains(got, "-s workspace-write") { - t.Errorf("codex invocation wrong: %q", got) - } -} - -func TestAgyAndKiro(t *testing.T) { - a := strings.Join(build("agy", nil).Args, " ") - if !strings.Contains(a, "agy -p") || strings.Contains(a, "--output-format") { - t.Errorf("agy invocation wrong: %q", a) - } - y := strings.Join(build("agy", func(ag *model.Agent) { ag.Approval = "yolo" }).Args, " ") - if !strings.Contains(y, "--dangerously-skip-permissions") { - t.Errorf("agy yolo→--dangerously-skip-permissions missing: %q", y) - } - k := build("kiro", nil) - if !strings.Contains(strings.Join(k.Args, " "), "kiro-cli chat --no-interactive") { - t.Errorf("kiro invocation wrong: %v", k.Args) - } - if !strings.Contains(k.Note, "KIRO_API_KEY") { - t.Errorf("kiro should note KIRO_API_KEY, got %q", k.Note) - } -} - -func TestManualAndAntigravityNotHeadless(t *testing.T) { - for _, kind := range []string{"manual", "antigravity", "shell"} { - inv := AdapterFor(kind).Build(RunRequest{Agent: model.Agent{Kind: kind}, Prompt: "p"}) - if inv.Headless { - t.Errorf("%s should not be headless", kind) - } - } -} - -func TestCmdOverride(t *testing.T) { - inv := build("claude", func(a *model.Agent) { a.Cmd = "claude-canary" }) - if inv.Args[0] != "claude-canary" { - t.Errorf("expected cmd override, got %q", inv.Args[0]) - } -} diff --git a/hatch/internal/orchestrator/adapters.go b/hatch/internal/orchestrator/adapters.go deleted file mode 100644 index b84faf9..0000000 --- a/hatch/internal/orchestrator/adapters.go +++ /dev/null @@ -1,104 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -// Per-kind adapters. Flags mirror docs/10-agent-adapters.md. Capability is -// controlled per agent via registry `sandbox`/`approval` hints. - -// claudeAdapter drives Claude Code headlessly: `claude -p`. -type claudeAdapter struct{} - -func (claudeAdapter) Kind() string { return "claude" } -func (claudeAdapter) Build(req RunRequest) Invocation { - args := []string{program(req.Agent, "claude"), "-p", req.Prompt, "--output-format", "json"} - if req.Agent.Model != "" { - args = append(args, "--model", req.Agent.Model) - } - mode := req.Agent.Approval - if mode == "" { - mode = "acceptEdits" - } - args = append(args, "--permission-mode", mode) - return Invocation{Args: args, Headless: true} -} - -// codexAdapter drives Codex headlessly: `codex exec`. -type codexAdapter struct{} - -func (codexAdapter) Kind() string { return "codex" } -func (codexAdapter) Build(req RunRequest) Invocation { - sandbox := req.Agent.Sandbox - if sandbox == "" { - sandbox = "workspace-write" - } - args := []string{program(req.Agent, "codex"), "exec", req.Prompt, - "-s", sandbox, "--skip-git-repo-check", "--json"} - if req.Agent.Model != "" { - args = append(args, "-m", req.Agent.Model) - } - return Invocation{Args: args, Headless: true} -} - -// agyAdapter drives Google's Antigravity CLI (`agy`, successor to Gemini CLI) -// headlessly: `agy -p <prompt>`. Auth is OAuth/OS-keyring (interactive `agy` -// login; no API-key env). It reads GEMINI.md / AGENTS.md for project context. -// Flags per the official repo (README/CHANGELOG): -p/--print, -m/--model, -// --dangerously-skip-permissions (the former --yolo), --sandbox, --print-timeout. -// NOTE (agy issue #76): `-p` can silently drop stdout when not attached to a -// TTY, so captured output may be empty in CI/subprocess use until upstream -// adds a real --format json. The transcript still records whatever is emitted. -type agyAdapter struct{} - -func (agyAdapter) Kind() string { return "agy" } -func (agyAdapter) Build(req RunRequest) Invocation { - args := []string{program(req.Agent, "agy"), "-p", req.Prompt} - if req.Agent.Model != "" { - args = append(args, "-m", req.Agent.Model) - } - switch req.Agent.Approval { - case "yolo", "dangerous", "skip": - args = append(args, "--dangerously-skip-permissions") - } - if req.Agent.Sandbox != "" { - args = append(args, "--sandbox") // boolean flag in agy - } - return Invocation{ - Args: args, - Headless: true, - Note: "agy -p may drop stdout on non-TTY (upstream #76); rely on transcript", - } -} - -// kiroAdapter drives Kiro CLI headlessly: `kiro-cli chat --no-interactive`. -// Requires KIRO_API_KEY in the environment (passed through, not set here). -type kiroAdapter struct{} - -func (kiroAdapter) Kind() string { return "kiro" } -func (kiroAdapter) Build(req RunRequest) Invocation { - args := []string{program(req.Agent, "kiro-cli"), "chat", "--no-interactive", req.Prompt} - return Invocation{Args: args, Headless: true, Note: "requires KIRO_API_KEY in environment"} -} - -// mockAdapter drives the hatch-mock test agent: `hatch-mock --prompt …`. -// Used to exercise the real spawn/capture path without a live agent CLI. -type mockAdapter struct{} - -func (mockAdapter) Kind() string { return "mock" } -func (mockAdapter) Build(req RunRequest) Invocation { - return Invocation{ - Args: []string{program(req.Agent, "hatch-mock"), "--prompt", req.Prompt}, - Headless: true, - } -} - -// manualAdapter represents agents with no headless contract: it produces a -// handoff prompt instead of spawning anything. -type manualAdapter struct { - kind string - reason string -} - -func (m manualAdapter) Kind() string { return m.kind } -func (m manualAdapter) Build(req RunRequest) Invocation { - return Invocation{Headless: false, Note: m.reason, StdinStr: req.Prompt} -} diff --git a/hatch/internal/orchestrator/comm_test.go b/hatch/internal/orchestrator/comm_test.go deleted file mode 100644 index e02bc24..0000000 --- a/hatch/internal/orchestrator/comm_test.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/scaffold" -) - -func TestCommContextGathersInboxAndRecall(t *testing.T) { - dir := t.TempDir() - l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, err := config.Load(l) - if err != nil { - t.Fatal(err) - } - b := bus.New(l) - // codex subscribes to #design and there's relevant chatter + a direct mention. - b.Subscribe("#design", "codex") - b.Post(bus.Message{Channel: "#design", From: "claude-code", To: []string{"#design"}, Body: "Export nên dùng CSV streaming"}) - b.Post(bus.Message{Channel: "T-001", From: "claude-code", To: []string{"codex"}, Type: bus.TypeAsk, Body: "@codex bắt đầu Export CSV nhé"}) - - codex, _ := ws.Registry.AgentByID("codex") - got := Orchestrator{Bus: b}.commContext(codex, "Export CSV") - if got == "" { - t.Fatal("expected non-empty comm context") - } - if !strings.Contains(got, "Inbox") || !strings.Contains(got, "bắt đầu Export CSV") { - t.Errorf("inbox mention missing:\n%s", got) - } - if !strings.Contains(got, "streaming") { - t.Errorf("relevant recall missing:\n%s", got) - } -} - -func TestCommContextEmptyWhenNothing(t *testing.T) { - dir := t.TempDir() - l, _, _ := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) - ws, _ := config.Load(l) - codex, _ := ws.Registry.AgentByID("codex") - if got := (Orchestrator{Bus: bus.New(l)}).commContext(codex, "anything"); got != "" { - t.Fatalf("expected empty comm context, got: %s", got) - } -} - -func TestPairPromptsAreRoleSpecific(t *testing.T) { - tk := mkTicket("T-007", "in-progress", "Export CSV") - d := BuildPairDriverPrompt(tk, "pair-T-007", "", "claude-code") - if !strings.Contains(d, "DRIVER") || !strings.Contains(d, "claude-code") { - t.Errorf("driver prompt wrong:\n%s", d) - } - n := BuildPairNavigatorPrompt(tk, "pair-T-007", "prev turn", "codex") - if !strings.Contains(n, "NAVIGATOR") || !strings.Contains(n, "READY") { - t.Errorf("navigator prompt wrong:\n%s", n) - } -} - -func mkTicket(id, lane, title string) model.Ticket { - return model.Ticket{ID: id, Lane: lane, Status: lane, Title: title} -} diff --git a/hatch/internal/orchestrator/e2e_test.go b/hatch/internal/orchestrator/e2e_test.go deleted file mode 100644 index 0c054cd..0000000 --- a/hatch/internal/orchestrator/e2e_test.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -// TestExecuteRunsMockAgentEndToEnd really spawns a process (a tiny shell script -// standing in for hatch-mock), exercising the full execute + capture + ledger -// path without a live agent CLI. -func TestExecuteRunsMockAgentEndToEnd(t *testing.T) { - dir := t.TempDir() - l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - - // A fake agent binary: echoes its args (incl the prompt) and exits 0. - script := filepath.Join(dir, "mock.sh") - if err := os.WriteFile(script, []byte("#!/bin/sh\necho \"MOCKREPLY $*\"\n"), 0o755); err != nil { - t.Fatal(err) - } - agent := model.Agent{ID: "codex", Kind: "mock", Cmd: script} - - o := Orchestrator{Ledger: store.NewLedger(l), Bus: bus.New(l)} - out, err := o.Execute(ws, agent, "T-1", "implement the export", RunOptions{Stdout: io_discard{}}) - if err != nil { - t.Fatal(err) - } - if !out.Executed || out.ExitCode != 0 { - t.Fatalf("expected executed exit 0, got executed=%v code=%d err=%v", out.Executed, out.ExitCode, out.Err) - } - if !strings.Contains(out.Output, "MOCKREPLY") || !strings.Contains(out.Output, "implement the export") { - t.Fatalf("prompt not passed/captured: %q", out.Output) - } - // Ledger should have recorded start + progress for the run. - files, _ := store.NewLedger(l).Files() - if len(files) == 0 { - t.Fatal("expected ledger entries from the run") - } - raw, _ := os.ReadFile(files[0]) - if !strings.Contains(string(raw), "T-1") { - t.Fatalf("ledger missing run entry: %s", raw) - } -} - -type io_discard struct{} - -func (io_discard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/hatch/internal/orchestrator/prompt.go b/hatch/internal/orchestrator/prompt.go deleted file mode 100644 index c4d0192..0000000 --- a/hatch/internal/orchestrator/prompt.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "fmt" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/model" -) - -// BuildPrompt produces the task prompt handed to an agent. The agent already -// loads L0+L1 from its compiled surface (CLAUDE.md/AGENTS.md/…); this prompt -// scopes it to one ticket and points at the L2 context to read on demand. -func BuildPrompt(t model.Ticket, role string) string { - var b strings.Builder - if role == "" { - role = t.Role - } - fmt.Fprintf(&b, "Bạn đang làm việc với vai **%s** trên ticket **%s**: %s\n\n", role, t.ID, t.Title) - b.WriteString("Quy trình bắt buộc:\n") - fmt.Fprintf(&b, "1. Đọc ticket đầy đủ tại `.hatch/board/%s/%s` và mọi `context_refs` của nó.\n", t.Lane, t.Filename()) - b.WriteString("2. Tra `.hatch/kb/index.md` cho quyết định/bài học liên quan trước khi bắt tay.\n") - b.WriteString("3. Thực thi đúng scope ticket, đạt Definition of Done (`.hatch/protocol/definition-of-done.md`).\n") - b.WriteString("4. KHÔNG sửa file ngoài `context_refs` đã khai. Mở rộng scope ⇒ cập nhật ticket trước.\n") - b.WriteString("5. Xong: cập nhật phần \"Handoff notes\" của ticket (đã làm gì / còn gì / cần gì).\n") - b.WriteString("6. Tri thức đáng giữ ⇒ thêm vào `.hatch/kb/` (decision/learning/domain).\n\n") - - if len(t.ContextRefs) > 0 { - b.WriteString("context_refs:\n") - for _, r := range t.ContextRefs { - fmt.Fprintf(&b, " - .hatch/%s\n", r) - } - b.WriteString("\n") - } - if body := strings.TrimSpace(t.Body); body != "" { - b.WriteString("--- Nội dung ticket ---\n") - b.WriteString(body) - b.WriteString("\n") - } - return b.String() -} - -// BuildConsultPrompt frames a synchronous question from one agent to another: -// the recipient sees the thread so far and answers in character. -func BuildConsultPrompt(fromAgent, role, thread, threadRaw, question string) string { - var b strings.Builder - fmt.Fprintf(&b, "Bạn đang trả lời trực tiếp một đồng đội với vai **%s**.\n", role) - fmt.Fprintf(&b, "Thread `%s`. %s hỏi bạn:\n\n%s\n\n", thread, fromAgent, question) - if strings.TrimSpace(threadRaw) != "" { - b.WriteString("--- Bối cảnh thread tới giờ ---\n") - b.WriteString(strings.TrimSpace(threadRaw)) - b.WriteString("\n--- hết bối cảnh ---\n\n") - } - b.WriteString("Trả lời NGẮN GỌN, đúng trọng tâm, đúng vai. Chỉ in nội dung trả lời (sẽ được ghi vào thread).") - return b.String() -} - -// BuildMeetingPrompt frames one turn in a multi-agent meeting (convene). -func BuildMeetingPrompt(role, thread, topic, threadRaw string, round, rounds int) string { - var b strings.Builder - fmt.Fprintf(&b, "Bạn dự một cuộc họp đội với vai **%s** (vòng %d/%d).\n", role, round, rounds) - fmt.Fprintf(&b, "Chủ đề: %s\n\n", topic) - if strings.TrimSpace(threadRaw) != "" { - b.WriteString("--- Diễn biến tới giờ ---\n") - b.WriteString(strings.TrimSpace(threadRaw)) - b.WriteString("\n--- hết ---\n\n") - } - b.WriteString("Đóng góp lượt của bạn: phản hồi ý đồng đội, nêu lo ngại/đề xuất từ góc nhìn vai của bạn. ") - b.WriteString("Nếu đã đồng thuận, mở đầu bằng `DECISION:` và chốt. In ngắn gọn (sẽ ghi vào thread).") - return b.String() -} - -// BuildPairDriverPrompt frames the driver's turn in a pairing session. -func BuildPairDriverPrompt(t model.Ticket, thread, threadRaw, navigator string) string { - var b strings.Builder - fmt.Fprintf(&b, "Bạn là **DRIVER** trên ticket **%s** (%s), pair cùng navigator **%s**.\n", t.ID, t.Title, navigator) - b.WriteString("Triển khai BƯỚC NHỎ tiếp theo theo ticket. Nếu navigator có feedback dưới đây, xử lý nó trước.\n") - b.WriteString("Đọc `.hatch/board/" + t.Lane + "/" + t.Filename() + "` + context_refs. Không ra ngoài scope.\n") - b.WriteString("In NGẮN: vừa làm gì + câu hỏi/điểm cần navigator soi (sẽ ghi vào thread pairing).\n\n") - if strings.TrimSpace(threadRaw) != "" { - b.WriteString("--- Pairing thread tới giờ ---\n" + strings.TrimSpace(threadRaw) + "\n--- hết ---\n") - } - return b.String() -} - -// BuildPairNavigatorPrompt frames the navigator's review turn. -func BuildPairNavigatorPrompt(t model.Ticket, thread, threadRaw, driver string) string { - var b strings.Builder - fmt.Fprintf(&b, "Bạn là **NAVIGATOR** trên ticket **%s** (%s), pair cùng driver **%s**.\n", t.ID, t.Title, driver) - b.WriteString("Soi việc driver vừa làm (dưới): lỗi, rủi ro, ca biên thiếu, sai scope, vi phạm DoD.\n") - b.WriteString("Gợi ý bước kế cụ thể. NGẮN GỌN. Nếu đã đủ tốt để chuyển review, mở đầu bằng `READY`.\n\n") - if strings.TrimSpace(threadRaw) != "" { - b.WriteString("--- Pairing thread tới giờ ---\n" + strings.TrimSpace(threadRaw) + "\n--- hết ---\n") - } - return b.String() -} - -// BuildTieBreakPrompt asks a decider to settle a meeting that ended without -// consensus: weigh the thread and make the final call. -func BuildTieBreakPrompt(role, thread, topic, threadRaw string) string { - var b strings.Builder - fmt.Fprintf(&b, "Cuộc họp về \"%s\" (thread `%s`) kết thúc mà CHƯA chốt. Bạn là người phân xử (vai **%s**).\n", topic, thread, role) - b.WriteString("Cân nhắc các lập luận dưới đây và RA QUYẾT ĐỊNH cuối cùng. Mở đầu bằng `DECISION:` rồi nêu lựa chọn + lý do ngắn gọn.\n\n") - if strings.TrimSpace(threadRaw) != "" { - b.WriteString("--- Diễn biến họp ---\n" + strings.TrimSpace(threadRaw) + "\n--- hết ---\n") - } - return b.String() -} - -// BuildPlanPrompt is the prompt for a Conductor planning pass. -func BuildPlanPrompt() string { - return strings.Join([]string{ - "Bạn là **Conductor**. Lập kế hoạch cho chu kỳ tới.", - "", - "1. Đọc `.hatch/charter.md` (mission) và `.hatch/board/` (trạng thái hiện tại).", - "2. Đọc `.hatch/kb/index.md` cho quyết định/ràng buộc liên quan.", - "3. Bẻ epic/yêu cầu thành ticket nhỏ, mỗi ticket có `role`, `priority`, `depends_on`, acceptance rõ ràng.", - "4. Tạo ticket bằng `hatch ticket new --title ... --role ... --priority ...` rồi điền nội dung.", - "5. KHÔNG tự viết code production. KHÔNG tự merge.", - "6. Ghi tóm tắt kế hoạch + lý do ưu tiên vào ledger (`why`).", - }, "\n") -} diff --git a/hatch/internal/orchestrator/run.go b/hatch/internal/orchestrator/run.go deleted file mode 100644 index 6d7f1ef..0000000 --- a/hatch/internal/orchestrator/run.go +++ /dev/null @@ -1,182 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - "time" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/port" -) - -// Orchestrator spawns agent CLIs to work tickets. It depends only on ports -// (Ledger for the audit trail, Bus for the agent's "read the room" catch-up); -// the composition root injects concrete adapters. -type Orchestrator struct { - Ledger port.Ledger - Bus port.Bus -} - -// RunOptions parameterise an orchestrated run. -type RunOptions struct { - DryRun bool // print the invocation, don't execute - Timeout time.Duration // 0 = no timeout - Stdout io.Writer // live output sink (defaults to os.Stdout) - SkipComms bool // don't prepend inbox + conversation recall -} - -// RunOutcome reports the result of a run. -type RunOutcome struct { - Invocation Invocation - Executed bool - ExitCode int - Output string - Err error -} - -// Run builds the ticket prompt and executes the agent for that ticket. Like a -// teammate starting work, the agent first "reads the room": its unread inbox -// plus a recall of conversation relevant to the ticket are prepended (unless -// SkipComms), and the inbox is marked read after a successful run. -func (o Orchestrator) Run(ws *config.Workspace, agent model.Agent, t model.Ticket, role string, opt RunOptions) (*RunOutcome, error) { - prompt := BuildPrompt(t, role) - if !opt.SkipComms { - if comm := o.commContext(agent, t.Title); comm != "" { - prompt = comm + "\n\n" + prompt - } - } - out, err := o.Execute(ws, agent, t.ID, prompt, opt) - if err == nil && out != nil && out.Executed && !opt.SkipComms { - _ = o.Bus.MarkRead(agent.ID) - } - return out, err -} - -// commContext renders the agent's unread inbox + query-scoped recall (via the -// Bus port) into a compact, token-bounded block. -func (o Orchestrator) commContext(agent model.Agent, query string) string { - inbox, recall := o.Bus.CatchUp(agent.ID, agent.Roles, query, 5) - if len(inbox) == 0 && len(recall) == 0 { - return "" - } - var s strings.Builder - s.WriteString("## Hộp thư & bối cảnh trao đổi (đọc nhanh trước khi vào việc)\n") - if len(inbox) > 0 { - s.WriteString("\n### Inbox — cần để ý (DM/@mention/broadcast)\n") - for _, line := range inbox { - s.WriteString("- " + line + "\n") - } - } - if len(recall) > 0 { - s.WriteString("\n### Liên quan tới ticket (recall, không cần trả lời hết)\n") - for _, line := range recall { - s.WriteString("- " + line + "\n") - } - } - s.WriteString("\nTrả lời/ghi nhận nếu @mention đích danh; còn lại chỉ là bối cảnh.") - return s.String() -} - -// Execute builds and (unless DryRun) runs an agent against an arbitrary prompt, -// recording the outcome in the ledger under ticketID ("-" for system tasks). -func (o Orchestrator) Execute(ws *config.Workspace, agent model.Agent, ticketID, prompt string, opt RunOptions) (*RunOutcome, error) { - repoRoot := ws.Layout.RepoRoot() - req := RunRequest{ - Agent: agent, - Prompt: prompt, - WorkDir: repoRoot, - RepoRoot: repoRoot, - } - inv := AdapterFor(agent.Kind).Build(req) - out := opt.Stdout - if out == nil { - out = os.Stdout - } - - // No headless contract: emit a handoff for a human/IDE and stop. - if !inv.Headless { - fmt.Fprintf(out, "Agent %s (%s) has no headless mode: %s\n", agent.ID, agent.Kind, inv.Note) - fmt.Fprintln(out, "--- handoff prompt ---") - fmt.Fprintln(out, inv.StdinStr) - _ = o.Ledger.Append(model.Entry{ - Agent: agent.ID, Ticket: ticketID, Action: model.ActNote, - Why: "manual handoff prepared (" + inv.Note + ")", - }) - return &RunOutcome{Invocation: inv, Executed: false}, nil - } - - if opt.DryRun { - fmt.Fprintln(out, "[dry-run] would run:") - fmt.Fprintln(out, " "+strings.Join(redactPrompt(inv.Args), " ")) - if inv.Note != "" { - fmt.Fprintln(out, " note: "+inv.Note) - } - return &RunOutcome{Invocation: inv, Executed: false}, nil - } - - ctx := context.Background() - if opt.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, opt.Timeout) - defer cancel() - } - cmd := exec.CommandContext(ctx, inv.Args[0], inv.Args[1:]...) - cmd.Dir = req.WorkDir - cmd.Env = append(os.Environ(), inv.Env...) - if inv.StdinStr != "" { - cmd.Stdin = strings.NewReader(inv.StdinStr) - } - var buf strings.Builder - writers := []io.Writer{out, &buf} - // Per-run transcript (raw stdout+stderr) for `hatch logs` + the TUI. - if tf, err := openTranscript(ws.Layout, ticketID, agent.ID); err == nil { - defer tf.Close() - writers = append(writers, tf) - } - mw := io.MultiWriter(writers...) - cmd.Stdout = mw - cmd.Stderr = mw - - _ = o.Ledger.Append(model.Entry{ - Agent: agent.ID, Ticket: ticketID, Action: model.ActStart, - Why: fmt.Sprintf("orchestrator spawned %s for %s", agent.ID, ticketID), - }) - - runErr := cmd.Run() - outcome := &RunOutcome{Invocation: inv, Executed: true, Output: buf.String(), Err: runErr} - if cmd.ProcessState != nil { - outcome.ExitCode = cmd.ProcessState.ExitCode() - } - - result := "ok" - if runErr != nil { - result = "failed: " + runErr.Error() - } - usd, tokens := extractUsage(buf.String(), agent.RatePerMTok) - _ = o.Ledger.Append(model.Entry{ - Agent: agent.ID, Ticket: ticketID, Action: model.ActProgress, - Result: result, Why: fmt.Sprintf("%s finished (exit %d)", agent.ID, outcome.ExitCode), - CostUSD: usd, Tokens: tokens, - }) - return outcome, nil -} - -// redactPrompt shortens the (often long) prompt argument for display. -func redactPrompt(args []string) []string { - out := make([]string, len(args)) - for i, a := range args { - if len(a) > 80 && strings.Contains(a, "\n") { - out[i] = fmt.Sprintf("<prompt %d chars>", len(a)) - } else { - out[i] = a - } - } - return out -} diff --git a/hatch/internal/orchestrator/transcript.go b/hatch/internal/orchestrator/transcript.go deleted file mode 100644 index 67e0460..0000000 --- a/hatch/internal/orchestrator/transcript.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "os" - "path/filepath" - "time" - - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -// transcriptDir is where per-run logs live, under .hatch/runs/<ticket>/. -func transcriptDir(l paths.Layout, ticket string) string { - return l.Runs(ticket) -} - -// openTranscript creates an append log file for a run and writes a header. -func openTranscript(l paths.Layout, ticket, agent string) (*os.File, error) { - dir := transcriptDir(l, ticket) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, err - } - ts := time.Now().Format("20060102-150405") - f, err := os.OpenFile(filepath.Join(dir, ts+"-"+paths.SafeSegment(agent)+".log"), - os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return nil, err - } - f.WriteString("# run " + agent + " · " + ticket + " · " + time.Now().Format(time.RFC3339) + "\n") - return f, nil -} diff --git a/hatch/internal/orchestrator/usage.go b/hatch/internal/orchestrator/usage.go deleted file mode 100644 index 9e0ad62..0000000 --- a/hatch/internal/orchestrator/usage.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "regexp" - "strconv" -) - -// Usage parsing is best-effort and provider-agnostic: agents print cost/usage -// in their JSON output in different shapes. We scrape what we can; when only -// tokens are known we estimate USD from the agent's configured rate. - -var ( - reCostUSD = regexp.MustCompile(`"(?:total_cost_usd|cost_usd|costUSD)"\s*:\s*([0-9.]+)`) - reTokens = regexp.MustCompile(`"(?:total_tokens|tokens|token_count|tokens_used)"\s*:\s*([0-9]+)`) -) - -// extractUsage scrapes cost (USD) and tokens from an agent's output. If cost is -// absent but tokens and a per-MTok rate are known, it estimates cost. -func extractUsage(output string, ratePerMTok float64) (usd float64, tokens int) { - if m := reCostUSD.FindStringSubmatch(output); m != nil { - usd, _ = strconv.ParseFloat(m[1], 64) - } - if m := reTokens.FindStringSubmatch(output); m != nil { - tokens, _ = strconv.Atoi(m[1]) - } - if usd == 0 && tokens > 0 && ratePerMTok > 0 { - usd = float64(tokens) / 1_000_000 * ratePerMTok - } - return usd, tokens -} diff --git a/hatch/internal/orchestrator/usage_test.go b/hatch/internal/orchestrator/usage_test.go deleted file mode 100644 index 0721a61..0000000 --- a/hatch/internal/orchestrator/usage_test.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import "testing" - -func TestExtractUsage(t *testing.T) { - usd, tok := extractUsage(`{"result":"ok","total_cost_usd":0.42,"total_tokens":18450}`, 0) - if usd != 0.42 || tok != 18450 { - t.Fatalf("parse failed: usd=%v tok=%d", usd, tok) - } - // tokens only → estimate from rate (15 USD / 1M tok). - usd2, _ := extractUsage(`{"tokens":1000000}`, 15) - if usd2 != 15 { - t.Fatalf("estimate wrong: %v", usd2) - } - if usd3, tok3 := extractUsage("no usage here", 10); usd3 != 0 || tok3 != 0 { - t.Fatalf("expected zero usage, got %v/%d", usd3, tok3) - } -} diff --git a/hatch/internal/orchestrator/worktree.go b/hatch/internal/orchestrator/worktree.go deleted file mode 100644 index ecda3b0..0000000 --- a/hatch/internal/orchestrator/worktree.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build hatch_legacy - -package orchestrator - -import ( - "fmt" - "os/exec" - "path/filepath" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -// WorktreeDir is where per-ticket git worktrees are created (under .hatch). -const WorktreeDir = ".hatch/.worktrees" - -// AddWorktree creates an isolated git worktree for a ticket on the given branch, -// creating the branch if needed. Returns the worktree path. -func AddWorktree(repoRoot, ticketID, branch string) (string, error) { - safe := paths.SafeSegment(ticketID) // defense-in-depth against path traversal - path := filepath.Join(repoRoot, WorktreeDir, safe) - if branch == "" { - branch = "hatch/" + safe - } - // Create the branch if it does not exist, then add the worktree. - args := []string{"worktree", "add"} - if !branchExists(repoRoot, branch) { - args = append(args, "-b", branch) - } - args = append(args, path) - if branchExists(repoRoot, branch) { - args = append(args, branch) - } - if out, err := git(repoRoot, args...); err != nil { - return "", fmt.Errorf("git worktree add: %v: %s", err, out) - } - return path, nil -} - -// RemoveWorktree tears down a ticket worktree. -func RemoveWorktree(repoRoot, path string) error { - if out, err := git(repoRoot, "worktree", "remove", "--force", path); err != nil { - return fmt.Errorf("git worktree remove: %v: %s", err, out) - } - return nil -} - -func branchExists(repoRoot, branch string) bool { - _, err := git(repoRoot, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) - return err == nil -} - -func git(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - return strings.TrimSpace(string(out)), err -} diff --git a/hatch/internal/paths/paths.go b/hatch/internal/paths/paths.go index 14304fa..46ec652 100644 --- a/hatch/internal/paths/paths.go +++ b/hatch/internal/paths/paths.go @@ -11,27 +11,39 @@ import ( // Dir is the workspace directory name placed at a repository root. const Dir = ".hatch" -// Layout names within a .hatch/ directory. +// Layout names within a .hatch/ directory. SSOT (committed) lives at the top +// level; everything regenerable or machine-local lives under RunDir so a single +// gitignore line (/.hatch/run/) covers all runtime state. const ( - CharterFile = "charter.md" - RegistryFile = "registry.yaml" - WorkflowFile = "workflow.yaml" - PresenceFile = "presence.json" - OncallFile = "oncall.json" + // SSOT — hand-edited, committed. + CharterFile = "charter.md" + WorkingAgreementFile = "working-agreement.md" + RegistryFile = "registry.yaml" + WorkflowFile = "workflow.yaml" RolesDir = "roles" ContextDir = "context" KBDir = "kb" - BoardDir = "board" - LedgerDir = "ledger" ProtocolDir = "protocol" - CompiledDir = "compiled" - LogsDir = "logs" - ManifestFile = "compiled/.manifest.json" - MCPLogFile = "logs/mcp.jsonl" - KBIndexFile = "kb/index.md" - KBMetaFile = "kb/.meta.json" + KBIndexFile = "kb/index.md" + KBMetaFile = "kb/.meta.json" + + // RunDir holds all runtime state (chat bus, presence, sessions, slack, + // compiled manifest, logs, ledger, mcp snippets). Gitignored as one dir. + RunDir = "run" + + BusDir = "bus" + RosterFile = "roster.json" + SessionsFile = "sessions.json" + LedgerDir = "ledger" + CompiledDir = "compiled" + LogsDir = "logs" + SlackDir = "slack" + MCPDir = "mcp" + ManifestFile = "compiled/.manifest.json" + MCPLogFile = "logs/mcp.jsonl" + SlackConfigDir = "slack" ) // Layout resolves absolute paths inside a single workspace root. @@ -41,25 +53,38 @@ func (l Layout) path(parts ...string) string { return filepath.Join(append([]string{l.Root}, parts...)...) } -func (l Layout) Charter() string { return l.path(CharterFile) } -func (l Layout) Registry() string { return l.path(RegistryFile) } -func (l Layout) Workflow() string { return l.path(WorkflowFile) } -func (l Layout) Roles() string { return l.path(RolesDir) } -func (l Layout) Context() string { return l.path(ContextDir) } -func (l Layout) KB() string { return l.path(KBDir) } -func (l Layout) Board() string { return l.path(BoardDir) } -func (l Layout) Lane(name string) string { return l.path(BoardDir, name) } -func (l Layout) Ledger() string { return l.path(LedgerDir) } -func (l Layout) Protocol() string { return l.path(ProtocolDir) } -func (l Layout) Compiled() string { return l.path(CompiledDir) } -func (l Layout) Logs() string { return l.path(LogsDir) } -func (l Layout) MCPLog() string { return l.path(MCPLogFile) } -func (l Layout) Manifest() string { return l.path(ManifestFile) } -func (l Layout) KBIndex() string { return l.path(KBIndexFile) } -func (l Layout) KBMeta() string { return l.path(KBMetaFile) } -func (l Layout) Presence() string { return l.path(PresenceFile) } -func (l Layout) Oncall() string { return l.path(OncallFile) } -func (l Layout) DocTemplates() string { return l.path("templates", "docs") } +// run resolves a path under the runtime dir (.hatch/run/...). +func (l Layout) run(parts ...string) string { + return l.path(append([]string{RunDir}, parts...)...) +} + +// SSOT (committed). +func (l Layout) Charter() string { return l.path(CharterFile) } +func (l Layout) WorkingAgreement() string { return l.path(WorkingAgreementFile) } +func (l Layout) Registry() string { return l.path(RegistryFile) } +func (l Layout) Workflow() string { return l.path(WorkflowFile) } +func (l Layout) Roles() string { return l.path(RolesDir) } +func (l Layout) Context() string { return l.path(ContextDir) } +func (l Layout) KB() string { return l.path(KBDir) } +func (l Layout) Protocol() string { return l.path(ProtocolDir) } +func (l Layout) KBIndex() string { return l.path(KBIndexFile) } +func (l Layout) KBMeta() string { return l.path(KBMetaFile) } + +// Runtime (under .hatch/run/, gitignored). +func (l Layout) Run() string { return l.path(RunDir) } +func (l Layout) Bus() string { return l.run(BusDir) } +func (l Layout) Ledger() string { return l.run(LedgerDir) } +func (l Layout) Compiled() string { return l.run(CompiledDir) } +func (l Layout) Logs() string { return l.run(LogsDir) } +func (l Layout) MCPLog() string { return l.run(MCPLogFile) } +func (l Layout) Manifest() string { return l.run(ManifestFile) } +func (l Layout) MCPSnippets() string { return l.run(MCPDir) } +func (l Layout) Roster() string { return l.run(RosterFile) } +func (l Layout) Sessions() string { return l.run(SessionsFile) } +func (l Layout) Slack() string { return l.run(SlackDir) } +func (l Layout) SlackConfig() string { return l.run(SlackDir, "config.json") } +func (l Layout) SlackThreadmap() string { return l.run(SlackDir, "threadmap.json") } +func (l Layout) DocTemplates() string { return l.path("templates", "docs") } // SafeSegment sanitizes s for use as a single path segment, preventing path // traversal: only [A-Za-z0-9._-] survive, the rest become '-', and the diff --git a/hatch/internal/port/port.go b/hatch/internal/port/port.go index b925ff7..7d0a1e7 100644 --- a/hatch/internal/port/port.go +++ b/hatch/internal/port/port.go @@ -7,14 +7,6 @@ package port import "github.com/fioenix/overclaud/hatch/internal/model" -// Board is the ticket-store port. -type Board interface { - ListLane(lane string) ([]model.Ticket, error) - Find(id string, lanes []string) (model.Ticket, bool, error) - Path(t model.Ticket) string - Write(t model.Ticket) (string, error) -} - // Ledger is the append-only audit-log port. type Ledger interface { Append(e model.Entry) error diff --git a/hatch/internal/presence/presence.go b/hatch/internal/presence/presence.go deleted file mode 100644 index f8ff01a..0000000 --- a/hatch/internal/presence/presence.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build hatch_legacy - -// Package presence tracks per-agent availability (like Slack presence + PTO): -// who is free to take work, who is busy/paused/offline. Capacity-aware -// assignment uses this plus WIP to route work to a free teammate. -package presence - -import ( - "encoding/json" - "os" - "path/filepath" - "sort" - "time" - - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -// Availability states. -const ( - Available = "available" - Busy = "busy" - Paused = "paused" - Offline = "offline" -) - -// State is one agent's presence. -type State struct { - Status string `json:"status"` - Since string `json:"since"` - Note string `json:"note,omitempty"` -} - -// Board maps agent id → presence. A missing agent is treated as Available. -type Board map[string]State - -// Load reads presence.json, returning an empty board if absent. -func Load(l paths.Layout) Board { - raw, err := os.ReadFile(l.Presence()) - if err != nil { - return Board{} - } - b := Board{} - if json.Unmarshal(raw, &b) != nil { - return Board{} - } - return b -} - -// Save writes the board to presence.json. -func (b Board) Save(l paths.Layout) error { - if err := os.MkdirAll(filepath.Dir(l.Presence()), 0o755); err != nil { - return err - } - raw, err := json.MarshalIndent(b, "", " ") - if err != nil { - return err - } - return os.WriteFile(l.Presence(), append(raw, '\n'), 0o644) -} - -// Set updates an agent's status (and optional note) to now. -func (b Board) Set(agent, status, note string) { - b[agent] = State{Status: status, Since: time.Now().Format(time.RFC3339), Note: note} -} - -// StatusOf returns an agent's status, defaulting to Available. -func (b Board) StatusOf(agent string) string { - s, ok := b[agent] - if !ok || s.Status == "" { - return Available - } - return s.Status -} - -// CanTakeWork reports whether an agent is free to be assigned (available/busy -// still count as reachable; paused/offline do not). -func (b Board) CanTakeWork(agent string) bool { - switch b.StatusOf(agent) { - case Paused, Offline: - return false - default: - return true - } -} - -// Agents returns the agent ids present in the board, sorted. -func (b Board) Agents() []string { - out := make([]string, 0, len(b)) - for a := range b { - out = append(out, a) - } - sort.Strings(out) - return out -} diff --git a/hatch/internal/presence/presence_test.go b/hatch/internal/presence/presence_test.go deleted file mode 100644 index 47f4979..0000000 --- a/hatch/internal/presence/presence_test.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build hatch_legacy - -package presence - -import ( - "testing" - - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -func TestPresenceDefaultsAvailableAndPersists(t *testing.T) { - l := paths.At(t.TempDir()) - b := Load(l) - if b.StatusOf("codex") != Available { - t.Fatal("missing agent should be available") - } - if !b.CanTakeWork("codex") { - t.Fatal("available agent can take work") - } - b.Set("codex", Offline, "PTO") - if err := b.Save(l); err != nil { - t.Fatal(err) - } - b2 := Load(l) - if b2.StatusOf("codex") != Offline || b2.CanTakeWork("codex") { - t.Fatalf("offline not persisted/honored: %+v", b2["codex"]) - } - if b2["codex"].Note != "PTO" { - t.Fatalf("note lost: %q", b2["codex"].Note) - } -} diff --git a/hatch/internal/roster/roster.go b/hatch/internal/roster/roster.go new file mode 100644 index 0000000..7caff63 --- /dev/null +++ b/hatch/internal/roster/roster.go @@ -0,0 +1,193 @@ +// Package roster persists the workspace room's membership: who has joined, the +// session that holds their memory, and whether they are reachable. It is the +// "team simulation" presence layer — distinct from the legacy presence package +// (availability-for-assignment) and from the registry (static config). The +// filesystem is the database: one JSON file per workspace. +package roster + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// IdleAfter is how long since LastSeen before an online member is shown as +// idle. It is cosmetic (idle members are still reachable/wakeable); it just +// tells the boss who is actively around. +const IdleAfter = 5 * time.Minute + +// mu serializes read-modify-write of the roster file within this process (the +// daemon wakes several agents concurrently, each updating status). Reads (Load) +// are safe lock-free because writes finish with an atomic rename. +var mu sync.Mutex + +// Store reads and writes the roster for one workspace. +type Store struct{ L paths.Layout } + +// New returns a roster store bound to a workspace layout. +func New(l paths.Layout) *Store { return &Store{L: l} } + +// Load reads the roster, returning an empty one if absent. +func (s *Store) Load() (model.Roster, error) { + raw, err := os.ReadFile(s.L.Roster()) + if err != nil { + if os.IsNotExist(err) { + return model.Roster{}, nil + } + return nil, err + } + r := model.Roster{} + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + return r, nil +} + +// Save writes the roster atomically (write then rename) to avoid a torn read by +// a concurrently-tailing reader. It serializes with the mutating helpers. +func (s *Store) Save(r model.Roster) error { + mu.Lock() + defer mu.Unlock() + return s.save(r) +} + +// save is the unlocked write; callers already hold mu. A per-pid temp name keeps +// concurrent processes from clobbering each other's temp file before rename. +func (s *Store) save(r model.Roster) error { + if err := os.MkdirAll(filepath.Dir(s.L.Roster()), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + tmp := fmt.Sprintf("%s.tmp.%d", s.L.Roster(), os.Getpid()) + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, s.L.Roster()) +} + +// Join adds or updates a member, marking it online with a fresh LastSeen. A +// non-empty sessionID records the resumable session that holds the member's +// memory (used by the wake daemon to resume the same teammate). An empty +// sessionID preserves any previously recorded one. +func (s *Store) Join(m model.Member) (model.Member, error) { + mu.Lock() + defer mu.Unlock() + r, err := s.Load() + if err != nil { + return model.Member{}, err + } + prev := r[m.ID] + if m.SessionID == "" { + m.SessionID = prev.SessionID + } + if m.Status == "" { + m.Status = model.MemberOnline + } + m.LastSeen = now() + r[m.ID] = m + return m, s.save(r) +} + +// Touch refreshes a member's LastSeen and revives it to online if it was idle +// or suspended. Called on any activity (e.g. an MCP tool call) so presence +// reflects reality. A missing or offline member is left unchanged. +func (s *Store) Touch(id string) error { + mu.Lock() + defer mu.Unlock() + r, err := s.Load() + if err != nil { + return err + } + m, ok := r[id] + if !ok || m.Status == model.MemberOffline { + return nil + } + m.LastSeen = now() + if m.Status == model.MemberIdle || m.Status == model.MemberSuspended { + m.Status = model.MemberOnline + } + r[id] = m + return s.save(r) +} + +// Leave marks a member offline (it has explicitly left the room). +func (s *Store) Leave(id string) error { + mu.Lock() + defer mu.Unlock() + r, err := s.Load() + if err != nil { + return err + } + if m, ok := r[id]; ok { + m.Status = model.MemberOffline + m.LastSeen = now() + r[id] = m + return s.save(r) + } + return nil +} + +// SetStatus sets a member's status explicitly (e.g. the daemon marking a member +// suspended after it finishes a turn). No-op for a missing member. +func (s *Store) SetStatus(id, status string) error { + mu.Lock() + defer mu.Unlock() + r, err := s.Load() + if err != nil { + return err + } + if m, ok := r[id]; ok { + m.Status = status + m.LastSeen = now() + r[id] = m + return s.save(r) + } + return nil +} + +// Effective returns the roster with cosmetic idle derivation applied: an online +// member whose LastSeen is older than IdleAfter is shown as idle. It does not +// persist the change. offline/suspended members are left as-is. +func (s *Store) Effective(at time.Time) (model.Roster, error) { + r, err := s.Load() + if err != nil { + return nil, err + } + for id, m := range r { + if m.Status == model.MemberOnline && stale(m.LastSeen, at) { + m.Status = model.MemberIdle + r[id] = m + } + } + return r, nil +} + +// Members returns the roster's members sorted by id (deterministic listing). +func Members(r model.Roster) []model.Member { + out := make([]model.Member, 0, len(r)) + for _, m := range r { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func now() string { return time.Now().Format(time.RFC3339) } + +func stale(lastSeen string, at time.Time) bool { + t, err := time.Parse(time.RFC3339, lastSeen) + if err != nil { + return false + } + return at.Sub(t) > IdleAfter +} diff --git a/hatch/internal/roster/roster_test.go b/hatch/internal/roster/roster_test.go new file mode 100644 index 0000000..aa18abd --- /dev/null +++ b/hatch/internal/roster/roster_test.go @@ -0,0 +1,92 @@ +package roster + +import ( + "testing" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +func newStore(t *testing.T) *Store { + t.Helper() + return New(paths.Layout{Root: t.TempDir()}) +} + +func TestJoinAndLoad(t *testing.T) { + s := newStore(t) + if _, err := s.Join(model.Member{ID: "codex", Kind: "codex", Roles: []string{"implementer"}, SessionID: "sess-1"}); err != nil { + t.Fatal(err) + } + r, err := s.Load() + if err != nil { + t.Fatal(err) + } + m, ok := r["codex"] + if !ok { + t.Fatal("codex not in roster") + } + if m.Status != model.MemberOnline { + t.Fatalf("want online, got %q", m.Status) + } + if m.SessionID != "sess-1" || m.LastSeen == "" { + t.Fatalf("session/last-seen not recorded: %+v", m) + } +} + +func TestJoinPreservesSession(t *testing.T) { + s := newStore(t) + _, _ = s.Join(model.Member{ID: "codex", Kind: "codex", SessionID: "sess-1"}) + // Re-join without a session id must keep the previously recorded one. + _, _ = s.Join(model.Member{ID: "codex", Kind: "codex"}) + r, _ := s.Load() + if r["codex"].SessionID != "sess-1" { + t.Fatalf("session id should be preserved, got %q", r["codex"].SessionID) + } +} + +func TestLeaveMarksOfflineNotReachable(t *testing.T) { + s := newStore(t) + _, _ = s.Join(model.Member{ID: "codex", Kind: "codex"}) + if err := s.Leave("codex"); err != nil { + t.Fatal(err) + } + r, _ := s.Load() + if r["codex"].Status != model.MemberOffline { + t.Fatalf("want offline, got %q", r["codex"].Status) + } + if r.Reachable("codex") { + t.Fatal("offline member must not be reachable") + } +} + +func TestTouchRevivesIdle(t *testing.T) { + s := newStore(t) + _, _ = s.Join(model.Member{ID: "codex", Kind: "codex"}) + _ = s.SetStatus("codex", model.MemberIdle) + if err := s.Touch("codex"); err != nil { + t.Fatal(err) + } + r, _ := s.Load() + if r["codex"].Status != model.MemberOnline { + t.Fatalf("touch should revive idle→online, got %q", r["codex"].Status) + } +} + +func TestEffectiveDerivesIdle(t *testing.T) { + s := newStore(t) + _, _ = s.Join(model.Member{ID: "codex", Kind: "codex"}) + // Look at the roster well after the idle threshold. + r, err := s.Effective(time.Now().Add(2 * IdleAfter)) + if err != nil { + t.Fatal(err) + } + if r["codex"].Status != model.MemberIdle { + t.Fatalf("want derived idle, got %q", r["codex"].Status) + } + // Effective must not persist the derivation. + persisted, _ := s.Load() + if persisted["codex"].Status != model.MemberOnline { + t.Fatalf("Effective must not persist idle, got %q", persisted["codex"].Status) + } +} diff --git a/hatch/internal/scaffold/templates/base/working-agreement.md b/hatch/internal/scaffold/templates/base/working-agreement.md new file mode 100644 index 0000000..799dc39 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/working-agreement.md @@ -0,0 +1,21 @@ +# Working Agreement — làm việc chuyên nghiệp, ownership cao + +> Tầng L0: nạp cùng charter mỗi message. Cách squad làm việc như một team người — +> hành động dứt khoát, sở hữu rõ ràng, không chat cho vui, không đùn đẩy. +> Sửa file này là sửa SSOT — chạy `hatch compile`. + +1. **Bias to action.** Việc reversible thì làm; chỉ hỏi khi irreversible/mơ hồ. +2. **Mỗi tin xứng token.** Mang thông tin/quyết định/deliverable/câu hỏi cụ thể — + không "ok/thanks/noted". (Tin không @ai thì không đánh thức ai.) +3. **Own cái mày mở / bị nhờ.** Handoff phải có **tên người kế** + được **ack**; + cấm im lặng thả bóng. +4. **Close the loop.** Xong → post kết quả; kẹt → post blocker + cần ai; không + biến mất giữa chừng. +5. **Self-check DoD trước khi báo xong.** Không tự review code mình viết + (@tag reviewer khác); không tự merge (human gate). +6. **Escalate sớm lên user** khi kẹt ở một *quyết định*. + +## Giao tiếp trong phòng +- Đầu session: `whoami` → `join` (kèm `session_id`) → `roster` (ai đang ở đây). +- Một task = một thread; `@tag` đúng người. Tra `chat_search`/`kb_search` trước + khi suy diễn lại; tri thức đáng giữ → `kb_add`. diff --git a/hatch/internal/session/store.go b/hatch/internal/session/store.go new file mode 100644 index 0000000..74c2710 --- /dev/null +++ b/hatch/internal/session/store.go @@ -0,0 +1,136 @@ +// Package session manages agents' resumable CLI sessions, keyed by (agent, +// bus-thread). It is the lifecycle layer the wake daemon uses to resume a +// teammate's warm context for a specific task. The bus + KB remain the source +// of truth; a session is only a cache pointer, so losing one degrades to a +// fresh read of the record, never to lost work. +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// book is the on-disk shape: agent id → bus thread → session. +type book map[string]map[string]model.Session + +// mu serializes read-modify-write of sessions.json within this process (the +// daemon resumes several agents concurrently). Reads are lock-free (writes end +// with an atomic rename). +var mu sync.Mutex + +// Store reads and writes .hatch/sessions.json. +type Store struct{ L paths.Layout } + +// New returns a store bound to a workspace layout. +func New(l paths.Layout) *Store { return &Store{L: l} } + +func (s *Store) load() (book, error) { + raw, err := os.ReadFile(s.L.Sessions()) + if err != nil { + if os.IsNotExist(err) { + return book{}, nil + } + return nil, err + } + var b book + if err := json.Unmarshal(raw, &b); err != nil { + return nil, err + } + if b == nil { + b = book{} + } + return b, nil +} + +func (s *Store) save(b book) error { + raw, err := json.MarshalIndent(b, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.L.Sessions()), 0o755); err != nil { + return err + } + tmp := fmt.Sprintf("%s.tmp.%d", s.L.Sessions(), os.Getpid()) + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, s.L.Sessions()) +} + +// Get returns the session for (agent, thread), if any. +func (s *Store) Get(agent, thread string) (model.Session, bool) { + b, err := s.load() + if err != nil { + return model.Session{}, false + } + sess, ok := b[agent][thread] + return sess, ok +} + +// Put upserts a session by (Agent, Thread). When it supersedes a session with a +// different id, the old id is pushed onto History for audit. +func (s *Store) Put(sess model.Session) error { + mu.Lock() + defer mu.Unlock() + b, err := s.load() + if err != nil { + return err + } + if b[sess.Agent] == nil { + b[sess.Agent] = map[string]model.Session{} + } + if prev, ok := b[sess.Agent][sess.Thread]; ok && prev.ID != "" && prev.ID != sess.ID { + sess.History = append(append([]string{}, prev.History...), prev.ID) + } + b[sess.Agent][sess.Thread] = sess + return s.save(b) +} + +// MarkStale flags a session unresumable so the next wake starts fresh. +func (s *Store) MarkStale(agent, thread string) error { + mu.Lock() + defer mu.Unlock() + b, err := s.load() + if err != nil { + return err + } + sess, ok := b[agent][thread] + if !ok { + return nil + } + sess.Status = model.SessionStale + b[agent][thread] = sess + return s.save(b) +} + +// All returns every recorded session, sorted by agent then thread. +func (s *Store) All() []model.Session { + b, err := s.load() + if err != nil { + return nil + } + var out []model.Session + for _, threads := range b { + for _, sess := range threads { + out = append(out, sess) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Agent != out[j].Agent { + return out[i].Agent < out[j].Agent + } + return out[i].Thread < out[j].Thread + }) + return out +} + +// Now returns an RFC3339 timestamp (exported so callers stamp consistently). +func Now() string { return time.Now().Format(time.RFC3339) } diff --git a/hatch/internal/session/store_test.go b/hatch/internal/session/store_test.go new file mode 100644 index 0000000..e12538d --- /dev/null +++ b/hatch/internal/session/store_test.go @@ -0,0 +1,75 @@ +package session + +import ( + "testing" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +func newStore(t *testing.T) *Store { + t.Helper() + return New(paths.At(t.TempDir())) +} + +func TestPutGetRoundTrip(t *testing.T) { + s := newStore(t) + if _, ok := s.Get("codex", "t-1"); ok { + t.Fatal("empty store should miss") + } + in := model.Session{Agent: "codex", Thread: "t-1", Kind: "codex", ID: "uuid-1", Status: model.SessionLive} + if err := s.Put(in); err != nil { + t.Fatal(err) + } + got, ok := s.Get("codex", "t-1") + if !ok || got.ID != "uuid-1" || got.Status != model.SessionLive { + t.Fatalf("round-trip wrong: %+v ok=%v", got, ok) + } + // a different thread is a different session + if _, ok := s.Get("codex", "t-2"); ok { + t.Fatal("t-2 should be independent of t-1") + } +} + +func TestPutSupersedePushesHistory(t *testing.T) { + s := newStore(t) + _ = s.Put(model.Session{Agent: "claude-code", Thread: "t-1", ID: "old", Status: model.SessionLive}) + _ = s.Put(model.Session{Agent: "claude-code", Thread: "t-1", ID: "new", Status: model.SessionLive}) + got, _ := s.Get("claude-code", "t-1") + if got.ID != "new" { + t.Fatalf("want current id new, got %q", got.ID) + } + if len(got.History) != 1 || got.History[0] != "old" { + t.Fatalf("want history [old], got %v", got.History) + } +} + +func TestMarkStale(t *testing.T) { + s := newStore(t) + _ = s.Put(model.Session{Agent: "codex", Thread: "t-1", ID: "x", Status: model.SessionLive}) + if err := s.MarkStale("codex", "t-1"); err != nil { + t.Fatal(err) + } + got, _ := s.Get("codex", "t-1") + if got.Status != model.SessionStale { + t.Fatalf("want stale, got %q", got.Status) + } + // marking a missing session is a no-op, not an error + if err := s.MarkStale("nobody", "t-9"); err != nil { + t.Fatal(err) + } +} + +func TestAllSorted(t *testing.T) { + s := newStore(t) + _ = s.Put(model.Session{Agent: "codex", Thread: "t-2", ID: "b"}) + _ = s.Put(model.Session{Agent: "codex", Thread: "t-1", ID: "a"}) + _ = s.Put(model.Session{Agent: "claude-code", Thread: "t-9", ID: "c"}) + all := s.All() + if len(all) != 3 { + t.Fatalf("want 3, got %d", len(all)) + } + if all[0].Agent != "claude-code" || all[1].Thread != "t-1" || all[2].Thread != "t-2" { + t.Fatalf("not sorted by agent then thread: %+v", all) + } +} diff --git a/hatch/internal/slack/bridge.go b/hatch/internal/slack/bridge.go new file mode 100644 index 0000000..6507716 --- /dev/null +++ b/hatch/internal/slack/bridge.go @@ -0,0 +1,207 @@ +package slack + +import ( + "regexp" + "sort" + "strings" + "time" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/roster" +) + +// poster sends one message to the configured Slack channel as the given agent +// (from). threadTS is "" for a new thread root, or the thread_ts to reply +// under. displayName/icon are impersonation hints used only when from has no +// real bot of its own. It returns the new message's ts. The concrete impl +// holds the per-agent Slack clients; tests use a fake. +type poster interface { + post(from, threadTS, displayName, icon, text string) (ts string, err error) +} + +// incoming is a normalised Slack message event, decoupled from slack-go so the +// IN path is a pure, testable method. +type incoming struct { + ChannelID string + User string + BotID string + SubType string + ThreadTS string + TS string + Text string +} + +// Bridge mirrors the bus into Slack (OUT) and ingests the boss's Slack messages +// onto the bus (IN). It owns no orchestration: IN just writes a peer message +// that the wake daemon later delivers. +type Bridge struct { + Bus *bus.Bus + Roster *roster.Store + Cfg Config + + poster poster + tm *threadmap + mentions map[string]string // slack bot user-id → agent id (for inbound @mention) + cursor time.Time // newest bus TS already mirrored to Slack +} + +// NewBridge wires a bridge. p, tm and mentions are injected so tests can supply +// fakes. mentions maps each agent's Slack bot user-id back to its agent id so a +// native "<@U…>" mention becomes a routable "@agent". +func NewBridge(b *bus.Bus, rs *roster.Store, cfg Config, p poster, tm *threadmap, mentions map[string]string) *Bridge { + if mentions == nil { + mentions = map[string]string{} + } + br := &Bridge{Bus: b, Roster: rs, Cfg: cfg, poster: p, tm: tm, mentions: mentions} + br.cursor = parseTS(tm.getCursor()) // resume the mirror; don't re-post history + return br +} + +// mirrorOnce posts every bus message newer than the cursor into Slack, skipping +// the boss's own messages (already visible in Slack — and the loop-break for +// IN). Each channel collapses into one Slack thread. On a post error it returns +// with the cursor advanced past whatever already succeeded, so a retry will not +// duplicate. +func (b *Bridge) mirrorOnce(now time.Time) error { + r, err := b.Roster.Effective(now) + if err != nil { + r = model.Roster{} // identity falls back to ids; mirroring still works + } + start := b.cursor + msgs := b.tailBus() + for _, m := range msgs { + t := parseTS(m.TS) + if m.From == b.Cfg.Boss { + b.advance(t) + continue + } + name, icon := identity(r, m.From) + text := m.Body + threadTS, known := b.tm.tsFor(m.Channel) + if !known { + text = "*#" + m.Channel + "*\n" + text // header on the thread root + } + ts, perr := b.poster.post(m.From, threadTS, name, icon, text) + if perr != nil { + b.persistCursor(start) // checkpoint what already succeeded + return perr + } + if !known { + _ = b.tm.bind(m.Channel, ts) + } + b.advance(t) + } + b.persistCursor(start) + return nil +} + +// persistCursor saves the mirror high-water mark if it advanced this pass, so a +// bridge restart resumes instead of re-posting the whole backlog. +func (b *Bridge) persistCursor(start time.Time) { + if b.cursor.After(start) { + _ = b.tm.setCursor(b.cursor.Format(time.RFC3339Nano)) + } +} + +// handleIncoming turns a genuine human Slack message into a bus post from the +// boss. bus.Post extracts @mentions from the text, so a literal "@codex …" +// reaches the daemon exactly like a peer message. Bot/echo/system events are +// dropped (this is the OUT→IN loop break together with the From==boss skip). +func (b *Bridge) handleIncoming(in incoming) error { + if in.ChannelID != b.Cfg.ChannelID { + return nil // not our room + } + if in.BotID != "" || in.User == "" || in.SubType != "" { + return nil // our own posts, other bots, or system/edit events + } + // Only the boss may drive the squad from Slack. When a boss Slack user id is + // configured, ignore everyone else (a shared channel must not let any member + // wake agents with the boss's authority). Empty BossUserID = single-tenant. + if b.Cfg.BossUserID != "" && in.User != b.Cfg.BossUserID { + return nil + } + text := strings.TrimSpace(b.translateMentions(in.Text)) + if text == "" { + return nil + } + ch := b.channelForIncoming(in) + _, err := b.Bus.Post(model.Message{ + Channel: ch, + From: b.Cfg.Boss, + Type: bus.TypeMsg, + Body: text, + }) + return err +} + +// channelForIncoming resolves which bus channel a Slack message belongs to. A +// thread reply maps back through the threadmap; a top-level message opens a new +// channel and binds its ts so agent replies (OUT) nest under it. +func (b *Bridge) channelForIncoming(in incoming) string { + if in.ThreadTS != "" { + if ch, ok := b.tm.channelFor(in.ThreadTS); ok { + return ch + } + ch := "t-" + paths.SafeSegment(in.ThreadTS) + _ = b.tm.bind(ch, in.ThreadTS) + return ch + } + ch := "t-" + paths.SafeSegment(in.TS) + _ = b.tm.bind(ch, in.TS) + return ch +} + +func (b *Bridge) advance(t time.Time) { + if t.After(b.cursor) { + b.cursor = t + } +} + +// tailBus returns bus messages newer than the cursor, across all channels, +// sorted by TS. +func (b *Bridge) tailBus() []model.Message { + chans, err := b.Bus.Channels() + if err != nil { + return nil + } + var out []model.Message + for _, ch := range chans { + msgs, err := b.Bus.Messages(ch) + if err != nil { + continue + } + for _, m := range msgs { + if parseTS(m.TS).After(b.cursor) { + out = append(out, m) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS }) + return out +} + +func parseTS(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, s) + return t +} + +// slackMention matches Slack's encoded user mention "<@U123>" or "<@U123|name>". +var slackMention = regexp.MustCompile(`<@([A-Z0-9]+)(?:\|[^>]*)?>`) + +// translateMentions rewrites native Slack bot mentions ("<@Ucodex>") into bus +// handles ("@codex") so bus.Post routes them to the right teammate. Mentions of +// real humans (unmapped ids) are left as their raw token. +func (b *Bridge) translateMentions(text string) string { + if len(b.mentions) == 0 { + return text + } + return slackMention.ReplaceAllStringFunc(text, func(tok string) string { + m := slackMention.FindStringSubmatch(tok) + if agent, ok := b.mentions[m[1]]; ok { + return "@" + agent + } + return tok + }) +} diff --git a/hatch/internal/slack/bridge_test.go b/hatch/internal/slack/bridge_test.go new file mode 100644 index 0000000..a66b5f1 --- /dev/null +++ b/hatch/internal/slack/bridge_test.go @@ -0,0 +1,198 @@ +package slack + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/roster" +) + +type fakePost struct{ from, threadTS, username, icon, text string } + +type fakePoster struct { + posts []fakePost + n int +} + +func (f *fakePoster) post(from, threadTS, username, icon, text string) (string, error) { + f.posts = append(f.posts, fakePost{from, threadTS, username, icon, text}) + f.n++ + return fmt.Sprintf("ts-%d", f.n), nil +} + +func newTestBridge(t *testing.T, cfg Config) (*Bridge, *bus.Bus, *roster.Store, *fakePoster) { + t.Helper() + return newTestBridgeM(t, cfg, nil) +} + +func newTestBridgeM(t *testing.T, cfg Config, mentions map[string]string) (*Bridge, *bus.Bus, *roster.Store, *fakePoster) { + t.Helper() + l := paths.At(t.TempDir()) + b := bus.New(l) + rs := roster.New(l) + fp := &fakePoster{} + return NewBridge(b, rs, cfg, fp, loadThreadmap(l), mentions), b, rs, fp +} + +// OUT: agent messages mirror (one thread per bus channel), the boss's own +// messages are skipped, and the first message of a channel carries a header. +func TestMirrorOut(t *testing.T) { + br, b, _, fp := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + mustPost(t, b, "task-1", "codex", "hello") + mustPost(t, b, "task-1", "claude", "hi back") + mustPost(t, b, "task-1", "fioenix", "boss talking") // skipped: boss sees own msg in Slack + + if err := br.mirrorOnce(time.Now()); err != nil { + t.Fatal(err) + } + if len(fp.posts) != 2 { + t.Fatalf("want 2 mirrored posts, got %d: %+v", len(fp.posts), fp.posts) + } + if fp.posts[0].threadTS != "" { + t.Errorf("first post should open a thread (empty threadTS), got %q", fp.posts[0].threadTS) + } + if !strings.Contains(fp.posts[0].text, "*#task-1*") { + t.Errorf("thread root should carry channel header, got %q", fp.posts[0].text) + } + if fp.posts[1].threadTS != "ts-1" { + t.Errorf("second post should reply under ts-1, got %q", fp.posts[1].threadTS) + } + if fp.posts[1].from != "claude" { + t.Errorf("want post attributed to agent claude, got %q", fp.posts[1].from) + } +} + +// IN: a native Slack mention "<@Ucodex>" is rewritten to "@codex" so bus.Post +// routes it; an unmapped mention (a real human) is left untouched. +func TestIngestTranslatesMention(t *testing.T) { + br, b, _, _ := newTestBridgeM(t, + Config{Boss: "fioenix", ChannelID: "C1"}, + map[string]string{"UCODEX": "codex"}) + err := br.handleIncoming(incoming{ChannelID: "C1", User: "U1", TS: "1700.5", + Text: "<@UCODEX> and <@UHUMAN> please look"}) + if err != nil { + t.Fatal(err) + } + msgs, _ := b.Messages("t-1700.5") + if len(msgs) != 1 { + t.Fatalf("want 1 bus message, got %d", len(msgs)) + } + if !contains(msgs[0].To, "codex") { + t.Errorf("want @codex routed from <@UCODEX>, got To=%v", msgs[0].To) + } + if !strings.Contains(msgs[0].Body, "<@UHUMAN>") { + t.Errorf("unmapped mention should survive, got %q", msgs[0].Body) + } +} + +// A second mirror pass must not re-post messages already seen (cursor advances). +func TestMirrorIdempotent(t *testing.T) { + br, b, _, fp := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + mustPost(t, b, "task-1", "codex", "hello") + _ = br.mirrorOnce(time.Now()) + _ = br.mirrorOnce(time.Now()) + if len(fp.posts) != 1 { + t.Fatalf("want 1 post across two passes, got %d", len(fp.posts)) + } +} + +// Impersonation prefers a member's Note as display name. +func TestMirrorIdentityFromRoster(t *testing.T) { + br, b, rs, fp := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + if _, err := rs.Join(model.Member{ID: "codex", Kind: "codex", Note: "Codex (impl)"}); err != nil { + t.Fatal(err) + } + mustPost(t, b, "task-1", "codex", "hello") + _ = br.mirrorOnce(time.Now()) + if fp.posts[0].username != "Codex (impl)" { + t.Errorf("want display name from Note, got %q", fp.posts[0].username) + } + if fp.posts[0].icon != ":gear:" { + t.Errorf("want codex icon, got %q", fp.posts[0].icon) + } +} + +// IN: a top-level Slack message opens a new bus channel from the boss, with the +// @mention parsed so the daemon can route it; the thread is bound so agent +// replies nest under the boss's message. +func TestIngestTopLevel(t *testing.T) { + br, b, _, _ := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + err := br.handleIncoming(incoming{ChannelID: "C1", User: "U1", TS: "1700.5", Text: "@codex fix the parser"}) + if err != nil { + t.Fatal(err) + } + msgs, _ := b.Messages("t-1700.5") + if len(msgs) != 1 { + t.Fatalf("want 1 bus message, got %d", len(msgs)) + } + if msgs[0].From != "fioenix" { + t.Errorf("want From=fioenix, got %q", msgs[0].From) + } + if !contains(msgs[0].To, "codex") { + t.Errorf("want @codex parsed into To, got %v", msgs[0].To) + } + if ts, ok := br.tm.tsFor("t-1700.5"); !ok || ts != "1700.5" { + t.Errorf("boss msg ts should be bound as the thread root, got %q ok=%v", ts, ok) + } +} + +// IN: a Slack reply inside a known thread routes to that thread's bus channel. +func TestIngestThreadReply(t *testing.T) { + br, b, _, _ := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + mustPost(t, b, "task-1", "codex", "hello") + _ = br.mirrorOnce(time.Now()) // binds task-1 → ts-1 + err := br.handleIncoming(incoming{ChannelID: "C1", User: "U1", ThreadTS: "ts-1", TS: "x", Text: "@codex looks good"}) + if err != nil { + t.Fatal(err) + } + msgs, _ := b.Messages("task-1") + if len(msgs) != 2 { + t.Fatalf("boss reply should land in task-1, got %d msgs", len(msgs)) + } + if msgs[1].From != "fioenix" { + t.Errorf("want boss reply, got %q", msgs[1].From) + } +} + +// IN: echoes of our own posts (bot_id), other channels, and system subtypes are +// dropped — this is the OUT→IN loop break. +func TestIngestDrops(t *testing.T) { + br, b, _, _ := newTestBridge(t, Config{Boss: "fioenix", ChannelID: "C1"}) + cases := []incoming{ + {ChannelID: "C1", BotID: "B123", User: "U1", TS: "1", Text: "echo of agent post"}, + {ChannelID: "C-other", User: "U1", TS: "2", Text: "different channel"}, + {ChannelID: "C1", User: "U1", SubType: "channel_join", TS: "3", Text: "joined"}, + {ChannelID: "C1", User: "U1", TS: "4", Text: " "}, + } + for _, in := range cases { + if err := br.handleIncoming(in); err != nil { + t.Fatalf("handleIncoming(%+v): %v", in, err) + } + } + chans, _ := b.Channels() + if len(chans) != 0 { + t.Fatalf("no message should have reached the bus, got channels %v", chans) + } +} + +func mustPost(t *testing.T, b *bus.Bus, channel, from, body string) { + t.Helper() + if _, err := b.Post(bus.Message{Channel: channel, From: from, Body: body}); err != nil { + t.Fatal(err) + } + time.Sleep(time.Millisecond) // ensure monotonic RFC3339Nano timestamps +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/hatch/internal/slack/config.go b/hatch/internal/slack/config.go new file mode 100644 index 0000000..8700083 --- /dev/null +++ b/hatch/internal/slack/config.go @@ -0,0 +1,90 @@ +// Package slack bridges the Hatch chat bus to a real Slack channel. Each agent +// maps to its own Slack app/bot, so it posts and is @mentioned as a real Slack +// principal; a single "hub" app runs Socket Mode (inbound) and is the fallback +// voice for escalations and any agent without its own token. It is a +// presentation/ingress adapter — it never orchestrates work; the boss in Slack +// is just a peer who happens to sit outside the terminal. +package slack + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Config holds the bridge's routing and the boss's own Slack credentials. +// Tokens are loaded from the environment first, then a gitignored +// .hatch/run/slack/config.json. They are never committed. +type Config struct { + AppToken string `json:"app_token"` // xapp- (hub app, Socket Mode / inbound) + HubToken string `json:"hub_token"` // xoxb- (hub app: escalations + impersonation fallback) + ChannelID string `json:"channel_id"` // the #squad channel (C...) + Boss string `json:"boss"` // member id of the human (Kind=user) + BossUserID string `json:"boss_user_id"` // Slack user id (U...) allowed to drive; "" = single-tenant + Agents map[string]string `json:"agents"` // agent id → xoxb- bot token (posts as that agent) +} + +// LoadConfig resolves config from .hatch/run/slack/config.json, then lets the +// environment override scalar fields. Per-agent tokens may also be supplied as +// HATCH_SLACK_TOKEN_<ID> (e.g. HATCH_SLACK_TOKEN_CLAUDE_CODE for "claude-code"). +func LoadConfig(l paths.Layout) (Config, error) { + var c Config + if raw, err := os.ReadFile(l.SlackConfig()); err == nil { + _ = json.Unmarshal(raw, &c) // best-effort: env still overrides below + } + if c.Agents == nil { + c.Agents = map[string]string{} + } + if v := os.Getenv("HATCH_SLACK_APP_TOKEN"); v != "" { + c.AppToken = v + } + if v := os.Getenv("HATCH_SLACK_BOT_TOKEN"); v != "" { + c.HubToken = v + } + if v := os.Getenv("HATCH_SLACK_CHANNEL"); v != "" { + c.ChannelID = v + } + if v := os.Getenv("HATCH_SLACK_BOSS"); v != "" { + c.Boss = v + } + if v := os.Getenv("HATCH_SLACK_BOSS_USER"); v != "" { + c.BossUserID = v + } + for _, kv := range os.Environ() { + k, v, ok := strings.Cut(kv, "=") + if !ok || v == "" || !strings.HasPrefix(k, "HATCH_SLACK_TOKEN_") { + continue + } + id := strings.ToLower(strings.ReplaceAll(strings.TrimPrefix(k, "HATCH_SLACK_TOKEN_"), "_", "-")) + c.Agents[id] = v + } + c.AppToken = strings.TrimSpace(c.AppToken) + c.HubToken = strings.TrimSpace(c.HubToken) + c.ChannelID = strings.TrimSpace(c.ChannelID) + c.Boss = strings.TrimSpace(c.Boss) + c.BossUserID = strings.TrimSpace(c.BossUserID) + return c, c.validate() +} + +func (c Config) validate() error { + var missing []string + if c.AppToken == "" { + missing = append(missing, "hub app token (HATCH_SLACK_APP_TOKEN)") + } + if c.HubToken == "" { + missing = append(missing, "hub bot token (HATCH_SLACK_BOT_TOKEN)") + } + if c.ChannelID == "" { + missing = append(missing, "channel id (HATCH_SLACK_CHANNEL)") + } + if c.Boss == "" { + missing = append(missing, "boss member id (HATCH_SLACK_BOSS)") + } + if len(missing) > 0 { + return fmt.Errorf("slack config incomplete: missing %s", strings.Join(missing, ", ")) + } + return nil +} diff --git a/hatch/internal/slack/identity.go b/hatch/internal/slack/identity.go new file mode 100644 index 0000000..c640994 --- /dev/null +++ b/hatch/internal/slack/identity.go @@ -0,0 +1,39 @@ +package slack + +import "github.com/fioenix/overclaud/hatch/internal/model" + +// identity maps a sender id to how it should appear in Slack: a display name +// and an icon emoji. One bot token posts for everyone; the per-message username +// + icon override is what makes each agent show up as itself. +func identity(r model.Roster, from string) (name, icon string) { + name = from + kind := from // fall back to id when the sender isn't a roster member + if m, ok := r[from]; ok { + if m.Note != "" { + name = m.Note + } + kind = m.Kind + } + return name, iconFor(kind) +} + +// iconFor returns a stable emoji per agent kind so the room is readable at a +// glance. "hatch" is the bridge/daemon voice (escalations). +func iconFor(kind string) string { + switch kind { + case "claude": + return ":robot_face:" + case "codex": + return ":gear:" + case "agy": + return ":sparkles:" + case "kiro": + return ":owl:" + case model.KindUser: + return ":bust_in_silhouette:" + case "hatch": + return ":bell:" + default: + return ":speech_balloon:" + } +} diff --git a/hatch/internal/slack/runtime.go b/hatch/internal/slack/runtime.go new file mode 100644 index 0000000..c5a1507 --- /dev/null +++ b/hatch/internal/slack/runtime.go @@ -0,0 +1,171 @@ +package slack + +import ( + "fmt" + "io" + "os" + "sort" + "time" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/roster" + slackapi "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// Options configures a bridge run. +type Options struct { + Interval time.Duration // OUT mirror poll period + Once bool // mirror current backlog once and exit (no Socket Mode) + DryRun bool // print OUT to Stdout instead of Slack; needs no tokens + Stdout io.Writer +} + +// Run assembles and runs the bridge for a workspace. It keeps all slack-go +// dependencies inside this package so the rest of the binary stays Slack-free. +// In the default mode it blocks on the hub app's Socket Mode connection (IN) +// while a ticker mirrors the bus (OUT); --once and --dry-run are non-blocking. +func Run(l paths.Layout, o Options) error { + if o.Stdout == nil { + o.Stdout = os.Stdout + } + b := bus.New(l) + rs := roster.New(l) + tm := loadThreadmap(l) + + if o.DryRun { + cfg := Config{Boss: os.Getenv("HATCH_SLACK_BOSS"), ChannelID: os.Getenv("HATCH_SLACK_CHANNEL")} + br := NewBridge(b, rs, cfg, &dryPoster{w: o.Stdout}, newMemThreadmap(), nil) + fmt.Fprintln(o.Stdout, "— slack bridge dry-run: mirroring current bus backlog —") + return br.mirrorOnce(time.Now()) + } + + cfg, err := LoadConfig(l) + if err != nil { + return err + } + + hub := slackapi.New(cfg.HubToken, slackapi.OptionAppLevelToken(cfg.AppToken)) + mp := &multiPoster{channel: cfg.ChannelID, hub: hub, agents: map[string]*slackapi.Client{}} + mentions := map[string]string{} // slack bot user-id → agent id + for _, id := range sortedKeys(cfg.Agents) { + cl := slackapi.New(cfg.Agents[id]) + mp.agents[id] = cl + if at, aerr := cl.AuthTest(); aerr == nil && at.UserID != "" { + mentions[at.UserID] = id + fmt.Fprintf(o.Stdout, "slack: %s → bot %s (%s)\n", id, at.User, at.UserID) + } else if aerr != nil { + fmt.Fprintf(o.Stdout, "slack: agent %s auth.test failed (will impersonate via hub): %v\n", id, aerr) + } + } + br := NewBridge(b, rs, cfg, mp, tm, mentions) + + if o.Once { + return br.mirrorOnce(time.Now()) + } + + sm := socketmode.New(hub) + go consume(sm, br) + go func() { + tk := time.NewTicker(o.Interval) + defer tk.Stop() + for range tk.C { + if merr := br.mirrorOnce(time.Now()); merr != nil { + fmt.Fprintf(o.Stdout, "slack mirror error: %v\n", merr) + } + } + }() + fmt.Fprintf(o.Stdout, "— slack bridge live on channel %s (boss=%s, %d agent bots) —\n", + cfg.ChannelID, cfg.Boss, len(mp.agents)) + return sm.Run() +} + +// consume reads Socket Mode events, acks them, and feeds genuine messages to +// the IN path. Non-message events are acked and ignored. +func consume(sm *socketmode.Client, br *Bridge) { + for evt := range sm.Events { + if evt.Request != nil { + _ = sm.Ack(*evt.Request) + } + if evt.Type != socketmode.EventTypeEventsAPI { + continue + } + api, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok || api.Type != slackevents.CallbackEvent { + continue + } + me, ok := api.InnerEvent.Data.(*slackevents.MessageEvent) + if !ok { + continue + } + _ = br.handleIncoming(incoming{ + ChannelID: me.Channel, + User: me.User, + BotID: me.BotID, + SubType: me.SubType, + ThreadTS: me.ThreadTimeStamp, + TS: me.TimeStamp, + Text: me.Text, + }) + } +} + +// multiPoster posts as each agent's own Slack bot. An agent with a configured +// client posts under its real identity; anyone else (e.g. the "hatch" voice for +// escalations, or an agent without a token) falls back to the hub bot with a +// username/icon override. +type multiPoster struct { + channel string + hub *slackapi.Client + agents map[string]*slackapi.Client +} + +func (p *multiPoster) post(from, threadTS, displayName, icon, text string) (string, error) { + if cl, ok := p.agents[from]; ok { + return sendMsg(cl, p.channel, threadTS, "", "", text) // real bot: its own name/avatar + } + return sendMsg(p.hub, p.channel, threadTS, displayName, icon, text) // impersonation fallback +} + +// sendMsg posts text to a channel, optionally as a thread reply and optionally +// impersonating a username+icon (only when username is non-empty). +func sendMsg(cl *slackapi.Client, channel, threadTS, username, icon, text string) (string, error) { + opts := []slackapi.MsgOption{slackapi.MsgOptionText(text, false)} + if username != "" { + opts = append(opts, slackapi.MsgOptionUsername(username), slackapi.MsgOptionIconEmoji(icon), slackapi.MsgOptionAsUser(false)) + } + if threadTS != "" { + opts = append(opts, slackapi.MsgOptionTS(threadTS)) + } + _, ts, err := cl.PostMessage(channel, opts...) + return ts, err +} + +// dryPoster prints what would be sent, so the OUT path can be smoke-tested +// without any Slack credentials. It fabricates monotonic ts values so the +// threadmap behaves as it would live. +type dryPoster struct { + w io.Writer + n int +} + +func (p *dryPoster) post(from, threadTS, displayName, icon, text string) (string, error) { + where := "(new thread)" + if threadTS != "" { + where = "↳ " + threadTS + } + fmt.Fprintf(p.w, " %s %-12s %s\n %s\n", icon, from, where, text) + p.n++ + return fmt.Sprintf("dry-%d", p.n), nil +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/hatch/internal/slack/threadmap.go b/hatch/internal/slack/threadmap.go new file mode 100644 index 0000000..93a2728 --- /dev/null +++ b/hatch/internal/slack/threadmap.go @@ -0,0 +1,110 @@ +package slack + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// threadmap remembers which Slack thread (thread_ts) carries each bus channel, +// so a channel's messages collapse into one Slack thread (1 task = 1 thread), +// plus the mirror cursor (last bus TS mirrored) so a bridge restart does not +// re-post history. It is shared by the inbound (Socket Mode) and outbound +// (ticker) goroutines, so every access is guarded by mu. +type threadmap struct { + path string + mu sync.Mutex + fwd map[string]string // bus channel → slack thread_ts + rev map[string]string // slack thread_ts → bus channel + cursor string // RFC3339Nano of the last bus message mirrored out +} + +// persisted is the on-disk shape of a threadmap. +type persisted struct { + Cursor string `json:"cursor,omitempty"` + Threads map[string]string `json:"threads"` +} + +func loadThreadmap(l paths.Layout) *threadmap { + tm := &threadmap{path: l.SlackThreadmap(), fwd: map[string]string{}, rev: map[string]string{}} + if raw, err := os.ReadFile(tm.path); err == nil { + var p persisted + if json.Unmarshal(raw, &p) == nil { + tm.cursor = p.Cursor + for ch, ts := range p.Threads { + tm.fwd[ch] = ts + tm.rev[ts] = ch + } + } + } + return tm +} + +// newMemThreadmap returns a throwaway, in-memory map (no persistence) — used by +// dry-run so it never touches the real threadmap.json. +func newMemThreadmap() *threadmap { + return &threadmap{fwd: map[string]string{}, rev: map[string]string{}} +} + +func (t *threadmap) tsFor(channel string) (string, bool) { + t.mu.Lock() + defer t.mu.Unlock() + ts, ok := t.fwd[channel] + return ts, ok +} + +func (t *threadmap) channelFor(ts string) (string, bool) { + t.mu.Lock() + defer t.mu.Unlock() + ch, ok := t.rev[ts] + return ch, ok +} + +func (t *threadmap) getCursor() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.cursor +} + +// bind records channel↔ts and persists. +func (t *threadmap) bind(channel, ts string) error { + if ts == "" { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + t.fwd[channel] = ts + t.rev[ts] = channel + return t.save() +} + +// setCursor records the mirror high-water mark and persists. +func (t *threadmap) setCursor(ts string) error { + t.mu.Lock() + defer t.mu.Unlock() + t.cursor = ts + return t.save() +} + +// save writes the whole map atomically. Caller holds mu. In-memory maps +// (path == "", dry-run) never touch disk. +func (t *threadmap) save() error { + if t.path == "" { + return nil + } + raw, err := json.MarshalIndent(persisted{Cursor: t.cursor, Threads: t.fwd}, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(t.path), 0o755); err != nil { + return err + } + tmp := t.path + ".tmp" + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, t.path) +} diff --git a/hatch/internal/store/board.go b/hatch/internal/store/board.go deleted file mode 100644 index 23a8f78..0000000 --- a/hatch/internal/store/board.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package store implements the filesystem-as-database: reading and writing -// tickets, ledger entries and KB notes under a .hatch/ workspace. -package store - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/mdfront" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/paths" -) - -// Board reads and writes tickets within the board directory. -type Board struct{ L paths.Layout } - -// NewBoard returns a board bound to a workspace layout. -func NewBoard(l paths.Layout) *Board { return &Board{L: l} } - -// readTicket parses a single ticket file and tags it with its lane. -func readTicket(path, lane string) (model.Ticket, error) { - raw, err := os.ReadFile(path) - if err != nil { - return model.Ticket{}, err - } - var t model.Ticket - body, err := mdfront.Decode(raw, &t) - if err != nil { - return model.Ticket{}, fmt.Errorf("%s: %w", path, err) - } - t.Body = body - t.Lane = lane - return t, nil -} - -// ListLane returns tickets in a single lane, sorted by id. -func (b *Board) ListLane(lane string) ([]model.Ticket, error) { - dir := b.L.Lane(lane) - ents, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var out []model.Ticket - for _, e := range ents { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { - continue - } - t, err := readTicket(filepath.Join(dir, e.Name()), lane) - if err != nil { - return nil, err - } - out = append(out, t) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -// List returns all tickets across the given lanes, sorted by id. -func (b *Board) List(lanes []string) ([]model.Ticket, error) { - var all []model.Ticket - for _, lane := range lanes { - ts, err := b.ListLane(lane) - if err != nil { - return nil, err - } - all = append(all, ts...) - } - sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) - return all, nil -} - -// Find locates a ticket by id across lanes, returning its current lane. -func (b *Board) Find(id string, lanes []string) (model.Ticket, bool, error) { - for _, lane := range lanes { - ts, err := b.ListLane(lane) - if err != nil { - return model.Ticket{}, false, err - } - for _, t := range ts { - if t.ID == id { - return t, true, nil - } - } - } - return model.Ticket{}, false, nil -} - -// Path returns the on-disk path a ticket occupies given its current lane. -func (b *Board) Path(t model.Ticket) string { - return filepath.Join(b.L.Lane(t.Lane), t.Filename()) -} - -// Write serializes a ticket into its lane directory. -func (b *Board) Write(t model.Ticket) (string, error) { - dir := b.L.Lane(t.Lane) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - out, err := mdfront.Encode(t, t.Body) - if err != nil { - return "", err - } - p := filepath.Join(dir, t.Filename()) - if err := os.WriteFile(p, out, 0o644); err != nil { - return "", err - } - return p, nil -} - -// NextID returns the next sequential ticket id (T-NNN) across all lanes. -func (b *Board) NextID(lanes []string) (string, error) { - max := 0 - for _, lane := range lanes { - ts, err := b.ListLane(lane) - if err != nil { - return "", err - } - for _, t := range ts { - var n int - if _, err := fmt.Sscanf(t.ID, "T-%d", &n); err == nil && n > max { - max = n - } - } - } - return fmt.Sprintf("T-%03d", max+1), nil -} diff --git a/hatch/internal/store/port_assert.go b/hatch/internal/store/port_assert.go index 29ae4c0..034a880 100644 --- a/hatch/internal/store/port_assert.go +++ b/hatch/internal/store/port_assert.go @@ -5,7 +5,6 @@ import "github.com/fioenix/overclaud/hatch/internal/port" // Compile-time guarantees that the filesystem store satisfies the use-case // ports it is wired into. var ( - _ port.Board = (*Board)(nil) _ port.Ledger = (*Ledger)(nil) _ port.KB = (*KB)(nil) ) diff --git a/hatch/internal/tui/chat.go b/hatch/internal/tui/chat.go deleted file mode 100644 index feddd7d..0000000 --- a/hatch/internal/tui/chat.go +++ /dev/null @@ -1,310 +0,0 @@ -package tui - -import ( - "bufio" - "fmt" - "hash/fnv" - "os" - "sort" - "strings" - "time" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" -) - -// senderPalette colours messages by author so a thread is easy to scan. -var senderPalette = []string{"#7c3aed", "#0ea5e9", "#16a34a", "#d97706", "#db2777", "#0891b2"} - -func senderStyle(name string) lipgloss.Style { - h := fnv.New32a() - _, _ = h.Write([]byte(name)) - c := senderPalette[int(h.Sum32())%len(senderPalette)] - return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(c)) -} - -type chat struct { - ws *config.Workspace - bus *bus.Bus - channels []string - counts map[string]int - sel int - msgs viewport.Model - md *glamour.TermRenderer // markdown renderer for message bodies - follow bool // tail: stick to newest message - showHelp bool - w, h int - ready bool -} - -// NewChat returns a read-only, live Slack-style TUI for watching the squad's -// shared chat. Agents post through the Hatch MCP server; this only observes. -func NewChat(ws *config.Workspace) *tea.Program { - c := &chat{ws: ws, bus: bus.New(ws.Layout), follow: true, counts: map[string]int{}} - return tea.NewProgram(c, tea.WithAltScreen()) -} - -func (c *chat) Init() tea.Cmd { return tick() } - -func (c *chat) reload() { - chs, _ := c.bus.Channels() - sort.Strings(chs) - c.channels = chs - for _, ch := range chs { - if m, err := c.bus.Messages(ch); err == nil { - c.counts[ch] = len(m) - } - } - if c.sel >= len(chs) { - c.sel = max(0, len(chs)-1) - } - off := c.msgs.YOffset - c.msgs.SetContent(c.renderMessages()) - if c.follow { - c.msgs.GotoBottom() - } else { - c.msgs.SetYOffset(off) // preserve scroll position when paused - } -} - -func (c *chat) current() string { - if c.sel >= 0 && c.sel < len(c.channels) { - return c.channels[c.sel] - } - return "" -} - -func (c *chat) renderMessages() string { - ch := c.current() - if ch == "" { - return dim.Render("(chưa có thread — agent mở task qua Hatch MCP `chat_open`)") - } - msgs, err := c.bus.Messages(ch) - if err != nil || len(msgs) == 0 { - return dim.Render("(thread trống)") - } - rule := dim.Render(strings.Repeat("─", max(8, c.msgWidth()-4))) - var b strings.Builder - for i, m := range msgs { - ts := m.TS - if t, e := time.Parse(time.RFC3339Nano, m.TS); e == nil { - ts = t.Format("15:04") - } - head := senderStyle(m.From).Render(m.From) + " " + dim.Render(ts) - if m.Type != bus.TypeMsg { - head += " " + laneSty.Render(m.Type) - } - if len(m.To) > 0 { - head += dim.Render(" → " + strings.Join(m.To, ", ")) - } - b.WriteString(head + "\n") - b.WriteString(c.renderBody(m.Body)) - if i < len(msgs)-1 { - b.WriteString("\n" + rule + "\n") - } - } - return b.String() -} - -func (c *chat) msgWidth() int { return max(20, c.w-c.chanWidth()-4) } - -func (c *chat) chanWidth() int { - w := c.w / 4 - if w < 16 { - w = 16 - } - if w > 32 { - w = 32 - } - return w -} - -func (c *chat) layout() { - c.msgs = viewport.New(c.msgWidth(), max(3, c.h-5)) - // Markdown renderer sized to the message pane — renders code blocks, - // headings, bold, lists like a real chat client. - if r, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(max(20, c.msgWidth()-4)), - ); err == nil { - c.md = r - } - c.ready = true -} - -// renderBody renders a message body as markdown, falling back to plain text. -func (c *chat) renderBody(body string) string { - body = strings.TrimRight(body, "\n") - if c.md == nil { - return body - } - out, err := c.md.Render(body) - if err != nil { - return body - } - return strings.Trim(out, "\n") -} - -// mcpTraceCounts returns total tool-calls and failed calls from the MCP log. -func mcpTraceCounts(path string) (calls, errs int) { - f, err := os.Open(path) - if err != nil { - return 0, 0 - } - defer f.Close() - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - line := sc.Bytes() - if len(line) == 0 { - continue - } - calls++ - if strings.Contains(string(line), `"ok":false`) { - errs++ - } - } - return calls, errs -} - -func (c *chat) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - c.w, c.h = msg.Width, msg.Height - c.layout() - c.reload() - case tickMsg: - if c.ready { - c.reload() - } - return c, tick() - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - return c, tea.Quit - case "?": - c.showHelp = !c.showHelp - case "]", "tab", "right", "l": // next thread - if c.sel < len(c.channels)-1 { - c.sel++ - c.follow = true - c.reload() - } - case "[", "shift+tab", "left", "h": // previous thread - if c.sel > 0 { - c.sel-- - c.follow = true - c.reload() - } - case "up", "k": - c.msgs.LineUp(1) - c.follow = c.msgs.AtBottom() - case "down", "j": - c.msgs.LineDown(1) - c.follow = c.msgs.AtBottom() - case "pgup", "ctrl+u", "b": - c.msgs.HalfViewUp() - c.follow = false - case "pgdown", "ctrl+d", " ": - c.msgs.HalfViewDown() - c.follow = c.msgs.AtBottom() - case "g", "home": - c.msgs.GotoTop() - c.follow = false - case "G", "end": - c.msgs.GotoBottom() - c.follow = true - case "f": // toggle live tail - c.follow = !c.follow - if c.follow { - c.msgs.GotoBottom() - } - } - } - return c, nil -} - -func (c *chat) View() string { - if !c.ready { - return "loading…" - } - if c.showHelp { - return c.helpView() - } - project := c.ws.Registry.Project - if project == "" { - project = "Hatch" - } - live := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#16a34a")).Render("● LIVE") - if !c.follow { - live = dim.Render("⏸ PAUSED") - } - header := hdr.Render(project+" — chat") + " " + live + " " + dim.Render("(read-only · ? help)") - - var cl strings.Builder - nameW := max(6, c.chanWidth()-7) // room for "▸ " + " (N)" inside the box - for i, ch := range c.channels { - cnt := dim.Render(fmt.Sprintf(" (%d)", c.counts[ch])) - name := trunc(ch, nameW) - if i == c.sel { - cl.WriteString(selSty.Render("▸ "+name) + cnt + "\n") - } else { - cl.WriteString(" " + name + cnt + "\n") - } - } - if len(c.channels) == 0 { - cl.WriteString(dim.Render(" (chưa có thread)")) - } - chanBox := paneBox(false, "THREADS", cl.String(), c.chanWidth(), c.h-5) - title := "—" - if c.current() != "" { - title = "#" + c.current() - } - msgBox := paneBox(true, title, c.msgs.View(), c.msgWidth(), c.h-5) - body := lipgloss.JoinHorizontal(lipgloss.Top, chanBox, msgBox) - - stats := laneSty.Render("◆ ") + dim.Render(c.statsLine()) - keys := dim.Render("[ ] thread · ↑↓ scroll · G newest · f follow · ? help · q quit") - return header + "\n" + body + "\n" + stats + "\n" + keys -} - -// statsLine summarises the squad (the numbers the old board showed): threads, -// total messages, roster size, and MCP tool-call activity from the trace log. -func (c *chat) statsLine() string { - msgs := 0 - for _, ch := range c.channels { - msgs += c.counts[ch] - } - calls, errs := mcpTraceCounts(c.ws.Layout.MCPLog()) - s := fmt.Sprintf("%d threads · %d msgs · %d agents · %d MCP calls", - len(c.channels), msgs, len(c.ws.Registry.Agents), calls) - if errs > 0 { - s += fmt.Sprintf(" (%d err)", errs) - } - return s -} - -func (c *chat) helpView() string { - rows := [][2]string{ - {"[ ] ← → h l", "chuyển thread (channel)"}, - {"↑ ↓ k j", "cuộn tin nhắn (1 dòng)"}, - {"PgUp/PgDn b/space ^u/^d", "cuộn nửa trang"}, - {"g / G (Home/End)", "lên đầu / xuống tin mới nhất"}, - {"f", "bật/tắt LIVE follow (tự bám tin mới)"}, - {"?", "đóng/mở trợ giúp này"}, - {"q esc ^c", "thoát"}, - } - var b strings.Builder - b.WriteString(hdr.Render("Hatch chat — phím tắt") + "\n\n") - for _, r := range rows { - b.WriteString(" " + selSty.Render(fmt.Sprintf("%-26s", r[0])) + dim.Render(r[1]) + "\n") - } - b.WriteString("\n" + dim.Render("Live: tự refresh mỗi giây. Cuộn lên = tạm dừng bám (PAUSED); G hoặc f để bám lại.")) - b.WriteString("\n" + dim.Render("Read-only — agent post qua Hatch MCP (chat_open/chat_post).")) - return focused.Width(max(40, c.w-4)).Render(b.String()) -} diff --git a/hatch/internal/tui/chat_test.go b/hatch/internal/tui/chat_test.go deleted file mode 100644 index 21ca8a5..0000000 --- a/hatch/internal/tui/chat_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/config" -) - -func TestRenderBodyMarkdown(t *testing.T) { - c := &chat{ws: &config.Workspace{}} - c.w, c.h = 100, 40 - c.layout() // builds the glamour renderer - if c.md == nil { - t.Fatal("markdown renderer not built") - } - body := "## Heading\n\n```go\nfunc Reverse(s string) string { return s }\n```\n\n- **bold** item" - out := c.renderBody(body) - // Markdown must be RENDERED, not shown raw: the ``` fence + ## must be gone. - if strings.Contains(out, "```") { - t.Errorf("code fence not rendered (raw markdown leaked):\n%s", out) - } - if strings.Contains(out, "## Heading") { - t.Errorf("heading not rendered (raw markdown leaked):\n%s", out) - } - // Code content should survive. - if !strings.Contains(out, "Reverse") { - t.Errorf("code content missing:\n%s", out) - } -} - -func TestTrunc(t *testing.T) { - if got := trunc("dogfood-verify-agy-mcp-path", 10); len([]rune(got)) != 10 { - t.Errorf("trunc len = %d, want 10 (%q)", len([]rune(got)), got) - } - if got := trunc("short", 10); got != "short" { - t.Errorf("trunc shortened a fitting string: %q", got) - } -} diff --git a/hatch/internal/tui/shared.go b/hatch/internal/tui/shared.go deleted file mode 100644 index 74d50fb..0000000 --- a/hatch/internal/tui/shared.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package tui renders read-only dashboards with Bubble Tea. The single view is -// `hatch chat` (chat.go): a live, Slack-style view of the squad's shared chat -// with squad stats in the footer. `hatch board` is an alias for it. It only -// observes — agents act through the Hatch MCP server. -package tui - -import ( - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var ( - hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#2563eb")) - dim = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - selSty = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7c3aed")) - laneSty = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0ea5e9")) - focused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#7c3aed")) - blurred = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")) -) - -type tickMsg time.Time - -func tick() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -// trunc shortens s to n runes with an ellipsis. -func trunc(s string, n int) string { - r := []rune(s) - if len(r) <= n { - return s - } - if n <= 1 { - return "…" - } - return string(r[:n-1]) + "…" -} - -// paneBox draws a titled, bordered pane sized to w×h (0 = auto). -func paneBox(focus bool, title, content string, w, h int) string { - style := blurred - if focus { - style = focused - } - if w > 0 { - style = style.Width(w) - } - if h > 0 { - style = style.Height(h) - } - return style.Render(laneSty.Render(title) + "\n" + content) -} diff --git a/hatch/internal/validate/board.go b/hatch/internal/validate/board.go deleted file mode 100644 index 5b71ba0..0000000 --- a/hatch/internal/validate/board.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package validate holds board-level checks that span tickets, registry and -// workflow (config-only checks live in the config package). -package validate - -import ( - "fmt" - "regexp" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -// safeID is the allowed shape of a ticket id (also keeps it safe as a path -// segment for branches, worktrees and run transcripts). -var safeID = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) - -// Board validates every ticket against the workflow lanes and registry roster. -func Board(ws *config.Workspace, b *store.Board) []config.Problem { - var probs []config.Problem - add := func(src, m string) { probs = append(probs, config.Problem{Source: src, Msg: m}) } - - tickets, err := b.List(ws.Workflow.LaneIDs()) - if err != nil { - add("board", err.Error()) - return probs - } - - seen := map[string]string{} // id → lane - for _, t := range tickets { - src := fmt.Sprintf("board/%s/%s", t.Lane, t.Filename()) - if t.ID == "" { - add(src, "ticket missing id") - continue - } - if !safeID.MatchString(t.ID) { - add(src, fmt.Sprintf("unsafe ticket id %q (allowed: letters, digits, . _ -)", t.ID)) - } - if prev, ok := seen[t.ID]; ok { - add(src, fmt.Sprintf("duplicate ticket id %q (also in %s)", t.ID, prev)) - } - seen[t.ID] = t.Lane - - // status must agree with the lane the file lives in. - if t.Status != "" && t.Status != t.Lane { - add(src, fmt.Sprintf("status %q disagrees with lane %q", t.Status, t.Lane)) - } - // role must exist in the registry. - if t.Role != "" { - if _, ok := ws.Registry.RoleByID(t.Role); !ok { - add(src, fmt.Sprintf("unknown role %q", t.Role)) - } - } - // a claiming agent must be allowed to hold the ticket's role. - if t.Claim != nil && t.Claim.Agent != "" { - a, ok := ws.Registry.AgentByID(t.Claim.Agent) - if !ok { - add(src, fmt.Sprintf("claim by unknown agent %q", t.Claim.Agent)) - } else if t.Role != "" && !hasRole(a.Roles, t.Role) { - add(src, fmt.Sprintf("agent %q may not hold role %q", a.ID, t.Role)) - } - } - // dependencies must reference real tickets. - for _, dep := range t.DependsOn { - if _, ok := seen[dep]; !ok { - // dep may appear later; do a second-pass check below. - _ = ok - } - } - } - - // second pass: dependency existence (now that all ids are known). - for _, t := range tickets { - for _, dep := range t.DependsOn { - if _, ok := seen[dep]; !ok { - add(fmt.Sprintf("board/%s/%s", t.Lane, t.Filename()), - fmt.Sprintf("depends_on unknown ticket %q", dep)) - } - } - } - return probs -} - -func hasRole(roles []string, r string) bool { - for _, x := range roles { - if x == r { - return true - } - } - return false -} diff --git a/hatch/internal/validate/board_test.go b/hatch/internal/validate/board_test.go deleted file mode 100644 index 1e50b74..0000000 --- a/hatch/internal/validate/board_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package validate - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func TestBoardFlagsUnsafeTicketID(t *testing.T) { - dir := t.TempDir() - l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - // A crafted ticket file: safe filename, but a traversal id in frontmatter. - raw := "---\nid: \"../evil\"\nstatus: backlog\nrole: implementer\n---\nx\n" - if err := os.WriteFile(filepath.Join(l.Lane("backlog"), "weird.md"), []byte(raw), 0o644); err != nil { - t.Fatal(err) - } - var joined string - for _, p := range Board(ws, store.NewBoard(l)) { - joined += p.String() + "\n" - } - if !strings.Contains(joined, "unsafe ticket id") { - t.Fatalf("expected unsafe-id problem, got:\n%s", joined) - } -} diff --git a/hatch/internal/wake/wake.go b/hatch/internal/wake/wake.go new file mode 100644 index 0000000..30aa282 --- /dev/null +++ b/hatch/internal/wake/wake.go @@ -0,0 +1,340 @@ +// Package wake is the delivery layer's decision core: given the room roster, a +// batch of new chat messages, and the current delivery state, it decides which +// teammates to wake, which wakes to defer, and when to escalate to the boss. +// +// It is a pure function with no IO — the daemon (an adapter) tails the bus and +// spawns agents around it. Keeping the policy pure makes the seven coordination +// rules (see Decide) table-testable, which is where the real complexity lives. +// +// Hard invariant: a wake is ALWAYS the consequence of a message someone sent. +// This package never invents work; it only routes and paces conversation. +package wake + +import ( + "sort" + "strings" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// Config tunes the delivery policy. Zero values fall back to sane defaults via +// withDefaults, so callers can pass Config{} for the standard behaviour. +type Config struct { + Depth int // max auto-wake cascade depth per episode (Rule 5) + RateCap int // max wakes per member per RateWindow (Rule 6) + RateWindow time.Duration // sliding window for the rate cap + LoopRounds int // max agent↔agent ping-pong rounds without progress (Rule 7) +} + +func (c Config) withDefaults() Config { + if c.Depth <= 0 { + c.Depth = 4 + } + if c.RateCap <= 0 { + c.RateCap = 6 + } + if c.RateWindow <= 0 { + c.RateWindow = time.Minute + } + if c.LoopRounds <= 0 { + c.LoopRounds = 3 + } + return c +} + +// State is the delivery layer's memory between batches. The daemon persists and +// reloads it; Decide treats it as immutable input and returns the next State. +type State struct { + Working map[string]bool // members mid-turn (Rule 3 debounce) + Rate map[string][]time.Time // recent delivered wakes per member (Rule 6) + Depth map[string]int // cascade depth per episode (Rule 5) + PingPong map[string]int // rounds per "episode|a|b" (Rule 7) + LastTrig map[string]string // last wake-causing sender per episode (Rule 7) + OpenAsks map[string]string // open ask message id → owner member id (Rule 1b) + Progress map[string]bool // episodes that saw a decision/deliverable this run +} + +func (s State) clone() State { + n := State{ + Working: map[string]bool{}, + Rate: map[string][]time.Time{}, + Depth: map[string]int{}, + PingPong: map[string]int{}, + LastTrig: map[string]string{}, + OpenAsks: map[string]string{}, + Progress: map[string]bool{}, + } + for k, v := range s.Working { + n.Working[k] = v + } + for k, v := range s.Rate { + n.Rate[k] = append([]time.Time(nil), v...) + } + for k, v := range s.Depth { + n.Depth[k] = v + } + for k, v := range s.PingPong { + n.PingPong[k] = v + } + for k, v := range s.LastTrig { + n.LastTrig[k] = v + } + for k, v := range s.OpenAsks { + n.OpenAsks[k] = v + } + for k, v := range s.Progress { + n.Progress[k] = v + } + return n +} + +// Decide applies the seven delivery rules to a batch of new messages. +// +// 1. TRIGGER — only an @mention/recipient, a reply to the member's open ask, +// or a DM wakes a member. An unaddressed message wakes no one. +// 2. COALESCE — several triggering messages for one member merge into one wake. +// 3. DEBOUNCE — a member mid-turn is not woken; its wake is held (HoldWorking). +// 4. NO SELF/ECHO — a member never wakes itself; replies don't re-wake the last +// speaker unless it is explicitly addressed (falls out of Rule 1). +// 5. DEPTH — auto-wake cascades from agents are capped per episode; over the +// cap escalates instead of waking. A boss message resets the episode depth. +// 6. RATE — per-member wake cap over a window; excess is held (HoldRate). +// 7. LOOP — two agents ping-ponging without progress escalate to the boss. +// +// now is injected for deterministic tests. The returned State must be fed back +// into the next call. +func Decide(roster model.Roster, msgs []model.Message, st State, cfg Config, now time.Time) (decisions []model.WakeDecision, escalations []model.Escalation, next State) { + cfg = cfg.withDefaults() + next = st.clone() + + wakes := map[string]*model.WakeDecision{} + escSeen := map[string]bool{} // dedupe escalations by episode|cause + + addEsc := func(episode, cause, note string) { + key := episode + "|" + cause + if escSeen[key] { + return + } + escSeen[key] = true + escalations = append(escalations, model.Escalation{Episode: episode, Cause: cause, To: bossID(roster), Note: note}) + } + + for _, m := range msgs { + e := episode(m) + fromHuman := roster.IsHuman(m.From) + + // A boss message starts a fresh episode: reset cascade + loop memory. + if fromHuman { + next.Depth[e] = 0 + resetEpisodeLoops(&next, e) + } + // A recorded decision is progress: it clears the loop counter so a + // resolved-then-resumed thread is not mistaken for a stuck loop. + if m.Type == model.MsgDecision { + next.Progress[e] = true + resetEpisodeLoops(&next, e) + } + + targets := recipients(roster, next, m) + if len(targets) == 0 { + continue // Rule 1 / Rule 4: nothing addressed → no wake. + } + + // Rule 5: depth applies only to cascades originating from an agent. + prospectiveDepth := next.Depth[e] + if !fromHuman { + prospectiveDepth = next.Depth[e] + 1 + } + + for _, t := range sortedKeys(targets) { + a := t + reason := targets[a] + if a == m.From || !roster.Reachable(a) { + continue // Rule 4 + presence guard. + } + + if !fromHuman && prospectiveDepth > cfg.Depth { + addEsc(e, model.EscalateDepthLimit, "cascade depth exceeded; boss decision needed") + continue + } + + // Rule 7: ping-pong between two agents without progress. + if !fromHuman { + key := pairKey(e, m.From, a) + if next.LastTrig[e] != "" && next.LastTrig[e] != m.From { + next.PingPong[key]++ + } + if next.PingPong[key] >= cfg.LoopRounds && !next.Progress[e] { + addEsc(e, model.EscalateLoopBreak, "ping-pong without progress; boss decision needed") + continue + } + } + + hold := model.HoldNone + switch { + case st.Working[a]: + hold = model.HoldWorking // Rule 3 + case rateExceeded(next.Rate[a], cfg, now): + hold = model.HoldRate // Rule 6 + } + + // Rule 2: coalesce per (member, thread) so each task thread is woken + // with its own session context, never merged across threads. + ck := agentThreadKey(a, m.Channel) + d, ok := wakes[ck] + if !ok { + d = &model.WakeDecision{Agent: a, Thread: m.Channel, Reason: reason} + wakes[ck] = d + } + if reason == model.WakeReplyAsk { + d.Reason = reason // a direct answer to my question outranks a generic mention + } + d.Payload = append(d.Payload, m) + // The strongest hold wins (working beats rate beats none). + if holdRank(hold) > holdRank(d.Hold) { + d.Hold = hold + } + + // Record a delivered (non-held) wake for depth/rate/loop accounting. + if hold == model.HoldNone { + next.Rate[a] = append(prune(next.Rate[a], cfg, now), now) + if !fromHuman && prospectiveDepth > next.Depth[e] { + next.Depth[e] = prospectiveDepth + } + if !fromHuman { + next.LastTrig[e] = m.From + } + } + } + } + + for _, a := range sortedDecisionKeys(wakes) { + decisions = append(decisions, *wakes[a]) + } + return decisions, escalations, next +} + +// episode is the thread-root id a message belongs to: its reply target if it is +// a reply, else the message itself. +func episode(m model.Message) string { + if m.InReplyTo != "" { + return m.InReplyTo + } + return m.ID +} + +// recipients resolves who a message addresses, mapping each to a wake reason. +// m.To already includes @mentions (the bus merges them at post time), plus +// explicit recipients, roles, and broadcast tokens. +func recipients(r model.Roster, st State, m model.Message) map[string]model.WakeReason { + out := map[string]model.WakeReason{} + dm := strings.HasPrefix(m.Channel, "dm-") + for _, to := range m.To { + to = strings.TrimPrefix(strings.TrimSpace(to), "@") + switch to { + case "", m.From: + continue + case "*", "all": + for id := range r { + if r.Reachable(id) && id != m.From { + out[id] = model.WakeMention + } + } + default: + if r.Reachable(to) { + if dm { + out[to] = model.WakeDM + } else { + out[to] = model.WakeMention + } + continue + } + // Not a member id → treat as a role and fan out to its holders. + for _, id := range r.WithRole(to) { + if id != m.From { + out[id] = model.WakeMention + } + } + } + } + // Rule 1b: a reply landing on someone's open ask wakes that owner. + if m.InReplyTo != "" { + if owner, ok := st.OpenAsks[m.InReplyTo]; ok && owner != m.From && r.Reachable(owner) { + out[owner] = model.WakeReplyAsk + } + } + return out +} + +func rateExceeded(stamps []time.Time, cfg Config, now time.Time) bool { + return len(prune(stamps, cfg, now)) >= cfg.RateCap +} + +func prune(stamps []time.Time, cfg Config, now time.Time) []time.Time { + cut := now.Add(-cfg.RateWindow) + out := stamps[:0:0] + for _, t := range stamps { + if t.After(cut) { + out = append(out, t) + } + } + return out +} + +func resetEpisodeLoops(st *State, e string) { + for k := range st.PingPong { + if strings.HasPrefix(k, e+"|") { + delete(st.PingPong, k) + } + } + delete(st.LastTrig, e) +} + +// agentThreadKey is the coalescing key: one wake per (member, bus thread). +func agentThreadKey(a, thread string) string { return a + "\x00" + thread } + +func pairKey(episode, a, b string) string { + if a > b { + a, b = b, a + } + return episode + "|" + a + "|" + b +} + +func bossID(r model.Roster) string { + for id, m := range r { + if m.Kind == model.KindUser { + return id + } + } + return "user" +} + +func holdRank(h model.HoldReason) int { + switch h { + case model.HoldWorking: + return 2 + case model.HoldRate: + return 1 + default: + return 0 + } +} + +func sortedKeys(m map[string]model.WakeReason) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedDecisionKeys(m map[string]*model.WakeDecision) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/hatch/internal/wake/wake_test.go b/hatch/internal/wake/wake_test.go new file mode 100644 index 0000000..e94269b --- /dev/null +++ b/hatch/internal/wake/wake_test.go @@ -0,0 +1,183 @@ +package wake + +import ( + "testing" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +func room() model.Roster { + return model.Roster{ + "boss": {ID: "boss", Kind: model.KindUser, Status: model.MemberOnline}, + "claude": {ID: "claude", Kind: "claude", Roles: []string{"reviewer"}, Status: model.MemberOnline}, + "codex": {ID: "codex", Kind: "codex", Roles: []string{"implementer"}, Status: model.MemberSuspended}, + "agy": {ID: "agy", Kind: "agy", Roles: []string{"implementer"}, Status: model.MemberSuspended}, + } +} + +func freshState() State { + return State{ + Working: map[string]bool{}, Rate: map[string][]time.Time{}, Depth: map[string]int{}, + PingPong: map[string]int{}, LastTrig: map[string]string{}, OpenAsks: map[string]string{}, Progress: map[string]bool{}, + } +} + +func msg(id, from, ch string, to []string, typ, replyTo, body string) model.Message { + return model.Message{ID: id, From: from, Channel: ch, To: to, Type: typ, InReplyTo: replyTo, Body: body} +} + +func wakeOf(ds []model.WakeDecision, agent string) (model.WakeDecision, bool) { + for _, d := range ds { + if d.Agent == agent { + return d, true + } + } + return model.WakeDecision{}, false +} + +// Rule 1: only addressed members are woken; an unaddressed broadcast wakes none. +func TestTriggerOnlyAddressed(t *testing.T) { + now := time.Now() + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"codex"}, model.MsgText, "", "fix the bug"), + }, freshState(), Config{}, now) + if len(ds) != 1 || ds[0].Agent != "codex" { + t.Fatalf("want one wake to codex, got %+v", ds) + } + + ds2, _, _ := Decide(room(), []model.Message{ + msg("m2", "claude", "#dev", nil, model.MsgText, "", "thinking out loud, no mention"), + }, freshState(), Config{}, now) + if len(ds2) != 0 { + t.Fatalf("unaddressed message must wake no one, got %+v", ds2) + } +} + +// Rule 4: a member never wakes itself even if it appears in recipients. +func TestNoSelfWake(t *testing.T) { + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "codex", "#dev", []string{"codex", "claude"}, model.MsgText, "", "@codex @claude"), + }, freshState(), Config{}, time.Now()) + if _, ok := wakeOf(ds, "codex"); ok { + t.Fatal("codex must not wake itself") + } + if _, ok := wakeOf(ds, "claude"); !ok { + t.Fatal("claude should still be woken") + } +} + +// Rule 2: multiple messages to one member coalesce into a single wake. +func TestCoalesce(t *testing.T) { + now := time.Now() + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"codex"}, model.MsgText, "", "part one"), + msg("m2", "claude", "#dev", []string{"codex"}, model.MsgText, "", "part two"), + }, freshState(), Config{}, now) + d, ok := wakeOf(ds, "codex") + if !ok || len(d.Payload) != 2 { + t.Fatalf("want one coalesced wake with 2 payloads, got %+v", ds) + } +} + +// Rule 3: a member mid-turn is held, not skipped. +func TestDebounceWorking(t *testing.T) { + st := freshState() + st.Working["codex"] = true + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"codex"}, model.MsgText, "", "ping"), + }, st, Config{}, time.Now()) + d, ok := wakeOf(ds, "codex") + if !ok || d.Hold != model.HoldWorking { + t.Fatalf("want codex held as working, got %+v", ds) + } +} + +// Rule 1b: a reply to an open ask wakes the asker with reason reply_to_open_ask. +func TestReplyToOpenAsk(t *testing.T) { + st := freshState() + st.OpenAsks["ask1"] = "claude" + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "codex", "#dev", nil, model.MsgReply, "ask1", "here is the answer"), + }, st, Config{}, time.Now()) + d, ok := wakeOf(ds, "claude") + if !ok || d.Reason != model.WakeReplyAsk { + t.Fatalf("want claude woken for open ask, got %+v", ds) + } +} + +// Rule 5: an agent→agent cascade past the depth cap escalates to the boss. +func TestDepthLimitEscalates(t *testing.T) { + cfg := Config{Depth: 2} + st := freshState() + now := time.Now() + var allEsc []model.Escalation + // Simulate a chain in one episode "E": each agent message wakes the next. + chain := []model.Message{ + msg("a", "claude", "#dev", []string{"codex"}, model.MsgText, "E", "1"), + msg("b", "codex", "#dev", []string{"agy"}, model.MsgText, "E", "2"), + msg("c", "agy", "#dev", []string{"claude"}, model.MsgText, "E", "3"), + } + for _, m := range chain { + var esc []model.Escalation + _, esc, st = Decide(room(), []model.Message{m}, st, cfg, now) + allEsc = append(allEsc, esc...) + } + found := false + for _, e := range allEsc { + if e.Cause == model.EscalateDepthLimit { + found = true + } + } + if !found { + t.Fatalf("expected a depth_limit escalation, got %+v", allEsc) + } +} + +// A boss message resets episode cascade depth (boss intervention is fresh). +func TestBossResetsDepth(t *testing.T) { + cfg := Config{Depth: 1} + st := freshState() + st.Depth["E"] = 5 // pretend the cascade was deep + now := time.Now() + ds, esc, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"codex"}, model.MsgText, "E", "fresh direction"), + }, st, cfg, now) + if len(esc) != 0 { + t.Fatalf("boss message must not escalate on depth, got %+v", esc) + } + if _, ok := wakeOf(ds, "codex"); !ok { + t.Fatal("boss message should wake codex despite prior deep cascade") + } +} + +// Rule 6: per-member rate cap holds excess wakes. +func TestRateCapHolds(t *testing.T) { + cfg := Config{RateCap: 2, RateWindow: time.Minute} + st := freshState() + now := time.Now() + st.Rate["codex"] = []time.Time{now.Add(-time.Second), now.Add(-2 * time.Second)} // already 2 in window + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"codex"}, model.MsgText, "", "one more"), + }, st, cfg, now) + d, ok := wakeOf(ds, "codex") + if !ok || d.Hold != model.HoldRate { + t.Fatalf("want codex held by rate cap, got %+v", ds) + } +} + +// Role fan-out: addressing a role wakes its reachable holders. +func TestRoleFanOut(t *testing.T) { + ds, _, _ := Decide(room(), []model.Message{ + msg("m1", "boss", "#dev", []string{"implementer"}, model.MsgText, "", "@implementer pick this up"), + }, freshState(), Config{}, time.Now()) + if _, ok := wakeOf(ds, "codex"); !ok { + t.Fatal("codex (implementer) should be woken") + } + if _, ok := wakeOf(ds, "agy"); !ok { + t.Fatal("agy (implementer) should be woken") + } + if _, ok := wakeOf(ds, "claude"); ok { + t.Fatal("claude (reviewer) should not be woken by an implementer mention") + } +} diff --git a/hatch/internal/wf/engine.go b/hatch/internal/wf/engine.go deleted file mode 100644 index dfadcff..0000000 --- a/hatch/internal/wf/engine.go +++ /dev/null @@ -1,234 +0,0 @@ -//go:build hatch_legacy - -// Package wf is the workflow engine: it authorises and performs lane -// transitions, enforcing gates and policies and recording the ledger. -package wf - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/gate" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/port" -) - -// MoveOptions parameterise a transition. -type MoveOptions struct { - To string // target lane - ByRole string // role performing the move (must satisfy transition.By) - Agent string // agent performing the move - Why string // ledger reason (required) - Handoff string // appended to handoff notes; required for handoff action - HumanApproved bool // operator acknowledges human/checklist gates - SkipGates bool // bypass gate evaluation (records it in the ledger) -} - -// Result reports a completed move. -type Result struct { - Ticket model.Ticket - From string - To string - Action string - Outcomes []gate.Outcome -} - -// Move validates and performs a transition for ticketID. -func (e Engine) Move(ws *config.Workspace, ticketID string, opt MoveOptions) (*Result, error) { - b, lg := e.Board, e.Ledger - if strings.TrimSpace(opt.Why) == "" { - return nil, fmt.Errorf("a reason (--why) is required") - } - t, ok, err := b.Find(ticketID, ws.Workflow.LaneIDs()) - if err != nil { - return nil, err - } - if !ok { - return nil, fmt.Errorf("ticket %s not found", ticketID) - } - from := t.Lane - if from == opt.To { - return nil, fmt.Errorf("ticket %s already in %s", ticketID, opt.To) - } - if !ws.Workflow.HasLane(opt.To) { - return nil, fmt.Errorf("unknown lane %q", opt.To) - } - tr, ok := ws.Workflow.FindTransition(from, opt.To) - if !ok { - return nil, fmt.Errorf("no transition %s → %s defined in workflow", from, opt.To) - } - if opt.ByRole != "" && !roleAllowed(tr.By, opt.ByRole) { - return nil, fmt.Errorf("role %q may not perform %s → %s (allowed: %s)", - opt.ByRole, from, opt.To, strings.Join(tr.By, ", ")) - } - - // depends_on + external blockers must be cleared before claiming. - if tr.Action == model.ActClaim { - if err := checkDependencies(ws, b, t); err != nil { - return nil, err - } - if open := t.OpenExternal(); len(open) > 0 { - return nil, fmt.Errorf("ticket %s blocked by external: %s", t.ID, open[0].What) - } - } - - // no-self-review: a reviewer transition may not be done by the agent that - // last held (implemented) the ticket. - if ws.Registry.Policy.NoSelfReview && isReviewerTransition(tr) && opt.Agent != "" && opt.Agent == t.Assignee { - return nil, fmt.Errorf("no-self-review: agent %q implemented this ticket and cannot review it", opt.Agent) - } - - // Evaluate gates. - var outcomes []gate.Outcome - if !opt.SkipGates && len(tr.Gates) > 0 { - outcomes = gate.EvaluateAll(ws, tr.Gates, t, ws.Layout.RepoRoot()) - for _, o := range outcomes { - if o.Human && !opt.HumanApproved { - _ = lg.Append(gateEntry(opt, t, from, "blocked", "human gate "+o.Name+" needs approval")) - return nil, fmt.Errorf("gate %q needs a human (%s); re-run with --approve once satisfied", o.Name, o.Detail) - } - if !o.Human && !o.Passed { - _ = lg.Append(gateEntry(opt, t, from, "failed", "gate "+o.Name+" failed: "+o.Detail)) - e.maybeEscalate(ws, t.ID) - return nil, fmt.Errorf("gate %q failed: %s", o.Name, o.Detail) - } - } - } - - // Apply state changes. - now := time.Now().Format(time.RFC3339) - oldPath := b.Path(t) - t.Lane = opt.To - t.Status = opt.To - t.Updated = now - action := tr.Action - if action == "" { - action = model.ActProgress - } - switch action { - case model.ActClaim: - t.Assignee = opt.Agent - t.Claim = &model.Claim{Agent: opt.Agent, TS: now} - if opt.ByRole != "" { - t.Role = opt.ByRole - } - } - if opt.Handoff != "" { - t.Body = appendHandoff(t.Body, now, opt.Agent, opt.To, opt.Handoff) - } - - newPath, err := b.Write(t) - if err != nil { - return nil, err - } - if newPath != oldPath { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - return nil, err - } - } - - // Record the ledger. - entry := model.Entry{ - TS: now, - Agent: agentOrSystem(opt.Agent), - Ticket: t.ID, - Action: action, - From: fmt.Sprintf("%s/ → %s/", from, opt.To), - Why: opt.Why, - Branch: t.Branch, - Handoff: opt.Handoff, - } - if opt.SkipGates && len(tr.Gates) > 0 { - entry.Note = "gates skipped" - } - if err := lg.Append(entry); err != nil { - return nil, err - } - - return &Result{Ticket: t, From: from, To: opt.To, Action: action, Outcomes: outcomes}, nil -} - -func checkDependencies(ws *config.Workspace, b port.Board, t model.Ticket) error { - if len(t.DependsOn) == 0 { - return nil - } - doneLanes := terminalLanes(ws) - for _, dep := range t.DependsOn { - dt, ok, err := b.Find(dep, ws.Workflow.LaneIDs()) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("dependency %s not found", dep) - } - if !doneLanes[dt.Lane] { - return fmt.Errorf("dependency %s not done (in %s)", dep, dt.Lane) - } - } - return nil -} - -// terminalLanes are lanes with no outgoing transition (done-like). -func terminalLanes(ws *config.Workspace) map[string]bool { - outgoing := map[string]bool{} - for _, tr := range ws.Workflow.Transitions { - if tr.From != "*" { - outgoing[tr.From] = true - } - } - term := map[string]bool{} - for _, l := range ws.Workflow.Lanes { - if !l.Side && !outgoing[l.ID] { - term[l.ID] = true - } - } - return term -} - -func isReviewerTransition(tr model.Transition) bool { - for _, r := range tr.By { - if r == "reviewer" { - return true - } - } - return false -} - -func roleAllowed(by []string, role string) bool { - for _, r := range by { - if r == "*" || r == role { - return true - } - } - return false -} - -func agentOrSystem(a string) string { - if a == "" { - return "human:operator" - } - return a -} - -func gateEntry(opt MoveOptions, t model.Ticket, from, result, why string) model.Entry { - return model.Entry{ - Agent: agentOrSystem(opt.Agent), - Ticket: t.ID, - Action: model.ActGate, - From: fmt.Sprintf("%s/ → %s/", from, opt.To), - Result: result, - Why: why, - } -} - -// appendHandoff inserts a dated bullet under the ticket's "Handoff notes". -func appendHandoff(body, ts, agent, lane, note string) string { - bullet := fmt.Sprintf("- %s %s→%s: %s", strings.SplitN(ts, "T", 2)[0], agent, lane, note) - if strings.Contains(body, "## Handoff notes") { - return strings.TrimRight(body, "\n") + "\n" + bullet + "\n" - } - return strings.TrimRight(body, "\n") + "\n\n## Handoff notes\n" + bullet + "\n" -} diff --git a/hatch/internal/wf/engine_test.go b/hatch/internal/wf/engine_test.go deleted file mode 100644 index 82c413a..0000000 --- a/hatch/internal/wf/engine_test.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build hatch_legacy - -package wf - -import ( - "testing" - "time" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" - "github.com/fioenix/overclaud/hatch/internal/oncall" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func setup(t *testing.T) (*config.Workspace, Engine, *store.Board) { - t.Helper() - dir := t.TempDir() - l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, err := config.Load(l) - if err != nil { - t.Fatal(err) - } - eng := Engine{Board: store.NewBoard(l), Ledger: store.NewLedger(l), Bus: bus.New(l), OnCall: oncall.Service{L: l}} - return ws, eng, store.NewBoard(l) -} - -func writeTicket(t *testing.T, b *store.Board, tk model.Ticket) { - t.Helper() - tk.Lane = "backlog" - tk.Status = "backlog" - tk.Created = time.Now().Format(time.RFC3339) - if _, err := b.Write(tk); err != nil { - t.Fatal(err) - } -} - -func TestClaimBlockedByDependency(t *testing.T) { - ws, eng, b := setup(t) - writeTicket(t, b, model.Ticket{ID: "T-001", Title: "dep", Role: "implementer"}) - writeTicket(t, b, model.Ticket{ID: "T-002", Title: "main", Role: "implementer", DependsOn: []string{"T-001"}}) - - _, err := eng.Move(ws, "T-002", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "go"}) - if err == nil { - t.Fatal("expected claim to be blocked by unfinished dependency") - } -} - -func TestClaimSetsAssignee(t *testing.T) { - ws, eng, b := setup(t) - writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - - res, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}) - if err != nil { - t.Fatal(err) - } - if res.Ticket.Assignee != "codex" || res.Ticket.Claim == nil { - t.Fatalf("claim did not set assignee/lock: %+v", res.Ticket) - } - if res.Action != model.ActClaim { - t.Fatalf("expected claim action, got %s", res.Action) - } -} - -func TestRoleNotAllowed(t *testing.T) { - ws, eng, b := setup(t) - writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "reviewer"}) - // reviewer is not in the claim transition's `by` list. - _, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "reviewer", Agent: "claude-code", Why: "x"}) - if err == nil { - t.Fatal("expected role-not-allowed error") - } -} - -func TestNoSelfReview(t *testing.T) { - ws, eng, b := setup(t) - writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - if _, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex", Why: "claim"}); err != nil { - t.Fatal(err) - } - if _, err := eng.Move(ws, "T-001", MoveOptions{To: "review", ByRole: "implementer", Agent: "codex", Why: "done", Handoff: "h", SkipGates: true}); err != nil { - t.Fatal(err) - } - // codex implemented it; it must not be able to review it. - _, err := eng.Move(ws, "T-001", MoveOptions{To: "done", ByRole: "reviewer", Agent: "codex", Why: "lgtm", SkipGates: true}) - if err == nil { - t.Fatal("expected no-self-review to block") - } - // a different agent can. - if _, err := eng.Move(ws, "T-001", MoveOptions{To: "done", ByRole: "reviewer", Agent: "claude-code", Why: "approved", SkipGates: true}); err != nil { - t.Fatalf("reviewer move failed: %v", err) - } -} - -func TestWhyRequired(t *testing.T) { - ws, eng, b := setup(t) - writeTicket(t, b, model.Ticket{ID: "T-001", Title: "x", Role: "implementer"}) - if _, err := eng.Move(ws, "T-001", MoveOptions{To: "in-progress", ByRole: "implementer", Agent: "codex"}); err == nil { - t.Fatal("expected error when why is empty") - } -} diff --git a/hatch/internal/wf/escalate.go b/hatch/internal/wf/escalate.go deleted file mode 100644 index 5073638..0000000 --- a/hatch/internal/wf/escalate.go +++ /dev/null @@ -1,91 +0,0 @@ -//go:build hatch_legacy - -package wf - -import ( - "fmt" - "strings" - - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/model" -) - -// escalationThreshold is the number of gate failures on a ticket that triggers -// an automatic escalation (like a teammate flagging a senior after retries). -const escalationThreshold = 2 - -// EscalateTarget resolves the system-level escalation target (no ticket role). -func (e Engine) EscalateTarget(ws *config.Workspace) string { - return e.EscalateTargetForRole(ws, "") -} - -// EscalateTargetForRole resolves who an escalation goes to: the current on-call, -// else up the org chart (the manager of the ticket's role), else registry -// policy, else the first conductor, else a human lead. -func (e Engine) EscalateTargetForRole(ws *config.Workspace, role string) string { - if e.OnCall != nil { - if oc := e.OnCall.Current(); oc != "" { - return oc - } - } - if role != "" { - if r, ok := ws.Registry.RoleByID(role); ok && r.ReportsTo != "" { - if as := ws.Registry.AgentsForRole(r.ReportsTo); len(as) > 0 { - return as[0].ID - } - } - } - if ws.Registry.Policy.EscalateTo != "" { - return ws.Registry.Policy.EscalateTo - } - if as := ws.Registry.AgentsForRole("conductor"); len(as) > 0 { - return as[0].ID - } - return "human:lead" -} - -// Escalate records an escalation in the ledger and notifies the target on the -// #escalations channel. -func (e Engine) Escalate(ws *config.Workspace, ticket, from, why string) error { - role := "" - if t, ok, _ := e.Board.Find(ticket, ws.Workflow.LaneIDs()); ok { - role = t.Role - } - target := e.EscalateTargetForRole(ws, role) - if from == "" { - from = "orchestrator" - } - if err := e.Ledger.Append(model.Entry{ - Agent: from, Ticket: ticket, Action: model.ActEscalate, - ToRole: target, Why: why, - }); err != nil { - return err - } - return e.Bus.Notify("#escalations", from, []string{target}, - fmt.Sprintf("@%s ESCALATION %s: %s", target, ticket, why)) -} - -// maybeEscalate auto-escalates when a ticket has hit the gate-failure threshold -// and hasn't already been escalated. -func (e Engine) maybeEscalate(ws *config.Workspace, ticket string) { - entries, err := e.Ledger.Recent(1) - if err != nil { - return - } - fails, escalated := 0, false - for _, en := range entries { - if en.Ticket != ticket { - continue - } - if en.Action == model.ActGate && strings.HasPrefix(en.Result, "failed") { - fails++ - } - if en.Action == model.ActEscalate { - escalated = true - } - } - if fails >= escalationThreshold && !escalated { - _ = e.Escalate(ws, ticket, "orchestrator", - fmt.Sprintf("gate failed %d times — cần can thiệp", fails)) - } -} diff --git a/hatch/internal/wf/escalate_test.go b/hatch/internal/wf/escalate_test.go deleted file mode 100644 index 8a60387..0000000 --- a/hatch/internal/wf/escalate_test.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build hatch_legacy - -package wf - -import ( - "testing" - - "github.com/fioenix/overclaud/hatch/internal/bus" - "github.com/fioenix/overclaud/hatch/internal/config" - "github.com/fioenix/overclaud/hatch/internal/oncall" - "github.com/fioenix/overclaud/hatch/internal/paths" - "github.com/fioenix/overclaud/hatch/internal/scaffold" - "github.com/fioenix/overclaud/hatch/internal/store" -) - -func engine(l paths.Layout) Engine { - return Engine{ - Board: store.NewBoard(l), - Ledger: store.NewLedger(l), - Bus: bus.New(l), - OnCall: oncall.Service{L: l}, - } -} - -func TestEscalatePostsAndTargets(t *testing.T) { - l, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - eng := engine(l) - // default target is the first conductor (claude-code in the template). - if got := eng.EscalateTarget(ws); got != "claude-code" { - t.Fatalf("escalate target = %q, want claude-code", got) - } - if err := eng.Escalate(ws, "T-001", "codex", "stuck"); err != nil { - t.Fatal(err) - } - msgs, _ := bus.New(l).Messages("#escalations") - if len(msgs) != 1 { - t.Fatalf("want 1 escalation message, got %d", len(msgs)) - } -} - -func TestEscalateTargetPrefersOncall(t *testing.T) { - l, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - (oncall.Rotation{Order: []string{"gemini"}}).Save(l) - if got := engine(l).EscalateTarget(ws); got != "gemini" { - t.Fatalf("on-call should win escalation target, got %q", got) - } -} - -func TestEscalateClimbsOrgChart(t *testing.T) { - l, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "scrum"}) - if err != nil { - t.Fatal(err) - } - ws, _ := config.Load(l) - for i := range ws.Registry.Roles { - if ws.Registry.Roles[i].ID == "implementer" { - ws.Registry.Roles[i].ReportsTo = "conductor" - } - } - if got := engine(l).EscalateTargetForRole(ws, "implementer"); got != "claude-code" { - t.Fatalf("escalation should climb to conductor agent claude-code, got %q", got) - } -} diff --git a/hatch/internal/wf/ports.go b/hatch/internal/wf/ports.go deleted file mode 100644 index a0699bf..0000000 --- a/hatch/internal/wf/ports.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build hatch_legacy - -package wf - -import "github.com/fioenix/overclaud/hatch/internal/port" - -// Engine is the workflow use-case service. It depends only on ports, never on -// concrete infrastructure — the composition root injects adapters. This keeps -// the engine free of IO concerns (see ARCHITECTURE.md). -type Engine struct { - Board port.Board - Ledger port.Ledger - Bus port.Bus - OnCall port.OnCall -}