diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..012f2d8 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "hatch", + "owner": { + "name": "Finolabs", + "url": "https://github.com/fioenix/overclaud" + }, + "plugins": [ + { + "name": "hatch", + "source": "./hatch/plugin", + "description": "Embedded harness for a coding-agent squad: shared chat (= comms + backlog) and a knowledge base over MCP." + } + ] +} diff --git a/.github/workflows/hatch.yml b/.github/workflows/hatch.yml new file mode 100644 index 0000000..ba191eb --- /dev/null +++ b/.github/workflows/hatch.yml @@ -0,0 +1,31 @@ +name: hatch CI + +on: + push: + branches: ["**"] + paths: ["hatch/**", ".github/workflows/hatch.yml"] + pull_request: + paths: ["hatch/**", ".github/workflows/hatch.yml"] + +jobs: + build-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: hatch + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: hatch/go.sum + - name: Verify modules tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum + - name: Lint (vet + gofmt) + run: make lint + - name: Test + run: make test + - name: Build + run: make build diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..37e609e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,45 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at **tangduyphuong@gmail.com**. All complaints will +be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc1d45c..378de42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,29 @@ If the skill setup flow doesn't work correctly, open an issue with: - What you expected vs what happened - Any error messages +## Contributing to Hatch (the `hatch/` Go project) + +Hatch is the multi-agent orchestrator CLI. To develop: + +```bash +cd hatch +./scripts/onboard.sh # build + a runnable demo (mock agent) +make test # unit + integration tests +make lint # go vet + gofmt check (CI enforces both) +make build # bin/hatch + bin/hatch-mock +``` + +Standards for Go changes: + +- [ ] `make lint` and `make test` pass (CI runs them on every PR) +- [ ] `go mod tidy` leaves `go.mod`/`go.sum` unchanged +- [ ] New behavior has a test; security-relevant paths are sanitized +- [ ] No secrets in code/config; credentials come from env vars only +- [ ] Keep packages small and documented; match the surrounding style + +See `hatch/docs/` for the design and `SECURITY.md` for trust boundaries. + ## Code of Conduct -Be kind. Be helpful. Skip the drama. +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). In short: be kind, be helpful, +skip the drama. diff --git a/README.md b/README.md index cd22696..91ae199 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Optimize how Claude works for you — across every surface. +> **Đang nâng cấp lên đa-agent → [Hatch](hatch/README.md).** overclaud (single-agent) đang được nâng thành một harness điều phối **nhiều coding agent** (Claude Code, Codex, Kiro, Antigravity) trên cùng một repo, tối ưu token, theo quy trình kiểu Agile. overclaud hiện tại trở thành compiler backend cho Claude bên trong Hatch. Sản phẩm thuộc hệ sinh thái **Finolabs**. Xem bộ thiết kế ở [`hatch/`](hatch/README.md). + Claude reads your instructions every message, but most users either don't set them up, put everything in one place, or waste tokens on vague guidance. **overclaud** fixes that. ## What This Is @@ -129,9 +131,13 @@ overclaud/ │ ├── layers.md # Complete layer architecture │ ├── token-optimization.md # Token efficiency techniques │ └── common-mistakes.md # Anti-patterns to avoid -└── guides/ - ├── layering-strategy.md # What goes where — decision guide - └── contributing-templates.md # How to contribute new templates +├── guides/ +│ ├── layering-strategy.md # What goes where — decision guide +│ └── contributing-templates.md # How to contribute new templates +└── hatch/ # Multi-agent upgrade (design docs) + ├── README.md # Hatch — multi-agent harness overview + ├── docs/ # 00-vision … 08-roadmap + └── spec/ # registry / ticket / ledger schemas ``` ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 36139c7..98f1bcd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,3 +21,31 @@ overclaud is an instruction optimization skill. It: ## Hook Security The auto-suggest hook processes `$TOOL_INPUT` through `jq` (JSON parser) before any string operations. User input never appears in the hook's output. The output is a hardcoded JSON system message. + +## Hatch (the orchestrator in `hatch/`) + +Hatch is a **local, file-based** tool: state lives in `.hatch/` and the repo; +there is no server and no telemetry. It does, however, execute programs, so the +following are deliberate **trust boundaries** — treat a `.hatch/` workspace and +its `registry.yaml`/`workflow.yaml` as trusted, code-reviewed inputs (like a +`Makefile` or CI config): + +- **Gate commands.** `workflow.yaml` gates of `type: command` run via `sh -c` + (e.g. `make test`). Anyone who can commit `workflow.yaml` can run commands on + a machine that runs `hatch gate`/`move`. Review workflow changes in PRs. +- **Agent execution.** The orchestrator spawns the agent CLIs named in + `registry.yaml` (`exec`, no shell — arguments are passed directly, not + interpolated into a shell string). Only list agents you trust. +- **Credentials.** Agent API keys are read from the environment at spawn time + (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `KIRO_API_KEY`) and + are **never** written to the repo, ledger, or config. Never commit secrets; + `registry.yaml` references env var names only. +- **Transcripts.** Raw agent stdout/stderr is captured to `.hatch/runs/` and the + ledger summary. If an agent prints a secret, it lands there — output redaction + is currently out of scope (tracked in `hatch/docs/17-pre-implementation.md`). + Keep `.hatch/runs/` out of public artifacts if your agents echo sensitive data. +- **Path safety.** Ticket ids, channel names and run targets are sanitized + before being used as path segments to prevent traversal; `hatch validate` + rejects unsafe ticket ids. + +Report Hatch vulnerabilities the same way as above (subject "hatch security"). diff --git a/hatch/.gitignore b/hatch/.gitignore new file mode 100644 index 0000000..dbbf2e3 --- /dev/null +++ b/hatch/.gitignore @@ -0,0 +1,7 @@ +# Build artifacts. Note: patterns must be anchored so they don't match the +# cmd/hatch source directory. +/bin/ +/hatch +/demo-workspace/ +*.out +*.test diff --git a/hatch/ARCHITECTURE.md b/hatch/ARCHITECTURE.md new file mode 100644 index 0000000..16b0b08 --- /dev/null +++ b/hatch/ARCHITECTURE.md @@ -0,0 +1,93 @@ +# Architecture + +Hatch follows a **Lean Hexagonal** (ports & adapters) design: a pure domain at +the center, use-case services that depend on **ports** (interfaces), and +infrastructure as **adapters** behind those ports. "Lean" = we only introduce a +port where it buys decoupling or testability; we don't abstract things we will +never swap (the filesystem *is* the database, by design). + +## The dependency rule + +Dependencies point **inward**. Inner layers never import outer ones. + +``` + driving adapters cmd/hatch, internal/cli, internal/tui, cmd/hatch-mock + │ wire + call + ▼ + use cases (application) wf · compile · metrics · ceremony · orchestrator · docs · config + │ depend on + ▼ + ports (interfaces) wf.Board · wf.Ledger · gate.Runner · orchestrator.Adapter + ▲ implemented by + │ + adapters (infrastructure) store · bus · gate.ShellRunner · orchestrator(claude/codex/…/mock) + presence · oncall · mux · mdfront · paths + │ all built on + ▼ + domain (pure) model (entities + value objects, zero IO imports) +``` + +## Layers + +| Layer | Packages | Rule | +|---|---|---| +| **Domain** | `internal/model` | Pure data + invariants (tickets, registry, workflow, ledger entries, **messages**). Imports nothing from the project. | +| **Use cases** | `internal/wf`, `compile`, `metrics`, `ceremony`, `orchestrator`, `docs`, `config` | Orchestrate the domain via **ports**; no direct knowledge of *how* IO happens. | +| **Ports** | `internal/port` (`Board`, `Ledger`, `Bus`, `OnCall`) + `gate.Runner`, `orchestrator.Adapter` | Interfaces the use-case layer depends on; adapters satisfy them. | +| **Adapters** | `internal/store` (filesystem board/ledger/KB), `bus` (filesystem messaging), `gate.ShellRunner` (exec), `orchestrator` per-kind agent adapters + `mock`, `mux` (tmux/zellij), `presence`, `oncall`, `mdfront`, `paths` | Implement ports / wrap the outside world. | +| **Driving adapters** | `cmd/hatch`, `internal/cli`, `internal/tui`, `cmd/hatch-mock` | The composition root: parse input, wire concrete adapters into use cases. | + +## Ports in practice + +The ports live in **`internal/port`** (`Board`, `Ledger`, `Bus`, `OnCall`), +plus consumer-local ports where they belong to one boundary (`gate.Runner`, +`orchestrator.Adapter`). Infrastructure packages provide adapters that satisfy +them, asserted at compile time (`var _ port.Board = (*store.Board)(nil)`). + +- **`wf.Engine`** — the workflow engine holds `port.Board/Ledger/Bus/OnCall` + and exposes `Move`/`Escalate` as methods. It imports **no infrastructure** + (only `port` + `model` + `config` + `gate`). `engineFor(ws)` in the CLI wires + the concrete adapters. +- **`orchestrator.Orchestrator`** — holds `port.Ledger` + `port.Bus`; `Run`/ + `Execute` are methods. The agent boundary itself is `orchestrator.Adapter` + (Claude/Codex/agy/Kiro + `mock` test adapter) — textbook ports & adapters. +- **`gate.Runner`** — gate command execution sits behind a port; `ShellRunner` + is the production adapter, fakes are used in tests (no real `sh`). +- **`metrics.Compute(port.Ledger)`** and **`ceremony.Service{Board, Ledger, + Bus, KB}`** — the reporting use cases read through ports too. To make this + clean, the messaging value types (`Message`, `SearchOpts`, message kinds) live + in the **domain** (`model`); `bus` re-exports them as aliases for terse call + sites, and `port.Bus.Search` / `port.KB.List` return domain types — so no + adapter type ever leaks into a port. + +### Every use case is port-based + +The whole use-case layer — `wf`, `orchestrator`, `gate`, `metrics`, +`ceremony` — depends only on `internal/port` (+ `model`/`config`). None import +`internal/store` or `internal/bus`. Concrete adapters are wired exclusively at +the composition root (`internal/cli` helpers `engineFor`, `orch`, +`ceremonyService`; the TUI builds them inline). The remaining direct adapter use +in `cli`/`tui` is the composition root itself, which is expected to know +concretes. + +## Where we stay Lean (intentional non-ports) + +We do **not** hide these behind interfaces, because there is one real +implementation and swapping it is a non-goal: + +- The filesystem layout (`paths`) and markdown encoding (`mdfront`) — the + storage format is the product; the adapters (`store`, `bus`) are built on them. +- `presence` is read by the CLI's capacity-aware assignment (`pickAgent`), which + is part of the composition root — no port needed there. + +If a second backend ever appears (e.g. a server-backed store), the seams above +(`store` behind `wf.Board`/`wf.Ledger`) are where a new adapter slots in without +touching the engine. + +## Testing reflects the architecture + +- Domain + use cases are unit-tested with fakes through ports (`gate` with a + fake `Runner`; `wf` with the real `store` satisfying the interfaces). +- `orchestrator` is exercised end-to-end with the **mock** agent adapter. +- `internal/cli` has a full lifecycle integration test driving the real command + tree (init → compile → ticket → run → logs → report). diff --git a/hatch/HANDOFF.md b/hatch/HANDOFF.md new file mode 100644 index 0000000..13d6c50 --- /dev/null +++ b/hatch/HANDOFF.md @@ -0,0 +1,114 @@ +# HANDOFF — tiếp tục phát triển Hatch ở local + +Tài liệu bàn giao để dev tiếp Hatch trên máy của bạn. Đọc cùng: +`docs/20-embedded-harness-pivot.md` (mô hình hiện hành), `ARCHITECTURE.md` +(Lean Hexagonal), `docs/architecture-diagram.md` (sơ đồ). + +## 1. Hatch là gì (sau pivot) + +Hatch là **embedded harness** cho một squad coding-agent làm trên một repo: +**coding agent là entrypoint và tự lái**; Hatch cung cấp **chat dùng chung +(= giao tiếp + backlog, thread = task)** và **Knowledge Base**, phơi cho agent +qua **MCP server**. Hatch **không** spawn/điều khiển agent. `hatch board`/ +`chat`/`status` chỉ là **view read-only**. Quy trình/phân vai là **protocol +được compile** vào `CLAUDE.md`/`AGENTS.md`/`GEMINI.md`/`.kiro` (prose), không +phải engine cưỡng chế. + +> Docs `00–19` mô tả thiết kế **trước pivot** (Hatch tự lái). Khi mâu thuẫn, +> **doc 20 thắng** và hành vi CLI thực tế là chuẩn. + +## 2. Build / test / run + +Yêu cầu: **Go 1.24+** (go.mod khai `go 1.25.0`), **git**. + +```bash +cd hatch +make build # → bin/hatch (+ bin/hatch-mock, chỉ dùng cho legacy) +make lint # go vet + kiểm gofmt +make test # toàn bộ test (build mặc định) +make install # go install ./cmd/hatch → $(go env GOPATH)/bin +./scripts/onboard.sh # build + demo trong ./demo-workspace (không cần agent thật) +``` + +Hai cấu hình build — **luôn chạy cả hai trước khi push**: + +```bash +go build ./... && go test ./... # default (sản phẩm) +go build -tags hatch_legacy ./... && go test -tags hatch_legacy ./... # + operator đã archive +``` + +## 3. Trạng thái hiện tại (đã xong) + +- **MCP server** `hatch mcp --as ` (stdio) — tools: whoami, chat_open, + chat_post, chat_read, chat_inbox, chat_search, chat_channels, kb_add, + kb_search. `--as` rỗng → `$HATCH_AGENT` → agent kind=claude đầu tiên. + (`internal/mcpserver/`, `internal/cli/mcp.go`) +- **compile** tiêm protocol (workflow prose + chat etiquette + DoD self-check + + khối orchestrator cho lead) vào surfaces; đăng ký MCP per-agent. + (`internal/compile/render.go`, `mcp.go`) +- **Claude plugin** `hatch/plugin/` (MCP + skill `hatch-chat` + `/hatch`) + + `.claude-plugin/marketplace.json` ở repo root. +- **board/chat/status read-only** (`internal/tui/`, `internal/cli/status.go`). +- **`hatch init --client cc|codex|agy|kiro`** — wire MCP cho từng client + (`internal/cli/client.go`). +- **Workspace phân tầng**: `~/.hatch` global + `.hatch` local override; output + compile luôn vào repo hiện tại. (`internal/paths/`, `config.Workspace.Out()`) +- **Operator tự-lái archived** sau tag `hatch_legacy` (run/plan/watch/tick, + orchestrator, workflow-engine, ceremonies, ask/convene, pair/mob, presence, + oncall, cost/budget, workload/perf, report). + +## 4. CẦN VERIFY Ở LOCAL (chưa kiểm được trên remote) + +Đây là việc đầu tiên nên làm — môi trường remote không có agent CLI thật: + +1. **MCP handshake thật** với từng agent: + - Claude Code: cài plugin (`/plugin marketplace add fioenix/overclaud` → + `/plugin install hatch@hatch`) hoặc dựa `.mcp.json`; gọi thử tool + `whoami`, `chat_open`, `chat_inbox`. Kiểm 2 agent (vd claude + codex) cùng + thấy một chat: agent A `chat_open @codex …`, agent B `chat_inbox` thấy. + - Codex: `hatch init --client codex` (cần `codex` trên PATH → nó gọi + `codex mcp add hatch -- hatch mcp --as codex`). Xác nhận `~/.codex/ + config.toml` có `[mcp_servers.hatch]` và Codex gọi được tool. + - **agy (Antigravity CLI)**: `hatch init --client agy` ghi + `~/.gemini/config/mcp_config.json`. **Cần xác nhận runtime path đúng** với + bản agy bạn cài (có migration; xem `google-antigravity/antigravity-cli#60`). + Lưu ý bug stdout non-TTY (#76) có thể ảnh hưởng. + - Kiro: `.kiro/settings/mcp.json`. +2. **`hatch doctor`** với agent thật đã đăng nhập (chỉ gọi lệnh auth của từng + CLI, KHÔNG quét thư mục creds — giữ nguyên nguyên tắc này). +3. **Orchestrator ≠ Claude**: gán `conductor` cho codex/agy trong + `registry.yaml`, `hatch compile`, kiểm khối orchestrator vào đúng + AGENTS.md/GEMINI.md và agent đó thật sự điều phối qua chat. + +## 5. Kiến trúc & nơi sửa + +Lean Hexagonal (xem `ARCHITECTURE.md`): +- `internal/model/` — domain types (Message, KBEntry, Registry, Workflow…). +- `internal/port/` — interfaces (Board/Ledger/Bus/KB…). +- Adapters: `internal/bus/` (chat), `internal/store/` (kb/ledger/board), + `internal/compile/` (SSOT→surfaces+MCP), `internal/mcpserver/` (MCP). +- Driving: `internal/cli/` (Cobra), `internal/tui/` (Bubble Tea), `cmd/hatch/`. +- SSOT mẫu + template: `internal/scaffold/templates/`. + +Thêm một MCP tool: sửa `internal/mcpserver/server.go` (+ struct ở `types.go`). +Đổi nội dung compiled: `internal/compile/render.go`. Thêm client: `resolveClientKind` ++ `setupClient` trong `internal/cli/client.go`. + +## 6. Hạn chế & ý tưởng kế tiếp + +- **agy path** dựa trên tài liệu, chưa chạy binary thật → verify (#4). +- **compile manifest** lưu trong SSOT; khi global SSOT dùng cho NHIỀU repo, + `compile --check` chỉ nhớ outputs repo cuối — chấp nhận tạm; nếu cần, đưa + manifest về theo output-root. +- **Plugin** mới có cho Claude; Codex/Kiro/agy dựa surfaces (đủ dùng). +- `cmd/hatch-mock` chỉ phục vụ legacy orchestrator — cân nhắc gỡ khỏi + `make build` mặc định. +- Ý tưởng: `hatch doctor` đọc/thử `~/.gemini/config/mcp_config.json` & + `~/.codex/config.toml` để báo trạng thái đăng ký MCP per-client; pixel-game + visualize cho `hatch chat` (đã nêu trong tầm nhìn). + +## 7. Git + +- Branch: `claude/agents-control-tower-bo6w3f` (PR #1, draft). +- Quy ước: dev trên branch này; chạy `make lint && make test` (+ legacy) trước + khi push; `go mod tidy` nếu đổi deps (CI có check tidy). diff --git a/hatch/Makefile b/hatch/Makefile new file mode 100644 index 0000000..bc531d9 --- /dev/null +++ b/hatch/Makefile @@ -0,0 +1,36 @@ +BINARY := hatch +PKG := ./... +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -X github.com/fioenix/overclaud/hatch/internal/cli.Version=$(VERSION) + +.PHONY: build install test lint vet fmt tidy clean onboard + +onboard: ## Build + spin up a local demo workspace (mock agent) + ./scripts/onboard.sh + +build: ## Build the hatch + hatch-mock binaries into ./bin + @mkdir -p bin + go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/hatch + go build -o bin/hatch-mock ./cmd/hatch-mock + +install: ## Install hatch into GOPATH/bin + go install -ldflags "$(LDFLAGS)" ./cmd/hatch + +test: ## Run all tests + go test $(PKG) + +vet: ## go vet + go vet $(PKG) + +fmt: ## gofmt -s -w + gofmt -s -w $(shell find . -name '*.go' -not -path './vendor/*') + +lint: vet ## Lint: vet + gofmt check + @unformatted=$$(gofmt -s -l $$(find . -name '*.go' -not -path './vendor/*')); \ + if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi + +tidy: ## go mod tidy + go mod tidy + +clean: + rm -rf bin diff --git a/hatch/README.md b/hatch/README.md new file mode 100644 index 0000000..6b1671f --- /dev/null +++ b/hatch/README.md @@ -0,0 +1,240 @@ +# Hatch + +> **Bản nâng cấp đa-agent của [overclaud](../README.md)** — sản phẩm thuộc hệ sinh thái **Finolabs** (Fioenix + Dinosaur Labs). +> +> overclaud (hiện tại) tối ưu instructions cho **một** agent trên nhiều surface. Hatch nâng chính overclaud lên để tối ưu instructions + context + **phối hợp** cho **nhiều** agent, trên nhiều surface, cùng một repo. overclaud cũ không bị thay thế — nó trở thành **compiler backend cho Claude** bên trong Hatch. + +## Tên gọi + +**Hatch** = ổ trứng *nở* ra thành viên đội. Đặt tên theo chủ đề khủng long/phượng hoàng của Finolabs, và khớp xương sống thiết kế — một bầy phối hợp săn mồi. (Nghĩa kép cũ "`hatch run ` nở một agent" thuộc về mô hình orchestrator trước pivot, nay đã archived — xem [doc 20](docs/20-embedded-harness-pivot.md).) + +## Nó là gì + +Hatch là một **embedded harness** cho coding agent. **Coding agent là entrypoint** — user mở Claude Code / Codex / Antigravity CLI (`agy`) / Kiro ngay trong workspace, và agent **tự lái mình**. Hatch không spawn, không điều khiển agent; nó cung cấp một **lớp nền chung** mà mọi agent với tới qua **MCP server**: + +- một **chat dùng chung** (bus) — đồng thời là **backlog**: một thread = một task; +- một **Knowledge Base dùng chung** (bộ nhớ chung bền của hệ); +- một **ledger** audit append-only. + +Nhờ đó nhiều agent trên cùng một repo phối hợp theo phong cách **async kiểu Slack** (mở thread cho mỗi task, brief tiến độ trong thread, `@mention` đồng đội, `chat_search`/`kb_search` để nhớ lại), giữ vai trò khác nhau theo một quy trình kiểu Agile, để lại audit trail đầy đủ, và **không lãng phí token** vì mỗi agent chỉ nạp đúng phần context của mình. + +> **Lưu ý về docs.** Các doc **00–19 mô tả thiết kế GỐC (trước pivot)** — khi Hatch còn là orchestrator tự lái agent. Mô hình **hiện hành** là [doc 20 — embedded-harness pivot](docs/20-embedded-harness-pivot.md). Khi đối chiếu hành vi CLI thực tế, **doc 20 thắng**. + +## Phép ẩn dụ neo: một đội Agile người + +Mọi thành phần trong Hatch đều có một đối ứng trong một squad người. Đây là la bàn thiết kế — khi phân vân, hỏi "đội người làm việc này thế nào?". + +| Thành phần Hatch | Đối ứng trong squad người | +|---|---| +| `charter.md` | Team charter / mục tiêu sản phẩm | +| `roles/` | Bản mô tả công việc (JD) từng vai | +| `registry.yaml` | Danh bạ nhân sự + năng lực + ai giữ vai gì | +| `kb/` | **Wiki / bộ não chung của đội** (Confluence) — đọc *và* ghi | +| `workflow.yaml` | Quy trình làm việc của đội — template, sửa được | +| `board/` | Bảng sprint (Jira) | +| `ledger/` | Sổ standup / nhật ký hoạt động / git log | +| `protocol/` | Working agreements của đội | +| `chat` (bus) | **Slack của đội** — kênh trao đổi *và* backlog (mỗi thread = một task) | +| `compiler` | Bộ sinh tài liệu onboarding + working agreements cho từng người mới | +| MCP server | Cánh cửa mỗi người dùng để vào Slack chung + wiki chung | +| agent lead/Conductor | Engineering Manager / Scrum Master — *là một agent tự lái*, không phải Hatch | + +> **Bộ nhớ chung.** Agents không chia sẻ RAM (mỗi con một process), nhưng cùng khai thác và đóng góp vào **Knowledge Base** (`kb/`) — y như một đội người không đọc được não nhau nhưng cùng tra cứu và cập nhật một wiki. KB là bộ nhớ chung *bền* của hệ; agent vừa *input* (tra cứu để khỏi suy diễn lại) vừa *ghi lại* (quyết định, bài học, gotcha) khi làm. Xem [09-knowledge-base](docs/09-knowledge-base.md). + +## Bốn agent, bốn vai mặc định + +| Agent | Vai mặc định | Vì sao | +|---|---|---| +| **Claude Code** (chính) | Architect / Tech Lead + **Conductor** | Reasoning sâu, giữ bức tranh tổng thể, lập kế hoạch | +| **Kiro** | Spec-driven Implementer | Mạnh quy trình PRD → design → tasks | +| **Codex** | Autonomous Implementer | Thực thi nhanh, lặp nhiều | +| **Antigravity CLI** | Implementer / Utility | Linh hoạt, gánh việc phụ | + +Bảng trên **chỉ là template khởi đầu**. Vai trò là do **người dùng tự cấu hình ở từng project** qua `registry.yaml` của project đó — không fix cứng. Cùng một agent có thể giữ vai khác nhau ở các project khác nhau. Xem [02-roles](docs/02-roles.md). + +## Bảy trụ thiết kế + +1. **Single Source of Truth → compile xuống từng agent.** Một nguồn canonical (`.hatch/`: `charter.md`, `roles/`, `registry.yaml`, `workflow.yaml`), một compiler sinh ra `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.kiro/steering` tự động. Không copy-paste, không drift. +2. **Knowledge Base dùng chung** (`kb/`) — bộ nhớ chung bền của hệ; mọi agent đọc *và* ghi qua MCP. Thay cho "shared memory" mà các process không có. +3. **Token optimization = context phân tầng** (L0 mission → L1 role → L2 task + KB on-demand). Mỗi agent chỉ nạp tầng của nó + thread task đang làm. +4. **Role assignment per-project** — map vai ↔ điểm mạnh từng agent; user tự thiết kế ở mỗi project qua `registry.yaml`. +5. **Coordination protocol = chat async.** Phối hợp qua **chat dùng chung** (bus): mở thread cho mỗi task, brief tiến độ trong thread, `@mention` để nhờ. Không agent nào spawn hay gọi trực tiếp agent khác; Hatch cũng không. Audit qua ledger. +6. **Workflow = protocol được COMPILE, không phải engine.** `workflow.yaml` (Agile: scrum/kanban/spec-first/…) + roles + DoD vẫn là SSOT, nhưng compile biến chúng thành **văn bản hành vi** (prose) tiêm vào instruction surface từng agent. Agent đọc và *tự* tuân theo; Hatch không cưỡng chế bằng code. +7. **Governance & audit** — ledger append-only; Definition-of-Done là **self-check agent tự chạy**; thẩm quyền agent khai trong SSOT. + +## Mô hình phối hợp: chat async, agent tự lái + +- **Agent đầu tiên user mở** (thường Claude Code) mặc định là **Conductor/orchestrator** và có thể kiêm các vai khác (architect/reviewer…). Việc này khai trong `registry.yaml` và tiêm qua compile vào CLAUDE.md (kèm khối "orchestrator"). +- Conductor bẻ việc → **mở một thread cho mỗi task** trong chat, brief, `@tag` agent phù hợp. +- Agent được tag đọc thread, làm, brief kết quả lại **trong cùng thread**. Trạng thái task suy ra từ hội thoại (post type `done`/`block`/`decision`), không cần lane/claim/gate engine. +- Reviewer ≠ implementer tự chạy DoD self-check rồi báo done trong thread. +- Tất cả **async** (kiểu Slack): agent chỉ phản hồi khi nó đang chạy. Không lời gọi trực tiếp giữa agent, không ai spawn ai — y như một đội người trao đổi qua Slack. + +## Đọc docs theo thứ tự + +> **Quan trọng:** Doc **00–19 mô tả thiết kế GỐC trước pivot** (Hatch tự lái agent, orchestrator là entrypoint, board là control panel, backlog kiểu Jira). Mô hình **hiện hành** nằm ở **doc 20**. Khi mâu thuẫn, **doc 20 thắng**. Đọc doc 20 trước để hiểu mô hình thực tế, rồi dùng 00–19 cho bối cảnh/ý tưởng nền. + +| # | Doc | Nội dung | +|---|---|---| +| 00 | [vision](docs/00-vision.md) | Vấn đề, tầm nhìn, phép ẩn dụ squad | +| 01 | [architecture](docs/01-architecture.md) | 6 trụ, layout `.hatch/`, các thành phần | +| 02 | [roles](docs/02-roles.md) | Mô hình vai, map năng lực agent | +| 03 | [coordination-protocol](docs/03-coordination-protocol.md) | Hybrid, board, claim/lock, handoff, DoD | +| 04 | [context-compiler](docs/04-context-compiler.md) | SSOT vs KB, compile per-agent, 3 tầng token | +| 05 | [workflow](docs/05-workflow.md) | Workflow-as-template, lifecycle, ceremonies | +| 06 | [governance](docs/06-governance.md) | Ledger, gates, giới hạn thẩm quyền | +| 07 | [orchestrator](docs/07-orchestrator.md) | Phase 3: CLI `hatch` launch & drive agent | +| 08 | [roadmap](docs/08-roadmap.md) | Lộ trình Phase 1 → 2 → 3 | +| 09 | [knowledge-base](docs/09-knowledge-base.md) | KB dùng chung: cấu trúc, đọc/ghi, vs SSOT/ledger | +| 10 | [agent-adapters](docs/10-agent-adapters.md) | Compile surface + headless invocation từng agent CLI | +| 11 | [communication](docs/11-communication.md) | Agents nói chuyện trực tiếp: DM · ask/reply · convene | +| 12 | [ceremonies-escalation](docs/12-ceremonies-escalation.md) | Standup · retro · planning · escalation · decision→ADR | +| 13 | [management](docs/13-management.md) | (thiết kế) Workload · performance · budget/lương — góc CEO/CTO | +| 14 | [org-and-cadence](docs/14-org-and-cadence.md) | (thiết kế) Org-chart/uỷ quyền · external deps · heartbeat | +| 15 | [obsidian-kb](docs/15-obsidian-kb.md) | (thiết kế) Obsidian vault làm KB chính qua CLI | +| 16 | [document-templates](docs/16-document-templates.md) | (thiết kế) Template/spec tài liệu theo framework, custom được | +| 17 | [pre-implementation](docs/17-pre-implementation.md) | Quyết định cần chốt trước khi implement toàn bộ | +| 18 | [observability](docs/18-observability.md) | Quan sát agents: transcript · TUI · tmux/Zellij | +| 19 | [going-real](docs/19-going-real.md) | Chuyển từ mock sang agent CLI thật (doctor, creds, an toàn) | +| **20** | [**embedded-harness-pivot**](docs/20-embedded-harness-pivot.md) | **MÔ HÌNH HIỆN HÀNH** — Hatch là embedded harness (MCP + chat=backlog), agent tự lái; thắng khi mâu thuẫn | + +Sơ đồ trực quan: [overview](docs/overview.md) (bản đồ tổng plaintext) · [architecture-diagram](docs/architecture-diagram.md) (kiến trúc + workflow + sequence). + +Spec kỹ thuật: [registry](spec/registry.schema.md) · [ticket](spec/ticket.schema.md) · [ledger](spec/ledger.schema.md) · [workflow](spec/workflow.schema.md) + +## Cài đặt & Onboarding (user mới) + +### Yêu cầu +- **Go 1.24+** (build `hatch`) và **git** (Hatch dùng filesystem + git làm database). +- **Ít nhất một coding agent CLI** để chạy thật: Claude Code (`claude`), Codex (`codex`), Antigravity (`agy`), hoặc Kiro (`kiro-cli`). *Không bắt buộc đủ cả bốn* — `hatch doctor` chỉ cần ≥1 sẵn sàng. Muốn thử trước khi cài agent thì dùng demo bên dưới. + +### Bước 1 — Cài `hatch` + +```bash +# A. Nhanh nhất: build + dựng workspace demo để xem ngay (không cần agent thật) +git clone https://github.com/fioenix/overclaud && cd overclaud/hatch +./scripts/onboard.sh # build bin/hatch + demo trong .hatch-demo/ + +# B. Cài lên PATH +make install # go install ./cmd/hatch → $(go env GOPATH)/bin +# hoặc: go install github.com/fioenix/overclaud/hatch/cmd/hatch@latest +export PATH="$(go env GOPATH)/bin:$PATH" # nếu chưa có +hatch --version +``` + +### Bước 2 — Khởi tạo workspace (global mặc định, local override) + +`.hatch` phân tầng giống `~/.claude` của Claude Code: **một bản global ở `~/.hatch` làm mặc định**, và mỗi repo có thể tạo **`.hatch` local để override**. + +```bash +hatch init -w scrum # tạo ~/.hatch (GLOBAL, dùng chung mọi repo). 8 template: + # scrum kanban spec-first lite dual-track shape-up stage-gate incident +# → ~/.hatch (SSOT): charter.md · roles/ · registry.yaml · workflow.yaml · kb/ · ledger/ + +# Tùy chọn: override riêng cho một repo +cd /đường/dẫn/repo-của-bạn +hatch init --local -w scrum # tạo ./.hatch trong repo này, đè lên global +``` + +Cơ chế resolve: lệnh `hatch` tìm `.hatch` local (đi ngược lên từ thư mục hiện tại); không có thì dùng `~/.hatch` global. Đặt `HATCH_HOME` để đổi vị trí global. Sửa `charter.md` (sản phẩm) + `registry.yaml` (ai giữ vai gì) cho đúng đội. + +### Bước 3 — Compile SSOT → surfaces + đăng ký MCP + +```bash +hatch compile +# → CLAUDE.md · AGENTS.md · GEMINI.md · .kiro/steering/ (protocol prose cho từng agent) +# → .mcp.json · .kiro/settings/mcp.json (merge) + .hatch/mcp/* (snippet codex/agy) +hatch validate # kiểm tra registry + workflow +hatch doctor # agent CLI nào đã cài + đã đăng nhập (chỉ gọi lệnh auth, KHÔNG quét thư mục creds) +``` + +### Bước 4 — Nối client vào MCP bằng một lệnh: `hatch init --client` + +Thay vì dán config thủ công, để Hatch set up đúng cho từng client. Lệnh này compile rồi ghi đăng ký MCP vào đúng nơi client đó đọc (chạy được cả khi `.hatch` đã có; thêm `--dry-run` để xem trước): + +```bash +hatch init --client cc # Claude Code: ghi project .mcp.json + in hướng dẫn /plugin install +hatch init --client codex # Codex: gọi `codex mcp add hatch -- hatch mcp --as codex` (→ ~/.codex/config.toml) +hatch init --client agy # Antigravity CLI: merge ~/.gemini/config/mcp_config.json (HOME-level, runtime mới) +hatch init --client kiro # Kiro: ghi .kiro/settings/mcp.json +hatch init --client cc,codex # nhiều client một lần +``` + +- **`cc`** — ngoài `.mcp.json` (project-scope, đủ để chạy), gói **plugin** Claude Code (MCP + skill `hatch-chat` + slash `/hatch`) cài qua marketplace: + ``` + /plugin marketplace add fioenix/overclaud + /plugin install hatch@hatch + ``` + (hoặc dev: `claude --plugin-dir /đường/dẫn/overclaud/hatch/plugin`) +- **`codex`** — cần `codex` trên PATH để tự ghi; nếu không, lệnh in sẵn khối để dán vào `~/.codex/config.toml`. +- **`agy`** — Antigravity CLI (khác Gemini CLI legacy) nạp MCP **chỉ từ file HOME-level riêng**: `~/.gemini/config/mcp_config.json` (cũ: `~/.gemini/antigravity-cli/mcp_config.json`, nay symlink). Project-local `.antigravitycli/mcp_config.json` bị bỏ qua ([issue #60](https://github.com/google-antigravity/antigravity-cli/issues/60)). `--client agy` chỉ ghi khi bạn chủ động chạy; merge giữ nguyên server khác. +- Mọi client đều dùng đúng danh tính `--as ` lấy từ `registry.yaml`, nên orchestrator có thể là Claude, Codex hay agy tùy bạn gán vai `conductor`. + +### Bước 5 — Làm việc & quan sát + +```bash +# Mở coding agent NGAY TRONG workspace (vd Claude Code). Nó đọc CLAUDE.md + .mcp.json, +# tự nối vào chat + KB chung qua MCP, rồi tự lái: mở thread mỗi task, @tag đồng đội, ghi KB. +cd /đường/dẫn/repo-của-bạn && claude # hoặc codex / agy / kiro-cli + +# Bạn (con người) quan sát read-only ở terminal khác: +hatch board # mission control: THREADS (task) + CHAT + ledger +hatch chat # viewer hội thoại kiểu Slack +hatch status # tóm tắt thread + roster +hatch msg --from human:operator -c '#design' "@claude-code ưu tiên streaming" # chèn ý kiến +``` + +> Trên môi trường remote/CI không cài được agent thật: `./scripts/onboard.sh` dựng demo (post một thread mẫu, in `status`/`thread`) để bạn thấy luồng mà không tốn token. + +## CLI (`hatch`) + +Hatch là CLI viết bằng Go (single binary). Đây là **embedded harness**: agent là entrypoint, Hatch lo SSOT/chat/KB và phơi chúng qua MCP. Bộ lệnh mặc định (xem `internal/cli/root.go`): + +```bash +./scripts/onboard.sh # build + dựng demo workspace để thử ngay — hoặc `make onboard` +make build # → bin/hatch +bin/hatch init -w scrum # tạo ~/.hatch GLOBAL (mặc định). --local: tạo ./.hatch override trong repo +bin/hatch init --client codex # set up MCP cho 1 client: cc|codex|agy|kiro (compile vào repo hiện tại; --dry-run xem trước) +bin/hatch compile # SSOT → CLAUDE.md / AGENTS.md / GEMINI.md / .kiro/steering + # (protocol prose: workflow + chat etiquette + DoD self-check + khối orchestrator + # cho lead) + đăng ký MCP per-agent (.mcp.json · .kiro/settings/mcp.json merge; + # snippet .hatch/mcp/* cho codex/agy) +bin/hatch compile --check # CI: fail nếu output stale so với SSOT +bin/hatch validate # kiểm tra registry + workflow +bin/hatch mcp --as claude-code # MCP server (stdio) phơi tool chat + KB với danh tính agent này + # (--as bỏ trống → $HATCH_AGENT, rồi agent kind=claude đầu tiên) +bin/hatch status # read-only: tóm tắt thread chat (task) + roster agent +bin/hatch board # read-only TUI: THREADS + CHAT + ledger ACTIVITY +bin/hatch chat # read-only TUI: viewer hội thoại kiểu Slack +bin/hatch kb add --type decision --title "CSV streaming" --tags export +bin/hatch kb query export # kb add|query|index|link|backlinks|graph|open +bin/hatch msg --from human --channel '#design' "Streaming hay buffer?" # human inject vào chat +bin/hatch inbox claude-code # message gửi tới một agent +bin/hatch thread # xem một thread (task) +bin/hatch channel ls # channel ls|show|join|leave|members +bin/hatch search export # full-text qua chat +bin/hatch doc ... # doc templates · logs · org · sync · hook · doctor +``` + +Luồng dùng thực tế: + +``` +hatch init → hatch compile → mở coding agent NGAY TRONG workspace + (CLAUDE.md + .mcp.json đã wire nó vào chat + KB chung) → agent tự lái, làm việc qua MCP +Người xem bằng hatch board / hatch chat / hatch status; chèn ý kiến bằng hatch msg. +``` + +> **Lệnh archived (tùy chọn).** Bộ operator tự-lái (`run`, `plan`, `watch`, `tick`, orchestrator, workflow-engine `gate`/`escalate`/`ticket`, `ceremony`/`standup`, `ask`/`convene`, `pair`/`mob`, `presence`, `oncall`, `cost`/`budget`, `workload`/`perf`, `report`) **không** nằm trong binary mặc định. Chúng được archive sau build tag và chỉ build khi cần: `go build -tags hatch_legacy`. Khôi phục được, không bị xóa. + +## Trạng thái implement + +Pivot **embedded-harness** đã implement (xem [doc 20](docs/20-embedded-harness-pivot.md)): + +- **MCP server** (`hatch mcp --as `, stdio) trên bus/KB — tools whoami · chat_open · chat_post · chat_read · chat_inbox · chat_search · chat_channels · kb_add · kb_search (`internal/mcpserver`, `internal/cli/mcp.go`). +- **compile đổi mục đích**: tiêm protocol (charter + roles + workflow-prose + DoD self-check + chat etiquette + khối orchestrator cho lead) vào CLAUDE.md/AGENTS.md/GEMINI.md/.kiro, kèm đăng ký MCP per-agent (`.mcp.json`, `.kiro/settings/mcp.json` merge; snippet `.hatch/mcp/*` cho Codex/agy). +- **Claude plugin** tại `hatch/plugin/` (MCP + skill `hatch-chat` + slash `/hatch`); `.claude-plugin/marketplace.json` ở repo root. +- **board/chat/status read-only**: board TUI = THREADS + CHAT + ledger ACTIVITY; chat = viewer; status = tóm tắt thread + roster. Không còn run/claim/compose. +- **Operator tự-lái archived** sau build tag `hatch_legacy` (không vào binary mặc định, khôi phục được). +- Cả build mặc định **và** `-tags hatch_legacy` đều compile/vet/test green. + +Có unit + integration test, CI, Makefile. Mã nguồn: `cmd/hatch` + `internal/`. Thiết kế gốc (trước pivot) trong `docs/00`–`19`; mô hình hiện hành ở `docs/20`. Kiến trúc (Lean Hexagonal, ports & adapters): [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/hatch/cmd/hatch-mock/main.go b/hatch/cmd/hatch-mock/main.go new file mode 100644 index 0000000..38012ad --- /dev/null +++ b/hatch/cmd/hatch-mock/main.go @@ -0,0 +1,60 @@ +// Command hatch-mock is a stand-in coding agent for testing Hatch end-to-end +// without a real agent CLI installed. It reads a prompt (from --prompt or +// stdin) and prints a short, deterministic reply shaped by the prompt's role +// cues, so execute/relay/pair/convene flows can be exercised in CI/remote. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" +) + +func main() { + prompt := flag.String("prompt", "", "task/prompt (else read from stdin)") + flag.Parse() + + text := *prompt + if text == "" { + if b, _ := io.ReadAll(os.Stdin); len(b) > 0 { + text = string(b) + } + } + fmt.Println(reply(text)) + // Emit a fake usage line so cost-capture is exercised end-to-end. + tokens := 200 + len(text) + fmt.Printf("{\"total_cost_usd\": %.4f, \"total_tokens\": %d}\n", float64(tokens)/100000.0, tokens) +} + +// reply returns a deterministic mock response based on cues in the prompt. +func reply(p string) string { + first := firstLine(p) + switch { + case strings.Contains(p, "NAVIGATOR"): + return "READY (mock): scope khớp, test ổn — chuyển review được." + case strings.Contains(p, "phân xử") || strings.Contains(p, "CHƯA chốt"): + return "DECISION: (mock) chọn phương án đơn giản nhất, đủ đáp ứng yêu cầu." + case strings.Contains(p, "DRIVER"): + return "(mock) Đã thêm một bước nhỏ theo ticket; cần navigator soi phần lỗi biên." + case strings.Contains(p, "vòng") || strings.Contains(p, "họp"): + return "(mock) Tán thành hướng hiện tại; lưu ý cover ca biên." + default: + return "(mock) Đã xử lý: " + truncate(first, 80) + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/hatch/cmd/hatch/main.go b/hatch/cmd/hatch/main.go new file mode 100644 index 0000000..6d63559 --- /dev/null +++ b/hatch/cmd/hatch/main.go @@ -0,0 +1,16 @@ +// Command hatch is the CLI entry point. +package main + +import ( + "fmt" + "os" + + "github.com/fioenix/overclaud/hatch/internal/cli" +) + +func main() { + if err := cli.NewRoot().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "hatch: "+err.Error()) + os.Exit(1) + } +} diff --git a/hatch/docs/00-vision.md b/hatch/docs/00-vision.md new file mode 100644 index 0000000..9100007 --- /dev/null +++ b/hatch/docs/00-vision.md @@ -0,0 +1,53 @@ +# 00 — Vision + +## Vấn đề + +Một developer hôm nay không còn dùng một agent. Họ có Claude Code làm chính, thêm Codex, Kiro, Antigravity CLI cho những việc khác nhau. Nhưng khi nhiều agent cùng đụng vào **một repo**, ba thứ vỡ ra: + +1. **Instructions phân mảnh & drift.** Mỗi agent đọc một file khác nhau (`CLAUDE.md`, `AGENTS.md`, `.kiro/steering/`…). Người dùng phải viết tay từng cái, rồi chúng lệch nhau theo thời gian. Cùng một "code style" được phát biểu 4 lần, 4 phiên bản hơi khác. + +2. **Lãng phí token.** Không có ai phân tầng context. Hoặc mỗi agent đọc tất cả (phình context mỗi message), hoặc đọc thiếu (làm sai). Không có khái niệm "agent này chỉ cần biết phần này". + +3. **Hỗn loạn khi cùng làm.** Hai agent sửa cùng một file. Không ai biết agent kia đang làm gì. Không có hàng đợi việc, không có hand-off, không có audit trail. Kết quả là conflict, việc trùng, và mất dấu vết "ai làm gì vì sao". + +overclaud đã giải quyết vế (1) và (2) cho **một** agent (Claude) qua kiến trúc layering + token optimization. Hatch mở rộng cả ba vế cho **nhiều** agent. + +## Tầm nhìn + +> Một repo, nhiều agent, vận hành như một squad Agile gọn — có charter chung, vai rõ ràng, bảng việc, quy ước phối hợp, và sổ ghi đầy đủ — với chi phí token tối thiểu vì mỗi agent chỉ nạp đúng phần của mình. + +Hatch là **harness**: bộ giàn giáo bao quanh để *ràng buộc* (mỗi agent biết vai, ranh giới, context của mình) và *điều phối* (các agent phối hợp qua artifact chung, không giẫm chân nhau). + +## Nguyên tắc neo: mô phỏng một squad người + +Đây không phải ẩn dụ trang trí — nó là **nguyên tắc thiết kế ràng buộc**. Mỗi khi thiết kế một cơ chế Hatch, ta hỏi: *"Một đội người làm việc này ra sao?"* và sao chép cấu trúc đó, vì: + +- Quy trình squad người đã được tối ưu hàng chục năm cho đúng bài toán: nhiều tác nhân thông minh nhưng **không chia sẻ bộ nhớ**, phối hợp qua **artifact bên ngoài** (bảng việc, tài liệu, code review), bất đồng bộ. +- Coding agent cũng đúng đặc tính đó: thông minh, không chung memory, mỗi con một process. Nên giải pháp của loài người áp gần như 1:1. + +Hệ quả thiết kế trực tiếp: +- Agent **không gọi trực tiếp** agent khác (người cũng không "ghi đè bộ nhớ" đồng nghiệp) — phối hợp qua board + ledger. +- Có **một người điều phối** lập kế hoạch (Conductor = EM/Scrum Master). +- Mỗi việc là một **ticket** có vòng đời rõ ràng. +- Mọi thay đổi trạng thái để lại **dấu vết** (ledger = standup notes + git log). +- Người mới vào được phát **handbook đúng vai** (compiler = onboarding). +- Tri thức tích lũy vào **wiki chung** (KB) — không đọc được não nhau, nhưng cùng tra/cập nhật một chỗ. +- **Vai trò và quy trình do đội tự định ở mỗi project** — không áp một khuôn cứng cho mọi nơi. + +## Mục tiêu đo được + +| Mục tiêu | Chỉ số | +|---|---| +| Hết drift instruction | 1 nguồn canonical, N output compiled tự động, 0 chỉnh tay file output | +| Tiết kiệm token | Mỗi agent nạp ≤ L0+L1+ticket; không nạp context ngoài vai | +| Không giẫm chân | 0 ticket bị 2 agent claim cùng lúc; branch-per-ticket | +| Audit đầy đủ | Mọi chuyển trạng thái ticket có entry ledger (who/what/when/why) | +| Onboard agent mới nhanh | Thêm 1 agent = 1 dòng `registry.yaml` + 1 lần compile | +| Tri thức không bốc hơi | Quyết định/bài học ghi vào KB; agent sau tra cứu thay vì dò lại | +| Linh hoạt per-project | Vai trò + workflow cấu hình riêng từng project, không sửa code | + +## Ngoài phạm vi (bản đầu) + +- Không tự huấn luyện/đánh giá model. +- Không thay thế CI/CD — Hatch *gọi* gate (test/lint/review) chứ không tự là CI. +- Không quản lý multi-repo (bản đầu giả định một repo; multi-repo là tương lai). diff --git a/hatch/docs/01-architecture.md b/hatch/docs/01-architecture.md new file mode 100644 index 0000000..ecf409c --- /dev/null +++ b/hatch/docs/01-architecture.md @@ -0,0 +1,118 @@ +# 01 — Architecture + +## Bảy trụ + +``` + ┌─────────────────────────────┐ + │ ORCHESTRATOR (hatch) │ ← Phase 3: EM/Scrum Master + │ plan · run · gate · status │ + └──────────────┬──────────────┘ + │ đọc/ghi + ┌──────────────────────────┼──────────────────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────────┐ ┌────────────────┐ + │ CONTEXT │ compile │ BOARD │ ghi vào │ LEDGER │ + │ (SSOT) │ ────────► │ (tickets) │ ───────► │ (audit append) │ + └─────┬──────┘ └───────┬────────┘ └────────────────┘ + │ │ claim/execute ▲ + ▼ sinh ra ▼ │ events + ┌──────────────────────┐ ┌──────────────────────────────────┐ + │ CLAUDE.md / AGENTS.md │ │ AGENTS theo ROLES (registry) │ + │ .kiro/steering/ … │◄──│ Architect·Implementer·Reviewer │ + └──────────────────────┘ └───────────────┬──────────────────┘ + ▲ đọc ▲ │ ghi (learnings/ADR) + │ │ ▼ + │ ┌──────┴────────────────────┐ + │ │ KNOWLEDGE BASE (kb/) │ ← bộ nhớ chung, đọc+ghi + │ └────────────────────────────┘ + └──────────── PROTOCOL + WORKFLOW (working agreements) ───────────┘ +``` + +1. **Context (SSOT)** — nguồn canonical duy nhất, *đầu vào* compile (con người/Architect chủ yếu ghi). +2. **Knowledge Base** (`kb/`) — bộ nhớ chung; agent đọc *và* ghi. SSOT là "config vào", KB là "tri thức vào-ra", Ledger là "sự kiện ra" (xem [09-knowledge-base](09-knowledge-base.md)). +3. **Compiler** — sinh file instruction native cho từng agent từ SSOT. +4. **Roles + Registry** — định nghĩa vai và gán agent vào vai (cấu hình per-project). +5. **Board + Workflow** — hàng đợi ticket; lane/transition do `workflow.yaml` định nghĩa (template sửa được). +6. **Ledger + Protocol** — sổ audit append-only + quy ước phối hợp (claim/lock, handoff, branching, DoD). +7. **Orchestrator** — (Phase 3) CLI điều khiển toàn bộ. + +### Ba kho tri thức — đừng lẫn + +| Kho | Hướng | Ai ghi | Ví dụ | +|---|---|---|---| +| `context/` (SSOT) | vào (compile → agent) | Human / Architect | conventions, PRD, stack | +| `kb/` (Knowledge Base) | vào **và** ra | Mọi agent | ADR, bài học, gotcha, ghi chú domain | +| `ledger/` | ra (sự kiện) | Mọi agent | "ai claim/làm/gate cái gì, vì sao" | + +## Layout `.hatch/` trong repo đích + +``` +.hatch/ +├── charter.md # L0 — mission chung, nhỏ gọn (mọi agent đều nạp) +├── registry.yaml # roster agent + năng lực + binding vai (per-project) +├── workflow.yaml # quy trình: lane, transition, gate, ceremony (template, sửa được) +│ +├── roles/ # L1 — context theo vai, mỗi vai 1 file (user định nghĩa) +│ ├── architect.md +│ ├── implementer.md +│ ├── reviewer.md +│ ├── tester.md +│ └── ... +│ +├── context/ # SSOT — tri thức canonical (config), compile ra ngoài +│ ├── product/ # PRD, domain, business rules +│ ├── tech/ # stack, conventions, kiến trúc +│ └── shared.md # những gì mọi vai cần +│ +├── kb/ # KNOWLEDGE BASE — bộ nhớ chung, agent đọc+ghi +│ ├── decisions/ # ADR — quyết định kiến trúc + lý do +│ ├── domain/ # tri thức nghiệp vụ tích lũy +│ ├── learnings/ # bài học, gotcha, pitfall đã gặp +│ ├── index.md # mục lục để tra cứu nhanh (tiết kiệm token) +│ └── .meta.json # tag/owner/updated cho từng mục +│ +├── board/ # tickets — mỗi ticket 1 file .md có frontmatter +│ ├── backlog/ +│ ├── in-progress/ +│ ├── review/ +│ └── done/ +│ +├── ledger/ # audit append-only (1 file / ngày hoặc / ticket) +│ └── 2026-06-14.md +│ +├── protocol/ # working agreements +│ ├── handoff.md +│ ├── claim-lock.md +│ ├── branching.md +│ └── definition-of-done.md +│ +└── compiled/ # output sinh tự động (KHÔNG sửa tay) + └── .manifest.json # hash nguồn → phát hiện stale +``` + +Compiler ghi ra **vị trí native** mà mỗi agent mong đợi (ngoài `.hatch/`): + +``` +repo/ +├── CLAUDE.md ← compiled cho Claude Code +├── AGENTS.md ← compiled cho Codex +├── .kiro/steering/*.md ← compiled cho Kiro +└── ← compiled cho Antigravity +``` + +## Quan hệ với overclaud + +overclaud cung cấp **lý thuyết layering + token-optimization + templates** cho riêng Claude. Trong Hatch: + +- overclaud trở thành **một backend của compiler** — phần sinh ra `CLAUDE.md`/`.claude/` tái dùng templates và nguyên tắc token của overclaud. +- Các nguyên tắc "mỗi từ phải đáng giá token", "đúng scope đúng nơi" được nâng từ *1 agent × N surface* lên *N agent × M surface*. + +## Trạng thái = nguồn sự thật phân tán nhưng hội tụ + +Hatch không có database. **Hệ thống file + git LÀ database**: +- Vị trí thư mục của ticket (`backlog/` vs `in-progress/`) = trạng thái. +- Frontmatter ticket = metadata. +- Ledger = lịch sử bất biến. +- git = transaction log + cơ chế lock tự nhiên (xem [protocol](03-coordination-protocol.md)). + +Lợi ích: con người đọc được, agent đọc được, diff được, review được qua PR, không cần hạ tầng thêm. diff --git a/hatch/docs/02-roles.md b/hatch/docs/02-roles.md new file mode 100644 index 0000000..88ed5dc --- /dev/null +++ b/hatch/docs/02-roles.md @@ -0,0 +1,73 @@ +# 02 — Roles + +## Tách bạch: Vai ≠ Agent + +Nguyên tắc cốt lõi, mượn từ squad người: **vai (role)** là một bộ trách nhiệm + ranh giới + context; **agent** là một thực thể thực thi. Một người có thể đảm nhiều vai; một vai có thể do nhiều người luân phiên. Hatch giữ đúng tách bạch này: + +- **Role** định nghĩa: làm gì, được phép làm gì, nạp context nào (L1). +- **Agent** định nghĩa: năng lực, lệnh CLI, surface đọc instruction. +- **Registry** là bảng phân công nối hai cái lại. + +Đổi agent cho một vai = sửa 1 dòng registry + compile lại. Không phải viết lại context. + +> **Vai là cấu hình per-project, không fix cứng.** `registry.yaml` sống trong `.hatch/` của *từng project*. Cùng một agent (vd Codex) có thể là `Implementer` ở project A, `Tester` ở project B, hoặc kiêm cả hai ở project C — do **người dùng quyết định ở từng project**. Bộ vai chuẩn + bảng map dưới đây chỉ là **template khởi đầu** để khỏi bắt đầu từ số 0; user sửa thoải mái. + +## Bộ vai chuẩn (template khởi đầu) + +Lấy theo một squad sản phẩm điển hình — copy rồi sửa cho project của bạn: + +| Vai | Trách nhiệm | Ranh giới (KHÔNG được) | Context L1 nạp | +|---|---|---|---| +| **Conductor** | Lập kế hoạch, bẻ epic→ticket, gán vai/agent, gỡ block, chạy ceremonies | Tự viết code production lớn; tự merge | charter + board tổng + roadmap | +| **Architect** | Spec kỹ thuật, design, ADR, đặt ràng buộc | Bỏ qua review; quyết business | tech/ + product/ | +| **Implementer** | Viết code theo ticket + DoD | Đổi scope ticket; sửa file ngoài ticket | role + ticket + context_refs | +| **Reviewer** | Review diff, gác DoD, phê duyệt | Tự sửa rồi tự duyệt (xung đột lợi ích) | tech/ conventions + ticket + DoD | +| **Tester** | Viết/chạy test, báo cáo, dựng repro | Sửa code production để test pass | tech/test + ticket | +| **Tech Writer** | Docs, changelog, runbook | Đổi hành vi code | product/ + ticket | + +Người dùng thêm/bớt/đổi tên vai tùy project (vd: `SecurityReviewer`, `DataEngineer`, `PromptEngineer`). Một project nhỏ có thể chỉ cần 2 vai (Implementer + Reviewer); một project lớn có thể có 8 vai. + +## Map năng lực → agent (template gợi ý, override per-project) + +Phân vai theo điểm mạnh quan sát được của từng agent. Đây **chỉ là gợi ý xuất phát**; người dùng tự đặt lại trong `registry.yaml` của mỗi project. + +| Agent | Vai phù hợp | Lý do | +|---|---|---| +| **Claude Code** | Conductor, Architect, Reviewer | Reasoning dài, giữ context lớn, phán đoán trade-off, planning | +| **Kiro** | Architect (spec), Implementer | Quy trình spec-driven PRD→design→tasks bài bản | +| **Codex** | Implementer, Tester | Vòng lặp code-test nhanh, autonomous | +| **Antigravity CLI** | Implementer, Tech Writer, Utility | Linh hoạt, gánh việc nền | + +### Vì sao CC làm Conductor + +Trong Hybrid, Conductor là vai cần bức tranh tổng thể + phán đoán ưu tiên — đúng điểm mạnh CC, và CC là agent chính của người dùng nên giữ vai trung tâm hợp lý. Conductor **không độc quyền code**; nó điều phối rồi giao cho workers. + +## Một agent giữ nhiều vai + +Hoàn toàn được, như một dev kiêm reviewer cho ticket người khác. Ràng buộc đạo đức (mượn từ người): **không tự review chính mình**. Registry enforce: nếu `assignee` của ticket = agent X ở vai Implementer, thì vai Reviewer của ticket đó phải là agent ≠ X (hoặc human). + +## Binding trong registry (xem [spec](../spec/registry.schema.md)) + +```yaml +agents: + claude-code: + cli: "claude" + surface: ["CLAUDE.md", ".claude/"] + roles: [conductor, architect, reviewer] + kiro: + cli: "kiro" + surface: [".kiro/steering/"] + roles: [architect, implementer] + codex: + cli: "codex" + surface: ["AGENTS.md"] + roles: [implementer, tester] + antigravity: + cli: "ag" + surface: [""] + roles: [implementer, tech-writer] + +policy: + no-self-review: true # implementer ≠ reviewer trên cùng ticket + require-human-gate: [deploy, secret, external-comms] +``` diff --git a/hatch/docs/03-coordination-protocol.md b/hatch/docs/03-coordination-protocol.md new file mode 100644 index 0000000..6420c0a --- /dev/null +++ b/hatch/docs/03-coordination-protocol.md @@ -0,0 +1,93 @@ +# 03 — Coordination Protocol + +Đây là trái tim của Hatch: làm sao nhiều agent **không chung bộ nhớ, mỗi con một process** phối hợp mà không giẫm chân. Câu trả lời mượn nguyên từ squad người: **phối hợp qua artifact chung, bất đồng bộ, có quy ước rõ.** + +## Mô hình Hybrid + +``` + ┌──────────── PLAN (đồng bộ, định kỳ) ────────────┐ + │ Conductor: epic → tickets, gán vai/agent, │ + │ set ưu tiên + dependency, đẩy vào backlog/ │ + └────────────────────┬────────────────────────────┘ + │ + ┌────────────────────▼──────── EXECUTE (bất đồng bộ) ──────────┐ + │ Workers tự CLAIM ticket trong lane vai mình → │ + │ làm trên branch riêng → ledger → đẩy sang review/ │ + │ Reviewer GATE → done/ ; bất kỳ ai gặp block → mở blocker │ + └──────────────────────────────────────────────────────────────┘ +``` + +- **Pha PLAN** giống sprint planning: tập trung, do Conductor chủ trì, sinh ra ticket có chủ. +- **Pha EXECUTE** giống ngày thường: workers tự kéo việc, async, phối hợp qua board + ledger. + +**Điều phối công việc** đi qua artifact: không agent nào tự ý ghi đè trạng thái của agent khác — "giao tiếp" về *công việc* là ghi/đọc file (board + ledger), như dev comment lên Jira/PR thay vì ghi đè não nhau. + +> **Nhưng đội người còn nói chuyện trực tiếp** — hỏi nhau, @mention, họp. Hatch có tầng [communication](11-communication.md) riêng cho *đối thoại* (DM, ask/reply đồng bộ, convene họp), qua bus + orchestrator và **luôn on-the-record**. Phân vai rạch ròi: bus chở *dialogue*, board/ledger chở *state*. Không phải peer-socket ngầm — phương tiện chung có ghi âm, đúng như Slack/phòng họp của một đội thật. + +## Board như một state machine + +Trạng thái ticket = thư mục chứa nó: + +``` +backlog/ → in-progress/ → review/ → done/ + │ ↑ + └──── blocked/ ──┘ (lane phụ; Conductor gỡ) +``` + +Chuyển trạng thái = `git mv` file ticket sang thư mục mới + cập nhật frontmatter + ghi ledger. Một chuyển = một commit. Diff của commit chính là biên bản. + +## Claim / Lock — chống hai agent cùng một việc + +git là cơ chế lock tự nhiên. Quy ước: + +1. **Claim:** worker muốn nhận ticket → `git mv backlog/T-123.md in-progress/` + set frontmatter `assignee: `, `claim: {agent, ts, branch}` → commit → **push ngay**. +2. **Lock thắng = push thắng.** Nếu hai agent claim cùng lúc, người push trước thắng; người thua bị reject khi push (non-fast-forward), pull về thấy ticket đã có chủ → bỏ, claim cái khác. +3. **Branch-per-ticket:** mỗi ticket làm trên `hatch/T-123-`. Cô lập file, tránh đụng working tree. (Phase 3: dùng git worktree để chạy song song thật.) +4. **TTL claim:** nếu một claim quá `stale_after` (vd 2h) không có ledger update → Conductor được quyền thu hồi (giống reassign khi đồng nghiệp mất tích). + +Chi tiết: [protocol/claim-lock](#) — sẽ sinh vào `.hatch/protocol/claim-lock.md` khi init. + +## Hand-off giữa các vai + +Hand-off = ticket đổi `role` + đổi thư mục, kèm một **handoff note** trong ledger nêu: đã làm gì, còn gì, cần gì ở vai sau. + +Ví dụ chuỗi một ticket feature: +``` +Architect (design xong) ──► Implementer (code) ──► Reviewer (gate) ──► done + ↑ handoff note: spec ở đâu, ràng buộc gì + ↑ handoff note: branch nào, test nào chạy +``` + +Handoff note bắt buộc khi đổi assignee — đây là "context tối thiểu để người sau không phải đọc lại toàn bộ", chính là cơ chế tiết kiệm token ở tầng phối hợp. + +## Dependency & thứ tự + +Ticket khai báo `depends_on: [T-100, T-101]`. Worker chỉ được claim khi mọi dependency đã ở `done/`. Conductor dùng đồ thị dependency để xếp ưu tiên ở pha PLAN. Vòng lặp dependency = lỗi, Conductor phát hiện lúc plan. + +## Blocker + +Bất kỳ vai nào gặp chặn → set `status: blocked`, `git mv` sang `blocked/`, ghi ledger nêu lý do + cần gì. Conductor quét `blocked/` mỗi lần plan/standup, gỡ hoặc leo thang cho human. + +## Definition of Done (DoD) + +Mỗi ticket kế thừa DoD mặc định + DoD riêng. Reviewer gác đúng checklist này. Mặc định: + +- [ ] Code khớp scope ticket, không đụng file ngoài `context_refs` không khai báo +- [ ] Test liên quan pass (Tester/CI xác nhận) +- [ ] Lint/format sạch +- [ ] Diff được review bởi agent ≠ implementer (no-self-review) +- [ ] Ledger có entry hoàn thành + handoff note +- [ ] Tri thức đáng giữ (ADR/gotcha/rule) đã ghi vào [KB](09-knowledge-base.md) — nếu ticket có hàm lượng tri thức cao +- [ ] Branch có PR (human gate cho merge) + +Xem thêm [governance](06-governance.md) về gate trước merge. + +## Vì sao async-board, không phải agent gọi agent + +| Tiêu chí | Async board (chọn) | Gọi trực tiếp | +|---|---|---| +| Heterogeneous CLI | Không cần API chung | Cần protocol liên-CLI (không tồn tại) | +| Audit | Mọi thứ là commit | Khó truy vết | +| Lỗi cô lập | Một agent chết không kéo cả hệ | Caller treo theo | +| Human đọc xen được | Có (đọc/sửa file) | Khó | +| Token | Chỉ nạp ticket cần | Dễ nhồi cả hội thoại | diff --git a/hatch/docs/04-context-compiler.md b/hatch/docs/04-context-compiler.md new file mode 100644 index 0000000..448eaa2 --- /dev/null +++ b/hatch/docs/04-context-compiler.md @@ -0,0 +1,93 @@ +# 04 — Context & Compiler + +Hai mục tiêu overclaud nâng lên multi-agent: **hết drift** (1 nguồn → N output) và **tiết kiệm token** (mỗi agent nạp tối thiểu). + +## SSOT vs Knowledge Base — hai loại tri thức + +Hatch tách hai loại tri thức vì chúng có vòng đời và quyền ghi khác nhau: + +| | **SSOT** (`context/`, `charter.md`, `roles/`) | **Knowledge Base** (`kb/`) | +|---|---|---| +| Là gì | *Config/chuẩn* để định hình agent | *Tri thức tích lũy* khi làm việc | +| Hướng | Đầu vào → **compile** vào prompt | Vào **và** ra; tra cứu on-demand | +| Ai ghi | Human / Architect (cẩn trọng, ít đổi) | Mọi agent (ghi liên tục khi học được) | +| Bị compile? | **Có** → `CLAUDE.md`/`AGENTS.md`… | **Không** — đọc động qua index/truy vấn | + +Doc này nói về SSOT + compile. Chi tiết KB ở [09-knowledge-base](09-knowledge-base.md). + +## Single Source of Truth (SSOT) + +Tất cả tri thức canonical (config) sống một nơi: `.hatch/context/` + `.hatch/charter.md` + `.hatch/roles/`. **Không bao giờ** viết tay vào `CLAUDE.md`/`AGENTS.md`/`.kiro/steering/` — chúng là output sinh ra. + +``` +.hatch/charter.md → mission, value, ràng buộc tối cao (L0) +.hatch/roles/.md → trách nhiệm + ranh giới + cách làm (L1) +.hatch/context/ + ├── product/ → PRD, domain, business rules + ├── tech/ → stack, conventions, kiến trúc, ADR + └── shared.md → điều mọi vai cần biết +``` + +## Ba tầng token (L0 / L1 / L2) + +Mô phỏng cách một nhân viên thật mang theo thông tin: ai cũng thuộc *mission công ty* (L0), nắm *JD của mình* (L1), và chỉ mở *hồ sơ việc đang làm* khi cần (L2). + +| Tầng | Nội dung | Ai nạp | Khi nào | +|---|---|---|---| +| **L0** Mission | charter — rất ngắn | Mọi agent, mọi message | Luôn (nằm trong file compiled) | +| **L1** Role | role file của (các) vai agent giữ | Agent theo vai | Luôn (trong file compiled) | +| **L2** Task | ticket active + `context_refs` + **mục KB liên quan** | Agent đang làm ticket | On-demand, chỉ khi claim | + +**Nguyên tắc vàng:** file compiled (luôn nạp mỗi message) chỉ chứa L0 + L1 + **con trỏ** tới L2. L2 (gồm cả tra cứu KB) được agent đọc *khi* nhận ticket, không nhồi sẵn. Đây là khác biệt token lớn nhất so với "một CLAUDE.md chứa tất cả". KB cũng giúp tiết kiệm token theo cách khác: agent **tra cứu quyết định/bài học có sẵn** thay vì suy diễn lại từ đầu. + +### Ước lượng tiết kiệm + +Giả sử context đầy đủ ~ 8.000 token. Naïve: mỗi agent nạp tất cả mỗi message. + +| Cách | Token/message/agent | +|---|---| +| Naïve (nạp tất cả) | ~8.000 | +| Hatch (L0 ~300 + L1 ~700 + con trỏ) | ~1.000; L2 chỉ nạp khi cần | + +Với 4 agent × nhiều message/ngày, chênh lệch tích lũy rất lớn (xem bảng lãng phí token trong [overclaud README](../../README.md)). + +## Compiler: SSOT → per-agent + +``` + ┌──────────────┐ + charter (L0) ─────┤ │ + roles (L1) ──────┤ COMPILER ├──► CLAUDE.md (backend: overclaud) + registry binding ─┤ ├──► AGENTS.md (backend: codex) + context (con trỏ)─┤ ├──► .kiro/steering/*.md (backend: kiro) + └──────┬───────┘──► (backend: antigravity) + ▼ + compiled/.manifest.json (hash nguồn để phát hiện stale) +``` + +### Quy trình compile cho mỗi agent + +1. Đọc registry → biết agent giữ vai gì, surface nào. +2. Ghép `charter` (L0) + role file của các vai đó (L1) + danh sách con trỏ context (đường dẫn tới `context/`, không nhúng nội dung). +3. Áp **adapter theo agent** (định dạng/cú pháp native): + - Claude Code → cú pháp `CLAUDE.md`, có thể tách `.claude/rules/` (tái dùng templates overclaud). + - Codex → `AGENTS.md`. + - Kiro → nhiều file `.kiro/steering/` (Kiro thích tách mảnh + spec). + - Antigravity → theo config của nó. +4. Áp **token pass** (nguyên tắc overclaud: bỏ thừa, mỗi từ đáng giá). +5. Ghi output + cập nhật `manifest` (hash nguồn). + +### Adapter là điểm mở rộng + +Thêm một agent mới = viết một adapter (SSOT → định dạng của nó) + thêm dòng registry. Phần SSOT không đổi. Đây là cách Hatch giữ "viết một lần, chạy mọi agent". + +## Stale detection + +`manifest.json` lưu hash của SSOT lúc compile gần nhất. Nếu SSOT đổi mà chưa compile lại → file output là **stale**. Cảnh báo: +- Phase 1: lệnh/checklist thủ công + git pre-commit hook gợi ý. +- Phase 2/3: `hatch compile --check` chặn ở CI / pre-commit; orchestrator tự compile trước khi `run`. + +## Quy tắc bất biến + +1. Output (`CLAUDE.md`, `AGENTS.md`, `.kiro/steering/`…) **không sửa tay** — sửa SSOT rồi compile. +2. Mỗi mẩu tri thức sống **đúng một chỗ** trong SSOT (DRY). Nhiều vai cần → để ở `shared.md` hoặc `context/`, role file chỉ trỏ tới. +3. File compiled = L0 + L1 + con trỏ; **không nhúng L2**. diff --git a/hatch/docs/05-workflow.md b/hatch/docs/05-workflow.md new file mode 100644 index 0000000..33423fc --- /dev/null +++ b/hatch/docs/05-workflow.md @@ -0,0 +1,92 @@ +# 05 — Workflow + +> **Workflow là template, không phải luật cứng.** Mọi thứ trong doc này là **mặc định** mà Hatch ship sẵn. Người dùng có toàn quyền **thiết kế lại workflow ở từng project** — đổi lane, transition, gate, ceremony — qua `.hatch/workflow.yaml` (xem [spec/workflow](../spec/workflow.schema.md)). Board lanes, điều kiện chuyển trạng thái, và gate đều *đọc từ* file này; sửa file = đổi quy trình, không cần sửa code. + +Mặc định, Hatch ghép hai chuẩn product development: **Agile** (vòng lặp, ticket, ceremonies) và **spec-driven** (PRD → design → tasks, kiểu Kiro). Cả hai đều ánh xạ về artifact file + chuyển trạng thái board. + +## Lifecycle tổng (mặc định) + +``` +CHARTER ─► SPEC ─► BACKLOG ─► SPRINT ─► IN-PROGRESS ─► REVIEW ─► DONE ─► RETRO + │ │ │ │ │ │ │ │ + L0 Architect Conductor Conductor Workers Reviewer merge Conductor + (+compaction) +``` + +1. **Charter** — định mission/ràng buộc (L0). Hiếm khi đổi. +2. **Spec** — với feature lớn, Architect viết spec-driven: PRD → Design → Tasks (xem dưới). +3. **Backlog** — Conductor bẻ spec/epic thành ticket có `role`, `priority`, `depends_on`. +4. **Sprint** — Conductor chọn tập ticket cho chu kỳ, đẩy lên đầu backlog. +5. **In-Progress** — workers claim & code (xem [protocol](03-coordination-protocol.md)). +6. **Review** — Reviewer gác DoD. +7. **Done** — merge sau human gate; ticket vào `done/`. +8. **Retro** — Conductor đúc kết, **nén ledger**, cập nhật SSOT nếu học được điều mới. + +## Spec-driven cho feature lớn (mượn Kiro) + +Một epic lớn không vào thẳng backlog mà qua 3 artifact, mỗi cái là một cổng: + +``` +product/epics/E-12/ +├── prd.md # WHY + WHAT: vấn đề, user, yêu cầu, tiêu chí chấp nhận +├── design.md # HOW: kiến trúc, data model, API, trade-off, ADR +└── tasks.md # ticket breakdown → Conductor sinh ra board/backlog/* +``` + +- `prd.md` do Conductor/PO + Architect. Gate: human duyệt PRD. +- `design.md` do Architect. Gate: review design trước khi code. +- `tasks.md` → Conductor sinh ticket. Mỗi ticket trỏ ngược về `design.md` qua `context_refs` (đây là L2 của nó). + +Feature nhỏ/bug đi tắt: tạo ticket thẳng vào backlog, bỏ qua PRD/Design. + +## Ceremonies → cơ chế async + +Mỗi ceremony Agile có một đối ứng máy trong Hatch. Không họp đồng bộ — thay bằng artifact. + +| Ceremony người | Đối ứng Hatch | Sinh ra | +|---|---|---| +| Sprint Planning | Conductor chạy pha PLAN | tickets có chủ trong backlog/ | +| Daily Standup | **Ledger digest** — Conductor tóm tắt ledger từ lần trước | trạng thái + blocker nổi lên | +| Sprint Review | Tổng hợp `done/` trong chu kỳ | bản tổng kết / changelog | +| Retro | Conductor đọc ledger → bài học | cập nhật SSOT/protocol + nén ledger | +| Backlog Grooming | Conductor xếp lại ưu tiên + dependency | backlog gọn | + +### Standup = ledger digest (cơ chế token quan trọng) + +Thay vì mỗi agent đọc lại toàn bộ lịch sử (tốn token), Conductor định kỳ đọc ledger thô và viết một **digest ngắn**: ai đang làm gì, gì xong, gì block. Digest này là "trí nhớ chung nén lại" — agent đọc digest thay vì lịch sử đầy đủ. Giống standup người: 15 phút thay vì đọc hết Jira. + +## Định nghĩa "sprint" linh hoạt + +Hatch không ép thời lượng. "Sprint" = một tập ticket Conductor cam kết cho một chu kỳ (có thể là một phiên làm việc, một ngày, hay một tuần). Cái cần là **ranh giới rõ** để có điểm review + retro + nén ledger. + +## Workflow-as-template: thiết kế lại per-project + +Lifecycle trên là khung **mặc định**. Người dùng chọn một **template có sẵn** hoặc **viết workflow riêng** trong `.hatch/workflow.yaml`. Hatch ship các template phủ các phong cách product development lifecycle phổ biến: + +| Template | Lanes | Hợp với | +|---|---|---| +| **`scrum`** (mặc định) | backlog → in-progress → review → done | sprint cố định + retro | +| **`kanban`** | + WIP limit | luồng liên tục, không sprint | +| **`spec-first`** | + spec | mọi epic qua requirements→design→tasks | +| **`lite`** | todo → doing → done | project nhỏ/cá nhân | +| **`dual-track`** | ideas → discovery → ready → … | dual-track agile: discovery ∥ delivery | +| **`shape-up`** | pitch → bet → building → … | chu kỳ cố định, cược scope đã shaped | +| **`stage-gate`** | requirements → design → build → test → release | PDLC phân pha, tuân thủ/audit nặng | +| **`incident`** | detected → triage → mitigating → resolved → postmortem | ứng cứu sự cố (ghép on-call + escalation) | + +`workflow.yaml` khai báo (xem [spec/workflow](../spec/workflow.schema.md)): +- **lanes** — các trạng thái (= thư mục trong `board/`). +- **transitions** — chuyển nào hợp lệ, ai (vai nào) được làm. +- **gates** — điều kiện pass khi qua một transition (test, DoD, human approval). +- **ceremonies** — sự kiện định kỳ (planning, standup-digest, retro) và ai chủ trì. + +Ví dụ một transition tùy biến: +```yaml +transitions: + - from: in-progress + to: review + by: [implementer] + gates: [tests-pass, lint-clean, handoff-note] +``` + +Đổi quy trình = sửa `workflow.yaml`. Board, protocol, và orchestrator đều đọc từ đây — Hatch cung cấp khung + cơ chế, *chuẩn cụ thể* luôn là cấu hình per-project, không hard-code. diff --git a/hatch/docs/06-governance.md b/hatch/docs/06-governance.md new file mode 100644 index 0000000..050ee31 --- /dev/null +++ b/hatch/docs/06-governance.md @@ -0,0 +1,70 @@ +# 06 — Governance & Audit + +Nhiều agent tự động chạm vào code → rủi ro tăng. Hatch đặt ba lớp kiểm soát: **audit trail đầy đủ**, **gates trước hành động không thể hoàn tác**, và **giới hạn thẩm quyền agent**. Khung này hợp với môi trường doanh nghiệp cần truy vết (vd IPO readiness, compliance). + +## Ledger — sổ audit append-only + +Mỗi thay đổi trạng thái có ý nghĩa sinh một entry. Ledger là **append-only** (không sửa/xóa entry cũ — sửa = thêm entry đính chính), nằm trong git nên bất biến kép. + +Một entry tối thiểu trả lời 5 câu: **WHO · WHAT · WHEN · WHY · WHERE**. + +``` +## 2026-06-14T09:12:03+07:00 · codex · T-123 +- action: claim +- from: backlog/ → in-progress/ +- why: sprint S-7, ưu tiên cao, dependency T-100 đã done +- branch: hatch/T-123-export-csv +``` + +Xem schema đầy đủ: [spec/ledger](../spec/ledger.schema.md). + +Ledger phục vụ: standup digest, retro, điều tra sự cố, và **chứng cứ audit** "agent nào làm gì, vì sao, lúc nào". + +## Gates — chặn trước hành động rủi ro + +Gate = điều kiện bắt buộc pass trước khi qua một ranh giới. Hai loại: + +### Gate tự động (agent/CI tự chạy) +- **DoD gate** (Reviewer): checklist [protocol/definition-of-done](03-coordination-protocol.md#definition-of-done-dod). +- **Test/lint gate:** xanh mới được vào review/. +- **No-self-review:** Implementer ≠ Reviewer trên cùng ticket (registry enforce). +- **Stale-context gate:** SSOT đổi mà chưa compile → chặn (xem [compiler](04-context-compiler.md#stale-detection)). + +### Human gate (bắt buộc người duyệt) +Một số hành động **agent không bao giờ tự làm** — luôn dừng và chờ người: + +| Hành động | Vì sao cần người | +|---|---| +| Merge vào nhánh chính | Quyết định cuối cùng thuộc về người | +| Deploy production | Không thể hoàn tác, ảnh hưởng thật | +| Xóa data / migration phá hủy | Mất mát không hồi | +| Đụng secret / credential | Bảo mật | +| Gửi communication ra ngoài | Brand/pháp lý | +| Quyết định pháp lý/tài chính ràng buộc | Ngoài thẩm quyền agent | + +Khai báo trong registry: `policy.require-human-gate: [merge, deploy, secret, external-comms, destructive-data]`. + +## Giới hạn thẩm quyền theo vai + +Ranh giới mỗi vai (cột "KHÔNG được" ở [roles](02-roles.md)) là một dạng governance: Implementer không đổi scope, Reviewer không tự sửa rồi tự duyệt, v.v. Vi phạm ranh giới = ticket bị Reviewer trả về. + +## Branch & PR là biên giới an toàn + +- **Branch-per-ticket:** mọi thay đổi cô lập trên nhánh `hatch/T-xxx`. Nhánh chính luôn sạch. +- **PR bắt buộc để merge:** PR là nơi human gate diễn ra — review cuối + CI + phê duyệt. Agent mở PR (draft), người duyệt và merge. +- Không agent nào push thẳng nhánh chính. + +## Sự cố & leo thang + +- Agent phát hiện rủi ro vượt thẩm quyền → set `blocked`, ghi ledger, **không tự xử**. +- Conductor quét blocker; cái nào thuộc human gate → leo thang cho người (không tự gỡ). +- Sự cố nghiêm trọng (lộ secret, phá data) → dừng ngay, ghi ledger mức cao nhất, báo người. + +## Nguyên tắc trung thực (áp cho mọi agent) + +Mượn thẳng từ chuẩn làm việc nghiêm túc: +- Báo cáo kết quả đúng sự thật: test fail thì nói fail kèm output; bước bị skip thì nói skip. +- Không bịa số liệu/nguồn. Phân biệt dữ kiện / suy luận / giả định. +- Việc xong và đã verify mới được tuyên bố "done". + +Những nguyên tắc này nên nằm trong `charter.md` (L0) để mọi agent đều nạp. diff --git a/hatch/docs/07-orchestrator.md b/hatch/docs/07-orchestrator.md new file mode 100644 index 0000000..d61bbc6 --- /dev/null +++ b/hatch/docs/07-orchestrator.md @@ -0,0 +1,79 @@ +# 07 — Orchestrator (`hatch` CLI) + +Phase 3 đích đến: một CLI `hatch` đóng vai **Engineering Manager / Scrum Master** — không tự viết code, mà *compile context, lập kế hoạch, spawn đúng agent cho đúng ticket, gác gate, và dựng dashboard*. Phase 1–2 nhiều việc dưới đây làm thủ công; orchestrator chỉ tự động hóa lại. + +> Đúng nghĩa cái tên: `hatch run ` là *nở* một agent ra khỏi ổ — spawn đúng agent, đặt vào worktree riêng với context vừa đủ, để nó đi làm rồi báo cáo lại qua ledger. + +## Lệnh + +| Lệnh | Vai trò người tương đương | Làm gì | +|---|---|---| +| `hatch init` | Lập đội + dựng quy trình | Scaffold `.hatch/` (charter, roles mẫu, protocol, board, registry) | +| `hatch compile [--check]` | Phát handbook onboarding | SSOT → file native từng agent; `--check` chặn nếu stale | +| `hatch plan` | Sprint planning | Conductor (CC) bẻ epic→ticket, gán vai/agent, xếp ưu tiên/dependency | +| `hatch run ` | Giao việc cho dev | Spawn CLI của agent được gán, với prompt + context scoped (L0+L1+L2), trong worktree riêng | +| `hatch gate ` | Review + CI | Chạy DoD/test/lint; cập nhật trạng thái | +| `hatch status` | Bảng sprint | Dashboard: lane, ai làm gì, blocker, stale | +| `hatch standup` | Daily standup | Sinh ledger digest từ entry mới | +| `hatch sync` | Đồng bộ thực tế | Đối chiếu board ↔ git ↔ PR, sửa lệch | + +## `hatch run` — cơ chế spawn (cốt lõi Phase 3) + +Đây là khác biệt giữa "convention" (Phase 1) và "orchestrator thật" (Phase 3). + +``` +act run T-123 + │ + 1. đọc ticket T-123 → biết assignee agent, role, context_refs + 2. compile prompt scoped: + L0 charter + L1 role(assignee) + L2 (ticket body + context_refs) + + protocol cần thiết (claim, DoD, branching) + 3. tạo git worktree hatch/T-123- (cô lập, chạy song song an toàn) + 4. spawn CLI của agent đó (vd: `codex`, `kiro`, `claude`, `ag`) + với prompt ở trên, cwd = worktree + 5. stream stdout/stderr → ledger entry (audit) + 6. khi agent xong → orchestrator: + - chạy gate tự động (test/lint) + - git mv ticket sang review/ + - mở PR draft + - ghi ledger +``` + +### Adapter spawn theo agent + +Mỗi agent có cách invoke khác nhau (cờ, cách nhận prompt, chế độ non-interactive). Hatch có một **spawn adapter** per agent (song song với compile adapter ở [compiler](04-context-compiler.md)): + +```yaml +# trong registry.yaml, mỗi agent +codex: + spawn: + cmd: "codex" + args: ["--non-interactive", "--prompt-file", "{prompt}"] + cwd: "{worktree}" + capture: stdout +``` + +Thêm agent mới = thêm compile adapter + spawn adapter + dòng registry. Không đụng lõi. + +## Cô lập bằng git worktree + +Chạy nhiều agent song song an toàn nhờ mỗi ticket một worktree riêng (cùng repo, khác thư mục làm việc, khác branch). Không tranh working tree, không tranh index. Đây là tương đương "mỗi dev một máy/branch". + +## Conductor là agent, không phải code + +`hatch plan` không nhồi logic planning vào CLI — nó **spawn CC ở vai Conductor** với context board + roadmap, và CC trả về tập ticket. Orchestrator chỉ là khung process + I/O + gate; *phán đoán* vẫn do agent. Giữ đúng triết lý: tool điều phối, agent suy nghĩ. + +## Điều orchestrator KHÔNG làm + +- Không tự merge / deploy / chạm secret — đó là human gate ([governance](06-governance.md)). +- Không tự sửa file output compiled — chỉ regenerate từ SSOT. +- Không quyết định business/scope — chỉ thực thi kế hoạch Conductor + ranh giới registry. + +## Lựa chọn kỹ thuật (đề xuất, chốt khi implement) + +- **Ngôn ngữ:** một CLI gọn (Go hoặc Node/TS) để dễ phân phối 1 binary; hoặc Python nếu ưu tiên tốc độ làm. +- **Trạng thái:** thuần file + git, không DB (xem [architecture](01-architecture.md)). +- **Config:** `registry.yaml` + `.hatch/`. +- **Quan sát:** `hatch status` đọc trực tiếp board/ledger; tùy chọn TUI. + +Các lựa chọn này để mở; xem [roadmap](08-roadmap.md) Phase 3 để chốt. diff --git a/hatch/docs/08-roadmap.md b/hatch/docs/08-roadmap.md new file mode 100644 index 0000000..eeabbff --- /dev/null +++ b/hatch/docs/08-roadmap.md @@ -0,0 +1,70 @@ +# 08 — Roadmap + +Đích cuối là full orchestrator, nhưng triển khai theo 3 phase để **chạy được giá trị ngay từ Phase 1** mà không cần viết code. + +## Phase 1 — Convention + Docs (chạy được không cần code) + +Mọi thứ là file + git + quy ước. Agent tuân theo protocol thủ công; con người (hoặc CC ở vai Conductor) chạy các bước bằng tay. + +**Giao được:** +- Bộ docs này (đã có). +- `hatch init` dạng **template thư mục** `.hatch/` để copy vào repo (charter mẫu, roles mẫu, protocol, board rỗng, registry mẫu, `kb/` rỗng + `index.md`, `workflow.yaml` chọn template scrum/kanban/spec-first/lite). +- Compile **thủ công**: hướng dẫn + checklist để CC tự sinh `CLAUDE.md`/`AGENTS.md`/`.kiro/steering/` từ SSOT (tái dùng skill overclaud). +- Một **skill/agent overclaud** mở rộng: "thiết lập Hatch cho repo này" — hỏi vai, agent, rồi dựng `.hatch/`. + +**Tiêu chí xong:** một repo thật chạy được vòng `plan → claim → code → review → done` hoàn toàn bằng convention, có ledger. + +## Phase 2 — CLI hỗ trợ (tự động hóa phần cơ học) + +Thêm CLI `hatch` cho các thao tác xác định, **chưa spawn agent**: + +**Giao được:** +- `hatch init` (scaffold thật). +- `hatch compile [--check]` — SSOT → output per-agent + manifest stale detection. +- `hatch status` / `hatch standup` — đọc board/ledger, dashboard + digest. +- `hatch gate` — chạy test/lint/DoD checklist (đọc gates từ `workflow.yaml`). +- `hatch kb query ` / `hatch kb add` — tra cứu + ghi Knowledge Base, cập nhật `index.md`. +- `hatch sync` — đối chiếu board ↔ git ↔ PR. +- git pre-commit hook: chặn output stale, validate frontmatter ticket + `workflow.yaml`/`registry.yaml`. + +**Tiêu chí xong:** không còn thao tác cơ học thủ công; agent vẫn được người/Conductor gọi tay, nhưng compile/status/gate đã tự động. + +## Phase 3 — Full Orchestrator (spawn & drive agent) + +Orchestrator tự spawn đúng agent cho đúng ticket (xem [orchestrator](07-orchestrator.md)). + +**Giao được:** +- `hatch run ` — spawn CLI agent với context scoped, trong worktree, capture → ledger. +- `hatch plan` — spawn CC (Conductor) để bẻ epic→ticket. +- Spawn adapter per agent trong registry. +- Worktree isolation cho chạy song song. +- Tùy chọn: `hatch watch` chạy vòng lặp pull-ticket tự động cho workers (có WIP limit). + +**Tiêu chí xong:** từ một backlog, `hatch` tự lập kế hoạch, phân việc, chạy nhiều agent song song, dừng đúng ở mọi human gate. + +> **Trạng thái:** cơ chế Phase 3 đã implement — `hatch run/plan/watch` + adapter headless (claude/codex/agy/kiro) + worktree + TUI `hatch board`. Việc còn lại: chạy song song thực sự nhiều agent (hiện `watch` tuần tự một pass) và kiểm thử với agent CLI cài đặt thật. + +## Phụ thuộc giữa phase + +``` +Phase 1 (convention) ──► Phase 2 (CLI cơ học) ──► Phase 3 (spawn) + SSOT + protocol compile + status run + plan + board + ledger gate + sync worktree + adapters +``` + +Mỗi phase đứng vững một mình. Phase 1 đã có giá trị (hết drift, có quy trình, có audit). Phase 2 bỏ việc tay. Phase 3 bỏ việc gọi agent tay. + +## Câu hỏi cần chốt trước khi code (Phase 2+) + +1. Ngôn ngữ CLI: Go (1 binary, dễ phân phối) vs Node/TS (gần hệ sinh thái agent) vs Python (làm nhanh)? +2. Compile adapter: viết riêng từng agent, hay một format trung gian + renderer? +3. Mức độ tự động của `hatch watch` ở Phase 3 — pull tự động tới đâu trước khi cần người? +4. Tích hợp CI thật (GitHub Actions) cho gate, hay chạy local? + +Những câu này không chặn Phase 1. Chốt khi tới Phase 2. + +## Việc kế tiếp ngay + +1. Review & chốt bộ docs này. +2. Dựng template `.hatch/` (Phase 1) như một thư mục trong repo overclaud. +3. Mở rộng skill overclaud để bootstrap Hatch cho một repo. diff --git a/hatch/docs/09-knowledge-base.md b/hatch/docs/09-knowledge-base.md new file mode 100644 index 0000000..1b6c63d --- /dev/null +++ b/hatch/docs/09-knowledge-base.md @@ -0,0 +1,79 @@ +# 09 — Knowledge Base + +Agents trong Hatch **không chia sẻ bộ nhớ** — mỗi con một process, hết phiên là quên. Nhưng một đội hiệu quả cần *trí nhớ chung tích lũy*. KB là câu trả lời: một **bộ não chung trên git** mà mọi agent đọc *và* ghi. Đối ứng người: wiki của đội (Confluence/Notion) + kho ADR. + +## Vì sao cần KB (tách khỏi SSOT và Ledger) + +| Kho | Trả lời câu hỏi | Vòng đời | +|---|---|---| +| `context/` (SSOT) | "Chuẩn/định hướng là gì?" | Ổn định, human-curated, **compile** vào agent | +| `kb/` (Knowledge Base) | "Ta đã biết/quyết gì về thứ này?" | Tăng dần, mọi agent ghi, tra cứu on-demand | +| `ledger/` | "Ai đã làm gì, khi nào, vì sao?" | Append-only, sự kiện theo thời gian | + +Không có KB, tri thức học được trong một ticket sẽ **bốc hơi** khi agent kết thúc — agent sau lại dò lại từ đầu (tốn token, dễ sai khác). KB biến trải nghiệm rời rạc thành tài sản chung. + +## Cấu trúc + +``` +.hatch/kb/ +├── index.md # mục lục có tag → tra cứu nhanh, ít token +├── decisions/ # ADR: quyết định kiến trúc + bối cảnh + lý do + hệ quả +│ └── ADR-007-csv-streaming.md +├── domain/ # tri thức nghiệp vụ tích lũy (rule, thuật ngữ, ràng buộc thật) +├── learnings/ # bài học, gotcha, pitfall đã gặp + cách xử lý +└── .meta.json # tag/owner/updated/links cho từng mục (để truy vấn) +``` + +Mỗi mục KB là một file Markdown ngắn có frontmatter: + +```yaml +--- +id: ADR-007 +type: decision # decision | domain | learning +title: "Dùng CSV streaming cho export" +tags: [reporting, performance, export] +related: [T-123, context/tech/reporting.md] +author: kiro # agent hoặc human: +created: "2026-06-14T10:00:00+07:00" +status: accepted # cho ADR: proposed | accepted | superseded +--- +``` + +## Đọc — input chung + +Khi nhận ticket, agent **truy vấn KB theo tag/`related`** (qua `index.md`) và chỉ nạp các mục liên quan như một phần L2. Không nạp cả KB. Đây là điểm tiết kiệm token: thay vì suy diễn lại "tại sao reporting làm thế này", agent đọc `ADR-007` trong vài trăm token. + +Phase 1: agent đọc `index.md` rồi mở mục cần. Phase 2/3: `hatch kb query ` trả về tập mục liên quan. + +## Ghi — output chung + +Agent **ghi vào KB khi học được điều đáng giữ**, không phải mọi lúc. Quy ước (đưa vào role context): + +- Ra một **quyết định kiến trúc** → tạo ADR trong `decisions/`. +- Phát hiện một **gotcha/pitfall** tốn thời gian → ghi `learnings/`. +- Làm rõ một **rule nghiệp vụ** chưa có ở đâu → ghi `domain/`. +- Cập nhật `index.md` + `.meta.json` (Phase 2/3 tự động hóa bước này). + +Ghi KB là một phần của **Definition of Done** cho ticket có hàm lượng tri thức cao (xem [protocol DoD](03-coordination-protocol.md#definition-of-done-dod)). + +## KB vs SSOT — đường thăng cấp + +KB là nơi tri thức *nảy mầm*; SSOT là nơi tri thức *đã chín thành chuẩn*. Trong **Retro** ([workflow](05-workflow.md)), Conductor rà KB: mục nào đã thành chuẩn chính thức → **đề bạt** (promote) vào `context/` (SSOT) để compile cho mọi agent. Ví dụ: một `learning` về cách viết test lặp lại nhiều lần → nâng thành rule trong `context/tech/`. + +``` +learnings/ (cá biệt) ──promote──► context/tech/ (chuẩn, compile vào agent) +decisions/ (ADR) ──reference─► agent tra cứu khi liên quan +``` + +## Tránh phình & nhiễu + +- **Một mục một ý.** Mục KB ngắn, có tag. Không nhật ký dài dòng (đó là việc của ledger). +- **Chống trùng:** trước khi ghi, agent tra `index.md`; nếu đã có mục gần giống → cập nhật/đính kèm thay vì tạo mới. +- **Khử lỗi thời:** ADR cũ bị thay → đánh `status: superseded` + trỏ tới ADR mới (không xóa, giữ lịch sử quyết định). +- **Retro dọn KB:** gộp mục trùng, archive mục hết giá trị. + +## Quyền ghi & governance + +- Mọi vai được ghi KB, nhưng **đề bạt KB→SSOT** cần Architect/Conductor (giống review trước khi lên wiki chính thức). +- Ghi KB cũng để lại entry ledger (`action: note`, link tới mục KB) → vẫn truy vết được ai thêm tri thức gì. +- KB nằm trong git → review được qua PR như mọi thay đổi khác. diff --git a/hatch/docs/10-agent-adapters.md b/hatch/docs/10-agent-adapters.md new file mode 100644 index 0000000..ee3a877 --- /dev/null +++ b/hatch/docs/10-agent-adapters.md @@ -0,0 +1,90 @@ +# 10 — Agent Adapters (compile surfaces + headless invocation) + +Tài liệu tham chiếu cho **compiler** (ghi instruction ra đâu) và **orchestrator** (spawn agent thế nào). Mọi thông tin dưới đây từ research docs chính thức tháng 6/2026; chỗ nào chưa chắc đều ghi rõ. Code trong `internal/compile` và `internal/orchestrator` cài đặt đúng theo bảng này. + +## Bảng tổng hợp + +| Agent | `kind` | Surface (compile ra) | Headless command | +|---|---|---|---| +| Claude Code | `claude` | `CLAUDE.md` (+ `.claude/agents/*.md`) | `claude -p` | +| Codex | `codex` | `AGENTS.md` | `codex exec` | +| Kiro | `kiro` | `.kiro/steering/*.md` | `kiro-cli chat --no-interactive` | +| **Antigravity CLI** | `agy` | `GEMINI.md` (cũng đọc `AGENTS.md`) | `agy -p ` | +| Antigravity IDE | `antigravity` | `AGENTS.md` | IDE / handoff (không headless CLI riêng) | +| (test) | `mock` | — | `hatch-mock` — agent giả để test end-to-end không cần CLI thật | +| (generic) | `manual` | — | không spawn; tạo handoff cho người/IDE | + +> **`agy`** (Antigravity CLI) là **kế nhiệm Gemini CLI** của Google — agent Google duy nhất Hatch hỗ trợ (Gemini CLI cũ đã bỏ). Auth **chỉ OAuth/OS-keyring** (chạy `agy` lần đầu để login; device-code khi SSH) — **không có API-key env**, token ở `~/.gemini/antigravity-cli/`. Cờ (theo repo chính thức): `-p`/`--print`, `-m`/`--model`, `--dangerously-skip-permissions` (thay `--yolo` đã bỏ), `--sandbox`, `--print-timeout`. **Không có `--output-format json`.** ⚠ Bug upstream #76: `agy -p` có thể **nuốt stdout khi không phải TTY** → output capture có thể rỗng; transcript vẫn ghi phần in ra. + +`AGENTS.md` là convention dùng chung — **một** file phục vụ Codex + Antigravity. + +## Kiểm tra auth (doctor) — gọi cmd, không scan file + +Agent CLI **không bắt buộc** — cài cái nào dùng cái nấy, chỉ cần **≥1**. `hatch doctor` xác minh đăng nhập bằng cách **gọi chính lệnh non-mutating của CLI** (exit 0 = đã login) hoặc đọc env key user tự set — **không bao giờ đọc thư mục credential** (bảo mật). Lệnh đã verify từ docs từng CLI: + +| kind | env key | auth-check command (exit 0 = authed) | +|---|---|---| +| `claude` | `ANTHROPIC_API_KEY` (hoặc `CLAUDE_CODE_OAUTH_TOKEN` cho CI) | `claude auth status` | +| `codex` | `OPENAI_API_KEY` | `codex login status` | +| `kiro` | `KIRO_API_KEY` | `kiro-cli whoami` | +| `agy` | *(không có)* | *(không có — OAuth/keyring; doctor để "?")* | + +CLI nào chưa có lệnh status non-interactive thì khai `auth_check: [...]` trên agent trong `registry.yaml`; doctor sẽ chạy lệnh đó. Không có thì doctor báo `?` (không đoán). + +--- + +## Claude Code (`kind: claude`) + +**Instruction file.** Đọc `CLAUDE.md` (project root) hoặc `.claude/CLAUDE.md`. **Không đọc `AGENTS.md`** — nếu cần thì `@AGENTS.md` import hoặc symlink. Nhiều `CLAUDE.md` theo cây thư mục được **nối** (root → cwd), không override. Import: `@path/to/file` (tối đa 4 hop, resolve theo file chứa import). + +**Subagents.** `.claude/agents/.md` — frontmatter: `name` (bắt buộc, lowercase-hyphen), `description` (bắt buộc), `tools` (CSV, kế thừa hết nếu bỏ), `model` (`sonnet|opus|haiku|fable||inherit`, mặc định `inherit`), `permissionMode`, `maxTurns`, `isolation: worktree`… Hatch map mỗi **role** → một subagent file (tùy chọn). + +**settings.json.** `permissions.allow/deny/ask` (rule `Tool(pattern)`, ưu tiên deny→ask→allow), `hooks`. Precedence: managed > CLI flags > local > project > user. + +**Headless.** `claude -p "" [--bare] --output-format json|stream-json --permission-mode --allowedTools "..." --model --append-system-prompt-file --max-turns N`. stdin được nạp làm context. JSON out có `result`, `session_id`, `total_cost_usd`; `--json-schema` → `structured_output`. CI nên dùng `--bare` (bỏ auto-discovery) + `ANTHROPIC_API_KEY`. +> permission-mode: `default|acceptEdits|plan|auto|dontAsk|bypassPermissions`. `dontAsk` = khóa chặt cho CI. + +## Codex (`kind: codex`) + +**Instruction file.** `AGENTS.md` (cũng `AGENTS.override.md` ưu tiên hơn). Merge root → cwd, cap mặc định **32 KiB** (`project_doc_max_bytes`). Global `~/.codex/AGENTS.md` **tùy version** — bản mới có thể không auto-merge; an toàn hơn dùng `developer_instructions` trong `config.toml`. + +**Config.** `~/.codex/config.toml` (TOML): `model`, `model_provider`, `approval_policy` (`untrusted|on-failure|on-request|never`), `sandbox_mode` (`read-only|workspace-write|danger-full-access`), `[sandbox_workspace_write]` (`writable_roots`, `network_access=false`), `[mcp_servers.*]`. + +**Headless.** `codex exec ""` (alias `codex e`); prompt từ stdin nếu là `-`. Cờ: `-s workspace-write` (capability), `-m `, `--json` (JSONL events), `-o ` (final message), `-C `, `--skip-git-repo-check`, `--ephemeral`, `-c key=value`. Resume: `codex exec resume --last "..."`. +> Trong `exec`, `-a/--ask-for-approval` và `--full-auto` không còn ý nghĩa (deprecated) — điều khiển quyền bằng `--sandbox`. + +## Kiro (`kind: kiro`) + +**Steering.** `.kiro/steering/*.md` (workspace) hoặc `~/.kiro/steering/*.md` (global). Mặc định: `product.md`, `tech.md`, `structure.md`. Frontmatter `inclusion`: +- `always` (mặc định) — nạp mọi lúc. +- `fileMatch` + `fileMatchPattern` (glob/array) — nạp khi đụng file khớp. +- `manual` — nạp khi `#tên-file` được nhắc. +> CLI có thể dùng `inclusion: auto` + `description` (skill-style) thay cho `always/fileMatch` — verify theo surface (IDE vs CLI). + +**Specs.** `.kiro/specs//{requirements.md, design.md, tasks.md}`. Requirements viết theo **EARS** (`WHEN … THE SYSTEM SHALL …`). Khớp khối spec-driven của Hatch. + +**MCP.** `.kiro/settings/mcp.json` (workspace) / `~/.kiro/settings/mcp.json`. + +**Headless.** `kiro-cli chat --no-interactive ""`; auth qua env `KIRO_API_KEY` (bỏ qua login). Có thể pipe context: `git diff | kiro-cli chat --no-interactive "review"`. Không có user-input giữa chừng. (Kiro CLI 2.0+.) + +## Antigravity CLI (`kind: agy`) + +**Instruction file.** Đọc `GEMINI.md` + `AGENTS.md` (project + nested). Hatch compile ra `GEMINI.md`. Config/cache ở `~/.gemini/antigravity-cli/`; plugin/hook/MCP dùng chung ở `~/.gemini/config/`. + +**Auth.** Chỉ OAuth/OS-keyring — chạy `agy` lần đầu để login (SSH: in URL + device-code). **Không có API-key env, không có lệnh auth-status non-interactive** → `hatch doctor` để `?`. + +**Headless.** `agy -p ""` (alias `--print`); `-m`/`--model`; `--dangerously-skip-permissions` (thay `--yolo` đã bỏ); `--sandbox`; `--print-timeout `. **Không có `--output-format json`.** ⚠ Upstream #76: `-p` có thể nuốt stdout khi không phải TTY → capture có thể rỗng (transcript vẫn ghi). + +## Antigravity IDE (`kind: antigravity`) + +IDE agent-first (Agent Manager; surfaces editor/terminal/browser), quanh Gemini 3. Đọc `GEMINI.md` + `AGENTS.md` + Skills. Không có CLI headless riêng → adapter `manual` (handoff). Để dùng terminal headless, dùng `kind: agy`. +> **Chưa xác nhận:** một CLI headless `agy` (`agy -p`) chỉ thấy ở blog thứ cấp 2026, không có nguồn chính thức. Vì vậy Hatch coi Antigravity là **surface AGENTS.md** + adapter `manual` (handoff cho IDE) cho tới khi có CLI chính thức. + +--- + +## Hệ quả thiết kế cho Hatch + +1. **Surface dedup:** compile theo *surface file*, không theo agent. Codex + Antigravity cùng `AGENTS.md` → sinh một lần. Mỗi agent trong registry khai `surfaces: [...]`; compiler hợp nhất. +2. **Layering giữ nguyên:** mọi surface nhận L0 (charter) + L1 (role(s) của agent đọc surface đó) + **con trỏ** tới L2. Không nhồi `context/` đầy đủ vào surface. +3. **Adapter orchestrator** (Phase 3) map `kind` → command + cờ ở bảng trên; `manual`/`kiro`(nếu thiếu key)/`antigravity` rơi về chế độ handoff. +4. **Sandbox/permission** dịch từ workflow gate + registry policy sang cờ tương ứng mỗi agent (vd `--permission-mode dontAsk` ↔ `--sandbox read-only`). diff --git a/hatch/docs/11-communication.md b/hatch/docs/11-communication.md new file mode 100644 index 0000000..a4e061a --- /dev/null +++ b/hatch/docs/11-communication.md @@ -0,0 +1,108 @@ +# 11 — Communication layer (agents nói chuyện trực tiếp) + +Một đội người không chỉ để lại artifact — họ **nói chuyện**: hỏi nhau, @mention, họp. Đây là tầng đưa Hatch tới gần "human simulation" hơn các orchestrator thuần-task (vd Paperclip điều phối *qua* server trung tâm, **không** có giao tiếp trực tiếp giữa agent). + +## Nguyên tắc: trực tiếp nhưng on-the-record + +Agent là process cô lập — không socket thẳng vào nhau được, và **không nên** (mất audit, thành kênh ngầm). Nên giao tiếp "trực tiếp" ở Hatch nghĩa là: **có địa chỉ, theo lượt, qua một phương tiện chung** — đúng như con người cần Slack/phòng họp. Phương tiện đó là **bus + orchestrator**; mọi lượt nói đều được ghi lại. + +| | **Bus** (`.hatch/bus/`) | **Ledger** (`.hatch/ledger/`) | **Board/KB** | +|---|---|---|---| +| Chở gì | *đối thoại* (hỏi/đáp/họp) | *sự kiện* trạng thái | *trạng thái* + *tri thức* | +| Hình thức | thread append-only | entry append-only | file ticket / KB | + +> Board/ledger/KB vẫn là **nguồn sự thật cho trạng thái**. Bus chở **dialogue**. Quyết định chốt trong họp ⇒ ghi KB (ADR); đổi trạng thái ⇒ ledger. + +## Mô hình Slack + +| Slack | Hatch | +|---|---| +| Channel `#design` | conversation id `#design` (một file) | +| DM | conversation id `dm-codex-claude` (hoặc tự đặt) | +| Per-topic | dùng id ticket `T-123` làm channel | +| Thread (reply dưới một message) | message có `re:` (cờ `--reply-to`) | +| Mở thread mới | post message mới (không `--reply-to`) | +| @mention | `@agent`/`@role` trong body → tự vào inbox người được tag | + +## Cấu trúc + +``` +.hatch/bus/ +├── threads/.md # mỗi file = một CHANNEL/DM/conversation +└── .cursors.json # con trỏ "đã đọc" của từng agent (tính inbox) +``` + +Mỗi message là một block append-only; reply tạo **thread** trong channel: +``` +## · · [ · re:] · {#} + +``` +`type`: `msg` · `ask` (chờ trả lời) · `reply` · `decision` (chốt/đồng thuận). +`to`: id agent · id vai · `#channel` · `*`/`all`. **@mention trong body** tự được gộp vào `to` → người/vai được tag thấy trong `hatch inbox`. + +## Cách agent "đọc": inbox vs subscribe vs search + +Agent **không** đọc/xử lý mọi message trong mọi channel — đó là phản token. Ba mức tách bạch: + +| Mức | Là gì | Lệnh | Token | +|---|---|---|---| +| **Inbox** | Việc *phải xử lý*: DM + @mention + broadcast `*` | `hatch inbox ` | nhỏ, chỉ phần cần hành động | +| **Subscribe** | *Phạm vi* channel agent quan tâm (membership) | `hatch channel join/leave <#ch> --agent X` | không nạp gì — chỉ định ranh giới | +| **Search/recall** | *Nạp đúng phần liên quan* vào context theo truy vấn | `hatch search --agent X` | có giới hạn (newest-first, `--limit`) | + +Mặc định `hatch search --agent X` chỉ tìm trong **channel X đã subscribe** (dùng `--all` để bỏ giới hạn). Đây chính là L2 on-demand cho hội thoại: agent **recall như search** thay vì đọc cả bus → giữ context gọn. Subscribe định nghĩa "tôi quan tâm channel nào"; search quyết định "lúc này nạp gì vào đầu". + +```bash +hatch channel join '#design' --agent codex # codex quan tâm #design +hatch search "encoding csv" --agent codex # recall liên quan trong scope của codex +hatch search "human-merge" --all --limit 10 # tra toàn bộ khi cần +``` + +## Tự động "đọc room" khi vào việc + +Như một người vào ca mở Slack trước khi code, `hatch run ` tự **chèn vào prompt của agent**: (1) **inbox** chưa đọc (DM/@mention/broadcast) và (2) **recall** hội thoại liên quan tới ticket (token-match, scope theo channel đã subscribe, cap nhỏ). Sau khi chạy xong, inbox được đánh dấu đã đọc. Tắt bằng `--no-catch-up`. + +Nhờ vậy agent "vào việc đã nắm bối cảnh trao đổi" mà vẫn token-bounded — giao tiếp trở thành một phần ngữ cảnh L2 tự động, không phải đổ cả bus. + +## Ba kiểu giao tiếp + +### 1. Channel · DM · @mention · thread (async) +```bash +# post vào channel, tag đồng đội ngay trong body +hatch msg --from codex -c '#design' "@claude-code @reviewer streaming hay buffer?" +# DM: channel riêng giữa hai agent +hatch msg --from codex -c dm-codex-claude "ping riêng nhé" +# reply trong thread (rooted tại message gốc) hoặc mở thread mới (không --reply-to) +hatch msg --from claude-code -c '#design' --reply-to m0614-145222-581011 "Streaming." +hatch inbox claude-code --mark # DM + @mention + broadcast gửi tới mình; --mark = đã đọc +hatch channel ls # liệt kê channel/DM/conversation +hatch channel show '#design' # cả channel +hatch channel show '#design' --in # chỉ một thread +``` +Inbox = **notification kiểu Slack**: chỉ DM + @mention (id/vai) + broadcast `*`, không phải mọi message trong mọi channel. Channel thì *mở ra xem* bằng `hatch channel show`. Không chặn — người nhận xử lý ở lượt sau. + +### 2. Hỏi-đáp đồng bộ (orchestrator relay) +```bash +hatch ask --from codex --to claude-code --thread T-123 "Chốt giúp: dùng streaming chứ?" +``` +Orchestrator dựng prompt = bối cảnh thread + câu hỏi, **spawn agent đích headless**, bắt câu trả lời, ghi `reply` vào thread, trả về cho người hỏi. Đây là "nói chuyện" thật giữa hai agent — qua một phương tiện có ghi âm. `--dry-run` in invocation mà không chạy. + +### 3. Họp nhiều agent (convene) +```bash +hatch convene --topic "Thiết kế export API" --agents claude-code,codex,kiro --rounds 2 +``` +Orchestrator chạy vòng luân phiên có giới hạn: mỗi vòng, mỗi agent thấy diễn biến thread tới giờ và đóng góp lượt của mình theo **vai** của nó. Agent mở đầu bằng `DECISION:` để chốt (message thành type `decision`). Đây là mô phỏng một cuộc họp/đánh giá thiết kế đầy đủ — kết quả nằm trong thread, sẵn sàng đề bạt sang KB. + +## TUI: `hatch chat` + +Xem/giao tiếp kiểu Slack ngay trong terminal: cột **CHANNELS** (chọn channel/DM) + khung **messages** (auto-refresh 1s, cuộn được), và **soạn–gửi** (`i` để nhập, Enter gửi — `@mention` tự tag). Mặc định gửi với danh tính `human:operator`, đổi bằng `--as`. Bổ trợ cho `hatch board` (board+live+ledger) — đây là cửa sổ "Slack" của đội. + +## Vì sao mạnh hơn task-only orchestration +- **Hỏi để gỡ vướng ngay** thay vì block ticket chờ vòng sau. +- **Tranh luận thiết kế** (convene) cho ra quyết định tốt hơn một agent đơn lẻ. +- **Vẫn audit đầy đủ**: mọi câu đều có `from/to/ts/thread`, tái lập được "ai nói gì, vì sao". +- **Không khóa cứng đồng bộ**: DM async cho luồng rời; ask/convene cho lúc cần đối thoại. + +## Giới hạn hiện tại +- Relay là **bán đồng bộ**: orchestrator chạy agent đích một lượt rồi lấy stdout làm câu trả lời (chưa phải phiên chat dài nhiều lượt giữ session). Mở rộng: dùng `--resume` của từng agent để giữ mạch hội thoại. +- Body chứa heading `## ` có thể làm parser tách nhầm — tránh dùng `## ` đầu dòng trong message (sẽ được xử lý ở bản sau). diff --git a/hatch/docs/12-ceremonies-escalation.md b/hatch/docs/12-ceremonies-escalation.md new file mode 100644 index 0000000..644ff38 --- /dev/null +++ b/hatch/docs/12-ceremonies-escalation.md @@ -0,0 +1,60 @@ +# 12 — Ceremonies, escalation & decisions + +Những nghi thức làm cho Hatch hành xử như một squad người thật, không chỉ một hàng đợi task. + +## Ceremonies sống + +`workflow.yaml` *khai báo* ceremonies; `hatch ceremony` *chạy* chúng. + +| Lệnh | Mô phỏng | Làm gì | +|---|---|---| +| `hatch ceremony standup [--days N] [--post]` | standup hằng ngày | Digest theo agent từ ledger (đã làm gì) + blockers từ lane blocked; mặc định post vào `#standup`. | +| `hatch ceremony retro [--write]` | retrospective cuối chu kỳ | Tổng kết: done / blocks / gate failures / decisions; liệt kê **ứng viên đề bạt KB→SSOT** (learnings). `--write` lưu `ledger/retro-.md`. | +| `hatch ceremony planning [--dry-run]` | sprint planning | Spawn Conductor bẻ việc (như `hatch plan`). | +| `hatch ceremony demo [--post]` | sprint review / demo | Trình diễn việc ở lane terminal; post `#demo`. | +| `hatch ceremony grooming` | backlog refinement | Soi ticket backlog thiếu role/priority/acceptance. | + +Chủ trì (`chair`) lấy từ `workflow.yaml > ceremonies..by`, mặc định `human:facilitator`. + +## Escalation / on-call + +Như dev kẹt thì gọi senior. Đích escalate = `registry.policy.escalate_to` → nếu trống thì Conductor đầu tiên → `human:lead`. + +- **Thủ công:** `hatch escalate --why "..."` → ghi ledger `action: escalate` + post `#escalations` (tag đích danh `@target`). +- **Tự động:** khi một ticket **fail gate ≥ 2 lần**, workflow engine tự escalate một lần (không spam) — xem [governance](06-governance.md). "Blocked quá lâu" có thể quét bằng cron + `hatch escalate`. + +## On-call & incidents + +Như đội có lịch trực: `hatch oncall set --rotation a,b,c` định nghĩa vòng trực, `hatch oncall` xem ai đang trực, `hatch oncall rotate` bàn giao pager (báo `#oncall`). **Escalation tự nhắm người đang trực** trước (rồi mới tới `policy.escalate_to` → Conductor → `human:lead`). + +Template workflow **`incident`** (`hatch init -w incident`): `detected → triage → mitigating → resolved → postmortem`, gate `fix-verified` (test) + `postmortem-written`. Ghép on-call + escalation tự động khi kẹt = quy trình ứng cứu sự cố đầy đủ. + +## Presence / capacity + +`hatch presence` cho thấy ai `available/busy/paused/offline` + tải WIP; `hatch presence set --status …`. Khâu phân việc (`run`/`watch`/pairing) **bỏ qua agent paused/offline và ưu tiên agent ít tải nhất dưới WIP** — như lead giao cho người đang rảnh. + +## Mob + +`hatch mob --agents a,b,c` mở rộng pairing cho 3+ agent: **driver xoay vòng mỗi vòng**, còn lại navigate; kết thúc sớm khi đa số navigator `READY`. + +## Decisions → ADR + +Khép vòng từ họp tới tri thức của record: trong `hatch convene`, khi một lượt mở đầu bằng `DECISION:`, Hatch **tự ghi một ADR** vào `kb/decisions/` (status `accepted`, link tới thread họp) + entry ledger. Quyết định không bốc hơi trong chat — nó thành tri thức tra cứu được (xem [knowledge-base](09-knowledge-base.md)). + +``` +convene (#meet) ── "DECISION: dùng CSV streaming" ──► kb/decisions/ADR-00X + ledger note +``` + +## Pairing (driver/navigator) + +Hai agent cùng một ticket như pair programming: **driver** triển khai từng bước nhỏ, **navigator** soi lỗi/rủi ro và gợi ý bước kế — luân phiên qua thread `pair-`. + +```bash +hatch pair T-001 --driver codex --navigator claude-code --rounds 3 [--claim] +``` + +Mỗi vòng: driver chạy một lượt (thấy feedback navigator gần nhất) → ghi thread → navigator review lượt đó → ghi thread. Navigator mở đầu `READY` ⇒ kết thúc sớm (đủ tốt để chuyển review). `--dry-run` xem cấu trúc lượt mà không spawn. Bắt buộc driver ≠ navigator. + +## Vì sao quan trọng + +Ba mảnh này biến vòng đời từ "giao task → chạy" thành nhịp một đội thật: **đồng bộ hằng ngày** (standup), **học từ chu kỳ** (retro → đề bạt SSOT), **gọi cứu viện khi kẹt** (escalation), và **chốt quyết định thành ADR**. Tất cả vẫn append-only, auditable. diff --git a/hatch/docs/13-management.md b/hatch/docs/13-management.md new file mode 100644 index 0000000..4cbaac3 --- /dev/null +++ b/hatch/docs/13-management.md @@ -0,0 +1,85 @@ +# 13 — Management plane (Founder/CEO/CTO view) + +> **Trạng thái: PHẦN LỚN ĐÃ IMPLEMENT** — `hatch workload · perf · cost · budget · report` chạy được; cost track-only (không auto-pause, theo quyết định ở [17](17-pre-implementation.md)). Doc này là thiết kế gốc của lớp quản lý vận hành. Mọi số liệu **derive từ artifact đã có** (ledger + board + bus) — không thêm hệ thống tracking song song. Một người ở vai Founder/CEO/CTO cần ba thứ: **workload** (đội đang tải bao nhiêu), **performance** (làm tốt không), **budget/lương** (tốn bao nhiêu, có vượt không). + +## 0. Nền dữ liệu: mọi metric đến từ ledger + +Không có DB phân tích riêng. Ledger append-only đã ghi who/what/when/why; board cho trạng thái; bus cho đối thoại. Mọi chỉ số tính ra từ đó: + +| Chỉ số | Nguồn (derive) | +|---|---| +| Cycle time (1 ticket) | ledger: `claim` → `done` | +| Lead time | ticket `created` → `done` | +| Throughput | số `done` / chu kỳ | +| Rework | số `review → in-progress` (changes-requested) | +| Gate pass rate | `gate` result passed / (passed+failed) | +| Escalations | số entry `escalate` | +| KB đóng góp | số mục `kb/` theo author | +| Review load | số `review` theo agent | + +Một ticket nên có thêm field tùy chọn cho ước lượng: +```yaml +estimate: 3 # điểm hoặc giờ (đơn vị do team chọn trong charter) +``` +"Actual" không cần nhập tay — tính từ ledger. **Estimate vs actual** = học để ước lượng tốt hơn (đưa vào retro). + +## 1. Workload management + +Mở rộng presence + WIP đã có thành bức tranh tải toàn đội. + +- **`hatch workload`** — bảng theo agent: WIP hiện tại / WIP limit, queue (ticket gán nhưng chưa làm), throughput chu kỳ, avg cycle time, trạng thái presence. Gắn cờ **quá tải** (WIP ≥ limit) và **rảnh** (idle) để cân tải. +- **Burndown/burnup** — `hatch workload --burn`: còn lại vs đã done theo thời gian trong sprint (đọc ledger). +- **Cân tải**: Conductor/`watch` đã ưu tiên agent ít tải; workload view cho người quyết định can thiệp thủ công. + +Human analogue: bảng capacity của EM — ai ngập việc, ai rảnh, sprint có kịp không. + +## 2. Performance management + +Bảng điểm vận hành mỗi agent, **tính từ ledger** — không phải "đánh giá con người". + +- **`hatch perf []`** — scorecard: done, avg cycle time, rework rate, gate pass rate, escalations gây ra, reviews thực hiện, KB đóng góp, decisions chốt. +- **Xu hướng**: so scorecard giữa các chu kỳ (đang lên/xuống) — đầu vào cho 1:1. +- **1:1 / feedback**: ghi nhận định kỳ dạng KB note (`kb/perf/-.md`) hoặc DM qua bus — phản hồi cụ thể + mục tiêu kỳ tới. +- **Thăng cấp năng lực**: registry có thể nâng/giảm vai một agent dựa trên scorecard (vd gate pass rate cao → cho làm reviewer). + +> **Cảnh báo trung thực (Goodhart).** Agent không phải người; đây là **chỉ số chất lượng vận hành**, không phải "thành tích cá nhân". Tối ưu mù theo một metric (vd throughput) sẽ phá chất lượng. Dùng để phát hiện bất thường + điều chỉnh phân vai/prompt/SSOT, không để "phạt". + +## 3. Budget & "lương" (cost control) + +Tương tự Paperclip (agents có lương + ngân sách), nhưng file-based và derive được. + +### Chi phí thật +Mỗi headless run trả về cost (Claude `total_cost_usd`, Codex/Gemini usage). Orchestrator ghi cost vào ledger mỗi run: +```markdown +## · codex · T-123 +- action: progress +- cost_usd: 0.42 +- tokens: 18450 +``` + +### "Lương" = ngân sách cấp cho mỗi agent +Trong registry: +```yaml +agents: + - id: claude-code + rate_per_mtok: 15.0 # giá tham chiếu (USD / 1M token) + budget_usd: 200 # "lương"/trần chi mỗi chu kỳ +policy: + team_budget_usd: 1000 # trần toàn đội / chu kỳ +``` + +### Theo dõi + trần cứng + auto-pause +- **`hatch budget`** — burn vs cap theo agent + toàn đội; cảnh báo ở 80%. +- **`hatch cost `** — tổng chi cho một ticket (sum cost ledger entries). +- **Trần cứng**: khi một agent (hoặc đội) vượt budget → tự `presence set paused` + báo `#budget`/escalate. Vì capacity-aware assignment đã bỏ qua agent paused, **vượt ngân sách = tự ngừng giao việc** cho agent đó. (Đây là chỗ presence + budget khớp nhau đẹp.) + +### Góc CEO +- **Payroll** = Σ `budget_usd` (cam kết). **Spend thực** = Σ cost ledger. **ROI** = throughput (hoặc story point done) / spend. +- Báo cáo: "tuần này đội tiêu $X/$Y ngân sách, ship N ticket, $/ticket = …". + +## 4. Status report ra ngoài (stakeholder) + +Khác standup (nội bộ, chi tiết) — **`hatch report`** sinh tóm tắt điều hành: trạng thái board (done/in-flight/blocked), throughput + burndown, budget burn, rủi ro (escalations, ticket quá hạn), quyết định lớn (ADR mới). Một trang cho Founder/đầu tư/đối tác. Có thể post `#leadership` hoặc xuất file. + +## Vì sao quan trọng +Ba lớp này biến Hatch từ "đội tự chạy" thành "đội **quản trị được**": người đứng đầu thấy **đội tải bao nhiêu** (workload), **chạy tốt không** (performance), **tốn bao nhiêu & có trong ngân sách không** (budget) — tất cả derive từ cùng một ledger append-only, nên minh bạch và audit được. Xem thêm tổ chức & uỷ quyền ở [14-org-and-cadence](14-org-and-cadence.md). diff --git a/hatch/docs/14-org-and-cadence.md b/hatch/docs/14-org-and-cadence.md new file mode 100644 index 0000000..a9e62c9 --- /dev/null +++ b/hatch/docs/14-org-and-cadence.md @@ -0,0 +1,71 @@ +# 14 — Org chart, delegation & cadence + +> **Trạng thái: ĐÃ IMPLEMENT** — `hatch org` (org-chart + DoA, escalation leo cây), `hatch ticket extdep` (external deps, chặn claim), `hatch tick` (heartbeat cho cron/CI) đều chạy. Doc này là thiết kế gốc. Còn lại: `daemon`, multi-repo deps. + +## 1. Org chart & Delegation of Authority (DoA) + +Hiện registry là phẳng (vai + agent). Đội thật có **đường báo cáo** và **hạn mức thẩm quyền**. Bổ sung vào registry: + +```yaml +roles: + - id: conductor + reports_to: "" # gốc cây (CEO/Founder) + authority: + can_approve: true # được duyệt merge + merge_limit: any # phạm vi được merge + budget_authority_usd: 1000 # tự duyệt chi tới mức này + decision_scope: [arch, hiring, budget] + - id: implementer + reports_to: conductor + authority: + can_approve: false + budget_authority_usd: 0 +``` + +Hệ quả: +- **Escalation đi theo cây báo cáo**: vượt thẩm quyền của vai hiện tại → chuyển lên `reports_to` (thay vì luôn về Conductor). Kết hợp on-call ở [12](12-ceremonies-escalation.md). +- **Gate `human-merge`/`budget`** kiểm tra DoA: hành động vượt `budget_authority_usd` hoặc ngoài `decision_scope` ⇒ bắt buộc escalate, không tự quyết. +- **`hatch org`** — in cây tổ chức + ma trận thẩm quyền (đối ứng "Delegation of Authority Matrix" của một công ty thật). + +Human analogue: ai báo cáo cho ai, ai được ký tới mức nào — chống việc một agent tự quyết vượt quyền. + +## 2. Phụ thuộc liên đội / bên ngoài + +`depends_on` hiện chỉ trỏ ticket nội bộ. Thực tế đội còn **chờ bên ngoài**: vendor, đội khác, phê duyệt của người, một repo khác. + +Bổ sung frontmatter ticket: +```yaml +blocked_by_external: + - what: "Khoá API từ nhà cung cấp thanh toán" + owner: "human:vendor-x" + eta: "2026-06-20" + status: waiting # waiting | received +``` + +- Không tự gỡ — phải có người/sự kiện đánh dấu `received`. +- Hiện trong `hatch status`/`workload`/standup như **rủi ro tiến độ** (khác blocker nội bộ). +- **Cross-repo (tương lai):** dependency trỏ tới ticket ở một workspace Hatch khác (multi-repo, ngoài phạm vi bản đầu). + +## 3. Heartbeat / cadence (scheduler) + +Ceremonies đã khai báo `trigger` (daily, end-of-sprint, cron) nhưng cần thứ **đánh thức** đội định kỳ — như Paperclip heartbeat. + +- **`hatch tick`** — chạy MỘT nhịp: thực thi ceremony tới hạn (standup digest, rotate on-call nếu tới lịch), dispatch việc claimable (`watch` một pass tôn trọng WIP/presence), kiểm budget (auto-pause nếu vượt). Idempotent, để gọi từ **cron / GitHub Actions / systemd timer**. +- **`hatch daemon`** (tuỳ chọn) — vòng lặp gọi `tick` theo chu kỳ, cho máy chạy thường trú. +- Mốc lần chạy lưu ở `.hatch/.schedule.json` (last-run mỗi ceremony) để biết cái gì "tới hạn". + +> **Lưu ý môi trường.** Trong môi trường remote ephemeral, không có tiến trình thường trú — cadence do **bên ngoài** kéo (cron của CI gọi `hatch tick`). Thiết kế nhắm "stateless tick gọi được từ scheduler bất kỳ", không phụ thuộc daemon. + +## 4. Mảnh nhỏ còn lại + +- **Estimate/time-tracking**: field `estimate` trên ticket + cycle time derive từ ledger (chi tiết ở [13 §0](13-management.md)). Estimate vs actual đưa vào retro. +- **Spike/research**: một `kind: spike` cho ticket điều tra có time-box (đánh dấu để không tính vào throughput sản phẩm). + +## Thứ tự đề xuất implement +1. Cost capture trong ledger + `hatch budget`/`cost` + auto-pause (giá trị CEO cao nhất, nối sẵn presence). +2. `hatch workload` + `hatch perf` (đọc ledger — rẻ, không đụng spawn). +3. Org-chart + DoA trong registry + escalation theo cây. +4. External dependency field + hiển thị rủi ro. +5. `hatch tick` (cadence) + `hatch report` (stakeholder). + +Mỗi mục đều **derive từ artifact sẵn có** nên rủi ro thấp, chủ yếu là đọc + trình bày + vài field cấu hình. diff --git a/hatch/docs/15-obsidian-kb.md b/hatch/docs/15-obsidian-kb.md new file mode 100644 index 0000000..baa242f --- /dev/null +++ b/hatch/docs/15-obsidian-kb.md @@ -0,0 +1,52 @@ +# 15 — Obsidian như KB chính (qua CLI) + +> **Trạng thái: THIẾT KẾ (chưa implement).** Mở rộng [09-knowledge-base](09-knowledge-base.md): ngoài MD thô tự tổ chức, cho phép dùng **Obsidian vault** làm KB chính, thao tác qua CLI. Lý do: Obsidian = markdown + wikilink + graph + tag + plugin — đúng "second brain" cho một đội, mà vault chỉ là thư mục .md nên CLI/agent thao tác trực tiếp được. + +## Nguyên tắc: vault là file, nên CLI sở hữu được + +Obsidian là GUI, **không có CLI chính thức**. Nhưng vault chỉ là folder markdown → Hatch thao tác trực tiếp (tạo/sửa/link/tag/index), và "tích hợp Obsidian" gồm: +1. **Tương thích định dạng** Obsidian (wikilink, tag, frontmatter, Dataview) để mở trong app là dùng được ngay. +2. **CLI quản lý vault** (`hatch kb …`) — nguồn ghi chính cho agent. +3. **Cầu nối app**: Obsidian URI (`obsidian://open?vault=…&file=…`) để mở note; tuỳ chọn plugin **Local REST API** cho tích hợp live khi app đang chạy. + +## Cấu hình + +Trong registry/charter: +```yaml +kb: + mode: obsidian # native | obsidian + vault: ./kb # đường dẫn vault (mặc định .hatch/kb là vault luôn) + wikilinks: true # related → [[wikilink]] thay vì đường dẫn + dataview: true # sinh frontmatter thân thiện Dataview +``` +`mode: native` = hành vi cũ (MD thô). `mode: obsidian` = bật các tính năng dưới. Vault có thể là `.hatch/kb/` (mặc định) hoặc một vault ngoài (chia sẻ với người). + +## Tương thích Obsidian + +- **Wikilinks**: `related:` và liên kết giữa note render thành `[[ADR-007 CSV streaming]]`, hỗ trợ `[[note#heading]]` và alias `[[note|tên hiển thị]]`. Hatch resolve được wikilink ↔ file. +- **Tags**: dùng cả frontmatter `tags:` lẫn `#tag` trong thân — khớp Obsidian. +- **Frontmatter**: giữ YAML hiện có (id/type/title/tags/related/...), thêm `aliases:` (Obsidian) khi cần. +- **MOC (Map of Content)**: `kb/index.md` trở thành MOC bằng wikilink, nhóm theo type/tag — Obsidian hiển thị graph + outline. +- **Backlinks**: `hatch kb` tự tính backlink (note nào trỏ tới đây) để bổ trợ index; Obsidian cũng tự có pane backlink. +- **Dataview (tuỳ chọn)**: sinh frontmatter chuẩn để user viết query Dataview (bảng ADR theo status, learnings theo tag) sống động trong app. + +## CLI + +```bash +hatch kb add --type decision --title "CSV streaming" --tags export --link T-123,ADR-003 +hatch kb link # tạo wikilink hai chiều +hatch kb backlinks # ai trỏ tới note này +hatch kb graph [--tag export] # in graph text (note ↔ liên kết) — recall theo đồ thị +hatch kb open # mở trong Obsidian qua obsidian:// URI +hatch kb index # build MOC index.md (wikilink, nhóm type/tag) +hatch kb sync # đối chiếu vault ↔ .meta.json, sửa link gãy +``` + +## Recall theo đồ thị (mạnh cho token) + +Ngoài `hatch search` (token match), Obsidian-mode cho **graph-aware recall**: "nạp ADR-007 **và mọi note nó link tới**" — agent lấy đúng cụm tri thức liên quan theo wikilink/backlink thay vì đọc cả vault. Đây là L2 on-demand nâng cấp. + +## Caveat trung thực +- Obsidian **không có CLI/đầu mối tự động chính thức**; tích hợp dựa trên (a) file trực tiếp, (b) URI mở app, (c) plugin Local REST API (chỉ khi app chạy). Hatch không phụ thuộc app đang mở — vault-as-files là chính. +- `mode: native` luôn là fallback an toàn; Obsidian là lớp tăng cường, không phải phụ thuộc cứng. +- Sync xung đột nếu vault vừa do người sửa trong app vừa do agent ghi — dựa git như mọi artifact khác. diff --git a/hatch/docs/16-document-templates.md b/hatch/docs/16-document-templates.md new file mode 100644 index 0000000..eb39a0a --- /dev/null +++ b/hatch/docs/16-document-templates.md @@ -0,0 +1,74 @@ +# 16 — Document & report templates/specs + +> **Trạng thái: THIẾT KẾ (chưa implement).** Như đội người, agent viết **rất nhiều tài liệu** trong lúc làm (PRD, design doc, ADR, RFC, postmortem, runbook, test plan, release notes, status report…). Mỗi loại cần **template + spec chuẩn theo một framework**, và **user override được** — đúng triết lý "template per-project" như workflow. + +## Mô hình: doc type → framework → template (sửa được) + +Hatch ship một bộ loại tài liệu, mỗi loại gắn một framework công nhận rộng rãi, dưới dạng template có spec. User copy & sửa per-project. + +| Doc type | Framework mặc định | Lưu ở | +|---|---|---| +| `adr` | MADR / Nygard ADR | `kb/decisions/` | +| `rfc` | RFC (đề xuất + thảo luận) | `kb/` hoặc `docs/rfc/` | +| `prd` | Product Requirements Doc | `context/product/` | +| `design` | Design doc (kiểu Google) | spec feature / `context/tech/` | +| `requirements` | **EARS** (khớp Kiro) | spec feature | +| `postmortem` | Blameless postmortem (Google SRE) | `kb/` | +| `runbook` | Ops runbook | `context/tech/` | +| `test-plan` | Test plan | ticket / spec | +| `release-notes` | Keep a Changelog + SemVer | `docs/` | +| `status-report` | Exec report (xem [13](13-management.md)) | post `#leadership` | +| `tech-doc` | **Diátaxis** (tutorial/how-to/reference/explanation) | `context/` | + +## Lưu trữ & cấu trúc + +``` +.hatch/templates/docs/ +├── adr.md # template + spec (frontmatter khai required sections/fields) +├── design.md +├── postmortem.md +└── ... # user thêm/sửa tự do +``` + +Mỗi template có frontmatter **spec** + thân **scaffold**: +```markdown +--- +doc-type: adr +framework: MADR +required-sections: [Context, Decision, Consequences, Alternatives] +required-frontmatter: [id, title, status, date] +--- +# {{title}} + +## Context + +## Decision +## Consequences +## Alternatives +``` + +## CLI + +```bash +hatch doc types # liệt kê doc type + framework +hatch doc new design --title "Export API" [--ticket T-123] # scaffold từ template +hatch doc lint # kiểm spec: đủ required sections + frontmatter? +hatch doc lint --all # quét toàn repo +``` + +- **`doc new`** sinh file đúng nơi (bảng trên) từ template, điền placeholder, mở (Obsidian nếu KB-mode). +- **`doc lint`** = "spec gate": file thiếu section/field bắt buộc ⇒ fail. Có thể gắn vào **DoD gate** của workflow (vd ticket loại design phải `doc lint` pass) hoặc pre-commit. + +## Tích hợp với phần đã có +- **KB**: `hatch kb add --type decision` dùng template `adr.md` (gộp, không trùng cơ chế). +- **Spec-first workflow**: artifact `requirements/design/tasks` dùng template `requirements` (EARS) + `design`. +- **Compiler/role**: charter/role nhắc agent "khi viết , dùng `hatch doc new ` và tuân spec". +- **Management**: `status-report` template chính là output của `hatch report` ([13](13-management.md)). + +## Custom hoá (yêu cầu cốt lõi) +- User sửa thẳng `templates/docs/.md` hoặc thêm type mới (vd `architecture-review`, `security-review`). +- Đổi framework = đổi nội dung template + `required-sections`. Hatch chỉ cưỡng chế cái template khai báo, không hardcode framework. +- Bộ ship sẵn chỉ là **điểm xuất phát**, hệt như 8 workflow template. + +## Vì sao quan trọng +Tài liệu agent viết ra sẽ **nhất quán, đúng chuẩn, lint được** — thay vì mỗi agent một kiểu. Cộng với Obsidian KB ([15](15-obsidian-kb.md)), đội có một "second brain" có cấu trúc: tài liệu đúng spec, liên kết wikilink, tra cứu theo đồ thị. diff --git a/hatch/docs/17-pre-implementation.md b/hatch/docs/17-pre-implementation.md new file mode 100644 index 0000000..cd14109 --- /dev/null +++ b/hatch/docs/17-pre-implementation.md @@ -0,0 +1,37 @@ +# 17 — Chốt trước khi implement toàn bộ backlog + +> Danh sách quyết định cần xử lý trước/trong lúc code phần còn lại. Mỗi mục có **đề xuất mặc định**; gắn 🔴 = cần user quyết, 🟡 = mặc định hợp lý nhưng nên xác nhận, 🟢 = tự quyết được. + +## Kỹ thuật cốt lõi + +1. 🟡 **Kiểm thử với agent thật.** Remote env không có `claude/codex/gemini/kiro-cli` → mới verify ở mức dry-run + unit test. *Đề xuất:* build một **mock agent CLI** (đọc prompt, in output giả + cost giả) để test trọn nhánh execute/relay/pair/convene end-to-end; chạy thật với agent xịn để CI/local sau. +2. 🔴 **Cost capture.** Mỗi agent trả usage khác nhau (Claude `total_cost_usd`, Codex/Gemini token usage). Giá thay đổi. *Đề xuất:* parse tokens từ JSON output mỗi adapter, lưu **tokens thô** + nhân với **bảng rate cấu hình** trong registry (không hardcode giá). Cần xác nhận: dùng giá tự khai hay cố đọc cost provider trả về. +3. 🟡 **Concurrency / song song thật.** `watch` hiện tuần tự. Chạy nhiều agent song song cần: worktree mỗi ticket (đã có helper), khóa qua git push, xử lý xung đột merge, giới hạn parallelism. *Đề xuất:* goroutine pool theo WIP toàn cục + worktree-per-ticket; claim vẫn "push thắng = lock thắng". +4. 🟡 **Bus parser.** Body chứa `## ` làm parser tách nhầm message. *Đề xuất:* escape `## ` đầu dòng khi ghi (hoặc dùng delimiter ít đụng hơn) — sửa trước khi dùng convene/pair nặng. +5. 🟢 **Nén ledger/bus.** Append-only sẽ phình. *Đề xuất:* retro nén ledger theo chu kỳ (đã có khái niệm); bus archive thread cũ sang `bus/archive/`. Implement khi cần. + +## Bảo mật / vận hành + +6. 🔴 **Secrets cho agent headless.** `ANTHROPIC_API_KEY`, `KIRO_API_KEY`… *Đề xuất:* chỉ qua env/secret manager, **không bao giờ trong repo**; registry chỉ tham chiếu tên biến. (Khớp chính sách "không hardcode credentials".) +7. 🔴 **Redaction.** Output agent capture vào ledger/bus có thể lộ dữ liệu nhạy cảm. *Đề xuất:* lớp redact (mask token/PII pattern) trước khi ghi; cấu hình pattern trong charter. Cần quyết mức độ. +8. 🟡 **Sandbox mặc định.** Mỗi adapter map sang quyền (codex `-s`, claude `--permission-mode`). *Đề xuất:* mặc định **bảo thủ** (workspace-write / acceptEdits), nâng quyền phải khai trong registry. + +## Phạm vi / sản phẩm + +9. 🟡 **Obsidian vault location.** Trong repo (`.hatch/kb`) hay vault ngoài? *Đề xuất:* mặc định trong repo (versioned, đơn giản); cho trỏ vault ngoài qua config. +10. 🟢 **Ranh giới nơi lưu tài liệu.** ADR→`kb/`, PRD→`context/product`, design→spec/`context/tech` (đã định ở [16](16-document-templates.md)). +11. 🟡 **Thứ tự implement.** *Đề xuất* (gộp [14](14-org-and-cadence.md) §thứ-tự + 2 mục mới): + 1. Hardening nền: mock agent, bus parser fix, secrets/redaction. + 2. Cost capture + `budget`/`cost` + auto-pause (nối presence). + 3. `workload` + `perf` (đọc ledger). + 4. Doc templates (`doc new/lint`) + Obsidian KB mode. + 5. Org-chart/DoA + external deps. + 6. `tick` (cadence) + `report` + parallel `watch`. +12. 🟢 **Versioning/migration.** registry/workflow có `version`; thêm migration nhẹ khi đổi schema. + +## Quyết định đã chốt (2026-06-14) +- **Mock agent: CÓ.** Dựng `hatch-mock` + adapter `kind: mock` để test end-to-end execute/relay/pair/convene/cost trong remote. +- **Obsidian vault: CẢ HAI.** Hỗ trợ qua config; **mặc định in-repo** (`.hatch/kb`), cho trỏ vault ngoài. +- **Cost/secrets: TỐI GIẢN.** Chỉ **track** cost/tokens (không hard-cap, không auto-pause, không redaction mặc định). *Ngoại lệ bất biến:* secrets vẫn **chỉ qua env**, không bao giờ trong repo (quy tắc cứng, độc lập với mức "tối giản"). + +Mặt thiết kế đã đủ phủ một human squad (vận hành + giao tiếp + quản trị). Bắt đầu implement theo thứ tự #11. diff --git a/hatch/docs/18-observability.md b/hatch/docs/18-observability.md new file mode 100644 index 0000000..4b06653 --- /dev/null +++ b/hatch/docs/18-observability.md @@ -0,0 +1,55 @@ +# 18 — Quan sát agents đang làm việc (observability) + +> **Trạng thái: ĐÃ IMPLEMENT** — transcript mỗi run + `hatch logs -f`, TUI tầng A (`hatch board`: board+live+activity), và mux tầng B (`hatch run --mux=tmux|zellij`) đều chạy. Doc này là thiết kế gốc. + +## Hai thứ KHÁC NHAU cần quan sát + +1. **Squad state** — board, ledger, bus, ai đang giữ ticket nào, cost/workload. Đây là *trạng thái đội*, đọc từ artifact. → Hatch tự render được (đã có `hatch board` TUI; thêm pane ledger/bus feed). +2. **Live output từng agent** — luồng stdout/stderr *trực tiếp* của process agent (claude/codex/…) trong lúc nó chạy. Đây là *quá trình*, là stream. + +Hai cái này cần cơ chế khác nhau → có **hai tầng quan sát**, không loại trừ nhau. + +## Tầng A — TUI một-process (mặc định, không phụ thuộc) + +Hatch tự dựng "mission control" bằng Bubble Tea/lipgloss (như k9s/lazygit): **một process, 4 pane nội bộ** (`hatch board`): BOARD + LIVE (transcript) + ACTIVITY (ledger) + CHAT (bus, soạn được). Phím: `tab` đổi pane, `↑/↓` di/scroll, `f` follow ticket, `r` chạy run, `c` đổi channel, `i` soạn chat, `q` thoát. +``` +┌ board ───────────┬ active runs ─────────────┐ +│ backlog 3 │ T-101 codex ▮▮▮ streaming│ +│ in-progress 2 │ T-102 claude ▮▮ streaming│ +│ review 1 ├──────────────────────────┤ +│ done 7 │ ledger feed (live tail) │ +├──────────────────┤ bus #design (live) │ +│ presence/WIP │ │ +└──────────────────┴──────────────────────────┘ +``` +- Live agent output lấy từ **transcript** mỗi run (xem dưới) — TUI tail file, không cần điều khiển terminal khác. +- **Không cần tmux/Zellij**, chạy mọi nơi (kể cả SSH đơn). Đây là mặc định khuyến nghị. +- Đối ứng người: màn hình "mission control" của EM. + +## Tầng B — Terminal multiplexer (tmux / Zellij) — tùy chọn + +Khi muốn **mỗi agent một pane THẬT**, side-by-side, thấy UI native của agent (nhất là agent có TUI riêng hoặc stream rất nhiều) → Hatch điều phối một multiplexer: +- `hatch watch --mux=tmux` (hoặc `--mux=zellij`): tạo session, **mỗi run một pane**, đặt `hatch run ` vào pane đó; layout tự chia. +- `hatch run --mux=tmux`: mở/split một pane cho run này. +- Cơ chế: shell ra `tmux new-session/split-window`/`zellij action new-pane`. Agent vốn đã là **process riêng** (orchestrator spawn) — multiplexer chỉ cấp cho mỗi process một khung nhìn. +- Đúng trực giác của bạn: **multiple TUI = tmux/Zellij**. Đây là tầng "phòng điều khiển nhiều màn hình" cho lúc theo dõi sát. + +## Nền chung: transcript mỗi run (cần có trước) + +Cả hai tầng dựa trên việc **ghi lại output thô** mỗi run (hiện orchestrator mới ghi tóm tắt vào ledger): +``` +.hatch/runs//-.log # stdout+stderr thô, append realtime +``` +- Orchestrator stream ra: (a) terminal gọi lệnh, (b) file transcript, (c) tóm tắt + cost vào ledger. +- `hatch logs [--follow]` — tail/replay transcript (như `kubectl logs -f`). +- Transcript là artifact replay được; TUI tầng A đọc nó; tmux tầng B hiển thị trực tiếp. + +## Remote / CI (không có terminal tương tác) +Không tmux, không TUI. Quan sát qua: transcript file, `hatch standup`/`status`/`workload`, ledger, và PR. Hatch không phụ thuộc terminal tương tác — đó là lý do transcript + artifact là nền, còn TUI/tmux chỉ là *cách nhìn*. + +## Đề xuất triển khai +1. **Transcript** mỗi run (`.hatch/runs/…`) + `hatch logs --follow` — nền cho mọi cách nhìn. +2. **TUI tầng A** mở rộng từ `hatch board`: thêm pane active-runs (tail transcript) + ledger/bus feed. +3. **Mux tầng B**: `--mux=tmux|zellij` cho `run`/`watch`. + +Thứ tự: transcript trước (rẻ, dùng được ngay qua `logs`), rồi TUI, rồi mux. diff --git a/hatch/docs/19-going-real.md b/hatch/docs/19-going-real.md new file mode 100644 index 0000000..8e2218f --- /dev/null +++ b/hatch/docs/19-going-real.md @@ -0,0 +1,65 @@ +# 19 — Từ mock sang chạy thật + +Hướng dẫn chuyển một workspace từ **mock agent** (demo) sang **agent CLI thật**. + +## 1. Cài agent CLI (không bắt buộc hết — chỉ cần ≥1) +Cài cái nào bạn dùng (xem [10-agent-adapters](10-agent-adapters.md)): +- **Claude Code** — `claude` ([code.claude.com](https://code.claude.com)) +- **Codex** — `codex` ([github.com/openai/codex](https://github.com/openai/codex)) +- **Antigravity CLI** — `agy` (kế nhiệm Gemini CLI; `agy` ([antigravity.google](https://antigravity.google))) +- **Kiro** — `kiro-cli` ([kiro.dev](https://kiro.dev)) + +## 2. Đặt `kind` thật trong registry +Trong `.hatch/registry.yaml`, đổi `kind: mock` về `claude|codex|agy|kiro`, khai thêm: +```yaml +agents: + - id: codex + kind: codex + roles: [implementer, tester] + model: gpt-5.2 # tùy chọn + sandbox: workspace-write # codex: read-only|workspace-write|danger-full-access + - id: claude-code + kind: claude + roles: [conductor, architect, reviewer] + approval: acceptEdits # claude permission-mode + budget_usd: 200 # track (xem 13) + rate_per_mtok: 15 +``` + +## 3. Đăng nhập (OAuth) hoặc cấp env key +Ưu tiên **login OAuth** của từng CLI (không cần key trong repo/env): +```bash +claude # /login (subscription) — hoặc env ANTHROPIC_API_KEY +codex login # ChatGPT OAuth — hoặc env OPENAI_API_KEY +agy # lần đầu: chọn Google OAuth (browser/device-code) — chỉ OAuth/keyring, không env key +kiro-cli # login — headless cần env KIRO_API_KEY +``` +Token do từng CLI tự quản (OS keyring / file riêng) — Hatch **không** đụng vào. Nếu tự động hoá thì cấp env key tương ứng (không bao giờ commit vào repo). + +## 4. Kiểm tra sẵn sàng +```bash +hatch doctor # config hợp lệ? compiled fresh? CLI nào đã cài? credential nào có? +hatch compile # nếu doctor báo stale +``` +`hatch doctor` xanh hết = sẵn sàng. + +## 5. Chạy + quan sát +```bash +hatch run T-001 --dry-run # xem invocation sẽ chạy (an toàn) +hatch run T-001 --claim # chạy thật, claim trước +hatch logs T-001 -f # tail live output +hatch board # TUI: board + live + activity +hatch run T-001 --mux=tmux # mỗi run một pane tmux/zellij +hatch watch --parallel 3 # tự phân việc backlog, chạy song song +hatch tick # một nhịp cho cron/CI +``` + +## 6. An toàn khi chạy thật +- **Sandbox bảo thủ** mặc định (codex `workspace-write`, claude `acceptEdits`); nâng quyền phải khai rõ. +- **Gate `human-merge`** giữ con người ở vòng cuối trước khi vào `done`. +- **`no-self-review`**: reviewer ≠ implementer (đã cưỡng chế). +- **Budget**: track-only — theo dõi qua `hatch budget`/`report`; chưa auto-pause (quyết định [17](17-pre-implementation.md)). +- **Mọi thứ auditable**: ledger + transcript (`.hatch/runs/`) + bus đều trên git, review qua PR. + +## 7. Quay lại mock bất cứ lúc nào +`./scripts/onboard.sh` dựng demo mock; hoặc đổi một agent về `kind: mock` để thử luồng mà không tốn token. diff --git a/hatch/docs/20-embedded-harness-pivot.md b/hatch/docs/20-embedded-harness-pivot.md new file mode 100644 index 0000000..ec58e64 --- /dev/null +++ b/hatch/docs/20-embedded-harness-pivot.md @@ -0,0 +1,101 @@ +# 20 — Pivot: Hatch là embedded harness (chat = comms + backlog) + +> **Đính chính hướng.** Bản build hiện tại coi Hatch là **orchestrator tự lái agent** và là **entrypoint** (CLI/TUI user mở đầu). Mô hình đúng ngược lại: **coding agent là entrypoint; agent tự lái; Hatch là lớp nền chung (chat + bộ nhớ) mà agent với tới qua MCP**. Doc này xác định cái sai và đề xuất nâng cấp. + +## User flow đúng + +1. User mở **một coding agent** (Claude Code / Codex / agy / Kiro) và bắt đầu session làm việc với nó — *đây* là entrypoint. +2. User mở thêm **`hatch chat`** để **xem** các agent nói chuyện (read-only; sau này visualize kiểu pixel-game). +3. **Chat vừa là kênh giao tiếp vừa là backlog management.** Một task → agent **mở một thread**, làm, brief kết quả vào thread; cần hỗ trợ thì **@tag** agent khác — agent được tag đọc thread để hiểu nhiệm vụ rồi phản hồi **trong cùng thread**; task cần nhiều/all agent → mở **topic + tag all**. +4. **`hatch board` = chỉ xem stats + hội thoại.** Không điều khiển gì. +5. **Hatch tích hợp VÀO agent** dưới dạng **MCP server (+ skill/plugin)** — một *embedded harness*. Hatch **không** phải first TUI/UI để user bắt đầu. + +## Cái đã đi sai (thẳng thắn) + +| Đã build (sai hướng) | Phải là | +|---|---| +| Hatch **tự lái** agent: `run/plan/watch/tick`, `pickAgent` theo capacity, spawn headless, `--mux` | Agent tự lái mình; Hatch là comms+memory chung agent với tới qua **MCP**. Hatch **không spawn** agent. | +| Hatch = CLI/TUI **entrypoint** user mở đầu | Coding agent là entrypoint; Hatch **nhúng** (MCP/skill/plugin) | +| `hatch board` là bảng điều khiển (`r`=run, claim) | Board = **read-only** stats + xem chat | +| Backlog kiểu Jira riêng (lanes/tickets/claim/gate engine) | **Thread chat CHÍNH LÀ task**; backlog = tập thread | +| ask/convene/pair/mob do orchestrator spawn agent đích | **Agent tự khởi xướng**: post + @tag trong thread; agent được tag trả lời khi nó đang chạy (async). Không spawn. | +| compile → instruction "role + workflow" cho operator | compile → **đăng ký Hatch MCP + tiêm "chat etiquette"** vào từng agent | + +## Cái GIỮ được (lõi tốt, không phí) + +- **bus** = chat (channel · thread · @mention · search · inbox) → **đây chính là lõi sản phẩm**, đã có sẵn. +- **KB** (bộ nhớ chung) · **ledger** (audit) · filesystem+git làm DB. +- Kiến trúc hexagonal (model/ports/adapters) — tái dùng nguyên. +- **compile/SSOT** đổi mục đích: tiêm "Hatch chat etiquette" + đăng ký MCP server vào surface từng agent (CLAUDE.md/AGENTS.md/GEMINI.md/.kiro + file MCP config). +- **`hatch chat` TUI** (quan sát) → tương lai pixel-game viz. + +## Tích hợp: MCP vs Skill vs Plugin + +| Hình thức | Phạm vi | Vai trò | +|---|---|---| +| **MCP server** *(chính)* | **Mọi agent** đều hỗ trợ: Claude `.mcp.json`/`claude mcp add`; Codex `config.toml [mcp_servers]`; agy MCP (`~/.gemini/config`); Kiro `.kiro/settings/mcp.json` | Một `hatch mcp` (stdio) phơi *tools* chat/KB → mọi agent dùng chung một chat. **Đây là embedded harness.** | +| **Skill** | Claude-specific (hành vi) | Dạy *etiquette*: mở thread mỗi task, brief kết quả, tag khi cần. Bản cross-agent = đoạn hướng dẫn compile vào CLAUDE.md/AGENTS.md/… | +| **Plugin** | Claude Code | Gói MCP + skill + slash command; DX đẹp cho Claude nhưng theo từng hệ. | + +→ **Đề xuất:** **MCP server là trục tích hợp**; **compile tiêm etiquette** vào instruction native + **đăng ký MCP** trong config từng agent. (Plugin Claude là lớp đóng gói tùy chọn về sau.) + +## MCP tool surface (đề xuất) — backed by bus/KB/ledger +- `chat.open_thread(channel, title, body)` → trả về thread/task id +- `chat.post(channel, body, reply_to?)` · `chat.reply(thread, body)` +- `chat.read(channel|thread)` · `chat.inbox()` · `chat.search(query)` +- `chat.threads(status?)` — liệt kê task (thread) đang mở +- `chat.tag(...)` (hoặc @mention trong body — đã hỗ trợ) +- `kb.add/kb.search/kb.link` +- `whoami()` — agent này là ai (gán per MCP-server instance qua `--as`) + +Mỗi agent chạy MCP server với danh tính của nó (`hatch mcp --as claude-code`), nên `post/tag/inbox` tự gắn đúng "from". + +## Thread = task (định nghĩa lại backlog) +- **Thread root = một task**; trạng thái suy ra từ hội thoại (open/in-progress/done/blocked) qua quy ước nhẹ (field `status` ở message gốc, hoặc post type `done`/`block`), **không** cần lane/claim/gate engine nặng. +- **Board = stats trên thread** (đang mở/đang chạy/blocked, ai đang phản hồi, decisions) — read-only. +- Workflow engine (lane/transition/gate/no-self-review) trở thành **overlay tùy chọn**, không phải mặc định. + +## Lộ trình nâng cấp (phased) +1. **`hatch mcp`** (stdio MCP server) trên bus/KB sẵn có — năng lực mới cốt lõi. +2. **compile đổi mục đích**: ghi đăng ký MCP (`.mcp.json` · `~/.codex/config.toml` · `.kiro/settings/mcp.json` · agy MCP) + block "chat etiquette" vào từng surface. +3. **board read-only**: bỏ điều khiển run/claim khỏi TUI; chỉ stats + chat. +4. **Hạ cấp/bỏ orchestration tự lái**: `run/plan/watch/tick`, pickAgent/capacity, mux, pair/mob/convene-dạng-spawn. Pair/mob/convene **tái định nghĩa thành pattern chat** (skill dạy), không phải vòng lặp Hatch spawn. +5. **thread-as-task**: quy ước status + board stats từ thread; workflow engine thành tùy chọn. +6. Giữ KB/ledger/clean-arch. + +## Quyết định đã chốt (2026-06-15) + +- **Tích hợp: MCP server + Claude plugin.** `hatch mcp` (stdio) phơi tool chat/KB cho mọi agent (Claude/Codex/agy/Kiro); thêm gói **plugin Claude Code** (đăng ký MCP + skill etiquette + slash) cho DX. compile đăng ký MCP vào config từng agent. +- **Lean pivot ở RUNTIME.** Bỏ phần Hatch tự-lái: `run/plan/watch/tick`, `pickAgent`/capacity/presence, `--mux`, board-control, **Go workflow-engine enforce lane/gate/transition**, pair/mob/convene-dạng-spawn, cost/oncall-as-runtime. Hatch không spawn, không enforce bằng code. +- **Workflow + phân vai = PROTOCOL được COMPILE, không phải engine.** `registry.yaml` (ai giữ vai gì) + `workflow.yaml` (quy trình agile: scrum/kanban/…) + charter + roles + DoD **vẫn là SSOT**, nhưng compile biến chúng thành **văn bản hành vi** tiêm vào `CLAUDE.md`/`AGENTS.md`/`GEMINI.md`/`.kiro`. Agent đọc và *tự* tuân theo qua chat (mở thread, phân vai, gate = self-checklist), Hatch không cưỡng chế. +- **Agent đầu tiên = orchestrator + đa vai.** Agent user mở trước (vd Claude Code) mặc định là **Conductor/orchestrator** (chắc chắn) và **có thể kiêm các vai khác** (architect/reviewer…). Việc này khai trong registry + tiêm qua compile. Các agent khác tham gia async qua chat khi chúng đang chạy. + +### Hệ quả: "agile workflow inject ở đâu?" → vào instruction surface + +``` +.hatch/ (SSOT) hatch compile (đổi mục đích) + charter.md (mission) ─┐ + roles/*.md (vai + ranh giới) ├─► CLAUDE.md (lead: "Bạn là Conductor; chạy scrum; + registry.yaml (ai giữ vai) │ mở 1 thread/task; phân vai; @tag; gate=DoD; + workflow.yaml (agile process)│ dùng Hatch MCP chat …") + đăng ký MCP server + protocol/DoD ─┘ AGENTS.md / GEMINI.md / .kiro (tương tự theo vai) + + .mcp.json / config.toml / .kiro/settings/mcp.json +``` + +Workflow templates (scrum/kanban/spec-first/…) **vẫn giữ**, nhưng từ "cấu hình cho engine" → **mô tả quy trình bằng prose** mà orchestrator-agent tuân theo. Gate (`make test`…) → mục trong **Definition-of-Done** mà agent tự chạy/tự kiểm, không phải Hatch chạy. + +### Thread = task (chat = backlog) +- Agent mở **thread/topic cho mỗi task** qua MCP; brief tiến độ + kết quả vào thread; @tag để nhờ; tag-all cho việc chung. +- Trạng thái task suy ra từ hội thoại (post type `done`/`block`/`decision`), không lane-engine. +- `hatch board` = stats read-only trên thread + ledger; `hatch chat` = xem hội thoại (→ pixel viz sau). + +### Lộ trình implement (đã chốt) — TRẠNG THÁI + +1. ✅ **`hatch mcp --as `** (stdio MCP server) trên bus/KB — tools: whoami, chat_open, chat_post, chat_read, chat_inbox, chat_search, chat_channels, kb_add, kb_search. (`internal/mcpserver`, `internal/cli/mcp.go`). `--as` mặc định = `$HATCH_AGENT` hoặc agent kind=claude đầu tiên. +2. ✅ **compile đổi mục đích**: tiêm protocol (charter+roles+workflow-prose+DoD+chat-etiquette) vào CLAUDE.md/AGENTS.md/GEMINI.md/.kiro; khối "orchestrator" cho lead agent; đăng ký MCP per-agent (`.mcp.json`, `.kiro/settings/mcp.json` merge; snippet `.hatch/mcp/*` cho codex/agy). (`internal/compile/render.go`, `mcp.go`). +3. ✅ **Claude plugin** (`hatch/plugin/`): `.mcp.json` + skill `hatch-chat` + slash `/hatch`; `.claude-plugin/marketplace.json` ở repo root. +4. ✅ **board/chat read-only**: board TUI = THREADS + CHAT + ACTIVITY(ledger); chat TUI = viewer; `status` tóm tắt thread + roster. Bỏ run/claim/compose. +5. ✅ **Prune runtime operator → archive** sau build tag `hatch_legacy` (khôi phục được, không vào binary mặc định): run/plan/watch/tick, orchestrator, workflow-engine (gate/escalate/ticket), ceremonies, ask/convene, pair/mob, presence, oncall, cost/budget, workload/perf, report. +6. ✅ Giữ: bus · KB · ledger · clean arch · compile · registry/workflow/charter/roles/DoD as SSOT. + +> **Lưu ý docs.** Các doc 00–19 mô tả **thiết kế gốc (trước pivot)** — Hatch tự lái agent. Mô hình hiện hành là doc này (20). Khi đối chiếu hành vi CLI thực tế, doc 20 thắng. diff --git a/hatch/docs/architecture-diagram.md b/hatch/docs/architecture-diagram.md new file mode 100644 index 0000000..310a3e1 --- /dev/null +++ b/hatch/docs/architecture-diagram.md @@ -0,0 +1,168 @@ +# Sơ đồ kiến trúc & workflow + +> **Mô hình embedded-harness** (xem [doc 20](20-embedded-harness-pivot.md)). Coding agent là entrypoint và **tự lái**; Hatch là lớp nền chung (chat = comms + backlog, KB, ledger) mà agent với tới **qua MCP**. Hatch **không** spawn/điều khiển agent. + +Bản plaintext là chính (đọc thẳng trong terminal/diff). Khối Mermaid bên dưới cho bản render trên GitHub. + +## 1. Kiến trúc hệ thống (plaintext) + +``` + .hatch/ — SINGLE SOURCE OF TRUTH + ┌────────────────────────────────────────────────────────────────┐ + │ charter.md(L0) roles/*.md(L1) context/(L2) │ + │ registry.yaml (ai giữ vai gì) workflow.yaml (quy trình) │ + └─────────────────────────────┬──────────────────────────────────-┘ + │ hatch compile + ▼ + ┌──────────────────────────────────────┐ ┌──────────────────────────┐ + │ SURFACES (per-agent, sinh ra) │ │ MCP REGISTRATION (sinh ra)│ + │ CLAUDE.md · AGENTS.md · GEMINI.md │ │ .mcp.json (Claude) │ + │ .kiro/steering/ │ │ .kiro/settings/mcp.json │ + │ = protocol PROSE: workflow + chat │ │ .hatch/mcp/* (codex, agy)│ + │ etiquette + DoD self-check │ └────────────┬──────────────┘ + │ (+ khối Orchestrator cho lead) │ │ "chạy: hatch mcp --as " + └───────────────────┬──────────────────┘ │ + │ agent đọc khi khởi động │ + ▼ ▼ + ┌──────────────────────────────────────┐ ┌────────────────────────┐ + │ CODING AGENTS (ENTRYPOINT, tự lái; │ │ HATCH MCP SERVER │ + │ mỗi con 1 process, KHÔNG chung RAM) │─────►│ hatch mcp --as │ + │ Claude Code · Codex · agy · Kiro │ MCP │ (stdio, 1 instance/ │ + │ • lead = Conductor (mở thread/task) │ tools│ agent, đúng danh tính)│ + └──────────────────────────────────────┘ └───────────┬─────────────┘ + │ đọc/ghi + ┌────────────────────────────────────────────────┘ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ HỆ THỐNG FILE = DATABASE (git) │ + │ bus/ = CHAT → comms + BACKLOG (1 thread = 1 task) │ + │ channel · thread · @mention · search · inbox │ + │ kb/ = Knowledge Base (đọc & GHI chung: decision/learning) │ + │ ledger/= append-only audit (who/what/why) │ + └─────────────────────────────────────────────────────────────────┘ + ▲ read-only + │ + ┌─────────────────────────────────────┐ + │ OBSERVE (con người xem, không lái) │ hatch msg ──► chèn ý kiến vào chat + │ hatch board · hatch chat · hatch status (read-only views) │ + └─────────────────────────────────────┘ + + Ba kho tri thức: SSOT = config VÀO · KB = tri thức VÀO+RA · ledger = sự kiện RA + (Archived sau -tags hatch_legacy: orchestrator spawn · workflow-engine · ceremonies…) +``` + +## 2. Vòng đời một task = một thread chat (quy ước, KHÔNG phải engine) + +``` + chat_open chat_post (tiến độ) @tag reviewer chat_post "done" + ┌────────┐ bắt tay ┌─────────────┐ xong việc ┌────────┐ duyệt ┌──────┐ + │ OPEN │ ───────────► │ IN-PROGRESS │ ──────────► │ REVIEW │ ───────► │ DONE │ + └────────┘ (mở task) └─────────────┘ └────────┘ └──────┘ + │ ▲ │ + post │ │ gỡ kẹt │ changes-requested + "block" ▼ │ (post tiếp) │ (@tag lại tác giả) + ┌─────────┐ ◄───────────────────┘ + │ BLOCKED │ + └─────────┘ + • Trạng thái SUY RA từ hội thoại (post type done/block/decision) — không có lane-engine. + • workflow.yaml chỉ là PROSE hướng dẫn (đã compile vào CLAUDE.md…); ai làm vai gì theo đó. + • Gate = Definition-of-Done agent TỰ chạy & xác nhận (make test/lint, no-self-review, human-merge). +``` + +## 3. Một vòng trao đổi giữa agent (qua MCP + bus) + +``` + Conductor (Claude) Hatch bus (#export-csv) Codex + │ chat_open "Export CSV │ │ + │ @codex stream giúp" ──► │ thread tạo, @codex vào "To" │ + │ │ ◄── chat_inbox ─────────────────-┤ (đầu session) + │ │ thấy @mention │ + │ │ ◄── chat_read #export-csv ───────-┤ hiểu nhiệm vụ + │ │ code + test │ + │ │ ◄── chat_post "PR #42, @claude │ + │ ◄── chat_inbox ──────────┤ review" (reply_to=root) ──────┘ + │ đọc, REVIEW (≠ tác giả) │ + │ kb_add "ADR: streaming" │ (tri thức đáng giữ → KB) + │ chat_post "done" ──────► │ thread khép (trạng thái: done) + ▼ ▼ + (không ai spawn ai — mọi trao đổi bất đồng bộ, agent trả lời khi đang chạy) +``` + +--- + +## Bản Mermaid (render trên GitHub) + +```mermaid +flowchart TB + classDef ssot fill:#1f2937,color:#fff,stroke:#111; + classDef surf fill:#2563eb,color:#fff,stroke:#1d4ed8; + classDef store fill:#eef2ff,stroke:#4338ca,color:#3730a3; + classDef eng fill:#fff,stroke:#2563eb,color:#1e3a8a,stroke-width:2px; + classDef obs fill:#f0fdf4,stroke:#16a34a,color:#166534; + + subgraph SSOT["SSOT — nguồn canonical (.hatch/)"] + direction LR + CH["charter.md (L0)"]:::ssot + RO["roles/*.md (L1)"]:::ssot + CX["context/ (L2)"]:::ssot + REG["registry.yaml"]:::ssot + WF["workflow.yaml"]:::ssot + end + COMPILER{{"hatch compile"}}:::eng + CH --> COMPILER + RO --> COMPILER + CX --> COMPILER + REG --> COMPILER + WF --> COMPILER + COMPILER --> SURF["Surfaces per-agent
CLAUDE.md · AGENTS.md · GEMINI.md · .kiro
(protocol prose + DoD + orchestrator)"]:::surf + COMPILER --> MCPREG["MCP registration
.mcp.json · .kiro/settings/mcp.json · .hatch/mcp/*"]:::surf + + subgraph AGENTS["Coding agents — ENTRYPOINT, tự lái (1 process/con)"] + direction LR + A1["Claude Code
(lead = Conductor)"] + A2["Codex"] + A3["agy"] + A4["Kiro"] + end + SURF --> AGENTS + MCPREG -. "hatch mcp --as id" .-> MCP + + MCP{{"Hatch MCP server
(stdio, per agent)"}}:::eng + AGENTS == "MCP tools" ==> MCP + + subgraph STORE["Hệ thống file = database (git)"] + direction LR + BUS[("bus/ — CHAT = comms + backlog
thread = task")]:::store + KB[("kb/ — Knowledge Base")]:::store + LEDGER[("ledger/ — audit")]:::store + end + MCP --> BUS + MCP --> KB + MCP --> LEDGER + + subgraph OBS["Observe (read-only, con người)"] + direction LR + O1["hatch board"]:::obs + O2["hatch chat"]:::obs + O3["hatch status"]:::obs + end + BUS --> OBS + LEDGER --> OBS +``` + +```mermaid +stateDiagram-v2 + direction LR + [*] --> open: chat_open (mở task) + open --> in_progress: bắt tay (chat_post) + in_progress --> review: xong + @tag reviewer + review --> done: reviewer duyệt + post "done" + review --> in_progress: changes-requested + in_progress --> blocked: post "block" + blocked --> in_progress: gỡ kẹt + done --> [*] + note right of review + Trạng thái suy ra từ hội thoại. + Gate = DoD agent tự kiểm. + end note +``` diff --git a/hatch/docs/overview.md b/hatch/docs/overview.md new file mode 100644 index 0000000..b5b0070 --- /dev/null +++ b/hatch/docs/overview.md @@ -0,0 +1,99 @@ +# Overview — bản đồ tổng Hatch (plaintext) + +> **Mô hình hiện hành = embedded harness** (xem [doc 20](20-embedded-harness-pivot.md)). Coding agent là entrypoint và **tự lái mình**; Hatch là lớp nền chung (chat = comms + backlog · KB · ledger) mà agent với tới qua **MCP server**. Hatch **không** spawn, **không** điều khiển agent. Các doc 00–19 mô tả thiết kế GỐC (trước pivot); khi mâu thuẫn, doc 20 thắng. + +Legend: **✓** đã implement (code + test) · **⌖** archived sau build tag `hatch_legacy` (không vào binary mặc định, khôi phục được). + +## 1. Bức tranh toàn cảnh + +``` +┌─ HATCH ─ embedded harness cho một bầy coding-agent, trên file + git ───────────────┐ +│ │ +│ SSOT (.hatch/) │ +│ charter(L0) · roles(L1) · context(L2) · registry(ai-giữ-vai) · workflow(quy trình)│ +│ │ ✓ hatch compile (1 nguồn → N surface, manifest bắt stale) │ +│ ▼ │ +│ SURFACES ✓ CLAUDE.md · AGENTS.md · GEMINI.md · .kiro/steering │ +│ │ PROTOCOL prose: workflow + chat etiquette + DoD self-check │ +│ │ + khối "orchestrator" cho lead + ĐĂNG KÝ MCP per-agent │ +│ │ (.mcp.json · .kiro/settings/mcp.json merge; snippet .hatch/mcp/* codex/agy) +│ ▼ │ +│ AGENTS ✓ claude · codex · agy · kiro (entrypoint; TỰ LÁI; mỗi con 1 process) │ +│ │ │ +│ │ với tới chat + KB qua ── hatch mcp --as (stdio) ──┐ │ +│ ▼ ▼ │ +│ MCP TOOLS ✓ whoami · chat_open · chat_post · chat_read · chat_inbox · │ +│ chat_search · chat_channels · kb_add · kb_search │ +│ │ +│ STORES = DATABASE (filesystem + git): │ +│ ✓ bus/ (chat = comms + backlog: 1 thread = 1 task) · ✓ kb/ (tri thức) · │ +│ ✓ ledger/ (audit append-only) │ +│ │ +│ ── lớp "quan sát của người" (READ-ONLY) ──────────────────────────────────────── │ +│ ✓ hatch board TUI: THREADS + CHAT + ledger ACTIVITY │ +│ ✓ hatch chat TUI: viewer hội thoại kiểu Slack │ +│ ✓ hatch status tóm tắt thread (task) + roster agent │ +│ ✓ hatch msg người chèn ý kiến vào chat │ +│ │ +│ ── operator tự-lái = ARCHIVED sau `hatch_legacy` ──────────────────────────────── │ +│ ⌖ run · plan · watch · tick · orchestrator · workflow-engine(gate/escalate/ticket)│ +│ ⌖ ceremony/standup · ask/convene · pair/mob · presence · oncall · cost/budget · │ +│ ⌖ workload/perf · report (không vào binary mặc định; go build -tags hatch_legacy)│ +└────────────────────────────────────────────────────────────────────────────────┘ + +3 kho tri thức: SSOT = config VÀO · KB = tri thức VÀO+RA · ledger = sự kiện RA + (bus = chat: đối thoại VÀ backlog — mỗi thread = một task) +``` + +## 2. Bản đồ lệnh CLI (theo nhóm) + +``` +SETUP/SSOT init · compile [--check] · validate · sync · hook + compile sinh: protocol prose (workflow + chat etiquette + DoD self-check + + khối orchestrator cho lead) + đăng ký MCP per-agent +HARNESS mcp --as (stdio MCP server: chat + KB tools cho agent) +OBSERVE (RO) status · board (TUI) · chat (TUI) · logs +CHAT/BACKLOG msg · inbox · thread · channel ls|show|join|leave|members · search +KNOWLEDGE kb add|query|index|link|backlinks|graph|open · doc +MISC org · doctor +⌖ archived run · plan · watch · tick · ceremony/standup · ask/convene · pair/mob · + (hatch_legacy) presence · oncall · cost/budget · workload/perf · report · gate/escalate/ticket +``` + +## 3. "Một ngày của squad" (luồng dệt mọi mảnh) + +``` + hatch init → hatch compile (SSOT → surface + đăng ký MCP) + │ + Người mở MỘT coding agent NGAY TRONG workspace (vd Claude Code). + CLAUDE.md (protocol + khối orchestrator) + .mcp.json đã wire nó vào chat + KB chung. + │ agent đầu tiên = Conductor/orchestrator + có thể kiêm vai khác + ▼ + Conductor bẻ việc → MỞ MỘT THREAD cho mỗi task qua MCP (chat_open) → brief → @tag agent phù hợp + │ + Agent được tag (đang chạy) đọc thread → làm → brief kết quả TRONG CÙNG THREAD (chat_post/reply) + │ recall bằng chat_search / kb_search; ghi quyết định/bài học bằng kb_add + │ trạng thái task suy ra từ hội thoại (post type done/block/decision) — không lane/gate engine + ▼ + Reviewer (≠ implementer) tự chạy DoD self-check → báo done trong thread + │ + Tất cả ASYNC kiểu Slack: agent chỉ phản hồi khi nó đang chạy; không ai spawn ai; Hatch không spawn. + │ + Người xem bằng hatch board / hatch chat / hatch status; chèn ý kiến bằng hatch msg. +``` + +## 4. Đối ứng squad người (cô đọng) + +``` + onboarding handbook + working agreements = compiler → CLAUDE.md/AGENTS.md (protocol prose) theo vai + Slack của đội = bus (chat: channel · thread · @mention · search · inbox) + — đồng thời là backlog: mỗi thread = một task + cánh cửa vào Slack + wiki chung = MCP server (hatch mcp --as ) + wiki / ADR / bộ não chung = kb/ (đọc VÀ ghi qua MCP) + nhật ký / git log = ledger/ + Engineering Manager / Scrum Master = agent lead/Conductor (LÀ một agent tự lái, không phải Hatch) + bảng quan sát của quản lý = hatch board / chat / status (read-only) + ⌖ EM/CEO dashboard (workload/perf/budget)= archived sau hatch_legacy +``` + +> Chi tiết: **mô hình hiện hành [20](20-embedded-harness-pivot.md)** · kiến trúc [01](01-architecture.md) · context-compiler [04](04-context-compiler.md) · KB [09](09-knowledge-base.md) · adapters [10](10-agent-adapters.md). Các doc 02–19 cho bối cảnh/ý tưởng nền (thiết kế gốc trước pivot). diff --git a/hatch/go.mod b/hatch/go.mod new file mode 100644 index 0000000..18c3eb9 --- /dev/null +++ b/hatch/go.mod @@ -0,0 +1,42 @@ +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/lipgloss v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/jsonschema-go v0.4.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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // 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 + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/hatch/go.sum b/hatch/go.sum new file mode 100644 index 0000000..8cd5170 --- /dev/null +++ b/hatch/go.sum @@ -0,0 +1,80 @@ +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/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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +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/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/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/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/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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/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/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= +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/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/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hatch/internal/bus/adapter.go b/hatch/internal/bus/adapter.go new file mode 100644 index 0000000..745c351 --- /dev/null +++ b/hatch/internal/bus/adapter.go @@ -0,0 +1,48 @@ +package bus + +import ( + "fmt" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/port" +) + +// Bus implements port.Bus. +var _ port.Bus = (*Bus)(nil) + +// Notify posts a plain message to a channel (the write side of port.Bus). +func (b *Bus) Notify(channel, from string, to []string, body string) error { + _, err := b.Post(Message{Channel: channel, From: from, To: to, Body: body}) + return err +} + +// CatchUp returns an agent's unread inbox and a query-scoped recall of recent +// conversation, formatted as compact, token-bounded lines (the read side an +// agent uses to "read the room" before starting work). +func (b *Bus) CatchUp(agent string, roles []string, query string, limit int) (inbox, recall []string) { + in, _ := b.Inbox(agent, roles) + for _, m := range capMsgs(in, 10) { + inbox = append(inbox, fmt.Sprintf("[%s] %s · %s: %s", m.Type, m.Channel, m.From, snippet(m.Body))) + } + subs := b.Subscriptions(agent) + rc, _ := b.Search(SearchOpts{Query: query, Channels: subs, Limit: limit}) + for _, m := range rc { + recall = append(recall, fmt.Sprintf("%s · %s: %s", m.Channel, m.From, snippet(m.Body))) + } + return inbox, recall +} + +func capMsgs(ms []Message, n int) []Message { + if len(ms) > n { + return ms[len(ms)-n:] + } + return ms +} + +func snippet(s string) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if len([]rune(s)) > 120 { + return string([]rune(s)[:120]) + "…" + } + return s +} diff --git a/hatch/internal/bus/bus.go b/hatch/internal/bus/bus.go new file mode 100644 index 0000000..c9ee440 --- /dev/null +++ b/hatch/internal/bus/bus.go @@ -0,0 +1,237 @@ +// Package bus is the agent communication layer: a file-based, append-only, +// auditable message bus that lets agents talk to each other directly — direct +// messages, @mentions, questions, and multi-agent meetings. The bus carries +// *dialogue*; the board/ledger remain the source of truth for *state*. The +// orchestrator is the medium that delivers turns between process-isolated +// agents (like a team's chat server), so every exchange stays on the record. +package bus + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Message types (aliases of the domain constants). +const ( + TypeMsg = model.MsgText + TypeAsk = model.MsgAsk + TypeReply = model.MsgReply + TypeDecision = model.MsgDecision +) + +// Message and SearchOpts are the communication domain types; bus re-exports +// them as aliases so call sites stay terse while the canonical types live in +// the domain (model), letting ports return them without coupling to this +// package. +type Message = model.Message + +// Bus reads and writes conversation threads under .hatch/bus/. +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) threadsDir() string { return filepath.Join(b.dir(), "threads") } +func (b *Bus) cursorsPath() string { return filepath.Join(b.dir(), ".cursors.json") } + +// safeThread maps a thread/channel id to a filename (drops a leading '#'). +func safeThread(id string) string { + id = strings.TrimPrefix(id, "#") + var s strings.Builder + for _, r := range strings.ToLower(id) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + s.WriteRune(r) + default: + s.WriteRune('-') + } + } + return strings.Trim(s.String(), "-") +} + +func (b *Bus) threadPath(thread string) string { + return filepath.Join(b.threadsDir(), safeThread(thread)+".md") +} + +// render formats a message as an append-only Markdown block. +func render(m Message) string { + var s strings.Builder + to := strings.Join(m.To, ", ") + fmt.Fprintf(&s, "## %s · %s → %s · %s", m.TS, m.From, to, m.Type) + if m.InReplyTo != "" { + fmt.Fprintf(&s, " · re:%s", m.InReplyTo) + } + fmt.Fprintf(&s, " · {#%s}\n", m.ID) + s.WriteString(strings.TrimRight(m.Body, "\n")) + s.WriteString("\n") + return s.String() +} + +// Post appends a message to its thread, defaulting ID and timestamp. +func (b *Bus) Post(m Message) (Message, error) { + if m.Channel == "" { + return m, fmt.Errorf("message requires a channel") + } + if m.From == "" { + return m, fmt.Errorf("message requires a sender") + } + if strings.TrimSpace(m.Body) == "" { + return m, fmt.Errorf("message body is empty") + } + if m.Type == "" { + m.Type = TypeMsg + } + // @mentions in the body tag teammates (agent ids or roles) just like Slack. + for _, tag := range Mentions(m.Body) { + if !contains(m.To, tag) { + m.To = append(m.To, tag) + } + } + if m.TS == "" { + m.TS = time.Now().Format(time.RFC3339Nano) + } + if m.ID == "" { + m.ID = newID(m.TS) + } + if err := os.MkdirAll(b.threadsDir(), 0o755); err != nil { + return m, err + } + p := b.threadPath(m.Channel) + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return m, err + } + defer f.Close() + block := render(m) + if fi, _ := f.Stat(); fi != nil && fi.Size() > 0 { + block = "\n" + block + } + _, err = f.WriteString(block) + return m, err +} + +// Messages returns the parsed messages of a channel in order. +func (b *Bus) Messages(channel string) ([]Message, error) { + raw, err := os.ReadFile(b.threadPath(channel)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return parseThread(safeThread(channel), string(raw)), nil +} + +// Raw returns the raw markdown of a channel (for handing to an agent as context). +func (b *Bus) Raw(channel string) (string, error) { + raw, err := os.ReadFile(b.threadPath(channel)) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(raw), nil +} + +// Channels lists all channel/conversation ids. +func (b *Bus) Channels() ([]string, error) { + ents, err := os.ReadDir(b.threadsDir()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, e := range ents { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + out = append(out, strings.TrimSuffix(e.Name(), ".md")) + } + } + sort.Strings(out) + return out, nil +} + +// Replies returns a threaded sub-conversation within a channel: the root +// message plus every message replying to it (one level). +func (b *Bus) Replies(channel, rootID string) ([]Message, error) { + msgs, err := b.Messages(channel) + if err != nil { + return nil, err + } + var out []Message + for _, m := range msgs { + if m.ID == rootID || m.InReplyTo == rootID { + out = append(out, m) + } + } + return out, nil +} + +// Inbox returns messages addressed to an agent (by id, one of its roles, "*", +// or "all") across all threads, newer than the agent's cursor. +func (b *Bus) Inbox(agent string, roles []string) ([]Message, error) { + cursors, _ := b.loadCursors() + var sinceT time.Time + if s := cursors[agent]; s != "" { + sinceT, _ = time.Parse(time.RFC3339Nano, s) + } + want := map[string]bool{agent: true, "*": true, "all": true} + for _, r := range roles { + want[r] = true + } + channels, err := b.Channels() + if err != nil { + return nil, err + } + var out []Message + for _, ch := range channels { + msgs, err := b.Messages(ch) + if err != nil { + return nil, err + } + for _, m := range msgs { + if m.From == agent { + continue + } + if !sinceT.IsZero() { + if mt, err := time.Parse(time.RFC3339Nano, m.TS); err == nil && !mt.After(sinceT) { + continue + } + } + for _, to := range m.To { + if want[to] { + out = append(out, m) + break + } + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS }) + return out, nil +} + +// MarkRead advances an agent's cursor to now. +func (b *Bus) MarkRead(agent string) error { + cursors, _ := b.loadCursors() + cursors[agent] = time.Now().Format(time.RFC3339Nano) + return b.saveCursors(cursors) +} + +func newID(ts string) string { + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + t = time.Now() + } + // microsecond suffix keeps ids unique within the same second. + return fmt.Sprintf("m%s-%06d", t.Format("0102-150405"), t.Nanosecond()/1000) +} diff --git a/hatch/internal/bus/bus_test.go b/hatch/internal/bus/bus_test.go new file mode 100644 index 0000000..0410739 --- /dev/null +++ b/hatch/internal/bus/bus_test.go @@ -0,0 +1,120 @@ +package bus + +import ( + "strings" + "testing" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +func newBus(t *testing.T) *Bus { + t.Helper() + return New(paths.At(t.TempDir())) +} + +func TestPostAndParseRoundTrip(t *testing.T) { + b := newBus(t) + if _, err := b.Post(Message{Channel: "T-1", From: "codex", To: []string{"claude-code", "reviewer"}, Type: TypeAsk, Body: "Có nên dùng streaming?\nDòng 2."}); err != nil { + t.Fatal(err) + } + msgs, err := b.Messages("T-1") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 { + t.Fatalf("want 1 msg, got %d", len(msgs)) + } + m := msgs[0] + if m.From != "codex" || m.Type != TypeAsk { + t.Fatalf("parsed wrong: %+v", m) + } + if len(m.To) != 2 || m.To[0] != "claude-code" || m.To[1] != "reviewer" { + t.Fatalf("recipients wrong: %v", m.To) + } + if m.Body != "Có nên dùng streaming?\nDòng 2." { + t.Fatalf("body wrong: %q", m.Body) + } + if m.ID == "" { + t.Fatal("id not assigned/parsed") + } +} + +func TestInboxMatchesIdRoleAndStar(t *testing.T) { + b := newBus(t) + b.Post(Message{Channel: "t", From: "a", To: []string{"reviewer"}, Body: "by role"}) + b.Post(Message{Channel: "t", From: "a", To: []string{"claude-code"}, Body: "by id"}) + b.Post(Message{Channel: "t", From: "a", To: []string{"*"}, Body: "broadcast"}) + b.Post(Message{Channel: "t", From: "a", To: []string{"codex"}, Body: "not for me"}) + + in, err := b.Inbox("claude-code", []string{"reviewer", "architect"}) + if err != nil { + t.Fatal(err) + } + if len(in) != 3 { + t.Fatalf("want 3 inbox msgs (role+id+star), got %d", len(in)) + } +} + +func TestInboxExcludesOwnAndRespectsCursor(t *testing.T) { + b := newBus(t) + b.Post(Message{Channel: "t", From: "claude-code", To: []string{"*"}, Body: "my own"}) + b.Post(Message{Channel: "t", From: "codex", To: []string{"claude-code"}, Body: "first"}) + if err := b.MarkRead("claude-code"); err != nil { + t.Fatal(err) + } + b.Post(Message{Channel: "t", From: "codex", To: []string{"claude-code"}, Body: "after read"}) + + in, err := b.Inbox("claude-code", nil) + if err != nil { + t.Fatal(err) + } + if len(in) != 1 || in[0].Body != "after read" { + t.Fatalf("cursor/own-exclusion wrong: %+v", in) + } +} + +func TestMentionsRouteToInbox(t *testing.T) { + b := newBus(t) + // no explicit --to; tagging via @mention in the body. + b.Post(Message{Channel: "#design", From: "codex", To: []string{"#design"}, + Body: "@claude-code @tester nên dùng streaming nhé"}) + in, err := b.Inbox("claude-code", nil) + if err != nil { + t.Fatal(err) + } + if len(in) != 1 { + t.Fatalf("mention @claude-code should hit inbox, got %d", len(in)) + } + // role mention reaches an agent holding that role. + tin, _ := b.Inbox("codexer", []string{"tester"}) + if len(tin) != 1 { + t.Fatalf("@tester role mention should reach a tester, got %d", len(tin)) + } +} + +func TestMentionsExtract(t *testing.T) { + got := Mentions("hey @codex and @claude-code, ask @reviewer; email a@b.com not a mention start") + want := map[string]bool{"codex": true, "claude-code": true, "reviewer": true} + for _, g := range got { + delete(want, g) + } + if len(want) != 0 { + t.Fatalf("missing mentions: %v (got %v)", want, got) + } +} + +func TestBodyWithMarkdownHeadingNotSplit(t *testing.T) { + b := newBus(t) + body := "Đề xuất:\n## Phương án A\nchi tiết\n## Phương án B\nkhác" + b.Post(Message{Channel: "#design", From: "codex", To: []string{"*"}, Body: body}) + msgs, err := b.Messages("#design") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 { + t.Fatalf("body with ## headings must stay 1 message, got %d", len(msgs)) + } + if !strings.Contains(msgs[0].Body, "Phương án B") { + t.Fatalf("body truncated: %q", msgs[0].Body) + } +} diff --git a/hatch/internal/bus/cursors.go b/hatch/internal/bus/cursors.go new file mode 100644 index 0000000..17c89ce --- /dev/null +++ b/hatch/internal/bus/cursors.go @@ -0,0 +1,30 @@ +package bus + +import ( + "encoding/json" + "os" + "path/filepath" +) + +func (b *Bus) loadCursors() (map[string]string, error) { + raw, err := os.ReadFile(b.cursorsPath()) + if err != nil { + return map[string]string{}, nil + } + m := map[string]string{} + if err := json.Unmarshal(raw, &m); err != nil { + return map[string]string{}, nil + } + return m, nil +} + +func (b *Bus) saveCursors(m map[string]string) error { + if err := os.MkdirAll(filepath.Dir(b.cursorsPath()), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(b.cursorsPath(), append(raw, '\n'), 0o644) +} diff --git a/hatch/internal/bus/members.go b/hatch/internal/bus/members.go new file mode 100644 index 0000000..e8eed1a --- /dev/null +++ b/hatch/internal/bus/members.go @@ -0,0 +1,74 @@ +package bus + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" +) + +// membersPath stores channel → subscribed agents. +func (b *Bus) membersPath() string { return filepath.Join(b.dir(), ".members.json") } + +func (b *Bus) loadMembers() map[string][]string { + raw, err := os.ReadFile(b.membersPath()) + if err != nil { + return map[string][]string{} + } + m := map[string][]string{} + if json.Unmarshal(raw, &m) != nil { + return map[string][]string{} + } + return m +} + +func (b *Bus) saveMembers(m map[string][]string) error { + if err := os.MkdirAll(b.dir(), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(b.membersPath(), append(raw, '\n'), 0o644) +} + +// Subscribe adds an agent to a channel's membership. +func (b *Bus) Subscribe(channel, agent string) error { + m := b.loadMembers() + if !contains(m[channel], agent) { + m[channel] = append(m[channel], agent) + sort.Strings(m[channel]) + } + return b.saveMembers(m) +} + +// Unsubscribe removes an agent from a channel. +func (b *Bus) Unsubscribe(channel, agent string) error { + m := b.loadMembers() + var kept []string + for _, a := range m[channel] { + if a != agent { + kept = append(kept, a) + } + } + m[channel] = kept + return b.saveMembers(m) +} + +// Members lists agents subscribed to a channel. +func (b *Bus) Members(channel string) []string { + return b.loadMembers()[channel] +} + +// Subscriptions lists channels an agent is subscribed to. +func (b *Bus) Subscriptions(agent string) []string { + var out []string + for ch, members := range b.loadMembers() { + if contains(members, agent) { + out = append(out, ch) + } + } + sort.Strings(out) + return out +} diff --git a/hatch/internal/bus/mention.go b/hatch/internal/bus/mention.go new file mode 100644 index 0000000..e301f52 --- /dev/null +++ b/hatch/internal/bus/mention.go @@ -0,0 +1,30 @@ +package bus + +import "regexp" + +// mentionRe matches @handles: @codex, @claude-code, @reviewer, @all. +var mentionRe = regexp.MustCompile(`@([A-Za-z0-9][A-Za-z0-9._-]*)`) + +// Mentions extracts @-tagged handles (agent ids or roles) from a message body, +// without the leading '@'. Used to route tags to recipients' inboxes. +func Mentions(body string) []string { + var out []string + seen := map[string]bool{} + for _, m := range mentionRe.FindAllStringSubmatch(body, -1) { + h := m[1] + if !seen[h] { + seen[h] = true + out = append(out, h) + } + } + return out +} + +func contains(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} diff --git a/hatch/internal/bus/parse.go b/hatch/internal/bus/parse.go new file mode 100644 index 0000000..09de78f --- /dev/null +++ b/hatch/internal/bus/parse.go @@ -0,0 +1,92 @@ +package bus + +import ( + "strings" + "time" +) + +// isMsgHeading reports whether a line is a message heading (not arbitrary +// Markdown in a body). Headings always start with "## · …", so a +// plain "## Section" inside a message body is NOT treated as a new message. +func isMsgHeading(ln string) bool { + if !strings.HasPrefix(ln, "## ") { + return false + } + rest := strings.TrimPrefix(ln, "## ") + i := strings.Index(rest, " · ") + if i < 0 { + return false + } + ts := rest[:i] + if _, err := time.Parse(time.RFC3339Nano, ts); err == nil { + return true + } + _, err := time.Parse(time.RFC3339, ts) + return err == nil +} + +// parseThread parses the append-only Markdown of a thread back into messages. +// Heading form: "## · · [ · re:] · {#}". +func parseThread(channel, raw string) []Message { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + lines := strings.Split(raw, "\n") + var msgs []Message + var cur *Message + var body []string + + flush := func() { + if cur != nil { + cur.Body = strings.TrimSpace(strings.Join(body, "\n")) + msgs = append(msgs, *cur) + } + cur = nil + body = nil + } + + for _, ln := range lines { + if isMsgHeading(ln) { + flush() + m := parseHeading(strings.TrimPrefix(ln, "## ")) + m.Channel = channel + cur = &m + continue + } + if cur != nil { + body = append(body, ln) + } + } + flush() + return msgs +} + +func parseHeading(h string) Message { + var m Message + // trailing {#id} + if i := strings.LastIndex(h, "{#"); i >= 0 { + if j := strings.Index(h[i:], "}"); j >= 0 { + m.ID = h[i+2 : i+j] + h = strings.TrimRight(h[:i], " ·") + } + } + parts := strings.Split(h, " · ") + for idx, p := range parts { + p = strings.TrimSpace(p) + switch { + case idx == 0: + m.TS = p + case strings.Contains(p, "→"): + fromTo := strings.SplitN(p, "→", 2) + m.From = strings.TrimSpace(fromTo[0]) + for _, to := range strings.Split(fromTo[1], ",") { + if t := strings.TrimSpace(to); t != "" { + m.To = append(m.To, t) + } + } + case strings.HasPrefix(p, "re:"): + m.InReplyTo = strings.TrimPrefix(p, "re:") + case p == TypeMsg || p == TypeAsk || p == TypeReply || p == TypeDecision: + m.Type = p + } + } + return m +} diff --git a/hatch/internal/bus/search.go b/hatch/internal/bus/search.go new file mode 100644 index 0000000..b0c3d4a --- /dev/null +++ b/hatch/internal/bus/search.go @@ -0,0 +1,110 @@ +package bus + +import ( + "sort" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// SearchOpts is the domain query type, re-exported for terse call sites. +type SearchOpts = model.SearchOpts + +// Search returns matching messages newest-first, capped at Limit. +func (b *Bus) Search(o SearchOpts) ([]Message, error) { + limit := o.Limit + if limit <= 0 { + limit = 20 + } + scope := o.Channels + if o.Channel != "" { + scope = []string{o.Channel} + } + if len(scope) == 0 { + chs, err := b.Channels() + if err != nil { + return nil, err + } + scope = chs + } + allow := map[string]bool{} + for _, c := range scope { + allow[safeThread(c)] = true + } + tokens := queryTokens(o.Query) + + type scored struct { + m Message + score int + } + var hits []scored + channels, err := b.Channels() + if err != nil { + return nil, err + } + for _, ch := range channels { + if !allow[ch] { + continue + } + msgs, err := b.Messages(ch) + if err != nil { + return nil, err + } + for _, m := range msgs { + if o.From != "" && m.From != o.From { + continue + } + if o.Type != "" && m.Type != o.Type { + continue + } + score := matchScore(tokens, m) + if len(tokens) > 0 && score == 0 { + continue + } + hits = append(hits, scored{m, score}) + } + } + // Rank by number of distinct query tokens matched, then by recency. + sort.Slice(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].m.TS > hits[j].m.TS + }) + out := make([]Message, 0, len(hits)) + for _, h := range hits { + out = append(out, h.m) + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// queryTokens lowercases the query and keeps tokens of length >= 2. +func queryTokens(q string) []string { + var out []string + for _, t := range strings.FieldsFunc(strings.ToLower(q), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ỹ') + }) { + if len([]rune(t)) >= 2 { + out = append(out, t) + } + } + return out +} + +// matchScore counts how many distinct query tokens appear in body or sender. +func matchScore(tokens []string, m Message) int { + if len(tokens) == 0 { + return 0 + } + hay := strings.ToLower(m.Body + " " + m.From) + n := 0 + for _, t := range tokens { + if strings.Contains(hay, t) { + n++ + } + } + return n +} diff --git a/hatch/internal/bus/search_test.go b/hatch/internal/bus/search_test.go new file mode 100644 index 0000000..51eba4e --- /dev/null +++ b/hatch/internal/bus/search_test.go @@ -0,0 +1,53 @@ +package bus + +import ( + "testing" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +func TestSearchFiltersAndScope(t *testing.T) { + b := New(paths.At(t.TempDir())) + b.Post(Message{Channel: "#design", From: "codex", To: []string{"#design"}, Body: "dùng CSV streaming cho export"}) + b.Post(Message{Channel: "#design", From: "claude-code", To: []string{"#design"}, Body: "đồng ý streaming"}) + b.Post(Message{Channel: "#random", From: "gemini", To: []string{"#random"}, Body: "streaming nhạc trưa nay"}) + + // query across all channels + all, _ := b.Search(SearchOpts{Query: "streaming"}) + if len(all) != 3 { + t.Fatalf("want 3 streaming hits, got %d", len(all)) + } + // restrict to a channel + d, _ := b.Search(SearchOpts{Query: "streaming", Channel: "#design"}) + if len(d) != 2 { + t.Fatalf("want 2 in #design, got %d", len(d)) + } + // scope by subscriptions + b.Subscribe("#design", "codex") + subs := b.Subscriptions("codex") + scoped, _ := b.Search(SearchOpts{Query: "streaming", Channels: subs}) + if len(scoped) != 2 { + t.Fatalf("subscription scope should yield 2, got %d", len(scoped)) + } + // newest-first + limit + lim, _ := b.Search(SearchOpts{Query: "streaming", Limit: 1}) + if len(lim) != 1 { + t.Fatalf("limit should cap to 1, got %d", len(lim)) + } +} + +func TestSubscribeUnsubscribe(t *testing.T) { + b := New(paths.At(t.TempDir())) + b.Subscribe("#design", "codex") + b.Subscribe("#design", "claude-code") + if got := b.Members("#design"); len(got) != 2 { + t.Fatalf("want 2 members, got %v", got) + } + b.Unsubscribe("#design", "codex") + if got := b.Members("#design"); len(got) != 1 || got[0] != "claude-code" { + t.Fatalf("unsubscribe failed: %v", got) + } + if subs := b.Subscriptions("claude-code"); len(subs) != 1 || subs[0] != "#design" { + t.Fatalf("subscriptions wrong: %v", subs) + } +} diff --git a/hatch/internal/ceremony/ceremony.go b/hatch/internal/ceremony/ceremony.go new file mode 100644 index 0000000..d90d2bd --- /dev/null +++ b/hatch/internal/ceremony/ceremony.go @@ -0,0 +1,270 @@ +//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 new file mode 100644 index 0000000..cd6ee8e --- /dev/null +++ b/hatch/internal/ceremony/ceremony_test.go @@ -0,0 +1,94 @@ +//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 new file mode 100644 index 0000000..52f9077 --- /dev/null +++ b/hatch/internal/cli/board.go @@ -0,0 +1,39 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/tui" +) + +func newBoardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "board", + Short: "Read-only mission-control TUI (threads + chat + ledger)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + _, err = tui.New(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 new file mode 100644 index 0000000..b8c752d --- /dev/null +++ b/hatch/internal/cli/ceremony.go @@ -0,0 +1,180 @@ +//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 new file mode 100644 index 0000000..1bb6e13 --- /dev/null +++ b/hatch/internal/cli/client.go @@ -0,0 +1,174 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/config" +) + +// resolveClientKind maps a user-facing --client alias to a registry agent kind. +func resolveClientKind(alias string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(alias)) { + case "cc", "claude", "claude-code", "claudecode": + return "claude", true + case "codex": + return "codex", true + case "agy", "antigravity": + return "agy", true + case "kiro", "kiro-cli": + return "kiro", true + default: + return "", false + } +} + +// agentIDForKind returns the first registry agent of a kind (the identity the +// MCP server posts as for that client). +func agentIDForKind(ws *config.Workspace, kind string) (string, bool) { + for _, a := range ws.Registry.Agents { + if a.Kind == kind { + return a.ID, true + } + } + return "", false +} + +// setupClient wires one client so it reaches the Hatch chat + KB over MCP under +// its own identity (`hatch mcp --as `). Repo-local configs are written in +// place; for clients whose config lives in $HOME (Codex, agy) it uses the +// client's own CLI when available, else merges/points at the home file. +// Running `hatch init --client ` is the user's explicit consent to set up x. +func setupClient(cmd *cobra.Command, ws *config.Workspace, repoRoot, alias string, dryRun bool) error { + out := cmd.OutOrStdout() + kind, ok := resolveClientKind(alias) + if !ok { + return fmt.Errorf("unknown client %q (use: cc | codex | agy | kiro)", alias) + } + id, ok := agentIDForKind(ws, kind) + if !ok { + return fmt.Errorf("no %s-kind agent in registry.yaml — add one, then re-run", kind) + } + args := []string{"mcp", "--as", id} + + switch kind { + case "claude": + p := filepath.Join(repoRoot, ".mcp.json") + if err := writeServerJSON(p, id, dryRun); err != nil { + return err + } + say(out, dryRun, "claude: project MCP config %s → server 'hatch' (--as %s)", rel(repoRoot, p), id) + fmt.Fprintf(out, " để cài plugin (skill + /hatch) cho Claude Code:\n") + fmt.Fprintf(out, " /plugin marketplace add fioenix/overclaud\n") + fmt.Fprintf(out, " /plugin install hatch@hatch\n") + fmt.Fprintf(out, " (project-scope .mcp.json ở trên đã đủ để Claude Code nạp MCP server.)\n") + + case "kiro": + p := filepath.Join(repoRoot, ".kiro", "settings", "mcp.json") + if err := writeServerJSON(p, id, dryRun); err != nil { + return err + } + say(out, dryRun, "kiro: project MCP config %s → server 'hatch' (--as %s)", rel(repoRoot, p), id) + + case "codex": + // Codex owns ~/.codex/config.toml; let its own CLI edit it. + snippet := filepath.Join(ws.Layout.Root, "mcp", 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...) + if b, err := c.CombinedOutput(); err != nil { + fmt.Fprintf(out, "codex: `codex mcp add` lỗi (%v): %s\n", err, strings.TrimSpace(string(b))) + fmt.Fprintf(out, " dán tay khối ở %s vào ~/.codex/config.toml\n", snippet) + } else { + fmt.Fprintf(out, "codex: đã `codex mcp add hatch -- hatch mcp --as %s` (→ ~/.codex/config.toml)\n", id) + } + } else { + say(out, dryRun, "codex: chạy `codex mcp add hatch -- hatch mcp --as %s`", id) + fmt.Fprintf(out, " (hoặc dán %s vào ~/.codex/config.toml)\n", snippet) + } + + case "agy": + // Antigravity CLI (NOT legacy Gemini CLI) loads MCP servers ONLY from a + // HOME-level mcp_config.json — a separate file, not inline in settings. + // Current path is ~/.gemini/config/mcp_config.json; older installs use + // ~/.gemini/antigravity-cli/mcp_config.json (now a symlink to it). + // Project-local .antigravitycli/mcp_config.json is detected but IGNORED + // (google-antigravity/antigravity-cli#60), so we don't write one. + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("agy: cannot resolve home dir: %w", err) + } + homeCfg := filepath.Join(home, ".gemini", "config", "mcp_config.json") + if err := writeServerJSON(homeCfg, id, dryRun); err != nil { + return err + } + say(out, dryRun, "agy: %s → server 'hatch' (--as %s)", homeCfg, id) + // If a legacy antigravity-cli dir exists and isn't just a symlink to the + // config dir, merge there too so pre-migration installs pick it up. + legacyDir := filepath.Join(home, ".gemini", "antigravity-cli") + if fi, err := os.Lstat(legacyDir); err == nil && fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 { + legacyCfg := filepath.Join(legacyDir, "mcp_config.json") + if err := writeServerJSON(legacyCfg, id, dryRun); err != nil { + return err + } + say(out, dryRun, "agy: %s (legacy) → server 'hatch'", legacyCfg) + } + fmt.Fprintf(out, " (agy chỉ nạp MCP ở HOME-level; project-local .antigravitycli/mcp_config.json bị bỏ qua — issue #60)\n") + } + return nil +} + +// writeServerJSON merges {"mcpServers":{"hatch":{command,args}}} into a JSON +// config, preserving any other servers/keys. Creates parents if needed. +func writeServerJSON(path, agentID string, dryRun bool) error { + root := map[string]any{} + if raw, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(raw, &root); err != nil { + return fmt.Errorf("%s is not valid JSON (edit or remove it): %w", path, err) + } + } else if !os.IsNotExist(err) { + return err + } + servers, _ := root["mcpServers"].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + servers["hatch"] = map[string]any{ + "command": "hatch", + "args": []any{"mcp", "--as", agentID}, + } + root["mcpServers"] = servers + if dryRun { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(root, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o644) +} + +func say(out io.Writer, dryRun bool, format string, a ...any) { + prefix := "✓ " + if dryRun { + prefix = "[dry-run] " + } + fmt.Fprintf(out, prefix+format+"\n", a...) +} + +func rel(root, p string) string { + if r, err := filepath.Rel(root, p); err == nil { + return r + } + return p +} diff --git a/hatch/internal/cli/client_test.go b/hatch/internal/cli/client_test.go new file mode 100644 index 0000000..2937a52 --- /dev/null +++ b/hatch/internal/cli/client_test.go @@ -0,0 +1,72 @@ +//go:build !hatch_legacy + +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestResolveClientKind(t *testing.T) { + cases := map[string]string{ + "cc": "claude", "claude": "claude", "claude-code": "claude", + "codex": "codex", "agy": "agy", "antigravity": "agy", + "kiro": "kiro", "kiro-cli": "kiro", + } + for alias, want := range cases { + got, ok := resolveClientKind(alias) + if !ok || got != want { + t.Errorf("resolveClientKind(%q) = %q,%v; want %q", alias, got, ok, want) + } + } + if _, ok := resolveClientKind("gpt"); ok { + t.Error("resolveClientKind(gpt) should be unknown") + } +} + +func TestWriteServerJSONMergePreserves(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "nested", "mcp_config.json") + // Seed with another server + an unrelated key. + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(`{"mcpServers":{"other":{"command":"x"}},"theme":"dark"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := writeServerJSON(p, "agy", false); err != nil { + t.Fatal(err) + } + var got map[string]any + raw, _ := os.ReadFile(p) + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("not valid JSON: %v", err) + } + servers := got["mcpServers"].(map[string]any) + if _, ok := servers["other"]; !ok { + t.Error("merge dropped pre-existing 'other' server") + } + h, ok := servers["hatch"].(map[string]any) + if !ok { + t.Fatal("merge did not add 'hatch' server") + } + if h["command"] != "hatch" { + t.Errorf("hatch command = %v, want hatch", h["command"]) + } + if got["theme"] != "dark" { + t.Error("merge dropped unrelated top-level 'theme' key") + } +} + +func TestWriteServerJSONDryRunWritesNothing(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "mcp.json") + if err := writeServerJSON(p, "codex", true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("dry-run should not create %s", p) + } +} diff --git a/hatch/internal/cli/comms.go b/hatch/internal/cli/comms.go new file mode 100644 index 0000000..657885a --- /dev/null +++ b/hatch/internal/cli/comms.go @@ -0,0 +1,327 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/bus" +) + +func newMsgCmd() *cobra.Command { + var from, to, channel, thread, replyTo, typ string + cmd := &cobra.Command{ + Use: "msg ", + Short: "Post to a channel (Slack-style): DM, @mention, or reply in a thread", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + conv := firstNonEmpty(channel, thread) + if from == "" || conv == "" { + return fmt.Errorf("--from and --channel are required") + } + // Default audience to the channel itself (a plain channel post). + recipients := splitCSV(to) + if len(recipients) == 0 { + recipients = []string{conv} + } + m, err := bus.New(ws.Layout).Post(bus.Message{ + Channel: conv, From: from, To: recipients, Type: typ, + InReplyTo: replyTo, Body: strings.Join(args, " "), + }) + if err != nil { + return err + } + where := conv + if replyTo != "" { + where += " (reply to " + replyTo + ")" + } + fmt.Fprintf(cmd.OutOrStdout(), "posted %s to %s\n", m.ID, where) + return nil + }, + } + cmd.Flags().StringVar(&from, "from", "", "sender (agent id or human:)") + cmd.Flags().StringVar(&to, "to", "", "recipients/mentions: agent/role/#channel/*, comma-separated") + cmd.Flags().StringVarP(&channel, "channel", "c", "", "channel/DM/conversation id (e.g. #design, dm-codex-claude, T-123)") + cmd.Flags().StringVar(&thread, "thread", "", "alias for --channel") + cmd.Flags().StringVar(&replyTo, "reply-to", "", "reply within a thread rooted at this message id") + cmd.Flags().StringVar(&typ, "type", "msg", "msg | ask | reply | decision") + return cmd +} + +func newChannelCmd() *cobra.Command { + cmd := &cobra.Command{Use: "channel", Aliases: []string{"chan"}, Short: "List and read channels"} + + ls := &cobra.Command{ + Use: "ls", + Short: "List all channels / DMs / conversations", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + chans, err := bus.New(ws.Layout).Channels() + if err != nil { + return err + } + if len(chans) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no channels yet") + } + for _, c := range chans { + fmt.Fprintln(cmd.OutOrStdout(), c) + } + return nil + }, + } + + var in string + show := &cobra.Command{ + Use: "show ", + Short: "Print a channel (or a single thread with --in )", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + bs := bus.New(ws.Layout) + out := cmd.OutOrStdout() + if in != "" { + msgs, err := bs.Replies(args[0], in) + if err != nil { + return err + } + if len(msgs) == 0 { + fmt.Fprintf(out, "no thread rooted at %s in %s\n", in, args[0]) + } + for _, m := range msgs { + fmt.Fprintf(out, "%s · %s → %s · %s\n %s\n", m.ID, m.From, strings.Join(m.To, ","), m.Type, oneLine(m.Body)) + } + return nil + } + raw, err := bs.Raw(args[0]) + if err != nil { + return err + } + if raw == "" { + fmt.Fprintf(out, "channel %s is empty\n", args[0]) + return nil + } + fmt.Fprint(out, raw) + return nil + }, + } + show.Flags().StringVar(&in, "in", "", "show only the thread rooted at this message id") + + var joinAgent string + join := &cobra.Command{ + Use: "join ", + Short: "Subscribe an agent to a channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if joinAgent == "" { + return fmt.Errorf("--agent is required") + } + if err := bus.New(ws.Layout).Subscribe(args[0], joinAgent); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s subscribed to %s\n", joinAgent, args[0]) + return nil + }, + } + join.Flags().StringVar(&joinAgent, "agent", "", "agent id to subscribe (required)") + + var leaveAgent string + leave := &cobra.Command{ + Use: "leave ", + Short: "Unsubscribe an agent from a channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if leaveAgent == "" { + return fmt.Errorf("--agent is required") + } + if err := bus.New(ws.Layout).Unsubscribe(args[0], leaveAgent); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s left %s\n", leaveAgent, args[0]) + return nil + }, + } + leave.Flags().StringVar(&leaveAgent, "agent", "", "agent id to unsubscribe (required)") + + members := &cobra.Command{ + Use: "members ", + Short: "List a channel's subscribers", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + ms := bus.New(ws.Layout).Members(args[0]) + if len(ms) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no subscribers") + } + for _, a := range ms { + fmt.Fprintln(cmd.OutOrStdout(), a) + } + return nil + }, + } + + cmd.AddCommand(ls, show, join, leave, members) + return cmd +} + +func newSearchCmd() *cobra.Command { + var channel, from, typ, agent string + var limit int + var all bool + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Recall relevant messages into context (not a firehose; newest-first, capped)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + bs := bus.New(ws.Layout) + opts := bus.SearchOpts{ + Query: strings.Join(args, " "), Channel: channel, From: from, Type: typ, Limit: limit, + } + // Default scope: the agent's subscribed channels (unless --all / --channel). + if agent != "" && channel == "" && !all { + subs := bs.Subscriptions(agent) + if len(subs) > 0 { + opts.Channels = subs + } + } + hits, err := bs.Search(opts) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(hits) == 0 { + fmt.Fprintln(out, "no matching messages") + } + for _, m := range hits { + fmt.Fprintf(out, "%s · %s · %s → %s · %s\n %s\n", m.TS, m.Channel, m.From, strings.Join(m.To, ","), m.Type, oneLine(m.Body)) + } + return nil + }, + } + cmd.Flags().StringVarP(&channel, "channel", "c", "", "restrict to one channel") + cmd.Flags().StringVar(&from, "from", "", "restrict to a sender") + cmd.Flags().StringVar(&typ, "type", "", "restrict to a message type") + cmd.Flags().StringVar(&agent, "agent", "", "scope to this agent's subscriptions by default") + cmd.Flags().IntVar(&limit, "limit", 20, "max results (newest first)") + cmd.Flags().BoolVar(&all, "all", false, "search all channels, ignoring subscriptions") + return cmd +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +func newInboxCmd() *cobra.Command { + var mark bool + cmd := &cobra.Command{ + Use: "inbox ", + Short: "Show messages addressed to an agent (since its last read)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + agent := args[0] + var roles []string + if a, ok := ws.Registry.AgentByID(agent); ok { + roles = a.Roles + } + msgs, err := bus.New(ws.Layout).Inbox(agent, roles) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(msgs) == 0 { + fmt.Fprintln(out, "inbox empty") + } + for _, m := range msgs { + fmt.Fprintf(out, "[%s] %s · %s → %s\n %s\n", m.Type, m.Channel, m.From, strings.Join(m.To, ","), oneLine(m.Body)) + } + if mark { + if err := bus.New(ws.Layout).MarkRead(agent); err != nil { + return err + } + fmt.Fprintln(out, "(marked read)") + } + return nil + }, + } + cmd.Flags().BoolVar(&mark, "mark", false, "mark inbox read (advance cursor)") + return cmd +} + +func newThreadCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "thread ", + Short: "Print a conversation thread (or list threads with no arg)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + bs := bus.New(ws.Layout) + out := cmd.OutOrStdout() + if len(args) == 0 { + threads, err := bs.Channels() + if err != nil { + return err + } + if len(threads) == 0 { + fmt.Fprintln(out, "no threads") + } + for _, th := range threads { + fmt.Fprintln(out, th) + } + return nil + } + raw, err := bs.Raw(args[0]) + if err != nil { + return err + } + if raw == "" { + fmt.Fprintf(out, "thread %s is empty\n", args[0]) + return nil + } + fmt.Fprint(out, raw) + return nil + }, + } + return cmd +} + +func oneLine(s string) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if len(s) > 100 { + return s[:100] + "…" + } + return s +} diff --git a/hatch/internal/cli/comms_legacy.go b/hatch/internal/cli/comms_legacy.go new file mode 100644 index 0000000..78e7603 --- /dev/null +++ b/hatch/internal/cli/comms_legacy.go @@ -0,0 +1,211 @@ +//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/compile.go b/hatch/internal/cli/compile.go new file mode 100644 index 0000000..cc01091 --- /dev/null +++ b/hatch/internal/cli/compile.go @@ -0,0 +1,51 @@ +package cli + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/compile" +) + +func newCompileCmd() *cobra.Command { + var check bool + cmd := &cobra.Command{ + Use: "compile", + Short: "Compile the SSOT into per-agent instruction files", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if check { + reason, err := compile.StaleReason(ws.Layout, ws.Layout.RepoRoot()) + if err != nil { + return err + } + if reason != "" { + return fmt.Errorf("compiled files are stale: %s\nrun `hatch compile`", reason) + } + fmt.Fprintln(cmd.OutOrStdout(), "compiled files are up to date") + return nil + } + res, warnings, err := compile.Run(ws) + if err != nil { + return err + } + repoRoot := ws.Layout.RepoRoot() + for _, w := range warnings { + fmt.Fprintln(cmd.ErrOrStderr(), "warning: "+w) + } + for _, out := range res.Written { + rel, _ := filepath.Rel(repoRoot, out) + fmt.Fprintf(cmd.OutOrStdout(), " ✓ %s\n", rel) + } + fmt.Fprintf(cmd.OutOrStdout(), "Compiled %d surface(s).\n", len(res.Bundles)) + return nil + }, + } + cmd.Flags().BoolVar(&check, "check", false, "verify compiled files are up to date (exit non-zero if stale)") + return cmd +} diff --git a/hatch/internal/cli/cost.go b/hatch/internal/cli/cost.go new file mode 100644 index 0000000..79f973d --- /dev/null +++ b/hatch/internal/cli/cost.go @@ -0,0 +1,92 @@ +//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/deps.go b/hatch/internal/cli/deps.go new file mode 100644 index 0000000..c34a08b --- /dev/null +++ b/hatch/internal/cli/deps.go @@ -0,0 +1,43 @@ +//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/doc.go b/hatch/internal/cli/doc.go new file mode 100644 index 0000000..565f10a --- /dev/null +++ b/hatch/internal/cli/doc.go @@ -0,0 +1,154 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/docs" +) + +func newDocCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "doc", + Short: "Scaffold and lint documents against per-project templates/specs", + } + cmd.AddCommand(newDocTypesCmd(), newDocNewCmd(), newDocLintCmd()) + return cmd +} + +func newDocTypesCmd() *cobra.Command { + return &cobra.Command{ + Use: "types", + Short: "List available document types + their framework", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + ts, err := docs.Types(ws.Layout) + if err != nil { + return err + } + if len(ts) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no doc templates (.hatch/templates/docs/)") + return nil + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "TYPE\tFRAMEWORK\tREQUIRED SECTIONS") + for _, t := range ts { + fmt.Fprintf(tw, "%s\t%s\t%v\n", t.Type, t.Framework, t.RequiredSections) + } + tw.Flush() + return nil + }, + } +} + +func newDocNewCmd() *cobra.Command { + var title, out string + cmd := &cobra.Command{ + Use: "new ", + Short: "Scaffold a document from its template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("--title is required") + } + tmpls, err := docs.Load(ws.Layout) + if err != nil { + return err + } + t, ok := tmpls[args[0]] + if !ok { + return fmt.Errorf("unknown doc type %q (see `hatch doc types`)", args[0]) + } + content, err := docs.New(t, title) + if err != nil { + return err + } + if out == "" { + out = args[0] + "-" + docs.Slug(title) + ".md" + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil && filepath.Dir(out) != "." { + return err + } + if err := os.WriteFile(out, content, 0o644); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "created %s (%s)\n", out, t.Framework) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "document title (required)") + cmd.Flags().StringVar(&out, "out", "", "output path (default: -.md)") + return cmd +} + +func newDocLintCmd() *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "lint [file]", + Short: "Check a document has the sections/frontmatter its spec requires", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + tmpls, err := docs.Load(ws.Layout) + if err != nil { + return err + } + var files []string + if all { + _ = filepath.Walk(ws.Layout.RepoRoot(), func(p string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() && filepath.Ext(p) == ".md" { + files = append(files, p) + } + return nil + }) + } else { + if len(args) != 1 { + return fmt.Errorf("provide a file or use --all") + } + files = args + } + out := cmd.OutOrStdout() + total := 0 + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return err + } + dt, probs, err := docs.Lint(raw, tmpls) + if err != nil { + return err + } + if all && dt == "" { + continue // not a typed document; skip in bulk mode + } + if len(probs) == 0 { + fmt.Fprintf(out, "✓ %s (%s)\n", f, dt) + continue + } + for _, p := range probs { + fmt.Fprintf(out, "✗ %s: %s\n", f, p) + total++ + } + } + if total > 0 { + return fmt.Errorf("%d doc spec problem(s)", total) + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "lint every typed .md document in the repo") + return cmd +} diff --git a/hatch/internal/cli/doctor.go b/hatch/internal/cli/doctor.go new file mode 100644 index 0000000..bc63c4b --- /dev/null +++ b/hatch/internal/cli/doctor.go @@ -0,0 +1,162 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/compile" + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// envForKind names the credential env var each agent kind accepts (besides +// interactive OAuth/login). agy has no documented API-key env (OAuth/keyring). +var envForKind = map[string]string{ + "claude": "ANTHROPIC_API_KEY", + "codex": "OPENAI_API_KEY", + "kiro": "KIRO_API_KEY", +} + +// defaultCmdForKind is the executable each kind drives. +var defaultCmdForKind = map[string]string{ + "claude": "claude", "codex": "codex", + "agy": "agy", "kiro": "kiro-cli", "mock": "hatch-mock", +} + +// defaultAuthCheck is a non-mutating, scriptable command (argv) per kind that +// exits 0 when authenticated — verified from each CLI's docs (see +// docs/10-agent-adapters.md). agy has no such command (OAuth/keyring only). +var defaultAuthCheck = map[string][]string{ + "claude": {"auth", "status"}, // exit 0 if logged in, 1 if not (JSON) + "codex": {"login", "status"}, // exit 0 if authed + "kiro": {"whoami"}, // exit 0 if authed +} + +// authKinds are agent kinds that require authentication (vs mock/manual/shell). +var authKinds = map[string]bool{"claude": true, "codex": true, "agy": true, "kiro": true} + +// authStatus reports how an agent authenticates, WITHOUT touching credential +// files (security): it honours an env key the user set, else runs the agent +// CLI's own auth-check command, else — for CLIs with no scriptable check +// (agy) — reports unknown rather than guessing. +func authStatus(a model.Agent, bin string, cliPresent bool) string { + ev := envForKind[a.Kind] + if ev != "" && os.Getenv(ev) != "" { + return "✓ env " + ev + } + check := a.AuthCheck + if len(check) == 0 { + check = defaultAuthCheck[a.Kind] + } + if len(check) > 0 { + if !cliPresent { + return "? (CLI vắng)" + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c := exec.CommandContext(ctx, bin, check...) + c.Stdin = nil // never block on interactive input + if err := c.Run(); err == nil { + return "✓ `" + bin + " " + joinArgs(check) + "`" + } + return "✗ chưa login (`" + bin + " login`)" + } + if !authKinds[a.Kind] { + return "—" // mock/manual/shell: no credential needed + } + // Needs auth but exposes no scriptable check (OAuth/keyring only). + if ev != "" { + return "? OAuth/keyring (hoặc set " + ev + ")" + } + return "? OAuth/keyring (login bằng `" + bin + "`)" +} + +func joinArgs(a []string) string { + out := "" + for i, s := range a { + if i > 0 { + out += " " + } + out += s + } + return out +} + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check readiness: config, compiled freshness, agent CLIs + auth", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + out := cmd.OutOrStdout() + issues := 0 + fmt.Fprintln(out, "Hatch doctor") + + if probs := ws.Validate(); len(probs) > 0 { + issues += len(probs) + fmt.Fprintf(out, "✗ config: %d problem(s) (run `hatch validate`)\n", len(probs)) + } else { + fmt.Fprintln(out, "✓ config valid") + } + + if reason, _ := compile.StaleReason(ws.Layout, ws.Layout.RepoRoot()); reason != "" { + issues++ + fmt.Fprintf(out, "✗ compiled stale: %s (run `hatch compile`)\n", reason) + } else { + fmt.Fprintln(out, "✓ compiled up to date") + } + + // Agents: which CLIs are present + how they authenticate. CLIs are + // NOT mandatory — you only need at least one usable agent. + fmt.Fprintln(out, "\nAgents (cài cái nào dùng cái nấy — chỉ cần ≥1):") + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, " AGENT\tKIND\tCLI\tAUTH") + present, presentReal := 0, 0 + for _, a := range ws.Registry.Agents { + bin := a.Cmd + if bin == "" { + bin = defaultCmdForKind[a.Kind] + } + cliPresent := false + cli := "n/a" + if bin != "" { + if _, err := exec.LookPath(bin); err == nil { + cli, cliPresent = "✓ "+bin, true + present++ + if a.Kind != "mock" { + presentReal++ + } + } else { + cli = "✗ " + bin + } + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", a.ID, a.Kind, cli, authStatus(a, bin, cliPresent)) + } + tw.Flush() + + fmt.Fprintln(out) + fmt.Fprintln(out, "auth: ✓ sẵn sàng · ? không kiểm được (keyring/cấu hình) — login OAuth hoặc env key đều dùng được") + if present == 0 { + issues++ + fmt.Fprintln(out, "✗ không có agent CLI nào khả dụng — cài ít nhất 1 (claude/codex/agy/kiro)") + } else if presentReal == 0 { + fmt.Fprintln(out, "● chỉ có mock — ổn để test; cài ≥1 agent CLI thật để chạy thật") + } + + if issues == 0 { + fmt.Fprintln(out, "✓ ready") + return nil + } + fmt.Fprintf(out, "%d issue(s) — see above.\n", issues) + return fmt.Errorf("%d readiness issue(s)", issues) + }, + } +} diff --git a/hatch/internal/cli/escalate.go b/hatch/internal/cli/escalate.go new file mode 100644 index 0000000..38f1111 --- /dev/null +++ b/hatch/internal/cli/escalate.go @@ -0,0 +1,36 @@ +//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 new file mode 100644 index 0000000..3c97b54 --- /dev/null +++ b/hatch/internal/cli/gate.go @@ -0,0 +1,65 @@ +//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/hook.go b/hatch/internal/cli/hook.go new file mode 100644 index 0000000..39f45ac --- /dev/null +++ b/hatch/internal/cli/hook.go @@ -0,0 +1,59 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +const preCommitHook = `#!/bin/sh +# Installed by ` + "`hatch hook install`" + `. Keeps SSOT, board and compiled +# files consistent before each commit. +set -e +if ! command -v hatch >/dev/null 2>&1; then + echo "hatch not on PATH; skipping hatch pre-commit checks" >&2 + exit 0 +fi +hatch validate +hatch compile --check +` + +func newHookCmd() *cobra.Command { + cmd := &cobra.Command{Use: "hook", Short: "Manage git hooks for this workspace"} + cmd.AddCommand(newHookInstallCmd()) + return cmd +} + +func newHookInstallCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "install", + Short: "Install a pre-commit hook (validate + compile --check)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + hooksDir := filepath.Join(ws.Layout.RepoRoot(), ".git", "hooks") + if fi, err := os.Stat(filepath.Dir(hooksDir)); err != nil || !fi.IsDir() { + return fmt.Errorf("not a git repository (no .git at %s)", ws.Layout.RepoRoot()) + } + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + return err + } + path := filepath.Join(hooksDir, "pre-commit") + if _, err := os.Stat(path); err == nil && !force { + return fmt.Errorf("%s already exists (use --force to overwrite)", path) + } + if err := os.WriteFile(path, []byte(preCommitHook), 0o755); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "installed pre-commit hook → %s\n", path) + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing pre-commit hook") + return cmd +} diff --git a/hatch/internal/cli/init.go b/hatch/internal/cli/init.go new file mode 100644 index 0000000..50848a8 --- /dev/null +++ b/hatch/internal/cli/init.go @@ -0,0 +1,118 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/compile" + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +func newInitCmd() *cobra.Command { + var workflow string + var force bool + var local bool + var clients []string + var dryRun bool + cmd := &cobra.Command{ + Use: "init [dir]", + Short: "Create the global ~/.hatch workspace (or a local one with --local); optionally wire a client", + Long: "By default `hatch init` creates the user-level workspace at ~/.hatch (like ~/.claude),\n" + + "used as the default in every repo. Use --local to create a project .hatch in the\n" + + "current repo that OVERRIDES the global one. Pass [dir] to target an explicit directory.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + cwd, err := os.Getwd() + if err != nil { + return err + } + + // Where the .hatch SSOT lives: explicit dir > --local (cwd) > global (~). + scaffoldDir := "" + switch { + case len(args) == 1: + scaffoldDir = args[0] + case local: + scaffoldDir = "." + default: + g := paths.GlobalRoot() + if g == "" { + return fmt.Errorf("cannot resolve home dir for ~/.hatch; use --local or pass a dir") + } + scaffoldDir = filepath.Dir(g) // parent of ~/.hatch + } + absScaffold, _ := filepath.Abs(scaffoldDir) + ssot := paths.At(absScaffold) + scope := "global (~/.hatch)" + if local || len(args) == 1 { + scope = "local override" + } + + // Scaffold unless it already exists and we're only wiring a client. + if _, statErr := os.Stat(ssot.Root); statErr == nil && len(clients) > 0 && !force { + fmt.Fprintf(out, "Workspace %s đã tồn tại — bỏ qua scaffold, chỉ set up client.\n", ssot.Root) + } else { + l, written, err := scaffold.Init(scaffold.Options{Dir: absScaffold, Workflow: workflow, Force: force}) + if err != nil { + return err + } + fmt.Fprintf(out, "Created %s [%s] (%d files, workflow=%s)\n", l.Root, scope, len(written), workflow) + if len(clients) == 0 { + fmt.Fprintln(out, "Next: edit charter.md + registry.yaml, then `hatch compile` (hoặc `hatch init --client cc`).") + } + } + + if len(clients) == 0 { + return nil + } + + // Load that workspace; compiled outputs + client config go to the + // current repo (cwd), even when the SSOT is the global ~/.hatch. + ws, err := config.Load(ssot) + if err != nil { + return err + } + ws.OutputRoot = cwd + if !dryRun { + if _, _, err := compile.Run(ws); err != nil { + return fmt.Errorf("compile: %w", err) + } + fmt.Fprintf(out, "Compiled surfaces + MCP registration vào %s.\n", cwd) + } + for _, c := range splitClients(clients) { + if err := setupClient(cmd, ws, cwd, c, dryRun); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().StringVarP(&workflow, "workflow", "w", "scrum", + "workflow template: "+strings.Join(scaffold.WorkflowTemplates, " | ")) + cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing .hatch") + cmd.Flags().BoolVar(&local, "local", false, "create a project .hatch in the current repo (overrides ~/.hatch)") + cmd.Flags().StringSliceVar(&clients, "client", nil, + "set up MCP for a client and wire it into the current repo: cc | codex | agy | kiro (repeatable / comma-separated)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what --client setup would do without writing") + return cmd +} + +// splitClients flattens comma-separated values inside the repeatable flag. +func splitClients(in []string) []string { + var out []string + for _, v := range in { + for _, p := range strings.Split(v, ",") { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + } + return out +} diff --git a/hatch/internal/cli/integration_default_test.go b/hatch/internal/cli/integration_default_test.go new file mode 100644 index 0000000..0047c7a --- /dev/null +++ b/hatch/internal/cli/integration_default_test.go @@ -0,0 +1,83 @@ +//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 +} + +// TestEmbeddedHarnessFlow drives the embedded-harness command set end to end in +// a temp workspace: init → compile (protocol + MCP registration) → post a chat +// thread → read it back through the read-only views. +func TestEmbeddedHarnessFlow(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // --local: create a project .hatch in this repo (the default now targets + // the global ~/.hatch). + if out, err := run(t, "init", "--local", "-w", "scrum"); err != nil { + t.Fatalf("init: %v\n%s", err, out) + } + + if out, err := run(t, "compile"); err != nil { + t.Fatalf("compile: %v\n%s", err, out) + } + claude, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + if err != nil { + t.Fatalf("compile did not produce CLAUDE.md: %v", err) + } + if !strings.Contains(string(claude), "Chat protocol") { + t.Error("CLAUDE.md missing the chat protocol") + } + if _, err := os.Stat(filepath.Join(dir, ".mcp.json")); err != nil { + t.Errorf("compile did not register the MCP server (.mcp.json): %v", err) + } + + // A chat thread is a task. Post one as a human operator. + if out, err := run(t, "msg", "--from", "human:operator", "--channel", "#export-csv", + "@codex please stream the CSV"); err != nil { + t.Fatalf("msg: %v\n%s", err, out) + } + + // The read-only views surface it. + thread, err := run(t, "thread", "#export-csv") + if err != nil { + t.Fatalf("thread: %v", err) + } + if !strings.Contains(thread, "stream the CSV") { + t.Fatalf("thread missing the message:\n%s", thread) + } + + status, err := run(t, "status") + if err != nil { + t.Fatalf("status: %v", err) + } + if !strings.Contains(status, "export-csv") || !strings.Contains(status, "claude-code") { + t.Fatalf("status should list the thread and roster:\n%s", status) + } + + search, err := run(t, "search", "stream") + if err != nil { + t.Fatalf("search: %v", err) + } + if !strings.Contains(search, "stream the CSV") { + t.Fatalf("search did not recall the message:\n%s", search) + } +} diff --git a/hatch/internal/cli/integration_test.go b/hatch/internal/cli/integration_test.go new file mode 100644 index 0000000..80ac335 --- /dev/null +++ b/hatch/internal/cli/integration_test.go @@ -0,0 +1,86 @@ +//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/kb.go b/hatch/internal/cli/kb.go new file mode 100644 index 0000000..adb5000 --- /dev/null +++ b/hatch/internal/cli/kb.go @@ -0,0 +1,259 @@ +package cli + +import ( + "fmt" + "io" + "path/filepath" + "strings" + "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" +) + +func newKBCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "kb", + Short: "Shared Knowledge Base: add, query, reindex, link (Obsidian-aware)", + } + cmd.AddCommand(newKBAddCmd(), newKBQueryCmd(), newKBIndexCmd(), + newKBLinkCmd(), newKBBacklinksCmd(), newKBGraphCmd(), newKBOpenCmd()) + return cmd +} + +// kbFor builds a KB configured from the registry (vault location + wikilinks). +func kbFor(ws *config.Workspace) *store.KB { + cfg := ws.Registry.KB + wikilinks := cfg.Wikilinks || cfg.Mode == "obsidian" + if cfg.Vault != "" { + root := cfg.Vault + if !filepath.IsAbs(root) { + root = filepath.Join(ws.Layout.RepoRoot(), root) + } + return store.NewKBVault(ws.Layout, root, wikilinks) + } + kb := store.NewKB(ws.Layout) + kb.Wikilinks = wikilinks + return kb +} + +func newKBAddCmd() *cobra.Command { + var typ, title, tags, related, author, body string + cmd := &cobra.Command{ + Use: "add", + Short: "Add a KB entry (body from --body or stdin)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("--title is required") + } + switch typ { + case model.KBDecision, model.KBDomain, model.KBLearning: + default: + return fmt.Errorf("--type must be one of: decision, domain, learning") + } + if body == "" { + if in, _ := io.ReadAll(cmd.InOrStdin()); len(in) > 0 { + body = string(in) + } + } + kb := kbFor(ws) + id := kb.NextID(typ) + entry := model.KBEntry{ + ID: id, + Type: typ, + Title: title, + Tags: splitCSV(tags), + Related: splitCSV(related), + Author: author, + Created: time.Now().Format(time.RFC3339), + Body: strings.TrimSpace(body), + } + if typ == model.KBDecision { + entry.Status = "accepted" + } + p, err := kb.Add(entry) + if err != nil { + return err + } + if err := kb.RebuildIndex(); err != nil { + return err + } + _ = store.NewLedger(ws.Layout).Append(model.Entry{ + Agent: orHuman(author), Ticket: "-", Action: model.ActNote, + Why: "KB add: " + title, Note: entry.Path, + }) + fmt.Fprintf(cmd.OutOrStdout(), "Added %s → %s\n", id, p) + return nil + }, + } + cmd.Flags().StringVar(&typ, "type", "learning", "decision | domain | learning") + cmd.Flags().StringVar(&title, "title", "", "entry title (required)") + cmd.Flags().StringVar(&tags, "tags", "", "comma-separated tags") + cmd.Flags().StringVar(&related, "related", "", "comma-separated related ids/paths") + cmd.Flags().StringVar(&author, "author", "", "author (agent id or human:)") + cmd.Flags().StringVar(&body, "body", "", "entry body (else read from stdin)") + return cmd +} + +func newKBQueryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "query [tags...]", + Short: "List KB entries, optionally filtered by tag", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + entries, err := kbFor(ws).Query(args) + if err != nil { + return err + } + if len(entries) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no matching KB entries") + return nil + } + for _, e := range entries { + tags := "" + if len(e.Tags) > 0 { + tags = " [" + strings.Join(e.Tags, ", ") + "]" + } + fmt.Fprintf(cmd.OutOrStdout(), "%-10s %-9s %s%s\n %s\n", e.ID, e.Type, e.Title, tags, e.Path) + } + return nil + }, + } + return cmd +} + +func newKBIndexCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "index", + Short: "Rebuild kb/index.md from current entries", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if err := kbFor(ws).RebuildIndex(); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "rebuilt kb/index.md") + return nil + }, + } + return cmd +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.TrimSpace(p); v != "" { + out = append(out, v) + } + } + return out +} + +func orHuman(a string) string { + if a == "" { + return "human:operator" + } + return a +} + +func newKBLinkCmd() *cobra.Command { + return &cobra.Command{ + Use: "link ", + Short: "Link two KB notes (adds to `related`; renders as [[wikilink]])", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + kb := kbFor(ws) + if err := kb.Link(args[0], args[1]); err != nil { + return err + } + _ = kb.RebuildIndex() + fmt.Fprintf(cmd.OutOrStdout(), "%s → %s linked\n", args[0], args[1]) + return nil + }, + } +} + +func newKBBacklinksCmd() *cobra.Command { + return &cobra.Command{ + Use: "backlinks ", + Short: "List notes that link to a note (id or name)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + bl, err := kbFor(ws).Backlinks(args[0]) + if err != nil { + return err + } + if len(bl) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no backlinks") + } + for _, id := range bl { + fmt.Fprintln(cmd.OutOrStdout(), id) + } + return nil + }, + } +} + +func newKBGraphCmd() *cobra.Command { + return &cobra.Command{ + Use: "graph", + Short: "Print the KB link graph (note → linked notes)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + g, err := kbFor(ws).Graph() + if err != nil { + return err + } + if len(g) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no links yet") + } + for from, tos := range g { + fmt.Fprintf(cmd.OutOrStdout(), "%s → %s\n", from, strings.Join(tos, ", ")) + } + return nil + }, + } +} + +func newKBOpenCmd() *cobra.Command { + return &cobra.Command{ + Use: "open ", + Short: "Print an obsidian:// URI to open a note in the app", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), kbFor(ws).ObsidianURI(args[0])) + return nil + }, + } +} diff --git a/hatch/internal/cli/logs.go b/hatch/internal/cli/logs.go new file mode 100644 index 0000000..4fece23 --- /dev/null +++ b/hatch/internal/cli/logs.go @@ -0,0 +1,100 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +func newLogsCmd() *cobra.Command { + var follow, all bool + cmd := &cobra.Command{ + Use: "logs ", + Short: "Show an agent run transcript (raw output); --follow to tail live", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + dir := ws.Layout.Runs(args[0]) + files, err := runLogs(dir) + if err != nil { + return err + } + if len(files) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "no runs for %s\n", args[0]) + return nil + } + out := cmd.OutOrStdout() + if all { + for _, f := range files { + fmt.Fprintf(out, "\n=== %s ===\n", filepath.Base(f)) + raw, _ := os.ReadFile(f) + out.Write(raw) + } + return nil + } + latest := files[len(files)-1] + if follow { + return tail(out, latest) + } + raw, err := os.ReadFile(latest) + if err != nil { + return err + } + out.Write(raw) + return nil + }, + } + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "tail the latest run live") + cmd.Flags().BoolVar(&all, "all", false, "print every run transcript") + return cmd +} + +func runLogs(dir string) ([]string, error) { + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, e := range ents { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") { + out = append(out, filepath.Join(dir, e.Name())) + } + } + sort.Strings(out) // timestamp-prefixed names sort chronologically + return out, nil +} + +// tail streams existing content then polls for appended bytes. +func tail(out io.Writer, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + out.Write(buf[:n]) + } + if err == io.EOF { + time.Sleep(300 * time.Millisecond) + continue + } + if err != nil { + return err + } + } +} diff --git a/hatch/internal/cli/mcp.go b/hatch/internal/cli/mcp.go new file mode 100644 index 0000000..99c1c66 --- /dev/null +++ b/hatch/internal/cli/mcp.go @@ -0,0 +1,62 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/mcpserver" +) + +func newMCPCmd() *cobra.Command { + var as string + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run the Hatch MCP server (chat + KB tools) over stdio for a coding agent", + Long: "Expose the shared chat (bus) and knowledge base to an MCP-capable agent.\n" + + "Each agent runs its own instance with its identity, e.g.\n hatch mcp --as claude-code\n" + + "If --as is omitted, it resolves from $HATCH_AGENT, else the first\n" + + "Claude-kind agent in the registry (so the Claude plugin needs no config).\n" + + "Wire it into the agent's MCP config (claude .mcp.json / codex config.toml / .kiro/settings/mcp.json).", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + if as == "" { + as = resolveIdentity(ws) + } + if as == "" { + return fmt.Errorf("could not resolve identity: pass --as , set $HATCH_AGENT, or add a claude-kind agent to registry.yaml") + } + if _, ok := ws.Registry.AgentByID(as); !ok { + return fmt.Errorf("unknown agent %q (not in registry.yaml)", as) + } + srv := mcpserver.New(ws, as, Version) + // Stdio transport: the agent launches this process and speaks MCP on + // stdin/stdout, so nothing may be printed to stdout outside the protocol. + return srv.Run(context.Background(), &mcp.StdioTransport{}) + }, + } + cmd.Flags().StringVar(&as, "as", "", "agent id this server acts as (default: $HATCH_AGENT or first claude-kind agent)") + return cmd +} + +// resolveIdentity picks the agent this MCP server posts as when --as is omitted: +// $HATCH_AGENT if set, otherwise the first Claude-kind agent in the registry +// (the agent the Claude plugin runs inside). +func resolveIdentity(ws *config.Workspace) string { + if env := os.Getenv("HATCH_AGENT"); env != "" { + return env + } + for _, a := range ws.Registry.Agents { + if a.Kind == "claude" { + return a.ID + } + } + return "" +} diff --git a/hatch/internal/cli/metrics.go b/hatch/internal/cli/metrics.go new file mode 100644 index 0000000..df60597 --- /dev/null +++ b/hatch/internal/cli/metrics.go @@ -0,0 +1,92 @@ +//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 new file mode 100644 index 0000000..a1babd8 --- /dev/null +++ b/hatch/internal/cli/mob.go @@ -0,0 +1,132 @@ +//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 new file mode 100644 index 0000000..c8bf754 --- /dev/null +++ b/hatch/internal/cli/oncall.go @@ -0,0 +1,95 @@ +//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 new file mode 100644 index 0000000..ffd9e3f --- /dev/null +++ b/hatch/internal/cli/orchestrate.go @@ -0,0 +1,315 @@ +//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/org.go b/hatch/internal/cli/org.go new file mode 100644 index 0000000..a2e7d3b --- /dev/null +++ b/hatch/internal/cli/org.go @@ -0,0 +1,86 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" +) + +func newOrgCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "org", + Short: "Print the org chart (reporting lines) + delegation-of-authority", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + out := cmd.OutOrStdout() + // roots = roles with no manager; print the tree depth-first. + children := map[string][]model.Role{} + var roots []model.Role + for _, r := range ws.Registry.Roles { + if r.ReportsTo == "" { + roots = append(roots, r) + } else { + children[r.ReportsTo] = append(children[r.ReportsTo], r) + } + } + sortRoles(roots) + fmt.Fprintln(out, "Org chart:") + for _, r := range roots { + printRole(out, ws, children, r, 0) + } + return nil + }, + } + return cmd +} + +func printRole(out interface{ Write([]byte) (int, error) }, ws *config.Workspace, children map[string][]model.Role, r model.Role, depth int) { + indent := strings.Repeat(" ", depth) + agents := agentIDs(ws.Registry.AgentsForRole(r.ID)) + auth := "" + if r.Authority != nil { + var parts []string + if r.Authority.CanApprove { + parts = append(parts, "approve") + } + if r.Authority.BudgetAuthorityUSD > 0 { + parts = append(parts, fmt.Sprintf("budget≤$%.0f", r.Authority.BudgetAuthorityUSD)) + } + if len(r.Authority.DecisionScope) > 0 { + parts = append(parts, "scope:"+strings.Join(r.Authority.DecisionScope, "/")) + } + if len(parts) > 0 { + auth = " [" + strings.Join(parts, " · ") + "]" + } + } + who := "—" + if len(agents) > 0 { + who = strings.Join(agents, ", ") + } + fmt.Fprintf(out, "%s• %s (%s)%s\n", indent, r.ID, who, auth) + kids := children[r.ID] + sortRoles(kids) + for _, c := range kids { + printRole(out, ws, children, c, depth+1) + } +} + +func sortRoles(rs []model.Role) { + sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID }) +} + +func agentIDs(as []model.Agent) []string { + out := make([]string, len(as)) + for i, a := range as { + out[i] = a.ID + } + return out +} diff --git a/hatch/internal/cli/pair.go b/hatch/internal/cli/pair.go new file mode 100644 index 0000000..1e8b569 --- /dev/null +++ b/hatch/internal/cli/pair.go @@ -0,0 +1,124 @@ +//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 new file mode 100644 index 0000000..0806a63 --- /dev/null +++ b/hatch/internal/cli/presence.go @@ -0,0 +1,75 @@ +//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 new file mode 100644 index 0000000..70d8483 --- /dev/null +++ b/hatch/internal/cli/report.go @@ -0,0 +1,126 @@ +//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 new file mode 100644 index 0000000..d031474 --- /dev/null +++ b/hatch/internal/cli/root.go @@ -0,0 +1,83 @@ +// Package cli wires the `hatch` command tree. +package cli + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Version is set at build time via -ldflags. +var Version = "dev" + +// NewRoot builds the root command with all subcommands attached. +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.", + 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. + root.AddCommand( + newInitCmd(), + newCompileCmd(), + newValidateCmd(), + newStatusCmd(), + newKBCmd(), + newBoardCmd(), + newChatCmd(), + newSyncCmd(), + newHookCmd(), + newMsgCmd(), + newChannelCmd(), + newInboxCmd(), + newThreadCmd(), + newSearchCmd(), + newDocCmd(), + newLogsCmd(), + newOrgCmd(), + newDoctorCmd(), + newMCPCmd(), + ) + addLegacyCommands(root) + return root +} + +// loadWorkspace resolves the workspace with global+local layering: a local +// .hatch (nearest ancestor of cwd) overrides the global ~/.hatch. Compiled +// outputs always target the current repo (cwd) — for a local workspace that is +// the repo root; for the global default it is wherever you're working. +func loadWorkspace() (*config.Workspace, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + if l, err := paths.FindLocal(cwd); err == nil { + ws, err := config.Load(l) + if err != nil { + return nil, err + } + ws.OutputRoot = l.RepoRoot() + return ws, nil + } + if g := paths.GlobalRoot(); g != "" { + if fi, statErr := os.Stat(g); statErr == nil && fi.IsDir() { + ws, err := config.Load(paths.Layout{Root: g}) + if err != nil { + return nil, err + } + ws.OutputRoot = cwd // global SSOT compiles into the current repo + return ws, nil + } + } + return nil, paths.ErrNotFound +} diff --git a/hatch/internal/cli/root_default.go b/hatch/internal/cli/root_default.go new file mode 100644 index 0000000..fabb66d --- /dev/null +++ b/hatch/internal/cli/root_default.go @@ -0,0 +1,9 @@ +//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 new file mode 100644 index 0000000..3d96443 --- /dev/null +++ b/hatch/internal/cli/root_legacy.go @@ -0,0 +1,35 @@ +//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/standup.go b/hatch/internal/cli/standup.go new file mode 100644 index 0000000..10397fc --- /dev/null +++ b/hatch/internal/cli/standup.go @@ -0,0 +1,54 @@ +//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/status.go b/hatch/internal/cli/status.go new file mode 100644 index 0000000..33a8273 --- /dev/null +++ b/hatch/internal/cli/status.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/bus" +) + +func newStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Read-only summary: open threads (tasks), recent activity, the roster", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := loadWorkspace() + if err != nil { + return err + } + b := bus.New(ws.Layout) + out := cmd.OutOrStdout() + + chans, err := b.Channels() + if err != nil { + return err + } + fmt.Fprintf(out, "## Threads (tasks) — %d\n", len(chans)) + if len(chans) == 0 { + fmt.Fprintln(out, " (none yet — agents open threads through the Hatch MCP server)") + } else { + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, " THREAD\tMSGS\tLAST\tWHO") + for _, ch := range chans { + msgs, _ := b.Messages(ch) + last, who := "", "" + if n := len(msgs); n > 0 { + who = msgs[n-1].From + if t, e := time.Parse(time.RFC3339Nano, msgs[n-1].TS); e == nil { + last = t.Format("01-02 15:04") + } + } + fmt.Fprintf(tw, " #%s\t%d\t%s\t%s\n", ch, len(msgs), last, who) + } + tw.Flush() + } + + // Roster: who's on the squad and what roles they may hold. + fmt.Fprintf(out, "\n## Roster — %d agents\n", len(ws.Registry.Agents)) + tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) + for _, a := range ws.Registry.Agents { + roles := "-" + if len(a.Roles) > 0 { + roles = fmt.Sprintf("%v", a.Roles) + } + fmt.Fprintf(tw, " %s\t%s\t%s\n", a.ID, a.Kind, roles) + } + tw.Flush() + fmt.Fprintln(out) + return nil + }, + } + return cmd +} diff --git a/hatch/internal/cli/sync.go b/hatch/internal/cli/sync.go new file mode 100644 index 0000000..a50ccac --- /dev/null +++ b/hatch/internal/cli/sync.go @@ -0,0 +1,71 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/fioenix/overclaud/hatch/internal/compile" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +func newSyncCmd() *cobra.Command { + var fix bool + cmd := &cobra.Command{ + Use: "sync", + Short: "Reconcile derived state: ticket status↔lane, KB index, 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. + 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). + reason, err := compile.StaleReason(ws.Layout, ws.Layout.RepoRoot()) + if err != nil { + return err + } + if reason != "" { + fmt.Fprintf(out, "compiled files stale: %s (run `hatch compile`)\n", reason) + } 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 new file mode 100644 index 0000000..dbf0cfc --- /dev/null +++ b/hatch/internal/cli/tick.go @@ -0,0 +1,65 @@ +//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 new file mode 100644 index 0000000..f29c7c8 --- /dev/null +++ b/hatch/internal/cli/ticket.go @@ -0,0 +1,304 @@ +//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 new file mode 100644 index 0000000..5fc6d61 --- /dev/null +++ b/hatch/internal/cli/validate.go @@ -0,0 +1,34 @@ +package cli + +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", + 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 + } + for _, p := range probs { + fmt.Fprintln(cmd.OutOrStdout(), "✗ "+p.String()) + } + return fmt.Errorf("%d problem(s) found", len(probs)) + }, + } + return cmd +} diff --git a/hatch/internal/compile/bundle.go b/hatch/internal/compile/bundle.go new file mode 100644 index 0000000..1434894 --- /dev/null +++ b/hatch/internal/compile/bundle.go @@ -0,0 +1,124 @@ +package compile + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// RoleContent is one role's L1 context: structured binding + the prose body. +type RoleContent struct { + Role model.Role + Body string // roles/.md body (empty if file missing) +} + +// Bundle is everything needed to render a surface: L0 charter, the L1 roles +// 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 + + Workflow *model.Workflow // process, rendered as prose (not an engine) + Policy model.Policy // governance toggles surfaced into the DoD + Lead *model.Agent // set when this surface carries the orchestrator agent +} + +// stripFrontmatter removes a leading `---`-fenced YAML block, returning the +// Markdown body trimmed of surrounding blank lines. +func stripFrontmatter(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + if strings.HasPrefix(s, "---\n") { + if end := strings.Index(s[4:], "\n---"); end >= 0 { + rest := s[4+end+len("\n---"):] + s = strings.TrimPrefix(rest, "\n") + } + } + return strings.TrimSpace(s) +} + +// readRoleBody loads roles/.md, returning "" if the file is absent. +func readRoleBody(l paths.Layout, role model.Role) string { + name := role.File + if name == "" { + name = role.ID + ".md" + } + raw, err := os.ReadFile(filepath.Join(l.Roles(), name)) + if err != nil { + return "" + } + return stripFrontmatter(string(raw)) +} + +// leadAgent returns the squad's orchestrator: the first agent holding the +// "conductor" role, falling back to the first agent in the roster. This is the +// agent a user typically opens first; its surface gets the orchestrator block. +func leadAgent(ws *config.Workspace) *model.Agent { + for i := range ws.Registry.Agents { + for _, r := range ws.Registry.Agents[i].Roles { + if r == "conductor" { + return &ws.Registry.Agents[i] + } + } + } + if len(ws.Registry.Agents) > 0 { + return &ws.Registry.Agents[0] + } + return nil +} + +// buildBundle assembles the bundle for a surface given the role ids it serves. +func buildBundle(ws *config.Workspace, surf Surface, agents []model.Agent, roleIDs []string) Bundle { + charter := "" + if raw, err := os.ReadFile(ws.Layout.Charter()); err == nil { + charter = stripFrontmatter(string(raw)) + } + sort.Strings(roleIDs) + var roles []RoleContent + refSet := map[string]bool{} + var refs []string + for _, id := range roleIDs { + role, ok := ws.Registry.RoleByID(id) + if !ok { + role = model.Role{ID: id} + } + roles = append(roles, RoleContent{Role: role, Body: readRoleBody(ws.Layout, role)}) + for _, r := range role.ContextRefs { + if !refSet[r] { + refSet[r] = true + refs = append(refs, r) + } + } + } + // The orchestrator block goes only on the surface that carries the lead. + var lead *model.Agent + if la := leadAgent(ws); la != nil { + for _, a := range agents { + if a.ID == la.ID { + lead = la + break + } + } + } + + 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, + } +} diff --git a/hatch/internal/compile/compile.go b/hatch/internal/compile/compile.go new file mode 100644 index 0000000..5fc81a2 --- /dev/null +++ b/hatch/internal/compile/compile.go @@ -0,0 +1,121 @@ +package compile + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// Result reports what a compile run produced. +type Result struct { + Written []string // output paths written (relative to repo root not guaranteed) + Bundles []Bundle +} + +// surfacePlan groups, per surface key, the agents and the union of their roles. +type surfacePlan struct { + surface Surface + agents []model.Agent + roleIDs []string +} + +// plan computes which surfaces to emit from the registry roster. +func plan(ws *config.Workspace) ([]surfacePlan, []string) { + bySurface := map[string]*surfacePlan{} + var warnings []string + + add := func(surf Surface, a model.Agent) { + sp := bySurface[surf.Key] + if sp == nil { + sp = &surfacePlan{surface: surf} + bySurface[surf.Key] = sp + } + sp.agents = append(sp.agents, a) + seen := map[string]bool{} + for _, r := range sp.roleIDs { + seen[r] = true + } + for _, r := range a.Roles { + if !seen[r] { + sp.roleIDs = append(sp.roleIDs, r) + seen[r] = true + } + } + } + + for _, a := range ws.Registry.Agents { + keys := a.Surfaces + if len(keys) == 0 { + if surf, ok := surfaceForKind(a.Kind); ok { + add(surf, a) + } else if a.Kind != "manual" && a.Kind != "shell" { + warnings = append(warnings, fmt.Sprintf("agent %q kind %q has no compile surface", a.ID, a.Kind)) + } + continue + } + for _, k := range keys { + if surf, ok := surfaceByKey(k); ok { + add(surf, a) + } else { + warnings = append(warnings, fmt.Sprintf("agent %q has unknown surface %q", a.ID, k)) + } + } + } + + var plans []surfacePlan + for _, sp := range bySurface { + plans = append(plans, *sp) + } + sort.Slice(plans, func(i, j int) bool { return plans[i].surface.Key < plans[j].surface.Key }) + return plans, warnings +} + +// Run compiles the SSOT to every surface and updates the manifest. Outputs go +// to ws.Out() (the working repo), which may differ from the SSOT location when +// the global ~/.hatch is in use. +func Run(ws *config.Workspace) (*Result, []string, error) { + repoRoot := ws.Out() + plans, warnings := plan(ws) + + m := &Manifest{ + GeneratedAt: time.Now().Format(time.RFC3339), + Sources: sourceHashes(ws.Layout), + Outputs: map[string]string{}, + } + res := &Result{} + + for _, sp := range plans { + b := buildBundle(ws, sp.surface, sp.agents, sp.roleIDs) + content := []byte(Render(b)) + for _, out := range sp.surface.OutputPaths(repoRoot) { + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return nil, warnings, err + } + if err := os.WriteFile(out, content, 0o644); err != nil { + return nil, warnings, err + } + rel, _ := filepath.Rel(repoRoot, out) + m.Outputs[filepath.ToSlash(rel)] = hashBytes(content) + res.Written = append(res.Written, out) + } + res.Bundles = append(res.Bundles, b) + } + + // Register the Hatch MCP server with each agent so it can reach the shared + // chat + KB under its own identity (the embedded-harness integration). + mcpFiles, err := writeMCPConfigs(ws, repoRoot) + if err != nil { + return nil, warnings, err + } + res.Written = append(res.Written, mcpFiles...) + + if err := m.Save(ws.Layout); err != nil { + return nil, warnings, err + } + return res, warnings, nil +} diff --git a/hatch/internal/compile/compile_test.go b/hatch/internal/compile/compile_test.go new file mode 100644 index 0000000..68c7a0e --- /dev/null +++ b/hatch/internal/compile/compile_test.go @@ -0,0 +1,154 @@ +package compile + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +func TestRunProducesSurfaces(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) + } + res, warnings, err := Run(ws) + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + // default roster touches claude, codex, agy, kiro → 4 surfaces. + if len(res.Bundles) != 4 { + t.Fatalf("expected 4 surfaces, got %d", len(res.Bundles)) + } + for _, f := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md", ".kiro/steering/hatch.md"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("missing surface %s: %v", f, err) + } + } + // Claude surface must not be hand-editable and must carry layering headers. + claude, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + for _, want := range []string{"DO NOT EDIT", "Mission (L0)", "Your roles (L1)", "Context map (L2"} { + if !strings.Contains(string(claude), want) { + t.Errorf("CLAUDE.md missing %q", want) + } + } +} + +func TestCompileInjectsProtocolAndMCP(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) + } + if _, _, err := Run(ws); err != nil { + t.Fatal(err) + } + + // Lead surface (claude-code holds conductor) carries the orchestrator block, + // the workflow prose, the chat protocol and the DoD self-check. + claude, _ := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) + for _, want := range []string{ + "Conductor (orchestrator)", + "Workflow — scrum", + "Chat protocol", + "chat_open", + "Definition of Done", + "make test", + } { + if !strings.Contains(string(claude), want) { + t.Errorf("CLAUDE.md missing %q", want) + } + } + + // A non-lead surface gets the protocol but NOT the orchestrator block. + agents, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if strings.Contains(string(agents), "Conductor (orchestrator)") { + t.Error("AGENTS.md should not carry the orchestrator block") + } + if !strings.Contains(string(agents), "Chat protocol") { + t.Error("AGENTS.md missing chat protocol") + } + + // MCP registration: Claude repo-local config plus a Codex paste-snippet. + mcp, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) + if err != nil { + t.Fatalf("missing .mcp.json: %v", err) + } + if !strings.Contains(string(mcp), `"hatch"`) || !strings.Contains(string(mcp), `"--as"`) { + t.Errorf(".mcp.json missing hatch server: %s", mcp) + } + if !strings.Contains(string(mcp), "claude-code") { + t.Errorf(".mcp.json should register --as claude-code: %s", mcp) + } + if _, err := os.Stat(filepath.Join(dir, ".hatch", "mcp", "codex.codex.toml")); err != nil { + t.Errorf("missing codex MCP snippet: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".kiro", "settings", "mcp.json")); err != nil { + t.Errorf("missing kiro MCP config: %v", err) + } +} + +func TestMergeJSONServerPreservesOthers(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".mcp.json") + seed := `{"mcpServers":{"other":{"command":"x"}},"extra":true}` + if err := os.WriteFile(p, []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + if err := mergeJSONServer(p, "claude-code"); err != nil { + t.Fatal(err) + } + var got map[string]any + raw, _ := os.ReadFile(p) + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("result not valid JSON: %v", err) + } + servers := got["mcpServers"].(map[string]any) + if _, ok := servers["other"]; !ok { + t.Error("merge dropped the pre-existing 'other' server") + } + if _, ok := servers["hatch"]; !ok { + t.Error("merge did not add the 'hatch' server") + } + if got["extra"] != true { + t.Error("merge dropped the top-level 'extra' key") + } +} + +func TestStaleDetection(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) + if _, _, err := Run(ws); err != nil { + t.Fatal(err) + } + if reason, _ := StaleReason(l, dir); reason != "" { + t.Fatalf("expected fresh, got stale: %s", reason) + } + // Mutating the SSOT makes outputs stale. + f := l.Charter() + data, _ := os.ReadFile(f) + os.WriteFile(f, append(data, []byte("\nchanged\n")...), 0o644) + if reason, _ := StaleReason(l, dir); reason == "" { + t.Fatal("expected stale after editing charter") + } +} diff --git a/hatch/internal/compile/manifest.go b/hatch/internal/compile/manifest.go new file mode 100644 index 0000000..65b69bc --- /dev/null +++ b/hatch/internal/compile/manifest.go @@ -0,0 +1,124 @@ +package compile + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Manifest records the hashes of SSOT inputs and the outputs they produced, so +// `hatch compile --check` can detect when compiled files are stale. +type Manifest struct { + GeneratedAt string `json:"generated_at"` + Sources map[string]string `json:"sources"` // path (rel to .hatch) → sha256 + Outputs map[string]string `json:"outputs"` // path (rel to repo) → sha256 +} + +func hashBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// hashTree hashes every regular file under root, keyed by path relative to base. +func hashTree(root, base string, into map[string]string) { + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return nil + } + rel, _ := filepath.Rel(base, p) + into[filepath.ToSlash(rel)] = hashBytes(raw) + return nil + }) +} + +// sourceHashes computes hashes of all SSOT inputs (charter, roles, context, +// 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()} { + if raw, err := os.ReadFile(f); err == nil { + rel, _ := filepath.Rel(l.Root, f) + srcs[filepath.ToSlash(rel)] = hashBytes(raw) + } + } + hashTree(l.Roles(), l.Root, srcs) + hashTree(l.Context(), l.Root, srcs) + return srcs +} + +// LoadManifest reads the manifest, returning an empty one if absent. +func LoadManifest(l paths.Layout) (*Manifest, error) { + raw, err := os.ReadFile(l.Manifest()) + if err != nil { + if os.IsNotExist(err) { + return &Manifest{Sources: map[string]string{}, Outputs: map[string]string{}}, nil + } + return nil, err + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return &m, nil +} + +// Save writes the manifest to disk. +func (m *Manifest) Save(l paths.Layout) error { + if err := os.MkdirAll(l.Compiled(), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(l.Manifest(), append(raw, '\n'), 0o644) +} + +// StaleReason compares current SSOT + outputs against the manifest and returns +// a human-readable reason if anything drifted, or "" if up to date. +func StaleReason(l paths.Layout, repoRoot string) (string, error) { + m, err := LoadManifest(l) + if err != nil { + return "", err + } + if m.GeneratedAt == "" { + return "no manifest — compiled files have never been generated", nil + } + cur := sourceHashes(l) + var changed []string + for p, h := range cur { + if m.Sources[p] != h { + changed = append(changed, p) + } + } + for p := range m.Sources { + if _, ok := cur[p]; !ok { + changed = append(changed, p+" (removed)") + } + } + if len(changed) > 0 { + sort.Strings(changed) + return "SSOT changed since last compile: " + strings.Join(changed, ", "), nil + } + // Outputs edited by hand or missing? + for rel, h := range m.Outputs { + raw, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + return "compiled output missing: " + rel, nil + } + if hashBytes(raw) != h { + return "compiled output edited by hand: " + rel, nil + } + } + return "", nil +} diff --git a/hatch/internal/compile/mcp.go b/hatch/internal/compile/mcp.go new file mode 100644 index 0000000..093c6cb --- /dev/null +++ b/hatch/internal/compile/mcp.go @@ -0,0 +1,126 @@ +package compile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/fioenix/overclaud/hatch/internal/config" +) + +// mcpServerName is the key the Hatch server registers under in every agent's +// MCP config. +const mcpServerName = "hatch" + +// mcpBinary is the command agents launch to reach the shared chat + KB. It is +// expected on PATH; agents speak MCP to it over stdio. +const mcpBinary = "hatch" + +// writeMCPConfigs registers the `hatch mcp --as ` server with every +// agent that has a compile surface, so each agent reaches the shared chat + KB +// under its own identity. +// +// Repo-local standards are written/merged in place (Claude `.mcp.json`, Kiro +// `.kiro/settings/mcp.json`). Agents whose config lives in the user's home +// (Codex, agy) get a paste-ready snippet under `.hatch/mcp/` instead — Hatch +// never edits files outside the repo. +func writeMCPConfigs(ws *config.Workspace, repoRoot string) ([]string, error) { + var written []string + for _, a := range ws.Registry.Agents { + switch a.Kind { + case "claude": + p := filepath.Join(repoRoot, ".mcp.json") + if err := mergeJSONServer(p, a.ID); err != nil { + return written, err + } + written = append(written, p) + case "kiro": + p := filepath.Join(repoRoot, ".kiro", "settings", "mcp.json") + if err := mergeJSONServer(p, a.ID); err != nil { + return written, err + } + written = append(written, p) + 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") + 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") + if err := writeFile(p, agySnippet(a.ID)); err != nil { + return written, err + } + written = append(written, p) + } + } + return written, nil +} + +// serverEntry is the MCP stdio launch spec shared by the JSON configs. +func serverEntry(agentID string) map[string]any { + return map[string]any{ + "command": mcpBinary, + "args": []any{"mcp", "--as", agentID}, + } +} + +// mergeJSONServer sets mcpServers.hatch in a JSON MCP config, preserving any +// other servers the user added. It creates the file (and parents) if absent. +func mergeJSONServer(path, agentID string) error { + root := map[string]any{} + if raw, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(raw, &root); err != nil { + return fmt.Errorf("%s is not valid JSON (edit or remove it): %w", path, err) + } + } else if !os.IsNotExist(err) { + return err + } + servers, _ := root["mcpServers"].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + servers[mcpServerName] = serverEntry(agentID) + root["mcpServers"] = servers + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return err + } + return writeFile(path, string(out)+"\n") +} + +func codexSnippet(agentID string) string { + return fmt.Sprintf(`# Hatch MCP cho Codex — dán khối này vào ~/.codex/config.toml +# (Codex đọc config ở $CODEX_HOME, ngoài repo; Hatch không tự sửa file đó.) + +[mcp_servers.%s] +command = "%s" +args = ["mcp", "--as", "%s"] +`, mcpServerName, mcpBinary, agentID) +} + +func agySnippet(agentID string) string { + entry := map[string]any{ + "mcpServers": map[string]any{mcpServerName: serverEntry(agentID)}, + } + body, _ := json.MarshalIndent(entry, "", " ") + return fmt.Sprintf("# Hatch MCP cho agy (Antigravity CLI — KHÁC Gemini CLI legacy)\n\n"+ + "Antigravity CLI nạp MCP server CHỈ từ file HOME-level riêng (không inline trong settings):\n"+ + " • hiện hành: `~/.gemini/config/mcp_config.json`\n"+ + " • cũ: `~/.gemini/antigravity-cli/mcp_config.json` (nay là symlink tới file trên)\n"+ + "Project-local `.antigravitycli/mcp_config.json` bị phát hiện nhưng BỎ QUA (issue #60).\n"+ + "Cách nhanh: `hatch init --client agy`. Hoặc trộn khối `mcpServers` dưới đây vào file HOME:\n\n"+ + "```json\n%s\n```\n", string(body)) +} + +// writeFile writes content, creating parent dirs. +func writeFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/hatch/internal/compile/render.go b/hatch/internal/compile/render.go new file mode 100644 index 0000000..1c856c5 --- /dev/null +++ b/hatch/internal/compile/render.go @@ -0,0 +1,235 @@ +package compile + +import ( + "fmt" + "sort" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// genHeader marks a file as compiler output that must not be hand-edited. +const genHeader = "" + +// Render produces the instruction-file content for a bundle, applying +// L0 (mission) + L1 (roles) + L2 pointers layering. +func Render(b Bundle) string { + var w strings.Builder + + // Kiro steering files take YAML frontmatter selecting the inclusion mode. + if b.Surface.Key == "kiro" { + w.WriteString("---\ninclusion: always\n---\n\n") + } + + w.WriteString(genHeader + "\n\n") + + title := "Hatch — Agent Operating Context" + if b.Project != "" { + title = b.Project + " — Agent Operating Context" + } + fmt.Fprintf(&w, "# %s\n\n", title) + + if len(b.Agents) > 0 { + names := make([]string, len(b.Agents)) + for i, a := range b.Agents { + names[i] = a.ID + } + fmt.Fprintf(&w, "_Surface: %s · read by: %s_\n\n", b.Surface.Desc, strings.Join(names, ", ")) + } + + // L0 — mission, loaded every message. + w.WriteString("## Mission (L0)\n\n") + if b.Charter != "" { + w.WriteString(b.Charter + "\n\n") + } else { + w.WriteString("_No charter.md yet._\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") + for _, rc := range b.Roles { + t := rc.Role.Title + if t == "" { + t = rc.Role.ID + } + fmt.Fprintf(&w, "### Role: %s (`%s`)\n\n", t, rc.Role.ID) + if rc.Body != "" { + w.WriteString(rc.Body + "\n\n") + } else { + fmt.Fprintf(&w, "_Chưa có roles/%s.md._\n\n", rc.Role.ID) + } + } + + // Orchestrator block — only on the lead agent's surface. + if b.Lead != nil { + renderOrchestrator(&w, b) + } + + // Workflow as prose: the process the squad self-follows (not an engine). + renderWorkflow(&w, b) + + // Chat protocol: how the agent reaches the squad through the Hatch MCP server. + renderChatProtocol(&w, b) + + // Definition of Done: a self-check the agent runs before reporting done. + renderDoD(&w, b) + + // L2 — pointers only; the agent reads these on demand when it takes a task. + w.WriteString("## Context map (L2 — đọc on-demand, KHÔNG nạp sẵn)\n\n") + w.WriteString("Khi nhận một task (thread), đọc thread + `context_refs` liên quan dưới đây. Tra Knowledge Base (`kb_search`) trước khi suy diễn lại.\n\n") + if len(b.ContextRefs) > 0 { + for _, r := range b.ContextRefs { + fmt.Fprintf(&w, "- `.hatch/%s`\n", r) + } + 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") + + out := w.String() + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out +} + +// 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`" + +// renderOrchestrator writes the lead agent's coordination duties. Hatch does +// not drive anyone; this agent self-conducts through chat. +func renderOrchestrator(w *strings.Builder, b Bundle) { + tmpl := "quy trình của squad" + if b.Workflow != nil && b.Workflow.Template != "" { + tmpl = b.Workflow.Template + } + fmt.Fprintf(w, "## Bạn là Conductor (orchestrator)\n\n") + fmt.Fprintf(w, "`%s` là agent user mở trước → bạn **mặc định là Conductor** và có thể kiêm các vai khác ở trên. Hatch KHÔNG điều phối thay bạn — bạn tự điều phối **qua chat**:\n\n", b.Lead.ID) + fmt.Fprintf(w, "- Chạy quy trình **%s** (mô tả ở mục Workflow). Chia việc thành các task; mỗi task mở **một thread** (`chat_open`).\n", tmpl) + w.WriteString("- Phân vai bằng **@tag** đúng agent/role trong thread; theo dõi `chat_inbox` để biết ai cần gì.\n") + w.WriteString("- Gate = **self-check DoD** (mục Definition of Done) — bạn yêu cầu và xác nhận, không có engine ép buộc.\n") + w.WriteString("- Khi bí/đụng quyết định lớn: mở thread + @tag các vai liên quan (hoặc @all) để chốt, ghi quyết định bằng `kb_add`.\n\n") +} + +// renderWorkflow turns workflow.yaml into prose stages + who-does-what, so the +// agent follows the process by reading, not via a Go state machine. +func renderWorkflow(w *strings.Builder, b Bundle) { + wf := b.Workflow + if wf == nil || len(wf.Lanes) == 0 { + return + } + name := wf.Template + if name == "" { + name = "tùy biến" + } + fmt.Fprintf(w, "## Workflow — %s (mô tả, không phải engine)\n\n", name) + w.WriteString("Đây là quy trình squad **tự tuân theo** qua chat. Trạng thái task suy ra từ hội thoại trong thread (post `done`/`block`/`decision`), không cần lane-engine.\n\n") + + // Stages = main-flow lanes in order; side lanes noted separately. + var main, side []string + for _, l := range wf.Lanes { + if l.Side { + side = append(side, l.ID) + } else { + main = append(main, l.ID) + } + } + if len(main) > 0 { + fmt.Fprintf(w, "**Các giai đoạn:** %s", strings.Join(main, " → ")) + if len(side) > 0 { + fmt.Fprintf(w, " (bên lề: %s)", strings.Join(side, ", ")) + } + w.WriteString("\n\n") + } + + // Transitions = who moves work, with the gates that must pass first. + if len(wf.Transitions) > 0 { + w.WriteString("**Ai làm gì:**\n\n") + for _, t := range wf.Transitions { + by := strings.Join(t.By, "/") + if by == "*" || by == "" { + by = "bất kỳ ai" + } + line := fmt.Sprintf("- `%s → %s`", t.From, t.To) + if t.Action != "" { + line += fmt.Sprintf(" (%s)", t.Action) + } + line += fmt.Sprintf(": **%s**", by) + if len(t.Gates) > 0 { + line += fmt.Sprintf(" — qua gate: %s", strings.Join(t.Gates, ", ")) + } + w.WriteString(line + "\n") + } + w.WriteString("\n") + } + + // Ceremonies become recurring chat habits. + if len(wf.Ceremonies) > 0 { + names := make([]string, 0, len(wf.Ceremonies)) + for n := range wf.Ceremonies { + names = append(names, n) + } + sort.Strings(names) + w.WriteString("**Nghi thức (mở thread định kỳ):** ") + parts := make([]string, 0, len(names)) + for _, n := range names { + c := wf.Ceremonies[n] + if c.Trigger != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", n, c.Trigger)) + } else { + parts = append(parts, n) + } + } + w.WriteString(strings.Join(parts, ", ") + ".\n\n") + } +} + +// renderChatProtocol teaches the Slack-style etiquette over the Hatch MCP bus. +func renderChatProtocol(w *strings.Builder, _ Bundle) { + w.WriteString("## Chat protocol (giao tiếp = backlog) — Hatch MCP\n\n") + w.WriteString("Bạn nối với cả squad qua MCP server **`hatch`** (đăng ký sẵn cho agent của bạn). Tools: " + chatTools + ".\n\n") + w.WriteString("Cư xử như một thành viên squad người:\n\n") + w.WriteString("- **Đầu session:** `whoami` rồi `chat_inbox` để \"đọc phòng\" (DM/@mention/broadcast mới).\n") + w.WriteString("- **Mỗi task = một thread:** `chat_open(title, body)`. Brief tiến độ & kết quả bằng `chat_post(reply_to=)` — thread chính là task.\n") + w.WriteString("- **Cần đồng đội:** `@tag` đúng agent/role trong body (hoặc `to=`). Ai được tag sẽ đọc thread rồi trả lời **trong cùng thread**.\n") + w.WriteString("- **Việc chung:** mở topic và `@all` (hoặc `to=*`).\n") + w.WriteString("- **Trước khi suy diễn lại:** `chat_search` / `kb_search`. Recall theo từ khoá, đừng đọc hết.\n") + w.WriteString("- **Tri thức đáng giữ:** `kb_add` (type=decision|domain|learning).\n") + w.WriteString("- **Báo trạng thái** ngay trong thread: post kết quả khi xong, nêu lý do khi block.\n\n") +} + +// renderDoD writes the self-check derived from workflow command-gates + policy. +func renderDoD(w *strings.Builder, b Bundle) { + w.WriteString("## Definition of Done (tự kiểm trước khi báo xong)\n\n") + w.WriteString("Hatch không chạy gate thay bạn — **bạn tự chạy & xác nhận** trước khi post kết quả:\n\n") + // Command gates from the workflow become explicit shell checks. + var ran bool + if b.Workflow != nil { + // Stable order for deterministic output. + names := make([]string, 0, len(b.Workflow.Gates)) + for n := range b.Workflow.Gates { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + if g := b.Workflow.Gates[n]; g.Type == model.GateCommand && g.Run != "" { + fmt.Fprintf(w, "- [ ] `%s` xanh (%s).\n", g.Run, n) + ran = true + } + } + } + if !ran { + w.WriteString("- [ ] Build/test/lint của dự án xanh.\n") + } + if b.Policy.NoSelfReview { + w.WriteString("- [ ] **Không tự review** code mình viết — @tag một reviewer khác.\n") + } + if b.Policy.HumanMerge { + w.WriteString("- [ ] **Không tự merge** — người cuối cùng merge là con người.\n") + } + w.WriteString("- [ ] Ghi quyết định/bài học đáng giữ bằng `kb_add`.\n") + w.WriteString("- [ ] Post kết quả (+ lý do nếu block) vào thread của task.\n\n") +} diff --git a/hatch/internal/compile/surface.go b/hatch/internal/compile/surface.go new file mode 100644 index 0000000..b37b0a4 --- /dev/null +++ b/hatch/internal/compile/surface.go @@ -0,0 +1,71 @@ +// Package compile turns the SSOT (charter + roles + context) into the native +// instruction files each coding-agent CLI expects, applying L0+L1+pointer +// layering. See docs/10-agent-adapters.md for the surface table. +package compile + +import ( + "path/filepath" +) + +// Surface is a distinct instruction-file target. Several agent kinds can share +// one surface (Codex and Antigravity both read AGENTS.md). +type Surface struct { + Key string // claude | agents | gemini | kiro + Desc string +} + +// Known surfaces. +var ( + SurfaceClaude = Surface{"claude", "Claude Code — CLAUDE.md"} + SurfaceAgents = Surface{"agents", "AGENTS.md (Codex / Antigravity / Gemini-compat)"} + SurfaceGemini = Surface{"gemini", "GEMINI.md (Antigravity / agy)"} + SurfaceKiro = Surface{"kiro", "Kiro — .kiro/steering/"} +) + +// surfaceForKind maps a registry agent kind to its default surface key. +// "manual" agents have no compile surface. +func surfaceForKind(kind string) (Surface, bool) { + switch kind { + case "claude": + return SurfaceClaude, true + case "codex", "antigravity": + return SurfaceAgents, true + case "agy": + return SurfaceGemini, true + case "kiro": + return SurfaceKiro, true + default: + return Surface{}, false + } +} + +// surfaceByKey resolves an explicit surface key from registry agent.Surfaces. +func surfaceByKey(key string) (Surface, bool) { + switch key { + case "claude": + return SurfaceClaude, true + case "agents", "codex", "antigravity": + return SurfaceAgents, true + case "gemini": + return SurfaceGemini, true + case "kiro": + return SurfaceKiro, true + default: + return Surface{}, false + } +} + +// OutputPaths returns the files a surface writes, relative to the repo root. +func (s Surface) OutputPaths(repoRoot string) []string { + switch s.Key { + case "claude": + return []string{filepath.Join(repoRoot, "CLAUDE.md")} + case "agents": + return []string{filepath.Join(repoRoot, "AGENTS.md")} + case "gemini": + return []string{filepath.Join(repoRoot, "GEMINI.md")} + case "kiro": + return []string{filepath.Join(repoRoot, ".kiro", "steering", "hatch.md")} + } + return nil +} diff --git a/hatch/internal/config/config.go b/hatch/internal/config/config.go new file mode 100644 index 0000000..6490f7c --- /dev/null +++ b/hatch/internal/config/config.go @@ -0,0 +1,72 @@ +// Package config loads and validates the per-project registry and workflow +// definitions that configure a .hatch/ workspace. +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Workspace bundles the loaded configuration for a workspace. +type Workspace struct { + Layout paths.Layout + Registry *model.Registry + Workflow *model.Workflow + // OutputRoot is where compiled surfaces (CLAUDE.md, .mcp.json, …) are + // written. For a local .hatch it is the repo root (parent of .hatch); for + // the global ~/.hatch it is the current working repo. Empty falls back to + // Layout.RepoRoot(). + OutputRoot string +} + +// Out returns the directory compiled outputs should be written to. +func (w *Workspace) Out() string { + if w.OutputRoot != "" { + return w.OutputRoot + } + return w.Layout.RepoRoot() +} + +// LoadRegistry reads and parses registry.yaml. +func LoadRegistry(path string) (*model.Registry, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var r model.Registry + if err := yaml.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &r, nil +} + +// LoadWorkflow reads and parses workflow.yaml. +func LoadWorkflow(path string) (*model.Workflow, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var w model.Workflow + if err := yaml.Unmarshal(raw, &w); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &w, nil +} + +// Load reads both config files for a workspace layout. +func Load(l paths.Layout) (*Workspace, error) { + reg, err := LoadRegistry(l.Registry()) + if err != nil { + return nil, err + } + wf, err := LoadWorkflow(l.Workflow()) + if err != nil { + return nil, err + } + return &Workspace{Layout: l, Registry: reg, Workflow: wf}, nil +} diff --git a/hatch/internal/config/validate.go b/hatch/internal/config/validate.go new file mode 100644 index 0000000..227f0d1 --- /dev/null +++ b/hatch/internal/config/validate.go @@ -0,0 +1,201 @@ +package config + +import ( + "fmt" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// Problem is a single validation finding. +type Problem struct { + Source string // file or component + Msg string +} + +func (p Problem) String() string { return fmt.Sprintf("%s: %s", p.Source, p.Msg) } + +// ValidateRegistry checks internal consistency of the roster. +func ValidateRegistry(r *model.Registry) []Problem { + var probs []Problem + add := func(f, m string) { probs = append(probs, Problem{f, m}) } + + if r.Version == 0 { + add("registry.yaml", "missing version") + } + seenRole := map[string]bool{} + for _, role := range r.Roles { + if role.ID == "" { + add("registry.yaml", "role with empty id") + continue + } + if seenRole[role.ID] { + add("registry.yaml", fmt.Sprintf("duplicate role id %q", role.ID)) + } + seenRole[role.ID] = true + } + // reporting lines must reference real roles and not cycle. + for _, role := range r.Roles { + if role.ReportsTo == "" { + continue + } + if !seenRole[role.ReportsTo] { + add("registry.yaml", fmt.Sprintf("role %q reports_to unknown role %q", role.ID, role.ReportsTo)) + continue + } + if orgCycle(r, role.ID) { + add("registry.yaml", fmt.Sprintf("reports_to cycle involving role %q", role.ID)) + } + } + seenAgent := map[string]bool{} + for _, a := range r.Agents { + if a.ID == "" { + add("registry.yaml", "agent with empty id") + continue + } + if seenAgent[a.ID] { + add("registry.yaml", fmt.Sprintf("duplicate agent id %q", a.ID)) + } + seenAgent[a.ID] = true + if a.Kind == "" { + add("registry.yaml", fmt.Sprintf("agent %q missing kind", a.ID)) + } + for _, rl := range a.Roles { + if !seenRole[rl] { + add("registry.yaml", fmt.Sprintf("agent %q references unknown role %q", a.ID, rl)) + } + } + } + return probs +} + +// orgCycle reports whether following reports_to from start loops. +func orgCycle(r *model.Registry, start string) bool { + seen := map[string]bool{} + cur := start + for cur != "" { + if seen[cur] { + return true + } + seen[cur] = true + role, ok := r.RoleByID(cur) + if !ok { + return false + } + cur = role.ReportsTo + } + return false +} + +// ValidateWorkflow checks lanes, transitions and gates refer to each other +// consistently and that the board can reach a terminal lane. +func ValidateWorkflow(w *model.Workflow) []Problem { + var probs []Problem + add := func(m string) { probs = append(probs, Problem{"workflow.yaml", m}) } + + if w.Version == 0 { + add("missing version") + } + if len(w.Lanes) == 0 { + add("no lanes defined") + return probs + } + seenLane := map[string]bool{} + for _, l := range w.Lanes { + if l.ID == "" { + add("lane with empty id") + continue + } + if seenLane[l.ID] { + add(fmt.Sprintf("duplicate lane id %q", l.ID)) + } + seenLane[l.ID] = true + } + for i, t := range w.Transitions { + if t.From != "*" && !seenLane[t.From] { + add(fmt.Sprintf("transition[%d] from unknown lane %q", i, t.From)) + } + if !seenLane[t.To] { + add(fmt.Sprintf("transition[%d] to unknown lane %q", i, t.To)) + } + if len(t.By) == 0 { + add(fmt.Sprintf("transition[%d] (%s→%s) has no `by` roles", i, t.From, t.To)) + } + for _, g := range t.Gates { + if _, ok := w.Gates[g]; !ok { + add(fmt.Sprintf("transition[%d] references undefined gate %q", i, g)) + } + } + } + for name, g := range w.Gates { + switch g.Type { + case model.GateCommand: + if g.Run == "" { + add(fmt.Sprintf("gate %q (command) missing `run`", name)) + } + case model.GateRequired: + if g.Field == "" { + add(fmt.Sprintf("gate %q (required-field) missing `field`", name)) + } + case model.GateChecklist, model.GatePolicy, model.GateHuman: + // ref optional / not required + case "": + add(fmt.Sprintf("gate %q missing type", name)) + default: + add(fmt.Sprintf("gate %q has unknown type %q", name, g.Type)) + } + } + // Reachability: every non-side lane should reach a lane with no outgoing + // transition (a terminal/done lane). + if !reachesTerminal(w) { + add("no lane graph path reaches a terminal (done) lane") + } + return probs +} + +func reachesTerminal(w *model.Workflow) bool { + outgoing := map[string]bool{} + for _, t := range w.Transitions { + if t.From != "*" { + outgoing[t.From] = true + } + } + for _, l := range w.Lanes { + if l.Side { + continue + } + if !outgoing[l.ID] { + return true // this lane is terminal + } + } + return false +} + +// ValidateCrossRefs checks links that span both files, e.g. transition roles +// must exist in the registry. +func ValidateCrossRefs(r *model.Registry, w *model.Workflow) []Problem { + var probs []Problem + roleOK := map[string]bool{"*": true} + for _, role := range r.Roles { + roleOK[role.ID] = true + } + for i, t := range w.Transitions { + for _, by := range t.By { + if !roleOK[by] { + probs = append(probs, Problem{ + "workflow.yaml", + fmt.Sprintf("transition[%d] (%s→%s) `by` role %q not in registry", i, t.From, t.To, by), + }) + } + } + } + return probs +} + +// ValidateAll runs every check for a loaded workspace. +func (ws *Workspace) Validate() []Problem { + var probs []Problem + probs = append(probs, ValidateRegistry(ws.Registry)...) + probs = append(probs, ValidateWorkflow(ws.Workflow)...) + probs = append(probs, ValidateCrossRefs(ws.Registry, ws.Workflow)...) + return probs +} diff --git a/hatch/internal/config/validate_test.go b/hatch/internal/config/validate_test.go new file mode 100644 index 0000000..1a2ed72 --- /dev/null +++ b/hatch/internal/config/validate_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "strings" + "testing" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +func TestValidateWorkflowDetectsUndefinedGateAndLane(t *testing.T) { + w := &model.Workflow{ + Version: 1, + Lanes: []model.Lane{{ID: "backlog"}, {ID: "done"}}, + Transitions: []model.Transition{ + {From: "backlog", To: "missing", By: []string{"*"}}, + {From: "backlog", To: "done", By: []string{"reviewer"}, Gates: []string{"ghost"}}, + }, + } + probs := ValidateWorkflow(w) + joined := problemsString(probs) + if !strings.Contains(joined, "missing") { + t.Errorf("expected unknown lane problem, got: %s", joined) + } + if !strings.Contains(joined, "ghost") { + t.Errorf("expected undefined gate problem, got: %s", joined) + } +} + +func TestValidateWorkflowReachability(t *testing.T) { + // every lane has an outgoing transition → no terminal lane. + w := &model.Workflow{ + Version: 1, + Lanes: []model.Lane{{ID: "a"}, {ID: "b"}}, + Transitions: []model.Transition{ + {From: "a", To: "b", By: []string{"*"}}, + {From: "b", To: "a", By: []string{"*"}}, + }, + } + if got := problemsString(ValidateWorkflow(w)); !strings.Contains(got, "terminal") { + t.Errorf("expected terminal-lane problem, got: %s", got) + } +} + +func TestValidateCrossRefsUnknownRole(t *testing.T) { + r := &model.Registry{Version: 1, Roles: []model.Role{{ID: "implementer"}}} + w := &model.Workflow{ + Version: 1, + Lanes: []model.Lane{{ID: "x"}, {ID: "y"}}, + Transitions: []model.Transition{{From: "x", To: "y", By: []string{"ghostrole"}}}, + } + if got := problemsString(ValidateCrossRefs(r, w)); !strings.Contains(got, "ghostrole") { + t.Errorf("expected unknown role problem, got: %s", got) + } +} + +func problemsString(ps []Problem) string { + var b strings.Builder + for _, p := range ps { + b.WriteString(p.String()) + b.WriteString("\n") + } + return b.String() +} diff --git a/hatch/internal/decide/decide.go b/hatch/internal/decide/decide.go new file mode 100644 index 0000000..404adf1 --- /dev/null +++ b/hatch/internal/decide/decide.go @@ -0,0 +1,43 @@ +//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 new file mode 100644 index 0000000..c2589ff --- /dev/null +++ b/hatch/internal/decide/decide_test.go @@ -0,0 +1,37 @@ +//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/docs/docs.go b/hatch/internal/docs/docs.go new file mode 100644 index 0000000..97889a0 --- /dev/null +++ b/hatch/internal/docs/docs.go @@ -0,0 +1,159 @@ +// Package docs implements the document template/spec system: agents scaffold +// PRDs, design docs, ADRs, postmortems… from per-project templates and can +// lint a document against its declared spec. Templates live in +// .hatch/templates/docs/ and are user-editable (see docs/16). +package docs + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/fioenix/overclaud/hatch/internal/mdfront" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// Template is a document type's spec + scaffold body. +type Template struct { + Type string `yaml:"doc-type"` + Framework string `yaml:"framework"` + RequiredFrontmatter []string `yaml:"required-frontmatter"` + RequiredSections []string `yaml:"required-sections"` + Body string `yaml:"-"` +} + +// Load reads all templates from .hatch/templates/docs/. +func Load(l paths.Layout) (map[string]Template, error) { + dir := l.DocTemplates() + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return map[string]Template{}, nil + } + return nil, err + } + out := map[string]Template{} + for _, e := range ents { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var t Template + body, err := mdfront.Decode(raw, &t) + if err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + t.Body = body + if t.Type == "" { + t.Type = strings.TrimSuffix(e.Name(), ".md") + } + out[t.Type] = t + } + return out, nil +} + +// Types lists available doc types, sorted. +func Types(l paths.Layout) ([]Template, error) { + m, err := Load(l) + if err != nil { + return nil, err + } + out := make([]Template, 0, len(m)) + for _, t := range m { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type }) + return out, nil +} + +// docMeta is the frontmatter written into a generated document. +type docMeta struct { + DocType string `yaml:"doc-type"` + ID string `yaml:"id,omitempty"` + Title string `yaml:"title"` + Status string `yaml:"status,omitempty"` + Date string `yaml:"date,omitempty"` +} + +// New renders a new document from a template, substituting {{title}} and +// seeding required frontmatter. +func New(t Template, title string) ([]byte, error) { + meta := docMeta{DocType: t.Type, Title: title} + for _, f := range t.RequiredFrontmatter { + switch f { + case "date": + meta.Date = time.Now().Format("2006-01-02") + case "status": + meta.Status = "draft" + case "id": + meta.ID = slug(title) + } + } + body := strings.ReplaceAll(t.Body, "{{title}}", title) + return mdfront.Encode(meta, body) +} + +// Lint checks a document against the template for its declared doc-type. +func Lint(raw []byte, templates map[string]Template) (docType string, problems []string, err error) { + var meta docMeta + body, err := mdfront.Decode(raw, &meta) + if err != nil { + return "", nil, err + } + if meta.DocType == "" { + return "", []string{"missing `doc-type` in frontmatter"}, nil + } + t, ok := templates[meta.DocType] + if !ok { + return meta.DocType, []string{"unknown doc-type " + meta.DocType}, nil + } + // frontmatter presence + have := map[string]bool{"title": meta.Title != "", "date": meta.Date != "", "status": meta.Status != "", "id": meta.ID != "", "doc-type": true} + for _, f := range t.RequiredFrontmatter { + if !have[f] { + problems = append(problems, "thiếu frontmatter `"+f+"`") + } + } + // required sections (## Heading) + for _, s := range t.RequiredSections { + if !hasSection(body, s) { + problems = append(problems, "thiếu mục `## "+s+"`") + } + } + return meta.DocType, problems, nil +} + +func hasSection(body, section string) bool { + want := strings.ToLower("## " + section) + for _, ln := range strings.Split(body, "\n") { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(ln)), want) { + return true + } + } + return false +} + +func slug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + dash := false + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + dash = false + } else if !dash { + b.WriteByte('-') + dash = true + } + } + return strings.Trim(b.String(), "-") +} + +// Slug exposes the slugifier for filename construction. +func Slug(s string) string { return slug(s) } diff --git a/hatch/internal/docs/docs_test.go b/hatch/internal/docs/docs_test.go new file mode 100644 index 0000000..684e96c --- /dev/null +++ b/hatch/internal/docs/docs_test.go @@ -0,0 +1,49 @@ +package docs + +import ( + "strings" + "testing" + + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +func TestNewAndLintRoundTrip(t *testing.T) { + dir := t.TempDir() + l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}) + if err != nil { + t.Fatal(err) + } + tmpls, err := Load(l) + if err != nil { + t.Fatal(err) + } + if _, ok := tmpls["adr"]; !ok { + t.Fatalf("default adr template missing; got %v", keys(tmpls)) + } + // A freshly scaffolded ADR (sections intact) should lint clean. + content, err := New(tmpls["adr"], "Use CSV streaming") + if err != nil { + t.Fatal(err) + } + dt, probs, err := Lint(content, tmpls) + if err != nil { + t.Fatal(err) + } + if dt != "adr" || len(probs) != 0 { + t.Fatalf("fresh adr should be clean: type=%s probs=%v", dt, probs) + } + // Removing a required section should be flagged. + broken := strings.Replace(string(content), "## Consequences", "## Misc", 1) + _, probs2, _ := Lint([]byte(broken), tmpls) + if len(probs2) == 0 { + t.Fatal("expected a missing-section problem") + } +} + +func keys(m map[string]Template) []string { + var k []string + for x := range m { + k = append(k, x) + } + return k +} diff --git a/hatch/internal/gate/gate.go b/hatch/internal/gate/gate.go new file mode 100644 index 0000000..53666a0 --- /dev/null +++ b/hatch/internal/gate/gate.go @@ -0,0 +1,163 @@ +//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 new file mode 100644 index 0000000..73e973b --- /dev/null +++ b/hatch/internal/gate/gate_test.go @@ -0,0 +1,36 @@ +//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 new file mode 100644 index 0000000..a58651e --- /dev/null +++ b/hatch/internal/mcpserver/server.go @@ -0,0 +1,189 @@ +// Package mcpserver exposes Hatch's shared chat (the bus) and knowledge base to +// any MCP-capable coding agent (Claude Code, Codex, agy, Kiro, …). This is the +// "embedded harness": the agent drives itself and reaches into the shared +// comms + memory through these tools — chat is both the communication channel +// and the backlog (a thread = a task). See docs/20-embedded-harness-pivot.md. +package mcpserver + +import ( + "context" + "fmt" + "strings" + + "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/store" +) + +// New builds the Hatch MCP server bound to a workspace, acting as agent `me`. +// All posts/inbox are attributed to `me`, so each agent runs its own instance +// (`hatch mcp --as <agent>`). +func New(ws *config.Workspace, me, version string) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{Name: "hatch", Title: "Hatch chat", Version: version}, nil) + b := bus.New(ws.Layout) + kb := store.NewKB(ws.Layout) + roles := rolesOf(ws, 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) { + return nil, whoamiOut{Agent: me, Roles: roles}, 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) { + ch := in.Channel + if ch == "" { + ch = "#" + slug(in.Title) + } + body := in.Body + if in.Title != "" { + body = "**" + in.Title + "**\n" + body + } + m, err := b.Post(bus.Message{Channel: ch, From: me, To: splitCSV(in.To), Type: model.MsgText, Body: body}) + if err != nil { + return nil, postOut{}, err + } + return nil, postOut{Channel: ch, MessageID: m.ID}, nil + }) + + 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) { + if in.Channel == "" { + return nil, postOut{}, fmt.Errorf("channel is required") + } + typ := in.Type + if typ == "" { + typ = model.MsgText + } + m, err := b.Post(bus.Message{Channel: in.Channel, From: me, To: splitCSV(in.To), Type: typ, InReplyTo: in.ReplyTo, Body: in.Body}) + if err != nil { + return nil, postOut{}, err + } + return nil, postOut{Channel: in.Channel, MessageID: m.ID}, nil + }) + + mcp.AddTool(s, &mcp.Tool{Name: "chat_read", + Description: "Đọc toàn bộ hội thoại của một channel/thread (để hiểu nhiệm vụ trước khi phản hồi)."}, + func(_ context.Context, _ *mcp.CallToolRequest, in channelIn) (*mcp.CallToolResult, textOut, error) { + raw, err := b.Raw(in.Channel) + return nil, textOut{Text: raw}, err + }) + + 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) { + msgs, err := b.Inbox(me, roles) + if err != nil { + return nil, messagesOut{}, err + } + if in.Mark { + _ = b.MarkRead(me) + } + return nil, messagesOut{Messages: brief(msgs)}, nil + }) + + mcp.AddTool(s, &mcp.Tool{Name: "chat_search", + Description: "Tra cứu hội thoại liên quan (recall theo từ khoá), không phải đọc hết. newest-first, có giới hạn."}, + func(_ context.Context, _ *mcp.CallToolRequest, in searchIn) (*mcp.CallToolResult, messagesOut, error) { + lim := in.Limit + if lim == 0 { + lim = 20 + } + msgs, err := b.Search(model.SearchOpts{Query: in.Query, Channel: in.Channel, From: in.From, Type: in.Type, Limit: lim}) + return nil, messagesOut{Messages: brief(msgs)}, err + }) + + mcp.AddTool(s, &mcp.Tool{Name: "chat_channels", + Description: "Liệt kê các channel/topic/task hiện có (backlog dạng hội thoại)."}, + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, channelsOut, error) { + chs, err := b.Channels() + // Present channels with a leading '#' so the surface matches the + // ids returned by chat_open (the bus stores them bare). + for i, c := range chs { + chs[i] = "#" + c + } + return nil, channelsOut{Channels: chs}, err + }) + + mcp.AddTool(s, &mcp.Tool{Name: "kb_add", + Description: "Ghi tri thức đáng giữ vào bộ nhớ chung: type=decision|domain|learning."}, + func(_ context.Context, _ *mcp.CallToolRequest, in kbAddIn) (*mcp.CallToolResult, kbAddOut, error) { + typ := in.Type + if typ == "" { + typ = model.KBLearning + } + e := model.KBEntry{ID: kb.NextID(typ), Type: typ, Title: in.Title, Tags: splitCSV(in.Tags), Author: me, Body: in.Body} + if _, err := kb.Add(e); err != nil { + return nil, kbAddOut{}, err + } + _ = kb.RebuildIndex() + return nil, kbAddOut{ID: e.ID}, nil + }) + + mcp.AddTool(s, &mcp.Tool{Name: "kb_search", + Description: "Tra bộ nhớ chung theo tag trước khi suy diễn lại."}, + func(_ context.Context, _ *mcp.CallToolRequest, in kbSearchIn) (*mcp.CallToolResult, kbSearchOut, error) { + es, err := kb.Query(splitCSV(in.Tags)) + out := kbSearchOut{} + for _, e := range es { + out.Entries = append(out.Entries, fmt.Sprintf("%s [%s] %s — kb/%s", e.ID, e.Type, e.Title, e.Path)) + } + return nil, out, err + }) + + return s +} + +func rolesOf(ws *config.Workspace, id string) []string { + if a, ok := ws.Registry.AgentByID(id); ok { + return a.Roles + } + return nil +} + +func brief(ms []bus.Message) []string { + out := make([]string, 0, len(ms)) + for _, m := range ms { + body := strings.ReplaceAll(strings.TrimSpace(m.Body), "\n", " ") + if len([]rune(body)) > 200 { + body = string([]rune(body)[:200]) + "…" + } + out = append(out, fmt.Sprintf("[%s] %s · %s: %s", m.Type, m.Channel, m.From, body)) + } + return out +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if v := strings.TrimSpace(p); v != "" { + out = append(out, v) + } + } + return out +} + +func slug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + dash := false + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + dash = false + } else if !dash { + b.WriteByte('-') + dash = true + } + } + return strings.Trim(b.String(), "-") +} diff --git a/hatch/internal/mcpserver/server_test.go b/hatch/internal/mcpserver/server_test.go new file mode 100644 index 0000000..b7666a0 --- /dev/null +++ b/hatch/internal/mcpserver/server_test.go @@ -0,0 +1,194 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/paths" + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +// newWorkspace scaffolds a fresh Hatch workspace in a temp dir. +func newWorkspace(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if _, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: "scrum"}); err != nil { + t.Fatalf("scaffold: %v", err) + } + return dir +} + +// connect scaffolds a workspace in a temp dir and connects a session as `me`. +func connect(t *testing.T, me string) *mcp.ClientSession { + t.Helper() + return connectIn(t, newWorkspace(t), me) +} + +// connectIn builds the Hatch MCP server for agent `me` against an existing +// workspace dir and wires an in-memory client session to it. Multiple agents +// can connect to the same dir to exercise cross-agent chat. +func connectIn(t *testing.T, dir, me string) *mcp.ClientSession { + t.Helper() + l, err := paths.Find(dir) + if err != nil { + t.Fatalf("find: %v", err) + } + ws, err := config.Load(l) + if err != nil { + t.Fatalf("load: %v", err) + } + + srv := New(ws, me, "test") + ct, st := mcp.NewInMemoryTransports() + ctx := context.Background() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + client := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +func call(t *testing.T, cs *mcp.ClientSession, name string, args any) *mcp.CallToolResult { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + if res.IsError { + t.Fatalf("call %s returned tool error: %s", name, text(res)) + } + return res +} + +// structured decodes a tool result's JSON content into the typed Out value. +// The SDK populates Content with JSON text mirroring the structured output. +func structured[T any](t *testing.T, res *mcp.CallToolResult) T { + t.Helper() + var out T + if err := json.Unmarshal([]byte(text(res)), &out); err != nil { + t.Fatalf("decode result %q: %v", text(res), err) + } + return out +} + +func text(res *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func TestToolsRegistered(t *testing.T) { + cs := connect(t, "claude-code") + res, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + got := map[string]bool{} + for _, tool := range res.Tools { + got[tool.Name] = true + } + for _, want := range []string{ + "whoami", "chat_open", "chat_post", "chat_read", + "chat_inbox", "chat_search", "chat_channels", "kb_add", "kb_search", + } { + if !got[want] { + t.Errorf("missing tool %q", want) + } + } +} + +func TestChatRoundTrip(t *testing.T) { + cs := connect(t, "claude-code") + + // Open a thread (= a task) and post a reply into it. + open := call(t, cs, "chat_open", openIn{Title: "Export CSV", Body: "@codex giúp streaming"}) + ch := structured[postOut](t, open).Channel + if ch == "" { + t.Fatal("chat_open returned empty channel") + } + if got := structured[postOut](t, open).MessageID; got == "" { + t.Fatal("chat_open returned empty message id") + } + + post := call(t, cs, "chat_post", postIn{Channel: ch, Body: "done, PR up"}) + if structured[postOut](t, post).Channel != ch { + t.Fatalf("chat_post channel mismatch: %s", text(post)) + } + + // chat_read should contain both messages. + read := call(t, cs, "chat_read", channelIn{Channel: ch}) + body := structured[textOut](t, read).Text + if !strings.Contains(body, "Export CSV") || !strings.Contains(body, "PR up") { + t.Fatalf("chat_read missing content: %q", body) + } + + // The channel shows up in chat_channels. + chans := call(t, cs, "chat_channels", struct{}{}) + if !contains(structured[channelsOut](t, chans).Channels, ch) { + t.Fatalf("chat_channels missing %s: %v", ch, structured[channelsOut](t, chans).Channels) + } +} + +func TestInboxAndMention(t *testing.T) { + // claude-code opens a thread tagging @codex; codex's inbox should see it. + // Both agents share one workspace. + dir := newWorkspace(t) + author := connectIn(t, dir, "claude-code") + open := call(t, author, "chat_open", openIn{Channel: "#design", Body: "@codex review please", To: "codex"}) + _ = open + + codex := connectIn(t, dir, "codex") + inbox := call(t, codex, "chat_inbox", inboxIn{Mark: true}) + msgs := structured[messagesOut](t, inbox).Messages + if len(msgs) == 0 { + t.Fatal("codex inbox empty; expected the @codex mention") + } + joined := strings.Join(msgs, "\n") + if !strings.Contains(joined, "review please") { + t.Fatalf("inbox missing mention: %v", msgs) + } +} + +func TestWhoami(t *testing.T) { + cs := connect(t, "claude-code") + res := call(t, cs, "whoami", struct{}{}) + who := structured[whoamiOut](t, res) + if who.Agent != "claude-code" { + t.Fatalf("whoami agent = %q, want claude-code", who.Agent) + } +} + +func TestKB(t *testing.T) { + cs := connect(t, "claude-code") + add := call(t, cs, "kb_add", kbAddIn{Type: "decision", Title: "Use streaming", Body: "lower memory", Tags: "perf,csv"}) + if structured[kbAddOut](t, add).ID == "" { + t.Fatal("kb_add returned empty id") + } + got := call(t, cs, "kb_search", kbSearchIn{Tags: "perf"}) + if len(structured[kbSearchOut](t, got).Entries) == 0 { + t.Fatal("kb_search found nothing for tag perf") + } +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/hatch/internal/mcpserver/types.go b/hatch/internal/mcpserver/types.go new file mode 100644 index 0000000..6de6dd4 --- /dev/null +++ b/hatch/internal/mcpserver/types.go @@ -0,0 +1,76 @@ +package mcpserver + +// Tool input/output schemas. The SDK generates JSON Schema from these structs; +// json tags are the wire names agents see. + +type openIn struct { + Channel string `json:"channel,omitempty" jsonschema:"channel/topic id (e.g. #design, T-12); empty = derive from title"` + Title string `json:"title,omitempty" jsonschema:"short task/topic title"` + Body string `json:"body" jsonschema:"first message; use @name to tag teammates"` + To string `json:"to,omitempty" jsonschema:"comma-separated recipients: agent/role/#channel/*"` +} + +type postIn struct { + Channel string `json:"channel" jsonschema:"channel/topic id to post into"` + Body string `json:"body" jsonschema:"message body; use @name to tag teammates"` + To string `json:"to,omitempty" jsonschema:"comma-separated recipients/mentions"` + ReplyTo string `json:"reply_to,omitempty" jsonschema:"root message id to thread under"` + Type string `json:"type,omitempty" jsonschema:"msg|ask|reply|decision (default msg)"` +} + +type postOut struct { + Channel string `json:"channel"` + MessageID string `json:"message_id"` +} + +type channelIn struct { + Channel string `json:"channel" jsonschema:"channel/topic id to read"` +} + +type inboxIn struct { + Mark bool `json:"mark,omitempty" jsonschema:"true = advance read cursor after reading"` +} + +type searchIn struct { + Query string `json:"query,omitempty"` + Channel string `json:"channel,omitempty"` + From string `json:"from,omitempty"` + Type string `json:"type,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type kbAddIn struct { + Type string `json:"type,omitempty" jsonschema:"decision|domain|learning (default learning)"` + Title string `json:"title"` + Body string `json:"body"` + Tags string `json:"tags,omitempty" jsonschema:"comma-separated tags"` +} + +type kbSearchIn struct { + Tags string `json:"tags,omitempty" jsonschema:"comma-separated tags (empty = all)"` +} + +type whoamiOut struct { + Agent string `json:"agent"` + Roles []string `json:"roles"` +} + +type textOut struct { + Text string `json:"text"` +} + +type messagesOut struct { + Messages []string `json:"messages"` +} + +type channelsOut struct { + Channels []string `json:"channels"` +} + +type kbAddOut struct { + ID string `json:"id"` +} + +type kbSearchOut struct { + Entries []string `json:"entries"` +} diff --git a/hatch/internal/mdfront/mdfront.go b/hatch/internal/mdfront/mdfront.go new file mode 100644 index 0000000..ccb978f --- /dev/null +++ b/hatch/internal/mdfront/mdfront.go @@ -0,0 +1,90 @@ +// Package mdfront parses and serializes Markdown files that carry a YAML +// frontmatter block delimited by `---` lines, as used by tickets, KB entries +// and role files throughout a .hatch/ workspace. +package mdfront + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +const fence = "---" + +// Doc is a parsed frontmatter document: the raw YAML node plus the Markdown +// body that follows it. +type Doc struct { + Meta yaml.Node // frontmatter as a YAML node (zero value if none) + Body string // everything after the closing fence +} + +// Parse splits raw bytes into frontmatter and body. A document without a +// leading `---` fence is treated as all-body with empty Meta. +func Parse(raw []byte) (*Doc, error) { + s := string(bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))) + if !strings.HasPrefix(s, fence+"\n") && s != fence { + return &Doc{Body: s}, nil + } + rest := strings.TrimPrefix(s, fence+"\n") + end := strings.Index(rest, "\n"+fence) + if end < 0 { + return nil, fmt.Errorf("frontmatter opened with %q but never closed", fence) + } + front := rest[:end] + body := rest[end+len("\n"+fence):] + // Drop the rest of the fence line and any blank lines Encode inserts for + // readability, so Decode(Encode(v, body)) == body. + if nl := strings.IndexByte(body, '\n'); nl >= 0 { + body = body[nl+1:] + } else { + body = "" + } + body = strings.TrimLeft(body, "\n") + + d := &Doc{Body: body} + if strings.TrimSpace(front) != "" { + if err := yaml.Unmarshal([]byte(front), &d.Meta); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + } + return d, nil +} + +// Decode parses raw bytes and unmarshals the frontmatter into v. +func Decode(raw []byte, v any) (body string, err error) { + d, err := Parse(raw) + if err != nil { + return "", err + } + if d.Meta.Kind != 0 { + if err := d.Meta.Decode(v); err != nil { + return "", fmt.Errorf("decode frontmatter: %w", err) + } + } + return d.Body, nil +} + +// Encode renders frontmatter v plus a Markdown body into a single document. +func Encode(v any, body string) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(fence + "\n") + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(v); err != nil { + return nil, fmt.Errorf("encode frontmatter: %w", err) + } + _ = enc.Close() + buf.WriteString(fence + "\n") + if body != "" { + if !strings.HasPrefix(body, "\n") { + buf.WriteString("\n") + } + buf.WriteString(body) + if !strings.HasSuffix(body, "\n") { + buf.WriteString("\n") + } + } + return buf.Bytes(), nil +} diff --git a/hatch/internal/mdfront/mdfront_test.go b/hatch/internal/mdfront/mdfront_test.go new file mode 100644 index 0000000..68011f8 --- /dev/null +++ b/hatch/internal/mdfront/mdfront_test.go @@ -0,0 +1,46 @@ +package mdfront + +import "testing" + +type meta struct { + ID string `yaml:"id"` + Tags []string `yaml:"tags,omitempty"` +} + +func TestRoundTrip(t *testing.T) { + m := meta{ID: "T-1", Tags: []string{"a", "b"}} + raw, err := Encode(m, "## Body\nhello\n") + if err != nil { + t.Fatal(err) + } + var got meta + body, err := Decode(raw, &got) + if err != nil { + t.Fatal(err) + } + if got.ID != "T-1" || len(got.Tags) != 2 { + t.Fatalf("meta round-trip failed: %+v", got) + } + if body != "## Body\nhello\n" { + t.Fatalf("body mismatch: %q", body) + } +} + +func TestNoFrontmatter(t *testing.T) { + d, err := Parse([]byte("just body\n")) + if err != nil { + t.Fatal(err) + } + if d.Meta.Kind != 0 { + t.Fatal("expected empty meta") + } + if d.Body != "just body\n" { + t.Fatalf("body: %q", d.Body) + } +} + +func TestUnterminated(t *testing.T) { + if _, err := Parse([]byte("---\nid: x\nno closing fence\n")); err == nil { + t.Fatal("expected error for unterminated frontmatter") + } +} diff --git a/hatch/internal/metrics/metrics.go b/hatch/internal/metrics/metrics.go new file mode 100644 index 0000000..8e66d71 --- /dev/null +++ b/hatch/internal/metrics/metrics.go @@ -0,0 +1,98 @@ +//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 new file mode 100644 index 0000000..00973a1 --- /dev/null +++ b/hatch/internal/metrics/metrics_test.go @@ -0,0 +1,36 @@ +//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/kb.go b/hatch/internal/model/kb.go new file mode 100644 index 0000000..a14f554 --- /dev/null +++ b/hatch/internal/model/kb.go @@ -0,0 +1,36 @@ +package model + +// KB entry types (docs/09-knowledge-base.md). +const ( + KBDecision = "decision" + KBDomain = "domain" + KBLearning = "learning" +) + +// KBEntry is a unit of shared knowledge: a short Markdown file with frontmatter +// that every agent can read and write. +type KBEntry struct { + ID string `yaml:"id"` + Type string `yaml:"type"` + Title string `yaml:"title"` + Tags []string `yaml:"tags,omitempty"` + Related []string `yaml:"related,omitempty"` + Author string `yaml:"author,omitempty"` + Created string `yaml:"created,omitempty"` + Status string `yaml:"status,omitempty"` // for decisions: proposed|accepted|superseded + + Body string `yaml:"-"` + Path string `yaml:"-"` // path relative to kb/, set by the store +} + +// Subdir returns the kb/ subdirectory an entry type lives in. +func KBSubdir(typ string) string { + switch typ { + case KBDecision: + return "decisions" + case KBDomain: + return "domain" + default: + return "learnings" + } +} diff --git a/hatch/internal/model/ledger.go b/hatch/internal/model/ledger.go new file mode 100644 index 0000000..e72ca31 --- /dev/null +++ b/hatch/internal/model/ledger.go @@ -0,0 +1,34 @@ +package model + +// Ledger action enum (spec/ledger.schema.md). +const ( + ActClaim = "claim" + ActStart = "start" + ActProgress = "progress" + ActHandoff = "handoff" + ActReview = "review" + ActDone = "done" + ActBlock = "block" + ActUnblock = "unblock" + ActRevoke = "revoke" + ActNote = "note" + ActGate = "gate" + ActEscalate = "escalate" +) + +// Entry is a single append-only ledger record answering who/what/when/why/where. +type Entry struct { + TS string // ISO-8601 with offset + Agent string // WHO ("human:<name>" allowed) + Ticket string // WHERE ("-" for system events) + Action string // WHAT + From string // lane change "a/ → b/" + Why string // WHY (required) + Result string // review/gate result + ToRole string // handoff target role + Handoff string // handoff context (required when Action=handoff) + Branch string + Note string + CostUSD float64 // cost of this run/action (tracked) + Tokens int // tokens consumed +} diff --git a/hatch/internal/model/message.go b/hatch/internal/model/message.go new file mode 100644 index 0000000..532394b --- /dev/null +++ b/hatch/internal/model/message.go @@ -0,0 +1,32 @@ +package model + +// Message is one turn in a channel conversation (the communication domain +// entity). A reply (InReplyTo set) forms a thread within a channel, Slack-style. +type Message struct { + ID string + Channel string // channel / DM / conversation id. "#design", "dm-a-b", "T-123" + TS string + From string + To []string // agent ids, role ids, "#channel", or "*"/"all" + Type string + InReplyTo string // root message id when replying inside a thread + Body string +} + +// Message types. +const ( + MsgText = "msg" // a statement / DM / mention + MsgAsk = "ask" // a question expecting a reply + MsgReply = "reply" // a reply to an ask + MsgDecision = "decision" // a recorded decision / consensus +) + +// SearchOpts filters a bus query (token-aware recall). Empty fields are ignored. +type SearchOpts struct { + Query string // case-insensitive token match over body + sender + Channel string // restrict to one channel + From string // restrict to a sender + Type string // restrict to a message type + Channels []string // restrict to a set of channels (e.g. an agent's subscriptions) + Limit int // max results, newest first (0 ⇒ default) +} diff --git a/hatch/internal/model/registry.go b/hatch/internal/model/registry.go new file mode 100644 index 0000000..e799d17 --- /dev/null +++ b/hatch/internal/model/registry.go @@ -0,0 +1,106 @@ +package model + +// Agent is an execution entity (a coding-agent CLI) the team can assign work +// to. Capabilities and the adapter that drives it are declared here. +type Agent struct { + ID string `yaml:"id"` + Kind string `yaml:"kind"` // adapter key: claude | codex | agy | kiro | mock | manual + Roles []string `yaml:"roles"` // role ids this agent may hold + Cmd string `yaml:"cmd,omitempty"` // executable name (defaults per kind) + Model string `yaml:"model,omitempty"` // model override passed to the adapter + Surfaces []string `yaml:"surfaces,omitempty"` // compile targets this agent reads (claude, codex, ...) + WIP int `yaml:"wip,omitempty"` // max concurrent tickets (0 = unlimited) + Sandbox string `yaml:"sandbox,omitempty"` // capability hint for orchestrator (e.g. workspace-write) + Approval string `yaml:"approval,omitempty"` // approval/permission mode hint + // AuthCheck is a non-mutating command (argv) that exits 0 when the agent CLI + // is authenticated, e.g. "login status". `hatch doctor` runs it instead of + // inspecting credential files. Overrides the per-kind default. + AuthCheck []string `yaml:"auth_check,omitempty"` + + BudgetUSD float64 `yaml:"budget_usd,omitempty"` // "salary": cost ceiling per cycle (tracked, not enforced) + RatePerMTok float64 `yaml:"rate_per_mtok,omitempty"` // USD per 1M tokens, for cost estimate when provider gives only tokens +} + +// Authority captures a role's delegation-of-authority limits (docs/14). +type Authority struct { + CanApprove bool `yaml:"can_approve,omitempty"` + BudgetAuthorityUSD float64 `yaml:"budget_authority_usd,omitempty"` + DecisionScope []string `yaml:"decision_scope,omitempty"` +} + +// Role is a bundle of responsibilities + boundaries + the L1 context to load. +// The prose lives in roles/<id>.md; this is the structured binding metadata. +type Role struct { + ID string `yaml:"id"` + Title string `yaml:"title,omitempty"` + File string `yaml:"file,omitempty"` // roles/<id>.md (defaulted) + ContextRefs []string `yaml:"context_refs,omitempty"` // L1 SSOT paths compiled in + ReportsTo string `yaml:"reports_to,omitempty"` // parent role in the org chart + Authority *Authority `yaml:"authority,omitempty"` +} + +// Policy captures team-wide governance toggles enforced at gates. +type Policy struct { + NoSelfReview bool `yaml:"no_self_review"` + HumanMerge bool `yaml:"human_merge"` + ProtectGlobs []string `yaml:"protect,omitempty"` // paths agents may not touch + EscalateTo string `yaml:"escalate_to,omitempty"` // role/agent to escalate to (default conductor) + TeamBudgetUSD float64 `yaml:"team_budget_usd,omitempty"` // team cost ceiling per cycle (tracked, not enforced) +} + +// WorkflowRef points the registry at the per-project workflow definition. +type WorkflowRef struct { + Ref string `yaml:"ref,omitempty"` +} + +// KBConfig configures the Knowledge Base backend (see docs/15). +type KBConfig struct { + Mode string `yaml:"mode,omitempty"` // native | obsidian + Vault string `yaml:"vault,omitempty"` // path (rel to repo) or "" = .hatch/kb + Wikilinks bool `yaml:"wikilinks,omitempty"` // render links as [[wikilinks]] +} + +// Registry mirrors spec/registry.schema.md: the team roster and bindings. +type Registry struct { + Version int `yaml:"version"` + Project string `yaml:"project,omitempty"` + Roles []Role `yaml:"roles"` + Agents []Agent `yaml:"agents"` + Policy Policy `yaml:"policy"` + Workflow WorkflowRef `yaml:"workflow,omitempty"` + KB KBConfig `yaml:"kb,omitempty"` +} + +// AgentByID returns the agent with the given id, if present. +func (r *Registry) AgentByID(id string) (Agent, bool) { + for _, a := range r.Agents { + if a.ID == id { + return a, true + } + } + return Agent{}, false +} + +// RoleByID returns the role with the given id, if present. +func (r *Registry) RoleByID(id string) (Role, bool) { + for _, role := range r.Roles { + if role.ID == id { + return role, true + } + } + return Role{}, false +} + +// AgentsForRole lists agents eligible to hold a role. +func (r *Registry) AgentsForRole(role string) []Agent { + var out []Agent + for _, a := range r.Agents { + for _, rl := range a.Roles { + if rl == role { + out = append(out, a) + break + } + } + } + return out +} diff --git a/hatch/internal/model/ticket.go b/hatch/internal/model/ticket.go new file mode 100644 index 0000000..772bd78 --- /dev/null +++ b/hatch/internal/model/ticket.go @@ -0,0 +1,63 @@ +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/workflow.go b/hatch/internal/model/workflow.go new file mode 100644 index 0000000..18b9055 --- /dev/null +++ b/hatch/internal/model/workflow.go @@ -0,0 +1,99 @@ +package model + +// Gate kinds understood by the workflow engine. +const ( + GateCommand = "command" // run a shell command, exit 0 = pass + GateChecklist = "checklist" // a DoD checklist referenced by file + GateRequired = "required-field" // a ticket frontmatter field must be set + GatePolicy = "policy" // a registry policy toggle + GateHuman = "human-gate" // stop and wait for a human +) + +// Gate is a condition checked when a transition declares it. +type Gate struct { + Type string `yaml:"type"` + Run string `yaml:"run,omitempty"` // command gates + Ref string `yaml:"ref,omitempty"` // checklist/policy gates + Field string `yaml:"field,omitempty"` // required-field gates +} + +// Lane is a board column; it maps to a directory under board/. +type Lane struct { + ID string `yaml:"id"` + WIPLimit int `yaml:"wip-limit,omitempty"` + Side bool `yaml:"side,omitempty"` // off the main flow (e.g. blocked) +} + +// Transition is the only thing that authorises a lane change. +type Transition struct { + From string `yaml:"from"` // lane id or "*" + To string `yaml:"to"` // lane id + By []string `yaml:"by"` // role ids allowed, or ["*"] + Action string `yaml:"action,omitempty"` // ledger action recorded + Gates []string `yaml:"gates,omitempty"` // gate names that must pass +} + +// Ceremony is a recurring coordination event. +type Ceremony struct { + By string `yaml:"by"` + Trigger string `yaml:"trigger,omitempty"` + Actions []string `yaml:"actions,omitempty"` +} + +// SpecConfig configures the optional spec-driven (PRD→Design→Tasks) flow. +type SpecConfig struct { + RequiredFor []string `yaml:"required-for,omitempty"` + Artifacts []string `yaml:"artifacts,omitempty"` + Gates map[string]string `yaml:"gates,omitempty"` +} + +// Workflow mirrors spec/workflow.schema.md: a per-project, editable process. +type Workflow struct { + Version int `yaml:"version"` + Template string `yaml:"template,omitempty"` + Lanes []Lane `yaml:"lanes"` + Transitions []Transition `yaml:"transitions"` + Gates map[string]Gate `yaml:"gates,omitempty"` + Ceremonies map[string]Ceremony `yaml:"ceremonies,omitempty"` + Spec *SpecConfig `yaml:"spec,omitempty"` +} + +// LaneIDs returns lane ids in declaration order. +func (w *Workflow) LaneIDs() []string { + ids := make([]string, len(w.Lanes)) + for i, l := range w.Lanes { + ids[i] = l.ID + } + return ids +} + +// HasLane reports whether a lane id is defined. +func (w *Workflow) HasLane(id string) bool { + for _, l := range w.Lanes { + if l.ID == id { + return true + } + } + return false +} + +// LaneByID returns the lane with the given id. +func (w *Workflow) LaneByID(id string) (Lane, bool) { + for _, l := range w.Lanes { + if l.ID == id { + return l, true + } + } + return Lane{}, false +} + +// FindTransition returns the transition matching a from→to move, honouring the +// "*" wildcard on From. +func (w *Workflow) FindTransition(from, to string) (Transition, bool) { + for _, t := range w.Transitions { + if t.To == to && (t.From == from || t.From == "*") { + return t, true + } + } + return Transition{}, false +} diff --git a/hatch/internal/mux/mux.go b/hatch/internal/mux/mux.go new file mode 100644 index 0000000..60d8822 --- /dev/null +++ b/hatch/internal/mux/mux.go @@ -0,0 +1,71 @@ +//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 new file mode 100644 index 0000000..b8f9660 --- /dev/null +++ b/hatch/internal/mux/mux_test.go @@ -0,0 +1,32 @@ +//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 new file mode 100644 index 0000000..bee002d --- /dev/null +++ b/hatch/internal/oncall/oncall.go @@ -0,0 +1,71 @@ +//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 new file mode 100644 index 0000000..26aed3a --- /dev/null +++ b/hatch/internal/oncall/oncall_test.go @@ -0,0 +1,29 @@ +//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 new file mode 100644 index 0000000..9e14dd7 --- /dev/null +++ b/hatch/internal/orchestrator/adapter.go @@ -0,0 +1,77 @@ +//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 new file mode 100644 index 0000000..e8ed88f --- /dev/null +++ b/hatch/internal/orchestrator/adapter_test.go @@ -0,0 +1,73 @@ +//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 new file mode 100644 index 0000000..b84faf9 --- /dev/null +++ b/hatch/internal/orchestrator/adapters.go @@ -0,0 +1,104 @@ +//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 new file mode 100644 index 0000000..e02bc24 --- /dev/null +++ b/hatch/internal/orchestrator/comm_test.go @@ -0,0 +1,68 @@ +//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 new file mode 100644 index 0000000..0c054cd --- /dev/null +++ b/hatch/internal/orchestrator/e2e_test.go @@ -0,0 +1,60 @@ +//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 new file mode 100644 index 0000000..c4d0192 --- /dev/null +++ b/hatch/internal/orchestrator/prompt.go @@ -0,0 +1,123 @@ +//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 new file mode 100644 index 0000000..6d7f1ef --- /dev/null +++ b/hatch/internal/orchestrator/run.go @@ -0,0 +1,182 @@ +//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 new file mode 100644 index 0000000..67e0460 --- /dev/null +++ b/hatch/internal/orchestrator/transcript.go @@ -0,0 +1,32 @@ +//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 new file mode 100644 index 0000000..9e0ad62 --- /dev/null +++ b/hatch/internal/orchestrator/usage.go @@ -0,0 +1,32 @@ +//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 new file mode 100644 index 0000000..0721a61 --- /dev/null +++ b/hatch/internal/orchestrator/usage_test.go @@ -0,0 +1,20 @@ +//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 new file mode 100644 index 0000000..ecda3b0 --- /dev/null +++ b/hatch/internal/orchestrator/worktree.go @@ -0,0 +1,58 @@ +//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 new file mode 100644 index 0000000..47be900 --- /dev/null +++ b/hatch/internal/paths/paths.go @@ -0,0 +1,146 @@ +// Package paths defines the on-disk layout of a .hatch/ workspace and helpers +// to locate it. The filesystem IS the database, so these paths are the schema. +package paths + +import ( + "errors" + "os" + "path/filepath" +) + +// Dir is the workspace directory name placed at a repository root. +const Dir = ".hatch" + +// Layout names within a .hatch/ directory. +const ( + CharterFile = "charter.md" + RegistryFile = "registry.yaml" + WorkflowFile = "workflow.yaml" + PresenceFile = "presence.json" + OncallFile = "oncall.json" + + RolesDir = "roles" + ContextDir = "context" + KBDir = "kb" + BoardDir = "board" + LedgerDir = "ledger" + ProtocolDir = "protocol" + CompiledDir = "compiled" + + ManifestFile = "compiled/.manifest.json" + KBIndexFile = "kb/index.md" + KBMetaFile = "kb/.meta.json" +) + +// Layout resolves absolute paths inside a single workspace root. +type Layout struct{ Root string } // Root is the .hatch directory itself. + +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) 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") } + +// SafeSegment sanitizes s for use as a single path segment, preventing path +// traversal: only [A-Za-z0-9._-] survive, the rest become '-', and the +// traversal tokens "", ".", ".." collapse to "_". +func SafeSegment(s string) string { + b := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b = append(b, r) + default: + b = append(b, '-') + } + } + out := string(b) + if out == "" || out == "." || out == ".." { + return "_" + } + return out +} + +func (l Layout) Runs(ticket string) string { + if ticket == "" || ticket == "-" { + ticket = "system" + } + return l.path("runs", SafeSegment(ticket)) +} + +// RepoRoot is the directory that contains the .hatch directory. +func (l Layout) RepoRoot() string { return filepath.Dir(l.Root) } + +// ErrNotFound indicates no .hatch workspace was located. +var ErrNotFound = errors.New("no .hatch workspace found (run `hatch init`)") + +// GlobalRoot is the user-level .hatch directory used as the default when no +// local .hatch overrides it: $HATCH_HOME if set (taken as the .hatch path +// directly), else ~/.hatch. Returns "" if the home dir can't be resolved. +func GlobalRoot() string { + if v := os.Getenv("HATCH_HOME"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, Dir) +} + +// FindLocal walks up from start looking for a .hatch directory and returns its +// Layout. It stops at the filesystem root. It does NOT fall back to the global +// workspace. +func FindLocal(start string) (Layout, error) { + dir, err := filepath.Abs(start) + if err != nil { + return Layout{}, err + } + for { + cand := filepath.Join(dir, Dir) + if fi, err := os.Stat(cand); err == nil && fi.IsDir() { + return Layout{Root: cand}, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return Layout{}, ErrNotFound + } + dir = parent + } +} + +// Find resolves the workspace the way commands should: a local .hatch (nearest +// ancestor of start) overrides the global ~/.hatch. Returns the local one if +// present, else the global one if it exists, else ErrNotFound. +func Find(start string) (Layout, error) { + if l, err := FindLocal(start); err == nil { + return l, nil + } + if g := GlobalRoot(); g != "" { + if fi, err := os.Stat(g); err == nil && fi.IsDir() { + return Layout{Root: g}, nil + } + } + return Layout{}, ErrNotFound +} + +// At returns a Layout rooted at <dir>/.hatch without requiring it to exist. +func At(dir string) Layout { + return Layout{Root: filepath.Join(dir, Dir)} +} diff --git a/hatch/internal/paths/paths_test.go b/hatch/internal/paths/paths_test.go new file mode 100644 index 0000000..9555e41 --- /dev/null +++ b/hatch/internal/paths/paths_test.go @@ -0,0 +1,73 @@ +package paths + +import ( + "os" + "strings" + "testing" +) + +func TestSafeSegmentBlocksTraversal(t *testing.T) { + cases := map[string]string{ + "T-001": "T-001", + "../../etc": "..-..-etc", // slashes neutralized + "..": "_", + ".": "_", + "a/b\\c": "a-b-c", + "x;rm -rf /": "x-rm--rf--", + } + for in, want := range cases { + if got := SafeSegment(in); got != want { + t.Errorf("SafeSegment(%q) = %q, want %q", in, got, want) + } + if strings.ContainsAny(SafeSegment(in), `/\`) { + t.Errorf("SafeSegment(%q) still has a separator", in) + } + } +} + +func TestRunsStaysInsideWorkspace(t *testing.T) { + l := At("/repo") + p := l.Runs("../../escape") + if !strings.HasPrefix(p, "/repo/.hatch/runs/") { + t.Fatalf("Runs escaped workspace: %s", p) + } +} + +func TestFindLocalOverridesGlobal(t *testing.T) { + root := t.TempDir() + global := root + "/home/.hatch" + repo := root + "/repo" + local := repo + "/.hatch" + for _, d := range []string{global, local, repo + "/sub"} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("HATCH_HOME", global) + + // From inside the repo (even a subdir), the local .hatch wins. + got, err := Find(repo + "/sub") + if err != nil { + t.Fatalf("Find: %v", err) + } + if got.Root != local { + t.Errorf("Find resolved %q, want local %q", got.Root, local) + } + + // Outside any local workspace, it falls back to the global one. + got, err = Find(root + "/elsewhere") + if err != nil { + t.Fatalf("Find global: %v", err) + } + if got.Root != global { + t.Errorf("Find resolved %q, want global %q", got.Root, global) + } +} + +func TestFindNoWorkspace(t *testing.T) { + root := t.TempDir() + t.Setenv("HATCH_HOME", root+"/nonexistent/.hatch") + if _, err := Find(root); err != ErrNotFound { + t.Errorf("Find = %v, want ErrNotFound", err) + } +} diff --git a/hatch/internal/port/port.go b/hatch/internal/port/port.go new file mode 100644 index 0000000..b925ff7 --- /dev/null +++ b/hatch/internal/port/port.go @@ -0,0 +1,43 @@ +// Package port declares the interfaces (ports) the use-case layer depends on. +// Infrastructure packages (store, bus, oncall, …) provide adapters that satisfy +// them; the CLI/TUI composition root wires concrete adapters into use cases. +// Keeping these here makes the dependency-inversion boundary explicit and lets +// the core be tested with fakes. See ARCHITECTURE.md. +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 + Recent(days int) ([]model.Entry, error) + Entries() ([]model.Entry, error) +} + +// Bus is the communication port the use-case layer needs: post a message, +// build a per-agent catch-up (inbox + query-scoped recall) as formatted lines, +// advance a read cursor, and search the conversation history. +type Bus interface { + Notify(channel, from string, to []string, body string) error + CatchUp(agent string, roles []string, query string, limit int) (inbox []string, recall []string) + MarkRead(agent string) error + Search(o model.SearchOpts) ([]model.Message, error) +} + +// KB is the knowledge-base port the use-case layer reads from. +type KB interface { + List() ([]model.KBEntry, error) +} + +// OnCall reports who currently holds the pager. +type OnCall interface { + Current() string +} diff --git a/hatch/internal/presence/presence.go b/hatch/internal/presence/presence.go new file mode 100644 index 0000000..f8ff01a --- /dev/null +++ b/hatch/internal/presence/presence.go @@ -0,0 +1,94 @@ +//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 new file mode 100644 index 0000000..47f4979 --- /dev/null +++ b/hatch/internal/presence/presence_test.go @@ -0,0 +1,31 @@ +//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/scaffold/scaffold.go b/hatch/internal/scaffold/scaffold.go new file mode 100644 index 0000000..4e17713 --- /dev/null +++ b/hatch/internal/scaffold/scaffold.go @@ -0,0 +1,103 @@ +// Package scaffold creates a fresh .hatch/ workspace from embedded templates. +package scaffold + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +//go:embed all:templates +var templates embed.FS + +// WorkflowTemplates lists the built-in workflow.yaml variants `hatch init` +// can install (see docs/05-workflow.md). +var WorkflowTemplates = []string{ + "scrum", "kanban", "spec-first", "lite", + "dual-track", "shape-up", "stage-gate", "incident", +} + +// Options configure an init run. +type Options struct { + Dir string // directory to create .hatch under + Workflow string // one of WorkflowTemplates + Force bool // overwrite an existing .hatch +} + +// Init writes a new .hatch workspace and returns its layout. +func Init(opt Options) (paths.Layout, []string, error) { + if opt.Workflow == "" { + opt.Workflow = "scrum" + } + if !validWorkflow(opt.Workflow) { + return paths.Layout{}, nil, fmt.Errorf("unknown workflow template %q (choose: %s)", + opt.Workflow, strings.Join(WorkflowTemplates, ", ")) + } + l := paths.At(opt.Dir) + if _, err := os.Stat(l.Root); err == nil && !opt.Force { + return paths.Layout{}, nil, fmt.Errorf("%s already exists (use --force to overwrite)", l.Root) + } + + var written []string + // Copy the shared base tree (everything except the workflow variants dir). + base, _ := fs.Sub(templates, "templates/base") + err := fs.WalkDir(base, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := base.Open(p) + if err != nil { + return err + } + defer data.Close() + content, err := fs.ReadFile(base, p) + if err != nil { + return err + } + // .keep files create empty directories only. + dest := filepath.Join(l.Root, p) + if filepath.Base(p) == ".keep" { + return os.MkdirAll(filepath.Dir(dest), 0o755) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, content, 0o644); err != nil { + return err + } + written = append(written, dest) + return nil + }) + if err != nil { + return paths.Layout{}, nil, err + } + + // Install the selected workflow.yaml. + wf, err := templates.ReadFile("templates/workflows/" + opt.Workflow + ".yaml") + if err != nil { + return paths.Layout{}, nil, err + } + if err := os.WriteFile(l.Workflow(), wf, 0o644); err != nil { + return paths.Layout{}, nil, err + } + written = append(written, l.Workflow()) + + return l, written, nil +} + +func validWorkflow(name string) bool { + for _, w := range WorkflowTemplates { + if w == name { + return true + } + } + return false +} diff --git a/hatch/internal/scaffold/scaffold_test.go b/hatch/internal/scaffold/scaffold_test.go new file mode 100644 index 0000000..6617f1b --- /dev/null +++ b/hatch/internal/scaffold/scaffold_test.go @@ -0,0 +1,37 @@ +package scaffold_test + +import ( + "testing" + + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/scaffold" +) + +// Every shipped workflow template must scaffold into a workspace that passes +// config validation (lanes/transitions/gates/roles all consistent). +func TestAllWorkflowTemplatesValidate(t *testing.T) { + for _, wf := range scaffold.WorkflowTemplates { + t.Run(wf, func(t *testing.T) { + dir := t.TempDir() + l, _, err := scaffold.Init(scaffold.Options{Dir: dir, Workflow: wf}) + if err != nil { + t.Fatalf("init %s: %v", wf, err) + } + ws, err := config.Load(l) + if err != nil { + t.Fatalf("load %s: %v", wf, err) + } + if probs := ws.Validate(); len(probs) != 0 { + for _, p := range probs { + t.Errorf("%s: %s", wf, p) + } + } + }) + } +} + +func TestUnknownWorkflowRejected(t *testing.T) { + if _, _, err := scaffold.Init(scaffold.Options{Dir: t.TempDir(), Workflow: "nope"}); err == nil { + t.Fatal("expected error for unknown workflow template") + } +} diff --git a/hatch/internal/scaffold/templates/base/board/backlog/.keep b/hatch/internal/scaffold/templates/base/board/backlog/.keep new file mode 100644 index 0000000..6d69f38 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/board/backlog/.keep @@ -0,0 +1 @@ +# Tickets chờ làm. diff --git a/hatch/internal/scaffold/templates/base/board/blocked/.keep b/hatch/internal/scaffold/templates/base/board/blocked/.keep new file mode 100644 index 0000000..79e5821 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/board/blocked/.keep @@ -0,0 +1 @@ +# Tickets bị block. diff --git a/hatch/internal/scaffold/templates/base/board/done/.keep b/hatch/internal/scaffold/templates/base/board/done/.keep new file mode 100644 index 0000000..edd602f --- /dev/null +++ b/hatch/internal/scaffold/templates/base/board/done/.keep @@ -0,0 +1 @@ +# Tickets đã xong. diff --git a/hatch/internal/scaffold/templates/base/board/in-progress/.keep b/hatch/internal/scaffold/templates/base/board/in-progress/.keep new file mode 100644 index 0000000..a0aa09a --- /dev/null +++ b/hatch/internal/scaffold/templates/base/board/in-progress/.keep @@ -0,0 +1 @@ +# Tickets đang làm. diff --git a/hatch/internal/scaffold/templates/base/board/review/.keep b/hatch/internal/scaffold/templates/base/board/review/.keep new file mode 100644 index 0000000..c1fb5e7 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/board/review/.keep @@ -0,0 +1 @@ +# Tickets chờ review. diff --git a/hatch/internal/scaffold/templates/base/charter.md b/hatch/internal/scaffold/templates/base/charter.md new file mode 100644 index 0000000..287da2e --- /dev/null +++ b/hatch/internal/scaffold/templates/base/charter.md @@ -0,0 +1,17 @@ +# Charter (L0 — mission) + +> Đây là tầng L0: ngắn gọn, mọi agent nạp mỗi message. Giữ dưới ~300 token. +> Sửa file này là sửa SSOT — chạy `hatch compile` để đẩy xuống mọi agent. + +## Sản phẩm +<!-- Một câu: xây gì, cho ai, vì sao. --> +TODO: mô tả sản phẩm. + +## Nguyên tắc tối cao +- Chất lượng > tốc độ; không merge khi chưa qua gate. +- Mỗi thay đổi có dấu vết (ledger) và lý do (`why`). +- Không sửa ngoài scope ticket đã khai. + +## Ràng buộc +<!-- Bảo mật, tuân thủ, kỹ thuật bất biến. --> +TODO: ràng buộc. diff --git a/hatch/internal/scaffold/templates/base/compiled/.keep b/hatch/internal/scaffold/templates/base/compiled/.keep new file mode 100644 index 0000000..e3815ec --- /dev/null +++ b/hatch/internal/scaffold/templates/base/compiled/.keep @@ -0,0 +1 @@ +# Output compile + .manifest.json (KHÔNG sửa tay). diff --git a/hatch/internal/scaffold/templates/base/context/product/.keep b/hatch/internal/scaffold/templates/base/context/product/.keep new file mode 100644 index 0000000..11499a7 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/context/product/.keep @@ -0,0 +1 @@ +# PRD, domain, business rules. diff --git a/hatch/internal/scaffold/templates/base/context/shared.md b/hatch/internal/scaffold/templates/base/context/shared.md new file mode 100644 index 0000000..87ccd0e --- /dev/null +++ b/hatch/internal/scaffold/templates/base/context/shared.md @@ -0,0 +1,9 @@ +# Shared context (SSOT) + +Điều mọi vai cần biết. Đây là nguồn canonical — compile xuống agent, không sửa file output. + +## Glossary +TODO: thuật ngữ domain. + +## Quy ước chung +TODO: ngôn ngữ, format, convention dùng chung. diff --git a/hatch/internal/scaffold/templates/base/context/tech/.keep b/hatch/internal/scaffold/templates/base/context/tech/.keep new file mode 100644 index 0000000..f85199e --- /dev/null +++ b/hatch/internal/scaffold/templates/base/context/tech/.keep @@ -0,0 +1 @@ +# Stack, conventions, kiến trúc. diff --git a/hatch/internal/scaffold/templates/base/kb/.meta.json b/hatch/internal/scaffold/templates/base/kb/.meta.json new file mode 100644 index 0000000..54c91ef --- /dev/null +++ b/hatch/internal/scaffold/templates/base/kb/.meta.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": {} +} diff --git a/hatch/internal/scaffold/templates/base/kb/decisions/.keep b/hatch/internal/scaffold/templates/base/kb/decisions/.keep new file mode 100644 index 0000000..07a6731 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/kb/decisions/.keep @@ -0,0 +1 @@ +# ADR — quyết định kiến trúc. diff --git a/hatch/internal/scaffold/templates/base/kb/domain/.keep b/hatch/internal/scaffold/templates/base/kb/domain/.keep new file mode 100644 index 0000000..86e2085 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/kb/domain/.keep @@ -0,0 +1 @@ +# Tri thức nghiệp vụ tích lũy. diff --git a/hatch/internal/scaffold/templates/base/kb/index.md b/hatch/internal/scaffold/templates/base/kb/index.md new file mode 100644 index 0000000..8c33417 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/kb/index.md @@ -0,0 +1,5 @@ +# Knowledge Base — Index + +<!-- Generated/maintained by `hatch kb index`. Tra cứu theo tag rồi mở file. --> + +Chưa có mục nào. Thêm bằng `hatch kb add` (xem docs/09-knowledge-base.md). diff --git a/hatch/internal/scaffold/templates/base/kb/learnings/.keep b/hatch/internal/scaffold/templates/base/kb/learnings/.keep new file mode 100644 index 0000000..7465e7c --- /dev/null +++ b/hatch/internal/scaffold/templates/base/kb/learnings/.keep @@ -0,0 +1 @@ +# Bài học, gotcha, pitfall. diff --git a/hatch/internal/scaffold/templates/base/ledger/.keep b/hatch/internal/scaffold/templates/base/ledger/.keep new file mode 100644 index 0000000..9fad6dc --- /dev/null +++ b/hatch/internal/scaffold/templates/base/ledger/.keep @@ -0,0 +1 @@ +# Ledger append-only: YYYY-MM-DD.md. diff --git a/hatch/internal/scaffold/templates/base/protocol/branching.md b/hatch/internal/scaffold/templates/base/protocol/branching.md new file mode 100644 index 0000000..d3f1855 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/protocol/branching.md @@ -0,0 +1,6 @@ +# Branching + +- **Branch-per-ticket:** `hatch/T-<id>-<slug>`. +- Một chuyển trạng thái = một commit (cùng commit với `git mv` + entry ledger). +- PR cho mỗi ticket; merge chỉ sau human gate. +- Mặc định branch-per-ticket; đổi sang trunk-based bằng cách sửa file này + workflow. diff --git a/hatch/internal/scaffold/templates/base/protocol/claim-lock.md b/hatch/internal/scaffold/templates/base/protocol/claim-lock.md new file mode 100644 index 0000000..abff59b --- /dev/null +++ b/hatch/internal/scaffold/templates/base/protocol/claim-lock.md @@ -0,0 +1,6 @@ +# 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/internal/scaffold/templates/base/protocol/definition-of-done.md b/hatch/internal/scaffold/templates/base/protocol/definition-of-done.md new file mode 100644 index 0000000..f80494f --- /dev/null +++ b/hatch/internal/scaffold/templates/base/protocol/definition-of-done.md @@ -0,0 +1,11 @@ +# Definition of Done (DoD) + +Mặc định cho mọi ticket. Reviewer gác đúng checklist này; ticket có thể thêm DoD riêng. + +- [ ] Code khớp scope ticket, không đụng file ngoài `context_refs` chưa khai. +- [ ] Test liên quan pass. +- [ ] Lint/format sạch. +- [ ] Diff review bởi agent ≠ implementer (no-self-review). +- [ ] Ledger có entry hoàn thành + handoff note. +- [ ] Tri thức đáng giữ đã ghi KB (nếu ticket nhiều tri thức). +- [ ] Branch có PR (human gate cho merge). diff --git a/hatch/internal/scaffold/templates/base/protocol/handoff.md b/hatch/internal/scaffold/templates/base/protocol/handoff.md new file mode 100644 index 0000000..2af8797 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/protocol/handoff.md @@ -0,0 +1,8 @@ +# Handoff + +Bắt buộc khi đổi `assignee` hoặc `role`. Thêm một mục vào "Handoff notes" của ticket: +- **Đã làm gì** — tóm tắt thay đổi chính. +- **Còn gì** — việc chưa xong / chưa cover. +- **Cần gì** — context tối thiểu để người sau không phải đọc lại toàn bộ. + +Mục tiêu: vai sau bắt nhịp mà không cần đọc lại từ đầu (tiết kiệm token). diff --git a/hatch/internal/scaffold/templates/base/registry.yaml b/hatch/internal/scaffold/templates/base/registry.yaml new file mode 100644 index 0000000..0ea6b78 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/registry.yaml @@ -0,0 +1,57 @@ +version: 1 +project: "" # tên project, hiển thị trong file compiled + +# Vai trò — template khởi đầu. Sửa/thêm/bớt tùy project (đây là cấu hình per-project). +# Prose của mỗi vai nằm ở roles/<id>.md (L1). context_refs là SSOT compile kèm. +roles: + - id: conductor + title: Conductor + context_refs: [charter.md] + - id: architect + title: Architect + context_refs: [context/tech, context/product] + - id: implementer + title: Implementer + context_refs: [context/tech] + - id: reviewer + title: Reviewer + context_refs: [context/tech] + - id: tester + title: Tester + context_refs: [context/tech] + - id: tech-writer + title: Tech Writer + context_refs: [context/product] + +# Roster agent — map vai ↔ agent theo điểm mạnh. `kind` chọn compile surface + +# orchestrator adapter (xem docs/10-agent-adapters.md). `surfaces` để override. +agents: + - id: claude-code + kind: claude + roles: [conductor, architect, reviewer] + wip: 2 + - id: codex + kind: codex + roles: [implementer, tester] + sandbox: workspace-write + wip: 2 + - id: kiro + kind: kiro + roles: [architect, implementer] + wip: 1 + - id: agy + kind: agy # Google Antigravity CLI (`agy`, kế nhiệm Gemini CLI). Auth: chạy `agy` để login (OAuth) + roles: [implementer, tech-writer] + wip: 1 + +# Governance toggles enforced ở gate (xem docs/06-governance.md). +policy: + no_self_review: true + human_merge: true + protect: + - ".hatch/charter.md" + - ".hatch/registry.yaml" + +# Quy trình tách riêng — sửa workflow.yaml để đổi lane/transition/gate. +workflow: + ref: workflow.yaml diff --git a/hatch/internal/scaffold/templates/base/roles/architect.md b/hatch/internal/scaffold/templates/base/roles/architect.md new file mode 100644 index 0000000..6ac5c42 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/architect.md @@ -0,0 +1,11 @@ +## Trách nhiệm +- Spec kỹ thuật, design, ADR; đặt ràng buộc kiến trúc. +- Bẻ feature lớn theo spec-driven: requirements → design → tasks. + +## Ranh giới (KHÔNG) +- Bỏ qua review của người khác. +- Quyết định nghiệp vụ thay product. + +## Cách làm +- Quyết định kiến trúc ⇒ ghi ADR vào `kb/decisions/`. +- Ưu tiên giải pháp đơn giản, ít phụ thuộc; nêu trade-off rõ ràng. diff --git a/hatch/internal/scaffold/templates/base/roles/conductor.md b/hatch/internal/scaffold/templates/base/roles/conductor.md new file mode 100644 index 0000000..1823eb2 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/conductor.md @@ -0,0 +1,12 @@ +## 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. + +## Ranh giới (KHÔNG) +- Tự viết code production lớn. +- Tự merge khi chưa qua human gate. + +## 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. diff --git a/hatch/internal/scaffold/templates/base/roles/implementer.md b/hatch/internal/scaffold/templates/base/roles/implementer.md new file mode 100644 index 0000000..49a5ad4 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/implementer.md @@ -0,0 +1,12 @@ +## Trách nhiệm +- Viết code đúng scope ticket, đạt Definition of Done. +- Viết/chỉnh test liên quan; giữ lint sạch. + +## Ranh giới (KHÔNG) +- Đổi scope ticket giữa chừng (cập nhật ticket trước). +- Sửa file ngoài `context_refs` đã khai. + +## Cách làm +- Claim ticket ⇒ đọc `context_refs` + KB liên quan trước khi code. +- Xong ⇒ ghi handoff note + đẩy sang `review/`. +- Gặp gotcha đáng giữ ⇒ ghi `kb/learnings/`. diff --git a/hatch/internal/scaffold/templates/base/roles/reviewer.md b/hatch/internal/scaffold/templates/base/roles/reviewer.md new file mode 100644 index 0000000..ed92c0e --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/reviewer.md @@ -0,0 +1,11 @@ +## Trách nhiệm +- Review diff theo Definition of Done; phê duyệt hoặc trả lại. +- Gác chất lượng: đúng scope, test đủ, không rủi ro bảo mật. + +## Ranh giới (KHÔNG) +- Tự sửa rồi tự duyệt (xung đột lợi ích — `no_self_review`). +- Bỏ qua gate để merge nhanh. + +## Cách làm +- Trả lại (`changes-requested`) kèm lý do cụ thể trong ledger. +- Approve ⇒ ticket sang `done/` sau human merge gate. diff --git a/hatch/internal/scaffold/templates/base/roles/tech-writer.md b/hatch/internal/scaffold/templates/base/roles/tech-writer.md new file mode 100644 index 0000000..0728bd6 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/tech-writer.md @@ -0,0 +1,9 @@ +## Trách nhiệm +- Docs, changelog, runbook đi kèm thay đổi. + +## Ranh giới (KHÔNG) +- Đổi hành vi code. + +## Cách làm +- Bám `context/product`; viết cho người đọc thật, không lặp lại code. +- Cập nhật KB/index khi thêm tri thức tra cứu. diff --git a/hatch/internal/scaffold/templates/base/roles/tester.md b/hatch/internal/scaffold/templates/base/roles/tester.md new file mode 100644 index 0000000..443a3fe --- /dev/null +++ b/hatch/internal/scaffold/templates/base/roles/tester.md @@ -0,0 +1,10 @@ +## Trách nhiệm +- Viết/chạy test, dựng repro, báo cáo kết quả. +- Xác nhận gate test trước khi ticket qua review. + +## Ranh giới (KHÔNG) +- Sửa code production chỉ để test pass. + +## Cách làm +- Cover ca biên (rỗng, unicode, lỗi mạng) theo acceptance của ticket. +- Test fail ⇒ ghi ledger `gate result: failed` + lý do. diff --git a/hatch/internal/scaffold/templates/base/templates/docs/adr.md b/hatch/internal/scaffold/templates/base/templates/docs/adr.md new file mode 100644 index 0000000..115e91e --- /dev/null +++ b/hatch/internal/scaffold/templates/base/templates/docs/adr.md @@ -0,0 +1,19 @@ +--- +doc-type: adr +framework: MADR +required-frontmatter: [id, title, status, date] +required-sections: [Context, Decision, Consequences, Alternatives] +--- +# {{title}} + +## Context +<!-- Vấn đề + ràng buộc dẫn tới quyết định này. --> + +## Decision +<!-- Quyết định cuối cùng. --> + +## Consequences +<!-- Hệ quả tích cực/tiêu cực, đánh đổi. --> + +## Alternatives +<!-- Các phương án đã cân nhắc và lý do loại. --> diff --git a/hatch/internal/scaffold/templates/base/templates/docs/design.md b/hatch/internal/scaffold/templates/base/templates/docs/design.md new file mode 100644 index 0000000..bee2b01 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/templates/docs/design.md @@ -0,0 +1,15 @@ +--- +doc-type: design +framework: design-doc +required-frontmatter: [title, status, date] +required-sections: [Goals, Non-goals, Design, Risks, Testing] +--- +# {{title}} + +## Goals +## Non-goals +## Design +<!-- Kiến trúc, luồng dữ liệu, interface chính. --> +## Risks +## Testing +<!-- Cách kiểm chứng (test, rollout). --> diff --git a/hatch/internal/scaffold/templates/base/templates/docs/postmortem.md b/hatch/internal/scaffold/templates/base/templates/docs/postmortem.md new file mode 100644 index 0000000..ef7abbf --- /dev/null +++ b/hatch/internal/scaffold/templates/base/templates/docs/postmortem.md @@ -0,0 +1,18 @@ +--- +doc-type: postmortem +framework: SRE-blameless +required-frontmatter: [title, date] +required-sections: [Summary, Impact, Timeline, Root cause, Action items, Lessons] +--- +# {{title}} + +## Summary +## Impact +<!-- Ai/cái gì bị ảnh hưởng, mức độ, thời lượng. --> +## Timeline +<!-- Mốc thời gian (phát hiện → giảm thiểu → khắc phục). --> +## Root cause +## Action items +<!-- Việc cụ thể, có chủ, có hạn. --> +## Lessons +<!-- Blameless: học gì để không tái diễn. --> diff --git a/hatch/internal/scaffold/templates/base/templates/docs/prd.md b/hatch/internal/scaffold/templates/base/templates/docs/prd.md new file mode 100644 index 0000000..755cbd2 --- /dev/null +++ b/hatch/internal/scaffold/templates/base/templates/docs/prd.md @@ -0,0 +1,15 @@ +--- +doc-type: prd +framework: PRD +required-frontmatter: [title, status, date] +required-sections: [Problem, Users, Requirements, Success metrics, Out of scope] +--- +# {{title}} + +## Problem +## Users +<!-- Đối tượng + nhu cầu. --> +## Requirements +<!-- Yêu cầu chức năng (cân nhắc EARS cho requirement chặt). --> +## Success metrics +## Out of scope diff --git a/hatch/internal/scaffold/templates/base/templates/docs/rfc.md b/hatch/internal/scaffold/templates/base/templates/docs/rfc.md new file mode 100644 index 0000000..e97ba1a --- /dev/null +++ b/hatch/internal/scaffold/templates/base/templates/docs/rfc.md @@ -0,0 +1,14 @@ +--- +doc-type: rfc +framework: RFC +required-frontmatter: [title, status, date] +required-sections: [Summary, Motivation, Proposal, Drawbacks, Alternatives, Open questions] +--- +# {{title}} + +## Summary +## Motivation +## Proposal +## Drawbacks +## Alternatives +## Open questions diff --git a/hatch/internal/scaffold/templates/workflows/dual-track.yaml b/hatch/internal/scaffold/templates/workflows/dual-track.yaml new file mode 100644 index 0000000..8032ad0 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/dual-track.yaml @@ -0,0 +1,68 @@ +version: 1 +template: dual-track +# Dual-track agile (Marty Cagan): một nhánh discovery (làm rõ vấn đề/giải pháp) +# chạy song song nhánh delivery (xây). Ticket "tốt nghiệp" discovery mới vào build. + +lanes: + - id: ideas + - id: discovery + wip-limit: 3 + - id: ready + - id: in-progress + wip-limit: 2 + - id: review + - id: done + - id: blocked + side: true + +transitions: + - from: ideas + to: discovery + by: [conductor, architect] + action: progress + - from: discovery + to: ready + by: [architect, conductor] + action: progress + gates: [discovery-signoff] + - from: ready + to: in-progress + by: [implementer, tester] + action: claim + - from: in-progress + to: review + by: [implementer, tester] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + action: done + gates: [dod-met, no-self-review, human-merge] + - from: review + to: in-progress + by: [reviewer] + action: review + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: in-progress + by: ["*"] + action: unblock + +gates: + discovery-signoff: { type: checklist, ref: protocol/definition-of-done.md } + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } + no-self-review: { type: policy, ref: no_self_review } + human-merge: { type: human-gate } + +ceremonies: + discovery-review: { by: architect, trigger: weekly } + planning: { by: conductor, trigger: manual } + standup-digest: { by: conductor, trigger: daily } + retro: { by: conductor, trigger: end-of-sprint, actions: [compact-ledger, promote-kb] } diff --git a/hatch/internal/scaffold/templates/workflows/incident.yaml b/hatch/internal/scaffold/templates/workflows/incident.yaml new file mode 100644 index 0000000..2d93acf --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/incident.yaml @@ -0,0 +1,55 @@ +version: 1 +template: incident +# Incident response: detected → triage → mitigating → resolved → postmortem. +# Dùng cho on-call/sự cố vận hành. Ghép với `hatch oncall` (người trực) và +# escalation tự động khi kẹt. + +lanes: + - id: detected + - id: triage + wip-limit: 3 + - id: mitigating + wip-limit: 2 + - id: resolved + - id: postmortem + - id: blocked + side: true + +transitions: + - from: detected + to: triage + by: [reviewer, conductor, tester] + action: claim + - from: triage + to: mitigating + by: [implementer, reviewer] + action: progress + - from: mitigating + to: resolved + by: [implementer] + action: progress + gates: [fix-verified] + - from: resolved + to: postmortem + by: [conductor, reviewer] + action: progress + gates: [postmortem-written] + - from: mitigating + to: triage + by: [implementer, reviewer] + action: progress # mitigation failed, re-triage + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: mitigating + by: ["*"] + action: unblock + +gates: + fix-verified: { type: command, run: "make test" } + postmortem-written: { type: required-field, field: handoff } + +ceremonies: + postmortem-review: { by: conductor, trigger: per-incident, actions: [promote-kb] } diff --git a/hatch/internal/scaffold/templates/workflows/kanban.yaml b/hatch/internal/scaffold/templates/workflows/kanban.yaml new file mode 100644 index 0000000..8ee07d3 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/kanban.yaml @@ -0,0 +1,51 @@ +version: 1 +template: kanban + +lanes: + - id: backlog + - id: in-progress + wip-limit: 3 + - id: review + wip-limit: 3 + - id: done + - id: blocked + side: true + +transitions: + - from: backlog + to: in-progress + by: [implementer, tester, architect] + action: claim + - from: in-progress + to: review + by: [implementer, tester] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + action: done + gates: [dod-met, no-self-review, human-merge] + - from: review + to: in-progress + by: [reviewer] + action: review + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: in-progress + by: ["*"] + action: unblock + +gates: + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } + no-self-review: { type: policy, ref: no_self_review } + human-merge: { type: human-gate } + +ceremonies: + standup-digest: { by: conductor, trigger: daily } diff --git a/hatch/internal/scaffold/templates/workflows/lite.yaml b/hatch/internal/scaffold/templates/workflows/lite.yaml new file mode 100644 index 0000000..364f833 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/lite.yaml @@ -0,0 +1,26 @@ +version: 1 +template: lite + +lanes: + - id: todo + - id: doing + wip-limit: 1 + - id: done + +transitions: + - from: todo + to: doing + by: ["*"] + action: claim + - from: doing + to: done + by: ["*"] + action: done + gates: [tests-pass] + - from: doing + to: todo + by: ["*"] + action: progress + +gates: + tests-pass: { type: command, run: "make test" } diff --git a/hatch/internal/scaffold/templates/workflows/scrum.yaml b/hatch/internal/scaffold/templates/workflows/scrum.yaml new file mode 100644 index 0000000..4f83169 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/scrum.yaml @@ -0,0 +1,52 @@ +version: 1 +template: scrum + +lanes: + - id: backlog + - id: in-progress + wip-limit: 2 + - id: review + - id: done + - id: blocked + side: true + +transitions: + - from: backlog + to: in-progress + by: [implementer, tester, architect] + action: claim + - from: in-progress + to: review + by: [implementer, tester] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + action: done + gates: [dod-met, no-self-review, human-merge] + - from: review + to: in-progress + by: [reviewer] + action: review # changes-requested + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: in-progress + by: ["*"] + action: unblock + +gates: + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } + no-self-review: { type: policy, ref: no_self_review } + human-merge: { type: human-gate } + +ceremonies: + planning: { by: conductor, trigger: manual } + standup-digest: { by: conductor, trigger: daily } + retro: { by: conductor, trigger: end-of-sprint, actions: [compact-ledger, promote-kb] } diff --git a/hatch/internal/scaffold/templates/workflows/shape-up.yaml b/hatch/internal/scaffold/templates/workflows/shape-up.yaml new file mode 100644 index 0000000..38cc5d2 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/shape-up.yaml @@ -0,0 +1,62 @@ +version: 1 +template: shape-up +# Shape Up (Basecamp): pitch được "shape" → đặt cược (bet) cho chu kỳ → +# building trong appetite cố định → review → done. Không ước lượng task nhỏ; +# cược cả scope đã shaped. + +lanes: + - id: pitch + - id: bet + - id: building + wip-limit: 2 + - id: review + - id: done + - id: blocked + side: true + +transitions: + - from: pitch + to: bet + by: [architect, conductor] + action: progress + gates: [pitch-shaped] + - from: bet + to: building + by: [implementer, tester] + action: claim + - from: building + to: review + by: [implementer, tester] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + action: done + gates: [dod-met, no-self-review, human-merge] + - from: review + to: building + by: [reviewer] + action: review + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: building + by: ["*"] + action: unblock + +gates: + pitch-shaped: { type: checklist, ref: protocol/definition-of-done.md } + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } + no-self-review: { type: policy, ref: no_self_review } + human-merge: { type: human-gate } + +ceremonies: + betting: { by: conductor, trigger: end-of-cycle } + standup-digest: { by: conductor, trigger: daily } + cooldown: { by: conductor, trigger: end-of-cycle, actions: [compact-ledger, promote-kb] } diff --git a/hatch/internal/scaffold/templates/workflows/spec-first.yaml b/hatch/internal/scaffold/templates/workflows/spec-first.yaml new file mode 100644 index 0000000..f53ac27 --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/spec-first.yaml @@ -0,0 +1,66 @@ +version: 1 +template: spec-first + +lanes: + - id: spec + - id: backlog + - id: in-progress + wip-limit: 2 + - id: review + - id: done + - id: blocked + side: true + +transitions: + - from: spec + to: backlog + by: [architect] + action: progress + gates: [spec-complete] + - from: backlog + to: in-progress + by: [implementer, tester] + action: claim + - from: in-progress + to: review + by: [implementer, tester] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + action: done + gates: [dod-met, no-self-review, human-merge] + - from: review + to: in-progress + by: [reviewer] + action: review + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: in-progress + by: ["*"] + action: unblock + +gates: + spec-complete: { type: checklist, ref: protocol/definition-of-done.md } + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } + no-self-review: { type: policy, ref: no_self_review } + human-merge: { type: human-gate } + +spec: + required-for: [epic] + artifacts: [requirements, design, tasks] + gates: + requirements: human-gate + design: review + +ceremonies: + planning: { by: conductor, trigger: manual } + standup-digest: { by: conductor, trigger: daily } + retro: { by: conductor, trigger: end-of-sprint, actions: [compact-ledger, promote-kb] } diff --git a/hatch/internal/scaffold/templates/workflows/stage-gate.yaml b/hatch/internal/scaffold/templates/workflows/stage-gate.yaml new file mode 100644 index 0000000..288722b --- /dev/null +++ b/hatch/internal/scaffold/templates/workflows/stage-gate.yaml @@ -0,0 +1,76 @@ +version: 1 +template: stage-gate +# Stage-Gate / phased PDLC: requirements → design → build → test → release. +# Mỗi ranh giới pha là một "cổng" (gate) có review/sign-off. Hợp với sản phẩm +# có ràng buộc tuân thủ, audit nặng, hoặc release theo mốc. + +lanes: + - id: requirements + - id: design + - id: build + wip-limit: 2 + - id: test + - id: release + - id: done + - id: blocked + side: true + +transitions: + - from: requirements + to: design + by: [architect, conductor] + action: progress + gates: [requirements-signoff] + - from: design + to: build + by: [architect] + action: progress + gates: [design-review] + - from: build + to: test + by: [implementer] + action: handoff + gates: [tests-pass, lint-clean, handoff-note] + - from: test + to: release + by: [tester] + action: progress + gates: [qa-signoff] + - from: test + to: build + by: [tester] + action: review # QA trả lại + - from: release + to: done + by: [reviewer] + action: done + gates: [human-merge] + - from: "*" + to: blocked + by: ["*"] + action: block + - from: blocked + to: build + by: ["*"] + action: unblock + +gates: + requirements-signoff: { type: human-gate } + design-review: { type: checklist, ref: protocol/definition-of-done.md } + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + handoff-note: { type: required-field, field: handoff } + qa-signoff: { type: checklist, ref: protocol/definition-of-done.md } + human-merge: { type: human-gate } + +ceremonies: + gate-review: { by: conductor, trigger: per-phase } + standup-digest: { by: conductor, trigger: daily } + retro: { by: conductor, trigger: end-of-release, actions: [compact-ledger, promote-kb] } + +spec: + required-for: [epic] + artifacts: [requirements, design, tasks] + gates: + requirements: human-gate + design: review diff --git a/hatch/internal/store/board.go b/hatch/internal/store/board.go new file mode 100644 index 0000000..23a8f78 --- /dev/null +++ b/hatch/internal/store/board.go @@ -0,0 +1,132 @@ +// 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/cost.go b/hatch/internal/store/cost.go new file mode 100644 index 0000000..dbbbb70 --- /dev/null +++ b/hatch/internal/store/cost.go @@ -0,0 +1,73 @@ +package store + +import ( + "os" + "strconv" + "strings" +) + +// CostRecord attributes a cost/token amount to an agent + ticket. +type CostRecord struct { + Agent string + Ticket string + USD float64 + Tokens int +} + +// ScanCosts parses all ledger files for cost_usd/tokens lines, attributing each +// to the agent + ticket from its entry heading. This is the track-only basis +// for `hatch cost` / `hatch budget`. +func (lg *Ledger) ScanCosts() ([]CostRecord, error) { + files, err := lg.Files() + if err != nil { + return nil, err + } + var out []CostRecord + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var cur *CostRecord + flush := func() { + if cur != nil && (cur.USD > 0 || cur.Tokens > 0) { + out = append(out, *cur) + } + cur = nil + } + for _, ln := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") { + if strings.HasPrefix(ln, "## ") { + flush() + // "## <ts> · <agent> · <ticket>" + parts := strings.Split(strings.TrimPrefix(ln, "## "), " · ") + rec := CostRecord{} + if len(parts) >= 2 { + rec.Agent = strings.TrimSpace(parts[1]) + } + if len(parts) >= 3 { + rec.Ticket = strings.TrimSpace(parts[2]) + } + cur = &rec + continue + } + if cur == nil { + continue + } + if v, ok := fieldVal(ln, "- cost_usd:"); ok { + cur.USD, _ = strconv.ParseFloat(v, 64) + } + if v, ok := fieldVal(ln, "- tokens:"); ok { + cur.Tokens, _ = strconv.Atoi(v) + } + } + flush() + } + return out, nil +} + +func fieldVal(line, prefix string) (string, bool) { + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)), true + } + return "", false +} diff --git a/hatch/internal/store/entries.go b/hatch/internal/store/entries.go new file mode 100644 index 0000000..c3513e2 --- /dev/null +++ b/hatch/internal/store/entries.go @@ -0,0 +1,72 @@ +package store + +import ( + "os" + "strconv" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// Entries parses every ledger file into structured entries (chronological). +// Powers metrics (workload/perf) and cost reporting. +func (lg *Ledger) Entries() ([]model.Entry, error) { + files, err := lg.Files() + if err != nil { + return nil, err + } + var out []model.Entry + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var cur *model.Entry + flush := func() { + if cur != nil { + out = append(out, *cur) + } + cur = nil + } + for _, ln := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") { + if strings.HasPrefix(ln, "## ") { + flush() + parts := strings.Split(strings.TrimPrefix(ln, "## "), " · ") + e := model.Entry{} + if len(parts) >= 1 { + e.TS = strings.TrimSpace(parts[0]) + } + if len(parts) >= 2 { + e.Agent = strings.TrimSpace(parts[1]) + } + if len(parts) >= 3 { + e.Ticket = strings.TrimSpace(parts[2]) + } + cur = &e + continue + } + if cur == nil { + continue + } + if v, ok := fieldVal(ln, "- action:"); ok { + cur.Action = v + } else if v, ok := fieldVal(ln, "- from:"); ok { + cur.From = v + } else if v, ok := fieldVal(ln, "- result:"); ok { + cur.Result = v + } else if v, ok := fieldVal(ln, "- why:"); ok { + cur.Why = v + } else if v, ok := fieldVal(ln, "- handoff:"); ok { + cur.Handoff = v + } else if v, ok := fieldVal(ln, "- branch:"); ok { + cur.Branch = v + } else if v, ok := fieldVal(ln, "- cost_usd:"); ok { + cur.CostUSD, _ = strconv.ParseFloat(v, 64) + } else if v, ok := fieldVal(ln, "- tokens:"); ok { + cur.Tokens, _ = strconv.Atoi(v) + } + } + flush() + } + return out, nil +} diff --git a/hatch/internal/store/kb.go b/hatch/internal/store/kb.go new file mode 100644 index 0000000..8d39753 --- /dev/null +++ b/hatch/internal/store/kb.go @@ -0,0 +1,305 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/mdfront" + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// KB reads and writes shared Knowledge Base entries. Root overrides the vault +// location (e.g. an external Obsidian vault); empty = .hatch/kb. +type KB struct { + L paths.Layout + Root string // vault dir override + Wikilinks bool // render links/index as [[wikilinks]] +} + +// NewKB returns a knowledge base bound to a workspace layout (default vault). +func NewKB(l paths.Layout) *KB { return &KB{L: l} } + +// NewKBVault returns a KB rooted at an explicit vault directory. +func NewKBVault(l paths.Layout, root string, wikilinks bool) *KB { + return &KB{L: l, Root: root, Wikilinks: wikilinks} +} + +// dir returns the vault root. +func (k *KB) dir() string { + if k.Root != "" { + return k.Root + } + return k.L.KB() +} + +func (k *KB) indexPath() string { return filepath.Join(k.dir(), "index.md") } + +// List walks the kb/ subdirectories and returns all entries. +func (k *KB) List() ([]model.KBEntry, error) { + root := k.dir() + var out []model.KBEntry + for _, sub := range []string{"decisions", "domain", "learnings"} { + dir := filepath.Join(root, sub) + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + for _, e := range ents { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + p := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var entry model.KBEntry + body, err := mdfront.Decode(raw, &entry) + if err != nil { + return nil, fmt.Errorf("%s: %w", p, err) + } + entry.Body = body + entry.Path = filepath.Join(sub, e.Name()) + out = append(out, entry) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +// Query returns entries matching any of the given tags (case-insensitive). +func (k *KB) Query(tags []string) ([]model.KBEntry, error) { + all, err := k.List() + if err != nil { + return nil, err + } + if len(tags) == 0 { + return all, nil + } + want := map[string]bool{} + for _, t := range tags { + want[strings.ToLower(t)] = true + } + var out []model.KBEntry + for _, e := range all { + for _, t := range e.Tags { + if want[strings.ToLower(t)] { + out = append(out, e) + break + } + } + } + return out, nil +} + +// NextID returns the next sequential id for a KB type: ADR-NNN for decisions, +// KB-NNN otherwise. +func (k *KB) NextID(typ string) string { + prefix := "KB" + if typ == model.KBDecision { + prefix = "ADR" + } + entries, _ := k.List() + max := 0 + for _, e := range entries { + var n int + if _, err := fmt.Sscanf(e.ID, prefix+"-%d", &n); err == nil && n > max { + max = n + } + } + return fmt.Sprintf("%s-%03d", prefix, max+1) +} + +// Add writes a new KB entry to its type subdirectory and returns the path. +func (k *KB) Add(e model.KBEntry) (string, error) { + sub := model.KBSubdir(e.Type) + dir := filepath.Join(k.dir(), sub) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + slug := e.ID + if slug == "" { + slug = slugify(e.Title) + } + name := slug + ".md" + out, err := mdfront.Encode(e, e.Body) + if err != nil { + return "", err + } + p := filepath.Join(dir, name) + if err := os.WriteFile(p, out, 0o644); err != nil { + return "", err + } + return p, nil +} + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + prevDash := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + default: + if !prevDash { + b.WriteRune('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +// RebuildIndex regenerates kb/index.md from current entries for fast lookup. +func (k *KB) RebuildIndex() error { + all, err := k.List() + if err != nil { + return err + } + var b strings.Builder + b.WriteString("# Knowledge Base — Index\n\n") + b.WriteString("<!-- Generated by `hatch kb index`. Tra cứu theo tag rồi mở file. -->\n\n") + byType := map[string][]model.KBEntry{} + for _, e := range all { + byType[e.Type] = append(byType[e.Type], e) + } + for _, typ := range []string{model.KBDecision, model.KBDomain, model.KBLearning} { + entries := byType[typ] + if len(entries) == 0 { + continue + } + fmt.Fprintf(&b, "## %s\n\n", strings.Title(typ)) + for _, e := range entries { + tags := "" + if len(e.Tags) > 0 { + tags = " — `" + strings.Join(e.Tags, "` `") + "`" + } + if k.Wikilinks { + fmt.Fprintf(&b, "- [[%s|%s %s]]%s\n", noteName(e), e.ID, e.Title, tags) + } else { + fmt.Fprintf(&b, "- [%s](%s) %s%s\n", e.ID, e.Path, e.Title, tags) + } + } + b.WriteString("\n") + } + return os.WriteFile(k.indexPath(), []byte(b.String()), 0o644) +} + +// noteName returns an entry's Obsidian note name (filename without extension). +func noteName(e model.KBEntry) string { + return strings.TrimSuffix(filepath.Base(e.Path), ".md") +} + +var wikilinkRe = regexp.MustCompile(`\[\[([^\]|#]+)`) + +// links returns the note names/ids an entry points to, from its Related field +// and any [[wikilinks]] in its body. +func links(e model.KBEntry) []string { + seen := map[string]bool{} + var out []string + add := func(s string) { + s = strings.TrimSpace(s) + if s != "" && !seen[s] { + seen[s] = true + out = append(out, s) + } + } + for _, r := range e.Related { + add(r) + } + for _, m := range wikilinkRe.FindAllStringSubmatch(e.Body, -1) { + add(m[1]) + } + return out +} + +// matches reports whether a link target refers to an entry (by id or note name). +func matches(target string, e model.KBEntry) bool { + return target == e.ID || target == noteName(e) || target == e.Path +} + +// Graph returns, per entry id, the ids of entries it links to (resolved). +func (k *KB) Graph() (map[string][]string, error) { + all, err := k.List() + if err != nil { + return nil, err + } + g := map[string][]string{} + for _, e := range all { + for _, l := range links(e) { + for _, target := range all { + if matches(l, target) && target.ID != e.ID { + g[e.ID] = append(g[e.ID], target.ID) + } + } + } + } + return g, nil +} + +// Backlinks returns ids of entries that link to the given note (id or name). +func (k *KB) Backlinks(note string) ([]string, error) { + all, err := k.List() + if err != nil { + return nil, err + } + var out []string + for _, e := range all { + for _, l := range links(e) { + if l == note || matchesName(l, note, all) { + out = append(out, e.ID) + break + } + } + } + return out, nil +} + +func matchesName(link, note string, all []model.KBEntry) bool { + for _, e := range all { + if matches(note, e) && matches(link, e) { + return true + } + } + return false +} + +// Link adds toID to fromID's Related list and rewrites the note. +func (k *KB) Link(fromID, toID string) error { + all, err := k.List() + if err != nil { + return err + } + for _, e := range all { + if e.ID == fromID { + for _, r := range e.Related { + if r == toID { + return nil // already linked + } + } + e.Related = append(e.Related, toID) + out, err := mdfront.Encode(e, e.Body) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(k.dir(), e.Path), out, 0o644) + } + } + return fmt.Errorf("kb entry %q not found", fromID) +} + +// ObsidianURI builds an obsidian:// URI to open a note in the app. +func (k *KB) ObsidianURI(note string) string { + vault := filepath.Base(k.dir()) + return fmt.Sprintf("obsidian://open?vault=%s&file=%s", vault, note) +} diff --git a/hatch/internal/store/ledger.go b/hatch/internal/store/ledger.go new file mode 100644 index 0000000..23d83f5 --- /dev/null +++ b/hatch/internal/store/ledger.go @@ -0,0 +1,117 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fioenix/overclaud/hatch/internal/model" + "github.com/fioenix/overclaud/hatch/internal/paths" +) + +// ledgerMu serializes ledger appends within a process so concurrent runs +// (parallel watch/tick) don't interleave entry blocks. +var ledgerMu sync.Mutex + +// Ledger appends audit entries to per-day Markdown files. +type Ledger struct{ L paths.Layout } + +// NewLedger returns a ledger bound to a workspace layout. +func NewLedger(l paths.Layout) *Ledger { return &Ledger{L: l} } + +// render formats an entry as the Markdown block defined by spec/ledger.schema.md. +func render(e model.Entry) string { + ticket := e.Ticket + if ticket == "" { + ticket = "-" + } + var b strings.Builder + fmt.Fprintf(&b, "## %s · %s · %s\n", e.TS, e.Agent, ticket) + fmt.Fprintf(&b, "- action: %s\n", e.Action) + if e.From != "" { + fmt.Fprintf(&b, "- from: %s\n", e.From) + } + if e.ToRole != "" { + fmt.Fprintf(&b, "- to-role: %s\n", e.ToRole) + } + if e.Result != "" { + fmt.Fprintf(&b, "- result: %s\n", e.Result) + } + fmt.Fprintf(&b, "- why: %s\n", e.Why) + if e.Branch != "" { + fmt.Fprintf(&b, "- branch: %s\n", e.Branch) + } + if e.CostUSD > 0 { + fmt.Fprintf(&b, "- cost_usd: %.4f\n", e.CostUSD) + } + if e.Tokens > 0 { + fmt.Fprintf(&b, "- tokens: %d\n", e.Tokens) + } + if e.Handoff != "" { + fmt.Fprintf(&b, "- handoff: %s\n", e.Handoff) + } + if e.Note != "" { + fmt.Fprintf(&b, "- note: %s\n", e.Note) + } + return b.String() +} + +// dayFile returns the ledger file path for an entry's timestamp. +func (lg *Ledger) dayFile(ts string) string { + day := time.Now().Format("2006-01-02") + if t, err := time.Parse(time.RFC3339, ts); err == nil { + day = t.Format("2006-01-02") + } + return filepath.Join(lg.L.Ledger(), day+".md") +} + +// Append writes an entry, defaulting its timestamp to now if unset. +func (lg *Ledger) Append(e model.Entry) error { + if e.TS == "" { + e.TS = time.Now().Format(time.RFC3339) + } + if e.Why == "" { + return fmt.Errorf("ledger entry requires a non-empty `why`") + } + if e.Action == model.ActHandoff && e.Handoff == "" { + return fmt.Errorf("handoff entry requires a `handoff` note") + } + ledgerMu.Lock() + defer ledgerMu.Unlock() + if err := os.MkdirAll(lg.L.Ledger(), 0o755); err != nil { + return err + } + path := lg.dayFile(e.TS) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + block := render(e) + if fi, _ := f.Stat(); fi != nil && fi.Size() > 0 { + block = "\n" + block + } + _, err = f.WriteString(block) + return err +} + +// Files lists ledger day-files in chronological order. +func (lg *Ledger) Files() ([]string, error) { + ents, err := os.ReadDir(lg.L.Ledger()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, e := range ents { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + out = append(out, filepath.Join(lg.L.Ledger(), e.Name())) + } + } + return out, nil // ReadDir returns sorted names; date format sorts chronologically +} diff --git a/hatch/internal/store/ledger_read.go b/hatch/internal/store/ledger_read.go new file mode 100644 index 0000000..a0ea89b --- /dev/null +++ b/hatch/internal/store/ledger_read.go @@ -0,0 +1,90 @@ +package store + +import ( + "os" + "strings" + + "github.com/fioenix/overclaud/hatch/internal/model" +) + +// parseLedger parses a ledger day-file back into entries. Heading form: +// "## <ts> · <agent> · <ticket>" followed by "- key: value" lines. +func parseLedger(raw string) []model.Entry { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + var entries []model.Entry + var cur *model.Entry + flush := func() { + if cur != nil { + entries = append(entries, *cur) + } + cur = nil + } + for _, ln := range strings.Split(raw, "\n") { + if strings.HasPrefix(ln, "## ") { + flush() + e := model.Entry{} + parts := strings.SplitN(strings.TrimPrefix(ln, "## "), " · ", 3) + if len(parts) > 0 { + e.TS = strings.TrimSpace(parts[0]) + } + if len(parts) > 1 { + e.Agent = strings.TrimSpace(parts[1]) + } + if len(parts) > 2 { + e.Ticket = strings.TrimSpace(parts[2]) + } + cur = &e + continue + } + if cur == nil || !strings.HasPrefix(ln, "- ") { + continue + } + kv := strings.SplitN(strings.TrimPrefix(ln, "- "), ":", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "action": + cur.Action = val + case "from": + cur.From = val + case "to-role": + cur.ToRole = val + case "result": + cur.Result = val + case "why": + cur.Why = val + case "handoff": + cur.Handoff = val + case "branch": + cur.Branch = val + case "note": + cur.Note = val + } + } + flush() + return entries +} + +// Recent returns ledger entries from the most recent `days` day-files, +// oldest-first. +func (lg *Ledger) Recent(days int) ([]model.Entry, error) { + files, err := lg.Files() + if err != nil { + return nil, err + } + if days > 0 && len(files) > days { + files = files[len(files)-days:] + } + var out []model.Entry + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + out = append(out, parseLedger(string(raw))...) + } + return out, nil +} diff --git a/hatch/internal/store/port_assert.go b/hatch/internal/store/port_assert.go new file mode 100644 index 0000000..29ae4c0 --- /dev/null +++ b/hatch/internal/store/port_assert.go @@ -0,0 +1,11 @@ +package store + +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/board.go b/hatch/internal/tui/board.go new file mode 100644 index 0000000..9f07df9 --- /dev/null +++ b/hatch/internal/tui/board.go @@ -0,0 +1,279 @@ +// Package tui renders read-only dashboards with Bubble Tea. `hatch board` is +// mission control: THREADS (chat channels — each thread is a task) + CHAT (the +// selected thread) + ACTIVITY (the ledger), in one view. It only observes; it +// never drives agents. `hatch chat` (chat.go) is a focused stand-alone chat +// view sharing the same widgets. Agents act through the Hatch MCP server. +package tui + +import ( + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/config" + "github.com/fioenix/overclaud/hatch/internal/store" +) + +type pane int + +const ( + paneThreads pane = iota + paneChat + paneActivity + numPanes +) + +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")) +) + +// threadStat summarises one chat thread (= one task) for the THREADS pane. +type threadStat struct { + name string + count int + last string // HH:MM of the last message +} + +type m struct { + ws *config.Workspace + bus *bus.Bus + ledger *store.Ledger + + threads []threadStat + sel int + focus pane + chat viewport.Model + activity viewport.Model + + w, h int + status string + ready bool +} + +type tickMsg time.Time + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// New returns the read-only mission-control dashboard program. +func New(ws *config.Workspace) *tea.Program { + mm := m{ + ws: ws, + bus: bus.New(ws.Layout), + ledger: store.NewLedger(ws.Layout), + } + return tea.NewProgram(&mm, tea.WithAltScreen()) +} + +func (mm *m) Init() tea.Cmd { return tick() } + +func (mm *m) reload() { + chs, _ := mm.bus.Channels() + sort.Strings(chs) + stats := make([]threadStat, 0, len(chs)) + for _, ch := range chs { + msgs, _ := mm.bus.Messages(ch) + last := "" + if len(msgs) > 0 { + if t, e := time.Parse(time.RFC3339Nano, msgs[len(msgs)-1].TS); e == nil { + last = t.Format("15:04") + } + } + stats = append(stats, threadStat{name: ch, count: len(msgs), last: last}) + } + mm.threads = stats + if mm.sel >= len(stats) { + mm.sel = max(0, len(stats)-1) + } + mm.chat.SetContent(mm.chatFeed()) + mm.activity.SetContent(mm.activityFeed()) +} + +func (mm *m) curChannel() string { + if mm.sel < len(mm.threads) { + return mm.threads[mm.sel].name + } + return "" +} + +func (mm *m) activityFeed() string { + files, _ := mm.ledger.Files() + if len(files) == 0 { + return dim.Render("(ledger trống)") + } + raw, _ := os.ReadFile(files[len(files)-1]) + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) > 200 { + lines = lines[len(lines)-200:] + } + return strings.Join(lines, "\n") +} + +func (mm *m) chatFeed() string { + ch := mm.curChannel() + if ch == "" { + return dim.Render("(chưa có thread nào)") + } + msgs, err := mm.bus.Messages(ch) + if err != nil || len(msgs) == 0 { + return dim.Render("(thread trống)") + } + var b strings.Builder + for _, msg := range msgs { + ts := msg.TS + if t, e := time.Parse(time.RFC3339Nano, msg.TS); e == nil { + ts = t.Format("15:04") + } + head := fmt.Sprintf("%s %s", dim.Render(ts), selSty.Render(msg.From)) + if msg.Type != bus.TypeMsg { + head += " " + laneSty.Render("["+msg.Type+"]") + } + b.WriteString(head + "\n " + strings.ReplaceAll(msg.Body, "\n", "\n ") + "\n") + } + return b.String() +} + +func (mm *m) layout() { + rightW := mm.w - mm.w/2 - 4 + vpH := (mm.h - 6) / 2 + if vpH < 3 { + vpH = 3 + } + mm.chat = viewport.New(rightW, vpH) + mm.activity = viewport.New(rightW, vpH) + mm.ready = true +} + +func (mm *m) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + mm.w, mm.h = msg.Width, msg.Height + mm.layout() + mm.reload() + case tickMsg: + if mm.ready { + mm.reload() + } + return mm, tick() + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return mm, tea.Quit + case "tab": + mm.focus = (mm.focus + 1) % numPanes + case "up", "k": + mm.scrollUp() + case "down", "j": + mm.scrollDown() + case "g": + mm.reload() + } + } + return mm, nil +} + +func (mm *m) scrollUp() { + switch mm.focus { + case paneThreads: + if mm.sel > 0 { + mm.sel-- + mm.chat.SetContent(mm.chatFeed()) + } + case paneChat: + mm.chat.LineUp(1) + case paneActivity: + mm.activity.LineUp(1) + } +} + +func (mm *m) scrollDown() { + switch mm.focus { + case paneThreads: + if mm.sel < len(mm.threads)-1 { + mm.sel++ + mm.chat.SetContent(mm.chatFeed()) + } + case paneChat: + mm.chat.LineDown(1) + case paneActivity: + mm.activity.LineDown(1) + } +} + +func (mm *m) View() string { + if !mm.ready { + return "loading…" + } + project := mm.ws.Registry.Project + if project == "" { + project = "Hatch" + } + header := hdr.Render(project+" — mission control") + " " + + dim.Render(fmt.Sprintf("(read-only · %d threads)", len(mm.threads))) + + // THREADS pane: each chat thread is a task. + var th strings.Builder + if len(mm.threads) == 0 { + th.WriteString(dim.Render("(chưa có thread — agent mở qua Hatch MCP)") + "\n") + } + for i, t := range mm.threads { + line := fmt.Sprintf(" #%-20s %3d %s", trunc(t.name, 20), t.count, t.last) + if i == mm.sel { + line = selSty.Render("▸ #" + trunc(t.name, 20) + fmt.Sprintf(" %d %s", t.count, t.last)) + } + th.WriteString(line + "\n") + } + threadsBox := paneBox(mm.focus == paneThreads, "THREADS (tasks)", th.String(), mm.w/2-2, mm.h-4) + + chatBox := paneBox(mm.focus == paneChat, "CHAT · #"+mm.curChannel(), mm.chat.View(), 0, 0) + actBox := paneBox(mm.focus == paneActivity, "ACTIVITY (ledger)", mm.activity.View(), 0, 0) + right := lipgloss.JoinVertical(lipgloss.Left, chatBox, actBox) + body := lipgloss.JoinHorizontal(lipgloss.Top, threadsBox, right) + + foot := dim.Render("tab pane · ↑/↓ move·scroll · g refresh · q quit") + if mm.status != "" { + foot = selSty.Render(mm.status) + " " + foot + } + return header + "\n" + body + "\n" + foot +} + +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) +} + +func trunc(s string, n int) string { + if len([]rune(s)) <= n { + return s + } + return string([]rune(s)[:n]) + "…" +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/hatch/internal/tui/chat.go b/hatch/internal/tui/chat.go new file mode 100644 index 0000000..e32e03d --- /dev/null +++ b/hatch/internal/tui/chat.go @@ -0,0 +1,169 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/fioenix/overclaud/hatch/internal/bus" + "github.com/fioenix/overclaud/hatch/internal/config" +) + +type chatFocus int + +const ( + focusChannels chatFocus = iota + focusMessages +) + +type chat struct { + ws *config.Workspace + bus *bus.Bus + channels []string + sel int + focus chatFocus + msgs viewport.Model + w, h int + ready bool +} + +// NewChat returns a read-only Slack-style TUI for observing the squad's +// communication bus. Agents post through the Hatch MCP server; this view only +// watches. +func NewChat(ws *config.Workspace) *tea.Program { + c := &chat{ws: ws, bus: bus.New(ws.Layout)} + 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 + if c.sel >= len(chs) { + c.sel = maxi(0, len(chs)-1) + } + c.msgs.SetContent(c.renderMessages()) +} + +func (c *chat) current() string { + if 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ở qua Hatch MCP)") + } + msgs, err := c.bus.Messages(ch) + if err != nil || len(msgs) == 0 { + return dim.Render("(thread trống)") + } + var b strings.Builder + for _, m := range msgs { + ts := m.TS + if t, e := time.Parse(time.RFC3339Nano, m.TS); e == nil { + ts = t.Format("15:04") + } + head := fmt.Sprintf("%s %s", dim.Render(ts), selSty.Render(m.From)) + 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 " + strings.ReplaceAll(m.Body, "\n", "\n ") + "\n\n") + } + return b.String() +} + +func (c *chat) layout() { + mw := c.w - c.w/4 - 4 + c.msgs = viewport.New(mw, c.h-5) + c.ready = true +} + +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() + c.msgs.GotoBottom() + } + return c, tick() + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return c, tea.Quit + case "tab": + c.focus = (c.focus + 1) % 2 + case "up", "k": + if c.focus == focusChannels && c.sel > 0 { + c.sel-- + c.reload() + } else { + c.msgs.LineUp(1) + } + case "down", "j": + if c.focus == focusChannels && c.sel < len(c.channels)-1 { + c.sel++ + c.reload() + } else { + c.msgs.LineDown(1) + } + case "g": + c.reload() + } + } + return c, nil +} + +func (c *chat) View() string { + if !c.ready { + return "loading…" + } + project := c.ws.Registry.Project + if project == "" { + project = "Hatch" + } + header := hdr.Render(project+" — chat") + " " + dim.Render("(read-only)") + + // Channel list. + var cl strings.Builder + for i, ch := range c.channels { + line := " " + ch + if i == c.sel { + line = selSty.Render("▸ " + ch) + } + cl.WriteString(line + "\n") + } + if len(c.channels) == 0 { + cl.WriteString(dim.Render(" (chưa có)")) + } + chanBox := paneBox(c.focus == focusChannels, "CHANNELS", cl.String(), c.w/4, c.h-5) + msgBox := paneBox(c.focus == focusMessages, "#"+c.current(), c.msgs.View(), 0, 0) + body := lipgloss.JoinHorizontal(lipgloss.Top, chanBox, msgBox) + + foot := dim.Render("tab pane · ↑/↓ channel·scroll · g refresh · q quit") + return header + "\n" + body + "\n" + foot +} + +func maxi(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/hatch/internal/validate/board.go b/hatch/internal/validate/board.go new file mode 100644 index 0000000..5b71ba0 --- /dev/null +++ b/hatch/internal/validate/board.go @@ -0,0 +1,90 @@ +// 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 new file mode 100644 index 0000000..1e50b74 --- /dev/null +++ b/hatch/internal/validate/board_test.go @@ -0,0 +1,33 @@ +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/wf/engine.go b/hatch/internal/wf/engine.go new file mode 100644 index 0000000..dfadcff --- /dev/null +++ b/hatch/internal/wf/engine.go @@ -0,0 +1,234 @@ +//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 new file mode 100644 index 0000000..82c413a --- /dev/null +++ b/hatch/internal/wf/engine_test.go @@ -0,0 +1,105 @@ +//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 new file mode 100644 index 0000000..5073638 --- /dev/null +++ b/hatch/internal/wf/escalate.go @@ -0,0 +1,91 @@ +//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 new file mode 100644 index 0000000..8a60387 --- /dev/null +++ b/hatch/internal/wf/escalate_test.go @@ -0,0 +1,71 @@ +//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 new file mode 100644 index 0000000..a0699bf --- /dev/null +++ b/hatch/internal/wf/ports.go @@ -0,0 +1,15 @@ +//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 +} diff --git a/hatch/plugin/.claude-plugin/plugin.json b/hatch/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..7c9f348 --- /dev/null +++ b/hatch/plugin/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "hatch", + "displayName": "Hatch", + "description": "Embedded harness for a coding-agent squad: shared chat (= communication + backlog) and a knowledge base, exposed over MCP. A thread is a task; @mention teammates; recall with search.", + "version": "0.1.0", + "author": { "name": "Finolabs" }, + "homepage": "https://github.com/fioenix/overclaud/tree/main/hatch", + "repository": "https://github.com/fioenix/overclaud", + "license": "MIT", + "keywords": ["mcp", "multi-agent", "squad", "chat", "backlog", "knowledge-base"] +} diff --git a/hatch/plugin/.mcp.json b/hatch/plugin/.mcp.json new file mode 100644 index 0000000..9a4b8ad --- /dev/null +++ b/hatch/plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hatch": { + "command": "hatch", + "args": ["mcp"] + } + } +} diff --git a/hatch/plugin/README.md b/hatch/plugin/README.md new file mode 100644 index 0000000..9c0076e --- /dev/null +++ b/hatch/plugin/README.md @@ -0,0 +1,46 @@ +# Hatch — Claude Code plugin + +Bundles the Hatch **embedded harness** for Claude Code: + +- **MCP server** (`.mcp.json`) — launches `hatch mcp`, exposing the shared squad + chat (= communication + backlog) and knowledge base as tools. +- **Skill** (`skills/hatch-chat`) — teaches the chat etiquette (one task = one + thread, `@mention`, recall before re-deriving, DoD self-check). +- **Slash command** (`/hatch`) — sync with the squad: read your inbox and open + task threads, then summarize what needs you. + +## Prerequisites + +The `hatch` binary must be on your `PATH`, and you must run Claude Code from +inside a Hatch workspace (a directory with `.hatch/`, created by `hatch init`). + +```sh +go install github.com/fioenix/overclaud/hatch/cmd/hatch@latest # or: make build +hatch init # in your repo +``` + +The MCP server posts under the workspace's Claude-kind agent automatically (or +set `HATCH_AGENT`, or pass `--as`). No per-project config is baked into the +plugin. + +## Install + +During development, point Claude Code at this directory: + +```sh +claude --plugin-dir /path/to/overclaud/hatch/plugin +``` + +Or add the marketplace and install by name: + +``` +/plugin marketplace add fioenix/overclaud +/plugin install hatch@hatch +``` + +## Use + +1. Open Claude Code in your Hatch workspace. +2. Run `/hatch` to sync with the squad. +3. Collaborate through the `hatch` chat tools — a thread is a task; `@mention` + teammates; `kb_add`/`kb_search` for shared memory. diff --git a/hatch/plugin/commands/hatch.md b/hatch/plugin/commands/hatch.md new file mode 100644 index 0000000..2d3a135 --- /dev/null +++ b/hatch/plugin/commands/hatch.md @@ -0,0 +1,20 @@ +--- +description: Sync with the Hatch squad — read your inbox and open tasks, then summarize what needs you. +--- + +You are a member of a coding-agent squad coordinated through Hatch's shared chat +(the `hatch` MCP server). Sync up now: + +1. Call `whoami` to confirm who you are and which roles you hold. +2. Call `chat_inbox` to see DMs, @mentions, and broadcasts waiting for you. +3. Call `chat_channels` to see the open threads (each thread = a task). +4. If the user gave an argument, treat it as focus: $ARGUMENTS + +Then give a short briefing: +- What's waiting on you (and from whom). +- Which open task threads are relevant. +- Your proposed next action (which thread to open or reply in). + +Remember the etiquette: one task = one thread (`chat_open`), brief progress and +results in-thread (`chat_post`), `@mention` teammates when you need them, and +`chat_search` / `kb_search` before re-deriving anything. diff --git a/hatch/plugin/skills/hatch-chat/SKILL.md b/hatch/plugin/skills/hatch-chat/SKILL.md new file mode 100644 index 0000000..c2b00f1 --- /dev/null +++ b/hatch/plugin/skills/hatch-chat/SKILL.md @@ -0,0 +1,53 @@ +--- +name: hatch-chat +description: >- + Use when working as part of a Hatch coding-agent squad — whenever the `hatch` + MCP tools (chat_open, chat_post, chat_inbox, chat_search, kb_add, ...) are + available, or the user mentions teammates, a shared backlog, or coordinating + with other agents. Teaches how to communicate and manage work through the + shared chat. +--- + +# Working in a Hatch squad + +Hatch connects you to the rest of the squad through one shared chat exposed by +the `hatch` MCP server. **The chat is both the communication channel and the +backlog: a thread is a task.** There is no separate ticket system — coordinate +like a human team in Slack. + +## Tools + +- `whoami` — who you are + the roles you may hold. Call at the start. +- `chat_inbox(mark?)` — DMs, @mentions, broadcasts since you last read. "Read + the room" before starting. +- `chat_channels` — list open threads/topics (the backlog). +- `chat_open(title, body, to?, channel?)` — open a thread for ONE task. Returns + the channel + the root message id (= the task id). +- `chat_post(channel, body, reply_to?, to?, type?)` — brief progress/results or + reply. Set `reply_to` to the root id to thread under a task. +- `chat_read(channel)` — read a whole thread before responding. +- `chat_search(query, ...)` — recall relevant conversation by keyword. +- `kb_add(title, body, type, tags)` / `kb_search(tags)` — shared memory: + decisions, domain knowledge, learnings. + +## Etiquette + +1. **Start of session:** `whoami`, then `chat_inbox` to see what's waiting. +2. **One task = one thread.** Open a thread with `chat_open`; do the work; brief + progress and the final result back into the same thread with `chat_post`. +3. **Ask for help by @mention.** Put `@teammate` (an agent id or role) in the + body, or pass `to=`. The tagged teammate reads the thread and replies in it + when they're running. You don't spawn anyone — collaboration is async. +4. **Squad-wide work:** open a topic and tag `@all` (or `to=*`). +5. **Recall before re-deriving.** `chat_search` and `kb_search` first; don't + re-read everything and don't repeat decisions already made. +6. **Capture knowledge.** Worthwhile decisions/learnings go to `kb_add`. +7. **Status lives in the thread.** Post the result when done; post the reason + when blocked. Task state is inferred from the conversation — no lane engine. + +## Definition of Done (self-check) + +Before you report a task done in its thread, run and confirm the project's +checks yourself (e.g. `make test`, `make lint`), don't self-review your own code +(tag another reviewer), leave the final merge to a human if the project requires +it, and record any decision worth keeping with `kb_add`. diff --git a/hatch/scripts/onboard.sh b/hatch/scripts/onboard.sh new file mode 100755 index 0000000..c1d2034 --- /dev/null +++ b/hatch/scripts/onboard.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Hatch local onboarding: build the binaries, optionally install them on PATH, +# and spin up a runnable demo workspace (using the mock agent) so you can try +# everything locally — no real agent CLI required. +# +# ./scripts/onboard.sh # build + demo in ./demo-workspace +# ./scripts/onboard.sh --install # also `go install` hatch + hatch-mock +# ./scripts/onboard.sh --no-demo # just build (+install) +# ./scripts/onboard.sh --demo DIR # demo in DIR +set -euo pipefail + +HATCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO_DIR="$HATCH_DIR/demo-workspace" +DO_INSTALL=0 +DO_DEMO=1 + +while [ $# -gt 0 ]; do + case "$1" in + --install) DO_INSTALL=1 ;; + --no-demo) DO_DEMO=0 ;; + --demo) shift; DEMO_DIR="$1" ;; + -h|--help) sed -n '2,9p' "$0"; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 1 ;; + esac + shift +done + +step() { printf '\n\033[1;34m▶ %s\033[0m\n' "$1"; } + +step "Checking Go toolchain" +if ! command -v go >/dev/null 2>&1; then + echo "Go is not installed. Get it at https://go.dev/dl/ (need 1.24+)." >&2 + exit 1 +fi +go version + +step "Building hatch" +cd "$HATCH_DIR" +make build +BIN="$HATCH_DIR/bin" +echo "built: $BIN/hatch" + +if [ "$DO_INSTALL" = "1" ]; then + step "Installing to GOPATH/bin (go install)" + make install + echo "installed hatch to $(go env GOPATH)/bin" + echo "ensure that dir is on your PATH: export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" +fi + +# Use the freshly built binaries for the demo regardless of install. +export PATH="$BIN:$PATH" + +if [ "$DO_DEMO" = "1" ]; then + step "Creating demo workspace at $DEMO_DIR" + rm -rf "$DEMO_DIR" + mkdir -p "$DEMO_DIR" + cd "$DEMO_DIR" + git init -q + hatch init --local -w scrum >/dev/null # --local: workspace của riêng demo repo này + + step "Compiling the SSOT (protocol + per-agent MCP registration)" + hatch compile + echo + echo " → CLAUDE.md / AGENTS.md / GEMINI.md carry the chat protocol + workflow." + echo " → .mcp.json registers the 'hatch' MCP server for Claude Code." + + step "Opening a task thread (a thread = a task)" + # In real use a coding agent does this through the Hatch MCP server; here we + # post as a human operator so the demo needs no agent CLI. + hatch msg --from human:operator -c '#export-csv' \ + "@codex hãy stream CSV để giảm bộ nhớ" >/dev/null + echo " posted to #export-csv" + + step "Read-only status (threads + roster)" + hatch status + + step "Read the thread" + hatch thread '#export-csv' | sed 's/^/ /' + + printf '\n\033[1;32m✔ Demo ready.\033[0m Workspace: %s\n' "$DEMO_DIR" + cat <<EOF + +How it works for real: open a coding agent (e.g. Claude Code) IN this workspace. +The compiled CLAUDE.md + .mcp.json wire it to the shared chat — it opens a +thread per task, @mentions teammates, and records knowledge, all over MCP. + +Watch the squad (read-only, from that dir, with $BIN on PATH): + hatch board # mission control: threads + chat + ledger + hatch chat # Slack-style conversation view + hatch search streaming # recall messages + hatch mcp --as claude-code # the MCP server an agent connects to + +Docs: $HATCH_DIR/docs/overview.md, $HATCH_DIR/docs/20-embedded-harness-pivot.md +EOF +fi diff --git a/hatch/spec/ledger.schema.md b/hatch/spec/ledger.schema.md new file mode 100644 index 0000000..f71b7cd --- /dev/null +++ b/hatch/spec/ledger.schema.md @@ -0,0 +1,62 @@ +# Spec — Ledger + +Sổ audit append-only. Mỗi thay đổi trạng thái có ý nghĩa = một entry. Trong git nên bất biến kép. Phục vụ standup digest, retro, điều tra sự cố, và chứng cứ audit. + +## Tổ chức file + +Một file / ngày: `.hatch/ledger/YYYY-MM-DD.md`. (Tùy chọn: thêm `.hatch/ledger/by-ticket/T-123.md` tổng hợp theo ticket — sinh tự động, không phải nguồn.) + +## Entry — trả lời WHO · WHAT · WHEN · WHY · WHERE + +```markdown +## 2026-06-14T09:12:03+07:00 · codex · T-123 +- action: claim # claim | start | progress | handoff | review | done | block | unblock | revoke | note +- from: backlog/ → in-progress/ # chuyển lane (nếu có) +- why: sprint S-7, P1, dependency T-100 done +- branch: hatch/T-123-export-csv +- note: bắt đầu với csv streaming utils + +## 2026-06-14T10:40:11+07:00 · codex · T-123 +- action: handoff +- from: in-progress/ → review/ +- to-role: reviewer +- why: code xong, test xanh +- handoff: endpoint ở reporting/export.go; test ở export_test.go; chưa cover file rỗng + +## 2026-06-14T11:05:00+07:00 · claude-code · T-123 +- action: review +- result: changes-requested +- why: thiếu test file rỗng (DoD) +- from: review/ → in-progress/ +``` + +## Trường + +| Trường | Bắt buộc | Ý nghĩa | +|---|---|---| +| timestamp (heading) | ✓ | ISO 8601 + offset (Asia/Ho_Chi_Minh) | +| agent (heading) | ✓ | WHO — agent (hoặc `human:<tên>`) | +| ticket (heading) | ✓* | WHERE — id ticket (`-` nếu sự kiện cấp hệ thống) | +| `action` | ✓ | WHAT — loại hành động (enum) | +| `from` | tùy | chuyển lane `A/ → B/` | +| `why` | ✓ | WHY — lý do; cốt lõi cho audit | +| `result` | tùy | với review/gate: approved \| changes-requested \| failed | +| `handoff` | ✓ khi action=handoff | context tối thiểu cho vai sau | +| `branch` / `note` | tùy | bổ sung | + +## Action enum + +`claim · start · progress · handoff · review · done · block · unblock · revoke · note · gate · escalate` + +## Quy tắc + +- **Append-only:** không sửa/xóa entry cũ. Sai → thêm entry `action: note` đính chính. +- **Một chuyển trạng thái = một entry = một commit** (cùng commit với `git mv` ticket). +- `why` không được rỗng — đây là giá trị audit. "vì sao" quan trọng hơn "cái gì" (cái gì đã có trong diff). +- `escalate` / sự cố nghiêm trọng → entry riêng, báo người ngay (xem [governance](../docs/06-governance.md)). + +## Tiêu thụ + +- **Standup digest:** Conductor đọc entry từ mốc trước → tóm tắt ngắn (ai làm gì, xong gì, block gì). Agent đọc digest thay vì toàn bộ ledger → tiết kiệm token. +- **Retro:** đọc cả chu kỳ → bài học → cập nhật SSOT/protocol. +- **Audit:** truy vết đầy đủ một ticket qua `by-ticket/` hoặc grep theo id. diff --git a/hatch/spec/registry.schema.md b/hatch/spec/registry.schema.md new file mode 100644 index 0000000..a5a0973 --- /dev/null +++ b/hatch/spec/registry.schema.md @@ -0,0 +1,80 @@ +# Spec — `registry.yaml` + +Bảng phân công của đội: roster agent, năng lực, binding vai, policy. Nguồn để compiler và orchestrator biết "ai là ai, làm được gì, được gọi thế nào". **File này sống trong `.hatch/` của từng project** — vai trò là cấu hình per-project, không fix cứng (xem [roles](../docs/02-roles.md)). Quy trình tách riêng ở [`workflow.yaml`](workflow.schema.md). + +## Schema + +```yaml +version: 1 + +# Định nghĩa vai (hoặc trỏ tới .hatch/roles/*.md) +roles: + conductor: { file: roles/conductor.md } + architect: { file: roles/architect.md } + implementer: { file: roles/implementer.md } + reviewer: { file: roles/reviewer.md } + tester: { file: roles/tester.md } + +# Roster agent +agents: + claude-code: + cli: "claude" # binary để invoke + surface: ["CLAUDE.md", ".claude/"] # nơi compiler ghi output + roles: [conductor, architect, reviewer] + compile: + adapter: claude # adapter SSOT → định dạng native + spawn: # (Phase 3) cách orchestrator spawn + cmd: "claude" + args: ["-p", "{prompt}"] + cwd: "{worktree}" + capture: stdout + + kiro: + cli: "kiro" + surface: [".kiro/steering/"] + roles: [architect, implementer] + compile: { adapter: kiro } + spawn: { cmd: "kiro", args: ["run", "--task", "{prompt}"], cwd: "{worktree}", capture: stdout } + + codex: + cli: "codex" + surface: ["AGENTS.md"] + roles: [implementer, tester] + compile: { adapter: codex } + spawn: { cmd: "codex", args: ["--non-interactive", "--prompt-file", "{prompt}"], cwd: "{worktree}", capture: stdout } + + antigravity: + cli: "ag" + surface: ["<antigravity-config>"] + roles: [implementer, tech-writer] + compile: { adapter: antigravity } + spawn: { cmd: "ag", args: ["task", "{prompt}"], cwd: "{worktree}", capture: stdout } + +# Quy ước phối hợp / governance +policy: + no-self-review: true # implementer ≠ reviewer cùng ticket + require-human-gate: [merge, deploy, secret, external-comms, destructive-data] + branch-pattern: "hatch/{ticket}-{slug}" + claim-stale-after: "2h" # quá hạn → Conductor thu hồi + wip-limit: # giới hạn ticket đồng thời / vai (Kanban) + implementer: 2 + reviewer: 3 + +# Quy trình tách riêng ở workflow.yaml (xem workflow.schema.md) +workflow: + ref: workflow.yaml # trỏ tới file workflow per-project +``` + +## Quy tắc + +- `roles` của mỗi agent quyết định **L1** nào được ghép vào file compiled của nó. +- `surface` quyết định compiler ghi output ở đâu. +- `compile.adapter` và `spawn` là hai điểm mở rộng độc lập; thêm agent mới chỉ cần khai báo ở đây + viết adapter tương ứng. +- `{prompt}` / `{worktree}` / `{ticket}` / `{slug}` là biến orchestrator nội suy lúc `hatch run`. +- `policy` được enforce: bởi convention (Phase 1), pre-commit hook (Phase 2), orchestrator (Phase 3). + +## Validate + +- Mọi vai trong `agents[].roles` phải tồn tại trong `roles`. +- Mỗi vai chuẩn nên có ≥ 1 agent đảm nhiệm (cảnh báo nếu vai không ai giữ). +- `no-self-review: true` → cần ≥ 2 agent có thể làm cặp implementer/reviewer. diff --git a/hatch/spec/ticket.schema.md b/hatch/spec/ticket.schema.md new file mode 100644 index 0000000..bb030f2 --- /dev/null +++ b/hatch/spec/ticket.schema.md @@ -0,0 +1,74 @@ +# Spec — Ticket + +Một đơn vị việc. File Markdown trong `.hatch/board/<lane>/T-<id>.md`. **Vị trí thư mục = trạng thái.** Frontmatter = metadata; body = mô tả + acceptance. + +## Frontmatter + +```yaml +--- +id: T-123 # duy nhất, ổn định +title: "Export báo cáo ra CSV" +status: in-progress # backlog | in-progress | review | done | blocked +role: implementer # vai chịu trách nhiệm ở giai đoạn hiện tại +assignee: codex # agent đang giữ (rỗng nếu chưa claim) +priority: P1 # P0 | P1 | P2 | P3 +epic: E-12 # epic/spec gốc (rỗng nếu standalone) +depends_on: [T-100, T-101] # phải done hết mới được claim +branch: "hatch/T-123-export-csv" # branch-per-ticket +context_refs: # L2 — đọc on-demand khi claim, KHÔNG nhúng sẵn + - context/tech/reporting.md + - product/epics/E-12/design.md +claim: # cơ chế lock (xem coordination-protocol) + agent: codex + ts: "2026-06-14T09:12:03+07:00" +dod: # bổ sung ngoài DoD mặc định + - "Có test cho ký tự đặc biệt và unicode" +created: "2026-06-14T08:00:00+07:00" +updated: "2026-06-14T09:12:03+07:00" +--- +``` + +## Body + +```markdown +## Bối cảnh +Người dùng cần xuất báo cáo doanh thu ra CSV để mở bằng Excel. + +## Yêu cầu +- Endpoint `GET /reports/{id}/export?format=csv` +- Encoding UTF-8 BOM (Excel đọc đúng tiếng Việt) +- Stream, không load hết vào RAM + +## Acceptance +- [ ] File mở đúng trên Excel, không lỗi font +- [ ] Báo cáo 1 triệu dòng không OOM +- [ ] Test pass + +## Handoff notes +<!-- mỗi lần đổi assignee/role, thêm 1 mục: đã làm gì, còn gì, cần gì --> +- 2026-06-14 architect→implementer: design ở E-12/design.md §4; dùng csv streaming có sẵn ở utils/stream. +``` + +## Quy tắc + +- **Đổi trạng thái** = `git mv` sang lane mới + cập nhật `status`/`updated` + entry ledger. Một thay đổi = một commit. +- **Claim** = set `assignee` + `claim` + `git mv` vào `in-progress/`, push ngay (push thắng = lock thắng). +- **`context_refs` là L2:** agent chỉ đọc khi nhận ticket → đây là điểm kiểm soát token chính. +- **`depends_on`:** không claim được khi còn dependency chưa `done`. +- **Handoff notes** bắt buộc khi đổi `assignee` — context tối thiểu để người sau không đọc lại toàn bộ. +- **Ranh giới:** không sửa file ngoài `context_refs` đã khai (Reviewer gác). Mở rộng scope → cập nhật ticket trước. + +## Vòng đời lane + +``` +backlog/ → in-progress/ → review/ → done/ + │ ↑ + └─ blocked/ ─┘ +``` + +## Validate (pre-commit / orchestrator) + +- `id` duy nhất toàn board. +- `status` khớp lane chứa file. +- Nếu có `claim.agent` → vai `role` phải nằm trong `roles` của agent đó (registry). +- `no-self-review`: khi vào `review/`, `assignee` (reviewer) ≠ implementer trước đó. diff --git a/hatch/spec/workflow.schema.md b/hatch/spec/workflow.schema.md new file mode 100644 index 0000000..81e20dd --- /dev/null +++ b/hatch/spec/workflow.schema.md @@ -0,0 +1,96 @@ +# Spec — `workflow.yaml` + +Định nghĩa quy trình làm việc của một project: lanes, transitions, gates, ceremonies. **Đây là template — user sửa tự do per-project.** Board, protocol, và orchestrator đều đọc từ file này; đổi quy trình = sửa file, không sửa code. + +## Schema + +```yaml +version: 1 +template: scrum # scrum | kanban | spec-first | lite | custom + +# Các trạng thái = thư mục trong board/ +lanes: + - id: backlog + - id: in-progress + wip-limit: 2 # tùy chọn (Kanban) + - id: review + - id: done + - id: blocked + side: true # lane phụ, không nằm trên luồng chính + +# Chuyển trạng thái hợp lệ + ai được làm + điều kiện +transitions: + - from: backlog + to: in-progress + by: [implementer, tester] # vai được phép (khớp registry) + action: claim # ghi ledger action gì + - from: in-progress + to: review + by: [implementer] + gates: [tests-pass, lint-clean, handoff-note] + - from: review + to: done + by: [reviewer] + gates: [dod-met, no-self-review, human-merge] + - from: review + to: in-progress + by: [reviewer] + action: changes-requested + - from: "*" + to: blocked + by: ["*"] + action: block + +# Cổng — điều kiện pass khi qua transition +gates: + tests-pass: { type: command, run: "make test" } + lint-clean: { type: command, run: "make lint" } + dod-met: { type: checklist, ref: protocol/definition-of-done.md } + handoff-note: { type: required-field, field: handoff } # bắt buộc có handoff note + no-self-review:{ type: policy, ref: registry.policy.no-self-review } + human-merge: { type: human-gate } # dừng chờ người + +# Sự kiện định kỳ + ai chủ trì +ceremonies: + planning: { by: conductor, trigger: manual } # sprint planning + standup-digest: { by: conductor, trigger: "daily" } # ledger digest + retro: { by: conductor, trigger: "end-of-sprint", actions: [compact-ledger, promote-kb] } + +# Cấu hình spec-driven (nếu bật) +spec: + required-for: [epic] # loại nào bắt buộc qua PRD→Design→Tasks + artifacts: [prd, design, tasks] + gates: + prd: human-gate + design: review +``` + +## Quy tắc + +- **`lanes`** sinh ra thư mục `board/<lane>/`. Đổi lane = đổi cấu trúc board. +- **`transitions`** là luật duy nhất quyết định chuyển trạng thái hợp lệ. `by` phải khớp vai trong [registry](registry.schema.md). `from: "*"` = áp cho mọi lane. +- **`gates`** chạy khi một transition khai báo nó; fail → transition bị chặn. `human-gate` luôn dừng chờ người ([governance](../docs/06-governance.md)). +- **`ceremonies`** map sang cơ chế async ([workflow](../docs/05-workflow.md#ceremonies--cơ-chế-async)); `retro` có thể kéo theo `promote-kb` (đề bạt KB→SSOT) và `compact-ledger`. +- Bỏ trống `spec` = tắt spec-driven (đi thẳng backlog). + +## Template ship sẵn + +| Template | Lanes | Đặc điểm | +|---|---|---| +| `scrum` | backlog→in-progress→review→done | sprint + retro định kỳ (mặc định) | +| `kanban` | + wip-limit | pull liên tục, không sprint | +| `spec-first` | + cổng prd/design | mọi epic qua PRD→Design→Tasks | +| `lite` | todo→doing→done | tối giản cho project nhỏ/cá nhân | +| `dual-track` | ideas→discovery→ready→in-progress→review→done | dual-track agile: discovery song song delivery | +| `shape-up` | pitch→bet→building→review→done | Shape Up: cược scope đã shaped, appetite cố định | +| `stage-gate` | requirements→design→build→test→release→done | PDLC phân pha, sign-off mỗi cổng | +| `incident` | detected→triage→mitigating→resolved→postmortem | ứng cứu sự cố; ghép `hatch oncall` + escalation | + +User chọn một template rồi sửa, hoặc đặt `template: custom` và tự khai báo toàn bộ. + +## Validate (pre-commit / orchestrator) + +- Mọi `lane` tham chiếu trong `transitions` phải tồn tại. +- `by` của transition phải là vai có trong registry. +- Mọi `gates[]` được transition dùng phải định nghĩa trong khối `gates`. +- Đồ thị lanes phải tới được `done` (không có lane chết).